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/log.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""300Hz 控制拍采集 —— 固件 `CMD_LOG_CTRL(0x2D)` / `CMD_LOG_READ(0x2E)` 的封装。
|
|
2
|
+
|
|
3
|
+
固件侧 (`hal/log_capture.h`): 跑激励轨迹时把每控制拍 (tick / 执行参考 q_ref /
|
|
4
|
+
执行速度 dq / 电机实测力矩 tau) 追加进 RAM 缓冲, `LOG_MAX_SAMPLES=2400` (≈8s@300Hz)
|
|
5
|
+
记满自停; 上位机用 `LOG_READ` 按字节游标分块读回 —— 这条"绕开连续 USB 流、按游标
|
|
6
|
+
续读"的设计正是为规避 CDC 间歇掉帧而做的, 所以本模块的重试/续读语义是**必须**的,
|
|
7
|
+
不是可选优化。
|
|
8
|
+
|
|
9
|
+
样本布局 (固件 `log_sample_t`): `u32 tick + q_ref[N] + dq[N] + tau[N]`, 即 `4 + 12N` 字节。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING, Iterator, List, Optional
|
|
17
|
+
|
|
18
|
+
from litearm import _protocol as P
|
|
19
|
+
from litearm.errors import (
|
|
20
|
+
InvalidCommandError,
|
|
21
|
+
MotionTimeoutError,
|
|
22
|
+
TransportError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING: # 避免与 arm 循环导入
|
|
26
|
+
from litearm.arm import Arm
|
|
27
|
+
|
|
28
|
+
__all__ = ["LogSample", "LogReader", "ArmLog", "sample_size"]
|
|
29
|
+
|
|
30
|
+
#: 固件 `LOG_MAX_SAMPLES` (hal/log_capture.h) —— 超过会回 ERR{0x2D,0x02}
|
|
31
|
+
LOG_MAX_SAMPLES = 2400
|
|
32
|
+
#: 固件单块读回上限 `LOG_READ_CHUNK` (usb_cmd.c) —— 9B 头 + n ≤ 245
|
|
33
|
+
LOG_READ_CHUNK = 245
|
|
34
|
+
#: 单块 RSP_LOG_DATA 的头长度: u32 total + u32 next + u8 n
|
|
35
|
+
_LOG_HDR = 9
|
|
36
|
+
#: 固件控制循环频率 (`litearm.h` 的 `LITEARM_CTRL_HZ`) —— 采集是**逐拍**记录的,
|
|
37
|
+
#: 故 n 拍需要 n/HZ 秒才录满。用于推算默认等待预算。
|
|
38
|
+
CTRL_HZ = 300
|
|
39
|
+
#: 轮询已记录量的间隔
|
|
40
|
+
_POLL_S = 0.05
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sample_size(n_joints: int) -> int:
|
|
44
|
+
"""单拍字节数 = `sizeof(u32) + 3 * N * sizeof(f32)`。"""
|
|
45
|
+
return 4 + 12 * int(n_joints)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class LogSample:
|
|
50
|
+
"""一拍采样 (固件 `log_sample_t`)。`q_ref`/`dq`/`tau` 为 N 元组。"""
|
|
51
|
+
|
|
52
|
+
tick: int
|
|
53
|
+
q_ref: tuple
|
|
54
|
+
dq: tuple
|
|
55
|
+
tau: tuple
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_samples(blob: bytes, n_joints: int) -> List[LogSample]:
|
|
59
|
+
"""把原始字节流解析成样本列表; 不是样本整数倍则报错。"""
|
|
60
|
+
sz = sample_size(n_joints)
|
|
61
|
+
if sz <= 0:
|
|
62
|
+
raise InvalidCommandError(f"关节数非法: {n_joints}")
|
|
63
|
+
if len(blob) % sz:
|
|
64
|
+
raise TransportError(
|
|
65
|
+
f"采集字节数 {len(blob)} 不是单拍 {sz}B 的整数倍 —— 数据截断/错位, "
|
|
66
|
+
f"拒绝解析出半截样本")
|
|
67
|
+
out = []
|
|
68
|
+
for off in range(0, len(blob), sz):
|
|
69
|
+
tick = struct.unpack_from("<I", blob, off)[0]
|
|
70
|
+
q_ref = tuple(P.unpack_f32s(blob, off + 4, n_joints))
|
|
71
|
+
dq = tuple(P.unpack_f32s(blob, off + 4 + 4 * n_joints, n_joints))
|
|
72
|
+
tau = tuple(P.unpack_f32s(blob, off + 4 + 8 * n_joints, n_joints))
|
|
73
|
+
out.append(LogSample(tick, q_ref, dq, tau))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class LogReader:
|
|
78
|
+
"""按固件游标 (`next_byte`) 分块读回采集缓冲。
|
|
79
|
+
|
|
80
|
+
`next_byte == 0` 表示读完; 掉帧 (应答整块丢失) 时按当前游标重试, 不会丢一段数据。
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(self, arm: "Arm", timeout: float = 1.0, retries: int = 3):
|
|
84
|
+
self._arm = arm
|
|
85
|
+
self.timeout = float(timeout)
|
|
86
|
+
self.retries = int(retries)
|
|
87
|
+
#: 最近一次读回的固件总字节数 (读完后可查)
|
|
88
|
+
self.total_bytes = 0
|
|
89
|
+
self._next = 0
|
|
90
|
+
|
|
91
|
+
def _read_chunk(self, offset: int) -> bytes:
|
|
92
|
+
last: Optional[Exception] = None
|
|
93
|
+
for _ in range(self.retries + 1):
|
|
94
|
+
try:
|
|
95
|
+
self._arm._write_query(P.CMD_LOG_READ, struct.pack("<I", offset))
|
|
96
|
+
_, p = self._arm._require().expect(
|
|
97
|
+
P.RSP_LOG_DATA, self.timeout, f"log_read@{offset}",
|
|
98
|
+
echo_cmd=P.CMD_LOG_READ)
|
|
99
|
+
except MotionTimeoutError as e: # 掉帧: 同一游标重试
|
|
100
|
+
last = e
|
|
101
|
+
continue
|
|
102
|
+
if len(p) < _LOG_HDR:
|
|
103
|
+
raise TransportError(f"RSP_LOG_DATA 帧短 ({len(p)}B)")
|
|
104
|
+
total, nxt, n = struct.unpack_from("<IIB", p, 0)
|
|
105
|
+
if n > LOG_READ_CHUNK or len(p) < _LOG_HDR + n:
|
|
106
|
+
raise TransportError(f"RSP_LOG_DATA 载荷异常: n={n}, 实到 {len(p) - _LOG_HDR}B")
|
|
107
|
+
self.total_bytes = int(total)
|
|
108
|
+
self._next = int(nxt)
|
|
109
|
+
return bytes(p[_LOG_HDR:_LOG_HDR + n])
|
|
110
|
+
raise last if last is not None else TransportError("log_read 失败")
|
|
111
|
+
|
|
112
|
+
def total(self) -> int:
|
|
113
|
+
"""查询固件当前**已记录**的字节数 (一次 `LOG_READ` 往返, 不保存数据)。
|
|
114
|
+
|
|
115
|
+
采集是 300Hz 逐拍进行的, `start(n)` 返回时缓冲里还没有数据 —— 等待录满
|
|
116
|
+
必须靠轮询本方法, 不能靠 sleep 猜时间。
|
|
117
|
+
"""
|
|
118
|
+
self._read_chunk(0)
|
|
119
|
+
return self.total_bytes
|
|
120
|
+
|
|
121
|
+
def wait_for(self, n_ticks: int, timeout: Optional[float] = None,
|
|
122
|
+
poll: float = _POLL_S) -> int:
|
|
123
|
+
"""等固件记满 `n_ticks` 拍; 返回实际总字节数。
|
|
124
|
+
|
|
125
|
+
`timeout=None` 时按 `n_ticks / CTRL_HZ * 1.5 + 3s` 推算 (300Hz 逐拍记录)。
|
|
126
|
+
超时抛 `MotionTimeoutError` 并报出实际录到多少拍 —— 绝不返回半截数据冒充成功。
|
|
127
|
+
"""
|
|
128
|
+
n = int(n_ticks)
|
|
129
|
+
sz = sample_size(self._arm.n)
|
|
130
|
+
want = n * sz
|
|
131
|
+
if want <= 0:
|
|
132
|
+
return 0
|
|
133
|
+
if timeout is None:
|
|
134
|
+
timeout = n / float(CTRL_HZ) * 1.5 + 3.0
|
|
135
|
+
end = time.monotonic() + float(timeout)
|
|
136
|
+
while True:
|
|
137
|
+
total = self.total()
|
|
138
|
+
if total >= want:
|
|
139
|
+
return total
|
|
140
|
+
if time.monotonic() >= end:
|
|
141
|
+
raise MotionTimeoutError(
|
|
142
|
+
f"采集未在 {float(timeout):.1f}s 内录满 {n} 拍 "
|
|
143
|
+
f"(只录到 {total // sz} 拍 / {total}B) —— 可调大 record_timeout")
|
|
144
|
+
time.sleep(poll)
|
|
145
|
+
|
|
146
|
+
def iter_chunks(self) -> Iterator[bytes]:
|
|
147
|
+
"""逐块产出原始字节; 读到 `next_byte == 0` 结束。
|
|
148
|
+
|
|
149
|
+
⚠ 必须自检**游标是否前进**: 固件游标若因异常/干扰回了一个不大于当前 offset
|
|
150
|
+
的非零值, 天真的实现会在同一位置无限次重读 (死循环)。这里直接报错。
|
|
151
|
+
"""
|
|
152
|
+
off = 0
|
|
153
|
+
while True:
|
|
154
|
+
chunk = self._read_chunk(off)
|
|
155
|
+
if chunk:
|
|
156
|
+
yield chunk
|
|
157
|
+
if self._next == 0:
|
|
158
|
+
return
|
|
159
|
+
if self._next <= off:
|
|
160
|
+
raise TransportError(
|
|
161
|
+
f"LOG_READ 游标未前进 (offset={off} -> next_byte={self._next}), "
|
|
162
|
+
f"拒绝在同一游标上重复读回 (死循环保护)")
|
|
163
|
+
off = self._next
|
|
164
|
+
|
|
165
|
+
def read_all(self) -> bytes:
|
|
166
|
+
return b"".join(self.iter_chunks())
|
|
167
|
+
|
|
168
|
+
def samples(self) -> List[LogSample]:
|
|
169
|
+
"""读回并解析为样本列表 (便捷; 大缓冲建议先 `dump()` 落盘)。"""
|
|
170
|
+
return parse_samples(self.read_all(), self._arm.n)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ArmLog:
|
|
174
|
+
"""`arm.log` —— 采集启停 + 读回入口。"""
|
|
175
|
+
|
|
176
|
+
def __init__(self, arm: "Arm"):
|
|
177
|
+
self._arm = arm
|
|
178
|
+
#: 最近一次 `start(n)` 的 n —— `dump()` 靠它知道该等录满多少拍
|
|
179
|
+
self._last_target = 0
|
|
180
|
+
|
|
181
|
+
def start(self, n_ticks: int) -> None:
|
|
182
|
+
"""开始记录 `n_ticks` 拍后自停 (n=0 等效 `stop()`)。
|
|
183
|
+
|
|
184
|
+
⚠ 固件是**逐拍**记录的: 本调用返回时缓冲里还没有数据 (300Hz, n 拍要
|
|
185
|
+
n/300 秒)。要拿完整数据须用 `capture()` (自动等) 或 `dump()` (默认等)。
|
|
186
|
+
|
|
187
|
+
固件侧 `n > LOG_MAX_SAMPLES(2400)` 回 `ERR{0x2D,0x02}`。
|
|
188
|
+
"""
|
|
189
|
+
n = int(n_ticks)
|
|
190
|
+
if n < 0:
|
|
191
|
+
raise InvalidCommandError(f"n_ticks 需 >=0 (给的是 {n_ticks})")
|
|
192
|
+
self._arm._cmd(P.CMD_LOG_CTRL, struct.pack("<I", n), "log_start")
|
|
193
|
+
self._last_target = n
|
|
194
|
+
|
|
195
|
+
def stop(self) -> None:
|
|
196
|
+
"""停止/清空记录 (停止后仍可用 `reader()` 读回已记录部分)。"""
|
|
197
|
+
self._arm._cmd(P.CMD_LOG_CTRL, struct.pack("<I", 0), "log_stop")
|
|
198
|
+
self._last_target = 0
|
|
199
|
+
|
|
200
|
+
def reader(self, timeout: float = 1.0, retries: int = 3) -> LogReader:
|
|
201
|
+
return LogReader(self._arm, timeout=timeout, retries=retries)
|
|
202
|
+
|
|
203
|
+
def capture(self, n_ticks: int, timeout: float = 1.0, retries: int = 3,
|
|
204
|
+
record_timeout: Optional[float] = None) -> List[LogSample]:
|
|
205
|
+
"""便捷: 采 `n_ticks` 拍 -> **等录满** -> 读回 -> 解析成样本列表。
|
|
206
|
+
|
|
207
|
+
⚠ 固件是 300Hz **逐拍**记录的 —— `start(n)` 返回时缓冲里还没有数据,
|
|
208
|
+
`n_ticks=600` 要约 2 秒才录满, 故本方法会轮询等待 (`record_timeout=None`
|
|
209
|
+
时按 `n/HZ*1.5+3s` 推算)。等不满则抛错, 不返回半截数据。
|
|
210
|
+
|
|
211
|
+
⚠ 读回是 ~`n_ticks*(4+12N)/245` 次往返 (7J 满量程 2400 拍 ≈ 863 次),
|
|
212
|
+
大缓冲建议用 `dump()` 落盘后离线解析。`timeout` 是**单块**超时。
|
|
213
|
+
"""
|
|
214
|
+
self.start(n_ticks)
|
|
215
|
+
reader = self.reader(timeout=timeout, retries=retries)
|
|
216
|
+
reader.wait_for(n_ticks, timeout=record_timeout)
|
|
217
|
+
return reader.samples()
|
|
218
|
+
|
|
219
|
+
def dump(self, path: str, wait: bool = True, timeout: float = 1.0,
|
|
220
|
+
retries: int = 3, record_timeout: Optional[float] = None) -> int:
|
|
221
|
+
"""读回原始字节流落盘 (解析交给调用方 / `parse_samples`)。返回字节数。
|
|
222
|
+
|
|
223
|
+
`wait=True`(默认) 会先等最近一次 `start(n)` 的 n 拍录满 —— 否则 `start()`
|
|
224
|
+
之后立刻 dump 会落盘一个空/半截文件。要读"此刻已录到多少"就传
|
|
225
|
+
`wait=False`。
|
|
226
|
+
"""
|
|
227
|
+
reader = self.reader(timeout=timeout, retries=retries)
|
|
228
|
+
if wait and self._last_target > 0:
|
|
229
|
+
reader.wait_for(self._last_target, timeout=record_timeout)
|
|
230
|
+
blob = reader.read_all()
|
|
231
|
+
with open(path, "wb") as f:
|
|
232
|
+
f.write(blob)
|
|
233
|
+
return len(blob)
|
litearm/model.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""动力学模型在线导入 —— 固件 `0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39` 的面向对象封装。
|
|
2
|
+
|
|
3
|
+
## 为什么需要它
|
|
4
|
+
|
|
5
|
+
stm32 固件里, 连杆动力学 (m / com / I) 与电机转子惯量 jm 原先都是**编译期常量**
|
|
6
|
+
(`dyn_model_data.h` / `dyn_jm_default.h`)。产线要求「每台臂出厂前上 HYY 控制器辨识
|
|
7
|
+
一次动力学 → 把参数写进固件」, 逐台不同 ⇒ 必须能在线写入, 否则每台都得重编译烧录。
|
|
8
|
+
|
|
9
|
+
固件的模型分三层 (见 `litearm-stm32/User/litearm/dynamics/dyn.h`):
|
|
10
|
+
|
|
11
|
+
bank 模型唯一权威, **永不含 payload**; 来源 = 编译期常量 或 导入/flash
|
|
12
|
+
staging 0x30 / 0x33 写这里, 不参与递推
|
|
13
|
+
生效层 bank + payload 复合, RNEA 读这一层
|
|
14
|
+
|
|
15
|
+
`0x32` commit 把 staging **整组原子**拷进 bank ⇒ 不存在"半截模型生效"。
|
|
16
|
+
|
|
17
|
+
## 正确用法
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
arm.model.set_body(1, [m, cmx, cmy, cmz, ixx, ixy, ixz, iyy, iyz, izz]) # J1
|
|
21
|
+
... # J2..J7
|
|
22
|
+
arm.model.set_jm([...]) # 7 个
|
|
23
|
+
arm.model.commit(0x2FE) # body1..7 + jm 的位图; 掩码必须与实际写过的项逐位相等
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`expected_mask` 的位: `bit i (0..8) = body i`, `bit 9 = jm`。固件用它关掉两类误用:
|
|
27
|
+
「写一半就 commit」与「跨会话拼接」。本设计的工具侧固定写 `body1..7 + jm` ⇒ `0x2FE`
|
|
28
|
+
(见 `MODEL_MASK_WRITTEN`)。
|
|
29
|
+
|
|
30
|
+
## 门控
|
|
31
|
+
|
|
32
|
+
`commit` 与 `revert` **要求失能** (固件判据 `enabled || enable_pending`), 否则回
|
|
33
|
+
`ERR{cmd,0x04}`。写 staging (0x30/0x33) 不要求失能 —— 不生效即无风险。
|
|
34
|
+
|
|
35
|
+
固件侧载荷与错误码见 `litearm-stm32/User/litearm/hal/usb_cmd.c` 的对应分支。
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import struct
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import TYPE_CHECKING, List, Sequence
|
|
42
|
+
|
|
43
|
+
from litearm import _protocol as P
|
|
44
|
+
from litearm.errors import (InvalidCommandError, TransportError,
|
|
45
|
+
UnsupportedByFirmwareError)
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING: # 避免与 arm 循环导入
|
|
48
|
+
from litearm.arm import Arm, Msg
|
|
49
|
+
|
|
50
|
+
__all__ = ["ModelStatus", "ModelParams", "MODEL_NBODY", "MODEL_BODY_PARAMS",
|
|
51
|
+
"MODEL_BIT_BODY", "MODEL_BIT_JM", "MODEL_MASK_WRITTEN"]
|
|
52
|
+
|
|
53
|
+
#: 模型刚体数 —— **与固件 `dyn_model_data.h` 的 `DYN_MODEL_NBODY` 同值**。
|
|
54
|
+
#: ⚠ 与 `arm.n` (关节数, 台架 1J 时为 1) **无关**: 模型恒 9 刚体 (0=base, 1..7=关节,
|
|
55
|
+
#: 8=ee 固定体)。照抄 `JointParams._check_idx` 用 `arm.n` 做上界会让台架完全无法验证模型。
|
|
56
|
+
MODEL_NBODY = 9
|
|
57
|
+
|
|
58
|
+
#: 单 body 的字段数: m, cmx, cmy, cmz, ixx, ixy, ixz, iyy, iyz, izz
|
|
59
|
+
MODEL_BODY_PARAMS = 10
|
|
60
|
+
|
|
61
|
+
#: jm 长度 (关节数)
|
|
62
|
+
MODEL_JM_N = 7
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def MODEL_BIT_BODY(i: int) -> int:
|
|
66
|
+
"""body i 的 `expected_mask` 位 (i = 0..8)。"""
|
|
67
|
+
return 1 << int(i)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
MODEL_BIT_JM = 1 << 9 #: jm 的 expected_mask 位
|
|
71
|
+
|
|
72
|
+
#: 工具/产线实际写入的集合: body1..7 + jm (body0=base 与 body8=ee 不写) = 0x2FE。
|
|
73
|
+
#: 固件侧的 `T_DIRTY_ALL & ~(1 | 1 << 8)` 必须与之逐位相等。
|
|
74
|
+
MODEL_MASK_WRITTEN = sum(MODEL_BIT_BODY(i) for i in range(1, 8)) | MODEL_BIT_JM
|
|
75
|
+
assert MODEL_MASK_WRITTEN == 0x2FE, "MODEL_MASK_WRITTEN 常量漂移"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ModelStatus:
|
|
80
|
+
"""固件 `RSP_MODEL_STATUS(0x56)` 的 5B 应答。"""
|
|
81
|
+
|
|
82
|
+
override: int #: 0 = 编译期常量; 1 = 导入/flash 模型 (**不区分二者**)
|
|
83
|
+
staged_mask: int #: bit0..8 = body0..8, bit9 = jm (0..0x3FF)
|
|
84
|
+
dirty: int #: 1 = RAM 有未固化改动 (定义 = RAM != flash)
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def decode(cls, payload: bytes) -> "ModelStatus":
|
|
88
|
+
# payload 含首字节 RSP id (与 RSP_FF_VEC/RSP_FF_SCALAR 同口径)
|
|
89
|
+
if len(payload) != 5:
|
|
90
|
+
raise TransportError(f"RSP_MODEL_STATUS 帧长 {len(payload)}B, 期望 5B")
|
|
91
|
+
if payload[0] != P.RSP_MODEL_STATUS:
|
|
92
|
+
raise TransportError(f"RSP_MODEL_STATUS 帧 id 0x{payload[0]:02X}, 期望 0x{P.RSP_MODEL_STATUS:02X}")
|
|
93
|
+
mask = struct.unpack_from("<H", payload, 2)[0] # u16 **LE**
|
|
94
|
+
return cls(int(payload[1]), int(mask), int(payload[4]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check_body_vals(vals: Sequence[float]) -> List[float]:
|
|
98
|
+
if len(vals) != MODEL_BODY_PARAMS:
|
|
99
|
+
raise InvalidCommandError(
|
|
100
|
+
f"body 参数须为 {MODEL_BODY_PARAMS} 个 (m, cmx, cmy, cmz, ixx, ixy, ixz, iyy, iyz, izz), "
|
|
101
|
+
f"给的是 {len(vals)} 个")
|
|
102
|
+
return [float(v) for v in vals]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ModelParams:
|
|
106
|
+
"""`arm.model` —— 动力学模型的读/写/提交/回退入口。
|
|
107
|
+
|
|
108
|
+
无状态: 每次调用直发一帧 (与 `arm.params` 同风格), 不做任何本地缓存
|
|
109
|
+
—— 固件才是唯一权威, 缓存会与"另一端可能改了"打架。
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(self, arm: "Arm"):
|
|
113
|
+
self._arm = arm
|
|
114
|
+
|
|
115
|
+
# ---- 内部 ----
|
|
116
|
+
def _check_body_idx(self, idx: int) -> int:
|
|
117
|
+
idx = int(idx)
|
|
118
|
+
if not 0 <= idx < MODEL_NBODY:
|
|
119
|
+
raise InvalidCommandError(f"刚体索引越界: {idx} (有效 0..{MODEL_NBODY - 1})")
|
|
120
|
+
return idx
|
|
121
|
+
|
|
122
|
+
# ---- 0x34 (探测 + 读回共用) ----
|
|
123
|
+
def probe(self) -> bool:
|
|
124
|
+
"""固件是否支持模型在线导入。
|
|
125
|
+
|
|
126
|
+
用 `0x34` 探测: 旧固件**没有**这条命令的 case ⇒ 落 `default` ⇒ `ERR{0x34,0x00}`
|
|
127
|
+
⇒ 这里捕获 `UnsupportedByFirmwareError` 并回 `False`。
|
|
128
|
+
|
|
129
|
+
⚠ **不能用 `0x30`/`0x33` 探测** —— 旧固件对它们有**显式 case** 回 `ERR{cmd,0x02}`
|
|
130
|
+
(与"数值非法"同码), 无法区分「无此命令」与「参数非法」。
|
|
131
|
+
"""
|
|
132
|
+
try:
|
|
133
|
+
self.get_body(0)
|
|
134
|
+
return True
|
|
135
|
+
except UnsupportedByFirmwareError:
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def get_body(self, idx: int, timeout: float = 1.0) -> "Msg[List[float]]":
|
|
139
|
+
"""读**生效 bank** 的第 `idx` 个刚体 (0..8) 的 10 个参数。
|
|
140
|
+
|
|
141
|
+
读的是 bank (无 payload 复合), 故与 `set_body` 写入值可直接逐项比对。
|
|
142
|
+
返回 :class:`Msg` 信封 (帧 id `RSP_MODEL_PARAM`); 单发请求/应答式, 见 `Msg`。
|
|
143
|
+
"""
|
|
144
|
+
idx = self._check_body_idx(idx)
|
|
145
|
+
arm = self._arm
|
|
146
|
+
arm._write_query(P.CMD_GET_MODEL_PARAM, bytes([idx]))
|
|
147
|
+
_, p = arm._require().expect(P.RSP_MODEL_PARAM, timeout, f"model.get_body({idx})",
|
|
148
|
+
echo_cmd=P.CMD_GET_MODEL_PARAM)
|
|
149
|
+
if len(p) != 2 + 4 * MODEL_BODY_PARAMS:
|
|
150
|
+
raise TransportError(f"RSP_MODEL_PARAM 帧长 {len(p)}B, 期望 {2 + 4 * MODEL_BODY_PARAMS}B")
|
|
151
|
+
if p[0] != P.RSP_MODEL_PARAM:
|
|
152
|
+
raise TransportError(f"RSP_MODEL_PARAM 帧 id 0x{p[0]:02X}, 期望 0x{P.RSP_MODEL_PARAM:02X}")
|
|
153
|
+
if p[1] != idx:
|
|
154
|
+
raise TransportError(f"RSP_MODEL_PARAM 回显 body_idx={p[1]}, 期望 {idx}")
|
|
155
|
+
return arm._msg(list(struct.unpack_from("<" + "f" * MODEL_BODY_PARAMS, p, 2)),
|
|
156
|
+
P.RSP_MODEL_PARAM)
|
|
157
|
+
|
|
158
|
+
# ---- 0x30 ----
|
|
159
|
+
def set_body(self, idx: int, vals: Sequence[float]) -> None:
|
|
160
|
+
"""写 staging 的第 `idx` 个刚体。**不生效**, 须 `commit()`。
|
|
161
|
+
|
|
162
|
+
固件拒绝 (回 `ERR{0x30,0x02}`) 的情形: 索引越界 / 任一分量非有限或 |v|>1e6 /
|
|
163
|
+
`idx == 8` (ee 固定体, payload 复合位) 而质量非 0。
|
|
164
|
+
"""
|
|
165
|
+
idx = self._check_body_idx(idx)
|
|
166
|
+
payload = bytes([idx]) + struct.pack("<" + "f" * MODEL_BODY_PARAMS,
|
|
167
|
+
*_check_body_vals(vals))
|
|
168
|
+
self._arm._cmd(P.CMD_SET_MODEL_PARAM, payload, f"model.set_body({idx})")
|
|
169
|
+
|
|
170
|
+
# ---- 0x33 ----
|
|
171
|
+
def get_jm(self, timeout: float = 1.0) -> "Msg[List[float]]":
|
|
172
|
+
"""读**生效 bank** 的 jm (7 个关节转子惯量)。
|
|
173
|
+
|
|
174
|
+
返回 :class:`Msg` 信封 (帧 id `RSP_MODEL_JM`); 单发请求/应答式, 见 `Msg`。
|
|
175
|
+
"""
|
|
176
|
+
arm = self._arm
|
|
177
|
+
arm._write_query(P.CMD_GET_MODEL_JM, b"")
|
|
178
|
+
_, p = arm._require().expect(P.RSP_MODEL_JM, timeout, "model.get_jm",
|
|
179
|
+
echo_cmd=P.CMD_GET_MODEL_JM)
|
|
180
|
+
if len(p) != 1 + 4 * MODEL_JM_N:
|
|
181
|
+
raise TransportError(f"RSP_MODEL_JM 帧长 {len(p)}B, 期望 {1 + 4 * MODEL_JM_N}B")
|
|
182
|
+
if p[0] != P.RSP_MODEL_JM:
|
|
183
|
+
raise TransportError(f"RSP_MODEL_JM 帧 id 0x{p[0]:02X}, 期望 0x{P.RSP_MODEL_JM:02X}")
|
|
184
|
+
return arm._msg(list(struct.unpack_from("<" + "f" * MODEL_JM_N, p, 1)),
|
|
185
|
+
P.RSP_MODEL_JM)
|
|
186
|
+
|
|
187
|
+
def set_jm(self, vals: Sequence[float]) -> None:
|
|
188
|
+
"""写 staging 的 jm[7]。**不生效**, 须 `commit()`。"""
|
|
189
|
+
if len(vals) != MODEL_JM_N:
|
|
190
|
+
raise InvalidCommandError(f"jm 须为 {MODEL_JM_N} 个, 给的是 {len(vals)} 个")
|
|
191
|
+
payload = struct.pack("<" + "f" * MODEL_JM_N, *[float(v) for v in vals])
|
|
192
|
+
self._arm._cmd(P.CMD_SET_MODEL_JM, payload, "model.set_jm")
|
|
193
|
+
|
|
194
|
+
# ---- 0x32 ----
|
|
195
|
+
def commit(self, expected_mask: int) -> None:
|
|
196
|
+
"""staging 整组原子生效。
|
|
197
|
+
|
|
198
|
+
`expected_mask` 必须与固件侧"本次会话实际写过的项"逐位相等, 否则回
|
|
199
|
+
`ERR{0x32,0x07}` (掩码不符 —— 与"数值非法"分开的码, 便于产线定位)。
|
|
200
|
+
零模型哨兵不过回 `ERR{0x32,0x02}`; 已武装回 `ERR{0x32,0x04}`。
|
|
201
|
+
|
|
202
|
+
⚠ u16 **LE** —— `_protocol` 没有 u16 pack helper, 必须显式 `struct.pack("<H", ...)`;
|
|
203
|
+
写成 `bytes([mask])` 会截断成 1 字节 (`0x2FE & 0xFF = 0xFE`) 而固件 `len < 2` 直接拒。
|
|
204
|
+
"""
|
|
205
|
+
self._arm._cmd(P.CMD_MODEL_COMMIT, struct.pack("<H", int(expected_mask)),
|
|
206
|
+
"model.commit", timeout=2.5)
|
|
207
|
+
|
|
208
|
+
# ---- 0x37 ----
|
|
209
|
+
def revert(self) -> None:
|
|
210
|
+
"""**只回退模型**: bank 回编译期常量 (含 jm) + 清 staging。须失能。
|
|
211
|
+
|
|
212
|
+
⚠ 三条后果 (工具/产线必须知道):
|
|
213
|
+
1. **不动 flash** ⇒ flash 里的导入模型仍在, **重新上电会复活**。
|
|
214
|
+
2. 此后任何一次 `save_params()` 会把 flash 里的导入模型**一并抹掉**
|
|
215
|
+
(整扇区擦除, 旧记录不可恢复)。
|
|
216
|
+
3. 回退后 `status().dirty == 1` (RAM != flash), 但**不要**据此提示"补固化"。
|
|
217
|
+
"""
|
|
218
|
+
self._arm._cmd(P.CMD_REVERT_MODEL, b"", "model.revert", timeout=2.5)
|
|
219
|
+
|
|
220
|
+
# ---- 0x38 ----
|
|
221
|
+
def status(self, timeout: float = 1.0) -> "Msg[ModelStatus]":
|
|
222
|
+
"""读模型状态 (override / staged_mask / dirty)。
|
|
223
|
+
|
|
224
|
+
返回 :class:`Msg` 信封 (帧 id `RSP_MODEL_STATUS`); 单发请求/应答式, 见 `Msg`。
|
|
225
|
+
"""
|
|
226
|
+
arm = self._arm
|
|
227
|
+
arm._write_query(P.CMD_GET_MODEL_STATUS, b"")
|
|
228
|
+
_, p = arm._require().expect(P.RSP_MODEL_STATUS, timeout, "model.status",
|
|
229
|
+
echo_cmd=P.CMD_GET_MODEL_STATUS)
|
|
230
|
+
return arm._msg(ModelStatus.decode(p), P.RSP_MODEL_STATUS)
|
|
231
|
+
|
|
232
|
+
# ---- 0x39 ----
|
|
233
|
+
def get_gravity(self, q: Sequence[float], timeout: float = 1.0) -> "Msg[List[float]]":
|
|
234
|
+
"""给定关节角算重力项 `G(q)` (纯读, 无门控, 不改任何状态)。
|
|
235
|
+
|
|
236
|
+
产线的「静态重力核查」用它: 到位静止后比对实测 τ 与 `G(q)` —— 这是唯一能抓住
|
|
237
|
+
「错台 yaml / 重力符号错 / 模型没生效」的判据 (读回比对只能证明字节落位)。
|
|
238
|
+
|
|
239
|
+
返回 :class:`Msg` 信封 (帧 id `RSP_GRAVITY`); 单发请求/应答式, 见 `Msg`。
|
|
240
|
+
"""
|
|
241
|
+
q = [float(v) for v in q]
|
|
242
|
+
if len(q) != MODEL_JM_N:
|
|
243
|
+
raise InvalidCommandError(f"q 须为 {MODEL_JM_N} 个, 给的是 {len(q)} 个")
|
|
244
|
+
arm = self._arm
|
|
245
|
+
arm._write_query(P.CMD_GET_GRAVITY, struct.pack("<" + "f" * MODEL_JM_N, *q))
|
|
246
|
+
_, p = arm._require().expect(P.RSP_GRAVITY, timeout, "model.get_gravity",
|
|
247
|
+
echo_cmd=P.CMD_GET_GRAVITY)
|
|
248
|
+
if len(p) != 1 + 4 * MODEL_JM_N:
|
|
249
|
+
raise TransportError(f"RSP_GRAVITY 帧长 {len(p)}B, 期望 {1 + 4 * MODEL_JM_N}B")
|
|
250
|
+
if p[0] != P.RSP_GRAVITY:
|
|
251
|
+
raise TransportError(f"RSP_GRAVITY 帧 id 0x{p[0]:02X}, 期望 0x{P.RSP_GRAVITY:02X}")
|
|
252
|
+
return arm._msg(list(struct.unpack_from("<" + "f" * MODEL_JM_N, p, 1)),
|
|
253
|
+
P.RSP_GRAVITY)
|
litearm/params.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""关节级运行时参数 —— 固件 `CMD_SET_JOINT_PARAM(0x22)` / `SET_JOINT_LIMITS(0x23)` /
|
|
2
|
+
`GET_JOINT_PARAM(0x24)` / `PARAM_RESET(0x36)` 的面向对象封装。
|
|
3
|
+
|
|
4
|
+
与 FF/动力学调参族 (留在 `Arm` 上) 的分工: 那一族是**前馈/控制律**参数
|
|
5
|
+
(0x26/0x27/0x28/0x31 + 读回 0x2B/0x2C); 本模块是**关节级** MIT 刚度/阻尼/力矩钳幅与
|
|
6
|
+
软限位。两者都是 RAM 生效, 持久化统一走 `Arm.save_params()` (0x25)。
|
|
7
|
+
|
|
8
|
+
固件侧载荷与错误码见 `litearm-stm32/User/litearm/hal/usb_cmd.c` 的
|
|
9
|
+
`CMD_SET_JOINT_PARAM`/`CMD_SET_JOINT_LIMITS`/`CMD_GET_JOINT_PARAM`/`CMD_PARAM_RESET` 分支。
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import struct
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from litearm import _protocol as P
|
|
18
|
+
from litearm.errors import InvalidCommandError, TransportError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # 避免与 arm 循环导入
|
|
21
|
+
from litearm.arm import Arm, Msg
|
|
22
|
+
|
|
23
|
+
__all__ = ["JointParam", "JointParams"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class JointParam:
|
|
28
|
+
"""固件 `RSP_JOINT_PARAM(0x49)` 的 21B 应答: idx + kp,kd,tau_max,q_min,q_max。"""
|
|
29
|
+
|
|
30
|
+
idx: int
|
|
31
|
+
kp: float
|
|
32
|
+
kd: float
|
|
33
|
+
tau_max: float
|
|
34
|
+
q_min: float
|
|
35
|
+
q_max: float
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def decode(cls, payload: bytes) -> "JointParam":
|
|
39
|
+
if len(payload) < 21:
|
|
40
|
+
raise TransportError(f"RSP_JOINT_PARAM 帧短 ({len(payload)}B, 期望 21B)")
|
|
41
|
+
idx = payload[0]
|
|
42
|
+
kp, kd, tm, lo, hi = struct.unpack_from("<fffff", payload, 1)
|
|
43
|
+
return cls(idx, kp, kd, tm, lo, hi)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JointParams:
|
|
47
|
+
"""`arm.params` —— 关节级参数的读写入口 (RAM 生效)。"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, arm: "Arm"):
|
|
50
|
+
self._arm = arm
|
|
51
|
+
|
|
52
|
+
# ---- 内部 ----
|
|
53
|
+
def _check_idx(self, idx: int) -> int:
|
|
54
|
+
idx = int(idx)
|
|
55
|
+
n = self._arm.n
|
|
56
|
+
if not 0 <= idx < max(n, 1):
|
|
57
|
+
raise InvalidCommandError(f"关节索引越界: {idx} (有效 0..{n - 1})")
|
|
58
|
+
return idx
|
|
59
|
+
|
|
60
|
+
# ---- 0x22 ----
|
|
61
|
+
def set_joint_param(self, idx: int, kp: float, kd: float, tau_max: float) -> None:
|
|
62
|
+
"""改写单关节 MIT 刚度/阻尼/力矩钳幅 (RAM; 须 `save_params()` 才持久化)。
|
|
63
|
+
|
|
64
|
+
固件侧还有一道值合法性校验 (`params_set_joint_ctrl`), 非法时回 `ERR{0x22,0x02}`。
|
|
65
|
+
"""
|
|
66
|
+
idx = self._check_idx(idx)
|
|
67
|
+
payload = bytes([idx]) + struct.pack("<fff", float(kp), float(kd), float(tau_max))
|
|
68
|
+
self._arm._cmd(P.CMD_SET_JOINT_PARAM, payload, f"set_joint_param(J{idx + 1})")
|
|
69
|
+
|
|
70
|
+
# ---- 0x23 ----
|
|
71
|
+
def set_joint_limits(self, idx: int, q_min: float, q_max: float) -> None:
|
|
72
|
+
"""改写单关节软限位 (RAM)。固件要求 `q_min < q_max`。
|
|
73
|
+
|
|
74
|
+
⚠⚠ **写入是"照编译期默认值取交集", 而且静默** —— 固件只收窄: 放宽请求
|
|
75
|
+
(**包括写回当前值**) 会被**接受、回 ACK、且值不变**, 不报错。
|
|
76
|
+
所以本方法**是幂等的**, 但**不能拿它当写口自检** —— 想确认写没写进去只能
|
|
77
|
+
:meth:`get_joint_param` 回读。
|
|
78
|
+
`ERR{0x23,0x02}` 的触发条件是 NaN / 交集为空 (`q_min >= q_max`) / idx 越界,
|
|
79
|
+
**不含放宽** (固件原文: "固件只许收窄, 照编译期默认值取交集")。
|
|
80
|
+
⚠ **收窄在 RAM 里不可逆**: 交集只会越取越小, 不重启回不去。
|
|
81
|
+
"""
|
|
82
|
+
idx = self._check_idx(idx)
|
|
83
|
+
if not float(q_min) < float(q_max):
|
|
84
|
+
raise InvalidCommandError(f"软限位需 q_min < q_max (给的是 {q_min}, {q_max})")
|
|
85
|
+
payload = bytes([idx]) + struct.pack("<ff", float(q_min), float(q_max))
|
|
86
|
+
self._arm._cmd(P.CMD_SET_JOINT_LIMITS, payload, f"set_joint_limits(J{idx + 1})")
|
|
87
|
+
|
|
88
|
+
# ---- 0x24 ----
|
|
89
|
+
def get_joint_param(self, idx: int, timeout: float = 1.0) -> "Msg[JointParam]":
|
|
90
|
+
"""读回单关节参数 (与 `set_joint_param`/`set_joint_limits` 构成写→读回闭环)。
|
|
91
|
+
|
|
92
|
+
返回 :class:`Msg` 信封 (帧 id `RSP_JOINT_PARAM`)。单发请求/应答式 ⇒ 第一次调用
|
|
93
|
+
`hz == 0.0`, 第二次起等于本调用方自己的轮询频率 (见 `Msg`)。
|
|
94
|
+
"""
|
|
95
|
+
idx = self._check_idx(idx)
|
|
96
|
+
arm = self._arm
|
|
97
|
+
arm._write_query(P.CMD_GET_JOINT_PARAM, bytes([idx])) # 查询类: 不受守卫限制
|
|
98
|
+
_, p = arm._require().expect(P.RSP_JOINT_PARAM, timeout,
|
|
99
|
+
f"get_joint_param(J{idx + 1})",
|
|
100
|
+
echo_cmd=P.CMD_GET_JOINT_PARAM)
|
|
101
|
+
return arm._msg(JointParam.decode(p), P.RSP_JOINT_PARAM)
|
|
102
|
+
|
|
103
|
+
def all_joint_params(self) -> list:
|
|
104
|
+
"""逐关节读回全部参数 (N 次往返)。
|
|
105
|
+
|
|
106
|
+
⚠ 返回 `list[JointParam]` —— **不是** `list[Msg]`: 它是 N 次往返的**聚合**
|
|
107
|
+
(N 帧、N 个到达时刻), 一个 `hz`/`timestamp` 描述不了它; 要单帧的信封请调
|
|
108
|
+
`get_joint_param(i)`。
|
|
109
|
+
"""
|
|
110
|
+
return [self.get_joint_param(i).value for i in range(self._arm.n)]
|
|
111
|
+
|
|
112
|
+
# ---- 0x36 ----
|
|
113
|
+
def reset_factory(self) -> None:
|
|
114
|
+
"""恢复出厂默认 + 失效 flash (固件 0x36)。
|
|
115
|
+
|
|
116
|
+
⚠ 固件**要求失能态** (擦写窗口 CPU 停顿, 电机不能在无监督下保持使能),
|
|
117
|
+
已武装时回 `ERR{0x36,0x04}`; 本方法不代劳 `disable()`, 以免替调用方做安全决策。
|
|
118
|
+
⚠ 失败时固件保持 RAM 与 flash 一致 (先失效 flash 再重载 RAM), 回出的 ERR 是真话。
|
|
119
|
+
"""
|
|
120
|
+
self._arm._cmd(P.CMD_PARAM_RESET, b"", "reset_factory", timeout=2.5)
|