From bf9075cd7e9293fae20c0245a0277719bcae8dd8 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 8 Nov 2026 10:08:54 +0800 Subject: [PATCH 1/5] Add Python-policy publication and feedback state --- .github/workflows/release-packages.yml | 2 +- CHANGELOG.md | 6 + CHANGELOG.zh-CN.md | 6 + CMakeLists.txt | 2 +- README.en.md | 2 +- README.md | 2 +- RELEASE_NOTES.md | 12 + docs/en/guides/python-behavior-baseline.md | 17 +- docs/en/guides/releases.md | 2 +- docs/en/guides/validation.md | 2 +- docs/zh/guides/python-behavior-baseline.md | 15 + docs/zh/guides/releases.md | 2 +- docs/zh/guides/validation.md | 2 +- include/linkerbot/hand/api/device.hpp | 6 + include/linkerbot/hand/api/events.hpp | 2 + include/linkerbot/hand/capabilities.hpp | 5 + include/linkerbot/hand/health.hpp | 3 + include/linkerbot/hand/identity.hpp | 3 + src/hand/capabilities.cpp | 30 +- src/hand/capabilities/l6_hop.cpp | 19 + src/hand/runtime/device_runtime.cpp | 36 ++ src/hand/unified/backend.cpp | 231 +++++++-- src/hand/unified/connection_initializer.cpp | 1 + src/hand/unified/detail/backend.hpp | 14 + src/hand/unified/detail/cached_snapshot.hpp | 11 +- src/hand/unified/detail/force.hpp | 1 + src/hand/unified/detail/python_state.hpp | 72 +++ src/hand/unified/detail/python_tactile.hpp | 48 ++ .../unified/detail/python_transactions.hpp | 8 +- src/hand/unified/detail/runtime_access.hpp | 7 + src/hand/unified/device.cpp | 139 ++++- src/hand/unified/force.cpp | 51 ++ src/hand/unified/python_state.cpp | 473 ++++++++++++++++++ tests/hand/device/test_device.cpp | 113 +++++ tests/hand/device/test_python_publication.cpp | 353 +++++++++++++ validation/installed-consumer/main.cpp | 13 + 36 files changed, 1641 insertions(+), 70 deletions(-) create mode 100644 src/hand/unified/detail/python_state.hpp create mode 100644 src/hand/unified/detail/python_tactile.hpp create mode 100644 src/hand/unified/python_state.cpp create mode 100644 tests/hand/device/test_python_publication.cpp diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 108514d6..d6a2497d 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.6' + default: '0.3.7' permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fa0dd4c..aefe899a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.7 + +- Add publication-based channel sequences, identity sample metadata, filtered retained-cache waits, firmware-optional query degradation and unified angle feedback policies to the explicit Python runtime policy. +- Correct matching tactile response validation and force aggregation; add independent L20Lite scalar queries in that policy. +- Preserve native defaults and the transport v0.3.4 minimum. Rebuild SDK and consumers. Hardware acceptance remains separate; see [release notes](RELEASE_NOTES.md). + ## 0.3.6 - Add an opt-in Python runtime policy for coalesced reads, response-stage windows, strict matching responses, close terminal outcomes and bounded explicit recovery. diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index abcb50a1..96bcfd2f 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -2,6 +2,12 @@ [English](CHANGELOG.md) | [简体中文](CHANGELOG.zh-CN.md) +## 0.3.7 + +- 为显式 Python 运行策略补齐按发布推进的通道序号、身份采样元数据、过滤反馈的缓存等待、固件可选查询降级和统一角度反馈策略。 +- 修正匹配触觉响应的校验和力数据合并;在该策略下增加 L20Lite 独立标量查询。 +- 保留原生默认行为和 transport v0.3.4 最低依赖。SDK 与消费者需要重新编译,实机验收单独进行。详见[发行说明](RELEASE_NOTES.md)。 + ## 0.3.6 - 新增显式 Python 运行时策略,覆盖读取合并、发送后的响应窗口、匹配响应校验、关闭终态及有界显式恢复。 diff --git a/CMakeLists.txt b/CMakeLists.txt index d6840244..e332bc32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,7 +1,7 @@ cmake_minimum_required(VERSION 3.16) project(linkerbot-hand-cpp - VERSION 0.3.6 + VERSION 0.3.7 DESCRIPTION "LinkerBot dexterous hand C++ SDK" LANGUAGES CXX ) diff --git a/README.en.md b/README.en.md index 807db37b..cef3ec68 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.6 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.7 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 807db37b..cef3ec68 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.6 requires transport 0.3.4 or newer in the 0.3 minor line, +Hand SDK 0.3.7 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 991751b1..7cba7688 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,17 @@ # Release notes +## 0.3.7 + +- Extend the explicit `RuntimeBehavior::kPython` policy with publication-based channel sequences and coherent retained snapshots. Cache readers do not advance acquisition counters. Identity reads carry independent sample metadata; closed Python-policy caches retain identity evidence with invalid metadata. +- Add `wait_for_cached_snapshot(previous, timeout, wake_on_command_feedback)` without overloading the existing two-argument wait signature. Filtered waits use the last non-command publication, retain command feedback in the current cache, and wake when their device generation ends. These waits perform no device I/O. +- Reject matching malformed Classic tactile rows in the Python policy, including invalid lengths, row markers and conflicting duplicates. Unrelated routes and opcodes remain unmatched. Correct force aggregation so one query replaces its own fields and scalar raw data follows the latest scalar query while matrix evidence from other queries remains available. +- Read L20Lite fingertip scalar categories independently in the Python policy. Track firmware-optional failures per channel and generation: only sent, unverified optional queries can degrade after repeated response/protocol failures; a previously successful channel is not disabled by later response loss. Disable only that channel and restore probing after explicit recovery. +- Add query-only, command-feedback-only and adaptive angle feedback policies to unified polling. Only verified current-state command feedback fulfills the requested angle rate. Equivalent plans are no-ops. Polling uses the existing native runtime and transport Session; no second physical send queue is added. + +Native behavior remains the default. The policy covers Classic L6, O6, L20Lite, L20, L30 and explicitly selected O30 V25. Existing C++ method signatures remain available; public metadata and configuration additions require rebuilding the SDK and downstream consumers with matching headers and libraries. The minimum and paired transport remain v0.3.4 (`0e190032d65ed0b315337b45b3779cf4f650ed68`). Binary compatibility with earlier headers is not claimed. + +Validation uses offline FakeTransport regressions, Python reference contracts, native/source/prebuilt/installed consumers, sanitizers, complete static analysis and the twelve-target release matrix. No new physical-device acceptance is claimed. Adapter/driver/firmware, physical timestamps, deployed tactile layouts, mixed CAN/FD channels and long-duration multi-device behavior require separate hardware validation. O20, Python bindings, automatic reconnect and hand/arm integration are outside this patch. Extended identity raw/version field parity and O30 field-by-field identity requests remain separate work. + ## 0.3.6 - Add an explicit `RuntimeBehavior::kPython` policy for the unified facade. The native policy remains the default. Classic L6, O6, L20Lite, L20, L30 and explicit O30 V25 are in scope; L6 HOP and mounted O30 retain their existing native path. diff --git a/docs/en/guides/python-behavior-baseline.md b/docs/en/guides/python-behavior-baseline.md index eae01d61..fbcace98 100644 --- a/docs/en/guides/python-behavior-baseline.md +++ b/docs/en/guides/python-behavior-baseline.md @@ -64,7 +64,7 @@ Still required before replacing the accepted Python implementation: - A pinned business configuration and same-input cross-language acceptance corpus for every deployed model, unit/range boundary and fault response. - Deployed-configuration acceptance for the opt-in transaction policy below, - plus adaptive feedback and automatic reconnect parity. Native defaults remain + plus automatic reconnect parity and the remaining deployment-specific behavior corpus. Native defaults remain separate from the Python policy; passing offline cases is not complete parity. - A Python binding proving receipt retention on allocation/wrapping failure, GIL/callback/destructor/interpreter-exit safety, exception conversion and @@ -121,3 +121,18 @@ runtime.recovery_attempts = 3; runtime.recovery_timeout = 5s; runtime.recovery_quiet = 200ms; ``` + + +## Third compatibility patch (0.3.7) + +The following additions apply to the explicit Python runtime policy; native defaults remain unchanged. + +- Complete telemetry publications advance their own channel sequence even when no caller reads a snapshot. A coherent publication view supplies sample sequence, generation, receive time and response/report/command delivery metadata. Global snapshot revisions also include identity and observed capability changes. Repeated cache reads do not create acquisitions. +- `DeviceIdentity::meta` carries its own identity-read sequence and monotonic collection metadata. A successful `refresh_device_info()` advances identity without advancing telemetry channels. Retained caches preserve identity evidence with unavailable metadata after generation loss. This does not add the remaining Python raw/version detail fields. +- `wait_for_cached_snapshot(previous, timeout, wake_on_command_feedback = true)` reads retained state and performs no device I/O. Filtering suppresses command-only wakeups, not cache retention. A non-command update returns its coherent revision, including command data that preceded that revision. Timeout returns the current cache; close or recovery releases old-generation waiters. The original wait signature remains unambiguous; native wait behavior is retained. +- Matching Classic tactile rows validate length and compact/nibble row indices before assembly. Conflicting repeats fail the transaction without replacing the previous complete sample. Complete identical duplicates do not fabricate a new acquisition. Unrelated routes/types/opcodes stay unmatched. +- Force aggregation replaces all fields belonging to the completed query while retaining other queries and their original collection metadata. Scalar-only aggregate raw data follows the latest scalar query. L20Lite Python-policy scalar getters issue only their selected category query (0x20..0x23); the native aggregate query remains available on the native path. +- Firmware-optional observations belong to one device generation. Only a physically sent optional query that has not succeeded in that generation can degrade. Three timeout/protocol failures disable that query; an explicit unsupported response can disable it immediately. Transport failure and expired unsent callers do not establish unsupported firmware. Capability queries, force status and polling reflect the observation; other channels remain usable. Explicit recovery clears those observations. Required initialization remains required. +- `PollingChannel::angle_feedback` selects `kQueryOnly`, `kCommandFeedbackOnly` or `kAdaptive`; the Python-policy default is adaptive. Command-only mode requires a declared verified current-state response and never repeats motion targets. Query-only mode does not use command feedback to postpone queries. Adaptive mode postpones only redundant angle queries after verified feedback. Feedback, completed-read, timeout and missed-deadline metrics remain distinct. Reapplying an equivalent plan preserves deadlines and metrics. All physical frames still pass through the existing Session. + +Python bindings must map retained snapshot and filtered wait APIs explicitly. GIL, callback, destructor/interpreter-exit and allocation-failure proof, wall-clock conversion and shared hand/arm channel integration are not provided by this native patch. Hardware acceptance remains separate. O20 and automatic reconnect are not included. diff --git a/docs/en/guides/releases.md b/docs/en/guides/releases.md index cb84c49e..fa1563be 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.6 requires transport 0.3.4 or newer in +are excluded. Versions remain independent. Hand 0.3.7 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 d522d48e..9e827542 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.6 requires transport 0.3.4 or newer in the 0.3 minor line. Source, +Hand 0.3.7 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 dff782f4..226cdef8 100644 --- a/docs/zh/guides/python-behavior-baseline.md +++ b/docs/zh/guides/python-behavior-baseline.md @@ -92,3 +92,18 @@ runtime.recovery_attempts = 3; runtime.recovery_timeout = 5s; runtime.recovery_quiet = 200ms; ``` + + +## 第三批兼容补全(0.3.7) + +本批新增运行行为仅用于显式 Python 策略,原生默认策略不变。 + +- 通道序号按完整样本发布次数推进,不依赖读取快照的次数。统一发布视图提供序号、连接代次、单调采样时间及查询/报告/命令反馈来源。身份刷新和能力观察也会产生可见快照修订。 +- `DeviceIdentity::meta` 提供独立身份序号和采样元数据。身份刷新不推进遥测通道序号;连接代次失效后,保留缓存中的身份证据携带不可用元数据。本批不补齐所有 Python 原始身份及版本详情字段。 +- 新增独立命名的 `wait_for_cached_snapshot(previous, timeout, wake_on_command_feedback = true)`。过滤只抑制纯命令反馈引起的唤醒,不删除当前缓存中的反馈。非命令发布返回该修订的一致视图;等待超时返回当前缓存,关闭或恢复释放旧代次等待者。原有等待方法签名与原生行为保持兼容,等待不产生设备 I/O。 +- 匹配的 Classic 触觉行校验长度、紧凑或高半字节行号及冲突重复。异常不能替换上次完整样本;无关路由、帧类型和操作码仍忽略。 +- 力数据按查询归属替换字段,保留其他查询的采样证据和各自时间。纯标量聚合的 raw 对应最新标量查询。L20Lite Python 策略使用独立的 0x20..0x23 类别查询,原生聚合查询不变。 +- 固件可选查询仅在当前设备连接代次内降级。只有已实际发送、且该代次尚未成功的可选查询,才累计超时或协议错误;三次失败停用该查询,明确不支持的响应可立即停用。传输故障和未发送调用超时不构成固件不支持的证据。能力、力字段状态和轮询同步反映观察,其他通道继续可用;显式恢复后重新探测,必需初始化项目仍然必需。 +- 统一角度轮询提供 query-only、command-feedback-only 和 adaptive 三种策略,Python 策略默认 adaptive。只有已声明为实际状态的命令反馈才能满足采样需求;command-only 不补发运动目标,query-only 不因命令反馈推迟查询。相同计划不重置期限或指标,物理帧继续交给既有 Session 发送。 + +绑定层仍需完成 API、异常、不可变对象及墙钟转换映射,并证明内存分配失败、GIL、回调、析构和解释器退出的行为。本批不接入 hand/arm,不包含 O20、自动重连或新的实机验收。实际后端、适配器/驱动、固件、触觉布局、时间戳及多设备长时间运行需独立验收。 diff --git a/docs/zh/guides/releases.md b/docs/zh/guides/releases.md index 4522aacd..1b2e7531 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.6 的源码、预构建及安装消费者均要求 transport 0.3.x 且不低于 0.3.4。 +两个项目版本独立。hand 0.3.7 的源码、预构建及安装消费者均要求 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 a7d56324..91093085 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.6 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 +hand 0.3.7 要求 transport 0.3.x 且不低于 0.3.4。源码、预构建及安装消费者测试均拒绝更低补丁版本和其他 minor 版本。 源码开发继续使用 README 与 CI 固定的提交,并在测试报告中记录实际版本和构建配置。 ### 从源码构建 diff --git a/include/linkerbot/hand/api/device.hpp b/include/linkerbot/hand/api/device.hpp index 90463772..fd404914 100644 --- a/include/linkerbot/hand/api/device.hpp +++ b/include/linkerbot/hand/api/device.hpp @@ -192,6 +192,12 @@ namespace linkerbot::hand { [[nodiscard]] HandSnapshot wait_for_snapshot(const HandSnapshot &previous, std::chrono::milliseconds timeout) const; + // Python-compatible retained-cache wait. Filtering excludes command-only + // publications from wakeups, but does not remove them from the cache. + [[nodiscard]] HandSnapshot wait_for_cached_snapshot(const HandSnapshot &previous, + std::chrono::milliseconds timeout, + bool wake_on_command_feedback = true) const; + void start_polling(); void start_polling(const PollingPlan &plan); void stop_polling(); diff --git a/include/linkerbot/hand/api/events.hpp b/include/linkerbot/hand/api/events.hpp index f2e9fa14..af175a48 100644 --- a/include/linkerbot/hand/api/events.hpp +++ b/include/linkerbot/hand/api/events.hpp @@ -11,6 +11,8 @@ namespace linkerbot::hand { struct PollingChannel { TelemetrySemantic semantic = TelemetrySemantic::kAngles; std::chrono::milliseconds interval{100}; + // Used for angles by the explicit Python runtime policy only. + AngleFeedbackPolicy angle_feedback = AngleFeedbackPolicy::kAdaptive; }; using PollingPlan = std::vector; diff --git a/include/linkerbot/hand/capabilities.hpp b/include/linkerbot/hand/capabilities.hpp index 043b78ed..871c5a3c 100644 --- a/include/linkerbot/hand/capabilities.hpp +++ b/include/linkerbot/hand/capabilities.hpp @@ -18,6 +18,8 @@ namespace linkerbot::hand { enum class HandSide : uint8_t { kUnknown, kLeft, kRight }; + enum class AngleFeedbackPolicy : uint8_t { kQueryOnly, kCommandFeedbackOnly, kAdaptive }; + enum class TransportKind : uint8_t { kCan, kCanFd }; enum class Confidence : uint8_t { kMeasured, kDocumented, kApproximate, kConflicting, kUnknown }; @@ -243,6 +245,7 @@ namespace linkerbot::hand { std::optional raw_unit; std::optional raw_encoding; std::optional raw_scale_note; + bool firmware_unsupported = false; }; // Capability strings are non-owning views into SDK-owned static profile data. @@ -258,6 +261,8 @@ namespace linkerbot::hand { CapabilitySchemaVersion schema_version = kCapabilitySchemaVersion; // Runtime descriptor observations; views still reference static profile names. std::vector observed_force_fields; + // Observed only in the current verified connection generation. + std::vector firmware_unsupported_telemetry; [[nodiscard]] bool supports(CommandSemantic semantic) const noexcept; [[nodiscard]] bool supports(TelemetrySemantic semantic) const noexcept; diff --git a/include/linkerbot/hand/health.hpp b/include/linkerbot/hand/health.hpp index 59e8fd01..9e676af2 100644 --- a/include/linkerbot/hand/health.hpp +++ b/include/linkerbot/hand/health.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -98,6 +99,8 @@ namespace linkerbot::hand { DataAge data_age; LatencyMetrics latency; QueueMetrics queue; + uint64_t command_feedback_samples = 0; + std::optional feedback_policy; }; // One independently scheduled command family. Counters count logical diff --git a/include/linkerbot/hand/identity.hpp b/include/linkerbot/hand/identity.hpp index 17c4c329..afbb46e1 100644 --- a/include/linkerbot/hand/identity.hpp +++ b/include/linkerbot/hand/identity.hpp @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -38,6 +39,8 @@ namespace linkerbot::hand { std::optional hardware_version; std::optional mechanical_version; std::chrono::steady_clock::time_point timestamp; + // Populated by the explicit Python runtime policy; timestamp is monotonic. + SampleMeta meta; }; // Python classic_can.parse_factory_identity contract. No protocol I/O. diff --git a/src/hand/capabilities.cpp b/src/hand/capabilities.cpp index a9bf9ba6..75c64aed 100644 --- a/src/hand/capabilities.cpp +++ b/src/hand/capabilities.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -53,7 +54,9 @@ namespace linkerbot::hand { bool DeviceCapabilities::supports(CommandSemantic semantic) const noexcept { return command(semantic) != nullptr; } bool DeviceCapabilities::supports(TelemetrySemantic semantic) const noexcept { - return telemetry_channel(semantic) != nullptr; + return telemetry_channel(semantic) != nullptr && + std::find(firmware_unsupported_telemetry.begin(), firmware_unsupported_telemetry.end(), semantic) == + firmware_unsupported_telemetry.end(); } bool OperationCapabilities::supports(DeviceOperation operation) const noexcept { @@ -231,6 +234,15 @@ namespace linkerbot::hand { return false; } } + for (size_t i = 0; i < firmware_unsupported_telemetry.size(); ++i) { + if (!telemetry_channel(firmware_unsupported_telemetry[i]) || + std::find(firmware_unsupported_telemetry.begin(), + firmware_unsupported_telemetry.begin() + static_cast(i), + firmware_unsupported_telemetry[i]) != + firmware_unsupported_telemetry.begin() + static_cast(i)) { + return false; + } + } return true; } @@ -408,6 +420,9 @@ namespace linkerbot::hand { } else { out << "null"; } + if (field.firmware_unsupported) { + out << ",\"firmware_unsupported\":true"; + } out << ",\"query_channel\":"; if (field.query_channel) { out << static_cast(*field.query_channel); @@ -420,7 +435,18 @@ namespace linkerbot::hand { append_strings(out, field.sensor_names); out << '}'; } - out << "],\"operations\":{" + out << "]"; + if (!capabilities.firmware_unsupported_telemetry.empty()) { + out << ",\"firmware_unsupported_telemetry\":["; + for (size_t i = 0; i < capabilities.firmware_unsupported_telemetry.size(); ++i) { + if (i) { + out << ','; + } + out << static_cast(capabilities.firmware_unsupported_telemetry[i]); + } + out << ']'; + } + out << ",\"operations\":{" << "\"read_device_info\":" << (capabilities.operations.read_device_info ? "true" : "false") << ",\"clear_faults\":" << (capabilities.operations.clear_faults ? "true" : "false") << ",\"enable\":" << (capabilities.operations.enable ? "true" : "false") diff --git a/src/hand/capabilities/l6_hop.cpp b/src/hand/capabilities/l6_hop.cpp index f1dba106..f3618408 100644 --- a/src/hand/capabilities/l6_hop.cpp +++ b/src/hand/capabilities/l6_hop.cpp @@ -52,6 +52,25 @@ namespace linkerbot::hand { } } } + if (config.model == Model::kL20Lite && config.runtime_behavior == RuntimeBehavior::kPython) { + auto fields = result.force_fields(); + for (auto &field : fields) { + if (field.name == "fns") { + field.query_channel = TelemetrySemantic::kForceFns; + } + if (field.name == "fts") { + field.query_channel = TelemetrySemantic::kForceFts; + } + if (field.name == "fds") { + field.query_channel = TelemetrySemantic::kForceFds; + } + if (field.name == "px") { + field.query_channel = TelemetrySemantic::kForcePx; + } + field.shared_fields = {field.name}; + } + result.observed_force_fields = std::move(fields); + } return result; } if (config.model != Model::kL6 || config.l6_protocol != L6Protocol::kHopV004) { diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index 45253949..12f19d75 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -96,6 +96,12 @@ namespace linkerbot::hand::runtime { return value.size() >= prefix.size() && value.substr(0, prefix.size()) == prefix; } + struct SnapshotView { + const DeviceRuntime *runtime; + const std::map *samples; + }; + thread_local std::optional current_snapshot_view; + std::string canonical_channel(std::string_view domain) { if (domain == "angle" || domain == "angles") { return "angles"; @@ -548,6 +554,7 @@ namespace linkerbot::hand::runtime { std::mutex lifecycle; std::mutex polling_operation; unified_detail::RuntimeAccess::PollingGate polling_gate; + unified_detail::RuntimeAccess::PublicationHandler publication_handler; std::thread rx; bool rx_stop = false; bool destruction_requested = false; @@ -816,6 +823,11 @@ namespace linkerbot::hand::runtime { last_successful_receive = sample.received_at; } samples[domain] = sample; + if (publication_handler) { + publication_handler(domain, sample); + ++polling_wake; + cv.notify_all(); + } for (const auto &[id, subscriber] : subscribers) { (void)id; if (subscriber.domain == domain && !Events::instance().post(subscriber.slot, sample, owner)) { @@ -1024,6 +1036,9 @@ namespace linkerbot::hand::runtime { ::linkerbot::hand::detail::checked_value( state->plan.optional_response_window, "missing runtime state: state->plan.optional_response_window")) { + if (publication_handler && state->scoped_response_policy) { + publication_handler(state->plan.domain, Sample{}); + } finish_locked(state, {}, true); } } @@ -1636,6 +1651,11 @@ namespace linkerbot::hand::runtime { (void)execute(std::move(plan), deadline, cancellation); } std::optional DeviceRuntime::snapshot(const std::string &domain) const { + if (current_snapshot_view && current_snapshot_view->runtime == this) { + const auto &samples = *current_snapshot_view->samples; + const auto found = samples.find(domain); + return found == samples.end() ? std::nullopt : std::optional{found->second}; + } std::lock_guard lock(impl_->mutex); const auto it = impl_->samples.find(domain); if (it == impl_->samples.end()) { @@ -2865,3 +2885,19 @@ void linkerbot::hand::unified_detail::RuntimeAccess::retire_closed_profiles( } } } + +void linkerbot::hand::unified_detail::RuntimeAccess::set_publication_handler(runtime::DeviceRuntime &device, + PublicationHandler handler) { + std::lock_guard lock(device.impl_->mutex); + device.impl_->publication_handler = std::move(handler); +} +void linkerbot::hand::unified_detail::RuntimeAccess::with_snapshot_view( + const runtime::DeviceRuntime &device, const std::map &samples, + const std::function &read) { + struct Restore { + std::optional previous; + ~Restore() { runtime::current_snapshot_view = previous; } + } restore{runtime::current_snapshot_view}; + runtime::current_snapshot_view = runtime::SnapshotView{&device, &samples}; + read(); +} diff --git a/src/hand/unified/backend.cpp b/src/hand/unified/backend.cpp index 16823c39..56a00597 100644 --- a/src/hand/unified/backend.cpp +++ b/src/hand/unified/backend.cpp @@ -1,5 +1,9 @@ #include "detail/backend.hpp" +#include "detail/cached_snapshot.hpp" +#include "detail/connection_initializer.hpp" +#include "detail/force.hpp" #include "detail/o30_v25.hpp" +#include "detail/python_state.hpp" #include "detail/python_transactions.hpp" #include "detail/read_coordinator.hpp" @@ -78,6 +82,12 @@ namespace linkerbot::hand::unified_detail { dispatch_guard_(std::make_shared()) { if (config_.runtime_behavior == RuntimeBehavior::kPython) { RuntimeAccess::resume_endpoint(*device_->runtime()); + python_ = std::make_shared(config_); + RuntimeAccess::set_publication_handler( + *device_->runtime(), + [state = python_](const std::string &domain, const runtime::Sample &sample) { + state->publish(domain, sample); + }); reads_ = std::make_unique>(config_.read_waiter_capacity); return; } @@ -90,6 +100,10 @@ namespace linkerbot::hand::unified_detail { } ~ModelBackend() override { + if (python_) { + python_->stop(); + RuntimeAccess::set_publication_handler(*device_->runtime(), {}); + } reads_closing_.store(true, std::memory_order_release); ++read_epoch_; if (reads_) { @@ -286,29 +300,54 @@ namespace linkerbot::hand::unified_detail { throw UnsupportedOperationError("telemetry semantic is not supported by this device"); } if (reads_) { + if (python_->disabled(semantic)) { + throw UnsupportedFirmwareError("optional telemetry is disabled for this generation"); + } const auto *channel = capabilities_.telemetry_channel(semantic); if (channel && (channel->read_mode == ReadMode::kCommandCache || channel->read_mode == ReadMode::kCommandResponse)) { - return read_telemetry(*device_, semantic, timeout); + (void)read_telemetry(*device_, semantic, timeout); + try { + auto cached = + telemetry_sample(snapshot(config_.model, config_.side, capabilities_), semantic); + if (!std::visit([](const auto &value) { return value.meta.valid; }, cached)) { + throw UnsupportedOperationError( + "no configuration response is cached for this generation"); + } + return cached; + } catch (const DeviceNotReadyError &) { + throw UnsupportedOperationError("no configuration response is cached for this generation"); + } } - std::optional baseline; + uint64_t baseline = 0; try { const auto previous = telemetry_sample(snapshot(Model::kUnknown, HandSide::kUnknown, capabilities_), semantic); - baseline = std::visit([](const auto &reading) { return reading.meta.collected_at; }, previous); + baseline = std::visit( + [semantic](const auto &reading) { + if constexpr (std::is_same_v, ForceSample>) { + for (const auto &field : reading.fields) { + if (field.query_channel == semantic) { + return field.meta.sequence; + } + } + return uint64_t{0}; + } else { + return reading.meta.sequence; + } + }, + previous); } catch (const DeviceNotReadyError &) { - baseline.reset(); + baseline = 0; } const auto deadline = runtime::deadline_after(timeout); const auto valid = read_generation(); while (true) { auto value = reads_->read_prepared( static_cast(semantic), deadline, - [this, semantic, valid](const std::shared_ptr &lane) { + [this, semantic, valid, polling](const std::shared_ptr &lane) { ReadValue result; - with_windows( - [&] { result = read_direct(semantic, config_.transaction_timeout, valid, lane); }, - valid); + with_windows([&] { result = python_read(semantic, polling, valid, lane); }, valid); return result; }, [this, valid](const std::function &demand) { @@ -318,10 +357,7 @@ namespace linkerbot::hand::unified_detail { polling, poll_cancel); auto sample = std::get(std::move(value)); const bool fresh = std::visit( - [&](const auto &reading) { - return reading.meta.collected_at && - (!baseline || *reading.meta.collected_at > *baseline); - }, + [&](const auto &reading) { return reading.meta.valid && reading.meta.sequence > baseline; }, sample); if (fresh) { return sample; @@ -345,6 +381,14 @@ namespace linkerbot::hand::unified_detail { : runtime::deadline_after(timeout); auto lease = prepared ? prepared : execution_gate_->acquire(false, deadline, {}, valid); dispatch_guard_->wait(deadline, {}, valid); + if constexpr (std::is_same_v) { + if (python_ && + (semantic == TelemetrySemantic::kForceFns || semantic == TelemetrySemantic::kForceFts || + semantic == TelemetrySemantic::kForceFds || semantic == TelemetrySemantic::kForcePx)) { + read_python_scalar(*device_->runtime(), config_, semantic); + return telemetry_sample(snapshot(config_.model, config_.side, capabilities_), semantic); + } + } if constexpr (std::is_same_v) { if (config_.o30_layout == O30Layout::kPythonV25 && semantic != TelemetrySemantic::kForce && semantic != TelemetrySemantic::kForceFns) { @@ -387,6 +431,13 @@ namespace linkerbot::hand::unified_detail { [[nodiscard]] HandSnapshot snapshot(Model model, HandSide side, const DeviceCapabilities &capabilities) const override { + if (python_) { + return filtered_snapshot(model, side, capabilities, true); + } + return native_snapshot(model, side, capabilities); + } + [[nodiscard]] HandSnapshot native_snapshot(Model model, HandSide side, + const DeviceCapabilities &capabilities) const { auto result = unified_snapshot(device_->get_snapshot(), model, side, capabilities); if constexpr (std::is_same_v) { if (config_.o30_layout == O30Layout::kPythonV25) { @@ -414,6 +465,33 @@ namespace linkerbot::hand::unified_detail { return result; } + DeviceCapabilities observed_capabilities(DeviceCapabilities caps) const override { + return python_ ? python_->capabilities(std::move(caps)) : caps; + } + HandSnapshot filtered_snapshot(Model model, HandSide side, const DeviceCapabilities &caps, + bool include_feedback) const override { + if (!python_) { + return native_snapshot(model, side, caps); + } + const auto view = python_->view(include_feedback); + HandSnapshot result; + RuntimeAccess::with_snapshot_view(*device_->runtime(), view.samples, + [&] { result = native_snapshot(model, side, caps); }); + python_->project(result, view, caps); + return result; + } + void wait_publication(uint64_t sequence, runtime::Deadline deadline, bool include_feedback, + const std::function &generation_valid) const override { + if (python_) { + python_->wait(sequence, deadline, include_feedback, generation_valid); + } + } + void finish_initialization() override { + if (python_) { + python_->initialized(); + } + } + [[nodiscard]] HandHealth health() const override { auto health = device_->health(); auto current = snapshot(Model::kUnknown, HandSide::kUnknown, capabilities_); @@ -460,6 +538,9 @@ namespace linkerbot::hand::unified_detail { } // These configuration channels also declare freshness bounds. update(current.configured_speeds, TelemetrySemantic::kConfiguredSpeeds, "speeds"); + if (python_) { + python_->project_health(health); + } return health; } @@ -491,7 +572,29 @@ namespace linkerbot::hand::unified_detail { : runtime::deadline_after(timeout); auto lease = prepared ? prepared : execution_gate_->acquire(false, deadline, {}, valid); dispatch_guard_->wait(deadline, {}, valid); - return read_identity(*device_, side, runtime::remaining(deadline)); + auto identity = read_identity(*device_, side, runtime::remaining(deadline)); + if (python_) { + const auto view = python_->view(); + const auto previous = view.samples.find("python/identity"); + std::optional expected; + if (previous != view.samples.end()) { + expected = std::any_cast(previous->second.value); + } + validate_connection_identity(identity, config_, expected ? &*expected : nullptr, + "refresh_device_info"); + auto provenance = device_->runtime()->response_provenance(); + identity.meta.valid = true; + identity.meta.stale = false; + identity.meta.origin = TelemetryValueOrigin::kDeviceConfigured; + identity.meta.delivery = TelemetryDelivery::kQueryResponse; + identity.meta.collected_at = identity.timestamp; + identity.meta.connection_generation = provenance.device_generation; + identity.meta.sequence = 1; + provenance.received_at = identity.timestamp; + device_->runtime()->publish("python/identity", identity, provenance); + identity = std::any_cast(python_->view().samples.at("python/identity").value); + } + return identity; } TelemetrySample initialize_read(TelemetrySemantic semantic, std::chrono::milliseconds timeout) override { @@ -506,31 +609,31 @@ namespace linkerbot::hand::unified_detail { } void start_polling(const PollingPlan &plan) override { if (reads_) { - std::vector tasks; - for (const auto &channel : plan) { - runtime::DeviceRuntime::PollTask task; - task.name = python_polling_domain(channel.semantic); - task.period = channel.interval; - task.run = [this, semantic = channel.semantic](runtime::CancellationToken token) { - try { - (void)coordinated_read(semantic, config_.transaction_timeout, true, token); - } catch (const TimeoutError &) { - // A poll miss or caller wait expiry does not fault - // unrelated channels or cancel shared readers. - return; - } catch (const ClosedError &) { - // A previous read generation ended. Lifecycle code - // owns poll shutdown/restart; do not fault peers. - return; - } catch (const ProtocolError &) { - // The native transaction already recorded this - // protocol failure and retained successful data. - return; - } - }; - tasks.push_back(std::move(task)); + if (!python_->replace(plan)) { + return; } - device_->runtime()->start_polling(std::move(tasks)); + runtime::DeviceRuntime::PollTask task; + task.name = "python-feedback"; + task.period = std::chrono::milliseconds{1}; + task.next = [state = python_] { return state->next_poll(); }; + task.run = [this](runtime::CancellationToken token) { + const auto semantic = python_->take_poll(); + if (!semantic || token.cancelled()) { + return; + } + try { + (void)coordinated_read(*semantic, config_.transaction_timeout, true, token); + } catch (const TimeoutError &) { + return; + } catch (const ClosedError &) { + return; + } catch (const ProtocolError &) { + return; + } catch (const UnsupportedFirmwareError &) { + return; + } + }; + device_->runtime()->start_polling({std::move(task)}); return; } if constexpr (std::is_same_v) { @@ -572,17 +675,26 @@ namespace linkerbot::hand::unified_detail { if (reads_) { reads_->drain(); RuntimeAccess::resume_endpoint(*device_->runtime()); + python_->resume(); reads_->activate(); } } void cancel_commands() override { + if (python_) { + python_->stop(); + } ++read_epoch_; if (reads_) { reads_->invalidate(); } device_->runtime()->cancel_all(); } - void stop_polling() override { device_->stop_polling(); } + void stop_polling() override { + if (python_) { + python_->stop_polling(); + } + device_->stop_polling(); + } void wait_quiet(std::chrono::milliseconds quiet, runtime::Deadline deadline, const std::function &cancelled) override { RuntimeAccess::wait_endpoint_quiet(*device_->runtime(), quiet, deadline, cancelled); @@ -605,6 +717,9 @@ namespace linkerbot::hand::unified_detail { reads_->drain(); } recover_device(*device_); + if (python_) { + python_->reset(); + } if (reads_) { reads_->activate(); } @@ -638,6 +753,45 @@ namespace linkerbot::hand::unified_detail { } private: + TelemetrySample python_read(TelemetrySemantic semantic, bool polling, const std::function &valid, + const std::shared_ptr &lane) { + if (python_->disabled(semantic)) { + throw UnsupportedFirmwareError("optional telemetry is disabled for this generation"); + } + const auto sent = device_->runtime()->runtime_health().sent_frames; + try { + (void)read_direct(semantic, config_.transaction_timeout, valid, lane); + auto result = telemetry_sample(snapshot(config_.model, config_.side, capabilities_), semantic); + if (auto *force = std::get_if(&result)) { + force->fields.erase( + std::remove_if(force->fields.begin(), force->fields.end(), + [semantic](const auto &field) { return field.query_channel != semantic; }), + force->fields.end()); + if (semantic != TelemetrySemantic::kForce) { + force->raw.clear(); + force->tactile.clear(); + force->sensors.clear(); + force->shape = {5}; + for (const auto &field : force->fields) { + force->meta = field.meta; + for (const auto &finger : field.raw) { + if (finger) { + for (const auto value : *finger) { + force->raw.push_back(static_cast(value)); + } + } + } + } + } + } + python_->read_succeeded(semantic, polling); + return result; + } catch (const HandError &error) { + python_->read_failed(semantic, device_->runtime()->runtime_health().sent_frames > sent, error, + polling); + throw; + } + } using ReadValue = std::variant; std::function read_generation() { const auto epoch = read_epoch_.load(std::memory_order_acquire); @@ -666,6 +820,7 @@ namespace linkerbot::hand::unified_detail { std::shared_ptr execution_gate_; std::shared_ptr dispatch_guard_; std::unique_ptr> reads_; + std::shared_ptr python_; std::atomic read_epoch_{0}; std::atomic reads_closing_{false}; std::atomic execution_deadline_{ diff --git a/src/hand/unified/connection_initializer.cpp b/src/hand/unified/connection_initializer.cpp index 9a689357..8a61e524 100644 --- a/src/hand/unified/connection_initializer.cpp +++ b/src/hand/unified/connection_initializer.cpp @@ -118,6 +118,7 @@ namespace linkerbot::hand::unified_detail { } } + backend.finish_initialization(); return {std::move(identity), std::move(verified)}; } } // namespace linkerbot::hand::unified_detail diff --git a/src/hand/unified/detail/backend.hpp b/src/hand/unified/detail/backend.hpp index 082a8307..57430f62 100644 --- a/src/hand/unified/detail/backend.hpp +++ b/src/hand/unified/detail/backend.hpp @@ -41,6 +41,20 @@ namespace linkerbot::hand::unified_detail { virtual ~DeviceBackend() = default; virtual void stop_admission() {} virtual void resume_admission() {} + virtual DeviceCapabilities observed_capabilities(DeviceCapabilities value) const { return value; } + virtual HandSnapshot filtered_snapshot(Model model, HandSide side, const DeviceCapabilities &caps, + bool include_feedback) const { + (void)include_feedback; + return snapshot(model, side, caps); + } + virtual void wait_publication(uint64_t sequence, runtime::Deadline deadline, bool include_feedback, + const std::function &generation_valid = {}) const { + (void)sequence; + (void)deadline; + (void)include_feedback; + (void)generation_valid; + } + virtual void finish_initialization() {} [[nodiscard]] virtual CommandHandle submit(const CommandRequest &request) = 0; [[nodiscard]] virtual TelemetrySample read(TelemetrySemantic semantic, std::chrono::milliseconds timeout) = 0; diff --git a/src/hand/unified/detail/cached_snapshot.hpp b/src/hand/unified/detail/cached_snapshot.hpp index add7b533..5e3f3a40 100644 --- a/src/hand/unified/detail/cached_snapshot.hpp +++ b/src/hand/unified/detail/cached_snapshot.hpp @@ -64,6 +64,9 @@ namespace linkerbot::hand::unified_detail { inline void set_cached_generation(HandSnapshot &snapshot, uint64_t generation) { snapshot.connection_generation = generation; + if (snapshot.device_info && snapshot.device_info->meta.valid) { + snapshot.device_info->meta.connection_generation = generation; + } visit_snapshot_samples(snapshot, [generation](auto &sample, TelemetrySemantic) { if (!sample) { return; @@ -78,7 +81,7 @@ namespace linkerbot::hand::unified_detail { }); } - inline void invalidate_cached_snapshot(HandSnapshot &snapshot) { + inline void invalidate_cached_snapshot(HandSnapshot &snapshot, bool retain_identity = false) { visit_snapshot_samples(snapshot, [generation = snapshot.connection_generation](auto &sample, TelemetrySemantic) { if (!sample) { @@ -100,6 +103,10 @@ namespace linkerbot::hand::unified_detail { joint.name = name; } // DeviceIdentity has no validity metadata, so stale identity is omitted. - snapshot.device_info.reset(); + if (retain_identity && snapshot.device_info) { + snapshot.device_info->meta = SampleMeta::unavailable(snapshot.connection_generation); + } else { + snapshot.device_info.reset(); + } } } diff --git a/src/hand/unified/detail/force.hpp b/src/hand/unified/detail/force.hpp index 6790d373..e1814a5a 100644 --- a/src/hand/unified/detail/force.hpp +++ b/src/hand/unified/detail/force.hpp @@ -7,6 +7,7 @@ namespace linkerbot::hand::unified_detail { void validate_force_field(std::string_view name); void merge_force_samples(ForceSample &target, const ForceSample &source); + void merge_python_force_samples(ForceSample &target, const ForceSample &source); ForceSample tip_force_sample(const l20lite::TipForceData &data, SampleMeta meta, const DeviceCapabilities &capabilities); ForceSample descriptor_force_sample(const o30::SensorData &data, SampleMeta meta); diff --git a/src/hand/unified/detail/python_state.hpp b/src/hand/unified/detail/python_state.hpp new file mode 100644 index 00000000..c242e2ea --- /dev/null +++ b/src/hand/unified/detail/python_state.hpp @@ -0,0 +1,72 @@ +#pragma once +#include "runtime_access.hpp" +#include +#include +#include +#include +#include + +namespace linkerbot::hand::unified_detail { + void read_python_scalar(runtime::DeviceRuntime &, const HandDeviceConfig &, TelemetrySemantic); + + // Bounded per-device publication/poll intent state. It never sends frames. + class PythonState { + public: + struct View { + std::map samples; + std::map metadata; + uint64_t sequence = 0; + }; + explicit PythonState(const HandDeviceConfig &config); + void publish(const std::string &domain, const runtime::Sample &sample); + View view(bool include_feedback = true) const; + void wait(uint64_t sequence, runtime::Deadline deadline, bool include_feedback, + const std::function &generation_valid = {}) const; + void stop(); + void reset(); + void resume(); + void initialized(); + DeviceCapabilities capabilities(DeviceCapabilities value) const; + bool disabled(TelemetrySemantic semantic) const; + void read_succeeded(TelemetrySemantic semantic, bool polling); + void read_failed(TelemetrySemantic semantic, bool sent, const HandError &error, bool polling); + bool replace(const PollingPlan &plan); + void stop_polling(); + std::optional next_poll() const; + std::optional take_poll(); + void project_health(HandHealth &health) const; + std::string domain(TelemetrySemantic semantic) const; + void project(HandSnapshot &snapshot, const View &view, const DeviceCapabilities &caps) const; + + private: + struct Poll { + PollingChannel channel; + runtime::Deadline next; + runtime::Deadline started; + uint64_t reads = 0; + uint64_t feedback = 0; + uint64_t commands = 0; + uint64_t missed = 0; + uint64_t timeouts = 0; + bool enabled = true; + }; + bool optional(TelemetrySemantic semantic) const; + void disable_locked(TelemetrySemantic semantic); + void visible_locked(bool command_feedback); + HandDeviceConfig config_; + std::map domains_; + mutable std::mutex mutex_; + mutable std::condition_variable changed_; + View current_; + View non_command_; + std::map sequences_; + std::set verified_; + std::set disabled_; + std::map failures_; + std::map polls_; + std::optional plan_; + uint64_t identity_sequence_ = 0; + bool stopped_ = false; + bool ready_ = false; + }; +} diff --git a/src/hand/unified/detail/python_tactile.hpp b/src/hand/unified/detail/python_tactile.hpp new file mode 100644 index 00000000..0e2cfcb0 --- /dev/null +++ b/src/hand/unified/detail/python_tactile.hpp @@ -0,0 +1,48 @@ +#pragma once +#include +#include + +namespace linkerbot::hand::unified_detail { + inline protocol::TransactionPlan python_tactile(protocol::TransactionPlan plan, Model model) { + if (plan.frames.empty() || plan.frames.front().is_fd || plan.frames.front().is_extended || + model == Model::kL30 || model == Model::kO30) { + return plan; + } + const auto id = plan.frames.front().id; + const auto first = plan.frames.front().data[0]; + if (first < 0xB1 || first > 0xB5) { + return plan; + } + auto decode = std::move(plan.consume); + plan.consume = [decode = std::move(decode), id, first, model, + all = plan.frames.size() > 1](const can::Message &message) mutable { + if (message.id != id || message.is_extended || message.is_fd || message.is_brs || message.dlc == 0 || + message.data[0] < 0xB1 || message.data[0] > 0xB5 || (!all && message.data[0] != first)) { + return protocol::Progress::unmatched(); + } + const auto length = model == Model::kO6 ? 6 : 8; + if (message.dlc != length) { + throw ProtocolError("matching tactile row has an invalid length"); + } + const auto rows = model == Model::kO6 ? 10 : 12; + const auto marker = message.data[1]; + // Python accepts compact row indices and nibble row markers. + const auto row = marker < rows ? marker : (marker & 15) == 0 ? marker >> 4 : 255; + if (row >= rows) { + throw ProtocolError("matching tactile row has an invalid index"); + } + auto normalized = message; + if (model != Model::kL6) { + normalized.data[1] = static_cast(row << 4); + } + auto progress = decode(normalized); + if (progress.kind == protocol::ProgressKind::failed) { + // A malformed matching sample is terminal for this transaction, + // without poisoning unrelated channels or successful cache data. + progress.terminal_error = false; + } + return progress; + }; + return plan; + } +} diff --git a/src/hand/unified/detail/python_transactions.hpp b/src/hand/unified/detail/python_transactions.hpp index 9168e447..8a7e6244 100644 --- a/src/hand/unified/detail/python_transactions.hpp +++ b/src/hand/unified/detail/python_transactions.hpp @@ -1,6 +1,7 @@ #pragma once #include "classic_response.hpp" #include "python_l30.hpp" +#include "python_tactile.hpp" #include #include #include @@ -59,8 +60,8 @@ namespace linkerbot::hand::unified_detail { auto guard = 5ms; const bool six = config.model == Model::kL6 || config.model == Model::kO6; const bool identity = plan.domain.find("version") != std::string::npos; - const bool tactile = - plan.domain.find("force") != std::string::npos || (config.model == Model::kL30 && python_l30_tactile(plan)); + const bool tactile = (plan.domain.find("force") != std::string::npos && plan.domain.rfind("python/", 0) != 0) || + (config.model == Model::kL30 && python_l30_tactile(plan)); if (six && !identity && !tactile) { window = 50ms; } @@ -152,6 +153,9 @@ namespace linkerbot::hand::unified_detail { if (config.model == Model::kL30) { plan = python_l30_response(std::move(plan)); } + if (tactile) { + plan = python_tactile(std::move(plan), config.model); + } plan.response_window = config.response_timeout.value_or(window); if (plan.optional_response_window) { plan.optional_response_window = plan.response_window; diff --git a/src/hand/unified/detail/runtime_access.hpp b/src/hand/unified/detail/runtime_access.hpp index 58f15443..b582e260 100644 --- a/src/hand/unified/detail/runtime_access.hpp +++ b/src/hand/unified/detail/runtime_access.hpp @@ -1,12 +1,19 @@ #pragma once #include +#include namespace linkerbot::hand::unified_detail { // Internal hook: preserve native polling plans/metrics and receivers while // making their entire composite read share the unified execution arbiter. class RuntimeAccess { public: + using PublicationHandler = std::function; + // SDK-internal sink; runs under the runtime lock and must not reenter it. + static void set_publication_handler(runtime::DeviceRuntime &, PublicationHandler); + // Pin all native Channel/cache reads on this thread to one immutable view. + static void with_snapshot_view(const runtime::DeviceRuntime &, const std::map &, + const std::function &); using PollingGate = std::function(runtime::CancellationToken)>; static void set_polling_gate(runtime::DeviceRuntime &runtime, PollingGate gate); static void wait_endpoint_quiet(runtime::DeviceRuntime &runtime, std::chrono::milliseconds quiet, diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index 3c107739..12f0895e 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -157,7 +157,7 @@ namespace linkerbot::hand { void close_backend(); void recover_backend(runtime::Deadline preparation_deadline = runtime::Deadline::max()); - [[nodiscard]] HandSnapshot snapshot(); + [[nodiscard]] HandSnapshot snapshot(bool include_feedback = true); void retain_snapshot() noexcept { try { @@ -174,7 +174,8 @@ namespace linkerbot::hand { void invalidate_cache_locked() { if (cache_valid_) { - unified_detail::invalidate_cached_snapshot(cached_snapshot_); + unified_detail::invalidate_cached_snapshot(cached_snapshot_, + config_.runtime_behavior == RuntimeBehavior::kPython); cached_snapshot_.captured_at = std::chrono::steady_clock::now(); cached_snapshot_.snapshot_sequence = ++cache_revision_; cache_valid_ = false; @@ -383,9 +384,9 @@ namespace linkerbot::hand { } } - [[nodiscard]] const DeviceCapabilities &capabilities_unlocked() const { + [[nodiscard]] DeviceCapabilities capabilities_unlocked() const { if (state_.load(std::memory_order_acquire) == State::kReady && verified_capabilities_) { - return *verified_capabilities_; + return backend_ ? backend_->observed_capabilities(*verified_capabilities_) : *verified_capabilities_; } return declared_capabilities_; } @@ -550,6 +551,11 @@ namespace linkerbot::hand { read_raw(TelemetrySemantic semantic, std::chrono::milliseconds timeout, const char *operation) { const auto context = capture(operation); const auto &capabilities = context.capabilities; + if (std::find(capabilities.firmware_unsupported_telemetry.begin(), + capabilities.firmware_unsupported_telemetry.end(), + semantic) != capabilities.firmware_unsupported_telemetry.end()) { + throw UnsupportedFirmwareError("optional telemetry is disabled for this generation"); + } if (!capabilities.supports(semantic)) { throw UnsupportedOperationError(std::string("configured hand model does not support ") + operation, {operation, unified_detail::model_name(config_.model)}); @@ -560,8 +566,13 @@ namespace linkerbot::hand { } auto lease = begin_io(context, operation); auto sample = complete_io(context, operation, [&] { return context.backend->read(semantic, timeout); }); - const auto generation = context.backend->health().connection_generation; + auto generation = context.backend->health().connection_generation; finish_io(context, operation); + if (config_.runtime_behavior == RuntimeBehavior::kPython) { + const std::lock_guard lock(mutex_); + check_context(context, operation); + generation = cache_generation_; + } std::visit([generation](auto &value) { value.meta.connection_generation = generation; }, sample); return sample; } @@ -755,6 +766,9 @@ namespace linkerbot::hand { impl_->verified_capabilities_ = std::move(initialized.capabilities); // Advance the readable generation only after initialization state is installed. ++impl_->cache_generation_; + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && impl_->identity_) { + impl_->identity_->meta.connection_generation = impl_->cache_generation_; + } impl_->cache_physical_generation_ = physical_generation; impl_->cached_source_sequence_.reset(); impl_->state_.store(Impl::State::kReady, std::memory_order_release); @@ -969,6 +983,9 @@ namespace linkerbot::hand { "refresh_device_info"); // Identity refresh leaves this generation's capability observations // and polling decisions intact, matching the Python read path. + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + identity.meta.connection_generation = impl_->cache_generation_; + } impl_->identity_ = identity; impl_->cached_source_sequence_.reset(); return identity; @@ -1034,7 +1051,8 @@ namespace linkerbot::hand { if (impl_->state_.load(std::memory_order_acquire) != Impl::State::kReady) { return std::nullopt; } - return impl_->verified_capabilities_; + return impl_->verified_capabilities_ ? std::optional{impl_->capabilities_unlocked()} + : std::nullopt; } CommandHandle HandDevice::set_angles(const std::vector &values) { @@ -1172,6 +1190,9 @@ namespace linkerbot::hand { for (const auto &name : requested) { const auto field = capabilities.force_field(name); if (field.supported == false) { + if (!fields.empty() && field.firmware_unsupported) { + throw UnsupportedFirmwareError("firmware does not support requested force field " + name); + } if (!fields.empty()) { throw UnsupportedOperationError("configured hand model does not support force field " + name); } @@ -1185,7 +1206,13 @@ namespace linkerbot::hand { if (queries.empty()) { throw UnsupportedOperationError("configured hand model does not support requested force fields"); } - const auto generation = selected.health().connection_generation; + const auto physical_generation = selected.health().connection_generation; + auto generation = physical_generation; + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + const std::lock_guard lock(impl_->mutex_); + impl_->check_context(context, "get_force"); + generation = impl_->cache_generation_; + } ForceSample result; for (const auto query : queries) { const auto remaining = @@ -1202,9 +1229,13 @@ namespace linkerbot::hand { sample.meta.sequence = ++impl_->snapshot_sequence_; } unified_detail::complete_force_meta(sample, generation, capabilities); - unified_detail::merge_force_samples(result, sample); + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + unified_detail::merge_python_force_samples(result, sample); + } else { + unified_detail::merge_force_samples(result, sample); + } } - if (selected.health().connection_generation != generation) { + if (selected.health().connection_generation != physical_generation) { throw DeviceNotReadyError("force queries crossed a connection generation"); } { @@ -1231,10 +1262,13 @@ namespace linkerbot::hand { HandSnapshot HandDevice::get_snapshot() const { return impl_->snapshot(); } - HandSnapshot HandDevice::Impl::snapshot() { + HandSnapshot HandDevice::Impl::snapshot(bool include_feedback) { const auto context = capture("get_snapshot"); const auto &capabilities = context.capabilities; - auto result = context.backend->snapshot(capabilities.model, capabilities.side, capabilities); + auto result = config_.runtime_behavior == RuntimeBehavior::kPython + ? context.backend->filtered_snapshot(capabilities.model, capabilities.side, capabilities, + include_feedback) + : context.backend->snapshot(capabilities.model, capabilities.side, capabilities); const auto health = context.backend->health(); const auto generation = health.connection_generation; const std::lock_guard lock(mutex_); @@ -1273,9 +1307,15 @@ namespace linkerbot::hand { last_snapshot_time_ = latest_sample; snapshot_cv_.notify_all(); } - result.snapshot_sequence = snapshot_sequence_; - result.connection_generation = generation; - result.device_info = identity_; + if (config_.runtime_behavior != RuntimeBehavior::kPython) { + result.snapshot_sequence = snapshot_sequence_; + result.device_info = identity_; + } + result.connection_generation = + config_.runtime_behavior == RuntimeBehavior::kPython ? cache_generation_ : generation; + if (result.device_info && config_.runtime_behavior == RuntimeBehavior::kPython) { + result.device_info->meta.connection_generation = result.connection_generation; + } const auto now = std::chrono::steady_clock::now(); const auto complete_meta = [&](auto &sample, TelemetrySemantic semantic) { @@ -1283,7 +1323,7 @@ namespace linkerbot::hand { return; } auto &meta = sample->meta; - if (meta.sequence == 0) { + if (meta.sequence == 0 && config_.runtime_behavior != RuntimeBehavior::kPython) { meta.sequence = result.snapshot_sequence; } meta.connection_generation = result.connection_generation; @@ -1327,8 +1367,11 @@ namespace linkerbot::hand { if (!cache_valid_ || cached_source_sequence_ != result.snapshot_sequence) { ++cache_revision_; } - retained.snapshot_sequence = cache_revision_; - cached_snapshot_ = std::move(retained); + retained.snapshot_sequence = + config_.runtime_behavior == RuntimeBehavior::kPython ? result.snapshot_sequence : cache_revision_; + if (include_feedback) { + cached_snapshot_ = std::move(retained); + } cached_source_sequence_ = result.snapshot_sequence; cache_valid_ = true; return result; @@ -1360,6 +1403,9 @@ namespace linkerbot::hand { {"wait_for_snapshot", unified_detail::model_name(impl_->config_.model)}); } + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython) { + return wait_for_cached_snapshot(previous, timeout, true); + } const auto changed = [&previous](const HandSnapshot ¤t) { return current.connection_generation != previous.connection_generation || current.snapshot_sequence != previous.snapshot_sequence; @@ -1380,6 +1426,49 @@ namespace linkerbot::hand { return current; } + HandSnapshot HandDevice::wait_for_cached_snapshot(const HandSnapshot &previous, std::chrono::milliseconds timeout, + bool wake_on_command_feedback) const { + if (timeout.count() <= 0) { + throw ValidationError("snapshot wait timeout must be positive"); + } + if (impl_->config_.runtime_behavior != RuntimeBehavior::kPython) { + if (!wake_on_command_feedback) { + throw UnsupportedOperationError("filtered waits require the Python runtime policy"); + } + return wait_for_snapshot(previous, timeout); + } + const auto deadline = runtime::deadline_after(timeout); + const auto changed = [&previous, wake_on_command_feedback](const HandSnapshot &value) { + return value.connection_generation != previous.connection_generation || + (wake_on_command_feedback ? value.snapshot_sequence != previous.snapshot_sequence + : value.snapshot_sequence > previous.snapshot_sequence); + }; + auto current = get_cached_snapshot(); + if (!impl_->open_.load(std::memory_order_acquire)) { + return current; + } + try { + const auto context = impl_->capture("wait_for_snapshot"); + current = impl_->snapshot(wake_on_command_feedback); + if (changed(current)) { + return current; + } + context.backend->wait_publication(previous.snapshot_sequence, deadline, wake_on_command_feedback, + [owner = impl_, epoch = context.generation] { + return owner->generation_.load(std::memory_order_acquire) == epoch && + owner->open_.load(std::memory_order_acquire); + }); + if (!impl_->open_.load(std::memory_order_acquire)) { + return get_cached_snapshot(); + } + current = impl_->snapshot(wake_on_command_feedback); + // A timeout returns the latest cache, including retained command feedback. + return changed(current) ? current : get_cached_snapshot(); + } catch (const DeviceNotReadyError &) { + return get_cached_snapshot(); + } + } + void HandDevice::start_polling() { start_polling({PollingChannel{TelemetrySemantic::kAngles, impl_->config_.polling_interval}}); } @@ -1401,6 +1490,19 @@ namespace linkerbot::hand { std::set unique; for (const auto &channel : plan) { + if (channel.angle_feedback != AngleFeedbackPolicy::kQueryOnly && + channel.angle_feedback != AngleFeedbackPolicy::kCommandFeedbackOnly && + channel.angle_feedback != AngleFeedbackPolicy::kAdaptive) { + throw PollingPlanError("unknown angle feedback policy"); + } + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && + channel.semantic == TelemetrySemantic::kAngles && + channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly) { + const auto *command = capabilities.command(CommandSemantic::kAngles); + if (!command || !command->returns_current_state) { + throw PollingPlanError("angle command does not return verified current state"); + } + } if (channel.interval.count() <= 0) { throw PollingPlanError("polling intervals must be positive", {"start_polling", unified_detail::model_name(impl_->config_.model)}); @@ -1574,6 +1676,9 @@ namespace linkerbot::hand { impl_->verified_capabilities_ = std::move(initialized.capabilities); // Advance the readable generation only after initialization state is installed. ++impl_->cache_generation_; + if (python && impl_->identity_) { + impl_->identity_->meta.connection_generation = impl_->cache_generation_; + } impl_->cache_physical_generation_ = physical_generation; impl_->cached_source_sequence_.reset(); impl_->state_.store(Impl::State::kReady, std::memory_order_release); diff --git a/src/hand/unified/force.cpp b/src/hand/unified/force.cpp index 94f2541e..502ed184 100644 --- a/src/hand/unified/force.cpp +++ b/src/hand/unified/force.cpp @@ -126,6 +126,57 @@ namespace linkerbot::hand::unified_detail { target.unsupported_fields.end()); } + void merge_python_force_samples(ForceSample &target, const ForceSample &source) { + if (target.fields.empty()) { + target = source; + return; + } + if (!target.meta.collected_at || + (source.meta.collected_at && *source.meta.collected_at > *target.meta.collected_at)) { + target.meta = source.meta; + } + if (!source.tactile.empty() || !source.sensors.empty() || (target.tactile.empty() && target.sensors.empty())) { + target.raw = source.raw; + target.shape = source.shape; + target.tactile = source.tactile; + target.sensors = source.sensors; + } + std::vector queries; + for (const auto &field : source.fields) { + queries.push_back(field.query_channel); + } + // Descriptor responses can stop exposing a previously present quantity. + // All descriptor fields belong to the same physical force query. + if (!source.sensors.empty() || !source.tactile.empty()) { + queries.push_back(TelemetrySemantic::kForce); + } + target.fields.erase(std::remove_if(target.fields.begin(), target.fields.end(), + [&](const auto &field) { + return std::find(queries.begin(), queries.end(), field.query_channel) != + queries.end(); + }), + target.fields.end()); + for (const auto &field : source.fields) { + auto found = std::find_if(target.fields.begin(), target.fields.end(), + [&](const auto &old) { return old.name == field.name; }); + if (found == target.fields.end()) { + target.fields.push_back(field); + } else { + *found = field; + } + } + for (const auto &name : source.unsupported_fields) { + if (std::find(target.unsupported_fields.begin(), target.unsupported_fields.end(), name) == + target.unsupported_fields.end()) { + target.unsupported_fields.push_back(name); + } + } + target.unsupported_fields.erase(std::remove_if(target.unsupported_fields.begin(), + target.unsupported_fields.end(), + [&](const auto &name) { return target.field(name); }), + target.unsupported_fields.end()); + } + ForceSample tip_force_sample(const l20lite::TipForceData &data, SampleMeta meta, const DeviceCapabilities &capabilities) { ForceSample result; diff --git a/src/hand/unified/python_state.cpp b/src/hand/unified/python_state.cpp new file mode 100644 index 00000000..d4321aa5 --- /dev/null +++ b/src/hand/unified/python_state.cpp @@ -0,0 +1,473 @@ +#include "detail/python_state.hpp" +#include "detail/cached_snapshot.hpp" +#include "detail/force.hpp" +#include "detail/freshness.hpp" +#include "detail/python_transactions.hpp" +#include +#include + +namespace linkerbot::hand::unified_detail { + namespace { + using S = TelemetrySemantic; + bool force_scalar(S semantic) { + return semantic == S::kForceFns || semantic == S::kForceFts || semantic == S::kForceFds || + semantic == S::kForcePx; + } + bool same_plan(const PollingPlan &left, const PollingPlan &right) { + if (left.size() != right.size()) { + return false; + } + return std::all_of(left.begin(), left.end(), [&](const auto &entry) { + return std::any_of(right.begin(), right.end(), [&](const auto &other) { + return entry.semantic == other.semantic && entry.interval == other.interval && + entry.angle_feedback == other.angle_feedback; + }); + }); + } + std::string health_name(S semantic) { + switch (semantic) { + case S::kAngles: + return "angles"; + case S::kMeasuredSpeeds: + return "speeds"; + case S::kCurrents: + return "currents"; + case S::kTemperatures: + return "temperatures"; + case S::kFaults: + return "faults"; + case S::kTorques: + return "torques"; + case S::kVoltages: + return "voltages"; + default: + return python_polling_domain(semantic); + } + } + } + PythonState::PythonState(const HandDeviceConfig &config) : config_(config) { + const auto caps = declared_capabilities(config); + for (const auto &channel : caps.telemetry) { + domains_.emplace(domain(channel.semantic), channel.semantic); + } + } + std::string PythonState::domain(S semantic) const { + if (config_.model == Model::kO30 && semantic != S::kForce && semantic != S::kForceFns) { + return o30::v25::domain(o30::v25::object(semantic)); + } + if (config_.model == Model::kL20Lite && force_scalar(semantic)) { + return "python/" + std::string(python_polling_domain(semantic)); + } + std::string result = python_polling_domain(semantic); + if (semantic == S::kConfiguredSpeeds) { + result = config_.model == Model::kL20Lite || config_.model == Model::kL30 ? "configured_speed" : "speed"; + } else if (semantic == S::kForce) { + result = config_.model == Model::kL6 ? "tactile" : "force_sensor_all"; + } + if (config_.model == Model::kL30) { + result = "l30." + (semantic == S::kForce ? std::string("force_sensor") : result); + } + if (config_.model == Model::kO30 && (semantic == S::kForce || semantic == S::kForceFns)) { + result = "force"; + } + return result; + } + void PythonState::visible_locked(bool command_feedback) { + ++current_.sequence; + if (!command_feedback) { + non_command_ = current_; + } + changed_.notify_all(); + } + void PythonState::publish(const std::string &name, const runtime::Sample &sample) { + const std::lock_guard lock(mutex_); + if (stopped_) { + return; + } + const auto found = domains_.find(name); + const bool identity = name == "python/identity"; + if (!sample.valid) { + // Internal optional-feedback timeout notification. Keep the last + // successful acquisition and count only the active angle policy. + if (found != domains_.end() && found->second == S::kAngles) { + if (auto poll = polls_.find(S::kAngles); poll != polls_.end()) { + ++poll->second.timeouts; + } + } + return; + } + // Native domains are SDK-owned and protocol-bounded, not user queues. + current_.samples[name] = sample; + if (found == domains_.end() && !identity) { + return; + } + if (identity) { + auto value = std::any_cast(sample.value); + value.meta.sequence = ++identity_sequence_; + current_.samples[name].value = std::move(value); + } + const bool command = sample.origin == protocol::Origin::command_feedback; + if (found != domains_.end()) { + const auto semantic = found->second; + auto &meta = current_.metadata[semantic]; + meta.sequence = ++sequences_[semantic]; + meta.collected_at = sample.received_at; + meta.connection_generation = sample.device_generation; + meta.valid = true; + meta.stale = false; + meta.delivery = command ? TelemetryDelivery::kCommandResponse + : sample.origin == protocol::Origin::report ? TelemetryDelivery::kPeriodicReport + : TelemetryDelivery::kQueryResponse; + if (auto poll = polls_.find(semantic); poll != polls_.end() && poll->second.enabled) { + auto &state = poll->second; + if (command && semantic == S::kAngles) { + ++state.commands; + if (state.channel.angle_feedback != AngleFeedbackPolicy::kQueryOnly) { + ++state.feedback; + state.next = std::max(state.next, runtime::Clock::now() + state.channel.interval); + } + } else if (!command && !(semantic == S::kAngles && + state.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly)) { + ++state.feedback; + } + } + } + visible_locked(command); + } + PythonState::View PythonState::view(bool include_feedback) const { + const std::lock_guard lock(mutex_); + return include_feedback ? current_ : non_command_; + } + void PythonState::wait(uint64_t sequence, runtime::Deadline deadline, bool include_feedback, + const std::function &generation_valid) const { + std::unique_lock lock(mutex_); + changed_.wait_until(lock, deadline, [&] { + return stopped_ || (generation_valid && !generation_valid()) || + (include_feedback ? current_.sequence != sequence : non_command_.sequence > sequence); + }); + } + void PythonState::stop() { + const std::lock_guard lock(mutex_); + stopped_ = true; + changed_.notify_all(); + } + void PythonState::reset() { + const std::lock_guard lock(mutex_); + current_ = {}; + non_command_ = {}; + sequences_.clear(); + verified_.clear(); + failures_.clear(); + disabled_.clear(); + polls_.clear(); + plan_.reset(); + identity_sequence_ = 0; + stopped_ = false; + ready_ = false; + changed_.notify_all(); + } + void PythonState::resume() { + const std::lock_guard lock(mutex_); + stopped_ = false; + } + void PythonState::initialized() { + const std::lock_guard lock(mutex_); + ready_ = true; + } + bool PythonState::optional(S semantic) const { + if (semantic == S::kForce) { + return config_.model != Model::kL30; + } + if (config_.model == Model::kL6) { + return semantic == S::kTorques || semantic == S::kPositionKp || semantic == S::kPositionKd || + semantic == S::kTorqueKd || semantic == S::kTemperatures || semantic == S::kCurrents || + semantic == S::kLimitCompensations || semantic == S::kStallDetectionTimes || + semantic == S::kStallThresholds || semantic == S::kStallHoldCurrents; + } + if (config_.model == Model::kO6) { + return semantic == S::kConfiguredSpeeds || semantic == S::kConfiguredAccelerations || + semantic == S::kTemperatures || semantic == S::kStallDetectionTimes || + semantic == S::kStallThresholds || semantic == S::kStallHoldCurrents; + } + if (config_.model == Model::kL20Lite || config_.model == Model::kL20) { + return semantic == S::kMeasuredSpeeds || semantic == S::kTemperatures || + (config_.model == Model::kL20Lite && force_scalar(semantic)); + } + return false; + } + bool PythonState::disabled(S semantic) const { + const std::lock_guard lock(mutex_); + return disabled_.count(semantic) != 0; + } + void PythonState::disable_locked(S semantic) { + disabled_.insert(semantic); + failures_.erase(semantic); + if (auto poll = polls_.find(semantic); poll != polls_.end()) { + poll->second.enabled = false; + } + visible_locked(false); + } + void PythonState::read_succeeded(S semantic, bool polling) { + const std::lock_guard lock(mutex_); + if (optional(semantic)) { + failures_.erase(semantic); + verified_.insert(semantic); + } + if (auto poll = polls_.find(semantic); poll != polls_.end()) { + ++poll->second.reads; + if (!polling && !(semantic == S::kAngles && + poll->second.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly)) { + poll->second.next = std::max(poll->second.next, runtime::Clock::now() + poll->second.channel.interval); + } + } + } + void PythonState::read_failed(S semantic, bool sent, const HandError &error, bool polling) { + const std::lock_guard lock(mutex_); + if (polling) { + if (auto poll = polls_.find(semantic); poll != polls_.end()) { + ++poll->second.timeouts; + } + } + if (!ready_ || !sent || !optional(semantic) || verified_.count(semantic) || disabled_.count(semantic)) { + return; + } + const auto status = error.context().native_status.value_or(0); + const bool unavailable_o30 = config_.model == Model::kO30 && semantic == S::kForce && + (error.code() == ErrorCode::kUnsupportedOperation || + (error.code() == ErrorCode::kProtocol && (status & 0x03) && !(status & ~0x23))); + if (unavailable_o30 || error.code() == ErrorCode::kUnsupportedFirmware || + ((error.code() == ErrorCode::kTimeout || error.code() == ErrorCode::kProtocol) && + ++failures_[semantic] >= 3)) { + disable_locked(semantic); + throw UnsupportedFirmwareError("firmware does not support this optional query", error.context(), + std::current_exception()); + } + } + DeviceCapabilities PythonState::capabilities(DeviceCapabilities caps) const { + const std::lock_guard lock(mutex_); + caps.firmware_unsupported_telemetry.assign(disabled_.begin(), disabled_.end()); + auto fields = caps.force_fields(); + for (auto &field : fields) { + if (field.query_channel && disabled_.count(*field.query_channel)) { + field.supported = false; + field.firmware_unsupported = true; + } + } + caps.observed_force_fields = std::move(fields); + return caps; + } + bool PythonState::replace(const PollingPlan &plan) { + const std::lock_guard lock(mutex_); + if (plan_ && same_plan(*plan_, plan)) { + return false; + } + std::map next; + const auto now = runtime::Clock::now(); + for (const auto &channel : plan) { + next.emplace(channel.semantic, + Poll{channel, + now + (channel.semantic == S::kAngles && + channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly + ? channel.interval + : std::chrono::milliseconds{0}), + now}); + } + plan_ = plan; + polls_ = std::move(next); + return true; + } + void PythonState::stop_polling() { + const std::lock_guard lock(mutex_); + plan_.reset(); + polls_.clear(); + } + std::optional PythonState::next_poll() const { + const std::lock_guard lock(mutex_); + std::optional result; + for (const auto &[semantic, state] : polls_) { + (void)semantic; + if (state.enabled && (!result || state.next < *result)) { + result = state.next; + } + } + return result; + } + std::optional PythonState::take_poll() { + const std::lock_guard lock(mutex_); + const auto now = runtime::Clock::now(); + auto selected = polls_.end(); + for (auto it = polls_.begin(); it != polls_.end(); ++it) { + if (it->second.enabled && it->second.next <= now && + (selected == polls_.end() || it->second.next < selected->second.next)) { + selected = it; + } + } + if (selected == polls_.end()) { + return std::nullopt; + } + auto &state = selected->second; + const auto periods = static_cast((now - state.next) / state.channel.interval) + 1; + state.next += state.channel.interval * periods; + const bool command_only = + selected->first == S::kAngles && state.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly; + state.missed += command_only ? periods : periods - 1; + return command_only ? std::nullopt : std::optional{selected->first}; + } + void PythonState::project_health(HandHealth &health) const { + const std::lock_guard lock(mutex_); + const auto now = runtime::Clock::now(); + health.polling_active = + std::any_of(polls_.begin(), polls_.end(), [](const auto &item) { return item.second.enabled; }); + for (const auto &[semantic, state] : polls_) { + const auto name = health_name(semantic); + auto found = std::find_if(health.channels.begin(), health.channels.end(), + [&](const auto &c) { return c.channel == name; }); + if (found == health.channels.end()) { + health.channels.push_back({}); + found = std::prev(health.channels.end()); + found->channel = name; + } + found->polling_active = state.enabled; + found->supported = disabled_.count(semantic) == 0; + found->requested_hz = state.enabled ? 1000.0 / state.channel.interval.count() : 0; + const auto seconds = std::chrono::duration(now - state.started).count(); + found->achieved_hz = seconds > 0 ? state.feedback / seconds : 0; + found->completed_reads = state.reads; + found->feedback_samples = state.feedback; + found->command_feedback_samples = state.commands; + found->missed_deadlines = state.missed; + found->timeouts = state.timeouts; + if (semantic == S::kAngles) { + found->feedback_policy = state.channel.angle_feedback; + } + } + } + void PythonState::project(HandSnapshot &snapshot, const View &view, const DeviceCapabilities &caps) const { + snapshot.snapshot_sequence = view.sequence; + visit_snapshot_samples(snapshot, [&](auto &sample, S semantic) { + if (!sample) { + return; + } + const auto found = view.metadata.find(semantic); + if (found != view.metadata.end()) { + const auto origin = sample->meta.origin; + const auto range = sample->meta.within_declared_range; + sample->meta = found->second; + sample->meta.origin = origin; + sample->meta.within_declared_range = range; + complete_freshness(sample->meta, caps.telemetry_channel(semantic), runtime::Clock::now()); + } + }); + if (const auto identity = view.samples.find("python/identity"); identity != view.samples.end()) { + snapshot.device_info = std::any_cast(identity->second.value); + // Identity owns its logical sequence, independently of native queries. + snapshot.device_info->meta.connection_generation = identity->second.device_generation; + complete_freshness(snapshot.device_info->meta, nullptr, runtime::Clock::now()); + } + std::vector> scalars; + for (const auto semantic : {S::kForceFns, S::kForceFts, S::kForceFds, S::kForcePx}) { + if (config_.model != Model::kL20Lite) { + break; + } + const auto scalar = view.samples.find(domain(semantic)); + if (scalar != view.samples.end() && scalar->second.value.type() == typeid(ForceSample)) { + scalars.emplace_back(semantic, scalar->second); + } + } + std::sort(scalars.begin(), scalars.end(), [](const auto &left, const auto &right) { + return left.second.received_at < right.second.received_at; + }); + for (const auto &[semantic, scalar] : scalars) { + auto sample = std::any_cast(scalar.value); + if (const auto meta = view.metadata.find(semantic); meta != view.metadata.end()) { + sample.meta = meta->second; + sample.meta.origin = TelemetryValueOrigin::kDeviceMeasured; + for (auto &field : sample.fields) { + field.meta = sample.meta; + } + } + if (!snapshot.force) { + snapshot.force = sample; + } else { + merge_python_force_samples(*snapshot.force, sample); + } + } + + if (!snapshot.force && caps.telemetry_channel(S::kForce)) { + snapshot.force.emplace(); + } + if (snapshot.force) { + for (auto &field : snapshot.force->fields) { + const auto meta = view.metadata.find(field.query_channel); + if (meta != view.metadata.end()) { + const auto origin = field.meta.origin; + field.meta = meta->second; + field.meta.origin = + origin == TelemetryValueOrigin::kNone ? TelemetryValueOrigin::kDeviceMeasured : origin; + } + } + for (const auto &field : caps.force_fields()) { + if (field.supported == false && !snapshot.force->field(field.name) && + std::find(snapshot.force->unsupported_fields.begin(), snapshot.force->unsupported_fields.end(), + field.name) == snapshot.force->unsupported_fields.end()) { + snapshot.force->unsupported_fields.emplace_back(field.name); + } + } + complete_force_meta(*snapshot.force, snapshot.force->meta.connection_generation, caps); + complete_freshness(snapshot.force->meta, caps.telemetry_channel(S::kForce), runtime::Clock::now()); + } + } +} + +namespace linkerbot::hand::unified_detail { + void read_python_scalar(runtime::DeviceRuntime &device, const HandDeviceConfig &config, + TelemetrySemantic semantic) { + const auto caps = declared_capabilities(config); + const auto field = semantic == TelemetrySemantic::kForceFns ? "fns" + : semantic == TelemetrySemantic::kForceFts ? "fts" + : semantic == TelemetrySemantic::kForceFds ? "fds" + : "px"; + const uint8_t opcode = semantic == TelemetrySemantic::kForceFns ? 0x20 + : semantic == TelemetrySemantic::kForceFts ? 0x21 + : semantic == TelemetrySemantic::kForceFds ? 0x22 + : 0x23; + const auto id = caps.transport.request_id.value_or(config.side == HandSide::kLeft ? 0x28U : 0x27U); + runtime::TransactionPlan plan; + plan.domain = "python/" + std::string(python_polling_domain(semantic)); + plan.keys = {plan.domain}; + can::Message request{}; + request.id = id; + request.dlc = 1; + request.data[0] = opcode; + request.format = can::FrameFormat::kClassic; + plan.frames = {request}; + plan.consume = [id, opcode, semantic, field, + names = caps.force_field(field).sensor_names](const can::Message &message) { + if (message.id != id || message.is_fd || message.is_extended || message.is_brs || message.dlc == 0 || + message.data[0] != opcode) { + return protocol::Progress::unmatched(); + } + if (message.dlc != 6) { + throw ProtocolError("force scalar response must contain five values"); + } + ForceSample result; + result.shape = {5}; + ForceFieldSample sample; + sample.name = field; + sample.query_channel = semantic; + for (size_t i = 0; i < 5; ++i) { + const auto value = message.data[i + 1]; + result.raw.push_back(value); + sample.sensor_names.emplace_back(names.at(i)); + sample.raw.push_back(std::vector{static_cast(value)}); + sample.units.push_back(std::nullopt); + sample.encodings.emplace_back("u8"); + sample.valid_values.push_back( + std::vector{semantic != TelemetrySemantic::kForceFds || value <= 127}); + } + result.fields.push_back(std::move(sample)); + return protocol::Progress::complete(std::move(result)); + }; + (void)device.execute(std::move(plan), runtime::Deadline::max()); + } +} diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index ca7b7938..0b3f39dc 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4030,3 +4030,116 @@ TEST_CASE("Python runtime initializes reads and recovers each Classic and L30 mo REQUIRE_FALSE(device.is_open()); } } + +TEST_CASE("Python published metadata is stable across Classic and L30 models", "[comm][python-phase3]") { + using namespace std::chrono_literals; + for (const auto model : {Model::kL6, Model::kO6, Model::kL20Lite, Model::kL20, Model::kL30}) { + CAPTURE(model); + auto config = config_for(model); + config.runtime_behavior = RuntimeBehavior::kPython; + config.response_timeout = 200ms; + std::shared_ptr hop; + HandDevice device(config, [&] { + auto transport = std::make_unique(); + install_fake_device(model, transport.get(), hop); + return transport; + }); + device.open(); + const auto initial = device.get_cached_snapshot(); + REQUIRE((initial.angles->meta.sequence == 1)); + REQUIRE((initial.faults->meta.sequence == 1)); + (void)device.get_angles(2s); + (void)device.get_angles(2s); + const auto updated = device.get_cached_snapshot(); + REQUIRE((updated.angles->meta.sequence == 3)); + REQUIRE((updated.faults->meta.sequence == 1)); + REQUIRE((updated.device_info->meta.sequence == 1)); + REQUIRE((updated.device_info->meta.valid)); + device.close(); + device.open(); + const auto reopened = device.get_cached_snapshot(); + REQUIRE((reopened.connection_generation == 2)); + REQUIRE((reopened.angles->meta.sequence == 1)); + REQUIRE((reopened.angles->meta.connection_generation == 2)); + REQUIRE((reopened.device_info->meta.connection_generation == 2)); + REQUIRE((device.get_identity()->meta.connection_generation == 2)); + REQUIRE((device.get_angles(2s).meta.connection_generation == 2)); + device.close(); + } +} + +TEST_CASE("Python L20Lite scalar queries retain independent data and degradation", "[comm][python-phase3]") { + using namespace std::chrono_literals; + auto config = config_for(Model::kL20Lite); + config.runtime_behavior = RuntimeBehavior::kPython; + config.response_timeout = 30ms; + FakeTransport *transport = nullptr; + std::shared_ptr hop; + HandDevice device(config, [&] { + auto next = std::make_unique(); + transport = next.get(); + install_fake_device(Model::kL20Lite, transport, hop); + return next; + }); + device.open(); + const auto count = transport->sent().size(); + const auto normal = device.get_force({"fns"}, 1s); + REQUIRE((transport->sent().size() == count + 1)); + REQUIRE((transport->sent().back().data[0] == 0x20)); + REQUIRE((normal.field("fns") != nullptr)); + REQUIRE((normal.field("fts") == nullptr)); + const auto sequence = normal.field("fns")->meta.sequence; + const auto tangential = device.get_force({"fts"}, 1s); + REQUIRE((transport->sent().back().data[0] == 0x21)); + REQUIRE((tangential.field("fns") == nullptr)); + const auto cached = device.get_cached_snapshot(); + REQUIRE((cached.force->field("fns")->meta.sequence == sequence)); + REQUIRE((cached.force->field("fts")->meta.sequence == 1)); + transport->on_request(0x28, [](const Message &request) { + if (request.data[0] == 0x23) { + return std::vector{}; + } + return classic_angle_reply(Model::kL20Lite, request); + }); + REQUIRE_THROWS_AS(device.get_force({"px"}, 1s), TimeoutError); + REQUIRE_THROWS_AS(device.get_force({"px"}, 1s), TimeoutError); + REQUIRE_THROWS_AS(device.get_force({"px"}, 1s), UnsupportedFirmwareError); + REQUIRE((device.get_capabilities().force_field("px").firmware_unsupported)); + REQUIRE((device.get_capabilities().force_field("fns").supported == true)); + REQUIRE((device.get_force({"fns"}, 1s).field("fns")->meta.sequence > sequence)); + REQUIRE((device.get_cached_snapshot().force->status("px") == "unsupported")); + device.close(); +} + +TEST_CASE("Python optional polling degradation preserves other poll channels and resets on recovery", + "[comm][python-phase3]") { + using namespace std::chrono_literals; + auto config = config_for(Model::kL6); + config.runtime_behavior = RuntimeBehavior::kPython; + 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::kL6, transport, hop); + return next; + }); + device.open(); + transport->on_request(0x28, [](const Message &request) { + if (request.data[0] == 0x36) { + return std::vector{}; + } + return classic_angle_reply(Model::kL6, request); + }); + device.start_polling({{TelemetrySemantic::kCurrents, 10ms}, {TelemetrySemantic::kAngles, 10ms}}); + REQUIRE((wait_until([&] { return !device.get_capabilities().supports(TelemetrySemantic::kCurrents); }))); + REQUIRE((device.get_health().connected())); + const auto before = device.get_cached_snapshot().angles->meta.sequence; + REQUIRE((wait_until([&] { return device.get_cached_snapshot().angles->meta.sequence > before; }))); + device.stop_polling(); + device.recover_for(2s); + REQUIRE((device.get_capabilities().supports(TelemetrySemantic::kCurrents))); + REQUIRE((device.get_currents(1s).meta.valid)); + device.close(); +} diff --git a/tests/hand/device/test_python_publication.cpp b/tests/hand/device/test_python_publication.cpp new file mode 100644 index 00000000..cdf60739 --- /dev/null +++ b/tests/hand/device/test_python_publication.cpp @@ -0,0 +1,353 @@ +#include "../../../src/hand/unified/detail/force.hpp" +#include "../../../src/hand/unified/detail/python_state.hpp" +#include "../../../src/hand/unified/detail/python_transactions.hpp" +#include "../../../validation/fake_classic_hand.hpp" +#include "hand/support/optional_value.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace linkerbot::hand; +using namespace std::chrono_literals; +using unified_detail::PythonState; +namespace { + HandDeviceConfig profile(Model model = Model::kL6) { + auto config = validation::config(); + config.model = model; + config.runtime_behavior = RuntimeBehavior::kPython; + config.identity_timeout = 100ms; + config.response_timeout = 50ms; + return config; + } + runtime::Sample publication(int value, protocol::Origin origin = protocol::Origin::response) { + return {value, true, runtime::Clock::now(), 1, 1, 1, origin}; + } + template bool eventually(Predicate predicate, std::chrono::milliseconds budget = 1s) { + const auto end = runtime::Clock::now() + budget; + while (!predicate()) { + if (runtime::Clock::now() >= end) { + return false; + } + std::this_thread::sleep_for(1ms); + } + return true; + } +} +static_assert(std::is_same_v); + +TEST_CASE("Python channel sequences count publications independently of cache readers", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto first = device.hand.get_cached_snapshot(); + const auto angle = test_support::require_value(first.angles).meta.sequence; + const auto fault = test_support::require_value(first.faults).meta.sequence; + REQUIRE((angle == 1)); + for (int i = 0; i < 3; ++i) { + (void)device.hand.get_angles(1s); + } + const auto current = device.hand.get_cached_snapshot(); + REQUIRE((test_support::require_value(current.angles).meta.sequence == angle + 3)); + REQUIRE((test_support::require_value(current.faults).meta.sequence == fault)); + REQUIRE((current.snapshot_sequence >= first.snapshot_sequence + 3)); + const auto repeated = device.hand.get_cached_snapshot(); + REQUIRE((repeated.snapshot_sequence == current.snapshot_sequence)); + REQUIRE((repeated.angles->meta.sequence == current.angles->meta.sequence)); +} + +TEST_CASE("Python identity has its own sample sequence and retained invalid evidence", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto before = device.hand.get_cached_snapshot(); + REQUIRE((before.device_info.has_value())); + REQUIRE((before.device_info->meta.valid)); + REQUIRE((before.device_info->meta.sequence == 1)); + const auto refreshed = device.hand.refresh_device_info(1s); + REQUIRE((refreshed.meta.sequence == 2)); + REQUIRE((refreshed.meta.collected_at == refreshed.timestamp)); + REQUIRE((refreshed.meta.connection_generation > 0)); + const auto after = device.hand.get_cached_snapshot(); + REQUIRE((after.angles->meta.sequence == before.angles->meta.sequence)); + REQUIRE((after.snapshot_sequence > before.snapshot_sequence)); + device.hand.close(); + const auto closed = device.hand.get_cached_snapshot(); + REQUIRE((closed.device_info.has_value())); + REQUIRE_FALSE(closed.device_info->meta.valid); + REQUIRE((closed.device_info->serial_number == refreshed.serial_number)); +} + +TEST_CASE("Python publication filtering retains exact non-command revisions", "[comm][python-phase3]") { + PythonState state(profile()); + state.publish("angle", publication(1)); + const auto before = state.view(); + state.publish("angle", publication(2, protocol::Origin::command_feedback)); + REQUIRE((state.view().sequence == before.sequence + 1)); + REQUIRE((state.view(false).sequence == before.sequence)); + REQUIRE((std::any_cast(state.view(false).samples.at("angle").value) == 1)); + state.publish("fault", publication(3)); + const auto non_command = state.view(false); + REQUIRE((non_command.sequence == before.sequence + 2)); + REQUIRE((std::any_cast(non_command.samples.at("angle").value) == 2)); +} + +TEST_CASE("Python filtered waiter does not busy-return for a newer command-only snapshot", "[comm][python-phase3]") { + PythonState state(profile()); + state.publish("angle", publication(1)); + state.publish("angle", publication(2, protocol::Origin::command_feedback)); + auto waited = + std::async(std::launch::async, [&] { state.wait(state.view().sequence, runtime::Clock::now() + 1s, false); }); + REQUIRE((waited.wait_for(20ms) == std::future_status::timeout)); + state.stop(); + REQUIRE((waited.wait_for(1s) == std::future_status::ready)); + waited.get(); +} + +TEST_CASE("Python retained-cache waiter wakes on close without device I/O", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto previous = device.hand.get_cached_snapshot(); + const auto count = device.transport->sent().size(); + auto waiter = + std::async(std::launch::async, [&] { return device.hand.wait_for_cached_snapshot(previous, 2s, false); }); + REQUIRE((waiter.wait_for(20ms) == std::future_status::timeout)); + device.hand.close(); + REQUIRE((waiter.wait_for(1s) == std::future_status::ready)); + const auto stopped = waiter.get(); + REQUIRE_FALSE(stopped.angles->meta.valid); + REQUIRE((count > 0)); +} + +TEST_CASE("Python optional failure degradation counts sent transactions and keeps peers ready", + "[comm][python-phase3]") { + validation::FakeHand device(profile()); + device.transport->on_request(0x28, [](const auto &request) { + if (request.data[0] == 0x36) { + return std::vector{}; + } + return validation::l6_reply(request); + }); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), UnsupportedFirmwareError); + const auto count = device.transport->sent().size(); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), UnsupportedFirmwareError); + REQUIRE((device.transport->sent().size() == count)); + REQUIRE((device.hand.is_open())); + REQUIRE((device.hand.get_angles(1s).meta.valid)); + const auto caps = device.hand.get_capabilities(); + REQUIRE_FALSE(caps.supports(TelemetrySemantic::kCurrents)); + REQUIRE((caps.supports(TelemetrySemantic::kAngles))); +} + +TEST_CASE("Python verified optional channels are not disabled by later response loss", "[comm][python-phase3]") { + PythonState state(profile()); + state.initialized(); + state.read_succeeded(TelemetrySemantic::kCurrents, false); + for (int i = 0; i < 5; ++i) { + state.read_failed(TelemetrySemantic::kCurrents, true, TimeoutError("lost"), false); + } + REQUIRE_FALSE(state.disabled(TelemetrySemantic::kCurrents)); + for (int i = 0; i < 5; ++i) { + state.read_failed(TelemetrySemantic::kTemperatures, false, TimeoutError("not sent"), false); + } + REQUIRE_FALSE(state.disabled(TelemetrySemantic::kTemperatures)); + for (int i = 0; i < 5; ++i) { + state.read_failed(TelemetrySemantic::kTemperatures, true, TransportError("offline"), false); + } + REQUIRE_FALSE(state.disabled(TelemetrySemantic::kTemperatures)); +} + +TEST_CASE("Python optional degradation is scoped to a connection generation", "[comm][python-phase3]") { + PythonState state(profile()); + state.initialized(); + state.read_failed(TelemetrySemantic::kCurrents, true, TimeoutError("lost"), false); + state.read_failed(TelemetrySemantic::kCurrents, true, ProtocolError("bad"), false); + REQUIRE_THROWS_AS(state.read_failed(TelemetrySemantic::kCurrents, true, TimeoutError("lost"), false), + UnsupportedFirmwareError); + REQUIRE((state.disabled(TelemetrySemantic::kCurrents))); + state.reset(); + state.initialized(); + REQUIRE_FALSE(state.disabled(TelemetrySemantic::kCurrents)); +} + +TEST_CASE("Python adaptive angle feedback defers queries but query-only does not", "[comm][python-phase3]") { + for (const auto policy : {AngleFeedbackPolicy::kAdaptive, AngleFeedbackPolicy::kQueryOnly}) { + PythonState state(profile()); + REQUIRE((state.replace({{TelemetrySemantic::kAngles, 200ms, policy}}))); + const auto due = *state.next_poll(); + state.publish("angle", publication(1, protocol::Origin::command_feedback)); + REQUIRE(((*state.next_poll() > due) == (policy == AngleFeedbackPolicy::kAdaptive))); + HandHealth health; + state.project_health(health); + REQUIRE((health.channels.at(0).command_feedback_samples == 1)); + REQUIRE((health.channels.at(0).feedback_samples == (policy == AngleFeedbackPolicy::kAdaptive ? 1 : 0))); + } +} + +TEST_CASE("Python identical polling plans preserve deadlines and metrics", "[comm][python-phase3]") { + PythonState state(profile()); + const PollingPlan plan{{TelemetrySemantic::kAngles, 100ms, AngleFeedbackPolicy::kAdaptive}}; + REQUIRE((state.replace(plan))); + state.publish("angle", publication(1)); + const auto next = state.next_poll(); + REQUIRE_FALSE(state.replace(plan)); + REQUIRE((state.next_poll() == next)); + HandHealth health; + state.project_health(health); + REQUIRE((health.channels.at(0).feedback_samples == 1)); +} + +TEST_CASE("Python command-only polling emits no angle queries and rejects illegal policies", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto count = device.transport->sent().size(); + device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, AngleFeedbackPolicy::kCommandFeedbackOnly}}); + std::this_thread::sleep_for(30ms); + REQUIRE((device.transport->sent().size() == count)); + REQUIRE_THROWS_AS( + device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, static_cast(255)}}), + PollingPlanError); + REQUIRE((device.hand.get_health().polling_active)); + device.hand.stop_polling(); +} + +TEST_CASE("Python filtered facade wait retains command feedback until a query wakes it", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto before = device.hand.get_cached_snapshot(); + auto waiter = + std::async(std::launch::async, [&] { return device.hand.wait_for_cached_snapshot(before, 2s, false); }); + auto command = device.hand.set_angles(std::vector(6, 11)); + validation::complete(command); + REQUIRE((eventually([&] { + return device.hand.get_cached_snapshot().angles->meta.delivery == TelemetryDelivery::kCommandResponse; + }))); + REQUIRE((waiter.wait_for(20ms) == std::future_status::timeout)); + (void)device.hand.get_faults(1s); + REQUIRE((waiter.wait_for(1s) == std::future_status::ready)); + const auto value = waiter.get(); + REQUIRE((value.snapshot_sequence > before.snapshot_sequence)); + REQUIRE((value.angles->raw == std::vector(6, 11))); +} + +TEST_CASE("force aggregation replaces one query and updates scalar raw evidence", "[comm][python-phase3]") { + ForceSample previous, next; + ForceFieldSample fns; + fns.name = "fns"; + fns.query_channel = TelemetrySemantic::kForceFns; + fns.raw = {std::vector{1}}; + previous.raw = {1}; + previous.fields = {fns}; + fns.raw = {std::vector{2}}; + next.raw = {2}; + next.fields = {fns}; + unified_detail::merge_python_force_samples(previous, next); + REQUIRE((previous.raw == std::vector{2})); + REQUIRE((previous.fields.front().raw.front() == std::vector{2})); + ForceSample descriptor; + ForceFieldSample fn; + fn.name = "fn"; + fn.query_channel = TelemetrySemantic::kForce; + fns.query_channel = TelemetrySemantic::kForce; + previous.fields = {fn, fns}; + descriptor.fields = {fn}; + descriptor.unsupported_fields = {"fns"}; + unified_detail::merge_python_force_samples(previous, descriptor); + REQUIRE((previous.field("fns") == nullptr)); + REQUIRE((previous.status("fns") == "unsupported")); +} + +TEST_CASE("Python tactile wrapper rejects matching malformed rows and ignores unrelated frames", + "[comm][python-phase3]") { + for (const auto model : {Model::kL6, Model::kO6, Model::kL20Lite, Model::kL20}) { + runtime::TransactionPlan native; + native.domain = "force"; + linkerbot::can::Message request{}; + request.id = 0x28; + request.dlc = 2; + request.data[0] = 0xB1; + native.frames = {request}; + native.consume = [](const auto &) { return protocol::Progress::pending(); }; + auto plan = unified_detail::python_transaction(native, profile(model)); + auto short_row = request; + short_row.dlc = 3; + REQUIRE_THROWS_AS(plan.consume(short_row), ProtocolError); + short_row.id = 0x27; + REQUIRE((plan.consume(short_row).kind == protocol::ProgressKind::unmatched)); + auto bad_row = request; + bad_row.dlc = model == Model::kO6 ? 6 : 8; + bad_row.data[1] = 0xFF; + REQUIRE_THROWS_AS(plan.consume(bad_row), ProtocolError); + REQUIRE((native.consume(bad_row).kind == protocol::ProgressKind::pending)); + } +} + +TEST_CASE("Python command-feedback timeout retains the successful sample and sequence", "[comm][python-phase3]") { + PythonState state(profile()); + state.replace({{TelemetrySemantic::kAngles, 100ms, AngleFeedbackPolicy::kAdaptive}}); + state.publish("angle", publication(1)); + const auto before = state.view(); + state.publish("angle", {}); + const auto after = state.view(); + REQUIRE((after.sequence == before.sequence)); + REQUIRE((after.metadata.at(TelemetrySemantic::kAngles).sequence == 1)); + REQUIRE((std::any_cast(after.samples.at("angle").value) == 1)); + HandHealth health; + state.project_health(health); + REQUIRE((health.channels.at(0).timeouts == 1)); +} + +TEST_CASE("Python production tactile assemblers preserve complete data and reject conflicting rows", + "[comm][python-phase3]") { + for (const auto model : {Model::kL6, Model::kO6, Model::kL20Lite, Model::kL20}) { + CAPTURE(model); + runtime::TransactionPlan native; + if (model == Model::kL6) { + native = l6::protocol::read_force(0x28, 0xB1, 100ms); + } + if (model == Model::kO6) { + native = o6::protocol::read_force_sensor(0x28, 0xB1, "force_sensor_thumb"); + } + if (model == Model::kL20Lite) { + native = l20lite::protocol::read_force_sensor(0x28, 0xB1, "force_sensor_thumb"); + } + if (model == Model::kL20) { + native = l20::protocol::read_force_sensor(0x28, 0xB1, "force_sensor_thumb"); + } + auto plan = unified_detail::python_transaction(native, profile(model)); + linkerbot::can::Message row{}; + row.id = 0x28; + row.dlc = model == Model::kO6 ? 6 : 8; + row.data[0] = 0xB1; + REQUIRE((plan.consume(row).kind == protocol::ProgressKind::pending)); + REQUIRE((plan.consume(row).kind == protocol::ProgressKind::pending)); + row.data[2] = 1; + REQUIRE((plan.consume(row).kind == protocol::ProgressKind::failed)); + auto complete = unified_detail::python_transaction(native, profile(model)); + const int rows = model == Model::kO6 ? 10 : 12; + for (int index = 0; index < rows; ++index) { + row.data[1] = static_cast(index); // Python compact row representation. + const auto result = complete.consume(row); + REQUIRE((result.kind == + (index + 1 == rows ? protocol::ProgressKind::complete : protocol::ProgressKind::pending))); + } + } +} + +TEST_CASE("Python O30 force absence degrades immediately without changing other capabilities", + "[comm][python-phase3]") { + auto config = profile(Model::kO30); + config.o30_layout = O30Layout::kPythonV25; + PythonState state(config); + state.initialized(); + ErrorContext context; + context.native_status = 0x21; + REQUIRE_THROWS_AS( + state.read_failed(TelemetrySemantic::kForce, true, ProtocolError("object unavailable", context), false), + UnsupportedFirmwareError); + const auto caps = state.capabilities(declared_capabilities(config)); + REQUIRE_FALSE(caps.supports(TelemetrySemantic::kForce)); + REQUIRE((caps.supports(TelemetrySemantic::kAngles))); + HandSnapshot snapshot; + REQUIRE_NOTHROW(state.project(snapshot, state.view(), caps)); +} diff --git a/validation/installed-consumer/main.cpp b/validation/installed-consumer/main.cpp index eaec278d..a90bba05 100644 --- a/validation/installed-consumer/main.cpp +++ b/validation/installed-consumer/main.cpp @@ -52,6 +52,10 @@ namespace { static_assert(std::is_same_v().send_results()), std::vector>); + static_assert(std::is_same_v); + void verify_python_runtime_contract() { using namespace std::chrono_literals; auto config = validation::config(); @@ -65,6 +69,15 @@ namespace { validation::complete(claimed); device.hand.recover_for(2s); validation::require(device.hand.is_open(), "profile recovery failed in installed consumer"); + const auto before = device.hand.get_cached_snapshot(); + const auto refreshed = device.hand.refresh_device_info(1s); + validation::require(refreshed.meta.valid && refreshed.meta.sequence > 0, "installed identity metadata missing"); + const auto changed = device.hand.wait_for_cached_snapshot(before, 100ms, false); + validation::require(changed.device_info && changed.device_info->meta.valid, + "installed filtered cache wait failed"); + device.hand.start_polling({{linkerbot::hand::TelemetrySemantic::kAngles, 20ms, + linkerbot::hand::AngleFeedbackPolicy::kCommandFeedbackOnly}}); + device.hand.stop_polling(); } void verify_unified_contract() { From 16329db0d2919ed3ed867aa47b4d11b424c70e54 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 8 Nov 2026 10:32:52 +0800 Subject: [PATCH 2/5] Preserve waiter shutdown and poll feedback boundaries --- src/hand/runtime/device_runtime.cpp | 11 +++ src/hand/unified/backend.cpp | 23 ++++- src/hand/unified/detail/python_state.hpp | 9 +- .../unified/detail/python_transactions.hpp | 1 + src/hand/unified/detail/read_coordinator.hpp | 8 +- src/hand/unified/detail/runtime_access.hpp | 2 + src/hand/unified/device.cpp | 6 +- src/hand/unified/python_state.cpp | 19 +++- tests/hand/device/test_python_publication.cpp | 91 +++++++++++++++++++ tests/hand/device/test_python_runtime.cpp | 4 +- tests/hand/device/test_read_coordinator.cpp | 49 ++++++++++ 11 files changed, 208 insertions(+), 15 deletions(-) diff --git a/src/hand/runtime/device_runtime.cpp b/src/hand/runtime/device_runtime.cpp index 12f19d75..a9d697f5 100644 --- a/src/hand/runtime/device_runtime.cpp +++ b/src/hand/runtime/device_runtime.cpp @@ -2901,3 +2901,14 @@ void linkerbot::hand::unified_detail::RuntimeAccess::with_snapshot_view( runtime::current_snapshot_view = runtime::SnapshotView{&device, &samples}; read(); } + +void linkerbot::hand::unified_detail::RuntimeAccess::wait_command_feedback( + runtime::DeviceRuntime &device, const std::function &generation_valid) { + auto &p = *device.impl_; + std::unique_lock lock(p.mutex); + p.cv.wait(lock, [&] { + return p.health.state != runtime::DeviceState::ready || (generation_valid && !generation_valid()) || + std::none_of(p.pending.begin(), p.pending.end(), + [](const auto &request) { return request->plan.optional_response_window.has_value(); }); + }); +} diff --git a/src/hand/unified/backend.cpp b/src/hand/unified/backend.cpp index 56a00597..9184a36f 100644 --- a/src/hand/unified/backend.cpp +++ b/src/hand/unified/backend.cpp @@ -253,6 +253,9 @@ namespace linkerbot::hand::unified_detail { } }, std::move(admit_send)); + if (python_ && !request.operation && request.semantic == CommandSemantic::kAngles) { + RuntimeAccess::wait_command_feedback(*device_->runtime(), request.generation_valid); + } }); } catch (...) { if (request.delivery && request.ack_expectation == AckExpectation::kNone) { @@ -295,7 +298,8 @@ namespace linkerbot::hand::unified_detail { } [[nodiscard]] TelemetrySample coordinated_read(TelemetrySemantic semantic, std::chrono::milliseconds timeout, bool polling, - runtime::CancellationToken poll_cancel = {}) { + runtime::CancellationToken poll_cancel = {}, + std::function poll_demand = {}) { if (!capabilities_.supports(semantic)) { throw UnsupportedOperationError("telemetry semantic is not supported by this device"); } @@ -354,7 +358,7 @@ namespace linkerbot::hand::unified_detail { return execution_gate_->acquire(false, runtime::Deadline::max(), {}, [valid, demand] { return valid() && demand(); }); }, - polling, poll_cancel); + polling, poll_cancel, poll_demand); auto sample = std::get(std::move(value)); const bool fresh = std::visit( [&](const auto &reading) { return reading.meta.valid && reading.meta.sequence > baseline; }, @@ -588,6 +592,7 @@ namespace linkerbot::hand::unified_detail { identity.meta.origin = TelemetryValueOrigin::kDeviceConfigured; identity.meta.delivery = TelemetryDelivery::kQueryResponse; identity.meta.collected_at = identity.timestamp; + identity.meta.age = runtime::Clock::now() - identity.timestamp; identity.meta.connection_generation = provenance.device_generation; identity.meta.sequence = 1; provenance.received_at = identity.timestamp; @@ -617,12 +622,14 @@ namespace linkerbot::hand::unified_detail { task.period = std::chrono::milliseconds{1}; task.next = [state = python_] { return state->next_poll(); }; task.run = [this](runtime::CancellationToken token) { - const auto semantic = python_->take_poll(); - if (!semantic || token.cancelled()) { + const auto ticket = python_->take_poll(); + if (!ticket || token.cancelled()) { return; } try { - (void)coordinated_read(*semantic, config_.transaction_timeout, true, token); + (void)coordinated_read( + ticket->semantic, config_.transaction_timeout, true, token, + [state = python_, ticket = *ticket] { return state->poll_needed(ticket); }); } catch (const TimeoutError &) { return; } catch (const ClosedError &) { @@ -667,6 +674,9 @@ namespace linkerbot::hand::unified_detail { void assert_external_lifecycle() const override { device_->runtime()->assert_external_lifecycle(); } void stop_admission() override { + if (python_) { + python_->stop(); + } if (reads_) { RuntimeAccess::stop_endpoint(*device_->runtime()); } @@ -725,6 +735,9 @@ namespace linkerbot::hand::unified_detail { } } void close() override { + if (python_) { + python_->stop(); + } reads_closing_.store(true, std::memory_order_release); ++read_epoch_; if (reads_) { diff --git a/src/hand/unified/detail/python_state.hpp b/src/hand/unified/detail/python_state.hpp index c242e2ea..d82802f2 100644 --- a/src/hand/unified/detail/python_state.hpp +++ b/src/hand/unified/detail/python_state.hpp @@ -33,7 +33,13 @@ namespace linkerbot::hand::unified_detail { bool replace(const PollingPlan &plan); void stop_polling(); std::optional next_poll() const; - std::optional take_poll(); + struct PollTicket { + TelemetrySemantic semantic; + uint64_t feedback; + uint64_t plan; + }; + std::optional take_poll(); + bool poll_needed(const PollTicket &ticket) const; void project_health(HandHealth &health) const; std::string domain(TelemetrySemantic semantic) const; void project(HandSnapshot &snapshot, const View &view, const DeviceCapabilities &caps) const; @@ -66,6 +72,7 @@ namespace linkerbot::hand::unified_detail { std::map polls_; std::optional plan_; uint64_t identity_sequence_ = 0; + uint64_t plan_sequence_ = 0; bool stopped_ = false; bool ready_ = false; }; diff --git a/src/hand/unified/detail/python_transactions.hpp b/src/hand/unified/detail/python_transactions.hpp index 8a7e6244..5c72710b 100644 --- a/src/hand/unified/detail/python_transactions.hpp +++ b/src/hand/unified/detail/python_transactions.hpp @@ -61,6 +61,7 @@ namespace linkerbot::hand::unified_detail { const bool six = config.model == Model::kL6 || config.model == Model::kO6; const bool identity = plan.domain.find("version") != std::string::npos; const bool tactile = (plan.domain.find("force") != std::string::npos && plan.domain.rfind("python/", 0) != 0) || + plan.domain.rfind("tactile", 0) == 0 || (config.model == Model::kL30 && python_l30_tactile(plan)); if (six && !identity && !tactile) { window = 50ms; diff --git a/src/hand/unified/detail/read_coordinator.hpp b/src/hand/unified/detail/read_coordinator.hpp index 18ee1b7a..fcbb4bf6 100644 --- a/src/hand/unified/detail/read_coordinator.hpp +++ b/src/hand/unified/detail/read_coordinator.hpp @@ -46,7 +46,8 @@ namespace linkerbot::hand::unified_detail { polling); } Value read_prepared(unsigned key, Clock::time_point deadline, Execute execute, Prepare prepare, - bool polling = false, transport::CancellationToken poll_cancel = {}) { + bool polling = false, transport::CancellationToken poll_cancel = {}, + std::function poll_demand = {}) { std::unique_lock lock(mutex_); if (!accepting_) { throw ClosedError("read generation ended"); @@ -84,6 +85,7 @@ namespace linkerbot::hand::unified_detail { } else { entry->requested_by_poll = true; entry->poll_cancel = std::move(poll_cancel); + entry->poll_demand = std::move(poll_demand); } changed_.notify_all(); bool completed = true; @@ -188,6 +190,7 @@ namespace linkerbot::hand::unified_detail { Execute execute; Prepare prepare; transport::CancellationToken poll_cancel; + std::function poll_demand; bool requested_by_poll = false; uint64_t generation = 0; std::optional value; @@ -201,7 +204,8 @@ namespace linkerbot::hand::unified_detail { bool demand_locked(const Entry &entry) const { return accepting_ && !entry.done && entry.generation == generation_ && (entry.claimed || (entry.waiters != 0 && Clock::now() < entry.last_waiter_deadline) || - (entry.requested_by_poll && !entry.poll_cancel.cancelled())); + (entry.requested_by_poll && !entry.poll_cancel.cancelled() && + (!entry.poll_demand || entry.poll_demand()))); } void run() { std::unique_lock lock(mutex_); diff --git a/src/hand/unified/detail/runtime_access.hpp b/src/hand/unified/detail/runtime_access.hpp index b582e260..1e67fac4 100644 --- a/src/hand/unified/detail/runtime_access.hpp +++ b/src/hand/unified/detail/runtime_access.hpp @@ -31,6 +31,8 @@ namespace linkerbot::hand::unified_detail { static void stop_endpoint(runtime::DeviceRuntime &runtime); static void resume_endpoint(runtime::DeviceRuntime &runtime); static void flush_sends(runtime::DeviceRuntime &runtime); + static void wait_command_feedback(runtime::DeviceRuntime &runtime, + const std::function &generation_valid); static void drain_sends(runtime::DeviceRuntime &runtime, runtime::Deadline deadline); static bool uses_response_windows(const runtime::DeviceRuntime &runtime); using FinalSend = std::function; diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index 12f0895e..637416cc 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -1040,7 +1040,11 @@ namespace linkerbot::hand { if (impl_->state_.load(std::memory_order_acquire) != Impl::State::kReady) { return std::nullopt; } - return impl_->identity_; + auto result = impl_->identity_; + if (result && result->meta.valid) { + unified_detail::complete_freshness(result->meta, nullptr, runtime::Clock::now()); + } + return result; } std::optional HandDevice::get_verified_capabilities() const { diff --git a/src/hand/unified/python_state.cpp b/src/hand/unified/python_state.cpp index d4321aa5..f5b5a0d5 100644 --- a/src/hand/unified/python_state.cpp +++ b/src/hand/unified/python_state.cpp @@ -96,11 +96,12 @@ namespace linkerbot::hand::unified_detail { } return; } - // Native domains are SDK-owned and protocol-bounded, not user queues. - current_.samples[name] = sample; if (found == domains_.end() && !identity) { return; } + // Keep only complete, declared channels. Partial assembler domains and + // externally published names cannot grow the retained view. + current_.samples[name] = sample; if (identity) { auto value = std::any_cast(sample.value); value.meta.sequence = ++identity_sequence_; @@ -160,6 +161,7 @@ namespace linkerbot::hand::unified_detail { failures_.clear(); disabled_.clear(); polls_.clear(); + ++plan_sequence_; plan_.reset(); identity_sequence_ = 0; stopped_ = false; @@ -273,12 +275,14 @@ namespace linkerbot::hand::unified_detail { now}); } plan_ = plan; + ++plan_sequence_; polls_ = std::move(next); return true; } void PythonState::stop_polling() { const std::lock_guard lock(mutex_); plan_.reset(); + ++plan_sequence_; polls_.clear(); } std::optional PythonState::next_poll() const { @@ -292,7 +296,7 @@ namespace linkerbot::hand::unified_detail { } return result; } - std::optional PythonState::take_poll() { + std::optional PythonState::take_poll() { const std::lock_guard lock(mutex_); const auto now = runtime::Clock::now(); auto selected = polls_.end(); @@ -311,7 +315,14 @@ namespace linkerbot::hand::unified_detail { const bool command_only = selected->first == S::kAngles && state.channel.angle_feedback == AngleFeedbackPolicy::kCommandFeedbackOnly; state.missed += command_only ? periods : periods - 1; - return command_only ? std::nullopt : std::optional{selected->first}; + return command_only ? std::nullopt + : std::optional{{selected->first, state.feedback, plan_sequence_}}; + } + bool PythonState::poll_needed(const PollTicket &ticket) const { + const std::lock_guard lock(mutex_); + const auto found = polls_.find(ticket.semantic); + return !stopped_ && ticket.plan == plan_sequence_ && found != polls_.end() && found->second.enabled && + found->second.feedback == ticket.feedback; } void PythonState::project_health(HandHealth &health) const { const std::lock_guard lock(mutex_); diff --git a/tests/hand/device/test_python_publication.cpp b/tests/hand/device/test_python_publication.cpp index cdf60739..8d1144f4 100644 --- a/tests/hand/device/test_python_publication.cpp +++ b/tests/hand/device/test_python_publication.cpp @@ -67,6 +67,7 @@ TEST_CASE("Python identity has its own sample sequence and retained invalid evid REQUIRE((before.device_info->meta.sequence == 1)); const auto refreshed = device.hand.refresh_device_info(1s); REQUIRE((refreshed.meta.sequence == 2)); + REQUIRE((refreshed.meta.age.has_value())); REQUIRE((refreshed.meta.collected_at == refreshed.timestamp)); REQUIRE((refreshed.meta.connection_generation > 0)); const auto after = device.hand.get_cached_snapshot(); @@ -351,3 +352,93 @@ TEST_CASE("Python O30 force absence degrades immediately without changing other HandSnapshot snapshot; REQUIRE_NOTHROW(state.project(snapshot, state.view(), caps)); } + +TEST_CASE("Python fulfilled feedback cancels only the still-unclaimed poll ticket", "[comm][python-phase3]") { + for (const auto policy : {AngleFeedbackPolicy::kAdaptive, AngleFeedbackPolicy::kQueryOnly}) { + PythonState state(profile()); + state.replace({{TelemetrySemantic::kAngles, 1s, policy}}); + const auto ticket = state.take_poll(); + REQUIRE((ticket.has_value())); + REQUIRE((state.poll_needed(*ticket))); + state.publish("angle", publication(2, protocol::Origin::command_feedback)); + REQUIRE((state.poll_needed(*ticket) == (policy == AngleFeedbackPolicy::kQueryOnly))); + state.stop_polling(); + REQUIRE_FALSE(state.poll_needed(*ticket)); + } +} + +TEST_CASE("Python retained publication view rejects undeclared and partial domains", "[comm][python-phase3]") { + PythonState state(profile()); + state.publish("external/domain", publication(1)); + state.publish("tactile/177", publication(2)); + REQUIRE((state.view().samples.empty())); + REQUIRE((state.view().sequence == 0)); + state.publish("angle", publication(3)); + REQUIRE((state.view().samples.size() == 1)); + REQUIRE((state.view().sequence == 1)); +} + +TEST_CASE("Python response-cache reads preserve publication metadata without sending", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + const auto initial = device.transport->sent().size(); + REQUIRE_THROWS_AS(device.hand.get_configured_speeds(1s), UnsupportedOperationError); + REQUIRE((device.transport->sent().size() == initial)); + validation::complete(device.hand.set_speeds(std::vector(6, 12))); + const auto count = device.transport->sent().size(); + const auto before = device.hand.get_cached_snapshot(); + const auto first = device.hand.get_configured_speeds(1s); + const auto second = device.hand.get_configured_speeds(1s); + REQUIRE((first.raw == std::vector(6, 12))); + REQUIRE((first.meta.sequence == 1)); + REQUIRE((second.meta.sequence == first.meta.sequence)); + REQUIRE((device.hand.get_cached_snapshot().snapshot_sequence == before.snapshot_sequence)); + REQUIRE((device.transport->sent().size() == count)); +} + +TEST_CASE("Python angle dispatch is observable while response observation retains its device lane", + "[comm][python-phase3]") { + auto config = profile(); + config.response_timeout = 2s; + validation::FakeHand device(config); + device.transport->on_request(0x28, [](const auto &request) { + if (request.data[0] == 1 && request.dlc > 1) { + return std::vector{}; + } + return validation::l6_reply(request); + }); + auto command = device.hand.set_angles(std::vector(6, 19)); + validation::complete(command); + const auto sent = device.transport->sent().size(); + auto read = std::async(std::launch::async, [&] { return device.hand.get_angles(2s); }); + const auto blocked = read.wait_for(20ms); + const auto count = device.transport->sent().size(); + linkerbot::can::Message response{}; + response.id = 0x28; + response.dlc = 7; + response.data[0] = 1; + std::fill_n(response.data.begin() + 1, 6, uint8_t{19}); + device.transport->inject(response); + const auto value = read.get(); + REQUIRE((blocked == std::future_status::timeout)); + REQUIRE((count == sent)); + REQUIRE((value.raw == std::vector(6, 64))); + REQUIRE((command.stage() == CommandStage::kDispatched)); +} + +TEST_CASE("Python adaptive feedback discards a redundant poll queued behind a command", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + auto command = device.block(); + device.hand.start_polling({{TelemetrySemantic::kAngles, 1s, AngleFeedbackPolicy::kAdaptive}}); + std::this_thread::sleep_for(20ms); + device.transport->release_tx_gate(); + validation::complete(command); + REQUIRE((eventually([&] { + return device.hand.get_cached_snapshot().angles->meta.delivery == TelemetryDelivery::kCommandResponse; + }))); + std::this_thread::sleep_for(20ms); + device.hand.stop_polling(); + const auto frames = device.transport->sent(); + const auto queries = std::count_if(frames.begin(), frames.end(), + [](const auto &frame) { return frame.data[0] == 1 && frame.dlc == 1; }); + REQUIRE((queries == 1)); // Only the initialization baseline. +} diff --git a/tests/hand/device/test_python_runtime.cpp b/tests/hand/device/test_python_runtime.cpp index 43918bf4..2406f2fe 100644 --- a/tests/hand/device/test_python_runtime.cpp +++ b/tests/hand/device/test_python_runtime.cpp @@ -488,7 +488,7 @@ TEST_CASE("Python unclaimed reads with no remaining callers do not send after th REQUIRE((device.hand.is_open())); } -TEST_CASE("Python poll demand survives its internal waiter timeout while the device lane is busy", +TEST_CASE("Python query-only poll demand survives its internal waiter timeout while the device lane is busy", "[comm][python-phase2]") { struct State { std::promise polled; @@ -504,7 +504,7 @@ TEST_CASE("Python poll demand survives its internal waiter timeout while the dev return validation::l6_reply(request); }); auto claimed = device.block(); - device.hand.start_polling({{TelemetrySemantic::kAngles, 2s}}); + device.hand.start_polling({{TelemetrySemantic::kAngles, 2s, AngleFeedbackPolicy::kQueryOnly}}); std::this_thread::sleep_for(150ms); // Longer than the internal 100 ms poll wait. device.transport->release_tx_gate(); validation::complete(claimed); diff --git a/tests/hand/device/test_read_coordinator.cpp b/tests/hand/device/test_read_coordinator.cpp index 08da0c41..895babfe 100644 --- a/tests/hand/device/test_read_coordinator.cpp +++ b/tests/hand/device/test_read_coordinator.cpp @@ -135,3 +135,52 @@ TEST_CASE("read deadlines precede results and errors published after their wait REQUIRE((result == ErrorCode::kTimeout)); } } + +TEST_CASE("fulfilled poll demand does not revoke an explicit shared reader", "[comm][python-phase3]") { + for (const bool explicit_reader : {false, true}) { + unified_detail::ReadCoordinator reads(1); + std::atomic needed{true}; + std::atomic executions{0}; + std::promise preparing, release; + auto gate = release.get_future().share(); + auto poll = std::async(std::launch::async, [&] { + try { + return reads.read_prepared( + 1, std::chrono::steady_clock::now() + 2s, + [&](const auto &) { + ++executions; + return 7; + }, + [&](const auto &) { + preparing.set_value(); + gate.wait(); + return std::shared_ptr{}; + }, + true, {}, [&] { return needed.load(); }); + } catch (const ClosedError &) { + return 0; + } + }); + preparing.get_future().wait(); + std::future reader; + if (explicit_reader) { + reader = std::async(std::launch::async, [&] { + return reads.read(1, std::chrono::steady_clock::now() + 2s, [&] { + ++executions; + return 7; + }); + }); + REQUIRE((reader.wait_for(20ms) == std::future_status::timeout)); + } + needed = false; + release.set_value(); + const auto polled = poll.get(); + if (explicit_reader) { + REQUIRE((reader.get() == 7)); + REQUIRE((executions == 1)); + } else { + REQUIRE((polled == 0)); + REQUIRE((executions == 0)); + } + } +} From abdaba057082530ae91927b1c01dabee2886c0c1 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 8 Nov 2026 10:40:32 +0800 Subject: [PATCH 3/5] Preserve optional firmware errors at facade boundaries --- src/hand/unified/device.cpp | 11 ++++++ tests/hand/device/test_python_publication.cpp | 34 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index 637416cc..87dc9e1d 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -1208,6 +1208,11 @@ namespace linkerbot::hand { } } if (queries.empty()) { + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && + std::any_of(requested.begin(), requested.end(), + [&](const auto &name) { return capabilities.force_field(name).firmware_unsupported; })) { + throw UnsupportedFirmwareError("firmware does not support requested force fields"); + } throw UnsupportedOperationError("configured hand model does not support requested force fields"); } const auto physical_generation = selected.health().connection_generation; @@ -1515,6 +1520,12 @@ namespace linkerbot::hand { throw PollingPlanError("polling semantics must be unique", {"start_polling", unified_detail::model_name(impl_->config_.model)}); } + if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && + std::find(capabilities.firmware_unsupported_telemetry.begin(), + capabilities.firmware_unsupported_telemetry.end(), + channel.semantic) != capabilities.firmware_unsupported_telemetry.end()) { + throw PollingPlanError("firmware does not support requested polling channel"); + } if (!capabilities.supports(channel.semantic) || (impl_->config_.l6_protocol != L6Protocol::kHopV004 && !unified_detail::polling_supported(impl_->config_.model, channel.semantic))) { diff --git a/tests/hand/device/test_python_publication.cpp b/tests/hand/device/test_python_publication.cpp index 8d1144f4..8c0b9b5a 100644 --- a/tests/hand/device/test_python_publication.cpp +++ b/tests/hand/device/test_python_publication.cpp @@ -442,3 +442,37 @@ TEST_CASE("Python adaptive feedback discards a redundant poll queued behind a co [](const auto &frame) { return frame.data[0] == 1 && frame.dlc == 1; }); REQUIRE((queries == 1)); // Only the initialization baseline. } + +TEST_CASE("Python default force reads preserve firmware-unsupported errors without I/O", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + device.transport->on_request(0x28, [](const auto &request) { + if (request.data[0] == 0xB1) { + return std::vector{}; + } + return validation::l6_reply(request); + }); + REQUIRE_THROWS_AS(device.hand.get_force(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_force(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_force(1s), UnsupportedFirmwareError); + const auto sent = device.transport->sent().size(); + REQUIRE_THROWS_AS(device.hand.get_force(1s), UnsupportedFirmwareError); + REQUIRE((device.transport->sent().size() == sent)); + REQUIRE((device.hand.is_open())); +} + +TEST_CASE("Python rejects a degraded polling channel before replacing an existing plan", "[comm][python-phase3]") { + validation::FakeHand device(profile()); + device.transport->on_request(0x28, [](const auto &request) { + if (request.data[0] == 0x36) { + return std::vector{}; + } + return validation::l6_reply(request); + }); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), TimeoutError); + REQUIRE_THROWS_AS(device.hand.get_currents(1s), UnsupportedFirmwareError); + device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, AngleFeedbackPolicy::kQueryOnly}}); + REQUIRE_THROWS_AS(device.hand.start_polling({{TelemetrySemantic::kCurrents, 10ms}}), PollingPlanError); + REQUIRE((eventually([&] { return device.hand.get_cached_snapshot().angles->meta.sequence >= 2; }))); + device.hand.stop_polling(); +} From f7206d4dd6ffa452a24603209b600b70b5eadcd7 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 8 Nov 2026 11:07:37 +0800 Subject: [PATCH 4/5] Check optional samples and make rate conversions explicit --- src/hand/unified/device.cpp | 6 ++- src/hand/unified/python_state.cpp | 4 +- tests/hand/device/test_device.cpp | 31 +++++++------ tests/hand/device/test_python_publication.cpp | 46 +++++++++++-------- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/src/hand/unified/device.cpp b/src/hand/unified/device.cpp index 87dc9e1d..c6661fa3 100644 --- a/src/hand/unified/device.cpp +++ b/src/hand/unified/device.cpp @@ -767,7 +767,8 @@ namespace linkerbot::hand { // Advance the readable generation only after initialization state is installed. ++impl_->cache_generation_; if (impl_->config_.runtime_behavior == RuntimeBehavior::kPython && impl_->identity_) { - impl_->identity_->meta.connection_generation = impl_->cache_generation_; + ::linkerbot::hand::detail::checked_value(impl_->identity_, "missing initialized identity") + .meta.connection_generation = impl_->cache_generation_; } impl_->cache_physical_generation_ = physical_generation; impl_->cached_source_sequence_.reset(); @@ -1692,7 +1693,8 @@ namespace linkerbot::hand { // Advance the readable generation only after initialization state is installed. ++impl_->cache_generation_; if (python && impl_->identity_) { - impl_->identity_->meta.connection_generation = impl_->cache_generation_; + ::linkerbot::hand::detail::checked_value(impl_->identity_, "missing initialized identity") + .meta.connection_generation = impl_->cache_generation_; } impl_->cache_physical_generation_ = physical_generation; impl_->cached_source_sequence_.reset(); diff --git a/src/hand/unified/python_state.cpp b/src/hand/unified/python_state.cpp index f5b5a0d5..2ef81298 100644 --- a/src/hand/unified/python_state.cpp +++ b/src/hand/unified/python_state.cpp @@ -340,9 +340,9 @@ namespace linkerbot::hand::unified_detail { } found->polling_active = state.enabled; found->supported = disabled_.count(semantic) == 0; - found->requested_hz = state.enabled ? 1000.0 / state.channel.interval.count() : 0; + found->requested_hz = state.enabled ? 1000.0 / static_cast(state.channel.interval.count()) : 0; const auto seconds = std::chrono::duration(now - state.started).count(); - found->achieved_hz = seconds > 0 ? state.feedback / seconds : 0; + found->achieved_hz = seconds > 0 ? static_cast(state.feedback) / seconds : 0; found->completed_reads = state.reads; found->feedback_samples = state.feedback; found->command_feedback_samples = state.commands; diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index 0b3f39dc..fe0e5c50 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -4046,23 +4046,23 @@ TEST_CASE("Python published metadata is stable across Classic and L30 models", " }); device.open(); const auto initial = device.get_cached_snapshot(); - REQUIRE((initial.angles->meta.sequence == 1)); - REQUIRE((initial.faults->meta.sequence == 1)); + REQUIRE((test_support::require_value(initial.angles).meta.sequence == 1)); + REQUIRE((test_support::require_value(initial.faults).meta.sequence == 1)); (void)device.get_angles(2s); (void)device.get_angles(2s); const auto updated = device.get_cached_snapshot(); - REQUIRE((updated.angles->meta.sequence == 3)); - REQUIRE((updated.faults->meta.sequence == 1)); - REQUIRE((updated.device_info->meta.sequence == 1)); - REQUIRE((updated.device_info->meta.valid)); + REQUIRE((test_support::require_value(updated.angles).meta.sequence == 3)); + REQUIRE((test_support::require_value(updated.faults).meta.sequence == 1)); + REQUIRE((test_support::require_value(updated.device_info).meta.sequence == 1)); + REQUIRE((test_support::require_value(updated.device_info).meta.valid)); device.close(); device.open(); const auto reopened = device.get_cached_snapshot(); REQUIRE((reopened.connection_generation == 2)); - REQUIRE((reopened.angles->meta.sequence == 1)); - REQUIRE((reopened.angles->meta.connection_generation == 2)); - REQUIRE((reopened.device_info->meta.connection_generation == 2)); - REQUIRE((device.get_identity()->meta.connection_generation == 2)); + REQUIRE((test_support::require_value(reopened.angles).meta.sequence == 1)); + REQUIRE((test_support::require_value(reopened.angles).meta.connection_generation == 2)); + REQUIRE((test_support::require_value(reopened.device_info).meta.connection_generation == 2)); + REQUIRE((test_support::require_value(device.get_identity()).meta.connection_generation == 2)); REQUIRE((device.get_angles(2s).meta.connection_generation == 2)); device.close(); } @@ -4093,8 +4093,8 @@ TEST_CASE("Python L20Lite scalar queries retain independent data and degradation REQUIRE((transport->sent().back().data[0] == 0x21)); REQUIRE((tangential.field("fns") == nullptr)); const auto cached = device.get_cached_snapshot(); - REQUIRE((cached.force->field("fns")->meta.sequence == sequence)); - REQUIRE((cached.force->field("fts")->meta.sequence == 1)); + REQUIRE((test_support::require_value(cached.force).field("fns")->meta.sequence == sequence)); + REQUIRE((test_support::require_value(cached.force).field("fts")->meta.sequence == 1)); transport->on_request(0x28, [](const Message &request) { if (request.data[0] == 0x23) { return std::vector{}; @@ -4107,7 +4107,7 @@ TEST_CASE("Python L20Lite scalar queries retain independent data and degradation REQUIRE((device.get_capabilities().force_field("px").firmware_unsupported)); REQUIRE((device.get_capabilities().force_field("fns").supported == true)); REQUIRE((device.get_force({"fns"}, 1s).field("fns")->meta.sequence > sequence)); - REQUIRE((device.get_cached_snapshot().force->status("px") == "unsupported")); + REQUIRE((test_support::require_value(device.get_cached_snapshot().force).status("px") == "unsupported")); device.close(); } @@ -4135,8 +4135,9 @@ TEST_CASE("Python optional polling degradation preserves other poll channels and device.start_polling({{TelemetrySemantic::kCurrents, 10ms}, {TelemetrySemantic::kAngles, 10ms}}); REQUIRE((wait_until([&] { return !device.get_capabilities().supports(TelemetrySemantic::kCurrents); }))); REQUIRE((device.get_health().connected())); - const auto before = device.get_cached_snapshot().angles->meta.sequence; - REQUIRE((wait_until([&] { return device.get_cached_snapshot().angles->meta.sequence > before; }))); + const auto before = test_support::require_value(device.get_cached_snapshot().angles).meta.sequence; + REQUIRE((wait_until( + [&] { return test_support::require_value(device.get_cached_snapshot().angles).meta.sequence > before; }))); device.stop_polling(); device.recover_for(2s); REQUIRE((device.get_capabilities().supports(TelemetrySemantic::kCurrents))); diff --git a/tests/hand/device/test_python_publication.cpp b/tests/hand/device/test_python_publication.cpp index 8c0b9b5a..a30beab0 100644 --- a/tests/hand/device/test_python_publication.cpp +++ b/tests/hand/device/test_python_publication.cpp @@ -56,28 +56,30 @@ TEST_CASE("Python channel sequences count publications independently of cache re REQUIRE((current.snapshot_sequence >= first.snapshot_sequence + 3)); const auto repeated = device.hand.get_cached_snapshot(); REQUIRE((repeated.snapshot_sequence == current.snapshot_sequence)); - REQUIRE((repeated.angles->meta.sequence == current.angles->meta.sequence)); + REQUIRE((test_support::require_value(repeated.angles).meta.sequence == + test_support::require_value(current.angles).meta.sequence)); } TEST_CASE("Python identity has its own sample sequence and retained invalid evidence", "[comm][python-phase3]") { validation::FakeHand device(profile()); const auto before = device.hand.get_cached_snapshot(); REQUIRE((before.device_info.has_value())); - REQUIRE((before.device_info->meta.valid)); - REQUIRE((before.device_info->meta.sequence == 1)); + REQUIRE((test_support::require_value(before.device_info).meta.valid)); + REQUIRE((test_support::require_value(before.device_info).meta.sequence == 1)); const auto refreshed = device.hand.refresh_device_info(1s); REQUIRE((refreshed.meta.sequence == 2)); REQUIRE((refreshed.meta.age.has_value())); REQUIRE((refreshed.meta.collected_at == refreshed.timestamp)); REQUIRE((refreshed.meta.connection_generation > 0)); const auto after = device.hand.get_cached_snapshot(); - REQUIRE((after.angles->meta.sequence == before.angles->meta.sequence)); + REQUIRE((test_support::require_value(after.angles).meta.sequence == + test_support::require_value(before.angles).meta.sequence)); REQUIRE((after.snapshot_sequence > before.snapshot_sequence)); device.hand.close(); const auto closed = device.hand.get_cached_snapshot(); REQUIRE((closed.device_info.has_value())); - REQUIRE_FALSE(closed.device_info->meta.valid); - REQUIRE((closed.device_info->serial_number == refreshed.serial_number)); + REQUIRE_FALSE(test_support::require_value(closed.device_info).meta.valid); + REQUIRE((test_support::require_value(closed.device_info).serial_number == refreshed.serial_number)); } TEST_CASE("Python publication filtering retains exact non-command revisions", "[comm][python-phase3]") { @@ -116,7 +118,7 @@ TEST_CASE("Python retained-cache waiter wakes on close without device I/O", "[co device.hand.close(); REQUIRE((waiter.wait_for(1s) == std::future_status::ready)); const auto stopped = waiter.get(); - REQUIRE_FALSE(stopped.angles->meta.valid); + REQUIRE_FALSE(test_support::require_value(stopped.angles).meta.valid); REQUIRE((count > 0)); } @@ -177,9 +179,9 @@ TEST_CASE("Python adaptive angle feedback defers queries but query-only does not for (const auto policy : {AngleFeedbackPolicy::kAdaptive, AngleFeedbackPolicy::kQueryOnly}) { PythonState state(profile()); REQUIRE((state.replace({{TelemetrySemantic::kAngles, 200ms, policy}}))); - const auto due = *state.next_poll(); + const auto due = test_support::require_value(state.next_poll()); state.publish("angle", publication(1, protocol::Origin::command_feedback)); - REQUIRE(((*state.next_poll() > due) == (policy == AngleFeedbackPolicy::kAdaptive))); + REQUIRE(((test_support::require_value(state.next_poll()) > due) == (policy == AngleFeedbackPolicy::kAdaptive))); HandHealth health; state.project_health(health); REQUIRE((health.channels.at(0).command_feedback_samples == 1)); @@ -206,9 +208,11 @@ TEST_CASE("Python command-only polling emits no angle queries and rejects illega device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, AngleFeedbackPolicy::kCommandFeedbackOnly}}); std::this_thread::sleep_for(30ms); REQUIRE((device.transport->sent().size() == count)); - REQUIRE_THROWS_AS( - device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, static_cast(255)}}), - PollingPlanError); + // The fixed uint8_t representation is valid; the policy value is deliberately unknown. + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) + const auto unknown_policy = static_cast(255); + REQUIRE_THROWS_AS(device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, unknown_policy}}), + PollingPlanError); REQUIRE((device.hand.get_health().polling_active)); device.hand.stop_polling(); } @@ -221,14 +225,15 @@ TEST_CASE("Python filtered facade wait retains command feedback until a query wa auto command = device.hand.set_angles(std::vector(6, 11)); validation::complete(command); REQUIRE((eventually([&] { - return device.hand.get_cached_snapshot().angles->meta.delivery == TelemetryDelivery::kCommandResponse; + return test_support::require_value(device.hand.get_cached_snapshot().angles).meta.delivery == + TelemetryDelivery::kCommandResponse; }))); REQUIRE((waiter.wait_for(20ms) == std::future_status::timeout)); (void)device.hand.get_faults(1s); REQUIRE((waiter.wait_for(1s) == std::future_status::ready)); const auto value = waiter.get(); REQUIRE((value.snapshot_sequence > before.snapshot_sequence)); - REQUIRE((value.angles->raw == std::vector(6, 11))); + REQUIRE((test_support::require_value(value.angles).raw == std::vector(6, 11))); } TEST_CASE("force aggregation replaces one query and updates scalar raw evidence", "[comm][python-phase3]") { @@ -359,11 +364,12 @@ TEST_CASE("Python fulfilled feedback cancels only the still-unclaimed poll ticke state.replace({{TelemetrySemantic::kAngles, 1s, policy}}); const auto ticket = state.take_poll(); REQUIRE((ticket.has_value())); - REQUIRE((state.poll_needed(*ticket))); + REQUIRE((state.poll_needed(test_support::require_value(ticket)))); state.publish("angle", publication(2, protocol::Origin::command_feedback)); - REQUIRE((state.poll_needed(*ticket) == (policy == AngleFeedbackPolicy::kQueryOnly))); + REQUIRE( + (state.poll_needed(test_support::require_value(ticket)) == (policy == AngleFeedbackPolicy::kQueryOnly))); state.stop_polling(); - REQUIRE_FALSE(state.poll_needed(*ticket)); + REQUIRE_FALSE(state.poll_needed(test_support::require_value(ticket))); } } @@ -433,7 +439,8 @@ TEST_CASE("Python adaptive feedback discards a redundant poll queued behind a co device.transport->release_tx_gate(); validation::complete(command); REQUIRE((eventually([&] { - return device.hand.get_cached_snapshot().angles->meta.delivery == TelemetryDelivery::kCommandResponse; + return test_support::require_value(device.hand.get_cached_snapshot().angles).meta.delivery == + TelemetryDelivery::kCommandResponse; }))); std::this_thread::sleep_for(20ms); device.hand.stop_polling(); @@ -473,6 +480,7 @@ TEST_CASE("Python rejects a degraded polling channel before replacing an existin REQUIRE_THROWS_AS(device.hand.get_currents(1s), UnsupportedFirmwareError); device.hand.start_polling({{TelemetrySemantic::kAngles, 10ms, AngleFeedbackPolicy::kQueryOnly}}); REQUIRE_THROWS_AS(device.hand.start_polling({{TelemetrySemantic::kCurrents, 10ms}}), PollingPlanError); - REQUIRE((eventually([&] { return device.hand.get_cached_snapshot().angles->meta.sequence >= 2; }))); + REQUIRE((eventually( + [&] { return test_support::require_value(device.hand.get_cached_snapshot().angles).meta.sequence >= 2; }))); device.hand.stop_polling(); } From 1fe64d186cdd7008a35870f24cfa98ef36100740 Mon Sep 17 00:00:00 2001 From: Su Yang Date: Sun, 8 Nov 2026 11:31:09 +0800 Subject: [PATCH 5/5] Give polling capability fixtures an explicit initialization budget --- tests/hand/device/test_device.cpp | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/hand/device/test_device.cpp b/tests/hand/device/test_device.cpp index fe0e5c50..484f14ef 100644 --- a/tests/hand/device/test_device.cpp +++ b/tests/hand/device/test_device.cpp @@ -3971,7 +3971,11 @@ TEST_CASE("unified polling accepts deployed measured-speed and voltage channels" CAPTURE(model); FakeTransport *raw = nullptr; std::shared_ptr hop; - HandDevice device(config_for(model), [&] { + auto config = config_for(model); + // This test checks channel support, not the default initialization deadline. + // Allow the FakeTransport workers to start on loaded platform runners. + config.transaction_timeout = 2s; + HandDevice device(config, [&] { auto transport = std::make_unique(); raw = transport.get(); install_fake_telemetry(model, raw, hop); @@ -3982,7 +3986,10 @@ TEST_CASE("unified polling accepts deployed measured-speed and voltage channels" } return transport; }); - device.open(); + { + INFO("initializing the offline polling fixture"); + device.open(); + } PollingPlan plan{{TelemetrySemantic::kMeasuredSpeeds, 20ms}}; if (model == Model::kO30) { plan.push_back({TelemetrySemantic::kVoltages, 20ms});