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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` 和
Expand Down
16 changes: 16 additions & 0 deletions cpp/include/taccap/follower_gripper.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> expected_open_reverse(std::uint8_t model_id,
discovery::Side side) noexcept;

} // namespace detail

class FollowerGripper {
Expand Down
42 changes: 42 additions & 0 deletions cpp/src/follower_gripper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -343,6 +376,15 @@ std::string fmt(float v) {

} // namespace

std::optional<bool> 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<protocol::MotorSpec>& spec) {
protocol::GripperEnvelope out{};
Expand Down
10 changes: 9 additions & 1 deletion cpp/tests/fake_follower.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::mutex> lk(mu_);
sn_ = sn;
}
std::string stored_sn() const {
std::lock_guard<std::mutex> lk(mu_);
return sn_;
Expand Down Expand Up @@ -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<uint8_t> 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;
Expand Down Expand Up @@ -340,6 +347,7 @@ class FakeFollower {
std::atomic<bool> spec_supported_{true};
std::atomic<bool> corrupt_sn_writes_{false};
std::atomic<bool> model_from_flash_{true};
std::atomic<bool> model_rs00_{false};
std::atomic<uint8_t> dev_type_{0xFF};
std::string sn_ = "TCGU01A28Z0001s";
mutable std::mutex mu_;
Expand Down
16 changes: 16 additions & 0 deletions cpp/tests/test_gripper_envelope.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(true));
EXPECT_EQ(expected_open_reverse(kRs00, Side::Right), std::optional<bool>(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);
}
42 changes: 42 additions & 0 deletions cpp/tests/test_power_cycle_warnings_pty.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
10 changes: 10 additions & 0 deletions python/bindings/bind_gripper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions python/xense/taccap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -349,6 +350,7 @@
"Role",
"ParsedSerial",
"parse_serial",
"expected_open_reverse",
"scan_grippers",
"find_one",
"find_left",
Expand Down
Loading