diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index 108514d..d6a2497 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 7fa0dd4..aefe899 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 abcb50a..96bcfd2 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 d684024..e332bc3 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 807db37..cef3ec6 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 807db37..cef3ec6 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 991751b..7cba768 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 eae01d6..fbcace9 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 cb84c49..fa1563b 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 d522d48..9e82754 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 dff782f..226cdef 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 4522aac..1b2e753 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 a7d5632..9109308 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 9046377..fd40491 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 f2e9fa1..af175a4 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 043b78e..871c5a3 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 59e8fd0..9e676af 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 17c4c32..afbb46e 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 a9bf9ba..75c64ae 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 f1dba10..f361840 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 4525394..a9d697f 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,30 @@ 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(); +} + +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 16823c3..9184a36 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_) { @@ -239,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) { @@ -281,47 +298,70 @@ 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"); } 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) { 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.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 +385,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 +435,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 +469,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 +542,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 +576,30 @@ 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.age = runtime::Clock::now() - 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 +614,33 @@ 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 ticket = python_->take_poll(); + if (!ticket || token.cancelled()) { + return; + } + try { + (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 &) { + return; + } catch (const ProtocolError &) { + return; + } catch (const UnsupportedFirmwareError &) { + return; + } + }; + device_->runtime()->start_polling({std::move(task)}); return; } if constexpr (std::is_same_v) { @@ -564,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()); } @@ -572,17 +685,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,11 +727,17 @@ namespace linkerbot::hand::unified_detail { reads_->drain(); } recover_device(*device_); + if (python_) { + python_->reset(); + } if (reads_) { reads_->activate(); } } void close() override { + if (python_) { + python_->stop(); + } reads_closing_.store(true, std::memory_order_release); ++read_epoch_; if (reads_) { @@ -638,6 +766,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 +833,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 9a68935..8a61e52 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 082a830..57430f6 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 add7b53..5e3f3a4 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 6790d37..e1814a5 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 0000000..d82802f --- /dev/null +++ b/src/hand/unified/detail/python_state.hpp @@ -0,0 +1,79 @@ +#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; + 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; + + 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; + uint64_t plan_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 0000000..0e2cfcb --- /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 9168e44..5c72710 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,9 @@ 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) || + plan.domain.rfind("tactile", 0) == 0 || + (config.model == Model::kL30 && python_l30_tactile(plan)); if (six && !identity && !tactile) { window = 50ms; } @@ -152,6 +154,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/read_coordinator.hpp b/src/hand/unified/detail/read_coordinator.hpp index 18ee1b7..fcbb4bf 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 58f1544..1e67fac 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, @@ -24,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 3c10773..c6661fa 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,10 @@ 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_) { + ::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(); impl_->state_.store(Impl::State::kReady, std::memory_order_release); @@ -969,6 +984,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; @@ -1023,7 +1041,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 { @@ -1034,7 +1056,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 +1195,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); } @@ -1183,9 +1209,20 @@ 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 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 +1239,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 +1272,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 +1317,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 +1333,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 +1377,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 +1413,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 +1436,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 +1500,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)}); @@ -1409,6 +1521,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))) { @@ -1574,6 +1692,10 @@ 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_) { + ::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(); 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 94f2541..502ed18 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 0000000..2ef8129 --- /dev/null +++ b/src/hand/unified/python_state.cpp @@ -0,0 +1,484 @@ +#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; + } + 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_; + 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_sequence_; + 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; + ++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 { + 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, 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_); + 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 / static_cast(state.channel.interval.count()) : 0; + const auto seconds = std::chrono::duration(now - state.started).count(); + 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; + 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 ca7b793..484f14e 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}); @@ -4030,3 +4037,117 @@ 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((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((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((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(); + } +} + +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((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{}; + } + 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((test_support::require_value(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 = 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))); + 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 0000000..a30beab --- /dev/null +++ b/tests/hand/device/test_python_publication.cpp @@ -0,0 +1,486 @@ +#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((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((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((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(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]") { + 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(test_support::require_value(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 = test_support::require_value(state.next_poll()); + state.publish("angle", publication(1, protocol::Origin::command_feedback)); + 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)); + 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)); + // 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(); +} + +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 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((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]") { + 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)); +} + +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(test_support::require_value(ticket)))); + state.publish("angle", publication(2, protocol::Origin::command_feedback)); + REQUIRE( + (state.poll_needed(test_support::require_value(ticket)) == (policy == AngleFeedbackPolicy::kQueryOnly))); + state.stop_polling(); + REQUIRE_FALSE(state.poll_needed(test_support::require_value(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 test_support::require_value(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. +} + +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 test_support::require_value(device.hand.get_cached_snapshot().angles).meta.sequence >= 2; }))); + device.hand.stop_polling(); +} diff --git a/tests/hand/device/test_python_runtime.cpp b/tests/hand/device/test_python_runtime.cpp index 43918bf..2406f2f 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 08da0c4..895babf 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)); + } + } +} diff --git a/validation/installed-consumer/main.cpp b/validation/installed-consumer/main.cpp index eaec278..a90bba0 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() {