litearm-python 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litearm/__init__.py +116 -0
- litearm/__main__.py +3 -0
- litearm/_protocol.py +513 -0
- litearm/_rot.py +245 -0
- litearm/arm.py +2656 -0
- litearm/cart.py +1403 -0
- litearm/diagnostics.py +197 -0
- litearm/errors.py +509 -0
- litearm/log.py +233 -0
- litearm/model.py +253 -0
- litearm/params.py +120 -0
- litearm/payload.py +718 -0
- litearm/py.typed +0 -0
- litearm/state.py +171 -0
- litearm/testing.py +959 -0
- litearm/tool.py +171 -0
- litearm/transport.py +375 -0
- litearm_python-1.2.0.dist-info/METADATA +508 -0
- litearm_python-1.2.0.dist-info/RECORD +23 -0
- litearm_python-1.2.0.dist-info/WHEEL +5 -0
- litearm_python-1.2.0.dist-info/entry_points.txt +2 -0
- litearm_python-1.2.0.dist-info/licenses/LICENSE +202 -0
- litearm_python-1.2.0.dist-info/top_level.txt +1 -0
litearm/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""litearm-python —— LiteArm STM32 直连后端 (薄协议 SDK)。
|
|
2
|
+
|
|
3
|
+
定位: 固件(litearm-stm32, 版本约定 ``Litearm1.5.0``+)已内置 S 曲线/IK/动力学/
|
|
4
|
+
控制律, 本包直连 USB CDC 用高层子集镜像 pylitearm 常用用法, **不改 pylitearm 源码**、
|
|
5
|
+
PC 端不做轨迹/运动学。
|
|
6
|
+
|
|
7
|
+
典型用法::
|
|
8
|
+
|
|
9
|
+
import litearm as pa
|
|
10
|
+
arm = pa.Arm().connect() # 校验固件版本约定
|
|
11
|
+
arm.enable()
|
|
12
|
+
arm.movej([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], speed=0.3)
|
|
13
|
+
tcp = arm.get_tcp().value # ⚠ 2.0 起读一帧的 getter 返回 Msg 信封, 读值要 .value
|
|
14
|
+
q = arm.ik((0.30, 0.0, 0.35, 3.14, 0.0, 0.0))
|
|
15
|
+
arm.move_l((0.30, 0.0, 0.40, 3.14, 0.0, 0.0), speed=0.5) # 笛卡尔直线 (固件规划)
|
|
16
|
+
arm.move_c(tcp, via_pose, goal_pose) # 笛卡尔圆弧 (起点须为实测 TCP)
|
|
17
|
+
arm.move_path([p1, p2, p3], speed=0.5) # 笛卡尔多路点 (尖角)
|
|
18
|
+
arm.disable()
|
|
19
|
+
arm.close()
|
|
20
|
+
|
|
21
|
+
**返回信封** `Msg(value, hz, timestamp)`: 11 个"读一帧"型 getter (`get_state` /
|
|
22
|
+
`get_status_now` / `get_tcp` / `get_ff_vec` / `get_ff_scalar` / `params.get_joint_param` /
|
|
23
|
+
`model.get_body` / `model.get_jm` / `model.status` / `model.get_gravity` /
|
|
24
|
+
`diag.kin_bench`) 返回它 —— `value` 是原返回值, `hz` 是该类帧在本会话里的平均到达频率,
|
|
25
|
+
`timestamp` 是最近一帧的本地时刻。`move_*`/`home` (动作结果) 与纯本地量不包。
|
|
26
|
+
|
|
27
|
+
**笛卡尔运动** 只有固件规划一条路: `move_l` / `move_c` / `move_path` —— 采样/逐点 IK/播放
|
|
28
|
+
全在固件里, PC 只发点、收 `0x4E` 结果帧, 返回 `CartPlan`。
|
|
29
|
+
|
|
30
|
+
⚠ 2.0 起**移除了 PC 侧规划**: 原子包 `cartesian/` 与 `Cartesian` / `CartesianPlanner` /
|
|
31
|
+
`MotionResult` / `arm.cartesian` / `movel` / `movec` 都不再存在, 替代是上面三个入口
|
|
32
|
+
(`move_l` 取代 `movel`、`move_c` 取代 `movec`、`move_path` 收多路点)。
|
|
33
|
+
|
|
34
|
+
⚠ `move_p` (`0x02`) 是**关节空间**点到点, 与上面这些**笛卡尔**路径不是一回事: 它只收
|
|
35
|
+
单个位姿 (多路点是 `move_path`), 末端轨迹不保证 (实测 30mm 的目标末端横摆 17.6mm)。
|
|
36
|
+
|
|
37
|
+
能力边界(刻意不做): 任意关节角 fk(q) —— 固件 `CMD_GET_TCP` 只能算**当前反馈 q** 的
|
|
38
|
+
位姿, 没有"给 q 求位姿"的下行命令, 所以拿不到任意构型的 FK (连带影响: 起点奇异预检
|
|
39
|
+
σmin 算不了); 阻抗控制 —— 高级场景走 pylitearm+server。
|
|
40
|
+
|
|
41
|
+
工具点 (TCP) 偏移是**宿主侧**的一层换算: 固件没有工具坐标系命令, `get_tcp()` 恒为法兰,
|
|
42
|
+
所以 `set_tcp_offset(xyz, rpy)` 只在 SDK 里把 `move_p`/`move_l`/`move_c`/`move_path`/`ik`
|
|
43
|
+
的位姿入参换算成法兰再下发, 并用 `get_tool_tcp()` 提供读回 (见 :mod:`litearm.tool`)。
|
|
44
|
+
换工具时的重量要另设 `set_payload()` —— 两者不是一回事。
|
|
45
|
+
|
|
46
|
+
末端负载辨识 (见 :mod:`litearm.payload`): 夹上负载后用它自己的重力矩反推质量与质心 ——
|
|
47
|
+
几个静态构型上持位采样, 与一份**空载基线 JSON** 逐构型相减, 回归矩阵 `Y_g(q)` 由固件探针
|
|
48
|
+
(`0x39` 收任意 `q`, 配合临时 `set_payload`) 现算 ⇒ 仍然**不需要** PC 侧任何动力学模型。
|
|
49
|
+
产物直接喂 `set_payload()`。
|
|
50
|
+
|
|
51
|
+
依赖边界(刻意保持): 只有 `pyserial`。PC 侧不做规划, 只用**纯 Python** 数学(`math` 模块,
|
|
52
|
+
见 `_rot.py` / `payload.py`)归一化位姿形态、判到位姿态、解 4 参数最小二乘, 不引入
|
|
53
|
+
numpy/pinocchio —— 重型计算本来就在固件的 IK 与 RNEA 里。
|
|
54
|
+
|
|
55
|
+
协议覆盖: 固件 `hal/usb_cmd.h` 里每条**已实现**的下行命令都有对应入口
|
|
56
|
+
(`arm.params.*` 关节级参数 / `arm.log.*` 300Hz 采集 / `arm.diag.kin_bench` 自检);
|
|
57
|
+
覆盖契约见 `_protocol.COMMAND_COVERAGE`, 由 `tests/test_protocol_sync.py` 直接解析
|
|
58
|
+
固件头文件强制。命令不被本机固件支持时抛 `UnsupportedByFirmwareError`。
|
|
59
|
+
|
|
60
|
+
零重力拖动示教见 `Arm.zero_g()` —— 返回上下文管理器, 保活由后台线程自动维持。
|
|
61
|
+
"""
|
|
62
|
+
from litearm.arm import (Arm, FIRMWARE_PREFIX, LicenseInfo, main, MIN_FW, Msg)
|
|
63
|
+
from litearm._rot import as_pose, mat_to_rpy, rpy_to_mat
|
|
64
|
+
from litearm.cart import CartPlan
|
|
65
|
+
from litearm.diagnostics import KinBenchResult, parse_kin_bench
|
|
66
|
+
from litearm.errors import (
|
|
67
|
+
ArmIsInDfuError,
|
|
68
|
+
CartReplyLostError,
|
|
69
|
+
CartesianPlanError,
|
|
70
|
+
CommandRejectedError,
|
|
71
|
+
FirmwareMismatchError,
|
|
72
|
+
ForkedSessionError,
|
|
73
|
+
IKError,
|
|
74
|
+
InvalidCommandError,
|
|
75
|
+
LiteArmError,
|
|
76
|
+
MotionSupersededError,
|
|
77
|
+
MotionTimeoutError,
|
|
78
|
+
MotorFaultError,
|
|
79
|
+
NotConnectedError,
|
|
80
|
+
NotRemoteable,
|
|
81
|
+
NotSupportedOnThisBackend,
|
|
82
|
+
TeleopBusyError,
|
|
83
|
+
TeleopLockedError,
|
|
84
|
+
TransportError,
|
|
85
|
+
UnsupportedByFirmwareError,
|
|
86
|
+
)
|
|
87
|
+
from litearm.log import LogReader, LogSample, parse_samples
|
|
88
|
+
from litearm.model import (MODEL_MASK_WRITTEN, ModelParams, ModelStatus)
|
|
89
|
+
from litearm.params import JointParam, JointParams
|
|
90
|
+
from litearm.payload import Baseline, HoldSample, PayloadResult
|
|
91
|
+
from litearm.state import JointState, RobotState
|
|
92
|
+
from litearm.tool import TcpOffset
|
|
93
|
+
from litearm.transport import find_cdc_port
|
|
94
|
+
|
|
95
|
+
__version__ = "1.2.0"
|
|
96
|
+
|
|
97
|
+
__all__ = [
|
|
98
|
+
"Arm", "Msg", "LicenseInfo", "RobotState", "JointState", "find_cdc_port", "main",
|
|
99
|
+
"MIN_FW", "FIRMWARE_PREFIX",
|
|
100
|
+
"LiteArmError", "NotConnectedError", "ForkedSessionError", "TransportError",
|
|
101
|
+
"FirmwareMismatchError", "InvalidCommandError", "MotorFaultError",
|
|
102
|
+
"MotionTimeoutError", "IKError", "CommandRejectedError",
|
|
103
|
+
"UnsupportedByFirmwareError",
|
|
104
|
+
"MotionSupersededError", "CartReplyLostError", "ArmIsInDfuError",
|
|
105
|
+
"NotRemoteable", "NotSupportedOnThisBackend",
|
|
106
|
+
"TeleopLockedError", "TeleopBusyError",
|
|
107
|
+
"CartPlan",
|
|
108
|
+
"JointParam", "JointParams", "ModelParams", "ModelStatus", "MODEL_MASK_WRITTEN",
|
|
109
|
+
"LogSample", "LogReader", "parse_samples",
|
|
110
|
+
"KinBenchResult", "parse_kin_bench",
|
|
111
|
+
"CartesianPlanError",
|
|
112
|
+
"TcpOffset",
|
|
113
|
+
"Baseline", "HoldSample", "PayloadResult",
|
|
114
|
+
"as_pose", "rpy_to_mat", "mat_to_rpy",
|
|
115
|
+
"__version__",
|
|
116
|
+
]
|
litearm/__main__.py
ADDED
litearm/_protocol.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
"""帧协议层 —— 与 litearm-stm32 固件 / tools/arm_console.py 逐字节一致。
|
|
2
|
+
|
|
3
|
+
帧: SOF(0xA5) CMD(1B) LEN(1B) PAYLOAD(0..255) CRC16_LO CRC16_HI
|
|
4
|
+
CRC16-CCITT-FALSE, 覆盖 [SOF..PAYLOAD]。
|
|
5
|
+
上行 100Hz 状态帧: 6+21N (固件 >=1.5.0, 尾部 joint_fault u16) 或 4+21N (<=1.4.x, 仅兼容解析);
|
|
6
|
+
应答/异步返回穿插。
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import binascii
|
|
11
|
+
import re
|
|
12
|
+
import struct
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
SOF = 0xA5
|
|
16
|
+
|
|
17
|
+
# 下行 CMD (固件 hal/usb_cmd.h)
|
|
18
|
+
CMD_MOVE_J = 0x01
|
|
19
|
+
CMD_MOVE_P = 0x02
|
|
20
|
+
CMD_MOVE_JS = 0x03
|
|
21
|
+
CMD_MOVE_MIT = 0x04
|
|
22
|
+
CMD_MOVE_MIT_ALL = 0x05
|
|
23
|
+
# [段②] 零重力(拖动示教) 进入/退出: payload [u8] 1=进 0=退 -> ACK
|
|
24
|
+
# ⚠ 固件侧自带 watchdog_kick, 重发即保活 —— 上位机需周期重发(建议 <=50ms),
|
|
25
|
+
# 否则 0.1s 后看门狗把模式掐回 fail-soft 持位
|
|
26
|
+
CMD_ZERO_G = 0x06
|
|
27
|
+
#: [SYNC] 同步 PTP movej: 全关节**同时到达**、末端沿**关节空间直线** q(s) = q0 + s·Δq。
|
|
28
|
+
#: 载荷与 `CMD_MOVE_J` **逐字节相同**; 语义差异只在轨迹形状 —— 0x01 每轴一条独立 S 曲线
|
|
29
|
+
#: (先到先停、末端轨迹不可预测), 本命令用**一条**路径标量 s: 0->1 的 S 曲线驱动全轴。
|
|
30
|
+
#: ⚠ 代价: 被最慢的轴拖住, 整体比 0x01 慢 —— 故是**可选模式**, 不是替换。
|
|
31
|
+
#: ⚠ 固件状态帧仍报 mode=MOVE_J, **无法**从状态区分同步/异步。
|
|
32
|
+
CMD_MOVE_J_SYNC = 0x07
|
|
33
|
+
#: [JOINT_FOLLOW] 从臂跟随 —— **伺服环落在固件里**,上位机只喂目标与增益。
|
|
34
|
+
#: 载荷 = q_target[N] f32 + dq_ref[N] f32 + K[N] f32 + B[N] f32 = 16N 字节。
|
|
35
|
+
#: ⚠ 与 `CMD_MOVE_MIT_ALL` 的关键差别: **帧里没有 tau** —— 前馈由固件算
|
|
36
|
+
#: `τ_ff = clamp(G(q) + wall, ±tau_max)`,省掉上位机的 `get_gravity` 往返。
|
|
37
|
+
#: ⚠ 固件状态帧仍报 `mode=MOVE_MIT_ALL`(模式字段是 3 bit 已满, 不新增模式号)。
|
|
38
|
+
CMD_JOINT_FOLLOW = 0x08
|
|
39
|
+
CMD_ENABLE = 0x10
|
|
40
|
+
CMD_DISABLE = 0x11
|
|
41
|
+
CMD_EMERGENCY_STOP = 0x12
|
|
42
|
+
CMD_CLEAR_FAULTS = 0x13
|
|
43
|
+
CMD_RESET = 0x14
|
|
44
|
+
# [DFU] 免探针进 ROM 系统 bootloader (**终端态**操作): 空载荷 -> ACK 表示"已登记",
|
|
45
|
+
# **不表示**"会跳"/"已经跳" (真跳转在 main 线程, 上界 100ms)。
|
|
46
|
+
# 非空载荷 -> ERR{0x15,0x01}; enabled||enable_pending -> ERR{0x15,0x03};
|
|
47
|
+
# ROM 向量表无效 -> ERR{0x15,0x02}。入口 = `Arm.enter_dfu()` (两段式, 见那里)。
|
|
48
|
+
CMD_ENTER_DFU = 0x15
|
|
49
|
+
CMD_SET_MOTION_MODE = 0x20
|
|
50
|
+
CMD_SET_SPEED_PERCENT = 0x21
|
|
51
|
+
CMD_SET_JOINT_PARAM = 0x22
|
|
52
|
+
CMD_SET_JOINT_LIMITS = 0x23
|
|
53
|
+
CMD_GET_JOINT_PARAM = 0x24
|
|
54
|
+
CMD_PARAM_SAVE = 0x25
|
|
55
|
+
# [P1-C] 恢复出厂默认 + 失效 flash (空载荷; 须失能, 否则 ERR{0x36,0x04})
|
|
56
|
+
CMD_PARAM_RESET = 0x36
|
|
57
|
+
# [HOME] 回零位舒展姿 (空载荷): 目标=URDF 零位 0, 固件侧速度写死 0.10;
|
|
58
|
+
# 注释明确「不受当前越软限/贴端限制」(软限位 clamp 作用于目标, 零位在限内)。
|
|
59
|
+
# 未使能 -> ERR{0x2A,0x03}
|
|
60
|
+
CMD_HOME = 0x2A
|
|
61
|
+
CMD_SET_FF_VEC = 0x26 # item(1B)+f32[7]: 1 friction/2 ki/3 i_max/4-6 wall/7 gs/8 is
|
|
62
|
+
# 9-11 摩擦 v2 (fv/fc0/fc1) / 12-14 零重力 (zg_kp/zg_kd/zg_damping)
|
|
63
|
+
# 15 kd_extra
|
|
64
|
+
#
|
|
65
|
+
# [item 12~15 语义 · 固件 段②/段③] 真源 = 固件 litearm.h:189,217-219
|
|
66
|
+
# 12 zg_kp 松手保持的虚拟弹簧刚度 Nm/rad 钳 [0, 500]
|
|
67
|
+
# 13 zg_kd MIT 速度阻尼 (固件会抬到下限以上) 钳 [0, 5]
|
|
68
|
+
# 14 zg_damping 附加速度阻尼 Nm·s/rad, 出厂 0 钳 [0, 50]
|
|
69
|
+
# 15 kd_extra τ 域软件微分阻尼: τ += kd_extra·(dq_d − dq_meas),
|
|
70
|
+
# 治 movej 起步振铃; 出厂 [6,6,6,6,0,0,0] ——
|
|
71
|
+
# **腕部 J5-J7 必须留 0** (J7≈2e-4 kg·m²,
|
|
72
|
+
# kd_extra/J ≈ 4e4 /s 远快于 8ms 环, 延迟阻尼
|
|
73
|
+
# 会反向激发)。 钳 [0, 50], 须 >= 0
|
|
74
|
+
# 12~14 属**零重力拖动示教**参数 (`Arm.zero_g` 会话);
|
|
75
|
+
# 共通: 任一分量 NaN 整组拒绝 (ERR{0x26,0x02}), 幅值超限静默钳制。
|
|
76
|
+
# ⚠ SDK 现状: `arm.FF_VEC_ITEMS` 一直按 **1..15** 收, 故 12~15 用
|
|
77
|
+
# 通用 `set_ff_vec(item, vals)` **能写**、`get_ff_vec(item)` **能读回**;
|
|
78
|
+
# **没有具名入口** (不像 item 7/8 有 `set_gravity_scale()`/
|
|
79
|
+
# `set_inertia_scale()`) —— 是否给具名方法留待后续。
|
|
80
|
+
# ⚠ 固件头 `usb_cmd.h:73-78` 的 0x26 注释**只写到 11**, 是固件侧
|
|
81
|
+
# 注释未随 [段②/段③] 更新 (实现见 params.c:112 守卫 + :147 item15
|
|
82
|
+
# + :159-161 item12-14): 别拿它当能力边界, 也**不要**据此把 SDK
|
|
83
|
+
# 收窄成 1..11。
|
|
84
|
+
# ⚠ `0x28` 的 `item 18 hold_kp_gain` (到位增刚倍数) 与 `kd_extra`
|
|
85
|
+
# 是**两回事**, 别混 (旧注释把两者写在一块, 已拆开)。
|
|
86
|
+
CMD_SET_FF_FLAGS = 0x27 # u32 ff_mask LE
|
|
87
|
+
CMD_SET_FF_SCALAR = 0x28 # item+sub+f32: 1 fric_db/2 margin/3 slew/4 payload_mass/5 payload_com/6 gravity
|
|
88
|
+
# 7 friction_model (0/1/2) / 8 fric_v2_eps
|
|
89
|
+
# 10-17 零重力 (drag_gain/drag_db/drag_kd_margin/vel_thr/
|
|
90
|
+
# engage_sec/engage_kp/engage_kd/wall_fw_kd)
|
|
91
|
+
# 18 hold_kp_gain (§6.2). item 9 保留给 0x2C 的 ff_mask 读回
|
|
92
|
+
CMD_FF_PRESET = 0x31 # u8: 0 全关 / 1 出厂(G+惯量+科氏+摩擦+积分) / 2 全开
|
|
93
|
+
# [readback 0x2B/0x2C] 参数读回 (镜像写口, 纯读)
|
|
94
|
+
CMD_GET_FF_VEC = 0x2B # item(1B) -> RSP_FF_VEC: [0x4B, item, 7×f32]; item 1..15
|
|
95
|
+
CMD_GET_FF_SCALAR = 0x2C # item(1B)+sub(1B) -> RSP_FF_SCALAR: [0x4C, item, sub, f32]
|
|
96
|
+
# item 1..8 + 10..18, **item 9 = ff_mask (只读)**
|
|
97
|
+
# [P1 傅里叶辨识采集] 300Hz 控制拍记录/读回 (固件 hal/log_capture.*)
|
|
98
|
+
CMD_LOG_CTRL = 0x2D # u32 n_ticks LE (0=停/清; 记满自停) -> ACK
|
|
99
|
+
CMD_SET_MODEL_PARAM = 0x30 # body_idx(1B)+f32[10] -> staging (不生效)
|
|
100
|
+
CMD_MODEL_COMMIT = 0x32 # u16 expected_mask LE -> staging 整组原子生效 (须失能)
|
|
101
|
+
CMD_SET_MODEL_JM = 0x33 # f32[7] -> staging 的 jm (原名 CMD_SET_MODEL_PARAM2, 已语义化改名)
|
|
102
|
+
CMD_GET_MODEL_PARAM = 0x34 # body_idx(1B) -> RSP_MODEL_PARAM (读 bank; 也是能力探测口)
|
|
103
|
+
CMD_GET_MODEL_JM = 0x35 # (空) -> RSP_MODEL_JM
|
|
104
|
+
CMD_REVERT_MODEL = 0x37 # (空) -> ACK: 只回退模型, 不动 flash (须失能)
|
|
105
|
+
CMD_GET_MODEL_STATUS = 0x38 # (空) -> RSP_MODEL_STATUS
|
|
106
|
+
CMD_GET_GRAVITY = 0x39 # q[7]f32 -> RSP_GRAVITY: G(q)[7] (纯读)
|
|
107
|
+
CMD_LOG_READ = 0x2E # u32 offset_byte LE -> RSP_LOG_DATA(0x4D)
|
|
108
|
+
# ⚠ 双 ID: 下行 CMD_KIN_BENCH 与上行 RSP_JOINT_PARAM 同为 0x49 (固件 usb_cmd.h)。
|
|
109
|
+
# 收发方向不同, 故靠方向区分, 不可用单张 ID 表查。
|
|
110
|
+
CMD_KIN_BENCH = 0x49 # 运动学开销自测 (DWT cycles) -> RSP_KIN_BENCH(0x4A) 文本
|
|
111
|
+
CMD_GET_STATUS = 0x40
|
|
112
|
+
CMD_GET_FIRMWARE = 0x41
|
|
113
|
+
CMD_GET_IK = 0x42
|
|
114
|
+
CMD_GET_TCP = 0x43
|
|
115
|
+
|
|
116
|
+
# ---- [授权/激活] 固件 `license.c` + `usb_cmd.c` 的 license 分支 (固件 1.8.0+) ----
|
|
117
|
+
# ⚠ **本包只做"查询 + 提交凭据", 不含任何算 MAC 的代码** —— 这是规格的硬要求
|
|
118
|
+
# (`litearm-stm32` 的 activation 设计 §3.3): 持密钥的签发件绝不得进客户侧交付物,
|
|
119
|
+
# 客户侧只要有一份能算 MAC 的代码, 这套机制就归零。故本文件里**没有** SipHash。
|
|
120
|
+
# 签发在厂商侧工具 (`litearm-stm32/tools/litearm_license/sign.py`), 不进本包。
|
|
121
|
+
CMD_GET_LICENSE = 0x2F # 无载荷 -> RSP_LICENSE(0x4F) 26B
|
|
122
|
+
# ⚠ 请求**不校验长度** (固件 `usb_cmd.c:1016` 一行都没碰 `len`) —— 这是**规格层面的有意
|
|
123
|
+
# 放宽**, 不是漏判: 给一条纯只读命令加 `ERR{0x2F,0x01}` 只会多一种没有安全收益的失败模式。
|
|
124
|
+
CMD_ACTIVATE = 0x3F # 28B -> ACK{0x3F} / ERR{0x3F,code}
|
|
125
|
+
# ⚠ 28B = cust_id u32 LE + issued u32 LE + flags u32 LE + mac[16] (两个 SipHash-2-4 标签)。
|
|
126
|
+
# 长度判据是 `len < 28` ⇒ **更长的载荷会被接受**, 尾部多余字节不进 MAC、不进记录、
|
|
127
|
+
# 不回显 (固件 `usb_cmd.h:229-232` 记为已记录的取舍)。
|
|
128
|
+
|
|
129
|
+
# ---- [笛卡尔运控] 固件原生规划 (受固件 `#if LITEARM_CART_PLAN` 编译开关约束) ----
|
|
130
|
+
# 关掉开关时这 5 条**整段不在固件里**, 会落到 `usb_cmd.c` 的 `default` 分支 ->
|
|
131
|
+
# `ERR{cmd,0x00}`。故 SDK 必须**探测**而不是假定 (见 `cart.probe`)。
|
|
132
|
+
# ⚠ `0x3A/0x3B/0x3C/0x3D` 的**长度校验排在门禁与一切副作用之前**
|
|
133
|
+
# (`usb_cmd.c:323` / `:336` / `:352` / `:415`; `cart_gate_ok` 在 `:331` / `:347`,
|
|
134
|
+
# 两条不走门禁的 `ctrl_cart_hold_begin()` 在 `:410` / `:436`), 所以"发空载荷"不会让臂动
|
|
135
|
+
# —— 这**四条**可以当能力探针 (见 `cart.probe`)。
|
|
136
|
+
# ⚠ **`0x3E (CART_RUN)` 是例外**: 它**没有长度校验、空载荷就是它的合法载荷**
|
|
137
|
+
# (`usb_cmd.c:440-445` 直接 `cart_gate_ok(CMD_CART_RUN)` -> `cart_req_run()`)。
|
|
138
|
+
# 而 `cart_gate_ok` 的门禁有**实打实的副作用** (`cart_limits_set` / `cart_q_start_fill` /
|
|
139
|
+
# `ctrl_cart_hold_begin()`, 见 `usb_cmd.c:192-215`), 所以发一条空载荷 `0x3E` 会在
|
|
140
|
+
# `cart_req_run()` 之前**先把副作用执行掉** (作废在途轨迹、置 mode、kick 看门狗)。
|
|
141
|
+
# 此后分两种情形: **臂正处在 `RECV` 且点名已满** -> `cart_req_run()` 返回 OK, 这一条
|
|
142
|
+
# **真的跑起来** (空载荷本就是它的合法载荷); 否则 (不在 `RECV` / 点名不满) 才落
|
|
143
|
+
# `ERR{0x3E,0x04}`。两种情形副作用都已发生 —— 别拿它当探针。
|
|
144
|
+
CMD_MOVE_L = 0x3A # pose[6]f32 + sp f32 (28B): 笛卡尔直线
|
|
145
|
+
CMD_MOVE_C = 0x3B # via[6] + end[6] + sp (52B): 圆弧 (via 只取位置三分量)
|
|
146
|
+
CMD_CART_BEGIN = 0x3C # n u8 + sp f32 (5B, n ∈ [1,32]): 进 RECV 收集态
|
|
147
|
+
CMD_CART_ADD = 0x3D # idx u8 + pose[6] (25B): 逐点追加 (重复 idx 拒 0x04)
|
|
148
|
+
CMD_CART_RUN = 0x3E # 空载荷: 提交并开始规划 (点名不齐 -> 0x04)
|
|
149
|
+
|
|
150
|
+
# 上行
|
|
151
|
+
RSP_STATUS = 0x40
|
|
152
|
+
# ⚠ 死登记 (本轮只加注释,**不删常量**)。两条事实:
|
|
153
|
+
# ① 固件**至今仍定义** `RSP_DETAIL 0x41` (`litearm-stm32/User/litearm/hal/usb_cmd.h:215`,
|
|
154
|
+
# 注释还写着"温度/错误详情 10Hz"), 但**发送方已删** —— `grep -c RSP_DETAIL usb_cmd.c`
|
|
155
|
+
# = 0, 整个 `User/` 只剩那一行 `#define`。故 SDK 侧这个常量**永远不会被收到**。
|
|
156
|
+
# ② 与下行 `CMD_GET_FIRMWARE 0x41` **撞号** —— 同 `0x49` (CMD_KIN_BENCH / RSP_JOINT_PARAM)
|
|
157
|
+
# 那类"双 ID": 收发方向不同, 靠方向区分, 不可用单张 ID 表查。
|
|
158
|
+
#
|
|
159
|
+
# ⚠ **为什么保留而不删**: `test_protocol_sync.py::test_every_firmware_rsp_has_sdk_constant`
|
|
160
|
+
# 按**名字**遍历固件头的 `RSP_*`, 固件头里那一行还在 ⇒ **删 SDK 常量会让段三验收立刻红**;
|
|
161
|
+
# 而且 0x41 属**固件仓**的决定, 本轮是纯 SDK/文档侧 (spec §8)。
|
|
162
|
+
# ⚠ **别说成"没有别的豁免路径"**: 机制上当然可以再往 `FIRMWARE_ONLY_RSPS` 添一项来豁免它;
|
|
163
|
+
# **不这么做是设计选择** —— 那张表的语义是"用户裁决 SDK **有意不暴露**" ("不做", 不是
|
|
164
|
+
# "还没做"), 把一条**没人发**的应答塞进去语义不符 (同理见 `PREEXISTING_GAPS` 的注释)。
|
|
165
|
+
# ⚠ **真要删这个死 ID**: 得先在**固件侧**删掉 `usb_cmd.h:215` 那行 `#define` —— 属另一笔。
|
|
166
|
+
RSP_DETAIL = 0x41
|
|
167
|
+
RSP_FIRMWARE = 0x44
|
|
168
|
+
RSP_ACK = 0x45
|
|
169
|
+
RSP_ERR = 0x46
|
|
170
|
+
RSP_IK = 0x47
|
|
171
|
+
RSP_TCP = 0x48
|
|
172
|
+
RSP_JOINT_PARAM = 0x49
|
|
173
|
+
RSP_KIN_BENCH = 0x4A # CMD_KIN_BENCH(0x49) 应答: 性能自检文本 (含诊断计数)
|
|
174
|
+
RSP_FF_VEC = 0x4B # 应答 CMD_GET_FF_VEC, payload 首字节冗余回填本 id
|
|
175
|
+
RSP_FF_SCALAR = 0x4C # 应答 CMD_GET_FF_SCALAR, 同上
|
|
176
|
+
RSP_LOG_DATA = 0x4D # 应答 CMD_LOG_READ(0x2E): [u32 total][u32 next][u8 n][n bytes]
|
|
177
|
+
#: [笛卡尔] 规划结果: `ok u8 + err u8 + n_wp u16 LE + plan_us u32 LE` = 8B。
|
|
178
|
+
#: ⚠ 载荷里**没有命令 id** —— 只能按**受理顺序** FIFO 配对 (见 `cart.py`)。
|
|
179
|
+
#: ⚠ 规划**失败也必须发这一条** (只回 ACK 的话上位机会以为臂在动, 其实一步没动)。
|
|
180
|
+
RSP_CART_PLAN = 0x4E
|
|
181
|
+
|
|
182
|
+
#: [授权] `CMD_GET_LICENSE(0x2F)` 的应答 —— **26B**:
|
|
183
|
+
#: `[state u8][ver u8][uid 12B 原始寄存器内存序][cust_id u32 LE][issued u32 LE][flags u32 LE]`。
|
|
184
|
+
#: ⚠ **载荷首字节是 `state`(0/1/2), 不是命令号** ⇒ 它**不在** `_read_key` 的回显归属那一类
|
|
185
|
+
#: (那一类只有 `RSP_ACK`/`RSP_ERR`), 队列键是 `(0x4F, None)`, 与 `0x2F` 一一对应。
|
|
186
|
+
#: ⚠ **未激活时 `cust_id`/`issued`/`flags` 全 0**, 但 **UID 照回** —— 签发器**必须**从本应答
|
|
187
|
+
#: 取那 12 字节, 不得经 USB 序列号字符串 (两者不是一个东西)。
|
|
188
|
+
RSP_LICENSE = 0x4F
|
|
189
|
+
#: [授权] **本版无生产者** —— 激活成功回的是 `ACK{0x3F}`(见 `CMD_ACTIVATE`),
|
|
190
|
+
#: 故固件里**没有任何代码会发出** 0x50; 该号只被占住。
|
|
191
|
+
#: ⚠ 保留定义是为了让 `test_protocol_sync` 的按名比对自然通过 —— **不要**给它造生产者,
|
|
192
|
+
#: 也不要塞进 `FIRMWARE_ONLY_RSPS` (那张表的语义是"SDK 有意不暴露", 与"固件没有生产者"不同)。
|
|
193
|
+
RSP_ACTIVATE = 0x50
|
|
194
|
+
|
|
195
|
+
RSP_MODEL_PARAM = 0x54 # [0x54, body_idx, f32[10]] = 42B
|
|
196
|
+
RSP_MODEL_JM = 0x55 # [0x55, f32[7]] = 29B
|
|
197
|
+
RSP_MODEL_STATUS = 0x56 # [0x56, override u8, staged_mask u16 LE, dirty u8] = 5B
|
|
198
|
+
RSP_GRAVITY = 0x57 # [0x57, G(q)[7]] = 29B
|
|
199
|
+
|
|
200
|
+
# ---- [2026-09-14] 动力学模型在线导入 (0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39) ----
|
|
201
|
+
# 固件侧三层语义 (bank/staging/生效层) 与完整注释见 litearm-stm32 的
|
|
202
|
+
# User/litearm/hal/usb_cmd.h; SDK 封装见 litearm/model.py。
|
|
203
|
+
# ⚠ 常量名必须与固件头**逐字相同** —— test_protocol_sync 按名字双向比对。
|
|
204
|
+
|
|
205
|
+
# ff_mask 位 (固件 litearm.h)
|
|
206
|
+
FF_MASTER = 0x0001
|
|
207
|
+
FF_G = 0x0002
|
|
208
|
+
FF_INERTIA = 0x0004
|
|
209
|
+
FF_CORIOLIS = 0x0008
|
|
210
|
+
FF_FRICTION = 0x0010
|
|
211
|
+
FF_INTEGRAL = 0x0020
|
|
212
|
+
FF_WALL = 0x0040
|
|
213
|
+
FF_QUANT = 0x0080
|
|
214
|
+
FF_VELREF = 0x0100
|
|
215
|
+
FF_ALL = 0x01FF
|
|
216
|
+
|
|
217
|
+
#: 固件 IK 的 seed 恒为**模型 7 轴** (`KIN_N`), 与 `LITEARM_NUM_JOINTS` 解耦
|
|
218
|
+
#: (usb_cmd.c CMD_GET_IK: "seed 恒为模型 7 轴 (KIN_N), 与 LITEARM_NUM_JOINTS 解耦")。
|
|
219
|
+
#: 台架 1J 版把台架那台电机映射到模型第 `BENCH_MODEL_AXIS` 轴。
|
|
220
|
+
KIN_N = 7
|
|
221
|
+
#: 台架电机对应的整臂模型轴 (固件 `joint_cfg.h` 的 `LITEARM_BENCH_MODEL_AXIS`, 台架=5)。
|
|
222
|
+
#:
|
|
223
|
+
#: ⚠ **LEGACY —— 固件 2026-09-28 已移除 `LITEARM_BENCH_1J` 台架单电机模式**
|
|
224
|
+
#: (joint_cfg.h 顶部三条理由, 其中 ② 是 flash 布局随开关变却不 bump
|
|
225
|
+
#: `FLASH_STORE_VERSION` ⇒ **静默丢现场标定**; 另有防"再打开"的 `#error` 硬守卫)。
|
|
226
|
+
#: ⇒ 当前固件**不再定义**这个宏, 本常量只服务**仍要支持的 1J 老固件**
|
|
227
|
+
#: (`Litearm1.7.0-1J`, 见 `tests/test_ik_bench.py` / `conftest.offline_arm_1j`)。
|
|
228
|
+
#: **不要当成死代码删掉** —— 删了 1J 会话的 IK 种子就会填错轴。
|
|
229
|
+
#: 跨仓一致性由 tests/test_protocol_sync.py::test_bench_model_axis_matches_sdk_constant
|
|
230
|
+
#: 守: 固件仍有该宏则逐值比对, 固件已移除则断言守卫在 + 本常量仍在。
|
|
231
|
+
BENCH_MODEL_AXIS = 5
|
|
232
|
+
|
|
233
|
+
#: 固件**状态帧频率** (Hz)。`RSP_STATUS` 是被动连续流, 固定 100 Hz (10 ms) ——
|
|
234
|
+
#: 见 `arm._Ack._on_status` 的说明与状态帧布局文档。实测 `get_state().hz` = 99.99。
|
|
235
|
+
#:
|
|
236
|
+
#: ⚠ 用途: 把**速度阈**换算成**每帧位移阈**。`_arrive` 与 `cart._wait_settled` 的
|
|
237
|
+
#: 静止判据现在都读"相邻两帧的位置差"而不是上报的 `dq` (理由见 `Arm._arrive`),
|
|
238
|
+
#: 换算靠本常量。
|
|
239
|
+
STATUS_HZ = 100.0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
MODE_ZERO_G = 7
|
|
243
|
+
MODE_NAMES = {0: "INIT", 1: "MOVE_J", 2: "MOVE_P", 3: "MOVE_JS",
|
|
244
|
+
4: "MOVE_MIT", 5: "MIT_ALL", 6: "EMERGENCY", 7: "ZERO_G"}
|
|
245
|
+
FLAG_NAMES = {0: "FAULT", 1: "WD_TRIPPED", 2: "FB_STALE",
|
|
246
|
+
3: "TEMP_WARN", 4: "POS_VIOL", 5: "OVERSPEED"}
|
|
247
|
+
#: flags bit9 = 使能位 (固件 1.5.0 起; 低 6 位才是安全 flag, 6..8 是 mode)
|
|
248
|
+
FLAG_ENABLED_BIT = 9
|
|
249
|
+
#: joint_fault 位图 (u16) 能表达的关节数上限
|
|
250
|
+
MAX_JOINTS = 16
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
#: 开机签名 (固件 usb_cmd.c 的 sig_ok/sig_iwdg), IWDG 复位多带 ", iwdg-rst"
|
|
254
|
+
_BANNER_RE = re.compile(r"\[litearm-usbcdc\]\s+ready\s*\(([^)]*)\)")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def parse_boot_banner(text: str) -> Optional[str]:
|
|
258
|
+
"""从串口噪声文本里解析开机签名 -> "normal" | "iwdg-rst"; 没有则 None。
|
|
259
|
+
|
|
260
|
+
固件唯一能区分「独立看门狗复位过」的地方 (banner 带 `, iwdg-rst`)。重连/多次
|
|
261
|
+
枚举会重复出现, 取最后一次。
|
|
262
|
+
"""
|
|
263
|
+
hits = _BANNER_RE.findall(text or "")
|
|
264
|
+
if not hits:
|
|
265
|
+
return None
|
|
266
|
+
return "iwdg-rst" if "iwdg-rst" in hits[-1] else "normal"
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
#: 「协议无死角」契约: 固件每条**已实现**的下行命令 -> SDK 上的一等方法入口。
|
|
270
|
+
#: 由 `tests/test_protocol_sync.py` 强制: 直接解析固件 `hal/usb_cmd.h`, 双向比对
|
|
271
|
+
#: 命令集合与 ID, 并逐条断言这里的入口在 `Arm` 上真的可解析到。少一条命令就红。
|
|
272
|
+
#: **请求 → 它的那条 `RSP_*` 应答**(只列"应答不是 `ACK`/`ERR`"的命令)。
|
|
273
|
+
#: ⚠ **名字刻意不叫 `CMD_*`**:`tests/test_protocol_sync.py` 把所有 `CMD_` 前缀的
|
|
274
|
+
#: 模块级名字当成**命令码常量**去和固件头对表,叫 `CMD_TO_RSP` 会被判成
|
|
275
|
+
#: "SDK 声明了固件没有的命令"(实测踩过)。
|
|
276
|
+
#:
|
|
277
|
+
#: 唯一的用途是 `Arm._raw_write` 在**发出前**清掉本命令的应答队列(见那里)——
|
|
278
|
+
#: `ACK`/`ERR` 两条由命令码自己就能推出来,故**不在这张表里**。
|
|
279
|
+
#: ⚠ `0x40` (`GET_STATUS`) 也不在:它的应答是 `RSP_STATUS`,走的是**单槽**不是队列。
|
|
280
|
+
#: ⚠ 与固件的对应关系逐条抄自本文件上面各 `CMD_*` 的注释;改固件时这里要跟着改
|
|
281
|
+
#: (`tests/test_protocol_sync.py` 会盯 `CMD_*`/`RSP_*` 常量本身,这张表**没有**自动判据)。
|
|
282
|
+
RSP_OF_CMD = {
|
|
283
|
+
0x24: 0x49, # GET_JOINT_PARAM -> RSP_JOINT_PARAM
|
|
284
|
+
0x2B: 0x4B, # GET_FF_VEC -> RSP_FF_VEC
|
|
285
|
+
0x2C: 0x4C, # GET_FF_SCALAR -> RSP_FF_SCALAR
|
|
286
|
+
0x2E: 0x4D, # LOG_READ -> RSP_LOG_DATA
|
|
287
|
+
0x34: 0x54, # GET_MODEL_PARAM -> RSP_MODEL_PARAM
|
|
288
|
+
0x35: 0x55, # GET_MODEL_JM -> RSP_MODEL_JM
|
|
289
|
+
0x38: 0x56, # GET_MODEL_STATUS-> RSP_MODEL_STATUS
|
|
290
|
+
0x39: 0x57, # GET_GRAVITY -> RSP_GRAVITY
|
|
291
|
+
0x41: 0x44, # GET_FIRMWARE -> RSP_FIRMWARE
|
|
292
|
+
0x42: 0x47, # GET_IK -> RSP_IK
|
|
293
|
+
0x43: 0x48, # GET_TCP -> RSP_TCP
|
|
294
|
+
0x49: 0x4A, # KIN_BENCH -> RSP_KIN_BENCH
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
COMMAND_COVERAGE = {
|
|
299
|
+
0x01: "Arm.movej",
|
|
300
|
+
0x07: "Arm.movej_sync",
|
|
301
|
+
0x02: "Arm.move_p",
|
|
302
|
+
0x03: "Arm.move_js",
|
|
303
|
+
0x04: "Arm.send_mit",
|
|
304
|
+
0x05: "Arm.send_mit_all",
|
|
305
|
+
0x06: "Arm.zero_g",
|
|
306
|
+
0x08: "Arm.joint_follow",
|
|
307
|
+
0x10: "Arm.enable",
|
|
308
|
+
0x11: "Arm.disable",
|
|
309
|
+
0x12: "Arm.emergency_stop",
|
|
310
|
+
0x13: "Arm.clear_faults",
|
|
311
|
+
0x14: "Arm.reset",
|
|
312
|
+
0x15: "Arm.enter_dfu",
|
|
313
|
+
0x20: "Arm.set_motion_mode",
|
|
314
|
+
0x21: "Arm.set_speed",
|
|
315
|
+
0x22: "Arm.params.set_joint_param",
|
|
316
|
+
0x23: "Arm.params.set_joint_limits",
|
|
317
|
+
0x24: "Arm.params.get_joint_param",
|
|
318
|
+
0x25: "Arm.save_params",
|
|
319
|
+
0x26: "Arm.set_ff_vec",
|
|
320
|
+
0x27: "Arm.set_ff_mask",
|
|
321
|
+
0x28: "Arm.set_ff_scalar",
|
|
322
|
+
0x2A: "Arm.home",
|
|
323
|
+
0x2B: "Arm.get_ff_vec",
|
|
324
|
+
0x2F: "Arm.license",
|
|
325
|
+
0x3F: "Arm.activate",
|
|
326
|
+
0x2C: "Arm.get_ff_scalar",
|
|
327
|
+
0x2D: "Arm.log.start",
|
|
328
|
+
0x2E: "Arm.log.reader",
|
|
329
|
+
0x30: "Arm.model.set_body",
|
|
330
|
+
0x31: "Arm.ff_preset",
|
|
331
|
+
0x32: "Arm.model.commit",
|
|
332
|
+
0x33: "Arm.model.set_jm",
|
|
333
|
+
0x34: "Arm.model.get_body",
|
|
334
|
+
0x35: "Arm.model.get_jm",
|
|
335
|
+
0x37: "Arm.model.revert",
|
|
336
|
+
0x38: "Arm.model.status",
|
|
337
|
+
0x39: "Arm.model.get_gravity",
|
|
338
|
+
0x36: "Arm.params.reset_factory",
|
|
339
|
+
0x40: "Arm.get_status_now",
|
|
340
|
+
0x41: "Arm.connect",
|
|
341
|
+
0x42: "Arm.ik",
|
|
342
|
+
0x43: "Arm.get_tcp",
|
|
343
|
+
0x49: "Arm.diag.kin_bench",
|
|
344
|
+
# [笛卡尔] 0x3C/0x3D/0x3E 是同一条入口的三个阶段 (BEGIN -> ADD×n -> RUN):
|
|
345
|
+
# 三者的帧都由 `Arm.move_path` 发出, 故三条都指向它。
|
|
346
|
+
0x3A: "Arm.move_l",
|
|
347
|
+
0x3B: "Arm.move_c",
|
|
348
|
+
0x3C: "Arm.move_path",
|
|
349
|
+
0x3D: "Arm.move_path",
|
|
350
|
+
0x3E: "Arm.move_path",
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#: 固件注释即「未实现」的命令 —— SDK 有意不提供入口。
|
|
354
|
+
#: ⚠ 不是永久豁免: 同步测试会回头核对固件头文件里这些命令**仍然**标注未实现,
|
|
355
|
+
#: 一旦固件实现它们, 测试立刻失败提醒补入口。
|
|
356
|
+
#:
|
|
357
|
+
#: [2026-09-14] 0x30 / 0x33 已由「动力学模型在线导入」实现 (见 model.py),
|
|
358
|
+
#: 故本表**清空**。留空是正常的 —— 它现在是"当前没有未实现命令"的断言载体。
|
|
359
|
+
UNIMPLEMENTED_CMDS: dict = {}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# [笛卡尔运控] 固件单边命令 —— 显式豁免表(**现已清空**)
|
|
364
|
+
#
|
|
365
|
+
# 历史: 用户 2026-09-13 一度裁决 SDK **不暴露**笛卡尔运控入口(spec §7.1 选项 A),
|
|
366
|
+
# 故 `0x3A~0x3E` / `0x4E` 这 6 条曾是"固件有、SDK 无",在这里显式豁免,
|
|
367
|
+
# 免得 test_protocol_sync.py 因双向比对而长期红着(哨兵红久了就失去意义)。
|
|
368
|
+
#
|
|
369
|
+
# [段二] 该裁决已被"固件原生笛卡尔"取代: 5 条命令与那条应答现在都有 SDK 入口与常量
|
|
370
|
+
# (`COMMAND_COVERAGE` 的 0x3A/0x3B/0x3C/0x3D/0x3E / 本节上方的 `RSP_CART_PLAN`),
|
|
371
|
+
# 于是两张豁免表**同时清空**。留空是有意的 —— 与 `UNIMPLEMENTED_CMDS` 同一个用法:
|
|
372
|
+
# 它现在是"当前没有任何固件单边命令"这句话的断言载体, 而不是可以顺手塞东西的地方。
|
|
373
|
+
#
|
|
374
|
+
# ⚠ 与 `test_protocol_sync.py` 的耦合: `test_firmware_only_exemption_is_still_valid`
|
|
375
|
+
# 逐个断言"豁免表里的名字在固件头文件里仍然存在、且 SDK **还没有**同名常量"。
|
|
376
|
+
# 往这里加条目等于宣布"SDK 有意不做这条命令", 加之前先想清楚是不是真的不做。
|
|
377
|
+
# ⚠ **不要**把"已知缺口"塞进来: 那是 `PREEXISTING_GAPS` 的语义(本应同步而未同步),
|
|
378
|
+
# 两者的理由不同, 混在一起会让读的人以为缺口是有意为之。
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
#: 固件单边下行命令: 码值 -> 名字(SDK 不提供入口)。当前为空。
|
|
382
|
+
FIRMWARE_ONLY_CMDS: dict = {}
|
|
383
|
+
|
|
384
|
+
#: 固件单边上行应答: 码值 -> 名字。当前为空。
|
|
385
|
+
FIRMWARE_ONLY_RSPS: dict = {}
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
# 【既有的固件/SDK 不同步 —— **只豁免, 不修复**】(**现已清空**)
|
|
389
|
+
#
|
|
390
|
+
# 历史: 固件自 `b6cbc33`(DFU 免探针烧录)起有 `CMD_ENTER_DFU = 0x15`, 而 SDK 侧
|
|
391
|
+
# `grep -ri dfu src/ tests/` **命中 0**。当时**豁免而非修复**的理由三条:
|
|
392
|
+
# ① 它不是那一笔的产物(修它等于把另一个特性的决定塞进去);
|
|
393
|
+
# ② 长期红着会让**真正的**回归看不见(哨兵失效, 与 `UNIMPLEMENTED_CMDS` 同一个理由);
|
|
394
|
+
# ③ DFU 是固件**维护**命令(进 ROM bootloader), 不是运动 API, 豁免语义成立。
|
|
395
|
+
#
|
|
396
|
+
# [段三] 该缺口已被**补上**: `0x15` 现在有常量、有入口 (`Arm.enter_dfu`, 两段式:
|
|
397
|
+
# ACK 之后还要**等设备真的消失**), 并登记进 `COMMAND_COVERAGE` —— 于是本表**清空**,
|
|
398
|
+
# 覆盖契约 = **47/47**。留空是有意的, 与 `UNIMPLEMENTED_CMDS` / `FIRMWARE_ONLY_CMDS`
|
|
399
|
+
# 同一个用法: 它现在是"**当前没有任何既有的固件/SDK 不同步**"这句话的断言载体,
|
|
400
|
+
# 而不是可以顺手塞东西的地方(哨兵 `test_firmware_only_exemption_is_still_valid`
|
|
401
|
+
# 对它逐条断言"固件里仍有这条命令、且 SDK **还没有**同名常量" ⇒ 往空表里塞东西前,
|
|
402
|
+
# 先确认那件事真的没做)。
|
|
403
|
+
# ⚠ **两张表的语义不要混**: `FIRMWARE_ONLY_CMDS`/`FIRMWARE_ONLY_RSPS` 的语义是
|
|
404
|
+
# "用户裁决 SDK **有意不暴露**"("不做", 不是"还没做"; 笛卡尔 0x3A~0x3E / 0x4E 曾是
|
|
405
|
+
# 那里的例子, 段二已暴露并补上入口, 见本节上方那段历史说明 —— ⚠ 别按行号找, 它会漂);
|
|
406
|
+
# 而本表的语义自始至终是"**已知缺口**、本应同步而未同步" —— 混进"有意不做"的条目
|
|
407
|
+
# 会让人以为那缺口是有意留的。两张表现在**都是空的**。
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
#: 既有的固件/SDK 不同步(非笛卡尔): 码值 -> 名字。当前为空 (DFU 已在段三补上)。
|
|
411
|
+
PREEXISTING_GAPS: dict = {}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def crc16_ccitt_false(data: bytes) -> int:
|
|
415
|
+
"""**CRC-16/CCITT-FALSE**(poly `0x1021` / init `0xFFFF` / 无末异或)。
|
|
416
|
+
|
|
417
|
+
覆盖 `[SOF..PAYLOAD]`,两字节**低字节在前**(见 `pack_frame`)。
|
|
418
|
+
`binascii.crc_hqx(data, 0xFFFF)` **就是同一个算法**(同 poly、同 init、同无末异或、
|
|
419
|
+
同 MSB-first),它由 C 实现。
|
|
420
|
+
|
|
421
|
+
⚠ **为什么从纯 Python 位循环换成它**(2026-09-22 实测,`.149`):
|
|
422
|
+
|
|
423
|
+
* 纯 Python 那个双重循环(每字节 8 次位运算)**每帧 158B 要 0.595 ms**,
|
|
424
|
+
而它**每一帧都要算**(`pack_frame` 下行 + `unpack_frame` 上行各一次);
|
|
425
|
+
* 那 0.595 ms 占读线程**每帧总成本(1.774 ms)的 34%**;读线程又按固件推的
|
|
426
|
+
**100 Hz** 跑 ⇒ 约 6% 的一个核,是**裸 SDK 空闲 21.4% 里的约 28%**;
|
|
427
|
+
* `crc_hqx` 实测 **232×** 快(68.1 µs → 0.29 µs,同一台机器同一载荷)。
|
|
428
|
+
|
|
429
|
+
⚠ **等价性不是"看算法像"认定的,是测出来的**:3260 个载荷(长度 0..259 全覆盖 +
|
|
430
|
+
随机)与真帧逐一比对 **0 不一致**;并由 `tests/test_protocol_crc.py` **长期钉住** ——
|
|
431
|
+
那里有一份**独立的纯 Python 参考实现当判据**,另有 CRC 目录的**标准检查值**
|
|
432
|
+
(`"123456789"` → `0x29B1`)。**别把那条用例改成拿本函数跟 `crc_hqx` 对拍** ——
|
|
433
|
+
那是自己验自己,判据会失去判别力。
|
|
434
|
+
"""
|
|
435
|
+
return binascii.crc_hqx(data, 0xFFFF)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def pack_frame(cmd: int, payload: bytes = b"") -> bytes:
|
|
439
|
+
if len(payload) > 255:
|
|
440
|
+
raise ValueError("payload >255B")
|
|
441
|
+
body = bytes([SOF, cmd, len(payload)]) + payload
|
|
442
|
+
crc = crc16_ccitt_false(body)
|
|
443
|
+
return body + bytes([crc & 0xFF, crc >> 8])
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def unpack_frame(frame: bytes) -> Optional[Tuple[int, bytes]]:
|
|
447
|
+
"""校验完整帧; 合法返回 (cmd,payload), 否则 None。"""
|
|
448
|
+
if len(frame) < 5 or frame[0] != SOF:
|
|
449
|
+
return None
|
|
450
|
+
ln = frame[2]
|
|
451
|
+
if len(frame) != ln + 5:
|
|
452
|
+
return None
|
|
453
|
+
body = frame[:3 + ln]
|
|
454
|
+
crc = frame[3 + ln] | (frame[4 + ln] << 8)
|
|
455
|
+
if crc16_ccitt_false(body) != crc:
|
|
456
|
+
return None
|
|
457
|
+
return frame[1], frame[3:3 + ln]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def pack_f32s(values) -> bytes:
|
|
461
|
+
return b"".join(struct.pack("<f", float(v)) for v in values)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def unpack_f32s(b: bytes, off: int, count: int) -> List[float]:
|
|
465
|
+
return list(struct.unpack_from("<" + "f" * count, b, off))
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def parse_firmware_version(ver: str) -> Optional[Tuple[int, int, int, str]]:
|
|
469
|
+
"""'Litearm1.5.2-7J' -> (1,5,2,'7J'); 旧 'A1.x-...-USB' 返回 None(不符合新约定)。
|
|
470
|
+
"""
|
|
471
|
+
s = ver.strip()
|
|
472
|
+
if s.startswith("Litearm"):
|
|
473
|
+
s = s[len("Litearm"):]
|
|
474
|
+
elif s.startswith("A"):
|
|
475
|
+
return None
|
|
476
|
+
else:
|
|
477
|
+
return None
|
|
478
|
+
ver_part, _, variant = s.partition("-")
|
|
479
|
+
variant = variant or ""
|
|
480
|
+
parts = ver_part.split(".")
|
|
481
|
+
if len(parts) != 3 or not all(p.isdigit() for p in parts):
|
|
482
|
+
return None
|
|
483
|
+
return int(parts[0]), int(parts[1]), int(parts[2]), variant
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def decode_status(payload: bytes):
|
|
487
|
+
"""状态帧 -> (flags:int, seq:int, mode:int, flag_names:list,
|
|
488
|
+
joints:list[(q,dq,tau,tmos,tcoil,err)], joint_fault:int); 非法返回 None。
|
|
489
|
+
|
|
490
|
+
兼容两种布局 (尾部 u16 joint_fault 为固件 1.5.0 起新增):
|
|
491
|
+
4+21N 固件 <=1.4.x (无 joint_fault, 视为 0)
|
|
492
|
+
6+21N 固件 >=1.5.0 (7J=153B / 1J=27B)
|
|
493
|
+
"""
|
|
494
|
+
if len(payload) < 4:
|
|
495
|
+
return None
|
|
496
|
+
flags = struct.unpack_from("<H", payload, 0)[0]
|
|
497
|
+
seq = struct.unpack_from("<H", payload, 2)[0]
|
|
498
|
+
n = (len(payload) - 4) // 21
|
|
499
|
+
if n not in (1, 7):
|
|
500
|
+
return None
|
|
501
|
+
tail = len(payload) - 4 - n * 21
|
|
502
|
+
if tail not in (0, 2): # 0 = 旧布局, 2 = joint_fault u16
|
|
503
|
+
return None
|
|
504
|
+
joint_fault = (struct.unpack_from("<H", payload, 4 + n * 21)[0]
|
|
505
|
+
if tail == 2 else 0)
|
|
506
|
+
joints = []
|
|
507
|
+
for i in range(n):
|
|
508
|
+
q, dq, tau, tmos, tcoil = struct.unpack_from("<fffff", payload, 4 + i * 21)
|
|
509
|
+
err = payload[4 + i * 21 + 20]
|
|
510
|
+
joints.append((q, dq, tau, tmos, tcoil, err))
|
|
511
|
+
mode = (flags >> 6) & 0x7
|
|
512
|
+
names = [FLAG_NAMES[k] for k in range(6) if flags & (1 << k)]
|
|
513
|
+
return flags, seq, mode, names, joints, joint_fault
|