diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index b84b695..8b3f779 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -26,7 +26,7 @@ name: Release packages expected_version: description: 'Hand SDK version (transport may use a compatible newer patch)' required: true - default: '0.3.9' + default: '0.3.10' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index fab9749..f9f7fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,16 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.10 + +- Correct Python-policy missing-command-feedback health without changing physical dispatch outcomes or Native defaults. +- Include the polling, logical latency and force-field health updates from the unpublished v0.3.9 tag. +- Keep transport v0.3.4 minimum, rebuild requirements and separate hardware acceptance. + ## 0.3.9 +Tagged but not published; superseded for publication by 0.3.10. + - Align explicit Python-policy polling counters and logical transaction latency with the Python reference, including nearest-even percentiles and a bounded device-lifetime window. - Add force-field health projection and shared-query interval resolution; preserve Native defaults and the transport v0.3.4 minimum. - Rebuild SDK and consumers. No new hardware acceptance is claimed; see [release notes](RELEASE_NOTES.md). diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index fa274d4..20af11d 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,8 +2,16 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.10 + +- 修正 Python 策略缺少命令反馈时的健康统计,保持实际发送结果及 Native 默认行为。 +- 包含未公开发行的 v0.3.9 标签中的轮询、逻辑延迟和力字段健康投影修正。 +- 保持 transport v0.3.4 最低依赖,SDK 和消费者需要重新编译,实机验收单独进行。 + ## 0.3.9 +已创建标签,但未公开发行;公开发行由 0.3.10 接替。 + - 统一显式 Python 策略的轮询计数和逻辑事务延迟,使用与 Python 一致的百分位取整及设备生命周期内的有界窗口。 - 补齐力字段健康投影和共享查询间隔合并,保留 Native 默认行为及 transport v0.3.4 最低依赖。 - SDK 和消费者需要重新编译,不新增实机验收结论,详见[发行说明](RELEASE_NOTES.md)。 diff --git a/CMakeLists.txt b/CMakeLists.txt index a176e95..55f30c4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(linkerbot-hand-cpp - VERSION 0.3.9 + VERSION 0.3.10 DESCRIPTION "LinkerBot dexterous hand C++ SDK" LANGUAGES CXX ) diff --git a/README.en.md b/README.en.md index 68b8354..aed1214 100644 --- a/README.en.md +++ b/README.en.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/README.md b/README.md index 68b8354..aed1214 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ This installs the SDK and communication library into the local `install/` direct Catch2 and vendor development packages are not required. Run the remaining commands from this repository's root directory. -Hand SDK 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line, for source builds, prebuilt packages and installed consumers. To reuse a prebuilt transport v0.3.4 SDK, configure with diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 597d5e5..34267b5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,7 +1,22 @@ # Release notes +## 0.3.10 + +- Treat missing verified angle-command feedback as a logical timeout under the Python policy. Preserve the command's physical `Dispatched` outcome, exclude it from successful transaction latency and retain its error count until a complete logical success. Native optional-feedback accounting is unchanged. +- Project Python-policy polling counters independently of native transaction totals. Inactive plans report zero polling counters; active reads, verified angle command feedback and disabled optional queries retain their distinct accounting. +- Measure complete logical transactions from their first transport admission through successful completion, including transport waiting and multi-stage execution. Partial stage success does not add a completed logical transaction or clear a prior logical error. Keep the latest 2048 successful durations across close, reopen and recovery, with Python nearest-even percentile selection. Native latency accounting remains unchanged. +- Add Python-policy force-field health rows with requested, shared-query and achieved rates, query identity and observed support. Optional `PollingChannel::force_fields` entries select named fields at millisecond intervals. Shared queries use the fastest requested interval; field rows must not be summed as physical traffic. L20Lite scalar categories remain independent. Native configurations reject the new selector. + +Existing C++ method signatures and Native defaults remain available. Public structure additions require rebuilding the SDK and downstream consumers with matching headers and libraries; binary compatibility with earlier headers is not claimed. The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). + +Validation covers offline lifecycle, polling, command feedback, multi-stage failure, field-query resolution, bounded latency windows, source/prebuilt/installed consumers, sanitizers, complete static analysis and the twelve-target release matrix. No new physical-device acceptance is claimed. Deployed adapters/drivers, firmware, physical timestamps, mixed CAN/FD topology and multi-device duration tests require separate hardware validation. O20, Python bindings and hand/arm integration are outside this patch. Tactile-stage angle scheduling, extended identity parity and optional automatic reconnect remain separate work. + +v0.3.9 was tagged but not published. Post-merge verification found that missing command feedback could add a successful latency sample; this patch includes that correction. The v0.3.9 tag remains unchanged. + ## 0.3.9 +Tagged but not published; superseded for publication by 0.3.10. + - Project Python-policy polling counters independently of native transaction totals. Inactive plans report zero polling counters; active reads, verified angle command feedback and disabled optional queries retain their distinct accounting. - Measure complete logical transactions from their first transport admission through successful completion, including transport waiting and multi-stage execution. Partial stage success does not add a completed logical transaction or clear a prior logical error. Keep the latest 2048 successful durations across close, reopen and recovery, with Python nearest-even percentile selection. Native latency accounting remains unchanged. - Add Python-policy force-field health rows with requested, shared-query and achieved rates, query identity and observed support. Optional `PollingChannel::force_fields` entries select named fields at millisecond intervals. Shared queries use the fastest requested interval; field rows must not be summed as physical traffic. L20Lite scalar categories remain independent. Native configurations reject the new selector. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index ab0dee4..cbe53ac 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -148,7 +148,7 @@ Public Python-policy health generations, including channel ages and recent-error This batch is offline preparation. It does not add O20, Python bindings, hand/arm integration or hardware acceptance. Tactile-stage angle scheduling, extended/raw identity fields, O30 field-by-field identity queries and optional automatic reconnect remain separate work. -## Fifth compatibility batch (0.3.9) +## Fifth compatibility batch (0.3.10) Only `RuntimeBehavior::kPython` uses the following health rules. Native counters, latency calculations and defaults are retained. @@ -161,3 +161,5 @@ Global transaction latency records one successful logical operation, including a `PollingChannel::force_fields` is an optional field-level selector on `kForce`. For example, `{{"fn", 20ms}, {"fns", 50ms}}` requests 50 Hz and 20 Hz respectively; if both share one wire query, that query runs at 50 Hz. An empty selector preserves the existing channel form and resolves Python's default force fields `fn` and `fns`. Intervals are positive representable whole milliseconds. Invalid or duplicate field names and unsupported explicit fields are rejected before replacing the active plan. Native policy rejects a nonempty field selector. Public methods remain available, but consumers must rebuild for the appended public structure fields. Offline tests cover counter boundaries, logical success/failure, shared-device isolation, force projection and an independent Python percentile comparison over 6144 rolling-window updates. These tests do not establish hardware timing or throughput. O20, Python bindings, hand/arm integration, tactile-stage angle scheduling and remaining identity/reconnect differences remain outside this batch. + +Missing verified angle-command feedback preserves physical `Dispatched` status while recording one logical response timeout. It does not add successful transaction latency or advance the last successful transaction timestamp. Repeated missing feedback increments consecutive errors; a subsequent complete logical success clears that counter. This applies with or without an active polling plan. Native optional-feedback behavior is unchanged. diff --git a/docs/en/guides/releases.md b/docs/en/guides/releases.md index 774143f..9faedf2 100644 --- a/docs/en/guides/releases.md +++ b/docs/en/guides/releases.md @@ -16,7 +16,7 @@ availability of proprietary ARM64 drivers. Hand SDK packages contain both hand and paired transport static libraries, headers, CMake packages, documentation and read-only command-line examples. Test executables and proprietary drivers/libraries -are excluded. Versions remain independent. Hand 0.3.9 requires transport 0.3.4 or newer in +are excluded. Versions remain independent. Hand 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line in source, prebuilt and installed-consumer builds. diff --git a/docs/en/guides/validation.md b/docs/en/guides/validation.md index 3c74afa..dbb73bd 100644 --- a/docs/en/guides/validation.md +++ b/docs/en/guides/validation.md @@ -14,7 +14,7 @@ A parent-provided `linkerbot::comm` target is reused and version-checked. The legacy empty `LINKERBOT_COMM_SOURCE_DIR=` still selects package discovery through `CMAKE_PREFIX_PATH` in AUTO mode. Configure logs identify the selected dependency. Use separate build directories when switching dependencies to avoid stale CMake caches. -Hand 0.3.9 requires transport 0.3.4 or newer in the 0.3 minor line. Source, +Hand 0.3.10 requires transport 0.3.4 or newer in the 0.3 minor line. Source, prebuilt and installed-consumer checks reject older patches and other minor lines. For paired development, keep the source commit pinned in README and CI; record the actual revision and configuration in test reports. diff --git a/docs/zh/guides/python-behavior-baseline.md b/docs/zh/guides/python-behavior-baseline.md index b1ed727..37f7a38 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -119,7 +119,7 @@ Python 策略公开健康状态、通道采样年龄和最近错误上下文的 本批为离线准备,不增加 O20、Python 绑定、hand/arm 接入或实机验收。触觉阶段之间的角度调度、扩展及原始身份字段、O30 逐字段身份查询和可选自动重连仍为独立工作。 -## 第五批兼容修正(0.3.9) +## 第五批兼容修正(0.3.10) 以下规则仅用于显式 `RuntimeBehavior::kPython`,Native 计数、延迟算法和默认行为保持不变。 @@ -132,3 +132,5 @@ Python 策略公开健康状态、通道采样年龄和最近错误上下文的 `PollingChannel::force_fields` 是 `kForce` 上的可选字段选择器。例如 `{{"fn", 20ms}, {"fns", 50ms}}` 分别请求 50 Hz 和 20 Hz;若共用查询,实际查询为 50 Hz。空选择器保留原有通道用法,解析为 Python 默认字段 `fn`、`fns`。间隔为可表示的正整数毫秒。非法名称、重复字段和显式不支持的字段在替换计划前拒绝。Native 策略拒绝非空字段选择器。公开方法保持兼容;公开结构追加字段后,SDK 和消费者需要重新编译。 离线回归覆盖计数边界、逻辑事务成功与失败、共享 Session 的设备隔离、力字段投影,以及 6144 次窗口更新的独立 Python 百分位对照。这些验证不代表硬件时序或吞吐验收。本批不包括 O20、Python 绑定、hand/arm 接入、触觉阶段角度调度以及剩余身份字段和重连差异。 + +缺少已声明的角度命令反馈时,保留实际发送的 `Dispatched` 状态,同时记录一次逻辑响应超时;不增加成功事务延迟,也不推进最近成功事务时间。连续缺失反馈增加连续错误数,后续完整逻辑成功清零该计数。是否启用轮询不影响这一规则。Native 可选反馈行为保持不变。 diff --git a/docs/zh/guides/releases.md b/docs/zh/guides/releases.md index ae97621..c1563e7 100644 --- a/docs/zh/guides/releases.md +++ b/docs/zh/guides/releases.md @@ -15,7 +15,7 @@ libstdc++ ABI1 和 C++17。Windows 使用原生架构 runner 与 v143 14.44 工 产物是开发 SDK,Hand 包包含 hand 与配套 transport 静态库、头文件、CMake 包和文档, 保留只读命令行示例;不包含测试程序、厂商驱动或厂商库。 -两个项目版本独立。hand 0.3.9 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 +两个项目版本独立。hand 0.3.10 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 配套源码固定提交和预构建下载统一使用 v0.3.4 发行版。transport 公共结构有新增字段, diff --git a/docs/zh/guides/validation.md b/docs/zh/guides/validation.md index c7b1dca..1a4759d 100644 --- a/docs/zh/guides/validation.md +++ b/docs/zh/guides/validation.md @@ -13,7 +13,7 @@ 父项目已经提供 `linkerbot::comm` 时,复用父项目的目标,并检查版本。 显式设置旧参数 `LINKERBOT_COMM_SOURCE_DIR=` 仍可在 AUTO 下通过 `CMAKE_PREFIX_PATH` 使用安装包。配置日志会显示实际选择的依赖;切换依赖时使用独立构建目录,避免旧 CMake 缓存。 -hand 0.3.9 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 +hand 0.3.10 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 源码开发继续使用 README 与 CI 固定的提交,并在测试报告中记录实际版本和构建配置。 ### 从源码构建 diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index 735b651..31aa0cc 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -1057,6 +1057,20 @@ namespace linkerbot::hand::runtime { ::linkerbot::hand::detail::checked_value( state->plan.optional_response_window, "missing runtime state: state->plan.optional_response_window")) { + if (state->timing && canonical_channel(state->plan.domain) == "angles") { + // Only angle feedback is a declared Python command response. + // Other native optional observations do not gate logical success. + state->timing->failed.store(true, std::memory_order_release); + (void)record_error_locked( + std::make_exception_ptr(ResponseTimeoutError( + "command feedback window expired", state->context, + state->plan.optional_response_window.value_or(std::chrono::microseconds{0}), + state->plan.late_guard)), + state->context, ErrorCode::kTimeout); + if (state->error_capture) { + state->error_capture->store(error_revision, std::memory_order_release); + } + } if (publication_handler && state->scoped_response_policy) { publication_handler(state->plan.domain, Sample{}); } diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 45d8c96..8cde25d 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4242,3 +4242,45 @@ TEST_CASE("Python O30 force fields project one completed shared polling query", device.stop_polling(); device.close(); } + +TEST_CASE("Python L20 non-angle writes do not require native optional observations", "[comm][python-phase5]") { + using namespace std::chrono_literals; + for (const auto semantic : {CommandSemantic::kSpeeds, CommandSemantic::kCurrentLimits}) { + CAPTURE(semantic); + auto config = config_for(Model::kL20); + config.runtime_behavior = RuntimeBehavior::kPython; + config.identity_timeout = 300ms; + config.response_timeout = 20ms; + FakeTransport *transport = nullptr; + std::shared_ptr hop; + HandDevice device(config, [&] { + auto next = std::make_unique(); + transport = next.get(); + install_fake_device(Model::kL20, transport, hop); + return next; + }); + device.open(); + const auto before = device.get_health().transaction_latency.count; + transport->on_request(0x28, [](const Message &request) { + return request.dlc > 1 ? std::vector{} : classic_angle_reply(Model::kL20, request); + }); + const std::vector values(device.get_capabilities().joints.size(), 10); + auto command = + semantic == CommandSemantic::kSpeeds ? device.set_speeds(values) : device.set_current_limits(values); + completed(command); + // A read with the same wire key drains the optional observation before + // succeeding. Its success must not conceal a spurious recent timeout. + if (semantic == CommandSemantic::kSpeeds) { + REQUIRE(device.get_speeds(1s).meta.valid); + } else { + REQUIRE(device.get_currents(1s).meta.valid); + } + const auto health = device.get_health(); + REQUIRE(health.transaction_latency.count == before + 2); + REQUIRE(health.consecutive_errors == 0); + REQUIRE_FALSE(health.recent_error); + REQUIRE(command.stage() == CommandStage::kDispatched); + REQUIRE_FALSE(command.error()); + device.close(); + } +} diff --git a/tests/hand/device/test_python_health_statistics.cpp b/tests/hand/device/test_python_health_statistics.cpp index dd46b0a..ce3dbf9 100644 --- a/tests/hand/device/test_python_health_statistics.cpp +++ b/tests/hand/device/test_python_health_statistics.cpp @@ -419,3 +419,93 @@ TEST_CASE("Python invalid field plan preserves active feedback plan without send REQUIRE(channel(device.hand.get_health(), "angles").feedback_policy == AngleFeedbackPolicy::kCommandFeedbackOnly); device.hand.stop_polling(); } + +namespace { + template bool await_health(HandDevice &device, Predicate predicate) { + const auto deadline = runtime::Clock::now() + 2s; + do { + if (predicate(device.get_health())) { + return true; + } + std::this_thread::sleep_for(1ms); + } while (runtime::Clock::now() < deadline); + return false; + } + auto drop_angle_feedback(const linkerbot::can::Message &request) { + return request.data[0] == 1 && request.dlc > 1 ? std::vector{} + : validation::l6_reply(request); + } +} + +TEST_CASE("Python missing command feedback retains dispatch without successful latency", "[comm][python-phase5]") { + auto c = config(); + c.response_timeout = 20ms; + validation::FakeHand device(c); + device.hand.start_polling({{S::kAngles, 1s, AngleFeedbackPolicy::kCommandFeedbackOnly}}); + const auto before = device.hand.get_health(); + device.transport->on_request(0x28, drop_angle_feedback); + for (uint64_t attempt = 1; attempt <= 2; ++attempt) { + auto command = device.hand.set_angles(std::vector(6, 10)); + validation::complete(command); + REQUIRE(await_health(device.hand, [&](const auto &h) { + return channel(h, "angles").timeouts == attempt && h.consecutive_errors == attempt; + })); + const auto health = device.hand.get_health(); + REQUIRE(health.transaction_latency.count == before.transaction_latency.count); + REQUIRE(health.last_successful_transaction == before.last_successful_transaction); + REQUIRE(test_support::require_value(health.recent_error).code == ErrorCode::kTimeout); + REQUIRE(channel(health, "angles").command_feedback_samples == 0); + REQUIRE(channel(health, "angles").feedback_samples == 0); + REQUIRE(command.stage() == CommandStage::kDispatched); + REQUIRE_FALSE(command.error()); + REQUIRE(command.send_results().size() == 1); + } + device.transport->on_request(0x28, validation::l6_reply); + auto successful = device.hand.set_angles(std::vector(6, 11)); + validation::complete(successful); + REQUIRE(await_health(device.hand, [&](const auto &h) { + return h.transaction_latency.count == before.transaction_latency.count + 1; + })); + const auto recovered = device.hand.get_health(); + REQUIRE(recovered.consecutive_errors == 0); + REQUIRE(channel(recovered, "angles").command_feedback_samples == 1); + REQUIRE(successful.stage() == CommandStage::kDispatched); + device.hand.stop_polling(); +} + +TEST_CASE("Python missing command feedback records a logical error without polling", "[comm][python-phase5]") { + auto c = config(); + c.response_timeout = 20ms; + validation::FakeHand device(c); + const auto before = device.hand.get_health(); + const auto sample = test_support::require_value(device.hand.get_cached_snapshot().angles); + device.transport->on_request(0x28, drop_angle_feedback); + auto command = device.hand.set_angles(std::vector(6, 10)); + validation::complete(command); + REQUIRE(await_health(device.hand, [](const auto &h) { return h.consecutive_errors == 1; })); + const auto health = device.hand.get_health(); + REQUIRE_FALSE(health.polling_active); + REQUIRE(channel(health, "angles").timeouts == 0); + REQUIRE(health.transaction_latency.count == before.transaction_latency.count); + REQUIRE(health.last_successful_transaction == before.last_successful_transaction); + REQUIRE(test_support::require_value(device.hand.get_cached_snapshot().angles).meta.sequence == + sample.meta.sequence); + REQUIRE(command.stage() == CommandStage::kDispatched); + REQUIRE_FALSE(command.error()); +} + +TEST_CASE("Native missing optional feedback retains its existing success accounting", "[comm][python-phase5]") { + validation::FakeHand device; + const auto before = device.hand.get_health(); + device.transport->on_request(0x28, drop_angle_feedback); + auto command = device.hand.set_angles(std::vector(6, 10)); + validation::complete(command); + REQUIRE(await_health(device.hand, [&](const auto &h) { + return h.transaction_latency.count == before.transaction_latency.count + 1; + })); + const auto health = device.hand.get_health(); + REQUIRE(health.consecutive_errors == before.consecutive_errors); + REQUIRE_FALSE(health.recent_error); + REQUIRE(command.stage() == CommandStage::kDispatched); + REQUIRE_FALSE(command.error()); +}