diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 3f10928..d20f19d 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7959772..bac32d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cpp/examples/CMakeLists.txt b/cpp/examples/CMakeLists.txt index 58c5eff..719482e 100644 --- a/cpp/examples/CMakeLists.txt +++ b/cpp/examples/CMakeLists.txt @@ -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) diff --git a/cpp/examples/example_target.hpp b/cpp/examples/example_target.hpp index 8538286..4b5f576 100644 --- a/cpp/examples/example_target.hpp +++ b/cpp/examples/example_target.hpp @@ -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 @@ -20,7 +23,7 @@ namespace example { inline void print_candidates(const std::vector& 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 " @@ -35,7 +38,8 @@ inline void print_candidates(const std::vector open_follower(int argc, char** argv) { using namespace xense::taccap; @@ -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"); } diff --git a/cpp/examples/follower_force_position.cpp b/cpp/examples/follower_force_position.cpp index 07013c5..33328ca 100644 --- a/cpp/examples/follower_force_position.cpp +++ b/cpp/examples/follower_force_position.cpp @@ -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" @@ -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); @@ -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); @@ -71,12 +77,12 @@ 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); @@ -84,16 +90,18 @@ int main(int argc, char** argv) { 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; } diff --git a/cpp/examples/follower_impedance.cpp b/cpp/examples/follower_impedance.cpp index b1878a8..092bea7 100644 --- a/cpp/examples/follower_impedance.cpp +++ b/cpp/examples/follower_impedance.cpp @@ -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" @@ -37,8 +41,8 @@ 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(); @@ -46,36 +50,39 @@ int main(int argc, char** argv) { 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( 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; } diff --git a/cpp/examples/follower_status.cpp b/cpp/examples/follower_status.cpp index b4638f4..cfa005b 100644 --- a/cpp/examples/follower_status.cpp +++ b/cpp/examples/follower_status.cpp @@ -1,16 +1,20 @@ // Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 // -// 读从爪的状态:一次性读、开流读、以及怎么判断状态是不是新鲜的。 +// Reading follower status: one-shot reads, streamed reads, and how to tell whether the status +// is fresh. // -// 从爪**只流电机状态**这一种数据 —— 固件在这个角色上把 IMU / 编码器 / 电子皮肤 -// 的流编译掉了,所以 start_streaming() 只有 motor_hz 一个参数。 +// The follower streams **only motor status** — the firmware compiles out the IMU / encoder / +// e-skin streams in this role, so start_streaming() takes a single motor_hz parameter. // -// 控制期间不要用 read_status():它是一次 ACK 往返,请求可能落在 MCU 自己的发送 -// 中间、毁掉在途的状态帧。控制器自己持有状态流,见 follower_impedance.cpp。 +// Do not use read_status() during control: it is an ACK round trip, and the request can land in +// the middle of the MCU's own transmission and corrupt the in-flight status frame. The +// controller owns the status stream itself; see follower_impedance.cpp. // -// 新鲜度必须看**固件的**时间戳,不能看「帧还在到」。固件曾经因为向不支持主动 -// 上报的电机请求了上报就停掉自己的轮询,于是空闲时状态永久冻结,而版本号、 -// 数据流、计数全都正常 —— 唯一的症状是读到的数悄悄是旧的。 +// Freshness must be judged by the **firmware's** timestamp, not by "frames are still arriving". +// The firmware once stopped its own polling after requesting active reporting from a motor that +// does not support it, so the status froze permanently while idle, yet the version, the data +// stream and the counters all looked normal — the only symptom was that the values read were +// silently stale. #include "example_target.hpp" @@ -29,28 +33,30 @@ int main(int argc, char** argv) { std::printf("[fw] %u.%u.%u.%u\n", v->major, v->minor, v->patch, v->build); } - // ---- 一次性读 ---------------------------------------------------------- + // ---- One-shot read ------------------------------------------------------ const auto s = g->motor().read_status(); - std::printf("\n[一次性读] motor().read_status()\n"); - std::printf(" 位置 %+.5f rad (电机轴原始角,零点由上电标定确定)\n", s.actual_pos); - std::printf(" 速度 %+.5f rad/s\n", s.actual_vel); - std::printf(" 力矩 %+.4f Nm (反馈值,不是命令值)\n", s.actual_torque); - std::printf(" 温度 %.1f C\n", s.motor_temp_c); + std::printf("\n[one-shot] motor().read_status()\n"); + std::printf(" position %+.5f rad (raw motor-shaft angle; zero set by power-on " + "calibration)\n", s.actual_pos); + std::printf(" velocity %+.5f rad/s\n", s.actual_vel); + std::printf(" torque %+.4f Nm (feedback value, not the command)\n", s.actual_torque); + std::printf(" temp %.1f C\n", s.motor_temp_c); std::printf(" status=0x%04X stop_reason=%u\n", s.status, static_cast(s.stop_reason)); const auto cfg = g->get_gripper_config(); - std::printf("\n[归一化] position() = %.4f 行程 min=%.5f max=%.5f rad\n", + std::printf("\n[normalized] position() = %.4f travel min=%.5f max=%.5f rad\n", g->position(), cfg.min_open_rad, cfg.max_open_rad); - // ---- 故障 -------------------------------------------------------------- + // ---- Faults ------------------------------------------------------------ const auto fr = g->motor().fault_report(); - std::printf("\n[故障] 电机 0x%08X 锁存 0x%08X 固件 0x%08X\n", + std::printf("\n[fault] motor 0x%08X latched 0x%08X firmware 0x%08X\n", fr.motor_fault_code, fr.motor_latched_fault_code, fr.firmware_fault_code); - std::printf(" 锁存位要显式清除才会消失 —— 当前为 0 不代表从没出过故障。\n"); + std::printf(" Latched bits only go away when explicitly cleared — 0 now does not mean " + "there was never a fault.\n"); - // ---- 开流读 ------------------------------------------------------------ + // ---- Streamed read ----------------------------------------------------- std::atomic n{0}; const auto sub = g->motor().on_status([&](const auto&) { ++n; }); @@ -63,22 +69,26 @@ int main(int argc, char** argv) { const double dt = std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); - std::printf("\n[开流读] start_streaming(100) + on_status()\n"); + std::printf("\n[streamed] start_streaming(100) + on_status()\n"); if (n.load() == 0) { - std::printf(" **一帧都没收到** —— 流没起来,或者回调注册晚于开流。\n"); + std::printf(" **not a single frame received** — the stream did not start, or the " + "callback was registered after streaming started.\n"); return 1; } - std::printf(" %llu 帧 / %.2f s = %.1f Hz\n", + std::printf(" %llu frames / %.2f s = %.1f Hz\n", static_cast(n.load()), dt, n.load() / dt); - // 固件侧最后一帧状态的 tick。用它而不是「收到了多少帧」:帧还在推不等于 - // 内容在更新,那正是上面说的那个洞。 + // Firmware-side tick of the last status frame. Use it instead of "how many frames arrived": + // frames still being pushed does not mean the contents are updating — that is exactly the + // hole described above. const uint32_t fw_after = g->motor().fault_report().status_timestamp_ms; const uint32_t advance = fw_after - fw_before; const double wall_ms = dt * 1000.0; - std::printf(" 固件状态时间戳推进 %u ms,墙钟 %.0f ms %s\n", + std::printf(" firmware status timestamp advanced %u ms, wall clock %.0f ms %s\n", advance, wall_ms, - advance > wall_ms * 0.5 ? "OK" - : "**状态是旧的 —— 流在推,内容没更新**"); + advance > wall_ms * 0.5 + ? "OK" + : "**status is stale — the stream is pushing, the contents are not " + "updating**"); return advance > wall_ms * 0.5 ? 0 : 1; } diff --git a/cpp/include/taccap/motor_ota.hpp b/cpp/include/taccap/motor_ota.hpp index 0ef427f..9638706 100644 --- a/cpp/include/taccap/motor_ota.hpp +++ b/cpp/include/taccap/motor_ota.hpp @@ -5,7 +5,7 @@ // follower firmware 1.2.8+): the MCU relays each CAN frame and waits for the // motor's reply, the sequencing lives here. // -// RobStride's OTA protocol ("OTA 协议说明", 20251114), communication types: +// RobStride's OTA protocol ("OTA Protocol Specification", 20251114), communication types: // // 0 get device id -> 64-bit MCU UID (reply goes to 0xFE) // 11 OTA start payload = that UID diff --git a/cpp/include/taccap/protocol/commands.hpp b/cpp/include/taccap/protocol/commands.hpp index 0333b98..2e78bb7 100644 --- a/cpp/include/taccap/protocol/commands.hpp +++ b/cpp/include/taccap/protocol/commands.hpp @@ -114,9 +114,9 @@ enum class Cmd : uint8_t { GetMotorFault = 0x52, // V2.2 — MotorFaultReport (64B); req 0B (cached) // or 1B non-zero (force a CAN read) GetMotorStatusExt = 0x53, // V2.2 — MotorStatusExt (72B), superset of 0x50 - GetMotorSpec = 0x56, // 电机型号规格(40B) - GetHomeDiag = 0x57, // 自动标定诊断(64B) - GetMotorVersion = 0x58, // 电机自身固件版本(8B,仅私有协议) + GetMotorSpec = 0x56, // motor model spec (40B) + GetHomeDiag = 0x57, // auto-calibration diagnostics (64B) + GetMotorVersion = 0x58, // motor's own firmware version (8B, private protocol only) // Which actuator this gripper is built around. NOT a query to the motor: // the motor cannot answer it (its version frame carries only a version // number, neither manual's parameter table has a model field, and private @@ -125,15 +125,17 @@ enum class Cmd : uint8_t { // It matters because MIT frames quantise torque and velocity against the // model's ranges, so the wrong model is not an error -- it is a fixed // scale factor on every frame (EL05 +/-6 Nm vs RS00 +/-14 Nm). - GetMotorModel = 0x59, // 本机记录的电机型号(20B) - SetMotorModel = 0x5A, // 写电机型号(1B),掉电保持,重启生效 + GetMotorModel = 0x59, // motor model recorded on this unit (20B) + SetMotorModel = 0x5A, // write motor model (1B); survives power-off, + // takes effect on reboot // Send one arbitrary 29-bit extended frame onto the motor's CAN bus and // wait for the first matching extended reply (firmware 1.2.8+). The // building block for host-side motor OTA and protocol probing. Refused // while the control loop, auto-calibration or a gripper OTA is running: // it can emit any frame, enable and stop included. - MotorCanExtXfer = 0x5B, // 扩展帧透传(req 24B / resp 16B) - SetMotorFwVersion = 0x5C, // 记电机固件版本进 flash(req 4B / resp 8B),fw >= 1.2.14 + MotorCanExtXfer = 0x5B, // extended-frame passthrough (req 24B / resp 16B) + SetMotorFwVersion = 0x5C, // store motor fw version in flash (req 4B / resp 8B), + // fw >= 1.2.14 // Diagnostics, firmware 1.1.3+ / 1.1.4+. Present on leader and follower // alike: the counters live in the firmware's UART layer, not in a // gripper-role-specific subsystem. diff --git a/cpp/include/taccap/protocol/payloads.hpp b/cpp/include/taccap/protocol/payloads.hpp index 801a2be..c5c9ce6 100644 --- a/cpp/include/taccap/protocol/payloads.hpp +++ b/cpp/include/taccap/protocol/payloads.hpp @@ -283,7 +283,7 @@ namespace HomeDiagFlag { constexpr std::uint8_t HasMoved = 0x08; // stall monitor saw real motion } -// ---- 电机自身固件版本 (Cmd::GetMotorVersion 0x58, firmware >= 1.2.6) ------ +// ---- Motor's own firmware version (Cmd::GetMotorVersion 0x58, firmware >= 1.2.6) // // NOT the gripper firmware version — that is Cmd::GetVersion (0x01). This is // the version of the program running inside the motor module. @@ -328,7 +328,7 @@ struct __attribute__((packed)) MotorFwVersionRecord { }; constexpr size_t MOTOR_FW_VERSION_RECORD_SIZE = 8; -// ---- 电机 CAN 扩展帧透传 (Cmd::MotorCanExtXfer 0x5B, firmware >= 1.2.8) ---- +// ---- Motor CAN extended-frame passthrough (Cmd::MotorCanExtXfer 0x5B, firmware >= 1.2.8) // // One frame out, then the first EXTENDED frame whose id satisfies // (id & match_mask) == match_value within timeout_ms comes back. A matched diff --git a/cpp/src/follower_gripper.cpp b/cpp/src/follower_gripper.cpp index 7356017..e79a313 100644 --- a/cpp/src/follower_gripper.cpp +++ b/cpp/src/follower_gripper.cpp @@ -135,47 +135,49 @@ FollowerGripper::FollowerGripper(const Config& cfg) const std::string msg = "\n" "==========================================================\n" - " 从爪固件版本过低,必须升级后才能使用本 SDK\n" " Follower firmware too old -- upgrade required\n" + " before using this SDK\n" "----------------------------------------------------------\n" - " 当前 / current : " + fw_version_str + "\n" - " 需要 / required: 1.2.5 或更高 / or newer\n" - " 设备 / device : " + cfg_.mcu_device + "\n" - " SN : " + fw_sn_str + "\n" + " current : " + fw_version_str + "\n" + " required: 1.2.5 or newer\n" + " device : " + cfg_.mcu_device + "\n" + " SN : " + fw_sn_str + "\n" "----------------------------------------------------------\n" - " 1.1.6 引入运动安全包络:在此之前 MIT 命令路径上没有任何\n" - " 堵转保护,夹爪被挡住时力矩只受电机 0x700B 限制,24V 下实测\n" - " 会拉垮母线、松手掉件、整机掉电重启。\n" + " 1.1.6 introduced the motion safety envelope. Before it\n" + " there was no stall protection at all on the MIT command\n" + " path: with the gripper blocked, torque was limited only by\n" + " motor 0x700B, and at 24 V this was measured to collapse\n" + " the supply rail, drop the held part and brown out / reboot\n" + " the whole unit.\n" "\n" - " 1.2.5 把标定写入的闭合零位移到机械止点本身(此前 1.2.3 及\n" - " 更早内缩 20mrad、1.2.4 内缩 5mrad)。本 SDK 的闭合端预压\n" - " (close_preload_nm)是按「归一化 0.0 就是止点」设计的;在更旧\n" - " 的固件上它会把爪子压过位置映射所说的完全闭合点 —— 力是有界的,\n" - " 但归一化刻度在闭合端不再表示它字面的意思。\n" - "\n" - " Before 1.1.6: no stall protection on the MIT path at all.\n" - " Before 1.2.5: the calibrated closed zero sat short of the\n" - " mechanical stop, so this SDK's closed-endpoint preload\n" - " presses past what normalized 0.0 claims to mean.\n" + " 1.2.5 moved the calibrated closed zero onto the mechanical\n" + " stop itself (1.2.3 and earlier sat 20 mrad short, 1.2.4\n" + " sat 5 mrad short). This SDK's closed-endpoint preload\n" + " (close_preload_nm) is designed around \"normalized 0.0 is\n" + " the stop\"; on older firmware it presses the jaw past the\n" + " fully-closed point the position mapping claims -- the\n" + " force is bounded, but at the closed end the normalized\n" + " scale no longer means what it literally says.\n" "----------------------------------------------------------\n" - " 升级 / upgrade:\n" + " upgrade:\n" " python python/examples/ota_update.py slave\n" "\n" - " (role 选择器,自动找从爪并挑对应镜像;插了多台时也可用\n" - " SN 精确指定: ota_update.py slave " + " (role selector: finds the follower automatically and picks\n" + " the matching image; with several plugged in, select one\n" + " exactly by SN: ota_update.py slave " + fw_sn_str + ")\n" - " 刷完后拔掉 24V 电源线约 2 秒再插回(USB 可不拔) /\n" - " after flashing, unplug 24 V for ~2 s (USB may stay in)\n" + " after flashing, unplug the 24 V power cable for ~2 s and\n" + " plug it back in (USB may stay in)\n" "=========================================================="; logger()->error(msg); throw ProtocolError(msg); } if (!known) { logger()->warn( - "FollowerGripper: 读不到固件版本,无法确认是否 >= 1.2.5。" - "低于 1.1.6 时 MIT 路径上没有堵转保护;低于 1.2.5 时闭合端" - "预压会压过归一化 0.0 所表示的位置。" - " (could not read firmware version; 1.2.5 or newer is required)"); + "FollowerGripper: could not read firmware version, cannot confirm it " + "is >= 1.2.5 (1.2.5 or newer is required). Below 1.1.6 there is no " + "stall protection on the MIT path; below 1.2.5 the closed-endpoint " + "preload presses past the position normalized 0.0 represents."); } // Below 1.2.11 the follower opens, but with a known field failure: one // control-UART overrun stops command reception for good while the @@ -186,11 +188,12 @@ FollowerGripper::FollowerGripper(const Config& cfg) constexpr uint32_t recommended = (1u << 16) | (2u << 8) | 11u; if (known && have >= need && have < recommended) { logger()->warn( - "FollowerGripper: 从爪固件 {} 低于 1.2.11 —— 控制串口一次溢出后会" - "永久收不到命令(状态流照常,300ms 后夹持降到 0.35N·m、手指松开)。" - "建议升级: python python/examples/ota_update.py slave {} ,刷完拔插 24V。" - " (follower firmware below 1.2.11 can lose its command channel for " - "good after one UART overrun; upgrade recommended)", + "FollowerGripper: follower firmware {} is below 1.2.11 -- after one " + "control-UART overrun it permanently stops receiving commands (the " + "status stream continues as normal; 300ms later the grip drops to " + "0.35N·m and the fingers open). Upgrade recommended: " + "python python/examples/ota_update.py slave {} , then unplug and " + "replug 24V after flashing.", fw_version_str, fw_sn_str); } } diff --git a/cpp/src/stream_rate.hpp b/cpp/src/stream_rate.hpp index f8da8af..552dc11 100644 --- a/cpp/src/stream_rate.hpp +++ b/cpp/src/stream_rate.hpp @@ -15,8 +15,8 @@ // 300 Hz becomes 1000/3 = 333 Hz; 150 Hz becomes 1000/6 = 167 Hz. // - Anything above 1000 Hz makes the divider 0, which the firmware rewrites // to 10 — so asking for 2000 Hz silently yields 100 Hz, not 1000. -// - Motor status is capped at STREAM_MOTOR_MAX_RATE_HZ = 100 ("给控制通道 -// 留带宽"), so a 200 Hz request silently yields 100 Hz. +// - Motor status is capped at STREAM_MOTOR_MAX_RATE_HZ = 100 ("leave bandwidth +// for the control channel"), so a 200 Hz request silently yields 100 Hz. // - **A rate of 0 does not disable a source.** Emission is gated purely on // the source_mask bit; rate 0 takes the same divider==0 fallback and // streams at 100 Hz. Turning a source off means clearing its mask bit, diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index f468678..1ea620d 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -40,10 +40,12 @@ using xense::taccap::FollowerGripper; class FakeFollower { public: - // 默认版本**从 SDK 的门槛常量派生**,不要写死。写死过一次:门槛从 1.1.6 抬到 - // 1.2.5 时,这里还停在 1.1.6,于是 47 个用默认构造的测试全被自己的版本门拒掉, - // 失败信息指向控制器而不是指向版本。派生之后,抬门槛不会再打断这些测试 —— - // 而真正该检查门槛的用例本来就显式传版本。 + // The default version is **derived from the SDK's floor constants**; do not hard-code it. It + // was hard-coded once: when the floor was raised from 1.1.6 to 1.2.5, this was still at + // 1.1.6, so all 47 tests using the default constructor were rejected by their own version + // gate, and the failure messages pointed at the controller instead of at the version. Once + // derived, raising the floor no longer breaks these tests — and the cases that really should + // check the floor pass a version explicitly anyway. FakeFollower(Pty& pty, uint8_t major = FollowerGripper::kMinFirmwareMajor, uint8_t minor = FollowerGripper::kMinFirmwareMinor, diff --git a/cpp/tests/test_force_position_pty.cpp b/cpp/tests/test_force_position_pty.cpp index 34ac7c0..5d6da01 100644 --- a/cpp/tests/test_force_position_pty.cpp +++ b/cpp/tests/test_force_position_pty.cpp @@ -157,9 +157,10 @@ TEST(ForcePositionControllerPty, StartRefusesADeviceLimitAboveTheMotionLimit) { // it says — which is worse to debug than a refusal. namespace { -// 门槛 ±1 个 patch,**从常量算出来**而不是写死。写死过一次:把门槛从 1.1.6 抬到 -// 1.2.5 时,这些用例里的 1.2.5 / 1.1.6 都成了字面量,门一动测试就塌,而失败信息 -// 看起来像功能坏了。 +// The floor ±1 patch, **computed from the constants** rather than hard-coded. It was hard-coded +// once: when the floor was raised from 1.1.6 to 1.2.5, the 1.2.5 / 1.1.6 in these cases were +// all literals, so moving the gate collapsed the tests, and the failure messages looked like a +// broken feature. struct Ver { uint8_t major, minor, patch; }; constexpr Ver kFloor{xense::taccap::FollowerGripper::kMinFirmwareMajor, @@ -167,7 +168,8 @@ constexpr Ver kFloor{xense::taccap::FollowerGripper::kMinFirmwareMajor, xense::taccap::FollowerGripper::kMinFirmwarePatch}; constexpr Ver just_below(Ver v) { - // patch 为 0 时不能简单减一,退到上一个 minor 的高 patch。 + // When patch is 0 you cannot just subtract one; fall back to a high patch of the + // previous minor. return v.patch > 0 ? Ver{v.major, v.minor, uint8_t(v.patch - 1)} : Ver{v.major, uint8_t(v.minor - 1), 255}; } diff --git a/cpp/tests/test_impedance_pty.cpp b/cpp/tests/test_impedance_pty.cpp index 2fd6146..e9788c4 100644 --- a/cpp/tests/test_impedance_pty.cpp +++ b/cpp/tests/test_impedance_pty.cpp @@ -186,7 +186,7 @@ TEST(ImpedanceControllerPty, StopCommandsZeroTorque) { // stop() must return when the device has vanished mid-run. // -// From the field: "阻抗控制器报错卡死". The USB tree on that bench dropped and +// From the field: "the impedance controller errors out and hangs". The USB tree on that bench dropped and // re-enumerated 15 times in 45 minutes, so the controller was repeatedly losing // its device underneath it -- and a stop() that blocks on join() while the run // thread is stuck writing to a dead tty is a hang with no way out but SIGKILL. diff --git a/cpp/tests/test_motor_ota.cpp b/cpp/tests/test_motor_ota.cpp index ee14c94..6cdcfa5 100644 --- a/cpp/tests/test_motor_ota.cpp +++ b/cpp/tests/test_motor_ota.cpp @@ -6,8 +6,8 @@ // follower firmware does, so a mask that would not admit the real reply fails // here as NoReply rather than passing by construction. // -// What is pinned: the frame sequence and payloads from RobStride's "OTA 协议 -// 说明" (20251114) and OTA.py, both readings where those two disagree (failure +// What is pinned: the frame sequence and payloads from RobStride's "OTA Protocol +// Specification" (20251114) and OTA.py, both readings where those two disagree (failure // code 0x0F vs 0xF0, data reply type 11 vs 13), and the resume paths. #include diff --git a/cpp/tests/test_power_cycle_warnings_pty.cpp b/cpp/tests/test_power_cycle_warnings_pty.cpp index af9289a..faa9133 100644 --- a/cpp/tests/test_power_cycle_warnings_pty.cpp +++ b/cpp/tests/test_power_cycle_warnings_pty.cpp @@ -73,7 +73,7 @@ TEST(FirmwareAdvisory, FollowerBelow1211WarnsButOpens) { FakeFollower fw(pty, v.a, v.b, v.c); CaptureLog log; EXPECT_NO_THROW({ auto g = open_follower(pty); }); - EXPECT_EQ(log.count("低于 1.2.11") > 0, v.warn) + EXPECT_EQ(log.count("below 1.2.11") > 0, v.warn) << int(v.a) << "." << int(v.b) << "." << int(v.c); } } diff --git a/cpp/tests/test_stream_rate.cpp b/cpp/tests/test_stream_rate.cpp index a67fbdc..2c7f615 100644 --- a/cpp/tests/test_stream_rate.cpp +++ b/cpp/tests/test_stream_rate.cpp @@ -48,7 +48,7 @@ TEST(StreamRate, ZeroRateIsOneHundredHzNotOff) { EXPECT_EQ(det::effective_stream_rate_hz(0, det::kMotorMaxRateHz), 100u); } -// STREAM_MOTOR_MAX_RATE_HZ = 100, "给控制通道留带宽". +// STREAM_MOTOR_MAX_RATE_HZ = 100, "leave bandwidth for the control channel". TEST(StreamRate, MotorStatusIsCappedAtOneHundred) { EXPECT_EQ(det::kMotorMaxRateHz, 100u); EXPECT_EQ(det::effective_stream_rate_hz(100, det::kMotorMaxRateHz), 100u); diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 86726d6..098793b 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -79,7 +79,7 @@ python python/examples/ota_update.py --get-status right | 脚本 | 对设备做什么 | 说明 | |---|---|---| | `ota_update.py` | **刷固件** | 夹爪固件 OTA,带进度条。**按角色选镜像,刷错会让 MCU 无法启动。** 刷完断电重启(从爪断 24 V,主爪拔插 USB)。| -| `motor_ota_update.py` | **刷电机固件** | 通过从爪 USB-C 升级 RobStride 电机固件。需要从爪固件 ≥ 1.2.8、电机处于私有协议;脚本会核对镜像与设备记录的电机型号。先刷夹爪再刷电机。详见 [firmware/README.md](../firmware/README.md#motor-firmware)。| +| `motor_ota_update.py` | **刷电机固件** | 通过从爪 USB-C 升级 RobStride 电机固件。需要从爪固件 ≥ 1.2.8、电机处于私有协议;不指定镜像时按设备记录的电机型号取 `firmware/` 里的镜像并核对 CRC32;脚本会核对镜像与设备记录的电机型号。先刷夹爪再刷电机。详见 [firmware/README.md](../firmware/README.md#motor-firmware)。| ### C++ 示例 diff --git a/docs/MOTOR_OTA-zh.md b/docs/MOTOR_OTA-zh.md index 5fd3be4..b7d97a0 100644 --- a/docs/MOTOR_OTA-zh.md +++ b/docs/MOTOR_OTA-zh.md @@ -15,7 +15,9 @@ 那一行。如果版本较旧,先用 `ota_update.py` 升级夹爪固件并断电重启。升级夹爪固件会把电机切回 常规协议,所以必须先升级夹爪、再升级电机,顺序不能反。 - **SDK 0.4.3 或更新版本**,并且 `import xense.taccap` 能正常导入。 -- **我们提供的电机固件镜像**,例如 `EL05_1.0.5.0.4.bin`。请保持原文件名:脚本会从文件名读出 +- **电机固件镜像**随仓库放在 [`firmware/`](../firmware/) 里(登记在 `manifest.json` 的 + `motors` 下)。脚本会按夹爪记录的电机型号自动选镜像,并核对 CRC32。要刷别的镜像,把文件名写在 + 目标前面(`motor_ota_update.py EL05_1.0.5.0.4.bin left`),并保持原文件名:脚本会从文件名读出 电机型号和版本,并与镜像内容和夹爪记录核对。 - **没有别的程序在使用夹爪。** 先停掉你自己的程序。 - **两指之间清空。** 每次断电重启后,夹爪会自动开合一次做标定。 @@ -46,13 +48,13 @@ python python/examples/follower_status.py left ``` -看 `[fw]`(夹爪固件)和 `[motor fw]`(电机固件)两行。此时电机固件可能显示读不到:电机在 MIT 下、 -且夹爪里还没有记录时就是这样,属于正常现象。 +看 `[fw]`(夹爪固件)和 `[motor fw]`(电机固件)两行。此时 `[motor fw]` 可能显示 `unreadable`: +电机在 MIT 下、且夹爪里还没有记录时就是这样,属于正常现象。 ### 2. 第一遍:切到私有协议 ```bash -python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left +python python/examples/motor_ota_update.py left ``` ```text @@ -70,7 +72,7 @@ wait ~10 s, then run the same command again to flash. ### 3. 第二遍:刷写 ```bash -python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left +python python/examples/motor_ota_update.py left ``` ```text @@ -78,7 +80,7 @@ python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left gripper : TCGU01A25Z0017s firmware FirmwareVersion(1.2.16.0) motor : EL05 can_id 1 uid ... motor fw now : 1.0.5.0.2 - image : EL05_1.0.5.0.4.bin + image : .../TacCap-Gripper/firmware/EL05_1.0.5.0.4.bin 92,584 B, 11,573 packs, version 1.0.5.0.4 envelope : at the motor's rating, unchanged Flash the MOTOR with this image? type 'yes': yes @@ -119,6 +121,8 @@ python python/examples/follower_status.py left | 现象 | 处理方法 | | --- | --- | +| `no recorded motor model` / `no shipped image for motor ...` | 夹爪没有记录电机型号,或 `firmware/` 里没有该型号的镜像。显式指定镜像,或先设置型号(见 [firmware/README.md](../firmware/README.md#follower-motor-setup))。 | +| `... has CRC32 ..., firmware/manifest.json says ...` | `firmware/` 里的文件损坏或被替换。用 `git checkout firmware/` 恢复。 | | `cannot tell the motor model from ...` | 镜像文件被改了名。恢复原文件名,或加 `--model EL05`。 | | `this gripper records a ... motor, the image is for ...` | 镜像对应的是另一种电机型号。不要强行刷写,请联系我们。 | | `follower firmware ... lacks the 0x5B relay` | 先升级夹爪固件(见"开始之前")。 | @@ -146,7 +150,7 @@ m = g.motor # the motor must already be on the private protocol (see step 2) session = t.MotorOtaSession(m, m.get_can_id()) session.preflight("EL05") # private protocol + recorded model -session.update_from_file("EL05_1.0.5.0.4.bin", lambda done, total: None) +session.update_from_file("firmware/EL05_1.0.5.0.4.bin", lambda done, total: None) m.set_motor_fw_version("1.0.5.0.4") # record it for MIT reads m.switch_protocol(t.MotorProtocol.Mit) # then power-cycle ``` diff --git a/docs/MOTOR_OTA.md b/docs/MOTOR_OTA.md index e256b4a..d97c187 100644 --- a/docs/MOTOR_OTA.md +++ b/docs/MOTOR_OTA.md @@ -19,9 +19,12 @@ motor stays mounted. also resets the motor to its normal protocol, so it must come before the motor update, never after. - **SDK 0.4.3 or newer**, installed so that `import xense.taccap` works. -- **The motor firmware image** from us, for example `EL05_1.0.5.0.4.bin`. Keep the - file name as delivered: the script reads the motor model and version from it - and checks them against the image and the gripper. +- **The motor firmware image** ships in [`firmware/`](../firmware/) (listed under + `motors` in `manifest.json`). The script picks the one for the motor model + recorded on the gripper and checks its CRC32. To flash a different image, put + it before the target (`motor_ota_update.py EL05_1.0.5.0.4.bin left`) and keep + the file name as delivered: the script reads the motor model and version from + it and checks them against the image and the gripper. - **Nothing else using the gripper.** Stop your own programs first. - **Jaws clear.** After each power cycle the gripper opens and closes once to calibrate. @@ -64,7 +67,7 @@ is on MIT and nothing has been recorded yet. ### 2. First run: switch to the private protocol ```bash -python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left +python python/examples/motor_ota_update.py left ``` ```text @@ -83,7 +86,7 @@ stays disabled and the gripper does not calibrate; that is expected. ### 3. Second run: flash ```bash -python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left +python python/examples/motor_ota_update.py left ``` ```text @@ -91,7 +94,7 @@ python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left gripper : TCGU01A25Z0017s firmware FirmwareVersion(1.2.16.0) motor : EL05 can_id 1 uid ... motor fw now : 1.0.5.0.2 - image : EL05_1.0.5.0.4.bin + image : .../TacCap-Gripper/firmware/EL05_1.0.5.0.4.bin 92,584 B, 11,573 packs, version 1.0.5.0.4 envelope : at the motor's rating, unchanged Flash the MOTOR with this image? type 'yes': yes @@ -137,6 +140,8 @@ gripper, so it stays readable on MIT from now on. | Symptom | What to do | | --- | --- | +| `no recorded motor model` / `no shipped image for motor ...` | The gripper's motor model is not recorded, or no image for it ships in `firmware/`. Pass the image explicitly, or set the model first ([firmware/README.md](../firmware/README.md#follower-motor-setup)). | +| `... has CRC32 ..., firmware/manifest.json says ...` | The file in `firmware/` is damaged or was replaced. Restore it with `git checkout firmware/`. | | `cannot tell the motor model from ...` | The image file was renamed. Restore the original name, or pass `--model EL05`. | | `this gripper records a ... motor, the image is for ...` | The image is for a different motor model. Do not override this; contact us. | | `follower firmware ... lacks the 0x5B relay` | Update the gripper firmware first (see *Before you start*). | @@ -164,7 +169,7 @@ m = g.motor # the motor must already be on the private protocol (see step 2) session = t.MotorOtaSession(m, m.get_can_id()) session.preflight("EL05") # private protocol + recorded model -session.update_from_file("EL05_1.0.5.0.4.bin", lambda done, total: None) +session.update_from_file("firmware/EL05_1.0.5.0.4.bin", lambda done, total: None) m.set_motor_fw_version("1.0.5.0.4") # record it for MIT reads m.switch_protocol(t.MotorProtocol.Mit) # then power-cycle ``` diff --git a/firmware/EL05_1.0.5.0.4.bin b/firmware/EL05_1.0.5.0.4.bin new file mode 100644 index 0000000..ff36209 Binary files /dev/null and b/firmware/EL05_1.0.5.0.4.bin differ diff --git a/firmware/README.md b/firmware/README.md index 74baa32..fe7dce4 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -8,6 +8,13 @@ part of this SDK. | `tc-gu-01-master-1.2.6.bin` | leader (SN ends **`m`**) | **1.2.6** | V2.6 | 118,300 B | `0x350393b5` | | `tc-gu-01-slave-1.2.16.bin` | follower (SN ends **`s`**) | **1.2.16** | V2.8 | 167,544 B | `0x4a8cd360` | +The follower's RobStride motor runs its own firmware; its images are listed +under `motors` in the manifest (see [Motor firmware](#motor-firmware)): + +| Image | Motor | Version | Size | CRC32 | +| --- | --- | --- | --- | --- | +| `EL05_1.0.5.0.4.bin` | EL05 | **1.0.5.0.4** | 92,584 B | `0x845a0474` | + Only the current release is kept here; older images are in git history. [`manifest.json`](manifest.json) carries the same data machine-readably — `ota_update.py` picks the file through it and verifies the image by CRC32. @@ -108,16 +115,23 @@ is returned under MIT as well — record it after each motor update with motor stays mounted): ```bash -python python/examples/motor_ota_update.py rs00-0.0.3.32.bin left +python python/examples/motor_ota_update.py left # image from this directory +python python/examples/motor_ota_update.py rs00-0.0.3.32.bin left # or name one ``` -1. Flash the gripper firmware first — a gripper OTA switches the motor back to - MIT. -2. Switch the motor to the private protocol: - `g.motor.switch_protocol(t.MotorProtocol.Private)`, then cut 24 V for ~2 s. -3. Run `motor_ota_update.py`. It refuses an image whose model does not match - the model recorded on the gripper (RobStride's own OTA does not check). -4. Cut 24 V again. The motor comes back on MIT. +Without an image the script takes the one the manifest lists for the motor +model recorded on the gripper, and checks its CRC32. Run the same command +twice, cutting 24 V for ~2 s after each: the first run switches the motor to its +private protocol, the second flashes it and switches it back to MIT. Flash the +gripper firmware first — a gripper OTA switches the motor back to MIT. The +script refuses an image whose model does not match the model recorded on the +gripper (RobStride's own OTA does not check). Full procedure: +[docs/MOTOR_OTA.md](../docs/MOTOR_OTA.md). + +No RS00 image ships yet. To add a motor image, keep the vendor's file name +(`_.bin` or `-.bin`; the script reads model and +version from it) and add a `motors.` entry with `file`, `version`, +`size_bytes` and `crc32` to `manifest.json`. ## Building from source diff --git a/firmware/manifest.json b/firmware/manifest.json index 427a4ff..cff837c 100644 --- a/firmware/manifest.json +++ b/firmware/manifest.json @@ -19,10 +19,20 @@ "size_bytes": 167544, "crc32": "0x4a8cd360", "sn_suffix": "s", - "protocol": "V2.8 + 运动安全包络", + "protocol": "V2.8 + motion envelope", "build": "local", "toolchain": "arm-none-eabi-gcc (15:13.2.rel1-2) 13.2.1 20231009", "note": "Stops decoding the motor's echo frames as status. A RobStride motor takes an MIT motion frame whose byte 0 equals its own CAN ID for another command and answers on 0xFD with the frame's first four bytes and zeros in bytes 4-7; byte 0 is the target position's high byte, and the factory ID 127 (0x7F) is -0.098..0 rad -- a LEFT-hand RS00's closed end. 1.2.15 decoded each echo as status (temperature 0, torque -t_max, position clamped to the far end), the envelope clamp then dragged the target toward the open end and the jaw was thrown open: force-position never closed to 0.0 on 0089s. The real fix is the CAN ID (RS00 followers: left 17, right 18, written by the factory config tool); this image is the backstop. Carries 1.2.15 (RS00 open direction from the SN's side). Verified on 0089s (left) / 0094s (right). AFTER FLASHING, unplug the 24 V power cable for ~2 s and plug it back; the USB cable can stay in." } + }, + "motors": { + "EL05": { + "file": "EL05_1.0.5.0.4.bin", + "version": "1.0.5.0.4", + "size_bytes": 92584, + "crc32": "0x845a0474", + "vendor": "RobStride", + "note": "RobStride EL05 motor firmware, flashed with motor_ota_update.py through the follower (follower firmware >= 1.2.8). Fixes the constant velocity feedback of 1.0.5.0.2. The motor itself answers the version as bytes 10.5.0.4; the vendor writes it 1.0.5.0.4. Keep the filename: the script reads the model and version from it." + } } } diff --git a/python/bindings/bind_control.cpp b/python/bindings/bind_control.cpp index 012ce54..4986490 100644 --- a/python/bindings/bind_control.cpp +++ b/python/bindings/bind_control.cpp @@ -22,25 +22,30 @@ void bind_control(py::module_& m) { "False until the first frame arrives, and again once the stream goes stale.") .def_readonly("position", &GripperObservation::position, "Normalized opening, 0 = closed, 1 = open. Needs a calibrated gripper.\n\n" - "**钳位在 [0, 1]**:爪子越过标定端点(比如软垫压过零点、全开时过冲)\n" - "时这里仍读 0 或 1,越界量看不到。要看就用 raw_pos 自己换算,\n" - "得到不钳位的开合弧度(0 = 标定闭合点,越过它为负)::\n\n" + "**Clamped to [0, 1]**: when the jaw goes past a calibrated end (e.g. a\n" + "soft pad pressed past the zero point, overshoot at full open) this still\n" + "reads 0 or 1, and the excursion is invisible. To see it, convert raw_pos\n" + "yourself to get the unclamped opening in radians (0 = calibrated closed\n" + "point, negative past it)::\n\n" " m = gripper.position_map\n" " opening = obs.raw_pos * (-1 if m.reverse else 1) - m.min_open_rad\n" - " normalized = opening / (m.max_open_rad - m.min_open_rad) # 可 <0 或 >1") + " normalized = opening / (m.max_open_rad - m.min_open_rad) # may be <0 or >1") .def_readonly("velocity", &GripperObservation::velocity, - "rad/s,**正 = 闭合**。\n\n" - "这是夹爪坐标系,不是电机坐标系 —— 电机装配方向已经被消掉,\n" - "所以同一个物理动作在每一台上符号都相同。0.3.1 之前它是原始\n" - "电机值,符号取决于那台的电机怎么装,而这一点在所有从爪都是\n" - "EL05 时看不出来。\n\n" - "**故意不等于 d(position)/dt**:position 往 1(张开)方向数,\n" - "而这里往闭合方向为正。position 是开合程度,它是有符号量,\n" - "而夹爪上有意义的有符号量是夹持。") + "rad/s, **positive = closing**.\n\n" + "This is the gripper frame, not the motor frame -- the motor mounting\n" + "direction has already been cancelled out, so the same physical motion\n" + "has the same sign on every unit. Before 0.3.1 it was the raw motor\n" + "value, whose sign depended on how that unit's motor was mounted, and\n" + "that did not show while every follower was EL05.\n\n" + "**Deliberately not d(position)/dt**: position counts toward 1 (open),\n" + "while this is positive toward closing. position measures how far open\n" + "the jaw is; velocity is signed, and the direction that matters on a\n" + "gripper is the gripping one.") .def_readonly("torque", &GripperObservation::torque, - "实测力矩 N*m,**正 = 闭合**,所以夹持力是正数。\n\n" - "和 velocity 同一个坐标系。要的是请求值而不是实测值的话,\n" - "读 snapshot 的 commanded_torque_nm(那是幅值)。") + "Measured torque, N*m, **positive = closing**, so grip force is positive.\n\n" + "Same frame as velocity. If you want the requested value rather than the\n" + "measured one, read the snapshot's commanded_torque_nm (that is a\n" + "magnitude).") .def_readonly("raw_pos", &GripperObservation::raw_pos, "Motor angle in radians, before the normalized position map. Not clamped:\n" "the only place an excursion past the calibrated ends shows (see position).") @@ -72,11 +77,13 @@ void bind_control(py::module_& m) { m.attr("MOTOR_ABSOLUTE_TORQUE_CEILING_NM") = MOTOR_ABSOLUTE_TORQUE_CEILING_NM; py::enum_(m, "ImpedanceState", - "Ordered by precedence: FAULT beats TORQUE_CAPPED beats TRACKING。\n\n" - "没有 STALLED —— 失速守卫已删除。被挡住的爪子会让误差钳位饱和在 " - "max_position_torque_nm 并一直保持在那里,那是预期的稳态而不是一个状态:" - "接触不需要检测,饱和本身就是接触。原来那个守卫会把有效目标压到爪子当前" - "位置,误差归零、力矩随之塌掉 —— 实测是接触后 60 ms 在 0.35 Nm 松手。") + "Ordered by precedence: FAULT beats TORQUE_CAPPED beats TRACKING.\n\n" + "There is no STALLED -- the stall guard was removed. A blocked jaw makes the " + "error clamp saturate at max_position_torque_nm and stay there; that is the " + "expected steady state, not a state: contact needs no detecting, saturation " + "itself is contact. The old guard pulled the effective target to the jaw's " + "current position, the error went to zero and the torque collapsed with it -- " + "measured as letting go at 0.35 Nm 60 ms after contact.") .value("IDLE", ImpedanceState::Idle) .value("TRACKING", ImpedanceState::Tracking) .value("TORQUE_CAPPED", ImpedanceState::TorqueCapped) @@ -121,15 +128,19 @@ void bind_control(py::module_& m) { "is submitted per status frame, so this is also the control rate."); impedance_config.def_static("for_spec", &ImpedanceConfig::for_spec, py::arg("spec"), - "按设备实际装的电机给出默认值(传 motor.get_spec() 的结果)。\n\n" - "类里的成员默认值是编译期烘进去的 EL05 数,**换了电机就不对**:RS00 上它把\n" - "持续夹持限在 1.1 Nm,而那颗电机能无限期维持 3.6 —— 换它的理由有三分之二\n" - "就这么没了。\n\n" + "Defaults for the motor the device actually has installed (pass the result of\n" + "motor.get_spec()).\n\n" + "The class's member defaults are EL05 numbers baked in at compile time, **wrong\n" + "once the motor changes**: on RS00 they cap sustained grip at 1.1 Nm, while that\n" + "motor can hold 3.6 indefinitely -- two thirds of the reason for switching to it\n" + "gone just like that.\n\n" " cfg = ImpedanceConfig.for_spec(g.motor.get_spec())\n\n" - "三个值必须一起动:预算取**连续堵转额定**(被挡住的爪子无限期坐在它上面),\n" - "天花板取**旋转额定**(必须高于预算,否则正常夹持就跳闸、位置控制丢失),\n" - "kd = 预算 / MOTOR_APPROACH_SPEED_RADPS —— 接近速度是 预算/kd,只提预算\n" - "会把接近速度一起提上去,这正是「直接把力矩调大」不对的地方。" + "The three values must move together: the budget takes the **continuous stall\n" + "rating** (a blocked jaw sits on it indefinitely), the ceiling takes the\n" + "**rotating rating** (must be above the budget, or a normal grip trips it and\n" + "position control is lost), kd = budget / MOTOR_APPROACH_SPEED_RADPS -- the\n" + "approach speed is budget/kd, so raising only the budget raises the approach\n" + "speed with it, which is exactly what is wrong with \"just turn the torque up\"." ); py::class_(m, "ImpedanceSnapshot", @@ -293,14 +304,16 @@ void bind_control(py::module_& m) { "seats it fully and 0.50 moves it no further, so raising this buys nothing."); fp_config.def_static("for_spec", &ForcePositionConfig::for_spec, py::arg("spec"), - "按设备实际装的电机给出默认值(传 motor.get_spec() 的结果)。\n\n" + "Defaults for the motor the device actually has installed (pass the result of\n" + "motor.get_spec()).\n\n" " cfg = ForcePositionConfig.for_spec(g.motor.get_spec())\n\n" - "grasp_torque_nm 取连续堵转额定、hold_torque_limit_nm 取旋转额定、\n" - "motion_torque_limit_nm 取力矩量程、close_speed_radps 取\n" - "MOTOR_APPROACH_SPEED_RADPS。\n\n" - "**close_preload_nm 不派生**:0.25 Nm 是在某一个机构上扫出来的落座力矩\n" - "(0.15 就能坐死,0.50 不再前进),它是爪子和减速箱的属性,不跟着电机走 ——\n" - "按电机推它等于凭空编一个数。" + "grasp_torque_nm takes the continuous stall rating, hold_torque_limit_nm the\n" + "rotating rating, motion_torque_limit_nm the torque range, close_speed_radps\n" + "MOTOR_APPROACH_SPEED_RADPS.\n\n" + "**close_preload_nm is not derived**: 0.25 Nm is a seating torque swept on one\n" + "particular mechanism (0.15 already seats it fully, 0.50 moves it no further);\n" + "it is a property of the jaw and gearbox and does not follow the motor --\n" + "deriving it from the motor would be making up a number." ); py::class_(m, "ForcePositionSnapshot", @@ -322,7 +335,7 @@ void bind_control(py::module_& m) { .def_readonly("motion_torque_limit_nm", &ForcePositionSnapshot::motion_torque_limit_nm) .def_readonly("device_limit_nm", &ForcePositionSnapshot::device_limit_nm, "The motor's own persisted torque limit (0x700B) read at start().") - // 观测量,非控制状态 —— 每帧从命令与误差导出 + // Observed quantities, not control state -- derived each frame from command and error .def_readonly("holding", &ForcePositionSnapshot::holding, "Derived each frame from the command and the error, not a state: the jaw\n" "is pressing rather than travelling.") diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index b0842c6..f8c907b 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -429,13 +429,18 @@ void bind_gripper(py::module_& m) { // pipelines, which all want RGB. Pass ColorMode.BGR for code that // hands frames straight to cv2.imshow/imwrite. py::arg("wrist_color_mode") = ColorMode::Rgb, - // 固件版本门:低于 **1.2.5** 直接拒绝打开。两条理由叠加 —— 1.1.6 - // 之前 MIT 路径上没有任何堵转保护(安全底线);1.2.5 之前标定写入的 - // 闭合零位带内缩量,而本 SDK 的闭合端预压按「归一化 0.0 就是止点」 - // 设计,在更旧固件上会压过位置映射所说的完全闭合点。 - // 置 true 只为了在升级前读一台旧设备的配置;OTA 本身走 - // LeaderGripper(不设门),升级通道始终可用。 - // 门槛的唯一真值源是 FollowerGripper::kMinFirmware*,别在这里写死。 + // Firmware version gate: below **1.2.5** opening is refused outright. + // Two reasons stack -- before 1.1.6 the MIT path had no stall + // protection at all (the safety floor); before 1.2.5 the closed zero + // written by calibration carried an inset, while this SDK's closed-end + // preload is designed on "normalized 0.0 is the hard stop", so on + // older firmware it presses past the point the position map calls + // fully closed. + // Set true only to read an old device's config before upgrading; OTA + // itself goes through LeaderGripper (no gate), so the upgrade path is + // always available. + // The single source of truth for the threshold is + // FollowerGripper::kMinFirmware*; do not hard-code it here. py::arg("allow_outdated_firmware") = false, "Open a follower by explicit device path -- mcu_device is the\n" "/dev/serial/by-id/ path from discovery.\n\n" @@ -572,16 +577,21 @@ void bind_gripper(py::module_& m) { py::gil_scoped_release r; return g.home_diag(std::chrono::milliseconds(timeout_ms)); }, py::arg("timeout_ms") = 200u, - "上电自动标定到底做了什么(0x57)。\n\n" - "它存在的理由:标定的五种失败原因此前只走固件的 UART7,而 UART7 没接到\n" - "USB —— 从主机看,一次提前结束的标定和一次正常的标定长得一模一样,两者\n" - "都会留下一个看起来合理的 max_open_rad。\n\n" - "判断一次堵转判定实不实:拿 last_abs_torque 和 stall_threshold_nm 比。\n" - "阈值 = 命令限流 × 型号的反馈饱和比 × 余量;实测只比阈值高一点点,说明\n" - "那次判定很勉强。型号表里饱和比还是 0(未测)时,阈值会落到一个保守的\n" - "回退值上,而保守意味着**偏向提前判出堵转**。\n\n" - "注意标定最后一步是从开限位**故意后退 0.12 rad**,所以爪子停在比\n" - "max_open_rad 小的位置是设计如此,不是故障。" + "What the power-on auto-calibration actually did (0x57).\n\n" + "Why it exists: the five calibration failure reasons used to go only to\n" + "the firmware's UART7, and UART7 is not wired to USB -- from the host, a\n" + "calibration that ended early looks exactly like a normal one, and both\n" + "leave a plausible-looking max_open_rad.\n\n" + "To judge whether a stall detection was solid: compare last_abs_torque\n" + "with stall_threshold_nm. Threshold = command current limit × the model's\n" + "feedback saturation ratio × margin; a measured value only slightly above\n" + "the threshold means that detection was marginal. While the saturation\n" + "ratio in the model table is still 0 (not measured), the threshold falls\n" + "back to a conservative value, and conservative means **biased toward\n" + "detecting the stall early**.\n\n" + "Note that the last calibration step **deliberately backs off 0.12 rad**\n" + "from the open limit, so the jaw stopping short of max_open_rad is by\n" + "design, not a fault." ) .def("audit_envelope", [](FollowerGripper& g, unsigned timeout_ms) { py::gil_scoped_release r; @@ -663,10 +673,11 @@ void bind_gripper(py::module_& m) { "when the gripper is not calibrated. While a controller is running, read\n" "its snapshot instead -- that comes from the status stream the controller\n" "already owns and costs no bus traffic.") - // set_position() 同样不暴露:follower_gripper.cpp 里它就是 - // motor_.submit_impedance() 外面包了一层归一化,绕开控制器的程度和裸 - // 原语完全一样,只是名字看起来更像正经 API。用 ImpedanceController 或 - // ForcePositionController 的 set_target()。 + // set_position() is not exposed either: in follower_gripper.cpp it is just + // motor_.submit_impedance() wrapped in a normalization layer, bypassing the + // controllers exactly as much as the raw primitive does, only with a name + // that looks more like a proper API. Use set_target() on ImpedanceController + // or ForcePositionController. .def("pos_to_rad", [](FollowerGripper& g, float position) { py::gil_scoped_release gil; return g.pos_to_rad(position); diff --git a/python/bindings/bind_motor.cpp b/python/bindings/bind_motor.cpp index 8c9836d..58405d2 100644 --- a/python/bindings/bind_motor.cpp +++ b/python/bindings/bind_motor.cpp @@ -274,7 +274,7 @@ void bind_motor(py::module_& m) { .def_property_readonly("version", [](const protocol::MotorVersion& v) { return py::make_tuple(v.version[0], v.version[1], v.version[2], v.version[3]); - }, "版本号四段,**高位在前**(手册 p.31 应答 Byte3~6)") + }, "The four version fields, **high first** (manual p.31, reply Byte3~6)") .def_readonly("valid", &protocol::MotorVersion::valid, "1 when the read succeeded. `version` means nothing otherwise.") .def_readonly("motor_stopped", &protocol::MotorVersion::motor_stopped, @@ -299,7 +299,8 @@ void bind_motor(py::module_& m) { .def("__str__", [](const protocol::MotorVersion& v) { if (!v.valid) { return std::string("MotorVersion(invalid, protocol=") + - (v.protocol_mode == 2 ? "MIT — 电机在 MIT 下不理扩展帧,且没有 flash 记录" + (v.protocol_mode == 2 ? "MIT — the motor ignores extended frames under MIT, " + "and there is no flash record" : "private") + ")"; } const char* src = v.source == protocol::MotorVersionSource::FlashHost @@ -458,8 +459,10 @@ void bind_motor(py::module_& m) { .def_readonly("control_mode", &MotorStatusSample::control_mode, "MotorMode of the last applied command, raw: 0 idle, 1 position,\n" "2 velocity, 3 torque, 4 impedance.") - // 诊断字段:从爪改发 59 字节 V2 状态后随流到达,不再需要轮询 0x53 - // (轮询会和相位锁里的控制帧对撞)。仍发 31 字节前缀的固件上这些为 0。 + // Diagnostic fields: since the follower switched to the 59-byte V2 status they + // arrive with the stream, so polling 0x53 is no longer needed (polling collides + // with the control frames in the phase lock). On firmware that still sends only + // the 31-byte prefix these are 0. .def_readonly("monitor_version", &MotorStatusSample::monitor_version, "Layout version of the monitor extension. 0 means the frame carried\n" "only the 31-byte prefix, so every diagnostic field here is zero-fill\n" @@ -652,35 +655,47 @@ void bind_motor(py::module_& m) { "Clear the motor's fault state. Blocks for the ACK; raises ProtocolError\n" "on NACK. Clears the motor only -- a controller's own FAULT state needs\n" "its reset().") - // ---- 裸电机控制 -------------------------------------------------- - // 这些是**无 ACK 的直投命令**:帧丢上总线就返回,没有主机侧的误差钳位、 - // 力矩天花板 —— 那些长在 ImpedanceController / ForcePositionController - // 里,越过控制器就一个都拿不到。 + // ---- Raw motor control ------------------------------------------- + // These are **fire-and-forget commands with no ACK**: they return as soon as + // the frame is on the bus, with no host-side error clamp and no torque + // ceiling -- those live in ImpedanceController / ForcePositionController, + // and going around the controllers you get none of them. // - // 它们曾经刻意不暴露给 Python。原因是真实事故:客户控制台用 - // submit_impedance() 在 kp=20 下顶住刚性物体,kp*误差 一路涨到电机的 - // 0x700B 上限,24V 下的电流需求把整块板子拉垮 —— 夹爪松手掉件、USB - // 链路消失。**那是固件 1.1.5、运动安全包络还不存在的时候。** + // They were once deliberately not exposed to Python. The reason was a real + // incident: a customer console used submit_impedance() at kp=20 against a + // rigid object, kp*error climbed all the way to the motor's 0x700B limit, + // and the current demand at 24V brought down the whole board -- the gripper + // let go and dropped the part, and the USB link vanished. **That was + // firmware 1.1.5, before the motion safety envelope existed.** // - // 1.1.6 的包络改变了这个结论。同一份代码、同一台设备、同一物体、 - // kp=20,只切包络标志位([实测]): - // 包络关闭 -> 整机掉电重启,力矩失控索要 ~12 Nm - // 包络启用 -> 25 秒稳定夹持,恒定 0.549 Nm - // 包络长在固件 MIT 分支上,是所有阻抗/位置/力/力位混合命令的唯一必经 - // 点 —— 裸命令绕不过它。控制分层本来就是 - // 「SDK 给意图,固件 500Hz 执行安全包络」,暴露命令层与之一致。 + // The 1.1.6 envelope changed that conclusion. Same code, same device, same + // object, kp=20, toggling only the envelope flag ([measured]): + // envelope off -> whole unit browns out and reboots, runaway torque demands ~12 Nm + // envelope on -> 25 s stable grip, constant 0.549 Nm + // The envelope lives in the firmware MIT branch and is the single mandatory + // path for every impedance/position/force/force-position hybrid command -- + // raw commands cannot get around it. The control layering already is + // "SDK gives intent, firmware enforces the safety envelope at 500Hz"; + // exposing the command layer is consistent with that. // - // 仍然要知道的两件事: - // 1. **热方向没有底层兜底**(同文档 §2 [实测]):电机自身的过温保护到 - // 100 °C 壳温都未动作,297 秒恒 1.65 Nm 故障位全零。热保护只有固件 - // 的温度墙,而温度墙是包络的一部分 —— 包络被关掉就什么都没有了。 - // 2. 主机看门狗照常计时:停止下发 300ms 后固件 T1 接管零速保持,30s 后 - // T2 失能。裸命令不豁免这条。 + // Two things you still need to know: + // 1. **No low-level backstop in the thermal direction** (same doc §2 + // [measured]): the motor's own over-temperature protection had not + // tripped even at 100 °C case temperature, 297 s at a constant 1.65 Nm + // with all fault bits zero. The only thermal protection is the firmware + // temperature wall, and the temperature wall is part of the envelope -- + // turn the envelope off and there is nothing at all. + // 2. The host watchdog keeps counting as usual: 300ms after commands stop, + // firmware T1 takes over with a zero-velocity hold, and after 30s T2 + // disables. Raw commands are not exempt from this. // - // 需要误差钳位、力矩天花板和堵转处理的调用方,仍应该用控制器。 - // 带 ACK 的版本(v0.3.3):每条命令等固件应答,拿得到 NACK。比 submit_* - // 慢一个往返,适合单步命令和测延迟,不适合 100Hz 控制环。和 submit_* - // 一样绕过控制器的误差钳位与力矩天花板,只受固件包络和 0x700B 约束。 + // Callers that need the error clamp, torque ceiling and stall handling should + // still use a controller. + // ACKed versions (v0.3.3): each command waits for the firmware reply, so you + // can see a NACK. One round trip slower than submit_*, suited to single-step + // commands and latency tests, not to a 100Hz control loop. Like submit_* they + // bypass the controllers' error clamp and torque ceiling, bounded only by the + // firmware envelope and 0x700B. .def("set_position", [](Motor& self, float pos, float max_vel, float max_torque) { py::gil_scoped_release g; self.set_position(pos, max_vel, max_torque); @@ -846,39 +861,52 @@ void bind_motor(py::module_& m) { py::gil_scoped_release g; return self.get_model(std::chrono::milliseconds(timeout_ms)); }, py::arg("timeout_ms") = 1000, - "本机记录的电机型号(0x59)。**问的是 MCU 的 flash,不是电机** ——\n" - "电机答不出自己的型号,见 MotorModel。需要从爪固件 >= 1.2.7。\n\n" - "当一台夹爪表现得像是力矩被整体缩放时先读它:回包里的 t_max_nm /\n" - "v_max_rad_s 就是当前真正在用的 MIT 量程。") + "The motor model recorded on this unit (0x59). **This asks the MCU's flash,\n" + "not the motor** -- the motor cannot report its own model, see MotorModel.\n" + "Requires follower firmware >= 1.2.7.\n\n" + "Read this first when a gripper behaves as if its torque were scaled across\n" + "the board: t_max_nm / v_max_rad_s in the reply are the MIT range actually\n" + "in use right now.") .def("set_model", [](Motor& self, uint8_t model_id, unsigned timeout_ms) { py::gil_scoped_release g; self.set_model(model_id, std::chrono::milliseconds(timeout_ms)); }, py::arg("model_id"), py::arg("timeout_ms") = 1000, - "记录本机装的是哪一款电机(0x5A)。**写 MCU flash,掉电保持。**\n\n" - "**下次上电才生效**,不是立刻:MIT 量程在启动时定下来,控制环已经按它在\n" - "跑,中途换刻度会让在途的命令和反馈用两套标准解释。和改电机 CAN ID 一样,\n" - "写完要断电重启(拔 24V 电源线约 2 秒,USB 可不拔)。\n\n" - "写错型号不会报错,只会让之后每一帧力矩差一个固定倍率 —— 先用\n" - "get_model() 确认写进去的是对的。") + "Record which motor model this unit has installed (0x5A). **Writes MCU\n" + "flash; persists across power loss.**\n\n" + "**Takes effect at the next power-on**, not immediately: the MIT range is\n" + "fixed at boot and the control loop is already running on it; switching\n" + "scale midway would make in-flight commands and feedback be interpreted by\n" + "two different standards. As with changing the motor CAN ID, power-cycle\n" + "after writing (unplug the 24V power cable for about 2 s; USB can stay in).\n\n" + "Writing the wrong model raises no error; it just makes every later torque\n" + "frame off by a fixed factor -- confirm with get_model() that what was\n" + "written is right.") .def("motor_version", [](Motor& self, unsigned timeout_ms) { py::gil_scoped_release g; return self.motor_version(std::chrono::milliseconds(timeout_ms)); }, py::arg("timeout_ms") = 3000, - "**电机自身**的固件版本(不是夹爪固件,那是 gripper.firmware_version)。\n" - "需要从爪固件 >= 1.2.6;更旧的固件没有 0x58 这条命令,会抛 InvalidCmd。\n\n" - "先看 valid 再读 version:读不到时在 payload 里如实上报而不是抛异常,\n" - "因为『电机没应答』和『这里不支持这条命令』是两种不同的诊断,一个 NACK\n" - "说不清是哪种。\n\n" - "**已实测**:私有协议下可读(0074s / 0018s 都答了 1.0.5.0.x)。MIT 下\n" - "电机**不回** —— 请求是扩展帧,MIT 下电机不理扩展帧(实测 0086s)。\n" - "从爪固件 >= 1.2.14 此时返回 flash 里的记录,source / from_flash 标明;\n" - "记录来自私有协议开机时的自动识别,或 set_motor_fw_version() 写入。\n" - "更旧的固件在 MIT 下 valid=0。\n\n" - "默认超时 3000 ms:实测往返可达约 1371 ms,原来的 500 ms 会在应答可能到达\n" - "之前就超时,把一次正常读取显示成电机没反应。\n\n" - "**可能把电机停掉**:请求帧复用了通信类型 4(电机停止),不认 00 C4 魔数\n" - "的电机会把它当停止执行。motor_stopped=1 就是这种情况,再发运动命令前\n" - "要重新 enable()。") + "The firmware version of **the motor itself** (not the gripper firmware;\n" + "that is gripper.firmware_version).\n" + "Requires follower firmware >= 1.2.6; older firmware has no 0x58 command and\n" + "raises InvalidCmd.\n\n" + "Check valid before reading version: a failed read is reported as-is in the\n" + "payload rather than raised, because 'the motor did not answer' and 'this\n" + "command is not supported here' are two different diagnoses, and a NACK\n" + "cannot tell which.\n\n" + "**Measured**: readable under the private protocol (0074s / 0018s both\n" + "answered 1.0.5.0.x). Under MIT the motor **does not reply** -- the request\n" + "is an extended frame, and under MIT the motor ignores extended frames\n" + "(measured on 0086s). Follower firmware >= 1.2.14 then returns the record in\n" + "flash, flagged by source / from_flash; the record comes from auto-detection\n" + "at a private-protocol boot, or from set_motor_fw_version().\n" + "Older firmware gives valid=0 under MIT.\n\n" + "Default timeout 3000 ms: the measured round trip can reach about 1371 ms;\n" + "the old 500 ms would time out before the reply could arrive, showing a\n" + "normal read as an unresponsive motor.\n\n" + "**May stop the motor**: the request frame reuses communication type 4\n" + "(motor stop), and a motor that does not know the 00 C4 magic executes it\n" + "as a stop. motor_stopped=1 is that case; enable() again before sending\n" + "motion commands.") .def("set_motor_fw_version", [](Motor& self, py::object version, unsigned timeout_ms) { std::array v{}; if (py::isinstance(version)) { @@ -905,14 +933,20 @@ void bind_motor(py::module_& m) { py::gil_scoped_release g; return self.set_motor_fw_version(v, std::chrono::milliseconds(timeout_ms)); }, py::arg("version"), py::arg("timeout_ms") = 1000, - "把**电机自身**的固件版本记进从爪 flash(0x5C,需从爪固件 >= 1.2.14)。\n\n" - "电机只在私有协议下回版本帧,现场几乎都在 MIT,所以这是产线 SOP 的一步:\n" - "工人照铭牌 / 上位机读到的版本写入;电机 OTA 成功后也应写一次。之后\n" - "motor_version() 在 MIT 下返回它(source=1)。\n\n" - "version:按**厂家写法**给字符串,照铭牌填即可 —— EL05 '1.0.5.0.4'、\n" - "RS00 '0.0.3.32'。也可给四个原始字节的元组,如 (10, 5, 0, 4)。\n\n" - "返回 flash 回显的记录;与所写不一致抛 ProtocolError。控制器 / 自动标定 /\n" - "OTA 运行中被拒(要擦 flash 扇区)。立即生效,不需要断电。") + "Record the firmware version of **the motor itself** in follower flash\n" + "(0x5C, requires follower firmware >= 1.2.14).\n\n" + "The motor only replies with its version frame under the private protocol,\n" + "and in the field almost everything runs MIT, so this is a production-line\n" + "SOP step: the worker writes the version from the nameplate / as read by the\n" + "host tool; it should also be written once after a successful motor OTA.\n" + "After that motor_version() returns it under MIT (source=1).\n\n" + "version: a string in the **vendor's notation**, copied from the nameplate --\n" + "EL05 '1.0.5.0.4', RS00 '0.0.3.32'. A tuple of the four raw bytes also\n" + "works, e.g. (10, 5, 0, 4).\n\n" + "Returns the record echoed back from flash; raises ProtocolError if it does\n" + "not match what was written. Rejected while a controller / auto-calibration /\n" + "OTA is running (it erases a flash sector). Takes effect immediately; no\n" + "power cycle needed.") .def("can_ext_xfer", [](Motor& self, uint32_t ext_id, py::bytes data, unsigned reply_timeout_ms, uint32_t match_mask, uint32_t match_value) { @@ -928,14 +962,17 @@ void bind_motor(py::module_& m) { }, py::arg("ext_id"), py::arg("data") = py::bytes(), py::arg("reply_timeout_ms") = 100, py::arg("match_mask") = 0, py::arg("match_value") = 0, - "往电机 CAN 总线发一帧 29 位扩展帧,在 MCU 上等第一帧满足\n" - "(id & match_mask) == match_value 的扩展帧应答(0x5B,从爪固件 >= 1.2.8)。\n\n" - "给电机 OTA 和协议探测用的底层工具,**不是控制接口**:它能发出任何帧,\n" - "包括使能、停止、改 CAN ID,所以控制环、自动标定、夹爪 OTA 运行时固件\n" - "会拒绝(SysBusy / OtaBusy -> ProtocolError)。\n\n" - "电机没回不是异常,返回 status == 1(NoReply)。match_mask=0 表示任何\n" - "扩展帧都算应答 —— 包括 500Hz 的状态帧,通常不是你要的。\n" - "reply_timeout_ms=0 只发不等,上限 2000。") + "Send one 29-bit extended frame on the motor CAN bus and wait on the MCU for\n" + "the first extended-frame reply satisfying (id & match_mask) == match_value\n" + "(0x5B, follower firmware >= 1.2.8).\n\n" + "A low-level tool for motor OTA and protocol probing, **not a control\n" + "interface**: it can send any frame, including enable, stop and CAN ID\n" + "changes, so the firmware rejects it while a control loop, auto-calibration\n" + "or gripper OTA is running (SysBusy / OtaBusy -> ProtocolError).\n\n" + "No reply from the motor is not an exception; it returns status == 1\n" + "(NoReply). match_mask=0 means any extended frame counts as the reply --\n" + "including the 500Hz status frames, usually not what you want.\n" + "reply_timeout_ms=0 sends without waiting; the maximum is 2000.") .def("set_startup_limit_torque", [](Motor& self, float torque_nm) { py::gil_scoped_release g; self.set_startup_limit_torque(torque_nm); }, py::arg("torque_nm"), @@ -952,8 +989,9 @@ void bind_motor(py::module_& m) { "the firmware's control task is running.") .def("get_spec", [](Motor& self) { py::gil_scoped_release g; return self.get_spec(); - }, "当前电机的型号规格(额定/峰值/量程)。设备是自身额定值的权威 —— " - "SDK 里的 MOTOR_*_TORQUE_NM 只是 EL05 的 fallback。字段为 0 表示未知。") + }, "Spec of the installed motor model (rated / peak / range). The device is the " + "authority on its own ratings -- MOTOR_*_TORQUE_NM in the SDK is only the EL05 " + "fallback. A field of 0 means unknown.") .def("get_startup_limit_torque", [](Motor& self) { py::gil_scoped_release g; return self.get_startup_limit_torque(); }, "Read back the persisted power-on limit torque, N*m. Blocks for the ACK;\n" diff --git a/python/bindings/bind_ota.cpp b/python/bindings/bind_ota.cpp index b7a4c08..473404f 100644 --- a/python/bindings/bind_ota.cpp +++ b/python/bindings/bind_ota.cpp @@ -157,14 +157,19 @@ void bind_ota(py::module_& m) { // ---- MotorOtaSession: reflash the RobStride motor itself ------------ py::class_(m, "MotorOtaSession", - "电机固件升级 —— 刷的是 RobStride 电机模组自己的程序,不是夹爪 MCU\n" - "(那是 gripper.ota)。每一帧经 Motor.can_ext_xfer(0x5B,从爪固件 >= 1.2.8)\n" - "由 MCU 转发并等电机应答,流程在主机侧。\n\n" - "前提(实测):电机必须在**私有协议**下 —— MIT 下电机不回 OTA 帧。切协议要\n" - "断 24V;而夹爪 OTA 会把电机切回 MIT,所以电机 OTA 要放在夹爪 OTA 之后。\n\n" - "**协议不校验型号**:升级包里没有型号字段,RS00 的包刷进 EL05 电机也会被\n" - "接受。update 之前先调 preflight(expected_model)。\n\n" - "中途失败时,RobStride 的建议是给电机断电重上,从头再刷。") + "Motor firmware upgrade -- this flashes the RobStride motor module's own\n" + "program, not the gripper MCU (that is gripper.ota). Each frame goes through\n" + "Motor.can_ext_xfer (0x5B, follower firmware >= 1.2.8), forwarded by the MCU,\n" + "which waits for the motor's reply; the flow runs on the host.\n\n" + "Precondition (measured): the motor must be in the **private protocol** --\n" + "under MIT the motor does not reply to OTA frames. Switching protocol needs a\n" + "24V power cycle; and gripper OTA switches the motor back to MIT, so motor\n" + "OTA must come after gripper OTA.\n\n" + "**The protocol does not check the model**: the upgrade package has no model\n" + "field, and an RS00 package flashed into an EL05 motor is accepted too. Call\n" + "preflight(expected_model) before update.\n\n" + "On a failure midway, RobStride's advice is to power-cycle the motor and\n" + "flash again from the start.") .def(py::init([](Motor& motor, uint8_t can_id, uint16_t handshake_timeout_ms, uint16_t data_timeout_ms, int max_attempts, int start_attempts, int max_resumes) { @@ -181,17 +186,22 @@ void bind_ota(py::module_& m) { py::arg("max_attempts") = 3, py::arg("start_attempts") = 6, py::arg("max_resumes") = 64, py::keep_alive<1, 2>(), - "绑定到一台从爪的 motor。can_id 用 motor.get_can_id() 读。\n" - "超时是每次尝试的时长;max_attempts 是单帧无应答时的重发次数,\n" - "start_attempts 单独给启动帧:电机先重启进 bootloader 才应答,实测约 4 秒。\n" - "max_resumes 是整次升级里允许电机要求回退续传的次数。") + "Bind to one follower's motor. Read can_id with motor.get_can_id().\n" + "The timeouts are per attempt; max_attempts is the number of resends when\n" + "a single frame gets no reply, and start_attempts is given separately for\n" + "the start frame: the motor first reboots into the bootloader before it\n" + "replies, measured at about 4 s.\n" + "max_resumes is how many times over the whole upgrade the motor is allowed\n" + "to ask to rewind and resume.") .def("preflight", [](MotorOtaSession& self, const std::string& expected_model) { py::gil_scoped_release g; self.preflight(expected_model); }, py::arg("expected_model"), - "刷之前的把关:电机必须在私有协议下,且本机记录的电机型号必须是\n" - "expected_model(\"RS00\" / \"EL05\")。不满足就抛 ProtocolError。\n" - "型号只是编译期默认值(不是写进 flash 的记录)时只告警 —— 请核对铭牌。") + "The gate before flashing: the motor must be in the private protocol, and\n" + "the motor model recorded on this unit must be expected_model (\"RS00\" /\n" + "\"EL05\"). Raises ProtocolError otherwise.\n" + "When the model is only the compile-time default (not a record written to\n" + "flash) it only warns -- check the nameplate.") .def("read_uid", [](MotorOtaSession& self) { std::array uid; { @@ -199,7 +209,8 @@ void bind_ota(py::module_& m) { uid = self.read_uid(); } return py::bytes(reinterpret_cast(uid.data()), uid.size()); - }, "通信类型 0,读电机的 64 位 MCU UID。只读,不改变电机状态。") + }, "Communication type 0: read the motor's 64-bit MCU UID. Read-only; does not\n" + "change the motor's state.") .def("update_from_file", [](MotorOtaSession& self, const std::string& path, py::object on_progress) { MotorOtaSession::ProgressCallback cb; @@ -213,10 +224,14 @@ void bind_ota(py::module_& m) { py::gil_scoped_release g; self.update_from_file(path, std::move(cb)); }, py::arg("path"), py::arg("on_progress") = py::none(), - "**会改写电机 flash。**完整流程:读 UID -> 启动 -> 信息 -> 逐包数据 -> 结束。\n" - "on_progress(packs_done, packs_total) 每 256 包调用一次,结束时再调一次。\n" - "电机不再应答抛 TimeoutError,电机报告无法恢复的失败抛 ProtocolError。\n" - "结束帧确认后电机会重启,并回到 MIT 协议(实测 0086s),当场读不到版本号。") + "**Rewrites motor flash.** Full flow: read UID -> start -> info -> data pack\n" + "by pack -> end.\n" + "on_progress(packs_done, packs_total) is called every 256 packs, and once\n" + "more at the end.\n" + "Raises TimeoutError when the motor stops replying, ProtocolError when the\n" + "motor reports an unrecoverable failure.\n" + "After the end frame is acknowledged the motor reboots and returns to the MIT\n" + "protocol (measured on 0086s), so the version cannot be read right then.") .def("update_from_bytes", [](MotorOtaSession& self, py::bytes blob, py::object on_progress) { const std::string buf = blob; @@ -232,8 +247,10 @@ void bind_ota(py::module_& m) { py::gil_scoped_release g; self.update_from_bytes(image, std::move(cb)); }, py::arg("image"), py::arg("on_progress") = py::none(), - "同 update_from_file,镜像已在内存里。**会改写电机 flash。**\n" - "结束后电机同样重启回 MIT 协议,当场读不到版本号。"); + "Same as update_from_file, with the image already in memory. **Rewrites\n" + "motor flash.**\n" + "Afterwards the motor likewise reboots back into the MIT protocol, so the\n" + "version cannot be read right then."); } } // namespace xense::taccap::python diff --git a/python/bindings/module.cpp b/python/bindings/module.cpp index 1f5d806..dffaca4 100644 --- a/python/bindings/module.cpp +++ b/python/bindings/module.cpp @@ -143,8 +143,9 @@ PYBIND11_MODULE(_taccap_native, m) { .value("GetMotorStatusExt", tp::Cmd::GetMotorStatusExt) .value("GetUartStats", tp::Cmd::GetUartStats) .value("SetLogConfig", tp::Cmd::SetLogConfig) - // 这三项此前漏在枚举外(GetMotorSpec / GetHomeDiag 从来没补过)。枚举的 - // 用处就是镜像命令集,缺项会让 transport.send_cmd() 的调用方只能写裸值。 + // These three were previously missing from the enum (GetMotorSpec / + // GetHomeDiag were never added). The enum exists to mirror the command set; + // a missing entry leaves callers of transport.send_cmd() writing raw values. .value("GetMotorSpec", tp::Cmd::GetMotorSpec) .value("GetHomeDiag", tp::Cmd::GetHomeDiag) .value("GetMotorVersion", tp::Cmd::GetMotorVersion) diff --git a/python/examples/control_and_read.py b/python/examples/control_and_read.py index 924f558..6331ab1 100644 --- a/python/examples/control_and_read.py +++ b/python/examples/control_and_read.py @@ -1,26 +1,31 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""边控制边读状态:控制器跑着的时候,状态从哪里拿。 +"""Read status while controlling: where to get state while a controller runs. -**控制期间不要调 `motor.read_status()`。** 那是一次命令往返,它的 ACK 会和控制帧 -在同一条串口上撞车。控制器已经持有电机状态流,所以状态是白拿的: +**Do not call `motor.read_status()` during control.** It is a command round +trip, and its ACK collides with control frames on the same serial port. The +controller already owns the motor status stream, so state comes for free: - s = c.snapshot() # 非阻塞,不发任何命令 - s.observation.position # 归一化开度 [0,1] - s.observation.torque # 反馈力矩 Nm + s = c.snapshot() # non-blocking, sends no commands + s.observation.position # normalized opening [0,1] + s.observation.torque # feedback torque Nm s.state / s.arrived / s.holding -`snapshot()` 在**一把锁下**取出整份视图,所以 state、observation、命令力矩描述的 -是同一时刻。分字段去读会拼出一个从没存在过的状态 —— 位置来自这一帧、状态来自 -下一帧。 +`snapshot()` takes the whole view **under one lock**, so state, observation +and commanded torque describe the same instant. Reading field by field stitches +together a state that never existed -- position from this frame, state from +the next. -`observation.age_ms` 是这份观测的年龄。它是判断「流还活着」的正确依据:帧还在推 -不等于内容在更新(见 follower_status.py 里状态冻结那个坑)。 +`observation.age_ms` is the age of this observation. It is the correct basis +for judging "the stream is alive": frames still being pushed does not mean +the content is updating (see the status-freeze trap in follower_status.py). -控制回路和读回路**频率不同是常态**:控制按状态流相位走 100 Hz,而策略/日志往往 -只要 10~30 Hz。两边不需要对齐,snapshot() 随便什么时候调都给最新的一致视图。 +The control loop and the read loop **normally run at different rates**: +control runs at 100 Hz phase-locked to the status stream, while policy/logging +often needs only 10~30 Hz. The two need no alignment; snapshot() called at any +time gives the latest consistent view. -用法 +Usage python python/examples/control_and_read.py right python python/examples/control_and_read.py --read-hz 20 --seconds 8 """ @@ -44,13 +49,14 @@ def main() -> int: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) _target.add_target_argument(ap) - ap.add_argument("--read-hz", type=float, default=10.0, help="读状态的频率") - ap.add_argument("--seconds", type=float, default=8.0, help="总时长") + ap.add_argument("--read-hz", type=float, default=10.0, help="status read rate") + ap.add_argument("--seconds", type=float, default=8.0, help="total duration") ap.add_argument( "--grasp-torque", type=float, default=None, - help="力矩预算 Nm。默认取电机的连续堵转额定(EL05 1.1 / RS00 3.6),不能超过它", + help="torque budget Nm. Defaults to the motor's continuous stall rating " + "(EL05 1.1 / RS00 3.6); must not exceed it", ) args = ap.parse_args() @@ -58,25 +64,27 @@ def main() -> int: g, _ep = _target.open_follower(args.target) print(f"[fw] {g.firmware_version}") - # 按设备实际装的电机取默认值 —— EL05 和 RS00 的持续夹持差 3.3 倍 + # Defaults come from the motor actually fitted -- EL05 and RS00 sustained + # grip differ by 3.3x cfg = ForcePositionConfig.for_spec(g.motor.get_spec()) if args.grasp_torque is not None: cfg.grasp_torque_nm = args.grasp_torque c = ForcePositionController(g, cfg) c.start() - # 控制侧:每 2 秒换一个目标。读侧:按 --read-hz 独立采样,不和控制对齐。 + # Control side: a new target every 2 s. Read side: samples independently at + # --read-hz, not aligned with control. targets = [1.0, 0.0, 1.0, 0.0] period = args.seconds / len(targets) interval = 1.0 / args.read_hz print( - f"\n控制 {args.seconds:.0f}s,每 {period:.1f}s 换一次目标;" - f"读侧独立跑 {args.read_hz:g} Hz\n" + f"\ncontrol {args.seconds:.0f}s, new target every {period:.1f}s; " + f"read side runs independently at {args.read_hz:g} Hz\n" ) print( - f" {'t':>5} {'目标':>5} {'位置':>7} {'力矩':>8} {'命令':>7} " - f"{'年龄':>6} {'状态':>17} {'到位':>5}" + f" {'t':>5} {'tgt':>5} {'pos':>7} {'torque':>8} {'cmd':>7} " + f"{'age':>6} {'state':>17} {'arr':>5}" ) stale = 0 @@ -86,12 +94,13 @@ def main() -> int: c.set_target(tgt) deadline = t0 + (i + 1) * period while time.perf_counter() < deadline: - s = c.snapshot() # 非阻塞,一致视图 + s = c.snapshot() # non-blocking, consistent view o = s.observation if s.state == ForcePositionState.FAULT: print(f" FAULT: {s.fault_reason}") return 1 - # 观测太旧说明流断了,而不是爪子不动 —— 两者的处理完全不同。 + # A too-old observation means the stream dropped, not that the gripper + # is not moving -- the two are handled completely differently. if o.valid and o.age_ms > 100: stale += 1 print( @@ -104,11 +113,17 @@ def main() -> int: finally: c.stop() - print(f"\n观测过期(age>100ms)次数: {stale}") + print(f"\nstale observations (age>100ms): {stale}") if stale: - print(" **流有断续** —— 控制器还在发帧,但它看到的状态是旧的。") + print( + " **stream is intermittent** -- the controller is still sending frames, " + "but the state it sees is stale." + ) return 1 - print(" 状态全程新鲜:控制和读互不干扰,读侧不需要发任何命令。") + print( + " state fresh throughout: control and reads do not interfere, the read " + "side needs to send no commands." + ) return 0 diff --git a/python/examples/control_ripple.py b/python/examples/control_ripple.py index fa40549..f631dbd 100644 --- a/python/examples/control_ripple.py +++ b/python/examples/control_ripple.py @@ -1,27 +1,34 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""量行进段的速度纹波 —— 控制器的**质量**,不是能不能到位。 - -impedance_control.py / force_position_control.py 验的是「到位」和「到位与被挡住 -可分」,那是功能。功能对了控制仍然可以很难看:爪子一边走一边抖,平均速度只有命令 -的七成。那正是重构前的实测状态。 - -指标和重构前的基线表一致,好直接对比: - - 相对速度纹波 = σ(|v|) / 均值(|v|) 行进段,掐掉加减速两头 - 峰峰 = max(|v|) - min(|v|) - 均速/命令 = 均值(|v|) / 命令速度 - ForcePosition:config 实际的 close_speed_radps - Impedance:设计接近速度 预算/kd(它没有斜坡速度) - 极限环频率 = 去均值后的过零次数 / 2 / 时长 - 逆向帧 = 速度方向与行进方向相反的帧数(区分**脉动**与**振动**) - -最后一条是当初用来定性的:旧设计逆向帧 0/300 —— 爪子从不倒退,所以那是速度脉动 -不是机械振动,再加上「纹波跟 kd 走、不跟速度走」的四组对照,排除了机械共振。 - -采样走状态流回调,满 100Hz,不额外发任何命令 —— 行进期间轮询会和控制帧撞车。 - -用法 +"""Measure velocity ripple over the travel segment -- the controller's +**quality**, not whether it arrives. + +impedance_control.py / force_position_control.py check "arrives" and +"arrived vs. blocked are distinguishable"; that is function. With function +correct, control can still look bad: the gripper shakes as it travels and the +mean speed is only 70% of the command. That was exactly the measured state +before the refactor. + +The metrics match the pre-refactor baseline table, for direct comparison: + + relative ripple = σ(|v|) / mean(|v|) travel segment, ramp up/down trimmed + peak-to-peak = max(|v|) - min(|v|) + mean/command = mean(|v|) / commanded speed + ForcePosition: the config's actual close_speed_radps + Impedance: design approach speed budget/kd (it has no ramp speed) + limit-cycle freq = zero crossings after mean removal / 2 / duration + reversal frames = frames whose velocity opposes the travel direction + (tells **pulsation** from **vibration**) + +The last one is what was used to characterize it back then: the old design had +0/300 reversal frames -- the gripper never moved backward, so it was velocity +pulsation, not mechanical vibration; together with four control comparisons +showing "ripple follows kd, not speed", that ruled out mechanical resonance. + +Sampling uses the status stream callback, full 100Hz, sending no extra +commands -- polling during travel collides with control frames. + +Usage python python/examples/control_ripple.py right python python/examples/control_ripple.py --rounds 10 --controller both """ @@ -43,22 +50,25 @@ def summarize(seg, commanded: float) -> dict: - """seg: [(t, pos, vel, torque)],已经是行进段。""" + """seg: [(t, pos, vel, torque)], already the travel segment.""" n = len(seg) - # 掐掉两头各 20%:加速段和减速段不是稳态,算进去等于把控制器的正常行为 - # 记成纹波。 + # Trim 20% off each end: the acceleration and deceleration phases are not + # steady state; including them would count normal controller behavior as + # ripple. core = seg[int(n * 0.2) : int(n * 0.8)] v = [abs(r[2]) for r in core] mu, sd = st.mean(v), st.pstdev(v) dur = core[-1][0] - core[0][0] - # 极限环频率:去均值后数过零。比 FFT 简单,而且在这种低频单峰的情况下 - # 给出的是同一个数。 + # Limit-cycle frequency: count zero crossings after mean removal. Simpler + # than an FFT, and in this low-frequency single-peak case it gives the same + # number. dev = [x - mu for x in v] crossings = sum(1 for a, b in zip(dev, dev[1:]) if a * b < 0) freq = crossings / 2.0 / dur if dur > 0 else 0.0 - # 逆向帧:整段的行进方向由位移决定,再数有多少帧的速度和它反号。 + # Reversal frames: the segment's travel direction comes from displacement, + # then count frames whose velocity has the opposite sign. direction = 1.0 if core[-1][1] > core[0][1] else -1.0 reversals = sum(1 for r in core if r[2] * direction < -0.02) @@ -74,7 +84,8 @@ def summarize(seg, commanded: float) -> dict: } -# 「停住了」的判据:这段时间内原始位置的极差不超过这么多。 +# "Stopped" criterion: raw position range within this window stays within +# this much. STILL_WINDOW_S = 0.3 STILL_RANGE_RAD = 0.0015 @@ -90,8 +101,10 @@ def run(name, make_ctrl, commanded, gripper, rounds: int) -> int: c.start() def settle(budget=8.0): - # 先等 arrived 变假(命令被采纳)再等变真 —— set_target() 不会立刻清掉 - # 上一次运动留下的 arrived,直接等真会在爪子还没动时就返回。 + # First wait for arrived to go false (command taken), then true -- + # set_target() does not immediately clear the arrived left by the + # previous move; waiting for true directly would return before the + # gripper has moved. t0 = time.perf_counter() while time.perf_counter() - t0 < 1.2 and _arrived(c): time.sleep(0.02) @@ -104,23 +117,25 @@ def _arrived(ctrl): a = getattr(s, "arrived", None) if a is not None: return a - # 阻抗控制器没有 arrived,用「位置不动了」代替,不用速度:MIT 状态帧的 - # 速度在电机停住时有约 ±0.07 rad/s 的量化噪声,|v| < 0.02 几乎从不成立, - # 每一步都要白等满 budget(0086s 上由 DVT 测试发现)。位置一格约 - # 0.00038 rad,0.3 s 内极差 <= 0.0015 rad 就是约 4 格。 + # The impedance controller has no arrived; use "position stopped + # moving" instead, not velocity: MIT status frame velocity has about + # ±0.07 rad/s quantization noise when the motor is stopped, so + # |v| < 0.02 almost never holds and every step would wait out the full + # budget (found by DVT testing on 0086s). One position count is about + # 0.00038 rad; a range <= 0.0015 rad within 0.3 s is about 4 counts. now = time.perf_counter() win = [r[1] for r in rows[-60:] if now - r[0] <= STILL_WINDOW_S] if len(win) < 20: return False return max(win) - min(win) <= STILL_RANGE_RAD - per_dir: dict[str, list] = {"闭合": [], "张开": []} + per_dir: dict[str, list] = {"close": [], "open": []} try: c.set_target(1.0) settle() time.sleep(0.4) for _ in range(rounds): - for label, tgt in (("闭合", 0.0), ("张开", 1.0)): + for label, tgt in (("close", 0.0), ("open", 1.0)): rows.clear() c.set_target(tgt) settle() @@ -133,15 +148,15 @@ def _arrived(ctrl): c.stop() gripper.motor.off(sub) - print(f"\n=== {name} === 命令速度 {commanded:.2f} rad/s,{rounds} 轮") + print(f"\n=== {name} === commanded speed {commanded:.2f} rad/s, {rounds} rounds") print( - f" {'方向':>4} {'轮':>3} {'相对纹波':>18} {'峰峰':>16} " - f"{'极限环':>8} {'均速/命令':>10} {'逆向帧':>8}" + f" {'dir':>4} {'n':>3} {'rel ripple':>18} {'pk-pk':>16} " + f"{'cycle':>8} {'mean/cmd':>10} {'reversal':>8}" ) bad = 0 for label, xs in per_dir.items(): if not xs: - print(f" {label:>4} —— 没有有效样本") + print(f" {label:>4} -- no valid samples") bad += 1 continue rip = [x["ripple"] for x in xs] @@ -164,7 +179,9 @@ def main() -> int: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) _target.add_target_argument(ap) - ap.add_argument("--rounds", type=int, default=10, help="每个方向测多少轮") + ap.add_argument( + "--rounds", type=int, default=10, help="rounds to measure per direction" + ) ap.add_argument( "--controller", choices=("force-position", "impedance", "both"), default="both" ) @@ -172,7 +189,8 @@ def main() -> int: "--close-speed", type=float, default=None, - help="ForcePosition 斜坡速度 rad/s,默认 for_spec 的 1.1;阻抗不用它", + help="ForcePosition ramp speed rad/s, default 1.1 from for_spec; " + "impedance does not use it", ) args = ap.parse_args() @@ -183,7 +201,7 @@ def main() -> int: print(f"[envelope] stored {a.stored}") print( f"[envelope] effective " - f"{a.effective if a.effective else '*** 固件什么都不执行 ***'}" + f"{a.effective if a.effective else '*** firmware enforces nothing ***'}" ) if not a.ok: print(f"[warn] {a.detail}") @@ -197,7 +215,8 @@ def main() -> int: def mk_fp(): return ForcePositionController(g, fp_cfg) - # 分母取 config 里实际生效的斜坡速度,不给 --close-speed 时就是 for_spec 的值。 + # The denominator is the ramp speed actually in effect in the config; without + # --close-speed that is the for_spec value. bad += run( "ForcePositionController", mk_fp, fp_cfg.close_speed_radps, g, args.rounds ) @@ -208,18 +227,22 @@ def mk_fp(): def mk_imp(): return ImpedanceController(g, imp_cfg) - # 阻抗没有「命令速度」:位置误差被钳在 预算/kp 以内,驱动力矩封顶在预算, - # 爪子加速到阻尼 kd·v 与它平衡为止 —— 设计接近速度就是 预算/kd。拿它当 - # 分母,「均速/命令」才有意义。 + # Impedance has no "commanded speed": position error is clamped within + # budget/kp, drive torque is capped at the budget, and the gripper + # accelerates until damping kd·v balances it -- the design approach + # speed is budget/kd. Only with that as the denominator is + # "mean/command" meaningful. # - # 曾经拿 --close-speed 当分母,那是 ForcePosition 的斜坡速度,阻抗根本没用 - # 它:RS00 上因此报出 187%,看着像超速,实际是设计值 1.1 rad/s 的 ~87%。 + # --close-speed was once used as the denominator; that is the + # ForcePosition ramp speed, which impedance does not use at all: RS00 + # reported 187% because of it, which looked like overspeed but was + # actually ~87% of the 1.1 rad/s design value. imp_speed = imp_cfg.max_position_torque_nm / imp_cfg.kd bad += run("ImpedanceController", mk_imp, imp_speed, g, args.rounds) - print("\n基线(重构前,10 轮):") - print(" 闭合 纹波 37% 峰峰 0.59 12-13Hz 均速/命令 77%") - print(" 张开 纹波 24% 峰峰 0.51 20Hz 均速/命令 94%") + print("\nbaseline (pre-refactor, 10 rounds):") + print(" close ripple 37% pk-pk 0.59 12-13Hz mean/cmd 77%") + print(" open ripple 24% pk-pk 0.51 20Hz mean/cmd 94%") return 1 if bad else 0 diff --git a/python/examples/fisheye_cal.py b/python/examples/fisheye_cal.py index 126cdc1..7ebe474 100644 --- a/python/examples/fisheye_cal.py +++ b/python/examples/fisheye_cal.py @@ -40,8 +40,9 @@ # Or write a known value directly python python/examples/fisheye_cal.py set-encoder-max --max-rad 1.30 -选择器和其它示例一致:位置参数 left / right(或直接给固件 SN), -省略则要求只插了一台。列出连着的夹爪: +The selector matches the other examples: positional argument left / right (or a +firmware SN directly); if omitted, exactly one unit must be plugged in. To list +the connected grippers: python -c "from xense.taccap import scan_grippers; \\ [print(g.firmware_sn, g.mcu_device) for g in scan_grippers()]" """ diff --git a/python/examples/follower_status.py b/python/examples/follower_status.py index 6726429..f64fad6 100644 --- a/python/examples/follower_status.py +++ b/python/examples/follower_status.py @@ -1,28 +1,35 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""读从爪的状态:一次性读、开流读、以及状态里每个字段的意思。 +"""Read follower state: one-shot read, streaming read, and what each status +field means. -开头先打印三个版本:夹爪固件、电机型号、电机自身固件(对照最低要求)。 -排查问题时把这个脚本的完整输出发给我们,就包含了需要的全部版本信息。 +It first prints three versions: gripper firmware, motor model, and the +motor's own firmware (checked against the minimum). When troubleshooting, send +us this script's full output; it contains all the version info we need. -从爪**只流电机状态**这一种数据。固件在这个角色上把 IMU / 编码器 / 电子皮肤的 -流编译掉了,所以 `start_streaming()` 只有 `motor_hz` 一个参数。 +The follower **streams motor status only**. In this role the firmware +compiles out the IMU / encoder / e-skin streams, so `start_streaming()` takes +only one parameter, `motor_hz`. -两种读法,各有各的用途: +Two ways to read, each with its own use: - g.motor.read_status() 一次命令往返,想什么时候读就什么时候读 - g.motor.on_status(cb) 注册回调,按固件的节奏推过来 + g.motor.read_status() one command round trip, read whenever you want + g.motor.on_status(cb) register a callback, pushed at the firmware's pace -**控制期间不要用 read_status()。** 命令的 ACK 会和控制帧在串口上撞车;控制器 -自己持有状态流,`snapshot()` 拿到的就是最新一帧,不需要额外轮询 —— 见 -control_and_read.py。 +**Do not use read_status() during control.** The command's ACK collides with +control frames on the serial port; the controller owns the status stream +itself and `snapshot()` returns the latest frame, no extra polling needed -- +see control_and_read.py. -一个容易踩的坑:**状态可能是旧的而一切看着正常。** 固件曾经因为向电机请求了 -主动上报(而这台电机不支持)就停掉了自己的轮询,于是空闲时状态帧永久冻结, -版本号、数据流、计数全都正常,唯一症状是读到的数悄悄是旧的。所以这个脚本会 -打印 `status_timestamp_ms` 的推进量:它必须跟着墙钟走。 +An easy trap: **status can be stale while everything looks normal.** The +firmware once stopped its own polling because it requested active reporting +from the motor (which this motor does not support), so the status frame froze +permanently while idle; version, data stream and counters were all normal, +and the only symptom was that the values read were silently stale. So this +script prints how far `status_timestamp_ms` advances: it must track the wall +clock. -用法 +Usage python python/examples/follower_status.py right python python/examples/follower_status.py --seconds 5 --hz 100 """ @@ -37,7 +44,7 @@ def describe_status(s) -> str: - """状态位 + 停止原因,翻成人能读的。""" + """Status bits + stop reason, rendered human-readable.""" try: reason = str(MotorStopReason(s.stop_reason)).split(".")[-1] except ValueError: @@ -45,9 +52,11 @@ def describe_status(s) -> str: return f"status=0x{s.status:04X} stop={reason}" -# 电机固件最低版本,按 MotorVersion.version 的原始四字节比较(高位在前)。 -# 两条产品线各自编号,互不可比。低于它的电机过不了出厂测试: -# EL05 1.0.5.0.2 速度反馈是常数,RS00 0.0.3.22 运动抖动。 +# Minimum motor firmware, compared on the raw four bytes of +# MotorVersion.version (most significant first). The two product lines are +# numbered independently and are not comparable. Motors below it fail factory +# test: EL05 1.0.5.0.2 has constant velocity feedback, RS00 0.0.3.22 jitters +# in motion. MOTOR_FW_MIN = { "EL05": ((10, 5, 0, 4), "1.0.5.0.4"), "RS00": ((0, 0, 3, 32), "0.0.3.32"), @@ -55,36 +64,41 @@ def describe_status(s) -> str: def print_motor_versions(g) -> None: - """电机型号 + 电机自身固件版本(不是夹爪固件),并对照最低要求。""" + """Motor model + the motor's own firmware version (not the gripper firmware), + checked against the minimum.""" model = g.motor.get_model() print(f"[motor] {model!r}") if not model.from_flash: print( - " 型号是编译期默认值,不是本机记录 —— 见 firmware/README.md「Follower motor setup」" + " model is the compile-time default, not this unit's record -- see " + 'firmware/README.md "Follower motor setup"' ) try: v = g.motor.motor_version() - except Exception as e: # 从爪固件 < 1.2.6 没有 0x58 - print(f"[motor fw] 读不到 ({e}) —— 需要从爪固件 >= 1.2.6") + except Exception as e: # follower firmware < 1.2.6 has no 0x58 + print(f"[motor fw] unreadable ({e}) -- needs follower firmware >= 1.2.6") return if not v.valid: print( - "[motor fw] 读不到 —— 电机在 MIT 协议下不应答版本,且 flash 里没有记录" - "(从爪固件 >= 1.2.14 才有);按铭牌用 g.motor.set_motor_fw_version() 记录" + "[motor fw] unreadable -- the motor does not answer version on the MIT " + "protocol, and there is no record in flash (needs follower firmware " + ">= 1.2.14); record it from the nameplate with " + "g.motor.set_motor_fw_version()" ) return - src = "flash 记录" if v.from_flash else "电机实时读取" + src = "flash record" if v.from_flash else "live read from motor" line = f"[motor fw] {v.vendor_str} ({src})" floor = MOTOR_FW_MIN.get(model.name) if floor is not None: ok = tuple(v.version) >= floor[0] - line += ( - f" {'OK' if ok else '**低于最低要求 ' + floor[1] + ',需要升级电机固件**'}" - ) + line += f" {'OK' if ok else '**below minimum ' + floor[1] + ', motor firmware update needed**'}" print(line) if v.motor_stopped: - print(" 这次读取让电机执行了一次停止(RobStride 通信类型 4),运动前重新使能即可") + print( + " this read made the motor execute a stop (RobStride comm type 4); " + "re-enable before moving" + ) def main() -> int: @@ -92,8 +106,15 @@ def main() -> int: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) _target.add_target_argument(ap) - ap.add_argument("--seconds", type=float, default=3.0, help="开流读多久") - ap.add_argument("--hz", type=int, default=100, help="电机状态流频率(固件上限 100)") + ap.add_argument( + "--seconds", type=float, default=3.0, help="how long to read the stream" + ) + ap.add_argument( + "--hz", + type=int, + default=100, + help="motor status stream rate (firmware max 100)", + ) args = ap.parse_args() log.set_level("warn") @@ -101,36 +122,46 @@ def main() -> int: print(f"[fw] {g.firmware_version}") print_motor_versions(g) - # ── 一次性读 ────────────────────────────────────────────────────────────── + # ── One-shot read ───────────────────────────────────────────────────────── s = g.motor.read_status() - print("\n[一次性读] g.motor.read_status()") - print(f" 位置 {s.actual_pos:+.5f} rad (电机轴原始角,零点由上电标定确定)") - print(f" 速度 {s.actual_vel:+.5f} rad/s") - print(f" 力矩 {s.actual_torque:+.4f} Nm (反馈值,不是命令值)") - print(f" 温度 {s.motor_temp_c:.1f} °C") + print("\n[one-shot] g.motor.read_status()") + print( + f" position {s.actual_pos:+.5f} rad " + "(raw motor shaft angle, zero set by power-on calibration)" + ) + print(f" velocity {s.actual_vel:+.5f} rad/s") + print(f" torque {s.actual_torque:+.4f} Nm (feedback, not the command)") + print(f" temp {s.motor_temp_c:.1f} °C") print(f" {describe_status(s)}") - # 归一化开度要标定过才有意义:0=闭合 1=张开,由 gripper_config 的行程定义。 + # Normalized opening is meaningful only after calibration: 0=closed 1=open, + # defined by the travel in gripper_config. cfg = g.get_gripper_config() - print(f"\n[归一化] g.position() = {g.position():.4f}") + print(f"\n[normalized] g.position() = {g.position():.4f}") print( - f" 行程 min_open={cfg.min_open_rad:.5f} max_open={cfg.max_open_rad:.5f} rad" + f" travel min_open={cfg.min_open_rad:.5f} max_open={cfg.max_open_rad:.5f} rad" ) - # ── 故障 ────────────────────────────────────────────────────────────────── + # ── Faults ──────────────────────────────────────────────────────────────── fr = g.motor.fault_report() print( - f"\n[故障] 电机 0x{fr.motor_fault_code:08X} 锁存 0x{fr.motor_latched_fault_code:08X}" - f" 固件 0x{fr.firmware_fault_code:08X}" + f"\n[faults] motor 0x{fr.motor_fault_code:08X} " + f"latched 0x{fr.motor_latched_fault_code:08X}" + f" firmware 0x{fr.firmware_fault_code:08X}" + ) + print( + " latched bits clear only when explicitly cleared -- 0 now does not mean " + "there was never a fault." ) - print(" 锁存位要显式清除才会消失 —— 当前为 0 不代表从没出过故障。") - # ── 开流读 ──────────────────────────────────────────────────────────────── + # ── Streaming read ──────────────────────────────────────────────────────── frames: list = [] sub = g.motor.on_status(lambda x: frames.append((time.perf_counter(), x))) - # 固件侧最后一帧状态的 tick。新鲜度必须用它,不能用 sample.host_time —— - # 后者是**主机收到的时刻**,只要流还在推就一直在涨,哪怕固件送的是同一份 - # 旧数据。这正是当初状态冻结 45 秒却毫无症状的那个洞。 + # Firmware-side tick of the last status frame. Freshness must use this, not + # sample.host_time -- the latter is **when the host received it**, and keeps + # rising as long as the stream is pushing, even if the firmware sends the + # same stale data. That is exactly the hole through which status froze for + # 45 s with no symptom. fw_ms_before = g.motor.fault_report().status_timestamp_ms t0 = time.perf_counter() g.start_streaming(motor_hz=args.hz) @@ -141,25 +172,33 @@ def main() -> int: g.motor.off(sub) elapsed = time.perf_counter() - t0 - print(f"\n[开流读] g.start_streaming(motor_hz={args.hz}) + on_status()") + print(f"\n[streaming] g.start_streaming(motor_hz={args.hz}) + on_status()") if not frames: - print(" **一帧都没收到** —— 流没起来,或者回调注册晚于开流。") + print( + " **not a single frame received** -- the stream did not start, or the " + "callback was registered after streaming started." + ) return 1 - print(f" {len(frames)} 帧 / {elapsed:.2f} s = {len(frames) / elapsed:.1f} Hz") + print(f" {len(frames)} frames / {elapsed:.2f} s = {len(frames) / elapsed:.1f} Hz") - # 新鲜度:固件的状态时间戳必须跟着墙钟推进。冻住 = 上面说的那个坑。 + # Freshness: the firmware status timestamp must advance with the wall clock. + # Frozen = the trap described above. fw_ms_after = g.motor.fault_report().status_timestamp_ms advance = fw_ms_after - fw_ms_before wall_ms = elapsed * 1000.0 fresh = advance > wall_ms * 0.5 print( - f" 固件状态时间戳推进 {advance} ms,墙钟 {wall_ms:.0f} ms" - f" {'OK' if fresh else '**状态是旧的 —— 流在推,内容没更新**'}" + f" firmware status timestamp advanced {advance} ms, wall clock " + f"{wall_ms:.0f} ms" + f" {'OK' if fresh else '**status is stale -- stream pushing, content not updating**'}" ) lo = min(f[1].actual_pos for f in frames) hi = max(f[1].actual_pos for f in frames) - print(f" 位置区间 [{lo:+.5f}, {hi:+.5f}] rad (静止时这两个数应当几乎相等)") + print( + f" position range [{lo:+.5f}, {hi:+.5f}] rad " + "(at rest these two should be nearly equal)" + ) return 0 diff --git a/python/examples/force_position_control.py b/python/examples/force_position_control.py index 4214ba9..443c71e 100644 --- a/python/examples/force_position_control.py +++ b/python/examples/force_position_control.py @@ -1,54 +1,69 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""ForcePositionController 的用法:力位混合控制。 +"""ForcePositionController usage: force-position hybrid control. -**全程只有一条控制律**,没有接触检测: +**There is only one 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 pays only friction, +and when blocked it clamps at exactly the budget and holds there. Contact is not +detected — **saturation itself is 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, and running a second copy on the host is worse than running it +on either side alone, because two copies of the same physical event drift apart. -(这段文档一度描述的是被删掉的那套「kp=0 闭合 → 接触判定 → kp=kd=0 纯 tau_ff -保持」。脚本一直能跑,但教的是一套不存在的模型 —— 现在跑起来状态是 -HOLDING_POSITION,不会进 HoldingForce,除非真的被挡住。) +(This docstring once described the removed scheme "kp=0 close -> contact +detection -> kp=kd=0 pure tau_ff hold". The script kept running, but it taught a +model that does not exist — now the state it runs into is HOLDING_POSITION, and +it does not enter HoldingForce unless actually blocked.) -行进用**时间基准的斜坡**调速,而不是「当前位置 + 误差上限」:后者会让位置误差 -永久饱和,那是恒力推不是跟踪速度,实测会跑到目标速度的两倍。 +Travel speed is set by a **time-based ramp**, not "current position + error +limit": the latter keeps the position error permanently saturated, which is a +constant-force push rather than speed tracking, and measured it runs at twice the +target speed. -闭合端点额外带一个**有界前馈预压**(close_preload_nm,默认 0.25 Nm),把爪子坐死 -在机械止点上。没有它,kp*(target-actual) 在目标处恰好归零,爪子到了就不再施力, -齿隙留在那里 —— 实测闭合位保持力矩只有 0.001 Nm。预压是从预算里**预留**而非 -叠加,所以总请求不超过 grasp_torque_nm 这条不变量仍然成立。 +The closed endpoint additionally carries a **bounded feedforward preload** +(close_preload_nm, default 0.25 Nm) that seats the jaw firmly against the +mechanical stop. Without it, kp*(target-actual) goes to exactly zero at the +target, the jaw stops applying force once it arrives, and the backlash stays +there — measured holding torque at the closed position was only 0.001 Nm. The +preload is **reserved** out of the budget, not added on top, so the invariant +that the total request never exceeds grasp_torque_nm still holds. -接口和 ImpedanceController 一样只有两个非阻塞调用: +Like ImpedanceController, the interface is just two non-blocking calls: - c.set_target(p) # p in [0,1],0=闭合 1=张开 - s = c.snapshot() # 状态 + 观测 + 命令力矩,一把锁一致视图 + c.set_target(p) # p in [0,1], 0=closed 1=open + s = c.snapshot() # state + observation + commanded torque, consistent + # view under one lock -注意 snapshot().grasp_torque_nm 是**设定值**;固件的 I2t 与温度墙会把实际输出 -降下来,所以握持力不是常数。 +Note that snapshot().grasp_torque_nm is the **setpoint**; the firmware's I2t and +temperature wall bring the actual output down, so the grip force is not constant. -这个脚本同时是**上机验收**:它跑一遍带中间位置的目标序列,并检查那条不变式 —— +This script is also the **on-hardware acceptance test**: it runs a target +sequence that includes intermediate positions and checks the invariant — - 报告 holding 时,爪子必须明显**没到**命令位置。 + when holding is reported, the jaw must clearly **not have reached** the + commanded position. -闭合端点(目标 0.0)除外:止点与指垫本身会挡住爪子,在那里报 holding 是按预算 -夹紧,是正常终态。 +The closed endpoint (target 0.0) is the exception: the stop and the finger pads +themselves block the jaw, so reporting holding there means clamping at the +budget, which is a normal final state. -holding 是观测量:命令用满了力矩预算而爪子仍然没在走。在命令位置上报 holding -意味着预算被一次本该到位的移动用满了,那是标定或映射出了问题。 +holding is an observation: the command has used up the torque budget and the jaw +still is not moving. Reporting holding at the commanded position means the budget +was used up by a move that should have arrived, which points to a calibration or +mapping problem. -任何一步失败 → 退出码非零。 +Any failed step -> non-zero exit code. -用法 +Usage python python/examples/force_position_control.py right python python/examples/force_position_control.py --grasp-torque 1.1 python python/examples/force_position_control.py --targets 1.0,0.7,0.35,0.0 -安全:真实运动 + 夹持力,退出路径必定下发零力矩并 disable。 +Safety: real motion + grip force; the exit path always sends zero torque and +disables. """ from __future__ import annotations @@ -65,23 +80,27 @@ TERMINAL = ("HOLDING_FORCE", "HOLDING_POSITION", "FAULT") -# detail::ForcePositionTuning::arrival_eps_rad —— 控制器判「到位」的半径。 -# 这里放宽一点再用来判定,免得测量噪声把通过的步骤报成失败。 +# detail::ForcePositionTuning::arrival_eps_rad — the radius within which the +# controller considers the jaw "arrived". It is widened a bit here before use, so +# that measurement noise does not report a passing step as a failure. ARRIVAL_BAND_RAD = 0.010 ARRIVAL_SLACK = 2.5 -# 与 SDK 的 kClosedEndpointEps 一致:归一化目标不大于它就是闭合端点。 +# Matches the SDK's kClosedEndpointEps: a normalized target no greater than this +# is the closed endpoint. CLOSED_ENDPOINT = 1e-3 def await_command( c: ForcePositionController, target: float, budget: float = 1.0 ) -> bool: - """等控制器真正接下这个目标。 - - set_target() 是**排队**的:命令在下一帧状态流的相位上才应用(这样每次写都落 - 在 MCU 空闲的窗口里)。刚下发完就去读 snapshot(),读到的是**上一个**目标的 - 终态 —— 于是「还没开始动」会被当成「已经收敛」,整轮验收在爪子一动不动的 - 情况下全部通过。别删这一步。 + """Wait until the controller has actually taken this target. + + set_target() is **queued**: the command is applied only on the next frame's + phase of the status stream (so every write lands in the MCU's idle window). + Reading snapshot() right after sending returns the final state of the + **previous** target — so "has not started moving" is taken as "already + converged", and the whole acceptance run passes with the jaw never moving. + Do not remove this step. """ t0 = time.perf_counter() while time.perf_counter() - t0 < budget: @@ -92,7 +111,8 @@ def await_command( def settle(c: ForcePositionController, budget: float = 8.0): - """等控制器收敛到终态,返回 (状态名, 快照, 用时)。""" + """Wait for the controller to settle in a final state; return (state name, + snapshot, elapsed).""" t0 = time.perf_counter() while time.perf_counter() - t0 < budget: s = c.snapshot() @@ -113,56 +133,61 @@ def main() -> int: "--grasp-torque", type=float, default=None, - help="力矩预算 Nm —— 自由行程用不到,被挡住时就停在这个值。默认取电机的" - "连续堵转额定(EL05 1.1 / RS00 3.6),不能超过它", + help="torque budget Nm — not used during free travel; when blocked the " + "jaw stops at this value. Defaults to the motor's continuous stall rating " + "(EL05 1.1 / RS00 3.6) and cannot exceed it", ) ap.add_argument( "--close-speed", type=float, default=None, - help="闭合速度 rad/s。默认 MOTOR_APPROACH_SPEED_RADPS(1.1)", + help="close speed rad/s. Default MOTOR_APPROACH_SPEED_RADPS (1.1)", ) ap.add_argument( "--targets", default="1.0,0.5,0.0,0.5,1.0", - help="逗号分隔的归一化目标序列。默认带中间位置 —— 只跑端点看不出" - "「到位/接触」判错", + help="comma-separated sequence of normalized targets. The default includes " + "intermediate positions — running only the endpoints cannot reveal a " + 'wrong "arrived/contact" judgment', ) args = ap.parse_args() try: targets = [float(x) for x in args.targets.split(",") if x.strip()] except ValueError: - ap.error(f"--targets 解析失败: {args.targets!r}") + ap.error(f"--targets failed to parse: {args.targets!r}") if not targets or any(not 0.0 <= t <= 1.0 for t in targets): - ap.error("--targets 必须是 [0,1] 内的归一化位置") + ap.error("--targets must be normalized positions in [0,1]") log.set_level("warn") g, _ep = _target.open_follower(args.target) print(f"[fw] {g.firmware_version}") - # audit 而不是 get_envelope:只看 Enforce 标志的话,对"存的值不是生效值" - # 和布局错配这两种情况都是瞎的,而这两种都会让保持失去热保护。 + # audit rather than get_envelope: looking only at the Enforce flag is blind to + # both "the stored value is not the effective value" and a layout mismatch, + # and either one leaves the hold without thermal protection. a = g.audit_envelope() print(f"[envelope] stored {a.stored}") print( f"[envelope] effective " - f"{a.effective if a.effective else '*** 固件什么都不执行 ***'}" + f"{a.effective if a.effective else '*** firmware enforces nothing ***'}" ) if not a.ok: print( f"[warn] {a.detail}\n" - " 长时间保持的热保护就落在这上面。" - "修:impedance_control.py --set-envelope" + " Thermal protection for long holds depends on this. " + "Fix: impedance_control.py --set-envelope" ) - # 按设备实际装的电机取默认值(grasp / hold / motion / close_speed 全部跟着走) + # Take defaults from the motor the device actually carries (grasp / hold / + # motion / close_speed all follow it) cfg = ForcePositionConfig.for_spec(g.motor.get_spec()) if args.grasp_torque is not None: - cfg.grasp_torque_nm = args.grasp_torque # 被挡住时停在这个值 = 夹持力 + cfg.grasp_torque_nm = args.grasp_torque # stops here when blocked = grip force if args.close_speed is not None: cfg.close_speed_radps = args.close_speed - # 其余走 for_spec:hold = 旋转额定、motion = 力矩量程(EL05 1.8/6.0, - # RS00 5.0/14.0),status_timeout_ms=350(流断 → FAULT + 零力矩)、motor_stream_hz=100。 + # The rest comes from for_spec: hold = rotating rating, motion = torque range + # (EL05 1.8/6.0, RS00 5.0/14.0), status_timeout_ms=350 (stream loss -> FAULT + + # zero torque), motor_stream_hz=100. print( f"[config] grasp={cfg.grasp_torque_nm:.2f}Nm close_speed={cfg.close_speed_radps:.2f}rad/s " f"hold={cfg.hold_torque_limit_nm:.2f}Nm motion={cfg.motion_torque_limit_nm:.2f}Nm" @@ -172,29 +197,36 @@ def main() -> int: failures = 0 try: g.motor.clear_fault() - c.start() # 校验设备持久化的 0x700B 启动上限,并播种位置保持 + c.start() # checks the device's persisted 0x700B limit at startup, seeds a + # position hold g.motor.enable() - # 到位带换算到归一化:控制器在 rad 空间判定,这里的位置是归一化的。 + # Convert the arrival band to normalized units: the controller judges in + # rad space, while positions here are normalized. travel = g.position_map().max_open_rad band = ARRIVAL_BAND_RAD * ARRIVAL_SLACK / travel - print(f"[band] 行程 {travel:.4f} rad,到位 |误差| <= {band:.4f}(归一化)") + print( + f"[band] travel {travel:.4f} rad, arrived if |err| <= {band:.4f} (normalized)" + ) for target in targets: before = c.snapshot().state.name c.set_target(target) if not await_command(c, target): - print(f" target={target:.2f} -> FAIL 控制器没有接下这个目标") + print( + f" target={target:.2f} -> FAIL controller did not take this target" + ) failures += 1 continue name, s, dt = settle(c) pos = s.observation.position err = abs(pos - target) - # 位置/力矩/温度全部来自状态流的 snapshot,控制期间不碰总线。 + # Position/torque/temperature all come from the status stream's + # snapshot; nothing touches the bus while controlling. print( f" target={target:.2f} -> {name:16s} pos={pos:.4f} err={err:.4f} " - f"cmd={s.commanded_torque_nm:.3f} 实测={s.observation.torque:+.3f} Nm " - f"temp={s.observation.motor_temp_c:.0f}°C 用时 {dt:.2f}s" + f"cmd={s.commanded_torque_nm:.3f} meas={s.observation.torque:+.3f} Nm " + f"temp={s.observation.motor_temp_c:.0f}°C took {dt:.2f}s" ) if name == "FAULT": @@ -202,51 +234,64 @@ def main() -> int: failures += 1 break - # 已经夹稳时,一个更深的闭合目标是被**故意**吞掉的:爪子已经在这个 - # 力上推不动了,重新起步只会把好好的夹持抖掉。不算通过也不算失败 —— - # 这一步根本没有发生运动,不该拿它去判「保护是否正确」。 + # Once the grip is stable, a deeper close target is **deliberately** + # swallowed: the jaw already cannot push further at this force, and + # restarting would only shake loose a good grip. It counts as neither + # pass nor fail — no motion happened in this step at all, so it must + # not be used to judge "is the protection correct". if before == "HOLDING_FORCE" and name == "HOLDING_FORCE" and dt < 0.05: print( - " skip 已在力保持中,更深的闭合目标被忽略(设计如此);" - "要离开只能下发张开目标" + " skip already holding force, deeper close target ignored " + "(by design); only an open target leaves it" ) continue if name not in ("HOLDING_POSITION", "HOLDING_FORCE"): - print(" FAIL 未在预算内收敛到终态") + print(" FAIL did not settle in a final state within budget") failures += 1 continue - # 这条是核心:holding 只有在爪子被挡在命令位置**之外**时才成立。 - # 到位了还报 holding,说明一次本该到位的移动把力矩预算用满了 —— - # 标定或归一化映射出了问题。 - # 闭合端例外(SDK 0.3.7 起):目标 0.0 就是机械止点,止点和指垫本身 - # 就会挡住爪子 —— 闭合到底被挡住、按预算夹紧,HOLDING_FORCE 是正常 - # 终态,和到位一样。0094s(RS00)实测:爪子在止点前 ~12mrad 碰到指垫, - # 控制器补到预算、报 HOLDING_FORCE,0.2 秒内压到止点(3.2Nm)。旧的 - # 判法把它当成"到位还报 holding"判失败,与控制器的设计矛盾。 + # This is the core check: holding is valid only when the jaw is + # blocked **short of** the commanded position. Reporting holding after + # arriving means a move that should have arrived used up the torque + # budget — a calibration or normalized-mapping problem. + # The closed endpoint is the exception (since SDK 0.3.7): target 0.0 + # is the mechanical stop, and the stop and finger pads themselves + # block the jaw — blocked at full close and clamped at the budget, + # HOLDING_FORCE is a normal final state, just like arrival. Measured on + # 0094s (RS00): the jaw touches the finger pads ~12mrad before the + # stop, the controller fills up to the budget and reports + # HOLDING_FORCE, and presses to the stop within 0.2 s (3.2Nm). The old + # check failed this as "holding reported after arrival", which + # contradicts the controller's design. if s.holding and target <= CLOSED_ENDPOINT: - print(f" ok 闭合端被止点/指垫挡住,按预算夹紧(err {err:.4f})") + print( + f" ok closed end blocked by stop/finger pads, clamped at " + f"budget (err {err:.4f})" + ) elif s.holding and err <= band: print( - " FAIL 在命令位置上报 holding —— 一次本该到位的移动" - "用满了力矩预算" + " FAIL holding reported at the commanded position — a move " + "that should have arrived used up the torque budget" ) failures += 1 elif s.holding: - print(f" ok 被挡在目标外 {err:.4f}(归一化),holding 正确") + print( + f" ok blocked {err:.4f} short of target (normalized), " + "holding correct" + ) elif s.arrived: - print(" ok 到位") + print(" ok arrived") else: - # 既没到位也没在 holding,却已经稳定下来 —— 没有任何东西解释 - # 它为什么停在这里。 + # Neither arrived nor holding, yet already settled — nothing + # explains why it stopped here. print( - f" FAIL 停在目标外 {err:.4f} 却既未到位也未 holding" - f" —— 运动没有真正发生?" + f" FAIL stopped {err:.4f} short of target, but neither arrived" + f" nor holding — did the motion actually happen?" ) failures += 1 finally: try: - c.stop() # stop() 先下发零力矩 + c.stop() # stop() sends zero torque first except Exception as exc: print("stop:", exc) try: @@ -255,9 +300,9 @@ def main() -> int: print("disable:", exc) if failures: - print(f"\n{failures} 步失败") + print(f"\n{failures} step(s) failed") return 1 - print(f"\n{len(targets)} 步执行完毕,无失败") + print(f"\n{len(targets)} steps completed, no failures") return 0 diff --git a/python/examples/gripper_console.py b/python/examples/gripper_console.py index 2202535..0c382ae 100644 --- a/python/examples/gripper_console.py +++ b/python/examples/gripper_console.py @@ -1,41 +1,54 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""单个 follower 夹爪的键盘交互控制台 —— 走 SDK 控制器,不裸发 MIT 帧。 - -原版控制台直接调 `motor.submit_impedance()`。那是**电机原语**:拼一帧 MIT 丢上 -总线,没有误差钳位、没有力矩天花板、没有堵转保护 —— 主机侧的保护一层都不在它 -的路径上(见 cpp/src/components/motor.cpp)。kp=20 顶住硬物体再把目标压到全闭, -kp x 位置误差一路涨到电机自己的 0x700B 上限(该型号的 t_max:EL05 6 Nm / -RS00 14 Nm),24 V 母线被电流拉垮:松手 -掉件,状态位打出 EN|FAULT|UNDER_VOLT,此后无力矩,必须重新上电。 - -这一版把控制交给 SDK 的两个控制器,并把固件侧的运动安全包络一起配好。 - - --mode impedance ImpedanceController —— 误差钳位 + 力矩天花板 + 堵转保持 - --mode force-position ForcePositionController —— 全程一条有界力矩控制律,饱和即接触 - -要记住的一条:**固件包络是唯一在 MIT -路径上、谁都绕不过的一层**,而它出厂默认不启用(GripperConfig.reserved 全 0)。 -不开就没有,主机侧这两个控制器也替代不了它 —— 100 Hz 相位锁的链路,主机反应 -下限几十毫秒,8 rad/s 下就是 0.24 rad。用 --set-envelope 写一次,掉电保持。 - -包络填什么不是使用者要回答的问题,所以这里没有数值旋钮:设备自己知道它装的电机 -额定多少,而固件无论写进去什么都按那个额定钳。--show-envelope 会把**存的**和 -**实际生效的**分开打印 —— 那两个数会不一样。 - -用法 +"""Keyboard console for a single follower gripper — goes through the SDK +controllers, never sends bare MIT frames. + +The original console called `motor.submit_impedance()` directly. That is a +**motor primitive**: it packs one MIT frame and drops it on the bus, with no +error clamp, no torque ceiling and no stall protection — not one layer of +host-side protection is on its path (see cpp/src/components/motor.cpp). With +kp=20 against a hard object and the target pushed to fully closed, kp x position +error kept rising up to the motor's own 0x700B limit (that model's t_max: EL05 +6 Nm / RS00 14 Nm), and the current dragged the 24 V bus down: the jaw let go +and dropped the part, the status bits read EN|FAULT|UNDER_VOLT, and from then on +there was no torque until a power cycle. + +This version hands control to the SDK's two controllers and configures the +firmware-side motion safety envelope along with them. + + --mode impedance ImpedanceController — error clamp + torque ceiling + + stall hold + --mode force-position ForcePositionController — one bounded-torque control + law throughout, saturation is contact + +The one thing to remember: **the firmware envelope is the only layer on the MIT +path that nobody can bypass**, and it ships disabled (GripperConfig.reserved all +0). If it is not turned on, it is not there, and the two host-side controllers +cannot replace it — over a 100 Hz phase-locked link the host reacts in tens of +milliseconds at best, which at 8 rad/s is 0.24 rad. Write it once with +--set-envelope; it persists across power loss. + +What to put in the envelope is not a question for the user, so there are no +numeric knobs here: the device knows the rating of the motor it carries, and the +firmware clamps to that rating whatever is written. --show-envelope prints the +**stored** and the **actually effective** values separately — those two numbers +will differ. + +Usage python python/examples/gripper_console.py --show-envelope python python/examples/gripper_console.py --set-envelope python python/examples/gripper_console.py --mode force-position --grasp-torque 0.8 -按键 - j / k — 目标开度 + / -(一个 --step;归一化 0..1,0=闭合 1=张开) - o — 全开(1.0) c — 全合(0.0) - h — 保持当前位置(仅力位混合) - e / d — 恢复控制/失能 f — 清故障(力位混合同时 reset 出 Fault) - q / ESC — 退出 +Keys + j / k — target opening + / - (one --step; normalized 0..1, 0=closed 1=open) + o — fully open (1.0) c — fully closed (0.0) + h — hold current position (force-position only) + e / d — resume control/disable + f — clear fault (force-position also resets out of Fault) + q / ESC — quit -安全:真实运动。退出路径必定先 stop()(下发零力矩)再 disable()。 +Safety: real motion. The exit path always calls stop() (sends zero torque) first, +then disable(). """ from __future__ import annotations @@ -59,18 +72,22 @@ log, ) -# argparse 的默认值全部是 None,真正的默认在设备打开之后由它自报的电机规格推出 -# (ImpedanceConfig.for_spec / ForcePositionConfig.for_spec)。 +# Every argparse default is None; the real defaults are derived, after the device +# is opened, from the motor spec it reports (ImpedanceConfig.for_spec / +# ForcePositionConfig.for_spec). # -# 不能在这里写死:EL05 的持续夹持是 1.1 Nm,RS00 是 3.6 —— 差 3.3 倍,而那正是 -# 换电机的理由。更隐蔽的是 kd:接近速度是 预算/kd,写死 kd 会让"夹得更紧"顺带 -# 变成"撞得更快"。这两件事必须一起由电机决定。 +# They cannot be hardcoded here: EL05 sustained grip is 1.1 Nm, RS00 is 3.6 — a +# 3.3x difference, and that is exactly the reason for changing motors. The subtler +# one is kd: approach speed is budget/kd, so a hardcoded kd turns "grip harder" +# into "crash faster" as a side effect. Both must be decided by the motor together. # -# (历史:--rated-torque 曾经硬编码 2.0,SDK 把上界从峰值收到额定后没人改它, -# 阻抗模式默认参数崩了十天。写死默认值的代价这里付过一次了。) +# (History: --rated-torque was once hardcoded to 2.0; nobody changed it after the +# SDK lowered the upper bound from peak to rated, and impedance mode's default +# arguments were broken for ten days. This file has already paid the price of a +# hardcoded default once.) -# ── 状态位(protocol::MotorStatusBit) ──────────────────────────────────────── +# ── Status bits (protocol::MotorStatusBit) ─────────────────────────────────── _STATUS_BITS = [ (1, "EN"), @@ -93,11 +110,11 @@ def _status_str(mask: Optional[int]) -> str: return "|".join(bits) if bits else f"0x{mask:02X}" -# ── 键盘原始模式 ────────────────────────────────────────────────────────────── +# ── Keyboard raw mode ───────────────────────────────────────────────────────── class RawKeyboard: - """将 stdin 切换到 non-blocking 原始模式,逐字节 poll。""" + """Switch stdin to non-blocking raw mode and poll it byte by byte.""" def __enter__(self): self._saved = termios.tcgetattr(sys.stdin.fileno()) @@ -120,44 +137,55 @@ def poll() -> Optional[str]: return None -# ── 两种后端 ────────────────────────────────────────────────────────────────── +# ── Two backends ────────────────────────────────────────────────────────────── # -# 控制器自己拥有状态流和控制路径:不要再调 g.start_streaming(),也不要注册 -# motor.on_status() —— 观测一律从 observation()/snapshot() 取,控制期间不碰总线。 +# The controller owns the status stream and the control path: do not also call +# g.start_streaming(), and do not register motor.on_status() — all observations +# come from observation()/snapshot(), and nothing touches the bus while +# controlling. class ImpedanceBackend: - """ImpedanceController:误差钳位 + 力矩天花板,带状态机。 + """ImpedanceController: error clamp + torque ceiling, with a state machine. - 被挡住时误差钳位饱和在 max_position_torque_nm 上并保持,那就是夹持力; - 天花板只兜底实测力矩。所有字段来自同一个 snapshot(),同一把锁。 + When blocked, the error clamp saturates at max_position_torque_nm and holds + there; that is the grip force. The ceiling is only a backstop on measured + torque. All fields come from the same snapshot(), under the same lock. """ label = "IMPEDANCE" def __init__(self, g: FollowerGripper, args): - # 默认值来自设备自报的电机规格,不是编译期常量 —— EL05 和 RS00 的持续 - # 夹持差 3.3 倍,而 kd 又跟着预算走好让接近速度不变。 + # Defaults come from the motor spec the device reports, not compile-time + # constants — EL05 and RS00 sustained grip differ by 3.3x, and kd follows + # the budget so that approach speed stays the same. cfg = ImpedanceConfig.for_spec(g.motor.get_spec()) if args.kp is not None: - cfg.kp = args.kp # 刚度 Nm/rad + cfg.kp = args.kp # stiffness Nm/rad if args.kd is not None: - cfg.kd = args.kd # 阻尼 Nm·s/rad,同时定接近速度 - # 误差钳位:命令目标限制在实测位置 ±(该值/kp) rad 内。主保护 —— 它同时 - # 限住了「接近」和「堵转」,而只防堵转是不够的:实测不钳位的大步进会以 - # 5.5 rad/s 掠过物体把它撞飞,全程力矩不到 0.03 Nm,没有持续接触可检测。 + cfg.kd = args.kd # damping Nm·s/rad, also sets approach speed + # Error clamp: the commanded target is limited to within ±(this/kp) rad of + # the measured position. The main protection — it bounds both "approach" + # and "stall", and guarding only against stall is not enough: measured, an + # unclamped large step sweeps past the object at 5.5 rad/s and knocks it + # away, with under 0.03 Nm of torque the whole time and no sustained + # contact to detect. if args.max_position_torque is not None: cfg.max_position_torque_nm = args.max_position_torque - # 力矩天花板:作用在实测力矩上(不是命令值)。顶住后改发 kp=kd=0 的纯前馈 - # 帧,位置误差再也加不进输出。上限是额定 1.8 而非峰值 6.0 —— 这个保持无限期。 + # Torque ceiling: acts on measured torque (not the commanded value). Once + # hit, it switches to pure-feedforward frames with kp=kd=0, so position + # error can no longer add to the output. The cap is rated 1.8, not peak + # 6.0 — this hold is indefinite. if args.rated_torque is not None: cfg.rated_torque_nm = args.rated_torque - # 前馈保持 0。它一度被加进来是为了在失速跳闸后还剩点力,而那个守卫已经 - # 删了 —— 现在误差钳位自己就保持在预算上。再给前馈只会叠加在总命令上, - # 把持续夹持力抬到预算之上,正好绕过"预算必须能无限期保持"这个选择。 - # status_timeout_ms / motor_stream_hz 用默认值(350 ms / 100 Hz)。 + # Feedforward stays 0. It was once added to leave some force after a stall + # trip, but that guard has been removed — the error clamp now holds at the + # budget by itself. Adding feedforward would only stack on the total + # command and lift the sustained grip above the budget, bypassing exactly + # the choice that "the budget must be holdable indefinitely". + # status_timeout_ms / motor_stream_hz use the defaults (350 ms / 100 Hz). self.ctl = ImpedanceController(g, cfg) - self.cfg = cfg # 标题行显示**生效值**,不是命令行原值 + self.cfg = cfg # header shows the **effective values**, not the raw CLI values self._snap = None def start(self): @@ -180,7 +208,10 @@ def after_fault_clear(self): self.ctl.reset() def hold(self) -> Optional[float]: - """停在当前位置。返回新的目标,None 表示还没有观测可用。""" + """Stop at the current position. + + Returns the new target, None if no observation is available yet. + """ s = self._snap or self.ctl.snapshot() if not s.observation.valid: return None @@ -204,26 +235,32 @@ def detail(self) -> str: class ForcePositionBackend: - """ForcePositionController:全程一条有界力矩控制律。 + """ForcePositionController: one bounded-torque control law throughout. - 误差钳位在 grasp_torque_nm,饱和即接触,没有单独的接触判定。 + The error is clamped at grasp_torque_nm; saturation is contact, there is no + separate contact detection. """ label = "FORCE-POSITION" def __init__(self, g: FollowerGripper, args): - # 同上:grasp / hold / motion / close_speed 全部按设备的电机推出来。 + # Same as above: grasp / hold / motion / close_speed are all derived from + # the device's motor. cfg = ForcePositionConfig.for_spec(g.motor.get_spec()) if args.grasp_torque is not None: - cfg.grasp_torque_nm = args.grasp_torque # 力矩预算 = 夹持力 - # 闭合速度与 grasp **是独立的**。曾经有过 grasp/close_speed 的耦合规则 - # (行程阻尼增益当年就是这个比值),现在斜坡自己调速、预算拆分定增益, - # 所以慢就只是慢 —— 见 force_position_controller.cpp 的 validate_config。 + cfg.grasp_torque_nm = args.grasp_torque # torque budget = grip force + # Close speed **is independent** of grasp. There used to be a coupling rule + # between grasp/close_speed (the travel damping gain was that ratio back + # then); now the ramp sets the speed itself and the budget split sets the + # gains, so slow is just slow — see validate_config in + # force_position_controller.cpp. if args.close_speed is not None: cfg.close_speed_radps = args.close_speed - # hold/motion 上限也来自电机(EL05 1.8/6.0,RS00 5.0/14.0)。 - # 位置增益不是可配项(接触判定常数已随状态机一起删掉):它们是实测值, - # 对这台夹爪只有一个正确答案。--kp/--kd 仍然喂 ImpedanceBackend。 + # The hold/motion limits also come from the motor (EL05 1.8/6.0, RS00 + # 5.0/14.0). Position gains are not configurable (the contact-detection + # constants were removed together with the state machine): they are + # measured values, and for this gripper there is only one right answer. + # --kp/--kd still feed ImpedanceBackend. self.ctl = ForcePositionController(g, cfg) self.cfg = cfg self._snap = None @@ -260,7 +297,7 @@ def detail(self) -> str: return out def open_full(self): - # 有界速度阻尼张开,而不是位置阶跃。 + # Open with bounded velocity damping, not a position step. self.ctl.release() def hold(self) -> Optional[float]: @@ -269,7 +306,8 @@ def hold(self) -> Optional[float]: return s.hold_position def after_fault_clear(self): - # 顺序是硬性的:先 motor.clear_fault(),再 reset() 退出 Fault 态。 + # The order is mandatory: motor.clear_fault() first, then reset() to leave + # the Fault state. self.ctl.reset() @@ -297,15 +335,16 @@ def _redraw( + "-".rjust(9) + "-".rjust(17) ) - age = "age= — (还没收到状态帧)" + age = "age= — (no status frame received yet)" lines = [ head, envline, " j/k=open-/+ o=open c=close h=hold e/d=en/dis f=fault_clear q=quit", f"{'Tgt[0-1]':>10}{'Act[0-1]':>10}{'Act(rad)':>11}" - # Vel/Torq 是夹爪坐标系:**正 = 闭合**。电机装配方向已被消掉,所以两台 - # 装反的爪子上同一个动作符号相同(0.3.1 之前不是这样)。 - f"{'Vel(+闭合)':>10}{'Torq(+闭合)':>10}{'Temp(C)':>9}{'State':>17}", + # Vel/Torq are in the gripper frame: **positive = closing** ("cls"). The + # motor mounting direction has been cancelled out, so the same motion + # has the same sign on two mirror-mounted jaws (not so before 0.3.1). + f"{'Vel(+cls)':>10}{'Torq(+cls)':>10}{'Temp(C)':>9}{'State':>17}", "-" * 77, row, " " + backend.detail(), @@ -328,46 +367,59 @@ def main() -> int: "--mode", default="impedance", choices=("impedance", "force-position"), - help="阻抗 (ImpedanceController) 或力位混合 (ForcePositionController)", + help="impedance (ImpedanceController) or force-position hybrid " + "(ForcePositionController)", + ) + ap.add_argument( + "--kp", type=float, default=None, help="stiffness Nm/rad (default 20)" ) - ap.add_argument("--kp", type=float, default=None, help="刚度 Nm/rad(默认 20)") ap.add_argument( "--kd", type=float, default=None, - help="阻尼 Nm·s/rad。**同时决定接近速度**(≈ 预算/kd)。默认由电机推出," - "让接近速度落在 1.1 rad/s(MOTOR_APPROACH_SPEED_RADPS)", + help="damping Nm·s/rad. **Also sets approach speed** (≈ budget/kd). The " + "default is derived from the motor so that approach speed lands at 1.1 " + "rad/s (MOTOR_APPROACH_SPEED_RADPS)", ) ap.add_argument( "--hz", type=float, default=100.0, - help="UI 刷新率。控制提交率不由它决定 —— 两个控制器都锁在状态流的" - "相位上,一帧一提交", + help="UI refresh rate. It does not set the control submit rate — both " + "controllers are locked to the status stream's phase, one submit per frame", ) ap.add_argument( - "--step", type=float, default=0.05, help="j/k 步进量(归一化 0..1,默认 0.05)" + "--step", + type=float, + default=0.05, + help="j/k step size (normalized 0..1, default 0.05)", ) - # ---- 阻抗模式的两层主机侧保护 ---- + # ---- Impedance mode's two layers of host-side protection ---- ap.add_argument( "--max-position-torque", type=float, default=None, dest="max_position_torque", - help="力矩预算/误差钳位:命令目标限制在实测位置 ±(该值/kp) rad 内。" - "被挡住时命令饱和在这里并一直保持,那就是夹持力。默认取电机自报的" - "**连续堵转额定**(EL05 1.10 / RS00 3.60),须低于 --rated-torque", + help="torque budget/error clamp: the commanded target is limited to within " + "±(this/kp) rad of the measured position. When blocked, the command " + "saturates here and stays there; that is the grip force. Defaults to the " + "motor's reported **continuous stall rating** (EL05 1.10 / RS00 3.60); " + "must be below --rated-torque", ) - # 默认值从 MOTOR_RATED_TORQUE_NM 派生,不要再写字面量:这里原本硬编码 2.0, - # 而 aa3d0a9 把 ImpedanceConfig 的上界从峰值 6.0 收到额定 1.8(那个保持是 - # 无限期的,所以按额定封顶),两边就此错开,阻抗模式默认参数直接抛 ValueError。 + # The default is derived from MOTOR_RATED_TORQUE_NM; do not write a literal + # again: this was originally hardcoded to 2.0, and aa3d0a9 lowered + # ImpedanceConfig's upper bound from peak 6.0 to rated 1.8 (that hold is + # indefinite, so it is capped at rated). The two drifted apart, and impedance + # mode's default arguments threw ValueError outright. ap.add_argument( "--rated-torque", type=float, default=None, dest="rated_torque", - help="力矩天花板:实测力矩到顶后转纯前馈保持。默认取电机自报的**旋转额定**" - "(EL05 1.80 / RS00 5.00);start() 会拿设备实际额定卡它", + help="torque ceiling: once measured torque hits it, switch to a pure " + "feedforward hold. Defaults to the motor's reported **rotating rating** " + "(EL05 1.80 / RS00 5.00); start() checks it against the device's actual " + "rating", ) # ---- ForcePositionController ---- ap.add_argument( @@ -375,32 +427,37 @@ def main() -> int: type=float, default=None, dest="grasp_torque", - help="力矩预算 Nm = 夹持力(误差钳位的上限,被挡住时饱和在这里)。默认取电机" - "自报的**连续堵转额定**(EL05 1.10 / RS00 3.60),不能超过它 —— 超过时 " - "start() 直接拒绝。被挡住的爪子会无限期坐在这个力矩上,没有任何东西给它计时", + help="torque budget Nm = grip force (the error clamp's upper bound; " + "saturates here when blocked). Defaults to the motor's reported " + "**continuous stall rating** (EL05 1.10 / RS00 3.60) and cannot exceed it " + "— start() rejects it outright if it does. A blocked jaw sits at this " + "torque indefinitely, and nothing times it", ) ap.add_argument( "--close-speed", type=float, default=None, dest="close_speed", - help="闭合速度 rad/s,默认 1.1(MOTOR_APPROACH_SPEED_RADPS)。它与 " - "--grasp-torque **是独立的** —— 曾经有过 grasp/close_speed 的耦合规则," - "因为行程阻尼增益当年就是这个比值;现在斜坡自己调速、预算拆分定增益," - "所以慢就只是慢", + help="close speed rad/s, default 1.1 (MOTOR_APPROACH_SPEED_RADPS). It " + "**is independent** of --grasp-torque — there used to be a coupling rule " + "between grasp/close_speed, because the travel damping gain was that ratio " + "back then; now the ramp sets the speed itself and the budget split sets " + "the gains, so slow is just slow", ) - # ---- 固件运动安全包络 ---- + # ---- Firmware motion safety envelope ---- ap.add_argument( "--show-envelope", action="store_true", - help="打印包络(存的 / 实际生效的)后退出,只读", + help="print the envelope (stored / actually effective) and exit; read-only", ) ap.add_argument( "--set-envelope", action="store_true", - help="按设备自报的电机额定修好包络后继续。**写 MCU flash**,掉电保持;" - "已经正确就什么都不写。设备报了规格时 cont/peak 严格等于额定,偏低也修;" - "只有规格未知时才保留更严的原值", + help="fix the envelope to the motor rating the device reports, then " + "continue. **Writes MCU flash**, persists across power loss; writes nothing " + "if already correct. When the device reports a spec, cont/peak are set " + "exactly to the rating, and values that are too low are fixed too; the " + "stricter existing value is kept only when the spec is unknown", ) args = ap.parse_args() @@ -408,28 +465,37 @@ def main() -> int: g, ep = _target.open_follower(args.target) print(f"[fw] {g.firmware_version}") - # ---- 运动安全包络:固件侧、MIT 路径上唯一绕不过的一层 ---- + # ---- Motion safety envelope: firmware-side, the only layer on the MIT path + # ---- that cannot be bypassed # - # 这里没有数值旋钮,这是有意的。包络该填什么不是调用方的问题:设备自己知道它 - # 装的电机额定多少,而固件无论写进去什么都按那个额定钳。数由 SDK 从设备读。 + # There are no numeric knobs here, on purpose. What the envelope should hold is + # not the caller's question: the device knows the rating of the motor it + # carries, and the firmware clamps to that rating whatever is written. The SDK + # reads the numbers from the device. # - # 要看清的是 stored 与 effective 的区别 —— 固件把 cont 钳下去这件事只记在一条 - # 没接到 USB 的 UART 上,而读回的是 flash 里的记录。实测 0015s 存着 1.800、 - # 一直按 1.100 跑,而这个控制台以前只会显示 1.800。 + # What matters is the difference between stored and effective — the firmware + # logs clamping cont down only on a UART that is not wired to USB, while what + # reads back is the record in flash. Measured: 0015s stored 1.800 and ran at + # 1.100 all along, and this console used to show only 1.800. a = g.audit_envelope() print(f"[envelope] stored {a.stored}") print( - f"[envelope] effective {a.effective if a.effective else '*** 固件什么都不执行 ***'}" + f"[envelope] effective {a.effective if a.effective else '*** firmware enforces nothing ***'}" ) if not a.ok: print(f"[warn] {a.detail}") if a.needs_write: - print(" 用 --set-envelope 修一次(写 MCU flash,掉电保持)。") + print( + " Fix it once with --set-envelope (writes MCU flash, " + "persists across power loss)." + ) if args.set_envelope: w = g.ensure_envelope() print( - f"[envelope] 已写入 {w.written}" if w.wrote else "[envelope] 已正确,未写入" + f"[envelope] written {w.written}" + if w.wrote + else "[envelope] already correct, not written" ) a = g.audit_envelope() @@ -439,31 +505,36 @@ def main() -> int: cfg = g.get_gripper_config() norm_ok = bool(cfg.flags & 0x0001) if not norm_ok: - log.warning("gripper not calibrated —— Act[0-1] 显示 N/A") + log.warning("gripper not calibrated — Act[0-1] shows N/A") Backend = {"impedance": ImpedanceBackend, "force-position": ForcePositionBackend}[ args.mode ] backend = Backend(g, args) - # 存的值超出堵转额定时,把固件实际执行的数一起显示 —— 只显示 flash 里的值 - # 会让人以为设置生效了。 + # When the stored value exceeds the stall rating, also show the number the + # firmware actually enforces — showing only the value in flash would make it + # look as if the setting took effect. eff = a.effective envline = ( f" envelope: cont={eff.cont_torque_nm:.3f} " f"peak={eff.peak_torque_nm:.3f} Nm " f"temp={eff.temp_derate_start_c or 90}/{eff.temp_wall_c or 100}C ENFORCED" if eff - else " envelope: *** 未生效 —— 固件不钳 kp x 误差,I2t 与温度墙也不生效 ***" + else " envelope: *** NOT ENFORCED — firmware does not clamp kp x error, " + "I2t and the temperature wall are not in effect either ***" ) - # 显示的是**生效值**而不是命令行原值 —— 现在这些默认值由设备的电机规格推出来 - # (for_spec),命令行不给就是 None,直接格式化会抛 TypeError。 + # This shows the **effective values**, not the raw CLI values — these defaults + # are now derived from the device's motor spec (for_spec); when not given on + # the command line they are None, and formatting them directly would raise + # TypeError. # - # 只在阻抗模式显示增益:力位模式的位置增益不是可配项(在 - # detail::ForcePositionTuning 里),把它印在标题上会让人以为它生效了。 + # Gains are shown only in impedance mode: force-position mode's position gains + # are not configurable (they live in detail::ForcePositionTuning), and printing + # them in the header would make it look as if they took effect. c = backend.cfg gains = ( - f" 预算={c.max_position_torque_nm:.2f}Nm kp={c.kp:.1f} kd={c.kd:.2f}" + f" budget={c.max_position_torque_nm:.2f}Nm kp={c.kp:.1f} kd={c.kd:.2f}" f" ({c.max_position_torque_nm / c.kd:.1f}rad/s)" if args.mode == "impedance" else f" grasp={c.grasp_torque_nm:.2f}Nm close={c.close_speed_radps:.2f}rad/s" @@ -486,12 +557,15 @@ def main() -> int: try: g.motor.clear_fault() - backend.start() # 控制器自己起状态流,并以当前位置播种,不会跳变 + backend.start() # controller starts its own status stream, seeded at the + # current position, so there is no jump g.motor.enable() controller_active = True - # 等首帧,用实际开度初始化本地目标。控制器 start() 已经播过种了,这里 - # 只是让 UI 和按键从同一个数出发 —— 不主动下发,避免多余的一次命令。 + # Wait for the first frame and initialize the local target from the actual + # opening. The controller's start() has already seeded itself; this only + # makes the UI and the keys start from the same number — nothing is sent, + # to avoid one redundant command. target = 0.0 deadline_init = time.monotonic() + 3.0 while time.monotonic() < deadline_init: @@ -568,7 +642,7 @@ def main() -> int: time.sleep(period) finally: try: - backend.stop() # stop() 先下发零力矩 + backend.stop() # stop() sends zero torque first except Exception as exc: print(f"\r\nstop: {exc}") try: diff --git a/python/examples/impedance_control.py b/python/examples/impedance_control.py index 4301f3d..09d55f7 100644 --- a/python/examples/impedance_control.py +++ b/python/examples/impedance_control.py @@ -1,34 +1,38 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""ImpedanceController 的用法:受监督的位置阻抗控制。 +"""ImpedanceController usage: supervised position-impedance control. -后台线程按状态流相位提交 MIT 帧,策略侧只碰两个非阻塞调用: +A background thread submits MIT frames phase-locked to the status stream; the +policy side only touches two non-blocking calls: - c.set_target(p) # p in [0,1],0=闭合 1=张开 - s = c.snapshot() # 状态 + 观测 + 两个保护的标志,一把锁一致视图 + c.set_target(p) # p in [0,1], 0=closed 1=open + s = c.snapshot() # state + observation + both guard flags, one-lock consistent view -这个脚本同时是**上机验收**:跑一遍目标序列,并检查那条把「到位」和「被挡住」 -分开的不变式 —— +This script is also the **on-hardware acceptance test**: it runs a target +sequence and checks the invariant that separates "arrived" from "blocked" -- - 被挡在目标外时,正确行为是误差钳位饱和在 max_position_torque_nm 上并 - 保持 —— 那就是夹持力,不是故障。没有失速状态可查:接触不需要检测, - 饱和本身就是接触。 + When blocked short of the target, the correct behavior is the error clamp + saturating at max_position_torque_nm and holding -- that is the grip + force, not a fault. There is no stall state to check: contact does not + need detecting, saturation itself is contact. -在命令位置上还报保护,说明保护把正常的跟随稳态误判成了堵转。任何一步失败 → -退出码非零。 +A guard still firing at the commanded position means the guard has mistaken +the normal tracking steady state for a stall. Any step failing -> non-zero +exit code. -运动安全包络 ------------- -被挡住时 kp x 位置误差没有上界,固件侧的包络负责钳它。包络默认**不启用**, -用 --set-envelope 写一次(掉电保持,每台设备配一次即可)。 +Motion safety envelope +---------------------- +When blocked, kp x position error has no upper bound; the firmware-side +envelope clamps it. The envelope is **disabled** by default; write it once +with --set-envelope (persists across power-off, once per device is enough). -用法 +Usage python python/examples/impedance_control.py --show-envelope python python/examples/impedance_control.py --set-envelope python python/examples/impedance_control.py right python python/examples/impedance_control.py --targets 1.0,0.5,0.0 -安全:真实运动,退出路径必定下发零力矩并 disable。 +Safety: real motion; the exit path always sends zero torque and disables. """ from __future__ import annotations @@ -44,16 +48,18 @@ log, ) -# 认为「到位」的归一化误差。阻抗跟随会停在 kp x 误差 抵住摩擦的地方,不是数学上 -# 的零点,所以这里比控制器内部的判据宽一些,免得把通过的步骤报成失败。 +# Normalized error counted as "arrived". Impedance tracking stops where +# kp x error balances friction, not at the mathematical zero, so this is a bit +# wider than the controller's internal criterion, to avoid reporting a passing +# step as failed. ARRIVED = 0.03 def settle(c: ImpedanceController, budget: float = 3.0): - """等控制器停下来,返回 (状态名, 快照, 用时)。 + """Wait for the controller to settle; return (state name, snapshot, elapsed). - 等的是「速度落下来」而不是固定睡 2 秒:固定等待既可能截断一次慢的运动, - 又会在快的运动上白等。 + Waits for "velocity drops" rather than a fixed 2 s sleep: a fixed wait can + both cut off a slow move and waste time on a fast one. """ t0 = time.perf_counter() while time.perf_counter() - t0 < budget: @@ -61,7 +67,8 @@ def settle(c: ImpedanceController, budget: float = 3.0): if s.state == ImpedanceState.FAULT: break if s.observation.valid and abs(s.observation.velocity) < 0.02: - # 连着两帧都停了才算,避免过零点的一瞬被当成停住。 + # Count it only when two frames in a row are stopped, so the instant of a + # zero crossing is not taken as stopped. time.sleep(0.05) s2 = c.snapshot() if s2.observation.valid and abs(s2.observation.velocity) < 0.02: @@ -76,65 +83,78 @@ def main() -> int: description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) _target.add_target_argument(ap) - # 默认值一律 None,由设备的电机规格推出来 —— 写死 kd 会把 for_spec() 算好的 - # 那个值盖掉,而 kd 正是让"夹得更紧"不变成"撞得更快"的那一项。 - ap.add_argument("--kp", type=float, default=None, help="阻抗刚度 Nm/rad(默认 20)") + # All defaults are None and derived from the device's motor spec -- a + # hard-coded kd would override the value for_spec() computed, and kd is + # exactly the term that keeps "grip tighter" from becoming "hit faster". + ap.add_argument( + "--kp", type=float, default=None, help="impedance stiffness Nm/rad (default 20)" + ) ap.add_argument( "--kd", type=float, default=None, - help="阻抗阻尼 Nm·s/rad。**同时决定接近速度**(≈ 预算/kd)。默认由电机推出:" - "预算 / MOTOR_APPROACH_SPEED_RADPS,EL05 上 1.00、RS00 上 3.27,两边都是 1.1 rad/s", + help="impedance damping Nm·s/rad. **Also sets the approach speed** " + "(≈ budget/kd). Default derived from the motor: budget / " + "MOTOR_APPROACH_SPEED_RADPS, 1.00 on EL05 and 3.27 on RS00, 1.1 rad/s on both", ) ap.add_argument( - "--show-envelope", action="store_true", help="打印包络(存的/生效的)后退出" + "--show-envelope", + action="store_true", + help="print the envelope (stored/effective) and exit", ) ap.add_argument( "--set-envelope", action="store_true", - help="按设备自报的电机额定修好包络后继续(写 MCU flash,已正确则不写)", + help="fix the envelope to the motor rating the device reports, then continue " + "(writes MCU flash; no write if already correct)", ) ap.add_argument( "--targets", default="1.0,0.6,0.3,0.6,1.0", - help="逗号分隔的归一化目标序列", + help="comma-separated normalized target sequence", ) args = ap.parse_args() try: targets = [float(x) for x in args.targets.split(",") if x.strip()] except ValueError: - ap.error(f"--targets 解析失败: {args.targets!r}") + ap.error(f"--targets failed to parse: {args.targets!r}") if not targets or any(not 0.0 <= t <= 1.0 for t in targets): - ap.error("--targets 必须是 [0,1] 内的归一化位置") + ap.error("--targets must be normalized positions in [0,1]") log.set_level("warn") g, _ep = _target.open_follower(args.target) print(f"[fw] {g.firmware_version}") - # 包络填什么不是调用方的问题:设备自己知道它装的电机额定多少,而固件无论写 - # 进去什么都按那个额定钳。数由 SDK 从设备读(0x56)。 + # What goes in the envelope is not the caller's problem: the device knows + # the rating of the motor it carries, and the firmware clamps to that + # rating whatever is written. The SDK reads the numbers from the device + # (0x56). # - # stored 与 effective 会不一样:固件把 cont 钳下去只记在一条没接到 USB 的 - # UART 上,而读回的是 flash 里的记录。 + # stored and effective can differ: the firmware logs clamping cont down + # only on a UART not wired to USB, while the readback is the record in + # flash. a = g.audit_envelope() print(f"[envelope] stored {a.stored}") print( f"[envelope] effective " - f"{a.effective if a.effective else '*** 固件什么都不执行 ***'}" + f"{a.effective if a.effective else '*** firmware enforces nothing ***'}" ) if not a.ok: print(f"[warn] {a.detail}") if args.set_envelope: w = g.ensure_envelope() print( - f"[envelope] 已写入 {w.written}" if w.wrote else "[envelope] 已正确,未写入" + f"[envelope] written {w.written}" + if w.wrote + else "[envelope] already correct, not written" ) if args.show_envelope: return 0 - # 按设备实际装的电机取默认值:预算取连续堵转额定、天花板取旋转额定、 - # kd 由预算算出来好让接近速度维持在 MOTOR_APPROACH_SPEED_RADPS + # Defaults come from the motor actually fitted: budget = continuous stall + # rating, ceiling = rotating rating, kd computed from the budget so the + # approach speed stays at MOTOR_APPROACH_SPEED_RADPS spec = g.motor.get_spec() cfg = ImpedanceConfig.for_spec(spec) if args.kp is not None: @@ -142,16 +162,16 @@ def main() -> int: if args.kd is not None: cfg.kd = args.kd print( - f"[motor] {spec.name} 预算={cfg.max_position_torque_nm:.2f}Nm " - f"天花板={cfg.rated_torque_nm:.2f}Nm kp={cfg.kp:.1f} kd={cfg.kd:.3f} " - f"-> 接近 {cfg.max_position_torque_nm / cfg.kd:.2f} rad/s" + f"[motor] {spec.name} budget={cfg.max_position_torque_nm:.2f}Nm " + f"ceiling={cfg.rated_torque_nm:.2f}Nm kp={cfg.kp:.1f} kd={cfg.kd:.3f} " + f"-> approach {cfg.max_position_torque_nm / cfg.kd:.2f} rad/s" ) c = ImpedanceController(g, cfg) failures = 0 try: g.motor.clear_fault() - c.start() # 以当前位置为初始目标,不会跳变 + c.start() # initial target is the current position, no jump g.motor.enable() for target in targets: @@ -159,12 +179,13 @@ def main() -> int: name, s, dt = settle(c) o = s.observation err = target - o.position - # 全部来自状态流,控制期间不碰总线 —— read_status() 那类需要 ACK 的 - # 调用会和控制帧对撞,而 snapshot() 不会。 + # All from the status stream, no bus traffic during control -- + # ACK-based calls like read_status() collide with control frames, + # snapshot() does not. print( f" target={target:.2f} -> {name:14s} pos={o.position:.4f} " f"err={err:+.4f} tq={o.torque:+.3f} Nm cmd={s.commanded_torque_nm:.3f} " - f"temp={o.motor_temp_c:.0f}°C 用时 {dt:.2f}s" + f"temp={o.motor_temp_c:.0f}°C took {dt:.2f}s" ) if name == "FAULT": @@ -175,41 +196,46 @@ def main() -> int: budget = cfg.max_position_torque_nm if abs(err) <= ARRIVED: if s.torque_capped: - # 到位了还顶到天花板,说明天花板把正常跟随稳态当成了堵转。 - print(" FAIL 到位却顶到力矩天花板") + # Arrived yet hitting the ceiling means the ceiling took the + # normal tracking steady state for a stall. + print(" FAIL arrived but hit the torque ceiling") failures += 1 else: - print(" ok 到位") + print(" ok arrived") elif s.commanded_torque_nm >= budget - 0.05: - # 停在目标外而命令力矩饱和在预算上 —— 这正是被挡住该有的样子, - # 而且它会一直保持,这就是夹持力。 + # Stopped short of the target with commanded torque saturated + # at the budget -- exactly what blocked should look like, and + # it holds; that is the grip force. print( - f" ok 被挡在目标外 {abs(err):.4f},命令饱和在预算 " - f"{s.commanded_torque_nm:.3f}/{budget:.3f} Nm 并保持" + f" ok blocked short of target by {abs(err):.4f}, command " + f"saturated at budget {s.commanded_torque_nm:.3f}/{budget:.3f} " + f"Nm and holding" ) else: - # 没到位、也没饱和:要么增益太软推不动,要么预算根本没用上。 + # Neither arrived nor saturated: either the gains are too soft to + # push, or the budget is not being used at all. print( - f" FAIL 停在目标外 {abs(err):.4f},但命令只有 " - f"{s.commanded_torque_nm:.3f} Nm,没有饱和在预算 {budget:.3f}" + f" FAIL stopped short of target by {abs(err):.4f}, but command " + f"is only {s.commanded_torque_nm:.3f} Nm, not saturated at " + f"budget {budget:.3f}" ) failures += 1 if o.torque > cfg.rated_torque_nm + 0.15: print( - f" FAIL 实测力矩 {o.torque:.3f} Nm 越过了天花板 " + f" FAIL measured torque {o.torque:.3f} Nm exceeded the ceiling " f"{cfg.rated_torque_nm:.3f} Nm" ) failures += 1 final = c.snapshot() print( - f"\n[guards] 力矩天花板触发 {final.torque_caps} 次" - f"(误差钳位不是状态,它每帧都在生效,没有计数)" + f"\n[guards] torque ceiling fired {final.torque_caps} times " + f"(the error clamp is not a state; it is active every frame, no count)" ) finally: try: - c.stop() # stop() 先下发零力矩 + c.stop() # stop() sends zero torque first except Exception as exc: print("stop:", exc) try: @@ -218,9 +244,9 @@ def main() -> int: print("disable:", exc) if failures: - print(f"\n{failures} 步失败") + print(f"\n{failures} step(s) failed") return 1 - print(f"\n{len(targets)} 步全部通过") + print(f"\nall {len(targets)} steps passed") return 0 diff --git a/python/examples/motor_ota_update.py b/python/examples/motor_ota_update.py index 48188bc..0907a31 100644 --- a/python/examples/motor_ota_update.py +++ b/python/examples/motor_ota_update.py @@ -38,8 +38,14 @@ wrote other values (for example cont 1.0 / peak 2.0) and the device never corrects them itself. --keep-envelope skips this. +The current images ship in firmware/ and are listed under "motors" in +firmware/manifest.json. Without an image argument the script takes the one for +the motor model recorded on the gripper; a shipped image is also checked +against the manifest's CRC32. + Usage: - python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left # run twice + python python/examples/motor_ota_update.py left # run twice + python python/examples/motor_ota_update.py EL05_1.0.5.0.4.bin left # explicit image python python/examples/motor_ota_update.py rs00-0.0.3.32.bin TCGU01A28Z0086s python python/examples/motor_ota_update.py --to-mit left # back to MIT only """ @@ -47,10 +53,12 @@ from __future__ import annotations import argparse +import json import os import re import sys import time +import zlib sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _target import add_target_argument, resolve_target # noqa: E402 @@ -66,6 +74,65 @@ # an EL05 string is never cut down to its first four parts -- that is how the # first version of this script read EL05_1.0.5.0.4.bin as "1.0.5.0". _VERSION_RE = r"\d+(?:\.\d+){3,4}" +FIRMWARE_DIR = os.path.abspath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "firmware") +) + + +def _shipped_motors() -> dict: + """The "motors" section of firmware/manifest.json, {} if absent.""" + try: + with open(os.path.join(FIRMWARE_DIR, "manifest.json"), "rb") as f: + return json.load(f).get("motors", {}) + except (OSError, ValueError): + return {} + + +def shipped_image(motor) -> str: + """The shipped image for the motor model recorded on the gripper.""" + rec = motor.get_model() + if not rec.from_flash: + # A compile-time default is a guess, and RobStride's OTA accepts any + # model's image -- picking by it could flash the wrong motor line. + sys.exit( + "error: the gripper has no recorded motor model (compile-time default " + f"{rec.name}), so the image cannot be picked for it. Pass the image " + "explicitly, or set the model first (firmware/README.md, Follower motor setup)" + ) + entry = _shipped_motors().get(rec.name) + if not entry: + sys.exit( + f"error: no shipped image for motor {rec.name} in firmware/manifest.json; " + "pass the image explicitly" + ) + return os.path.join(FIRMWARE_DIR, entry["file"]) + + +def resolve_image(path: str) -> str: + """`path` as given, else the bare name under firmware/.""" + if os.path.isfile(path): + return path + cand = os.path.join(FIRMWARE_DIR, os.path.basename(path)) + if os.path.isfile(cand): + return cand + sys.exit(f"error: image not found: {path} (shipped images live in firmware/)") + + +def check_shipped_crc(path: str, data: bytes) -> None: + """The shipped image in firmware/ must match its manifest CRC32. Images + from elsewhere are only checked against their own name (image_facts).""" + if os.path.dirname(os.path.abspath(path)) != FIRMWARE_DIR: + return + name = os.path.basename(path) + for entry in _shipped_motors().values(): + if entry.get("file") == name: + crc = f"0x{zlib.crc32(data):08x}" + if crc != entry.get("crc32"): + sys.exit( + f"error: {name} has CRC32 {crc}, firmware/manifest.json says " + f"{entry.get('crc32')}; the file is damaged or not the released image" + ) + return def image_facts(path: str, data: bytes, model_arg: str | None): @@ -146,7 +213,12 @@ def confirm(question: str, assume_yes: bool) -> bool: def main() -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) - ap.add_argument("image", nargs="?", help="RobStride motor firmware .bin") + ap.add_argument( + "image", + nargs="?", + help="RobStride motor firmware .bin; default: the shipped image in firmware/ " + "for the motor model recorded on the gripper", + ) add_target_argument(ap, required=True) ap.add_argument("--model", help="override the model taken from the filename") ap.add_argument( @@ -164,12 +236,12 @@ def main() -> int: ) args = ap.parse_args() - if not args.to_mit and not args.image: - ap.error("an image is required (or pass --to-mit)") data = model = version = None - if not args.to_mit: + if not args.to_mit and args.image: + args.image = resolve_image(args.image) with open(args.image, "rb") as f: data = f.read() + check_shipped_crc(args.image, data) model, version = image_facts(args.image, data, args.model) ep, _, _ = resolve_target(args.target) @@ -187,6 +259,13 @@ def main() -> int: ) motor = g.motor + if not args.to_mit and not args.image: + # Picked before anything is switched, so a missing image fails here. + args.image = shipped_image(motor) + with open(args.image, "rb") as f: + data = f.read() + check_shipped_crc(args.image, data) + model, version = image_facts(args.image, data, args.model) proto = motor.get_protocol() if args.to_mit: diff --git a/python/examples/ota_update.py b/python/examples/ota_update.py index 3c7b6a1..24c0980 100644 --- a/python/examples/ota_update.py +++ b/python/examples/ota_update.py @@ -411,21 +411,22 @@ def _resolve_or_report(path: str) -> Optional[str]: def _identify_image(fw_bytes: bytes): - """CRC32 反查 firmware/manifest.json,返回 (role, meta) 或 (None, None)。 + """Look up the CRC32 in firmware/manifest.json; return (role, meta) or (None, None). - **按内容认,不按文件名认。** 发布镜像的文件名带版本 - (tc-gu-01-slave-1.2.16.bin),但文件名是可以被改的 —— 拷走、重命名、从别处 - 下载,名字就开始说谎。CRC32 不会:它认的是这一份字节。 + **Identify by content, not by file name.** Released image file names carry + the version (tc-gu-01-slave-1.2.16.bin), but file names can change — copy + it, rename it, download it from somewhere else, and the name starts lying. + CRC32 does not: it identifies these exact bytes. - 认不出来不是错误,只是"不是我们发布的镜像"(自己编的、第三方的),调用方按 - 未知处理即可。 + Not recognizing an image is not an error, only "not an image we released" + (self-built, third-party); the caller just treats it as unknown. """ images = _load_manifest().get("images", {}) if not images: return None, None - # 按整数比,不要按字符串:"0x...".upper() 会把前缀里的 "x" 也大写, - # 于是字符串比较永远不匹配 —— 这个坑踩过一次。 + # Compare as integers, not strings: "0x...".upper() also uppercases the "x" + # in the prefix, so the string comparison never matches — we hit this once. def _crc_of(meta) -> int: try: return int(str(meta.get("crc32", "")), 16) @@ -458,8 +459,9 @@ def _check_role(fw_bytes: bytes, firmware_sn: str, force: bool) -> int: print(f" role check : {_dim('image not in manifest, cannot verify')}") return 0 - # matched 之后还要 images,是为了反查「那这台其实该刷哪个」—— 给出替代文件名 - # 比只说「刷错了」有用得多。 + # images is still needed after matched, to look up "which one should this unit + # actually get" — naming the alternative file is far more useful than just + # saying "wrong image". images = _load_manifest().get("images", {}) want = meta.get("sn_suffix", "") have = firmware_sn[-1:] @@ -506,9 +508,11 @@ def _cmd_update(args: argparse.Namespace, g: LeaderGripper, eps, fw_path: str) - role, meta = _identify_image(fw_bytes) image_ver = meta.get("version") if meta else None - # 没显式指定就用镜像自报的版本。这个字段是发给固件的,它拿去写 bank 元数据和 - # 安装后的校验日志;缺省的 0.0.0 等于告诉固件"不知道刷的是什么",而我们其实 - # 知道 —— CRC 刚刚认出来了。认不出来(自编/第三方镜像)才退回 0.0.0。 + # If not given explicitly, use the version the image reports for itself. This + # field is sent to the firmware, which uses it for the bank metadata and the + # post-install verification log; the default 0.0.0 tells the firmware "no idea + # what is being flashed", when we actually do know — the CRC just identified + # it. Only an unrecognized (self-built / third-party) image falls back to 0.0.0. target = _parse_version(args.target_version or image_ver) print("=== OTA update ===") @@ -523,13 +527,15 @@ def _cmd_update(args: argparse.Namespace, g: LeaderGripper, eps, fw_path: str) - ) print(f" image ver : {image_ver} ({role}) [{src}]") else: - print(f" image ver : {_dim('未知 —— CRC32 不在 manifest 里,不是发布镜像')}") + print( + f" image ver : {_dim('unknown — CRC32 not in manifest, not a released image')}" + ) print( f" target ver : {_target.format_version(target.major, target.minor, target.patch)}" + ( "" if args.target_version or image_ver - else _dim(" (没有版本可报,固件会记下 0.0.0)") + else _dim(" (no version to report, firmware will record 0.0.0)") ) ) if _check_role(fw_bytes, getattr(eps, "firmware_sn", "") or "", args.force): diff --git a/python/examples/read_intrinsics.py b/python/examples/read_intrinsics.py index e60a519..725a444 100644 --- a/python/examples/read_intrinsics.py +++ b/python/examples/read_intrinsics.py @@ -1,26 +1,32 @@ #!/usr/bin/env python3 # Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 -"""读取腕相机内参,输出 JSON。只读 —— 不写 flash,不动电机。 +"""Read the wrist-camera intrinsics and print them as JSON. Read-only — no flash +writes, no motor motion. -走的是 `Calibration.resolve_fisheye()`,不是 `read_fisheye()`。区别很重要: -**没标定过的机器对读请求回的是一条全零记录,而不是 NACK**,把它交给 -FisheyeUndistorter 会把每一帧都重映射成全黑。resolve_fisheye() 把"读 → 判可用 -→ 回退到 SDK 参考值"这条策略做在一处,和 SDK 内部用的是同一个决定。 +Goes through `Calibration.resolve_fisheye()`, not `read_fisheye()`. The +difference matters: **an uncalibrated unit answers the read request with an +all-zero record, not a NACK**, and handing that to FisheyeUndistorter remaps +every frame to solid black. resolve_fisheye() keeps the "read → check usable → +fall back to the SDK reference values" policy in one place, and it is the same +decision the SDK makes internally. -输出里的 `source` 字段就是这个决定的结果: +The `source` field in the output is the result of that decision: - "device" 内参来自这台设备烧录的标定记录 - "reference" 设备没标定(或记录不可用),用的是 SDK 的共享参考值。 - 参考值是近似的 —— 镜头装配存在个体差异,拿它做精确测量之前 - 先想清楚。用 --require-device 可以让这种情况直接失败。 + "device" intrinsics come from the calibration record flashed on this unit + "reference" the unit is not calibrated (or the record is unusable); the SDK's + shared reference values are used. The reference values are + approximate — lens assembly varies unit to unit, so think twice + before using them for precise measurement. --require-device + makes this case fail outright. -JSON 走 stdout,诊断信息走日志(stderr),所以可以直接管道: +JSON goes to stdout, diagnostics go to the log (stderr), so it can be piped +directly: python python/examples/read_intrinsics.py left > left_cal.json python python/examples/read_intrinsics.py left | jq .K -用法 - python python/examples/read_intrinsics.py # 只插一台时可省略 +Usage + python python/examples/read_intrinsics.py # omit when only one is plugged in python python/examples/read_intrinsics.py left python python/examples/read_intrinsics.py right --out right_cal.json python python/examples/read_intrinsics.py left --require-device @@ -35,8 +41,10 @@ import _target from xense.taccap import FollowerGripper, LeaderGripper, log -# 标定记录里**不存图像尺寸**。SDK 一律按标定时的 640x480 使用这组内参,按别的 -# 分辨率缩放就是猜 —— 所以这里把假设显式写进输出,而不是假装知道。 +# The calibration record **does not store the image size**. The SDK always uses these +# intrinsics at the 640x480 they were calibrated at; scaling them to another resolution +# is a guess — so the assumption is written explicitly into the output here, rather +# than pretending to know. ASSUMED_IMAGE_SIZE = [640, 480] @@ -52,16 +60,22 @@ def main() -> int: ap.add_argument( "--follower", action="store_true", - help="以 FollowerGripper 打开(默认 LeaderGripper)。" - "内参两个类都能读,默认走 leader 是为了绕开从爪的固件版本门", + help="open as FollowerGripper (default LeaderGripper). " + "Both classes can read the intrinsics; leader is the default to get around " + "the follower's firmware version gate", + ) + ap.add_argument( + "--out", metavar="FILE", help="write to a file; if omitted, print to stdout" ) - ap.add_argument("--out", metavar="FILE", help="写入文件,不给就打到 stdout") ap.add_argument( "--require-device", action="store_true", - help="内参若回退到 SDK 参考值则报错退出,而不是静默使用", + help="exit with an error if the intrinsics fall back to the SDK reference " + "values, instead of using them silently", + ) + ap.add_argument( + "--indent", type=int, default=2, help="JSON indent, 0 = single line" ) - ap.add_argument("--indent", type=int, default=2, help="JSON 缩进,0 = 单行") args = ap.parse_args() ep, _by_side, _all = _target.resolve_target(args.target) @@ -73,13 +87,14 @@ def main() -> int: if is_reference: log.warning( - f"内参回退到 SDK 参考值({reason})—— 这台设备没有可用的" - "标定记录。参考值是近似的,镜头装配有个体差异。" + f"intrinsics fell back to the SDK reference values ({reason}) — this " + "unit has no usable calibration record. The reference values are " + "approximate; lens assembly varies unit to unit." ) if args.require_device: sys.exit( - "error: --require-device 要求设备自带标定,但当前回退到了" - f"参考值:{reason}" + "error: --require-device requires the unit's own calibration, but it " + f"fell back to the reference values: {reason}" ) doc = { @@ -110,7 +125,7 @@ def main() -> int: if args.out: with open(args.out, "w", encoding="utf-8") as f: f.write(text + "\n") - log.info(f"内参已写入 {args.out} (source={doc['source']})") + log.info(f"intrinsics written to {args.out} (source={doc['source']})") else: print(text) return 0 diff --git a/python/examples/wrist_camera.py b/python/examples/wrist_camera.py index d7867af..b2d0923 100755 --- a/python/examples/wrist_camera.py +++ b/python/examples/wrist_camera.py @@ -156,7 +156,7 @@ def _yellow(s): def side_of_serial(sn: str) -> str: - """Odd trailing sequence digit → left, even → right (单左双右).""" + """Odd trailing sequence digit → left, even → right (odd-left / even-right rule).""" seq = sn[-5:-1] if sn[-1] in PATCH_ROLE else sn[-4:] return "left" if int(seq[-1]) % 2 == 1 else "right" @@ -208,7 +208,8 @@ def print_devices() -> None: for d in devs["tactile"]: print(f" {d['serial']:<14} {d['side']:<5} {'':<9} {d['path']}") print( - " 这些走 xensesdk,不是本 SDK: Sensor.create() — 见 docs/USAGE.md" + " These go through xensesdk, not this SDK: Sensor.create() — " + "see docs/USAGE.md" ) if devs["other"]: @@ -231,13 +232,15 @@ def resolve_wrist_camera(target: str) -> dict: if TACTILE_SN_RE.search(key): raise SystemExit( - f"{key} 是视触觉(GSPS)传感器,不是腕相机。它的采集与矫正在 xensesdk 里," - "不走本 SDK —— 见 docs/USAGE-zh.md 的触觉一节。" + f"{key} is a visuotactile (GSPS) sensor, not a wrist camera. Its capture " + "and rectification live in xensesdk, not this SDK — see the tactile " + "section of docs/USAGE.md." ) if key.startswith("/dev/"): raise SystemExit( - f"不接受设备路径({key})。/dev/videoN 的编号随插拔顺序变,而且紧挨着腕相机的" - "那个节点通常是视触觉传感器 —— 请用 left / right 或 XC 序列号。" + f"device paths are not accepted ({key}). /dev/videoN numbering changes " + "with plug order, and the node right next to the wrist camera is " + "usually the visuotactile sensor — use left / right or an XC serial." ) if key.lower() in SIDES: @@ -245,12 +248,12 @@ def resolve_wrist_camera(target: str) -> dict: matches = [d for d in devs["wrist"] if d["side"] == side] if not matches: print_devices() - raise SystemExit(f"没有找到 {side} 侧的 XC 腕相机(见上表)。") + raise SystemExit(f"no {side} XC wrist camera found (see the table above).") if len(matches) > 1: names = ", ".join(f"{d['serial']}({d['role']})" for d in matches) raise SystemExit( - f"{side} 侧有 {len(matches)} 个腕相机:{names}。" - "leader 和 follower 各带一个时请直接给序列号。" + f"{len(matches)} wrist cameras on the {side} side: {names}. " + "When leader and follower each carry one, give the serial directly." ) return matches[0] @@ -258,12 +261,15 @@ def resolve_wrist_camera(target: str) -> dict: for d in devs["wrist"]: if d["serial"] == key: return d - found = ", ".join(d["serial"] for d in devs["wrist"]) or "无" - raise SystemExit(f"序列号 {key} 没插在这台机器上。已连接的腕相机:{found}") + found = ", ".join(d["serial"] for d in devs["wrist"]) or "none" + raise SystemExit( + f"serial {key} is not plugged into this machine. Connected wrist " + f"cameras: {found}" + ) raise SystemExit( - f"无法识别的目标 {target!r} —— 用 left / right,或形如 XCA24Z0003m 的腕相机序列号" - "(--list 可以列出连着的设备)。" + f"unrecognized target {target!r} — use left / right, or a wrist camera " + "serial like XCA24Z0003m (--list lists the connected devices)." ) @@ -319,7 +325,7 @@ def cal_from_gripper(cam: dict) -> tuple[CameraFisheyeCal, str]: if want_role is not None and any(g.role == want_role for g in matches): matches = [g for g in matches if g.role == want_role] if not matches: - found = ", ".join(f"{g.firmware_sn or ''}" for g in grippers) or "无" + found = ", ".join(f"{g.firmware_sn or ''}" for g in grippers) or "none" raise RuntimeError( f"no {cam['side']} gripper on the bus to read the calibration from " f"(connected: {found})" @@ -513,8 +519,8 @@ def main(argv=None) -> int: return 0 if not args.target: raise SystemExit( - "缺少目标 —— 用 left / right,或腕相机序列号(如 XCA24Z0003m);" - "--list 可以列出连着的设备。" + "missing target — use left / right, or a wrist camera serial (e.g. " + "XCA24Z0003m); --list lists the connected devices." ) cam_info = resolve_wrist_camera(args.target) diff --git a/python/tests/test_control_config_surface.py b/python/tests/test_control_config_surface.py index 991d798..93a64e9 100644 --- a/python/tests/test_control_config_surface.py +++ b/python/tests/test_control_config_surface.py @@ -56,7 +56,7 @@ "startup_guard_ms", "contact_samples", "close_position", - # 接触状态机随 2026-09 重构一并删除 + # The contact state machine was removed along with the 2026-09 refactor "arrival_band_rad", "brake_distance_rad", "startup_guard_ms", @@ -151,13 +151,17 @@ def test_force_position_states_cover_the_machine(): "kp": 20.0, "kd": 1.0, "feedforward_torque": 0.0, - # 1.1 = EL05 连续堵转额定,和 ForcePositionConfig.grasp_torque_nm 同一个数。 - # 被挡住的爪子会稳定坐在这个预算上、没有任何东西给它计时,所以它必须是能 - # 无限期保持的力矩。曾是 1.5 —— 那是还指望失速守卫来终止保持的时候。 + # 1.1 = EL05 continuous stall rating, the same number as + # ForcePositionConfig.grasp_torque_nm. A blocked jaw sits steadily on this + # budget with nothing timing it, so it must be a torque that can be held + # indefinitely. It used to be 1.5 — back when the stall guard was still + # expected to end the hold. "max_position_torque_nm": 1.1, "rated_torque_nm": 1.8, - # EL05 的力矩量程。实测力矩超过它就判故障(读数不可能是真的);for_spec() - # 从设备取,RS00 是 14。曾写死 6.0 在 policy 里,RS00 上会误判被反拖的读数。 + # EL05 torque range. A measured torque above it is judged a fault (the reading + # cannot be real); for_spec() takes it from the device, RS00 is 14. It used to + # be hard-coded as 6.0 in the policy, which on RS00 misjudged back-driven + # readings. "peak_torque_nm": 6.0, "status_timeout_ms": 350, "motor_stream_hz": 100, @@ -166,8 +170,9 @@ def test_force_position_states_cover_the_machine(): IMPEDANCE_INTERNAL = [ "rated_hold_ms", "rated_release_rad", - # 失速守卫已删除,这几个字段连内部都不存在了 —— 列在这里是为了钉住"它们不该 - # 再出现":误差钳位就是保护,再加一层跳闸会把它撤销掉。 + # The stall guard was removed; these fields no longer exist even internally — + # they are listed here to pin "they must not come back": the error clamp is the + # protection, and another layer of tripping on top would undo it. "stall_torque_nm", "stall_vel_radps", "stall_hold_ms", @@ -207,8 +212,9 @@ def test_impedance_ceiling_is_the_rated_torque_not_the_peak(): def test_impedance_states_are_ordered_by_precedence(): names = [v.name for v in t.ImpedanceState.__members__.values()] - # 没有 STALLED:失速守卫已删除,被挡住的爪子让误差钳位饱和在预算上并保持, - # 那是 TRACKING 的稳态而不是另一个状态。 + # No STALLED: the stall guard was removed. A blocked jaw saturates the error + # clamp at the budget and holds there; that is the steady state of TRACKING, + # not another state. assert names == ["IDLE", "TRACKING", "TORQUE_CAPPED", "FAULT"] diff --git a/python/tests/test_dispatch_decoupling.py b/python/tests/test_dispatch_decoupling.py index 4167c8b..2f831e5 100644 --- a/python/tests/test_dispatch_decoupling.py +++ b/python/tests/test_dispatch_decoupling.py @@ -7,7 +7,7 @@ budget of a 200 Hz three-source stream (~1.67 ms), so a busy main thread would stall the reader, overflow the kernel tty buffer (4 KB on n_tty), and cost *bytes* — the stream silently dropping to a fraction of its configured rate, -which is what customers reported as "encoder state 降频". +which is what customers reported as "encoder state rate drop". The C++ tests cover the queue mechanics. This one exists because only Python reproduces the GIL half: a callback that holds the GIL must not stop the diff --git a/python/tests/test_firmware_manifest_motors.py b/python/tests/test_firmware_manifest_motors.py new file mode 100644 index 0000000..05258ea --- /dev/null +++ b/python/tests/test_firmware_manifest_motors.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +"""firmware/manifest.json `motors` entries must match the files on disk. + +motor_ota_update.py picks the image by the model recorded on the gripper and +refuses one whose CRC32 disagrees, so a stale entry breaks the default path +customers are told to use. Reads JSON and bytes only; no native module. +""" + +import json +import re +import zlib +from pathlib import Path + +FIRMWARE = Path(__file__).resolve().parents[2] / "firmware" + + +def _motors() -> dict: + return json.loads((FIRMWARE / "manifest.json").read_text())["motors"] + + +def test_motor_images_match_manifest(): + motors = _motors() + assert motors, "manifest has no motors section" + for model, entry in motors.items(): + data = (FIRMWARE / entry["file"]).read_bytes() + assert len(data) == entry["size_bytes"], model + assert f"0x{zlib.crc32(data):08x}" == entry["crc32"], model + + +def test_motor_filename_names_model_and_version(): + # The script reads model and version from the file name (image_facts). + for model, entry in _motors().items(): + m = re.match(r"(?i)(rs00|el05)[-_](\d+(?:\.\d+){3,4})\.bin$", entry["file"]) + assert m, entry["file"] + assert m.group(1).upper() == model + assert m.group(2) == entry["version"] diff --git a/python/xense/taccap/__init__.py b/python/xense/taccap/__init__.py index daf782b..313de69 100644 --- a/python/xense/taccap/__init__.py +++ b/python/xense/taccap/__init__.py @@ -89,7 +89,8 @@ ) # motion safety envelope (in GripperConfig) GRIPPER_ENVELOPE_VALID = _taccap_native.GRIPPER_ENVELOPE_VALID GRIPPER_ENVELOPE_ENFORCE = _taccap_native.GRIPPER_ENVELOPE_ENFORCE -# 审计结果:存的 / 生效的 / 推荐的是三件不同的事 —— 见 EnvelopeAudit +# Audit result: stored / in effect / recommended are three different things — see +# EnvelopeAudit EnvelopeAudit = _taccap_native.EnvelopeAudit EnvelopeWrite = _taccap_native.EnvelopeWrite GRIPPER_ENVELOPE_ISSUE_NOT_WRITTEN = _taccap_native.GRIPPER_ENVELOPE_ISSUE_NOT_WRITTEN @@ -132,14 +133,18 @@ ImpedanceController = _taccap_native.ImpedanceController MOTOR_RATED_TORQUE_NM = _taccap_native.MOTOR_RATED_TORQUE_NM # 1.8 Nm, indefinite MOTOR_PEAK_TORQUE_NM = _taccap_native.MOTOR_PEAK_TORQUE_NM # 6.0 Nm, transient -# 1.1 Nm —— 堵转额定,被挡住的爪子可以无限期维持的那个。别和 RATED(旋转额定)混 +# 1.1 Nm — the stall rating, the one a blocked jaw can hold indefinitely. Do not +# confuse it with RATED (the rotating rating) MOTOR_STALL_CONT_TORQUE_NM = _taccap_native.MOTOR_STALL_CONT_TORQUE_NM -# 两个控制器共用的目标接近速度。它是**目标**不是测量:阻抗侧的接近速度是 -# 预算/kd(涌现的),力位侧由斜坡直接调到这个值。提预算必须同时提 kd,否则 -# 夹持力上去了、接近速度也跟着上去 —— 这是 for_spec() 替你做的事 +# Target approach speed shared by both controllers. It is a **target**, not a +# measurement: on the impedance side the approach speed is budget/kd (emergent); +# on the force-position side a ramp drives it straight to this value. Raising the +# budget must raise kd too, otherwise grip force goes up and the approach speed +# goes up with it — this is what for_spec() does for you MOTOR_APPROACH_SPEED_RADPS = _taccap_native.MOTOR_APPROACH_SPEED_RADPS MOTOR_MAX_APPROACH_SPEED_RADPS = _taccap_native.MOTOR_MAX_APPROACH_SPEED_RADPS -# 只是理智上限,不是电机额定 —— 真正的界是设备自报的规格,在 start() 里校验 +# Only a sanity cap, not a motor rating — the real bound is the device's +# self-reported spec, checked in start() MOTOR_ABSOLUTE_TORQUE_CEILING_NM = _taccap_native.MOTOR_ABSOLUTE_TORQUE_CEILING_NM ForcePositionState = _taccap_native.ForcePositionState ForcePositionConfig = _taccap_native.ForcePositionConfig @@ -159,25 +164,28 @@ MotorFaultReport = ( _taccap_native.MotorFaultReport ) # V2.2 64-byte fault report (Cmd 0x52) -# 本机记录的电机型号 (Cmd 0x59/0x5A, 需固件 >= 1.2.7)。问的是 MCU 的 flash, -# 不是电机 —— 电机答不出自己的型号 +# Motor model recorded on this unit (Cmd 0x59/0x5A, needs firmware >= 1.2.7). This +# asks the MCU's flash, not the motor — the motor cannot report its own model MotorModel = _taccap_native.MotorModel -# 上电自动标定的诊断 (Cmd 0x57)。标定的失败原因此前只走没接到 USB 的 UART7 +# Power-on auto-calibration diagnostics (Cmd 0x57). Calibration failure reasons +# used to go only to UART7, which is not wired to USB HomeDiagReport = _taccap_native.HomeDiagReport HomeState = _taccap_native.HomeState HomeFail = _taccap_native.HomeFail -MotorVersion = ( - _taccap_native.MotorVersion -) # 电机自身固件版本 (Cmd 0x58, 需固件 >= 1.2.6;>= 1.2.14 时 MIT 下返回 flash 记录) -# 从爪 flash 里的电机固件版本记录 (Cmd 0x5C 回显, 需固件 >= 1.2.14) +# The motor's own firmware version (Cmd 0x58, needs firmware >= 1.2.6; on >= 1.2.14 +# returns the flash record under MIT) +MotorVersion = _taccap_native.MotorVersion +# Motor firmware version record in follower flash (Cmd 0x5C echo, needs firmware +# >= 1.2.14) MotorFwVersionRecord = _taccap_native.MotorFwVersionRecord -# 电机 CAN 扩展帧透传的结果 (Cmd 0x5B, 需固件 >= 1.2.8) +# Result of a motor CAN extended-frame passthrough (Cmd 0x5B, needs firmware >= 1.2.8) MotorCanXferResp = _taccap_native.MotorCanXferResp -# MCU 级命令:心跳 / 复位 / SN / 设备类型(gripper.device,v0.3.3) +# MCU-level commands: heartbeat / reset / SN / device type (gripper.device, v0.3.3) Device = _taccap_native.Device Heartbeat = _taccap_native.Heartbeat DeviceType = _taccap_native.DeviceType -# 电机固件升级(刷 RobStride 电机本身,不是夹爪 MCU),经 0x5B 透传 +# Motor firmware upgrade (flashes the RobStride motor itself, not the gripper MCU), +# via 0x5B passthrough MotorOtaSession = _taccap_native.MotorOtaSession MotorStopReason = _taccap_native.MotorStopReason # V2.2 # V2.2 partial auto-cal writes — patch stall params without a read-modify-write