Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
26f28a3
feat(imu): 为从爪增加显式 IMU 流选项和校准接口
xensexyq Oct 22, 2026
8e74998
feat(imu): 协商完整时间戳并识别启动会话和旧协议回绕
xensexyq Oct 22, 2026
c06bc34
fix(imu): 锁存旧协议时间异常并过滤重复样本
xensexyq Oct 23, 2026
504134a
test(imu): 补充从爪共流并发验证与硬件验收方案
xensexyq Oct 26, 2026
5078eee
test(imu): 用硬件抓包验证解析恢复并修正丢帧归因
xensexyq Oct 26, 2026
da015ed
docs(imu): 根据硬件抓包修正链路完整性诊断说明
xensexyq Oct 26, 2026
2112776
fix(imu): 隔离电机反馈回调并保留真实接收时间
xensexyq Oct 26, 2026
0dfa4bc
fix(transport): 保护 ACK 等待序号并增加控制目标送达观测
xensexyq Oct 28, 2026
bfabaf7
feat(follower): 新增从爪分时交换模式 start_exchange
xensexyq Oct 30, 2026
4d094e1
fix(transport): 半双工等待线路轮次计入请求超时
xensexyq Oct 30, 2026
4b3d5c2
feat(leader): 新增主爪分时交换模式 start_exchange
xensexyq Oct 30, 2026
0888ef9
feat(leader): 主爪交换在固件租约回退后自动重新进入并补充时间语义说明
xensexyq Nov 2, 2026
7be1996
fix(python): 设备扫描与查找期间释放 GIL,避免冻结同进程其他线程
xensexyq Nov 2, 2026
e147e30
merge: 合并 main 0.4.4 到 codex/gripper-imu-support
xensexyq Nov 2, 2026
f9962ce
merge: 合并 main 示例修正(视触觉标记流叠加)到 codex/gripper-imu-support
xensexyq Nov 2, 2026
91c4c6f
merge: 合并 main(0.4.4 CHANGELOG 补充标记流修正说明)到 codex/gripper-imu-support
xensexyq Nov 3, 2026
8d94c38
feat(leader): 普通上报可选旧版 IMU 帧,交换默认频率封顶 200 Hz
xensexyq Nov 3, 2026
74b9a8e
fix(sync): 交换线程、半双工轮次锁与控制器看门狗改用单调时钟等待
xensexyq Nov 3, 2026
682eda6
fix(follower): 交换应答丢失时以同一 seq 重试,避免丢 IMU 样本
xensexyq Nov 3, 2026
3371b40
fix(ci): 对照配套固件校验交换协议并修复格式检查
xensexyq Nov 4, 2026
69a0f5e
chore(firmware): bundle leader 1.2.7 / follower 1.2.17 (protocol V2.9)
Vertax42 Nov 4, 2026
f22e5c8
Merge pull request #30 from XenseRobotics-AI/release/fw-1.2.7-1.2.17
Vertax42 Nov 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
99 changes: 99 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,105 @@ 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
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
`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).
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
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

- **`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
`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.

- **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`).
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.

## [0.4.4] - 2026-11-01

_The `v0.4.4` tag was moved on 2026-11-02 to include the tactile marker-flow
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
2 changes: 2 additions & 0 deletions cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ add_library(taccap_core SHARED
src/discovery.cpp
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
Expand Down
59 changes: 52 additions & 7 deletions cpp/include/taccap/bus/frame.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
#include <taccap/protocol/commands.hpp>

#include <cstdint>
#include <chrono>
#include <array>
#include <atomic>
#include <mutex>
#include <cstddef>
#include <deque>
#include <vector>
Expand Down Expand Up @@ -58,6 +62,9 @@ struct Frame {
protocol::FrameType type;
protocol::Cmd cmd;
std::vector<uint8_t> 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<uint8_t, 1024> bytes{};
};
struct TraceSnapshot {
std::vector<TraceRecord> 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<uint8_t> rx_;
std::deque<Frame> ready_;
std::size_t max_buffered_;
Stats stats_{};
uint64_t rx_offset_ = 0;
std::atomic<bool> trace_enabled_{false};
std::mutex trace_mu_;
std::vector<TraceRecord> trace_ring_;
std::size_t trace_next_ = 0, trace_count_ = 0;
uint64_t trace_overwritten_ = 0;
};

} // namespace xense::taccap::bus
Loading
Loading