From 26f28a3615a72438039b406e3fabf6180a7be2aa Mon Sep 17 00:00:00 2001 From: xensexyq Date: Thu, 22 Oct 2026 12:52:21 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat(imu):=20=E4=B8=BA=E4=BB=8E=E7=88=AA?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=98=BE=E5=BC=8F=20IMU=20=E6=B5=81=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=92=8C=E6=A0=A1=E5=87=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留原一参数 C++ 方法和 Python motor_hz 默认值,新增 imu_hz=0 可选参数。显式启用前查询能力,旧固件拒绝时不改原流;已开流时 拒绝静默新增源,避免 IMU-only 被控制器误当作电机流。 增加加速度/陀螺仪校准后保存接口,重试复用序号,补齐接口说明。 扩展 PTY 覆盖默认、旧固件、双流、参数错误和校准失败路径;修正 既有安全断言的异步等待,验证实际收到零力矩帧,而不只看 Fault 状态。 验证:C++ 477 项通过;Python 151 项通过、2 项需真机而跳过; 7 项针对性测试重复 10 轮通过;本次文件 pre-commit 检查通过。 仅隔离目录本地构建,未替换原 SDK 安装、未推送、未操作设备。 --- cpp/include/taccap/components/imu.hpp | 12 ++- cpp/include/taccap/follower_gripper.hpp | 9 ++- cpp/src/components/imu.cpp | 11 +++ cpp/src/follower_gripper.cpp | 39 ++++++---- cpp/tests/CMakeLists.txt | 1 + cpp/tests/fake_follower.hpp | 39 +++++++++- cpp/tests/test_follower_imu_pty.cpp | 92 +++++++++++++++++++++++ cpp/tests/test_impedance_pty.cpp | 7 ++ python/bindings/bind_gripper.cpp | 22 +++--- python/bindings/bind_sensors.cpp | 17 ++++- python/tests/test_follower_imu_surface.py | 23 ++++++ 11 files changed, 236 insertions(+), 36 deletions(-) create mode 100644 cpp/tests/test_follower_imu_pty.cpp create mode 100644 python/tests/test_follower_imu_surface.py diff --git a/cpp/include/taccap/components/imu.hpp b/cpp/include/taccap/components/imu.hpp index b5a9b7d..6455bfd 100644 --- a/cpp/include/taccap/components/imu.hpp +++ b/cpp/include/taccap/components/imu.hpp @@ -55,6 +55,12 @@ class IMU { SubId on_data(Callback cb); void off(SubId id); + // Keep the IMU stationary, sensor Z up. Caller must stop the controller + // and disable the motor first on followers. Cmd 0x25 actions 1 then 4: + // firmware performs its static verification before we request persistence. + // timeout applies to each command; timeout does NOT cancel device work. + void calibrate_accel_gyro(std::chrono::milliseconds timeout = std::chrono::milliseconds{16000}); + // V1.4 — write magnetometer hard-iron offsets + soft-iron correction // matrix to firmware (Cmd::SetImuMagCal, 0x26). `hard_iron` is the // 3-axis bias in µT; `soft_iron` is the 3×3 correction matrix in @@ -64,8 +70,10 @@ class IMU { const std::array& soft_iron_row_major, std::chrono::milliseconds timeout = std::chrono::milliseconds{500}); - // Magnetometer calibration session (Cmd 0x26 with a 1-byte payload; leader - // firmware only -- a follower NACKs InvalidCmd). + // Magnetometer calibration session (Cmd 0x26 with a 1-byte payload). + // Historical followers NACK; capable followers require stopped/disabled + // motors, restore the previous stream on finish, and expire after 180s. + // The following stream-replacement semantics apply to leader firmware: // // start: the firmware flags calibration and STARTS AN IMU STREAM ON ITS OWN // (separate mode, IMU only, rate code 5) so the host can collect raw diff --git a/cpp/include/taccap/follower_gripper.hpp b/cpp/include/taccap/follower_gripper.hpp index 1a1bbf6..8af4f83 100644 --- a/cpp/include/taccap/follower_gripper.hpp +++ b/cpp/include/taccap/follower_gripper.hpp @@ -478,7 +478,7 @@ class FollowerGripper { // // Motor status is the ONLY thing a follower streams. This used to mirror // the leader's (imu_hz, encoder_hz, motor_hz) signature, which was worse - // than useless: the follower firmware emits IMU, encoder and eskin only + // than useless: historical follower firmware emitted IMU/encoder/eskin only // under #ifdef ENABLE_MASTER_GRIPPER, so those two rates set their mask // bits and then produced nothing (measured: 1000Hz of each yields zero // frames and leaves byte volume unchanged). Worse, the old defaults were @@ -501,6 +501,12 @@ class FollowerGripper { // for a bad rate, so the SDK logs a warning whenever it has to adjust one. // See cpp/src/stream_rate.hpp for the firmware model this mirrors. void start_streaming(unsigned motor_hz = 100); + // Opt-in IMU on capable follower firmware, separate 28-byte packets. + // imu_hz=0 preserves legacy behavior; 1..100 enables IMU after a GetImu + // capability probe. Old firmware NACKs without changing its stream. + // motor_hz=0 permits IMU-only; both zero is invalid. Stop before changing + // an existing stream (as with the original one-argument interface). + void start_streaming(unsigned motor_hz, unsigned imu_hz); void stop_streaming(); bool is_streaming() const noexcept { return streaming_; } @@ -541,6 +547,7 @@ class FollowerGripper { OtaSession ota_; // V1.3 std::unique_ptr wrist_; bool streaming_ = false; + uint16_t streaming_sources_ = 0; GripperPosition pos_map_; // raw<->position, cached bool pos_map_loaded_ = false; }; diff --git a/cpp/src/components/imu.cpp b/cpp/src/components/imu.cpp index 8e2c838..7044e97 100644 --- a/cpp/src/components/imu.cpp +++ b/cpp/src/components/imu.cpp @@ -79,6 +79,17 @@ IMU::SubId IMU::on_data(Callback cb) { void IMU::off(SubId id) { t_.unsubscribe(id); } +void IMU::calibrate_accel_gyro(std::chrono::milliseconds timeout) { + for (uint8_t action : {0x01, 0x04}) { + auto ack = t_.send_cmd(protocol::Cmd::SetImuCal, {action}, timeout, + bus::RetryMode::SameSeq); + if (ack.is_nack) { + throw ProtocolError(std::string("IMU::calibrate_accel_gyro NACK: ") + + protocol::to_string(ack.error_code)); + } + } +} + void IMU::start_mag_calibration(std::chrono::milliseconds timeout) { auto ack = t_.send_cmd(protocol::Cmd::SetImuMagCal, {0x01}, timeout); if (ack.is_nack) { diff --git a/cpp/src/follower_gripper.cpp b/cpp/src/follower_gripper.cpp index e79a313..f9fcccf 100644 --- a/cpp/src/follower_gripper.cpp +++ b/cpp/src/follower_gripper.cpp @@ -318,18 +318,29 @@ std::unique_ptr FollowerGripper::open() { } void FollowerGripper::start_streaming(unsigned motor_hz) { - if (streaming_) return; - - // Motor status is the only source the follower firmware emits; everything - // else in its stream task is compiled out on this role. So there is exactly - // one rate to honour, and a rate of 0 has nowhere useful to go. - if (motor_hz == 0) { - throw IoError("FollowerGripper::start_streaming: motor_hz is 0, and " - "motor status is the only source a follower streams — " - "pass a non-zero rate, or just don't start the stream", + start_streaming(motor_hz, 0); +} + +void FollowerGripper::start_streaming(unsigned motor_hz, unsigned imu_hz) { + if (imu_hz > 100) throw IoError("Follower IMU rate must be 0..100 Hz", EINVAL); + if (streaming_) { + const auto requested = detail::stream_source_mask(imu_hz, 0, motor_hz); + if ((requested & streaming_sources_) != requested) + throw IoError("FollowerGripper: stop streaming before adding sources", EBUSY); + return; + } + + // Zero rates clear source bits, rather than selecting firmware defaults. + if (motor_hz == 0 && imu_hz == 0) { + throw IoError("FollowerGripper::start_streaming: both rates are 0", EINVAL); } + // Probe before StopStream: old firmware must fail explicitly without + // disturbing a stream left by another client. Never infer support from a + // shared firmware version number (leader/follower have separate features). + if (imu_hz) (void)imu_.read_once(std::chrono::milliseconds{500}); + // Drain the firmware queue from any previous host process — same // rationale as LeaderGripper::start_streaming. try { @@ -341,16 +352,15 @@ void FollowerGripper::start_streaming(unsigned motor_hz) { detail::kMotorMaxRateHz); protocol::StreamConfig sc{}; - // Only the MotorStatus bit: a rate alone would not turn a source on, and a - // set bit with rate 0 would stream at the firmware's 100Hz default rather - // than staying off. See stream_rate.hpp. - sc.source_mask = protocol::StreamSrc::MotorStatus; + // Legacy calls keep only MotorStatus; IMU is always explicit opt-in. + sc.source_mask = detail::stream_source_mask(imu_hz, 0, motor_hz); sc.mode = static_cast(protocol::StreamMode::Separate); - sc.imu_rate = 0; + sc.imu_rate = static_cast(imu_hz); sc.encoder_rate = 0; sc.eskin_rate = 0; sc.motor_rate = static_cast(motor_hz); sc.output_iface = static_cast(protocol::StreamInterface::Uart); + if (imu_hz) detail::warn_if_rate_adjusted("FollowerGripper", "IMU", imu_hz, 100); auto wire = protocol::encode(sc); auto ack = t_.send_cmd(protocol::Cmd::StartStream, wire); @@ -359,6 +369,7 @@ void FollowerGripper::start_streaming(unsigned motor_hz) { protocol::to_string(ack.error_code)); } + streaming_sources_ = sc.source_mask; streaming_ = true; } diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index c0fe7fb..4933ceb 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -36,6 +36,7 @@ add_executable(taccap_unit_tests test_crc32_boundaries.cpp test_log.cpp test_stream_rate.cpp + test_follower_imu_pty.cpp ) # stream_rate.hpp is a private helper under src/ (not an installed header), diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index 1ea620d..44204c1 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -88,6 +88,13 @@ class FakeFollower { } unsigned submit_count() const { return submits_.load(); } unsigned disable_count() const { return disables_.load(); } + void set_imu_supported(bool on) { imu_supported_.store(on); } + unsigned imu_queries() const { return imu_queries_.load(); } + unsigned stream_stops() const { return stream_stops_.load(); } + tp::StreamConfig stream_config() const { + std::lock_guard lk(mu_); + return stream_config_; + } // Make 0x5C store something other than what was sent. void set_corrupt_fw_ver_writes(bool on) { corrupt_fw_ver_writes_.store(on); } @@ -158,8 +165,14 @@ class FakeFollower { if (streaming_.load() && !frozen_.load() && now >= next_status) { next_status = now + std::chrono::milliseconds(10); std::lock_guard lk(mu_); - pty_.send_data(0, tp::Cmd::GetMotorStatus, + if (stream_config_.source_mask & tp::StreamSrc::MotorStatus) + pty_.send_data(0, tp::Cmd::GetMotorStatus, pod_bytes(&status_, sizeof(status_))); + if (stream_config_.source_mask & tp::StreamSrc::Imu) { + tp::ImuData imu{}; + imu.accel_z = 1000; + pty_.send_data(0, tp::Cmd::GetImu, pod_bytes(&imu, sizeof(imu))); + } } } } @@ -321,11 +334,30 @@ class FakeFollower { pod_bytes(&status_, sizeof(status_))); return; } - case tp::Cmd::StartStream: + case tp::Cmd::GetImu: { + ++imu_queries_; + if (!imu_supported_.load()) { + pty_.send_nack(f.seq, tp::ErrorCode::InvalidCmd); + } else { + tp::ImuData imu{}; + imu.accel_z = 1000; + pty_.send_response(f.seq, f.cmd, pod_bytes(&imu, sizeof(imu))); + } + return; + } + case tp::Cmd::StartStream: { + std::lock_guard lk(mu_); + if (f.payload.size() != sizeof(stream_config_)) { + pty_.send_nack(f.seq, tp::ErrorCode::LengthMismatch); + return; + } + std::memcpy(&stream_config_, f.payload.data(), sizeof(stream_config_)); streaming_.store(true); pty_.send_ack_ok(f.seq, f.cmd); return; + } case tp::Cmd::StopStream: + ++stream_stops_; streaming_.store(false); pty_.send_ack_ok(f.seq, f.cmd); return; @@ -351,6 +383,9 @@ class FakeFollower { std::thread thread_; std::atomic stop_{false}; std::atomic streaming_{false}; + std::atomic imu_supported_{false}; + std::atomic imu_queries_{0}, stream_stops_{0}; + tp::StreamConfig stream_config_{}; std::atomic frozen_{false}; std::atomic submits_{0}; std::atomic disables_{0}; diff --git a/cpp/tests/test_follower_imu_pty.cpp b/cpp/tests/test_follower_imu_pty.cpp new file mode 100644 index 0000000..269241b --- /dev/null +++ b/cpp/tests/test_follower_imu_pty.cpp @@ -0,0 +1,92 @@ +#include "fake_follower.hpp" +#include +#include +#include +#include + +namespace tx = xense::taccap; +namespace tp = tx::protocol; +using namespace taccap_test; + +TEST(FollowerImu, DefaultRemainsMotorOnlyWithoutProbe) { + Pty pty; FakeFollower fw(pty); auto g = open_follower(pty); + g->start_streaming(); + EXPECT_EQ(fw.imu_queries(), 0u); + EXPECT_EQ(fw.stream_config().source_mask, tp::StreamSrc::MotorStatus); + EXPECT_EQ(fw.stream_config().imu_rate, 0u); + EXPECT_EQ(fw.stream_config().motor_rate, 100u); + EXPECT_THROW(g->start_streaming(100, 50), tx::IoError); +} + +TEST(FollowerImu, OldFirmwareFailsBeforeChangingStream) { + Pty pty; FakeFollower fw(pty); auto g = open_follower(pty); + const auto stops = fw.stream_stops(); + EXPECT_THROW(g->start_streaming(100, 50), tx::ProtocolError); + EXPECT_EQ(fw.stream_stops(), stops); + EXPECT_FALSE(g->is_streaming()); + EXPECT_EQ(fw.stream_config().source_mask, 0u); + EXPECT_NO_THROW(g->start_streaming(100)); +} + +TEST(FollowerImu, OptInDeliversBothCallbacksWithUnchangedMotorRate) { + Pty pty; FakeFollower fw(pty); fw.set_imu_supported(true); + auto g = open_follower(pty); + std::atomic imus{0}, motors{0}; + auto i = g->imu().on_data([&](const tx::ImuSample& s) { + if (std::abs(s.accel_mps2[2] - 9.80665f) < .001f) ++imus; + }); + auto m = g->motor().on_status([&](const auto&) { ++motors; }); + g->start_streaming(100, 50); + for (int n = 0; n < 100 && (!imus || !motors); ++n) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_GT(imus.load(), 0u); EXPECT_GT(motors.load(), 0u); + EXPECT_EQ(fw.stream_config().motor_rate, 100u); + EXPECT_EQ(fw.stream_config().imu_rate, 50u); + EXPECT_EQ(fw.stream_config().source_mask, tp::StreamSrc::Imu | tp::StreamSrc::MotorStatus); + EXPECT_EQ(fw.stream_config().mode, static_cast(tp::StreamMode::Separate)); + g->stop_streaming(); + g->imu().off(i); g->motor().off(m); + g->close(); // Join dispatcher before callback captures go out of scope. +} + +TEST(FollowerImu, ImuOnlyAndInvalidRates) { + Pty pty; FakeFollower fw(pty); fw.set_imu_supported(true); + auto g = open_follower(pty); + EXPECT_THROW(g->start_streaming(0, 0), tx::IoError); + EXPECT_THROW(g->start_streaming(100, 101), tx::IoError); + EXPECT_EQ(fw.imu_queries(), 0u); + g->start_streaming(0, 25); + EXPECT_EQ(fw.stream_config().source_mask, tp::StreamSrc::Imu); + EXPECT_EQ(fw.stream_config().motor_rate, 0u); + EXPECT_THROW(g->start_streaming(), tx::IoError); +} + +TEST(FollowerImu, CalibrationSavesOnlyAfterSuccessfulVerification) { + Pty pty; tx::bus::Transport transport(base_config(pty.slave_path())); + tx::IMU imu(transport); + std::vector actions; + std::thread device([&] { + for (int n = 0; n < 2; ++n) { + auto f = pty.expect_frame(); + if (!f) return; + EXPECT_EQ(f->cmd, tp::Cmd::SetImuCal); + actions.push_back(f->payload.at(0)); + pty.send_ack_ok(f->seq, f->cmd); + } + }); + EXPECT_NO_THROW(imu.calibrate_accel_gyro()); + device.join(); + EXPECT_EQ(actions, (std::vector{1, 4})); +} + +TEST(FollowerImu, CalibrationNackDoesNotSave) { + Pty pty; tx::bus::Transport transport(base_config(pty.slave_path())); + tx::IMU imu(transport); + std::thread device([&] { + auto f = pty.expect_frame(); + if (f) pty.send_nack(f->seq, tp::ErrorCode::SysBusy); + EXPECT_FALSE(pty.expect_frame(50).has_value()); + }); + EXPECT_THROW(imu.calibrate_accel_gyro(), tx::ProtocolError); + device.join(); +} diff --git a/cpp/tests/test_impedance_pty.cpp b/cpp/tests/test_impedance_pty.cpp index e9788c4..a7c0f4c 100644 --- a/cpp/tests/test_impedance_pty.cpp +++ b/cpp/tests/test_impedance_pty.cpp @@ -85,6 +85,13 @@ TEST(ImpedanceControllerPty, StaleStreamFaultsWithZeroTorqueAndAnInvalidObservat << "frozen numbers still presented as a live reading"; EXPECT_FALSE(snap.fault_reason.empty()); + // Fault is published before submit(), and the PTY peer consumes the frame + // on another thread. Wait for the safety action itself, not only state(). + ASSERT_TRUE(wait_for([&] { + const auto received = fw.last_submit(); + return received && received->kp == 0.0f && received->kd == 0.0f && + received->target_torque == 0.0f; + }, Ms(1000))); const auto s = fw.last_submit(); ASSERT_TRUE(s.has_value()); EXPECT_FLOAT_EQ(s->kp, 0.0f); diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index f8c907b..1a7fb0e 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -474,18 +474,14 @@ void bind_gripper(py::module_& m) { "explicit mcu_device from find_follower() in that case. It does not\n" "check that the hardware really is a follower -- see the class docstring.\n" "Cameras stay closed.") - .def("start_streaming", [](FollowerGripper& self, unsigned motor_hz) { + .def("start_streaming", [](FollowerGripper& self, unsigned motor_hz, unsigned imu_hz) { py::gil_scoped_release gil; - self.start_streaming(motor_hz); - }, py::arg("motor_hz") = 100u, - "Start the motor-status stream.\n\n" - "Motor status is the ONLY source a follower streams -- the firmware\n" - "compiles IMU, encoder and eskin streaming out on this role. This used\n" - "to take imu_hz and encoder_hz for parity with the leader; they set\n" - "their mask bits and produced nothing, and the old defaults\n" - "(imu=100, encoder=100, motor=0) meant a bare start_streaming() call\n" - "started a stream carrying NOTHING and reported success.\n\n" - "motor_hz=0 raises IoError(EINVAL) rather than starting an empty\n" + self.start_streaming(motor_hz, imu_hz); + }, py::arg("motor_hz") = 100u, py::arg("imu_hz") = 0u, + "Start motor status, optionally IMU (default off).\n\n" + "imu_hz=1..100 requires capable follower firmware; an IMU read probes\n" + "support before changing the stream. Old firmware raises ProtocolError.\n" + "Stop streaming before changing rates. Both rates zero raises IoError(EINVAL).\n" "stream. The firmware caps motor status at 100 Hz and divides a 1 kHz\n" "tick by an integer, so only divisors of 1000 arrive at the requested\n" "rate. It never NACKs a rate it had to adjust, so the SDK logs a\n" @@ -497,8 +493,8 @@ void bind_gripper(py::module_& m) { "Best-effort: a no-op when no stream was started, and it returns even\n" "when the MCU never ACKs the StopStream.") .def_property_readonly("imu", [](FollowerGripper& g) -> IMU& { return g.imu(); }, py::return_value_policy::reference_internal, - "IMU component. Present on both roles, but a follower does not stream\n" - "it -- see start_streaming().") + "IMU component; follower support requires capable firmware and explicit\n" + "imu_hz opt-in -- see start_streaming().") .def_property_readonly("encoder", [](FollowerGripper& g) -> Encoder& { return g.encoder(); }, py::return_value_policy::reference_internal, "Encoder component. Present on both roles, but a follower does not\n" "stream it -- see start_streaming().") diff --git a/python/bindings/bind_sensors.cpp b/python/bindings/bind_sensors.cpp index 6cb96b9..d14d9f4 100644 --- a/python/bindings/bind_sensors.cpp +++ b/python/bindings/bind_sensors.cpp @@ -143,6 +143,13 @@ void bind_sensors(py::module_& m) { .def("off", &IMU::off, py::arg("subscription_id"), "Cancel a subscription taken out by on_data(). Takes effect even for\n" "frames already queued for dispatch.") + .def("calibrate_accel_gyro", [](IMU& self, unsigned timeout_ms) { + py::gil_scoped_release gil; + self.calibrate_accel_gyro(std::chrono::milliseconds(timeout_ms)); + }, py::arg("timeout_ms") = 16000u, + "Calibrate, statically verify and save accel/gyro offsets (Cmd 0x25).\n" + "Keep sensor Z up and stationary; stop control and disable follower motor first.\n" + "Timeout does not cancel device work. Requires supporting firmware.") .def("set_mag_calibration", [](IMU& self, std::array hard, std::array soft, @@ -161,13 +168,14 @@ void bind_sensors(py::module_& m) { "firmware stores after MotionCal. Blocks for the ACK with the GIL\n" "released; raises ProtocolError on NACK. Also ends a session begun with\n" "start_mag_calibration(): the firmware stops its stream and clears the\n" - "flag itself.") + "flag itself. Capable followers restore the previous stream instead.") .def("start_mag_calibration", [](IMU& self, unsigned timeout_ms) { py::gil_scoped_release gil; self.start_mag_calibration(std::chrono::milliseconds(timeout_ms)); }, py::arg("timeout_ms") = 500u, - "Begin a magnetometer calibration session (Cmd 0x26, payload 1). Leader\n" - "firmware only; a follower raises ProtocolError(InvalidCmd).\n\n" + "Begin a magnetometer calibration session (Cmd 0x26, payload 1).\n" + "Requires supporting firmware; old followers NACK. On followers stop\n" + "control and disable the motor first. Session expires after 180 seconds.\n\n" "The firmware STARTS AN IMU STREAM BY ITSELF so raw samples reach\n" "on_data() while the gripper is rotated; the gripper's own\n" "start_streaming() state does not know about it. Finish with\n" @@ -178,7 +186,8 @@ void bind_sensors(py::module_& m) { }, py::arg("timeout_ms") = 500u, "Abandon a magnetometer calibration session (Cmd 0x26, payload 2): stops\n" "the stream the firmware started and clears the flag. Not needed after\n" - "set_mag_calibration(), which does both."); + "set_mag_calibration(), which does both. Capable followers restore the\n" + "previous stream instead of stopping it."); // ---- KeySample + Key (V1.4) ---------------------------------------- py::class_(m, "KeySample", diff --git a/python/tests/test_follower_imu_surface.py b/python/tests/test_follower_imu_surface.py new file mode 100644 index 0000000..4a58173 --- /dev/null +++ b/python/tests/test_follower_imu_surface.py @@ -0,0 +1,23 @@ +"""The Python opt-in stays optional; the leader's existing signature is intact.""" + +import re + +import xense.taccap as t + + +def test_follower_keeps_motor_default_and_opts_in_to_imu(): + doc = t.FollowerGripper.start_streaming.__doc__ + assert re.search(r"motor_hz: [^,]* = 100", doc) + assert re.search(r"imu_hz: [^,)]* = 0", doc) + + +def test_leader_signature_is_unchanged(): + doc = t.LeaderGripper.start_streaming.__doc__ + assert re.search(r"imu_hz: [^,]* = 100", doc) + assert re.search(r"encoder_hz: [^,)]* = 100", doc) + + +def test_calibration_has_explicit_long_timeout(): + doc = t.IMU.calibrate_accel_gyro.__doc__ + assert "16000" in doc + assert "disable" in doc From 8e74998df6cf9105672fb2e26e9dd2c025258af4 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Thu, 22 Oct 2026 15:08:33 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat(imu):=20=E5=8D=8F=E5=95=86=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=97=B6=E9=97=B4=E6=88=B3=E5=B9=B6=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=BC=9A=E8=AF=9D=E5=92=8C=E6=97=A7=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=9B=9E=E7=BB=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目的:保持旧固件可用,同时避免把时间回退或设备重启误当成连续采样。 - 增加 TimeSession/GetImuV2,只有明确不支持命令才降级,超时及错误响应继续报错。 - IMU 时间扩展为 64 位,提供会话、原生格式、时间有效性和不连续标志。 - 旧包保留原始值并按观测段展开;17 秒回退及不确定断流不伪造回绕。 - 各订阅和轮询独立维护时间线,主从爪开流自动选择格式,默认从爪仍只开电机流。 - Python 提供对应字段,新增串口仿真和协议边界回归,记录 ABI/ROS 接入限制。 验证:489 项 C++ 测试通过;Python 152 通过、2 项硬件测试跳过; 协议对照 75 命令、17 错误码、7 停止原因及 37 结构尺寸通过。 Python 扩展在本任务副本重建,未安装到业务环境;未改 ROS 仓库。 未进行真机长期验收,未发布、未推送。 --- cpp/include/taccap/components/imu.hpp | 33 +++++- cpp/include/taccap/error.hpp | 12 +++ cpp/include/taccap/protocol/commands.hpp | 2 + cpp/include/taccap/protocol/payloads.hpp | 1 + cpp/src/bus/transport.cpp | 4 +- cpp/src/components/imu.cpp | 116 +++++++++++++++++++-- cpp/src/follower_gripper.cpp | 4 +- cpp/src/leader_gripper.cpp | 1 + cpp/src/protocol/commands.cpp | 2 + cpp/tests/CMakeLists.txt | 1 + cpp/tests/fake_follower.hpp | 26 +++++ cpp/tests/test_imu_time.cpp | 124 +++++++++++++++++++++++ docs/FIRMWARE.md | 38 +++++++ python/bindings/bind_sensors.cpp | 13 ++- python/bindings/module.cpp | 2 + python/tests/test_imu_time.py | 61 +++++++++++ 16 files changed, 421 insertions(+), 19 deletions(-) create mode 100644 cpp/tests/test_imu_time.cpp create mode 100644 python/tests/test_imu_time.py diff --git a/cpp/include/taccap/components/imu.hpp b/cpp/include/taccap/components/imu.hpp index 6455bfd..77ddf9b 100644 --- a/cpp/include/taccap/components/imu.hpp +++ b/cpp/include/taccap/components/imu.hpp @@ -16,6 +16,8 @@ #include #include #include +#include +#include namespace xense::taccap { @@ -24,7 +26,11 @@ struct ImuSample { // since the MCU's `timestamp_us` is relative to its own boot. std::chrono::steady_clock::time_point host_time; - uint32_t mcu_timestamp_us; // MCU-side timestamp (us since MCU boot) + uint64_t mcu_timestamp_us; // V2: exact capture time in the boot epoch. + uint64_t boot_session_id{}; // V2: nonzero, constant per boot. Legacy: 0. + bool timestamp_is_64bit{}; // True only for native V2, not legacy unfolding. + bool timestamp_valid{true}; // False on ambiguous legacy discontinuities. + bool timestamp_discontinuity{}; // New boot / ambiguous jump; reset estimators. uint16_t valid_flag; // protocol::ImuValid::* bits uint16_t seq; // MCU-side packet sequence @@ -46,9 +52,27 @@ class IMU { explicit IMU(bus::Transport& transport); - // Synchronous one-shot read (sends CMD_NEED_ACK GetImu, waits for ACK). - // Throws on timeout / NACK / size mismatch. + // Negotiate time format, then GetImu/GetImuV2. Timeout is per command + // (plus transport retries). Throws on timeout / NACK / size mismatch. ImuSample read_once(std::chrono::milliseconds timeout = std::chrono::milliseconds{100}); + // Read-only capability negotiation except for assigning a first-boot RAM + // nonce. Repeated calls preserve the existing nonce. No flash writes. + uint16_t negotiate_time(std::chrono::milliseconds timeout = std::chrono::milliseconds{500}); + + // One tracker per subscription (and one for polling), so fan-out does not + // consume another subscriber's reset notification. Legacy epochs are + // relative to first observation, not guaranteed absolute boot uptime. + class Timeline { + public: + ImuSample decode(const std::uint8_t*, std::size_t, + std::chrono::steady_clock::time_point arrival = std::chrono::steady_clock::now()); + private: + bool seen_{}; + uint64_t last_time_{}, session_{}; + uint32_t raw_{}; + uint16_t seq_{}; + std::chrono::steady_clock::time_point host_{}; + }; // Subscribe to streaming DATA frames. The Transport keeps streaming // running independently — see LeaderGripper::start_streaming(). @@ -94,6 +118,9 @@ class IMU { private: bus::Transport& t_; + std::mutex read_mutex_, subscriptions_mutex_; + Timeline polling_time_; + std::unordered_map subscriptions_; }; } // namespace xense::taccap diff --git a/cpp/include/taccap/error.hpp b/cpp/include/taccap/error.hpp index da25065..015dc7d 100644 --- a/cpp/include/taccap/error.hpp +++ b/cpp/include/taccap/error.hpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace xense::taccap { @@ -28,6 +29,17 @@ class CrcError : public ProtocolError { using ProtocolError::ProtocolError; }; +// Preserve ProtocolError compatibility while allowing capability negotiation +// to fall back ONLY on an explicit unsupported-command response. +class NackError : public ProtocolError { +public: + NackError(const std::string& what, std::uint8_t code) + : ProtocolError(what), code_(code) {} + std::uint8_t code() const noexcept { return code_; } +private: + std::uint8_t code_; +}; + // Errno-backed I/O failure (open/read/write/tcsetattr). class IoError : public Error { public: diff --git a/cpp/include/taccap/protocol/commands.hpp b/cpp/include/taccap/protocol/commands.hpp index 2e78bb7..426a9b7 100644 --- a/cpp/include/taccap/protocol/commands.hpp +++ b/cpp/include/taccap/protocol/commands.hpp @@ -76,6 +76,8 @@ enum class Cmd : uint8_t { GetEskin2 = 0x13, GetAllSensors = 0x14, KeyStatus = 0x15, // V1.4 — device-side button, payload=key_status_payload_t + TimeSession = 0x16, // request nonce:u64; response time_us:u64, session:u64 + GetImuV2 = 0x17, // legacy 28B + capture_us:u64 + session:u64 // Stream control + calibration (0x20–0x2F) StartStream = 0x20, diff --git a/cpp/include/taccap/protocol/payloads.hpp b/cpp/include/taccap/protocol/payloads.hpp index c5c9ce6..88b9492 100644 --- a/cpp/include/taccap/protocol/payloads.hpp +++ b/cpp/include/taccap/protocol/payloads.hpp @@ -983,6 +983,7 @@ struct Ws2812Effect { namespace StreamSrc { constexpr uint16_t Imu = 0x0001; + constexpr uint16_t ImuV2 = 0x0020; constexpr uint16_t Encoder = 0x0002; constexpr uint16_t Eskin1 = 0x0004; constexpr uint16_t Eskin2 = 0x0008; diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index afaadba..6cea1a4 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -364,8 +364,8 @@ void Transport::handle_ack_(const Frame& f) { r.error_code = err; try { winning_promise.set_exception(std::make_exception_ptr( - ProtocolError(std::string("NACK: ") + - protocol::to_string(err)))); + NackError(std::string("NACK: ") + + protocol::to_string(err), static_cast(err)))); } catch (...) { /* promise already satisfied — ignore */ } return; } diff --git a/cpp/src/components/imu.cpp b/cpp/src/components/imu.cpp index 7044e97..5cc00d3 100644 --- a/cpp/src/components/imu.cpp +++ b/cpp/src/components/imu.cpp @@ -6,6 +6,8 @@ #include #include +#include +#include namespace xense::taccap { @@ -22,6 +24,12 @@ constexpr float kCdpsToRps = 0.01f * static_cast(M_PI) / 180.0f; constexpr float kCuTtoUT = 0.01f; constexpr float kCdegToDeg = 0.01f; +uint64_t read64(const uint8_t* p) { + uint64_t v = 0; + for (unsigned i = 0; i < 8; ++i) v |= uint64_t(p[i]) << (8 * i); + return v; +} + } // namespace IMU::IMU(bus::Transport& transport) : t_(transport) {} @@ -29,9 +37,16 @@ IMU::IMU(bus::Transport& transport) : t_(transport) {} ImuSample IMU::decode(const std::uint8_t* payload, std::size_t len) { ImuSample s{}; s.host_time = std::chrono::steady_clock::now(); - s.raw = protocol::decode_imu(payload, len); // throws on size mismatch + s.raw = protocol::decode_imu(payload, len == 44 ? 28 : len); s.mcu_timestamp_us = s.raw.timestamp_us; + if (len == 44) { + s.mcu_timestamp_us = read64(payload + 28); + s.boot_session_id = read64(payload + 36); + s.timestamp_is_64bit = true; + if (!s.boot_session_id || uint32_t(s.mcu_timestamp_us) != s.raw.timestamp_us) + throw ProtocolError("IMU V2: invalid session or inconsistent timestamp"); + } s.valid_flag = s.raw.valid_flag; s.seq = s.raw.seq; @@ -54,30 +69,109 @@ ImuSample IMU::decode(const std::uint8_t* payload, std::size_t len) { return s; } +ImuSample IMU::Timeline::decode(const uint8_t* p, std::size_t n, + std::chrono::steady_clock::time_point arrival) { + auto s = IMU::decode(p, n); + s.host_time = arrival; + if (seen_) { + const auto gap = std::chrono::duration_cast(s.host_time - host_).count(); + if (s.timestamp_is_64bit) { + s.timestamp_discontinuity = session_ != s.boot_session_id; + if (!s.timestamp_discontinuity && s.mcu_timestamp_us < last_time_) { + s.timestamp_valid = false; + s.timestamp_discontinuity = true; + return s; // stale frame: do not move the high-water mark + } + } else { + const uint32_t delta = s.raw.timestamp_us - raw_; + const uint16_t seq_delta = uint16_t(s.seq - seq_); + // Legacy cannot prove boot identity or reconstruct arbitrary gaps. + // Never turn a historical 17-second DWT reset into a 71-minute wrap. + bool ambiguous = session_ != 0 || gap < 0 || gap >= INT32_MAX || + delta >= INT32_MAX || seq_delta >= 0x8000U; + if (s.raw.timestamp_us < raw_) + ambiguous |= raw_ < 0xf0000000U || s.raw.timestamp_us > 0x0fffffffU || + seq_delta == 0 || uint64_t(delta) > uint64_t(gap) + 2000000ULL; + s.timestamp_valid = !ambiguous; + s.timestamp_discontinuity = ambiguous; + if (!ambiguous) s.mcu_timestamp_us = last_time_ + delta; + } + } + seen_ = true; + last_time_ = s.mcu_timestamp_us; + raw_ = s.raw.timestamp_us; + seq_ = s.seq; + session_ = s.boot_session_id; + host_ = s.host_time; + return s; +} + +uint16_t IMU::negotiate_time(std::chrono::milliseconds timeout) { + std::random_device random; + uint64_t nonce = (uint64_t(random()) << 32) ^ uint64_t(random()); + if (!nonce) nonce = 1; + std::vector request(8); + for (unsigned i = 0; i < 8; ++i) request[i] = uint8_t(nonce >> (8 * i)); + bus::AckResponse ack; + try { + ack = t_.send_cmd(protocol::Cmd::TimeSession, request, timeout); + } catch (const NackError& e) { + if (e.code() == uint8_t(protocol::ErrorCode::InvalidCmd)) return protocol::StreamSrc::Imu; + throw; + } + const auto error = bus::ack_error_code(ack); + if (error == protocol::ErrorCode::InvalidCmd) return protocol::StreamSrc::Imu; + if (error != protocol::ErrorCode::Ok || ack.data.size() != 16 || !read64(ack.data.data() + 8)) + throw ProtocolError("Invalid IMU time-session response"); + return protocol::StreamSrc::ImuV2; +} + ImuSample IMU::read_once(std::chrono::milliseconds timeout) { - auto ack = t_.send_cmd(protocol::Cmd::GetImu, {}, timeout); + std::lock_guard lock(read_mutex_); + const auto cmd = negotiate_time(timeout) == protocol::StreamSrc::ImuV2 + ? protocol::Cmd::GetImuV2 : protocol::Cmd::GetImu; + auto ack = t_.send_cmd(cmd, {}, timeout); if (ack.is_nack) { throw ProtocolError(std::string("IMU::read_once NACK: ") + protocol::to_string(ack.error_code)); } - return decode(ack.data.data(), ack.data.size()); + if (ack.data.size() != (cmd == protocol::Cmd::GetImuV2 ? 44U : 28U)) + throw ProtocolError("IMU response does not match negotiated format"); + return polling_time_.decode(ack.data.data(), ack.data.size()); } IMU::SubId IMU::on_data(Callback cb) { - return t_.subscribe( - protocol::Cmd::GetImu, - [cb = std::move(cb)](const bus::Frame& f) { + auto timeline = std::make_shared(); + auto callback = [cb = std::move(cb), timeline](const bus::Frame& f) { // Dispatcher thread context, not the reader -- Transport keeps // user code off the read path on purpose. decode() may throw on a - // malformed frame; swallow here (Transport already counts - // callback_exc). + // malformed frame; preserve the component's existing policy of + // dropping malformed frames and containing callback exceptions. try { - cb(decode(f.payload.data(), f.payload.size())); + if (f.payload.size() != (f.cmd == protocol::Cmd::GetImuV2 ? 44U : 28U)) + throw ProtocolError("IMU stream format mismatch"); + cb(timeline->decode(f.payload.data(), f.payload.size())); } catch (...) {} - }); + }; + std::lock_guard lock(subscriptions_mutex_); + auto legacy = t_.subscribe(protocol::Cmd::GetImu, callback); + try { + auto v2 = t_.subscribe(protocol::Cmd::GetImuV2, callback); + try { subscriptions_.emplace(legacy, v2); } + catch (...) { t_.unsubscribe(v2); throw; } + } catch (...) { t_.unsubscribe(legacy); throw; } + return legacy; } -void IMU::off(SubId id) { t_.unsubscribe(id); } +void IMU::off(SubId id) { + std::lock_guard lock(subscriptions_mutex_); + auto it = subscriptions_.find(id); + if (it != subscriptions_.end()) { + t_.unsubscribe(it->second); + subscriptions_.erase(it); + } + t_.unsubscribe(id); +} void IMU::calibrate_accel_gyro(std::chrono::milliseconds timeout) { for (uint8_t action : {0x01, 0x04}) { diff --git a/cpp/src/follower_gripper.cpp b/cpp/src/follower_gripper.cpp index f9fcccf..ef5661e 100644 --- a/cpp/src/follower_gripper.cpp +++ b/cpp/src/follower_gripper.cpp @@ -354,6 +354,8 @@ void FollowerGripper::start_streaming(unsigned motor_hz, unsigned imu_hz) { protocol::StreamConfig sc{}; // Legacy calls keep only MotorStatus; IMU is always explicit opt-in. sc.source_mask = detail::stream_source_mask(imu_hz, 0, motor_hz); + const auto logical_sources = sc.source_mask; + if (imu_hz) sc.source_mask = (sc.source_mask & ~protocol::StreamSrc::Imu) | imu_.negotiate_time(); sc.mode = static_cast(protocol::StreamMode::Separate); sc.imu_rate = static_cast(imu_hz); sc.encoder_rate = 0; @@ -369,7 +371,7 @@ void FollowerGripper::start_streaming(unsigned motor_hz, unsigned imu_hz) { protocol::to_string(ack.error_code)); } - streaming_sources_ = sc.source_mask; + streaming_sources_ = logical_sources; streaming_ = true; } diff --git a/cpp/src/leader_gripper.cpp b/cpp/src/leader_gripper.cpp index 528401d..4d80d60 100644 --- a/cpp/src/leader_gripper.cpp +++ b/cpp/src/leader_gripper.cpp @@ -184,6 +184,7 @@ void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz) { // Build StreamConfig (12 bytes — see protocol::StreamConfig). protocol::StreamConfig sc{}; sc.source_mask = mask; + if (imu_hz) sc.source_mask = (mask & ~protocol::StreamSrc::Imu) | imu_.negotiate_time(); sc.mode = static_cast(protocol::StreamMode::Separate); sc.imu_rate = static_cast(imu_hz); sc.encoder_rate = static_cast(encoder_hz); diff --git a/cpp/src/protocol/commands.cpp b/cpp/src/protocol/commands.cpp index e306dea..ceb3a65 100644 --- a/cpp/src/protocol/commands.cpp +++ b/cpp/src/protocol/commands.cpp @@ -36,6 +36,8 @@ const char* to_string(Cmd c) noexcept { case Cmd::Ws2812Effect: return "Ws2812Effect"; case Cmd::GetImu: return "GetImu"; + case Cmd::TimeSession: return "TimeSession"; + case Cmd::GetImuV2: return "GetImuV2"; case Cmd::GetEncoder: return "GetEncoder"; case Cmd::GetEskin1: return "GetEskin1"; case Cmd::GetEskin2: return "GetEskin2"; diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 4933ceb..0fe8234 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(taccap_unit_tests test_log.cpp test_stream_rate.cpp test_follower_imu_pty.cpp + test_imu_time.cpp ) # stream_rate.hpp is a private helper under src/ (not an installed header), diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index 44204c1..636fbef 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -89,6 +89,7 @@ class FakeFollower { unsigned submit_count() const { return submits_.load(); } unsigned disable_count() const { return disables_.load(); } void set_imu_supported(bool on) { imu_supported_.store(on); } + void set_imu_v2_supported(bool on) { imu_v2_supported_.store(on); imu_supported_.store(on); } unsigned imu_queries() const { return imu_queries_.load(); } unsigned stream_stops() const { return stream_stops_.load(); } tp::StreamConfig stream_config() const { @@ -173,6 +174,8 @@ class FakeFollower { imu.accel_z = 1000; pty_.send_data(0, tp::Cmd::GetImu, pod_bytes(&imu, sizeof(imu))); } + if (stream_config_.source_mask & tp::StreamSrc::ImuV2) + pty_.send_data(0,tp::Cmd::GetImuV2,imu_v2_packet_()); } } } @@ -334,6 +337,18 @@ class FakeFollower { pod_bytes(&status_, sizeof(status_))); return; } + case tp::Cmd::TimeSession: { + if(!imu_v2_supported_.load()) {pty_.send_nack(f.seq,tp::ErrorCode::InvalidCmd);return;} + if(f.payload.size()!=8) {pty_.send_nack(f.seq,tp::ErrorCode::LengthMismatch);return;} + uint64_t proposed=0; std::memcpy(&proposed,f.payload.data(),8); + if(!imu_session_) imu_session_=proposed; + uint64_t reply[2]={imu_time_,imu_session_}; + pty_.send_response(f.seq,f.cmd,pod_bytes(reply,sizeof(reply)));return; + } + case tp::Cmd::GetImuV2: { + if(!imu_v2_supported_.load()) {pty_.send_nack(f.seq,tp::ErrorCode::InvalidCmd);return;} + pty_.send_response(f.seq,f.cmd,imu_v2_packet_());return; + } case tp::Cmd::GetImu: { ++imu_queries_; if (!imu_supported_.load()) { @@ -384,6 +399,17 @@ class FakeFollower { std::atomic stop_{false}; std::atomic streaming_{false}; std::atomic imu_supported_{false}; + std::atomic imu_v2_supported_{false}; + uint64_t imu_session_{0}, imu_time_{UINT64_C(0x200000000)}; + uint16_t imu_seq_{0}; + std::vector imu_v2_packet_() { + imu_time_+=10000; + tp::ImuData raw{};raw.accel_z=1000;raw.timestamp_us=uint32_t(imu_time_);raw.seq=imu_seq_++; + auto p=pod_bytes(&raw,sizeof(raw)); + for(auto value:{imu_time_,imu_session_}) + for(unsigned i=0;i<8;i++)p.push_back(uint8_t(value>>(i*8))); + return p; + } std::atomic imu_queries_{0}, stream_stops_{0}; tp::StreamConfig stream_config_{}; std::atomic frozen_{false}; diff --git a/cpp/tests/test_imu_time.cpp b/cpp/tests/test_imu_time.cpp new file mode 100644 index 0000000..ca73a86 --- /dev/null +++ b/cpp/tests/test_imu_time.cpp @@ -0,0 +1,124 @@ +#include "fake_follower.hpp" +#include +#include +#include + +namespace tx = xense::taccap; +namespace tp = tx::protocol; +using namespace taccap_test; + +static void append64(std::vector& p, uint64_t v) { + for(unsigned i=0;i<8;i++) p.push_back(uint8_t(v>>(i*8))); +} +static std::vector packet(uint64_t time, uint16_t seq, uint64_t session=0) { + tp::ImuData raw{}; raw.timestamp_us=uint32_t(time); raw.seq=seq; raw.accel_z=1000; + auto p=pod_bytes(&raw,sizeof(raw)); + if(session) {append64(p,time);append64(p,session);} + return p; +} +static tx::ImuSample sample(tx::IMU::Timeline& timeline,uint64_t time,uint16_t seq,uint64_t session=0) { + auto p=packet(time,seq,session); return timeline.decode(p.data(),p.size()); +} +TEST(ImuTime, NativeAcrossMultipleWrapsAndNewBoot) { + tx::IMU::Timeline t; + EXPECT_EQ(sample(t,17179869,1,123).mcu_timestamp_us,17179869u); + auto a=sample(t,(uint64_t(5)<<32)+10,2,123); + EXPECT_EQ(a.mcu_timestamp_us,(uint64_t(5)<<32)+10); + EXPECT_TRUE(a.timestamp_valid); EXPECT_FALSE(a.timestamp_discontinuity); + auto b=sample(t,1000,0,456); + EXPECT_EQ(b.boot_session_id,456u); EXPECT_EQ(b.mcu_timestamp_us,1000u); + EXPECT_TRUE(b.timestamp_valid); EXPECT_TRUE(b.timestamp_discontinuity); + EXPECT_FALSE(sample(t,2000,1,456).timestamp_discontinuity); +} +TEST(ImuTime, NativeReorderedFrameCannotMoveHighWaterMark) { + tx::IMU::Timeline t; sample(t,20000,2,123); + EXPECT_FALSE(sample(t,10000,1,123).timestamp_valid); + EXPECT_FALSE(sample(t,15000,1,123).timestamp_valid); + EXPECT_TRUE(sample(t,30000,3,123).timestamp_valid); +} +TEST(ImuTime, LegacyWrapAndSequenceWrap) { + tx::IMU::Timeline t; sample(t,0xfffffff0U,65535); + auto s=sample(t,0x10,0); + EXPECT_EQ(s.mcu_timestamp_us,UINT64_C(0x100000010)); + EXPECT_TRUE(s.timestamp_valid); EXPECT_FALSE(s.timestamp_is_64bit); + EXPECT_EQ(s.boot_session_id,0u); +} +TEST(ImuTime, LegacyDwtRegressionIsNotInventedAsWrap) { + tx::IMU::Timeline t; sample(t,17173802,15421); + auto s=sample(t,3933,15422); + EXPECT_EQ(s.mcu_timestamp_us,3933u); + EXPECT_FALSE(s.timestamp_valid); EXPECT_TRUE(s.timestamp_discontinuity); +} +TEST(ImuTime, LegacyRestartAndOutOfOrderAreFlagged) { + tx::IMU::Timeline t; sample(t,500000,200); + EXPECT_FALSE(sample(t,100,0).timestamp_valid); + sample(t,300,2); EXPECT_FALSE(sample(t,200,1).timestamp_valid); +} +TEST(ImuTime, LegacyLongSilenceIsAmbiguousRatherThanInventedUptime) { + tx::IMU::Timeline t; + auto p=packet(100,1); auto start=std::chrono::steady_clock::now(); + t.decode(p.data(),p.size(),start); + p=packet(200,2); + auto s=t.decode(p.data(),p.size(),start+std::chrono::minutes(80)); + EXPECT_FALSE(s.timestamp_valid);EXPECT_TRUE(s.timestamp_discontinuity); +} +TEST(ImuTime, RejectsCorruptNativeTimestampAndSession) { + auto p=packet(UINT64_C(0x100000001),2,123); + p[0]=2; EXPECT_THROW(tx::IMU::decode(p.data(),p.size()),tx::ProtocolError); + p[0]=1; std::fill(p.begin()+36,p.end(),0); + EXPECT_THROW(tx::IMU::decode(p.data(),p.size()),tx::ProtocolError); + p.resize(43); EXPECT_THROW(tx::IMU::decode(p.data(),p.size()),tx::ProtocolError); +} +TEST(ImuTimePty, NegotiatesAndReadsNativeFrame) { + Pty pty; tx::bus::Transport transport(base_config(pty.slave_path())); tx::IMU imu(transport); + std::thread device([&] { + auto f=pty.expect_frame(); ASSERT_TRUE(f); EXPECT_EQ(f->cmd,tp::Cmd::TimeSession); + EXPECT_EQ(f->payload.size(),8u); + std::vector reply; append64(reply,UINT64_C(0x200000000));append64(reply,123); + pty.send_response(f->seq,f->cmd,reply); + f=pty.expect_frame(); ASSERT_TRUE(f); EXPECT_EQ(f->cmd,tp::Cmd::GetImuV2); + pty.send_response(f->seq,f->cmd,packet(UINT64_C(0x200000010),3,123)); + }); + auto s=imu.read_once(std::chrono::milliseconds{500}); + EXPECT_EQ(s.mcu_timestamp_us,UINT64_C(0x200000010)); EXPECT_EQ(s.boot_session_id,123u); + device.join(); +} +TEST(ImuTimePty, OnlyUnsupportedCommandPermitsFallback) { + for(auto error : {tp::ErrorCode::InvalidCmd,tp::ErrorCode::SysBusy}) { + Pty pty; tx::bus::Transport transport(base_config(pty.slave_path())); tx::IMU imu(transport); + std::thread device([&] {auto f=pty.expect_frame(); ASSERT_TRUE(f); pty.send_nack(f->seq,error);}); + if(error==tp::ErrorCode::InvalidCmd) EXPECT_EQ(imu.negotiate_time(),tp::StreamSrc::Imu); + else EXPECT_THROW(imu.negotiate_time(),tx::NackError); + device.join(); + } +} +TEST(ImuTimePty, TimeoutDoesNotSilentlyDowngrade) { + Pty pty; auto config=base_config(pty.slave_path()); config.max_retries=0; + tx::bus::Transport transport(config); tx::IMU imu(transport); + EXPECT_THROW(imu.negotiate_time(std::chrono::milliseconds{20}),tx::TimeoutError); +} +TEST(ImuTimePty, MalformedCapabilityResponseDoesNotDowngrade) { + Pty pty; tx::bus::Transport transport(base_config(pty.slave_path())); tx::IMU imu(transport); + std::thread device([&] {auto f=pty.expect_frame(); ASSERT_TRUE(f); + pty.send_response(f->seq,f->cmd,{0,0,0});}); + EXPECT_THROW(imu.negotiate_time(),tx::ProtocolError); + device.join(); +} +TEST(ImuTimePty, NativeStreamFansOutAndOffRemovesBothFormats) { + Pty pty; FakeFollower fw(pty); fw.set_imu_v2_supported(true); + auto g=open_follower(pty); + std::atomic a{0}, b{0}, motors{0}; + auto first=g->imu().on_data([&](const auto& s) { + if(s.timestamp_is_64bit && s.timestamp_valid && s.boot_session_id && + s.mcu_timestamp_us>UINT32_MAX)++a; + }); + auto second=g->imu().on_data([&](const auto& s) {if(s.timestamp_is_64bit)++b;}); + auto motor=g->motor().on_status([&](const auto&){++motors;}); + g->start_streaming(100,50); + EXPECT_EQ(fw.stream_config().source_mask,tp::StreamSrc::MotorStatus|tp::StreamSrc::ImuV2); + EXPECT_NO_THROW(g->start_streaming(100,50)); // logical source mask stays stable + for(unsigned i=0;i<100 && (!a||!b||!motors);i++)std::this_thread::sleep_for(std::chrono::milliseconds(5)); + EXPECT_GT(a.load(),0u);EXPECT_GT(b.load(),0u);EXPECT_GT(motors.load(),0u); + g->imu().off(first);g->imu().off(second);g->motor().off(motor); + g->close(); +} diff --git a/docs/FIRMWARE.md b/docs/FIRMWARE.md index f7167b5..50f5ad5 100644 --- a/docs/FIRMWARE.md +++ b/docs/FIRMWARE.md @@ -7,6 +7,44 @@ > `GetVersion` 查(`gripper.firmware_version`)——它返回的是编译进镜像的常量, > 是唯一能证明刷进去了什么的东西。 +## 本地候选:IMU 单调时间与协议兼容 + +尚未正式发布。新固件支持 `TimeSession (0x16)` 和 `GetImuV2 (0x17)`; +SDK 的 `read_once()` 和主/从爪显式 IMU streaming 自动协商: +明确 InvalidCmd 才使用旧 28 字节包;超时/忙/畸形响应报错,不静默降级。 +从爪默认仍只开电机状态流,主爪现有 `start_streaming` 参数不变。 +V2 使用 source_mask=0x0020,保持电机与编码器 source 位和速率不变。 + +新采样字段(C++ 与 Python): + +| 字段 | 含义 | +| --- | --- | +| `mcu_timestamp_us` | C++ uint64_t / Python int;V2 完整捕获时间,旧包为首次观测起的展开时间 | +| `boot_session_id` | V2 本次启动的非零会话号;旧包为 0,应与设备 SN 联合使用 | +| `timestamp_is_64bit` | 是否来自原生 V2,而不是 SDK 展开 | +| `timestamp_valid` | 本帧时间是否可用于当前观测段;异常旧包跳变或 V2 乱序为 false | +| `timestamp_discontinuity` | 会话改变或不确定跳变,消费者应重置积分/滤波/时间拟合 | + +会话号由 SDK 提议随机 64 位 nonce,固件每次启动只接受第一个,保存在 RAM; +重复协商不改变它,不写 Flash。设备重启后必须重新协商、重开流。 +会话号是概率唯一的诊断标识,不是认证凭据。V2 可跨任意多次 32 位回绕和断流恢复, +但重启会重置时间,应按会话分段,不能把新启动拼成旧启动的连续轨迹。 + +旧包只在短间隔、序列正常的连续观测下展开自然回绕。旧 DWT 固件的约 17 秒回退、 +重启样跳变和超过约 35.8 分钟的观测空档标记 discontinuity/invalid,不制造假时间。 +重新建立观测段不保证恢复绝对开机时间;旧包无法排除所有重启歧义。 +`IMU::decode` 是无状态解码;`read_once` 与每个 `on_data` 订阅分别维护时间线。 + +**集成限制:**这是 C++ `ImuSample` 布局变更,所有链接消费者必须重编译,不能仅替换共享库。 +Python 扩展也须重建并核实实际加载路径。ROS 的时间字段须为 uint64,并增加会话/有效性字段, +否则旧 uint32 消息仍会截断;本任务不修改独立 ROS 仓库或其依赖副本。 +ROS header 时间仍是另外的时钟域,以上修改不代表相机/IMU 同步通过验收。 +固件/SDK 本地构建成功也不等于已刷设备或安装到业务环境。 + +软件检查:`ctest --test-dir --output-on-failure`、`python -m pytest python/tests -q`。 +真机还需超过 75 分钟连续采集与冷重启验收;默认 motor-only、旧 SDK、新旧协议互通、 +IMU 同时上报时的控制时延和 OTA 都需验证。IMU 数值精度另行验收。 + ## Firmware / PC GUI reference repos The wire protocol this SDK speaks is defined by the firmware that runs diff --git a/python/bindings/bind_sensors.cpp b/python/bindings/bind_sensors.cpp index d14d9f4..bb85f08 100644 --- a/python/bindings/bind_sensors.cpp +++ b/python/bindings/bind_sensors.cpp @@ -25,7 +25,15 @@ void bind_sensors(py::module_& m) { "comparable with time.monotonic(). The MCU timestamp counts from the\n" "MCU's own boot instead.") .def_readonly("mcu_timestamp_us", &ImuSample::mcu_timestamp_us, - "MCU-side capture time, microseconds since the MCU booted.") + "64-bit capture time in microseconds. Native V2 boot epoch, or legacy observed epoch.") + .def_readonly("boot_session_id", &ImuSample::boot_session_id, + "Nonzero V2 boot identity; zero on legacy firmware. Changes after a reboot.") + .def_readonly("timestamp_is_64bit", &ImuSample::timestamp_is_64bit, + "True for native V2 timestamps, false for unfolded legacy timestamps.") + .def_readonly("timestamp_valid", &ImuSample::timestamp_valid, + "False for ambiguous legacy jumps or reordered V2 timestamps.") + .def_readonly("timestamp_discontinuity", &ImuSample::timestamp_discontinuity, + "Reset time-dependent estimators when true; this is not a clock synchronization result.") .def_readonly("valid_flag", &ImuSample::valid_flag, "Bitmask of the groups this frame carries: 0x01 accel, 0x02 gyro,\n" "0x04 mag, 0x08 temperature.") @@ -124,7 +132,8 @@ void bind_sensors(py::module_& m) { return self.read_once(std::chrono::milliseconds(timeout_ms)); }, py::arg("timeout_ms") = 100, "Poll one sample and wait for the answer. Works without start_streaming().\n\n" - "Blocks up to timeout_ms with the GIL released. Raises ProtocolError when\n" + "Negotiates native 64-bit time, then reads the sample; timeout_ms applies\n" + "to each command (plus transport retries), with the GIL released. Raises ProtocolError when\n" "the firmware NACKs or answers with the wrong payload size, TimeoutError\n" "when it does not answer at all.") .def("on_data", [](IMU& self, py::function pycb) { diff --git a/python/bindings/module.cpp b/python/bindings/module.cpp index dffaca4..68f54ad 100644 --- a/python/bindings/module.cpp +++ b/python/bindings/module.cpp @@ -103,6 +103,8 @@ PYBIND11_MODULE(_taccap_native, m) { .value("Ws2812Set", tp::Cmd::Ws2812Set) .value("Ws2812Effect", tp::Cmd::Ws2812Effect) .value("GetImu", tp::Cmd::GetImu) + .value("TimeSession", tp::Cmd::TimeSession) + .value("GetImuV2", tp::Cmd::GetImuV2) .value("GetEncoder", tp::Cmd::GetEncoder) .value("GetEskin1", tp::Cmd::GetEskin1) .value("GetEskin2", tp::Cmd::GetEskin2) diff --git a/python/tests/test_imu_time.py b/python/tests/test_imu_time.py new file mode 100644 index 0000000..d5caba5 --- /dev/null +++ b/python/tests/test_imu_time.py @@ -0,0 +1,61 @@ +"""Native V2 timestamp width and boot identity survive the Python boundary.""" +import os +import pty +import select +import struct +import threading + +import xense.taccap as t + + +def test_native_64bit_time_and_boot_session_over_pty(): + master, slave = pty.openpty() + stopped = threading.Event() + errors = [] + capture = (5 << 32) + 1234 + + def device(): + parser = t.FrameParser() + try: + while not stopped.is_set(): + if not select.select([master], [], [], .02)[0]: + continue + parser.feed(os.read(master, 4096)) + while (frame := parser.pop()) is not None: + payload = b"\x00" + if frame.cmd == t.Cmd.GetVersion: + payload = bytes([1, 2, 6, 0]) + elif frame.cmd == t.Cmd.GetSn: + payload = b"TCGU01A25Z0016m\0" + elif frame.cmd == t.Cmd.TimeSession: + assert len(frame.payload) == 8 + payload = struct.pack(" Date: Fri, 23 Oct 2026 17:58:02 +0800 Subject: [PATCH 03/18] =?UTF-8?q?fix(imu):=20=E9=94=81=E5=AD=98=E6=97=A7?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E6=97=B6=E9=97=B4=E5=BC=82=E5=B8=B8=E5=B9=B6?= =?UTF-8?q?=E8=BF=87=E6=BB=A4=E9=87=8D=E5=A4=8D=E6=A0=B7=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合主从爪统一时间分支:旧时间回退不因重复帧恢复有效;订阅过滤连续重复载荷,保留 V2 会话恢复与旧协议回退,补充恢复边界文档。 验证:491 项 C++/PTY 测试通过;152 项 Python 通过、2 跳过;固件协议镜像核对通过。未安装业务环境、未进行真机或 ROS 验收。 --- cpp/include/taccap/components/imu.hpp | 3 ++ cpp/src/components/imu.cpp | 19 ++++++++--- cpp/tests/test_imu_time.cpp | 48 +++++++++++++++++++++++++++ docs/FIRMWARE.md | 19 ++++++++--- 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/cpp/include/taccap/components/imu.hpp b/cpp/include/taccap/components/imu.hpp index 77ddf9b..bfa5f1b 100644 --- a/cpp/include/taccap/components/imu.hpp +++ b/cpp/include/taccap/components/imu.hpp @@ -68,6 +68,9 @@ class IMU { std::chrono::steady_clock::time_point arrival = std::chrono::steady_clock::now()); private: bool seen_{}; + // Legacy cannot establish a new boot epoch after an ambiguous jump. + // Keep it invalid until a new tracker (subscription/instance) or V2. + bool legacy_faulted_{}; uint64_t last_time_{}, session_{}; uint32_t raw_{}; uint16_t seq_{}; diff --git a/cpp/src/components/imu.cpp b/cpp/src/components/imu.cpp index 5cc00d3..2d8145d 100644 --- a/cpp/src/components/imu.cpp +++ b/cpp/src/components/imu.cpp @@ -82,18 +82,20 @@ ImuSample IMU::Timeline::decode(const uint8_t* p, std::size_t n, s.timestamp_discontinuity = true; return s; // stale frame: do not move the high-water mark } + legacy_faulted_ = false; } else { const uint32_t delta = s.raw.timestamp_us - raw_; const uint16_t seq_delta = uint16_t(s.seq - seq_); // Legacy cannot prove boot identity or reconstruct arbitrary gaps. // Never turn a historical 17-second DWT reset into a 71-minute wrap. - bool ambiguous = session_ != 0 || gap < 0 || gap >= INT32_MAX || + bool ambiguous = legacy_faulted_ || session_ != 0 || gap < 0 || gap >= INT32_MAX || delta >= INT32_MAX || seq_delta >= 0x8000U; if (s.raw.timestamp_us < raw_) ambiguous |= raw_ < 0xf0000000U || s.raw.timestamp_us > 0x0fffffffU || seq_delta == 0 || uint64_t(delta) > uint64_t(gap) + 2000000ULL; s.timestamp_valid = !ambiguous; s.timestamp_discontinuity = ambiguous; + legacy_faulted_ = ambiguous; if (!ambiguous) s.mcu_timestamp_us = last_time_ + delta; } } @@ -141,8 +143,12 @@ ImuSample IMU::read_once(std::chrono::milliseconds timeout) { } IMU::SubId IMU::on_data(Callback cb) { - auto timeline = std::make_shared(); - auto callback = [cb = std::move(cb), timeline](const bus::Frame& f) { + struct SubscriptionState { + Timeline timeline; + std::vector last_payload; + }; + auto state = std::make_shared(); + auto callback = [cb = std::move(cb), state](const bus::Frame& f) { // Dispatcher thread context, not the reader -- Transport keeps // user code off the read path on purpose. decode() may throw on a // malformed frame; preserve the component's existing policy of @@ -150,7 +156,12 @@ IMU::SubId IMU::on_data(Callback cb) { try { if (f.payload.size() != (f.cmd == protocol::Cmd::GetImuV2 ? 44U : 28U)) throw ProtocolError("IMU stream format mismatch"); - cb(timeline->decode(f.payload.data(), f.payload.size())); + // Legacy devices may send the identical cache at 1000 Hz + // although acquisition is 100 Hz. Do not integrate it twice. + if (f.payload == state->last_payload) return; + auto sample = state->timeline.decode(f.payload.data(), f.payload.size()); + state->last_payload = f.payload; + cb(sample); } catch (...) {} }; std::lock_guard lock(subscriptions_mutex_); diff --git a/cpp/tests/test_imu_time.cpp b/cpp/tests/test_imu_time.cpp index ca73a86..22db814 100644 --- a/cpp/tests/test_imu_time.cpp +++ b/cpp/tests/test_imu_time.cpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace tx = xense::taccap; namespace tp = tx::protocol; @@ -54,6 +55,23 @@ TEST(ImuTime, LegacyRestartAndOutOfOrderAreFlagged) { EXPECT_FALSE(sample(t,100,0).timestamp_valid); sample(t,300,2); EXPECT_FALSE(sample(t,200,1).timestamp_valid); } +TEST(ImuTime, LegacyRegressionRemainsInvalidAcrossDuplicatesAndForwardSamples) { + tx::IMU::Timeline t; + EXPECT_TRUE(sample(t,17173017,5113).timestamp_valid); + for (int i=0;i<10;++i) { + auto s=sample(t,3148,5114); + EXPECT_FALSE(s.timestamp_valid); + EXPECT_TRUE(s.timestamp_discontinuity); + } + EXPECT_FALSE(sample(t,13148,5115).timestamp_valid); + // Explicit new tracker establishes a new, unrelated legacy segment. + tx::IMU::Timeline restarted; + EXPECT_TRUE(sample(restarted,13148,5115).timestamp_valid); + // Native time carries a boot identity, so it can safely replace legacy. + auto native=sample(t,23148,5116,123); + EXPECT_TRUE(native.timestamp_valid); + EXPECT_TRUE(native.timestamp_discontinuity); +} TEST(ImuTime, LegacyLongSilenceIsAmbiguousRatherThanInventedUptime) { tx::IMU::Timeline t; auto p=packet(100,1); auto start=std::chrono::steady_clock::now(); @@ -122,3 +140,33 @@ TEST(ImuTimePty, NativeStreamFansOutAndOffRemovesBothFormats) { g->imu().off(first);g->imu().off(second);g->motor().off(motor); g->close(); } + +TEST(ImuTimePty, DuplicateLegacyFramesDoNotRestoreValidityOrRepeatCallbacks) { + Pty pty; + tx::bus::Transport transport(base_config(pty.slave_path())); + tx::IMU imu(transport); + std::mutex mutex; + std::condition_variable ready; + std::vector received; + auto id=imu.on_data([&](const auto& s) { + std::lock_guard lock(mutex); + received.push_back(s); + ready.notify_all(); + }); + pty.send_data(0,tp::Cmd::GetImu,packet(17173017,5113)); + for (int i=0;i<10;++i) + pty.send_data(0,tp::Cmd::GetImu,packet(3148,5114)); + pty.send_data(0,tp::Cmd::GetImu,packet(13148,5115)); + { + std::unique_lock lock(mutex); + EXPECT_TRUE(ready.wait_for(lock,std::chrono::seconds(2),[&] { + return !received.empty() && received.back().seq==5115; + })); + } + imu.off(id); + std::lock_guard lock(mutex); + ASSERT_EQ(received.size(),3u); + EXPECT_TRUE(received[0].timestamp_valid); + EXPECT_FALSE(received[1].timestamp_valid); + EXPECT_FALSE(received[2].timestamp_valid); +} diff --git a/docs/FIRMWARE.md b/docs/FIRMWARE.md index 50f5ad5..d7d22ac 100644 --- a/docs/FIRMWARE.md +++ b/docs/FIRMWARE.md @@ -9,6 +9,10 @@ ## 本地候选:IMU 单调时间与协议兼容 +固件与 SDK 当前本地分支均为 `codex/gripper-imu-support`,由原从爪分支延续。 +主爪也启用 TIM2 单调时间和 V2,并只发送新缓存样本;请求 1000 Hz 不代表 +1000 Hz 独立采样。主爪原校准和从爪电机控制算法不在本轮修改范围。 + 尚未正式发布。新固件支持 `TimeSession (0x16)` 和 `GetImuV2 (0x17)`; SDK 的 `read_once()` 和主/从爪显式 IMU streaming 自动协商: 明确 InvalidCmd 才使用旧 28 字节包;超时/忙/畸形响应报错,不静默降级。 @@ -32,9 +36,17 @@ V2 使用 source_mask=0x0020,保持电机与编码器 source 位和速率不 旧包只在短间隔、序列正常的连续观测下展开自然回绕。旧 DWT 固件的约 17 秒回退、 重启样跳变和超过约 35.8 分钟的观测空档标记 discontinuity/invalid,不制造假时间。 -重新建立观测段不保证恢复绝对开机时间;旧包无法排除所有重启歧义。 +检测到上述旧包异常后,该时间线保持 invalid/discontinuity,不因下一帧重复或正常前进 +自动恢复。流可 `off(id)` 后重新 `on_data(...)` 建立新观测段;轮询需新建 IMU 实例。 +有效原生 V2 可恢复可信时间。重新建立观测段不保证恢复绝对开机时间,不能将两段直接积分; +旧 DWT 固件仍会再次回退,根治需要升级固件。旧包无法排除所有重启歧义。 `IMU::decode` 是无状态解码;`read_once` 与每个 `on_data` 订阅分别维护时间线。 +`on_data` 现在过滤连续字节完全相同的 IMU DATA 载荷,减少缓存重复引起的重复积分; +原始传输订阅与 `read_once` 不过滤。多个订阅者各自保有去重及时间线状态。 +回调频率因此可能由约 1000 Hz 降至约 100 Hz;不要把回调次数当成串口接收帧数。 +使用者必须检查 `timestamp_valid`,无效样本不能用于积分;这不是坐标系或精度修正。 + **集成限制:**这是 C++ `ImuSample` 布局变更,所有链接消费者必须重编译,不能仅替换共享库。 Python 扩展也须重建并核实实际加载路径。ROS 的时间字段须为 uint64,并增加会话/有效性字段, 否则旧 uint32 消息仍会截断;本任务不修改独立 ROS 仓库或其依赖副本。 @@ -76,9 +88,8 @@ What's in them: the human-readable spec, including the §10 offset table for the 72-byte extended motor status that `test_codec_v22.cpp` is transcribed from. - `tc-gu-01/App/tasks/task_data_stream.c` + `task_imu.c` + - `task_encoder.c` — explains why IMU/encoder unique-data rate caps at - ~60 Hz even when you request 100 (see the SDK's stream-dup note in - the Claude memory). + `task_encoder.c` — acquisition, cached samples and stream scheduling are + separate; requested rate is not evidence of independent sample rate. - `tc-gu-01-pc/core/protocol.py` + `core/serial_worker.py` — Python reference implementation of the same wire protocol; useful as a cross-check when debugging the C++ codec. From 504134a6cb6819a8f85a1511e450e6ce02190644 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 26 Oct 2026 16:12:36 +0800 Subject: [PATCH 04/18] =?UTF-8?q?test(imu):=20=E8=A1=A5=E5=85=85=E4=BB=8E?= =?UTF-8?q?=E7=88=AA=E5=85=B1=E6=B5=81=E5=B9=B6=E5=8F=91=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E4=B8=8E=E7=A1=AC=E4=BB=B6=E9=AA=8C=E6=94=B6=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为评估从爪开启 IMU 上报对电机控制的影响,新增 5 项 PTY 回归, 覆盖两类控制器借用共流、阻塞回调时的超时零输出及退出资源释放。 增加硬件完整性与稳定性测试矩阵,明确设备身份、运动范围、A/B/A、 时间与帧率判据,以及分发积压下状态新鲜度判断的限制。 验证:C++ 全套 496 通过;Python 152 通过、2 项主爪硬件用例跳过; 新增用例重复 20 轮通过;适用 pre-commit 检查通过。 已完成 EL05 失能上报 15 段、450 秒采集,通信错误计数未增加, 首次 IMU 数组回调出现约 123 ms 延迟,保留记录并另行排查。 独立 PTY 诊断复现持续慢回调引起的控制降频和旧反馈新鲜度失真, 本提交仅增加测试和方案,不修改产品实现,不代表稳定性验收通过。 --- cpp/tests/CMakeLists.txt | 1 + cpp/tests/test_imu_motor_concurrency.cpp | 271 +++++++++++++++++++++++ docs/IMU_MOTOR_TESTING.md | 247 +++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 cpp/tests/test_imu_motor_concurrency.cpp create mode 100644 docs/IMU_MOTOR_TESTING.md diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 0fe8234..53f1abc 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -37,6 +37,7 @@ add_executable(taccap_unit_tests test_log.cpp test_stream_rate.cpp test_follower_imu_pty.cpp + test_imu_motor_concurrency.cpp test_imu_time.cpp ) diff --git a/cpp/tests/test_imu_motor_concurrency.cpp b/cpp/tests/test_imu_motor_concurrency.cpp new file mode 100644 index 0000000..e5f7d7a --- /dev/null +++ b/cpp/tests/test_imu_motor_concurrency.cpp @@ -0,0 +1,271 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// IMU and motor DATA share a dispatcher, while ACK matching and controller +// safety timeouts run independently. Exercise that boundary through a PTY: +// the fake models packet flow, not firmware scheduling or physical motion. + +#include "fake_follower.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace tx = xense::taccap; +namespace tp = tx::protocol; +using taccap_test::FakeFollower; +using taccap_test::Pty; +using taccap_test::open_follower; +using taccap_test::wait_for; + +namespace { +using Ms = std::chrono::milliseconds; + +template struct ControllerTraits; +template <> struct ControllerTraits { + using Config = tx::ForcePositionConfig; + static constexpr auto fault = tx::ForcePositionState::Fault; +}; +template <> struct ControllerTraits { + using Config = tx::ImpedanceConfig; + static constexpr auto fault = tx::ImpedanceState::Fault; +}; + +// A deterministic slow subscriber: hold one callback until the test has +// observed the independent safety/close path, without relying on a particular +// scheduler delay. The release guard also unblocks early ASSERT returns. +class ImuCallbackGate { +public: + void operator()() { + ++calls; + std::unique_lock lock(mu_); + if (!armed_ || released_) return; + entered_ = true; + cv_.notify_all(); + cv_.wait(lock, [&] { return released_; }); + completed.store(true); + } + + void arm() { + std::lock_guard lock(mu_); + armed_ = true; + } + bool wait_until_entered() { + std::unique_lock lock(mu_); + return cv_.wait_for(lock, Ms(2000), [&] { return entered_; }); + } + void release() { + { + std::lock_guard lock(mu_); + released_ = true; + } + cv_.notify_all(); + } + + std::atomic calls{0}; + std::atomic completed{false}; + +private: + std::mutex mu_; + std::condition_variable cv_; + bool armed_ = false; + bool entered_ = false; + bool released_ = false; +}; + +struct ReleaseCallbackOnExit { + std::shared_ptr gate; + ~ReleaseCallbackOnExit() { gate->release(); } +}; + +bool is_zero_torque(const std::optional& command) { + return command && command->kp == 0.0f && command->kd == 0.0f && + command->target_torque == 0.0f && command->vel == 0.0f; +} + +template +void controller_keeps_preexisting_imu_stream() { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); // advancing timestamps and sample IDs + auto imus = std::make_shared>(0); + auto g = open_follower(pty); + const auto imu_sub = g->imu().on_data([imus](const tx::ImuSample&) { + ++*imus; + }); + g->start_streaming(100, 100); + const auto initial_stops = fw.stream_stops(); + + Controller controller(*g); + controller.start(); + ASSERT_TRUE(wait_for([&] { + return fw.submit_count() >= 8 && imus->load() >= 8; + }, Ms(2000))); + + // Repeated target updates must keep producing commands and IMU samples. + // Hold the fake's observed position: it deliberately has no motor model. + for (unsigned phase = 0; phase < 3; ++phase) { + const auto submits_before = fw.submit_count(); + const auto imus_before = imus->load(); + controller.set_target(controller.snapshot().observation.position); + ASSERT_TRUE(wait_for([&] { + return fw.submit_count() >= submits_before + 8 && + imus->load() >= imus_before + 8; + }, Ms(2000))); + EXPECT_TRUE(controller.snapshot().observation.valid); + EXPECT_NE(controller.state(), ControllerTraits::fault); + } + + const auto stream = fw.stream_config(); + EXPECT_EQ(stream.source_mask, + tp::StreamSrc::MotorStatus | tp::StreamSrc::ImuV2); + EXPECT_EQ(stream.motor_rate, 100u); + EXPECT_EQ(stream.imu_rate, 100u); + EXPECT_EQ(stream.mode, static_cast(tp::StreamMode::Separate)); + EXPECT_EQ(fw.stream_stops(), initial_stops); + + controller.stop(); + EXPECT_FALSE(controller.running()); + EXPECT_TRUE(g->is_streaming()); + EXPECT_EQ(fw.stream_stops(), initial_stops) + << "stopping a borrower must preserve the caller's shared stream"; + EXPECT_EQ(fw.disable_count(), 1u); + EXPECT_TRUE(is_zero_torque(fw.last_submit())); + + const auto stopped_submits = fw.submit_count(); + const auto imus_before = imus->load(); + ASSERT_TRUE(wait_for([&] { return imus->load() >= imus_before + 8; }, + Ms(2000))); + EXPECT_EQ(fw.submit_count(), stopped_submits); + const auto stats = g->transport().stats(); + EXPECT_EQ(stats.ack_timeouts, 0u); + EXPECT_EQ(stats.crc_errors, 0u); + EXPECT_EQ(stats.callback_exceptions, 0u); + EXPECT_EQ(stats.queue_dropped, 0u); + g->imu().off(imu_sub); + g->close(); +} + +template +void slow_imu_callback_triggers_safe_timeout() { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto gate = std::make_shared(); + auto g = open_follower(pty); + const auto imu_sub = g->imu().on_data([gate](const tx::ImuSample&) { + (*gate)(); + }); + g->start_streaming(100, 100); + typename ControllerTraits::Config config; + config.status_timeout_ms = 120; + Controller controller(*g, config); + ReleaseCallbackOnExit release_on_exit{gate}; + controller.start(); + ASSERT_TRUE(wait_for([&] { + return fw.submit_count() >= 4 && gate->calls.load() >= 4; + }, Ms(2000))); + ASSERT_TRUE(controller.snapshot().observation.valid); + + gate->arm(); + ASSERT_TRUE(gate->wait_until_entered()); + const auto submits_before = fw.submit_count(); + const auto frames_before = g->transport().stats().frames_received; + + // A user callback stalls motor delivery, even though the firmware and + // reader still run. ACK matching must remain usable during the stall. + EXPECT_NO_THROW((void)g->device().heartbeat(Ms(500))); + ASSERT_TRUE(wait_for([&] { + return controller.state() == ControllerTraits::fault && + fw.submit_count() > submits_before && + is_zero_torque(fw.last_submit()); + }, Ms(2000))); + const auto snapshot = controller.snapshot(); + EXPECT_FALSE(snapshot.observation.valid); + EXPECT_EQ(snapshot.fault_reason, "motor status stream stale"); + EXPECT_FALSE(gate->completed.load()); + EXPECT_GT(g->transport().stats().frames_received, frames_before); + EXPECT_EQ(g->transport().stats().ack_timeouts, 0u); + + // Queued frames can resume after the callback returns; the fault must + // remain latched rather than silently resuming an old motion command. + const auto imus_before = gate->calls.load(); + gate->release(); + ASSERT_TRUE(wait_for([&] { + return gate->completed.load() && gate->calls.load() >= imus_before + 8; + }, Ms(2000))); + EXPECT_EQ(controller.state(), ControllerTraits::fault); + EXPECT_TRUE(is_zero_torque(fw.last_submit())); + controller.stop(); + EXPECT_EQ(fw.disable_count(), 1u); + g->imu().off(imu_sub); + g->close(); +} +} // namespace + +TEST(ImuMotorConcurrency, ForcePositionKeepsPreexistingCombinedStream) { + controller_keeps_preexisting_imu_stream(); +} + +TEST(ImuMotorConcurrency, ImpedanceKeepsPreexistingCombinedStream) { + controller_keeps_preexisting_imu_stream(); +} + +TEST(ImuMotorConcurrency, ForcePositionSlowImuCallbackFaultsWithZeroTorque) { + slow_imu_callback_triggers_safe_timeout(); +} + +TEST(ImuMotorConcurrency, ImpedanceSlowImuCallbackFaultsWithZeroTorque) { + slow_imu_callback_triggers_safe_timeout(); +} + +TEST(ImuMotorConcurrency, CloseJoinsActiveImuCallbackAndReleasesCaptures) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto gate = std::make_shared(); + auto capture = std::make_shared(42); + std::weak_ptr weak_capture = capture; + auto g = open_follower(pty); + ReleaseCallbackOnExit release_on_exit{gate}; + g->imu().on_data([gate, capture](const tx::ImuSample&) { (*gate)(); }); + capture.reset(); // only the registered callback now owns the resource + gate->arm(); + g->start_streaming(100, 100); + ASSERT_TRUE(gate->wait_until_entered()); + const auto frames_before = g->transport().stats().frames_received; + ASSERT_TRUE(wait_for([&] { + return g->transport().stats().frames_received >= frames_before + 8; + }, Ms(2000))); + + std::atomic close_returned{false}; + std::thread closer([&] { + g->close(); + close_returned.store(true); + }); + // Do not ASSERT while closer is joinable: release and join even when a + // check fails, so the test itself cannot strand the dispatcher. + EXPECT_TRUE(wait_for([&] { return !g->transport().is_running(); }, Ms(2000))); + EXPECT_FALSE(close_returned.load()); + EXPECT_FALSE(gate->completed.load()); + EXPECT_FALSE(weak_capture.expired()); + const auto calls_before_release = gate->calls.load(); + gate->release(); + closer.join(); + + EXPECT_TRUE(close_returned.load()); + EXPECT_TRUE(gate->completed.load()); + EXPECT_TRUE(g->is_closed()); + EXPECT_TRUE(weak_capture.expired()); + EXPECT_EQ(gate->calls.load(), calls_before_release) + << "close must discard queued DATA instead of entering more callbacks"; + EXPECT_GT(g->transport().stats().queue_dropped, 0u); +} diff --git a/docs/IMU_MOTOR_TESTING.md b/docs/IMU_MOTOR_TESTING.md new file mode 100644 index 0000000..b88b009 --- /dev/null +++ b/docs/IMU_MOTOR_TESTING.md @@ -0,0 +1,247 @@ +# 从爪 IMU 与电机控制完整性、稳定性测试 + +本文是可重复执行的测试方案,不是通过报告。软件测试、当前机器上的 SDK 加载状态、设备上实际运行的固件,以及机械运动验收必须分别记录结果。未执行的项目标为 `NOT RUN`,设备或条件不具备时标为 `BLOCKED`。 + +方案针对支持 `FollowerGripper.start_streaming(motor_hz=100, imu_hz=...)` 的 SDK,以及配套固件的从爪 IMU 功能。编写时核对的基线为 SDK `c06bc342ba0c534f0e830e5ef8574e4fc0ba5d1f`、固件仓库 `XenseRobotics-AI/tc-gu-01` 的 `codex/gripper-imu-support@1d43fcaa423aca515ca4ef666e6e605fc5d4a5`;执行时记录实际 SHA 和未提交修改,不把这两个历史核对值当作“最新”。 + +## 1. 能确认什么,仍须测量什么 + +当前设计默认只上报电机状态;显式设置 `imu_hz=1..100` 才请求 IMU。SDK 先读 IMU 探测能力,再协商时间格式和修改流;旧固件拒绝时不会先改写流。实现见 [follower_gripper.cpp](../cpp/src/follower_gripper.cpp)、[imu.cpp](../cpp/src/components/imu.cpp)。 + +配套固件 `App/tasks/task_imu.c`、`task_slave_motor_control.c`、`task_data_stream.c`、`slave_imu.c` 中,电机控制、数据流、从爪 IMU 的线程优先级分别为 8、11、12;电机控制周期为 2 ms,电机状态最高 100 Hz;同时到期时先发电机状态。IMU 采样失败不将旧包伪装成新样本,也不主动停止电机控制。这些设计降低了干扰,但共享 CPU、UART 和主机处理仍可能产生时延。 + +SDK 的 motor 与 IMU 回调共享 Transport 分发线程。慢 IMU 回调会拖延电机状态回调和控制器目标更新,即使串口读取线程仍在接收。当前控制器按分发时刻刷新状态年龄,积压的旧反馈仍可能使 `snapshot().observation.age_ms` 很小、`valid` 为真;这些字段与 TRACKING 状态不能单独用于实时性验收。必须同时测量设备反馈时间、主机接收/分发积压和目标更新节奏。软件替身测试不能证明真实 RTOS 抢占、SPI/CAN/UART 时序、电源稳定或夹爪运动质量。 + +## 2. 设备、环境与运行身份 + +每次运行记录以下内容,EL05 与 RS00、左右爪分别建档,不用一种设备结果替代另一种: + +| 类别 | 必须记录 | +| --- | --- | +| SDK | 仓库分支、完整 SHA、dirty 状态、构建配置、Python 可执行文件、Python 包与 `_taccap_native` 实际加载路径、版本 | +| 固件 | MCU 返回版本、固件构建 SHA、烧录 BIN 的 SHA-256、烧录记录;电机型号及其固件版本来源 | +| 设备 | 精确 firmware SN、角色和左右侧、CH343 序列号、MCU 的 by-id 路径、硬件版本 | +| 台架 | 电源电压/限流、线缆与 USB 拓扑、夹持物/治具、环境温度、传感器安装方向、外部急停与独立测量仪器 | +| 控制 | 控制器类型、完整配置、行程标定、安全包络、实际轨迹、循环次数、驻留时间、负载和温度停止条件 | + +配套试验固件曾沿用从爪 `1.2.16`,因此相同版本号不代表相同程序,也不能证明 IMU 或时间扩展已刷入。能力探测只能证明被探测接口可用;没有烧录证据时将固件 SHA 记为 `unknown`,不能用本机源码 SHA 填充。 + +运行前核对独立构建/测试环境。构建源码不等于当前 Python 已加载它;尤其要检查原生扩展路径,避免旧 `.so` 与新 Python 代码混用。构建与安装操作按 [INSTALL.md](INSTALL.md) 执行;本文采集片段不自动安装或刷机。 + +### 串口与发现副作用 + +`scan_grippers()` 不是被动 USB 枚举:它只选择 `/dev/serial/by-id/usb-1a86_USB_Dual_Serial_*-if02`,以 3,000,000 baud 打开控制口,发送 `StopStream`、`GetDevType`、`GetSn` 后关闭。不要根据 `/dev/ttyACM0`、`ttyACM1` 编号猜测 MCU 接口,也不要试开另一接口。实现见 [discovery.cpp](../cpp/src/discovery.cpp)。 + +SDK 使用 `flock` 与 `TIOCEXCL` 独占串口,`in_use` 必须视为占用并退出,不能抢占、杀死现有控制进程或绕过锁。发现和构造 `FollowerGripper` 都可能停止遗留流,应在设备原控制任务已正常结束、机械状态安全后进行;独占锁不能代替现场确认。 + +`FollowerGripper` 默认构造会停流、读取版本/SN/电机型号/行程配置,部分设备还读取 CAN ID;默认不开相机,不使能电机、不发运动目标、不写 Flash。它不凭构造参数证明设备角色,仍须根据发现结果确认唯一 SN 的 `Role.Follower`。上电自动回零属于固件行为,不能因采集脚本没有运动命令就忽略正在进行的回零。 + +## 3. 分阶段测试矩阵 + +各阶段先记录预期、门槛和停止条件,再执行;表中时长是建议的采样预算,可按产品要求调整,不是已完成次数。 + +| 阶段 | 场景与步骤 | 主要证据与进入下一阶段条件 | +| --- | --- | --- | +| S0 软件回归 | 运行第 8 节 C++/Python 测试;有固件源码时运行其主机替身测试 | 通过接口、混合数据流、时间、并发和退出检查;缺依赖/失败保留原始结果 | +| H0 硬件预检 | 核实唯一设备、启动终态、方向与行程标定、型号、包络、实际已失能 | `home_diag()` 无 ACTIVE,状态/故障可读且新鲜;角色、固件或机械状态未知时不运动 | +| H1 静止上报 | motor=100 Hz;IMU=0、10、50、100 Hz,5 s 预热后每段采集 60 s,A/B/A 重复至少 3 次 | 有效电机/IMU 速率、时延分位数、时间连续性、错误计数;确认开 IMU 与恢复 motor-only 后均稳定 | +| H2 空载小行程 | 采用已确认的行程内部范围,先少量慢速往返,再每条件不少于 10 次;两类控制器分别做同轨迹 A/B/A | 到位误差、轨迹完成时间、速度纹波、反向帧、峰值力矩、温升、控制 deadline 和目标年龄 | +| H3 代表性负载 | 明确夹持物、接触位置、摩擦/柔顺条件;重复 H2,再测试规定保持时长 | 力矩/温升、滑移或掉落、包络行为;需要夹持力结论时使用标定的力传感器 | +| H4 长时间稳定性 | 初筛通过后 100 Hz IMU 连续 30–60 min;产品验收再按任务周期做 8 h 或规定时长;前后追加 IMU=0 对照 | 每段数据量、错误计数、温度、RSS/CPU、重连/复位/意外失能、轨迹完成率;长稳通过仅覆盖实测时长 | +| H5 生命周期 | 停止后切换 IMU 频率、重复开/关和连接/退出;安全停机后冷启动,分别测工具提前连接和启动后连接 | 启动时间、回零结果、IMU 就绪时间、端口释放、重复会话无回调残留;不能将 MCU 软复位代替整机断电 | +| H6 故障/保护 | 独立急停、人员在场、无坠落危险的专用台架上测试 host 中断/断链、IMU 故障、限位/堵转 | 故障可见、停机时间与最终状态、恢复需明确动作且无意外运动;不在普通长稳循环自动注入 | +| H7 校准维护 | 运动中请求校准应拒绝且不停止控制;停止失能后在指定姿态单独执行校准、保存、重启恢复 | BUSY 响应、控制连续性、偏置复测和参数恢复;持久化/断电故障另列步骤,不自动混入采集 | + +H6/H7 的断电、传感器断开、堵转、校准保存、OTA 等操作具有实际设备副作用。执行前需明确设备、动作、机械条件和恢复方式;普通数据采集流程不执行这些动作。**慢回调/分发积压只在 PTY 或已安全失能的场景注入;真机运动不做该注入,除非独立安全机制和运动范围已经明确。** 记录预期触发的故障与非预期故障,不能用正常阶段的“错误必须为零”否定有意故障注入,也不能把注入阶段的错误掩盖到正常基线里。 + +### A/B/A 控制变量 + +A 为同一候选固件、同一 SDK 的 `imu_hz=0`;B 分别为 10、50、100 Hz;再次 A 检查温升和时间漂移。建议 `0→10→0→50→0→100→0`,重复时交替 B 的顺序。若还要评估固件改动本身,需要另测已知基线固件,刷机与冷启动应单独授权和记录。 + +保持 motor_hz=100、控制器参数、轨迹、负载、电源、主机负载、USB 拓扑、日志配置和诊断查询时点一致。改变 IMU 频率时先安全停止控制器、停流,再重新配置。不要在运动中反复 `start_streaming()`,也不要把切流停顿算作稳态抖动。 + +## 4. API、控制顺序与退出 + +推荐接口如下,定义见 [Python bindings](../python/bindings/bind_gripper.cpp)、[motor bindings](../python/bindings/bind_motor.cpp)、[control bindings](../python/bindings/bind_control.cpp): + +| 用途 | API | +| --- | --- | +| 发现与打开 | `scan_grippers()`;`FollowerGripper(mcu_device=endpoint.mcu_device, open_cameras=False)` | +| 预检 | `g.home_diag()`、`g.get_gripper_config()`、`g.motor.get_model()`、`g.motor.get_spec()`、`g.audit_envelope()` | +| 订阅 | `g.motor.on_status(callback)`、`g.imu.on_data(callback)`;保存订阅 ID 并使用对应 `off(id)` | +| 配置流 | `g.start_streaming(motor_hz=100, imu_hz=rate)`;改频率前 `g.stop_streaming()` | +| 主机控制 | `ForcePositionConfig.for_spec(g.motor.get_spec())` / `ImpedanceConfig.for_spec(...)`,对应 Controller 的 `start()`、`set_target()`、`snapshot()`、`stop()` | +| 显式使能 | 控制器 `start()` 成功后,由调用者执行 `g.motor.enable()`;`start()` 本身不使能 | +| 诊断 | `g.motor.control_stats()`、`g.diagnostics.uart_stats()`、`g.transport.stats` | +| 退出 | `controller.stop()` → `g.stop_streaming()` → 注销回调 → `g.close()` | + +**先开 motor+IMU 共流,再 `controller.start()`,成功后显式 `g.motor.enable()`。** 两种控制器会借用已开启的流;若先启动控制器,默认是 motor-only,之后不能直接新增 IMU。控制器启动会读取并校验电机限制、以当前位置初始化目标,但不使能电机;应在使能前完成这些启动检查。控制时不得预开 IMU-only:控制器需要电机状态反馈。借用的流需由调用者最终关闭。 + +回调只做有界、快速的数据复制/入队。不要在 IMU 或电机回调中 `sleep`、同步写磁盘、绘图、打印、获取长期持有的锁或发送 ACK 查询;另设消费线程批量保存,并记录采集队列溢出。`queue_dropped=0` 也不证明回调无害,必须同时测量 motor 间隔和控制状态。 + +运动中从流和 `controller.snapshot()` 获取状态;不要频繁 `read_status()`、`read_status_ext()`、`fault_report()` 或重新 `scan_grippers()`。诊断 ACK 同样占用链路,应放在统一区段边界;需要运行中诊断时采用一致、已记录的低频查询,并区分查询附近窗口。 + +`close()` 释放端口但不失能电机。控制器 `stop()` 会尝试零输出和失能,但失能失败可能只记录警告;断链时不能以函数返回证明物理停机。运动测试退出后在链路仍可用时确认已失能/实际反馈模式,再关闭;断链使用台架独立急停及恢复流程。无运动采集不应为了“清理”而擅自对正在夹持的设备发失能。 + +### 默认无运动的采集片段 + +以下片段在确认设备原任务已经结束后使用。它不会使能、设目标、校准、写 Flash 或刷机;发现/构造和开停流具有前述副作用。先把代码保存为本次运行脚本,再传入精确 SN;没有 SN 时会在发现设备前退出。为便于核对只输出段摘要,正式验收应按第 6 节保存原始数据与完整诊断差分。 + +```python +import collections +import sys +import time +from xense.taccap import FollowerGripper, Role, scan_grippers + +if len(sys.argv) != 2: + raise SystemExit("usage: python capture.py ") +endpoints = scan_grippers() +matches = [e for e in endpoints if e.firmware_sn == sys.argv[1]] +if len(matches) != 1 or matches[0].role != Role.Follower or matches[0].in_use: + raise SystemExit("Need exactly one available follower with the requested SN") + +g = FollowerGripper(mcu_device=matches[0].mcu_device, open_cameras=False) +motor_sub = imu_sub = None +motor_rows = collections.deque(maxlen=65536) +imu_rows = collections.deque(maxlen=65536) +try: + print("firmware", g.firmware_version, "SN", g.firmware_sn) + if g.home_diag().flags & 0x01: # HomeDiagFlag.Active + raise RuntimeError("Power-on calibration is active; end this run") + initial = g.motor.read_status() + if initial.status & 0x01: # MotorStatusBit.Enabled + raise RuntimeError("Motor already enabled; require a safe stopped session") + motor_sub = g.motor.on_status(lambda s: motor_rows.append(( + time.monotonic(), s.host_time, s.raw.status_timestamp_ms, + s.actual_pos, s.actual_vel, s.actual_torque, s.motor_temp_c, + s.status, s.fault_code, s.latched_fault_code))) + imu_sub = g.imu.on_data(lambda s: imu_rows.append(( + time.monotonic(), s.host_time, s.mcu_timestamp_us, s.seq, + s.boot_session_id, s.timestamp_is_64bit, s.timestamp_valid, + s.timestamp_discontinuity, s.valid_flag))) + for rate in (0, 10, 0, 50, 0, 100, 0): + before_uart = g.diagnostics.uart_stats() + before_transport = g.transport.stats + g.start_streaming(motor_hz=100, imu_hz=rate) + time.sleep(5.0) + begin = time.monotonic() + time.sleep(60.0) + end = time.monotonic() + g.stop_streaming() + # A timestamp window excludes preheat and any late-dispatched samples. + motor = [r for r in list(motor_rows) if begin <= r[0] <= end] + imu = [r for r in list(imu_rows) if begin <= r[0] <= end] + after_uart = g.diagnostics.uart_stats() + after_transport = g.transport.stats + print({"imu_hz": rate, "seconds": end - begin, + "motor_samples": len(motor), "imu_samples": len(imu), + "motor_hz_observed": len(motor) / (end - begin), + "imu_hz_observed": len(imu) / (end - begin)}) + # Serialize the rows and before/after counters outside the callbacks. + # This short example's 65536-row buffers cover its planned duration; + # a long run needs a draining queue plus an explicit overflow counter. +finally: + try: + g.stop_streaming() + finally: + try: + if motor_sub is not None: + g.motor.off(motor_sub) + if imu_sub is not None: + g.imu.off(imu_sub) + finally: + g.close() +``` + +### 运动配置必须先填写 + +行程用归一化 `[0, 1]` 表示,原始轴角为 rad、轴角速度为 rad/s、力矩为 N·m。归一化开度不直接等于 mm;须使用该装置的机构/标定映射。电机力矩不能未经传动比、有效力臂、效率/摩擦、接触点与实测标定直接换算成指尖夹持力 N。空载初筛采用行程内部范围,避免把端点预载或硬限位作为普通轨迹。 + +运行记录中先填写下表;空值表示不能开始该项运动。`for_spec()` 的默认值反映电机型号,不等于本次台架已批准的负载、速度或力矩。 + +| 参数 | 待填写的本次值 | +| --- | --- | +| 精确 SN、左右侧、EL05/RS00、控制器 | `待填写` | +| 起点/终点归一化开度,允许 raw rad 范围 | `待填写` | +| 起止姿态、夹爪固定方式、运动区域和负载 | `待填写` | +| 速度 rad/s、ForcePosition.grasp_torque_nm 或 Impedance.max_position_torque_nm | `待填写` | +| Impedance 的 kp/kd/feedforward 或 ForcePosition 的预载、保护配置 | `待填写完整配置` | +| 每段驻留、往返次数、完成时限、允许位置误差 | `待填写` | +| 最大温度、力矩、电流及独立急停条件 | `待填写` | + +运动时顺序为:确认已失能,读取/审核型号与包络,配置控制器,订阅轻量采集回调,`g.start_streaming(motor_hz=100, imu_hz=rate)`,再 `controller.start()`;启动成功且所有运动前提满足后,显式调用 `g.motor.enable()`,随后在规定范围内 `set_target()`,以 `snapshot()` 和流检验轨迹。两类控制器的 `start()` 都不调用电机使能,不能省略或提前执行这个独立步骤;对应示例为 [impedance_control.py](../python/examples/impedance_control.py) 和 [force_position_control.py](../python/examples/force_position_control.py)。每段停止前取得结束控制统计,再执行本节退出顺序;相邻段恢复同一初始条件。 + +## 5. 时间、帧率和序列号的解释 + +- IMU 当前固件开流后约每 10 ms 采样,上报设置为 10/50/100 Hz 时,采样线程仍约 100 Hz。`seq` 是 16 位采样序号,低上报频率自然会跨号;先按设备时间和抽样倍率解释,不能直接把所有跨号加成丢包数。遇到回绕用无符号模差,遇到重启先切分会话。 +- SDK 会滤除相邻完整相同的 IMU 载荷。回调计数是被交付的新样本数,不是所有线上帧数;必要时结合 Transport 帧数和固件 UART 计数区分重复包、丢包及采样变慢。 +- `mcu_timestamp_us` 表示设备侧时间;`timestamp_is_64bit=True` 是协商后的原生 V2,legacy 展开时间仍有歧义。保存 `boot_session_id`、`timestamp_valid`、`timestamp_discontinuity`,会话首帧的 discontinuity 与运行中意外跳变分别处理。 +- `host_time` 与 callback 中的 `time.monotonic()` 属于主机单调时钟;当前解码位于分发路径,不能把它当作精确 UART 到达或硬件采样时刻。它们可反映应用接收/分发节奏,不能和设备启动时基直接相减声称端到端延迟,也不证明多设备同步。 +- 电机 `s.raw.status_timestamp_ms` 是设备最近一次实际状态反馈时刻;结合 `monitor_version`/有效标志检查其推进,避免“主机持续收到旧状态”假稳定。`monitor_version=0` 表示扩展缺失,零填充字段不能当作已测零故障。 +- `control_stats.actual_hz` 的窗口由统计查询推进;查询时控制已停止会返回 0。运动段应在 `controller.stop()` 前取末次统计,并记录 `sample_ms`。还应使用 `loop_count` 差分与窗口时长独立交叉核对,避免启动/停止部分污染稳态窗口。 +- 当前 SDK 文档将 `timeout_count` 描述为 reserved,但上述候选固件已在 host watchdog 首次进入 T1 时递增。按已核实固件语义解释,不把旧固件的零值当作无超时证明。当前固件控制周期是 500 Hz;SDK 状态驱动的目标提交通常是 100 Hz,这是不同指标。 + +## 6. 量化记录与判据 + +| 记录域 | 字段/计算 | +| --- | --- | +| 区段 | run_id、A/B/A 次序、设定频率、预热/测量起止、墙钟时间与单调时长、诊断查询时点、完整参数 | +| Motor | host_time、callback 单调时间、monitor_version、status_timestamp_ms、actual/target pos/vel/torque、温度、status、stop_reason、fault/latched_fault_code;速率、帧间隔 p50/p95/p99/max、反馈新鲜度 | +| IMU | host_time、callback 单调时间、mcu_timestamp_us、seq、boot_session_id、所有 timestamp 标志、valid_flag、accel m/s²、gyro rad/s、mag μT、温度;新样本率、设备时间间隔、有效位缺失率 | +| 固件控制 | running/mode、target_hz/actual_hz、period_ms/sample_ms、target_seq/applied_seq、loop/error/deadline_miss/timeout 差分、last_error、target_age_ms、target_update_hz | +| 固件 UART | tx_bytes_ok/tx_calls_ok、tx_fail_timeout/tx_fail_other、rx_bytes/rx_errors/rx_rearms/rx_overflow、debug_tx_bytes/log_dropped 的差分 | +| SDK Transport | bytes/frames、crc_errors/resync_bytes/parser_overflow_bytes、queue_dropped/queue_high_water、callback_max_us、ack_timeouts/retries/callback_exceptions;采集自身丢样另计 | +| 运动质量 | 每程起止时间、目标和实际位置、末端误差、完成率、速度均值/标准差/峰峰值、反向帧、力矩/温度峰值;保持阶段滑移、外部力或位移测量 | + +所有累计计数按真实位宽做无符号差分,检测设备重启后开启新段,不跨重启直接相减。`callback_max_us` 等历史最大值和高水位不能直接当作区段差值。保存原始时间序列,摘要同时报告分位数和最坏间隔,不能只报告平均帧率。 + +正常稳定段的硬失败包括:非预期运动/夹持失效/复位/失能、控制器 FAULT、控制错误或 host timeout 增量、UART 发送失败或 RX 溢出、CRC/parser 错误或分发/采集队列丢样、运行中无法解释的时间回退/会话切换、反馈长期不推进、应有流消失。旧故障与新故障分开;在初始故障来源未解释前不能直接继续运动。 + +`deadline_miss_count` 增加说明发生了周期未按期完成,应列为实时性异常并保留上下文,不能因轨迹最终到位忽略。频率偏差、p99/最大间隔、deadline miss 比例、位置误差、速度纹波、力矩/温升、最大无数据时间、停止时间和长期完成率的产品验收上限必须在运行前由需求/台架负责人给定。尚无阈值时报告 A/B/A 原始值与差异,结论为“已测量、验收阈值待定”,不能自行指定通用百分比后宣称不影响电机。 + +即便应用数据完全有效,静态加速度幅值、陀螺零偏、磁场合理性与轴向仍须独立验证。电机通断电、不同力矩和姿态会影响磁力计;不把磁航向当作电机附近的绝对真值。 + +## 7. 源码可复用测试与覆盖边界 + +| 测试 | 证明范围 | +| --- | --- | +| [test_follower_imu_pty.cpp](../cpp/tests/test_follower_imu_pty.cpp) | motor-only 默认、旧固件拒绝不改流、双回调、非法频率和校准协议流程 | +| [test_imu_motor_concurrency.cpp](../cpp/tests/test_imu_motor_concurrency.cpp) | 两类控制器保留预先开启的混合流;特定慢回调时序下故障/零输出;活动回调期间关闭的生命周期,共 5 项。不能据此推断持续积压时能及时识别旧反馈 | +| [test_imu_time.cpp](../cpp/tests/test_imu_time.cpp)、[test_imu_time.py](../python/tests/test_imu_time.py) | 时间协商、回绕/歧义、会话、重复样本等协议与绑定行为 | +| [test_transport_concurrent_write.cpp](../cpp/tests/test_transport_concurrent_write.cpp)、[test_dispatch_decoupling.py](../python/tests/test_dispatch_decoupling.py)、[test_port_release.py](../python/tests/test_port_release.py) | 并发写入、读/分发分离、端口释放相关行为 | +| [control_ripple.py](../python/examples/control_ripple.py) | 实机速度纹波统计可供复用;现有脚本包含实际运动且无 IMU A/B 参数,不能直接等同本文完整验收 | + +固件仓库的 `tools/slave_imu_test/run.sh` 运行生产 service/stream/寄存器/启动代码的主机替身测试,`tools/timebase_test/run.sh` 运行时间、双角色协议及主爪流回归。替身中的 RTOS、SPI、CAN、Flash 不覆盖真实抢占、电气故障或电机运动。对固件建立独立源码身份和结果记录,不能将 SDK 测试 SHA 当作固件验证身份。 + +## 8. 软件验证命令 + +以下从本 SDK 仓库根目录执行,使用已配置好依赖的测试环境。CMake 与现有 CI 同样构建核心、Python、示例和测试;生成的本地扩展不代表已安装到其他业务环境。不要在运行硬件控制的进程旁并行运行会占用相同端口的测试。 + +```bash +cmake -S . -B build -G Ninja \ + -DTACCAP_BUILD_TESTS=ON -DTACCAP_BUILD_EXAMPLES=ON \ + -DTACCAP_BUILD_PYTHON=ON \ + -Dpybind11_DIR="$(python -m pybind11 --cmakedir)" +cmake --build build + +# 有关功能先单独检查;随后执行完整软件回归。 +./build/cpp/tests/taccap_unit_tests \ + --gtest_filter='FollowerImu.*:ImuMotorConcurrency.*' +./build/cpp/tests/taccap_unit_tests +env -u PYTHONPATH python -m pytest python/tests -q + +# 或使用 CTest 运行已注册的 C++ 测试。 +ctest --test-dir build --output-on-failure +``` + +若另有对应固件仓库 checkout,从该仓库根目录执行,不依赖某台开发机的绝对路径: + +```bash +git branch --show-current +git rev-parse HEAD +git status --short +bash tools/slave_imu_test/run.sh +bash tools/timebase_test/run.sh +``` + +将实际命令、退出码、原始日志、通过/失败/跳过项和环境写入本次运行证据。软件 PASS 与硬件 PASS 分开,CI 未查询时不声称 CI 通过;完整性结论必须列出尚未具备的电机型号、负载、故障场景和长期运行覆盖。 From 5078eeeabc5cb7faa0f21516205a8978a8e24c8c Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 26 Oct 2026 17:07:36 +0800 Subject: [PATCH 05/18] =?UTF-8?q?test(imu):=20=E7=94=A8=E7=A1=AC=E4=BB=B6?= =?UTF-8?q?=E6=8A=93=E5=8C=85=E9=AA=8C=E8=AF=81=E8=A7=A3=E6=9E=90=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B9=B6=E4=BF=AE=E6=AD=A3=E4=B8=A2=E5=B8=A7=E5=BD=92?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将真实电机、IMU、控制统计应答和缺字节帧固化为回放样本,覆盖逐字节、分块和损坏后恢复;说明 resync/CRC 计数不能单独定位故障层。 验证:C++ 全量 502 项通过;新增 6 项回放涵盖 621 个分割场景;git diff --check 通过。实物短帧在进入 SDK 解析前已存在,串口链路根因仍在验证。 --- cpp/include/taccap/bus/transport.hpp | 37 +++--- cpp/tests/test_frame.cpp | 176 +++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 20 deletions(-) diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index c1e1ede..82a2e2e 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -153,27 +153,24 @@ class Transport { uint64_t callback_exceptions = 0; // ---- Loss accounting ------------------------------------------ - // Three distinct ways a frame can fail to reach a subscriber. Read - // together they say *where* a rate drop came from: + // These counters identify the stage that observed damage or dropped + // data, not the component that originally caused a rate drop: // - // crc_errors / resync_bytes > 0 -> bytes were lost before the - // parser. Two different causes, told apart by bytes_read: - // - bytes_read also down: the host stopped draining the tty - // long enough to overflow the kernel/USB buffers. - // Cross-check callback_max_us. - // - bytes_read at full volume, crc_errors flat, resync_bytes - // climbing in ~one-frame steps: the MCU dropped bytes out of - // the middle of a frame it was transmitting, which it does - // when host->MCU traffic overlaps its own send. The short - // payload fails the LEN check before CRC is ever reached, - // which is why crc_errors stays at zero. Nothing on the host - // is broken; stop overlapping the writes (submit on the - // status-frame doorbell, as both controllers do). - // queue_dropped > 0 -> bytes arrived fine, but the - // subscriber could not keep up and old frames were evicted. - // both zero, rate still low -> the firmware really is sending - // slower; nothing on the host side to fix. - uint64_t crc_errors = 0; // well-framed, bad CRC → byte loss + // crc_errors / resync_bytes > 0 -> framing, length or CRC validation + // rejected bytes. A short frame can fail LEN before CRC, leaving + // crc_errors at zero. Neither bytes_read nor these counters can + // distinguish firmware TX, UART/USB/driver loss, host buffering, + // or a parser defect. Capturing malformed bytes in read() proves + // they reached the host before SDK parsing; locating the cause + // still needs independent link/firmware evidence. Nearby host + // writes alone do not prove physical TX/RX interference. + // queue_dropped > 0 -> parsed DATA was evicted from a full dispatch + // queue, or discarded during shutdown. Check callback_max_us and + // the run/cleanup boundaries before attributing this to load. + // all zero, rate still low -> whole-frame loss, delayed callbacks, + // component filtering or slower firmware production remain + // possible. Compare raw frames, device time and delivered data. + uint64_t crc_errors = 0; // framing/length valid; CRC mismatch uint64_t resync_bytes = 0; // bytes discarded without a frame uint64_t parser_overflow_bytes = 0; // dropped by the parser_max_buf cap uint64_t queue_dropped = 0; // DATA frames evicted, queue full diff --git a/cpp/tests/test_frame.cpp b/cpp/tests/test_frame.cpp index 21c8304..d620042 100644 --- a/cpp/tests/test_frame.cpp +++ b/cpp/tests/test_frame.cpp @@ -7,6 +7,7 @@ #include #include +#include namespace tb = xense::taccap::bus; namespace tp = xense::taccap::protocol; @@ -383,3 +384,178 @@ TEST(FrameParser, ResetKeepsDamageCounters) { EXPECT_EQ(p.buffered_bytes(), 0u); EXPECT_EQ(p.stats().crc_errors, 1u); } + +namespace { + +// Real follower bytes captured before SDK parsing on 2026-10-26, motor +// disabled; no pack_frame()/SDK encoder is used to construct these fixtures. +// Evidence: dist/debug/runs/imu-link-fix-504134a-20261026/logs/idle-uart.strace +// SHA-256: 9c939e9728d2e20b67a448536fb6db06cf89ec8a75d1cce627c96cb7c5010afb +// RX offsets below refer to the concatenated successful read() buffers. +// These bytes prove parser behavior, not firmware timing or loss origin. +std::vector captured_hex(const char* text) { + const std::string hex(text); + std::vector bytes; + for (std::size_t i = 0; i < hex.size(); i += 2) { + bytes.push_back(static_cast(std::stoul(hex.substr(i, 2), nullptr, 16))); + } + return bytes; +} + +struct CapturedFrame { + const char* name; + std::vector wire; + uint8_t seq; + tp::FrameType type; + tp::Cmd cmd; + std::vector payload; +}; + +std::vector captured_good_frames() { + return { + // Line 30, RX offset 307, 69 wire bytes; extended motor status. + {"motor DATA", captured_hex( + "aa025703503b00c05987bf001048bc0010c0ba000004420000262887bf" + "000000000000000004021301850000000000000000db587b00e85a7b00" + "00000000af2d78005b0a55"), + 0x57, tp::FrameType::DATA, tp::Cmd::GetMotorStatus, captured_hex( + "c05987bf001048bc0010c0ba000004420000262887bf0000000000000000" + "04021301850000000000000000db587b00e85a7b0000000000af2d7800")}, + // Line 7627, RX offset 245546, 55 wire bytes; payload contains 7d75. + {"IMU V2 DATA", captured_hex( + "aa022803172c00016a749d0f000300ffffe803f4ff1200faff7d75f65f" + "ecb211360e1e7c016a749d060000002bf06c202a29f306c7dd55"), + 0x28, tp::FrameType::DATA, tp::Cmd::GetImuV2, captured_hex( + "016a749d0f000300ffffe803f4ff1200faff55f65fecb211360e1e7c01" + "6a749d060000002bf06c202a29f306")}, + // Line 29, RX offset 248, 59 wire bytes; CRC contains escaped 0x55. + {"control stats ACK", captured_hex( + "aa020a025130000004f4010200ffff00000000344a0100324a0100fe71" + "0600000000002200000000000000000000000000000000000000637d7555"), + 0x0a, tp::FrameType::ACK, tp::Cmd::GetMotorControlStats, captured_hex( + "0004f4010200ffff00000000344a0100324a0100fe7106000000000022" + "00000000000000000000000000000000000000")}, + }; +} + +std::vector captured_short_motor_frame() { + // Lines 55 and 58 (unfinished/resumed read), RX offset 1138, 67 wire + // bytes. LEN is 59 but the unstuffed body is 65 bytes instead of 67. + // This is the captured damage, not a test-side deletion from a good frame. + return captured_hex( + "aa025e03503b00c05987bf001048bc0010c0ba000004420000262887bf" + "00000000000000000402130185000000000000db587b00245b7b000000" + "0000af2d78008f5155"); +} + +std::vector captured_stream(const std::vector& frames, + bool include_damage = false) { + std::vector stream; + for (std::size_t i = 0; i < frames.size(); ++i) { + stream.insert(stream.end(), frames[i].wire.begin(), frames[i].wire.end()); + if (include_damage && i == 0) { + const auto bad = captured_short_motor_frame(); + stream.insert(stream.end(), bad.begin(), bad.end()); + } + } + return stream; +} + +void expect_captured_frames(tb::FrameParser& parser, + const std::vector& expected) { + ASSERT_EQ(parser.pending(), expected.size()); + for (const auto& fixture : expected) { + SCOPED_TRACE(fixture.name); + tb::Frame frame; + ASSERT_TRUE(parser.try_pop(frame)); + expect_frame_eq(frame, tp::Address::MCU, fixture.seq, fixture.type, + fixture.cmd, fixture.payload); + } + tb::Frame extra; + EXPECT_FALSE(parser.try_pop(extra)); + EXPECT_EQ(parser.buffered_bytes(), 0u); + EXPECT_EQ(parser.stats().overflow_bytes, 0u); +} + +} // namespace + +TEST(HardwareFrameReplay, ValidFramesAtEverySingleSplit) { + for (const auto& fixture : captured_good_frames()) { + SCOPED_TRACE(fixture.name); + for (std::size_t split = 0; split <= fixture.wire.size(); ++split) { + SCOPED_TRACE(split); + tb::FrameParser parser; + parser.feed(fixture.wire.data(), split); + EXPECT_EQ(parser.pending(), split == fixture.wire.size() ? 1u : 0u); + parser.feed(fixture.wire.data() + split, fixture.wire.size() - split); + expect_captured_frames(parser, {fixture}); + EXPECT_EQ(parser.stats().resync_bytes, 0u); + EXPECT_EQ(parser.stats().crc_errors, 0u); + } + } +} + +TEST(HardwareFrameReplay, CoalescedValidFramesAtEverySingleSplit) { + const auto fixtures = captured_good_frames(); + const auto stream = captured_stream(fixtures); + // Includes one coalesced read and every boundary across DATA and ACK, + // including a split immediately after ESC in payload or CRC. + for (std::size_t split = 0; split <= stream.size(); ++split) { + SCOPED_TRACE(split); + tb::FrameParser parser; + parser.feed(stream.data(), split); + parser.feed(stream.data() + split, stream.size() - split); + expect_captured_frames(parser, fixtures); + EXPECT_EQ(parser.stats().resync_bytes, 0u); + EXPECT_EQ(parser.stats().crc_errors, 0u); + } +} + +TEST(HardwareFrameReplay, ValidFramesByteByByte) { + const auto fixtures = captured_good_frames(); + const auto stream = captured_stream(fixtures); + tb::FrameParser parser; + for (const auto byte : stream) parser.feed(&byte, 1); + expect_captured_frames(parser, fixtures); + EXPECT_EQ(parser.stats().resync_bytes, 0u); + EXPECT_EQ(parser.stats().crc_errors, 0u); +} + +TEST(HardwareFrameReplay, CapturedShortMotorFrameIsRejectedBeforeCrc) { + const auto bad = captured_short_motor_frame(); + ASSERT_EQ(bad.size(), 67u); + const auto result = tb::try_parse_frame(bad.data(), bad.size()); + EXPECT_EQ(result.status, tb::ParseStatus::Resync); + EXPECT_EQ(result.reason, tb::ParseReason::BadLength); + tb::FrameParser parser; + parser.feed(bad); + EXPECT_EQ(parser.pending(), 0u); + EXPECT_EQ(parser.buffered_bytes(), 0u); + EXPECT_EQ(parser.stats().resync_bytes, bad.size()); + EXPECT_EQ(parser.stats().crc_errors, 0u) + << "zero CRC errors cannot establish intact input when LEN already fails"; +} + +TEST(HardwareFrameReplay, RecoversAfterCapturedDamageAtEverySingleSplit) { + const auto fixtures = captured_good_frames(); + const auto stream = captured_stream(fixtures, true); + for (std::size_t split = 0; split <= stream.size(); ++split) { + SCOPED_TRACE(split); + tb::FrameParser parser; + parser.feed(stream.data(), split); + parser.feed(stream.data() + split, stream.size() - split); + expect_captured_frames(parser, fixtures); + EXPECT_EQ(parser.stats().resync_bytes, captured_short_motor_frame().size()); + EXPECT_EQ(parser.stats().crc_errors, 0u); + } +} + +TEST(HardwareFrameReplay, RecoversAfterCapturedDamageByteByByte) { + const auto fixtures = captured_good_frames(); + const auto stream = captured_stream(fixtures, true); + tb::FrameParser parser; + for (const auto byte : stream) parser.feed(&byte, 1); + expect_captured_frames(parser, fixtures); + EXPECT_EQ(parser.stats().resync_bytes, captured_short_motor_frame().size()); + EXPECT_EQ(parser.stats().crc_errors, 0u); +} From da015ed70172ff34780177a68ec6d68a43a71fab Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 26 Oct 2026 17:37:25 +0800 Subject: [PATCH 06/18] =?UTF-8?q?docs(imu):=20=E6=A0=B9=E6=8D=AE=E7=A1=AC?= =?UTF-8?q?=E4=BB=B6=E6=8A=93=E5=8C=85=E4=BF=AE=E6=AD=A3=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E6=80=A7=E8=AF=8A=E6=96=AD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明冷启动记录、MCU重启与串口桥断电的证据区别,保留历史测量但撤除通用化结论。 补充USBmon与read全843445字节一致、131条接收坏记录的观测边界,以及uart_stats调用和发送/接收字节对账规则。 验证:git diff --check通过;对照实际USBmon/strace原始数据和固件统计实现,由独立复核确认。 本提交只修改文档;此前502项C++回归通过。修复固件的主短帧硬件复测仍失败,未宣称验收通过。 --- docs/FIRMWARE.md | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/docs/FIRMWARE.md b/docs/FIRMWARE.md index d7d22ac..21bffeb 100644 --- a/docs/FIRMWARE.md +++ b/docs/FIRMWARE.md @@ -162,20 +162,32 @@ are told to use. `test_ota_update_all.py` fails if they disagree. > 复位。在一台 EL05 从爪上实测:拔 24V 的瞬间(<0.3 秒)板载 USB 集线器连同串口芯片 > 一起掉线、MCU 重启,从最快的一碰到断开 10 秒,15/15 次都如此;OTA 后只断一次 24V, > 60 秒状态流 6000 帧一帧不丢。**主爪**没有 24V:拔插它的 USB 即可。 -> 确认真的断过电:看 `gripper.device.heartbeat().uptime_ms` 是否从接近 0 重新计起。 -> `/dev/serial/by-id/` 的时间戳通常也会跟着变,但 uptime 才是 MCU 真的重启过的直接证据。 +> 记录现场断开并重接 24V 的操作,再检查 `gripper.device.heartbeat().uptime_ms` 是否重新计时。 +> uptime 重置只证明 MCU 重启,软复位也会重置它;不能单凭 uptime 或 +> `/dev/serial/by-id/` 的时间戳证明串口桥已经断电。 > -> bank-swap 重启是软复位:MCU 重新初始化,但片外的 USB 转串口桥从没断过电。设备 -> 回来之后停在一个降级状态,而这个状态和健康状态**从任何可观测的角度都分不出来** -> —— 版本号正确、数据流在跑、`uart_stats()` 的收发计数全是干净的。唯一的症状是它 -> 在悄悄丢状态帧。 +> bank-swap 重启是软复位:MCU 重新初始化,片外 USB 转串口桥可能仍保持供电。 +> 历史实测曾出现升级后丢状态帧,而版本号、流启动状态和 `uart_stats()` 计数正常的情况。 +> 这些检查不足以证明数据完整;仍需检查帧间隔、序号和解析器统计。 > > 实测,同一只夹爪、同一个固件、同一条线、60 秒一轮:仅 OTA 之后每轮丢 35~39 帧, > 断电重插之后连续三轮为 0。 > > 在这上面栽过两次:先把它误判成"某只夹爪链路劣化、疑似硬件损坏",又用两组都没 -> 断电的数据去比较固件版本,得出了并不存在的版本差异。**任何在断电之前取的数字 -> 都不可信。** +> 断电的数据去比较固件版本,得出了并不存在的版本差异。断电状态应记录为测试条件。 + +> 冷启动后仍需在相同负载下复验;断电不能代替故障定位。关闭 IMU、只接收上报、 +> 同时发送查询应分开对照。若 `read()` 原始字节中已出现短帧,只能确定损坏发生在 +> SDK 帧解析之前,仍需 USB/UART 或固件侧证据区分原因。不要把 `crc_errors=0` +> 当成无丢帧:长度检查可以先于 CRC 拒绝短帧。 +> +> 2026-10-26 的另一轮实测在确认断电后,仍在双向通信负载下复现接收短帧:USBmon 接收载荷与 +> 串口 `read()` 的 843445 字节逐字节一致,131 条协议坏记录已经出现在两者中。 +> 这排除了本次 SDK 解析或 TTY 接收额外删字节的解释,但 USBmon 位于 HCD/驱动 +> 边界,并不是 UART 引脚或 USB 线上的物理抓取,不能据此直接认定桥接芯片或固件责任。 +> 查询 `gripper.diagnostics.uart_stats()` 时需要调用方法;只访问方法对象不能获得计数。 +> MCU `tx_bytes_ok` 与主机接收字节对账应在停流、排空后取快照,并使用包含前一个统计 ACK、排除后一个统计 ACK 的 +> 原始接收字节区间;`tx_bytes_ok` 是 HAL 成功返回时累计的请求长度,也不能替代引脚观测。 > Only builds you made yourself need that path. To flash the **released** > images, name them and let the script find them in [`firmware/`](../firmware/) — From 21127761ec4e04b4f41a12cf5a89e1b20db8cea3 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 26 Oct 2026 20:03:55 +0800 Subject: [PATCH 07/18] =?UTF-8?q?fix(imu):=20=E9=9A=94=E7=A6=BB=E7=94=B5?= =?UTF-8?q?=E6=9C=BA=E5=8F=8D=E9=A6=88=E5=9B=9E=E8=B0=83=E5=B9=B6=E4=BF=9D?= =?UTF-8?q?=E7=95=99=E7=9C=9F=E5=AE=9E=E6=8E=A5=E6=94=B6=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为内部电机控制建立独立有界接收通道,用户IMU和motor慢回调不再阻塞控制反馈;控制通道过载锁存并由现有超时保护停机,重建Transport后才能恢复。 将完整帧read接收时间贯穿Frame/ACK、IMU/Motor/Encoder和两类控制器,拒绝过期、倒序及未来反馈,避免队列旧数据在分发时被重标为新鲜。 锁存现有判据下的严重故障、非有限反馈和过量程力矩,防止后续健康帧覆盖故障;包含启动ACK与显式reset接收边界,保持原阈值及线协议。 新增默认关闭的有界原始字节/拒帧诊断、分原因计数及Python绑定;更新验证文档和独立native测试加载入口,未覆盖原安装。 验证:最终C++512项通过;Python153项通过、2项缺硬件跳过;18项并发/退出用例重复10轮共180次通过;适用pre-commit检查通过。 旧实现2条慢回调回归先失败,修复后通过;重复测试发现并修正状态发布与PTY零输出接收之间的异步断言竞态,未放宽原截止时间,失败证据保留。 独立构建native与core实际加载路径已核对;未刷机、未安装、未推送、未发布。真实UART缺口定位与真机长稳仍待执行,精确过期故障队列时序注入未验证。 --- cpp/include/taccap/bus/frame.hpp | 59 ++++- cpp/include/taccap/bus/transport.hpp | 57 ++++- cpp/include/taccap/components/motor.hpp | 5 + .../taccap/force_position_controller.hpp | 3 + cpp/include/taccap/impedance_controller.hpp | 2 + cpp/src/bus/frame.cpp | 75 ++++++- cpp/src/bus/transport.cpp | 122 ++++++++++ cpp/src/components/encoder.cpp | 2 + cpp/src/components/imu.cpp | 4 +- cpp/src/components/motor.cpp | 25 ++- cpp/src/force_position_controller.cpp | 58 +++-- cpp/src/impedance_controller.cpp | 55 +++-- cpp/tests/fake_follower.hpp | 14 +- cpp/tests/test_frame.cpp | 51 +++++ cpp/tests/test_imu_motor_concurrency.cpp | 208 +++++++++++++++++- docs/IMU_MOTOR_TESTING.md | 18 +- python/bindings/module.cpp | 40 +++- python/tests/conftest.py | 33 ++- python/tests/test_dispatch_decoupling.py | 22 ++ 19 files changed, 783 insertions(+), 70 deletions(-) diff --git a/cpp/include/taccap/bus/frame.hpp b/cpp/include/taccap/bus/frame.hpp index b714191..78130ea 100644 --- a/cpp/include/taccap/bus/frame.hpp +++ b/cpp/include/taccap/bus/frame.hpp @@ -16,6 +16,10 @@ #include #include +#include +#include +#include +#include #include #include #include @@ -58,6 +62,9 @@ struct Frame { protocol::FrameType type; protocol::Cmd cmd; std::vector payload; + // Host monotonic time of the read completing this frame, not callback time + // or hardware sample time. Zero for standalone parser-created frames. + std::chrono::steady_clock::time_point receive_time{}; }; // Build a wire frame. @@ -90,9 +97,10 @@ enum class ParseStatus { Success, NeedMoreData, Resync }; // status == Resync; Success / NeedMoreData always carry ParseReason::None. // // This exists to separate "we are mid-stream on a byte boundary" from "we -// found a well-formed frame whose CRC is wrong". The latter is the signature -// of *lost bytes* — a host that stopped draining the tty long enough for the -// kernel/USB buffers to overflow — and is the one counter worth alarming on. +// found a well-formed frame whose CRC is wrong". Length/framing damage can be +// rejected BEFORE CRC, so zero CRC errors never proves a loss-free link. +// None of these reasons alone identifies the firmware, bridge, or host as +// the source of damage; retain raw bytes for attribution. enum class ParseReason : uint8_t { None = 0, NoHead, // first byte is not HEAD; scanned forward to the next one @@ -130,29 +138,66 @@ class FrameParser { // Bytes currently held in the resync buffer (not yet a complete frame). std::size_t buffered_bytes() const noexcept { return rx_.size(); } - // Monotonic damage counters. Everything the parser throws away lands in - // exactly one of these, so "the firmware sent fewer frames" and "we lost - // bytes on the host" stop being indistinguishable. + // Monotonic parser counters. Discarded bytes are counted as resync or + // buffer overflow; CRC and rejection reasons describe parser outcomes, + // not the physical location where data was damaged. // // Not synchronised: only the thread calling feed() may read these. The // Transport mirrors them into its own atomics from the reader thread. struct Stats { - uint64_t crc_errors = 0; // well-framed, CRC mismatch → byte loss + uint64_t crc_errors = 0; // well-framed CRC mismatch; cause unknown uint64_t resync_bytes = 0; // bytes discarded without yielding a frame uint64_t overflow_bytes = 0; // bytes dropped by the max_buffered cap + uint64_t no_head = 0; + uint64_t oversize = 0; + uint64_t short_body = 0; + uint64_t bad_length = 0; }; const Stats& stats() const noexcept { return stats_; } + // Opt-in bounded evidence, never a user callback or disk write on reader. + // Input events split large read chunks without omitting bytes. Reject + // events retain a prefix plus total available size; offsets join them to + // Input events. Time is host feed time, NOT MCU sample/exposure time. + struct TraceRecord { + enum class Kind { Input, Reject }; + Kind kind = Kind::Input; + ParseReason reason = ParseReason::None; + std::chrono::steady_clock::time_point host_time{}; + uint64_t offset = 0; + std::size_t available = 0; + std::size_t consumed = 0; + std::size_t size = 0; + std::array bytes{}; + }; + struct TraceSnapshot { + std::vector records; + uint64_t overwritten_records = 0; + }; + // Thread-safe. Enabling starts a fresh 256-record ring (~280 KiB). + // Export after stopping streaming when possible: snapshot holds a mutex. + void set_trace_enabled(bool enabled); + TraceSnapshot take_trace(); + // Clears the buffers. Damage counters are monotonic and survive. void reset(); private: void drain_(); + void trace_(TraceRecord::Kind kind, ParseReason reason, uint64_t offset, + const uint8_t* data, std::size_t available, std::size_t consumed); + void rejected_(ParseReason reason); std::vector rx_; std::deque ready_; std::size_t max_buffered_; Stats stats_{}; + uint64_t rx_offset_ = 0; + std::atomic trace_enabled_{false}; + std::mutex trace_mu_; + std::vector trace_ring_; + std::size_t trace_next_ = 0, trace_count_ = 0; + uint64_t trace_overwritten_ = 0; }; } // namespace xense::taccap::bus diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 82a2e2e..0bf1963 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -1,16 +1,18 @@ // Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 // // Asynchronous transport for TC-GU-01: -// - owns a SerialBus + FrameParser + a reader thread + a dispatcher thread +// - owns a SerialBus, parser, reader, user dispatcher and control worker // - matches CMD_NEED_ACK frames to incoming ACK frames by seq, with // host-side timeout/retry honouring the firmware's 10ms / 3-attempt spec // - dispatches DATA frames to per-Cmd subscriber callbacks // -// Threading contract (two threads, deliberately): +// Threading contract: // // reader thread read() -> parse -> { ACK: fulfil the promise inline -// DATA: push to dispatch_queue_ } +// DATA: push to bounded queues } // dispatcher thread pop -> fan out to matching subscriber callbacks +// control worker motor DATA -> private SDK controller subscriptions +// (no user/Python callbacks, no logging on this path) // // The split exists because subscriber callbacks are user code: a Python // callback must take the GIL, and CPython's default 5ms switch interval means @@ -51,11 +53,13 @@ #include #include #include +#include #include #include #include #include +namespace xense::taccap { class Motor; } namespace xense::taccap::bus { // Decoded ACK / NACK from the firmware. The wire format mirrors firmware @@ -77,6 +81,7 @@ struct AckResponse { protocol::ErrorCode error_code; // Ok on success; payload[0] on NACK std::vector data; // wire payload as-is (response data on // success, [err_code] on NACK) + std::chrono::steady_clock::time_point receive_time{}; }; // Recover the firmware's error code from a response, including the echoed-cmd @@ -140,6 +145,10 @@ class Transport { // queue_dropped) — lower this when latency matters more than // surviving hiccups. Clamped to >= 1. std::size_t dispatch_queue_frames = 256; + // Private motor-control lane, independent of all user callbacks. + // Overflow latches the lane closed until transport recreation: a + // dropped fault cannot be replaced silently with a healthy sample. + std::size_t control_queue_frames = 8; }; struct Stats { @@ -173,15 +182,22 @@ class Transport { uint64_t crc_errors = 0; // framing/length valid; CRC mismatch uint64_t resync_bytes = 0; // bytes discarded without a frame uint64_t parser_overflow_bytes = 0; // dropped by the parser_max_buf cap + uint64_t parser_no_head = 0; + uint64_t parser_oversize = 0; + uint64_t parser_short_body = 0; + uint64_t parser_bad_length = 0; uint64_t queue_dropped = 0; // DATA frames evicted, queue full uint64_t queue_high_water = 0; // deepest the queue has ever been uint64_t callback_max_us = 0; // slowest single fan-out, in us + uint64_t control_queue_dropped = 0; + uint64_t control_queue_high_water = 0; + uint64_t control_queue_overflows = 0; // latched; controller times out }; // Opens the serial port, starts the reader thread. explicit Transport(const Config& cfg); - // Stops the reader, fails any pending ACKs with IoError, joins. +// Stops all workers, fails any pending ACKs with IoError, joins. ~Transport(); Transport(const Transport&) = delete; @@ -226,10 +242,28 @@ class Transport { bool is_open() const noexcept { return serial_.is_open(); } Stats stats() const noexcept; + void enable_receive_trace(bool enabled) { parser_.set_trace_enabled(enabled); } + FrameParser::TraceSnapshot take_receive_trace() { return parser_.take_trace(); } const Config& config() const noexcept { return cfg_; } private: + friend class ::xense::taccap::Motor; + // Only SDK-owned controller subscribers may use this lane. Never expose + // arbitrary user/Python callbacks here or on the serial reader. + SubscriptionId subscribe_control_(DataCallback cb); + void control_loop_(); + void enqueue_control_(const Frame& f); + bool unsubscribe_control_(SubscriptionId id); + struct ControlSub { + SubscriptionId id; + DataCallback cb; + std::chrono::steady_clock::time_point since; + std::mutex mutex; + std::condition_variable idle; + bool active = true; + unsigned inflight = 0; + }; struct PendingAck { std::promise promise; }; @@ -279,6 +313,14 @@ class Transport { std::atomic running_{false}; std::thread reader_; std::thread dispatcher_; + std::thread control_worker_; + std::mutex control_queue_mu_; + std::condition_variable control_queue_cv_; + std::deque control_queue_; + bool control_failed_ = false; // control_queue_mu_ + std::mutex control_sub_mu_; + std::vector> control_subs_; + std::atomic control_sub_count_{0}; // Serialises writers; see write_frame_(). std::mutex write_mu_; @@ -312,9 +354,16 @@ class Transport { std::atomic stat_crc_errors_ {0}; std::atomic stat_resync_bytes_ {0}; std::atomic stat_parser_overflow_ {0}; + std::atomic stat_parser_no_head_ {0}; + std::atomic stat_parser_oversize_ {0}; + std::atomic stat_parser_short_body_ {0}; + std::atomic stat_parser_bad_length_ {0}; std::atomic stat_queue_dropped_ {0}; std::atomic stat_queue_high_water_ {0}; std::atomic stat_callback_max_us_ {0}; + std::atomic stat_control_queue_dropped_{0}; + std::atomic stat_control_queue_high_water_{0}; + std::atomic stat_control_queue_overflows_{0}; }; } // namespace xense::taccap::bus diff --git a/cpp/include/taccap/components/motor.hpp b/cpp/include/taccap/components/motor.hpp index 02032ad..05f1902 100644 --- a/cpp/include/taccap/components/motor.hpp +++ b/cpp/include/taccap/components/motor.hpp @@ -522,6 +522,11 @@ class Motor { static MotorStatusSample decode(const std::uint8_t* payload, std::size_t len); private: + friend class ForcePositionController; + friend class ImpedanceController; + // SDK-owned bounded control callbacks only; never user code. + SubId on_control_status_(Callback cb); + SubId subscribe_status_(Callback cb, bool control); bus::Transport& t_; }; diff --git a/cpp/include/taccap/force_position_controller.hpp b/cpp/include/taccap/force_position_controller.hpp index 426067d..cb8d0d9 100644 --- a/cpp/include/taccap/force_position_controller.hpp +++ b/cpp/include/taccap/force_position_controller.hpp @@ -458,6 +458,9 @@ class ForcePositionController { std::vector queue_; void enqueue_(const QueuedCommand& c); MotorStatusSample latest_{}; + // Lightweight first-fault latch, consumed/logged only by run_(). + const char* pending_status_fault_ = nullptr; + std::chrono::steady_clock::time_point feedback_epoch_{}; std::chrono::steady_clock::time_point latest_time_{}; GripperObservation observation_{}; std::unique_ptr policy_; diff --git a/cpp/include/taccap/impedance_controller.hpp b/cpp/include/taccap/impedance_controller.hpp index a95b26d..ea63dd3 100644 --- a/cpp/include/taccap/impedance_controller.hpp +++ b/cpp/include/taccap/impedance_controller.hpp @@ -318,6 +318,8 @@ class ImpedanceController { bool command_woke_ = false; MotorStatusSample latest_{}; + const char* pending_status_fault_ = nullptr; + std::chrono::steady_clock::time_point feedback_epoch_{}; std::chrono::steady_clock::time_point latest_time_{}; GripperObservation observation_{}; std::unique_ptr policy_; diff --git a/cpp/src/bus/frame.cpp b/cpp/src/bus/frame.cpp index 51d8306..8a58cb2 100644 --- a/cpp/src/bus/frame.cpp +++ b/cpp/src/bus/frame.cpp @@ -188,6 +188,13 @@ FrameParser::FrameParser(std::size_t max_buffered) : max_buffered_(max_buffered) void FrameParser::feed(const uint8_t* data, std::size_t len) { if (data && len) { + if (trace_enabled_.load(std::memory_order_relaxed)) { + for (std::size_t i = 0; i < len; i += 1024) { + const auto n = std::min(1024, len - i); + trace_(TraceRecord::Kind::Input, ParseReason::None, + rx_offset_ + rx_.size() + i, data + i, n, 0); + } + } rx_.insert(rx_.end(), data, data + len); } drain_(); @@ -205,10 +212,64 @@ bool FrameParser::try_pop(Frame& out) { } void FrameParser::reset() { + rx_offset_ += rx_.size(); rx_.clear(); ready_.clear(); } +void FrameParser::set_trace_enabled(bool enabled) { + std::lock_guard lock(trace_mu_); + trace_enabled_.store(false, std::memory_order_relaxed); + trace_ring_.clear(); + trace_next_ = trace_count_ = 0; + trace_overwritten_ = 0; + if (enabled) trace_ring_.resize(256); + trace_enabled_.store(enabled, std::memory_order_relaxed); +} + +FrameParser::TraceSnapshot FrameParser::take_trace() { + std::lock_guard lock(trace_mu_); + TraceSnapshot result; + result.overwritten_records = trace_overwritten_; + result.records.reserve(trace_count_); + const auto start = trace_count_ == trace_ring_.size() ? trace_next_ : 0; + for (std::size_t i = 0; i < trace_count_; ++i) + result.records.push_back(trace_ring_[(start + i) % trace_ring_.size()]); + trace_count_ = trace_next_ = 0; + return result; +} + +void FrameParser::trace_(TraceRecord::Kind kind, ParseReason reason, + uint64_t offset, const uint8_t* data, + std::size_t available, std::size_t consumed) { + if (!trace_enabled_.load(std::memory_order_relaxed)) return; + std::lock_guard lock(trace_mu_); + if (!trace_enabled_.load(std::memory_order_relaxed)) return; + auto& r = trace_ring_[trace_next_]; + r.kind = kind; + r.reason = reason; + r.host_time = std::chrono::steady_clock::now(); + r.offset = offset; + r.available = available; + r.consumed = consumed; + r.size = std::min(available, r.bytes.size()); + std::copy_n(data, r.size, r.bytes.begin()); + trace_next_ = (trace_next_ + 1) % trace_ring_.size(); + if (trace_count_ == trace_ring_.size()) ++trace_overwritten_; + else ++trace_count_; +} + +void FrameParser::rejected_(ParseReason reason) { + switch (reason) { + case ParseReason::NoHead: ++stats_.no_head; break; + case ParseReason::Oversize: ++stats_.oversize; break; + case ParseReason::ShortBody: ++stats_.short_body; break; + case ParseReason::BadLength: ++stats_.bad_length; break; + case ParseReason::BadCrc: ++stats_.crc_errors; break; + case ParseReason::None: break; + } +} + void FrameParser::drain_() { std::size_t cursor = 0; @@ -221,8 +282,10 @@ void FrameParser::drain_() { continue; } if (r.status == ParseStatus::Resync) { - if (r.reason == ParseReason::BadCrc) ++stats_.crc_errors; + rejected_(r.reason); const std::size_t step = (r.consumed > 0 ? r.consumed : 1); + trace_(TraceRecord::Kind::Reject, r.reason, rx_offset_ + cursor, + rx_.data() + cursor, rx_.size() - cursor, step); stats_.resync_bytes += step; cursor += step; continue; @@ -247,12 +310,16 @@ void FrameParser::drain_() { // Counted once here rather than per speculative step, so the // scan below can't double-bill the same bytes. stats_.resync_bytes += (alt - cursor); + trace_(TraceRecord::Kind::Reject, ParseReason::NoHead, + rx_offset_ + cursor, rx_.data() + cursor, alt - cursor, + alt - cursor); cursor = alt + r2.consumed; recovered = true; break; } if (r2.status == ParseStatus::Resync) { - if (r2.reason == ParseReason::BadCrc) ++stats_.crc_errors; + // Speculative lookahead has not discarded anything. Do not + // count it repeatedly when subsequent reads revisit it. alt += (r2.consumed > 0 ? r2.consumed : 1); continue; } @@ -267,6 +334,7 @@ void FrameParser::drain_() { // Drop everything we consumed from the front of the buffer. We use a // single erase rather than per-byte pops to keep this O(n) overall. if (cursor > 0) { + rx_offset_ += cursor; rx_.erase(rx_.begin(), rx_.begin() + static_cast(cursor)); } @@ -274,6 +342,9 @@ void FrameParser::drain_() { if (rx_.size() > max_buffered_) { const std::size_t excess = rx_.size() - max_buffered_; stats_.overflow_bytes += excess; + trace_(TraceRecord::Kind::Reject, ParseReason::Oversize, rx_offset_, + rx_.data(), excess, excess); + rx_offset_ += excess; rx_.erase(rx_.begin(), rx_.begin() + static_cast(excess)); } } diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index 6cea1a4..2dec575 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -20,10 +20,12 @@ Transport::Transport(const Config& cfg) serial_(cfg.serial), parser_(cfg.parser_max_buf) { if (cfg_.dispatch_queue_frames == 0) cfg_.dispatch_queue_frames = 1; + if (cfg_.control_queue_frames == 0) cfg_.control_queue_frames = 1; running_.store(true, std::memory_order_release); // Dispatcher first: the reader must never find itself without a consumer. dispatcher_ = std::thread(&Transport::dispatch_loop_, this); try { + control_worker_ = std::thread(&Transport::control_loop_, this); reader_ = std::thread(&Transport::reader_loop_, this); } catch (...) { // The ctor did not complete, so ~Transport never runs — and a joinable @@ -152,6 +154,7 @@ Transport::subscribe(protocol::Cmd cmd, DataCallback cb) { } void Transport::unsubscribe(SubscriptionId id) { + if (unsubscribe_control_(id)) return; std::lock_guard lk(sub_mu_); subs_.erase(std::remove_if(subs_.begin(), subs_.end(), [id](const Sub& s) { return s.id == id; }), @@ -163,6 +166,8 @@ bool Transport::is_running() const noexcept { } void Transport::clear_subs_() noexcept { + { std::lock_guard lock(control_sub_mu_); + control_subs_.clear(); control_sub_count_.store(0); } std::vector doomed; { std::lock_guard lk(sub_mu_); @@ -182,6 +187,10 @@ void Transport::join_workers_() noexcept { // within one VTIME tick regardless of how slow subscribers are. if (reader_.joinable()) reader_.join(); + { std::lock_guard lk(control_queue_mu_); } + control_queue_cv_.notify_all(); + if (control_worker_.joinable()) control_worker_.join(); + // Taking queue_mu_ here closes the lost-wakeup window: if the dispatcher // has evaluated its predicate but not yet blocked, it still holds the // mutex, so this blocks until it is genuinely waiting on the cv. @@ -225,6 +234,11 @@ void Transport::stop() noexcept { } fail_pending_("transport stopped"); + { + std::lock_guard lock(control_queue_mu_); + stat_control_queue_dropped_.fetch_add(control_queue_.size()); + control_queue_.clear(); + } } Transport::Stats Transport::stats() const noexcept { @@ -240,9 +254,16 @@ Transport::Stats Transport::stats() const noexcept { s.crc_errors = stat_crc_errors_.load(std::memory_order_relaxed); s.resync_bytes = stat_resync_bytes_.load(std::memory_order_relaxed); s.parser_overflow_bytes = stat_parser_overflow_.load(std::memory_order_relaxed); + s.parser_no_head = stat_parser_no_head_.load(std::memory_order_relaxed); + s.parser_oversize = stat_parser_oversize_.load(std::memory_order_relaxed); + s.parser_short_body = stat_parser_short_body_.load(std::memory_order_relaxed); + s.parser_bad_length = stat_parser_bad_length_.load(std::memory_order_relaxed); s.queue_dropped = stat_queue_dropped_.load(std::memory_order_relaxed); s.queue_high_water = stat_queue_high_water_.load(std::memory_order_relaxed); s.callback_max_us = stat_callback_max_us_.load(std::memory_order_relaxed); + s.control_queue_dropped = stat_control_queue_dropped_.load(std::memory_order_relaxed); + s.control_queue_high_water = stat_control_queue_high_water_.load(std::memory_order_relaxed); + s.control_queue_overflows = stat_control_queue_overflows_.load(std::memory_order_relaxed); return s; } @@ -261,6 +282,7 @@ void Transport::reader_loop_() { return; } if (n == 0) continue; // VTIME wakeup, no data + const auto received = std::chrono::steady_clock::now(); stat_bytes_read_.fetch_add(n, std::memory_order_relaxed); parser_.feed(buf.data(), n); @@ -272,9 +294,14 @@ void Transport::reader_loop_() { stat_crc_errors_.store(ps.crc_errors, std::memory_order_relaxed); stat_resync_bytes_.store(ps.resync_bytes, std::memory_order_relaxed); stat_parser_overflow_.store(ps.overflow_bytes, std::memory_order_relaxed); + stat_parser_no_head_.store(ps.no_head, std::memory_order_relaxed); + stat_parser_oversize_.store(ps.oversize, std::memory_order_relaxed); + stat_parser_short_body_.store(ps.short_body, std::memory_order_relaxed); + stat_parser_bad_length_.store(ps.bad_length, std::memory_order_relaxed); Frame f; while (parser_.try_pop(f)) { + f.receive_time = received; stat_frames_received_.fetch_add(1, std::memory_order_relaxed); dispatch_(std::move(f)); } @@ -312,6 +339,7 @@ void Transport::dispatch_(Frame&& f) { handle_ack_(f); break; case protocol::FrameType::DATA: + if (f.cmd == protocol::Cmd::GetMotorStatus) enqueue_control_(f); enqueue_data_(std::move(f)); break; case protocol::FrameType::CMD_NEED_ACK: @@ -347,6 +375,7 @@ void Transport::handle_ack_(const Frame& f) { r.cmd = f.cmd; r.is_nack = false; r.data = f.payload; + r.receive_time = f.receive_time; if (static_cast(f.cmd) == 0) { // cmd == 0 means the firmware took the protocol_send_ack(seq, err) @@ -442,6 +471,99 @@ void Transport::handle_data_(const Frame& f) { } } +Transport::SubscriptionId Transport::subscribe_control_(DataCallback cb) { + { + std::lock_guard lock(control_queue_mu_); + if (control_failed_) throw IoError("motor control receive lane overflowed; reopen transport", EOVERFLOW); + } + auto sub = std::make_shared(); + sub->id = next_sub_id_.fetch_add(1, std::memory_order_relaxed); + sub->cb = std::move(cb); + sub->since = std::chrono::steady_clock::now(); + std::lock_guard lock(control_sub_mu_); + control_subs_.push_back(sub); + control_sub_count_.fetch_add(1, std::memory_order_release); + return sub->id; +} + +bool Transport::unsubscribe_control_(SubscriptionId id) { + std::shared_ptr sub; + { + std::lock_guard lock(control_sub_mu_); + auto it = std::find_if(control_subs_.begin(), control_subs_.end(), + [id](const auto& s) { return s->id == id; }); + if (it == control_subs_.end()) return false; + sub = *it; + control_subs_.erase(it); + control_sub_count_.fetch_sub(1, std::memory_order_release); + } + std::unique_lock lock(sub->mutex); + sub->active = false; + // A copied subscription may already be executing. Controller destruction + // must wait for it; simply removing the callback from a vector is unsafe. + sub->idle.wait(lock, [&] { return sub->inflight == 0; }); + return true; +} + +void Transport::enqueue_control_(const Frame& f) { + if (!control_sub_count_.load(std::memory_order_acquire)) return; + { + std::lock_guard lock(control_queue_mu_); + if (control_failed_) { + stat_control_queue_dropped_.fetch_add(1, std::memory_order_relaxed); + return; + } + if (control_queue_.size() >= cfg_.control_queue_frames) { + // Do not silently evict an old fault and continue on a healthy + // newest sample. Stop delivering control feedback; its existing + // receive-age watchdog must then fail safe, even if user delivery + // continues. Recreating the transport explicitly clears the latch. + control_failed_ = true; + stat_control_queue_overflows_.fetch_add(1, std::memory_order_relaxed); + stat_control_queue_dropped_.fetch_add(control_queue_.size() + 1, + std::memory_order_relaxed); + control_queue_.clear(); + return; + } + control_queue_.push_back(f); + const auto depth = control_queue_.size(); + auto high = stat_control_queue_high_water_.load(std::memory_order_relaxed); + if (depth > high) stat_control_queue_high_water_.store(depth, std::memory_order_relaxed); + } + control_queue_cv_.notify_one(); +} + +void Transport::control_loop_() { + for (;;) { + Frame frame; + { + std::unique_lock lock(control_queue_mu_); + control_queue_cv_.wait(lock, [&] { + return stop_requested_.load(std::memory_order_acquire) || !control_queue_.empty(); + }); + if (stop_requested_.load(std::memory_order_acquire)) return; + frame = std::move(control_queue_.front()); + control_queue_.pop_front(); + } + std::vector> hits; + { std::lock_guard lock(control_sub_mu_); hits = control_subs_; } + for (const auto& sub : hits) { + { + std::lock_guard lock(sub->mutex); + if (!sub->active || frame.receive_time < sub->since) continue; + ++sub->inflight; + } + try { sub->cb(frame); } + catch (...) { stat_callback_exceptions_.fetch_add(1, std::memory_order_relaxed); } + { + std::lock_guard lock(sub->mutex); + --sub->inflight; + } + sub->idle.notify_all(); + } + } +} + void Transport::fail_pending_(const std::string& reason) noexcept { std::lock_guard lk(pending_mu_); for (auto& kv : pending_acks_) { diff --git a/cpp/src/components/encoder.cpp b/cpp/src/components/encoder.cpp index 47cff26..9bc8c1f 100644 --- a/cpp/src/components/encoder.cpp +++ b/cpp/src/components/encoder.cpp @@ -49,6 +49,7 @@ EncoderSample Encoder::read_once(std::chrono::milliseconds timeout) { protocol::to_string(ack.error_code)); } auto s = decode(ack.data.data(), ack.data.size()); + s.host_time = ack.receive_time; normalize(s); return s; } @@ -135,6 +136,7 @@ Encoder::SubId Encoder::on_data(Callback cb) { [this, cb = std::move(cb)](const bus::Frame& f) { try { auto s = decode(f.payload.data(), f.payload.size()); + s.host_time = f.receive_time; normalize(s); cb(s); } catch (...) {} diff --git a/cpp/src/components/imu.cpp b/cpp/src/components/imu.cpp index 2d8145d..f788824 100644 --- a/cpp/src/components/imu.cpp +++ b/cpp/src/components/imu.cpp @@ -139,7 +139,7 @@ ImuSample IMU::read_once(std::chrono::milliseconds timeout) { } if (ack.data.size() != (cmd == protocol::Cmd::GetImuV2 ? 44U : 28U)) throw ProtocolError("IMU response does not match negotiated format"); - return polling_time_.decode(ack.data.data(), ack.data.size()); + return polling_time_.decode(ack.data.data(), ack.data.size(), ack.receive_time); } IMU::SubId IMU::on_data(Callback cb) { @@ -159,7 +159,7 @@ IMU::SubId IMU::on_data(Callback cb) { // Legacy devices may send the identical cache at 1000 Hz // although acquisition is 100 Hz. Do not integrate it twice. if (f.payload == state->last_payload) return; - auto sample = state->timeline.decode(f.payload.data(), f.payload.size()); + auto sample = state->timeline.decode(f.payload.data(), f.payload.size(), f.receive_time); state->last_payload = f.payload; cb(sample); } catch (...) {} diff --git a/cpp/src/components/motor.cpp b/cpp/src/components/motor.cpp index 2b9bbfa..6fd8bba 100644 --- a/cpp/src/components/motor.cpp +++ b/cpp/src/components/motor.cpp @@ -127,7 +127,9 @@ MotorStatusSample Motor::read_status(std::chrono::milliseconds timeout) { throw ProtocolError(std::string("Motor::read_status NACK: ") + protocol::to_string(ack.error_code)); } - return decode(ack.data.data(), ack.data.size()); + auto sample = decode(ack.data.data(), ack.data.size()); + sample.host_time = ack.receive_time; + return sample; } protocol::MotorStatusExt Motor::read_status_ext(std::chrono::milliseconds timeout) { @@ -310,19 +312,26 @@ bool is_stray_fault_reply_frame(bool seen_real_temp, float motor_temp_c) noexcep } // namespace detail Motor::SubId Motor::on_status(Callback cb) { + return subscribe_status_(std::move(cb), false); +} + +Motor::SubId Motor::on_control_status_(Callback cb) { + return subscribe_status_(std::move(cb), true); +} + +Motor::SubId Motor::subscribe_status_(Callback cb, bool control) { // Per subscription: the dispatch thread is the only caller, so no lock. auto seen_real_temp = std::make_shared(false); auto dropped = std::make_shared(0); - return t_.subscribe( - protocol::Cmd::GetMotorStatus, - [cb = std::move(cb), seen_real_temp, dropped](const bus::Frame& f) { + auto callback = [cb = std::move(cb), seen_real_temp, dropped, control](const bus::Frame& f) { try { - const MotorStatusSample s = decode(f.payload.data(), f.payload.size()); + MotorStatusSample s = decode(f.payload.data(), f.payload.size()); + s.host_time = f.receive_time; if (detail::is_stray_fault_reply_frame(*seen_real_temp, s.motor_temp_c)) { // Rate-limited: a closing move on an affected unit draws a few // per second. Powers of two keep the count visible. const std::uint64_t n = ++*dropped; - if ((n & (n - 1)) == 0) { + if (!control && (n & (n - 1)) == 0) { logger()->warn( "Motor::on_status: dropped {} status frame(s) that were not " "status (temperature 0.0, pos {:.4f} rad, torque {:.2f} N*m): " @@ -336,7 +345,9 @@ Motor::SubId Motor::on_status(Callback cb) { if (s.motor_temp_c != 0.0f) *seen_real_temp = true; cb(s); } catch (...) {} - }); + }; + return control ? t_.subscribe_control_(std::move(callback)) + : t_.subscribe(protocol::Cmd::GetMotorStatus, std::move(callback)); } void Motor::off(SubId id) { t_.unsubscribe(id); } diff --git a/cpp/src/force_position_controller.cpp b/cpp/src/force_position_controller.cpp index 028e48f..2dcf1a5 100644 --- a/cpp/src/force_position_controller.cpp +++ b/cpp/src/force_position_controller.cpp @@ -182,6 +182,15 @@ bool has_serious_fault(uint16_t status) noexcept { return (status & mask) != 0; } +const char* status_fault_reason(const MotorStatusSample& sample, float torque_limit) noexcept { + if (!std::isfinite(sample.actual_pos) || !std::isfinite(sample.actual_vel) || + !std::isfinite(sample.actual_torque)) return "non-finite motor status"; + if (has_serious_fault(sample.status)) return "motor status reports a fault"; + if (std::abs(sample.actual_torque) > torque_limit + 1e-4f) + return "measured torque exceeded motion_torque_limit_nm"; + return nullptr; +} + } // namespace const char* to_string(ForcePositionState state) noexcept { @@ -770,15 +779,7 @@ protocol::MotorImpedanceCtrl ForcePositionPolicy::travel_track_( protocol::MotorImpedanceCtrl ForcePositionPolicy::step( const MotorStatusSample& sample, std::chrono::steady_clock::time_point now) { - if (!std::isfinite(sample.actual_pos) || !std::isfinite(sample.actual_vel) || - !std::isfinite(sample.actual_torque)) { - fail("non-finite motor status"); - } else if (has_serious_fault(sample.status)) { - fail("motor status reports a fault"); - } else if (std::abs(sample.actual_torque) > - cfg_.motion_torque_limit_nm + 1e-4f) { - fail("measured torque exceeded motion_torque_limit_nm"); - } + if (const char* reason = status_fault_reason(sample, cfg_.motion_torque_limit_nm)) fail(reason); if (state_ == ForcePositionState::Fault || state_ == ForcePositionState::Idle) { arrived_ = false; @@ -1161,7 +1162,9 @@ void ForcePositionController::start() { policy_->reset(initial, now); queue_.clear(); latest_ = initial; - latest_time_ = now; + latest_time_ = initial.host_time; + pending_status_fault_ = status_fault_reason(initial, cfg_.motion_torque_limit_nm); + feedback_epoch_ = now; have_sample_ = true; observation_ = GripperObservation{}; observation_.valid = true; @@ -1178,8 +1181,16 @@ void ForcePositionController::start() { stall_limit_nm_ = stall_limit_nm; } - sub_ = g_.motor().on_status( - [this](const MotorStatusSample& sample) { on_status_(sample); }); + try { + sub_ = g_.motor().on_control_status_( + [this](const MotorStatusSample& sample) { on_status_(sample); }); + } catch (...) { + std::lock_guard lock(mu_); + stop_requested_ = true; + have_sample_ = false; + observation_.valid = false; + throw; + } sub_active_ = true; try { start_motor_stream_(); @@ -1259,7 +1270,6 @@ void ForcePositionController::stop() { } void ForcePositionController::on_status_(const MotorStatusSample& sample) { - const auto now = std::chrono::steady_clock::now(); { std::lock_guard lk(mu_); // stop() unsubscribes only AFTER the zero frame and the disable ACK, so @@ -1268,8 +1278,21 @@ void ForcePositionController::on_status_(const MotorStatusSample& sample) { // observation valid again -- ImpedanceControllerPty.StopCommandsZeroTorque // caught it roughly one run in five under CPU load. if (stop_requested_) return; + const auto now = std::chrono::steady_clock::now(); + if (sample.host_time.time_since_epoch().count() <= 0 || + sample.host_time > now || sample.host_time < feedback_epoch_) return; + // Preserve safety evidence before latest-value coalescing/age filtering. + // No logging, allocation or policy execution on the control worker. + if (!pending_status_fault_) + pending_status_fault_ = status_fault_reason(sample, cfg_.motion_torque_limit_nm); + if (pending_status_fault_) { + step_requested_ = true; + cv_.notify_one(); + } + if ((have_sample_ && sample.host_time < latest_time_) || + now - sample.host_time >= std::chrono::milliseconds(cfg_.status_timeout_ms)) return; latest_ = sample; - latest_time_ = now; + latest_time_ = sample.host_time; have_sample_ = true; observation_.valid = true; observation_.position = map_.to_position(sample.actual_pos); @@ -1372,6 +1395,8 @@ void ForcePositionController::reset() { throw std::logic_error("ForcePositionController::reset called before start"); } policy_->reset(latest_, std::chrono::steady_clock::now()); + pending_status_fault_ = nullptr; + feedback_epoch_ = std::chrono::steady_clock::now(); // The cached sample may still carry the fault bit that was true immediately // before Motor::clear_fault() ACKed. Wait for the next streamed status rather // than re-faulting the freshly reset policy on stale evidence. @@ -1424,6 +1449,11 @@ void ForcePositionController::run_() { command_woke_ = false; const auto now = std::chrono::steady_clock::now(); if (!policy_ || !have_sample_) continue; + if (pending_status_fault_) { + if (policy_->state() != ForcePositionState::Fault) policy_->fail(pending_status_fault_); + pending_status_fault_ = nullptr; + step_requested_ = true; + } // Staleness is checked unconditionally. Doing it only in the // !step_requested_ branch let set_target()/release()/hold_position() diff --git a/cpp/src/impedance_controller.cpp b/cpp/src/impedance_controller.cpp index d85052f..2f6b008 100644 --- a/cpp/src/impedance_controller.cpp +++ b/cpp/src/impedance_controller.cpp @@ -115,6 +115,15 @@ bool has_serious_fault(uint16_t status) noexcept { return (status & mask) != 0; } +const char* status_fault_reason(const MotorStatusSample& sample, float torque_limit) noexcept { + if (!std::isfinite(sample.actual_pos) || !std::isfinite(sample.actual_vel) || + !std::isfinite(sample.actual_torque)) return "non-finite motor status"; + if (has_serious_fault(sample.status)) return "motor status reports a fault"; + if (std::abs(sample.actual_torque) > torque_limit + 1e-4f) + return "measured torque exceeded the motor's torque range"; + return nullptr; +} + } // namespace const char* to_string(ImpedanceState state) noexcept { @@ -251,14 +260,7 @@ protocol::MotorImpedanceCtrl ImpedancePolicy::zero_(const MotorStatusSample& s) protocol::MotorImpedanceCtrl ImpedancePolicy::step( const MotorStatusSample& sample, std::chrono::steady_clock::time_point now) { - if (!std::isfinite(sample.actual_pos) || !std::isfinite(sample.actual_vel) || - !std::isfinite(sample.actual_torque)) { - fail("non-finite motor status"); - } else if (has_serious_fault(sample.status)) { - fail("motor status reports a fault"); - } else if (std::abs(sample.actual_torque) > cfg_.peak_torque_nm + 1e-4f) { - fail("measured torque exceeded the motor's torque range"); - } + if (const char* reason = status_fault_reason(sample, cfg_.peak_torque_nm)) fail(reason); if (state_ == ImpedanceState::Fault || state_ == ImpedanceState::Idle) { return zero_(sample); @@ -490,7 +492,9 @@ void ImpedanceController::start() { policy_ = std::make_unique(map_, cfg_); policy_->reset(initial); // seeds the target with where we are latest_ = initial; - latest_time_ = now; + latest_time_ = initial.host_time; + pending_status_fault_ = status_fault_reason(initial, cfg_.peak_torque_nm); + feedback_epoch_ = now; have_sample_ = true; observation_ = GripperObservation{}; observation_.valid = true; @@ -506,8 +510,16 @@ void ImpedanceController::start() { device_limit_nm_ = device_limit; } - sub_ = g_.motor().on_status( - [this](const MotorStatusSample& sample) { on_status_(sample); }); + try { + sub_ = g_.motor().on_control_status_( + [this](const MotorStatusSample& sample) { on_status_(sample); }); + } catch (...) { + std::lock_guard lock(mu_); + stop_requested_ = true; + have_sample_ = false; + observation_.valid = false; + throw; + } sub_active_ = true; try { start_motor_stream_(); @@ -575,7 +587,6 @@ void ImpedanceController::stop() { } void ImpedanceController::on_status_(const MotorStatusSample& sample) { - const auto now = std::chrono::steady_clock::now(); { std::lock_guard lk(mu_); // stop() unsubscribes only AFTER the zero frame and the disable ACK, so @@ -584,8 +595,19 @@ void ImpedanceController::on_status_(const MotorStatusSample& sample) { // observation valid again -- ImpedanceControllerPty.StopCommandsZeroTorque // caught it roughly one run in five under CPU load. if (stop_requested_) return; + const auto now = std::chrono::steady_clock::now(); + if (sample.host_time.time_since_epoch().count() <= 0 || + sample.host_time > now || sample.host_time < feedback_epoch_) return; + if (!pending_status_fault_) + pending_status_fault_ = status_fault_reason(sample, cfg_.peak_torque_nm); + if (pending_status_fault_) { + step_requested_ = true; + cv_.notify_one(); + } + if ((have_sample_ && sample.host_time < latest_time_) || + now - sample.host_time >= std::chrono::milliseconds(cfg_.status_timeout_ms)) return; latest_ = sample; - latest_time_ = now; + latest_time_ = sample.host_time; have_sample_ = true; observation_.valid = true; observation_.position = map_.to_position(sample.actual_pos); @@ -640,6 +662,8 @@ void ImpedanceController::reset() { throw std::logic_error("ImpedanceController::reset called before start"); } policy_->reset(latest_); + pending_status_fault_ = nullptr; + feedback_epoch_ = std::chrono::steady_clock::now(); // The cached sample may still carry the fault bit that was true immediately // before Motor::clear_fault() ACKed. Wait for the next streamed status // rather than re-faulting the freshly reset policy on stale evidence. @@ -689,6 +713,11 @@ void ImpedanceController::run_() { command_woke_ = false; const auto now = std::chrono::steady_clock::now(); if (!policy_ || !have_sample_) continue; + if (pending_status_fault_) { + if (policy_->state() != ImpedanceState::Fault) policy_->fail(pending_status_fault_); + pending_status_fault_ = nullptr; + step_requested_ = true; + } const bool stale = now - latest_time_ >= timeout; if (stale) { diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index 636fbef..c8a410a 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -82,6 +82,12 @@ class FakeFollower { status_.status = bits; } + // Next polling ACK can carry a fault while subsequent DATA is healthy. + void set_next_polled_status_word(uint16_t bits) { + std::lock_guard lk(mu_); + next_polled_status_word_ = bits; + } + std::optional last_submit() const { std::lock_guard lk(mu_); return last_submit_; @@ -333,8 +339,13 @@ class FakeFollower { } case tp::Cmd::GetMotorStatus: { std::lock_guard lk(mu_); + auto reply = status_; + if (next_polled_status_word_) { + reply.status = *next_polled_status_word_; + next_polled_status_word_.reset(); + } pty_.send_response(f.seq, f.cmd, - pod_bytes(&status_, sizeof(status_))); + pod_bytes(&reply, sizeof(reply))); return; } case tp::Cmd::TimeSession: { @@ -428,6 +439,7 @@ class FakeFollower { std::string sn_ = "TCGU01A28Z0001s"; mutable std::mutex mu_; tp::MotorStatus status_{}; + std::optional next_polled_status_word_; std::optional last_submit_; // Factory state: a valid travel calibration and a ZEROED envelope, which diff --git a/cpp/tests/test_frame.cpp b/cpp/tests/test_frame.cpp index d620042..8cda4bd 100644 --- a/cpp/tests/test_frame.cpp +++ b/cpp/tests/test_frame.cpp @@ -534,6 +534,57 @@ TEST(HardwareFrameReplay, CapturedShortMotorFrameIsRejectedBeforeCrc) { EXPECT_EQ(parser.stats().resync_bytes, bad.size()); EXPECT_EQ(parser.stats().crc_errors, 0u) << "zero CRC errors cannot establish intact input when LEN already fails"; + EXPECT_EQ(parser.stats().bad_length, 1u); +} + +TEST(FrameTrace, DisabledByDefaultAndRawBytesPreservedAcrossReads) { + tb::FrameParser parser; + const auto bad = captured_short_motor_frame(); + parser.feed(bad); + EXPECT_TRUE(parser.take_trace().records.empty()); + parser.set_trace_enabled(true); + const auto good = captured_stream(captured_good_frames()); + parser.feed(good.data(), 13); + parser.feed(good.data() + 13, good.size() - 13); + parser.feed(bad); + const auto snapshot = parser.take_trace(); + std::vector input; + bool saw_length = false; + uint64_t offset = bad.size(); + for (const auto& r : snapshot.records) { + if (r.kind == tb::FrameParser::TraceRecord::Kind::Input) { + EXPECT_EQ(r.offset, offset); + offset += r.size; + input.insert(input.end(), r.bytes.begin(), r.bytes.begin() + r.size); + } else if (r.reason == tb::ParseReason::BadLength) { + saw_length = true; + EXPECT_EQ(r.offset, bad.size() + good.size()); + EXPECT_EQ(r.available, bad.size()); + EXPECT_EQ(r.bytes[0], 0xAA); + } + } + auto expected = good; + expected.insert(expected.end(), bad.begin(), bad.end()); + EXPECT_EQ(input, expected); + EXPECT_TRUE(saw_length); + EXPECT_EQ(snapshot.overwritten_records, 0u); + EXPECT_TRUE(parser.take_trace().records.empty()); +} + +TEST(FrameTrace, BoundedRingReportsOverwriteAndDisableClearsEvidence) { + tb::FrameParser parser; + parser.set_trace_enabled(true); + const auto frame = captured_good_frames().front().wire; + for (unsigned i = 0; i < 300; ++i) parser.feed(frame); + const auto snapshot = parser.take_trace(); + ASSERT_EQ(snapshot.records.size(), 256u); + EXPECT_EQ(snapshot.overwritten_records, 44u); + EXPECT_EQ(snapshot.records.front().offset, 44 * frame.size()); + EXPECT_EQ(snapshot.records.back().offset, 299 * frame.size()); + EXPECT_EQ(parser.pending(), 300u); + parser.set_trace_enabled(false); + parser.feed(frame); + EXPECT_TRUE(parser.take_trace().records.empty()); } TEST(HardwareFrameReplay, RecoversAfterCapturedDamageAtEverySingleSplit) { diff --git a/cpp/tests/test_imu_motor_concurrency.cpp b/cpp/tests/test_imu_motor_concurrency.cpp index e5f7d7a..95b810c 100644 --- a/cpp/tests/test_imu_motor_concurrency.cpp +++ b/cpp/tests/test_imu_motor_concurrency.cpp @@ -1,7 +1,7 @@ // Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 // -// IMU and motor DATA share a dispatcher, while ACK matching and controller -// safety timeouts run independently. Exercise that boundary through a PTY: +// User IMU delivery must not block control feedback or hide its receive age. +// Exercise that boundary through a PTY: // the fake models packet flow, not firmware scheduling or physical motion. #include "fake_follower.hpp" @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -153,7 +154,7 @@ void controller_keeps_preexisting_imu_stream() { } template -void slow_imu_callback_triggers_safe_timeout() { +void slow_imu_callback_isolated_and_real_loss_times_out() { Pty pty; ASSERT_GE(pty.master(), 0); FakeFollower fw(pty); @@ -179,9 +180,17 @@ void slow_imu_callback_triggers_safe_timeout() { const auto submits_before = fw.submit_count(); const auto frames_before = g->transport().stats().frames_received; - // A user callback stalls motor delivery, even though the firmware and - // reader still run. ACK matching must remain usable during the stall. + // A blocked user callback must not stall control feedback. Keep it blocked + // for several status timeouts, while the independent control lane advances. EXPECT_NO_THROW((void)g->device().heartbeat(Ms(500))); + ASSERT_TRUE(wait_for([&] { return fw.submit_count() >= submits_before + 30; }, Ms(2000))); + EXPECT_NE(controller.state(), ControllerTraits::fault); + EXPECT_TRUE(controller.snapshot().observation.valid); + EXPECT_FALSE(gate->completed.load()); + + // Real loss, not a user callback, must still trip the existing failsafe. + // Backlogged user DATA must never refresh the controller on release. + fw.freeze_stream(true); ASSERT_TRUE(wait_for([&] { return controller.state() == ControllerTraits::fault && fw.submit_count() > submits_before && @@ -218,12 +227,12 @@ TEST(ImuMotorConcurrency, ImpedanceKeepsPreexistingCombinedStream) { controller_keeps_preexisting_imu_stream(); } -TEST(ImuMotorConcurrency, ForcePositionSlowImuCallbackFaultsWithZeroTorque) { - slow_imu_callback_triggers_safe_timeout(); +TEST(ImuMotorConcurrency, ForcePositionSlowImuIsIsolatedAndRealLossFaults) { + slow_imu_callback_isolated_and_real_loss_times_out(); } -TEST(ImuMotorConcurrency, ImpedanceSlowImuCallbackFaultsWithZeroTorque) { - slow_imu_callback_triggers_safe_timeout(); +TEST(ImuMotorConcurrency, ImpedanceSlowImuIsIsolatedAndRealLossFaults) { + slow_imu_callback_isolated_and_real_loss_times_out(); } TEST(ImuMotorConcurrency, CloseJoinsActiveImuCallbackAndReleasesCaptures) { @@ -269,3 +278,184 @@ TEST(ImuMotorConcurrency, CloseJoinsActiveImuCallbackAndReleasesCaptures) { << "close must discard queued DATA instead of entering more callbacks"; EXPECT_GT(g->transport().stats().queue_dropped, 0u); } + +TEST(ImuMotorConcurrency, QueuedUserSamplesKeepReceiveTime) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto gate = std::make_shared(); + auto g = open_follower(pty); + ReleaseCallbackOnExit release_on_exit{gate}; + std::atomic released{false}; + std::atomic old_motor{0}, old_imu{0}; + const auto motor_sub = g->motor().on_status([&](const tx::MotorStatusSample& s) { + if (released && std::chrono::steady_clock::now() - s.host_time >= Ms(80)) ++old_motor; + }); + const auto imu_sub = g->imu().on_data([&](const tx::ImuSample& s) { + (*gate)(); + if (released && std::chrono::steady_clock::now() - s.host_time >= Ms(80)) ++old_imu; + }); + gate->arm(); + g->start_streaming(100, 100); + ASSERT_TRUE(gate->wait_until_entered()); + auto before = g->transport().stats().frames_received; + ASSERT_TRUE(wait_for([&] { return g->transport().stats().frames_received >= before + 20; }, Ms(1000))); + fw.freeze_stream(true); + std::this_thread::sleep_for(Ms(120)); + released = true; + gate->release(); + EXPECT_TRUE(wait_for([&] { return old_motor >= 5 && old_imu >= 5; }, Ms(1000))); + g->motor().off(motor_sub); + g->imu().off(imu_sub); + g->close(); +} + +template +void sustained_slow_user_callbacks_do_not_retime_control() { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto g = open_follower(pty); + const auto imu = g->imu().on_data([](const tx::ImuSample&) { std::this_thread::sleep_for(Ms(20)); }); + const auto motor = g->motor().on_status([](const tx::MotorStatusSample&) { std::this_thread::sleep_for(Ms(5)); }); + g->start_streaming(100, 100); + typename ControllerTraits::Config config; + config.status_timeout_ms = 150; + Controller controller(*g, config); + controller.start(); + const auto before = fw.submit_count(); + ASSERT_TRUE(wait_for([&] { return fw.submit_count() >= before + 60; }, Ms(1200))); + EXPECT_NE(controller.state(), ControllerTraits::fault); + EXPECT_TRUE(controller.snapshot().observation.valid); + EXPECT_EQ(g->transport().stats().control_queue_overflows, 0u); + fw.freeze_stream(true); + // State changes before submit() and the PTY peer consumes it. Observe both + // within the original deadline instead of racing the asynchronous send. + EXPECT_TRUE(wait_for([&] { + return controller.state() == ControllerTraits::fault && + is_zero_torque(fw.last_submit()); + }, Ms(500))); + controller.stop(); + g->motor().off(motor); + g->imu().off(imu); + g->close(); +} + +TEST(ImuMotorConcurrency, ForcePositionSustainedSlowCallbacksDoNotHideFeedbackLoss) { + sustained_slow_user_callbacks_do_not_retime_control(); +} + +TEST(ImuMotorConcurrency, ImpedanceSustainedSlowCallbacksDoNotHideFeedbackLoss) { + sustained_slow_user_callbacks_do_not_retime_control(); +} + +TEST(ImuMotorConcurrency, ControlQueueOverflowLatchesClosedAndRequiresReopen) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + tx::ForcePositionConfig config; + config.status_timeout_ms = 200; + tx::ForcePositionController controller(*g, config); + controller.start(); + ASSERT_TRUE(wait_for([&] { return fw.submit_count() >= 4; }, Ms(1000))); + fw.freeze_stream(true); + // A deliberately faster-than-real-wire burst exercises overload handling, + // not physical throughput. Each write stays below the PTY buffer size. + tp::MotorStatus status{}; + status.actual_pos = 0.60f; + const auto payload = taccap_test::pod_bytes(&status, sizeof(status)); + std::vector burst; + for (unsigned i = 0; i < 64; ++i) { + const auto frame = tx::bus::pack_frame(tp::Address::MCU, uint8_t(i), tp::FrameType::DATA, + tp::Cmd::GetMotorStatus, payload); + burst.insert(burst.end(), frame.begin(), frame.end()); + } + for (unsigned i = 0; i < 40 && !g->transport().stats().control_queue_overflows; ++i) { + pty.send_raw(burst); + std::this_thread::sleep_for(Ms(5)); + } + ASSERT_GT(g->transport().stats().control_queue_overflows, 0u); + EXPECT_GT(g->transport().stats().control_queue_dropped, 0u); + EXPECT_LE(g->transport().stats().control_queue_high_water, 8u); + fw.freeze_stream(false); + ASSERT_TRUE(wait_for([&] { + return controller.state() == tx::ForcePositionState::Fault && + is_zero_torque(fw.last_submit()); + }, Ms(1000))); + controller.stop(); + const auto stopped_submits = fw.submit_count(); + EXPECT_THROW(controller.start(), tx::IoError); + EXPECT_FALSE(controller.running()); + EXPECT_FALSE(controller.snapshot().observation.valid); + EXPECT_EQ(fw.submit_count(), stopped_submits); + g->close(); +} + +template +void fault_then_healthy_cannot_hide_safety_evidence() { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + Controller controller(*g); + controller.start(); + ASSERT_TRUE(wait_for([&] { return fw.submit_count() >= 4; }, Ms(1000))); + fw.freeze_stream(true); + tp::MotorStatus healthy{}; + healthy.actual_pos = 0.60f; + for (unsigned kind = 0; kind < 3; ++kind) { + auto bad = healthy; + if (kind == 0) bad.status = tp::MotorStatusBit::OverCurrent; + if (kind == 1) bad.actual_vel = std::numeric_limits::quiet_NaN(); + if (kind == 2) bad.actual_torque = 19.0f; + auto burst = tx::bus::pack_frame(tp::Address::MCU, 1, tp::FrameType::DATA, + tp::Cmd::GetMotorStatus, taccap_test::pod_bytes(&bad, sizeof(bad))); + const auto good = tx::bus::pack_frame(tp::Address::MCU, 2, tp::FrameType::DATA, + tp::Cmd::GetMotorStatus, taccap_test::pod_bytes(&healthy, sizeof(healthy))); + burst.insert(burst.end(), good.begin(), good.end()); + pty.send_raw(burst); + ASSERT_TRUE(wait_for([&] { return controller.state() == ControllerTraits::fault; }, Ms(1000))); + const auto reason = controller.snapshot().fault_reason; + EXPECT_NE(reason.find(kind == 0 ? "reports a fault" : kind == 1 ? "non-finite" : "torque exceeded"), std::string::npos); + EXPECT_TRUE(wait_for([&] { return is_zero_torque(fw.last_submit()); }, Ms(1000))); + EXPECT_EQ(g->transport().stats().control_queue_overflows, 0u); + controller.reset(); // explicit reset establishes a new receive epoch + } + controller.stop(); + g->close(); +} + +TEST(ImuMotorConcurrency, ForcePositionFaultThenHealthyBurstStaysFaulted) { + fault_then_healthy_cannot_hide_safety_evidence(); +} + +TEST(ImuMotorConcurrency, ImpedanceFaultThenHealthyBurstStaysFaulted) { + fault_then_healthy_cannot_hide_safety_evidence(); +} + +template +void initial_fault_ack_is_not_overwritten_by_healthy_stream() { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + fw.set_next_polled_status_word(tp::MotorStatusBit::OverCurrent); + Controller controller(*g); + controller.start(); + EXPECT_TRUE(wait_for([&] { return controller.state() == ControllerTraits::fault; }, Ms(1000))); + EXPECT_EQ(controller.snapshot().fault_reason, "motor status reports a fault"); + EXPECT_TRUE(wait_for([&] { return is_zero_torque(fw.last_submit()); }, Ms(1000))); + controller.stop(); + g->close(); +} + +TEST(ImuMotorConcurrency, ForcePositionInitialFaultAckIsLatched) { + initial_fault_ack_is_not_overwritten_by_healthy_stream(); +} + +TEST(ImuMotorConcurrency, ImpedanceInitialFaultAckIsLatched) { + initial_fault_ack_is_not_overwritten_by_healthy_stream(); +} diff --git a/docs/IMU_MOTOR_TESTING.md b/docs/IMU_MOTOR_TESTING.md index b88b009..09df553 100644 --- a/docs/IMU_MOTOR_TESTING.md +++ b/docs/IMU_MOTOR_TESTING.md @@ -10,7 +10,19 @@ 配套固件 `App/tasks/task_imu.c`、`task_slave_motor_control.c`、`task_data_stream.c`、`slave_imu.c` 中,电机控制、数据流、从爪 IMU 的线程优先级分别为 8、11、12;电机控制周期为 2 ms,电机状态最高 100 Hz;同时到期时先发电机状态。IMU 采样失败不将旧包伪装成新样本,也不主动停止电机控制。这些设计降低了干扰,但共享 CPU、UART 和主机处理仍可能产生时延。 -SDK 的 motor 与 IMU 回调共享 Transport 分发线程。慢 IMU 回调会拖延电机状态回调和控制器目标更新,即使串口读取线程仍在接收。当前控制器按分发时刻刷新状态年龄,积压的旧反馈仍可能使 `snapshot().observation.age_ms` 很小、`valid` 为真;这些字段与 TRACKING 状态不能单独用于实时性验收。必须同时测量设备反馈时间、主机接收/分发积压和目标更新节奏。软件替身测试不能证明真实 RTOS 抢占、SPI/CAN/UART 时序、电源稳定或夹爪运动质量。 +当前修复版将 SDK 内部电机控制反馈放入独立有界队列和工作线程;普通 motor/IMU 用户回调仍共享用户分发线程,慢回调会延迟用户观测,但不再阻塞两个控制器的反馈入口。控制队列溢出会锁存关闭该通道、停止继续交付反馈,由控制器现有状态超时逻辑进入 Fault/零输出;必须重建 Transport 才能清除锁存,不能自动恢复运动。`control_queue_overflows`、`control_queue_dropped` 和 `control_queue_high_water` 可观测此边界。 + +控制回调还会按现有策略的严重故障位、非有限数值及超量程力矩条件锁存安全异常,避免同批次的后续健康帧覆盖故障;判据及阈值不变,日志与策略处理仍在控制器线程。属于当前控制会话但已过期的故障可锁存,却不能刷新反馈年龄;显式 `reset()` 建立新接收边界,忽略 reset 前积压帧。 + +Frame/ACK 的本机单调接收时间贯穿 IMU、motor、encoder 样本至控制器;完整帧以其最后字节所在 `read()` 返回时刻为准,不再在延迟分发时重标为“现在”。控制器拒绝过期、未来或倒序的反馈。`host_time` 仍不是硬件采样/相机曝光时间,串口之前的缓存延迟和 MCU 重复缓存反馈仍须结合设备时间戳判断。直接调用静态 `decode()` 无接收上下文,保留调用时刻的旧行为。 + +历史 `c06bc34` 的共享控制/用户队列及分发时刻计龄缺陷,保留在已有测试报告中,不应当作修复版结果。需同源重建 C++ core 和 Python native(结构布局已扩展),不能把新 Python 与旧 `.so` 混用。软件替身测试不证明真实 RTOS 抢占、SPI/CAN/UART 时序、电源稳定或夹爪运动质量;仍须测量设备反馈时间、主机接收/分发积压和目标更新节奏。 + +### 原始接收证据(默认关闭) + +`g.transport.enable_receive_trace(True)` 开启 SDK 解析前字节与拒帧记录,存入最多 256 条内存环;不在接收线程调用用户回调或写磁盘。完成采集后建议先停流,再调用 `g.transport.take_receive_trace()` 导出、随后 `enable_receive_trace(False)` 关闭。导出结果含 `records` 和 `overwritten_records`:覆盖计数不为零说明早期记录已丢失,不能宣称完整捕获。 + +每条记录给出 `kind`(input/reject)、`host_time_ns`、全局字节 `offset`、`available`、`consumed` 及 `bytes`。原始输入较大时拆分保存;拒帧保存最多 1024 字节前缀,按 offset 对齐 input。`reason` 数值:0 无错误、1 无帧头、2 超长、3 短正文、4 长度不符、5 CRC不符。Transport Stats 同时提供 `parser_no_head/oversize/short_body/bad_length`;长度检查先于 CRC,故 `crc_errors=0` 不能排除损坏输入。此证据只能证明字节到达 SDK 之前的状态;MCU 发送、桥接器、线路责任仍需独立抓取对齐。 ## 2. 设备、环境与运行身份 @@ -206,7 +218,7 @@ finally: | 测试 | 证明范围 | | --- | --- | | [test_follower_imu_pty.cpp](../cpp/tests/test_follower_imu_pty.cpp) | motor-only 默认、旧固件拒绝不改流、双回调、非法频率和校准协议流程 | -| [test_imu_motor_concurrency.cpp](../cpp/tests/test_imu_motor_concurrency.cpp) | 两类控制器保留预先开启的混合流;特定慢回调时序下故障/零输出;活动回调期间关闭的生命周期,共 5 项。不能据此推断持续积压时能及时识别旧反馈 | +| [test_imu_motor_concurrency.cpp](../cpp/tests/test_imu_motor_concurrency.cpp) | 两类控制器保留混合流;阻塞/持续慢用户回调与控制隔离,真实断流仍零输出;旧用户样本保持接收时间;控制队列溢出锁存与重启拒绝;故障后紧跟健康帧仍锁存;关闭生命周期。PTY 不代表真实调度/运动验收 | | [test_imu_time.cpp](../cpp/tests/test_imu_time.cpp)、[test_imu_time.py](../python/tests/test_imu_time.py) | 时间协商、回绕/歧义、会话、重复样本等协议与绑定行为 | | [test_transport_concurrent_write.cpp](../cpp/tests/test_transport_concurrent_write.cpp)、[test_dispatch_decoupling.py](../python/tests/test_dispatch_decoupling.py)、[test_port_release.py](../python/tests/test_port_release.py) | 并发写入、读/分发分离、端口释放相关行为 | | [control_ripple.py](../python/examples/control_ripple.py) | 实机速度纹波统计可供复用;现有脚本包含实际运动且无 IMU A/B 参数,不能直接等同本文完整验收 | @@ -234,6 +246,8 @@ env -u PYTHONPATH python -m pytest python/tests -q ctest --test-dir build --output-on-failure ``` +若不能覆盖源码目录中正在使用的 native,可将上述配置增加 `-DSKBUILD=ON`,使扩展保留在独立构建目录 `build/python/`;随后用 `TACCAP_TEST_NATIVE_DIR="$(pwd)/build/python" python -m pytest python/tests -q`。该测试入口严格验证实际 native 路径,无效目录立即拒绝。仍须用 `ldd` 或进程模块映射确认 core 也来自同一构建,不要用这个入口绕过来源核验;默认未设置时继续要求源码目录的 native。 + 若另有对应固件仓库 checkout,从该仓库根目录执行,不依赖某台开发机的绝对路径: ```bash diff --git a/python/bindings/module.cpp b/python/bindings/module.cpp index 68f54ad..803a13d 100644 --- a/python/bindings/module.cpp +++ b/python/bindings/module.cpp @@ -322,20 +322,22 @@ PYBIND11_MODULE(_taccap_native, m) { .def_readonly("unexpected_frames", &tb::Transport::Stats::unexpected_frames) .def_readonly("callback_exceptions", &tb::Transport::Stats::callback_exceptions) // ---- Loss accounting ------------------------------------------ - // Read together these localise a rate drop: - // crc_errors / resync_bytes rising -> bytes lost before the parser - // (the host stopped draining the tty long enough to overflow - // the kernel/USB buffers). Check callback_max_us for the cause. - // queue_dropped rising -> bytes were fine, the - // subscriber could not keep up and stale frames were evicted. - // both flat, rate still low -> the firmware really is - // sending slower; nothing host-side to fix. + // Counters identify the rejecting/dropping stage, not the original + // cause. Length errors can reject bytes before CRC; raw receive trace + // and independent MCU/link evidence are needed to locate damage. .def_readonly("crc_errors", &tb::Transport::Stats::crc_errors) .def_readonly("resync_bytes", &tb::Transport::Stats::resync_bytes) .def_readonly("parser_overflow_bytes", &tb::Transport::Stats::parser_overflow_bytes) + .def_readonly("parser_no_head", &tb::Transport::Stats::parser_no_head) + .def_readonly("parser_oversize", &tb::Transport::Stats::parser_oversize) + .def_readonly("parser_short_body", &tb::Transport::Stats::parser_short_body) + .def_readonly("parser_bad_length", &tb::Transport::Stats::parser_bad_length) .def_readonly("queue_dropped", &tb::Transport::Stats::queue_dropped) .def_readonly("queue_high_water", &tb::Transport::Stats::queue_high_water) .def_readonly("callback_max_us", &tb::Transport::Stats::callback_max_us) + .def_readonly("control_queue_dropped", &tb::Transport::Stats::control_queue_dropped) + .def_readonly("control_queue_high_water", &tb::Transport::Stats::control_queue_high_water) + .def_readonly("control_queue_overflows", &tb::Transport::Stats::control_queue_overflows) .def("__repr__", [](const tb::Transport::Stats& s) { char buf[320]; std::snprintf(buf, sizeof(buf), @@ -354,6 +356,28 @@ PYBIND11_MODULE(_taccap_native, m) { }); py::class_(m, "Transport") + .def("enable_receive_trace", &tb::Transport::enable_receive_trace, + py::arg("enabled") = true) + .def("take_receive_trace", [](tb::Transport& transport) { + auto snapshot = transport.take_receive_trace(); + py::dict result; + py::list records; + for (const auto& record : snapshot.records) { + py::dict row; + row["kind"] = record.kind == tb::FrameParser::TraceRecord::Kind::Input ? "input" : "reject"; + row["reason"] = static_cast(record.reason); + row["host_time_ns"] = std::chrono::duration_cast( + record.host_time.time_since_epoch()).count(); + row["offset"] = record.offset; + row["available"] = record.available; + row["consumed"] = record.consumed; + row["bytes"] = py::bytes(reinterpret_cast(record.bytes.data()), record.size); + records.append(std::move(row)); + } + result["records"] = std::move(records); + result["overwritten_records"] = snapshot.overwritten_records; + return result; + }) .def(py::init([](const std::string& dev, uint32_t baud, tp::Address peer, diff --git a/python/tests/conftest.py b/python/tests/conftest.py index cdb1d7d..bbf84a4 100644 --- a/python/tests/conftest.py +++ b/python/tests/conftest.py @@ -19,6 +19,9 @@ from __future__ import annotations +import importlib.machinery +import importlib.util +import os import pathlib import sys @@ -30,6 +33,30 @@ if _REPO_PYTHON not in sys.path: sys.path.insert(0, _REPO_PYTHON) +# Explicit isolated native build for validation without replacing the checkout's +# live extension. Default remains strict in-tree loading. Never just relabel +# __file__: load the actual module and verify its resolved location below. +_native_dir_value = os.environ.get("TACCAP_TEST_NATIVE_DIR") +_expected_native_dir = None +if _native_dir_value: + _expected_native_dir = pathlib.Path(_native_dir_value).resolve(strict=True) + if not _expected_native_dir.is_dir(): + raise RuntimeError("TACCAP_TEST_NATIVE_DIR must name a directory") + _candidates = [ + _expected_native_dir / ("_taccap_native" + suffix) + for suffix in importlib.machinery.EXTENSION_SUFFIXES + if (_expected_native_dir / ("_taccap_native" + suffix)).is_file() + ] + if len(_candidates) != 1: + raise RuntimeError( + "TACCAP_TEST_NATIVE_DIR must contain exactly one native extension" + ) + _name = "xense.taccap._taccap_native" + _spec = importlib.util.spec_from_file_location(_name, _candidates[0]) + _module = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(_module) + sys.modules[_name] = _module + import xense.taccap as _t # noqa: E402 (must follow the path surgery above) assert _REPO_PYTHON in _t.__file__, ( @@ -41,7 +68,11 @@ # checkout and site-packages, so __file__ pointing here does NOT prove the # compiled extension came from here. Check the extension separately. _native = sys.modules.get("xense.taccap._taccap_native") -assert _native is not None and _REPO_PYTHON in _native.__file__, ( +assert _native is not None and ( + pathlib.Path(_native.__file__).resolve().parent == _expected_native_dir + if _expected_native_dir + else _REPO_PYTHON in _native.__file__ +), ( f"_taccap_native came from {getattr(_native, '__file__', None)}, not this " "checkout — rebuild with `uv pip install -e . --no-build-isolation`." ) diff --git a/python/tests/test_dispatch_decoupling.py b/python/tests/test_dispatch_decoupling.py index 2f831e5..6552feb 100644 --- a/python/tests/test_dispatch_decoupling.py +++ b/python/tests/test_dispatch_decoupling.py @@ -63,6 +63,13 @@ def _encoder_frame(seq: int) -> bytes: "queue_dropped", "queue_high_water", "callback_max_us", + "control_queue_dropped", + "control_queue_high_water", + "control_queue_overflows", + "parser_no_head", + "parser_oversize", + "parser_short_body", + "parser_bad_length", ) @@ -95,6 +102,21 @@ def test_stop_with_a_python_subscriber_does_not_crash(fake_mcu): transport.stop() # must not segfault; fixture stops again +def test_receive_trace_is_opt_in_and_contains_raw_bytes(fake_mcu): + transport, master = fake_mcu + assert transport.take_receive_trace()["records"] == [] + transport.enable_receive_trace() + wire = _encoder_frame(19) + os.write(master, wire) + assert _wait_until(lambda: transport.stats.frames_received >= 1) + trace = transport.take_receive_trace() + assert trace["overwritten_records"] == 0 + inputs = [r for r in trace["records"] if r["kind"] == "input"] + assert b"".join(r["bytes"] for r in inputs) == wire + assert all(r["host_time_ns"] > 0 and r["reason"] == 0 for r in inputs) + transport.enable_receive_trace(False) + + def test_slow_python_callback_does_not_stall_the_reader(fake_mcu): """The regression that caused the customer-visible rate drop. From 0dfa4bcbf1692cd0ee0ece93fca9947f312fb2db Mon Sep 17 00:00:00 2001 From: xensexyq Date: Wed, 28 Oct 2026 17:51:12 +0800 Subject: [PATCH 08/18] =?UTF-8?q?fix(transport):=20=E4=BF=9D=E6=8A=A4=20AC?= =?UTF-8?q?K=20=E7=AD=89=E5=BE=85=E5=BA=8F=E5=8F=B7=E5=B9=B6=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=8E=A7=E5=88=B6=E7=9B=AE=E6=A0=87=E9=80=81=E8=BE=BE?= =?UTF-8?q?=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 ACK 请求序号至调用结束及 SameSeq 重试期间,防止回绕覆盖等待项; 校验应答命令,保留旧通用 ACK/NACK 支持。清除继承的 INPCK/IGNPAR。 增加本地目标写入次数/间隔、显式 MCU 接受数量快照和可选 UART 错误分类 查询,配套 C++/Python 接口与说明;不加入周期查询或重发控制目标。 纠正状态反馈后串口必然空闲及 HAL 计数可单独定责的说明。 验证:独立构建核心库、Python 绑定与示例;518 项 C++ 回归通过,含 ACK 回绕/错配、目标快照、旧/新诊断兼容性及现有 OTA/控制器测试; 155 项 Python 回归通过。未安装、刷写或访问真实夹爪/相机,未推送。 --- cpp/include/taccap/bus/transport.hpp | 20 ++- cpp/include/taccap/components/diagnostics.hpp | 23 +-- cpp/include/taccap/components/motor.hpp | 49 +++--- cpp/include/taccap/protocol/codec.hpp | 1 + cpp/include/taccap/protocol/payloads.hpp | 16 +- cpp/src/bus/serial_bus.cpp | 2 +- cpp/src/bus/transport.cpp | 157 +++++++++++------- cpp/src/components/diagnostics.cpp | 9 + cpp/src/components/motor.cpp | 8 + cpp/src/force_position_controller.cpp | 12 +- cpp/src/protocol/codec.cpp | 8 + cpp/tests/CMakeLists.txt | 1 + cpp/tests/test_transport_reliability.cpp | 104 ++++++++++++ docs/UART_RELIABILITY.md | 77 +++++++++ python/bindings/bind_motor.cpp | 37 ++++- python/xense/taccap/__init__.py | 5 +- 16 files changed, 406 insertions(+), 123 deletions(-) create mode 100644 cpp/tests/test_transport_reliability.cpp create mode 100644 docs/UART_RELIABILITY.md diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 0bf1963..8025284 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -46,6 +46,7 @@ #include #include +#include #include #include #include @@ -194,6 +195,15 @@ class Transport { uint64_t control_queue_overflows = 0; // latched; controller times out }; + // Successful local writes of the four motor target commands. Counts wire + // attempts, not MCU acceptance; retries count again. No extra traffic. + struct TargetWrites { + uint64_t count = 0; + int64_t last_write_ns = 0; // steady_clock, host only + uint64_t last_gap_us = 0, max_gap_us = 0; + }; + TargetWrites target_writes(); + // Opens the serial port, starts the reader thread. explicit Transport(const Config& cfg); @@ -265,6 +275,7 @@ class Transport { unsigned inflight = 0; }; struct PendingAck { + protocol::Cmd cmd{}; std::promise promise; }; struct Sub { @@ -282,7 +293,8 @@ class Transport { // the kernel tty layer's, which a non-blocking fd or a non-tty transport // would quietly take away. Held across the (blocking) write, so a submit // can queue behind a large frame; that is the cost of one link. - void write_frame_(const std::vector& wire); + void write_frame_(const std::vector& wire, protocol::Cmd cmd); + uint8_t allocate_seq_locked_(); void reader_loop_(); void dispatch_loop_(); @@ -324,6 +336,7 @@ class Transport { // Serialises writers; see write_frame_(). std::mutex write_mu_; + TargetWrites target_writes_; // write_mu_ // reader -> dispatcher hand-off. Bounded; drop-oldest on overflow. std::mutex queue_mu_; @@ -332,8 +345,9 @@ class Transport { // ACK matching std::mutex pending_mu_; - std::unordered_map pending_acks_; - std::atomic next_seq_{0}; + std::unordered_map> pending_acks_; + std::array reserved_seqs_{}; + uint8_t next_seq_{0}; // pending_mu_ // Subscriptions std::mutex sub_mu_; diff --git a/cpp/include/taccap/components/diagnostics.hpp b/cpp/include/taccap/components/diagnostics.hpp index a692285..865f443 100644 --- a/cpp/include/taccap/components/diagnostics.hpp +++ b/cpp/include/taccap/components/diagnostics.hpp @@ -26,20 +26,10 @@ class Diagnostics { // Free-running UART counters since MCU boot (Cmd 0x54). // - // The reason this exists: when the host sees a status frame arrive short a - // couple of bytes, nothing on the host can say whether the MCU failed to - // send them or whether they were lost after leaving it. `tx_bytes_ok` and - // `tx_calls_ok` count only what the firmware's transmit call accepted, so - // comparing them against what the host decoded over the same window - // separates the two: - // - // firmware frame count == host (received + missing), bytes short - // -> lost downstream of the MCU's transmit register. Cable or - // USB-serial bridge; no firmware change reaches it. - // tx_fail_timeout > 0 - // -> the firmware truncated frames itself. - // rx_overflow > 0 - // -> the firmware's command task could not keep up with the host. + // Successful HAL-call counts are not pin-level capture. Differences from + // aligned host byte counts narrow the fault domain but cannot by themselves + // exclude MCU firmware or identify the bridge/USB/TTY layer. RX errors + // describe host-to-MCU reception, not MCU-to-host telemetry loss. // // Counters are cumulative, so take a reading before and after a // measurement window and subtract. @@ -50,6 +40,11 @@ class Diagnostics { protocol::UartStats uart_stats( std::chrono::milliseconds timeout = std::chrono::milliseconds{100}); + // Explicit opt-in; unsupported firmware raises ProtocolError. Never + // silently report missing subtype counters as zero. No periodic polling. + protocol::UartRxErrors uart_rx_errors( + std::chrono::milliseconds timeout = std::chrono::milliseconds{100}); + // Turn firmware logging on or off at runtime (Cmd 0x55). Returns the // configuration the firmware reports as active afterwards. // diff --git a/cpp/include/taccap/components/motor.hpp b/cpp/include/taccap/components/motor.hpp index 05f1902..ad2dfe2 100644 --- a/cpp/include/taccap/components/motor.hpp +++ b/cpp/include/taccap/components/motor.hpp @@ -156,6 +156,11 @@ namespace detail { bool is_stray_fault_reply_frame(bool seen_real_temp, float motor_temp_c) noexcept; } // namespace detail +struct MotorDeliverySnapshot { + bus::Transport::TargetWrites before, after; + protocol::MotorControlStats firmware{}; +}; + class Motor { public: using SubId = bus::Transport::SubscriptionId; @@ -192,34 +197,23 @@ class Motor { // NO NACK, NO retry, NO timeout, and NO throw on a target the firmware // rejects. The firmware's slave control task consumes the *latest* // submitted target. Unlike set_*(), which block on an ACK and throw - // ProtocolError on NACK, submit() never blocks. + // ProtocolError on NACK, submit() does not wait for an ACK; its serial + // write can still block or throw an I/O error. // // ---- On submission rate ------------------------------------------------ // This used to say you could submit "up to the firmware's 500Hz slave // control rate", which read as a budget to spend. It is not one. // - // The 500Hz figure is real — that is how often the firmware's control task - // applies the latest target — but it says nothing about what submitting at - // that rate costs you. Every host->MCU frame that lands while the MCU is - // transmitting makes it drop bytes out of the middle of the frame it is - // sending, and that frame is then discarded whole (tc-gu-01 issue #1). A - // 41-byte status frame at 3 Mbps fills only ~137us of each 10ms period, so - // whether a given submit collides is down to *when* it lands, not how many - // you send: - // - // - 250Hz lost 154 status frames on one 60s run and none on the next. - // - 300Hz was clean on a run where 250Hz was not. - // - 1000Hz has produced both 0 and 146 lost frames on the same firmware. - // - // So rate does not predict loss; it only sets how many chances to collide - // you take per second. Submitting faster than the status stream also buys - // nothing on the observation side, since motor status is capped at 100Hz. - // - // Prefer ImpedanceController or ForcePositionController, which submit once - // per received status frame and therefore never overlap a transmission — - // stream-locked submission measured at zero lost frames across 8 runs while - // sending MORE frames than the free-running comparison. Reach for raw submit() at your own cadence - // only when you cannot ride the status stream. + // The MCU control task applies the latest accepted target at 500 Hz. + // This is not a guarantee of host throughput or loss-free delivery. + // Historical concurrent-traffic tests found intermittent short telemetry + // frames; the rate alone did not predict them or establish the failing + // layer. Full-duplex overlap is not itself evidence of a firmware defect. + // + // The controllers pace updates from status feedback. That does not prove + // the UART is idle: IMU or ACK frames may immediately follow status. + // target_writes() observes host cadence without I/O; delivery_snapshot() + // explicitly queries MCU acceptance off the control thread. // // Separately: sustained input above a few hundred Hz used to livelock the // firmware's command handler outright (tc-gu-01 issue #2, fixed in firmware @@ -493,6 +487,15 @@ class Motor { // boot and needs no parameter access at all. protocol::MotorPrivateParam get_private_param(uint16_t index); void set_private_param(uint16_t index, uint32_t raw_value); + // One explicit 0x51 query, bracketed by local target-write counters. + // Compare two snapshots on the same connection/boot. The range captures + // query overlap, NOT arbitrary in-flight buffering. Counter differences + // indicate a health concern, not exact packet loss. No automatic retries + // of control targets, logging, background queries or stop policy changes. + MotorDeliverySnapshot delivery_snapshot( + std::chrono::milliseconds timeout = std::chrono::milliseconds{100}); + bus::Transport::TargetWrites target_writes() { return t_.target_writes(); } + protocol::MotorControlStats control_stats( // Cmd 0x51 std::chrono::milliseconds timeout = std::chrono::milliseconds{100}); diff --git a/cpp/include/taccap/protocol/codec.hpp b/cpp/include/taccap/protocol/codec.hpp index 13ac1fe..d596647 100644 --- a/cpp/include/taccap/protocol/codec.hpp +++ b/cpp/include/taccap/protocol/codec.hpp @@ -128,6 +128,7 @@ GripperAutoCalConfig decode_gripper_auto_cal_config(const uint8_t* data, std::si MotorControlStats decode_motor_control_stats(const uint8_t* data, std::size_t len); // fw 1.1.3 shipped a 32-byte packet; 1.1.4 appended log_dropped. Both decode // here, with the missing tail zero-filled, so one SDK build talks to either. +UartRxErrors decode_uart_rx_errors(const uint8_t* data, std::size_t len); UartStats decode_uart_stats(const uint8_t* data, std::size_t len); LogConfig decode_log_config(const uint8_t* data, std::size_t len); StreamConfig decode_stream_config(const uint8_t* data, std::size_t len); diff --git a/cpp/include/taccap/protocol/payloads.hpp b/cpp/include/taccap/protocol/payloads.hpp index 88b9492..fa91b50 100644 --- a/cpp/include/taccap/protocol/payloads.hpp +++ b/cpp/include/taccap/protocol/payloads.hpp @@ -755,13 +755,8 @@ static_assert(sizeof(GripperEnvelope) == 16, // ---- UART counters (fw 1.1.3 — Cmd::GetUartStats 0x54) -------------------- // -// Exists to answer "which side dropped the bytes". tx_bytes_ok counts only -// what HAL_UART_Transmit accepted, i.e. what actually reached the MCU's -// transmit register. When that disagrees with what the host received, the loss -// happened downstream of the MCU — cable or USB-serial bridge — and no -// firmware change can help. tx_fail_timeout is called out separately because a -// polled transmit that times out mid-frame leaves a short frame on the wire, -// which looks identical to bytes lost in transit. +// Counts successful HAL calls, not pin-level observations. Aligned byte +// differences do not alone distinguish MCU, bridge, USB and TTY faults. // // All counters are free-running since boot; take deltas across a measurement // window rather than reading absolutes. @@ -783,6 +778,13 @@ struct UartStats { // silenced the command channel for good. }; +// Optional 0x54 payload {1}: 44-byte legacy prefix + seven uint32 LE fields. +struct UartRxErrors { + uint32_t overrun, framing, noise, parity, receiver_timeout; + uint32_t last_error, last_error_ms; +}; +static_assert(sizeof(UartRxErrors) == 28); + // ---- Log configuration (fw 1.1.4 — Cmd::SetLogConfig 0x55) ---------------- // // Firmware logging is OFF by default and this is the only way to turn it back diff --git a/cpp/src/bus/serial_bus.cpp b/cpp/src/bus/serial_bus.cpp index 38912f0..81f35d0 100644 --- a/cpp/src/bus/serial_bus.cpp +++ b/cpp/src/bus/serial_bus.cpp @@ -146,7 +146,7 @@ void SerialBus::apply_termios_() { tio.c_cflag &= ~(CSIZE | PARENB | CSTOPB | CRTSCTS); tio.c_cflag |= CS8 | CREAD | CLOCAL; - tio.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | + tio.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INPCK | IGNPAR | INLCR | IGNCR | ICRNL | IXON | IXOFF | IXANY); tio.c_oflag &= ~(OPOST); tio.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index 2dec575..eac4044 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -62,84 +62,113 @@ AckResponse Transport::send_cmd(protocol::Cmd cmd, const std::vector& payload, std::chrono::milliseconds timeout, RetryMode retry) { - if (!running_.load(std::memory_order_acquire)) { - throw IoError("send_cmd on stopped transport", EBADF); - } const auto t = (timeout.count() == 0) ? cfg_.ack_timeout : timeout; - - uint8_t seq = 0; - bool seq_allocated = false; - - for (unsigned attempt = 0; attempt <= cfg_.max_retries; ++attempt) { - // SameSeq keeps the first attempt's seq for every retry so the - // firmware recognises the repeat and replays its cached response - // rather than running a non-idempotent handler twice. Allocating - // lazily keeps NewSeq's wire behaviour byte-for-byte unchanged. - if (!seq_allocated || retry == RetryMode::NewSeq) { - seq = next_seq_.fetch_add(1, std::memory_order_relaxed); - seq_allocated = true; - } - std::future fut; - { - std::lock_guard lk(pending_mu_); - // unique_ptr-style emplace: default-construct the PendingAck. - auto& p = pending_acks_[seq]; - // If the slot was somehow taken (uint8 wrap collision with a - // very-late stray ACK), reset its promise. - p.promise = std::promise{}; - fut = p.promise.get_future(); - } - - try { + uint8_t seq = 0; + bool reserved = false; + std::shared_ptr pending; + auto release = [&]() { + std::lock_guard lk(pending_mu_); + if (!reserved) return; + auto it = pending_acks_.find(seq); + if (it != pending_acks_.end() && it->second == pending) + pending_acks_.erase(it); + reserved_seqs_[seq] = false; + reserved = false; + }; + try { + for (unsigned attempt = 0; attempt <= cfg_.max_retries; ++attempt) { + if (reserved && retry == RetryMode::NewSeq) release(); + std::future fut; + { + std::lock_guard lk(pending_mu_); + if (!running_.load(std::memory_order_acquire)) + throw IoError("send_cmd on stopped transport", EBADF); + if (!reserved) { + seq = allocate_seq_locked_(); + reserved_seqs_[seq] = true; + reserved = true; + } + pending = std::make_shared(); + pending->cmd = cmd; + fut = pending->promise.get_future(); + pending_acks_[seq] = pending; + } write_frame_(pack_frame(cfg_.peer, seq, - protocol::FrameType::CMD_NEED_ACK, - cmd, payload)); - } catch (...) { - std::lock_guard lk(pending_mu_); - pending_acks_.erase(seq); - throw; - } - - const auto status = fut.wait_for(t); - if (status == std::future_status::ready) { - // Note: pending entry already erased by handle_ack_ on fulfilment. - return fut.get(); // may rethrow IoError set by fail_pending_ - } - - // Timeout — clean up entry, retry. - { - std::lock_guard lk(pending_mu_); - pending_acks_.erase(seq); - } - stat_ack_timeouts_.fetch_add(1, std::memory_order_relaxed); - if (attempt < cfg_.max_retries) { - stat_retries_.fetch_add(1, std::memory_order_relaxed); - std::this_thread::sleep_for(cfg_.retry_interval); + protocol::FrameType::CMD_NEED_ACK, cmd, payload), cmd); + if (fut.wait_for(t) == std::future_status::ready) { + auto ack = fut.get(); + release(); + return ack; + } + { + std::lock_guard lk(pending_mu_); + auto it = pending_acks_.find(seq); + if (it != pending_acks_.end() && it->second == pending) + pending_acks_.erase(it); + } + // SameSeq owns its slot throughout retry delays (OTA deduplication). + stat_ack_timeouts_.fetch_add(1, std::memory_order_relaxed); + if (attempt < cfg_.max_retries) { + stat_retries_.fetch_add(1, std::memory_order_relaxed); + std::this_thread::sleep_for(cfg_.retry_interval); + } } + throw TimeoutError("send_cmd " + std::string(protocol::to_string(cmd)) + + ": no ACK after " + std::to_string(cfg_.max_retries + 1) + + " attempts", ETIMEDOUT); + } catch (...) { + release(); + throw; } +} - throw TimeoutError("send_cmd " + std::string(protocol::to_string(cmd)) + - ": no ACK after " + - std::to_string(cfg_.max_retries + 1) + " attempts", - ETIMEDOUT); +// pending_mu_ guards reservation across the entire request lifetime, including +// timeout/ACK races and SameSeq retries. Never overwrite another promise. +uint8_t Transport::allocate_seq_locked_() { + for (unsigned i = 0; i < 256; ++i) { + const uint8_t seq = next_seq_++; + if (!reserved_seqs_[seq]) return seq; + } + throw IoError("Transport: all ACK sequence slots occupied", EBUSY); } void Transport::send_cmd_no_ack(protocol::Cmd cmd, const std::vector& payload) { - if (!running_.load(std::memory_order_acquire)) { - throw IoError("send_cmd_no_ack on stopped transport", EBADF); + uint8_t seq; + { + std::lock_guard lk(pending_mu_); + if (!running_.load(std::memory_order_acquire)) + throw IoError("send_cmd_no_ack on stopped transport", EBADF); + seq = allocate_seq_locked_(); } - const uint8_t seq = next_seq_.fetch_add(1, std::memory_order_relaxed); write_frame_(pack_frame(cfg_.peer, seq, - protocol::FrameType::CMD_NO_ACK, cmd, payload)); + protocol::FrameType::CMD_NO_ACK, cmd, payload), cmd); +} + +Transport::TargetWrites Transport::target_writes() { + std::lock_guard lk(write_mu_); + return target_writes_; } // The one place bytes leave for the MCU. Packing stays outside the lock; only // the write is serialised. -void Transport::write_frame_(const std::vector& wire) { +void Transport::write_frame_(const std::vector& wire, protocol::Cmd cmd) { { std::lock_guard lk(write_mu_); serial_.write(wire); + if (cmd == protocol::Cmd::MotorPosCtrl || cmd == protocol::Cmd::MotorVelCtrl || + cmd == protocol::Cmd::MotorTorqueCtrl || cmd == protocol::Cmd::MotorImpedanceCtrl) { + const auto now = std::chrono::steady_clock::now(); + const auto ns = std::chrono::duration_cast( + now.time_since_epoch()).count(); + if (target_writes_.count != 0) { + target_writes_.last_gap_us = (ns - target_writes_.last_write_ns) / 1000; + target_writes_.max_gap_us = std::max(target_writes_.max_gap_us, + target_writes_.last_gap_us); + } + ++target_writes_.count; + target_writes_.last_write_ns = ns; + } } stat_bytes_written_.fetch_add(wire.size(), std::memory_order_relaxed); stat_frames_sent_.fetch_add(1, std::memory_order_relaxed); @@ -358,8 +387,10 @@ void Transport::handle_ack_(const Frame& f) { { std::lock_guard lk(pending_mu_); auto it = pending_acks_.find(f.seq); - if (it != pending_acks_.end()) { - winning_promise = std::move(it->second.promise); + if (it != pending_acks_.end() && + (f.cmd == it->second->cmd || + (static_cast(f.cmd) == 0 && f.payload.size() == 1))) { + winning_promise = std::move(it->second->promise); pending_acks_.erase(it); found = true; } @@ -568,7 +599,7 @@ void Transport::fail_pending_(const std::string& reason) noexcept { std::lock_guard lk(pending_mu_); for (auto& kv : pending_acks_) { try { - kv.second.promise.set_exception(std::make_exception_ptr( + kv.second->promise.set_exception(std::make_exception_ptr( IoError("transport: " + reason, EIO))); } catch (...) { /* already satisfied */ } } diff --git a/cpp/src/components/diagnostics.cpp b/cpp/src/components/diagnostics.cpp index 0488bf0..bb2f217 100644 --- a/cpp/src/components/diagnostics.cpp +++ b/cpp/src/components/diagnostics.cpp @@ -8,6 +8,15 @@ namespace xense::taccap { Diagnostics::Diagnostics(bus::Transport& transport) : t_(transport) {} +protocol::UartRxErrors Diagnostics::uart_rx_errors(std::chrono::milliseconds timeout) { + auto ack = t_.send_cmd(protocol::Cmd::GetUartStats, {1}, timeout); + const auto error = bus::ack_error_code(ack); + if (error != protocol::ErrorCode::Ok) + throw ProtocolError(std::string("Diagnostics::uart_rx_errors NACK: ") + + protocol::to_string(error)); + return protocol::decode_uart_rx_errors(ack.data.data(), ack.data.size()); +} + protocol::UartStats Diagnostics::uart_stats(std::chrono::milliseconds timeout) { auto ack = t_.send_cmd(protocol::Cmd::GetUartStats, {}, timeout); if (ack.is_nack) { diff --git a/cpp/src/components/motor.cpp b/cpp/src/components/motor.cpp index 6fd8bba..3a447d8 100644 --- a/cpp/src/components/motor.cpp +++ b/cpp/src/components/motor.cpp @@ -433,6 +433,14 @@ void Motor::set_private_param(uint16_t index, uint32_t raw_value) { send_or_throw(t_, protocol::Cmd::MotorSetPrivateParam, req, "set_private_param"); } +MotorDeliverySnapshot Motor::delivery_snapshot(std::chrono::milliseconds timeout) { + MotorDeliverySnapshot out; + out.before = t_.target_writes(); + out.firmware = control_stats(timeout); + out.after = t_.target_writes(); + return out; +} + protocol::MotorControlStats Motor::control_stats(std::chrono::milliseconds timeout) { auto ack = t_.send_cmd(protocol::Cmd::GetMotorControlStats, {}, timeout); if (ack.is_nack) { diff --git a/cpp/src/force_position_controller.cpp b/cpp/src/force_position_controller.cpp index 2dcf1a5..1238d98 100644 --- a/cpp/src/force_position_controller.cpp +++ b/cpp/src/force_position_controller.cpp @@ -1326,13 +1326,11 @@ void ForcePositionController::enqueue_(const QueuedCommand& c) { // Caller commands are QUEUED, not applied here. // -// Applying them on the caller's thread also submitted on the caller's thread, -// at whatever moment that thread happened to call in. The MCU drops bytes out -// of the middle of a status frame it is transmitting if host->MCU traffic -// overlaps it, so an off-phase write costs a telemetry frame. Every submit now -// happens in run_() on the status-frame doorbell, inside the ~9.8 ms the MCU -// is known to be idle. The cost is that a command takes effect on the next status frame -// instead of instantly: at motor_stream_hz = 100 that is under 10 ms. +// Applying targets on the controller worker keeps control state ordered with +// the latest feedback. A received status frame does NOT establish a UART idle +// window: IMU/ACK traffic can follow it immediately. This is feedback-paced, +// not a hard 100 Hz timer or a guarantee of loss-free full-duplex transport. +// Use motor.target_writes()/delivery_snapshot() to observe gaps and acceptance. // // QUEUED IN ORDER AND STAMPED HERE (0.4.2). Each target carries the time this // call was made, on the caller's thread, because the policy builds its diff --git a/cpp/src/protocol/codec.cpp b/cpp/src/protocol/codec.cpp index 2f2858d..f5371cf 100644 --- a/cpp/src/protocol/codec.cpp +++ b/cpp/src/protocol/codec.cpp @@ -238,6 +238,14 @@ UartStats decode_uart_stats(const uint8_t* data, std::size_t len) { return out; } +UartRxErrors decode_uart_rx_errors(const uint8_t* data, std::size_t len) { + if (!data || len != 72) + throw ProtocolError("decode UartRxErrors: expected 72-byte extended reply"); + UartRxErrors out{}; + std::memcpy(&out, data + 44, sizeof(out)); + return out; +} + LogConfig decode_log_config(const uint8_t* data, std::size_t len) { return pod_from_bytes(data, len, "LogConfig"); } diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 53f1abc..156e368 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(taccap_unit_tests test_ota_retry_pty.cpp test_python_compat.cpp test_stuff.cpp + test_transport_reliability.cpp test_transport.cpp test_transport_concurrent_write.cpp test_diagnostics_codec.cpp diff --git a/cpp/tests/test_transport_reliability.cpp b/cpp/tests/test_transport_reliability.cpp new file mode 100644 index 0000000..c63d620 --- /dev/null +++ b/cpp/tests/test_transport_reliability.cpp @@ -0,0 +1,104 @@ +#include +#include +#include +#include +#include "pty_helper.hpp" +#include +#include +namespace tb = xense::taccap::bus; +namespace tp = xense::taccap::protocol; +using namespace std::chrono_literals; +using taccap_test::Pty; +using taccap_test::base_config; + +TEST(TransportReliability, WrongCommandCannotConsumePendingAck) { + Pty p; auto cfg = base_config(p.slave_path()); cfg.max_retries = 0; + tb::Transport host(cfg); + auto request = std::async(std::launch::async, [&] { + return host.send_cmd(tp::Cmd::GetVersion, {}, 500ms); + }); + auto f = p.expect_frame(); ASSERT_TRUE(f); + p.send_ack_ok(f->seq, tp::Cmd::Heartbeat); + EXPECT_EQ(request.wait_for(20ms), std::future_status::timeout); + p.send_ack_ok(f->seq, tp::Cmd::GetVersion); + EXPECT_EQ(request.get().cmd, tp::Cmd::GetVersion); + EXPECT_EQ(host.stats().unexpected_frames, 1u); +} + +TEST(TransportReliability, WrapCannotOverwritePendingRequest) { + Pty p; auto cfg = base_config(p.slave_path()); cfg.max_retries = 0; + tb::Transport host(cfg); + auto first = std::async(std::launch::async, [&] { + return host.send_cmd(tp::Cmd::GetVersion, {}, 2000ms); + }); + auto f = p.expect_frame(); ASSERT_TRUE(f); + for (unsigned i = 0; i < 256; ++i) { + host.send_cmd_no_ack(tp::Cmd::MotorImpedanceCtrl, {0}); + auto next = p.expect_frame(); ASSERT_TRUE(next); + EXPECT_NE(next->seq, f->seq); // active ACK slots are reserved even for no-ACK TX + } + auto second = std::async(std::launch::async, [&] { + return host.send_cmd(tp::Cmd::Heartbeat, {}, 500ms); + }); + auto g = p.expect_frame(); ASSERT_TRUE(g); EXPECT_NE(g->seq, f->seq); + p.send_ack_ok(g->seq, tp::Cmd::Heartbeat); + EXPECT_EQ(second.get().cmd, tp::Cmd::Heartbeat); + p.send_ack_ok(f->seq, tp::Cmd::GetVersion); + EXPECT_EQ(first.get().cmd, tp::Cmd::GetVersion); +} + +TEST(TransportReliability, DeliverySnapshotExposesUnacceptedTargetsWithoutExtraControl) { + Pty p; tb::Transport host(base_config(p.slave_path())); + xense::taccap::Motor motor(host); + EXPECT_EQ(motor.target_writes().count, 0u); + tp::MotorImpedanceCtrl target{}; + for (int i = 0; i < 3; ++i) { + motor.submit(target); auto f = p.expect_frame(); ASSERT_TRUE(f); + EXPECT_EQ(f->type, tp::FrameType::CMD_NO_ACK); + } + auto request = std::async(std::launch::async, [&] { return motor.delivery_snapshot(500ms); }); + auto f = p.expect_frame(); ASSERT_TRUE(f); EXPECT_EQ(f->cmd, tp::Cmd::GetMotorControlStats); + tp::MotorControlStats stats{}; stats.target_seq = 2; stats.applied_seq = 2; + std::vector data(sizeof(stats)); std::memcpy(data.data(), &stats, sizeof(stats)); + p.send_response(f->seq, f->cmd, data); + auto out = request.get(); + EXPECT_EQ(out.before.count, 3u); EXPECT_EQ(out.after.count, 3u); + EXPECT_EQ(out.firmware.target_seq, 2u); + EXPECT_GT(out.after.last_write_ns, 0); + EXPECT_GE(out.after.max_gap_us, out.after.last_gap_us); + EXPECT_FALSE(p.expect_frame(20)); // no automatic polling, target replay or stop +} + +TEST(TransportReliability, InheritedParityFlagsAreCleared) { + Pty p; int fd = ::open(p.slave_path().c_str(), O_RDWR | O_NOCTTY); + ASSERT_GE(fd, 0); termios tio{}; ASSERT_EQ(tcgetattr(fd, &tio), 0); + tio.c_iflag |= INPCK | IGNPAR; ASSERT_EQ(tcsetattr(fd, TCSANOW, &tio), 0); + { tb::Transport host(base_config(p.slave_path())); + ASSERT_EQ(tcgetattr(fd, &tio), 0); EXPECT_EQ(tio.c_iflag & (INPCK | IGNPAR), 0u); } + ::close(fd); +} + +TEST(DiagnosticsCodec, ExtendedErrorsRequireExplicitCompleteReply) { + tp::UartRxErrors errors{1,2,3,4,5,8,1234}; + std::vector wire(72, 0); std::memcpy(wire.data()+44, &errors, 28); + auto out = tp::decode_uart_rx_errors(wire.data(), wire.size()); + EXPECT_EQ(out.overrun, 1u); EXPECT_EQ(out.framing, 2u); EXPECT_EQ(out.noise, 3u); + EXPECT_EQ(out.parity, 4u); EXPECT_EQ(out.receiver_timeout, 5u); + EXPECT_EQ(out.last_error, 8u); EXPECT_EQ(out.last_error_ms, 1234u); + EXPECT_THROW(tp::decode_uart_rx_errors(wire.data(), 44), xense::taccap::ProtocolError); + EXPECT_THROW(tp::decode_uart_rx_errors(wire.data(), 71), xense::taccap::ProtocolError); + EXPECT_THROW(tp::decode_uart_rx_errors(nullptr, 72), xense::taccap::ProtocolError); +} + +TEST(TransportReliability, ExtendedErrorsAreOptInAndOldFirmwareIsNotZeroFilled) { + Pty p; tb::Transport host(base_config(p.slave_path())); + xense::taccap::Diagnostics diag(host); + auto first = std::async(std::launch::async, [&] { return diag.uart_stats(500ms); }); + auto f = p.expect_frame(); ASSERT_TRUE(f); EXPECT_TRUE(f->payload.empty()); + p.send_response(f->seq, f->cmd, std::vector(44)); + EXPECT_EQ(first.get().rx_errors, 0u); + auto second = std::async(std::launch::async, [&] { return diag.uart_rx_errors(500ms); }); + f = p.expect_frame(); ASSERT_TRUE(f); EXPECT_EQ(f->payload, std::vector{1}); + p.send_response(f->seq, f->cmd, std::vector(44)); + EXPECT_THROW(second.get(), xense::taccap::ProtocolError); +} diff --git a/docs/UART_RELIABILITY.md b/docs/UART_RELIABILITY.md new file mode 100644 index 0000000..f4e48b8 --- /dev/null +++ b/docs/UART_RELIABILITY.md @@ -0,0 +1,77 @@ +# UART reliability candidate (2026-10-28) + +This source change addresses reproducible code risks; it does **not** establish +that the EL05 short frames are fixed. No firmware flash, live-port access, camera +access or host installation is part of the offline verification. + +## Transport + +- Reserve pending ACK sequence slots until the requesting call releases them, + including SameSeq retry delays. No-ACK allocation skips reserved slots. +- Match echoed command as well as sequence; keep legacy cmd=0, one-byte ACK/NACK + support. A mismatched response cannot consume a waiting request. +- Clear inherited INPCK/IGNPAR in raw serial setup. Existing full-frame write + locking, one serial reader, retries and independent dispatch remain. +- The wire has only 8 sequence bits. An arbitrarily late response with the same + sequence **and command** after slot reuse remains indistinguishable without a + protocol transaction/session identifier. This patch does not claim otherwise. +- Target writes are counted after successful full writes, under the writer lock; + four target commands (position, velocity, torque, impedance) share the counter. + ACK retries count as wire attempts. These are not MCU acceptance counts. + +## Explicit observations (C++ and Python) + +Existing `diagnostics.uart_stats()` sends an empty 0x54 request and retains the +44-byte struct. `diagnostics.uart_rx_errors()` sends payload `01` and requires a +72-byte reply. It returns overrun, framing, noise, parity, receiver_timeout, +last_error (HAL bitmask) and last_error_ms (32-bit boot milliseconds). Missing +support is an error, never zero-filled. Multiple error bits can count in one event. +Successful HAL TX counts are not pin-level proof and cannot exclude firmware. + +`motor.target_writes()` returns local count, last_write_ns (host steady clock), +last_gap_us and max_gap_us. It sends nothing. Gaps include deliberate pauses and +are cumulative for the Transport lifetime; start a new baseline for a new run. + +`motor.delivery_snapshot()` makes one explicit 0x51 control-stat query and returns +`before`, `after` local write snapshots plus `firmware` (target_seq, applied_seq, +loop/error counts etc.). Call from a monitoring thread, never from a controller +or a serial callback. No automatic polling or change to the motor stop policy. + +For snapshots a and b on the same boot/connection: + +```python +# g is the application's already-open follower. No discovery/open in this example. +a = g.motor.delivery_snapshot() +# ... application continues its normal control workload ... +b = g.motor.delivery_snapshot() +host_min = max(0, b.before.count - a.after.count) +host_max = b.after.count - a.before.count +accepted = (b.firmware.target_seq - a.firmware.target_seq) & 0xffffffff +print(f"Host write window: {host_min}..{host_max}, MCU accepted: {accepted}") +print(f"Host max write gap: {b.after.max_gap_us / 1000:.3f} ms") +``` + +This range brackets **query overlap**, not arbitrary bytes already buffered in +the OS/bridge/MCU. It cannot prove exact loss for a short window. Invalidate the +baseline after restart, counter reset, reconnect, other command producers or +control-mode changes. Do not equate target_seq-applied_seq with packet loss: +multiple targets can legitimately be replaced before a 500 Hz controller step. + +Feedback-paced controllers remain feedback-paced. A status frame does not prove +an idle interval: IMU/ACK frames may immediately follow it. Average 100 Hz is not +a guarantee of 10 ms spacing. No stale target replay is introduced. + +## Validation and deployment + +Run the PTY reliability cases plus existing transport, controller, IMU and OTA +regressions; compile the Python bindings. PTYs cannot reproduce UART electrical +errors, physical FIFO overrun, bridge loss, USB contention or camera throughput. +Build with `-DSKBUILD=ON` into a separate directory to avoid replacing an SDK +library used by a running test. New Python sources and native libraries must come +from the same build. Do not install or replace a running test's module. + +Use the firmware's optional error classification in the next matched hardware +run. Keep logs off; retain raw host input and, if authorized, follower-only +usbmon data. Test main/modified cross combinations separately. This patch does +not change camera capture/Rectify performance or claim to recover the observed +101 fps to the configured 120 fps. diff --git a/python/bindings/bind_motor.cpp b/python/bindings/bind_motor.cpp index 58405d2..ed86e0a 100644 --- a/python/bindings/bind_motor.cpp +++ b/python/bindings/bind_motor.cpp @@ -43,10 +43,9 @@ void bind_motor(py::module_& m) { py::class_(m, "UartStats", "Free-running firmware UART counters since MCU boot (Cmd 0x54).\n\n" "Take a reading before and after a measurement window and subtract.\n" - "tx_bytes_ok / tx_calls_ok count only what the firmware's transmit call\n" - "accepted, i.e. what reached the MCU's transmit register -- compare them\n" - "against what the host decoded to tell 'the MCU never sent it' apart from\n" - "'it was lost after leaving the MCU'.\n\n" + "tx_bytes_ok / tx_calls_ok count successful HAL transmit calls, not\n" + "pin-level capture. Compare aligned windows; a difference alone cannot\n" + "assign the fault to MCU firmware, bridge, USB or TTY.\n\n" "log_dropped reads 0 on firmware 1.1.3, which had no such field; that is\n" "not distinguishable from a genuine zero.") .def_readonly("tx_bytes_ok", &protocol::UartStats::tx_bytes_ok) @@ -117,9 +116,32 @@ void bind_motor(py::module_& m) { m.attr("LOG_OUTPUT_NONE") = py::int_(protocol::LogOutput::None); m.attr("LOG_OUTPUT_UART") = py::int_(protocol::LogOutput::Uart); + py::class_(m, "UartRxErrors") + .def_readonly("overrun", &protocol::UartRxErrors::overrun) + .def_readonly("framing", &protocol::UartRxErrors::framing) + .def_readonly("noise", &protocol::UartRxErrors::noise) + .def_readonly("parity", &protocol::UartRxErrors::parity) + .def_readonly("receiver_timeout", &protocol::UartRxErrors::receiver_timeout) + .def_readonly("last_error", &protocol::UartRxErrors::last_error) + .def_readonly("last_error_ms", &protocol::UartRxErrors::last_error_ms); + py::class_(m, "MotorTargetWrites") + .def_readonly("count", &bus::Transport::TargetWrites::count) + .def_readonly("last_write_ns", &bus::Transport::TargetWrites::last_write_ns) + .def_readonly("last_gap_us", &bus::Transport::TargetWrites::last_gap_us) + .def_readonly("max_gap_us", &bus::Transport::TargetWrites::max_gap_us); + py::class_(m, "MotorDeliverySnapshot") + .def_readonly("before", &MotorDeliverySnapshot::before) + .def_readonly("after", &MotorDeliverySnapshot::after) + .def_readonly("firmware", &MotorDeliverySnapshot::firmware); + py::class_(m, "Diagnostics", "Firmware UART counters and log control. Available on both gripper\n" "roles; needs firmware 1.1.3 (counters) / 1.1.4 (log control).") + .def("uart_rx_errors", [](Diagnostics& self, unsigned timeout_ms) { + py::gil_scoped_release release; + return self.uart_rx_errors(std::chrono::milliseconds(timeout_ms)); + }, py::arg("timeout_ms") = 100, + "Explicit extended UART error query; unsupported firmware raises ProtocolError.") .def("uart_stats", [](Diagnostics& self, unsigned timeout_ms) { py::gil_scoped_release g; return self.uart_stats(std::chrono::milliseconds(timeout_ms)); @@ -824,6 +846,13 @@ void bind_motor(py::module_& m) { "get_private_param().\n\n" "Writing 0x700B also rewrites the persisted startup limit torque, so a\n" "one-off tweak silently becomes the new boot default.") + .def("target_writes", &Motor::target_writes, py::call_guard(), + "Local counters only; does not query or control the motor.") + .def("delivery_snapshot", [](Motor& self, unsigned timeout_ms) { + py::gil_scoped_release release; + return self.delivery_snapshot(std::chrono::milliseconds(timeout_ms)); + }, py::arg("timeout_ms") = 100, + "One control-stats query bracketed by host target-write counters.") .def("control_stats", [](Motor& self, unsigned timeout_ms) { py::gil_scoped_release g; return self.control_stats(std::chrono::milliseconds(timeout_ms)); diff --git a/python/xense/taccap/__init__.py b/python/xense/taccap/__init__.py index 313de69..6a4779c 100644 --- a/python/xense/taccap/__init__.py +++ b/python/xense/taccap/__init__.py @@ -153,6 +153,9 @@ FORCE_POSITION_MAX_HOLD_TORQUE_NM = _taccap_native.FORCE_POSITION_MAX_HOLD_TORQUE_NM FORCE_POSITION_MAX_MOTION_TORQUE_NM = _taccap_native.FORCE_POSITION_MAX_MOTION_TORQUE_NM Diagnostics = _taccap_native.Diagnostics # firmware UART counters + log control +UartRxErrors = _taccap_native.UartRxErrors +MotorTargetWrites = _taccap_native.MotorTargetWrites +MotorDeliverySnapshot = _taccap_native.MotorDeliverySnapshot UartStats = _taccap_native.UartStats # Cmd 0x54 payload LogConfig = _taccap_native.LogConfig # Cmd 0x55 payload LogLevel = _taccap_native.LogLevel # firmware log verbosity @@ -310,7 +313,7 @@ "FORCE_POSITION_MAX_HOLD_TORQUE_NM", "FORCE_POSITION_MAX_MOTION_TORQUE_NM", "Diagnostics", - "UartStats", + "UartStats", "UartRxErrors", "MotorTargetWrites", "MotorDeliverySnapshot", "LogConfig", "LogLevel", "LOG_OUTPUT_NONE", From bfabaf71e41a46843ba584d9cd5dab2525952700 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Fri, 30 Oct 2026 13:10:15 +0800 Subject: [PATCH 09/18] =?UTF-8?q?feat(follower):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BB=8E=E7=88=AA=E5=88=86=E6=97=B6=E4=BA=A4=E6=8D=A2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20start=5Fexchange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合固件 STREAM_MODE_POLLED 与 CMD_SLAVE_EXCHANGE(0x5D),从结构上消除 主机下行与从爪上行在线上的重叠: - FollowerGripper::start_exchange 以轮询模式开流并启动交换线程;每周期一次 带遥测的交换,期间提交的目标立即以只带目标的交换发出,保持原有控制时延 - 应答拆成合成的 0x50/0x10/0x17 DATA 帧注入传输层原有分派(含控制通道), 订阅者和 ForcePositionController/ImpedanceController 无需改动 - Motor::submit 在交换模式下改走交换;超时的目标未被新目标取代时下次重发 - 传输层半双工模式:每个请求从写出到应答或超时独占线路,用户查询也排队 - 新增 RetryMode::Once、ExchangeStats 与 Python 绑定;旧固件 NACK 时抛 ProtocolError 且不遗留任何运行状态 新增 8 个 PTY 测试(轮询遥测、目标随交换、并发查询下无重叠、超时重发、 目标拒绝、旧固件、参数校验、力位控制器复用);去掉半双工锁的变异被检出。 全部 526 个测试四轮 -j16 并行通过。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++ cpp/CMakeLists.txt | 1 + cpp/include/taccap/bus/transport.hpp | 24 +++ cpp/include/taccap/components/motor.hpp | 10 + cpp/include/taccap/follower_gripper.hpp | 39 ++++ cpp/include/taccap/protocol/commands.hpp | 4 + cpp/include/taccap/protocol/payloads.hpp | 35 ++++ cpp/src/bus/transport.cpp | 24 ++- cpp/src/components/motor.cpp | 25 ++- cpp/src/follower_exchange.cpp | 172 ++++++++++++++++++ cpp/src/follower_exchange.hpp | 78 ++++++++ cpp/src/follower_gripper.cpp | 58 ++++++ cpp/src/protocol/commands.cpp | 1 + cpp/tests/CMakeLists.txt | 1 + cpp/tests/fake_follower.hpp | 107 ++++++++++- cpp/tests/test_follower_exchange_pty.cpp | 222 +++++++++++++++++++++++ python/bindings/bind_gripper.cpp | 46 +++++ 17 files changed, 848 insertions(+), 10 deletions(-) create mode 100644 cpp/src/follower_exchange.cpp create mode 100644 cpp/src/follower_exchange.hpp create mode 100644 cpp/tests/test_follower_exchange_pty.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index bac32d7..c790323 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Follower half-duplex exchange: `FollowerGripper.start_exchange(motor_hz, + imu_hz, exchange_hz)`.** Requires follower firmware with `StreamMode::Polled` + and `Cmd::SlaveExchange` (0x5D, tc-gu-01 `docs/HALF_DUPLEX_EXCHANGE.md`). + The follower stops pushing frames; the SDK polls it and delivers motor + status / IMU to the usual subscribers, `Motor.submit_*()` targets ride on the + exchange, and every request on the link takes turns, so host and follower + never transmit at the same time. Existing controllers run unchanged on top. + `exchange_stats()` reports timeouts, resent and rejected targets and round + trips. Older firmware raises `ProtocolError` and nothing is left running. + `bus::RetryMode::Once` sends exactly one attempt. + - **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 diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 3c681ea..bcf167a 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -20,6 +20,7 @@ add_library(taccap_core SHARED src/discovery.cpp src/leader_gripper.cpp src/follower_gripper.cpp + src/follower_exchange.cpp src/force_position_controller.cpp src/impedance_controller.cpp src/ota.cpp diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 8025284..32895a0 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -121,6 +121,10 @@ enum class RetryMode : uint8_t { // The firmware caches exactly one request, so nothing else may be sent on // this transport between attempts — true for the single-threaded OTA flow. SameSeq, + // Exactly one attempt regardless of Config::max_retries. For the follower + // exchange loop, which owns its own resend policy and must never write a + // second frame while the first one's reply may still be on the wire. + Once, }; class Transport { @@ -252,6 +256,21 @@ class Transport { bool is_open() const noexcept { return serial_.is_open(); } Stats stats() const noexcept; + + // ---- Follower half-duplex exchange support (SDK-internal) -------------- + // While enabled, every request holds one link-wide turn from its write + // until its ACK or timeout, and no-ACK writes take the same turn: at most + // one host frame is ever waiting for the MCU, so a request is never + // written while the MCU may still be answering the previous one. Only + // meaningful when the MCU itself pushes nothing (StreamMode::Polled). + void set_half_duplex(bool enabled) noexcept { + half_duplex_.store(enabled, std::memory_order_release); + } + bool half_duplex() const noexcept { return half_duplex_.load(std::memory_order_acquire); } + // Dispatch a DATA frame decoded from an exchange reply exactly as if it + // had been received: control lane for motor status, then subscribers. + void inject_data(Frame f); + void enable_receive_trace(bool enabled) { parser_.set_trace_enabled(enabled); } FrameParser::TraceSnapshot take_receive_trace() { return parser_.take_trace(); } @@ -295,6 +314,11 @@ class Transport { // can queue behind a large frame; that is the cost of one link. void write_frame_(const std::vector& wire, protocol::Cmd cmd); uint8_t allocate_seq_locked_(); + // Taken (only while half_duplex_) around one request's write and wait. + // Never held by the reader or dispatcher, so callbacks may still send. + std::unique_lock half_duplex_turn_(); + std::mutex half_duplex_mu_; + std::atomic half_duplex_{false}; void reader_loop_(); void dispatch_loop_(); diff --git a/cpp/include/taccap/components/motor.hpp b/cpp/include/taccap/components/motor.hpp index ad2dfe2..0def7b9 100644 --- a/cpp/include/taccap/components/motor.hpp +++ b/cpp/include/taccap/components/motor.hpp @@ -18,6 +18,8 @@ #include #include #include +#include +#include #include namespace xense::taccap { @@ -527,10 +529,18 @@ class Motor { private: friend class ForcePositionController; friend class ImpedanceController; + friend class FollowerGripper; // SDK-owned bounded control callbacks only; never user code. SubId on_control_status_(Callback cb); SubId subscribe_status_(Callback cb, bool control); + // While FollowerGripper runs the half-duplex exchange, submit() hands the + // encoded target to it instead of writing a no-ACK frame. Empty = direct. + using TargetRouter = std::function)>; + void set_target_router_(TargetRouter router); + void submit_(protocol::Cmd cmd, std::vector payload); bus::Transport& t_; + std::mutex router_mu_; + std::shared_ptr router_; }; } // namespace xense::taccap diff --git a/cpp/include/taccap/follower_gripper.hpp b/cpp/include/taccap/follower_gripper.hpp index 8af4f83..d0edb5b 100644 --- a/cpp/include/taccap/follower_gripper.hpp +++ b/cpp/include/taccap/follower_gripper.hpp @@ -214,8 +214,25 @@ std::optional expected_open_reverse(std::uint8_t model_id, std::optional expected_motor_can_id(std::uint8_t model_id, discovery::Side side) noexcept; +class FollowerExchange; + } // namespace detail +// Counters of FollowerGripper's half-duplex exchange since start_exchange(). +// timeouts are exchanges without a reply; a target in one is resent in the +// next exchange unless superseded (targets_resent). targets_rejected counts +// replies whose target_result was not Ok; last_target_result is the raw +// ErrorCode byte of the latest target reply (0xFF before any). +struct ExchangeStats { + uint64_t exchanges = 0, telemetry_exchanges = 0, target_exchanges = 0; + uint64_t timeouts = 0, nacks = 0, malformed = 0; + uint64_t targets_submitted = 0, targets_superseded = 0, targets_resent = 0; + uint64_t targets_rejected = 0; + uint8_t last_target_result = 0xFF; + uint64_t motor_samples = 0, imu_samples = 0, imu_dropped = 0; + uint64_t last_rtt_us = 0, max_rtt_us = 0; +}; + class FollowerGripper { public: struct Config { @@ -510,6 +527,26 @@ class FollowerGripper { void stop_streaming(); bool is_streaming() const noexcept { return streaming_; } + // Half-duplex alternative to start_streaming() (firmware with 0x5D). + // + // The follower is put in StreamMode::Polled: it samples as configured but + // pushes nothing. A host thread sends one exchange per 1/exchange_hz s and + // receives motor status and new IMU samples in the reply; Motor::submit*() + // targets ride on the next exchange (sent at once if the link is idle). + // All requests on this transport, including user queries, take turns, so + // the host never writes while the follower may be answering. This removes + // the full-duplex overlap that loses uplink bytes on the current hardware. + // + // Subscribers (motor().on_status, imu().on_data) and the controllers see + // the same frames as with start_streaming(); is_streaming() is true and + // stop_streaming() ends the exchange. exchange_hz=0 means max(motor_hz, + // imu_hz, 1). Firmware without the polled mode NACKs: ProtocolError, and + // nothing is left running. + void start_exchange(unsigned motor_hz = 100, unsigned imu_hz = 0, + unsigned exchange_hz = 0); + bool is_exchanging() const noexcept { return exchange_ != nullptr; } + ExchangeStats exchange_stats() const; + const Config& config() const noexcept { return cfg_; } private: @@ -548,6 +585,8 @@ class FollowerGripper { std::unique_ptr wrist_; bool streaming_ = false; uint16_t streaming_sources_ = 0; + std::shared_ptr exchange_; + void stop_exchange_() noexcept; GripperPosition pos_map_; // raw<->position, cached bool pos_map_loaded_ = false; }; diff --git a/cpp/include/taccap/protocol/commands.hpp b/cpp/include/taccap/protocol/commands.hpp index 426a9b7..81828a4 100644 --- a/cpp/include/taccap/protocol/commands.hpp +++ b/cpp/include/taccap/protocol/commands.hpp @@ -138,6 +138,10 @@ enum class Cmd : uint8_t { 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 + // Follower half-duplex exchange: request may carry one 0x40..0x43 target, + // reply carries motor status V2 + new IMU samples. Used with + // StreamMode::Polled; see FollowerGripper::start_exchange(). + SlaveExchange = 0x5D, // 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 fa91b50..d39b3bc 100644 --- a/cpp/include/taccap/protocol/payloads.hpp +++ b/cpp/include/taccap/protocol/payloads.hpp @@ -995,6 +995,39 @@ namespace StreamSrc { enum class StreamMode : uint8_t { Separate = 0, Combined = 1, + // Follower only: acquisition runs as configured but nothing is pushed; + // the host pulls it with Cmd::SlaveExchange. Older firmware NACKs it. + Polled = 2, +}; + +// ---- Follower half-duplex exchange (Cmd::SlaveExchange, 0x5D) ------------- +// Request: ExchangeRequestHeader + the target struct for target_kind, exact +// length. Reply: ExchangeResponseHeader, then MotorStatus V2 (59 B) when +// ExchangeRespFlag::Motor is set, then imu_count IMU packets whose format is +// imu_cmd (GetImu 28 B or GetImuV2 44 B), oldest first. +namespace ExchangeFlag { constexpr uint8_t Telemetry = 0x01; } +namespace ExchangeRespFlag { constexpr uint8_t Motor = 0x01; } +enum class ExchangeTarget : uint8_t { + None = 0, Position = 1, Velocity = 2, Torque = 3, Impedance = 4, +}; +constexpr uint8_t EXCHANGE_VERSION = 1; +constexpr uint8_t EXCHANGE_TARGET_RESULT_NONE = 0xFF; + +struct ExchangeRequestHeader { + uint8_t version; // EXCHANGE_VERSION + uint8_t flags; // ExchangeFlag + uint8_t target_kind; // ExchangeTarget + uint8_t reserved; +}; + +struct ExchangeResponseHeader { + uint8_t version; // EXCHANGE_VERSION + uint8_t flags; // ExchangeRespFlag + uint8_t target_result; // ErrorCode of the carried target, 0xFF if none + uint8_t imu_cmd; // 0, Cmd::GetImu or Cmd::GetImuV2 + uint8_t imu_count; + uint8_t imu_dropped; // firmware queue overflow since the previous take + uint8_t reserved[2]; }; enum class StreamInterface : uint8_t { @@ -1162,6 +1195,8 @@ struct OtaStatus { static_assert(sizeof(FirmwareVersion) == 4); static_assert(sizeof(SnInfo) == 17); static_assert(sizeof(ImuData) == 28); // IMU_PACKET_SIZE +static_assert(sizeof(ExchangeRequestHeader) == 4); +static_assert(sizeof(ExchangeResponseHeader) == 8); static_assert(sizeof(ImuConfig) == 10); static_assert(sizeof(EncoderData) == 16); // ENCODER_PACKET_SIZE static_assert(sizeof(EncoderConfig) == 5); // firmware 0086da6 (was 14) diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index eac4044..2f7d111 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -75,8 +75,9 @@ AckResponse Transport::send_cmd(protocol::Cmd cmd, reserved_seqs_[seq] = false; reserved = false; }; + const unsigned max_retries = retry == RetryMode::Once ? 0u : cfg_.max_retries; try { - for (unsigned attempt = 0; attempt <= cfg_.max_retries; ++attempt) { + for (unsigned attempt = 0; attempt <= max_retries; ++attempt) { if (reserved && retry == RetryMode::NewSeq) release(); std::future fut; { @@ -93,9 +94,12 @@ AckResponse Transport::send_cmd(protocol::Cmd cmd, fut = pending->promise.get_future(); pending_acks_[seq] = pending; } + auto turn = half_duplex_turn_(); write_frame_(pack_frame(cfg_.peer, seq, protocol::FrameType::CMD_NEED_ACK, cmd, payload), cmd); - if (fut.wait_for(t) == std::future_status::ready) { + const bool answered = fut.wait_for(t) == std::future_status::ready; + if (turn.owns_lock()) turn.unlock(); + if (answered) { auto ack = fut.get(); release(); return ack; @@ -108,13 +112,13 @@ AckResponse Transport::send_cmd(protocol::Cmd cmd, } // SameSeq owns its slot throughout retry delays (OTA deduplication). stat_ack_timeouts_.fetch_add(1, std::memory_order_relaxed); - if (attempt < cfg_.max_retries) { + if (attempt < max_retries) { stat_retries_.fetch_add(1, std::memory_order_relaxed); std::this_thread::sleep_for(cfg_.retry_interval); } } throw TimeoutError("send_cmd " + std::string(protocol::to_string(cmd)) + - ": no ACK after " + std::to_string(cfg_.max_retries + 1) + + ": no ACK after " + std::to_string(max_retries + 1) + " attempts", ETIMEDOUT); } catch (...) { release(); @@ -141,10 +145,22 @@ void Transport::send_cmd_no_ack(protocol::Cmd cmd, throw IoError("send_cmd_no_ack on stopped transport", EBADF); seq = allocate_seq_locked_(); } + auto turn = half_duplex_turn_(); write_frame_(pack_frame(cfg_.peer, seq, protocol::FrameType::CMD_NO_ACK, cmd, payload), cmd); } +std::unique_lock Transport::half_duplex_turn_() { + if (!half_duplex_.load(std::memory_order_acquire)) return {}; + return std::unique_lock(half_duplex_mu_); +} + +void Transport::inject_data(Frame f) { + f.type = protocol::FrameType::DATA; + if (f.cmd == protocol::Cmd::GetMotorStatus) enqueue_control_(f); + enqueue_data_(std::move(f)); +} + Transport::TargetWrites Transport::target_writes() { std::lock_guard lk(write_mu_); return target_writes_; diff --git a/cpp/src/components/motor.cpp b/cpp/src/components/motor.cpp index 3a447d8..f2f4d4a 100644 --- a/cpp/src/components/motor.cpp +++ b/cpp/src/components/motor.cpp @@ -95,16 +95,33 @@ void Motor::set_impedance(float pos, float kp, float kd, float ff_torque, // throw on NACK (there is none). The struct overloads are the single source of // truth; the float wrappers delegate. See motor.hpp for the health contract. void Motor::submit(const protocol::MotorImpedanceCtrl& c) { - t_.send_cmd_no_ack(protocol::Cmd::MotorImpedanceCtrl, protocol::encode(c)); + submit_(protocol::Cmd::MotorImpedanceCtrl, protocol::encode(c)); } void Motor::submit(const protocol::MotorPosCtrl& c) { - t_.send_cmd_no_ack(protocol::Cmd::MotorPosCtrl, protocol::encode(c)); + submit_(protocol::Cmd::MotorPosCtrl, protocol::encode(c)); } void Motor::submit(const protocol::MotorVelCtrl& c) { - t_.send_cmd_no_ack(protocol::Cmd::MotorVelCtrl, protocol::encode(c)); + submit_(protocol::Cmd::MotorVelCtrl, protocol::encode(c)); } void Motor::submit(const protocol::MotorTorqueCtrl& c) { - t_.send_cmd_no_ack(protocol::Cmd::MotorTorqueCtrl, protocol::encode(c)); + submit_(protocol::Cmd::MotorTorqueCtrl, protocol::encode(c)); +} + +void Motor::set_target_router_(TargetRouter router) { + std::shared_ptr next; + if (router) next = std::make_shared(std::move(router)); + std::lock_guard lk(router_mu_); + router_ = std::move(next); +} + +void Motor::submit_(protocol::Cmd cmd, std::vector payload) { + std::shared_ptr router; + { + std::lock_guard lk(router_mu_); + router = router_; + } + if (router) (*router)(cmd, std::move(payload)); + else t_.send_cmd_no_ack(cmd, payload); } void Motor::submit_impedance(float pos, float kp, float kd, float ff_torque, diff --git a/cpp/src/follower_exchange.cpp b/cpp/src/follower_exchange.cpp new file mode 100644 index 0000000..c8a52cf --- /dev/null +++ b/cpp/src/follower_exchange.cpp @@ -0,0 +1,172 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 + +#include "follower_exchange.hpp" + +#include + +#include + +namespace xense::taccap::detail { + +namespace { + +using clock = std::chrono::steady_clock; + +std::optional target_kind(protocol::Cmd cmd) { + switch (cmd) { + case protocol::Cmd::MotorPosCtrl: return protocol::ExchangeTarget::Position; + case protocol::Cmd::MotorVelCtrl: return protocol::ExchangeTarget::Velocity; + case protocol::Cmd::MotorTorqueCtrl: return protocol::ExchangeTarget::Torque; + case protocol::Cmd::MotorImpedanceCtrl: return protocol::ExchangeTarget::Impedance; + default: return std::nullopt; + } +} + +std::size_t imu_size(uint8_t cmd) { + if (cmd == static_cast(protocol::Cmd::GetImu)) return sizeof(protocol::ImuData); + if (cmd == static_cast(protocol::Cmd::GetImuV2)) return sizeof(protocol::ImuData) + 16; + return 0; +} + +} // namespace + +FollowerExchange::FollowerExchange(bus::Transport& transport, + std::chrono::microseconds period, + std::chrono::milliseconds timeout) + : t_(transport), period_(period), timeout_(timeout) { + worker_ = std::thread([this] { run_(); }); +} + +FollowerExchange::~FollowerExchange() { stop(); } + +void FollowerExchange::stop() noexcept { + { + std::lock_guard lk(mu_); + stop_ = true; + } + cv_.notify_all(); + if (worker_.joinable() && worker_.get_id() != std::this_thread::get_id()) worker_.join(); +} + +void FollowerExchange::submit(protocol::Cmd cmd, std::vector payload) { + const auto kind = target_kind(cmd); + if (!kind) throw IoError("FollowerExchange: not a motor target command", EINVAL); + { + std::lock_guard lk(mu_); + if (stop_) return; + if (pending_) ++stats_.targets_superseded; + pending_ = Target{*kind, std::move(payload)}; + ++stats_.targets_submitted; + } + cv_.notify_all(); +} + +ExchangeStats FollowerExchange::stats() const { + std::lock_guard lk(mu_); + return stats_; +} + +void FollowerExchange::run_() { + auto next_tick = clock::now(); + std::unique_lock lk(mu_); + while (!stop_) { + cv_.wait_until(lk, next_tick, [&] { return stop_ || pending_.has_value(); }); + if (stop_) break; + const bool telemetry = clock::now() >= next_tick; + std::optional target = std::move(pending_); + pending_.reset(); + lk.unlock(); + + protocol::ExchangeRequestHeader head{}; + head.version = protocol::EXCHANGE_VERSION; + head.flags = telemetry ? protocol::ExchangeFlag::Telemetry : 0; + head.target_kind = static_cast(target ? target->kind : protocol::ExchangeTarget::None); + std::vector req(sizeof(head)); + std::memcpy(req.data(), &head, sizeof(head)); + if (target) req.insert(req.end(), target->payload.begin(), target->payload.end()); + + const auto started = clock::now(); + std::optional ack; + bool link_down = false; + try { + ack = t_.send_cmd(protocol::Cmd::SlaveExchange, req, timeout_, bus::RetryMode::Once); + } catch (const TimeoutError&) { + } catch (const std::exception&) { + link_down = !t_.is_running(); + } + const auto rtt_us = static_cast( + std::chrono::duration_cast(clock::now() - started).count()); + + if (ack) handle_reply_(*ack, target.has_value()); + + lk.lock(); + ++stats_.exchanges; + if (telemetry) ++stats_.telemetry_exchanges; + if (target) ++stats_.target_exchanges; + if (ack) { + stats_.last_rtt_us = rtt_us; + if (rtt_us > stats_.max_rtt_us) stats_.max_rtt_us = rtt_us; + } else { + ++stats_.timeouts; + // Not acknowledged: resend unless a newer target replaced it. + if (target && !pending_) { + pending_ = std::move(target); + ++stats_.targets_resent; + } + } + if (link_down) break; + if (telemetry) { + next_tick += period_; + const auto now = clock::now(); + if (next_tick <= now) next_tick = now + period_; // never burst to catch up + } + } +} + +void FollowerExchange::handle_reply_(const bus::AckResponse& ack, bool carried_target) { + const auto& d = ack.data; + protocol::ExchangeResponseHeader h{}; + bool ok = !ack.is_nack && d.size() >= sizeof(h); + if (ok) { + std::memcpy(&h, d.data(), sizeof(h)); + ok = h.version == protocol::EXCHANGE_VERSION; + } + std::size_t pos = sizeof(h); + const std::size_t motor = (ok && (h.flags & protocol::ExchangeRespFlag::Motor)) + ? protocol::MOTOR_STATUS_V2_SIZE : 0; + const std::size_t imu = ok ? imu_size(h.imu_cmd) : 0; + if (ok && h.imu_count && !imu) ok = false; + if (ok && d.size() != pos + motor + std::size_t(h.imu_count) * imu) ok = false; + + std::vector frames; + if (ok) { + auto frame = [&](protocol::Cmd cmd, std::size_t n) { + bus::Frame f{}; + f.addr = protocol::Address::MCU; + f.seq = ack.seq; + f.type = protocol::FrameType::DATA; + f.cmd = cmd; + f.payload.assign(d.begin() + static_cast(pos), + d.begin() + static_cast(pos + n)); + f.receive_time = ack.receive_time; + pos += n; + frames.push_back(std::move(f)); + }; + if (motor) frame(protocol::Cmd::GetMotorStatus, motor); + for (unsigned i = 0; i < h.imu_count; ++i) frame(static_cast(h.imu_cmd), imu); + } + for (auto& f : frames) t_.inject_data(std::move(f)); + + std::lock_guard lk(mu_); + if (ack.is_nack) { ++stats_.nacks; return; } + if (!ok) { ++stats_.malformed; return; } + if (motor) ++stats_.motor_samples; + stats_.imu_samples += h.imu_count; + stats_.imu_dropped += h.imu_dropped; + if (carried_target) { + stats_.last_target_result = h.target_result; + if (h.target_result != 0) ++stats_.targets_rejected; + } +} + +} // namespace xense::taccap::detail diff --git a/cpp/src/follower_exchange.hpp b/cpp/src/follower_exchange.hpp new file mode 100644 index 0000000..ce86163 --- /dev/null +++ b/cpp/src/follower_exchange.hpp @@ -0,0 +1,78 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// Host side of the follower half-duplex exchange (Cmd::SlaveExchange). +// +// Why: with the firmware streaming on its own clock, a host write that lands +// while the follower is transmitting loses the overlapping uplink bytes +// (tc-gu-01 docs/TX_COLLISION_RETRY.md). In StreamMode::Polled the follower +// pushes nothing; this loop is the only thing that asks it to talk, and the +// transport's half-duplex turn keeps every other request out of the way while +// a reply may be on the wire. The line therefore carries one direction at a +// time by construction, not by luck. +// +// Cadence: one telemetry exchange per period. A target submitted between +// ticks goes out at once in a target-only exchange (header-only reply), so a +// controller that reacts to a status sample keeps today's ~1 ms latency +// instead of waiting a whole period. A request that times out keeps its +// target for the next exchange unless a newer one superseded it; targets are +// absolute set-points, so applying one twice is harmless. +// +// Replies are split back into the DATA frames a stream would have carried +// (GetMotorStatus, GetImu/GetImuV2) and injected into the transport's normal +// dispatch, so subscribers and the controllers' control lane see the same +// samples as in streaming mode. + +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xense::taccap::detail { + +class FollowerExchange { +public: + FollowerExchange(bus::Transport& transport, std::chrono::microseconds period, + std::chrono::milliseconds timeout); + ~FollowerExchange(); + + FollowerExchange(const FollowerExchange&) = delete; + FollowerExchange& operator=(const FollowerExchange&) = delete; + + // Latest target wins. Only the four Motor*Ctrl commands are accepted; + // anything else throws IoError(EINVAL). After stop() it is ignored. + void submit(protocol::Cmd cmd, std::vector payload); + + // Idempotent; joins the worker. + void stop() noexcept; + + ExchangeStats stats() const; + +private: + struct Target { + protocol::ExchangeTarget kind; + std::vector payload; + }; + + void run_(); + void handle_reply_(const bus::AckResponse& ack, bool carried_target); + + bus::Transport& t_; + const std::chrono::microseconds period_; + const std::chrono::milliseconds timeout_; + + mutable std::mutex mu_; + std::condition_variable cv_; + std::optional pending_; + bool stop_ = false; + ExchangeStats stats_{}; + std::thread worker_; +}; + +} // namespace xense::taccap::detail diff --git a/cpp/src/follower_gripper.cpp b/cpp/src/follower_gripper.cpp index ef5661e..4c2257e 100644 --- a/cpp/src/follower_gripper.cpp +++ b/cpp/src/follower_gripper.cpp @@ -3,6 +3,7 @@ #include #include "wrist_fisheye.hpp" #include "stream_rate.hpp" +#include "follower_exchange.hpp" #include #include #include @@ -295,6 +296,7 @@ FollowerGripper::~FollowerGripper() { } void FollowerGripper::close() noexcept { + stop_exchange_(); if (!t_.is_open()) return; if (t_.is_running()) { try { stop_streaming(); } catch (...) {} @@ -375,7 +377,63 @@ void FollowerGripper::start_streaming(unsigned motor_hz, unsigned imu_hz) { streaming_ = true; } +void FollowerGripper::start_exchange(unsigned motor_hz, unsigned imu_hz, + unsigned exchange_hz) { + if (imu_hz > 100) throw IoError("Follower IMU rate must be 0..100 Hz", EINVAL); + if (motor_hz > 100) throw IoError("Follower motor status rate must be 0..100 Hz", EINVAL); + if (motor_hz == 0 && imu_hz == 0) + throw IoError("FollowerGripper::start_exchange: both rates are 0", EINVAL); + if (exchange_hz == 0) exchange_hz = std::max({motor_hz, imu_hz, 1u}); + if (exchange_hz > 500) throw IoError("FollowerGripper::start_exchange: exchange_hz must be <= 500", EINVAL); + if (streaming_) throw IoError("FollowerGripper: stop streaming before start_exchange", EBUSY); + + if (imu_hz) (void)imu_.read_once(std::chrono::milliseconds{500}); + try { + t_.send_cmd(protocol::Cmd::StopStream, {}, std::chrono::milliseconds(500)); + } catch (...) { /* expected when fw is already idle */ } + + protocol::StreamConfig sc{}; + sc.source_mask = detail::stream_source_mask(imu_hz, 0, motor_hz); + const auto logical_sources = sc.source_mask; + if (imu_hz) sc.source_mask = (sc.source_mask & ~protocol::StreamSrc::Imu) | imu_.negotiate_time(); + sc.mode = static_cast(protocol::StreamMode::Polled); + sc.imu_rate = static_cast(imu_hz); + sc.motor_rate = static_cast(motor_hz); + sc.output_iface = static_cast(protocol::StreamInterface::Uart); + auto ack = t_.send_cmd(protocol::Cmd::StartStream, protocol::encode(sc)); + if (ack.is_nack) { + throw ProtocolError(std::string("FollowerGripper::start_exchange NACK (firmware " + "without polled mode / 0x5D?): ") + protocol::to_string(ack.error_code)); + } + + t_.set_half_duplex(true); + // A stuck reply must not hold the link longer than a couple of periods. + const auto period = std::chrono::microseconds(1'000'000 / exchange_hz); + const auto timeout = std::chrono::milliseconds(std::max( + 5, std::chrono::duration_cast(2 * period).count())); + exchange_ = std::make_shared(t_, period, timeout); + std::weak_ptr weak = exchange_; + motor_.set_target_router_([weak](protocol::Cmd cmd, std::vector payload) { + if (auto ex = weak.lock()) ex->submit(cmd, std::move(payload)); + }); + streaming_sources_ = logical_sources; + streaming_ = true; +} + +ExchangeStats FollowerGripper::exchange_stats() const { + return exchange_ ? exchange_->stats() : ExchangeStats{}; +} + +void FollowerGripper::stop_exchange_() noexcept { + if (!exchange_) return; + motor_.set_target_router_({}); + exchange_->stop(); + exchange_.reset(); + t_.set_half_duplex(false); +} + void FollowerGripper::stop_streaming() { + stop_exchange_(); if (!streaming_) return; streaming_ = false; try { diff --git a/cpp/src/protocol/commands.cpp b/cpp/src/protocol/commands.cpp index ceb3a65..b3f1f05 100644 --- a/cpp/src/protocol/commands.cpp +++ b/cpp/src/protocol/commands.cpp @@ -85,6 +85,7 @@ const char* to_string(Cmd c) noexcept { case Cmd::SetMotorModel: return "SetMotorModel"; case Cmd::MotorCanExtXfer: return "MotorCanExtXfer"; case Cmd::SetMotorFwVersion: return "SetMotorFwVersion"; + case Cmd::SlaveExchange: return "SlaveExchange"; case Cmd::GetUartStats: return "GetUartStats"; case Cmd::SetLogConfig: return "SetLogConfig"; diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 156e368..a739ff4 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(taccap_unit_tests test_follower_imu_pty.cpp test_imu_motor_concurrency.cpp test_imu_time.cpp + test_follower_exchange_pty.cpp ) # stream_rate.hpp is a private helper under src/ (not an installed header), diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index c8a410a..c6d8810 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -102,6 +102,25 @@ class FakeFollower { std::lock_guard lk(mu_); return stream_config_; } + // ---- Half-duplex exchange (StreamMode::Polled + Cmd::SlaveExchange) ---- + // Firmware older than the polled mode NACKs StartStream mode 2. + void set_exchange_supported(bool on) { exchange_supported_.store(on); } + bool polled() const { return polled_.load(); } + unsigned exchanges() const { return exchanges_.load(); } + unsigned telemetry_exchanges() const { return telemetry_exchanges_.load(); } + unsigned exchange_targets() const { return exchange_targets_.load(); } + // Hold each exchange reply this long, and count any host frame that + // arrives meanwhile: a half-duplex host never writes while waiting. + void set_exchange_reply_delay_ms(int ms) { exchange_delay_ms_.store(ms); } + unsigned overlaps() const { return overlaps_.load(); } + // Swallow the next n exchange requests (the target is still applied), + // so the host sees timeouts. + void drop_exchange_replies(unsigned n) { drop_replies_.store(n); } + // Make the next exchange reply's target_result this ErrorCode byte. + void reject_next_exchange_target(uint8_t err) { reject_next_.store(err); } + // Non-exchange frames seen while polled (heartbeats, queries, etc.). + unsigned other_frames_while_polled() const { return other_while_polled_.load(); } + // Make 0x5C store something other than what was sent. void set_corrupt_fw_ver_writes(bool on) { corrupt_fw_ver_writes_.store(on); } @@ -187,7 +206,12 @@ class FakeFollower { } void handle_(const xense::taccap::bus::Frame& f) { + if (polled_.load() && f.cmd != tp::Cmd::SlaveExchange) ++other_while_polled_; switch (f.cmd) { + case tp::Cmd::SlaveExchange: { + handle_exchange_(f); + return; + } case tp::Cmd::GetVersion: { const tp::FirmwareVersion v{fw_major_, fw_minor_, fw_patch_, 0}; pty_.send_response(f.seq, f.cmd, pod_bytes(&v, sizeof(v))); @@ -377,13 +401,22 @@ class FakeFollower { pty_.send_nack(f.seq, tp::ErrorCode::LengthMismatch); return; } - std::memcpy(&stream_config_, f.payload.data(), sizeof(stream_config_)); - streaming_.store(true); + tp::StreamConfig requested{}; + std::memcpy(&requested, f.payload.data(), sizeof(requested)); + const bool want_polled = requested.mode == static_cast(tp::StreamMode::Polled); + if (want_polled && !exchange_supported_.load()) { + pty_.send_nack(f.seq, tp::ErrorCode::InvalidParam); + return; + } + stream_config_ = requested; + polled_.store(want_polled); + streaming_.store(!want_polled); pty_.send_ack_ok(f.seq, f.cmd); return; } case tp::Cmd::StopStream: ++stream_stops_; + polled_.store(false); streaming_.store(false); pty_.send_ack_ok(f.seq, f.cmd); return; @@ -404,6 +437,76 @@ class FakeFollower { } } + void handle_exchange_(const xense::taccap::bus::Frame& f) { + tp::ExchangeRequestHeader h{}; + if (f.payload.size() < sizeof(h)) { pty_.send_nack(f.seq, tp::ErrorCode::LengthMismatch); return; } + std::memcpy(&h, f.payload.data(), sizeof(h)); + ++exchanges_; + const bool telemetry = h.flags & tp::ExchangeFlag::Telemetry; + if (telemetry) ++telemetry_exchanges_; + uint8_t result = tp::EXCHANGE_TARGET_RESULT_NONE; + if (h.target_kind != 0) { + ++exchange_targets_; + result = reject_next_.exchange(0); + if (h.target_kind == static_cast(tp::ExchangeTarget::Impedance) && + f.payload.size() == sizeof(h) + sizeof(tp::MotorImpedanceCtrl) && result == 0) { + std::lock_guard lk(mu_); + tp::MotorImpedanceCtrl c{}; + std::memcpy(&c, f.payload.data() + sizeof(h), sizeof(c)); + last_submit_ = c; + } + } + if (const int delay = exchange_delay_ms_.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + if (auto extra = pty_.expect_frame(1)) { + ++overlaps_; + deferred_.push_back(*extra); + } + } + unsigned drop = drop_replies_.load(); + while (drop && !drop_replies_.compare_exchange_weak(drop, drop - 1)) {} + if (drop) return; + tp::ExchangeResponseHeader r{}; + r.version = tp::EXCHANGE_VERSION; + r.target_result = result; + std::vector out(sizeof(r)); + if (telemetry) { + std::lock_guard lk(mu_); + if (stream_config_.source_mask & tp::StreamSrc::MotorStatus) { + r.flags |= tp::ExchangeRespFlag::Motor; + auto s = pod_bytes(&status_, sizeof(status_)); + s.resize(tp::MOTOR_STATUS_V2_SIZE, 0); + out.insert(out.end(), s.begin(), s.end()); + } + if (stream_config_.source_mask & tp::StreamSrc::ImuV2) { + r.imu_cmd = static_cast(tp::Cmd::GetImuV2); + r.imu_count = 1; + auto imu = imu_v2_packet_(); + out.insert(out.end(), imu.begin(), imu.end()); + } else if (stream_config_.source_mask & tp::StreamSrc::Imu) { + r.imu_cmd = static_cast(tp::Cmd::GetImu); + r.imu_count = 1; + tp::ImuData imu{}; + imu.accel_z = 1000; + imu.seq = imu_seq_++; + auto b = pod_bytes(&imu, sizeof(imu)); + out.insert(out.end(), b.begin(), b.end()); + } + } + std::memcpy(out.data(), &r, sizeof(r)); + pty_.send_response(f.seq, f.cmd, out); + auto later = std::move(deferred_); + deferred_.clear(); + for (auto& g : later) handle_(g); + } + + std::atomic exchange_supported_{true}, polled_{false}; + std::atomic exchanges_{0}, telemetry_exchanges_{0}, exchange_targets_{0}; + std::atomic overlaps_{0}, drop_replies_{0}, other_while_polled_{0}; + std::atomic exchange_delay_ms_{0}; + std::atomic reject_next_{0}; + std::vector deferred_; + Pty& pty_; uint8_t fw_major_, fw_minor_, fw_patch_; std::thread thread_; diff --git a/cpp/tests/test_follower_exchange_pty.cpp b/cpp/tests/test_follower_exchange_pty.cpp new file mode 100644 index 0000000..e59566c --- /dev/null +++ b/cpp/tests/test_follower_exchange_pty.cpp @@ -0,0 +1,222 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// FollowerGripper::start_exchange() against the fake follower: polled mode, +// telemetry fan-out to the ordinary subscribers, targets riding on exchanges +// instead of no-ACK frames, strict one-request-at-a-time on the link (with +// concurrent user queries), timeout resend, rejection reporting, and a +// ForcePositionController running unmodified on top. + +#include "fake_follower.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace tx = xense::taccap; +namespace tp = xense::taccap::protocol; + +using taccap_test::FakeFollower; +using taccap_test::Pty; +using taccap_test::open_follower; +using taccap_test::wait_for; + +namespace { +constexpr auto kSecond = std::chrono::milliseconds(1000); +} + +TEST(FollowerExchangePty, PolledModeFeedsStatusAndImuSubscribersWithoutStreaming) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto g = open_follower(pty); + + std::atomic status{0}, imu{0}; + g->motor().on_status([&](const tx::MotorStatusSample&) { ++status; }); + g->imu().on_data([&](const tx::ImuSample&) { ++imu; }); + g->start_exchange(100, 100); + + EXPECT_TRUE(g->is_exchanging()); + EXPECT_TRUE(g->is_streaming()); + EXPECT_TRUE(fw.polled()); + EXPECT_EQ(fw.stream_config().mode, static_cast(tp::StreamMode::Polled)); + ASSERT_TRUE(wait_for([&] { return status.load() >= 20 && imu.load() >= 20; }, 2 * kSecond)) + << "status=" << status << " imu=" << imu; + const auto s = g->exchange_stats(); + EXPECT_GE(s.telemetry_exchanges, 20u); + EXPECT_EQ(s.timeouts, 0u); + EXPECT_EQ(s.malformed, 0u); + EXPECT_EQ(s.motor_samples, s.telemetry_exchanges); + + g->stop_streaming(); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_FALSE(fw.polled()); + const unsigned after = fw.exchanges(); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + EXPECT_EQ(fw.exchanges(), after) << "exchange loop kept running after stop_streaming"; +} + +TEST(FollowerExchangePty, SubmitRidesOnAnExchangeAndGoesOutPromptly) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + g->start_exchange(10); // slow telemetry: a target must not wait for the tick + + ASSERT_TRUE(wait_for([&] { return fw.telemetry_exchanges() >= 1; }, kSecond)); + const unsigned targets = fw.exchange_targets(); + const auto t0 = std::chrono::steady_clock::now(); + g->motor().submit_impedance(0.25f, 2.0f, 0.1f, 0.3f, 0.0f); + ASSERT_TRUE(wait_for([&] { return fw.exchange_targets() > targets; }, kSecond)); + const auto latency = std::chrono::steady_clock::now() - t0; + EXPECT_LT(latency, std::chrono::milliseconds(30)) << "target waited for a telemetry tick"; + EXPECT_EQ(fw.submit_count(), 0u) << "no-ACK 0x43 frame written during exchange"; + const auto last = fw.last_submit(); + ASSERT_TRUE(last.has_value()); + EXPECT_FLOAT_EQ(last->target_pos, 0.25f); + EXPECT_FLOAT_EQ(last->kp, 2.0f); + EXPECT_FLOAT_EQ(last->target_torque, 0.3f); + // The fake records the target before the host has processed its reply. + EXPECT_TRUE(wait_for([&] { return g->exchange_stats().last_target_result == 0; }, kSecond)); + + g->stop_streaming(); + g->motor().submit_impedance(0.1f, 0.0f, 0.0f, 0.0f, 0.0f); + EXPECT_TRUE(wait_for([&] { return fw.submit_count() == 1; }, kSecond)) + << "after stop the target must go out as the ordinary no-ACK frame"; +} + +TEST(FollowerExchangePty, NeverWritesWhileAReplyIsPendingEvenWithConcurrentQueries) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + fw.set_exchange_reply_delay_ms(3); + g->start_exchange(100); + + std::atomic stop{false}; + std::atomic heartbeats{0}, heartbeat_timeouts{0}; + std::thread user([&] { + while (!stop.load()) { + try { g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200)); ++heartbeats; } + catch (...) { ++heartbeat_timeouts; } + } + }); + std::thread submitter([&] { + for (int i = 0; i < 200 && !stop.load(); ++i) { + g->motor().submit_impedance(0.01f * i, 0, 0, 0, 0); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + }); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + stop.store(true); + submitter.join(); + user.join(); + + EXPECT_GT(fw.exchanges(), 15u); + EXPECT_GT(heartbeats.load(), 5u); + EXPECT_GT(fw.exchange_targets(), 5u); + // The host may only write over a pending reply after giving up on it. On + // an unloaded machine that never happens; a starved test runner can delay + // the fake past the timeout, and then each overlap must be a timeout. + const auto gave_up = g->exchange_stats().timeouts + heartbeat_timeouts.load(); + EXPECT_LE(fw.overlaps(), gave_up) << "host wrote a frame while an exchange reply was pending"; + g->stop_streaming(); +} + +TEST(FollowerExchangePty, TimedOutTargetIsResentUnlessSuperseded) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + g->start_exchange(20); + ASSERT_TRUE(wait_for([&] { return fw.telemetry_exchanges() >= 1; }, kSecond)); + + fw.drop_exchange_replies(1); + const unsigned targets = fw.exchange_targets(); + g->motor().submit_impedance(0.5f, 1.0f, 0.0f, 0.0f, 0.0f); + ASSERT_TRUE(wait_for([&] { return fw.exchange_targets() >= targets + 2; }, kSecond)) + << "dropped target was not resent"; + ASSERT_TRUE(wait_for([&] { return g->exchange_stats().targets_resent >= 1; }, kSecond)); + const auto s = g->exchange_stats(); + EXPECT_GE(s.timeouts, 1u); + ASSERT_TRUE(fw.last_submit().has_value()); + EXPECT_FLOAT_EQ(fw.last_submit()->target_pos, 0.5f); + g->stop_streaming(); +} + +TEST(FollowerExchangePty, RejectedTargetIsCountedAndTelemetryContinues) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + std::atomic status{0}; + g->motor().on_status([&](const tx::MotorStatusSample&) { ++status; }); + g->start_exchange(100); + + fw.reject_next_exchange_target(static_cast(tp::ErrorCode::SysBusy)); + g->motor().submit_impedance(0.2f, 0, 0, 0, 0); + ASSERT_TRUE(wait_for([&] { return g->exchange_stats().targets_rejected == 1; }, kSecond)); + EXPECT_EQ(g->exchange_stats().last_target_result, static_cast(tp::ErrorCode::SysBusy)); + const unsigned before = status.load(); + EXPECT_TRUE(wait_for([&] { return status.load() >= before + 5; }, kSecond)); + g->stop_streaming(); +} + +TEST(FollowerExchangePty, OldFirmwareNacksAndNothingIsLeftRunning) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_exchange_supported(false); + auto g = open_follower(pty); + EXPECT_THROW(g->start_exchange(100), tx::ProtocolError); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_FALSE(g->is_streaming()); + EXPECT_FALSE(g->transport().half_duplex()); + g->motor().submit_impedance(0.1f, 0, 0, 0, 0); + EXPECT_TRUE(wait_for([&] { return fw.submit_count() == 1; }, kSecond)); +} + +TEST(FollowerExchangePty, ArgumentsAndStateAreValidated) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + EXPECT_THROW(g->start_exchange(0, 0), tx::IoError); + EXPECT_THROW(g->start_exchange(101), tx::IoError); + EXPECT_THROW(g->start_exchange(100, 101), tx::IoError); + EXPECT_THROW(g->start_exchange(100, 0, 501), tx::IoError); + g->start_streaming(100); + EXPECT_THROW(g->start_exchange(100), tx::IoError); + g->stop_streaming(); + g->start_exchange(100); + EXPECT_TRUE(g->transport().half_duplex()); + g->close(); + EXPECT_FALSE(g->is_exchanging()); +} + +TEST(FollowerExchangePty, ForcePositionControllerRunsUnmodifiedOverTheExchange) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_exchange_reply_delay_ms(1); + auto g = open_follower(pty); + g->start_exchange(100); + + tx::ForcePositionConfig cfg; + tx::ForcePositionController c(*g, cfg); + c.start(); + ASSERT_TRUE(c.running()); + ASSERT_TRUE(wait_for([&] { return fw.exchange_targets() >= 20; }, 2 * kSecond)) + << "controller targets via exchange: " << fw.exchange_targets(); + EXPECT_EQ(fw.submit_count(), 0u); + EXPECT_EQ(c.state(), tx::ForcePositionState::HoldingPosition); + EXPECT_TRUE(c.snapshot().observation.valid); + EXPECT_LE(fw.overlaps(), g->exchange_stats().timeouts); + c.stop(); + EXPECT_TRUE(g->is_exchanging()) << "controller must not stop a stream it did not start"; + g->stop_streaming(); +} diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index 1a7fb0e..5805e5a 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -371,6 +371,36 @@ void bind_gripper(py::module_& m) { "." + std::to_string(v.build) + ")"; }); + py::class_(m, "ExchangeStats", + "Counters of FollowerGripper's half-duplex exchange since start_exchange().") + .def_readonly("exchanges", &ExchangeStats::exchanges) + .def_readonly("telemetry_exchanges", &ExchangeStats::telemetry_exchanges) + .def_readonly("target_exchanges", &ExchangeStats::target_exchanges) + .def_readonly("timeouts", &ExchangeStats::timeouts, + "Exchanges without a reply; a carried target is resent.") + .def_readonly("nacks", &ExchangeStats::nacks) + .def_readonly("malformed", &ExchangeStats::malformed) + .def_readonly("targets_submitted", &ExchangeStats::targets_submitted) + .def_readonly("targets_superseded", &ExchangeStats::targets_superseded, + "Targets replaced by a newer one before they went out.") + .def_readonly("targets_resent", &ExchangeStats::targets_resent) + .def_readonly("targets_rejected", &ExchangeStats::targets_rejected) + .def_readonly("last_target_result", &ExchangeStats::last_target_result, + "Raw ErrorCode byte of the latest target reply; 0xFF before any.") + .def_readonly("motor_samples", &ExchangeStats::motor_samples) + .def_readonly("imu_samples", &ExchangeStats::imu_samples) + .def_readonly("imu_dropped", &ExchangeStats::imu_dropped, + "IMU samples the firmware queue overflowed before they were fetched.") + .def_readonly("last_rtt_us", &ExchangeStats::last_rtt_us) + .def_readonly("max_rtt_us", &ExchangeStats::max_rtt_us) + .def("__repr__", [](const ExchangeStats& s) { + return "ExchangeStats(exchanges=" + std::to_string(s.exchanges) + + ", timeouts=" + std::to_string(s.timeouts) + + ", targets=" + std::to_string(s.target_exchanges) + + ", rejected=" + std::to_string(s.targets_rejected) + + ", max_rtt_us=" + std::to_string(s.max_rtt_us) + ")"; + }); + py::class_(m, "FollowerGripper", "One TacCap-Gripper follower (robot-side executor).\n\n" "The leader's sensor surface plus a Motor driving the FDCAN-attached\n" @@ -486,6 +516,22 @@ void bind_gripper(py::module_& m) { "tick by an integer, so only divisors of 1000 arrive at the requested\n" "rate. It never NACKs a rate it had to adjust, so the SDK logs a\n" "warning when that happens.") + .def("start_exchange", [](FollowerGripper& self, unsigned motor_hz, unsigned imu_hz, + unsigned exchange_hz) { + py::gil_scoped_release gil; + self.start_exchange(motor_hz, imu_hz, exchange_hz); + }, py::arg("motor_hz") = 100u, py::arg("imu_hz") = 0u, py::arg("exchange_hz") = 0u, + "Half-duplex alternative to start_streaming() (firmware with 0x5D).\n\n" + "The follower stops pushing frames; the SDK polls it once per\n" + "1/exchange_hz s and delivers motor status / IMU to the usual\n" + "subscribers. motor.submit_*() targets ride on the exchange. Every\n" + "request on this link takes turns, so host and follower never transmit\n" + "at the same time. exchange_hz=0 means max(motor_hz, imu_hz). End it with\n" + "stop_streaming(). Old firmware raises ProtocolError.") + .def_property_readonly("is_exchanging", &FollowerGripper::is_exchanging, + "True between start_exchange() and stop_streaming().") + .def("exchange_stats", &FollowerGripper::exchange_stats, + "ExchangeStats of the running exchange (zeros when not exchanging).") .def("stop_streaming", [](FollowerGripper& self) { py::gil_scoped_release gil; self.stop_streaming(); From 4d094e14745991383d4089e9b7b5c6dd0fab3c94 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Fri, 30 Oct 2026 14:35:04 +0800 Subject: [PATCH 10/18] =?UTF-8?q?fix(transport):=20=E5=8D=8A=E5=8F=8C?= =?UTF-8?q?=E5=B7=A5=E7=AD=89=E5=BE=85=E7=BA=BF=E8=B7=AF=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E8=AE=A1=E5=85=A5=E8=AF=B7=E6=B1=82=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 交换模式下每个请求独占线路直到应答或超时,原先等待轮次没有时限:一个 长耗时命令(如 IMU 加速度计/陀螺仪校准,应答超时 15 s)会让设了 100 ms 超时的查询被卡住数秒,超时语义失效。现在等待轮次与等待应答共用该请求的 超时,轮次等不到即抛 TimeoutError;交换线程据此记一次超时并保留目标。 新增 PTY 测试:持有线路的请求未应答时,50 ms 查询在 300 ms 内超时,释放后 查询恢复正常;改回无限等待的变异被检出。全部 527 个测试三轮 -j16 通过。 Co-Authored-By: Claude Opus 5.5 --- cpp/include/taccap/bus/transport.hpp | 7 +++++-- cpp/src/bus/transport.cpp | 21 ++++++++++++++----- cpp/tests/test_follower_exchange_pty.cpp | 26 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 32895a0..2236ba8 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -316,8 +316,11 @@ class Transport { uint8_t allocate_seq_locked_(); // Taken (only while half_duplex_) around one request's write and wait. // Never held by the reader or dispatcher, so callbacks may still send. - std::unique_lock half_duplex_turn_(); - std::mutex half_duplex_mu_; + // Waiting for the turn counts against the request's own timeout: a long + // command (IMU calibration, 15 s) must not stretch a 100 ms query. + std::unique_lock half_duplex_turn_(std::chrono::milliseconds wait, + protocol::Cmd cmd); + std::timed_mutex half_duplex_mu_; std::atomic half_duplex_{false}; void reader_loop_(); diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index 2f7d111..36141ab 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -94,10 +94,14 @@ AckResponse Transport::send_cmd(protocol::Cmd cmd, fut = pending->promise.get_future(); pending_acks_[seq] = pending; } - auto turn = half_duplex_turn_(); + const auto attempt_start = std::chrono::steady_clock::now(); + auto turn = half_duplex_turn_(t, cmd); write_frame_(pack_frame(cfg_.peer, seq, protocol::FrameType::CMD_NEED_ACK, cmd, payload), cmd); - const bool answered = fut.wait_for(t) == std::future_status::ready; + const auto left = t - std::chrono::duration_cast( + std::chrono::steady_clock::now() - attempt_start); + const bool answered = + fut.wait_for(std::max(left, std::chrono::milliseconds(1))) == std::future_status::ready; if (turn.owns_lock()) turn.unlock(); if (answered) { auto ack = fut.get(); @@ -145,14 +149,21 @@ void Transport::send_cmd_no_ack(protocol::Cmd cmd, throw IoError("send_cmd_no_ack on stopped transport", EBADF); seq = allocate_seq_locked_(); } - auto turn = half_duplex_turn_(); + auto turn = half_duplex_turn_(cfg_.ack_timeout, cmd); write_frame_(pack_frame(cfg_.peer, seq, protocol::FrameType::CMD_NO_ACK, cmd, payload), cmd); } -std::unique_lock Transport::half_duplex_turn_() { +std::unique_lock Transport::half_duplex_turn_(std::chrono::milliseconds wait, + protocol::Cmd cmd) { if (!half_duplex_.load(std::memory_order_acquire)) return {}; - return std::unique_lock(half_duplex_mu_); + std::unique_lock turn(half_duplex_mu_, std::defer_lock); + if (!turn.try_lock_for(wait)) { + throw TimeoutError("send " + std::string(protocol::to_string(cmd)) + + ": half-duplex link busy for " + std::to_string(wait.count()) + " ms", + ETIMEDOUT); + } + return turn; } void Transport::inject_data(Frame f) { diff --git a/cpp/tests/test_follower_exchange_pty.cpp b/cpp/tests/test_follower_exchange_pty.cpp index e59566c..0c2af9c 100644 --- a/cpp/tests/test_follower_exchange_pty.cpp +++ b/cpp/tests/test_follower_exchange_pty.cpp @@ -220,3 +220,29 @@ TEST(FollowerExchangePty, ForcePositionControllerRunsUnmodifiedOverTheExchange) EXPECT_TRUE(g->is_exchanging()) << "controller must not stop a stream it did not start"; g->stop_streaming(); } + +TEST(FollowerExchangePty, WaitingForTheLinkCountsAgainstTheRequestTimeout) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + auto g = open_follower(pty); + g->transport().set_half_duplex(true); + fw.drop_exchange_replies(1); // the first request holds the turn for its full 600 ms + + const std::vector req{tp::EXCHANGE_VERSION, tp::ExchangeFlag::Telemetry, 0, 0}; + std::thread holder([&] { + EXPECT_THROW(g->transport().send_cmd(tp::Cmd::SlaveExchange, req, std::chrono::milliseconds(600), + tx::bus::RetryMode::Once), + tx::TimeoutError); + }); + ASSERT_TRUE(wait_for([&] { return fw.exchanges() >= 1; }, kSecond)); + const auto t0 = std::chrono::steady_clock::now(); + EXPECT_THROW(g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(50)), + tx::TimeoutError); + const auto waited = std::chrono::steady_clock::now() - t0; + EXPECT_LT(waited, std::chrono::milliseconds(300)) << "query waited behind a long request"; + holder.join(); + // Once the turn is free a query goes through normally. + EXPECT_NO_THROW(g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200))); + g->transport().set_half_duplex(false); +} From 4b3d5c21810be41f9601dc06c8482fe409c433b7 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Fri, 30 Oct 2026 17:24:39 +0800 Subject: [PATCH 11/18] =?UTF-8?q?feat(leader):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=B8=BB=E7=88=AA=E5=88=86=E6=97=B6=E4=BA=A4=E6=8D=A2=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20start=5Fexchange?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合主爪固件 0x5E 与 StreamMode::Polled,从结构上消除主机下行与主爪上行 重叠导致的上报缺字节: - LeaderGripper::start_exchange(imu_hz, encoder_hz, exchange_hz, fallback_to_streaming):先用 0x5E 探测能力,旧固件回 InvalidCmd 时默认 退回 start_streaming() 并返回 false,或按参数抛 ProtocolError - 交换线程按周期取回暂存记录,积压时立即续取(每周期最多 8 次),记录按原 DATA 帧注入分派,IMU/编码器/按键/传感器错误订阅者无需改动 - 请求超时以同一 seq 重试,固件重放缓存应答,已取出的记录不丢 - 半双工期间所有请求轮流占线;stop_streaming()/close()/析构结束交换 - 新增 LeaderExchangeStats 与 Python 绑定 新增假主爪与 9 个 PTY 测试;五种变异(改为单次发送、去掉半双工、去掉续取、 去掉回退、停止时不结束交换)均被检出。C++ 536 项三轮 -j16 通过,Python 155 项通过。start_streaming() 与已有命令线格式不变。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++ cpp/CMakeLists.txt | 1 + cpp/include/taccap/leader_gripper.hpp | 46 ++++ cpp/include/taccap/protocol/commands.hpp | 3 + cpp/include/taccap/protocol/payloads.hpp | 31 ++- cpp/src/leader_exchange.cpp | 151 +++++++++++++ cpp/src/leader_exchange.hpp | 67 ++++++ cpp/src/leader_gripper.cpp | 124 ++++++++--- cpp/src/protocol/commands.cpp | 1 + cpp/tests/CMakeLists.txt | 1 + cpp/tests/fake_leader.hpp | 242 +++++++++++++++++++++ cpp/tests/test_leader_exchange_pty.cpp | 256 +++++++++++++++++++++++ python/bindings/bind_gripper.cpp | 47 +++++ 13 files changed, 959 insertions(+), 26 deletions(-) create mode 100644 cpp/src/leader_exchange.cpp create mode 100644 cpp/src/leader_exchange.hpp create mode 100644 cpp/tests/fake_leader.hpp create mode 100644 cpp/tests/test_leader_exchange_pty.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c790323..52eb145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Leader half-duplex exchange: `LeaderGripper.start_exchange(imu_hz, + encoder_hz, exchange_hz, fallback_to_streaming=True)`.** Requires leader + firmware with `StreamMode::Polled` and `Cmd::LeaderExchange` (0x5E, tc-gu-01 + `docs/LEADER_EXCHANGE.md`). The leader holds IMU, encoder, key and + sensor-error frames instead of pushing them; the SDK fetches them (again at + once while a backlog remains) and delivers them to the usual subscribers, + and every request on the link takes turns, so host and leader never + transmit at the same time. A lost reply is retried with the same seq, so the + firmware replays it and no records are lost. On firmware without 0x5E it + falls back to `start_streaming()` and returns `False` (or raises + `ProtocolError` with `fallback_to_streaming=False`); the device version is + not used to decide, since it stays 1.2.6. `exchange_stats()` returns + `LeaderExchangeStats`. `start_streaming()` and the wire protocol of older + commands are unchanged. + - **Follower half-duplex exchange: `FollowerGripper.start_exchange(motor_hz, imu_hz, exchange_hz)`.** Requires follower firmware with `StreamMode::Polled` and `Cmd::SlaveExchange` (0x5D, tc-gu-01 `docs/HALF_DUPLEX_EXCHANGE.md`). diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index bcf167a..c76fa27 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -21,6 +21,7 @@ add_library(taccap_core SHARED src/leader_gripper.cpp src/follower_gripper.cpp src/follower_exchange.cpp + src/leader_exchange.cpp src/force_position_controller.cpp src/impedance_controller.cpp src/ota.cpp diff --git a/cpp/include/taccap/leader_gripper.hpp b/cpp/include/taccap/leader_gripper.hpp index 4dd91c2..7f3e88e 100644 --- a/cpp/include/taccap/leader_gripper.hpp +++ b/cpp/include/taccap/leader_gripper.hpp @@ -48,6 +48,26 @@ namespace xense::taccap { +namespace detail { +class LeaderExchange; +} // namespace detail + +// Counters of LeaderGripper's half-duplex exchange since start_exchange(). +// records counts every record received; the per-kind counters split them. +// records_dropped is what the firmware queue overflowed before it was +// fetched. timeouts are exchanges without a reply even after same-seq retries. +// continuation_exchanges went out immediately because the firmware reported a +// backlog. not_polled counts replies saying the firmware is not in the +// exchange (another process restarted the stream, or the leader rebooted). +struct LeaderExchangeStats { + uint64_t exchanges = 0, continuation_exchanges = 0; + uint64_t timeouts = 0, nacks = 0, malformed = 0, not_polled = 0; + uint64_t records = 0, records_dropped = 0; + uint64_t imu_samples = 0, encoder_samples = 0, key_events = 0; + uint64_t sensor_errors = 0, other_records = 0; + uint64_t last_rtt_us = 0, max_rtt_us = 0; +}; + class LeaderGripper { public: struct Config { @@ -208,6 +228,30 @@ class LeaderGripper { void stop_streaming(); bool is_streaming() const noexcept { return streaming_; } + // Half-duplex alternative to start_streaming() (firmware with 0x5E). + // + // The leader is put in StreamMode::Polled: it samples as configured but + // pushes nothing -- IMU, encoder, key events and sensor errors are held + // in the firmware. A host thread fetches them once per 1/exchange_hz s + // (immediately again while a backlog remains) and dispatches them as the + // DATA frames streaming would have delivered, so imu().on_data, + // encoder().on_data, key().on_event and sensor-error subscribers need no + // change. All requests on this transport take turns, so the host never + // writes while the leader may be answering; this removes the full-duplex + // overlap that loses uplink bytes on the current hardware. + // + // is_streaming() is true and stop_streaming() ends the exchange. + // exchange_hz=0 means max(imu_hz, encoder_hz, 1), at most 500. + // + // Firmware without 0x5E answers InvalidCmd. With fallback_to_streaming + // (the default) the SDK then calls start_streaming(imu_hz, encoder_hz) + // and returns false; otherwise it throws ProtocolError. Returns true when + // the exchange is running. + bool start_exchange(unsigned imu_hz = 100, unsigned encoder_hz = 100, + unsigned exchange_hz = 0, bool fallback_to_streaming = true); + bool is_exchanging() const noexcept { return exchange_ != nullptr; } + LeaderExchangeStats exchange_stats() const; + const Config& config() const noexcept { return cfg_; } private: @@ -244,6 +288,8 @@ class LeaderGripper { OtaSession ota_; // V1.3 std::unique_ptr wrist_; bool streaming_ = false; + std::shared_ptr exchange_; + void stop_exchange_() noexcept; GripperPosition pos_map_; // raw<->position, cached bool pos_map_loaded_ = false; }; diff --git a/cpp/include/taccap/protocol/commands.hpp b/cpp/include/taccap/protocol/commands.hpp index 81828a4..467eccb 100644 --- a/cpp/include/taccap/protocol/commands.hpp +++ b/cpp/include/taccap/protocol/commands.hpp @@ -142,6 +142,9 @@ enum class Cmd : uint8_t { // reply carries motor status V2 + new IMU samples. Used with // StreamMode::Polled; see FollowerGripper::start_exchange(). SlaveExchange = 0x5D, + // Leader half-duplex exchange: reply carries the DATA frames the leader + // held in StreamMode::Polled. See LeaderGripper::start_exchange(). + LeaderExchange = 0x5E, // 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 d39b3bc..66198ae 100644 --- a/cpp/include/taccap/protocol/payloads.hpp +++ b/cpp/include/taccap/protocol/payloads.hpp @@ -995,11 +995,36 @@ namespace StreamSrc { enum class StreamMode : uint8_t { Separate = 0, Combined = 1, - // Follower only: acquisition runs as configured but nothing is pushed; - // the host pulls it with Cmd::SlaveExchange. Older firmware NACKs it. + // Acquisition runs as configured but nothing is pushed; the host pulls it + // with Cmd::SlaveExchange (follower) or Cmd::LeaderExchange (leader). + // Older firmware NACKs it. Polled = 2, }; +// ---- Leader half-duplex exchange (Cmd::LeaderExchange, 0x5E) -------------- +// Request: LeaderExchangeRequest, exact length, flags/reserved zero. +// Reply: LeaderExchangeResponseHeader, then record_count records, oldest first: +// [cmd:1][len:1][payload:len] +// Each record is the DATA frame the leader would have pushed outside the +// exchange (same cmd, same payload): IMU, encoder, key, sensor error. +namespace LeaderExchangeRespFlag { constexpr uint8_t Polled = 0x01; } +constexpr uint8_t LEADER_EXCHANGE_VERSION = 1; + +struct LeaderExchangeRequest { + uint8_t version; // LEADER_EXCHANGE_VERSION + uint8_t flags; // 0 + uint8_t reserved[2]; +}; + +struct LeaderExchangeResponseHeader { + uint8_t version; // LEADER_EXCHANGE_VERSION + uint8_t flags; // LeaderExchangeRespFlag + uint8_t record_count; + uint8_t reserved; + uint16_t dropped; // records the firmware queue overflowed since the previous take + uint16_t pending; // records still queued after this reply +}; + // ---- Follower half-duplex exchange (Cmd::SlaveExchange, 0x5D) ------------- // Request: ExchangeRequestHeader + the target struct for target_kind, exact // length. Reply: ExchangeResponseHeader, then MotorStatus V2 (59 B) when @@ -1197,6 +1222,8 @@ static_assert(sizeof(SnInfo) == 17); static_assert(sizeof(ImuData) == 28); // IMU_PACKET_SIZE static_assert(sizeof(ExchangeRequestHeader) == 4); static_assert(sizeof(ExchangeResponseHeader) == 8); +static_assert(sizeof(LeaderExchangeRequest) == 4); +static_assert(sizeof(LeaderExchangeResponseHeader) == 8); static_assert(sizeof(ImuConfig) == 10); static_assert(sizeof(EncoderData) == 16); // ENCODER_PACKET_SIZE static_assert(sizeof(EncoderConfig) == 5); // firmware 0086da6 (was 14) diff --git a/cpp/src/leader_exchange.cpp b/cpp/src/leader_exchange.cpp new file mode 100644 index 0000000..809589d --- /dev/null +++ b/cpp/src/leader_exchange.cpp @@ -0,0 +1,151 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 + +#include "leader_exchange.hpp" + +#include + +#include +#include + +namespace xense::taccap::detail { + +namespace { + +using clock = std::chrono::steady_clock; + +// Back-to-back exchanges allowed per period while the firmware reports a +// backlog; a backlog larger than this is left for the next period. +constexpr unsigned kMaxContinuations = 8; + +} // namespace + +LeaderExchange::LeaderExchange(bus::Transport& transport, + std::chrono::microseconds period, + std::chrono::milliseconds timeout) + : t_(transport), period_(period), timeout_(timeout) { + worker_ = std::thread([this] { run_(); }); +} + +LeaderExchange::~LeaderExchange() { stop(); } + +void LeaderExchange::stop() noexcept { + { + std::lock_guard lk(mu_); + stop_ = true; + } + cv_.notify_all(); + if (worker_.joinable() && worker_.get_id() != std::this_thread::get_id()) worker_.join(); +} + +LeaderExchangeStats LeaderExchange::stats() const { + std::lock_guard lk(mu_); + return stats_; +} + +bool LeaderExchange::decode(const std::vector& d, + protocol::LeaderExchangeResponseHeader& h, + std::vector& frames) { + frames.clear(); + if (d.size() < sizeof(h)) return false; + std::memcpy(&h, d.data(), sizeof(h)); + if (h.version != protocol::LEADER_EXCHANGE_VERSION) return false; + std::size_t pos = sizeof(h); + for (unsigned i = 0; i < h.record_count; ++i) { + if (d.size() - pos < 2) { frames.clear(); return false; } + const std::size_t n = d[pos + 1]; + if (d.size() - pos - 2 < n) { frames.clear(); return false; } + bus::Frame f{}; + f.addr = protocol::Address::MCU; + f.type = protocol::FrameType::DATA; + f.cmd = static_cast(d[pos]); + f.payload.assign(d.begin() + static_cast(pos + 2), + d.begin() + static_cast(pos + 2 + n)); + pos += 2 + n; + frames.push_back(std::move(f)); + } + if (pos != d.size()) { frames.clear(); return false; } + return true; +} + +void LeaderExchange::run_() { + protocol::LeaderExchangeRequest req{}; + req.version = protocol::LEADER_EXCHANGE_VERSION; + std::vector wire(sizeof(req)); + std::memcpy(wire.data(), &req, sizeof(req)); + + auto next_tick = clock::now(); + unsigned continuations = 0; + std::unique_lock lk(mu_); + while (!stop_) { + cv_.wait_until(lk, next_tick, [&] { return stop_; }); + if (stop_) break; + lk.unlock(); + + const auto started = clock::now(); + std::optional ack; + bool nack = false, link_down = false; + try { + // SameSeq: a retry after a lost reply makes the firmware replay its + // cached reply instead of handing out (and losing) new records. + ack = t_.send_cmd(protocol::Cmd::LeaderExchange, wire, timeout_, bus::RetryMode::SameSeq); + } catch (const NackError&) { + nack = true; + } catch (const TimeoutError&) { + } catch (const std::exception&) { + link_down = !t_.is_running(); + } + const auto rtt_us = static_cast( + std::chrono::duration_cast(clock::now() - started).count()); + + protocol::LeaderExchangeResponseHeader h{}; + std::vector frames; + const bool ok = ack && decode(ack->data, h, frames); + LeaderExchangeStats add{}; + for (auto& f : frames) { + f.seq = ack->seq; + f.receive_time = ack->receive_time; + switch (f.cmd) { + case protocol::Cmd::GetImu: + case protocol::Cmd::GetImuV2: ++add.imu_samples; break; + case protocol::Cmd::GetEncoder: ++add.encoder_samples; break; + case protocol::Cmd::KeyStatus: ++add.key_events; break; + case protocol::Cmd::SensorErrorReport: ++add.sensor_errors; break; + default: ++add.other_records; break; + } + t_.inject_data(std::move(f)); + } + + lk.lock(); + ++stats_.exchanges; + if (continuations) ++stats_.continuation_exchanges; + if (ok) { + stats_.records += h.record_count; + stats_.records_dropped += h.dropped; + stats_.imu_samples += add.imu_samples; + stats_.encoder_samples += add.encoder_samples; + stats_.key_events += add.key_events; + stats_.sensor_errors += add.sensor_errors; + stats_.other_records += add.other_records; + if (!(h.flags & protocol::LeaderExchangeRespFlag::Polled)) ++stats_.not_polled; + stats_.last_rtt_us = rtt_us; + if (rtt_us > stats_.max_rtt_us) stats_.max_rtt_us = rtt_us; + } else if (nack) { + ++stats_.nacks; + } else if (ack) { + ++stats_.malformed; + } else { + ++stats_.timeouts; + } + if (link_down) break; + if (ok && h.pending && continuations < kMaxContinuations) { + ++continuations; // backlog: fetch again now, keep the tick + continue; + } + continuations = 0; + next_tick += period_; + const auto now = clock::now(); + if (next_tick <= now) next_tick = now + period_; // never burst to catch up + } +} + +} // namespace xense::taccap::detail diff --git a/cpp/src/leader_exchange.hpp b/cpp/src/leader_exchange.hpp new file mode 100644 index 0000000..f8cc83b --- /dev/null +++ b/cpp/src/leader_exchange.hpp @@ -0,0 +1,67 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// Host side of the leader half-duplex exchange (Cmd::LeaderExchange). +// +// Why: same as FollowerExchange. With the leader streaming on its own clock, a +// host write that lands while the leader is transmitting loses the overlapping +// uplink bytes (tc-gu-01 docs/LEADER_EXCHANGE.md). In StreamMode::Polled the +// leader holds every DATA frame it would have pushed (IMU, encoder, key, +// sensor error); this loop is the only thing that asks for them, and the +// transport's half-duplex turn keeps user queries from overlapping a reply. +// +// Cadence: one exchange per period; when the reply says records are still +// pending, the next exchange goes out at once (bounded) instead of waiting. +// A timed-out exchange is retried with the same seq, so the firmware replays +// its cached reply and the records it already handed out are not lost. +// +// Records are re-dispatched as the DATA frames they are, so imu().on_data, +// encoder().on_data, key().on_event and sensor-error subscribers see exactly +// what streaming would have delivered. + +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace xense::taccap::detail { + +class LeaderExchange { +public: + LeaderExchange(bus::Transport& transport, std::chrono::microseconds period, + std::chrono::milliseconds timeout); + ~LeaderExchange(); + + LeaderExchange(const LeaderExchange&) = delete; + LeaderExchange& operator=(const LeaderExchange&) = delete; + + // Idempotent; joins the worker. + void stop() noexcept; + + LeaderExchangeStats stats() const; + + // Split one reply into its header and the DATA frames its records carry. + // False (and no frames) when the reply is malformed. Public for tests. + static bool decode(const std::vector& reply, + protocol::LeaderExchangeResponseHeader& head, + std::vector& frames); + +private: + void run_(); + + bus::Transport& t_; + const std::chrono::microseconds period_; + const std::chrono::milliseconds timeout_; + + mutable std::mutex mu_; + std::condition_variable cv_; + bool stop_ = false; + LeaderExchangeStats stats_{}; + std::thread worker_; +}; + +} // namespace xense::taccap::detail diff --git a/cpp/src/leader_gripper.cpp b/cpp/src/leader_gripper.cpp index 4d80d60..c3585b0 100644 --- a/cpp/src/leader_gripper.cpp +++ b/cpp/src/leader_gripper.cpp @@ -1,6 +1,7 @@ // Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 #include +#include "leader_exchange.hpp" #include "wrist_fisheye.hpp" #include "stream_rate.hpp" #include @@ -10,6 +11,7 @@ #include #include +#include #include #include @@ -42,6 +44,35 @@ Camera::Config make_wrist_config(const LeaderGripper::Config& cfg) { return c; } +// The StreamConfig both start_streaming() and start_exchange() send. A rate of +// 0 means "off", and the only way to say that to the firmware is to leave the +// source's mask bit clear -- the rate field alone would fall through to the +// firmware's 100 Hz default. See stream_rate.hpp. +protocol::StreamConfig leader_stream_config(const char* caller, IMU& imu, unsigned imu_hz, + unsigned encoder_hz, protocol::StreamMode mode) { + const uint16_t mask = detail::stream_source_mask(imu_hz, encoder_hz, 0); + if (mask == 0) { + throw IoError(std::string("LeaderGripper::") + caller + ": both rates are 0, so " + "nothing would be streamed — pass a non-zero rate for at " + "least one source, or just don't start the stream", + EINVAL); + } + detail::warn_if_rate_adjusted("LeaderGripper", "IMU", imu_hz, 0); + detail::warn_if_rate_adjusted("LeaderGripper", "encoder", encoder_hz, 0); + + // Build StreamConfig (12 bytes — see protocol::StreamConfig). + protocol::StreamConfig sc{}; + sc.source_mask = mask; + if (imu_hz) sc.source_mask = (mask & ~protocol::StreamSrc::Imu) | imu.negotiate_time(); + sc.mode = static_cast(mode); + sc.imu_rate = static_cast(imu_hz); + sc.encoder_rate = static_cast(encoder_hz); + sc.eskin_rate = 0; + sc.motor_rate = 0; + sc.output_iface = static_cast(protocol::StreamInterface::Uart); + return sc; +} + } // namespace LeaderGripper::LeaderGripper(const Config& cfg) @@ -129,6 +160,7 @@ LeaderGripper::LeaderGripper(const Config& cfg) LeaderGripper::~LeaderGripper() { try { stop_streaming(); } catch (...) {} + stop_exchange_(); } void LeaderGripper::close() noexcept { @@ -136,6 +168,7 @@ void LeaderGripper::close() noexcept { if (t_.is_running()) { try { stop_streaming(); } catch (...) {} } + stop_exchange_(); if (wrist_) { try { wrist_->stop(); } catch (...) {} } @@ -168,30 +201,8 @@ void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz) { std::chrono::milliseconds(500)); } catch (...) { /* expected when fw is already idle */ } - // A rate of 0 means "off", and the only way to say that to the firmware - // is to leave the source's mask bit clear — the rate field alone would - // fall through to the firmware's 100 Hz default. See stream_rate.hpp. - const uint16_t mask = detail::stream_source_mask(imu_hz, encoder_hz, 0); - if (mask == 0) { - throw IoError("LeaderGripper::start_streaming: both rates are 0, so " - "nothing would be streamed — pass a non-zero rate for at " - "least one source, or just don't start the stream", - EINVAL); - } - detail::warn_if_rate_adjusted("LeaderGripper", "IMU", imu_hz, 0); - detail::warn_if_rate_adjusted("LeaderGripper", "encoder", encoder_hz, 0); - - // Build StreamConfig (12 bytes — see protocol::StreamConfig). - protocol::StreamConfig sc{}; - sc.source_mask = mask; - if (imu_hz) sc.source_mask = (mask & ~protocol::StreamSrc::Imu) | imu_.negotiate_time(); - sc.mode = static_cast(protocol::StreamMode::Separate); - sc.imu_rate = static_cast(imu_hz); - sc.encoder_rate = static_cast(encoder_hz); - sc.eskin_rate = 0; - sc.motor_rate = 0; - sc.output_iface = static_cast(protocol::StreamInterface::Uart); - + const auto sc = leader_stream_config("start_streaming", imu_, imu_hz, encoder_hz, + protocol::StreamMode::Separate); auto wire = protocol::encode(sc); auto ack = t_.send_cmd(protocol::Cmd::StartStream, wire); if (ack.is_nack) { @@ -205,9 +216,74 @@ void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz) { streaming_ = true; } +bool LeaderGripper::start_exchange(unsigned imu_hz, unsigned encoder_hz, + unsigned exchange_hz, bool fallback_to_streaming) { + if (exchange_) return true; + if (streaming_) throw IoError("LeaderGripper: stop streaming before start_exchange", EBUSY); + if (exchange_hz == 0) exchange_hz = std::max({imu_hz, encoder_hz, 1u}); + if (exchange_hz > 500) + throw IoError("LeaderGripper::start_exchange: exchange_hz must be <= 500", EINVAL); + if (detail::stream_source_mask(imu_hz, encoder_hz, 0) == 0) + (void)leader_stream_config("start_exchange", imu_, imu_hz, encoder_hz, + protocol::StreamMode::Polled); // throws EINVAL + + try { + t_.send_cmd(protocol::Cmd::StopStream, {}, std::chrono::milliseconds(500)); + } catch (...) { /* expected when fw is already idle */ } + + // Probe with the exchange itself: firmware without it answers InvalidCmd + // (the device version is not a capability signal -- it stays 1.2.6). + auto unsupported = [&](const std::string& why) { + if (!fallback_to_streaming) + throw ProtocolError("LeaderGripper::start_exchange: " + why); + logger()->info("LeaderGripper: {}; falling back to start_streaming", why); + start_streaming(imu_hz, encoder_hz); + return false; + }; + protocol::LeaderExchangeRequest req{}; + req.version = protocol::LEADER_EXCHANGE_VERSION; + std::vector probe(sizeof(req)); + std::memcpy(probe.data(), &req, sizeof(req)); + try { + t_.send_cmd(protocol::Cmd::LeaderExchange, probe, std::chrono::milliseconds(500)); + } catch (const NackError& e) { + if (e.code() != static_cast(protocol::ErrorCode::InvalidCmd)) throw; + return unsupported("firmware without the leader exchange (0x5E)"); + } + + const auto sc = leader_stream_config("start_exchange", imu_, imu_hz, encoder_hz, + protocol::StreamMode::Polled); + try { + t_.send_cmd(protocol::Cmd::StartStream, protocol::encode(sc)); + } catch (const NackError& e) { + return unsupported(std::string("firmware rejected the polled stream mode: ") + e.what()); + } + + t_.set_half_duplex(true); + // A stuck reply must not hold the link longer than a couple of periods. + const auto period = std::chrono::microseconds(1'000'000 / exchange_hz); + const auto timeout = std::chrono::milliseconds(std::max( + 5, std::chrono::duration_cast(2 * period).count())); + exchange_ = std::make_shared(t_, period, timeout); + streaming_ = true; + return true; +} + +LeaderExchangeStats LeaderGripper::exchange_stats() const { + return exchange_ ? exchange_->stats() : LeaderExchangeStats{}; +} + +void LeaderGripper::stop_exchange_() noexcept { + if (!exchange_) return; + exchange_->stop(); + exchange_.reset(); + t_.set_half_duplex(false); +} + void LeaderGripper::stop_streaming() { if (!streaming_) return; streaming_ = false; + stop_exchange_(); try { t_.send_cmd(protocol::Cmd::StopStream, {}, std::chrono::milliseconds{500}); } catch (...) { diff --git a/cpp/src/protocol/commands.cpp b/cpp/src/protocol/commands.cpp index b3f1f05..cbf273c 100644 --- a/cpp/src/protocol/commands.cpp +++ b/cpp/src/protocol/commands.cpp @@ -86,6 +86,7 @@ const char* to_string(Cmd c) noexcept { case Cmd::MotorCanExtXfer: return "MotorCanExtXfer"; case Cmd::SetMotorFwVersion: return "SetMotorFwVersion"; case Cmd::SlaveExchange: return "SlaveExchange"; + case Cmd::LeaderExchange: return "LeaderExchange"; case Cmd::GetUartStats: return "GetUartStats"; case Cmd::SetLogConfig: return "SetLogConfig"; diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index a739ff4..6887739 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -41,6 +41,7 @@ add_executable(taccap_unit_tests test_imu_motor_concurrency.cpp test_imu_time.cpp test_follower_exchange_pty.cpp + test_leader_exchange_pty.cpp ) # stream_rate.hpp is a private helper under src/ (not an installed header), diff --git a/cpp/tests/fake_leader.hpp b/cpp/tests/fake_leader.hpp new file mode 100644 index 0000000..b7d004a --- /dev/null +++ b/cpp/tests/fake_leader.hpp @@ -0,0 +1,242 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// A minimal fake leader firmware over a pty for the leader exchange tests. +// It answers what LeaderGripper's constructor and start_streaming()/ +// start_exchange() issue, produces 100 Hz IMU (legacy 28 B) + encoder samples +// while streaming, and either pushes them as DATA frames (StreamMode +// Separate) or holds them as [cmd][len][payload] records for Cmd:: +// LeaderExchange (StreamMode::Polled), mirroring tc-gu-01 leader_uplink.c: +// 1 KB queue, oldest record dropped when full, 504 B of records per reply, +// and a byte-identical repeat of the last request replays the cached reply. + +#pragma once + +#include "pty_helper.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace taccap_test { + +class FakeLeader { +public: + explicit FakeLeader(Pty& pty) : pty_(pty) { thread_ = std::thread([this] { run_(); }); } + ~FakeLeader() { + stop_.store(true); + if (thread_.joinable()) thread_.join(); + } + + // Firmware without 0x5E: InvalidCmd for the exchange, InvalidParam for mode 2. + void set_exchange_supported(bool on) { exchange_supported_.store(on); } + // Produce samples every this many ms while streaming (default 10). + void set_sample_period_ms(int ms) { sample_ms_.store(ms); } + // Hold each exchange reply this long and count host frames that arrive meanwhile. + void set_exchange_reply_delay_ms(int ms) { delay_ms_.store(ms); } + // Do not answer the next n exchange requests (records are still taken). + void drop_exchange_replies(unsigned n) { drop_.store(n); } + // Make the next exchange reply's record lengths inconsistent. + void corrupt_next_reply() { corrupt_.store(true); } + // A button event, held or pushed like any other DATA frame. Emitted from + // the fake's own thread so the PTY never has two writers. + void press_key(uint8_t state) { key_state_.store(static_cast(state)); } + + bool polled() const { return polled_.load(); } + bool streaming() const { return streaming_.load(); } + uint8_t stream_mode() const { return mode_.load(); } + unsigned exchanges() const { return exchanges_.load(); } + unsigned replays() const { return replays_.load(); } + unsigned overlaps() const { return overlaps_.load(); } + // DATA frames written on the wire (only outside StreamMode::Polled). + unsigned data_frames_pushed() const { return pushed_.load(); } + uint16_t imu_seq_produced() const { return imu_seq_.load(); } + +private: + struct Record { uint8_t cmd; std::vector payload; }; + + void emit_(tp::Cmd cmd, const void* p, std::size_t n) { + std::lock_guard lk(mu_); + std::vector bytes(static_cast(p), static_cast(p) + n); + if (polled_.load()) { + queue_bytes_ += 2 + n; + queue_.push_back({static_cast(cmd), std::move(bytes)}); + while (queue_bytes_ > 1024) { + queue_bytes_ -= 2 + queue_.front().payload.size(); + queue_.pop_front(); + if (dropped_ != 0xFFFF) ++dropped_; + } + } else { + pty_.send_data(0, cmd, bytes); + ++pushed_; + } + } + + void produce_() { + tp::ImuData imu{}; + imu.valid_flag = 0x0F; + imu.accel_z = 1000; + imu.seq = imu_seq_.load(); + imu.timestamp_us = 10000u * imu.seq + 1u; + emit_(tp::Cmd::GetImu, &imu, sizeof(imu)); + tp::EncoderData enc{}; + enc.seq = imu.seq; + enc.timestamp_us = imu.timestamp_us; + enc.position_rad = 0.01f * static_cast(imu.seq % 100); + emit_(tp::Cmd::GetEncoder, &enc, sizeof(enc)); + imu_seq_.fetch_add(1); + } + + std::vector take_reply_() { + std::lock_guard lk(mu_); + tp::LeaderExchangeResponseHeader h{}; + h.version = tp::LEADER_EXCHANGE_VERSION; + h.flags = polled_.load() ? tp::LeaderExchangeRespFlag::Polled : 0; + std::vector out(sizeof(h)); + while (!queue_.empty() && out.size() + 2 + queue_.front().payload.size() <= 512) { + auto& r = queue_.front(); + out.push_back(r.cmd); + out.push_back(static_cast(r.payload.size())); + out.insert(out.end(), r.payload.begin(), r.payload.end()); + queue_bytes_ -= 2 + r.payload.size(); + queue_.pop_front(); + ++h.record_count; + } + h.dropped = dropped_; + dropped_ = 0; + h.pending = static_cast(queue_.size()); + std::memcpy(out.data(), &h, sizeof(h)); + return out; + } + + void handle_(const tb::Frame& f) { + if (pending_reply_) ++overlaps_; + // Duplicate request (same seq/cmd/payload): replay the cached reply. + if (last_ && last_->seq == f.seq && last_->cmd == f.cmd && last_->payload == f.payload) { + ++replays_; + if (f.cmd == tp::Cmd::LeaderExchange) pty_.send_response(f.seq, f.cmd, last_reply_); + else pty_.send_ack_ok(f.seq, f.cmd); + return; + } + last_ = f; + switch (f.cmd) { + case tp::Cmd::StopStream: { + std::lock_guard lk(mu_); + streaming_.store(false); polled_.store(false); + queue_.clear(); queue_bytes_ = 0; dropped_ = 0; + pty_.send_ack_ok(f.seq, f.cmd); + return; + } + case tp::Cmd::StartStream: { + const auto sc = tp::decode_stream_config(f.payload.data(), f.payload.size()); + if (sc.mode == static_cast(tp::StreamMode::Polled) && !exchange_supported_.load()) { + pty_.send_nack(f.seq, tp::ErrorCode::InvalidParam); + return; + } + { + std::lock_guard lk(mu_); + queue_.clear(); queue_bytes_ = 0; dropped_ = 0; + polled_.store(sc.mode == static_cast(tp::StreamMode::Polled)); + } + mode_.store(sc.mode); + streaming_.store(true); + pty_.send_ack_ok(f.seq, f.cmd); + return; + } + case tp::Cmd::LeaderExchange: { + if (!exchange_supported_.load()) { pty_.send_nack(f.seq, tp::ErrorCode::InvalidCmd); return; } + if (f.payload.size() != sizeof(tp::LeaderExchangeRequest) || + f.payload[0] != tp::LEADER_EXCHANGE_VERSION || f.payload[1] || f.payload[2] || f.payload[3]) { + pty_.send_nack(f.seq, tp::ErrorCode::InvalidParam); + return; + } + ++exchanges_; + last_reply_ = take_reply_(); + if (corrupt_.exchange(false) && last_reply_.size() > 9) last_reply_[9] = 0xFF; + if (drop_.load()) { drop_.fetch_sub(1); return; } + if (const int ms = delay_ms_.load()) { + pending_reply_ = true; + const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms); + while (std::chrono::steady_clock::now() < until) { + if (auto g = pty_.expect_frame(1)) { ++overlaps_; deferred_.push_back(*g); } + } + pending_reply_ = false; + } + pty_.send_response(f.seq, f.cmd, last_reply_); + return; + } + case tp::Cmd::GetVersion: { + tp::FirmwareVersion v{1, 2, 6, 0}; + std::vector d(sizeof(v)); + std::memcpy(d.data(), &v, sizeof(v)); + pty_.send_response(f.seq, f.cmd, d); + return; + } + case tp::Cmd::GetSn: { + const char sn[] = "TCGU01FAKELEADm"; + pty_.send_response(f.seq, f.cmd, std::vector(sn, sn + sizeof(sn))); + return; + } + case tp::Cmd::Heartbeat: + pty_.send_response(f.seq, f.cmd, {1, 0, 0, 0, 1, 2, 6, 0}); + return; + default: + // TimeSession and anything else: legacy firmware. + pty_.send_nack(f.seq, tp::ErrorCode::InvalidCmd); + return; + } + } + + void run_() { + auto next = std::chrono::steady_clock::now(); + while (!stop_.load()) { + if (!deferred_.empty()) { + auto f = deferred_.front(); + deferred_.erase(deferred_.begin()); + handle_(f); + } else if (auto f = pty_.expect_frame(1)) { + handle_(*f); + } + const int key = key_state_.exchange(-1); + if (key >= 0) { + tp::KeyStatusPayload k{0, static_cast(key)}; + emit_(tp::Cmd::KeyStatus, &k, sizeof(k)); + } + const auto now = std::chrono::steady_clock::now(); + if (streaming_.load() && now >= next) { + next = now + std::chrono::milliseconds(sample_ms_.load()); + produce_(); + } + } + } + + Pty& pty_; + std::thread thread_; + std::atomic stop_{false}; + std::atomic exchange_supported_{true}; + std::atomic sample_ms_{10}; + std::atomic delay_ms_{0}; + std::atomic drop_{0}; + std::atomic corrupt_{false}; + std::atomic streaming_{false}, polled_{false}; + std::atomic mode_{0}; + std::atomic exchanges_{0}, replays_{0}, overlaps_{0}, pushed_{0}; + std::atomic key_state_{-1}; + std::atomic imu_seq_{0}; + bool pending_reply_ = false; + std::vector deferred_; + std::optional last_; + std::vector last_reply_; + std::mutex mu_; + std::deque queue_; + std::size_t queue_bytes_ = 0; + uint16_t dropped_ = 0; +}; + +} // namespace taccap_test diff --git a/cpp/tests/test_leader_exchange_pty.cpp b/cpp/tests/test_leader_exchange_pty.cpp new file mode 100644 index 0000000..a9493c2 --- /dev/null +++ b/cpp/tests/test_leader_exchange_pty.cpp @@ -0,0 +1,256 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// LeaderGripper::start_exchange() against the fake leader: polled mode with +// IMU/encoder/key fan-out to the ordinary subscribers, fallback on firmware +// without 0x5E, backlog draining, a lost reply replayed by same-seq retry +// rather than lost, strict one-request-at-a-time with concurrent queries, +// malformed replies, and stop. + +#include "fake_leader.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +namespace tx = xense::taccap; +namespace tp = xense::taccap::protocol; + +using taccap_test::FakeLeader; +using taccap_test::Pty; + +namespace { + +constexpr auto kSecond = std::chrono::milliseconds(1000); + +std::unique_ptr open_leader(const Pty& pty) { + tx::LeaderGripper::Config cfg; + cfg.mcu_device = pty.slave_path(); + cfg.ack_timeout_ms = 300; + cfg.max_retries = 2; + return std::make_unique(cfg); +} + +template +bool wait_for(Pred pred, std::chrono::milliseconds budget) { + const auto deadline = std::chrono::steady_clock::now() + budget; + while (std::chrono::steady_clock::now() < deadline) { + if (pred()) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return pred(); +} + +// Collects delivered sequence numbers so a test can assert "nothing lost". +struct SeqLog { + std::mutex mu; + std::vector seqs; + void add(uint16_t s) { std::lock_guard lk(mu); seqs.push_back(s); } + std::size_t size() { std::lock_guard lk(mu); return seqs.size(); } + // Count of missing sequence numbers between first and last delivered. + unsigned gaps() { + std::lock_guard lk(mu); + unsigned missing = 0; + for (std::size_t i = 1; i < seqs.size(); ++i) { + const uint16_t d = static_cast(seqs[i] - seqs[i - 1]); + if (d != 1) missing += d == 0 ? 1u : d - 1u; + } + return missing; + } +}; + +} // namespace + +TEST(LeaderExchangePty, PolledModeFeedsImuEncoderAndKeySubscribersWithoutPush) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + + SeqLog imu, enc; + std::atomic keys{0}; + std::atomic last_key{-1}; + g->imu().on_data([&](const tx::ImuSample& s) { imu.add(s.seq); }); + g->encoder().on_data([&](const tx::EncoderSample& s) { enc.add(s.seq); }); + g->key().on_event([&](const tx::KeySample& k) { ++keys; last_key.store(k.key_state); }); + + ASSERT_TRUE(g->start_exchange(100, 100)); + EXPECT_TRUE(g->is_exchanging()); + EXPECT_TRUE(g->is_streaming()); + EXPECT_TRUE(fw.polled()); + EXPECT_EQ(fw.stream_mode(), static_cast(tp::StreamMode::Polled)); + ASSERT_TRUE(wait_for([&] { return imu.size() >= 30 && enc.size() >= 30; }, 2 * kSecond)) + << "imu=" << imu.size() << " enc=" << enc.size(); + + fw.press_key(tp::KeyState::DoubleClick); + ASSERT_TRUE(wait_for([&] { return keys.load() == 1; }, kSecond)); + EXPECT_EQ(last_key.load(), tp::KeyState::DoubleClick); + + EXPECT_EQ(fw.data_frames_pushed(), 0u) << "the leader pushed a DATA frame during the exchange"; + EXPECT_EQ(imu.gaps(), 0u); + EXPECT_EQ(enc.gaps(), 0u); + const auto s = g->exchange_stats(); + EXPECT_GE(s.exchanges, 20u); + EXPECT_EQ(s.timeouts, 0u); + EXPECT_EQ(s.malformed, 0u); + EXPECT_EQ(s.not_polled, 0u); + EXPECT_EQ(s.key_events, 1u); + EXPECT_EQ(s.records, s.imu_samples + s.encoder_samples + s.key_events + s.sensor_errors + s.other_records); + EXPECT_EQ(s.records_dropped, 0u); + + g->stop_streaming(); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_FALSE(g->is_streaming()); + EXPECT_FALSE(fw.polled()); + EXPECT_FALSE(fw.streaming()); + const unsigned after = fw.exchanges(); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + EXPECT_EQ(fw.exchanges(), after) << "exchange loop kept running after stop_streaming"; +} + +TEST(LeaderExchangePty, OldFirmwareFallsBackToTheOriginalStream) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + fw.set_exchange_supported(false); + auto g = open_leader(pty); + + std::atomic imu{0}; + g->imu().on_data([&](const tx::ImuSample&) { ++imu; }); + EXPECT_FALSE(g->start_exchange(100, 100)); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_TRUE(g->is_streaming()); + EXPECT_EQ(fw.stream_mode(), static_cast(tp::StreamMode::Separate)); + EXPECT_TRUE(wait_for([&] { return imu.load() >= 10; }, kSecond)); + EXPECT_GT(fw.data_frames_pushed(), 0u); + EXPECT_EQ(fw.exchanges(), 0u); + g->stop_streaming(); + EXPECT_FALSE(fw.streaming()); +} + +TEST(LeaderExchangePty, OldFirmwareWithoutFallbackThrowsAndLeavesNothingRunning) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + fw.set_exchange_supported(false); + auto g = open_leader(pty); + + EXPECT_THROW(g->start_exchange(100, 100, 0, false), tx::ProtocolError); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_FALSE(g->is_streaming()); + EXPECT_FALSE(fw.streaming()); + // The link is still usable for ordinary requests. + EXPECT_NO_THROW(g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200))); +} + +TEST(LeaderExchangePty, RejectsBadArgumentsBeforeTouchingTheDevice) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + EXPECT_THROW(g->start_exchange(0, 0), tx::IoError); + EXPECT_THROW(g->start_exchange(100, 100, 501), tx::IoError); + EXPECT_FALSE(fw.streaming()); + g->start_streaming(100, 100); + EXPECT_THROW(g->start_exchange(100, 100), tx::IoError) << "must stop streaming first"; + g->stop_streaming(); +} + +TEST(LeaderExchangePty, BacklogIsFetchedAtOnceWithoutLoss) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + + SeqLog imu; + g->imu().on_data([&](const tx::ImuSample& s) { imu.add(s.seq); }); + // 100 Hz samples, 5 Hz exchanges: ~40 records (>504 B) per period. + ASSERT_TRUE(g->start_exchange(100, 100, 5)); + ASSERT_TRUE(wait_for([&] { return imu.size() >= 120; }, 3 * kSecond)) << imu.size(); + const auto s = g->exchange_stats(); + EXPECT_GT(s.continuation_exchanges, 0u); + EXPECT_EQ(s.records_dropped, 0u); + EXPECT_EQ(imu.gaps(), 0u); + g->stop_streaming(); +} + +TEST(LeaderExchangePty, LostReplyIsReplayedBySameSeqRetryNotLost) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + + SeqLog imu; + g->imu().on_data([&](const tx::ImuSample& s) { imu.add(s.seq); }); + ASSERT_TRUE(g->start_exchange(100, 100, 50)); + ASSERT_TRUE(wait_for([&] { return imu.size() >= 10; }, kSecond)); + + const auto timeouts_before = g->transport().stats().ack_timeouts; + fw.drop_exchange_replies(1); + ASSERT_TRUE(wait_for([&] { return fw.replays() >= 1; }, kSecond)); + const std::size_t at_replay = imu.size(); + ASSERT_TRUE(wait_for([&] { return imu.size() >= at_replay + 20; }, kSecond)); + EXPECT_GT(g->transport().stats().ack_timeouts, timeouts_before); + EXPECT_EQ(g->exchange_stats().timeouts, 0u) << "the retry recovered the reply"; + EXPECT_EQ(imu.gaps(), 0u) << "records from the lost reply were lost"; + g->stop_streaming(); +} + +TEST(LeaderExchangePty, NeverWritesWhileAReplyIsPendingEvenWithConcurrentQueries) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + fw.set_exchange_reply_delay_ms(3); + ASSERT_TRUE(g->start_exchange(100, 100)); + + std::atomic stop{false}; + std::atomic heartbeats{0}, heartbeat_timeouts{0}; + std::thread user([&] { + while (!stop.load()) { + try { g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200)); ++heartbeats; } + catch (...) { ++heartbeat_timeouts; } + } + }); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + stop.store(true); + user.join(); + + EXPECT_GT(fw.exchanges(), 15u); + EXPECT_GT(heartbeats.load(), 5u); + // Only after giving up on a reply may the host write over it. + const auto gave_up = g->transport().stats().ack_timeouts; + EXPECT_LE(fw.overlaps(), gave_up) << "host wrote a frame while an exchange reply was pending"; + g->stop_streaming(); +} + +TEST(LeaderExchangePty, MalformedReplyIsCountedAndDispatchesNothing) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + std::atomic imu{0}; + g->imu().on_data([&](const tx::ImuSample&) { ++imu; }); + ASSERT_TRUE(g->start_exchange(100, 100, 20)); + ASSERT_TRUE(wait_for([&] { return imu.load() >= 5; }, kSecond)); + fw.corrupt_next_reply(); + ASSERT_TRUE(wait_for([&] { return g->exchange_stats().malformed >= 1; }, kSecond)); + EXPECT_TRUE(wait_for([&] { return imu.load() >= 30; }, kSecond)) << "exchange did not continue"; + g->stop_streaming(); +} + +TEST(LeaderExchangePty, CloseEndsTheExchange) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + ASSERT_TRUE(g->start_exchange(100, 100)); + ASSERT_TRUE(wait_for([&] { return fw.exchanges() >= 3; }, kSecond)); + g->close(); + EXPECT_FALSE(g->is_exchanging()); + EXPECT_FALSE(fw.polled()); +} diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index 5805e5a..75761b7 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -150,6 +150,35 @@ void bind_gripper(py::module_& m) { "Unknown and never matches."); // ---- LeaderGripper -------------------------------------------------- + py::class_(m, "LeaderExchangeStats", + "Counters of LeaderGripper's half-duplex exchange since start_exchange().") + .def_readonly("exchanges", &LeaderExchangeStats::exchanges) + .def_readonly("continuation_exchanges", &LeaderExchangeStats::continuation_exchanges, + "Exchanges sent at once because the firmware reported a backlog.") + .def_readonly("timeouts", &LeaderExchangeStats::timeouts, + "Exchanges without a reply even after same-seq retries.") + .def_readonly("nacks", &LeaderExchangeStats::nacks) + .def_readonly("malformed", &LeaderExchangeStats::malformed) + .def_readonly("not_polled", &LeaderExchangeStats::not_polled, + "Replies saying the firmware left the exchange (stream restarted or reboot).") + .def_readonly("records", &LeaderExchangeStats::records) + .def_readonly("records_dropped", &LeaderExchangeStats::records_dropped, + "Records the firmware queue overflowed before they were fetched.") + .def_readonly("imu_samples", &LeaderExchangeStats::imu_samples) + .def_readonly("encoder_samples", &LeaderExchangeStats::encoder_samples) + .def_readonly("key_events", &LeaderExchangeStats::key_events) + .def_readonly("sensor_errors", &LeaderExchangeStats::sensor_errors) + .def_readonly("other_records", &LeaderExchangeStats::other_records) + .def_readonly("last_rtt_us", &LeaderExchangeStats::last_rtt_us) + .def_readonly("max_rtt_us", &LeaderExchangeStats::max_rtt_us) + .def("__repr__", [](const LeaderExchangeStats& s) { + return "LeaderExchangeStats(exchanges=" + std::to_string(s.exchanges) + + ", records=" + std::to_string(s.records) + + ", dropped=" + std::to_string(s.records_dropped) + + ", timeouts=" + std::to_string(s.timeouts) + + ", max_rtt_us=" + std::to_string(s.max_rtt_us) + ")"; + }); + py::class_(m, "LeaderGripper", "One TacCap-Gripper leader (teleop master end).\n\n" "Owns the MCU control link plus IMU, encoder, button, LED and -- only when\n" @@ -237,6 +266,24 @@ void bind_gripper(py::module_& m) { self.start_streaming(imu_hz, enc_hz); }, py::arg("imu_hz") = 100u, py::arg("encoder_hz") = 100u, "Start the MCU sensor stream.\n\nA rate of 0 turns that source OFF (its source_mask bit is cleared). Passing 0 used to stream the source at the firmware's 100 Hz default instead; raises IoError(EINVAL) if every rate is 0.\n\nThe firmware divides a 1 kHz tick by an integer, so only divisors of 1000 arrive at the requested rate -- 300 Hz becomes 333 Hz, 150 Hz becomes 167 Hz, and anything above 1000 Hz collapses to 100 Hz. Motor status is additionally capped at 100 Hz. None of this is NACKed by the firmware, so the SDK logs a warning when it applies.") + .def("start_exchange", [](LeaderGripper& self, unsigned imu_hz, unsigned enc_hz, + unsigned exchange_hz, bool fallback) { + py::gil_scoped_release gil; + return self.start_exchange(imu_hz, enc_hz, exchange_hz, fallback); + }, py::arg("imu_hz") = 100u, py::arg("encoder_hz") = 100u, py::arg("exchange_hz") = 0u, + py::arg("fallback_to_streaming") = true, + "Half-duplex alternative to start_streaming() (firmware with 0x5E).\n\n" + "The leader stops pushing frames and holds IMU, encoder, key and sensor-error\n" + "frames; the SDK fetches them once per 1/exchange_hz s and delivers them to the\n" + "same subscribers. The host never writes while the leader is answering, which\n" + "removes the overlap that loses uplink bytes. exchange_hz=0 means\n" + "max(imu_hz, encoder_hz, 1). Returns True when the exchange runs; on firmware\n" + "without 0x5E it falls back to start_streaming() and returns False, or raises\n" + "ProtocolError when fallback_to_streaming=False. stop_streaming() ends it.") + .def_property_readonly("is_exchanging", &LeaderGripper::is_exchanging, + "True between a successful start_exchange() and stop_streaming().") + .def("exchange_stats", &LeaderGripper::exchange_stats, + "LeaderExchangeStats of the running exchange (zeros when not exchanging).") .def("stop_streaming", [](LeaderGripper& self) { py::gil_scoped_release gil; self.stop_streaming(); From 0888ef92f2d8004ceea684fd61a4dc72e50e6e4b Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 2 Nov 2026 14:24:53 +0800 Subject: [PATCH 12/18] =?UTF-8?q?feat(leader):=20=E4=B8=BB=E7=88=AA?= =?UTF-8?q?=E4=BA=A4=E6=8D=A2=E5=9C=A8=E5=9B=BA=E4=BB=B6=E7=A7=9F=E7=BA=A6?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E5=90=8E=E8=87=AA=E5=8A=A8=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E5=B9=B6=E8=A1=A5=E5=85=85=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主爪固件 1 秒未收到 0x5E 会自行退回主动上报(主机崩溃或卡顿)。 - 应答 flags 不含 POLLED 时,交换线程重发同一 POLLED StartStream (最多每 200 ms 一次),卡顿恢复后回到交换模式,期间数据经主动上报 照常分发不丢;LeaderExchangeStats 新增 rearms / rearm_failures - start_exchange 文档说明 exchange_hz 取数据频率 2 倍可消除偶发多等 一个周期,以及同一应答内样本共用到达时间作为 host_time - 测试替身模拟固件租约回退;并发查询测试改为 1 kHz 实际频率并在 CHANGELOG 记录半双工轮次锁不公平的已知限制(该测试在 4b3d5c2 上 已约 1/3 偶发失败) 新增租约回退 PTY 测试,两种变异均被检出;C++ 537 项三轮 -j16 通过, Python 155 项通过。线格式与已有接口不变。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++ cpp/include/taccap/leader_gripper.hpp | 22 ++++++++++++++-- cpp/src/leader_exchange.cpp | 21 +++++++++++++-- cpp/src/leader_exchange.hpp | 7 ++++- cpp/src/leader_gripper.cpp | 7 ++++- cpp/tests/fake_leader.hpp | 16 ++++++++++++ cpp/tests/test_leader_exchange_pty.cpp | 36 ++++++++++++++++++++++++++ python/bindings/bind_gripper.cpp | 11 ++++++-- 8 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52eb145..73a75fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `LeaderExchangeStats`. `start_streaming()` and the wire protocol of older commands are unchanged. +- **Leader exchange survives the firmware lease.** Leader firmware leaves the + exchange by itself after 1 s without `Cmd::LeaderExchange` (host crashed or + stalled), flushes what it held as ordinary DATA frames and goes back to + pushing. A reply without `LeaderExchangeRespFlag::Polled` now makes the SDK + re-send the polled `StartStream` (at most every 200 ms), so a stalled host + returns to the exchange with nothing lost; `LeaderExchangeStats` gains + `rearms` and `rearm_failures`. `start_exchange` documents `exchange_hz` + (2x the data rate removes the occasional one-period wait) and that samples + from one reply share its arrival time as `host_time`. + + Known limit: the half-duplex turn lock is not fair. A user thread issuing + requests back-to-back with no pause can delay exchanges (seen in a test at + ~10 kHz of heartbeats); health checks at realistic rates (<= 1 kHz) are + unaffected. + - **Follower half-duplex exchange: `FollowerGripper.start_exchange(motor_hz, imu_hz, exchange_hz)`.** Requires follower firmware with `StreamMode::Polled` and `Cmd::SlaveExchange` (0x5D, tc-gu-01 `docs/HALF_DUPLEX_EXCHANGE.md`). diff --git a/cpp/include/taccap/leader_gripper.hpp b/cpp/include/taccap/leader_gripper.hpp index 7f3e88e..047265d 100644 --- a/cpp/include/taccap/leader_gripper.hpp +++ b/cpp/include/taccap/leader_gripper.hpp @@ -58,10 +58,13 @@ class LeaderExchange; // fetched. timeouts are exchanges without a reply even after same-seq retries. // continuation_exchanges went out immediately because the firmware reported a // backlog. not_polled counts replies saying the firmware is not in the -// exchange (another process restarted the stream, or the leader rebooted). +// exchange: its 1 s lease expired while this host stalled, another process +// restarted the stream, or the leader rebooted. Each such reply makes the SDK +// re-send the polled StartStream (rearms; rearm_failures when that failed). struct LeaderExchangeStats { uint64_t exchanges = 0, continuation_exchanges = 0; uint64_t timeouts = 0, nacks = 0, malformed = 0, not_polled = 0; + uint64_t rearms = 0, rearm_failures = 0; uint64_t records = 0, records_dropped = 0; uint64_t imu_samples = 0, encoder_samples = 0, key_events = 0; uint64_t sensor_errors = 0, other_records = 0; @@ -241,7 +244,22 @@ class LeaderGripper { // overlap that loses uplink bytes on the current hardware. // // is_streaming() is true and stop_streaming() ends the exchange. - // exchange_hz=0 means max(imu_hz, encoder_hz, 1), at most 500. + // exchange_hz=0 means max(imu_hz, encoder_hz, 1), at most 500. A sample is + // fetched on the next exchange after it is taken, so it waits at most one + // exchange period; at exchange_hz == data rate the two clocks drift, and a + // sample occasionally waits one extra period (~10 ms at 100 Hz, measured + // ~330 times per hour). Pass exchange_hz = 2x the data rate (e.g. 200) to + // remove that, at the cost of twice the (small) request traffic. + // + // Timestamps: every sample delivered from one exchange reply carries that + // reply's arrival time as host_time, i.e. up to one exchange period after + // the sample was taken. Use mcu_timestamp_us for the sampling instant. + // + // The firmware leaves the exchange by itself if no exchange arrives for + // 1 s (host crashed or stalled): it flushes what it held as ordinary DATA + // frames and goes back to pushing, so a dead host never leaves the leader + // silent. If this process was only stalled, the next reply says so and the + // SDK re-enters the exchange; nothing is lost in between. // // Firmware without 0x5E answers InvalidCmd. With fallback_to_streaming // (the default) the SDK then calls start_streaming(imu_hz, encoder_hz) diff --git a/cpp/src/leader_exchange.cpp b/cpp/src/leader_exchange.cpp index 809589d..e9f0373 100644 --- a/cpp/src/leader_exchange.cpp +++ b/cpp/src/leader_exchange.cpp @@ -16,13 +16,17 @@ using clock = std::chrono::steady_clock; // Back-to-back exchanges allowed per period while the firmware reports a // backlog; a backlog larger than this is left for the next period. constexpr unsigned kMaxContinuations = 8; +// At most one re-arm attempt in this interval, so a firmware that keeps +// refusing the polled mode is not hammered with StartStream. +constexpr auto kRearmInterval = std::chrono::milliseconds(200); } // namespace LeaderExchange::LeaderExchange(bus::Transport& transport, std::chrono::microseconds period, - std::chrono::milliseconds timeout) - : t_(transport), period_(period), timeout_(timeout) { + std::chrono::milliseconds timeout, + std::function rearm) + : t_(transport), period_(period), timeout_(timeout), rearm_(std::move(rearm)) { worker_ = std::thread([this] { run_(); }); } @@ -74,6 +78,7 @@ void LeaderExchange::run_() { std::memcpy(wire.data(), &req, sizeof(req)); auto next_tick = clock::now(); + auto last_rearm = clock::now() - kRearmInterval; unsigned continuations = 0; std::unique_lock lk(mu_); while (!stop_) { @@ -137,6 +142,18 @@ void LeaderExchange::run_() { ++stats_.timeouts; } if (link_down) break; + // Firmware fell back to pushing (lease expired while this host stalled, + // or another process restarted the stream): its pushed frames are being + // dispatched normally meanwhile; ask for the exchange again. + if (ok && !(h.flags & protocol::LeaderExchangeRespFlag::Polled) && rearm_ && + clock::now() - last_rearm >= kRearmInterval) { + last_rearm = clock::now(); + lk.unlock(); + bool rearmed = false; + try { rearm_(); rearmed = true; } catch (const std::exception&) {} + lk.lock(); + if (rearmed) ++stats_.rearms; else ++stats_.rearm_failures; + } if (ok && h.pending && continuations < kMaxContinuations) { ++continuations; // backlog: fetch again now, keep the tick continue; diff --git a/cpp/src/leader_exchange.hpp b/cpp/src/leader_exchange.hpp index f8cc83b..5afbe13 100644 --- a/cpp/src/leader_exchange.hpp +++ b/cpp/src/leader_exchange.hpp @@ -25,6 +25,7 @@ #include #include +#include #include #include @@ -32,8 +33,11 @@ namespace xense::taccap::detail { class LeaderExchange { public: + // rearm re-sends the polled StartStream; it runs on the worker when a reply + // says the firmware left the exchange (its 1 s lease expired, or another + // process restarted the stream). LeaderExchange(bus::Transport& transport, std::chrono::microseconds period, - std::chrono::milliseconds timeout); + std::chrono::milliseconds timeout, std::function rearm = {}); ~LeaderExchange(); LeaderExchange(const LeaderExchange&) = delete; @@ -56,6 +60,7 @@ class LeaderExchange { bus::Transport& t_; const std::chrono::microseconds period_; const std::chrono::milliseconds timeout_; + const std::function rearm_; mutable std::mutex mu_; std::condition_variable cv_; diff --git a/cpp/src/leader_gripper.cpp b/cpp/src/leader_gripper.cpp index c3585b0..626c01a 100644 --- a/cpp/src/leader_gripper.cpp +++ b/cpp/src/leader_gripper.cpp @@ -264,7 +264,12 @@ bool LeaderGripper::start_exchange(unsigned imu_hz, unsigned encoder_hz, const auto period = std::chrono::microseconds(1'000'000 / exchange_hz); const auto timeout = std::chrono::milliseconds(std::max( 5, std::chrono::duration_cast(2 * period).count())); - exchange_ = std::make_shared(t_, period, timeout); + // Re-enter the exchange after the firmware's lease fallback with the same + // configuration (sources, rates and the negotiated IMU format). + auto rearm = [this, wire = protocol::encode(sc)] { + t_.send_cmd(protocol::Cmd::StartStream, wire, std::chrono::milliseconds(200)); + }; + exchange_ = std::make_shared(t_, period, timeout, std::move(rearm)); streaming_ = true; return true; } diff --git a/cpp/tests/fake_leader.hpp b/cpp/tests/fake_leader.hpp index b7d004a..05c9b2f 100644 --- a/cpp/tests/fake_leader.hpp +++ b/cpp/tests/fake_leader.hpp @@ -44,6 +44,9 @@ class FakeLeader { void drop_exchange_replies(unsigned n) { drop_.store(n); } // Make the next exchange reply's record lengths inconsistent. void corrupt_next_reply() { corrupt_.store(true); } + // Firmware lease fallback (tc-gu-01 LEADER_EXCHANGE.md): leave the exchange, + // flush held records as DATA frames, then push as the original firmware. + void expire_lease() { expire_.store(true); } // A button event, held or pushed like any other DATA frame. Emitted from // the fake's own thread so the PTY never has two writers. void press_key(uint8_t state) { key_state_.store(static_cast(state)); } @@ -52,6 +55,8 @@ class FakeLeader { bool streaming() const { return streaming_.load(); } uint8_t stream_mode() const { return mode_.load(); } unsigned exchanges() const { return exchanges_.load(); } + unsigned lease_expiries() const { return lease_expiries_.load(); } + unsigned polled_starts() const { return polled_starts_.load(); } unsigned replays() const { return replays_.load(); } unsigned overlaps() const { return overlaps_.load(); } // DATA frames written on the wire (only outside StreamMode::Polled). @@ -143,6 +148,7 @@ class FakeLeader { std::lock_guard lk(mu_); queue_.clear(); queue_bytes_ = 0; dropped_ = 0; polled_.store(sc.mode == static_cast(tp::StreamMode::Polled)); + if (polled_.load()) ++polled_starts_; } mode_.store(sc.mode); streaming_.store(true); @@ -203,6 +209,14 @@ class FakeLeader { } else if (auto f = pty_.expect_frame(1)) { handle_(*f); } + if (expire_.exchange(false) && polled_.load()) { + std::lock_guard lk(mu_); + polled_.store(false); + mode_.store(static_cast(tp::StreamMode::Separate)); + for (auto& rec : queue_) { pty_.send_data(0, static_cast(rec.cmd), rec.payload); ++pushed_; } + queue_.clear(); queue_bytes_ = 0; + ++lease_expiries_; + } const int key = key_state_.exchange(-1); if (key >= 0) { tp::KeyStatusPayload k{0, static_cast(key)}; @@ -228,6 +242,8 @@ class FakeLeader { std::atomic mode_{0}; std::atomic exchanges_{0}, replays_{0}, overlaps_{0}, pushed_{0}; std::atomic key_state_{-1}; + std::atomic expire_{false}; + std::atomic lease_expiries_{0}, polled_starts_{0}; std::atomic imu_seq_{0}; bool pending_reply_ = false; std::vector deferred_; diff --git a/cpp/tests/test_leader_exchange_pty.cpp b/cpp/tests/test_leader_exchange_pty.cpp index a9493c2..f17822d 100644 --- a/cpp/tests/test_leader_exchange_pty.cpp +++ b/cpp/tests/test_leader_exchange_pty.cpp @@ -210,10 +210,14 @@ TEST(LeaderExchangePty, NeverWritesWhileAReplyIsPendingEvenWithConcurrentQueries std::atomic stop{false}; std::atomic heartbeats{0}, heartbeat_timeouts{0}; + // 1 kHz of user queries: far above a real health check. An unthrottled + // back-to-back loop is a different case: the transport's half-duplex turn + // lock is not fair, so it can starve the exchange (see the SDK CHANGELOG). std::thread user([&] { while (!stop.load()) { try { g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200)); ++heartbeats; } catch (...) { ++heartbeat_timeouts; } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); } }); std::this_thread::sleep_for(std::chrono::milliseconds(500)); @@ -243,6 +247,38 @@ TEST(LeaderExchangePty, MalformedReplyIsCountedAndDispatchesNothing) { g->stop_streaming(); } +TEST(LeaderExchangePty, FirmwareLeaseFallbackIsReArmedWithoutLoss) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + + SeqLog imu, enc; + g->imu().on_data([&](const tx::ImuSample& s) { imu.add(s.seq); }); + g->encoder().on_data([&](const tx::EncoderSample& s) { enc.add(s.seq); }); + ASSERT_TRUE(g->start_exchange(100, 100, 20)); // slow exchange: records are held when it fires + ASSERT_TRUE(wait_for([&] { return imu.size() >= 20; }, kSecond)); + EXPECT_EQ(fw.polled_starts(), 1u); + + fw.expire_lease(); + ASSERT_TRUE(wait_for([&] { return fw.lease_expiries() == 1; }, kSecond)); + ASSERT_TRUE(wait_for([&] { return fw.polled() && fw.polled_starts() == 2; }, kSecond)) + << "SDK did not re-enter the exchange"; + const std::size_t at_rearm = imu.size(); + ASSERT_TRUE(wait_for([&] { return imu.size() >= at_rearm + 30; }, 2 * kSecond)); + + const auto s = g->exchange_stats(); + EXPECT_GE(s.not_polled, 1u); + EXPECT_EQ(s.rearms, 1u); + EXPECT_EQ(s.rearm_failures, 0u); + EXPECT_EQ(s.timeouts, 0u); + EXPECT_EQ(imu.gaps(), 0u) << "samples lost across the fallback"; + EXPECT_EQ(enc.gaps(), 0u); + EXPECT_TRUE(g->is_exchanging()); + g->stop_streaming(); + EXPECT_FALSE(fw.streaming()); +} + TEST(LeaderExchangePty, CloseEndsTheExchange) { Pty pty; ASSERT_GE(pty.master(), 0); diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index 75761b7..e1adfdc 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -160,7 +160,10 @@ void bind_gripper(py::module_& m) { .def_readonly("nacks", &LeaderExchangeStats::nacks) .def_readonly("malformed", &LeaderExchangeStats::malformed) .def_readonly("not_polled", &LeaderExchangeStats::not_polled, - "Replies saying the firmware left the exchange (stream restarted or reboot).") + "Replies saying the firmware left the exchange (1 s lease expired, stream restarted or reboot).") + .def_readonly("rearms", &LeaderExchangeStats::rearms, + "Times the SDK re-sent the polled StartStream after a not_polled reply.") + .def_readonly("rearm_failures", &LeaderExchangeStats::rearm_failures) .def_readonly("records", &LeaderExchangeStats::records) .def_readonly("records_dropped", &LeaderExchangeStats::records_dropped, "Records the firmware queue overflowed before they were fetched.") @@ -277,7 +280,11 @@ void bind_gripper(py::module_& m) { "frames; the SDK fetches them once per 1/exchange_hz s and delivers them to the\n" "same subscribers. The host never writes while the leader is answering, which\n" "removes the overlap that loses uplink bytes. exchange_hz=0 means\n" - "max(imu_hz, encoder_hz, 1). Returns True when the exchange runs; on firmware\n" + "max(imu_hz, encoder_hz, 1); pass 2x the data rate (e.g. 200) to avoid a sample\n" + "occasionally waiting one extra period. Samples from one reply share its arrival\n" + "time as host_time; use mcu_timestamp_us for the sampling instant. If the host stalls\n" + "for more than 1 s the leader returns to pushing on its own and the SDK re-enters\n" + "the exchange afterwards. Returns True when the exchange runs; on firmware\n" "without 0x5E it falls back to start_streaming() and returns False, or raises\n" "ProtocolError when fallback_to_streaming=False. stop_streaming() ends it.") .def_property_readonly("is_exchanging", &LeaderGripper::is_exchanging, From 7be1996ad0aedb27bf7eef83fd899d59ca275f1b Mon Sep 17 00:00:00 2001 From: xensexyq Date: Mon, 2 Nov 2026 16:00:27 +0800 Subject: [PATCH 13/18] =?UTF-8?q?fix(python):=20=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E6=89=AB=E6=8F=8F=E4=B8=8E=E6=9F=A5=E6=89=BE=E6=9C=9F=E9=97=B4?= =?UTF-8?q?=E9=87=8A=E6=94=BE=20GIL=EF=BC=8C=E9=81=BF=E5=85=8D=E5=86=BB?= =?UTF-8?q?=E7=BB=93=E5=90=8C=E8=BF=9B=E7=A8=8B=E5=85=B6=E4=BB=96=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_grippers() 与 find_one/find_left/find_right/find_leader/find_follower 在整个扫描期间持有 GIL(两台空闲夹爪约 280 ms,遇到无响应端口可达 ACK 超时)。上位机在后台线程扫描时,同进程 100 Hz 目标推送线程被同步 冻结;上位机在某一角色被其他进程占用时每 3 秒重扫,导致开主爪测试时 从爪往复每 3 秒卡顿一次。 绑定改用 py::call_guard,与其他阻塞调用一致。 实测扫描期间推送线程最大延迟由 263–280 ms 降为 0 ms;Python 153 项 通过(2 项因主爪被占用跳过)。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++++++++++ python/bindings/bind_gripper.cpp | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a75fb..205cc11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Python discovery no longer freezes other threads.** `scan_grippers()` and + the `find_*()` helpers held the GIL for the whole scan (~280 ms with two idle + grippers attached, up to the ACK timeout on a silent port), so a GUI scanning + on a background thread stalled its own 100 Hz target thread for the same + time. The PC config tool rescans every 3 s while one role's gripper is held + by another process, which made reciprocation stutter every 3 s whenever the + leader was in use elsewhere. The bindings now release the GIL; measured + stall during a scan: 263-280 ms before, 0 ms after. + ### Added - **Leader half-duplex exchange: `LeaderGripper.start_exchange(imu_hz, diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index e1adfdc..35ae0f5 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -122,29 +122,40 @@ void bind_gripper(py::module_& m) { " fw_sn=" + e.firmware_sn + (e.in_use ? " IN USE" : "") + ")"; }); + // Discovery opens and probes every serial port (~100+ ms per free device, + // up to the ACK timeout on a silent one). Drop the GIL so a scan on a + // background thread does not freeze the caller's other Python threads + // (e.g. a GUI pushing 100 Hz targets). Results are converted after the + // GIL is re-acquired. m.def("scan_grippers", &discovery::scan_all, + py::call_guard(), "Enumerate every gripper currently plugged in.\n\n" "Opens a transient link to each MCU and probes it for DEV_TYPE and the\n" "SN before closing it again, so a scan is not free and cannot run while\n" "another process holds the port. Returns an empty list rather than\n" "raising when nothing is attached."); m.def("find_one", &discovery::find_one, + py::call_guard(), "The single attached gripper, whatever its side or role.\n\n" "Raises IoError when none is attached, and again when more than one is --\n" "pick with find_left()/find_right() or find_leader()/find_follower()."); m.def("find_left", &discovery::find_left, + py::call_guard(), "The attached gripper whose firmware reports side Left.\n\n" "Raises IoError when none matches; a unit whose firmware answered neither\n" "GetSn nor GetDevType has side Unknown and never matches."); m.def("find_right", &discovery::find_right, + py::call_guard(), "The attached gripper whose firmware reports side Right.\n\n" "Raises IoError when none matches; a unit whose firmware answered neither\n" "GetSn nor GetDevType has side Unknown and never matches."); m.def("find_leader", &discovery::find_leader, + py::call_guard(), "The attached gripper whose SN patch suffix is 'm' (Master).\n\n" "Raises IoError when none matches; a legacy or unburned SN reports role\n" "Unknown and never matches."); m.def("find_follower", &discovery::find_follower, + py::call_guard(), "The attached gripper whose SN patch suffix is 's' (Slave).\n\n" "Raises IoError when none matches; a legacy or unburned SN reports role\n" "Unknown and never matches."); From 8d94c38ed1a25e2576bdceaed256393d198a51eb Mon Sep 17 00:00:00 2001 From: xensexyq Date: Tue, 3 Nov 2026 11:20:55 +0800 Subject: [PATCH 14/18] =?UTF-8?q?feat(leader):=20=E6=99=AE=E9=80=9A?= =?UTF-8?q?=E4=B8=8A=E6=8A=A5=E5=8F=AF=E9=80=89=E6=97=A7=E7=89=88=20IMU=20?= =?UTF-8?q?=E5=B8=A7=EF=BC=8C=E4=BA=A4=E6=8D=A2=E9=BB=98=E8=AE=A4=E9=A2=91?= =?UTF-8?q?=E7=8E=87=E5=B0=81=E9=A1=B6=20200=20Hz?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start_streaming(imu_hz, encoder_hz, imu_v2_time=True):传 False 时 不协商时间会话、上报 28 字节旧版 IMU 帧(同 SDK <= 0.4.4)。普通上报 下主机写出与上行重叠会撞坏帧,帧越长越易中:主爪 a9bd2c3、请求 IMU 1000 Hz + 编码器 100 Hz + 每秒 100 次查询、120 s ×3,IMU 坏帧 V2 868 个、旧版 1 个。默认不变;交换模式可消除重叠并保留 V2 - start_exchange 未指定 exchange_hz 时取数据频率并封顶 200 Hz:主爪 无论请求多少都按 100 Hz 采样,start_exchange(1000, 100) 原先会因 1000 > 500 抛错;显式 exchange_hz 上限仍为 500 新增 3 项测试,两种变异均被检出;C++ 540 项 -j16 四轮通过,Python 155 项 通过;主爪实测见上。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +++++++++++ cpp/include/taccap/leader_gripper.hpp | 16 ++++++++++-- cpp/src/leader_gripper.cpp | 14 +++++++---- cpp/src/stream_rate.hpp | 8 ++++++ cpp/tests/fake_leader.hpp | 7 +++++- cpp/tests/test_leader_exchange_pty.cpp | 35 ++++++++++++++++++++++++++ cpp/tests/test_stream_rate.cpp | 9 +++++++ python/bindings/bind_gripper.cpp | 10 ++++---- 8 files changed, 101 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b4f0c5..0c2aee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`LeaderGripper.start_exchange(1000, 100)` no longer throws.** With no + `exchange_hz` the default exchange rate was the requested data rate, so a + 1000 Hz IMU request asked for 1000 exchanges/s and was rejected (limit 500). + The default is now the data rate capped at 200 Hz (the leader samples IMU and + encoder at 100 Hz whatever is requested); an explicit `exchange_hz` may still + be up to 500. + - **Python discovery no longer freezes other threads.** `scan_grippers()` and the `find_*()` helpers held the GIL for the whole scan (~280 ms with two idle grippers attached, up to the ACK timeout on a silent port), so a GUI scanning @@ -20,6 +27,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`LeaderGripper.start_streaming(imu_hz, encoder_hz, imu_v2_time=True)`.** + `imu_v2_time=False` streams the legacy 28 B IMU frame without a time session, + as SDK <= 0.4.4 did. A pushed stream loses an uplink frame whenever a host + write overlaps it, and a longer frame is hit more often: on a leader at + 100 Hz queries (IMU 1000 Hz requested, encoder 100 Hz, 120 s x3) IMU bad + frames were 868 with V2 and 1 with the legacy frame. Default unchanged; + `start_exchange()` removes the overlap and keeps V2. + - **Leader half-duplex exchange: `LeaderGripper.start_exchange(imu_hz, encoder_hz, exchange_hz, fallback_to_streaming=True)`.** Requires leader firmware with `StreamMode::Polled` and `Cmd::LeaderExchange` (0x5E, tc-gu-01 diff --git a/cpp/include/taccap/leader_gripper.hpp b/cpp/include/taccap/leader_gripper.hpp index 047265d..2caf68b 100644 --- a/cpp/include/taccap/leader_gripper.hpp +++ b/cpp/include/taccap/leader_gripper.hpp @@ -227,7 +227,17 @@ class LeaderGripper { // anything above 1000 Hz collapses to 100 Hz. StartStream is never NACKed // for a bad rate, so the SDK logs a warning whenever it has to adjust one. // See cpp/src/stream_rate.hpp for the firmware model this mirrors. - void start_streaming(unsigned imu_hz = 100, unsigned encoder_hz = 100); + // + // imu_v2_time (default true): negotiate the 64-bit time session and stream + // GetImuV2 (44 B payload) when the firmware supports it; false always + // streams the legacy GetImu (28 B) without a time session, as SDK <= 0.4.4 + // did. The pushed stream loses an uplink frame whenever a host write + // overlaps it; a longer frame is hit more often (measured on a leader at + // 100 Hz queries: IMU bad frames ~4.7x with V2). If the stream must stay + // pushed and queries are frequent, pass false; start_exchange() removes the + // overlap altogether and keeps V2. + void start_streaming(unsigned imu_hz = 100, unsigned encoder_hz = 100, + bool imu_v2_time = true); void stop_streaming(); bool is_streaming() const noexcept { return streaming_; } @@ -244,7 +254,9 @@ class LeaderGripper { // overlap that loses uplink bytes on the current hardware. // // is_streaming() is true and stop_streaming() ends the exchange. - // exchange_hz=0 means max(imu_hz, encoder_hz, 1), at most 500. A sample is + // exchange_hz=0 means max(imu_hz, encoder_hz, 1) capped at 200 (the leader + // samples at 100 Hz whatever is requested); an explicit exchange_hz may be up + // to 500. A sample is // fetched on the next exchange after it is taken, so it waits at most one // exchange period; at exchange_hz == data rate the two clocks drift, and a // sample occasionally waits one extra period (~10 ms at 100 Hz, measured diff --git a/cpp/src/leader_gripper.cpp b/cpp/src/leader_gripper.cpp index 626c01a..4ececcb 100644 --- a/cpp/src/leader_gripper.cpp +++ b/cpp/src/leader_gripper.cpp @@ -49,7 +49,8 @@ Camera::Config make_wrist_config(const LeaderGripper::Config& cfg) { // source's mask bit clear -- the rate field alone would fall through to the // firmware's 100 Hz default. See stream_rate.hpp. protocol::StreamConfig leader_stream_config(const char* caller, IMU& imu, unsigned imu_hz, - unsigned encoder_hz, protocol::StreamMode mode) { + unsigned encoder_hz, protocol::StreamMode mode, + bool imu_v2_time = true) { const uint16_t mask = detail::stream_source_mask(imu_hz, encoder_hz, 0); if (mask == 0) { throw IoError(std::string("LeaderGripper::") + caller + ": both rates are 0, so " @@ -63,7 +64,10 @@ protocol::StreamConfig leader_stream_config(const char* caller, IMU& imu, unsign // Build StreamConfig (12 bytes — see protocol::StreamConfig). protocol::StreamConfig sc{}; sc.source_mask = mask; - if (imu_hz) sc.source_mask = (mask & ~protocol::StreamSrc::Imu) | imu.negotiate_time(); + // imu_v2_time=false keeps the legacy 28 B GetImu frame and skips the time + // session entirely (what SDK <= 0.4.4 always did). + if (imu_hz && imu_v2_time) + sc.source_mask = (mask & ~protocol::StreamSrc::Imu) | imu.negotiate_time(); sc.mode = static_cast(mode); sc.imu_rate = static_cast(imu_hz); sc.encoder_rate = static_cast(encoder_hz); @@ -187,7 +191,7 @@ std::unique_ptr LeaderGripper::open() { return std::make_unique(cfg); } -void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz) { +void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz, bool imu_v2_time) { if (streaming_) return; // Best-effort reset of the firmware streaming state. If a previous host @@ -202,7 +206,7 @@ void LeaderGripper::start_streaming(unsigned imu_hz, unsigned encoder_hz) { } catch (...) { /* expected when fw is already idle */ } const auto sc = leader_stream_config("start_streaming", imu_, imu_hz, encoder_hz, - protocol::StreamMode::Separate); + protocol::StreamMode::Separate, imu_v2_time); auto wire = protocol::encode(sc); auto ack = t_.send_cmd(protocol::Cmd::StartStream, wire); if (ack.is_nack) { @@ -220,7 +224,7 @@ bool LeaderGripper::start_exchange(unsigned imu_hz, unsigned encoder_hz, unsigned exchange_hz, bool fallback_to_streaming) { if (exchange_) return true; if (streaming_) throw IoError("LeaderGripper: stop streaming before start_exchange", EBUSY); - if (exchange_hz == 0) exchange_hz = std::max({imu_hz, encoder_hz, 1u}); + if (exchange_hz == 0) exchange_hz = detail::leader_default_exchange_hz(imu_hz, encoder_hz); if (exchange_hz > 500) throw IoError("LeaderGripper::start_exchange: exchange_hz must be <= 500", EINVAL); if (detail::stream_source_mask(imu_hz, encoder_hz, 0) == 0) diff --git a/cpp/src/stream_rate.hpp b/cpp/src/stream_rate.hpp index 552dc11..cc35e1b 100644 --- a/cpp/src/stream_rate.hpp +++ b/cpp/src/stream_rate.hpp @@ -86,4 +86,12 @@ inline void warn_if_rate_adjusted(const char* who, const char* source, } } +// LeaderGripper::start_exchange() default exchange rate: the data rate, capped at +// 200 Hz. The leader samples IMU and encoder at 100 Hz whatever is requested, so +// a 1000 Hz request must not turn into 1000 exchanges/s (it used to throw). +inline unsigned leader_default_exchange_hz(unsigned imu_hz, unsigned encoder_hz) { + const unsigned hz = imu_hz > encoder_hz ? imu_hz : encoder_hz; + return hz < 1 ? 1 : (hz > 200 ? 200 : hz); +} + } // namespace xense::taccap::detail diff --git a/cpp/tests/fake_leader.hpp b/cpp/tests/fake_leader.hpp index 05c9b2f..f7e4a7d 100644 --- a/cpp/tests/fake_leader.hpp +++ b/cpp/tests/fake_leader.hpp @@ -56,6 +56,8 @@ class FakeLeader { uint8_t stream_mode() const { return mode_.load(); } unsigned exchanges() const { return exchanges_.load(); } unsigned lease_expiries() const { return lease_expiries_.load(); } + uint16_t stream_source_mask() const { return source_mask_.load(); } + unsigned time_session_requests() const { return time_sessions_.load(); } unsigned polled_starts() const { return polled_starts_.load(); } unsigned replays() const { return replays_.load(); } unsigned overlaps() const { return overlaps_.load(); } @@ -151,6 +153,7 @@ class FakeLeader { if (polled_.load()) ++polled_starts_; } mode_.store(sc.mode); + source_mask_.store(sc.source_mask); streaming_.store(true); pty_.send_ack_ok(f.seq, f.cmd); return; @@ -194,6 +197,7 @@ class FakeLeader { return; default: // TimeSession and anything else: legacy firmware. + if (f.cmd == tp::Cmd::TimeSession) ++time_sessions_; pty_.send_nack(f.seq, tp::ErrorCode::InvalidCmd); return; } @@ -243,7 +247,8 @@ class FakeLeader { std::atomic exchanges_{0}, replays_{0}, overlaps_{0}, pushed_{0}; std::atomic key_state_{-1}; std::atomic expire_{false}; - std::atomic lease_expiries_{0}, polled_starts_{0}; + std::atomic lease_expiries_{0}, polled_starts_{0}, time_sessions_{0}; + std::atomic source_mask_{0}; std::atomic imu_seq_{0}; bool pending_reply_ = false; std::vector deferred_; diff --git a/cpp/tests/test_leader_exchange_pty.cpp b/cpp/tests/test_leader_exchange_pty.cpp index f17822d..247d11d 100644 --- a/cpp/tests/test_leader_exchange_pty.cpp +++ b/cpp/tests/test_leader_exchange_pty.cpp @@ -160,6 +160,41 @@ TEST(LeaderExchangePty, RejectsBadArgumentsBeforeTouchingTheDevice) { g->stop_streaming(); } +TEST(LeaderExchangePty, HighRateRequestDefaultsToCappedExchangeRate) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + // A 1000 Hz request used to default exchange_hz to 1000 and throw. The cap + // itself is pinned in test_stream_rate.cpp (no wall-clock rate here: flaky + // on a loaded runner). + ASSERT_TRUE(g->start_exchange(1000, 100)); + EXPECT_TRUE(wait_for([&] { return fw.exchanges() >= 10; }, kSecond)); + g->stop_streaming(); + EXPECT_THROW(g->start_exchange(1000, 100, 501), tx::IoError) << "explicit > 500 still rejected"; +} + +TEST(LeaderExchangePty, StreamingCanKeepTheLegacyImuFrame) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeLeader fw(pty); + auto g = open_leader(pty); + std::atomic imu{0}; + g->imu().on_data([&](const tx::ImuSample&) { ++imu; }); + + const unsigned before = fw.time_session_requests(); + g->start_streaming(100, 100, false); + EXPECT_EQ(fw.time_session_requests(), before) << "imu_v2_time=false must not negotiate a time session"; + EXPECT_TRUE(fw.stream_source_mask() & tp::StreamSrc::Imu); + EXPECT_FALSE(fw.stream_source_mask() & tp::StreamSrc::ImuV2); + EXPECT_TRUE(wait_for([&] { return imu.load() >= 10; }, kSecond)); + g->stop_streaming(); + + g->start_streaming(100, 100); // default keeps today's behaviour: negotiate + EXPECT_GT(fw.time_session_requests(), before); + g->stop_streaming(); +} + TEST(LeaderExchangePty, BacklogIsFetchedAtOnceWithoutLoss) { Pty pty; ASSERT_GE(pty.master(), 0); diff --git a/cpp/tests/test_stream_rate.cpp b/cpp/tests/test_stream_rate.cpp index 2c7f615..bea5b3a 100644 --- a/cpp/tests/test_stream_rate.cpp +++ b/cpp/tests/test_stream_rate.cpp @@ -93,3 +93,12 @@ TEST(StreamRate, NeverEnablesEskin) { const uint16_t all = det::stream_source_mask(100, 100, 100); EXPECT_EQ(all & (tp::StreamSrc::Eskin1 | tp::StreamSrc::Eskin2), 0u); } + +TEST(StreamRate, LeaderDefaultExchangeRateIsTheDataRateCappedAt200) { + using xense::taccap::detail::leader_default_exchange_hz; + EXPECT_EQ(leader_default_exchange_hz(100, 100), 100u); + EXPECT_EQ(leader_default_exchange_hz(0, 50), 50u); + EXPECT_EQ(leader_default_exchange_hz(200, 100), 200u); + EXPECT_EQ(leader_default_exchange_hz(1000, 100), 200u); + EXPECT_EQ(leader_default_exchange_hz(0, 0), 1u); +} diff --git a/python/bindings/bind_gripper.cpp b/python/bindings/bind_gripper.cpp index 35ae0f5..f86377e 100644 --- a/python/bindings/bind_gripper.cpp +++ b/python/bindings/bind_gripper.cpp @@ -275,11 +275,11 @@ void bind_gripper(py::module_& m) { "Raises IoError when none is attached or when more than one is; pass an\n" "explicit mcu_device from find_leader() in that case. Cameras stay\n" "closed -- an external service owns the wrist V4L2 device.") - .def("start_streaming", [](LeaderGripper& self, unsigned imu_hz, unsigned enc_hz) { + .def("start_streaming", [](LeaderGripper& self, unsigned imu_hz, unsigned enc_hz, bool v2) { py::gil_scoped_release gil; - self.start_streaming(imu_hz, enc_hz); - }, py::arg("imu_hz") = 100u, py::arg("encoder_hz") = 100u, - "Start the MCU sensor stream.\n\nA rate of 0 turns that source OFF (its source_mask bit is cleared). Passing 0 used to stream the source at the firmware's 100 Hz default instead; raises IoError(EINVAL) if every rate is 0.\n\nThe firmware divides a 1 kHz tick by an integer, so only divisors of 1000 arrive at the requested rate -- 300 Hz becomes 333 Hz, 150 Hz becomes 167 Hz, and anything above 1000 Hz collapses to 100 Hz. Motor status is additionally capped at 100 Hz. None of this is NACKed by the firmware, so the SDK logs a warning when it applies.") + self.start_streaming(imu_hz, enc_hz, v2); + }, py::arg("imu_hz") = 100u, py::arg("encoder_hz") = 100u, py::arg("imu_v2_time") = true, + "Start the MCU sensor stream.\n\nimu_v2_time=True (default) streams IMU V2 with 64-bit capture time when the firmware supports it; False keeps the legacy 28-byte IMU frame, as SDK <= 0.4.4 did. A pushed stream loses a frame whenever a host write overlaps it and longer frames are hit more often, so with frequent queries False loses fewer IMU samples; start_exchange() avoids the overlap entirely.\n\nA rate of 0 turns that source OFF (its source_mask bit is cleared). Passing 0 used to stream the source at the firmware's 100 Hz default instead; raises IoError(EINVAL) if every rate is 0.\n\nThe firmware divides a 1 kHz tick by an integer, so only divisors of 1000 arrive at the requested rate -- 300 Hz becomes 333 Hz, 150 Hz becomes 167 Hz, and anything above 1000 Hz collapses to 100 Hz. Motor status is additionally capped at 100 Hz. None of this is NACKed by the firmware, so the SDK logs a warning when it applies.") .def("start_exchange", [](LeaderGripper& self, unsigned imu_hz, unsigned enc_hz, unsigned exchange_hz, bool fallback) { py::gil_scoped_release gil; @@ -291,7 +291,7 @@ void bind_gripper(py::module_& m) { "frames; the SDK fetches them once per 1/exchange_hz s and delivers them to the\n" "same subscribers. The host never writes while the leader is answering, which\n" "removes the overlap that loses uplink bytes. exchange_hz=0 means\n" - "max(imu_hz, encoder_hz, 1); pass 2x the data rate (e.g. 200) to avoid a sample\n" + "max(imu_hz, encoder_hz, 1) capped at 200 (explicit values up to 500); pass 2x the data rate (e.g. 200) to avoid a sample\n" "occasionally waiting one extra period. Samples from one reply share its arrival\n" "time as host_time; use mcu_timestamp_us for the sampling instant. If the host stalls\n" "for more than 1 s the leader returns to pushing on its own and the SDK re-enters\n" From 74b9a8efc51a1c286e0307653ed3cd5b09de884f Mon Sep 17 00:00:00 2001 From: xensexyq Date: Tue, 3 Nov 2026 11:35:07 +0800 Subject: [PATCH 15/18] =?UTF-8?q?fix(sync):=20=E4=BA=A4=E6=8D=A2=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E3=80=81=E5=8D=8A=E5=8F=8C=E5=B7=A5=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E9=94=81=E4=B8=8E=E6=8E=A7=E5=88=B6=E5=99=A8=E7=9C=8B=E9=97=A8?= =?UTF-8?q?=E7=8B=97=E6=94=B9=E7=94=A8=E5=8D=95=E8=B0=83=E6=97=B6=E9=92=9F?= =?UTF-8?q?=E7=AD=89=E5=BE=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conda-forge/manylinux 等旧 sysroot 工具链下,libstdc++ 的 condition_variable::wait_until(steady_clock) 与 timed_mutex::try_lock_for 退化为 CLOCK_REALTIME 的 pthread_cond_timedwait/pthread_mutex_timedlock: NTP 把墙钟往回调多少,等待就多等多少。实测从爪交换在 "Clock change detected" 后 0.2 s 停顿超过 status_timeout_ms,力位控制器进入 Fault。 新增 detail::MonotonicCondition(pthread_condattr_setclock(CLOCK_MONOTONIC)) 与 MonotonicTimedLock,用于主从爪交换线程、半双工轮次锁、力位与阻抗控制器 的状态看门狗;与工具链无关。新增 5 项单元测试;C++ 545 项 -j16 九轮中 8 轮 全过、1 轮为既有定时敏感用例偶发(修改前同条件 6 轮失败 2 轮);Python 155 项通过;核心库不再引用 pthread_mutex_timedlock。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 +++ cpp/include/taccap/bus/transport.hpp | 15 ++- cpp/include/taccap/detail/monotonic_cv.hpp | 99 +++++++++++++++++++ .../taccap/force_position_controller.hpp | 3 +- cpp/include/taccap/impedance_controller.hpp | 3 +- cpp/src/bus/transport.cpp | 8 +- cpp/src/follower_exchange.hpp | 3 +- cpp/src/leader_exchange.hpp | 3 +- cpp/tests/CMakeLists.txt | 1 + cpp/tests/test_monotonic_cv.cpp | 74 ++++++++++++++ 10 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 cpp/include/taccap/detail/monotonic_cv.hpp create mode 100644 cpp/tests/test_monotonic_cv.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2aee3..8b56810 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A wall-clock step no longer stalls the exchange or the controllers.** + libstdc++ built against an older glibc sysroot (conda-forge, manylinux) + implements `condition_variable::wait_until(steady_clock)` and + `timed_mutex::try_lock_for` on `CLOCK_REALTIME`, so when NTP stepped the wall + clock back the waits lasted that much longer. A follower exchange stalled + past `status_timeout_ms` 0.2 s after "Clock change detected" and the + ForcePositionController faulted. The leader/follower exchange loops, the + half-duplex turn lock and both controllers' status watchdog now wait on a + `pthread_cond` bound to `CLOCK_MONOTONIC` (`taccap/detail/monotonic_cv.hpp`), + whatever toolchain builds the SDK. + - **`LeaderGripper.start_exchange(1000, 100)` no longer throws.** With no `exchange_hz` the default exchange rate was the requested data rate, so a 1000 Hz IMU request asked for 1000 exchanges/s and was rejected (limit 500). diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 2236ba8..8f32988 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -54,6 +54,7 @@ #include #include #include +#include #include #include #include @@ -318,9 +319,19 @@ class Transport { // Never held by the reader or dispatcher, so callbacks may still send. // Waiting for the turn counts against the request's own timeout: a long // command (IMU calibration, 15 s) must not stretch a 100 ms query. - std::unique_lock half_duplex_turn_(std::chrono::milliseconds wait, + struct Turn { // RAII, like std::unique_lock + detail::MonotonicTimedLock* lock = nullptr; + bool owns_lock() const noexcept { return lock != nullptr; } + void unlock() { if (lock) { lock->unlock(); lock = nullptr; } } + Turn() = default; + explicit Turn(detail::MonotonicTimedLock* l) : lock(l) {} + Turn(Turn&& o) noexcept : lock(o.lock) { o.lock = nullptr; } + Turn& operator=(Turn&&) = delete; + ~Turn() { unlock(); } + }; + Turn half_duplex_turn_(std::chrono::milliseconds wait, protocol::Cmd cmd); - std::timed_mutex half_duplex_mu_; + detail::MonotonicTimedLock half_duplex_mu_; // not std::timed_mutex: see monotonic_cv.hpp std::atomic half_duplex_{false}; void reader_loop_(); diff --git a/cpp/include/taccap/detail/monotonic_cv.hpp b/cpp/include/taccap/detail/monotonic_cv.hpp new file mode 100644 index 0000000..c5cb59a --- /dev/null +++ b/cpp/include/taccap/detail/monotonic_cv.hpp @@ -0,0 +1,99 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// Timed waits that really run on CLOCK_MONOTONIC. +// +// Why: libstdc++ only implements condition_variable::wait_until(steady_clock) +// and timed_mutex::try_lock_for on the monotonic clock when it was built +// against glibc >= 2.30 (pthread_cond_clockwait / pthread_mutex_clocklock). +// Toolchains built on an older sysroot -- conda-forge's, manylinux's -- fall +// back to pthread_cond_timedwait / pthread_mutex_timedlock on CLOCK_REALTIME: +// a steady deadline is converted to wall-clock time, so when NTP steps the +// wall clock back by X, the wait lasts X longer. That stalled a half-duplex +// exchange loop past the controllers' 200 ms status timeout (observed: +// "Clock change detected" 0.2 s before the fault). +// +// A pthread condition variable whose attribute selects CLOCK_MONOTONIC is +// available in every glibc, so these waits are immune to wall-clock steps +// whatever the toolchain. std::chrono::steady_clock is CLOCK_MONOTONIC on +// Linux (libstdc++ and libc++), so its time points convert directly. + +#pragma once + +#include +#include + +#include +#include + +namespace xense::taccap::detail { + +class MonotonicCondition { +public: + MonotonicCondition() { + pthread_condattr_t attr; + pthread_condattr_init(&attr); + pthread_condattr_setclock(&attr, CLOCK_MONOTONIC); + pthread_cond_init(&cond_, &attr); + pthread_condattr_destroy(&attr); + } + ~MonotonicCondition() { pthread_cond_destroy(&cond_); } + MonotonicCondition(const MonotonicCondition&) = delete; + MonotonicCondition& operator=(const MonotonicCondition&) = delete; + + void notify_one() noexcept { pthread_cond_signal(&cond_); } + void notify_all() noexcept { pthread_cond_broadcast(&cond_); } + + // Same contract as std::condition_variable::wait_until with a predicate: + // returns pred() (false only when the deadline passed with pred false). + template + bool wait_until(std::unique_lock& lk, + std::chrono::steady_clock::time_point deadline, Pred pred) { + while (!pred()) { + if (std::chrono::steady_clock::now() >= deadline) return pred(); + const auto ns = std::chrono::duration_cast( + deadline.time_since_epoch()).count(); + timespec ts{}; + ts.tv_sec = static_cast(ns / 1'000'000'000); + ts.tv_nsec = static_cast(ns % 1'000'000'000); + pthread_cond_timedwait(&cond_, lk.mutex()->native_handle(), &ts); + } + return true; + } + + template + bool wait_for(std::unique_lock& lk, + std::chrono::duration d, Pred pred) { + return wait_until(lk, std::chrono::steady_clock::now() + + std::chrono::duration_cast(d), + std::move(pred)); + } + +private: + pthread_cond_t cond_; +}; + +// A lock that can be waited for with a monotonic timeout (replacement for +// std::timed_mutex, whose try_lock_for has the same wall-clock fallback). +class MonotonicTimedLock { +public: + bool try_lock_for(std::chrono::steady_clock::duration wait) { + std::unique_lock lk(mu_); + if (!cv_.wait_for(lk, wait, [this] { return !held_; })) return false; + held_ = true; + return true; + } + void unlock() { + { + std::lock_guard lk(mu_); + held_ = false; + } + cv_.notify_one(); + } + +private: + std::mutex mu_; + MonotonicCondition cv_; + bool held_ = false; +}; + +} // namespace xense::taccap::detail diff --git a/cpp/include/taccap/force_position_controller.hpp b/cpp/include/taccap/force_position_controller.hpp index cb8d0d9..3ff9697 100644 --- a/cpp/include/taccap/force_position_controller.hpp +++ b/cpp/include/taccap/force_position_controller.hpp @@ -58,6 +58,7 @@ #pragma once +#include #include #include #include @@ -430,7 +431,7 @@ class ForcePositionController { GripperPosition map_; mutable std::mutex mu_; - std::condition_variable cv_; + detail::MonotonicCondition cv_; // status watchdog: must not stretch on a wall-clock step bool step_requested_ = false; bool stop_requested_ = false; bool have_sample_ = false; diff --git a/cpp/include/taccap/impedance_controller.hpp b/cpp/include/taccap/impedance_controller.hpp index ea63dd3..a275b15 100644 --- a/cpp/include/taccap/impedance_controller.hpp +++ b/cpp/include/taccap/impedance_controller.hpp @@ -96,6 +96,7 @@ #pragma once +#include #include #include #include @@ -311,7 +312,7 @@ class ImpedanceController { GripperPosition map_; mutable std::mutex mu_; - std::condition_variable cv_; + detail::MonotonicCondition cv_; // status watchdog: must not stretch on a wall-clock step bool step_requested_ = false; bool stop_requested_ = false; bool have_sample_ = false; diff --git a/cpp/src/bus/transport.cpp b/cpp/src/bus/transport.cpp index 36141ab..62a2350 100644 --- a/cpp/src/bus/transport.cpp +++ b/cpp/src/bus/transport.cpp @@ -154,16 +154,14 @@ void Transport::send_cmd_no_ack(protocol::Cmd cmd, protocol::FrameType::CMD_NO_ACK, cmd, payload), cmd); } -std::unique_lock Transport::half_duplex_turn_(std::chrono::milliseconds wait, - protocol::Cmd cmd) { +Transport::Turn Transport::half_duplex_turn_(std::chrono::milliseconds wait, protocol::Cmd cmd) { if (!half_duplex_.load(std::memory_order_acquire)) return {}; - std::unique_lock turn(half_duplex_mu_, std::defer_lock); - if (!turn.try_lock_for(wait)) { + if (!half_duplex_mu_.try_lock_for(wait)) { throw TimeoutError("send " + std::string(protocol::to_string(cmd)) + ": half-duplex link busy for " + std::to_string(wait.count()) + " ms", ETIMEDOUT); } - return turn; + return Turn(&half_duplex_mu_); } void Transport::inject_data(Frame f) { diff --git a/cpp/src/follower_exchange.hpp b/cpp/src/follower_exchange.hpp index ce86163..e866e93 100644 --- a/cpp/src/follower_exchange.hpp +++ b/cpp/src/follower_exchange.hpp @@ -25,6 +25,7 @@ #pragma once #include +#include #include #include @@ -68,7 +69,7 @@ class FollowerExchange { const std::chrono::milliseconds timeout_; mutable std::mutex mu_; - std::condition_variable cv_; + detail::MonotonicCondition cv_; // monotonic even on old-sysroot toolchains std::optional pending_; bool stop_ = false; ExchangeStats stats_{}; diff --git a/cpp/src/leader_exchange.hpp b/cpp/src/leader_exchange.hpp index 5afbe13..eaf1068 100644 --- a/cpp/src/leader_exchange.hpp +++ b/cpp/src/leader_exchange.hpp @@ -21,6 +21,7 @@ #pragma once #include +#include #include #include @@ -63,7 +64,7 @@ class LeaderExchange { const std::function rearm_; mutable std::mutex mu_; - std::condition_variable cv_; + detail::MonotonicCondition cv_; // monotonic even on old-sysroot toolchains bool stop_ = false; LeaderExchangeStats stats_{}; std::thread worker_; diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 6887739..6fb7851 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -42,6 +42,7 @@ add_executable(taccap_unit_tests test_imu_time.cpp test_follower_exchange_pty.cpp test_leader_exchange_pty.cpp + test_monotonic_cv.cpp ) # stream_rate.hpp is a private helper under src/ (not an installed header), diff --git a/cpp/tests/test_monotonic_cv.cpp b/cpp/tests/test_monotonic_cv.cpp new file mode 100644 index 0000000..1ffaef6 --- /dev/null +++ b/cpp/tests/test_monotonic_cv.cpp @@ -0,0 +1,74 @@ +// Copyright (c) 2026 XenseRobotics Co., Ltd. — Apache-2.0 +// +// detail::MonotonicCondition / MonotonicTimedLock: timed waits on +// CLOCK_MONOTONIC (see monotonic_cv.hpp for why std:: ones are not enough on +// old-sysroot toolchains). Wall-clock steps cannot be produced without root, +// so this pins the contract; steady_clock == CLOCK_MONOTONIC is checked below. + +#include + +#include + +#include +#include + +namespace td = xense::taccap::detail; +using clk = std::chrono::steady_clock; +using namespace std::chrono_literals; + +TEST(MonotonicCv, TimesOutAfterTheDeadlineWithPredicateFalse) { + std::mutex m; td::MonotonicCondition cv; + std::unique_lock lk(m); + const auto t0 = clk::now(); + EXPECT_FALSE(cv.wait_for(lk, 30ms, [] { return false; })); + const auto dt = clk::now() - t0; + EXPECT_GE(dt, 30ms); + EXPECT_LT(dt, 500ms); + EXPECT_TRUE(lk.owns_lock()); +} + +TEST(MonotonicCv, NotifyWakesBeforeTheDeadline) { + std::mutex m; td::MonotonicCondition cv; bool ready = false; + std::thread th([&] { + std::this_thread::sleep_for(20ms); + { std::lock_guard g(m); ready = true; } + cv.notify_all(); + }); + std::unique_lock lk(m); + const auto t0 = clk::now(); + EXPECT_TRUE(cv.wait_until(lk, t0 + 5s, [&] { return ready; })); + EXPECT_LT(clk::now() - t0, 2s); + lk.unlock(); + th.join(); +} + +TEST(MonotonicCv, PredicateAlreadyTrueReturnsImmediately) { + std::mutex m; td::MonotonicCondition cv; + std::unique_lock lk(m); + EXPECT_TRUE(cv.wait_until(lk, clk::now() - 1s, [] { return true; })); +} + +TEST(MonotonicCv, SteadyClockIsTheMonotonicClock) { + // The helper hands steady_clock time points to a CLOCK_MONOTONIC cond. + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + const auto mono = std::chrono::seconds(ts.tv_sec) + std::chrono::nanoseconds(ts.tv_nsec); + const auto steady = clk::now().time_since_epoch(); + EXPECT_LT(std::chrono::abs(steady - mono), 50ms); +} + +TEST(MonotonicTimedLock, ExcludesTimesOutAndHandsOver) { + td::MonotonicTimedLock lock; + ASSERT_TRUE(lock.try_lock_for(0ms)); + const auto t0 = clk::now(); + EXPECT_FALSE(lock.try_lock_for(30ms)) << "second owner admitted"; + EXPECT_GE(clk::now() - t0, 30ms); + std::atomic got{false}; + std::thread th([&] { got = lock.try_lock_for(2s); if (got) lock.unlock(); }); + std::this_thread::sleep_for(20ms); + lock.unlock(); + th.join(); + EXPECT_TRUE(got.load()); + EXPECT_TRUE(lock.try_lock_for(0ms)); + lock.unlock(); +} From 682eda67a20d2aa729df824bce05b2cfad490e0c Mon Sep 17 00:00:00 2001 From: xensexyq Date: Tue, 3 Nov 2026 15:35:52 +0800 Subject: [PATCH 16/18] =?UTF-8?q?fix(follower):=20=E4=BA=A4=E6=8D=A2?= =?UTF-8?q?=E5=BA=94=E7=AD=94=E4=B8=A2=E5=A4=B1=E6=97=B6=E4=BB=A5=E5=90=8C?= =?UTF-8?q?=E4=B8=80=20seq=20=E9=87=8D=E8=AF=95=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E4=B8=A2=20IMU=20=E6=A0=B7=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从爪交换原先每个请求只发一次(RetryMode::Once):主机卡顿超过超时 导致应答丢失时,下一次交换取走新样本,丢失应答里的 IMU 样本随之丢失 (高负载主机上每小时 5–9 个)。改为与主爪相同的 RetryMode::SameSeq, 固件命令层对逐字节相同的重复请求原样重放缓存应答(遥测一致、目标 不会执行两次)。 测试替身模拟固件重放缓存应答,新增“应答丢失由同 seq 重试找回、IMU 不缺”测试;改回 Once 的变异被检出。并发查询测试改为 1 kHz(与主爪测试 一致,原无节流循环在不公平轮次锁下偶发失败,改前单独 30 次失败 3 次)。 C++ 546 项三轮通过,Python 153 项通过(2 项需主爪硬件跳过)。 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++ cpp/include/taccap/bus/transport.hpp | 4 +-- cpp/src/follower_exchange.cpp | 6 +++- cpp/src/follower_exchange.hpp | 8 ++++-- cpp/tests/fake_follower.hpp | 21 ++++++++++++++ cpp/tests/test_follower_exchange_pty.cpp | 36 +++++++++++++++++++++++- 6 files changed, 74 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b56810..665d691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A lost follower exchange reply no longer loses IMU samples.** The follower + exchange sent each request once; when its reply was lost (host stalled past + the timeout) the next exchange took new samples and the ones in the lost reply + were gone (5-9 per hour on a loaded host). It now retries with the same seq, + like the leader, so the firmware replays its cached reply. + - **A wall-clock step no longer stalls the exchange or the controllers.** libstdc++ built against an older glibc sysroot (conda-forge, manylinux) implements `condition_variable::wait_until(steady_clock)` and diff --git a/cpp/include/taccap/bus/transport.hpp b/cpp/include/taccap/bus/transport.hpp index 8f32988..7d18781 100644 --- a/cpp/include/taccap/bus/transport.hpp +++ b/cpp/include/taccap/bus/transport.hpp @@ -123,8 +123,8 @@ enum class RetryMode : uint8_t { // this transport between attempts — true for the single-threaded OTA flow. SameSeq, // Exactly one attempt regardless of Config::max_retries. For the follower - // exchange loop, which owns its own resend policy and must never write a - // second frame while the first one's reply may still be on the wire. + // caller that owns its own resend policy. (The leader and follower + // exchange loops use SameSeq, so a lost reply is replayed, not lost.) Once, }; diff --git a/cpp/src/follower_exchange.cpp b/cpp/src/follower_exchange.cpp index c8a52cf..ed55119 100644 --- a/cpp/src/follower_exchange.cpp +++ b/cpp/src/follower_exchange.cpp @@ -89,7 +89,11 @@ void FollowerExchange::run_() { std::optional ack; bool link_down = false; try { - ack = t_.send_cmd(protocol::Cmd::SlaveExchange, req, timeout_, bus::RetryMode::Once); + // SameSeq: when a reply is lost the retry is byte-identical, so the + // firmware's command layer replays its cached reply (same telemetry, + // the target not applied twice) instead of running a new exchange -- + // otherwise the IMU samples that reply carried would be lost. + ack = t_.send_cmd(protocol::Cmd::SlaveExchange, req, timeout_, bus::RetryMode::SameSeq); } catch (const TimeoutError&) { } catch (const std::exception&) { link_down = !t_.is_running(); diff --git a/cpp/src/follower_exchange.hpp b/cpp/src/follower_exchange.hpp index e866e93..3439d06 100644 --- a/cpp/src/follower_exchange.hpp +++ b/cpp/src/follower_exchange.hpp @@ -13,9 +13,11 @@ // Cadence: one telemetry exchange per period. A target submitted between // ticks goes out at once in a target-only exchange (header-only reply), so a // controller that reacts to a status sample keeps today's ~1 ms latency -// instead of waiting a whole period. A request that times out keeps its -// target for the next exchange unless a newer one superseded it; targets are -// absolute set-points, so applying one twice is harmless. +// instead of waiting a whole period. A lost reply is retried with the same seq +// (the firmware replays its cached reply, so no IMU sample is lost); a request +// still unanswered after the retries keeps its target for the next exchange +// unless a newer one superseded it; targets are absolute set-points, so +// applying one twice is harmless. // // Replies are split back into the DATA frames a stream would have carried // (GetMotorStatus, GetImu/GetImuV2) and injected into the transport's normal diff --git a/cpp/tests/fake_follower.hpp b/cpp/tests/fake_follower.hpp index c6d8810..ad4f386 100644 --- a/cpp/tests/fake_follower.hpp +++ b/cpp/tests/fake_follower.hpp @@ -116,6 +116,10 @@ class FakeFollower { // Swallow the next n exchange requests (the target is still applied), // so the host sees timeouts. void drop_exchange_replies(unsigned n) { drop_replies_.store(n); } + // Build (and cache) the next n exchange replies -- consuming their IMU + // samples -- but lose them on the wire: what a host stall looks like. + void lose_exchange_replies(unsigned n) { drop_after_build_.store(n); } + unsigned exchange_replays() const { return exchange_replays_.load(); } // Make the next exchange reply's target_result this ErrorCode byte. void reject_next_exchange_target(uint8_t err) { reject_next_.store(err); } // Non-exchange frames seen while polled (heartbeats, queries, etc.). @@ -209,7 +213,19 @@ class FakeFollower { if (polled_.load() && f.cmd != tp::Cmd::SlaveExchange) ++other_while_polled_; switch (f.cmd) { case tp::Cmd::SlaveExchange: { + // Firmware command layer: a byte-identical repeat (same seq, cmd, + // payload) replays the cached reply instead of re-running it. + if (last_exchange_ && last_exchange_->seq == f.seq && last_exchange_->payload == f.payload) { + ++exchange_replays_; + if (last_exchange_reply_) pty_.send_response(f.seq, f.cmd, *last_exchange_reply_); + return; + } + // drop_exchange_replies() models a request that never reached the + // firmware (nothing cached, a retry runs fresh); lose_exchange_replies() + // a reply lost on the wire (cached, a retry replays it). + last_exchange_reply_.reset(); handle_exchange_(f); + if (last_exchange_reply_) last_exchange_ = f; else last_exchange_.reset(); return; } case tp::Cmd::GetVersion: { @@ -494,6 +510,8 @@ class FakeFollower { } } std::memcpy(out.data(), &r, sizeof(r)); + last_exchange_reply_ = out; + if (drop_after_build_) { --drop_after_build_; return; } // reply lost on the wire pty_.send_response(f.seq, f.cmd, out); auto later = std::move(deferred_); deferred_.clear(); @@ -503,6 +521,9 @@ class FakeFollower { std::atomic exchange_supported_{true}, polled_{false}; std::atomic exchanges_{0}, telemetry_exchanges_{0}, exchange_targets_{0}; std::atomic overlaps_{0}, drop_replies_{0}, other_while_polled_{0}; + std::atomic drop_after_build_{0}, exchange_replays_{0}; + std::optional last_exchange_; + std::optional> last_exchange_reply_; std::atomic exchange_delay_ms_{0}; std::atomic reject_next_{0}; std::vector deferred_; diff --git a/cpp/tests/test_follower_exchange_pty.cpp b/cpp/tests/test_follower_exchange_pty.cpp index 0c2af9c..38c081a 100644 --- a/cpp/tests/test_follower_exchange_pty.cpp +++ b/cpp/tests/test_follower_exchange_pty.cpp @@ -99,10 +99,13 @@ TEST(FollowerExchangePty, NeverWritesWhileAReplyIsPendingEvenWithConcurrentQueri std::atomic stop{false}; std::atomic heartbeats{0}, heartbeat_timeouts{0}; + // 1 kHz of user queries, as in the leader test: an unthrottled loop starves + // the exchange on the (unfair) half-duplex turn lock, a documented limit. std::thread user([&] { while (!stop.load()) { try { g->transport().send_cmd(tp::Cmd::Heartbeat, {}, std::chrono::milliseconds(200)); ++heartbeats; } catch (...) { ++heartbeat_timeouts; } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); } }); std::thread submitter([&] { @@ -135,7 +138,9 @@ TEST(FollowerExchangePty, TimedOutTargetIsResentUnlessSuperseded) { g->start_exchange(20); ASSERT_TRUE(wait_for([&] { return fw.telemetry_exchanges() >= 1; }, kSecond)); - fw.drop_exchange_replies(1); + // Two lost requests exhaust the same-seq retry (open_follower: max_retries=1), + // so the exchange times out and the loop resends the target itself. + fw.drop_exchange_replies(2); const unsigned targets = fw.exchange_targets(); g->motor().submit_impedance(0.5f, 1.0f, 0.0f, 0.0f, 0.0f); ASSERT_TRUE(wait_for([&] { return fw.exchange_targets() >= targets + 2; }, kSecond)) @@ -148,6 +153,35 @@ TEST(FollowerExchangePty, TimedOutTargetIsResentUnlessSuperseded) { g->stop_streaming(); } +TEST(FollowerExchangePty, LostReplyIsReplayedBySameSeqRetryNotLost) { + Pty pty; + ASSERT_GE(pty.master(), 0); + FakeFollower fw(pty); + fw.set_imu_v2_supported(true); + auto g = open_follower(pty); + std::mutex m; std::vector seqs; + g->imu().on_data([&](const tx::ImuSample& s) { std::lock_guard lk(m); seqs.push_back(s.seq); }); + g->start_exchange(100, 100, 50); + ASSERT_TRUE(wait_for([&] { std::lock_guard lk(m); return seqs.size() >= 10; }, kSecond)); + + const auto acks_before = g->transport().stats().ack_timeouts; + fw.lose_exchange_replies(1); // telemetry built (IMU consumed) but lost on the wire + ASSERT_TRUE(wait_for([&] { return fw.exchange_replays() >= 1; }, kSecond)) << "no same-seq retry"; + std::size_t at; + { std::lock_guard lk(m); at = seqs.size(); } + ASSERT_TRUE(wait_for([&] { std::lock_guard lk(m); return seqs.size() >= at + 20; }, kSecond)); + EXPECT_GT(g->transport().stats().ack_timeouts, acks_before); + EXPECT_EQ(g->exchange_stats().timeouts, 0u) << "the retry recovered the reply"; + std::lock_guard lk(m); + unsigned missing = 0; + for (std::size_t i = 1; i < seqs.size(); ++i) { + const uint16_t d = static_cast(seqs[i] - seqs[i - 1]); + if (d != 1) missing += d == 0 ? 1u : d - 1u; + } + EXPECT_EQ(missing, 0u) << "IMU samples of the lost reply were lost"; + g->stop_streaming(); +} + TEST(FollowerExchangePty, RejectedTargetIsCountedAndTelemetryContinues) { Pty pty; ASSERT_GE(pty.master(), 0); From 3371b40f94bbc5a6226a5d514fa8450238c685c3 Mon Sep 17 00:00:00 2001 From: xensexyq Date: Wed, 4 Nov 2026 16:14:02 +0800 Subject: [PATCH 17/18] =?UTF-8?q?fix(ci):=20=E5=AF=B9=E7=85=A7=E9=85=8D?= =?UTF-8?q?=E5=A5=97=E5=9B=BA=E4=BB=B6=E6=A0=A1=E9=AA=8C=E4=BA=A4=E6=8D=A2?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK PR #29 使用固件 PR #23 的头文件,其余受信运行仍校验 hw_v1.1.0。补齐诊断命令 TxTrace 和四种交换报文头的尺寸检查,按锁定的 Ruff 版本格式化两处 Python 文件。 验证:全量 pre-commit、Ruff 和严格协议对照通过;旧基线与注入的报文尺寸错误均被检出。构建通过,C++ 546 项通过,Python 153 项通过、2 项跳过。 --- .github/workflows/ci.yml | 39 ++++++++++++++++-------- cpp/include/taccap/protocol/commands.hpp | 1 + cpp/src/protocol/commands.cpp | 1 + docs/FIRMWARE.md | 11 ++++--- python/bindings/module.cpp | 1 + python/tests/test_imu_time.py | 35 +++++++++++++++++---- python/xense/taccap/__init__.py | 5 ++- scripts/check_protocol_drift.py | 4 +++ 8 files changed, 72 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbe2e26..450e780 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,30 +120,43 @@ jobs: # credential. Configure a read-only deploy key or a fine-grained PAT # scoped to that one repo as the FIRMWARE_REPO_TOKEN secret. # - # The secret is deliberately optional. It is absent on pull requests from - # forks (GitHub withholds secrets there by design), so the check degrades - # to a loud SKIP instead of failing work it cannot verify. Real coverage - # comes from pushes to main and from PRs opened inside the repo. - - name: Fetch firmware reference headers + # Fork PRs do not receive the private-repo credential. On trusted runs + # a missing credential is a configuration error, not a clean mirror. + - name: Check firmware access id: firmware env: TOKEN: ${{ secrets.FIRMWARE_REPO_TOKEN }} + FORK_PR: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }} run: | if [ -z "$TOKEN" ]; then - echo "::notice::FIRMWARE_REPO_TOKEN unavailable - drift check will SKIP" + if [ "$FORK_PR" != true ]; then + echo "::error::FIRMWARE_REPO_TOKEN is required for protocol comparison" + exit 1 + fi + echo "::notice::Private firmware headers unavailable to fork PR" echo "available=false" >> "$GITHUB_OUTPUT" exit 0 fi - # Only the protocol headers are needed, so keep the clone shallow. - git clone --depth 1 --branch hw_v1.1.0 \ - "https://x-access-token:${TOKEN}@github.com/XenseRobotics-AI/tc-gu-01.git" \ - third_party/firmware/tc-gu-01 echo "available=true" >> "$GITHUB_OUTPUT" + # #29 introduces the wire protocol together with firmware PR #23. + # All other runs, including pushes after merge, track the firmware base. + - name: Fetch firmware reference headers + if: steps.firmware.outputs.available == 'true' + uses: actions/checkout@v5 + with: + repository: XenseRobotics-AI/tc-gu-01 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.number == 29 && 'refs/pull/23/head' || 'hw_v1.1.0' }} + path: third_party/firmware/tc-gu-01 + token: ${{ secrets.FIRMWARE_REPO_TOKEN }} + persist-credentials: false + sparse-checkout: | + App/protocol + App/drivers + - name: Check protocol mirror - run: | - python3 scripts/check_protocol_drift.py \ - ${{ steps.firmware.outputs.available == 'true' && '--require' || '' }} + if: steps.firmware.outputs.available == 'true' + run: python3 scripts/check_protocol_drift.py --require # Leaves nothing on disk that a later step could publish; the clone lives # in a gitignored path, but be explicit rather than rely on that. diff --git a/cpp/include/taccap/protocol/commands.hpp b/cpp/include/taccap/protocol/commands.hpp index 467eccb..e62af65 100644 --- a/cpp/include/taccap/protocol/commands.hpp +++ b/cpp/include/taccap/protocol/commands.hpp @@ -78,6 +78,7 @@ enum class Cmd : uint8_t { KeyStatus = 0x15, // V1.4 — device-side button, payload=key_status_payload_t TimeSession = 0x16, // request nonce:u64; response time_us:u64, session:u64 GetImuV2 = 0x17, // legacy 28B + capture_us:u64 + session:u64 + TxTrace = 0x18, // RAM transmit trace; diagnostic firmware builds only // Stream control + calibration (0x20–0x2F) StartStream = 0x20, diff --git a/cpp/src/protocol/commands.cpp b/cpp/src/protocol/commands.cpp index cbf273c..6cae760 100644 --- a/cpp/src/protocol/commands.cpp +++ b/cpp/src/protocol/commands.cpp @@ -38,6 +38,7 @@ const char* to_string(Cmd c) noexcept { case Cmd::GetImu: return "GetImu"; case Cmd::TimeSession: return "TimeSession"; case Cmd::GetImuV2: return "GetImuV2"; + case Cmd::TxTrace: return "TxTrace"; case Cmd::GetEncoder: return "GetEncoder"; case Cmd::GetEskin1: return "GetEskin1"; case Cmd::GetEskin2: return "GetEskin2"; diff --git a/docs/FIRMWARE.md b/docs/FIRMWARE.md index 21bffeb..192b770 100644 --- a/docs/FIRMWARE.md +++ b/docs/FIRMWARE.md @@ -79,11 +79,12 @@ What's in them: - `tc-gu-01/App/protocol/protocol_cmd.h` + `protocol_data.h` — canonical command enum + POD payload layouts. The SDK's `cpp/include/taccap/protocol/{commands.hpp,payloads.hpp}` mirror these - 1:1 with `static_assert(sizeof(...) == ...)` size checks. Currently - mirrored through command `0x5B` (protocol doc V2.6: V2.2 plus the - 0x54 / 0x55 diagnostic pair, 0x56–0x58 motor spec / homing diagnostics / - motor version, 0x59 / 0x5A motor model, 0x5B CAN extended-frame relay); - `scripts/check_protocol_drift.py` is green against firmware `07ab9bb`. + with compile-time and cross-repository size checks. SDK PR #29 compares + against firmware PR #23 (`refs/pull/23/head`), which defines the time, + IMU V2 and exchange commands. `TxTrace` (0x18) is reserved for diagnostic + firmware builds; its enum value does not imply support in ordinary images. + Other CI runs use `hw_v1.1.0`. If the firmware PR changes independently, + rerun the SDK mirror job before merging; the log records the tested revision. - `tc-gu-01/App/protocol/PROTOCOL_SPEC.md` + `tc-gu-01/docs/PROTOCOL.md` — the human-readable spec, including the §10 offset table for the 72-byte extended motor status that `test_codec_v22.cpp` is transcribed from. diff --git a/python/bindings/module.cpp b/python/bindings/module.cpp index 803a13d..cde7453 100644 --- a/python/bindings/module.cpp +++ b/python/bindings/module.cpp @@ -105,6 +105,7 @@ PYBIND11_MODULE(_taccap_native, m) { .value("GetImu", tp::Cmd::GetImu) .value("TimeSession", tp::Cmd::TimeSession) .value("GetImuV2", tp::Cmd::GetImuV2) + .value("TxTrace", tp::Cmd::TxTrace) .value("GetEncoder", tp::Cmd::GetEncoder) .value("GetEskin1", tp::Cmd::GetEskin1) .value("GetEskin2", tp::Cmd::GetEskin2) diff --git a/python/tests/test_imu_time.py b/python/tests/test_imu_time.py index d5caba5..2a0b36f 100644 --- a/python/tests/test_imu_time.py +++ b/python/tests/test_imu_time.py @@ -1,4 +1,5 @@ """Native V2 timestamp width and boot identity survive the Python boundary.""" + import os import pty import select @@ -18,7 +19,7 @@ def device(): parser = t.FrameParser() try: while not stopped.is_set(): - if not select.select([master], [], [], .02)[0]: + if not select.select([master], [], [], 0.02)[0]: continue parser.feed(os.read(master, 4096)) while (frame := parser.pop()) is not None: @@ -31,11 +32,33 @@ def device(): assert len(frame.payload) == 8 payload = struct.pack(" Date: Wed, 4 Nov 2026 19:33:54 +0800 Subject: [PATCH 18/18] chore(firmware): bundle leader 1.2.7 / follower 1.2.17 (protocol V2.9) Replace the bundled images with leader 1.2.7 and follower 1.2.17 built from tc-gu-01 8c059e9 (tc-gu-01#24 on top of #23) with arm-none-eabi-gcc 10.3.1; manifest size/CRC32 verified against the files. Leader image is the one verified on the taccap-collector backpack. Docs, CHANGELOG and OTA example names updated; host-breaking timestamp timebase change called out (this SDK already unwraps it). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 ++++++++++++ README.md | 2 +- docs/FIRMWARE.md | 8 ++++---- firmware/README.md | 11 +++++++--- firmware/manifest.json | 32 ++++++++++++++--------------- firmware/tc-gu-01-master-1.2.6.bin | Bin 118300 -> 0 bytes firmware/tc-gu-01-master-1.2.7.bin | Bin 0 -> 122848 bytes firmware/tc-gu-01-slave-1.2.16.bin | Bin 167544 -> 0 bytes firmware/tc-gu-01-slave-1.2.17.bin | Bin 0 -> 180100 bytes python/examples/ota_update.py | 10 ++++----- 10 files changed, 47 insertions(+), 29 deletions(-) delete mode 100755 firmware/tc-gu-01-master-1.2.6.bin create mode 100755 firmware/tc-gu-01-master-1.2.7.bin delete mode 100755 firmware/tc-gu-01-slave-1.2.16.bin create mode 100755 firmware/tc-gu-01-slave-1.2.17.bin diff --git a/CHANGELOG.md b/CHANGELOG.md index 665d691..f45bb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Bundled firmware is now leader 1.2.7 / follower 1.2.17 (protocol V2.9).** + Both add time-shared exchange (`0x5E` leader, `0x5D` follower) so host and + gripper never transmit at the same time; an uplink frame that overlaps a host + byte loses the bytes in flight. **Host-breaking:** IMU/encoder `timestamp` moves + from DWT/250 (wraps every 17.18 s) to TIM2 1 MHz (wraps every 71 min 35 s); this + SDK already unwraps the new timebase, older host software does not. Images are + built with arm-none-eabi-gcc 10.3.1 from tc-gu-01 `8c059e9`; size, CRC32 and + notes are in `firmware/manifest.json`. Leader verified on the collector backpack + (0 bad uplink frames under load vs 17/7208 on 1.2.6 push). As before, replug the + leader's USB / power-cycle the follower's 24 V after flashing. + ### Fixed - **A lost follower exchange reply no longer loses IMU samples.** The follower diff --git a/README.md b/README.md index c530d95..ef8df66 100644 --- a/README.md +++ b/README.md @@ -364,7 +364,7 @@ motor's own firmware, checked against the required minimum. It is read-only: ```bash python python/examples/follower_status.py left -# [fw] FirmwareVersion(1.2.16.0) +# [fw] FirmwareVersion(1.2.17.0) # [motor] MotorModel(RS00, id=1, from flash, t_max=14.0Nm, v_max=33.0rad/s) # [motor fw] 0.0.3.32 (...) OK ``` diff --git a/docs/FIRMWARE.md b/docs/FIRMWARE.md index 192b770..c7179c6 100644 --- a/docs/FIRMWARE.md +++ b/docs/FIRMWARE.md @@ -126,11 +126,11 @@ inactive bank and uses the STM32H5 bank swap, so one build serves both banks. ```bash python python/examples/ota_update.py \ third_party/firmware/tc-gu-01/build/master/tc-gu-01-master.bin \ - left --target-version 1.2.6 + left --target-version 1.2.7 ``` **The two roles have independent version numbers** — at the time of writing the -leader is 1.2.6 and the follower 1.2.16, and neither is "behind" the other. Both +leader is 1.2.7 and the follower 1.2.17, and neither is "behind" the other. Both carry the same control-UART receive fix, which lives in shared code. The roles were briefly forced onto one number; that was dropped on 2026-09-24. @@ -142,13 +142,13 @@ them apart. If a leader reports 1.2.6, check `g.diagnostics.uart_stats()`: the fixed image answers with 44 bytes, so `rx_errors` / `rx_rearms` exist and are meaningful; the old one answers 32 bytes and the SDK reads both as 0 -- which is indistinguishable from a healthy zero. When in doubt, reflash -`tc-gu-01-master-1.2.6.bin` from `firmware/`; OTA compares no versions +the current leader image from `firmware/`; OTA compares no versions (`--target-version` is informational), so reflashing the same number is fine. Compare versions only within one role. Note the build output keeps the Makefile's unversioned name (`build/master/tc-gu-01-master.bin`), while the images released under -`firmware/` carry the version (`tc-gu-01-master-1.2.6.bin`). That is deliberate: +`firmware/` carry the version (`tc-gu-01-master-1.2.7.bin`). That is deliberate: a build artifact is whatever you just compiled, a release is a specific version someone may still be holding a copy of months later. **If you promote a local build into `firmware/`, rename it and update `firmware/manifest.json` in the diff --git a/firmware/README.md b/firmware/README.md index fe7dce4..9a896d1 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -5,8 +5,8 @@ part of this SDK. | Image | Role | Version | Protocol | Size | CRC32 | | --- | --- | --- | --- | --- | --- | -| `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` | +| `tc-gu-01-master-1.2.7.bin` | leader (SN ends **`m`**) | **1.2.7** | V2.9 | 122,848 B | `0x19e881dd` | +| `tc-gu-01-slave-1.2.17.bin` | follower (SN ends **`s`**) | **1.2.17** | V2.9 | 180,100 B | `0xf019df36` | The follower's RobStride motor runs its own firmware; its images are listed under `motors` in the manifest (see [Motor firmware](#motor-firmware)): @@ -23,6 +23,11 @@ Leader and follower firmware are **versioned independently**; different numbers on the two halves of a pair are normal. The SDK refuses to drive a follower below **1.2.5**. +> **1.2.7 / 1.2.17 change the IMU/encoder `timestamp` timebase** (DWT/250, wrapping every +> 17.18 s → TIM2 1 MHz, wrapping every 71 min 35 s). This SDK handles both; older host +> software that unwraps with the 17.18 s period will produce wrong times. Upgrade host +> software together with the firmware. Protocol: tc-gu-01 `docs/PROTOCOL.md` V2.9. + ## Flashing ```bash @@ -53,7 +58,7 @@ To verify an image file before flashing: ```bash python -c "from xense.taccap import crc32_iso_hdlc -print(hex(crc32_iso_hdlc(open('firmware/tc-gu-01-master-1.2.6.bin','rb').read())))" +print(hex(crc32_iso_hdlc(open('firmware/tc-gu-01-master-1.2.7.bin','rb').read())))" # -> 0x350393b5 ``` diff --git a/firmware/manifest.json b/firmware/manifest.json index cff837c..988e58b 100644 --- a/firmware/manifest.json +++ b/firmware/manifest.json @@ -1,28 +1,28 @@ { "images": { "master": { - "file": "tc-gu-01-master-1.2.6.bin", - "version": "1.2.6", - "source_commit": "1336ebb", - "size_bytes": 118300, - "crc32": "0x350393b5", + "file": "tc-gu-01-master-1.2.7.bin", + "version": "1.2.7", + "source_commit": "8c059e9", + "size_bytes": 122848, + "crc32": "0x19e881dd", "sn_suffix": "m", - "protocol": "V2.6", + "protocol": "V2.9", "build": "local", - "toolchain": "arm-none-eabi-gcc (15:13.2.rel1-2) 13.2.1 20231009", - "note": "Leader line. Same fix as follower 1.2.11 in the shared receive path: the control UART (3 Mbaud, one interrupt per byte) stopped receiving for good after a single overrun, because the error callback never restarted reception -- the stream kept flowing while every command went unanswered. Reception is now restarted, and GET_UART_STATS reports rx_errors / rx_rearms. Verified on 0115m and 0116m (upgraded from 1.2.2): 15 min each of a 100 Hz encoder+IMU stream with a 100 Hz acknowledged command, ~87,000 commands, zero failures. AFTER FLASHING, unplug and replug the leader's USB cable." + "toolchain": "arm-none-eabi-gcc (15:10.3-2021.07-4) 10.3.1 20210621", + "note": "Leader time-shared exchange (STREAM_MODE_POLLED + LEADER_EXCHANGE 0x5E): the leader queues uplink records and only answers, so host and leader never transmit at the same time -- an uplink frame being sent while a host byte arrives loses the bytes in flight. Verified on the taccap-collector backpack (0029m/0030m): 0 bad uplink frames under full host load with ~20 Hz LED commands vs 17/7208 pushing on 1.2.6. HOST-BREAKING: IMU/encoder timestamp is now TIM2 1 MHz (low 32 bits, wraps every 71 min 35 s), no longer DWT/250 (17.18 s); upgrade host software together. Known: after a power-up the leader can stop answering ~0.5 s after the first stream start until the port is reopened (also on 1.2.6). AFTER FLASHING, unplug and replug the leader's USB cable." }, "slave": { - "file": "tc-gu-01-slave-1.2.16.bin", - "version": "1.2.16", - "source_commit": "a7dc07f", - "size_bytes": 167544, - "crc32": "0x4a8cd360", + "file": "tc-gu-01-slave-1.2.17.bin", + "version": "1.2.17", + "source_commit": "8c059e9", + "size_bytes": 180100, + "crc32": "0xf019df36", "sn_suffix": "s", - "protocol": "V2.8 + motion envelope", + "protocol": "V2.9 + 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." + "toolchain": "arm-none-eabi-gcc (15:10.3-2021.07-4) 10.3.1 20210621", + "note": "Follower time-shared exchange (SLAVE_EXCHANGE 0x5D, queue 32), optional 1-100 Hz IMU with accel/gyro calibration and magnetometer sessions, uplink collision detect-and-resend, cold-start homing order fix. HOST-BREAKING: IMU timestamp is now TIM2 1 MHz (wraps every 71 min 35 s). Carries 1.2.16. AFTER FLASHING, unplug the 24 V power cable for ~2 s and plug it back; the USB cable can stay in." } }, "motors": { diff --git a/firmware/tc-gu-01-master-1.2.6.bin b/firmware/tc-gu-01-master-1.2.6.bin deleted file mode 100755 index 73d812f653a9b7b55c18f0571c8a8ad884f0d53f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 118300 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 diff --git a/firmware/tc-gu-01-master-1.2.7.bin b/firmware/tc-gu-01-master-1.2.7.bin new file mode 100755 index 0000000000000000000000000000000000000000..9e3cf536f3297e2da6a3778592303a63a506314b GIT binary patch literal 122848 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ðVAf}{2GX7*Y(o&4&4EhxOtg!d)tKUf3iZGdHv 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

a)k2JmwyOBAZ+7O*XR#<BF<(XNIz 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 diff --git a/firmware/tc-gu-01-slave-1.2.17.bin b/firmware/tc-gu-01-slave-1.2.17.bin new file mode 100755 index 0000000000000000000000000000000000000000..a7e732d651c8f9fb94ddeec2f1e6e462d3d2ad07 GIT binary patch literal 180100 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

wojZ;|2VJeQv49A16zA6q!(M}adqFUAnl=UsV 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

l>sChIpdf$t@x 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 str: def _default_firmware_for_role(role: str) -> str: """The shipped image for a role, named by firmware/manifest.json. - The filename carries the version (tc-gu-01-slave-1.2.16.bin), so it cannot be + The filename carries the version (tc-gu-01-slave-1.2.17.bin), so it cannot be derived from the role alone — the manifest is the single place the current release's filename is written, and `file` there is what this reads. That also means the manifest is load-bearing for every role-selector and --all @@ -363,7 +363,7 @@ def _resolve_firmware(path: str) -> Optional[str]: """Find the image whether `path` is relative to the cwd or to this repo. The images ship inside this repo, but the repo is usually vendored as a - submodule of something else — so `firmware/tc-gu-01-master-1.2.6.bin`, the path + submodule of something else — so `firmware/tc-gu-01-master-1.2.7.bin`, the path our docs print because it works from the SDK root, is not the path that works from the parent repo's root. Rather than making every downstream README carry its own prefix, accept both: the literal path first, then the @@ -414,7 +414,7 @@ def _identify_image(fw_bytes: bytes): """Look up the CRC32 in firmware/manifest.json; return (role, meta) or (None, None). **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 + the version (tc-gu-01-slave-1.2.17.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.