From 896d5bbc8133fcce784d35f567c90ad3c005a140 Mon Sep 17 00:00:00 2001 From: Vertax42 <1007644658@qq.com> Date: Sat, 10 Oct 2026 00:01:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(follower):=20RS00=20=E4=BB=8E=E7=88=AA?= =?UTF-8?q?=E5=88=86=E5=B7=A6=E5=8F=B3=E6=89=8B=EF=BC=8C=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=97=B6=E6=A0=A1=E9=AA=8C=E5=BC=80=E5=90=88=E6=96=B9=E5=90=91?= =?UTF-8?q?=E5=B9=B6=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RS00 夹爪内部齿轮对称安装:右从爪(SN 末位偶数)Reverse 0 / flags 0x0001, 左从爪(奇数)Reverse 1 / flags 0x0003(0094s、0086s 右,0089s 左,2026-10-09)。 固件按型号只认一个方向、标定又把方向写回,左手 RS00 新机会标反且不自纠。 - detail::expected_open_reverse(model_id, side):RS00 左 true、右 false,其它 nullopt - FollowerGripper 打开时,存的 Reverse 与左右不符就告警(给出应写的 flags),不写 flash - Python 导出 expected_open_reverse - EL05 暂不纳入(只测过一台) C++ 466 passed(新增 1 条纯函数 + 3 条 pty);pytest 129 passed / 2 skipped; 实机 0089s(左)/ 0094s(右)打开无告警。 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 14 +++++++ README.md | 13 +++++++ README_CN.md | 9 +++++ cpp/include/taccap/follower_gripper.hpp | 16 ++++++++ cpp/src/follower_gripper.cpp | 42 +++++++++++++++++++++ cpp/tests/fake_follower.hpp | 10 ++++- cpp/tests/test_gripper_envelope.cpp | 16 ++++++++ cpp/tests/test_power_cycle_warnings_pty.cpp | 42 +++++++++++++++++++++ python/bindings/bind_gripper.cpp | 10 +++++ python/xense/taccap/__init__.py | 2 + 10 files changed, 173 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418e6bf..e587e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **RS00 followers are handed; `FollowerGripper` warns when the stored open + direction does not match the side.** The gear train is mirror-mounted, so a + right (even SN) RS00 follower stores Reverse 0 (flags `0x0001`) and a left + (odd SN) one Reverse 1 (`0x0003`) -- 0094s / 0086s right, 0089s left + (2026-10-09). The firmware assumes one direction per model and calibration + writes back whatever it used, so a new left-hand RS00 calibrates backwards + and stays that way, with the normalized position mirrored. The SDK only + warns, naming the flags to write; it does not write flash on open. + `detail::expected_open_reverse(model_id, side)` (Python + `expected_open_reverse`) is the rule; EL05 is not covered (one unit + measured). + ## [0.4.2] - 2026-10-08 ### Fixed diff --git a/README.md b/README.md index 1d8eaa5..6513d37 100644 --- a/README.md +++ b/README.md @@ -299,6 +299,19 @@ whatever `0x700B` it stored (6.0 on units upgraded from 1.2.8 or earlier); raise it with `g.motor.set_startup_limit_torque(14.0)` and cut 24 V again. Details in [`firmware/README.md`](firmware/README.md). +**RS00 followers are left- or right-handed.** The gear train inside is mounted +mirror-symmetrically, so the same motor turns the opposite way to open: a +**right** follower (even SN) stores flags `0x0001` (Reverse 0), a **left** one +(odd SN) `0x0003` (Reverse 1). The firmware still assumes one direction per +model (RS00 = Reverse 0) and auto-calibration writes back whatever direction it +used, so a new left-hand RS00 calibrates backwards and stays that way -- the +normalized position is mirrored, a closed jaw reads ~1.0 and `set_target(0.0)` +opens it. `FollowerGripper` warns on open when the stored bit disagrees with +the side; `t.expected_open_reverse(model_id, side)` gives the right bit. Fix it +with `g.set_gripper_config()` (change the flags only) and cut 24 V so +calibration re-runs. A model-record change resets the bit to the model default +again. EL05 has no rule yet. + ### By task **Control.** Both controllers take the same two non-blocking calls, diff --git a/README_CN.md b/README_CN.md index 352b2c2..2585103 100644 --- a/README_CN.md +++ b/README_CN.md @@ -258,6 +258,15 @@ python python/examples/force_position_control.py left --grasp-torque 1.1 `g.motor.set_startup_limit_torque(14.0)` 调上去,再拔插一次 24V。细节见 [`firmware/README.md`](firmware/README.md)。 +**RS00 从爪分左右手。** 夹爪内部齿轮对称安装,同一款电机张开时转向相反:**右** +从爪(SN 末位偶数)存 flags `0x0001`(Reverse 0),**左**从爪(奇数)存 `0x0003` +(Reverse 1)。固件仍按型号只认一个方向(RS00 = Reverse 0),自动标定又会把实际用的 +方向原样写回,所以新的左手 RS00 第一次标定一定标反、而且不会自己纠正 —— 开度整个 +颠倒,合着读成接近 1,`set_target(0.0)` 会张开。`FollowerGripper` 打开时若存的方向 +与左右不符会告警;`t.expected_open_reverse(model_id, side)` 给出应有的值。修法:用 +`g.set_gripper_config()` 只改 flags,再拔插 24V 让标定按新方向重跑。改型号记录会把 +它重置回型号默认。EL05 暂无规则。 + ### 按任务分 **控制。** 两个控制器都只用同样两个非阻塞调用 —— `set_target(0..1)` 和 diff --git a/cpp/include/taccap/follower_gripper.hpp b/cpp/include/taccap/follower_gripper.hpp index f2db334..8fcd8fc 100644 --- a/cpp/include/taccap/follower_gripper.hpp +++ b/cpp/include/taccap/follower_gripper.hpp @@ -183,6 +183,22 @@ EnvelopeAudit audit_envelope(const protocol::GripperEnvelope& stored, // only what is absent, untrustworthy or unenforceable is replaced. protocol::GripperEnvelope repair_envelope(const EnvelopeAudit& audit); +// The open direction a follower of this model and side must have stored +// (GripperConfigFlag::Reverse): true = "open" is the motor's negative +// direction. std::nullopt = no rule for this model or side, check nothing. +// +// RS00 FOLLOWERS COME IN A LEFT AND A RIGHT HAND. The gear train inside is +// mounted mirror-symmetrically, so the same motor turns the opposite way to +// open: right (even SN) Reverse 0, left (odd SN) Reverse 1 -- 0094s and 0086s +// right, 0089s left (2026-10-09). The firmware still picks one default per +// model (RS00 = 0), and auto-calibration writes back whichever direction it +// used, so a new left-hand RS00 calibrates backwards and stays that way. +// +// EL05 is deliberately not covered: only one unit (0015s, left, Reverse 1) has +// been measured, and whether EL05 followers are handed too is not known. +std::optional expected_open_reverse(std::uint8_t model_id, + discovery::Side side) noexcept; + } // namespace detail class FollowerGripper { diff --git a/cpp/src/follower_gripper.cpp b/cpp/src/follower_gripper.cpp index 98b7a0e..d9ecd12 100644 --- a/cpp/src/follower_gripper.cpp +++ b/cpp/src/follower_gripper.cpp @@ -214,6 +214,39 @@ FollowerGripper::FollowerGripper(const Config& cfg) } } catch (...) {} + // Open direction against the hand this follower is (see + // detail::expected_open_reverse). Wrong, nothing errors: the normalized + // position is mirrored, so a closed jaw reads ~1.0 and set_target(0.0) + // drives it open. Warn only -- writing flash on open would change a + // device behind the caller's back; the fix is a flags write plus a 24 V + // power cycle so auto-calibration re-runs the right way. + try { + const auto m = motor_.get_model(std::chrono::milliseconds{300}); + const auto side = discovery::parse_serial(fw_sn_).side; + const auto want = (m.from_flash && side) + ? detail::expected_open_reverse(m.id, *side) + : std::nullopt; + const auto gc = get_gripper_config(); + if (want && (gc.flags & protocol::GripperConfigFlag::Valid) != 0) { + const bool have = (gc.flags & protocol::GripperConfigFlag::Reverse) != 0; + if (have != *want) { + const std::string name(m.name, ::strnlen(m.name, sizeof(m.name))); + const unsigned fixed = *want + ? (gc.flags | protocol::GripperConfigFlag::Reverse) + : (gc.flags & ~protocol::GripperConfigFlag::Reverse); + logger()->warn( + "FollowerGripper: 开合方向与左右不符 —— {} {} 侧从爪应为 Reverse {}," + "存的是 {}(flags=0x{:04x})。开度会整个颠倒:合着读成接近 1," + "set_target(0.0) 会张开。修:set_gripper_config 把 flags 改为 0x{:04x}," + "再拔插 24V 让自动标定按新方向重跑。 (open direction does not match " + "the {} hand of this {} follower: Reverse should be {}, stored {})", + name, *side == discovery::Side::Left ? "左" : "右", + *want ? 1 : 0, have ? 1 : 0, gc.flags, fixed, + discovery::to_string(*side), name, *want ? 1 : 0, have ? 1 : 0); + } + } + } catch (...) {} + // The wrist camera is off by default — an external camera service owns the // wrist UVC V4L2 device. Only open it when explicitly asked AND a device // path is provided. @@ -343,6 +376,15 @@ std::string fmt(float v) { } // namespace +std::optional expected_open_reverse(std::uint8_t model_id, + discovery::Side side) noexcept { + constexpr std::uint8_t kRs00 = 1; // MotorModel::id, persisted, never reused + if (model_id != kRs00) return std::nullopt; + if (side == discovery::Side::Left) return true; + if (side == discovery::Side::Right) return false; + return std::nullopt; +} + protocol::GripperEnvelope recommend_envelope( const std::optional& spec) { protocol::GripperEnvelope out{}; diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index 4e359d7..2b24093 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -129,6 +129,12 @@ class FakeFollower { void set_corrupt_sn_writes(bool on) { corrupt_sn_writes_.store(on); } // What 0x59 reports: a model recorded in flash, or the compile-time default. void set_model_from_flash(bool on) { model_from_flash_.store(on); } + // What 0x59 names: id 0 "EL05" (default) or id 1 "RS00". + void set_model_rs00(bool on) { model_rs00_.store(on); } + void set_sn(const std::string& sn) { + std::lock_guard lk(mu_); + sn_ = sn; + } std::string stored_sn() const { std::lock_guard lk(mu_); return sn_; @@ -284,7 +290,8 @@ class FakeFollower { // motor_model_t, 20 B. from_flash is the only field the SDK's // open-time check reads. std::vector out(20, 0); - std::memcpy(out.data() + 1, "EL05", 4); + out[0] = model_rs00_.load() ? 1 : 0; + std::memcpy(out.data() + 1, model_rs00_.load() ? "RS00" : "EL05", 4); out[9] = model_from_flash_.load() ? 1 : 0; pty_.send_response(f.seq, f.cmd, out); return; @@ -340,6 +347,7 @@ class FakeFollower { std::atomic spec_supported_{true}; std::atomic corrupt_sn_writes_{false}; std::atomic model_from_flash_{true}; + std::atomic model_rs00_{false}; std::atomic dev_type_{0xFF}; std::string sn_ = "TCGU01A28Z0001s"; mutable std::mutex mu_; diff --git a/cpp/tests/test_gripper_envelope.cpp b/cpp/tests/test_gripper_envelope.cpp index bf17e9e..f54b3e8 100644 --- a/cpp/tests/test_gripper_envelope.cpp +++ b/cpp/tests/test_gripper_envelope.cpp @@ -354,3 +354,19 @@ TEST(EnvelopeCrossLayer, FirmwareContSitsAtOrAboveTheDefaultGrasp) { } } // namespace + +// RS00 followers are handed: the gear train is mirror-mounted, so a left one +// opens toward the motor's negative direction (Reverse 1) and a right one the +// other way (0089s left, 0094s / 0086s right, 2026-10-09). EL05 has no rule +// yet -- one measured unit is not a rule -- and an unknown side checks nothing. +TEST(OpenDirection, Rs00IsHandedAndNothingElseIsChecked) { + using xense::taccap::detail::expected_open_reverse; + using xense::taccap::discovery::Side; + constexpr std::uint8_t kEl05 = 0, kRs00 = 1; + EXPECT_EQ(expected_open_reverse(kRs00, Side::Left), std::optional(true)); + EXPECT_EQ(expected_open_reverse(kRs00, Side::Right), std::optional(false)); + EXPECT_EQ(expected_open_reverse(kRs00, Side::Unknown), std::nullopt); + EXPECT_EQ(expected_open_reverse(kEl05, Side::Left), std::nullopt); + EXPECT_EQ(expected_open_reverse(kEl05, Side::Right), std::nullopt); + EXPECT_EQ(expected_open_reverse(7, Side::Left), std::nullopt); +} diff --git a/cpp/tests/test_power_cycle_warnings_pty.cpp b/cpp/tests/test_power_cycle_warnings_pty.cpp index 28ee8d2..1e4af81 100644 --- a/cpp/tests/test_power_cycle_warnings_pty.cpp +++ b/cpp/tests/test_power_cycle_warnings_pty.cpp @@ -77,3 +77,45 @@ TEST(FirmwareAdvisory, FollowerBelow1211WarnsButOpens) { << int(v.a) << "." << int(v.b) << "." << int(v.c); } } + +// RS00 followers are handed (mirror-mounted gear train): a left one must store +// Reverse 1, a right one Reverse 0. A wrong bit mirrors the normalized +// position, so opening warns; writing flash behind the caller's back is not +// the SDK's call, so it only warns. +TEST(OpenDirectionWarning, ALeftRs00StoredAsRightWarns) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_model_rs00(true); + fw.set_sn("TCGU01A28Z0089s"); // odd -> left + fw.set_reverse(false); + CaptureLog log; + auto g = open_follower(pty); + EXPECT_EQ(log.count("open direction does not match the Left hand"), 1u); + EXPECT_EQ(log.count("0x0003"), 1u) << "the warning names the flags to write"; +} + +TEST(OpenDirectionWarning, CorrectlyHandedRs00OpensQuietly) { + for (const bool left : {true, false}) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_model_rs00(true); + fw.set_sn(left ? "TCGU01A28Z0089s" : "TCGU01A28Z0094s"); + fw.set_reverse(left); + CaptureLog log; + auto g = open_follower(pty); + EXPECT_EQ(log.count("open direction does not match"), 0u) << (left ? "left" : "right"); + } +} + +TEST(OpenDirectionWarning, El05IsNotChecked) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); // EL05 + fw.set_sn("TCGU01A28Z0094s"); + fw.set_reverse(true); + CaptureLog log; + auto g = open_follower(pty); + EXPECT_EQ(log.count("open direction does not match"), 0u); +} diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index 0e09892..11377c6 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -63,6 +63,16 @@ void bind_gripper(py::module_& m) { ", role=" + discovery::to_string(p.role) + ", valid=" + (p.valid ? "True" : "False") + ")"; }); + m.def("expected_open_reverse", + [](std::uint8_t model_id, discovery::Side side) { + return xense::taccap::detail::expected_open_reverse(model_id, side); + }, + py::arg("model_id"), py::arg("side"), + "The Reverse bit a follower of this motor model id and side should store,\n" + "or None when there is no rule. RS00 (id 1) followers are handed -- the gear\n" + "train is mirror-mounted: Left -> True (Reverse 1, flags 0x0003),\n" + "Right -> False (Reverse 0, flags 0x0001). EL05 is not covered yet.\n" + "FollowerGripper warns on open when the stored bit disagrees."); m.def("parse_serial", &discovery::parse_serial, py::arg("serial"), "Parse a TacCap SN into its fields.\n\n" "Never raises: a string that does not match the grammar comes back with\n" diff --git a/python/xense/taccap/__init__.py b/python/xense/taccap/__init__.py index 0ae0271..8afe211 100644 --- a/python/xense/taccap/__init__.py +++ b/python/xense/taccap/__init__.py @@ -209,6 +209,7 @@ Role = _taccap_native.Role ParsedSerial = _taccap_native.ParsedSerial parse_serial = _taccap_native.parse_serial +expected_open_reverse = _taccap_native.expected_open_reverse scan_grippers = _taccap_native.scan_grippers find_one = _taccap_native.find_one find_left = _taccap_native.find_left @@ -349,6 +350,7 @@ "Role", "ParsedSerial", "parse_serial", + "expected_open_reverse", "scan_grippers", "find_one", "find_left",