From 0b01c33fa5afce58653d900b8d69007fb741d32c Mon Sep 17 00:00:00 2001 From: zhuzhixiang <1849346915@qq.com> Date: Wed, 16 Sep 2026 11:47:23 +0800 Subject: [PATCH] refactor(hand): rename L25 and O30i models --- CHANGELOG.md | 4 + docs/zh/src/SUMMARY.md | 26 +-- docs/zh/src/guide/basics.md | 2 +- docs/zh/src/guide/installation.md | 2 +- docs/zh/src/reference/canfd.md | 32 ++-- docs/zh/src/reference/{l25 => l20}/README.md | 16 +- docs/zh/src/reference/{l25 => l20}/angle.md | 22 +-- docs/zh/src/reference/{l25 => l20}/fault.md | 26 +-- .../reference/{l25 => l20}/force-sensor.md | 18 +- docs/zh/src/reference/{l25 => l20}/speed.md | 22 +-- .../src/reference/{l25 => l20}/temperature.md | 16 +- docs/zh/src/reference/{l25 => l20}/torque.md | 16 +- docs/zh/src/reference/{l25 => l20}/version.md | 6 +- docs/zh/src/reference/l30/README.md | 2 +- docs/zh/src/reference/o20/README.md | 2 +- docs/zh/src/reference/{o30i => o30}/README.md | 36 ++-- .../{o30i => o30}/hand-protocol-v1.md | 10 +- .../{o30i => o30}/implementation-status.md | 14 +- .../zh/src/reference/{o30i => o30}/runtime.md | 30 ++-- .../{o30i_full_demo.py => o30_full_demo.py} | 94 +++++----- pyproject.toml | 4 +- src/linkerbot/__init__.py | 10 +- src/linkerbot/hand/__init__.py | 10 +- .../hand/hand_protocol_v1/__init__.py | 2 +- src/linkerbot/hand/{l25 => l20}/__init__.py | 34 ++-- src/linkerbot/hand/{l25 => l20}/angle.py | 38 ++-- src/linkerbot/hand/{l25 => l20}/events.py | 10 +- src/linkerbot/hand/{l25 => l20}/fault.py | 119 +++++++------ .../hand/{l25 => l20}/force_sensor.py | 6 +- src/linkerbot/hand/{l25/l25.py => l20/l20.py} | 32 ++-- src/linkerbot/hand/{l25 => l20}/speed.py | 36 ++-- .../hand/{l25 => l20}/temperature.py | 30 ++-- src/linkerbot/hand/{l25 => l20}/torque.py | 36 ++-- src/linkerbot/hand/{l25 => l20}/version.py | 2 +- src/linkerbot/hand/o30/__init__.py | 92 ++++++++++ src/linkerbot/hand/{o30i => o30}/_runtime.py | 4 +- .../hand/{o30i => o30}/acceleration.py | 24 +-- src/linkerbot/hand/{o30i => o30}/angle.py | 55 +++--- src/linkerbot/hand/{o30i => o30}/current.py | 20 ++- .../hand/{o30i => o30}/diagnostics.py | 16 +- src/linkerbot/hand/o30/events.py | 113 ++++++++++++ src/linkerbot/hand/{o30i => o30}/fault.py | 24 +-- src/linkerbot/hand/{o30i => o30}/joints.py | 42 +++-- .../hand/{o30i => o30}/motion_time.py | 36 ++-- .../hand/{o30i/o30i.py => o30/o30.py} | 30 ++-- src/linkerbot/hand/{o30i => o30}/protocol.py | 168 +++++++++--------- src/linkerbot/hand/{o30i => o30}/sensor.py | 28 +-- src/linkerbot/hand/{o30i => o30}/speed.py | 24 +-- .../hand/{o30i => o30}/temperature.py | 20 ++- src/linkerbot/hand/{o30i => o30}/torque.py | 24 +-- src/linkerbot/hand/{o30i => o30}/version.py | 30 ++-- src/linkerbot/hand/{o30i => o30}/voltage.py | 20 ++- src/linkerbot/hand/o30i/__init__.py | 92 ---------- src/linkerbot/hand/o30i/events.py | 100 ----------- tests/hand/hand_protocol_v1/test_client.py | 2 +- tests/hand/hand_protocol_v1/test_protocol.py | 2 +- tests/hand/{l25 => l20}/__init__.py | 0 tests/hand/{l25 => l20}/conftest.py | 36 ++-- tests/hand/{l25 => l20}/test_angle.py | 76 ++++---- tests/hand/{l25 => l20}/test_angle_mapping.py | 6 +- tests/hand/l20/test_error_handling.py | 77 ++++++++ tests/hand/{l25 => l20}/test_fault.py | 50 +++--- tests/hand/{l25 => l20}/test_force_sensor.py | 38 ++-- tests/hand/{l25 => l20}/test_lifecycle.py | 22 +-- tests/hand/{l25 => l20}/test_polling.py | 76 ++++---- tests/hand/{l25 => l20}/test_speed.py | 48 ++--- tests/hand/{l25 => l20}/test_streaming.py | 100 +++++------ tests/hand/{l25 => l20}/test_stress.py | 30 ++-- tests/hand/{l25 => l20}/test_temperature.py | 36 ++-- tests/hand/{l25 => l20}/test_torque.py | 44 ++--- tests/hand/{l25 => l20}/test_validation.py | 134 +++++++------- tests/hand/{l25 => l20}/test_version.py | 24 +-- tests/hand/l25/test_error_handling.py | 77 -------- tests/hand/o30/__init__.py | 1 + tests/hand/{o30i => o30}/fakes.py | 4 +- tests/hand/{o30i => o30}/test_joints.py | 50 +++--- tests/hand/{o30i => o30}/test_managers.py | 72 ++++---- tests/hand/{o30i => o30}/test_metadata.py | 36 ++-- .../{o30i/test_o30i.py => o30/test_o30.py} | 88 ++++----- tests/hand/{o30i => o30}/test_protocol.py | 4 +- tests/hand/o30i/__init__.py | 1 - tests/hand/test_atomic_blocking_requests.py | 4 +- tests/hand/test_classic_force_polling.py | 4 +- 83 files changed, 1482 insertions(+), 1387 deletions(-) rename docs/zh/src/reference/{l25 => l20}/README.md (92%) rename docs/zh/src/reference/{l25 => l20}/angle.md (86%) rename docs/zh/src/reference/{l25 => l20}/fault.md (88%) rename docs/zh/src/reference/{l25 => l20}/force-sensor.md (87%) rename docs/zh/src/reference/{l25 => l20}/speed.md (85%) rename docs/zh/src/reference/{l25 => l20}/temperature.md (87%) rename docs/zh/src/reference/{l25 => l20}/torque.md (88%) rename docs/zh/src/reference/{l25 => l20}/version.md (90%) rename docs/zh/src/reference/{o30i => o30}/README.md (81%) rename docs/zh/src/reference/{o30i => o30}/hand-protocol-v1.md (90%) rename docs/zh/src/reference/{o30i => o30}/implementation-status.md (89%) rename docs/zh/src/reference/{o30i => o30}/runtime.md (79%) rename examples/{o30i_full_demo.py => o30_full_demo.py} (85%) rename src/linkerbot/hand/{l25 => l20}/__init__.py (62%) rename src/linkerbot/hand/{l25 => l20}/angle.py (93%) rename src/linkerbot/hand/{l25 => l20}/events.py (86%) rename src/linkerbot/hand/{l25 => l20}/fault.py (82%) rename src/linkerbot/hand/{l25 => l20}/force_sensor.py (98%) rename src/linkerbot/hand/{l25/l25.py => l20/l20.py} (94%) rename src/linkerbot/hand/{l25 => l20}/speed.py (92%) rename src/linkerbot/hand/{l25 => l20}/temperature.py (93%) rename src/linkerbot/hand/{l25 => l20}/torque.py (92%) rename src/linkerbot/hand/{l25 => l20}/version.py (99%) create mode 100644 src/linkerbot/hand/o30/__init__.py rename src/linkerbot/hand/{o30i => o30}/_runtime.py (87%) rename src/linkerbot/hand/{o30i => o30}/acceleration.py (70%) rename src/linkerbot/hand/{o30i => o30}/angle.py (72%) rename src/linkerbot/hand/{o30i => o30}/current.py (67%) rename src/linkerbot/hand/{o30i => o30}/diagnostics.py (65%) create mode 100644 src/linkerbot/hand/o30/events.py rename src/linkerbot/hand/{o30i => o30}/fault.py (69%) rename src/linkerbot/hand/{o30i => o30}/joints.py (84%) rename src/linkerbot/hand/{o30i => o30}/motion_time.py (70%) rename src/linkerbot/hand/{o30i/o30i.py => o30/o30.py} (95%) rename src/linkerbot/hand/{o30i => o30}/protocol.py (56%) rename src/linkerbot/hand/{o30i => o30}/sensor.py (63%) rename src/linkerbot/hand/{o30i => o30}/speed.py (72%) rename src/linkerbot/hand/{o30i => o30}/temperature.py (65%) rename src/linkerbot/hand/{o30i => o30}/torque.py (72%) rename src/linkerbot/hand/{o30i => o30}/version.py (73%) rename src/linkerbot/hand/{o30i => o30}/voltage.py (67%) delete mode 100644 src/linkerbot/hand/o30i/__init__.py delete mode 100644 src/linkerbot/hand/o30i/events.py rename tests/hand/{l25 => l20}/__init__.py (100%) rename tests/hand/{l25 => l20}/conftest.py (74%) rename tests/hand/{l25 => l20}/test_angle.py (80%) rename tests/hand/{l25 => l20}/test_angle_mapping.py (95%) create mode 100644 tests/hand/l20/test_error_handling.py rename tests/hand/{l25 => l20}/test_fault.py (55%) rename tests/hand/{l25 => l20}/test_force_sensor.py (83%) rename tests/hand/{l25 => l20}/test_lifecycle.py (69%) rename tests/hand/{l25 => l20}/test_polling.py (62%) rename tests/hand/{l25 => l20}/test_speed.py (82%) rename tests/hand/{l25 => l20}/test_streaming.py (70%) rename tests/hand/{l25 => l20}/test_stress.py (94%) rename tests/hand/{l25 => l20}/test_temperature.py (71%) rename tests/hand/{l25 => l20}/test_torque.py (80%) rename tests/hand/{l25 => l20}/test_validation.py (54%) rename tests/hand/{l25 => l20}/test_version.py (79%) delete mode 100644 tests/hand/l25/test_error_handling.py create mode 100644 tests/hand/o30/__init__.py rename tests/hand/{o30i => o30}/fakes.py (97%) rename tests/hand/{o30i => o30}/test_joints.py (71%) rename tests/hand/{o30i => o30}/test_managers.py (67%) rename tests/hand/{o30i => o30}/test_metadata.py (72%) rename tests/hand/{o30i/test_o30i.py => o30/test_o30.py} (73%) rename tests/hand/{o30i => o30}/test_protocol.py (96%) delete mode 100644 tests/hand/o30i/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f0c8c..508cb6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ ## [Unreleased] +### 变更 + +- 统一灵巧手型号命名:L25 SDK 重命名为 L20,O30i SDK 重命名为 O30;新 API 仅使用 L20 和 O30。 + ### 新增 - 新增灵巧手原始角度与逻辑角度映射,覆盖 L6、O6、L20Lite 和 L25 diff --git a/docs/zh/src/SUMMARY.md b/docs/zh/src/SUMMARY.md index 9105623..81e1165 100644 --- a/docs/zh/src/SUMMARY.md +++ b/docs/zh/src/SUMMARY.md @@ -9,7 +9,7 @@ # 参考 - [CAN 总线](reference/can.md) -- [CAN FD 总线(L30 / O20 / O30i)](reference/canfd.md) +- [CAN FD 总线(L30 / O20 / O30)](reference/canfd.md) - [L6](reference/l6/README.md) - [角度控制](reference/l6/angle.md) - [速度控制](reference/l6/speed.md) @@ -41,10 +41,10 @@ - [故障管理](reference/o20/fault.md) - [设备信息](reference/o20/version.md) - [轮询与事件流](reference/o20/stream-polling.md) -- [O30i](reference/o30i/README.md) - - [实现状态与测试范围](reference/o30i/implementation-status.md) - - [HandProtocol_v1.0 协议](reference/o30i/hand-protocol-v1.md) - - [运行时控制与状态](reference/o30i/runtime.md) +- [O30](reference/o30/README.md) + - [实现状态与测试范围](reference/o30/implementation-status.md) + - [HandProtocol_v1.0 协议](reference/o30/hand-protocol-v1.md) + - [运行时控制与状态](reference/o30/runtime.md) - [L20Lite](reference/l20lite/README.md) - [角度控制](reference/l20lite/angle.md) - [速度控制](reference/l20lite/speed.md) @@ -53,14 +53,14 @@ - [力传感器](reference/l20lite/force-sensor.md) - [故障管理](reference/l20lite/fault.md) - [版本信息](reference/l20lite/version.md) -- [L25](reference/l25/README.md) - - [角度控制](reference/l25/angle.md) - - [速度控制](reference/l25/speed.md) - - [扭矩控制](reference/l25/torque.md) - - [温度读取](reference/l25/temperature.md) - - [力传感器](reference/l25/force-sensor.md) - - [故障管理](reference/l25/fault.md) - - [版本信息](reference/l25/version.md) +- [L20](reference/l20/README.md) + - [角度控制](reference/l20/angle.md) + - [速度控制](reference/l20/speed.md) + - [扭矩控制](reference/l20/torque.md) + - [温度读取](reference/l20/temperature.md) + - [力传感器](reference/l20/force-sensor.md) + - [故障管理](reference/l20/fault.md) + - [版本信息](reference/l20/version.md) - [O6](reference/o6/README.md) - [角度控制](reference/o6/angle.md) - [速度控制](reference/o6/speed.md) diff --git a/docs/zh/src/guide/basics.md b/docs/zh/src/guide/basics.md index 14d10b0..dbf9596 100644 --- a/docs/zh/src/guide/basics.md +++ b/docs/zh/src/guide/basics.md @@ -74,7 +74,7 @@ with L6(side="left", interface_name="can0") as hand: | L6 | 6 | `6 x 256` | | O6 | 6 | `6 x 256` | | L20Lite | 10 | `10 x 256` | -| L25 | 16 | `16 x 256` | +| L20 | 16 | `16 x 256` | 映射表默认保存到 `~/.config/linkerbot/hand_angle_mappings.toml`,并按手型号、左右手和 CAN 接口分别保存,例如 `l6:left:can0`。如果需要按项目或设备指定独立配置文件,可以在构造手对象时传入 `angle_mapping_path`。 diff --git a/docs/zh/src/guide/installation.md b/docs/zh/src/guide/installation.md index 7ae8bdd..6515f96 100644 --- a/docs/zh/src/guide/installation.md +++ b/docs/zh/src/guide/installation.md @@ -40,4 +40,4 @@ uv add linkerbot --extra kinetix > conda install pinocchio -c conda-forge > ``` -如果只使用灵巧手(L6 / L20 Lite / L25 等),无需安装此依赖。 +如果只使用灵巧手(L6 / L20 Lite / L20 等),无需安装此依赖。 diff --git a/docs/zh/src/reference/canfd.md b/docs/zh/src/reference/canfd.md index 0e2628f..e7cb807 100644 --- a/docs/zh/src/reference/canfd.md +++ b/docs/zh/src/reference/canfd.md @@ -1,13 +1,13 @@ -# CAN FD 总线(L30 / O20 / O30i) +# CAN FD 总线(L30 / O20 / O30) -本页说明如何在 Linux 上通过 **python-can + SocketCAN** 连接 L30、O20 和 O30i。SDK 仍保留厂商动态库后端;如需使用 `libcanbus.so` / `HCanbus.dll`,请分别参考 [L30](./l30/README.md)、[O20](./o20/README.md) 和 [O30i](./o30i/README.md)。 +本页说明如何在 Linux 上通过 **python-can + SocketCAN** 连接 L30、O20 和 O30。SDK 仍保留厂商动态库后端;如需使用 `libcanbus.so` / `HCanbus.dll`,请分别参考 [L30](./l30/README.md)、[O20](./o20/README.md) 和 [O30](./o30/README.md)。 ## 两种 CAN FD 后端 -| 后端 | `interface_type` | 适用环境 | 连接参数 | -| ---------- | ------------------ | --------------- | ------------------------------------------------------------------- | -| 厂商动态库 | `"ctypes"`(默认) | Linux / Windows | `device_index`、`channel_index` 或 O20 的 `device`、`channel` | -| SocketCAN | `"socketcan"` | Linux | L30 使用 `channel="can0"`;O20/O30i 使用 `socketcan_channel="can0"` | +| 后端 | `interface_type` | 适用环境 | 连接参数 | +| ---------- | ------------------ | --------------- | ------------------------------------------------------------------ | +| 厂商动态库 | `"ctypes"`(默认) | Linux / Windows | `device_index`、`channel_index` 或 O20 的 `device`、`channel` | +| SocketCAN | `"socketcan"` | Linux | L30 使用 `channel="can0"`;O20/O30 使用 `socketcan_channel="can0"` | 选择 SocketCAN 后不需要 `libcanbus.so`,但 Linux 必须已经识别 CAN 适配器,并将对应网络接口配置为 CAN FD 模式。 @@ -47,7 +47,7 @@ sudo ip link set can0 up ip -details -statistics link show can0 ``` -L30、O20 和 O30i 默认都使用 `frame_type=0x04`,即发送 CAN FD 帧但不启用 bit-rate switching。同一条按上述方式配置的 CAN FD 链路仍需保留 5 Mbit/s 数据段,以接收设备可能发送的 BRS 帧。 +L30、O20 和 O30 默认都使用 `frame_type=0x04`,即发送 CAN FD 帧但不启用 bit-rate switching。同一条按上述方式配置的 CAN FD 链路仍需保留 5 Mbit/s 数据段,以接收设备可能发送的 BRS 帧。 ## 连接 L30 @@ -76,7 +76,7 @@ L30 的 SocketCAN 参数: | `channel` | `None` | SocketCAN 接口名,例如 `"can0"` | | `bitrate` | `1_000_000` | 仲裁段速率 | | `data_bitrate` | `5_000_000` | 数据段速率 | -| `frame_type` | `0x04` | CAN FD、不启用 BRS;`0x0C` 显式启用 BRS | +| `frame_type` | `0x04` | CAN FD、不启用 BRS;`0x0C` 显式启用 BRS | | `auto_reconfigure` | `False` | 是否允许 SDK 调用 `ip link` 重新配置接口 | | `node_id` | `1` | L30 设备 NodeID,范围 1~31 | | `host_id` | `0` | 主机节点 ID,范围 0~31 | @@ -114,16 +114,16 @@ O20 的 SocketCAN 参数: | `side` | `"right"` | 决定默认 `device_id` | | `device_id` | `None` | 显式覆盖 O20 设备 ID | -L30 与 O20/O30i 的 SocketCAN 接口参数名称不同,是为了保留各手型原有构造函数中整数 `channel` 参数的兼容性。 +L30 与 O20/O30 的 SocketCAN 接口参数名称不同,是为了保留各手型原有构造函数中整数 `channel` 参数的兼容性。 -## 连接 O30i +## 连接 O30 -O30i 使用 HandProtocol_v1.0(HOP)、11 位标准帧 ID,实测默认请求 ID 为 `0x001`、响应 ID 为 `0x401`。它与 O20 一样使用 CAN FD、无 BRS(`frame_type=0x04`)。 +O30 使用 HandProtocol_v1.0(HOP)、11 位标准帧 ID,实测默认请求 ID 为 `0x001`、响应 ID 为 `0x401`。它与 O20 一样使用 CAN FD、无 BRS(`frame_type=0x04`)。 ```python -from linkerbot import O30i +from linkerbot import O30 -with O30i( +with O30( interface_type="socketcan", socketcan_channel="can0", ) as hand: @@ -131,7 +131,7 @@ with O30i( print(info.product_model, info.protocol_name, info.protocol_version) ``` -O30i 的 SocketCAN 参数: +O30 的 SocketCAN 参数: | 参数 | 默认值 | 说明 | | ------------------- | ----------- | ---------------------------------------- | @@ -144,7 +144,7 @@ O30i 的 SocketCAN 参数: | `request_id` | `0x001` | 11 位标准帧请求 ID | | `response_id` | `None` | 默认自动计算为 `request_id \| 0x400` | -O30i 的连接、协议安全边界和控制接口详见 [O30i reference](./o30i/README.md)。 +O30 的连接、协议安全边界和控制接口详见 [O30 reference](./o30/README.md)。 ## 让 SDK 配置接口 @@ -206,7 +206,7 @@ ip -details link show can0 - 灵巧手和 CAN 适配器是否供电; - CAN_H、CAN_L 和 GND 是否正确连接; - 总线两端是否各有一个 120 Ω 终端电阻; -- L30 的 `node_id`、O20 的 `side/device_id`,或 O30i 的 `request_id/response_id` 是否与设备一致; +- L30 的 `node_id`、O20 的 `side/device_id`,或 O30 的 `request_id/response_id` 是否与设备一致; - `ip -details -statistics link show can0` 中是否出现 `bus-off`、错误计数持续增长; - 是否显式设置了 `frame_type=0x0C`;若 BRS 发送后 bus-off 或发送队列卡住,恢复默认 `0x04`; - `candump -tz -x can0` 是否能看到请求帧和设备应答帧。 diff --git a/docs/zh/src/reference/l25/README.md b/docs/zh/src/reference/l20/README.md similarity index 92% rename from docs/zh/src/reference/l25/README.md rename to docs/zh/src/reference/l20/README.md index 7138d4f..842e348 100644 --- a/docs/zh/src/reference/l25/README.md +++ b/docs/zh/src/reference/l20/README.md @@ -1,11 +1,11 @@ -# L25 灵巧手 +# L20 灵巧手 ## 快速开始 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 设置角度 hand.angle.set_angles([50.0] * 16) @@ -25,7 +25,7 @@ with L25(side="left", interface_name="can0") as hand: ## 关节说明 -L25 灵巧手拥有 16 个自由度,分布在 5 根手指上。 +L20 灵巧手拥有 16 个自由度,分布在 5 根手指上。 | 索引 | 名称 | 标识 | | ---- | ---------- | -------------- | @@ -60,13 +60,13 @@ L25 灵巧手拥有 16 个自由度,分布在 5 根手指上。 ## 统一流式读取 -L25 提供统一的事件流接口,通过 `hand.stream()` 和 `hand.start_polling()` 获取所有传感器数据。初始化时会自动以默认间隔启动轮询(角度 60 Hz、力传感器 30 Hz),无需手动调用 `start_polling()`。 +L20 提供统一的事件流接口,通过 `hand.stream()` 和 `hand.start_polling()` 获取所有传感器数据。初始化时会自动以默认间隔启动轮询(角度 60 Hz、力传感器 30 Hz),无需手动调用 `start_polling()`。 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource, AngleEvent, TemperatureEvent +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource, AngleEvent, TemperatureEvent -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 可同时指定多个传感器及各自的轮询间隔(秒) # 再次调用 start_polling() 会覆盖之前的设置 hand.start_polling({SensorSource.ANGLE: 0.05, SensorSource.TEMPERATURE: 1.0}) diff --git a/docs/zh/src/reference/l25/angle.md b/docs/zh/src/reference/l20/angle.md similarity index 86% rename from docs/zh/src/reference/l25/angle.md rename to docs/zh/src/reference/l20/angle.md index 0d55b97..39313be 100644 --- a/docs/zh/src/reference/l25/angle.md +++ b/docs/zh/src/reference/l20/angle.md @@ -1,6 +1,6 @@ # 角度控制 -通过 `hand.angle` 控制和读取 L25 灵巧手的 16 个关节电机角度。 +通过 `hand.angle` 控制和读取 L20 灵巧手的 16 个关节电机角度。 - **角度范围**: 0-100 - **单位**: 无量纲(映射到关节电机实际角度) @@ -8,14 +8,14 @@ ## 设置角度 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import L25Angle +from linkerbot import L20 +from linkerbot.hand.l20 import L20Angle # 使用列表(16 个关节) hand.angle.set_angles([50.0] * 16) -# 使用 L25Angle 对象 -angles = L25Angle( +# 使用 L20Angle 对象 +angles = L20Angle( thumb_abd=50.0, # 拇指侧摆 thumb_yaw=30.0, # 拇指旋转 thumb_root1=60.0, # 拇指根部 @@ -47,7 +47,7 @@ hand.angle.set_raw_angles([128] * 16) SDK 发送控制帧前会将“标准 raw 值”通过角度映射表转换为“硬件 raw 值”。默认映射是线性直通:`0 -> 0`,`255 -> 255`。 -L25 的映射表形状为 `16 x 256`: +L20 的映射表形状为 `16 x 256`: - 行:关节索引,顺序与 [关节说明](./README.md#关节说明) 一致。 - 列:标准 raw 输入值 `0-255`。 @@ -61,14 +61,14 @@ hand.angle.set_angle_mapping(mapping) hand.angle.reset_angle_mapping() ``` -映射表默认保存到 `~/.config/linkerbot/hand_angle_mappings.toml`。保存时会按手型号、左右手和 CAN 接口区分,例如 `l25:left:can0`。也可以在构造手对象时通过 `angle_mapping_path` 指定 TOML 路径。 +映射表默认保存到 `~/.config/linkerbot/hand_angle_mappings.toml`。保存时会按手型号、左右手和 CAN 接口区分,例如 `l20:left:can0`。也可以在构造手对象时通过 `angle_mapping_path` 指定 TOML 路径。 ## 读取角度 ### 阻塞读取 ```python -from linkerbot import L25 +from linkerbot import L20 from linkerbot.exceptions import TimeoutError try: @@ -93,7 +93,7 @@ if data: 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, AngleEvent +from linkerbot.hand.l20 import SensorSource, AngleEvent hand.start_polling({SensorSource.ANGLE: 0.1}) @@ -110,9 +110,9 @@ finally: ## 完整示例 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 设置角度 hand.angle.set_angles([0.0] * 16) diff --git a/docs/zh/src/reference/l25/fault.md b/docs/zh/src/reference/l20/fault.md similarity index 88% rename from docs/zh/src/reference/l25/fault.md rename to docs/zh/src/reference/l20/fault.md index e1278fd..5e0d71f 100644 --- a/docs/zh/src/reference/l25/fault.md +++ b/docs/zh/src/reference/l20/fault.md @@ -1,6 +1,6 @@ # 故障管理 -L25 灵巧手的故障检测与清除功能。 +L20 灵巧手的故障检测与清除功能。 ## 概述 @@ -11,7 +11,7 @@ L25 灵巧手的故障检测与清除功能。 ## 故障码表 -L25 使用位标志(Flag)表示故障状态,每个关节电机可同时存在多个故障。 +L20 使用位标志(Flag)表示故障状态,每个关节电机可同时存在多个故障。 | 故障码 | 值 | 说明 | | --------------------- | --- | ------------ | @@ -44,7 +44,7 @@ except TimeoutError: **返回值**:`FaultData` 对象,包含: -- `faults`:`L25Fault` 故障数据 +- `faults`:`L20Fault` 故障数据 - `timestamp`:时间戳 **异常**: @@ -74,7 +74,7 @@ hand.fault.clear_faults() 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, FaultEvent +from linkerbot.hand.l20 import SensorSource, FaultEvent hand.start_polling({SensorSource.FAULT: 0.2}) @@ -93,7 +93,7 @@ finally: ## 数据类说明 -### L25Fault 属性 +### L20Fault 属性 | 属性 | 说明 | | -------------- | ---------- | @@ -114,20 +114,20 @@ finally: | `pinky_root1` | 小指根部 | | `pinky_tip` | 小指指尖 | -### L25Fault 方法 +### L20Fault 方法 ```python # 检查是否有任何故障 faults.has_any_fault() # -> bool # 转为列表 -faults.to_list() # -> list[L25FaultCode] +faults.to_list() # -> list[L20FaultCode] # 索引访问 faults[0] # thumb_abd ``` -### L25FaultCode 方法 +### L20FaultCode 方法 ```python # 检查单个关节电机是否有故障 @@ -142,9 +142,9 @@ faults.thumb_abd.get_fault_names() # -> list[str] ### 检查故障状态 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 读取故障状态 data = hand.fault.get_blocking(timeout_ms=500) @@ -164,10 +164,10 @@ with L25(side="left", interface_name="can0") as hand: ### 持续监控 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource, FaultEvent +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource, FaultEvent -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: hand.start_polling({SensorSource.FAULT: 0.2}) try: diff --git a/docs/zh/src/reference/l25/force-sensor.md b/docs/zh/src/reference/l20/force-sensor.md similarity index 87% rename from docs/zh/src/reference/l25/force-sensor.md rename to docs/zh/src/reference/l20/force-sensor.md index 5a84dc6..8adb10a 100644 --- a/docs/zh/src/reference/l25/force-sensor.md +++ b/docs/zh/src/reference/l20/force-sensor.md @@ -1,15 +1,15 @@ # 力传感器 -L25 灵巧手配备 5 个手指的力传感器(thumb, index, middle, ring, pinky),支持阻塞读取和缓存读取两种模式。 +L20 灵巧手配备 5 个手指的力传感器(thumb, index, middle, ring, pinky),支持阻塞读取和缓存读取两种模式。 ## 概述 通过 `hand.force_sensor` 访问力传感器功能: ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: data = hand.force_sensor.get_blocking() ``` @@ -83,7 +83,7 @@ if thumb_data: 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, ForceSensorEvent +from linkerbot.hand.l20 import SensorSource, ForceSensorEvent hand.start_polling({SensorSource.FORCE_SENSOR: 0.1}) @@ -102,9 +102,9 @@ finally: ### 阻塞读取所有手指 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: data = hand.force_sensor.get_blocking(timeout_ms=1000) print(f"拇指:{data.thumb.values.shape}") print(f"食指:{data.index.values.shape}") @@ -117,10 +117,10 @@ with L25(side="left", interface_name="can0") as hand: ```python import time -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource, ForceSensorEvent +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource, ForceSensorEvent -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: hand.start_polling({SensorSource.FORCE_SENSOR: 0.05}) start = time.time() diff --git a/docs/zh/src/reference/l25/speed.md b/docs/zh/src/reference/l20/speed.md similarity index 85% rename from docs/zh/src/reference/l25/speed.md rename to docs/zh/src/reference/l20/speed.md index 7984bef..10b290d 100644 --- a/docs/zh/src/reference/l25/speed.md +++ b/docs/zh/src/reference/l20/speed.md @@ -1,6 +1,6 @@ # 速度控制 -通过 `hand.speed` 控制和读取 L25 灵巧手各关节电机的运动速度。 +通过 `hand.speed` 控制和读取 L20 灵巧手各关节电机的运动速度。 - **速度范围**: 0-100 - **单位**: 无量纲 @@ -8,14 +8,14 @@ ## 设置速度 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import L25Speed +from linkerbot import L20 +from linkerbot.hand.l20 import L20Speed # 使用列表(16 个关节) hand.speed.set_speeds([50.0] * 16) -# 使用 L25Speed 对象 -speed = L25Speed( +# 使用 L20Speed 对象 +speed = L20Speed( thumb_abd=30.0, # 拇指侧摆 thumb_yaw=30.0, # 拇指旋转 thumb_root1=30.0, # 拇指根部 @@ -41,7 +41,7 @@ hand.speed.set_speeds(speed) ### 阻塞读取 ```python -from linkerbot import L25 +from linkerbot import L20 from linkerbot.exceptions import TimeoutError try: @@ -66,7 +66,7 @@ if data: 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, SpeedEvent +from linkerbot.hand.l20 import SensorSource, SpeedEvent hand.start_polling({SensorSource.SPEED: 0.1}) @@ -83,12 +83,12 @@ finally: ## 完整示例 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import L25Speed +from linkerbot import L20 +from linkerbot.hand.l20 import L20Speed -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 设置速度 - speed = L25Speed( + speed = L20Speed( thumb_abd=30.0, thumb_yaw=30.0, thumb_root1=30.0, diff --git a/docs/zh/src/reference/l25/temperature.md b/docs/zh/src/reference/l20/temperature.md similarity index 87% rename from docs/zh/src/reference/l25/temperature.md rename to docs/zh/src/reference/l20/temperature.md index 7cd1789..2e55c73 100644 --- a/docs/zh/src/reference/l25/temperature.md +++ b/docs/zh/src/reference/l20/temperature.md @@ -1,6 +1,6 @@ # 温度读取 -通过 `hand.temperature` 读取 L25 灵巧手 16 个关节电机的温度数据(单位:°C)。 +通过 `hand.temperature` 读取 L20 灵巧手 16 个关节电机的温度数据(单位:°C)。 **温度属性** @@ -43,7 +43,7 @@ except TimeoutError: **返回值** -- `TemperatureData`: 包含 `temperatures`(L25Temperature)和 `timestamp` +- `TemperatureData`: 包含 `temperatures`(L20Temperature)和 `timestamp` **异常** @@ -68,7 +68,7 @@ if data: 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, TemperatureEvent +from linkerbot.hand.l20 import SensorSource, TemperatureEvent hand.start_polling({SensorSource.TEMPERATURE: 0.1}) @@ -87,9 +87,9 @@ finally: ### 读取所有关节电机温度 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: data = hand.temperature.get_blocking(timeout_ms=500) # 按属性访问 @@ -107,10 +107,10 @@ with L25(side="left", interface_name="can0") as hand: ### 温度监控 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource, TemperatureEvent +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource, TemperatureEvent -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: hand.start_polling({SensorSource.TEMPERATURE: 0.1}) try: diff --git a/docs/zh/src/reference/l25/torque.md b/docs/zh/src/reference/l20/torque.md similarity index 88% rename from docs/zh/src/reference/l25/torque.md rename to docs/zh/src/reference/l20/torque.md index 7bc2403..97631ca 100644 --- a/docs/zh/src/reference/l25/torque.md +++ b/docs/zh/src/reference/l20/torque.md @@ -1,6 +1,6 @@ # 扭矩控制 -通过 `hand.torque` 控制和读取 L25 灵巧手的 16 个关节电机扭矩。 +通过 `hand.torque` 控制和读取 L20 灵巧手的 16 个关节电机扭矩。 - **扭矩范围**: 0-100 - **单位**: 无量纲百分比 @@ -8,14 +8,14 @@ ## 设置扭矩 ```python -from linkerbot import L25 -from linkerbot.hand.l25 import L25Torque +from linkerbot import L20 +from linkerbot.hand.l20 import L20Torque # 使用列表(16 个关节) hand.torque.set_torques([50.0] * 16) -# 使用 L25Torque 对象 -torques = L25Torque( +# 使用 L20Torque 对象 +torques = L20Torque( thumb_abd=50.0, # 拇指侧摆 thumb_yaw=30.0, # 拇指旋转 thumb_root1=60.0, # 拇指根部 @@ -65,7 +65,7 @@ if data: 通过顶层 `hand.stream()` 统一接收所有传感器事件: ```python -from linkerbot.hand.l25 import SensorSource, TorqueEvent +from linkerbot.hand.l20 import SensorSource, TorqueEvent hand.start_polling({SensorSource.TORQUE: 0.1}) @@ -82,9 +82,9 @@ finally: ## 完整示例 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: # 设置扭矩 hand.torque.set_torques([50.0] * 16) diff --git a/docs/zh/src/reference/l25/version.md b/docs/zh/src/reference/l20/version.md similarity index 90% rename from docs/zh/src/reference/l25/version.md rename to docs/zh/src/reference/l20/version.md index 797a144..dedceeb 100644 --- a/docs/zh/src/reference/l25/version.md +++ b/docs/zh/src/reference/l20/version.md @@ -1,6 +1,6 @@ # 版本信息 -获取 L25 灵巧手的版本信息。 +获取 L20 灵巧手的版本信息。 ## 概述 @@ -35,9 +35,9 @@ hand.version.get_device_info() -> DeviceInfo ### 读取设备信息 ```python -from linkerbot import L25 +from linkerbot import L20 -with L25(side="left", interface_name="can0") as hand: +with L20(side="left", interface_name="can0") as hand: info = hand.version.get_device_info() print(f"序列号:{info.serial_number}") print(f"PCB 版本:{info.pcb_version}") diff --git a/docs/zh/src/reference/l30/README.md b/docs/zh/src/reference/l30/README.md index 0eed81a..8f46cf3 100644 --- a/docs/zh/src/reference/l30/README.md +++ b/docs/zh/src/reference/l30/README.md @@ -2,7 +2,7 @@ L30 是基于 CANFD 的 17 关节灵巧手。SDK 使用 `node_id` 寻址设备,支持单只手、同一 CANFD 总线上的多只手,以及多个 CANFD 模块/通道上的多只手。 -SDK 支持两种 CAN FD 连接方式:默认使用厂商 `libcanbus.so` / `HCanbus.dll`,Linux 也可以通过 python-can 使用 SocketCAN。SocketCAN 的系统配置与连接示例见 [CAN FD 总线(L30 / O20 / O30i)](../canfd.md)。 +SDK 支持两种 CAN FD 连接方式:默认使用厂商 `libcanbus.so` / `HCanbus.dll`,Linux 也可以通过 python-can 使用 SocketCAN。SocketCAN 的系统配置与连接示例见 [CAN FD 总线(L30 / O20 / O30)](../canfd.md)。 ## 厂商动态库后端准备工作 diff --git a/docs/zh/src/reference/o20/README.md b/docs/zh/src/reference/o20/README.md index df06faa..1ca5c39 100644 --- a/docs/zh/src/reference/o20/README.md +++ b/docs/zh/src/reference/o20/README.md @@ -2,7 +2,7 @@ O20 是基于 CANFD 的 16 自由度灵巧手。SDK 使用 `device_id` 寻址设备,支持单只手、同一 CANFD 总线上的多只手 (左右手共享一条 bus)、以及多个 CANFD 模块/通道上的多只手。 -SDK 支持两种 CAN FD 连接方式:默认使用厂商 `libcanbus.so` / `HCanbus.dll`,Linux 也可以通过 python-can 使用 SocketCAN。SocketCAN 的系统配置与连接示例见 [CAN FD 总线(L30 / O20 / O30i)](../canfd.md)。 +SDK 支持两种 CAN FD 连接方式:默认使用厂商 `libcanbus.so` / `HCanbus.dll`,Linux 也可以通过 python-can 使用 SocketCAN。SocketCAN 的系统配置与连接示例见 [CAN FD 总线(L30 / O20 / O30)](../canfd.md)。 ## 厂商动态库后端准备工作 diff --git a/docs/zh/src/reference/o30i/README.md b/docs/zh/src/reference/o30/README.md similarity index 81% rename from docs/zh/src/reference/o30i/README.md rename to docs/zh/src/reference/o30/README.md index ab12af5..fd63a05 100644 --- a/docs/zh/src/reference/o30i/README.md +++ b/docs/zh/src/reference/o30/README.md @@ -1,6 +1,6 @@ -# O30i CAN FD 灵巧手 +# O30 CAN FD 灵巧手 -O30i 使用 HandProtocol_v1.0(协议名 HOP)。当前固件实测使用 11 位标准 CAN ID:上位机请求 `0x001`,设备响应 `0x401`;报文为 CAN FD、无 BRS(`frame_type=0x04`)。SDK 支持两种后端: +O30 使用 HandProtocol_v1.0(协议名 HOP)。当前固件实测使用 11 位标准 CAN ID:上位机请求 `0x001`,设备响应 `0x401`;报文为 CAN FD、无 BRS(`frame_type=0x04`)。SDK 支持两种后端: | 后端 | 参数 | 平台 | 说明 | | -------------------- | --------------------------------- | --------------- | ----------------------------------- | @@ -12,9 +12,9 @@ O30i 使用 HandProtocol_v1.0(协议名 HOP)。当前固件实测使用 11 厂商动态库后端: ```python -from linkerbot import O30i +from linkerbot import O30 -with O30i(library_path="/usr/local/lib/libcanbus.so") as hand: +with O30(library_path="/usr/local/lib/libcanbus.so") as hand: info = hand.version.get_device_info(timeout_ms=1000) print(info.product_model, info.protocol_name, info.protocol_version) @@ -33,9 +33,9 @@ sudo ip link set can0 up ``` ```python -from linkerbot import O30i +from linkerbot import O30 -with O30i( +with O30( interface_type="socketcan", socketcan_channel="can0", ) as hand: @@ -44,25 +44,25 @@ with O30i( 完整的 SocketCAN 配置和排错方式见 [CAN FD 总线](../canfd.md)。 -完整实物诊断示例位于 `examples/o30i_full_demo.py`。默认只读,不会发送运动命令: +完整实物诊断示例位于 `examples/o30_full_demo.py`。默认只读,不会发送运动命令: ```bash -uv run python examples/o30i_full_demo.py --channel can0 +uv run python examples/o30_full_demo.py --channel can0 ``` 显式点动一个真实关节时使用 `--move`;程序会要求再次输入 `MOVE`,并在退出前恢复原始位置: ```bash -uv run python examples/o30i_full_demo.py \ +uv run python examples/o30_full_demo.py \ --channel can0 --move --joint tip_1 --delta-raw 8 ``` ## 构造参数 ```python -from linkerbot import O30i +from linkerbot import O30 -hand = O30i( +hand = O30( request_id=0x001, response_id=None, device=0, @@ -88,13 +88,13 @@ hand.close() | `data_bitrate` | `int` | SocketCAN 数据段配置值,默认 5 Mbit/s | | `auto_reconfigure` | `bool` | 是否允许 SDK 配置 SocketCAN 链路 | -推荐始终使用 `with O30i(...) as hand:`,确保收发线程和底层连接被释放。 +推荐始终使用 `with O30(...) as hand:`,确保收发线程和底层连接被释放。 ## 20 个实际关节 -固件运行时对象包含 36 个单字节槽位,但 O30i 实际只存在 20 个关节。SDK 读取完整 36 字节后只返回真实关节;完整写入会自动跳过不存在的槽位。 +固件运行时对象包含 36 个单字节槽位,但 O30 实际只存在 20 个关节。SDK 读取完整 36 字节后只返回真实关节;完整写入会自动跳过不存在的槽位。 -当前只确认协议原始范围为 `0~255`,没有可靠的角度标定值。实物验证确认 O30i 的原始值方向与 SDK 逻辑百分比相反:`0%` 是张开端、对应 raw `255`;`100%` 是闭合端、对应 raw `0`。`set_raw_angles()`、原始切片和稀疏写始终使用设备原生方向,不做反转。 +当前只确认协议原始范围为 `0~255`,没有可靠的角度标定值。实物验证确认 O30 的原始值方向与 SDK 逻辑百分比相反:`0%` 是张开端、对应 raw `255`;`100%` 是闭合端、对应 raw `0`。`set_raw_angles()`、原始切片和稀疏写始终使用设备原生方向,不做反转。 | 索引范围 | 标识 | 说明 | | -------: | -------------------- | ------------------------ | @@ -106,12 +106,12 @@ hand.close() 完整 ID、名称、线上 SI、默认值、已实现和未实现功能见[实现状态与测试范围](./implementation-status.md)。 -程序中可通过 `O30I_JOINT_SPECS` 获取同一张表: +程序中可通过 `O30_JOINT_SPECS` 获取同一张表: ```python -from linkerbot.hand.o30i import O30I_JOINT_SPECS +from linkerbot.hand.o30 import O30_JOINT_SPECS -for index, spec in enumerate(O30I_JOINT_SPECS): +for index, spec in enumerate(O30_JOINT_SPECS): print(index, spec.name, spec.minimum, spec.maximum) ``` @@ -137,7 +137,7 @@ for index, spec in enumerate(O30I_JOINT_SPECS): ## 实测安全边界 -O30i 当前固件与早期协议文档有若干不一致,SDK 的高层接口有意遵循以下边界: +O30 当前固件与早期协议文档有若干不一致,SDK 的高层接口有意遵循以下边界: - `MI=0x00` 关节映射无可靠响应,不提供高层配置接口; - `MI=0x0C` 使能掩码通信可通但功能不生效,不作为使能状态; diff --git a/docs/zh/src/reference/o30i/hand-protocol-v1.md b/docs/zh/src/reference/o30/hand-protocol-v1.md similarity index 90% rename from docs/zh/src/reference/o30i/hand-protocol-v1.md rename to docs/zh/src/reference/o30/hand-protocol-v1.md index 5c868fc..deb8b12 100644 --- a/docs/zh/src/reference/o30i/hand-protocol-v1.md +++ b/docs/zh/src/reference/o30/hand-protocol-v1.md @@ -1,10 +1,10 @@ # HandProtocol_v1.0 (HOP) -`HandProtocolV1` 是与具体手型、`libcanbus.so` 和 SocketCAN 解耦的对象协议层。O30i 在它之上提供设备对象定义和安全的高层 manager。 +`HandProtocolV1` 是与具体手型、`libcanbus.so` 和 SocketCAN 解耦的对象协议层。O30 在它之上提供设备对象定义和安全的高层 manager。 ## 帧格式 -O30i 默认使用标准帧请求 ID `0x001` 和响应 ID `0x401`: +O30 默认使用标准帧请求 ID `0x001` 和响应 ID `0x401`: ```text Byte 0 CTRL:bit7=RTS,bit0..6=主索引 MI @@ -63,12 +63,12 @@ SDK 会抛出 `HandProtocolDeviceError`,并保留 `.error_index` 和 `.error_c ## 原始对象 API -每个 `O30i` 实例通过 `hand.protocol` 暴露传输无关客户端: +每个 `O30` 实例通过 `hand.protocol` 暴露传输无关客户端: ```python -from linkerbot import O30i +from linkerbot import O30 -with O30i(interface_type="socketcan", socketcan_channel="can0") as hand: +with O30(interface_type="socketcan", socketcan_channel="can0") as hand: # 读取实际值:CTRL 的 RTS=0 actual_tips = hand.protocol.read( main_index=0x01, diff --git a/docs/zh/src/reference/o30i/implementation-status.md b/docs/zh/src/reference/o30/implementation-status.md similarity index 89% rename from docs/zh/src/reference/o30i/implementation-status.md rename to docs/zh/src/reference/o30/implementation-status.md index dd0b3c7..192c34b 100644 --- a/docs/zh/src/reference/o30i/implementation-status.md +++ b/docs/zh/src/reference/o30/implementation-status.md @@ -1,6 +1,6 @@ -# O30i 实现状态与测试范围 +# O30 实现状态与测试范围 -本文以 O30i 实际存在的 20 个关节为唯一公开关节模型。固件的运行时对象仍保留 36 个协议槽位,SDK 会负责在 20 个真实关节与 36 个线上槽位之间转换。 +本文以 O30 实际存在的 20 个关节为唯一公开关节模型。固件的运行时对象仍保留 36 个协议槽位,SDK 会负责在 20 个真实关节与 36 个线上槽位之间转换。 ## 实际关节定义 @@ -27,14 +27,14 @@ | 18 | `tip_3` | 无名指尖 | 23 | 0~255 | 128 | 指尖 | | 19 | `tip_4` | 小指尖 | 24 | 0~255 | 128 | 指尖 | -线上槽位 1~4、15、25~35 不对应 O30i 实际关节。所有高层运行时读取只返回上表的 20 个值;所有高层写入都会跳过这些不存在的槽位。 +线上槽位 1~4、15、25~35 不对应 O30 实际关节。所有高层运行时读取只返回上表的 20 个值;所有高层写入都会跳过这些不存在的槽位。 ## 已实现 | 功能 | API | 状态 | | ---------------------- | ----------------------------------------------- | ---------------------------------------------------- | -| CAN FD 连接与生命周期 | `O30i(...)`、`close()`、上下文管理器 | 已实现;支持厂商动态库与 SocketCAN | -| 关节元数据与默认值 | `O30I_JOINT_SPECS`、`O30I_DEFAULT_RAW_VALUES` | 已实现;固定为上表 20 个关节 | +| CAN FD 连接与生命周期 | `O30(...)`、`close()`、上下文管理器 | 已实现;支持厂商动态库与 SocketCAN | +| 关节元数据与默认值 | `O30_JOINT_SPECS`、`O30_DEFAULT_RAW_VALUES` | 已实现;固定为上表 20 个关节 | | 8 位位置读写 | `hand.angle` | 已实现;百分比反向映射、原始值、目标值、切片及稀疏写 | | 16 位位置读取 | `hand.angle.get_i16_blocking()` | 已实现;读取完整 72B 后返回 20 个真实关节 | | 速度读写 | `hand.speed` | 已实现;20 个原始字节值 | @@ -73,7 +73,7 @@ ## 自动化测试 -O30i 测试位于 `tests/hand/o30i/`,底层 HOP 测试位于 `tests/hand/hand_protocol_v1/`。当前自动化测试覆盖: +O30 测试位于 `tests/hand/o30/`,底层 HOP 测试位于 `tests/hand/hand_protocol_v1/`。当前自动化测试覆盖: - 20 个关节的 ID、中文名、默认值、分组和线上槽位; - `0/5/50/100%` 到反向 raw 值的换算、全部 raw 字节往返和默认 raw 姿态; @@ -87,7 +87,7 @@ O30i 测试位于 `tests/hand/o30i/`,底层 HOP 测试位于 `tests/hand/hand_ 运行命令: ```bash -uv run pytest tests/hand/o30i tests/hand/hand_protocol_v1 +uv run pytest tests/hand/o30 tests/hand/hand_protocol_v1 ``` 这些测试使用模拟 dispatcher,不会驱动真实电机。百分比与 raw 的反向关系已经实物确认;真实硬件仍需在限速、卸载和可急停条件下验证默认姿态、各关节具体机械方向、单位及固件版本兼容性。 diff --git a/docs/zh/src/reference/o30i/runtime.md b/docs/zh/src/reference/o30/runtime.md similarity index 79% rename from docs/zh/src/reference/o30i/runtime.md rename to docs/zh/src/reference/o30/runtime.md index 43b84b8..e1dbc86 100644 --- a/docs/zh/src/reference/o30i/runtime.md +++ b/docs/zh/src/reference/o30/runtime.md @@ -1,24 +1,24 @@ # 运行时控制与状态 -O30i 的常用运行时对象在协议线上都是 36 槽向量,但实际只有 20 个关节。高层 API 的完整读写均使用[实际关节顺序](./implementation-status.md#实际关节定义),SDK 自动跳过不存在的线上槽位。 +O30 的常用运行时对象在协议线上都是 36 槽向量,但实际只有 20 个关节。高层 API 的完整读写均使用[实际关节顺序](./implementation-status.md#实际关节定义),SDK 自动跳过不存在的线上槽位。 ## 位置控制 百分比 API: ```python -from linkerbot import O30i +from linkerbot import O30 -with O30i() as hand: +with O30() as hand: hand.angle.set_angles([50.0] * 20, timeout_ms=1000) ``` -实物验证确认 O30i 的百分比与原始字节反向换算:`0%`(张开端)对应 raw `255`,`100%`(闭合端)对应 raw `0`。百分比仍是逻辑归一化值,不是以度为单位的标定角度。 +实物验证确认 O30 的百分比与原始字节反向换算:`0%`(张开端)对应 raw `255`,`100%`(闭合端)对应 raw `0`。百分比仍是逻辑归一化值,不是以度为单位的标定角度。 原始字节 API: ```python -with O30i() as hand: +with O30() as hand: hand.angle.set_raw_angles([0x80] * 20, timeout_ms=1000) # 只改五指指尖:SDK 关节索引 15~19 @@ -45,7 +45,7 @@ hand.angle.set_sparse({15: 0x80, 16: 0x80}) ## 位置读取 ```python -with O30i() as hand: +with O30() as hand: actual = hand.angle.get_blocking(timeout_ms=1000) print("百分比", actual.angles.to_list()) print("原始值", actual.angles.to_raw()) @@ -64,7 +64,7 @@ with O30i() as hand: 这些 manager 接受 20 个协议原始字节: ```python -with O30i() as hand: +with O30() as hand: hand.speed.set_all(40) hand.acceleration.set_all(20) hand.torque.set_all(30) @@ -81,7 +81,7 @@ with O30i() as hand: 运动时间每格为 10 ms,可使用 ticks 或严格的毫秒整数: ```python -with O30i() as hand: +with O30() as hand: hand.motion_time.set_all_ticks(10) # 100 ms hand.motion_time.set_milliseconds([100] * 20) @@ -95,7 +95,7 @@ with O30i() as hand: ## 只读运行状态 ```python -with O30i() as hand: +with O30() as hand: currents = hand.current.get_blocking(timeout_ms=1000).currents voltages = hand.voltage.get_blocking(timeout_ms=1000).voltages temperatures = hand.temperature.get_blocking(timeout_ms=1000).temperatures @@ -110,7 +110,7 @@ with O30i() as hand: ## 产品信息、传感器能力与诊断 ```python -with O30i() as hand: +with O30() as hand: info = hand.version.get_device_info(timeout_ms=1000) print(info.product_model, info.protocol_name, info.protocol_version) @@ -122,15 +122,15 @@ with O30i() as hand: print(errors.latest_index, errors.history) ``` -`get_device_info()` 自动重组 225 字节多帧响应。`sensor.get_info()` 先读取 `MI=0x31` 的 50 字节描述;当前 O30i 实测为 `NO_SENSOR` 且总长度为 0,因此 SDK 不会假定 `0x32~0x34` 数据通道可用。 +`get_device_info()` 自动重组 225 字节多帧响应。`sensor.get_info()` 先读取 `MI=0x31` 的 50 字节描述;当前 O30 实测为 `NO_SENSOR` 且总长度为 0,因此 SDK 不会假定 `0x32~0x34` 数据通道可用。 ## 快照、轮询和事件流 ```python -from linkerbot import O30i -from linkerbot.hand.o30i import AngleEvent, SensorSource +from linkerbot import O30 +from linkerbot.hand.o30 import AngleEvent, SensorSource -with O30i() as hand: +with O30() as hand: stream = hand.stream(maxsize=100) hand.start_polling( { @@ -150,4 +150,4 @@ with O30i() as hand: hand.stop_stream() ``` -O30i 没有 SDK 管理的设备端周期上报配置,`start_polling()` 是主机主动读。支持的源包括 `ANGLE`、`SPEED`、`ACCELERATION`、`CURRENT`、`VOLTAGE`、`TORQUE`、`TEMPERATURE`、`MOTION_TIME` 和 `FAULT`。 +O30 没有 SDK 管理的设备端周期上报配置,`start_polling()` 是主机主动读。支持的源包括 `ANGLE`、`SPEED`、`ACCELERATION`、`CURRENT`、`VOLTAGE`、`TORQUE`、`TEMPERATURE`、`MOTION_TIME` 和 `FAULT`。 diff --git a/examples/o30i_full_demo.py b/examples/o30_full_demo.py similarity index 85% rename from examples/o30i_full_demo.py rename to examples/o30_full_demo.py index f20ed37..f2b07e5 100644 --- a/examples/o30i_full_demo.py +++ b/examples/o30_full_demo.py @@ -1,4 +1,4 @@ -"""O30i 实物只读诊断、轮询和低幅度单关节点动示例。 +"""O30 实物只读诊断、轮询和低幅度单关节点动示例。 准备 SocketCAN FD(默认 1 Mbit/s 仲裁、5 Mbit/s 数据段): @@ -8,19 +8,19 @@ 执行只读诊断(默认不会发送运动命令): - uv run python examples/o30i_full_demo.py --channel can0 + uv run python examples/o30_full_demo.py --channel can0 使用厂商动态库: - uv run python examples/o30i_full_demo.py \ + uv run python examples/o30_full_demo.py \ --interface ctypes --library-path /usr/local/lib/libcanbus.so 低幅度点动食指指尖;程序会要求输入 ``MOVE``,并在 finally 中恢复原值: - uv run python examples/o30i_full_demo.py \ + uv run python examples/o30_full_demo.py \ --channel can0 --move --joint tip_1 --delta-raw 8 -O30i 高层 SDK 没有可靠的使能/失能接口。运行 ``--move`` 前必须确保设备 +O30 高层 SDK 没有可靠的使能/失能接口。运行 ``--move`` 前必须确保设备 处于可运动状态、手部无负载、人员远离机构,并准备好硬件急停或断电手段。 """ @@ -34,18 +34,18 @@ from collections.abc import Callable, Sequence from linkerbot.exceptions import LinkerbotError -from linkerbot.hand.o30i import ( - O30I_DEFAULT_RAW_VALUES, - O30I_JOINT_COUNT, - O30I_JOINT_SPECS, - O30i, +from linkerbot.hand.o30 import ( + O30, + O30_DEFAULT_RAW_VALUES, + O30_JOINT_COUNT, + O30_JOINT_SPECS, SensorSource, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( - description="O30i 实物全功能示例(默认仅执行只读诊断)" + description="O30 实物全功能示例(默认仅执行只读诊断)" ) parser.add_argument( "--interface", @@ -125,13 +125,13 @@ def run() -> None: _confirm_motion(args, joint_index) print( - f"[O30i] interface={args.interface}, request_id=0x{args.request_id:03X}, " + f"[O30] interface={args.interface}, request_id=0x{args.request_id:03X}, " f"response_id={_format_response_id(args.response_id)}" ) hand = _open_hand(args) with hand: print( - f"[O30i] 已连接:request_id=0x{hand.request_id:03X}, " + f"[O30] 已连接:request_id=0x{hand.request_id:03X}, " f"response_id=0x{hand.response_id:03X}" ) _show_joint_definitions() @@ -184,7 +184,7 @@ def run() -> None: if args.move: _step( - f"低幅度点动 {O30I_JOINT_SPECS[joint_index].id}", + f"低幅度点动 {O30_JOINT_SPECS[joint_index].id}", lambda: _move_one_joint( hand, joint_index=joint_index, @@ -196,16 +196,16 @@ def run() -> None: strict=True, ) else: - print("\n[O30i] 未传 --move:没有发送任何运动命令") + print("\n[O30] 未传 --move:没有发送任何运动命令") _show_snapshot_summary(hand) - print(f"\n[O30i] 连接已关闭:closed={hand.is_closed()}") + print(f"\n[O30] 连接已关闭:closed={hand.is_closed()}") -def _open_hand(args: argparse.Namespace) -> O30i: +def _open_hand(args: argparse.Namespace) -> O30: if args.interface == "socketcan": - return O30i( + return O30( request_id=args.request_id, response_id=args.response_id, interface_type="socketcan", @@ -214,7 +214,7 @@ def _open_hand(args: argparse.Namespace) -> O30i: data_bitrate=args.data_bitrate, auto_reconfigure=args.auto_reconfigure, ) - return O30i( + return O30( request_id=args.request_id, response_id=args.response_id, interface_type="ctypes", @@ -225,16 +225,16 @@ def _open_hand(args: argparse.Namespace) -> O30i: def _show_joint_definitions() -> None: - print("\n[O30i] 20 个实际关节(SDK index -> wire SI)") - for index, spec in enumerate(O30I_JOINT_SPECS): + print("\n[O30] 20 个实际关节(SDK index -> wire SI)") + for index, spec in enumerate(O30_JOINT_SPECS): print( f" {index:2d} {spec.id:8s} {spec.name:6s} " f"SI={spec.wire_slot:02d} default_raw={spec.default:3d}" ) - print(" default_raw:", list(O30I_DEFAULT_RAW_VALUES)) + print(" default_raw:", list(O30_DEFAULT_RAW_VALUES)) -def _show_device_info(hand: O30i, timeout_ms: float) -> None: +def _show_device_info(hand: O30, timeout_ms: float) -> None: info = hand.version.get_device_info(timeout_ms=timeout_ms) print(" product_model:", info.product_model) print(" voltage_range:", info.voltage_range) @@ -252,7 +252,7 @@ def _show_device_info(hand: O30i, timeout_ms: float) -> None: print(" supported_interfaces:", info.supported_interfaces) -def _show_angles(hand: O30i, timeout_ms: float) -> None: +def _show_angles(hand: O30, timeout_ms: float) -> None: actual = hand.angle.get_blocking(timeout_ms=timeout_ms).angles target = hand.angle.get_target_blocking(timeout_ms=timeout_ms) _print_joint_values("actual raw", actual.to_raw()) @@ -263,13 +263,13 @@ def _show_angles(hand: O30i, timeout_ms: float) -> None: ) -def _show_i16_angles(hand: O30i, timeout_ms: float) -> None: +def _show_i16_angles(hand: O30, timeout_ms: float) -> None: values = hand.angle.get_i16_blocking(timeout_ms=timeout_ms) _print_joint_values("position i16", values) def _runtime_readers( - hand: O30i, timeout_ms: float + hand: O30, timeout_ms: float ) -> tuple[tuple[str, Callable[[], Sequence[int]]], ...]: return ( ("speed", lambda: hand.speed.get_blocking(timeout_ms).speeds), @@ -292,18 +292,18 @@ def _show_runtime_state(name: str, values: Sequence[int]) -> None: print(f" {name:12s}: {list(values)}") -def _show_faults(hand: O30i, timeout_ms: float) -> None: +def _show_faults(hand: O30, timeout_ms: float) -> None: faults = hand.fault.get_blocking(timeout_ms) print(" raw:", list(faults.faults)) active = [ - O30I_JOINT_SPECS[index].id - for index in range(O30I_JOINT_COUNT) + O30_JOINT_SPECS[index].id + for index in range(O30_JOINT_COUNT) if faults.has_fault(index) ] print(" non-zero joints:", active or "none") -def _show_sensor_info(hand: O30i, timeout_ms: float) -> None: +def _show_sensor_info(hand: O30, timeout_ms: float) -> None: info = hand.sensor.get_info(timeout_ms=timeout_ms) print(" sensor_type:", info.sensor_type) print(" unit:", info.unit) @@ -313,13 +313,13 @@ def _show_sensor_info(hand: O30i, timeout_ms: float) -> None: print(" 当前固件没有可读取的触觉数据通道") -def _show_diagnostics(hand: O30i, timeout_ms: float) -> None: +def _show_diagnostics(hand: O30, timeout_ms: float) -> None: data = hand.diagnostics.get_communication_errors(timeout_ms=timeout_ms) print(" latest_index:", data.latest_index) print(" history:", data.history.hex(" ")) -def _poll_all_sources(hand: O30i, duration_s: float) -> None: +def _poll_all_sources(hand: O30, duration_s: float) -> None: stream = hand.stream(maxsize=200) event_counts: Counter[str] = Counter() @@ -327,7 +327,7 @@ def consume() -> None: for event in stream: event_counts[type(event).__name__] += 1 - consumer = threading.Thread(target=consume, daemon=True, name="O30i-Demo-Stream") + consumer = threading.Thread(target=consume, daemon=True, name="O30-Demo-Stream") consumer.start() intervals = { SensorSource.ANGLE: 0.10, @@ -351,7 +351,7 @@ def consume() -> None: def _move_one_joint( - hand: O30i, + hand: O30, *, joint_index: int, delta_raw: int, @@ -359,7 +359,7 @@ def _move_one_joint( timeout_ms: float, method: str, ) -> None: - spec = O30I_JOINT_SPECS[joint_index] + spec = O30_JOINT_SPECS[joint_index] baseline = hand.angle.get_blocking(timeout_ms=timeout_ms).angles.to_raw() original = baseline[joint_index] target = max(spec.minimum, min(spec.maximum, original + delta_raw)) @@ -386,7 +386,7 @@ def _move_one_joint( def _write_one_joint( - hand: O30i, + hand: O30, joint_index: int, value: int, timeout_ms: float, @@ -398,9 +398,9 @@ def _write_one_joint( hand.angle.set_raw_slice(joint_index, [value], timeout_ms=timeout_ms) -def _show_snapshot_summary(hand: O30i) -> None: +def _show_snapshot_summary(hand: O30) -> None: snapshot = hand.get_snapshot() - print("\n[O30i] manager snapshots") + print("\n[O30] manager snapshots") for name in ( "angle", "speed", @@ -417,12 +417,12 @@ def _show_snapshot_summary(hand: O30i) -> None: def _print_joint_values(label: str, values: Sequence[int]) -> None: print(f" {label}:") - for index, (spec, value) in enumerate(zip(O30I_JOINT_SPECS, values, strict=True)): + for index, (spec, value) in enumerate(zip(O30_JOINT_SPECS, values, strict=True)): print(f" {index:2d} {spec.id:8s} {spec.name:6s} {value}") def _step(label: str, action: Callable[[], None], *, strict: bool) -> None: - print(f"\n[O30i] {label}") + print(f"\n[O30] {label}") try: action() except LinkerbotError as error: @@ -435,19 +435,19 @@ def _resolve_joint(value: str) -> int: try: index = int(value, 0) except ValueError: - for index, spec in enumerate(O30I_JOINT_SPECS): + for index, spec in enumerate(O30_JOINT_SPECS): if value == spec.id: return index raise SystemExit(f"unknown --joint {value!r}") from None - if index < 0 or index >= O30I_JOINT_COUNT: - raise SystemExit(f"--joint index must be between 0 and {O30I_JOINT_COUNT - 1}") + if index < 0 or index >= O30_JOINT_COUNT: + raise SystemExit(f"--joint index must be between 0 and {O30_JOINT_COUNT - 1}") return index def _confirm_motion(args: argparse.Namespace, joint_index: int) -> None: - spec = O30I_JOINT_SPECS[joint_index] + spec = O30_JOINT_SPECS[joint_index] print( - "\n[O30i] 警告:即将向实物发送运动命令。SDK 无可靠的使能/失能接口。\n" + "\n[O30] 警告:即将向实物发送运动命令。SDK 无可靠的使能/失能接口。\n" f"目标关节:{joint_index}:{spec.id}/{spec.name},delta={args.delta_raw}\n" "确认无负载、人员远离机构,并已准备硬件急停或断电。" ) @@ -486,8 +486,8 @@ def _auto_int(value: str) -> int: try: run() except KeyboardInterrupt: - print("\n[O30i] 用户中断,正在释放连接", file=sys.stderr) + print("\n[O30] 用户中断,正在释放连接", file=sys.stderr) sys.exit(130) except LinkerbotError as error: - print(f"[O30i] SDK 错误:{error}", file=sys.stderr) + print(f"[O30] SDK 错误:{error}", file=sys.stderr) sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index baa052d..5f90174 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,11 +40,11 @@ markers = [ "interactive: Interactive tests requiring human observation", "l6: Tests for L6 robotic hand", "l20lite: Tests for L20Lite robotic hand", - "l25: Tests for L25 robotic hand", + "l20: Tests for L20 robotic hand", "o6: Tests for O6 robotic hand", "l30: Tests for L30 robotic hand", "o20: Tests for O20 robotic hand", - "o30i: Tests for O30i robotic hand and HandProtocol_v1.0", + "o30: Tests for O30 robotic hand and HandProtocol_v1.0", "canfd: Tests for CANFD communication", "hardware: Tests requiring physical hardware", "slow: Tests that take longer to run", diff --git a/src/linkerbot/__init__.py b/src/linkerbot/__init__.py index bd13242..d92c4f3 100644 --- a/src/linkerbot/__init__.py +++ b/src/linkerbot/__init__.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: # pragma: no cover - import-time only for static checkers from .arm import A7, P7, A7lite, ControlMode, Pose - from .hand import L6, L25, L30, O6, O20, L20lite, L30Bus, O30i + from .hand import L6, L20, L30, O6, O20, O30, L20lite, L30Bus # Map every lazily-exported public name to (submodule, attribute). # Adding a new public re-export here is the only change required to surface it @@ -38,12 +38,12 @@ "Pose": ("linkerbot.arm", "Pose"), "L6": ("linkerbot.hand", "L6"), "L20lite": ("linkerbot.hand", "L20lite"), - "L25": ("linkerbot.hand", "L25"), + "L20": ("linkerbot.hand", "L20"), "L30": ("linkerbot.hand", "L30"), "L30Bus": ("linkerbot.hand", "L30Bus"), "O6": ("linkerbot.hand", "O6"), "O20": ("linkerbot.hand", "O20"), - "O30i": ("linkerbot.hand", "O30i"), + "O30": ("linkerbot.hand", "O30"), } @@ -70,11 +70,11 @@ def __dir__() -> list[str]: "L6", "L20lite", "O6", - "L25", + "L20", "L30", "L30Bus", "O20", - "O30i", + "O30", "A7", "P7", "A7lite", diff --git a/src/linkerbot/hand/__init__.py b/src/linkerbot/hand/__init__.py index 68a7b31..d12ddef 100644 --- a/src/linkerbot/hand/__init__.py +++ b/src/linkerbot/hand/__init__.py @@ -14,22 +14,22 @@ if TYPE_CHECKING: # pragma: no cover - import-time only for static checkers from .l6 import L6 + from .l20 import L20 from .l20lite import L20lite - from .l25 import L25 from .l30 import L30, L30Bus from .o6 import O6 from .o20 import O20 - from .o30i import O30i + from .o30 import O30 _LAZY_EXPORTS: dict[str, tuple[str, str]] = { "L6": ("linkerbot.hand.l6", "L6"), "L20lite": ("linkerbot.hand.l20lite", "L20lite"), - "L25": ("linkerbot.hand.l25", "L25"), + "L20": ("linkerbot.hand.l20", "L20"), "L30": ("linkerbot.hand.l30", "L30"), "L30Bus": ("linkerbot.hand.l30", "L30Bus"), "O6": ("linkerbot.hand.o6", "O6"), "O20": ("linkerbot.hand.o20", "O20"), - "O30i": ("linkerbot.hand.o30i", "O30i"), + "O30": ("linkerbot.hand.o30", "O30"), } @@ -47,4 +47,4 @@ def __dir__() -> list[str]: return sorted({*globals(), *_LAZY_EXPORTS}) -__all__ = ["L6", "O6", "L20lite", "L25", "L30", "L30Bus", "O20", "O30i"] +__all__ = ["L6", "O6", "L20lite", "L20", "L30", "L30Bus", "O20", "O30"] diff --git a/src/linkerbot/hand/hand_protocol_v1/__init__.py b/src/linkerbot/hand/hand_protocol_v1/__init__.py index 4574bd7..524df41 100644 --- a/src/linkerbot/hand/hand_protocol_v1/__init__.py +++ b/src/linkerbot/hand/hand_protocol_v1/__init__.py @@ -2,7 +2,7 @@ The package contains the reusable framing and transaction layer shared by hands that implement the HOP object protocol. Device-specific packages, such -as :mod:`linkerbot.hand.o30i`, define their supported main-index objects and +as :mod:`linkerbot.hand.o30`, define their supported main-index objects and safe high-level operations on top of :class:`HandProtocolV1`. """ diff --git a/src/linkerbot/hand/l25/__init__.py b/src/linkerbot/hand/l20/__init__.py similarity index 62% rename from src/linkerbot/hand/l25/__init__.py rename to src/linkerbot/hand/l20/__init__.py index e86f507..2dd1c13 100644 --- a/src/linkerbot/hand/l25/__init__.py +++ b/src/linkerbot/hand/l20/__init__.py @@ -1,31 +1,31 @@ -"""L25 robotic hand control package. +"""L20 robotic hand control package. -This package provides the L25 interface for controlling the L25 robotic hand +This package provides the L20 interface for controlling the L20 robotic hand via CAN bus communication. """ -from .angle import AngleData, L25Angle +from .angle import AngleData, L20Angle from .events import ( AngleEvent, FaultEvent, ForceSensorEvent, - L25Snapshot, + L20Snapshot, SensorEvent, SensorSource, SpeedEvent, TemperatureEvent, TorqueEvent, ) -from .fault import FaultData, FaultManager, L25Fault, L25FaultCode +from .fault import FaultData, FaultManager, L20Fault, L20FaultCode from .force_sensor import AllFingersData, ForceSensorData, ForceSensorManager -from .l25 import L25 -from .speed import L25Speed, SpeedData -from .temperature import L25Temperature, TemperatureData, TemperatureManager -from .torque import L25Torque, TorqueData +from .l20 import L20 +from .speed import L20Speed, SpeedData +from .temperature import L20Temperature, TemperatureData, TemperatureManager +from .torque import L20Torque, TorqueData from .version import DeviceInfo, Version, VersionManager __all__ = [ - "L25", + "L20", # Managers "ForceSensorManager", "TemperatureManager", @@ -40,7 +40,7 @@ "TemperatureData", "FaultData", "DeviceInfo", - "L25Snapshot", + "L20Snapshot", # Event types "AngleEvent", "SpeedEvent", @@ -51,11 +51,11 @@ "SensorEvent", "SensorSource", # Type classes - "L25Angle", - "L25Speed", - "L25Torque", - "L25Temperature", - "L25Fault", - "L25FaultCode", + "L20Angle", + "L20Speed", + "L20Torque", + "L20Temperature", + "L20Fault", + "L20FaultCode", "Version", ] diff --git a/src/linkerbot/hand/l25/angle.py b/src/linkerbot/hand/l20/angle.py similarity index 93% rename from src/linkerbot/hand/l25/angle.py rename to src/linkerbot/hand/l20/angle.py index bcb2fbe..0162ad0 100644 --- a/src/linkerbot/hand/l25/angle.py +++ b/src/linkerbot/hand/l20/angle.py @@ -1,7 +1,7 @@ -"""Angle control and sensing for L25 robotic hand. +"""Angle control and sensing for L20 robotic hand. This module provides the AngleManager class for controlling joint angles -and reading angle sensor data via CAN bus communication. L25 has 16 +and reading angle sensor data via CAN bus communication. L20 has 16 degrees of freedom split across five CAN frames (0x41-0x45), one per finger. """ @@ -18,7 +18,7 @@ from linkerbot.hand.angle_mapping import AngleMappingManager, validate_raw_values _JOINT_COUNT = 16 -_MODEL_NAME = "l25" +_MODEL_NAME = "l20" _JOINT_NAMES = [ "thumb_abd", "thumb_yaw", @@ -40,8 +40,8 @@ @dataclass -class L25Angle: - """Joint angles for L25 hand (0-100 range). +class L20Angle: + """Joint angles for L20 hand (0-100 range). Attributes: thumb_abd: Thumb abduction joint angle (0-100) @@ -109,14 +109,14 @@ def to_list(self) -> list[float]: ] @classmethod - def from_list(cls, values: list[float]) -> "L25Angle": + def from_list(cls, values: list[float]) -> "L20Angle": """Construct from list of floats (0-100 range). Args: values: List of 16 float values in 0-100 range Returns: - L25Angle instance + L20Angle instance Raises: ValueError: If list doesn't have exactly 16 elements @@ -162,7 +162,7 @@ def __getitem__(self, index: int) -> float: return self.to_list()[index] def __len__(self) -> int: - """Return number of joints (always 16 for L25).""" + """Return number of joints (always 16 for L20).""" return _JOINT_COUNT @@ -171,18 +171,18 @@ class AngleData: """Immutable angle data container. Attributes: - angles: L25Angle instance containing joint angles (0-100 range). + angles: L20Angle instance containing joint angles (0-100 range). timestamp: Unix timestamp when the data was received. """ - angles: L25Angle + angles: L20Angle timestamp: float class AngleManager: """Manager for joint angle control and sensing. - L25 splits 16 joints across five CAN frames, one per finger: + L20 splits 16 joints across five CAN frames, one per finger: - 0x41: thumb (abd, yaw, root1, reserve, reserve, tip) - 0x42: index (abd, reserve, root1, reserve, reserve, tip) - 0x43: middle (abd, reserve, root1, reserve, reserve, tip) @@ -236,7 +236,7 @@ def __init__( interface_name=interface_name, ) - def set_angles(self, angles: L25Angle | list[float]) -> None: + def set_angles(self, angles: L20Angle | list[float]) -> None: """Send target angles to the robotic hand. This method sends 16 target angles across five CAN frames. The hand @@ -244,7 +244,7 @@ def set_angles(self, angles: L25Angle | list[float]) -> None: and can be retrieved via get_snapshot(). Args: - angles: L25Angle instance or list of 16 target angles (range 0-100 each). + angles: L20Angle instance or list of 16 target angles (range 0-100 each). Raises: ValidationError: If angles count is not 16 or values are out of range. @@ -252,8 +252,8 @@ def set_angles(self, angles: L25Angle | list[float]) -> None: Example: >>> manager.set_angles([50.0] * 16) """ - if not isinstance(angles, L25Angle): - angles = L25Angle.from_list(angles) + if not isinstance(angles, L20Angle): + angles = L20Angle.from_list(angles) raw_angles = [round(value * 255 / 100) for value in angles.to_list()] self._send_raw_angles(raw_angles) @@ -375,13 +375,17 @@ def _on_message(self, msg: can.Message) -> None: if set(self._pending.keys()) != set(self._FRAME_MAP.keys()): return - # All frames received -- merge into L25Angle + # All frames received -- merge into L20Angle kwargs: dict[str, float] = {} for frame_cmd, fields in self._FRAME_MAP.items(): for field, value in zip(fields, self._pending[frame_cmd], strict=True): if field is not None: kwargs[field] = value - angles = L25Angle(**kwargs) + angles = L20Angle(**kwargs) angle_data = AngleData(angles=angles, timestamp=time.time()) self._relay.push(angle_data) + + +# Backward-compatible aliases for the former model name. +L25Angle = L20Angle diff --git a/src/linkerbot/hand/l25/events.py b/src/linkerbot/hand/l20/events.py similarity index 86% rename from src/linkerbot/hand/l25/events.py rename to src/linkerbot/hand/l20/events.py index 5684f93..155da15 100644 --- a/src/linkerbot/hand/l25/events.py +++ b/src/linkerbot/hand/l20/events.py @@ -1,6 +1,6 @@ -"""Unified event types for L25 sensor data streaming. +"""Unified event types for L20 sensor data streaming. -This module defines the event types used by L25.stream() for delivering +This module defines the event types used by L20.stream() for delivering sensor data as a tagged union, enabling match-case dispatching. """ @@ -67,7 +67,7 @@ class SensorSource(str, Enum): @dataclass(frozen=True) -class L25Snapshot: +class L20Snapshot: """Complete snapshot of all sensor data at a point in time.""" angle: AngleData | None @@ -77,3 +77,7 @@ class L25Snapshot: fault: FaultData | None force_sensor: AllFingersData | None timestamp: float + + +# Backward-compatible aliases for the former model name. +L25Snapshot = L20Snapshot diff --git a/src/linkerbot/hand/l25/fault.py b/src/linkerbot/hand/l20/fault.py similarity index 82% rename from src/linkerbot/hand/l25/fault.py rename to src/linkerbot/hand/l20/fault.py index 9be1fd3..3284f83 100644 --- a/src/linkerbot/hand/l25/fault.py +++ b/src/linkerbot/hand/l20/fault.py @@ -1,7 +1,7 @@ -"""Fault management for L25 robotic hand. +"""Fault management for L20 robotic hand. This module provides the FaultManager class for reading joint fault codes, -fault status, and clearing faults. L25 has 16 degrees of freedom split +fault status, and clearing faults. L20 has 16 degrees of freedom split across five CAN frames (0x59-0x5D), one per finger. """ @@ -19,8 +19,8 @@ _JOINT_COUNT = 16 -class L25FaultCode(Flag): - """Motor fault code flags for L25 hand. +class L20FaultCode(Flag): + """Motor fault code flags for L20 hand. Each bit represents a specific fault condition: - BIT0 (1): Motor rotor lock @@ -50,13 +50,13 @@ def has_fault(self) -> bool: True if any fault bit is set, False otherwise. Example: - >>> code = L25FaultCode.MOTOR_ROTOR_LOCK | L25FaultCode.OVER_TEMPERATURE + >>> code = L20FaultCode.MOTOR_ROTOR_LOCK | L20FaultCode.OVER_TEMPERATURE >>> code.has_fault() True - >>> L25FaultCode.NONE.has_fault() + >>> L20FaultCode.NONE.has_fault() False """ - return self != L25FaultCode.NONE + return self != L20FaultCode.NONE def get_fault_names(self) -> list[str]: """Get human-readable fault names for this fault code. @@ -65,40 +65,40 @@ def get_fault_names(self) -> list[str]: List of fault names. Returns ["No faults"] if no faults are present. Example: - >>> code = L25FaultCode.MOTOR_ROTOR_LOCK | L25FaultCode.OVER_TEMPERATURE + >>> code = L20FaultCode.MOTOR_ROTOR_LOCK | L20FaultCode.OVER_TEMPERATURE >>> code.get_fault_names() ['Motor rotor lock', 'Overtemperature'] - >>> L25FaultCode.NONE.get_fault_names() + >>> L20FaultCode.NONE.get_fault_names() ['No faults'] """ if not self.has_fault(): return ["No faults"] names: list[str] = [] - if self & L25FaultCode.MOTOR_ROTOR_LOCK: + if self & L20FaultCode.MOTOR_ROTOR_LOCK: names.append("Motor rotor lock") - if self & L25FaultCode.MOTOR_OVER_CURRENT: + if self & L20FaultCode.MOTOR_OVER_CURRENT: names.append("Motor overcurrent") - if self & L25FaultCode.MOTOR_STALL_FAULT: + if self & L20FaultCode.MOTOR_STALL_FAULT: names.append("Motor stall fault") - if self & L25FaultCode.VOLTAGE_ABNORMAL: + if self & L20FaultCode.VOLTAGE_ABNORMAL: names.append("Voltage abnormal") - if self & L25FaultCode.SELF_CHECK_ABNORMAL: + if self & L20FaultCode.SELF_CHECK_ABNORMAL: names.append("Self-check abnormal") - if self & L25FaultCode.OVER_TEMPERATURE: + if self & L20FaultCode.OVER_TEMPERATURE: names.append("Overtemperature") - if self & L25FaultCode.SOFT_ROTOR_LOCK: + if self & L20FaultCode.SOFT_ROTOR_LOCK: names.append("Soft rotor lock") - if self & L25FaultCode.MOTOR_COMM_ABNORMAL: + if self & L20FaultCode.MOTOR_COMM_ABNORMAL: names.append("Motor communication abnormal") return names @dataclass(frozen=True) -class L25Fault: - """Joint fault codes for L25 hand. +class L20Fault: + """Joint fault codes for L20 hand. - Each attribute is an L25FaultCode enum value representing the fault status + Each attribute is an L20FaultCode enum value representing the fault status for that joint. You can directly call methods on each joint's fault code. Attributes: @@ -120,7 +120,7 @@ class L25Fault: pinky_tip: Pinky finger tip motor fault code Example: - >>> faults = L25Fault(...) + >>> faults = L20Fault(...) >>> # Check if thumb yaw has any fault >>> if faults.thumb_abd.has_fault(): ... print(f"Thumb abd faults: {faults.thumb_abd.get_fault_names()}") @@ -131,25 +131,25 @@ class L25Fault: >>> print(faults[0].get_fault_names()) # thumb_abd """ - thumb_abd: L25FaultCode - thumb_yaw: L25FaultCode - thumb_root1: L25FaultCode - thumb_tip: L25FaultCode - index_abd: L25FaultCode - index_root1: L25FaultCode - index_tip: L25FaultCode - middle_abd: L25FaultCode - middle_root1: L25FaultCode - middle_tip: L25FaultCode - ring_abd: L25FaultCode - ring_root1: L25FaultCode - ring_tip: L25FaultCode - pinky_abd: L25FaultCode - pinky_root1: L25FaultCode - pinky_tip: L25FaultCode - - def to_list(self) -> list[L25FaultCode]: - """Convert to list of L25FaultCode in joint order. + thumb_abd: L20FaultCode + thumb_yaw: L20FaultCode + thumb_root1: L20FaultCode + thumb_tip: L20FaultCode + index_abd: L20FaultCode + index_root1: L20FaultCode + index_tip: L20FaultCode + middle_abd: L20FaultCode + middle_root1: L20FaultCode + middle_tip: L20FaultCode + ring_abd: L20FaultCode + ring_root1: L20FaultCode + ring_tip: L20FaultCode + pinky_abd: L20FaultCode + pinky_root1: L20FaultCode + pinky_tip: L20FaultCode + + def to_list(self) -> list[L20FaultCode]: + """Convert to list of L20FaultCode in joint order. Returns: List of 16 joint fault codes in order: thumb_abd, thumb_yaw, @@ -177,14 +177,14 @@ def to_list(self) -> list[L25FaultCode]: ] @classmethod - def from_list(cls, values: list[L25FaultCode]) -> "L25Fault": - """Construct from list of L25FaultCode enum values. + def from_list(cls, values: list[L20FaultCode]) -> "L20Fault": + """Construct from list of L20FaultCode enum values. Args: - values: List of 16 L25FaultCode values + values: List of 16 L20FaultCode values Returns: - L25Fault instance + L20Fault instance Raises: ValueError: If list doesn't have exactly 16 elements @@ -211,14 +211,14 @@ def from_list(cls, values: list[L25FaultCode]) -> "L25Fault": ) @classmethod - def from_raw(cls, values: list[int]) -> "L25Fault": + def from_raw(cls, values: list[int]) -> "L20Fault": # Internal: Construct from hardware communication format if len(values) != _JOINT_COUNT: raise ValueError(f"Expected {_JOINT_COUNT} values, got {len(values)}") for value in values: if value < 0 or value > 255: raise ValueError(f"Value {value} out of range [0, 255]") - fault_codes = [L25FaultCode(v) for v in values] + fault_codes = [L20FaultCode(v) for v in values] return cls.from_list(fault_codes) def has_any_fault(self) -> bool: @@ -228,13 +228,13 @@ def has_any_fault(self) -> bool: True if any joint has a fault, False otherwise. Example: - >>> faults = L25Fault(...) + >>> faults = L20Fault(...) >>> if faults.has_any_fault(): ... print("At least one joint has a fault") """ return any(code.has_fault() for code in self.to_list()) - def __getitem__(self, index: int) -> L25FaultCode: + def __getitem__(self, index: int) -> L20FaultCode: """Support indexing: faults[0] returns thumb_abd (abduction). Args: @@ -249,7 +249,7 @@ def __getitem__(self, index: int) -> L25FaultCode: return self.to_list()[index] def __len__(self) -> int: - """Return number of joints (always 16 for L25).""" + """Return number of joints (always 16 for L20).""" return _JOINT_COUNT @@ -258,18 +258,18 @@ class FaultData: """Immutable fault data container. Attributes: - faults: L25Fault instance containing fault codes for all joints. + faults: L20Fault instance containing fault codes for all joints. timestamp: Unix timestamp when the data was received. """ - faults: L25Fault + faults: L20Fault timestamp: float class FaultManager: """Manager for joint fault management. - L25 splits 16 joints across five CAN frames, one per finger: + L20 splits 16 joints across five CAN frames, one per finger: - 0x59: thumb (abd, yaw, root1, reserve, reserve, tip) - 0x5A: index (abd, reserve, root1, reserve, reserve, tip) - 0x5B: middle (abd, reserve, root1, reserve, reserve, tip) @@ -303,7 +303,7 @@ def __init__(self, arbitration_id: int, dispatcher: CANMessageDispatcher) -> Non self._arbitration_id = arbitration_id self._dispatcher = dispatcher self._dispatcher.subscribe(self._on_message) - self._pending: dict[int, list[L25FaultCode]] = {} + self._pending: dict[int, list[L20FaultCode]] = {} self._relay = PollingDataRelay[FaultData](self._pending.clear) def get_blocking(self, timeout_ms: float = 100) -> FaultData: @@ -398,20 +398,25 @@ def _on_message(self, msg: can.Message) -> None: if len(raw_codes) != len(expected_fields): return - decoded = [L25FaultCode(v) for v in raw_codes] + decoded = [L20FaultCode(v) for v in raw_codes] self._pending[cmd] = decoded # Check if all frames have been received if set(self._pending.keys()) != set(self._FRAME_MAP.keys()): return - # All frames received -- merge into L25Fault - kwargs: dict[str, L25FaultCode] = {} + # All frames received -- merge into L20Fault + kwargs: dict[str, L20FaultCode] = {} for frame_cmd, fields in self._FRAME_MAP.items(): for field, value in zip(fields, self._pending[frame_cmd], strict=True): if field is not None: kwargs[field] = value - faults = L25Fault(**kwargs) + faults = L20Fault(**kwargs) fault_data = FaultData(faults=faults, timestamp=time.time()) self._relay.push(fault_data) + + +# Backward-compatible aliases for the former model name. +L25Fault = L20Fault +L25FaultCode = L20FaultCode diff --git a/src/linkerbot/hand/l25/force_sensor.py b/src/linkerbot/hand/l20/force_sensor.py similarity index 98% rename from src/linkerbot/hand/l25/force_sensor.py rename to src/linkerbot/hand/l20/force_sensor.py index 1e53b58..6c7874d 100644 --- a/src/linkerbot/hand/l25/force_sensor.py +++ b/src/linkerbot/hand/l20/force_sensor.py @@ -1,6 +1,6 @@ -"""Force sensor data acquisition for L25 robotic hand. +"""Force sensor data acquisition for L20 robotic hand. -This module provides force sensor management for the L25 robotic hand: +This module provides force sensor management for the L20 robotic hand: - SingleForceSensorManager: Manages a single finger's force sensor. - ForceSensorManager: Manages all 5 fingers' force sensors (thumb, index, middle, ring, pinky). @@ -199,7 +199,7 @@ def _on_message(self, msg: can.Message) -> None: class ForceSensorManager: - """Manager for all finger force sensors on the L25 robotic hand. + """Manager for all finger force sensors on the L20 robotic hand. This class manages force sensors for all 5 fingers (thumb, index, middle, ring, pinky) and provides unified access to sensor data from all fingers. diff --git a/src/linkerbot/hand/l25/l25.py b/src/linkerbot/hand/l20/l20.py similarity index 94% rename from src/linkerbot/hand/l25/l25.py rename to src/linkerbot/hand/l20/l20.py index 4fd62b8..c1a1b9e 100644 --- a/src/linkerbot/hand/l25/l25.py +++ b/src/linkerbot/hand/l20/l20.py @@ -1,6 +1,6 @@ -"""L25 robotic hand control interface. +"""L20 robotic hand control interface. -This module provides the main L25 class for controlling the L25 robotic hand +This module provides the main L20 class for controlling the L20 robotic hand via CAN bus communication. It integrates angle control, speed control, torque control, temperature sensing, fault management, force sensor data acquisition, and unified sensor streaming into a single interface. @@ -22,7 +22,7 @@ AngleEvent, FaultEvent, ForceSensorEvent, - L25Snapshot, + L20Snapshot, SensorEvent, SensorSource, SpeedEvent, @@ -42,18 +42,18 @@ } -class L25: - """Main interface for L25 robotic hand control. +class L20: + """Main interface for L20 robotic hand control. - This class provides a unified interface for controlling the L25 robotic hand, + This class provides a unified interface for controlling the L20 robotic hand, integrating angle control, speed control, torque control, temperature sensing, fault management, sensor data acquisition, and unified sensor streaming. - The L25 class should be used as a context manager to ensure proper resource + The L20 class should be used as a context manager to ensure proper resource cleanup: ```python - with L25(side='left', interface_name='can0') as hand: + with L20(side='left', interface_name='can0') as hand: # Control angles hand.angle.set_angles([50.0] * 16) @@ -100,7 +100,7 @@ def __init__( interface_type: str = "socketcan", angle_mapping_path: str | Path | None = None, ) -> None: - """Initialize the L25 robotic hand interface. + """Initialize the L20 robotic hand interface. Args: side: Side of the hand (left or right, default: left). @@ -194,7 +194,7 @@ def can_interface(self) -> CanInterface: """CAN interface this hand is bound to.""" return self._dispatcher.can_interface - def __enter__(self) -> "L25": + def __enter__(self) -> "L20": """Enter the context manager. Returns: @@ -213,14 +213,14 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> bool: # ===== Unified snapshot ===== - def get_snapshot(self) -> L25Snapshot: + def get_snapshot(self) -> L20Snapshot: """Get all sensor data as a single snapshot (non-blocking). Returns: - L25Snapshot with the latest cached data from all sensors. + L20Snapshot with the latest cached data from all sensors. Individual fields are None if no data has been received yet. """ - return L25Snapshot( + return L20Snapshot( angle=self.angle.get_snapshot(), speed=self.speed.get_snapshot(), torque=self.torque.get_snapshot(), @@ -298,7 +298,7 @@ def start_polling( target=self._polling_loop, args=(source.value, interval), daemon=True, - name=f"L25-Polling-{source.value}", + name=f"L20-Polling-{source.value}", ) t.start() self._polling_threads[source.value] = t @@ -316,7 +316,7 @@ def stop_polling(self) -> None: # ===== Lifecycle ===== def close(self) -> None: - """Close the L25 interface and release all resources. + """Close the L20 interface and release all resources. This method is idempotent and safe to call multiple times. """ @@ -354,7 +354,7 @@ def _ensure_open(self) -> None: raise CANError(f"CAN bus unavailable: {self._bus_error}") if self._closed: raise StateError( - "L25 interface is closed. Create a new instance or use context manager." + "L20 interface is closed. Create a new instance or use context manager." ) # ===== Internal ===== diff --git a/src/linkerbot/hand/l25/speed.py b/src/linkerbot/hand/l20/speed.py similarity index 92% rename from src/linkerbot/hand/l25/speed.py rename to src/linkerbot/hand/l20/speed.py index af0722f..a6950d5 100644 --- a/src/linkerbot/hand/l25/speed.py +++ b/src/linkerbot/hand/l20/speed.py @@ -1,7 +1,7 @@ -"""Speed control and sensing for L25 robotic hand. +"""Speed control and sensing for L20 robotic hand. This module provides the SpeedManager class for controlling joint speeds -and reading speed sensor data via CAN bus communication. L25 has 16 +and reading speed sensor data via CAN bus communication. L20 has 16 degrees of freedom split across five CAN frames (0x49-0x4D), one per finger. """ @@ -19,8 +19,8 @@ @dataclass -class L25Speed: - """Joint speeds for L25 hand (0-100 range). +class L20Speed: + """Joint speeds for L20 hand (0-100 range). Attributes: thumb_abd: Thumb abduction joint speed (0-100) @@ -87,14 +87,14 @@ def to_list(self) -> list[float]: ] @classmethod - def from_list(cls, values: list[float]) -> "L25Speed": + def from_list(cls, values: list[float]) -> "L20Speed": """Construct from list of floats (0-100 range). Args: values: List of 16 float values in 0-100 range Returns: - L25Speed instance + L20Speed instance Raises: ValueError: If list doesn't have exactly 16 elements @@ -140,7 +140,7 @@ def __getitem__(self, index: int) -> float: return self.to_list()[index] def __len__(self) -> int: - """Return number of joints (always 16 for L25).""" + """Return number of joints (always 16 for L20).""" return _JOINT_COUNT @@ -149,18 +149,18 @@ class SpeedData: """Immutable speed data container. Attributes: - speeds: L25Speed instance containing joint speeds (0-100 range). + speeds: L20Speed instance containing joint speeds (0-100 range). timestamp: Unix timestamp when the data was received. """ - speeds: L25Speed + speeds: L20Speed timestamp: float class SpeedManager: """Manager for joint speed control and sensing. - L25 splits 16 joints across five CAN frames, one per finger: + L20 splits 16 joints across five CAN frames, one per finger: - 0x49: thumb (abd, yaw, root1, reserve, reserve, tip) - 0x4A: index (abd, reserve, root1, reserve, reserve, tip) - 0x4B: middle (abd, reserve, root1, reserve, reserve, tip) @@ -197,7 +197,7 @@ def __init__(self, arbitration_id: int, dispatcher: CANMessageDispatcher) -> Non self._pending: dict[int, list[float]] = {} self._relay = PollingDataRelay[SpeedData](self._pending.clear) - def set_speeds(self, speeds: L25Speed | list[float]) -> None: + def set_speeds(self, speeds: L20Speed | list[float]) -> None: """Send target speeds to the robotic hand. This method sends 16 target speeds across five CAN frames. The hand @@ -205,7 +205,7 @@ def set_speeds(self, speeds: L25Speed | list[float]) -> None: and can be retrieved via get_snapshot(). Args: - speeds: L25Speed instance or list of 16 target speeds (range 0-100 each). + speeds: L20Speed instance or list of 16 target speeds (range 0-100 each). Raises: ValidationError: If speeds count is not 16 or values are out of range. @@ -213,8 +213,8 @@ def set_speeds(self, speeds: L25Speed | list[float]) -> None: Example: >>> manager.set_speeds([50.0] * 16) """ - if not isinstance(speeds, L25Speed): - speeds = L25Speed.from_list(speeds) + if not isinstance(speeds, L20Speed): + speeds = L20Speed.from_list(speeds) for cmd, fields in self._FRAME_MAP.items(): data = [cmd] + [ @@ -307,13 +307,17 @@ def _on_message(self, msg: can.Message) -> None: if set(self._pending.keys()) != set(self._FRAME_MAP.keys()): return - # All frames received -- merge into L25Speed + # All frames received -- merge into L20Speed kwargs: dict[str, float] = {} for frame_cmd, fields in self._FRAME_MAP.items(): for field, value in zip(fields, self._pending[frame_cmd], strict=True): if field is not None: kwargs[field] = value - speeds = L25Speed(**kwargs) + speeds = L20Speed(**kwargs) speed_data = SpeedData(speeds=speeds, timestamp=time.time()) self._relay.push(speed_data) + + +# Backward-compatible aliases for the former model name. +L25Speed = L20Speed diff --git a/src/linkerbot/hand/l25/temperature.py b/src/linkerbot/hand/l20/temperature.py similarity index 93% rename from src/linkerbot/hand/l25/temperature.py rename to src/linkerbot/hand/l20/temperature.py index 4aff0b2..50632b7 100644 --- a/src/linkerbot/hand/l25/temperature.py +++ b/src/linkerbot/hand/l20/temperature.py @@ -1,7 +1,7 @@ -"""Temperature sensing for L25 robotic hand. +"""Temperature sensing for L20 robotic hand. This module provides the TemperatureManager class for reading motor temperature -sensor data via CAN bus communication. L25 has 16 temperature sensors +sensor data via CAN bus communication. L20 has 16 temperature sensors split across five CAN frames (0x61-0x65), one per finger. """ @@ -19,8 +19,8 @@ @dataclass -class L25Temperature: - """Motor temperatures for L25 hand in degrees Celsius (°C). +class L20Temperature: + """Motor temperatures for L20 hand in degrees Celsius (°C). Attributes: thumb_abd: Thumb abduction motor temperature in °C @@ -91,14 +91,14 @@ def to_raw(self) -> list[int]: return [int(v) for v in self.to_list()] @classmethod - def from_list(cls, values: list[float]) -> "L25Temperature": + def from_list(cls, values: list[float]) -> "L20Temperature": """Construct from list of floats in degrees Celsius. Args: values: List of 16 float values in °C Returns: - L25Temperature instance + L20Temperature instance Raises: ValueError: If list doesn't have exactly 16 elements @@ -125,7 +125,7 @@ def from_list(cls, values: list[float]) -> "L25Temperature": ) @classmethod - def from_raw(cls, values: list[int]) -> "L25Temperature": + def from_raw(cls, values: list[int]) -> "L20Temperature": # Internal: Construct from hardware communication format if len(values) != _JOINT_COUNT: raise ValueError(f"Expected {_JOINT_COUNT} values, got {len(values)}") @@ -150,7 +150,7 @@ def __getitem__(self, index: int) -> float: return self.to_list()[index] def __len__(self) -> int: - """Return number of temperature sensors (always 16 for L25).""" + """Return number of temperature sensors (always 16 for L20).""" return _JOINT_COUNT @@ -159,18 +159,18 @@ class TemperatureData: """Immutable temperature data container. Attributes: - temperatures: L25Temperature instance containing motor temperatures in degrees Celsius (°C). + temperatures: L20Temperature instance containing motor temperatures in degrees Celsius (°C). timestamp: Unix timestamp when the data was received. """ - temperatures: L25Temperature + temperatures: L20Temperature timestamp: float class TemperatureManager: """Manager for motor temperature sensing. - L25 splits 16 temperature sensors across five CAN frames, one per finger: + L20 splits 16 temperature sensors across five CAN frames, one per finger: - 0x61: thumb (abd, yaw, root1, reserve, reserve, tip) - 0x62: index (abd, reserve, root1, reserve, reserve, tip) - 0x63: middle (abd, reserve, root1, reserve, reserve, tip) @@ -285,13 +285,17 @@ def _on_message(self, msg: can.Message) -> None: if set(self._pending.keys()) != set(self._FRAME_MAP.keys()): return - # All frames received — merge into L25Temperature + # All frames received — merge into L20Temperature kwargs: dict[str, float] = {} for frame_cmd, fields in self._FRAME_MAP.items(): for field, value in zip(fields, self._pending[frame_cmd], strict=True): if field is not None: kwargs[field] = value - temperatures = L25Temperature(**kwargs) + temperatures = L20Temperature(**kwargs) temp_data = TemperatureData(temperatures=temperatures, timestamp=time.time()) self._relay.push(temp_data) + + +# Backward-compatible aliases for the former model name. +L25Temperature = L20Temperature diff --git a/src/linkerbot/hand/l25/torque.py b/src/linkerbot/hand/l20/torque.py similarity index 92% rename from src/linkerbot/hand/l25/torque.py rename to src/linkerbot/hand/l20/torque.py index 792407a..701a8af 100644 --- a/src/linkerbot/hand/l25/torque.py +++ b/src/linkerbot/hand/l20/torque.py @@ -1,7 +1,7 @@ -"""Torque control and sensing for L25 robotic hand. +"""Torque control and sensing for L20 robotic hand. This module provides the TorqueManager class for controlling joint torques -and reading torque sensor data via CAN bus communication. L25 has 16 +and reading torque sensor data via CAN bus communication. L20 has 16 degrees of freedom split across five CAN frames (0x51-0x55), one per finger. """ @@ -19,8 +19,8 @@ @dataclass -class L25Torque: - """Joint torques for L25 hand (0-100 range). +class L20Torque: + """Joint torques for L20 hand (0-100 range). Attributes: thumb_abd: Thumb abduction joint torque (0-100) @@ -87,14 +87,14 @@ def to_list(self) -> list[float]: ] @classmethod - def from_list(cls, values: list[float]) -> "L25Torque": + def from_list(cls, values: list[float]) -> "L20Torque": """Construct from list of floats (0-100 range). Args: values: List of 16 float values in 0-100 range Returns: - L25Torque instance + L20Torque instance Raises: ValueError: If list doesn't have exactly 16 elements @@ -140,7 +140,7 @@ def __getitem__(self, index: int) -> float: return self.to_list()[index] def __len__(self) -> int: - """Return number of joints (always 16 for L25).""" + """Return number of joints (always 16 for L20).""" return _JOINT_COUNT @@ -149,18 +149,18 @@ class TorqueData: """Immutable torque data container. Attributes: - torques: L25Torque instance containing joint torques (0-100 range). + torques: L20Torque instance containing joint torques (0-100 range). timestamp: Unix timestamp when the data was received. """ - torques: L25Torque + torques: L20Torque timestamp: float class TorqueManager: """Manager for joint torque control and sensing. - L25 splits 16 joints across five CAN frames, one per finger: + L20 splits 16 joints across five CAN frames, one per finger: - 0x51: thumb (abd, yaw, root1, reserve, reserve, tip) - 0x52: index (abd, reserve, root1, reserve, reserve, tip) - 0x53: middle (abd, reserve, root1, reserve, reserve, tip) @@ -197,7 +197,7 @@ def __init__(self, arbitration_id: int, dispatcher: CANMessageDispatcher) -> Non self._pending: dict[int, list[float]] = {} self._relay = PollingDataRelay[TorqueData](self._pending.clear) - def set_torques(self, torques: L25Torque | list[float]) -> None: + def set_torques(self, torques: L20Torque | list[float]) -> None: """Send target torques to the robotic hand. This method sends 16 target torques across five CAN frames. The hand @@ -205,7 +205,7 @@ def set_torques(self, torques: L25Torque | list[float]) -> None: and can be retrieved via get_snapshot(). Args: - torques: L25Torque instance or list of 16 target torques (range 0-100 each). + torques: L20Torque instance or list of 16 target torques (range 0-100 each). Raises: ValidationError: If torques count is not 16 or values are out of range. @@ -213,8 +213,8 @@ def set_torques(self, torques: L25Torque | list[float]) -> None: Example: >>> manager.set_torques([50.0] * 16) """ - if not isinstance(torques, L25Torque): - torques = L25Torque.from_list(torques) + if not isinstance(torques, L20Torque): + torques = L20Torque.from_list(torques) for cmd, fields in self._FRAME_MAP.items(): raw_values = [ @@ -308,13 +308,17 @@ def _on_message(self, msg: can.Message) -> None: if set(self._pending.keys()) != set(self._FRAME_MAP.keys()): return - # All frames received — merge into L25Torque + # All frames received — merge into L20Torque kwargs: dict[str, float] = {} for frame_cmd, fields in self._FRAME_MAP.items(): for field, value in zip(fields, self._pending[frame_cmd], strict=True): if field is not None: kwargs[field] = value - torques = L25Torque(**kwargs) + torques = L20Torque(**kwargs) torque_data = TorqueData(torques=torques, timestamp=time.time()) self._relay.push(torque_data) + + +# Backward-compatible aliases for the former model name. +L25Torque = L20Torque diff --git a/src/linkerbot/hand/l25/version.py b/src/linkerbot/hand/l20/version.py similarity index 99% rename from src/linkerbot/hand/l25/version.py rename to src/linkerbot/hand/l20/version.py index f8d757d..6069ce7 100644 --- a/src/linkerbot/hand/l25/version.py +++ b/src/linkerbot/hand/l20/version.py @@ -1,4 +1,4 @@ -"""Version information and serial number management for L25 robotic hand. +"""Version information and serial number management for L20 robotic hand. This module provides the VersionManager class for reading device version information. """ diff --git a/src/linkerbot/hand/o30/__init__.py b/src/linkerbot/hand/o30/__init__.py new file mode 100644 index 0000000..bffc9f5 --- /dev/null +++ b/src/linkerbot/hand/o30/__init__.py @@ -0,0 +1,92 @@ +"""Public O30 SDK API.""" + +from linkerbot.hand.hand_protocol_v1 import ( + HandProtocolDeviceError, + HandProtocolError, + HandProtocolV1, +) + +from .acceleration import AccelerationManager, O30AccelerationData +from .angle import AngleManager, O30AngleData +from .current import CurrentManager, O30CurrentData +from .diagnostics import DiagnosticsManager, O30CommunicationErrors +from .events import ( + AccelerationEvent, + AngleEvent, + CurrentEvent, + FaultEvent, + MotionTimeEvent, + O30Snapshot, + SensorEvent, + SensorSource, + SpeedEvent, + TemperatureEvent, + TorqueEvent, + VoltageEvent, +) +from .fault import FaultManager, O30FaultData +from .joints import ( + O30_DEFAULT_RAW_VALUES, + O30_JOINT_COUNT, + O30_JOINT_SPECS, + JointSpec, + O30Angle, +) +from .motion_time import MotionTimeManager, O30MotionTimeData +from .o30 import O30 +from .protocol import O30Object +from .sensor import O30SensorInfo, SensorManager +from .speed import O30SpeedData, SpeedManager +from .temperature import O30TemperatureData, TemperatureManager +from .torque import O30TorqueData, TorqueManager +from .version import O30DeviceInfo, VersionManager +from .voltage import O30VoltageData, VoltageManager + +__all__ = [ + "AccelerationEvent", + "AccelerationManager", + "AngleEvent", + "AngleManager", + "CurrentEvent", + "CurrentManager", + "DiagnosticsManager", + "FaultEvent", + "FaultManager", + "HandProtocolDeviceError", + "HandProtocolError", + "HandProtocolV1", + "MotionTimeEvent", + "MotionTimeManager", + "O30_DEFAULT_RAW_VALUES", + "O30_JOINT_COUNT", + "O30_JOINT_SPECS", + "O30", + "O30AccelerationData", + "O30Angle", + "O30AngleData", + "O30CommunicationErrors", + "O30CurrentData", + "O30DeviceInfo", + "O30FaultData", + "O30MotionTimeData", + "O30Object", + "O30SensorInfo", + "O30Snapshot", + "O30SpeedData", + "O30TemperatureData", + "O30TorqueData", + "O30VoltageData", + "JointSpec", + "SensorEvent", + "SensorManager", + "SensorSource", + "SpeedEvent", + "SpeedManager", + "TemperatureEvent", + "TemperatureManager", + "TorqueEvent", + "TorqueManager", + "VersionManager", + "VoltageEvent", + "VoltageManager", +] diff --git a/src/linkerbot/hand/o30i/_runtime.py b/src/linkerbot/hand/o30/_runtime.py similarity index 87% rename from src/linkerbot/hand/o30i/_runtime.py rename to src/linkerbot/hand/o30/_runtime.py index f9614ca..faebb70 100644 --- a/src/linkerbot/hand/o30i/_runtime.py +++ b/src/linkerbot/hand/o30/_runtime.py @@ -1,4 +1,4 @@ -"""Internal helpers shared by O30i one-byte runtime-object managers.""" +"""Internal helpers shared by O30 one-byte runtime-object managers.""" from __future__ import annotations @@ -16,7 +16,7 @@ def read_vector( ) -> tuple[int, ...]: response = client.read( main_index=main_index, - length=protocol.O30I_RUNTIME_SLOT_COUNT, + length=protocol.O30_RUNTIME_SLOT_COUNT, rts=rts, timeout_ms=timeout_ms, ) diff --git a/src/linkerbot/hand/o30i/acceleration.py b/src/linkerbot/hand/o30/acceleration.py similarity index 70% rename from src/linkerbot/hand/o30i/acceleration.py rename to src/linkerbot/hand/o30/acceleration.py index cb4f06b..c1a5c11 100644 --- a/src/linkerbot/hand/o30i/acceleration.py +++ b/src/linkerbot/hand/o30/acceleration.py @@ -1,4 +1,4 @@ -"""Acceleration control and sensing for O30i.""" +"""Acceleration control and sensing for O30.""" from __future__ import annotations @@ -15,7 +15,7 @@ @dataclass(frozen=True, slots=True) -class O30iAccelerationData: +class O30AccelerationData: accelerations: tuple[int, ...] timestamp: float @@ -25,7 +25,7 @@ class AccelerationManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iAccelerationData]() + self._relay = DataRelay[O30AccelerationData]() def set_accelerations( self, @@ -36,22 +36,22 @@ def set_accelerations( normalized = validate_u8_values(values, name="accelerations") write_vector( self._client, - main_index=protocol.O30I_MI_ACCELERATION, + main_index=protocol.O30_MI_ACCELERATION, values=normalized, timeout_ms=timeout_ms, ) def set_all(self, value: int, *, timeout_ms: float = 100) -> None: self.set_accelerations( - [value] * protocol.O30I_CONTROLLED_JOINT_COUNT, + [value] * protocol.O30_CONTROLLED_JOINT_COUNT, timeout_ms=timeout_ms, ) - def get_blocking(self, timeout_ms: float = 100) -> O30iAccelerationData: - data = O30iAccelerationData( + def get_blocking(self, timeout_ms: float = 100) -> O30AccelerationData: + data = O30AccelerationData( accelerations=read_vector( self._client, - main_index=protocol.O30I_MI_ACCELERATION, + main_index=protocol.O30_MI_ACCELERATION, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -59,14 +59,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iAccelerationData: self._relay.push(data) return data - def get_snapshot(self) -> O30iAccelerationData | None: + def get_snapshot(self) -> O30AccelerationData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iAccelerationData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30AccelerationData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iAccelerationData = O30AccelerationData diff --git a/src/linkerbot/hand/o30i/angle.py b/src/linkerbot/hand/o30/angle.py similarity index 72% rename from src/linkerbot/hand/o30i/angle.py rename to src/linkerbot/hand/o30/angle.py index cf6ad24..dc85256 100644 --- a/src/linkerbot/hand/o30i/angle.py +++ b/src/linkerbot/hand/o30/angle.py @@ -1,4 +1,4 @@ -"""Position control and sensing for the 20 physical O30i joints.""" +"""Position control and sensing for the 20 physical O30 joints.""" from __future__ import annotations @@ -12,14 +12,14 @@ from . import protocol from ._runtime import read_vector, write_vector -from .joints import O30iAngle, validate_u8_values +from .joints import O30Angle, validate_u8_values @dataclass(frozen=True, slots=True) -class O30iAngleData: - """O30i position data in logical percentage space (0=open, 100=closed).""" +class O30AngleData: + """O30 position data in logical percentage space (0=open, 100=closed).""" - angles: O30iAngle + angles: O30Angle timestamp: float @@ -32,22 +32,22 @@ class AngleManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iAngleData]() + self._relay = DataRelay[O30AngleData]() def set_angles( self, - angles: O30iAngle | list[float] | tuple[float, ...], + angles: O30Angle | list[float] | tuple[float, ...], *, timeout_ms: float = 100, ) -> None: """Set all physical joints from 0=open to 100=closed percentages.""" - if isinstance(angles, O30iAngle): + if isinstance(angles, O30Angle): raw = angles.to_raw() elif isinstance(angles, (list, tuple)): - raw = O30iAngle.from_list(angles).to_raw() + raw = O30Angle.from_list(angles).to_raw() else: raise ValidationError( - "angles must be O30iAngle or a list/tuple of percentages" + "angles must be O30Angle or a list/tuple of percentages" ) self.set_raw_angles(raw, timeout_ms=timeout_ms) @@ -61,7 +61,7 @@ def set_raw_angles( normalized = validate_u8_values(values, name="raw_angles") write_vector( self._client, - main_index=protocol.O30I_MI_POSITION, + main_index=protocol.O30_MI_POSITION, values=normalized, timeout_ms=timeout_ms, ) @@ -78,7 +78,7 @@ def set_raw_slice( offset, values, name="positions" ): self._client.write( - main_index=protocol.O30I_MI_POSITION, + main_index=protocol.O30_MI_POSITION, sub_index=sub_index, payload=payload, timeout_ms=timeout_ms, @@ -92,57 +92,62 @@ def set_sparse( ) -> None: """Set selected physical-joint indices through sparse object ``0x30``. - Keys use the same 0..19 order as :data:`O30I_JOINT_SPECS`. The SDK + Keys use the same 0..19 order as :data:`O30_JOINT_SPECS`. The SDK translates them to object 0x30's distinct finger-major joint IDs. """ payload = protocol.encode_sparse_positions(values) self._client.write( - main_index=protocol.O30I_MI_SPARSE_POSITION, + main_index=protocol.O30_MI_SPARSE_POSITION, payload=payload, timeout_ms=timeout_ms, ) - def get_blocking(self, timeout_ms: float = 100) -> O30iAngleData: + def get_blocking(self, timeout_ms: float = 100) -> O30AngleData: """Read actual positions (RTS=0) and update the snapshot.""" values = read_vector( self._client, - main_index=protocol.O30I_MI_POSITION, + main_index=protocol.O30_MI_POSITION, timeout_ms=timeout_ms, ) - data = O30iAngleData( - angles=O30iAngle.from_raw(values), + data = O30AngleData( + angles=O30Angle.from_raw(values), timestamp=time.time(), ) self._relay.push(data) return data - def get_target_blocking(self, timeout_ms: float = 100) -> O30iAngle: + def get_target_blocking(self, timeout_ms: float = 100) -> O30Angle: """Read position set-points with RTS=1 without changing the snapshot.""" values = read_vector( self._client, - main_index=protocol.O30I_MI_POSITION, + main_index=protocol.O30_MI_POSITION, timeout_ms=timeout_ms, rts=True, ) - return O30iAngle.from_raw(values) + return O30Angle.from_raw(values) def get_i16_blocking(self, timeout_ms: float = 100) -> tuple[int, ...]: """Read the only measured-safe full 72-byte little-endian position block.""" response = self._client.read( - main_index=protocol.O30I_MI_POSITION_I16, - length=protocol.O30I_POSITION_I16_BYTE_LENGTH, + main_index=protocol.O30_MI_POSITION_I16, + length=protocol.O30_POSITION_I16_BYTE_LENGTH, timeout_ms=timeout_ms, ) return protocol.decode_i16_position_block(response) - def get_snapshot(self) -> O30iAngleData | None: + def get_snapshot(self) -> O30AngleData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iAngleData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30AngleData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iAngle = O30Angle +O30iAngleData = O30AngleData diff --git a/src/linkerbot/hand/o30i/current.py b/src/linkerbot/hand/o30/current.py similarity index 67% rename from src/linkerbot/hand/o30i/current.py rename to src/linkerbot/hand/o30/current.py index e8894cb..3e9e5a2 100644 --- a/src/linkerbot/hand/o30i/current.py +++ b/src/linkerbot/hand/o30/current.py @@ -1,4 +1,4 @@ -"""Current-state sensing for O30i.""" +"""Current-state sensing for O30.""" from __future__ import annotations @@ -14,7 +14,7 @@ @dataclass(frozen=True, slots=True) -class O30iCurrentData: +class O30CurrentData: currents: tuple[int, ...] timestamp: float @@ -24,13 +24,13 @@ class CurrentManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iCurrentData]() + self._relay = DataRelay[O30CurrentData]() - def get_blocking(self, timeout_ms: float = 100) -> O30iCurrentData: - data = O30iCurrentData( + def get_blocking(self, timeout_ms: float = 100) -> O30CurrentData: + data = O30CurrentData( currents=read_vector( self._client, - main_index=protocol.O30I_MI_CURRENT, + main_index=protocol.O30_MI_CURRENT, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -38,14 +38,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iCurrentData: self._relay.push(data) return data - def get_snapshot(self) -> O30iCurrentData | None: + def get_snapshot(self) -> O30CurrentData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iCurrentData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30CurrentData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iCurrentData = O30CurrentData diff --git a/src/linkerbot/hand/o30i/diagnostics.py b/src/linkerbot/hand/o30/diagnostics.py similarity index 65% rename from src/linkerbot/hand/o30i/diagnostics.py rename to src/linkerbot/hand/o30/diagnostics.py index 511d75d..5b1093d 100644 --- a/src/linkerbot/hand/o30i/diagnostics.py +++ b/src/linkerbot/hand/o30/diagnostics.py @@ -1,4 +1,4 @@ -"""Communication-error history for O30i object ``0x4F``.""" +"""Communication-error history for O30 object ``0x4F``.""" from __future__ import annotations @@ -11,7 +11,7 @@ @dataclass(frozen=True, slots=True) -class O30iCommunicationErrors: +class O30CommunicationErrors: latest_index: int history: bytes timestamp: float @@ -25,14 +25,18 @@ def __init__(self, client: HandProtocolV1) -> None: def get_communication_errors( self, *, timeout_ms: float = 1000 - ) -> O30iCommunicationErrors: + ) -> O30CommunicationErrors: payload = self._client.read( - main_index=protocol.O30I_MI_COMMUNICATION_ERROR, - length=protocol.O30I_COMMUNICATION_ERROR_BYTE_LENGTH, + main_index=protocol.O30_MI_COMMUNICATION_ERROR, + length=protocol.O30_COMMUNICATION_ERROR_BYTE_LENGTH, timeout_ms=timeout_ms, ) - return O30iCommunicationErrors( + return O30CommunicationErrors( latest_index=payload[0], history=bytes(payload[1:]), timestamp=time.time(), ) + + +# Backward-compatible aliases for the former model name. +O30iCommunicationErrors = O30CommunicationErrors diff --git a/src/linkerbot/hand/o30/events.py b/src/linkerbot/hand/o30/events.py new file mode 100644 index 0000000..dfe87af --- /dev/null +++ b/src/linkerbot/hand/o30/events.py @@ -0,0 +1,113 @@ +"""Unified polling and stream types for O30 runtime data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from .acceleration import O30AccelerationData +from .angle import O30AngleData +from .current import O30CurrentData +from .fault import O30FaultData +from .motion_time import O30MotionTimeData +from .speed import O30SpeedData +from .temperature import O30TemperatureData +from .torque import O30TorqueData +from .voltage import O30VoltageData + + +@dataclass(frozen=True, slots=True) +class AngleEvent: + data: O30AngleData + + +@dataclass(frozen=True, slots=True) +class SpeedEvent: + data: O30SpeedData + + +@dataclass(frozen=True, slots=True) +class AccelerationEvent: + data: O30AccelerationData + + +@dataclass(frozen=True, slots=True) +class CurrentEvent: + data: O30CurrentData + + +@dataclass(frozen=True, slots=True) +class VoltageEvent: + data: O30VoltageData + + +@dataclass(frozen=True, slots=True) +class TorqueEvent: + data: O30TorqueData + + +@dataclass(frozen=True, slots=True) +class TemperatureEvent: + data: O30TemperatureData + + +@dataclass(frozen=True, slots=True) +class MotionTimeEvent: + data: O30MotionTimeData + + +@dataclass(frozen=True, slots=True) +class FaultEvent: + data: O30FaultData + + +SensorEvent = ( + AngleEvent + | SpeedEvent + | AccelerationEvent + | CurrentEvent + | VoltageEvent + | TorqueEvent + | TemperatureEvent + | MotionTimeEvent + | FaultEvent +) + + +class SensorSource(str, Enum): + ANGLE = "angle" + SPEED = "speed" + ACCELERATION = "acceleration" + CURRENT = "current" + VOLTAGE = "voltage" + TORQUE = "torque" + TEMPERATURE = "temperature" + MOTION_TIME = "motion_time" + FAULT = "fault" + + +@dataclass(frozen=True, slots=True) +class O30Snapshot: + angle: O30AngleData | None + speed: O30SpeedData | None + acceleration: O30AccelerationData | None + current: O30CurrentData | None + voltage: O30VoltageData | None + torque: O30TorqueData | None + temperature: O30TemperatureData | None + motion_time: O30MotionTimeData | None + fault: O30FaultData | None + timestamp: float + + +# Backward-compatible aliases for the former model name. +O30iAccelerationData = O30AccelerationData +O30iAngleData = O30AngleData +O30iCurrentData = O30CurrentData +O30iFaultData = O30FaultData +O30iMotionTimeData = O30MotionTimeData +O30iSpeedData = O30SpeedData +O30iTemperatureData = O30TemperatureData +O30iTorqueData = O30TorqueData +O30iVoltageData = O30VoltageData +O30iSnapshot = O30Snapshot diff --git a/src/linkerbot/hand/o30i/fault.py b/src/linkerbot/hand/o30/fault.py similarity index 69% rename from src/linkerbot/hand/o30i/fault.py rename to src/linkerbot/hand/o30/fault.py index de1551c..c14012e 100644 --- a/src/linkerbot/hand/o30i/fault.py +++ b/src/linkerbot/hand/o30/fault.py @@ -1,4 +1,4 @@ -"""Raw per-slot fault-state sensing for O30i.""" +"""Raw per-slot fault-state sensing for O30.""" from __future__ import annotations @@ -15,17 +15,17 @@ @dataclass(frozen=True, slots=True) -class O30iFaultData: +class O30FaultData: faults: tuple[int, ...] timestamp: float def has_fault(self, joint_index: int) -> bool: """Return whether one physical joint's raw fault byte is non-zero.""" protocol._validate_int(joint_index, "joint_index") - if joint_index < 0 or joint_index >= protocol.O30I_CONTROLLED_JOINT_COUNT: + if joint_index < 0 or joint_index >= protocol.O30_CONTROLLED_JOINT_COUNT: raise ValidationError( "joint_index must be between 0 and " - f"{protocol.O30I_CONTROLLED_JOINT_COUNT - 1}" + f"{protocol.O30_CONTROLLED_JOINT_COUNT - 1}" ) return self.faults[joint_index] != 0 @@ -35,13 +35,13 @@ class FaultManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iFaultData]() + self._relay = DataRelay[O30FaultData]() - def get_blocking(self, timeout_ms: float = 100) -> O30iFaultData: - data = O30iFaultData( + def get_blocking(self, timeout_ms: float = 100) -> O30FaultData: + data = O30FaultData( faults=read_vector( self._client, - main_index=protocol.O30I_MI_FAULT, + main_index=protocol.O30_MI_FAULT, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -49,14 +49,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iFaultData: self._relay.push(data) return data - def get_snapshot(self) -> O30iFaultData | None: + def get_snapshot(self) -> O30FaultData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iFaultData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30FaultData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iFaultData = O30FaultData diff --git a/src/linkerbot/hand/o30i/joints.py b/src/linkerbot/hand/o30/joints.py similarity index 84% rename from src/linkerbot/hand/o30i/joints.py rename to src/linkerbot/hand/o30/joints.py index 36b00b4..123cc83 100644 --- a/src/linkerbot/hand/o30i/joints.py +++ b/src/linkerbot/hand/o30/joints.py @@ -1,4 +1,4 @@ -"""O30i physical-joint definitions and normalized angle values.""" +"""O30 physical-joint definitions and normalized angle values.""" from __future__ import annotations @@ -9,16 +9,16 @@ from . import protocol -O30I_JOINT_COUNT = protocol.O30I_CONTROLLED_JOINT_COUNT +O30_JOINT_COUNT = protocol.O30_CONTROLLED_JOINT_COUNT @dataclass(frozen=True, slots=True) class JointSpec: - """One physical O30i joint and its public metadata. + """One physical O30 joint and its public metadata. The measured protocol currently defines only a normalized byte range. It does not provide calibrated degrees. The public percentage direction is - defined separately by :class:`O30iAngle` and is inverse to the raw bytes. + defined separately by :class:`O30Angle` and is inverse to the raw bytes. """ id: str @@ -53,7 +53,7 @@ def to_dict(self) -> dict[str, str | int]: } -O30I_JOINT_SPECS: tuple[JointSpec, ...] = ( +O30_JOINT_SPECS: tuple[JointSpec, ...] = ( JointSpec( "roll_0", "拇指侧摆", "thumb", "roll", default=128, group="侧摆", wire_slot=0 ), @@ -87,16 +87,16 @@ def to_dict(self) -> dict[str, str | int]: JointSpec("tip_3", "无名指尖", "ring", "tip", group="指尖", wire_slot=23), JointSpec("tip_4", "小指尖", "pinky", "tip", group="指尖", wire_slot=24), ) -O30I_DEFAULT_RAW_VALUES: tuple[int, ...] = tuple( - spec.default for spec in O30I_JOINT_SPECS +O30_DEFAULT_RAW_VALUES: tuple[int, ...] = tuple( + spec.default for spec in O30_JOINT_SPECS ) @dataclass(frozen=True, slots=True) -class O30iAngle: - """Twenty physical O30i joint values represented as percentages. +class O30Angle: + """Twenty physical O30 joint values represented as percentages. - Hardware verification confirms that O30i raw positions run opposite to the + Hardware verification confirms that O30 raw positions run opposite to the public logical direction: 0% (open endpoint) maps to byte 255 and 100% (closed endpoint) maps to byte 0. These values are normalized endpoints, not calibrated angles in degrees. @@ -108,20 +108,20 @@ def __post_init__(self) -> None: _validate_percentage_values(self.values) @classmethod - def from_list(cls, values: list[float] | tuple[float, ...]) -> O30iAngle: + def from_list(cls, values: list[float] | tuple[float, ...]) -> O30Angle: """Construct from 20 numeric percentages in public joint order.""" return cls(tuple(values)) @classmethod - def from_raw(cls, values: list[int] | tuple[int, ...]) -> O30iAngle: + def from_raw(cls, values: list[int] | tuple[int, ...]) -> O30Angle: """Construct from 20 protocol-native bytes using inverse direction.""" normalized = validate_u8_values(values, name="raw_angles") return cls(tuple((255 - value) * 100 / 255 for value in normalized)) @classmethod - def defaults(cls) -> O30iAngle: + def defaults(cls) -> O30Angle: """Construct the device's documented default 20-joint pose.""" - return cls.from_raw(O30I_DEFAULT_RAW_VALUES) + return cls.from_raw(O30_DEFAULT_RAW_VALUES) def to_list(self) -> list[float]: """Return a mutable copy of the normalized values.""" @@ -135,7 +135,7 @@ def __getitem__(self, index: int) -> float: return self.values[index] def __len__(self) -> int: - return O30I_JOINT_COUNT + return O30_JOINT_COUNT def validate_u8_values( @@ -145,9 +145,9 @@ def validate_u8_values( ) -> tuple[int, ...]: """Validate one complete 20-joint byte vector.""" normalized = tuple(values) - if len(normalized) != O30I_JOINT_COUNT: + if len(normalized) != O30_JOINT_COUNT: raise ValidationError( - f"{name} must contain {O30I_JOINT_COUNT} values, got {len(normalized)}" + f"{name} must contain {O30_JOINT_COUNT} values, got {len(normalized)}" ) for index, value in enumerate(normalized): protocol._validate_u8(value, f"{name}[{index}]") @@ -155,9 +155,9 @@ def validate_u8_values( def _validate_percentage_values(values: tuple[float, ...]) -> None: - if len(values) != O30I_JOINT_COUNT: + if len(values) != O30_JOINT_COUNT: raise ValidationError( - f"angles must contain {O30I_JOINT_COUNT} values, got {len(values)}" + f"angles must contain {O30_JOINT_COUNT} values, got {len(values)}" ) for index, value in enumerate(values): if not isinstance(value, (int, float)) or isinstance(value, bool): @@ -168,3 +168,7 @@ def _validate_percentage_values(values: tuple[float, ...]) -> None: raise ValidationError( f"angles[{index}] must be between 0 and 100, got {value}" ) + + +# Backward-compatible aliases for the former model name. +O30iAngle = O30Angle diff --git a/src/linkerbot/hand/o30i/motion_time.py b/src/linkerbot/hand/o30/motion_time.py similarity index 70% rename from src/linkerbot/hand/o30i/motion_time.py rename to src/linkerbot/hand/o30/motion_time.py index 5f2c8b1..a43e1cd 100644 --- a/src/linkerbot/hand/o30i/motion_time.py +++ b/src/linkerbot/hand/o30/motion_time.py @@ -1,4 +1,4 @@ -"""Per-slot motion-time control for O30i (10 ms per protocol tick).""" +"""Per-slot motion-time control for O30 (10 ms per protocol tick).""" from __future__ import annotations @@ -14,18 +14,18 @@ from ._runtime import read_vector, write_vector from .joints import validate_u8_values -O30I_MOTION_TIME_TICK_MS = 10 +O30_MOTION_TIME_TICK_MS = 10 @dataclass(frozen=True, slots=True) -class O30iMotionTimeData: +class O30MotionTimeData: ticks: tuple[int, ...] timestamp: float @property def milliseconds(self) -> tuple[int, ...]: """Return every tick value converted to milliseconds.""" - return tuple(value * O30I_MOTION_TIME_TICK_MS for value in self.ticks) + return tuple(value * O30_MOTION_TIME_TICK_MS for value in self.ticks) class MotionTimeManager: @@ -33,7 +33,7 @@ class MotionTimeManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iMotionTimeData]() + self._relay = DataRelay[O30MotionTimeData]() def set_ticks( self, @@ -44,14 +44,14 @@ def set_ticks( normalized = validate_u8_values(values, name="motion_time_ticks") write_vector( self._client, - main_index=protocol.O30I_MI_MOTION_TIME, + main_index=protocol.O30_MI_MOTION_TIME, values=normalized, timeout_ms=timeout_ms, ) def set_all_ticks(self, value: int, *, timeout_ms: float = 100) -> None: self.set_ticks( - [value] * protocol.O30I_CONTROLLED_JOINT_COUNT, + [value] * protocol.O30_CONTROLLED_JOINT_COUNT, timeout_ms=timeout_ms, ) @@ -63,27 +63,27 @@ def set_milliseconds( ) -> None: """Set durations that are exact multiples of 10 ms, up to 2550 ms.""" normalized = tuple(values) - if len(normalized) != protocol.O30I_CONTROLLED_JOINT_COUNT: + if len(normalized) != protocol.O30_CONTROLLED_JOINT_COUNT: raise ValidationError( "motion_time_ms must contain " - f"{protocol.O30I_CONTROLLED_JOINT_COUNT} values" + f"{protocol.O30_CONTROLLED_JOINT_COUNT} values" ) ticks: list[int] = [] for index, value in enumerate(normalized): protocol._validate_int(value, f"motion_time_ms[{index}]") - if value < 0 or value > 2550 or value % O30I_MOTION_TIME_TICK_MS: + if value < 0 or value > 2550 or value % O30_MOTION_TIME_TICK_MS: raise ValidationError( f"motion_time_ms[{index}] must be a multiple of 10 " "between 0 and 2550" ) - ticks.append(value // O30I_MOTION_TIME_TICK_MS) + ticks.append(value // O30_MOTION_TIME_TICK_MS) self.set_ticks(ticks, timeout_ms=timeout_ms) - def get_blocking(self, timeout_ms: float = 100) -> O30iMotionTimeData: - data = O30iMotionTimeData( + def get_blocking(self, timeout_ms: float = 100) -> O30MotionTimeData: + data = O30MotionTimeData( ticks=read_vector( self._client, - main_index=protocol.O30I_MI_MOTION_TIME, + main_index=protocol.O30_MI_MOTION_TIME, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -91,14 +91,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iMotionTimeData: self._relay.push(data) return data - def get_snapshot(self) -> O30iMotionTimeData | None: + def get_snapshot(self) -> O30MotionTimeData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iMotionTimeData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30MotionTimeData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iMotionTimeData = O30MotionTimeData diff --git a/src/linkerbot/hand/o30i/o30i.py b/src/linkerbot/hand/o30/o30.py similarity index 95% rename from src/linkerbot/hand/o30i/o30i.py rename to src/linkerbot/hand/o30/o30.py index 2349f74..10dd25d 100644 --- a/src/linkerbot/hand/o30i/o30i.py +++ b/src/linkerbot/hand/o30/o30.py @@ -1,4 +1,4 @@ -"""High-level O30i interface on top of HandProtocol_v1.0.""" +"""High-level O30 interface on top of HandProtocol_v1.0.""" from __future__ import annotations @@ -33,7 +33,7 @@ CurrentEvent, FaultEvent, MotionTimeEvent, - O30iSnapshot, + O30Snapshot, SensorEvent, SensorSource, SpeedEvent, @@ -56,8 +56,8 @@ _THREAD_JOIN_TIMEOUT_S = 2.0 -class O30i: - """O30i robotic hand using the measured HandProtocol_v1.0/HOP behavior. +class O30: + """O30 robotic hand using the measured HandProtocol_v1.0/HOP behavior. The default backend remains the vendor ``libcanbus.so`` / ``HCanbus.dll`` ctypes adapter. Linux callers can opt into python-can SocketCAN with @@ -87,13 +87,13 @@ class O30i: def __init__( self, *, - request_id: int = protocol.O30I_REQUEST_ID, + request_id: int = protocol.O30_REQUEST_ID, response_id: int | None = None, device: int = 0, channel: int = 0, library_path: str | Path | None = None, config: CANFDConfigOptions | None = None, - frame_type: int | None = protocol.O30I_FRAME_TYPE, + frame_type: int | None = protocol.O30_FRAME_TYPE, dispatcher: HandProtocolV1DispatcherLike | None = None, interface_type: Literal["ctypes", "socketcan"] = "ctypes", socketcan_channel: str | None = None, @@ -101,11 +101,11 @@ def __init__( data_bitrate: int = 5_000_000, auto_reconfigure: bool = False, ) -> None: - """Open one O30i endpoint and construct all safe managers. + """Open one O30 endpoint and construct all safe managers. Args: request_id: Standard CAN ID used for HOP requests. Defaults to the - measured O30i endpoint ``0x001``. + measured O30 endpoint ``0x001``. response_id: Standard CAN ID expected for responses. ``None`` derives ``request_id | 0x400``; explicit IDs may use the full 11-bit range. @@ -116,14 +116,14 @@ def __init__( frame_type: Outgoing vendor FrameType byte. The measured default is ``0x04`` (CAN FD without bit-rate switching). dispatcher: Existing dispatcher for tests, custom transports, or - shared buses. When supplied, the O30i does not own or stop it. + shared buses. When supplied, the O30 does not own or stop it. interface_type: ``"ctypes"`` for the vendor library or ``"socketcan"`` for python-can on Linux. socketcan_channel: SocketCAN interface name such as ``"can0"``; required for the SocketCAN backend. bitrate: SocketCAN nominal bitrate in bits per second. data_bitrate: SocketCAN data-phase configuration in bits per - second. O30i frames do not enable BRS, but the Linux FD link + second. O30 frames do not enable BRS, but the Linux FD link still exposes this setting. auto_reconfigure: Allow the SocketCAN backend to run ``ip link`` when the interface configuration differs. Requires network @@ -243,16 +243,16 @@ def request_id(self) -> int: def response_id(self) -> int: return self.protocol.response_id - def __enter__(self) -> O30i: + def __enter__(self) -> O30: return self def __exit__(self, exc_type, exc_val, exc_tb) -> bool: self.close() return False - def get_snapshot(self) -> O30iSnapshot: + def get_snapshot(self) -> O30Snapshot: """Return current manager caches without issuing bus requests.""" - return O30iSnapshot( + return O30Snapshot( angle=self.angle.get_snapshot(), speed=self.speed.get_snapshot(), acceleration=self.acceleration.get_snapshot(), @@ -303,7 +303,7 @@ def start_polling( target=self._polling_loop, args=(source, float(interval)), daemon=True, - name=f"O30i-Polling-{source.value}", + name=f"O30-Polling-{source.value}", ) thread.start() self._polling_threads[source] = thread @@ -400,7 +400,7 @@ def _ensure_open(self) -> None: if self._bus_error is not None: raise CANError(f"CANFD bus unavailable: {self._bus_error}") if self._closed: - raise StateError("O30i interface is closed") + raise StateError("O30 interface is closed") def _polling_loop(self, source: SensorSource, interval: float) -> None: sender = self._polling_senders[source] diff --git a/src/linkerbot/hand/o30i/protocol.py b/src/linkerbot/hand/o30/protocol.py similarity index 56% rename from src/linkerbot/hand/o30i/protocol.py rename to src/linkerbot/hand/o30/protocol.py index 4fd6f87..09d887f 100644 --- a/src/linkerbot/hand/o30i/protocol.py +++ b/src/linkerbot/hand/o30/protocol.py @@ -1,6 +1,6 @@ -"""O30i object indices and payload codecs for HandProtocol_v1.0. +"""O30 object indices and payload codecs for HandProtocol_v1.0. -The firmware exposes 36 one-byte runtime slots, but O30i physically implements +The firmware exposes 36 one-byte runtime slots, but O30 physically implements only 20 of them. State reads ask for the complete object and are projected onto the physical-joint order. Writes are split around unimplemented wire slots so commands never target joints that do not exist on the hand. @@ -14,12 +14,12 @@ from linkerbot.exceptions import ValidationError from linkerbot.hand.hand_protocol_v1 import HandProtocolError -O30I_REQUEST_ID = 0x001 -O30I_RESPONSE_ID = 0x401 -O30I_FRAME_TYPE = 0x04 +O30_REQUEST_ID = 0x001 +O30_RESPONSE_ID = 0x401 +O30_FRAME_TYPE = 0x04 -O30I_RUNTIME_SLOT_COUNT = 36 -O30I_JOINT_SLOT_INDICES: tuple[int, ...] = ( +O30_RUNTIME_SLOT_COUNT = 36 +O30_JOINT_SLOT_INDICES: tuple[int, ...] = ( 0, 5, 6, @@ -41,14 +41,14 @@ 23, 24, ) -O30I_CONTROLLED_JOINT_COUNT = len(O30I_JOINT_SLOT_INDICES) -O30I_U8_MIN = 0 -O30I_U8_MAX = 0xFF -O30I_MAX_SPARSE_PAIRS = O30I_CONTROLLED_JOINT_COUNT +O30_CONTROLLED_JOINT_COUNT = len(O30_JOINT_SLOT_INDICES) +O30_U8_MIN = 0 +O30_U8_MAX = 0xFF +O30_MAX_SPARSE_PAIRS = O30_CONTROLLED_JOINT_COUNT # Object 0x30 numbers joints by finger while dense objects group them by # motion. This table is indexed by the 20-joint public SDK order. -O30I_JOINT_TO_SPARSE_JOINT_ID: tuple[int, ...] = ( +O30_JOINT_TO_SPARSE_JOINT_ID: tuple[int, ...] = ( 0, 1, 6, @@ -71,70 +71,70 @@ 24, ) -O30I_MI_JOINT_MAPPING = 0x00 -O30I_MI_POSITION = 0x01 -O30I_MI_SPEED = 0x02 -O30I_MI_ACCELERATION = 0x03 -O30I_MI_CURRENT = 0x04 -O30I_MI_VOLTAGE = 0x05 -O30I_MI_TORQUE = 0x06 -O30I_MI_TEMPERATURE = 0x07 -O30I_MI_MOTION_TIME = 0x08 -O30I_MI_STALL_TIME = 0x09 -O30I_MI_STALL_THRESHOLD = 0x0A -O30I_MI_STALL_HOLD_CURRENT = 0x0B -O30I_MI_ENABLE_MASK = 0x0C -O30I_MI_FAULT = 0x0D -O30I_MI_POSITION_I16 = 0x20 -O30I_MI_SPARSE_POSITION = 0x30 -O30I_MI_SENSOR_INFO = 0x31 -O30I_MI_SENSOR_DATA_1 = 0x32 -O30I_MI_SENSOR_DATA_2 = 0x33 -O30I_MI_SENSOR_DATA_3 = 0x34 -O30I_MI_CONFIGURATION = 0x35 -O30I_MI_PRODUCT_INFO = 0x41 -O30I_MI_ENGINEERING = 0x42 -O30I_MI_UNIT_RANGE = 0x43 -O30I_MI_COMMUNICATION_ERROR = 0x4F +O30_MI_JOINT_MAPPING = 0x00 +O30_MI_POSITION = 0x01 +O30_MI_SPEED = 0x02 +O30_MI_ACCELERATION = 0x03 +O30_MI_CURRENT = 0x04 +O30_MI_VOLTAGE = 0x05 +O30_MI_TORQUE = 0x06 +O30_MI_TEMPERATURE = 0x07 +O30_MI_MOTION_TIME = 0x08 +O30_MI_STALL_TIME = 0x09 +O30_MI_STALL_THRESHOLD = 0x0A +O30_MI_STALL_HOLD_CURRENT = 0x0B +O30_MI_ENABLE_MASK = 0x0C +O30_MI_FAULT = 0x0D +O30_MI_POSITION_I16 = 0x20 +O30_MI_SPARSE_POSITION = 0x30 +O30_MI_SENSOR_INFO = 0x31 +O30_MI_SENSOR_DATA_1 = 0x32 +O30_MI_SENSOR_DATA_2 = 0x33 +O30_MI_SENSOR_DATA_3 = 0x34 +O30_MI_CONFIGURATION = 0x35 +O30_MI_PRODUCT_INFO = 0x41 +O30_MI_ENGINEERING = 0x42 +O30_MI_UNIT_RANGE = 0x43 +O30_MI_COMMUNICATION_ERROR = 0x4F -O30I_POSITION_I16_BYTE_LENGTH = O30I_RUNTIME_SLOT_COUNT * 2 -O30I_PRODUCT_INFO_BYTE_LENGTH = 225 -O30I_SENSOR_INFO_BYTE_LENGTH = 50 -O30I_COMMUNICATION_ERROR_BYTE_LENGTH = 17 +O30_POSITION_I16_BYTE_LENGTH = O30_RUNTIME_SLOT_COUNT * 2 +O30_PRODUCT_INFO_BYTE_LENGTH = 225 +O30_SENSOR_INFO_BYTE_LENGTH = 50 +O30_COMMUNICATION_ERROR_BYTE_LENGTH = 17 -class O30iObject(IntEnum): - """Measured HOP objects exposed by the O30i firmware.""" +class O30Object(IntEnum): + """Measured HOP objects exposed by the O30 firmware.""" - POSITION = O30I_MI_POSITION - SPEED = O30I_MI_SPEED - ACCELERATION = O30I_MI_ACCELERATION - CURRENT = O30I_MI_CURRENT - VOLTAGE = O30I_MI_VOLTAGE - TORQUE = O30I_MI_TORQUE - TEMPERATURE = O30I_MI_TEMPERATURE - MOTION_TIME = O30I_MI_MOTION_TIME - STALL_TIME = O30I_MI_STALL_TIME - STALL_THRESHOLD = O30I_MI_STALL_THRESHOLD - STALL_HOLD_CURRENT = O30I_MI_STALL_HOLD_CURRENT - FAULT = O30I_MI_FAULT - POSITION_I16 = O30I_MI_POSITION_I16 - SPARSE_POSITION = O30I_MI_SPARSE_POSITION - SENSOR_INFO = O30I_MI_SENSOR_INFO - PRODUCT_INFO = O30I_MI_PRODUCT_INFO - UNIT_RANGE = O30I_MI_UNIT_RANGE - COMMUNICATION_ERROR = O30I_MI_COMMUNICATION_ERROR + POSITION = O30_MI_POSITION + SPEED = O30_MI_SPEED + ACCELERATION = O30_MI_ACCELERATION + CURRENT = O30_MI_CURRENT + VOLTAGE = O30_MI_VOLTAGE + TORQUE = O30_MI_TORQUE + TEMPERATURE = O30_MI_TEMPERATURE + MOTION_TIME = O30_MI_MOTION_TIME + STALL_TIME = O30_MI_STALL_TIME + STALL_THRESHOLD = O30_MI_STALL_THRESHOLD + STALL_HOLD_CURRENT = O30_MI_STALL_HOLD_CURRENT + FAULT = O30_MI_FAULT + POSITION_I16 = O30_MI_POSITION_I16 + SPARSE_POSITION = O30_MI_SPARSE_POSITION + SENSOR_INFO = O30_MI_SENSOR_INFO + PRODUCT_INFO = O30_MI_PRODUCT_INFO + UNIT_RANGE = O30_MI_UNIT_RANGE + COMMUNICATION_ERROR = O30_MI_COMMUNICATION_ERROR def decode_runtime_vector(data: bytes) -> tuple[int, ...]: """Project a complete 36-slot object onto the 20 physical joints.""" normalized = bytes(data) - if len(normalized) < O30I_RUNTIME_SLOT_COUNT: + if len(normalized) < O30_RUNTIME_SLOT_COUNT: raise HandProtocolError( - "O30i runtime vector too short: expected " - f"{O30I_RUNTIME_SLOT_COUNT} bytes, got {len(normalized)}" + "O30 runtime vector too short: expected " + f"{O30_RUNTIME_SLOT_COUNT} bytes, got {len(normalized)}" ) - return tuple(normalized[slot] for slot in O30I_JOINT_SLOT_INDICES) + return tuple(normalized[slot] for slot in O30_JOINT_SLOT_INDICES) def encode_control_vector( @@ -144,7 +144,7 @@ def encode_control_vector( normalized = _validate_u8_vector( values, name="values", - expected_count=O30I_CONTROLLED_JOINT_COUNT, + expected_count=O30_CONTROLLED_JOINT_COUNT, ) return _encode_joint_slices(0, normalized) @@ -157,17 +157,17 @@ def encode_control_slice( ) -> tuple[tuple[int, bytes], ...]: """Map a non-empty public-joint slice to contiguous wire-slot writes.""" _validate_int(offset, "offset") - if offset < 0 or offset >= O30I_CONTROLLED_JOINT_COUNT: + if offset < 0 or offset >= O30_CONTROLLED_JOINT_COUNT: raise ValidationError( - f"offset must be between 0 and {O30I_CONTROLLED_JOINT_COUNT - 1}" + f"offset must be between 0 and {O30_CONTROLLED_JOINT_COUNT - 1}" ) normalized = tuple(values) if not normalized: raise ValidationError(f"{name} must not be empty") - if offset + len(normalized) > O30I_CONTROLLED_JOINT_COUNT: + if offset + len(normalized) > O30_CONTROLLED_JOINT_COUNT: raise ValidationError( - f"{name} slice must stay within the {O30I_CONTROLLED_JOINT_COUNT} " - "physical O30i joints" + f"{name} slice must stay within the {O30_CONTROLLED_JOINT_COUNT} " + "physical O30 joints" ) for index, value in enumerate(normalized): _validate_u8(value, f"{name}[{index}]") @@ -177,26 +177,26 @@ def encode_control_slice( def encode_sparse_positions(values: Mapping[int, int]) -> bytes: """Encode public SDK joint indices for sparse wire object ``0x30``. - The mapping keys use the same 0..19 order as :data:`O30I_JOINT_SPECS` and + The mapping keys use the same 0..19 order as :data:`O30_JOINT_SPECS` and are transposed to the firmware's finger-major sparse joint IDs. """ if not isinstance(values, Mapping): raise ValidationError("sparse positions must be a mapping") if not values: raise ValidationError("sparse positions must not be empty") - if len(values) > O30I_MAX_SPARSE_PAIRS: + if len(values) > O30_MAX_SPARSE_PAIRS: raise ValidationError( - f"one sparse command supports at most {O30I_MAX_SPARSE_PAIRS} joints" + f"one sparse command supports at most {O30_MAX_SPARSE_PAIRS} joints" ) encoded_pairs: list[tuple[int, int]] = [] for joint_index, value in sorted(values.items()): _validate_int(joint_index, "joint_index") - if joint_index < 0 or joint_index >= O30I_CONTROLLED_JOINT_COUNT: + if joint_index < 0 or joint_index >= O30_CONTROLLED_JOINT_COUNT: raise ValidationError( "sparse joint_index must reference a physical joint 0..19" ) _validate_u8(value, f"position[{joint_index}]") - encoded_pairs.append((O30I_JOINT_TO_SPARSE_JOINT_ID[joint_index], value)) + encoded_pairs.append((O30_JOINT_TO_SPARSE_JOINT_ID[joint_index], value)) payload = bytearray() for sparse_joint_id, value in sorted(encoded_pairs): payload.extend((sparse_joint_id, value)) @@ -206,16 +206,16 @@ def encode_sparse_positions(values: Mapping[int, int]) -> bytes: def decode_i16_position_block(data: bytes) -> tuple[int, ...]: """Decode the full object and return its 20 physical-joint values.""" normalized = bytes(data) - if len(normalized) < O30I_POSITION_I16_BYTE_LENGTH: + if len(normalized) < O30_POSITION_I16_BYTE_LENGTH: raise HandProtocolError( - "O30i i16 position block too short: expected " - f"{O30I_POSITION_I16_BYTE_LENGTH} bytes, got {len(normalized)}" + "O30 i16 position block too short: expected " + f"{O30_POSITION_I16_BYTE_LENGTH} bytes, got {len(normalized)}" ) wire_values = tuple( int.from_bytes(normalized[index : index + 2], "little", signed=True) - for index in range(0, O30I_POSITION_I16_BYTE_LENGTH, 2) + for index in range(0, O30_POSITION_I16_BYTE_LENGTH, 2) ) - return tuple(wire_values[slot] for slot in O30I_JOINT_SLOT_INDICES) + return tuple(wire_values[slot] for slot in O30_JOINT_SLOT_INDICES) def _encode_joint_slices( @@ -223,11 +223,11 @@ def _encode_joint_slices( values: tuple[int, ...], ) -> tuple[tuple[int, bytes], ...]: slices: list[tuple[int, bytes]] = [] - start_slot = O30I_JOINT_SLOT_INDICES[offset] + start_slot = O30_JOINT_SLOT_INDICES[offset] payload = bytearray((values[0],)) previous_slot = start_slot for joint_index, value in enumerate(values[1:], start=offset + 1): - wire_slot = O30I_JOINT_SLOT_INDICES[joint_index] + wire_slot = O30_JOINT_SLOT_INDICES[joint_index] if wire_slot != previous_slot + 1: slices.append((start_slot, bytes(payload))) start_slot = wire_slot @@ -256,7 +256,7 @@ def _validate_u8_vector( def _validate_u8(value: int, name: str) -> None: _validate_int(value, name) - if value < O30I_U8_MIN or value > O30I_U8_MAX: + if value < O30_U8_MIN or value > O30_U8_MAX: raise ValidationError(f"{name} must be between 0 and 255") diff --git a/src/linkerbot/hand/o30i/sensor.py b/src/linkerbot/hand/o30/sensor.py similarity index 63% rename from src/linkerbot/hand/o30i/sensor.py rename to src/linkerbot/hand/o30/sensor.py index d27cd7f..372fc9c 100644 --- a/src/linkerbot/hand/o30i/sensor.py +++ b/src/linkerbot/hand/o30/sensor.py @@ -1,4 +1,4 @@ -"""O30i sensor-capability metadata from HOP object ``0x31``.""" +"""O30 sensor-capability metadata from HOP object ``0x31``.""" from __future__ import annotations @@ -11,8 +11,8 @@ @dataclass(frozen=True, slots=True) -class O30iSensorInfo: - """Measured sensor descriptor; current O30i units may report NO_SENSOR.""" +class O30SensorInfo: + """Measured sensor descriptor; current O30 units may report NO_SENSOR.""" sensor_type: str unit: str @@ -34,24 +34,24 @@ class SensorManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - def get_info(self, *, timeout_ms: float = 1000) -> O30iSensorInfo: + def get_info(self, *, timeout_ms: float = 1000) -> O30SensorInfo: payload = self._client.read( - main_index=protocol.O30I_MI_SENSOR_INFO, - length=protocol.O30I_SENSOR_INFO_BYTE_LENGTH, + main_index=protocol.O30_MI_SENSOR_INFO, + length=protocol.O30_SENSOR_INFO_BYTE_LENGTH, timeout_ms=timeout_ms, ) return decode_sensor_info(payload) -def decode_sensor_info(payload: bytes) -> O30iSensorInfo: +def decode_sensor_info(payload: bytes) -> O30SensorInfo: normalized = bytes(payload) - if len(normalized) < protocol.O30I_SENSOR_INFO_BYTE_LENGTH: + if len(normalized) < protocol.O30_SENSOR_INFO_BYTE_LENGTH: raise HandProtocolError( - "O30i sensor info too short: expected " - f"{protocol.O30I_SENSOR_INFO_BYTE_LENGTH} bytes, got {len(normalized)}" + "O30 sensor info too short: expected " + f"{protocol.O30_SENSOR_INFO_BYTE_LENGTH} bytes, got {len(normalized)}" ) - data = normalized[: protocol.O30I_SENSOR_INFO_BYTE_LENGTH] - return O30iSensorInfo( + data = normalized[: protocol.O30_SENSOR_INFO_BYTE_LENGTH] + return O30SensorInfo( sensor_type=_decode_ascii(data[0x00:0x20]), unit=_decode_ascii(data[0x20:0x28]), total_data_length=int.from_bytes(data[0x2B:0x2D], "little"), @@ -65,3 +65,7 @@ def decode_sensor_info(payload: bytes) -> O30iSensorInfo: def _decode_ascii(data: bytes) -> str: return bytes(data).split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() + + +# Backward-compatible aliases for the former model name. +O30iSensorInfo = O30SensorInfo diff --git a/src/linkerbot/hand/o30i/speed.py b/src/linkerbot/hand/o30/speed.py similarity index 72% rename from src/linkerbot/hand/o30i/speed.py rename to src/linkerbot/hand/o30/speed.py index 58f10b9..dcaa2c9 100644 --- a/src/linkerbot/hand/o30i/speed.py +++ b/src/linkerbot/hand/o30/speed.py @@ -1,4 +1,4 @@ -"""Speed control and sensing for O30i.""" +"""Speed control and sensing for O30.""" from __future__ import annotations @@ -15,7 +15,7 @@ @dataclass(frozen=True, slots=True) -class O30iSpeedData: +class O30SpeedData: speeds: tuple[int, ...] timestamp: float @@ -25,7 +25,7 @@ class SpeedManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iSpeedData]() + self._relay = DataRelay[O30SpeedData]() def set_speeds( self, @@ -36,22 +36,22 @@ def set_speeds( normalized = validate_u8_values(values, name="speeds") write_vector( self._client, - main_index=protocol.O30I_MI_SPEED, + main_index=protocol.O30_MI_SPEED, values=normalized, timeout_ms=timeout_ms, ) def set_all(self, value: int, *, timeout_ms: float = 100) -> None: self.set_speeds( - [value] * protocol.O30I_CONTROLLED_JOINT_COUNT, + [value] * protocol.O30_CONTROLLED_JOINT_COUNT, timeout_ms=timeout_ms, ) - def get_blocking(self, timeout_ms: float = 100) -> O30iSpeedData: - data = O30iSpeedData( + def get_blocking(self, timeout_ms: float = 100) -> O30SpeedData: + data = O30SpeedData( speeds=read_vector( self._client, - main_index=protocol.O30I_MI_SPEED, + main_index=protocol.O30_MI_SPEED, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -59,14 +59,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iSpeedData: self._relay.push(data) return data - def get_snapshot(self) -> O30iSpeedData | None: + def get_snapshot(self) -> O30SpeedData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iSpeedData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30SpeedData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iSpeedData = O30SpeedData diff --git a/src/linkerbot/hand/o30i/temperature.py b/src/linkerbot/hand/o30/temperature.py similarity index 65% rename from src/linkerbot/hand/o30i/temperature.py rename to src/linkerbot/hand/o30/temperature.py index f76893c..fc0a17d 100644 --- a/src/linkerbot/hand/o30i/temperature.py +++ b/src/linkerbot/hand/o30/temperature.py @@ -1,4 +1,4 @@ -"""Temperature-state sensing for O30i.""" +"""Temperature-state sensing for O30.""" from __future__ import annotations @@ -14,7 +14,7 @@ @dataclass(frozen=True, slots=True) -class O30iTemperatureData: +class O30TemperatureData: temperatures: tuple[int, ...] timestamp: float @@ -24,13 +24,13 @@ class TemperatureManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iTemperatureData]() + self._relay = DataRelay[O30TemperatureData]() - def get_blocking(self, timeout_ms: float = 100) -> O30iTemperatureData: - data = O30iTemperatureData( + def get_blocking(self, timeout_ms: float = 100) -> O30TemperatureData: + data = O30TemperatureData( temperatures=read_vector( self._client, - main_index=protocol.O30I_MI_TEMPERATURE, + main_index=protocol.O30_MI_TEMPERATURE, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -38,14 +38,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iTemperatureData: self._relay.push(data) return data - def get_snapshot(self) -> O30iTemperatureData | None: + def get_snapshot(self) -> O30TemperatureData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iTemperatureData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30TemperatureData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iTemperatureData = O30TemperatureData diff --git a/src/linkerbot/hand/o30i/torque.py b/src/linkerbot/hand/o30/torque.py similarity index 72% rename from src/linkerbot/hand/o30i/torque.py rename to src/linkerbot/hand/o30/torque.py index a5b16dd..4a14c48 100644 --- a/src/linkerbot/hand/o30i/torque.py +++ b/src/linkerbot/hand/o30/torque.py @@ -1,4 +1,4 @@ -"""Torque-byte control and sensing for O30i.""" +"""Torque-byte control and sensing for O30.""" from __future__ import annotations @@ -15,7 +15,7 @@ @dataclass(frozen=True, slots=True) -class O30iTorqueData: +class O30TorqueData: torques: tuple[int, ...] timestamp: float @@ -25,7 +25,7 @@ class TorqueManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iTorqueData]() + self._relay = DataRelay[O30TorqueData]() def set_torques( self, @@ -36,22 +36,22 @@ def set_torques( normalized = validate_u8_values(values, name="torques") write_vector( self._client, - main_index=protocol.O30I_MI_TORQUE, + main_index=protocol.O30_MI_TORQUE, values=normalized, timeout_ms=timeout_ms, ) def set_all(self, value: int, *, timeout_ms: float = 100) -> None: self.set_torques( - [value] * protocol.O30I_CONTROLLED_JOINT_COUNT, + [value] * protocol.O30_CONTROLLED_JOINT_COUNT, timeout_ms=timeout_ms, ) - def get_blocking(self, timeout_ms: float = 100) -> O30iTorqueData: - data = O30iTorqueData( + def get_blocking(self, timeout_ms: float = 100) -> O30TorqueData: + data = O30TorqueData( torques=read_vector( self._client, - main_index=protocol.O30I_MI_TORQUE, + main_index=protocol.O30_MI_TORQUE, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -59,14 +59,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iTorqueData: self._relay.push(data) return data - def get_snapshot(self) -> O30iTorqueData | None: + def get_snapshot(self) -> O30TorqueData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iTorqueData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30TorqueData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iTorqueData = O30TorqueData diff --git a/src/linkerbot/hand/o30i/version.py b/src/linkerbot/hand/o30/version.py similarity index 73% rename from src/linkerbot/hand/o30i/version.py rename to src/linkerbot/hand/o30/version.py index e0f9863..9d27c4c 100644 --- a/src/linkerbot/hand/o30i/version.py +++ b/src/linkerbot/hand/o30/version.py @@ -1,4 +1,4 @@ -"""Read-only product information for O30i object ``0x41``.""" +"""Read-only product information for O30 object ``0x41``.""" from __future__ import annotations @@ -11,8 +11,8 @@ @dataclass(frozen=True, slots=True) -class O30iDeviceInfo: - """Decoded 225-byte O30i product-information object.""" +class O30DeviceInfo: + """Decoded 225-byte O30 product-information object.""" product_model: str voltage_range: str @@ -33,30 +33,30 @@ class O30iDeviceInfo: class VersionManager: - """Read complete O30i identity/version data without exposing writes.""" + """Read complete O30 identity/version data without exposing writes.""" def __init__(self, client: HandProtocolV1) -> None: self._client = client - def get_device_info(self, *, timeout_ms: float = 1000) -> O30iDeviceInfo: + def get_device_info(self, *, timeout_ms: float = 1000) -> O30DeviceInfo: payload = self._client.read( - main_index=protocol.O30I_MI_PRODUCT_INFO, - length=protocol.O30I_PRODUCT_INFO_BYTE_LENGTH, + main_index=protocol.O30_MI_PRODUCT_INFO, + length=protocol.O30_PRODUCT_INFO_BYTE_LENGTH, timeout_ms=timeout_ms, ) return decode_device_info(payload) -def decode_device_info(payload: bytes) -> O30iDeviceInfo: +def decode_device_info(payload: bytes) -> O30DeviceInfo: """Decode the fixed offsets verified for product object ``0x41``.""" normalized = bytes(payload) - if len(normalized) < protocol.O30I_PRODUCT_INFO_BYTE_LENGTH: + if len(normalized) < protocol.O30_PRODUCT_INFO_BYTE_LENGTH: raise HandProtocolError( - "O30i product info too short: expected " - f"{protocol.O30I_PRODUCT_INFO_BYTE_LENGTH} bytes, got {len(normalized)}" + "O30 product info too short: expected " + f"{protocol.O30_PRODUCT_INFO_BYTE_LENGTH} bytes, got {len(normalized)}" ) - data = normalized[: protocol.O30I_PRODUCT_INFO_BYTE_LENGTH] - return O30iDeviceInfo( + data = normalized[: protocol.O30_PRODUCT_INFO_BYTE_LENGTH] + return O30DeviceInfo( product_model=_decode_ascii(data[0x00:0x10]), voltage_range=_decode_ascii(data[0x10:0x20]), mcu_uid=_decode_ascii(data[0x20:0x39]), @@ -78,3 +78,7 @@ def decode_device_info(payload: bytes) -> O30iDeviceInfo: def _decode_ascii(data: bytes) -> str: return bytes(data).split(b"\x00", 1)[0].decode("ascii", errors="replace").strip() + + +# Backward-compatible aliases for the former model name. +O30iDeviceInfo = O30DeviceInfo diff --git a/src/linkerbot/hand/o30i/voltage.py b/src/linkerbot/hand/o30/voltage.py similarity index 67% rename from src/linkerbot/hand/o30i/voltage.py rename to src/linkerbot/hand/o30/voltage.py index b368d29..98a19ab 100644 --- a/src/linkerbot/hand/o30i/voltage.py +++ b/src/linkerbot/hand/o30/voltage.py @@ -1,4 +1,4 @@ -"""Voltage-state sensing for O30i.""" +"""Voltage-state sensing for O30.""" from __future__ import annotations @@ -14,7 +14,7 @@ @dataclass(frozen=True, slots=True) -class O30iVoltageData: +class O30VoltageData: voltages: tuple[int, ...] timestamp: float @@ -24,13 +24,13 @@ class VoltageManager: def __init__(self, client: HandProtocolV1) -> None: self._client = client - self._relay = DataRelay[O30iVoltageData]() + self._relay = DataRelay[O30VoltageData]() - def get_blocking(self, timeout_ms: float = 100) -> O30iVoltageData: - data = O30iVoltageData( + def get_blocking(self, timeout_ms: float = 100) -> O30VoltageData: + data = O30VoltageData( voltages=read_vector( self._client, - main_index=protocol.O30I_MI_VOLTAGE, + main_index=protocol.O30_MI_VOLTAGE, timeout_ms=timeout_ms, ), timestamp=time.time(), @@ -38,14 +38,18 @@ def get_blocking(self, timeout_ms: float = 100) -> O30iVoltageData: self._relay.push(data) return data - def get_snapshot(self) -> O30iVoltageData | None: + def get_snapshot(self) -> O30VoltageData | None: return self._relay.snapshot() def _send_sense_request(self) -> None: self.get_blocking(timeout_ms=100) - def _set_event_sink(self, sink: Callable[[O30iVoltageData], None]) -> None: + def _set_event_sink(self, sink: Callable[[O30VoltageData], None]) -> None: self._relay.set_sink(sink) def close(self) -> None: """Release manager resources; this manager owns no threads.""" + + +# Backward-compatible aliases for the former model name. +O30iVoltageData = O30VoltageData diff --git a/src/linkerbot/hand/o30i/__init__.py b/src/linkerbot/hand/o30i/__init__.py deleted file mode 100644 index fbf2275..0000000 --- a/src/linkerbot/hand/o30i/__init__.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Public O30i SDK API.""" - -from linkerbot.hand.hand_protocol_v1 import ( - HandProtocolDeviceError, - HandProtocolError, - HandProtocolV1, -) - -from .acceleration import AccelerationManager, O30iAccelerationData -from .angle import AngleManager, O30iAngleData -from .current import CurrentManager, O30iCurrentData -from .diagnostics import DiagnosticsManager, O30iCommunicationErrors -from .events import ( - AccelerationEvent, - AngleEvent, - CurrentEvent, - FaultEvent, - MotionTimeEvent, - O30iSnapshot, - SensorEvent, - SensorSource, - SpeedEvent, - TemperatureEvent, - TorqueEvent, - VoltageEvent, -) -from .fault import FaultManager, O30iFaultData -from .joints import ( - O30I_DEFAULT_RAW_VALUES, - O30I_JOINT_COUNT, - O30I_JOINT_SPECS, - JointSpec, - O30iAngle, -) -from .motion_time import MotionTimeManager, O30iMotionTimeData -from .o30i import O30i -from .protocol import O30iObject -from .sensor import O30iSensorInfo, SensorManager -from .speed import O30iSpeedData, SpeedManager -from .temperature import O30iTemperatureData, TemperatureManager -from .torque import O30iTorqueData, TorqueManager -from .version import O30iDeviceInfo, VersionManager -from .voltage import O30iVoltageData, VoltageManager - -__all__ = [ - "AccelerationEvent", - "AccelerationManager", - "AngleEvent", - "AngleManager", - "CurrentEvent", - "CurrentManager", - "DiagnosticsManager", - "FaultEvent", - "FaultManager", - "HandProtocolDeviceError", - "HandProtocolError", - "HandProtocolV1", - "MotionTimeEvent", - "MotionTimeManager", - "O30I_DEFAULT_RAW_VALUES", - "O30I_JOINT_COUNT", - "O30I_JOINT_SPECS", - "O30i", - "O30iAccelerationData", - "O30iAngle", - "O30iAngleData", - "O30iCommunicationErrors", - "O30iCurrentData", - "O30iDeviceInfo", - "O30iFaultData", - "O30iMotionTimeData", - "O30iObject", - "O30iSensorInfo", - "O30iSnapshot", - "O30iSpeedData", - "O30iTemperatureData", - "O30iTorqueData", - "O30iVoltageData", - "JointSpec", - "SensorEvent", - "SensorManager", - "SensorSource", - "SpeedEvent", - "SpeedManager", - "TemperatureEvent", - "TemperatureManager", - "TorqueEvent", - "TorqueManager", - "VersionManager", - "VoltageEvent", - "VoltageManager", -] diff --git a/src/linkerbot/hand/o30i/events.py b/src/linkerbot/hand/o30i/events.py deleted file mode 100644 index 9cb6105..0000000 --- a/src/linkerbot/hand/o30i/events.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Unified polling and stream types for O30i runtime data.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum - -from .acceleration import O30iAccelerationData -from .angle import O30iAngleData -from .current import O30iCurrentData -from .fault import O30iFaultData -from .motion_time import O30iMotionTimeData -from .speed import O30iSpeedData -from .temperature import O30iTemperatureData -from .torque import O30iTorqueData -from .voltage import O30iVoltageData - - -@dataclass(frozen=True, slots=True) -class AngleEvent: - data: O30iAngleData - - -@dataclass(frozen=True, slots=True) -class SpeedEvent: - data: O30iSpeedData - - -@dataclass(frozen=True, slots=True) -class AccelerationEvent: - data: O30iAccelerationData - - -@dataclass(frozen=True, slots=True) -class CurrentEvent: - data: O30iCurrentData - - -@dataclass(frozen=True, slots=True) -class VoltageEvent: - data: O30iVoltageData - - -@dataclass(frozen=True, slots=True) -class TorqueEvent: - data: O30iTorqueData - - -@dataclass(frozen=True, slots=True) -class TemperatureEvent: - data: O30iTemperatureData - - -@dataclass(frozen=True, slots=True) -class MotionTimeEvent: - data: O30iMotionTimeData - - -@dataclass(frozen=True, slots=True) -class FaultEvent: - data: O30iFaultData - - -SensorEvent = ( - AngleEvent - | SpeedEvent - | AccelerationEvent - | CurrentEvent - | VoltageEvent - | TorqueEvent - | TemperatureEvent - | MotionTimeEvent - | FaultEvent -) - - -class SensorSource(str, Enum): - ANGLE = "angle" - SPEED = "speed" - ACCELERATION = "acceleration" - CURRENT = "current" - VOLTAGE = "voltage" - TORQUE = "torque" - TEMPERATURE = "temperature" - MOTION_TIME = "motion_time" - FAULT = "fault" - - -@dataclass(frozen=True, slots=True) -class O30iSnapshot: - angle: O30iAngleData | None - speed: O30iSpeedData | None - acceleration: O30iAccelerationData | None - current: O30iCurrentData | None - voltage: O30iVoltageData | None - torque: O30iTorqueData | None - temperature: O30iTemperatureData | None - motion_time: O30iMotionTimeData | None - fault: O30iFaultData | None - timestamp: float diff --git a/tests/hand/hand_protocol_v1/test_client.py b/tests/hand/hand_protocol_v1/test_client.py index db338bb..51b7c33 100644 --- a/tests/hand/hand_protocol_v1/test_client.py +++ b/tests/hand/hand_protocol_v1/test_client.py @@ -15,7 +15,7 @@ ) from tests.hand.hand_protocol_v1.fakes import FakeDispatcher -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] def _response(data: bytes, *, arbitration_id: int = 0x401) -> CANFDMessage: diff --git a/tests/hand/hand_protocol_v1/test_protocol.py b/tests/hand/hand_protocol_v1/test_protocol.py index f4f7178..4d6fc8b 100644 --- a/tests/hand/hand_protocol_v1/test_protocol.py +++ b/tests/hand/hand_protocol_v1/test_protocol.py @@ -3,7 +3,7 @@ from linkerbot.exceptions import ValidationError from linkerbot.hand.hand_protocol_v1 import protocol -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] def test_response_id_uses_hop_response_flag() -> None: diff --git a/tests/hand/l25/__init__.py b/tests/hand/l20/__init__.py similarity index 100% rename from tests/hand/l25/__init__.py rename to tests/hand/l20/__init__.py diff --git a/tests/hand/l25/conftest.py b/tests/hand/l20/conftest.py similarity index 74% rename from tests/hand/l25/conftest.py rename to tests/hand/l20/conftest.py index 8e9f969..2355220 100644 --- a/tests/hand/l25/conftest.py +++ b/tests/hand/l20/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures for L25 robotic hand tests.""" +"""Fixtures for L20 robotic hand tests.""" import os import time @@ -7,9 +7,9 @@ import pytest -from linkerbot import L25 +from linkerbot import L20 -L25_TEST_ORDER: dict[str, int] = { +L20_TEST_ORDER: dict[str, int] = { "test_validation": 1, "test_lifecycle": 2, "test_fault": 3, @@ -28,17 +28,17 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: - """Reorder l25 tests: validation -> lifecycle -> ... -> stress.""" + """Reorder l20 tests: validation -> lifecycle -> ... -> stress.""" indexed: list[tuple[pytest.Item, tuple[int, int, int]]] = [] for idx, item in enumerate(items): path_str = str(item.path) - if "hand/l25" not in path_str or "conftest" in path_str: + if "hand/l20" not in path_str or "conftest" in path_str: indexed.append((item, (0, idx, 0))) else: stem = item.path.stem if stem not in _NON_HARDWARE_TEST_MODULES: item.add_marker(pytest.mark.hardware) - rank = L25_TEST_ORDER.get(stem) + rank = L20_TEST_ORDER.get(stem) if rank is not None: indexed.append((item, (1, rank, 0))) else: @@ -49,15 +49,15 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @pytest.fixture(scope="module") -def l25_hand(isolate_hand_config: Path): - """Create L25 hand instance for the test module. +def l20_hand(isolate_hand_config: Path): + """Create L20 hand instance for the test module. Uses environment variables for configuration: - CAN_INTERFACE: CAN interface name (default: "can0") - - L25_SIDE: Hand side, "left" or "right" (default: "left") + - L20_SIDE: Hand side, "left" or "right" (default: "left") """ interface = os.environ.get("CAN_INTERFACE", "can0") - side = cast(Literal["left", "right"], os.environ.get("L25_SIDE", "left")) + side = cast(Literal["left", "right"], os.environ.get("L20_SIDE", "left")) # Hardware-verified open pose (natural splay) open_pose = [ @@ -79,10 +79,10 @@ def l25_hand(isolate_hand_config: Path): 100.0, # pinky ] - with L25( + with L20( side=side, interface_name=interface, - angle_mapping_path=isolate_hand_config / "l25.toml", + angle_mapping_path=isolate_hand_config / "l20.toml", ) as hand: hand.speed.set_speeds([100.0] * 16) hand.angle.set_angles(open_pose) @@ -92,26 +92,26 @@ def l25_hand(isolate_hand_config: Path): @pytest.fixture(scope="session") def closed_hand(isolate_hand_config: Path): - """Create a closed L25 hand instance for post-close tests.""" + """Create a closed L20 hand instance for post-close tests.""" interface = os.environ.get("CAN_INTERFACE", "can0") - side = cast(Literal["left", "right"], os.environ.get("L25_SIDE", "left")) + side = cast(Literal["left", "right"], os.environ.get("L20_SIDE", "left")) - with L25( + with L20( side=side, interface_name=interface, - angle_mapping_path=isolate_hand_config / "l25.toml", + angle_mapping_path=isolate_hand_config / "l20.toml", ) as hand: pass return hand -def move_and_wait(hand: L25, angles: list[float], wait_sec: float = 2.0) -> None: +def move_and_wait(hand: L20, angles: list[float], wait_sec: float = 2.0) -> None: """Move hand to target angles and wait for completion.""" hand.angle.set_angles(angles) time.sleep(wait_sec) -def move_and_print(hand: L25, angles: list[float], wait_sec: float = 2.0): +def move_and_print(hand: L20, angles: list[float], wait_sec: float = 2.0): """Move hand to target angles, wait, then print current angles.""" hand.angle.set_angles(angles) time.sleep(wait_sec) diff --git a/tests/hand/l25/test_angle.py b/tests/hand/l20/test_angle.py similarity index 80% rename from tests/hand/l25/test_angle.py rename to tests/hand/l20/test_angle.py index d7028c4..45ccaaf 100644 --- a/tests/hand/l25/test_angle.py +++ b/tests/hand/l20/test_angle.py @@ -1,15 +1,15 @@ -"""Tests for L25 AngleManager with hardware.""" +"""Tests for L20 AngleManager with hardware.""" import threading import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import AngleEvent, L25Angle, SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import AngleEvent, L20Angle, SensorSource from tests.conftest import InteractiveSession -pytestmark = [pytest.mark.l25, pytest.mark.control] +pytestmark = [pytest.mark.l20, pytest.mark.control] TOLERANCE = 5.0 MOTION_TIMEOUT_SEC = 5.0 @@ -55,7 +55,7 @@ def _move_until_settled( - hand: L25, + hand: L20, target: list[float], tolerance: float = TOLERANCE, timeout_sec: float = MOTION_TIMEOUT_SEC, @@ -97,9 +97,9 @@ def _move_until_settled( class TestAngleManagerBlocking: """Test AngleManager blocking read.""" - def test_get_blocking_returns_valid_data(self, l25_hand: L25): + def test_get_blocking_returns_valid_data(self, l20_hand: L20): """Blocking read should return 16 angles, all in [0, 100].""" - data = l25_hand.angle.get_blocking(timeout_ms=500) + data = l20_hand.angle.get_blocking(timeout_ms=500) assert data is not None assert len(data.angles) == 16 @@ -108,9 +108,9 @@ def test_get_blocking_returns_valid_data(self, l25_hand: L25): print(f"\n Angles: {[f'{a:.1f}' for a in data.angles.to_list()]}") - def test_get_blocking_has_timestamp(self, l25_hand: L25): + def test_get_blocking_has_timestamp(self, l20_hand: L20): """Angle data timestamp should be positive and not in the future.""" - data = l25_hand.angle.get_blocking(timeout_ms=500) + data = l20_hand.angle.get_blocking(timeout_ms=500) assert data.timestamp > 0 assert data.timestamp <= time.time() @@ -119,21 +119,21 @@ def test_get_blocking_has_timestamp(self, l25_hand: L25): class TestAngleManagerSetAngles: """Test AngleManager set_angles method.""" - def test_set_angles_with_list(self, l25_hand: L25): + def test_set_angles_with_list(self, l20_hand: L20): """set_angles should accept list[float] without error and allow read-back.""" - l25_hand.angle.set_angles(OPEN) + l20_hand.angle.set_angles(OPEN) time.sleep(2.0) - data = l25_hand.angle.get_blocking(timeout_ms=500) + data = l20_hand.angle.get_blocking(timeout_ms=500) assert data is not None assert len(data.angles) == 16 print( f"\n Read-back after set_angles (list): {[f'{a:.1f}' for a in data.angles.to_list()]}" ) - def test_set_angles_with_l25_angle(self, l25_hand: L25): - """set_angles should accept L25Angle instance without error and allow read-back.""" - target = L25Angle( + def test_set_angles_with_l20_angle(self, l20_hand: L20): + """set_angles should accept L20Angle instance without error and allow read-back.""" + target = L20Angle( thumb_abd=100.0, thumb_yaw=100.0, thumb_root1=100.0, @@ -152,32 +152,32 @@ def test_set_angles_with_l25_angle(self, l25_hand: L25): pinky_tip=100.0, ) - l25_hand.angle.set_angles(target) + l20_hand.angle.set_angles(target) time.sleep(2.0) - data = l25_hand.angle.get_blocking(timeout_ms=500) + data = l20_hand.angle.get_blocking(timeout_ms=500) assert data is not None assert len(data.angles) == 16 print( - f"\n Read-back after set_angles (L25Angle): {[f'{a:.1f}' for a in data.angles.to_list()]}" + f"\n Read-back after set_angles (L20Angle): {[f'{a:.1f}' for a in data.angles.to_list()]}" ) - def test_set_all_closed(self, l25_hand: L25): + def test_set_all_closed(self, l20_hand: L20): """Set closed grip pose and verify read-back within tolerance.""" - _move_until_settled(l25_hand, CLOSED, timeout_sec=10.0) + _move_until_settled(l20_hand, CLOSED, timeout_sec=10.0) - data = l25_hand.angle.get_snapshot() + data = l20_hand.angle.get_snapshot() assert data is not None for i, expected in enumerate(CLOSED): assert abs(data.angles[i] - expected) < TOLERANCE, ( f"Angle {i} expected ~{expected}, got {data.angles[i]}" ) - def test_set_all_open(self, l25_hand: L25): + def test_set_all_open(self, l20_hand: L20): """Set open pose and verify read-back within tolerance.""" - _move_until_settled(l25_hand, OPEN) + _move_until_settled(l20_hand, OPEN) - data = l25_hand.angle.get_snapshot() + data = l20_hand.angle.get_snapshot() assert data is not None for i, expected in enumerate(OPEN): assert abs(data.angles[i] - expected) < TOLERANCE, ( @@ -188,20 +188,20 @@ def test_set_all_open(self, l25_hand: L25): class TestAngleManagerSnapshot: """Test AngleManager snapshot (cache) mode.""" - def test_snapshot_populated_after_blocking_read(self, l25_hand: L25): + def test_snapshot_populated_after_blocking_read(self, l20_hand: L20): """After get_blocking(), get_snapshot() should return non-None with 16 angles.""" - l25_hand.angle.get_blocking(timeout_ms=500) + l20_hand.angle.get_blocking(timeout_ms=500) - data = l25_hand.angle.get_snapshot() + data = l20_hand.angle.get_snapshot() assert data is not None assert len(data.angles) == 16 - def test_set_and_read_within_tolerance(self, l25_hand: L25): + def test_set_and_read_within_tolerance(self, l20_hand: L20): """Set open pose, read back, each angle within tolerance.""" - l25_hand.angle.set_angles(OPEN) + l20_hand.angle.set_angles(OPEN) time.sleep(4.0) - data = l25_hand.angle.get_blocking(timeout_ms=500) + data = l20_hand.angle.get_blocking(timeout_ms=500) for i, expected in enumerate(OPEN): assert abs(data.angles[i] - expected) < TOLERANCE, ( f"Angle {i} expected ~{expected}, got {data.angles[i]}" @@ -213,7 +213,7 @@ class TestAngleInteractive: """Interactive tests for angle control requiring human verification.""" def test_individual_finger_movement( - self, l25_hand: L25, interactive_session: InteractiveSession + self, l20_hand: L20, interactive_session: InteractiveSession ): """Human verifies each finger moves independently. @@ -222,7 +222,7 @@ def test_individual_finger_movement( """ session = interactive_session - l25_hand.speed.set_speeds([100.0] * 16) + l20_hand.speed.set_speeds([100.0] * 16) joint_names = [ "thumb_abd", @@ -245,13 +245,13 @@ def test_individual_finger_movement( session.step( instruction="Setting all joints to fully open pose", - action=lambda: _move_until_settled(l25_hand, OPEN), + action=lambda: _move_until_settled(l20_hand, OPEN), expected="All fingers should be fully open / extended", ) def _reset_then_bend(target: list[float]) -> None: - _move_until_settled(l25_hand, OPEN) - _move_until_settled(l25_hand, target) + _move_until_settled(l20_hand, OPEN) + _move_until_settled(l20_hand, target) for joint_idx, joint_name in enumerate(joint_names): if joint_name == "thumb_abd": @@ -278,12 +278,12 @@ def _reset_then_bend(target: list[float]) -> None: pytest.fail(f"{len(failures)} step(s) failed:\n" + "\n".join(msgs)) def test_gradual_movement( - self, l25_hand: L25, interactive_session: InteractiveSession + self, l20_hand: L20, interactive_session: InteractiveSession ): """Human verifies smooth gradual motion from open to closed.""" session = interactive_session - _move_until_settled(l25_hand, OPEN) + _move_until_settled(l20_hand, OPEN) for pct in [0, 25, 50, 75, 100]: # Build target: root1/tip joints go from OPEN(100) to CLOSED(0) @@ -299,7 +299,7 @@ def test_gradual_movement( session.step( instruction=f"Moving all fingers to {pct}% closed (thumb_abd=100)", - action=lambda t=target: _move_until_settled(l25_hand, t), + action=lambda t=target: _move_until_settled(l20_hand, t), expected=f"All fingers (except thumb_abd) at ~{pct}% toward closed", ) diff --git a/tests/hand/l25/test_angle_mapping.py b/tests/hand/l20/test_angle_mapping.py similarity index 95% rename from tests/hand/l25/test_angle_mapping.py rename to tests/hand/l20/test_angle_mapping.py index aef5917..56f9376 100644 --- a/tests/hand/l25/test_angle_mapping.py +++ b/tests/hand/l20/test_angle_mapping.py @@ -1,4 +1,4 @@ -"""Unit tests for L25 angle mapping (no hardware required).""" +"""Unit tests for L20 angle mapping (no hardware required).""" from collections.abc import Callable from pathlib import Path @@ -7,9 +7,9 @@ import pytest from linkerbot.exceptions import ValidationError -from linkerbot.hand.l25.angle import AngleManager +from linkerbot.hand.l20.angle import AngleManager -pytestmark = [pytest.mark.l25, pytest.mark.validation] +pytestmark = [pytest.mark.l20, pytest.mark.validation] class FakeDispatcher: diff --git a/tests/hand/l20/test_error_handling.py b/tests/hand/l20/test_error_handling.py new file mode 100644 index 0000000..648793a --- /dev/null +++ b/tests/hand/l20/test_error_handling.py @@ -0,0 +1,77 @@ +"""Tests for L20 error handling with hardware.""" + +import pytest + +from linkerbot import L20 +from linkerbot.exceptions import ValidationError +from linkerbot.hand.l20 import SensorSource + +pytestmark = [pytest.mark.l20, pytest.mark.error_handling] + + +class TestAngleErrorHandling: + """Validate angle-related error handling.""" + + def test_get_blocking_timeout_zero(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.angle.get_blocking(timeout_ms=0) + + def test_get_blocking_timeout_negative(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.angle.get_blocking(timeout_ms=-1) + + def test_set_angles_empty_list(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.angle.set_angles([]) + + def test_set_angles_too_few(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.angle.set_angles([50.0] * 15) + + def test_set_angles_too_many(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.angle.set_angles([50.0] * 17) + + def test_set_angles_negative_value(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.angle.set_angles([-1.0] + [50.0] * 15) + + def test_set_angles_over_100(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.angle.set_angles([101.0] + [50.0] * 15) + + +class TestSpeedErrorHandling: + """Validate speed-related error handling.""" + + def test_get_blocking_timeout_zero(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.speed.get_blocking(timeout_ms=0) + + def test_set_speeds_wrong_length(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.speed.set_speeds([50.0] * 15) + + +class TestTorqueErrorHandling: + """Validate torque-related error handling.""" + + def test_get_blocking_timeout_zero(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.torque.get_blocking(timeout_ms=0) + + def test_set_torques_wrong_length(self, l20_hand: L20): + with pytest.raises(ValueError): + l20_hand.torque.set_torques([50.0] * 15) + + +class TestPollingErrorHandling: + """Validate polling-related error handling.""" + + def test_start_polling_interval_zero(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.start_polling({SensorSource.ANGLE: 0}) + + def test_start_polling_interval_negative(self, l20_hand: L20): + with pytest.raises(ValidationError): + l20_hand.start_polling({SensorSource.ANGLE: -0.1}) diff --git a/tests/hand/l25/test_fault.py b/tests/hand/l20/test_fault.py similarity index 55% rename from tests/hand/l25/test_fault.py rename to tests/hand/l20/test_fault.py index da995be..50a89ca 100644 --- a/tests/hand/l25/test_fault.py +++ b/tests/hand/l20/test_fault.py @@ -1,47 +1,47 @@ -"""Tests for L25 FaultManager with hardware.""" +"""Tests for L20 FaultManager with hardware.""" import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import L25Fault, L25FaultCode +from linkerbot import L20 +from linkerbot.hand.l20 import L20Fault, L20FaultCode -pytestmark = [pytest.mark.l25, pytest.mark.sensor] +pytestmark = [pytest.mark.l20, pytest.mark.sensor] class TestFaultManagerBlocking: """Test FaultManager blocking mode.""" - def test_get_blocking_returns_valid_data(self, l25_hand: L25): - """Blocking read should return FaultData with 16 L25FaultCode values.""" - data = l25_hand.fault.get_blocking(timeout_ms=100) + def test_get_blocking_returns_valid_data(self, l20_hand: L20): + """Blocking read should return FaultData with 16 L20FaultCode values.""" + data = l20_hand.fault.get_blocking(timeout_ms=100) assert data is not None, "get_blocking should return FaultData" - assert isinstance(data.faults, L25Fault), ( - "faults should be an L25Fault instance" + assert isinstance(data.faults, L20Fault), ( + "faults should be an L20Fault instance" ) - assert len(data.faults) == 16, "L25Fault should have exactly 16 joints" + assert len(data.faults) == 16, "L20Fault should have exactly 16 joints" for i in range(16): - assert isinstance(data.faults[i], L25FaultCode), ( - f"Joint {i} should be a L25FaultCode" + assert isinstance(data.faults[i], L20FaultCode), ( + f"Joint {i} should be a L20FaultCode" ) print(f"\n Faults: {[str(f) for f in data.faults.to_list()]}") - def test_no_fault_on_healthy_device(self, l25_hand: L25): - """All 16 joints should report L25FaultCode.NONE on a healthy device.""" - data = l25_hand.fault.get_blocking(timeout_ms=100) + def test_no_fault_on_healthy_device(self, l20_hand: L20): + """All 16 joints should report L20FaultCode.NONE on a healthy device.""" + data = l20_hand.fault.get_blocking(timeout_ms=100) for i in range(16): - assert data.faults[i] == L25FaultCode.NONE, ( + assert data.faults[i] == L20FaultCode.NONE, ( f"Joint {i} should have no fault, got {data.faults[i]}" ) - def test_has_any_fault_returns_false_when_healthy(self, l25_hand: L25): + def test_has_any_fault_returns_false_when_healthy(self, l20_hand: L20): """has_any_fault should return False on a healthy device.""" - data = l25_hand.fault.get_blocking(timeout_ms=100) + data = l20_hand.fault.get_blocking(timeout_ms=100) assert data.faults.has_any_fault() is False, ( "Healthy device should have no faults" @@ -51,9 +51,9 @@ def test_has_any_fault_returns_false_when_healthy(self, l25_hand: L25): class TestFaultManagerOperations: """Test FaultManager operations.""" - def test_fault_code_methods(self, l25_hand: L25): - """L25FaultCode methods should be callable and return correct types.""" - data = l25_hand.fault.get_blocking(timeout_ms=100) + def test_fault_code_methods(self, l20_hand: L20): + """L20FaultCode methods should be callable and return correct types.""" + data = l20_hand.fault.get_blocking(timeout_ms=100) for i, code in enumerate(data.faults.to_list()): result = code.has_fault() @@ -73,14 +73,14 @@ def test_fault_code_methods(self, l25_hand: L25): class TestFaultManagerSnapshot: """Test FaultManager snapshot mode.""" - def test_snapshot_populated_after_read(self, l25_hand: L25): + def test_snapshot_populated_after_read(self, l20_hand: L20): """get_snapshot should return non-None FaultData after a blocking read.""" - l25_hand.fault.get_blocking(timeout_ms=100) + l20_hand.fault.get_blocking(timeout_ms=100) - data = l25_hand.fault.get_snapshot() + data = l20_hand.fault.get_snapshot() assert data is not None, "Snapshot should be populated after blocking read" - assert isinstance(data.faults, L25Fault), "Snapshot faults should be L25Fault" + assert isinstance(data.faults, L20Fault), "Snapshot faults should be L20Fault" assert len(data.faults) == 16, "Snapshot should have 16 joints" assert data.timestamp > 0, "Snapshot timestamp should be positive" assert data.timestamp <= time.time(), ( diff --git a/tests/hand/l25/test_force_sensor.py b/tests/hand/l20/test_force_sensor.py similarity index 83% rename from tests/hand/l25/test_force_sensor.py rename to tests/hand/l20/test_force_sensor.py index 5d30744..2ef6e17 100644 --- a/tests/hand/l25/test_force_sensor.py +++ b/tests/hand/l20/test_force_sensor.py @@ -1,14 +1,14 @@ -"""Tests for L25 ForceSensorManager with hardware.""" +"""Tests for L20 ForceSensorManager with hardware.""" import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource from tests.conftest import InteractiveSession -pytestmark = [pytest.mark.l25, pytest.mark.sensor] +pytestmark = [pytest.mark.l20, pytest.mark.sensor] FINGER_NAMES = ["thumb", "index", "middle", "ring", "pinky"] PRESSURE_THRESHOLD = 20 # mean value above this → pressed; tune to hardware @@ -37,14 +37,14 @@ class TestForceSensorBlocking: """Test ForceSensorManager blocking reads against real hardware.""" - def test_blocking_double_read_and_baseline(self, l25_hand: L25): + def test_blocking_double_read_and_baseline(self, l20_hand: L20): """Open pose, two reads 0.2s apart, verify shape and timestamps.""" # Move to open pose - l25_hand.angle.set_angles(OPEN) + l20_hand.angle.set_angles(OPEN) time.sleep(2.0) # First read - data1 = l25_hand.force_sensor.get_blocking(timeout_ms=2000) + data1 = l20_hand.force_sensor.get_blocking(timeout_ms=2000) assert data1 is not None for name in FINGER_NAMES: @@ -64,7 +64,7 @@ def test_blocking_double_read_and_baseline(self, l25_hand: L25): time.sleep(0.2) # Second read - data2 = l25_hand.force_sensor.get_blocking(timeout_ms=2000) + data2 = l20_hand.force_sensor.get_blocking(timeout_ms=2000) assert data2 is not None # Timestamp must increase per finger @@ -77,20 +77,20 @@ def test_blocking_double_read_and_baseline(self, l25_hand: L25): print("\n Second read timestamps are later — OK") - def test_each_finger_accessible(self, l25_hand: L25): + def test_each_finger_accessible(self, l20_hand: L20): """Each finger should be accessible by name via get_finger().""" for name in FINGER_NAMES: - data = l25_hand.force_sensor.get_finger(name).get_blocking(timeout_ms=1000) + data = l20_hand.force_sensor.get_finger(name).get_blocking(timeout_ms=1000) assert data is not None, f"get_finger({name!r}) returned None" assert data.values.shape == (12, 6), ( f"{name} expected shape (12, 6), got {data.values.shape}" ) - def test_snapshot_populated_after_read(self, l25_hand: L25): + def test_snapshot_populated_after_read(self, l20_hand: L20): """After get_blocking(), get_snapshot() returns non-None with all 5 fingers.""" - l25_hand.force_sensor.get_blocking(timeout_ms=2000) + l20_hand.force_sensor.get_blocking(timeout_ms=2000) - data = l25_hand.force_sensor.get_snapshot() + data = l20_hand.force_sensor.get_snapshot() assert data is not None, "Snapshot should be populated after blocking read" for name in FINGER_NAMES: assert hasattr(data, name), f"Snapshot missing finger: {name}" @@ -101,7 +101,7 @@ class TestForceSensorInteractive: """Interactive per-finger pressure test (thumb → pinky).""" def test_per_finger_pressure( - self, l25_hand: L25, interactive_session: InteractiveSession + self, l20_hand: L20, interactive_session: InteractiveSession ): """Per-finger pressure: thumb→pinky, one session.run(), user confirms all.""" session = interactive_session @@ -109,7 +109,7 @@ def test_per_finger_pressure( for name in FINGER_NAMES: def _read_and_print(n: str = name) -> None: - data = l25_hand.force_sensor.get_finger(n).get_blocking(timeout_ms=2000) + data = l20_hand.force_sensor.get_finger(n).get_blocking(timeout_ms=2000) print(f"\n [{n}] mean={data.values.mean():.2f}") print(f" {data.values}") @@ -137,7 +137,7 @@ def _read_and_print(n: str = name) -> None: class TestForceSensorStreaming: """Streaming auto-detect: poll at 50ms, detect each finger by threshold.""" - def test_streaming_all_fingers_detected(self, l25_hand: L25): + def test_streaming_all_fingers_detected(self, l20_hand: L20): """Prompt user to release fingers, poll 50ms, auto-detect all 5, pass/fail.""" print("\n [Streaming pressure detection]") print(" Please RELEASE all fingers — do NOT press any sensor.") @@ -148,7 +148,7 @@ def test_streaming_all_fingers_detected(self, l25_hand: L25): deadline = time.monotonic() + TIMEOUT_S try: - l25_hand.start_polling({SensorSource.FORCE_SENSOR: 0.05}) + l20_hand.start_polling({SensorSource.FORCE_SENSOR: 0.05}) print( "\n Now press each finger sensor one by one " @@ -158,7 +158,7 @@ def test_streaming_all_fingers_detected(self, l25_hand: L25): while time.monotonic() < deadline: time.sleep(0.05) # match 50ms polling cadence - snapshot = l25_hand.force_sensor.get_snapshot() + snapshot = l20_hand.force_sensor.get_snapshot() if snapshot is None: continue @@ -180,4 +180,4 @@ def test_streaming_all_fingers_detected(self, l25_hand: L25): f"Timeout after {TIMEOUT_S}s. Fingers not detected: {missing}" ) finally: - l25_hand.stop_polling() + l20_hand.stop_polling() diff --git a/tests/hand/l25/test_lifecycle.py b/tests/hand/l20/test_lifecycle.py similarity index 69% rename from tests/hand/l25/test_lifecycle.py rename to tests/hand/l20/test_lifecycle.py index d40951d..0188a70 100644 --- a/tests/hand/l25/test_lifecycle.py +++ b/tests/hand/l20/test_lifecycle.py @@ -1,30 +1,30 @@ -"""Tests for L25 lifecycle management with hardware.""" +"""Tests for L20 lifecycle management with hardware.""" import os from typing import Literal, cast import pytest -from linkerbot import L25 +from linkerbot import L20 -pytestmark = [pytest.mark.l25, pytest.mark.lifecycle] +pytestmark = [pytest.mark.l20, pytest.mark.lifecycle] class TestLifecycle: - """Test L25 instance lifecycle (context manager, close, post-close behavior).""" + """Test L20 instance lifecycle (context manager, close, post-close behavior).""" - def test_context_manager_creates_usable_instance(self, l25_hand: L25): + def test_context_manager_creates_usable_instance(self, l20_hand: L20): """Within a context-managed block the hand should be open and usable.""" - assert not l25_hand.is_closed(), ( + assert not l20_hand.is_closed(), ( "Hand should not be closed inside context manager" ) def test_context_manager_closes_on_exit(self): """After exiting the `with` block the hand should be closed.""" interface = os.environ.get("CAN_INTERFACE", "can0") - side = cast(Literal["left", "right"], os.environ.get("L25_SIDE", "left")) + side = cast(Literal["left", "right"], os.environ.get("L20_SIDE", "left")) - with L25(side=side, interface_name=interface) as hand: + with L20(side=side, interface_name=interface) as hand: assert not hand.is_closed(), "Hand should be open inside context manager" assert hand.is_closed(), "Hand should be closed after exiting context manager" @@ -32,9 +32,9 @@ def test_context_manager_closes_on_exit(self): def test_close_is_idempotent(self): """Calling close() multiple times should not raise.""" interface = os.environ.get("CAN_INTERFACE", "can0") - side = cast(Literal["left", "right"], os.environ.get("L25_SIDE", "left")) + side = cast(Literal["left", "right"], os.environ.get("L20_SIDE", "left")) - with L25(side=side, interface_name=interface) as hand: + with L20(side=side, interface_name=interface) as hand: pass # Close again several times -- none should raise @@ -42,7 +42,7 @@ def test_close_is_idempotent(self): hand.close() hand.close() - def test_operations_after_close_raise(self, closed_hand: L25): + def test_operations_after_close_raise(self, closed_hand: L20): """Operations on a closed hand should raise an exception.""" with pytest.raises(RuntimeError, match="stopped CANMessageDispatcher"): closed_hand.angle.get_blocking() diff --git a/tests/hand/l25/test_polling.py b/tests/hand/l20/test_polling.py similarity index 62% rename from tests/hand/l25/test_polling.py rename to tests/hand/l20/test_polling.py index 8fe5c16..e33f0ca 100644 --- a/tests/hand/l25/test_polling.py +++ b/tests/hand/l20/test_polling.py @@ -1,33 +1,33 @@ -"""Tests for L25 polling with real hardware.""" +"""Tests for L20 polling with real hardware.""" import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource -pytestmark = [pytest.mark.l25, pytest.mark.polling] +pytestmark = [pytest.mark.l20, pytest.mark.polling] class TestPolling: - """Test L25 polling functionality against real hardware.""" + """Test L20 polling functionality against real hardware.""" - def test_start_polling_single_source(self, l25_hand: L25): + def test_start_polling_single_source(self, l20_hand: L20): """Polling a single source should populate its snapshot.""" try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) time.sleep(0.5) - data = l25_hand.angle.get_snapshot() + data = l20_hand.angle.get_snapshot() assert data is not None, "Angle snapshot should not be None after polling" finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - def test_start_polling_multiple_sources(self, l25_hand: L25): + def test_start_polling_multiple_sources(self, l20_hand: L20): """Polling multiple sources should populate all their snapshots.""" try: - l25_hand.start_polling( + l20_hand.start_polling( { SensorSource.ANGLE: 0.1, SensorSource.TORQUE: 0.1, @@ -36,67 +36,67 @@ def test_start_polling_multiple_sources(self, l25_hand: L25): ) time.sleep(1) - angle = l25_hand.angle.get_snapshot() - torque = l25_hand.torque.get_snapshot() - temperature = l25_hand.temperature.get_snapshot() + angle = l20_hand.angle.get_snapshot() + torque = l20_hand.torque.get_snapshot() + temperature = l20_hand.temperature.get_snapshot() assert angle is not None, "Angle snapshot should not be None" assert torque is not None, "Torque snapshot should not be None" assert temperature is not None, "Temperature snapshot should not be None" finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - def test_snapshot_updates_over_time(self, l25_hand: L25): + def test_snapshot_updates_over_time(self, l20_hand: L20): """Snapshot timestamp should advance as polling continues.""" try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) time.sleep(0.3) - snap1 = l25_hand.get_snapshot() + snap1 = l20_hand.get_snapshot() timestamp1 = snap1.timestamp time.sleep(0.3) - snap2 = l25_hand.get_snapshot() + snap2 = l20_hand.get_snapshot() timestamp2 = snap2.timestamp assert timestamp2 > timestamp1, ( f"Second timestamp ({timestamp2}) should be greater than first ({timestamp1})" ) finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - def test_snapshot_frozen_after_stop(self, l25_hand: L25): + def test_snapshot_frozen_after_stop(self, l20_hand: L20): """After stop_polling, snapshot should exist but not advance rapidly.""" try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) time.sleep(0.5) finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - snap = l25_hand.angle.get_snapshot() + snap = l20_hand.angle.get_snapshot() assert snap is not None, "Snapshot should exist after polling stopped" ts1 = snap.timestamp time.sleep(0.3) - snap2 = l25_hand.angle.get_snapshot() + snap2 = l20_hand.angle.get_snapshot() assert snap2 is not None assert snap2.timestamp == ts1, "Snapshot should not update after stop_polling" - def test_stop_polling_clean(self, l25_hand: L25): + def test_stop_polling_clean(self, l20_hand: L20): """stop_polling should be idempotent and not raise errors.""" try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) - l25_hand.stop_polling() - l25_hand.stop_polling() # Second call should not raise + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) + l20_hand.stop_polling() + l20_hand.stop_polling() # Second call should not raise finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - def test_polling_all_sources(self, l25_hand: L25): + def test_polling_all_sources(self, l20_hand: L20): """Polling with all sources explicitly specified should populate all snapshots.""" try: - l25_hand.start_polling( + l20_hand.start_polling( { SensorSource.ANGLE: 0.1, SensorSource.SPEED: 0.1, @@ -108,7 +108,7 @@ def test_polling_all_sources(self, l25_hand: L25): ) time.sleep(1.5) - snapshot = l25_hand.get_snapshot() + snapshot = l20_hand.get_snapshot() assert snapshot.angle is not None, "Angle should not be None" assert snapshot.torque is not None, "Torque should not be None" @@ -116,12 +116,12 @@ def test_polling_all_sources(self, l25_hand: L25): assert snapshot.force_sensor is not None, "Force sensor should not be None" assert snapshot.fault is not None, "Fault should not be None" finally: - l25_hand.stop_polling() + l20_hand.stop_polling() - def test_get_snapshot_reflects_polling_data(self, l25_hand: L25): + def test_get_snapshot_reflects_polling_data(self, l20_hand: L20): """get_snapshot should reflect data from polled sources.""" try: - l25_hand.start_polling( + l20_hand.start_polling( { SensorSource.ANGLE: 0.1, SensorSource.TEMPERATURE: 0.1, @@ -129,9 +129,9 @@ def test_get_snapshot_reflects_polling_data(self, l25_hand: L25): ) time.sleep(1) - snapshot = l25_hand.get_snapshot() + snapshot = l20_hand.get_snapshot() assert snapshot.angle is not None, "Angle should not be None" assert snapshot.temperature is not None, "Temperature should not be None" finally: - l25_hand.stop_polling() + l20_hand.stop_polling() diff --git a/tests/hand/l25/test_speed.py b/tests/hand/l20/test_speed.py similarity index 82% rename from tests/hand/l25/test_speed.py rename to tests/hand/l20/test_speed.py index bab3374..313c2db 100644 --- a/tests/hand/l25/test_speed.py +++ b/tests/hand/l20/test_speed.py @@ -1,15 +1,15 @@ -"""Tests for L25 SpeedManager with hardware.""" +"""Tests for L20 SpeedManager with hardware.""" import threading import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import AngleEvent, L25Speed, SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import AngleEvent, L20Speed, SensorSource from tests.conftest import InteractiveSession -pytestmark = [pytest.mark.l25, pytest.mark.control] +pytestmark = [pytest.mark.l20, pytest.mark.control] TOLERANCE = 3.0 MOTION_TIMEOUT_SEC = 20.0 @@ -55,7 +55,7 @@ def _move_and_time( - hand: L25, + hand: L20, target: list[float], tolerance: float = TOLERANCE, timeout_sec: float = MOTION_TIMEOUT_SEC, @@ -100,14 +100,14 @@ def _move_and_time( class TestSpeedManagerSet: """Test SpeedManager set_speeds method.""" - def test_set_speeds_with_list(self, l25_hand: L25): + def test_set_speeds_with_list(self, l20_hand: L20): """set_speeds should accept a list of floats without error.""" - l25_hand.speed.set_speeds([50.0] * 16) + l20_hand.speed.set_speeds([50.0] * 16) - def test_set_speeds_with_l25_speed(self, l25_hand: L25): - """set_speeds should accept an L25Speed instance without error.""" - l25_hand.speed.set_speeds( - L25Speed( + def test_set_speeds_with_l20_speed(self, l20_hand: L20): + """set_speeds should accept an L20Speed instance without error.""" + l20_hand.speed.set_speeds( + L20Speed( thumb_abd=100.0, thumb_yaw=100.0, thumb_root1=50.0, @@ -127,9 +127,9 @@ def test_set_speeds_with_l25_speed(self, l25_hand: L25): ) ) - def test_set_different_speeds(self, l25_hand: L25): + def test_set_different_speeds(self, l20_hand: L20): """set_speeds should accept different per-motor speeds without error.""" - l25_hand.speed.set_speeds( + l20_hand.speed.set_speeds( [ 20.0, 40.0, @@ -154,17 +154,17 @@ def test_set_different_speeds(self, l25_hand: L25): class TestSpeedManagerBlocking: """Test SpeedManager blocking read.""" - def test_get_blocking_returns_valid_data(self, l25_hand: L25): + def test_get_blocking_returns_valid_data(self, l20_hand: L20): """Blocking read should return 16 speed values.""" - data = l25_hand.speed.get_blocking(timeout_ms=500) + data = l20_hand.speed.get_blocking(timeout_ms=500) assert data is not None assert len(data.speeds) == 16 print(f"\n Speeds: {[f'{s:.1f}' for s in data.speeds.to_list()]}") - def test_get_blocking_has_timestamp(self, l25_hand: L25): + def test_get_blocking_has_timestamp(self, l20_hand: L20): """Speed data should have a valid timestamp.""" - data = l25_hand.speed.get_blocking(timeout_ms=500) + data = l20_hand.speed.get_blocking(timeout_ms=500) assert data.timestamp > 0 assert data.timestamp <= time.time() @@ -173,10 +173,10 @@ def test_get_blocking_has_timestamp(self, l25_hand: L25): class TestSpeedManagerSnapshot: """Test SpeedManager snapshot mode.""" - def test_snapshot_populated_after_read(self, l25_hand: L25): + def test_snapshot_populated_after_read(self, l20_hand: L20): """get_snapshot should return non-None after blocking read.""" - l25_hand.speed.get_blocking(timeout_ms=500) - data = l25_hand.speed.get_snapshot() + l20_hand.speed.get_blocking(timeout_ms=500) + data = l20_hand.speed.get_snapshot() assert data is not None assert len(data.speeds) == 16 @@ -185,13 +185,13 @@ def test_snapshot_populated_after_read(self, l25_hand: L25): class TestSpeedInteractive: """Interactive tests for verifying speed affects movement.""" - def test_speed_levels(self, l25_hand: L25, interactive_session: InteractiveSession): + def test_speed_levels(self, l20_hand: L20, interactive_session: InteractiveSession): """Verify low/mid/high speed visibly affects finger movement speed.""" session = interactive_session motion_results: list[tuple[str, float, bool]] = [] def track(label: str, target: list[float]) -> None: - elapsed, timed_out = _move_and_time(l25_hand, target) + elapsed, timed_out = _move_and_time(l20_hand, target) motion_results.append((label, elapsed, timed_out)) for level, speed_val in [("LOW", 10.0), ("MID", 30.0), ("HIGH", 100.0)]: @@ -199,7 +199,7 @@ def track(label: str, target: list[float]) -> None: session.step( instruction=f"[{level} speed={speed_val}] Closing fingers", action=lambda lbl=f"{level} close", sv=speed_val: ( - l25_hand.speed.set_speeds([sv] * 16), + l20_hand.speed.set_speeds([sv] * 16), track(lbl, CLOSED), ), expected=( @@ -217,7 +217,7 @@ def track(label: str, target: list[float]) -> None: session.run() - _move_and_time(l25_hand, OPEN) + _move_and_time(l20_hand, OPEN) print("\n" + "=" * 52) print(" Motion Time Summary") diff --git a/tests/hand/l25/test_streaming.py b/tests/hand/l20/test_streaming.py similarity index 70% rename from tests/hand/l25/test_streaming.py rename to tests/hand/l20/test_streaming.py index 742dda0..3f3c0f4 100644 --- a/tests/hand/l25/test_streaming.py +++ b/tests/hand/l20/test_streaming.py @@ -1,30 +1,30 @@ -"""Tests for L25 streaming with real hardware.""" +"""Tests for L20 streaming with real hardware.""" import threading import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import ( +from linkerbot import L20 +from linkerbot.hand.l20 import ( AngleEvent, SensorSource, TemperatureEvent, TorqueEvent, ) -pytestmark = [pytest.mark.l25, pytest.mark.streaming] +pytestmark = [pytest.mark.l20, pytest.mark.streaming] class TestStreaming: - """Test L25 streaming functionality against real hardware.""" + """Test L20 streaming functionality against real hardware.""" - def test_stream_receives_events(self, l25_hand: L25): + def test_stream_receives_events(self, l20_hand: L20): """Stream should receive at least one event.""" events = [] try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) - queue = l25_hand.stream() + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) + queue = l20_hand.stream() def collect(): for event in queue: @@ -33,20 +33,20 @@ def collect(): t = threading.Thread(target=collect, daemon=True) t.start() time.sleep(2) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) assert len(events) >= 1, "Should have received at least 1 event" finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stream_event_types_match_sources(self, l25_hand: L25): + def test_stream_event_types_match_sources(self, l20_hand: L20): """When polling only ANGLE, all events should be AngleEvent.""" events = [] try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) - queue = l25_hand.stream() + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) + queue = l20_hand.stream() def collect(): for event in queue: @@ -55,7 +55,7 @@ def collect(): t = threading.Thread(target=collect, daemon=True) t.start() time.sleep(2) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) assert len(events) >= 1, "Should have received at least 1 event" @@ -64,15 +64,15 @@ def collect(): f"Expected AngleEvent, got {type(event).__name__}" ) finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stream_multiple_event_types(self, l25_hand: L25): + def test_stream_multiple_event_types(self, l20_hand: L20): """When polling ANGLE and TORQUE, both event types should appear.""" events: list = [] try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1, SensorSource.TORQUE: 0.1}) - queue = l25_hand.stream() + l20_hand.start_polling({SensorSource.ANGLE: 0.1, SensorSource.TORQUE: 0.1}) + queue = l20_hand.stream() def collect() -> None: for event in queue: @@ -82,7 +82,7 @@ def collect() -> None: t.start() time.sleep(2) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) assert len(events) >= 1, "Should have received at least 1 event" @@ -91,15 +91,15 @@ def collect() -> None: assert has_angle, "Should have received at least one AngleEvent" assert has_torque, "Should have received at least one TorqueEvent" finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stream_event_has_valid_data(self, l25_hand: L25): + def test_stream_event_has_valid_data(self, l20_hand: L20): """AngleEvent data should contain 16 angles in [0, 100].""" events = [] try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) - queue = l25_hand.stream() + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) + queue = l20_hand.stream() def collect(): for event in queue: @@ -108,7 +108,7 @@ def collect(): t = threading.Thread(target=collect, daemon=True) t.start() time.sleep(2) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) angle_events = [e for e in events if isinstance(e, AngleEvent)] @@ -122,23 +122,23 @@ def collect(): for angle in angles: assert 0 <= angle <= 100, f"Angle {angle} out of range [0, 100]" finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stream_stop_idempotent_and_reopen(self, l25_hand: L25): + def test_stream_stop_idempotent_and_reopen(self, l20_hand: L20): """stop_stream is idempotent; re-opening stream should not raise.""" try: - l25_hand.start_polling( + l20_hand.start_polling( {SensorSource.ANGLE: 0.1, SensorSource.TEMPERATURE: 0.1} ) - queue = l25_hand.stream() + queue = l20_hand.stream() assert queue is not None - l25_hand.stop_stream() - l25_hand.stop_stream() # idempotent + l20_hand.stop_stream() + l20_hand.stop_stream() # idempotent # Re-open - queue2 = l25_hand.stream() + queue2 = l20_hand.stream() assert queue2 is not None angle_events: list[AngleEvent] = [] @@ -154,7 +154,7 @@ def collect() -> None: t = threading.Thread(target=collect, daemon=True) t.start() time.sleep(0.5) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) # Verify re-opened stream delivered valid angle data @@ -165,30 +165,30 @@ def collect() -> None: for angle in angles: assert 0 <= angle <= 100, f"Angle {angle} out of range [0, 100]" finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stop_stream_clean(self, l25_hand: L25): + def test_stop_stream_clean(self, l20_hand: L20): """stop_stream should not raise errors.""" try: - l25_hand.start_polling({SensorSource.ANGLE: 0.1}) - l25_hand.stream() - l25_hand.stop_stream() + l20_hand.start_polling({SensorSource.ANGLE: 0.1}) + l20_hand.stream() + l20_hand.stop_stream() finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() - def test_stream_pattern_matching(self, l25_hand: L25): + def test_stream_pattern_matching(self, l20_hand: L20): """Events should be categorizable via match/case.""" events: list = [] try: - l25_hand.start_polling( + l20_hand.start_polling( { SensorSource.ANGLE: 0.1, SensorSource.TEMPERATURE: 0.1, } ) - queue = l25_hand.stream() + queue = l20_hand.stream() def collect() -> None: for event in queue: @@ -197,7 +197,7 @@ def collect() -> None: t = threading.Thread(target=collect, daemon=True) t.start() time.sleep(0.5) - l25_hand.stop_stream() + l20_hand.stop_stream() t.join(timeout=2) angle_count = 0 @@ -214,5 +214,5 @@ def collect() -> None: "Should have matched at least one TemperatureEvent" ) finally: - l25_hand.stop_stream() - l25_hand.stop_polling() + l20_hand.stop_stream() + l20_hand.stop_polling() diff --git a/tests/hand/l25/test_stress.py b/tests/hand/l20/test_stress.py similarity index 94% rename from tests/hand/l25/test_stress.py rename to tests/hand/l20/test_stress.py index ba0146d..cb1b992 100644 --- a/tests/hand/l25/test_stress.py +++ b/tests/hand/l20/test_stress.py @@ -1,4 +1,4 @@ -"""Stress tests: binary search for minimum polling interval on L25.""" +"""Stress tests: binary search for minimum polling interval on L20.""" import random import threading @@ -6,10 +6,10 @@ import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import AngleEvent, ForceSensorEvent, SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import AngleEvent, ForceSensorEvent, SensorSource -pytestmark = [pytest.mark.l25, pytest.mark.stress] +pytestmark = [pytest.mark.l20, pytest.mark.stress] # ── Configurable parameters ─────────────────────────────────────────────────── N_GROUPS = 3 # Number of independent binary search runs per test @@ -32,7 +32,7 @@ def _collect( - hand: L25, + hand: L20, sources: list[SensorSource], interval_sec: float, window_sec: float, @@ -73,7 +73,7 @@ def _passes(results: dict[str, tuple[int, int]]) -> bool: def _binary_search( - hand: L25, + hand: L20, sources: list[SensorSource], search_high_sec: float, ) -> float: @@ -114,7 +114,7 @@ def _binary_search( def _run_groups( - hand: L25, + hand: L20, sources: list[SensorSource], search_high_sec: float, label: str, @@ -143,28 +143,28 @@ def _run_groups( class TestPollingIntervalStress: """Binary search for minimum polling interval without data loss.""" - def test_angle_interval(self, l25_hand: L25): + def test_angle_interval(self, l20_hand: L20): """Find minimum safe interval for ANGLE polling.""" _run_groups( - l25_hand, + l20_hand, [SensorSource.ANGLE], ANGLE_SEARCH_HIGH_SEC, "ANGLE", ) - def test_force_sensor_interval(self, l25_hand: L25): + def test_force_sensor_interval(self, l20_hand: L20): """Find minimum safe interval for FORCE_SENSOR polling.""" _run_groups( - l25_hand, + l20_hand, [SensorSource.FORCE_SENSOR], FS_SEARCH_HIGH_SEC, "FORCE_SENSOR", ) - def test_combined_interval(self, l25_hand: L25): + def test_combined_interval(self, l20_hand: L20): """Find minimum safe interval when polling ANGLE + FORCE_SENSOR simultaneously.""" _run_groups( - l25_hand, + l20_hand, [SensorSource.ANGLE, SensorSource.FORCE_SENSOR], FS_SEARCH_HIGH_SEC, "ANGLE+FORCE_SENSOR", @@ -181,7 +181,7 @@ class TestInterleavedReadDuringPolling: interleaves blocking speed reads to verify no frame collisions. """ - def test_speed_read_during_polling(self, l25_hand: L25): + def test_speed_read_during_polling(self, l20_hand: L20): """Randomly issue speed.get_blocking() while default polling is running. Default polling (ANGLE@60Hz, FORCE_SENSOR@30Hz) is auto-started. @@ -189,7 +189,7 @@ def test_speed_read_during_polling(self, l25_hand: L25): that the majority succeed without timeout, validating that polling and ad-hoc reads can coexist on the CAN bus. """ - hand = l25_hand + hand = l20_hand # Default polling is already active from __init__; restart to be explicit hand.start_polling() diff --git a/tests/hand/l25/test_temperature.py b/tests/hand/l20/test_temperature.py similarity index 71% rename from tests/hand/l25/test_temperature.py rename to tests/hand/l20/test_temperature.py index 0520c9c..5b7e781 100644 --- a/tests/hand/l25/test_temperature.py +++ b/tests/hand/l20/test_temperature.py @@ -1,21 +1,21 @@ -"""Tests for L25 TemperatureManager with hardware.""" +"""Tests for L20 TemperatureManager with hardware.""" import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import SensorSource -pytestmark = [pytest.mark.l25, pytest.mark.sensor] +pytestmark = [pytest.mark.l20, pytest.mark.sensor] class TestTemperatureManagerBlocking: """Test TemperatureManager blocking mode.""" - def test_get_blocking_returns_valid_data(self, l25_hand: L25): + def test_get_blocking_returns_valid_data(self, l20_hand: L20): """Blocking read should return 16 temperature values in reasonable range.""" - data = l25_hand.temperature.get_blocking(timeout_ms=100) + data = l20_hand.temperature.get_blocking(timeout_ms=100) assert data is not None assert len(data.temperatures) == 16 @@ -26,16 +26,16 @@ def test_get_blocking_returns_valid_data(self, l25_hand: L25): print(f"\n Temperatures: {[f'{t:.1f}' for t in data.temperatures.to_list()]}") - def test_get_blocking_has_timestamp(self, l25_hand: L25): + def test_get_blocking_has_timestamp(self, l20_hand: L20): """Temperature data should have a valid timestamp.""" - data = l25_hand.temperature.get_blocking(timeout_ms=100) + data = l20_hand.temperature.get_blocking(timeout_ms=100) assert data.timestamp > 0, "Timestamp should be positive" assert data.timestamp <= time.time(), "Timestamp should not be in the future" - def test_temperature_field_access(self, l25_hand: L25): + def test_temperature_field_access(self, l20_hand: L20): """Should be able to access all 16 temperature fields by name.""" - data = l25_hand.temperature.get_blocking(timeout_ms=100) + data = l20_hand.temperature.get_blocking(timeout_ms=100) temps = data.temperatures joint_names = [ @@ -63,11 +63,11 @@ def test_temperature_field_access(self, l25_hand: L25): class TestTemperatureManagerSnapshot: """Test TemperatureManager snapshot mode.""" - def test_snapshot_populated_after_read(self, l25_hand: L25): + def test_snapshot_populated_after_read(self, l20_hand: L20): """get_snapshot should return non-None after a blocking read.""" - l25_hand.temperature.get_blocking(timeout_ms=100) + l20_hand.temperature.get_blocking(timeout_ms=100) - data = l25_hand.temperature.get_snapshot() + data = l20_hand.temperature.get_snapshot() assert data is not None, "Snapshot should be populated after blocking read" assert len(data.temperatures) == 16 @@ -76,21 +76,21 @@ def test_snapshot_populated_after_read(self, l25_hand: L25): class TestTemperatureExercise: """Test temperature after motion.""" - def test_temperature_after_exercise(self, l25_hand: L25): + def test_temperature_after_exercise(self, l20_hand: L20): """Temperatures should be readable after exercising the hand.""" # Read baseline - baseline = l25_hand.temperature.get_blocking(timeout_ms=100) + baseline = l20_hand.temperature.get_blocking(timeout_ms=100) assert baseline is not None baseline_list = baseline.temperatures.to_list() print(f"\n Baseline temps: {[f'{t:.1f}' for t in baseline_list]}") # Exercise: open/close polling - l25_hand.start_polling({SensorSource.TEMPERATURE: 0.1}) + l20_hand.start_polling({SensorSource.TEMPERATURE: 0.1}) time.sleep(1.0) - l25_hand.stop_polling() + l20_hand.stop_polling() # Read after exercise - after = l25_hand.temperature.get_blocking(timeout_ms=100) + after = l20_hand.temperature.get_blocking(timeout_ms=100) assert after is not None after_list = after.temperatures.to_list() print(f" After exercise: {[f'{t:.1f}' for t in after_list]}") diff --git a/tests/hand/l25/test_torque.py b/tests/hand/l20/test_torque.py similarity index 80% rename from tests/hand/l25/test_torque.py rename to tests/hand/l20/test_torque.py index 247f88a..cc6d854 100644 --- a/tests/hand/l25/test_torque.py +++ b/tests/hand/l20/test_torque.py @@ -1,15 +1,15 @@ -"""Tests for L25 TorqueManager with hardware.""" +"""Tests for L20 TorqueManager with hardware.""" import threading import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import AngleEvent, L25Torque, SensorSource +from linkerbot import L20 +from linkerbot.hand.l20 import AngleEvent, L20Torque, SensorSource from tests.conftest import InteractiveSession -pytestmark = [pytest.mark.l25, pytest.mark.control] +pytestmark = [pytest.mark.l20, pytest.mark.control] TOLERANCE = 3.0 MOTION_TIMEOUT_SEC = 20.0 @@ -55,7 +55,7 @@ def _move_and_time( - hand: L25, + hand: L20, target: list[float], tolerance: float = TOLERANCE, timeout_sec: float = MOTION_TIMEOUT_SEC, @@ -98,35 +98,35 @@ def _move_and_time( class TestTorqueManagerBlocking: """Test TorqueManager blocking read.""" - def test_get_blocking_returns_valid_data(self, l25_hand: L25): + def test_get_blocking_returns_valid_data(self, l20_hand: L20): """Blocking read should return 16 torque values.""" - data = l25_hand.torque.get_blocking(timeout_ms=500) + data = l20_hand.torque.get_blocking(timeout_ms=500) assert data is not None assert len(data.torques) == 16 print(f"\n Torques: {[f'{t:.1f}' for t in data.torques.to_list()]}") - def test_get_blocking_has_timestamp(self, l25_hand: L25): + def test_get_blocking_has_timestamp(self, l20_hand: L20): """Torque data should have a valid timestamp.""" - data = l25_hand.torque.get_blocking(timeout_ms=500) + data = l20_hand.torque.get_blocking(timeout_ms=500) assert data.timestamp > 0 assert data.timestamp <= time.time() - def test_set_torques_with_list(self, l25_hand: L25): + def test_set_torques_with_list(self, l20_hand: L20): """set_torques should accept list[float] without error.""" - l25_hand.torque.set_torques([50.0] * 16) + l20_hand.torque.set_torques([50.0] * 16) - def test_set_torques_with_l25_torque(self, l25_hand: L25): - """set_torques should accept L25Torque instance without error.""" - l25_hand.torque.set_torques(L25Torque.from_list([50.0] * 16)) + def test_set_torques_with_l20_torque(self, l20_hand: L20): + """set_torques should accept L20Torque instance without error.""" + l20_hand.torque.set_torques(L20Torque.from_list([50.0] * 16)) class TestTorqueManagerSnapshot: """Test TorqueManager snapshot mode.""" - def test_snapshot_populated_after_read(self, l25_hand: L25): + def test_snapshot_populated_after_read(self, l20_hand: L20): """get_snapshot should return non-None after blocking read.""" - l25_hand.torque.get_blocking(timeout_ms=500) - data = l25_hand.torque.get_snapshot() + l20_hand.torque.get_blocking(timeout_ms=500) + data = l20_hand.torque.get_snapshot() assert data is not None assert len(data.torques) == 16 @@ -136,17 +136,17 @@ class TestTorqueInteractive: """Interactive tests for verifying torque affects grip strength.""" def test_torque_levels( - self, l25_hand: L25, interactive_session: InteractiveSession + self, l20_hand: L20, interactive_session: InteractiveSession ): """Verify low/mid/high torque visibly affects grip strength.""" session = interactive_session motion_results: list[tuple[str, float, bool]] = [] # Set speed to max for consistent observation - l25_hand.speed.set_speeds([100.0] * 16) + l20_hand.speed.set_speeds([100.0] * 16) def track(label: str, target: list[float]) -> None: - elapsed, timed_out = _move_and_time(l25_hand, target) + elapsed, timed_out = _move_and_time(l20_hand, target) motion_results.append((label, elapsed, timed_out)) for level, torque_val in [("LOW", 10.0), ("MID", 50.0), ("HIGH", 100.0)]: @@ -154,7 +154,7 @@ def track(label: str, target: list[float]) -> None: session.step( instruction=f"[{level} torque={torque_val}] Closing grip", action=lambda lbl=f"{level} close", tv=torque_val: ( - l25_hand.torque.set_torques([tv] * 16), + l20_hand.torque.set_torques([tv] * 16), track(lbl, CLOSED), ), expected=( @@ -172,7 +172,7 @@ def track(label: str, target: list[float]) -> None: session.run() - _move_and_time(l25_hand, OPEN) + _move_and_time(l20_hand, OPEN) print("\n" + "=" * 52) print(" Motion Time Summary") diff --git a/tests/hand/l25/test_validation.py b/tests/hand/l20/test_validation.py similarity index 54% rename from tests/hand/l25/test_validation.py rename to tests/hand/l20/test_validation.py index 5a16368..a236683 100644 --- a/tests/hand/l25/test_validation.py +++ b/tests/hand/l20/test_validation.py @@ -1,200 +1,200 @@ -"""Unit tests for L25 data classes (no hardware required).""" +"""Unit tests for L20 data classes (no hardware required).""" import pytest -from linkerbot.hand.l25 import ( - L25Angle, - L25Fault, - L25FaultCode, - L25Speed, - L25Torque, +from linkerbot.hand.l20 import ( + L20Angle, + L20Fault, + L20FaultCode, + L20Speed, + L20Torque, ) -pytestmark = [pytest.mark.l25, pytest.mark.validation] +pytestmark = [pytest.mark.l20, pytest.mark.validation] JOINT_COUNT = 16 VALID_VALUES = [50.0] * JOINT_COUNT -class TestL25Angle: - """Validate L25Angle construction, boundary, and round-trip.""" +class TestL20Angle: + """Validate L20Angle construction, boundary, and round-trip.""" def test_from_list_wrong_length_short(self): with pytest.raises(ValueError, match="Expected 16"): - L25Angle.from_list([50.0] * 15) + L20Angle.from_list([50.0] * 15) def test_from_list_wrong_length_long(self): with pytest.raises(ValueError, match="Expected 16"): - L25Angle.from_list([50.0] * 17) + L20Angle.from_list([50.0] * 17) def test_from_list_negative_value(self): with pytest.raises(ValueError, match="out of range"): - L25Angle.from_list([-1.0] + [50.0] * 15) + L20Angle.from_list([-1.0] + [50.0] * 15) def test_from_list_over_100(self): with pytest.raises(ValueError, match="out of range"): - L25Angle.from_list([101.0] + [50.0] * 15) + L20Angle.from_list([101.0] + [50.0] * 15) def test_from_list_non_numeric(self): with pytest.raises(ValueError, match="must be float/int"): - L25Angle.from_list( + L20Angle.from_list( ["a"] + [50.0] * 15 # ty: ignore[invalid-argument-type] ) def test_round_trip(self): - angle = L25Angle.from_list(VALID_VALUES) + angle = L20Angle.from_list(VALID_VALUES) assert angle.to_list() == VALID_VALUES def test_getitem(self): - angle = L25Angle.from_list(VALID_VALUES) + angle = L20Angle.from_list(VALID_VALUES) for i in range(JOINT_COUNT): assert angle[i] == VALID_VALUES[i] def test_len(self): - angle = L25Angle.from_list(VALID_VALUES) + angle = L20Angle.from_list(VALID_VALUES) assert len(angle) == JOINT_COUNT def test_getitem_out_of_range(self): - angle = L25Angle.from_list(VALID_VALUES) + angle = L20Angle.from_list(VALID_VALUES) with pytest.raises(IndexError): _ = angle[JOINT_COUNT] def test_boundary_zero(self): - angle = L25Angle.from_list([0.0] * JOINT_COUNT) + angle = L20Angle.from_list([0.0] * JOINT_COUNT) assert angle.to_list() == [0.0] * JOINT_COUNT def test_boundary_one_hundred(self): - angle = L25Angle.from_list([100.0] * JOINT_COUNT) + angle = L20Angle.from_list([100.0] * JOINT_COUNT) assert angle.to_list() == [100.0] * JOINT_COUNT -class TestL25Speed: - """Validate L25Speed construction and boundary.""" +class TestL20Speed: + """Validate L20Speed construction and boundary.""" def test_from_list_wrong_length(self): with pytest.raises(ValueError, match="Expected 16"): - L25Speed.from_list([50.0] * 15) + L20Speed.from_list([50.0] * 15) def test_from_list_negative_value(self): with pytest.raises(ValueError, match="out of range"): - L25Speed.from_list([-1.0] + [50.0] * 15) + L20Speed.from_list([-1.0] + [50.0] * 15) def test_from_list_over_100(self): with pytest.raises(ValueError, match="out of range"): - L25Speed.from_list([101.0] + [50.0] * 15) + L20Speed.from_list([101.0] + [50.0] * 15) def test_from_list_non_numeric(self): with pytest.raises(ValueError, match="must be float/int"): - L25Speed.from_list( + L20Speed.from_list( ["a"] + [50.0] * 15 # ty: ignore[invalid-argument-type] ) def test_round_trip(self): - speed = L25Speed.from_list(VALID_VALUES) + speed = L20Speed.from_list(VALID_VALUES) assert speed.to_list() == VALID_VALUES def test_len(self): - assert len(L25Speed.from_list(VALID_VALUES)) == JOINT_COUNT + assert len(L20Speed.from_list(VALID_VALUES)) == JOINT_COUNT -class TestL25Torque: - """Validate L25Torque construction and boundary.""" +class TestL20Torque: + """Validate L20Torque construction and boundary.""" def test_from_list_wrong_length(self): with pytest.raises(ValueError, match="Expected 16"): - L25Torque.from_list([50.0] * 15) + L20Torque.from_list([50.0] * 15) def test_from_list_negative_value(self): with pytest.raises(ValueError, match="out of range"): - L25Torque.from_list([-1.0] + [50.0] * 15) + L20Torque.from_list([-1.0] + [50.0] * 15) def test_from_list_over_100(self): with pytest.raises(ValueError, match="out of range"): - L25Torque.from_list([101.0] + [50.0] * 15) + L20Torque.from_list([101.0] + [50.0] * 15) def test_from_list_non_numeric(self): with pytest.raises(ValueError, match="must be float/int"): - L25Torque.from_list( + L20Torque.from_list( ["a"] + [50.0] * 15 # ty: ignore[invalid-argument-type] ) def test_round_trip(self): - torque = L25Torque.from_list(VALID_VALUES) + torque = L20Torque.from_list(VALID_VALUES) assert torque.to_list() == VALID_VALUES def test_len(self): - assert len(L25Torque.from_list(VALID_VALUES)) == JOINT_COUNT + assert len(L20Torque.from_list(VALID_VALUES)) == JOINT_COUNT -class TestL25Fault: - """Validate L25Fault construction and L25FaultCode operations.""" +class TestL20Fault: + """Validate L20Fault construction and L20FaultCode operations.""" def test_from_list_wrong_length(self): with pytest.raises(ValueError, match="Expected 16"): - L25Fault.from_list([L25FaultCode.NONE] * 15) + L20Fault.from_list([L20FaultCode.NONE] * 15) def test_round_trip(self): - codes = [L25FaultCode.NONE] * JOINT_COUNT - fault = L25Fault.from_list(codes) + codes = [L20FaultCode.NONE] * JOINT_COUNT + fault = L20Fault.from_list(codes) assert fault.to_list() == codes def test_len(self): - fault = L25Fault.from_list([L25FaultCode.NONE] * JOINT_COUNT) + fault = L20Fault.from_list([L20FaultCode.NONE] * JOINT_COUNT) assert len(fault) == JOINT_COUNT def test_has_any_fault_false(self): - fault = L25Fault.from_list([L25FaultCode.NONE] * JOINT_COUNT) + fault = L20Fault.from_list([L20FaultCode.NONE] * JOINT_COUNT) assert fault.has_any_fault() is False def test_has_any_fault_true(self): - codes = [L25FaultCode.NONE] * JOINT_COUNT - codes[0] = L25FaultCode.MOTOR_OVER_CURRENT - fault = L25Fault.from_list(codes) + codes = [L20FaultCode.NONE] * JOINT_COUNT + codes[0] = L20FaultCode.MOTOR_OVER_CURRENT + fault = L20Fault.from_list(codes) assert fault.has_any_fault() is True def test_getitem(self): - codes = [L25FaultCode.NONE] * JOINT_COUNT - codes[2] = L25FaultCode.OVER_TEMPERATURE - fault = L25Fault.from_list(codes) - assert fault[2] == L25FaultCode.OVER_TEMPERATURE + codes = [L20FaultCode.NONE] * JOINT_COUNT + codes[2] = L20FaultCode.OVER_TEMPERATURE + fault = L20Fault.from_list(codes) + assert fault[2] == L20FaultCode.OVER_TEMPERATURE -class TestL25FaultCode: - """Validate L25FaultCode enum methods.""" +class TestL20FaultCode: + """Validate L20FaultCode enum methods.""" def test_none_has_no_fault(self): - assert L25FaultCode.NONE.has_fault() is False + assert L20FaultCode.NONE.has_fault() is False def test_single_fault_detected(self): - assert L25FaultCode.MOTOR_OVER_CURRENT.has_fault() is True + assert L20FaultCode.MOTOR_OVER_CURRENT.has_fault() is True def test_combined_faults(self): - combined = L25FaultCode.MOTOR_OVER_CURRENT | L25FaultCode.OVER_TEMPERATURE + combined = L20FaultCode.MOTOR_OVER_CURRENT | L20FaultCode.OVER_TEMPERATURE assert combined.has_fault() is True def test_get_fault_names_none(self): - assert L25FaultCode.NONE.get_fault_names() == ["No faults"] + assert L20FaultCode.NONE.get_fault_names() == ["No faults"] def test_get_fault_names_single(self): - names = L25FaultCode.MOTOR_OVER_CURRENT.get_fault_names() + names = L20FaultCode.MOTOR_OVER_CURRENT.get_fault_names() assert "Motor overcurrent" in names def test_get_fault_names_combined(self): - combined = L25FaultCode.MOTOR_OVER_CURRENT | L25FaultCode.OVER_TEMPERATURE + combined = L20FaultCode.MOTOR_OVER_CURRENT | L20FaultCode.OVER_TEMPERATURE names = combined.get_fault_names() assert "Motor overcurrent" in names assert "Overtemperature" in names def test_all_fault_types(self): for code in [ - L25FaultCode.MOTOR_ROTOR_LOCK, - L25FaultCode.MOTOR_OVER_CURRENT, - L25FaultCode.MOTOR_STALL_FAULT, - L25FaultCode.VOLTAGE_ABNORMAL, - L25FaultCode.SELF_CHECK_ABNORMAL, - L25FaultCode.OVER_TEMPERATURE, - L25FaultCode.SOFT_ROTOR_LOCK, - L25FaultCode.MOTOR_COMM_ABNORMAL, + L20FaultCode.MOTOR_ROTOR_LOCK, + L20FaultCode.MOTOR_OVER_CURRENT, + L20FaultCode.MOTOR_STALL_FAULT, + L20FaultCode.VOLTAGE_ABNORMAL, + L20FaultCode.SELF_CHECK_ABNORMAL, + L20FaultCode.OVER_TEMPERATURE, + L20FaultCode.SOFT_ROTOR_LOCK, + L20FaultCode.MOTOR_COMM_ABNORMAL, ]: assert code.has_fault() is True assert len(code.get_fault_names()) == 1 diff --git a/tests/hand/l25/test_version.py b/tests/hand/l20/test_version.py similarity index 79% rename from tests/hand/l25/test_version.py rename to tests/hand/l20/test_version.py index 3529ea5..2d2d0e8 100644 --- a/tests/hand/l25/test_version.py +++ b/tests/hand/l20/test_version.py @@ -1,21 +1,21 @@ -"""Tests for L25 VersionManager with hardware.""" +"""Tests for L20 VersionManager with hardware.""" import time import pytest -from linkerbot import L25 -from linkerbot.hand.l25 import DeviceInfo, Version +from linkerbot import L20 +from linkerbot.hand.l20 import DeviceInfo, Version -pytestmark = [pytest.mark.l25, pytest.mark.lifecycle] +pytestmark = [pytest.mark.l20, pytest.mark.lifecycle] class TestDeviceInfo: """Test reading device information from real hardware.""" - def test_get_device_info_returns_complete(self, l25_hand: L25): + def test_get_device_info_returns_complete(self, l20_hand: L20): """get_device_info should return a DeviceInfo with all fields populated.""" - info = l25_hand.version.get_device_info() + info = l20_hand.version.get_device_info() assert isinstance(info, DeviceInfo), "Expected a DeviceInfo instance" assert info.serial_number is not None, "serial_number should not be None" @@ -30,9 +30,9 @@ def test_get_device_info_returns_complete(self, l25_hand: L25): print(f" Firmware version: {info.firmware_version}") print(f" Mechanical version: {info.mechanical_version}") - def test_serial_number_format(self, l25_hand: L25): + def test_serial_number_format(self, l20_hand: L20): """serial_number should be a non-empty string of reasonable length.""" - info = l25_hand.version.get_device_info() + info = l20_hand.version.get_device_info() assert isinstance(info.serial_number, str), "serial_number should be a string" assert len(info.serial_number) > 0, "serial_number should not be empty" @@ -40,9 +40,9 @@ def test_serial_number_format(self, l25_hand: L25): "serial_number should be less than 100 chars" ) - def test_version_format(self, l25_hand: L25): + def test_version_format(self, l20_hand: L20): """pcb, firmware, and mechanical versions should have non-negative integer fields.""" - info = l25_hand.version.get_device_info() + info = l20_hand.version.get_device_info() for label, version in [ ("pcb_version", info.pcb_version), @@ -57,9 +57,9 @@ def test_version_format(self, l25_hand: L25): assert version.minor >= 0, f"{label}.minor should be non-negative" assert version.patch >= 0, f"{label}.patch should be non-negative" - def test_device_info_has_timestamp(self, l25_hand: L25): + def test_device_info_has_timestamp(self, l20_hand: L20): """timestamp should be a positive value no later than now.""" - info = l25_hand.version.get_device_info() + info = l20_hand.version.get_device_info() assert info.timestamp > 0, "timestamp should be positive" assert info.timestamp <= time.time(), "timestamp should not be in the future" diff --git a/tests/hand/l25/test_error_handling.py b/tests/hand/l25/test_error_handling.py deleted file mode 100644 index faa917a..0000000 --- a/tests/hand/l25/test_error_handling.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Tests for L25 error handling with hardware.""" - -import pytest - -from linkerbot import L25 -from linkerbot.exceptions import ValidationError -from linkerbot.hand.l25 import SensorSource - -pytestmark = [pytest.mark.l25, pytest.mark.error_handling] - - -class TestAngleErrorHandling: - """Validate angle-related error handling.""" - - def test_get_blocking_timeout_zero(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.angle.get_blocking(timeout_ms=0) - - def test_get_blocking_timeout_negative(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.angle.get_blocking(timeout_ms=-1) - - def test_set_angles_empty_list(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.angle.set_angles([]) - - def test_set_angles_too_few(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.angle.set_angles([50.0] * 15) - - def test_set_angles_too_many(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.angle.set_angles([50.0] * 17) - - def test_set_angles_negative_value(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.angle.set_angles([-1.0] + [50.0] * 15) - - def test_set_angles_over_100(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.angle.set_angles([101.0] + [50.0] * 15) - - -class TestSpeedErrorHandling: - """Validate speed-related error handling.""" - - def test_get_blocking_timeout_zero(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.speed.get_blocking(timeout_ms=0) - - def test_set_speeds_wrong_length(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.speed.set_speeds([50.0] * 15) - - -class TestTorqueErrorHandling: - """Validate torque-related error handling.""" - - def test_get_blocking_timeout_zero(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.torque.get_blocking(timeout_ms=0) - - def test_set_torques_wrong_length(self, l25_hand: L25): - with pytest.raises(ValueError): - l25_hand.torque.set_torques([50.0] * 15) - - -class TestPollingErrorHandling: - """Validate polling-related error handling.""" - - def test_start_polling_interval_zero(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.start_polling({SensorSource.ANGLE: 0}) - - def test_start_polling_interval_negative(self, l25_hand: L25): - with pytest.raises(ValidationError): - l25_hand.start_polling({SensorSource.ANGLE: -0.1}) diff --git a/tests/hand/o30/__init__.py b/tests/hand/o30/__init__.py new file mode 100644 index 0000000..f34090a --- /dev/null +++ b/tests/hand/o30/__init__.py @@ -0,0 +1 @@ +"""O30 unit tests.""" diff --git a/tests/hand/o30i/fakes.py b/tests/hand/o30/fakes.py similarity index 97% rename from tests/hand/o30i/fakes.py rename to tests/hand/o30/fakes.py index 6baceee..057bebe 100644 --- a/tests/hand/o30i/fakes.py +++ b/tests/hand/o30/fakes.py @@ -13,8 +13,8 @@ class RecordedWrite: payload: bytes -class FakeO30iDispatcher: - """Synchronous HOP endpoint used by O30i device-level tests.""" +class FakeO30Dispatcher: + """Synchronous HOP endpoint used by O30 device-level tests.""" def __init__( self, diff --git a/tests/hand/o30i/test_joints.py b/tests/hand/o30/test_joints.py similarity index 71% rename from tests/hand/o30i/test_joints.py rename to tests/hand/o30/test_joints.py index 2d53b21..40825ef 100644 --- a/tests/hand/o30i/test_joints.py +++ b/tests/hand/o30/test_joints.py @@ -3,17 +3,17 @@ import pytest from linkerbot.exceptions import ValidationError -from linkerbot.hand.o30i import ( - O30I_DEFAULT_RAW_VALUES, - O30I_JOINT_COUNT, - O30I_JOINT_SPECS, - O30iAngle, +from linkerbot.hand.o30 import ( + O30_DEFAULT_RAW_VALUES, + O30_JOINT_COUNT, + O30_JOINT_SPECS, + O30Angle, ) -pytestmark = pytest.mark.o30i +pytestmark = pytest.mark.o30 -def test_joint_table_exactly_matches_physical_o30i_layout() -> None: +def test_joint_table_exactly_matches_physical_o30_layout() -> None: expected_ids = [ "roll_0", "yaw_0", @@ -59,11 +59,11 @@ def test_joint_table_exactly_matches_physical_o30i_layout() -> None: "小指尖", ] - assert O30I_JOINT_COUNT == 20 - assert len(O30I_JOINT_SPECS) == 20 - assert [spec.id for spec in O30I_JOINT_SPECS] == expected_ids - assert [spec.name for spec in O30I_JOINT_SPECS] == expected_names - assert [spec.group for spec in O30I_JOINT_SPECS] == [ + assert O30_JOINT_COUNT == 20 + assert len(O30_JOINT_SPECS) == 20 + assert [spec.id for spec in O30_JOINT_SPECS] == expected_ids + assert [spec.name for spec in O30_JOINT_SPECS] == expected_names + assert [spec.group for spec in O30_JOINT_SPECS] == [ "侧摆", "横滚", *(["侧摆"] * 4), @@ -71,25 +71,25 @@ def test_joint_table_exactly_matches_physical_o30i_layout() -> None: *(["指根 2"] * 4), *(["指尖"] * 5), ] - assert [spec.wire_slot for spec in O30I_JOINT_SPECS] == [ + assert [spec.wire_slot for spec in O30_JOINT_SPECS] == [ 0, *range(5, 15), *range(16, 25), ] - assert all(spec.minimum == 0 and spec.maximum == 255 for spec in O30I_JOINT_SPECS) + assert all(spec.minimum == 0 and spec.maximum == 255 for spec in O30_JOINT_SPECS) def test_joint_defaults_match_product_definition() -> None: - assert O30I_DEFAULT_RAW_VALUES == (128, *([50] * 5), *([128] * 14)) - defaults = O30iAngle.defaults() - assert defaults.to_raw() == list(O30I_DEFAULT_RAW_VALUES) + assert O30_DEFAULT_RAW_VALUES == (128, *([50] * 5), *([128] * 14)) + defaults = O30Angle.defaults() + assert defaults.to_raw() == list(O30_DEFAULT_RAW_VALUES) assert defaults.to_list() == pytest.approx( - [(255 - value) * 100 / 255 for value in O30I_DEFAULT_RAW_VALUES] + [(255 - value) * 100 / 255 for value in O30_DEFAULT_RAW_VALUES] ) def test_joint_spec_serializes_to_application_json_shape() -> None: - assert O30I_JOINT_SPECS[0].to_dict() == { + assert O30_JOINT_SPECS[0].to_dict() == { "id": "roll_0", "name": "拇指侧摆", "min": 0, @@ -104,7 +104,7 @@ def test_raw_angles_reject_values_outside_u8_contract(value: object) -> None: values: list[object] = [128] * 20 values[7] = value with pytest.raises(ValidationError): - O30iAngle.from_raw(values) # ty: ignore[invalid-argument-type] + O30Angle.from_raw(values) # ty: ignore[invalid-argument-type] @pytest.mark.parametrize( @@ -115,7 +115,7 @@ def test_angle_percentage_uses_hardware_verified_inverse_direction( percentage: float, raw: int, ) -> None: - assert O30iAngle.from_list([percentage] * 20).to_raw() == [raw] * 20 + assert O30Angle.from_list([percentage] * 20).to_raw() == [raw] * 20 @pytest.mark.parametrize( @@ -126,17 +126,17 @@ def test_raw_angle_readback_uses_inverse_percentage_direction( raw: int, percentage: float, ) -> None: - assert O30iAngle.from_raw([raw] * 20).to_list() == pytest.approx([percentage] * 20) + assert O30Angle.from_raw([raw] * 20).to_list() == pytest.approx([percentage] * 20) def test_angle_round_trip_preserves_every_raw_byte() -> None: for raw in range(256): - angle = O30iAngle.from_raw([raw] * 20) + angle = O30Angle.from_raw([raw] * 20) assert angle.to_raw() == [raw] * 20 def test_angle_list_and_index_access_do_not_expose_mutable_state() -> None: - angle = O30iAngle.from_list([0, 50, 100] + [25] * 17) + angle = O30Angle.from_list([0, 50, 100] + [25] * 17) values = angle.to_list() values[0] = 100 @@ -157,4 +157,4 @@ def test_angle_list_and_index_access_do_not_expose_mutable_state() -> None: ) def test_angle_rejects_invalid_vectors(values: list[float], message: str) -> None: with pytest.raises(ValidationError, match=message): - O30iAngle.from_list(values) + O30Angle.from_list(values) diff --git a/tests/hand/o30i/test_managers.py b/tests/hand/o30/test_managers.py similarity index 67% rename from tests/hand/o30i/test_managers.py rename to tests/hand/o30/test_managers.py index ad994b5..a2ad564 100644 --- a/tests/hand/o30i/test_managers.py +++ b/tests/hand/o30/test_managers.py @@ -3,25 +3,25 @@ import pytest from linkerbot.exceptions import ValidationError -from linkerbot.hand.o30i import O30i, protocol -from tests.hand.o30i.fakes import FakeO30iDispatcher +from linkerbot.hand.o30 import O30, protocol +from tests.hand.o30.fakes import FakeO30Dispatcher -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] -_RUNTIME = bytes(range(protocol.O30I_RUNTIME_SLOT_COUNT)) +_RUNTIME = bytes(range(protocol.O30_RUNTIME_SLOT_COUNT)) @pytest.mark.parametrize( ("manager_name", "main_index", "field_name"), [ - ("speed", protocol.O30I_MI_SPEED, "speeds"), - ("acceleration", protocol.O30I_MI_ACCELERATION, "accelerations"), - ("current", protocol.O30I_MI_CURRENT, "currents"), - ("voltage", protocol.O30I_MI_VOLTAGE, "voltages"), - ("torque", protocol.O30I_MI_TORQUE, "torques"), - ("temperature", protocol.O30I_MI_TEMPERATURE, "temperatures"), - ("motion_time", protocol.O30I_MI_MOTION_TIME, "ticks"), - ("fault", protocol.O30I_MI_FAULT, "faults"), + ("speed", protocol.O30_MI_SPEED, "speeds"), + ("acceleration", protocol.O30_MI_ACCELERATION, "accelerations"), + ("current", protocol.O30_MI_CURRENT, "currents"), + ("voltage", protocol.O30_MI_VOLTAGE, "voltages"), + ("torque", protocol.O30_MI_TORQUE, "torques"), + ("temperature", protocol.O30_MI_TEMPERATURE, "temperatures"), + ("motion_time", protocol.O30_MI_MOTION_TIME, "ticks"), + ("fault", protocol.O30_MI_FAULT, "faults"), ], ) def test_runtime_managers_read_full_object_but_return_only_physical_joints( @@ -29,9 +29,9 @@ def test_runtime_managers_read_full_object_but_return_only_physical_joints( main_index: int, field_name: str, ) -> None: - dispatcher = FakeO30iDispatcher({main_index: _RUNTIME}) + dispatcher = FakeO30Dispatcher({main_index: _RUNTIME}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: data = getattr(hand, manager_name).get_blocking() assert getattr(data, field_name) == (0, *range(5, 15), *range(16, 25)) @@ -41,28 +41,28 @@ def test_runtime_managers_read_full_object_but_return_only_physical_joints( def test_angle_reads_actual_and_target_vectors() -> None: actual = bytes(range(36)) target = bytes(reversed(range(36))) - dispatcher = FakeO30iDispatcher( + dispatcher = FakeO30Dispatcher( { - (protocol.O30I_MI_POSITION, False): actual, - (protocol.O30I_MI_POSITION, True): target, + (protocol.O30_MI_POSITION, False): actual, + (protocol.O30_MI_POSITION, True): target, } ) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: actual_data = hand.angle.get_blocking() target_angle = hand.angle.get_target_blocking() - slots = protocol.O30I_JOINT_SLOT_INDICES + slots = protocol.O30_JOINT_SLOT_INDICES assert actual_data.angles.to_raw() == [actual[index] for index in slots] assert target_angle.to_raw() == [target[index] for index in slots] assert dispatcher.sent[0].data == bytes.fromhex("01 00 24") assert dispatcher.sent[1].data == bytes.fromhex("81 00 24") -def test_angle_percentage_write_reverses_o30i_raw_direction() -> None: - dispatcher = FakeO30iDispatcher() +def test_angle_percentage_write_reverses_o30_raw_direction() -> None: + dispatcher = FakeO30Dispatcher() - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.angle.set_angles([5.0] * 20) assert [(write.sub_index, write.payload) for write in dispatcher.writes] == [ @@ -73,9 +73,9 @@ def test_angle_percentage_write_reverses_o30i_raw_direction() -> None: def test_full_runtime_writes_touch_only_physical_joint_slots() -> None: - dispatcher = FakeO30iDispatcher() + dispatcher = FakeO30Dispatcher() - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.angle.set_raw_angles(list(range(20))) hand.speed.set_all(10) hand.acceleration.set_all(20) @@ -91,9 +91,9 @@ def test_full_runtime_writes_touch_only_physical_joint_slots() -> None: def test_position_slice_uses_physical_joint_order() -> None: - dispatcher = FakeO30iDispatcher() + dispatcher = FakeO30Dispatcher() - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.angle.set_raw_slice(10, [0xAA, 0xBB]) with pytest.raises(ValidationError, match="20 physical"): hand.angle.set_raw_slice(19, [0xAA, 0xBB]) @@ -105,18 +105,18 @@ def test_position_slice_uses_physical_joint_order() -> None: def test_sparse_position_uses_measured_finger_major_wire_ids() -> None: - dispatcher = FakeO30iDispatcher() + dispatcher = FakeO30Dispatcher() - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.angle.set_sparse({15: 0x80, 16: 0x80}) assert dispatcher.sent[0].data == bytes.fromhex("30 00 04 04 80 09 80") def test_motion_time_exact_millisecond_conversion() -> None: - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_MOTION_TIME: _RUNTIME}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_MOTION_TIME: _RUNTIME}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.motion_time.set_milliseconds([100] * 20) data = hand.motion_time.get_blocking() with pytest.raises(ValidationError, match="multiple of 10"): @@ -134,9 +134,9 @@ def test_motion_time_exact_millisecond_conversion() -> None: def test_full_i16_read_is_reassembled_and_decoded() -> None: values = tuple(range(-18, 18)) payload = b"".join(value.to_bytes(2, "little", signed=True) for value in values) - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_POSITION_I16: payload}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_POSITION_I16: payload}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: result = hand.angle.get_i16_blocking() assert result == (values[0], *values[5:15], *values[16:25]) @@ -146,9 +146,9 @@ def test_full_i16_read_is_reassembled_and_decoded() -> None: def test_fault_data_preserves_raw_bytes_without_inventing_bit_meanings() -> None: wire_faults = bytearray(36) wire_faults[5] = 3 - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_FAULT: bytes(wire_faults)}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_FAULT: bytes(wire_faults)}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: data = hand.fault.get_blocking() assert data.has_fault(0) is False @@ -160,9 +160,9 @@ def test_fault_data_preserves_raw_bytes_without_inventing_bit_meanings() -> None def test_fault_data_rejects_invalid_physical_joint_index( joint_index: int, ) -> None: - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_FAULT: bytes(36)}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_FAULT: bytes(36)}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: data = hand.fault.get_blocking() with pytest.raises(ValidationError, match="joint_index"): diff --git a/tests/hand/o30i/test_metadata.py b/tests/hand/o30/test_metadata.py similarity index 72% rename from tests/hand/o30i/test_metadata.py rename to tests/hand/o30/test_metadata.py index 5b7a439..40a859a 100644 --- a/tests/hand/o30i/test_metadata.py +++ b/tests/hand/o30/test_metadata.py @@ -3,12 +3,12 @@ import pytest from linkerbot.hand.hand_protocol_v1 import HandProtocolError -from linkerbot.hand.o30i import O30i, protocol -from linkerbot.hand.o30i.sensor import decode_sensor_info -from linkerbot.hand.o30i.version import decode_device_info -from tests.hand.o30i.fakes import FakeO30iDispatcher +from linkerbot.hand.o30 import O30, protocol +from linkerbot.hand.o30.sensor import decode_sensor_info +from linkerbot.hand.o30.version import decode_device_info +from tests.hand.o30.fakes import FakeO30Dispatcher -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] def _put_ascii(buffer: bytearray, start: int, length: int, text: str) -> None: @@ -18,12 +18,12 @@ def _put_ascii(buffer: bytearray, start: int, length: int, text: str) -> None: def _product_info() -> bytes: - data = bytearray(protocol.O30I_PRODUCT_INFO_BYTE_LENGTH) + data = bytearray(protocol.O30_PRODUCT_INFO_BYTE_LENGTH) fields = ( - (0x00, 0x10, "O30i"), + (0x00, 0x10, "O30"), (0x10, 0x10, "12V~24V"), (0x20, 0x19, "MCU-UID-123"), - (0x39, 0x20, "DEVICE-UID-O30I"), + (0x39, 0x20, "DEVICE-UID-O30"), (0x59, 0x08, "HOP"), (0x61, 0x08, "0.0.3"), (0x69, 0x08, "V1.2.0"), @@ -42,12 +42,12 @@ def _product_info() -> bytes: def test_product_info_reads_and_reassembles_all_225_bytes() -> None: - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_PRODUCT_INFO: _product_info()}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_PRODUCT_INFO: _product_info()}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: info = hand.version.get_device_info() - assert info.product_model == "O30i" + assert info.product_model == "O30" assert info.protocol_name == "HOP" assert info.protocol_version == "0.0.3" assert info.application_version == "1.1.0" @@ -62,11 +62,11 @@ def test_product_info_decoder_rejects_truncation() -> None: def test_sensor_info_reports_no_sensor_without_reading_data_channels() -> None: - payload = bytearray(protocol.O30I_SENSOR_INFO_BYTE_LENGTH) + payload = bytearray(protocol.O30_SENSOR_INFO_BYTE_LENGTH) _put_ascii(payload, 0x00, 0x20, "NO_SENSOR") - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_SENSOR_INFO: bytes(payload)}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_SENSOR_INFO: bytes(payload)}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: info = hand.sensor.get_info() assert info.sensor_type == "NO_SENSOR" @@ -77,7 +77,7 @@ def test_sensor_info_reports_no_sensor_without_reading_data_channels() -> None: def test_sensor_info_decodes_shape_and_little_endian_length() -> None: - payload = bytearray(protocol.O30I_SENSOR_INFO_BYTE_LENGTH) + payload = bytearray(protocol.O30_SENSOR_INFO_BYTE_LENGTH) _put_ascii(payload, 0x00, 0x20, "TACTILE") _put_ascii(payload, 0x20, 0x08, "raw") payload[0x2B:0x2D] = (0x1234).to_bytes(2, "little") @@ -95,10 +95,10 @@ def test_sensor_info_decodes_shape_and_little_endian_length() -> None: def test_diagnostics_reads_4f_as_normal_object() -> None: - payload = bytes(range(protocol.O30I_COMMUNICATION_ERROR_BYTE_LENGTH)) - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_COMMUNICATION_ERROR: payload}) + payload = bytes(range(protocol.O30_COMMUNICATION_ERROR_BYTE_LENGTH)) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_COMMUNICATION_ERROR: payload}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: errors = hand.diagnostics.get_communication_errors() assert errors.latest_index == 0 diff --git a/tests/hand/o30i/test_o30i.py b/tests/hand/o30/test_o30.py similarity index 73% rename from tests/hand/o30i/test_o30i.py rename to tests/hand/o30/test_o30.py index 7d6b2e0..2421641 100644 --- a/tests/hand/o30i/test_o30i.py +++ b/tests/hand/o30/test_o30.py @@ -7,23 +7,23 @@ import linkerbot from linkerbot.exceptions import StateError, ValidationError -from linkerbot.hand.o30i import AngleEvent, O30i, SensorSource, protocol -from tests.hand.o30i.fakes import FakeO30iDispatcher +from linkerbot.hand.o30 import O30, AngleEvent, SensorSource, protocol +from tests.hand.o30.fakes import FakeO30Dispatcher -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] -def test_o30i_is_lazily_exported_at_package_and_top_level() -> None: - from linkerbot.hand import O30i as HandO30i +def test_o30_is_lazily_exported_at_package_and_top_level() -> None: + from linkerbot.hand import O30 as HandO30 - assert linkerbot.O30i is O30i - assert HandO30i is O30i + assert linkerbot.O30 is O30 + assert HandO30 is O30 -def test_o30i_defaults_match_measured_standard_can_endpoint() -> None: - dispatcher = FakeO30iDispatcher() +def test_o30_defaults_match_measured_standard_can_endpoint() -> None: + dispatcher = FakeO30Dispatcher() - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: assert hand.request_id == 0x001 assert hand.response_id == 0x401 hand.angle.set_raw_angles([0x80] * 20) @@ -38,10 +38,10 @@ def test_o30i_defaults_match_measured_standard_can_endpoint() -> None: ] -def test_o30i_supports_custom_standard_request_response_ids() -> None: - dispatcher = FakeO30iDispatcher(response_id=0x456) +def test_o30_supports_custom_standard_request_response_ids() -> None: + dispatcher = FakeO30Dispatcher(response_id=0x456) - with O30i( + with O30( request_id=0x123, response_id=0x456, dispatcher=dispatcher, @@ -64,7 +64,7 @@ def test_o30i_supports_custom_standard_request_response_ids() -> None: ({"channel": -1}, "channel must be non-negative"), ], ) -def test_o30i_validates_endpoints_before_opening_backend( +def test_o30_validates_endpoints_before_opening_backend( monkeypatch: pytest.MonkeyPatch, kwargs: dict[str, object], message: str, @@ -76,17 +76,17 @@ def fail_if_opened(**dispatcher_kwargs): opened = True raise AssertionError("backend must not open") - import linkerbot.hand.o30i.o30i as o30i_module + import linkerbot.hand.o30.o30 as o30_module - monkeypatch.setattr(o30i_module, "CANFDMessageDispatcher", fail_if_opened) + monkeypatch.setattr(o30_module, "CANFDMessageDispatcher", fail_if_opened) with pytest.raises(ValidationError, match=message): - O30i(**kwargs) # ty: ignore[invalid-argument-type] + O30(**kwargs) # ty: ignore[invalid-argument-type] assert opened is False def test_socketcan_requires_channel() -> None: with pytest.raises(ValidationError, match="socketcan_channel"): - O30i(interface_type="socketcan") + O30(interface_type="socketcan") def test_socketcan_routes_all_link_configuration( @@ -99,17 +99,17 @@ class StubBackend: def __init__(self, **kwargs: object) -> None: captured_backend.update(kwargs) - class StubDispatcher(FakeO30iDispatcher): + class StubDispatcher(FakeO30Dispatcher): def __init__(self, **kwargs: object) -> None: super().__init__() captured_dispatcher.update(kwargs) - import linkerbot.hand.o30i.o30i as o30i_module + import linkerbot.hand.o30.o30 as o30_module - monkeypatch.setattr(o30i_module, "SocketCANFDBackend", StubBackend) - monkeypatch.setattr(o30i_module, "CANFDMessageDispatcher", StubDispatcher) + monkeypatch.setattr(o30_module, "SocketCANFDBackend", StubBackend) + monkeypatch.setattr(o30_module, "CANFDMessageDispatcher", StubDispatcher) - with O30i( + with O30( interface_type="socketcan", socketcan_channel="can7", bitrate=2_000_000, @@ -133,16 +133,16 @@ def test_ctypes_routes_vendor_adapter_configuration( ) -> None: captured: dict[str, object] = {} - class StubDispatcher(FakeO30iDispatcher): + class StubDispatcher(FakeO30Dispatcher): def __init__(self, **kwargs: object) -> None: super().__init__() captured.update(kwargs) - import linkerbot.hand.o30i.o30i as o30i_module + import linkerbot.hand.o30.o30 as o30_module - monkeypatch.setattr(o30i_module, "CANFDMessageDispatcher", StubDispatcher) + monkeypatch.setattr(o30_module, "CANFDMessageDispatcher", StubDispatcher) - with O30i(device=2, channel=3, library_path="/tmp/libcanbus.so"): + with O30(device=2, channel=3, library_path="/tmp/libcanbus.so"): pass assert captured["device_index"] == 2 @@ -152,8 +152,8 @@ def __init__(self, **kwargs: object) -> None: def test_external_dispatcher_is_unsubscribed_but_not_stopped() -> None: - dispatcher = FakeO30iDispatcher() - hand = O30i(dispatcher=dispatcher) + dispatcher = FakeO30Dispatcher() + hand = O30(dispatcher=dispatcher) assert len(dispatcher.filtered_subscribers) == 1 hand.close() @@ -169,9 +169,9 @@ def test_external_dispatcher_is_unsubscribed_but_not_stopped() -> None: def test_owned_dispatcher_is_stopped_if_protocol_construction_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: - created: list[FakeO30iDispatcher] = [] + created: list[FakeO30Dispatcher] = [] - class StubDispatcher(FakeO30iDispatcher): + class StubDispatcher(FakeO30Dispatcher): def __init__(self, **kwargs: object) -> None: super().__init__() created.append(self) @@ -179,22 +179,22 @@ def __init__(self, **kwargs: object) -> None: def fail_protocol(*args: object, **kwargs: object) -> None: raise RuntimeError("simulated HandProtocol failure") - import linkerbot.hand.o30i.o30i as o30i_module + import linkerbot.hand.o30.o30 as o30_module - monkeypatch.setattr(o30i_module, "CANFDMessageDispatcher", StubDispatcher) - monkeypatch.setattr(o30i_module, "HandProtocolV1", fail_protocol) + monkeypatch.setattr(o30_module, "CANFDMessageDispatcher", StubDispatcher) + monkeypatch.setattr(o30_module, "HandProtocolV1", fail_protocol) with pytest.raises(RuntimeError, match="HandProtocol failure"): - O30i() + O30() assert len(created) == 1 assert created[0].stopped is True def test_snapshot_and_stream_receive_blocking_angle_read() -> None: - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_POSITION: bytes(range(36))}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_POSITION: bytes(range(36))}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: assert hand.get_snapshot().angle is None stream = hand.stream() data = hand.angle.get_blocking() @@ -207,9 +207,9 @@ def test_snapshot_and_stream_receive_blocking_angle_read() -> None: def test_polling_uses_serialized_protocol_reads() -> None: - dispatcher = FakeO30iDispatcher({protocol.O30I_MI_POSITION: bytes(range(36))}) + dispatcher = FakeO30Dispatcher({protocol.O30_MI_POSITION: bytes(range(36))}) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: hand.start_polling({SensorSource.ANGLE: 0.01}) deadline = time.monotonic() + 1 while not dispatcher.sent and time.monotonic() < deadline: @@ -223,13 +223,13 @@ def test_polling_uses_serialized_protocol_reads() -> None: def test_polling_validation_and_partial_start_rollback( monkeypatch: pytest.MonkeyPatch, ) -> None: - dispatcher = FakeO30iDispatcher( + dispatcher = FakeO30Dispatcher( { - protocol.O30I_MI_POSITION: bytes(36), - protocol.O30I_MI_SPEED: bytes(36), + protocol.O30_MI_POSITION: bytes(36), + protocol.O30_MI_SPEED: bytes(36), } ) - with O30i(dispatcher=dispatcher) as hand: + with O30(dispatcher=dispatcher) as hand: with pytest.raises(ValidationError, match="positive"): hand.start_polling({SensorSource.ANGLE: 0}) with pytest.raises(ValidationError, match="finite"): @@ -256,7 +256,7 @@ def start(self) -> None: def test_dunder_del_swallows_close_errors(monkeypatch: pytest.MonkeyPatch) -> None: - hand = O30i(dispatcher=FakeO30iDispatcher()) + hand = O30(dispatcher=FakeO30Dispatcher()) def fail_close() -> None: raise RuntimeError("simulated close failure") diff --git a/tests/hand/o30i/test_protocol.py b/tests/hand/o30/test_protocol.py similarity index 96% rename from tests/hand/o30i/test_protocol.py rename to tests/hand/o30/test_protocol.py index b5a4b24..d3a353b 100644 --- a/tests/hand/o30i/test_protocol.py +++ b/tests/hand/o30/test_protocol.py @@ -4,9 +4,9 @@ from linkerbot.exceptions import ValidationError from linkerbot.hand.hand_protocol_v1 import HandProtocolError -from linkerbot.hand.o30i import protocol +from linkerbot.hand.o30 import protocol -pytestmark = [pytest.mark.o30i, pytest.mark.canfd] +pytestmark = [pytest.mark.o30, pytest.mark.canfd] def test_runtime_read_projects_only_physical_joint_slots() -> None: diff --git a/tests/hand/o30i/__init__.py b/tests/hand/o30i/__init__.py deleted file mode 100644 index 1d167c7..0000000 --- a/tests/hand/o30i/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""O30i unit tests.""" diff --git a/tests/hand/test_atomic_blocking_requests.py b/tests/hand/test_atomic_blocking_requests.py index bd2f3a4..18828e4 100644 --- a/tests/hand/test_atomic_blocking_requests.py +++ b/tests/hand/test_atomic_blocking_requests.py @@ -14,8 +14,8 @@ from linkerbot.exceptions import TimeoutError from linkerbot.hand.l6.angle import AngleManager as L6AngleManager from linkerbot.hand.l6.version import VersionManager as L6VersionManager +from linkerbot.hand.l20.angle import AngleManager as L20AngleManager from linkerbot.hand.l20lite.angle import AngleManager as L20LiteAngleManager -from linkerbot.hand.l25.angle import AngleManager as L25AngleManager from linkerbot.hand.o6.angle import AngleManager as O6AngleManager from linkerbot.hand.o6.fault import FaultManager as O6FaultManager @@ -54,7 +54,7 @@ def _response(request: can.Message, data: list[int]) -> can.Message: (L6AngleManager, {0x01: 6}, 6), (O6AngleManager, {0x01: 6}, 6), (L20LiteAngleManager, {0x01: 6, 0x04: 4}, 10), - (L25AngleManager, {cmd: 6 for cmd in range(0x41, 0x46)}, 16), + (L20AngleManager, {cmd: 6 for cmd in range(0x41, 0x46)}, 16), ), ) def test_classic_angle_managers_capture_synchronous_responses( diff --git a/tests/hand/test_classic_force_polling.py b/tests/hand/test_classic_force_polling.py index 1cdf006..439a9d2 100644 --- a/tests/hand/test_classic_force_polling.py +++ b/tests/hand/test_classic_force_polling.py @@ -9,10 +9,10 @@ import pytest from linkerbot.hand.l6.force_sensor import ForceSensorManager as L6ForceManager +from linkerbot.hand.l20.force_sensor import ForceSensorManager as L20ForceManager from linkerbot.hand.l20lite.force_sensor import ( ForceSensorManager as L20LiteForceManager, ) -from linkerbot.hand.l25.force_sensor import ForceSensorManager as L25ForceManager from linkerbot.hand.o6.force_sensor import ForceSensorManager as O6ForceManager @@ -34,7 +34,7 @@ def send(self, message: can.Message) -> None: (L6ForceManager, 0xC6), (O6ForceManager, 0xA4), (L20LiteForceManager, 0xC6), - (L25ForceManager, 0xC6), + (L20ForceManager, 0xC6), ), ) def test_force_poll_sends_all_fingers_in_order_without_fixed_delay(