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/testing.py ADDED
@@ -0,0 +1,959 @@
1
+ """脚本化应答的假传输 —— 让 Arm 全流程可离线测试 (镜像 pylitearm 桩硬件风格)。
2
+
3
+ 按收到的下行命令推送预设帧 (ACK/状态/RSP_TCP/RSP_IK), 维持 Arm 接口
4
+ (write_frame/read_frame/close/is_open), 供 pytest monkeypatch SerialTransport 用。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import math
9
+ import struct
10
+ import threading
11
+ import time
12
+ from typing import Optional, Tuple
13
+
14
+ from litearm import _protocol as P
15
+ from litearm.errors import TransportError
16
+
17
+ _N = 7
18
+
19
+ #: 假固件的「臂本体」几何 —— 只为一件事: 让 `G(q)` 与「载荷改变 G」两条契约在离线侧
20
+ #: 可验证。桩**不做**真实 RNEA (也不需要): `_fake_arm_gravity` / `_fake_payload_gravity`
21
+ #: 是一个**固定轴链**的点质量重力模型 —— 每关节轴在基座系里固定, 末端位置随 q 变。
22
+ #: 于是单构型的回归矩阵 rank 恰好是 3(`m` 一列 + `m·c` 的像最多 2 维, 因为重力矢量的
23
+ #: 叉乘矩阵秩为 2),与真机同一个病态结构 ⇒ "一个姿态解不出 4 参数"这件事在离线也成立。
24
+ _FAKE_AXIS = [(0.0, 0.0, 1.0), (0.0, 1.0, 0.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0),
25
+ (0.0, 1.0, 0.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0)]
26
+ _FAKE_LINK = 0.06 # 每节等效臂长 (m)
27
+ _FAKE_G = (0.0, 0.0, -9.81)
28
+
29
+
30
+ def _cross(a, b):
31
+ return (a[1] * b[2] - a[2] * b[1],
32
+ a[2] * b[0] - a[0] * b[2],
33
+ a[0] * b[1] - a[1] * b[0])
34
+
35
+
36
+ def _dot(a, b):
37
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
38
+
39
+
40
+ def _basis(ax):
41
+ """与单位轴 `ax` 正交的一组单位基 `(u, v)`。"""
42
+ ref = (1.0, 0.0, 0.0) if abs(ax[0]) < 0.9 else (0.0, 1.0, 0.0)
43
+ u = _cross(ax, ref)
44
+ nu = math.sqrt(_dot(u, u))
45
+ u = (u[0] / nu, u[1] / nu, u[2] / nu)
46
+ return u, _cross(ax, u)
47
+
48
+
49
+ def fake_ee(q, n=_N):
50
+ """假固件的末端位置 —— 每关节把一节臂绕自身固定轴转 `q[i]`,位置累加。"""
51
+ p = [0.0, 0.0, 0.0]
52
+ for i in range(min(n, _N)):
53
+ u, v = _basis(_FAKE_AXIS[i])
54
+ c, s = math.cos(q[i]), math.sin(q[i])
55
+ for k in range(3):
56
+ p[k] += _FAKE_LINK * (i + 1) * (c * u[k] + s * v[k])
57
+ return tuple(p)
58
+
59
+
60
+ def _rot_axis(ax, ang):
61
+ """绕单位轴 `ax` 转 `ang` 的 3×3 旋转矩阵。"""
62
+ x, y, z = ax
63
+ c, s = math.cos(ang), math.sin(ang)
64
+ C = 1.0 - c
65
+ return [[c + x * x * C, x * y * C - z * s, x * z * C + y * s],
66
+ [y * x * C + z * s, c + y * y * C, y * z * C - x * s],
67
+ [z * x * C - y * s, z * y * C + x * s, c + z * z * C]]
68
+
69
+
70
+ def _mat_mul(A, B):
71
+ return [[sum(A[i][k] * B[k][j] for k in range(3)) for j in range(3)]
72
+ for i in range(3)]
73
+
74
+
75
+ def _mat_vec(A, v):
76
+ return tuple(sum(A[i][k] * v[k] for k in range(3)) for i in range(3))
77
+
78
+
79
+ def fake_flange_rotation(q, n=_N):
80
+ """假固件的法兰姿态 `R(q)` —— 逐关节旋转的累乘。
81
+
82
+ 有它才能把质心的**第三个分量**观测出来:`m·c` 那三列在一个姿态下的像最多 2 维
83
+ (`g × ω_j` 恒 ⟂ 重力),换个姿态 `R` 变了才补上第三维。真机靠的就是这一点。
84
+ """
85
+ R = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
86
+ for i in range(min(n, _N)):
87
+ R = _mat_mul(R, _rot_axis(_FAKE_AXIS[i], q[i]))
88
+ return R
89
+
90
+
91
+ def _fake_jacobian(q, n=_N):
92
+ """末端平动雅可比的逐关节列(固定轴、各轴过基座: `J_j = ω_j × p_ee`)。"""
93
+ p = fake_ee(q, n)
94
+ return [_cross(_FAKE_AXIS[i], p) for i in range(min(n, _N))]
95
+
96
+
97
+ def fake_arm_gravity(q, msum, n=_N):
98
+ """点质量口径的「臂本体」重力项: 末端挂 `msum` 质量 (G(q))。
99
+
100
+ 给用例当**植物**用(想造一个"力学上说得通"的臂)。⚠ 它不是 `0x39` 的应答 ——
101
+ 桩固件自己那份模型是 :func:`fake_model_gravity`,两者**不能混用**:差分基线要靠
102
+ `0x39` 那份做姿态搬运,搬的模型与力矩出自同一个口径才有意义。
103
+ """
104
+ return [_dot(j, _FAKE_G) * msum for j in _fake_jacobian(q, n)]
105
+
106
+
107
+ def fake_model_gravity(q, msum):
108
+ """桩固件 `0x39` 的「臂本体」项 —— 位形相关、确定性,**不做真实 RNEA**。
109
+
110
+ 只为断言"帧格式对、值能被 SDK 解出来"以及"载荷改了 G(q) 就跟着改"。
111
+ 力矩的物理意义在这里不重要,重要的是 `0x39` 的应答与实测力矩能用同一个口径对上。
112
+ """
113
+ g = [msum * 9.81 * 0.001 * (i + 1) for i in range(_N)]
114
+ g[0] = msum * 9.81 * q[1] * 0.1
115
+ return g
116
+
117
+
118
+ def fake_payload_gravity(q, mass, com, n=_N):
119
+ """假固件的末端负载重力项: `Y_g(q)·[m, m·cx, m·cy, m·cz]`,7 值 (N·m)。
120
+
121
+ `τ_j = m·(J_j·g) + (m·c)·(g × ω_j)` —— 与真机同一条式子(质心在法兰系)。探针法能
122
+ 在离线侧被验证,靠的就是桩把这条契约也实现出来;`0x39` 的应答与「实测力矩」用的是
123
+ 同一个函数,于是「辨识能否回收注入的真值」变成一条可断言的等式。
124
+ """
125
+ if not mass > 0.0:
126
+ return [0.0] * min(n, _N)
127
+ c_base = _mat_vec(fake_flange_rotation(q, n), com) # 质心换到基座系
128
+ mc = (mass * c_base[0], mass * c_base[1], mass * c_base[2])
129
+ out = []
130
+ for i, j in enumerate(_fake_jacobian(q, n)):
131
+ out.append(mass * _dot(j, _FAKE_G) + _dot(mc, _cross(_FAKE_G, _FAKE_AXIS[i])))
132
+ return out
133
+
134
+
135
+ def _status(q, dq=None, mode=1, flags=0, seq=0, joint_fault=0, n=_N,
136
+ tau=None) -> bytes:
137
+ """按**固件 1.5.x 真实布局** (6+21N) 造状态帧 —— 尾部带 joint_fault u16。
138
+
139
+ 旧桩只造 4+21N, 导致"SDK 解不了真固件状态帧"这一 P0 在离线测试里全绿被掩盖。
140
+ `tau` 不给就沿用旧桩的恒 0.5 —— 只想验帧格式的用例不必关心力矩。
141
+ """
142
+ body = bytearray(struct.pack("<HH", flags | (mode << 6), seq))
143
+ for i in range(n):
144
+ body += struct.pack("<fffff", q[i] if i < len(q) else 0.0,
145
+ (dq[i] if dq is not None else 0.0),
146
+ (tau[i] if tau is not None else 0.5), 30.0, 25.0)
147
+ body.append(0)
148
+ body += struct.pack("<H", joint_fault)
149
+ return P.pack_frame(P.RSP_STATUS, bytes(body))
150
+
151
+
152
+ def _ack(cmd: int) -> bytes:
153
+ return P.pack_frame(P.RSP_ACK, bytes([cmd]))
154
+
155
+
156
+ #: [笛卡尔] 5 条命令各自的最小载荷长度 —— **逐字照固件 `usb_cmd.c` 的长度校验**
157
+ #: (`CMD_MOVE_L` 28 / `CMD_MOVE_C` 52 / `CART_BEGIN` 5 / `CART_ADD` 25 / `CART_RUN` 0)。
158
+ #: 固件把长度校验排在门禁与一切副作用**之前**, 桩照同一顺序, 于是空载荷只会得到
159
+ #: `ERR{cmd,0x01}` 而不会让臂动 —— SDK 的能力探测正是靠这一点, 别把这层改成"先受理"。
160
+ _CART_MIN_LEN = {0x3A: 28, 0x3B: 52, 0x3C: 5, 0x3D: 25, 0x3E: 0}
161
+
162
+ #: 会**起规划**、因而会产出一条 `0x4E` 的三条; `0x3C`/`0x3D` (收集态) 不产出。
163
+ _CART_PLANNING_CMDS = frozenset({0x3A, 0x3B, 0x3E})
164
+
165
+
166
+ #: KIN_BENCH 回文本 —— **逐字节镜像固件 kin_runner.c 的组装结果**, 不是"看着像"。
167
+ #: 关键在 `u32_cat()` 的语义: 数字**先写digits再补一个尾随空格**, 而 `txt()` 原样拼接
168
+ #: (不加分隔符)。于是:
169
+ #: txt("FK") + u32_cat(200) + u32_cat(108) + u32_cat(240) -> `FK200 108 240 `
170
+ #: txt("LOOP1") + u32_cat(4000) -> `LOOP14000 ` ← 名字与数字相连!
171
+ #: ...u32_cat(12345) + txt("/") + u32_cat(6) + txt("st=") -> `flt=12345 /6 st=1 ` ← 数字与 '/' 间有空格
172
+ #: 最后一行 LINK 是拿到 CRC 坏帧 / 应答 FIFO 丢弃 / CAN TX 失败分类桶等诊断计数的唯一通道。
173
+ KIN_BENCH_TEXT = (
174
+ "FK200 108 240 \n"
175
+ "JAC200 220 610 \n"
176
+ "IK100 2600 5200 \n"
177
+ "LOOP14000 \n"
178
+ "SC200 60 120 \n"
179
+ "G200 300 700 \n"
180
+ "RNEA200 800 1500 \n"
181
+ "M200 900 2200 \n"
182
+ "LAW200 50 90 \n"
183
+ # ⚠ LINK 行的键**按固件 `kin_runner.c:513-560` 的实际发射顺序与命名**抄全 ——
184
+ # 桩比真机短是这一类缺陷反复漏网的原因 (`rxl0`/`rxl1`/`rbd`/`cmode`… 从前就没有,
185
+ # 于是"解析器漏掉它们"在离线永远测不出来)。
186
+ # ⚠ `rxl0`/`rxl1` **名字里带数字** —— 解析器必须吃得下 (见 `test_..._digit_keys`)。
187
+ "LINK crc=3 ovf=11 rfd=1 txf=7 rxlen=5 rxl0=13 rxl1=17 rbd=19 "
188
+ "cmode=127 cmb=23 tfe=1 tfd=2 tfc=3 tfs=0 tfm=1 tec=29 rec=31 psr=8 "
189
+ "ebo=0 eep=0 eew=0 epa=0 epd=0 "
190
+ " loop_k=2 ovr=4 flt=12345 /6 st=1 \n"
191
+ )
192
+
193
+
194
+ def _split_kin_bench(text, one_frame=False):
195
+ """把 KIN_BENCH 全文按**真机那样**拆帧 (第 1 帧耗时 / 第 2 帧 LINK 行)。
196
+
197
+ 固件侧 `[H-9 fix 2026-09-13]` 的原话: "把 bench 各项耗时作为第 1 帧先发,
198
+ 再把 LINK 诊断行重置缓冲后作第 2 帧单独发。`usb_cmd_reply` 走 FIFO 保序,
199
+ 上位机按连续两帧组装即可"。
200
+
201
+ ⚠ 拆点取 **`LINK` 行首** —— 固件是"重置缓冲后从 `LINK ` 重新写", 故第 2 帧
202
+ 以 `LINK` 开头、不含前面的换行。
203
+ ⚠ 不含 `LINK` 的文本仍回**一帧** —— 空文本要走 `parse_kin_bench` 的
204
+ "空回执 = 异常" 判据, 拆成零帧会让那条判据变成"收不到帧"。
205
+ """
206
+ if "LINK" not in text:
207
+ return [text]
208
+ head, _, tail = text.partition("LINK")
209
+ parts = [head, "LINK" + tail]
210
+ return parts[:1] if one_frame else parts
211
+
212
+
213
+ def _mk_log(n_ticks: int, n: int) -> bytes:
214
+ """固件 log_sample_t 的字节流: u32 tick + q_ref[N] + dq[N] + tau[N] (4+12N B)。
215
+
216
+ 内容取确定值, 便于断言端到端字节一致 (不模拟轨迹)。
217
+ """
218
+ out = bytearray()
219
+ for i in range(n_ticks):
220
+ out += struct.pack("<I", i)
221
+ out += P.pack_f32s([float(10 * j + 1) for j in range(n)]) # q_ref
222
+ out += P.pack_f32s([0.5] * n) # dq
223
+ out += P.pack_f32s([-0.25] * n) # tau
224
+ return bytes(out)
225
+
226
+
227
+ class FakeTransport:
228
+ """真实串口传输的替换物: 不收字节, 直接对 write_frame 的命令做脚本应答。"""
229
+
230
+ def __init__(self, port: str = "fake", timeout: float = 0.2,
231
+ fw: str = "Litearm1.7.0-7J", n: int = _N):
232
+ self.port = port
233
+ self.timeout = timeout
234
+ self.fw = fw
235
+ self.n = int(n)
236
+ self._resp: list[bytes] = []
237
+ self.ff_vec: dict = {} # item -> 7 值 (供 0x2B 读回)
238
+ self.ff_scalar: dict = {} # (item, sub) -> 值 (供 0x2C 读回)
239
+ self.ff_mask = 0 # item 9 只读回填
240
+ self.q = [0.0] * self.n # 当前(假)关节位形
241
+ #: 逐关节**持位力矩** (N·m) —— 状态帧里报它。默认 0.5 保持旧桩的值。
242
+ #: 只看帧格式的用例不必关心它; 采样/辨识类用例需要它随构型变。
243
+ self.tau = [0.5] * self.n
244
+ #: 逐关节**速度** (rad/s) —— 状态帧里报它。默认全 0 (静止), 与旧桩一致;
245
+ #: 静置采样的 |dq| 门要靠它才能在离线侧被踩到。
246
+ self.dq = [0.0] * self.n
247
+ #: `callable(q) -> 7 值`: 给了就**逐帧**按当前位形算力矩 (准静态植物),
248
+ #: 于是"拉一个构型, 力矩随之变"这条契约成立 —— 辨识用例靠它注入真值。
249
+ self.tau_provider = None
250
+ self.pose = [0.300, 0.0, 0.350, 0.0, 0.0, 0.0]
251
+ self.auto_status = False # 缓冲空时是否补一拍 idle 状态 (模拟 100Hz)
252
+ #: 合成状态帧的**最小间隔**(秒)—— 见 `read_frame`。
253
+ #: ⚠⚠ 没有它,`read_frame` 会**无限快**地造状态帧。从前没人在意(读是**调用方
254
+ #: 驱动**的:调用方不读就没有帧);2026-09-22 起 SDK 有**读线程**,它会以
255
+ #: 桩能供上的最高速率空转 ⇒ 整套用例被 GIL 拖垮(实测单文件从秒级变到十几秒)。
256
+ #: 取 1ms 而不是真实的 10ms(100Hz):桩**可以**比真机快,但不能无界。
257
+ self.auto_status_period = 0.001
258
+ self._auto_status_last = 0.0
259
+ self.closed = False
260
+ #: 收到的全部下行命令 —— 保活线程会并发写, 故用锁保护。
261
+ #: `tx_stamps` 与 `tx_log` 同下标, 供"相邻帧间隔"类不变量断言用。
262
+ self.tx_log: list = []
263
+ self.tx_stamps: list = []
264
+ self._tx_lock = threading.Lock()
265
+ #: 注入写失败: 第 N 次 CMD_ZERO_G 之后抛 TransportError (模拟 CDC 掉线)
266
+ self.zg_fail_after: Optional[int] = None
267
+ self.zg_writes = 0
268
+ #: >0 时每次保活写 (0x06 on=1) 卡这么久 —— 模拟 CDC 写阻塞
269
+ self.zg_slow_write_s = 0.0
270
+ #: 固件"没有实现"的命令 —— 按固件 default 分支回 ERR{cmd,0x00}
271
+ self.unknown_cmds: set = set()
272
+ #: 强制某命令回指定错误码 (模拟 ERR{cmd,code})
273
+ self.err_override: dict = {}
274
+ #: [2026-09-14] 动力学模型在线导入 (0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39)。
275
+ #: 模型恒 9 刚体 —— **与 self.n (关节数) 无关**, 台架 1J 下也是 9。
276
+ #: body8 (ee) 质量必须恒 0; 初始 = "编译期常量" (用全 1 质量 / 0 com / 0 I 代表)。
277
+ self.model_nbody = 9
278
+ self.model_body = [[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
279
+ for _ in range(self.model_nbody)]
280
+ self.model_body[self.model_nbody - 1][0] = 0.0 # body8 质量恒 0
281
+ self.model_jm = [0.0] * 7
282
+ #: staging (0x30/0x33 写这里, 不生效)
283
+ self.model_stage_body = {}
284
+ self.model_stage_jm = None
285
+ self.model_override = False
286
+ self.model_dirty = False
287
+ #: 关节级参数表 (0x22/0x23 写, 0x24 读, 0x36 回出厂默认)
288
+ self.enabled = False
289
+ # ---- [授权] 见 `_protocol` 的 license 段; 判据在 tests/test_license.py ----
290
+ #: 是否已激活。**默认 True** —— 否则每一条既有用例的 `enable()` 都会撞
291
+ #: `ERR{0x10,0x08}`(固件 `ctrl_enable()` 的第一条判据), 而那些用例与授权无关。
292
+ #: 授权用例把它置 False 来复现"开机即锁"。
293
+ self.activated = True
294
+ #: 激活记录 —— `0x2F` 回报的就是这几格 (未激活时后三格按固件约定**恒 0**)。
295
+ self.license_ver = 1
296
+ self.license_uid = bytes(range(0x10, 0x1C)) # 12B, 原始寄存器内存序
297
+ self.license_cust_id = 0
298
+ self.license_issued = 0
299
+ self.license_flags = 0
300
+ #: 注入: 下一次 `0x3F` 回 `ERR{0x3F,0x02}`(模拟 MAC 不符 / 密钥非法 / 写失败,
301
+ #: 它们**全折成这一档**)。
302
+ #: ⚠ 桩**不可能**真的验 MAC —— 它**没有也不需要**密钥(规格硬要求: 客户侧不许有
303
+ #: 算 MAC 的代码, 否则机制归零)。真验签是固件的活; SDK 侧要测的只是
304
+ #: 「0x02 是聚合档 ⇒ 必须回读 `0x2F` 才能定性」那一段。
305
+ self.license_fail_next = False
306
+ self._jp_default = dict(kp=50.0, kd=2.0, tau_max=10.0, q_min=-3.0, q_max=3.0)
307
+ self.joint_params = [dict(self._jp_default) for _ in range(self.n)]
308
+ #: 300Hz 采集: log_bytes 为固件侧缓冲的原始字节流 (LOG_MAX_SAMPLES=2400)
309
+ self.log_max = 2400
310
+ self.log_bytes = b""
311
+ self.log_active = False
312
+ #: 模拟 USB 掉帧: 下一次 CMD_LOG_READ 不回 (读者须按游标续读/重试)
313
+ self.log_read_fail_once = False
314
+ #: >0 时模拟固件的**逐拍记录** (300Hz 语义): 启动后按"每拍 1/log_hz 秒"
315
+ #: 推进, **与读回次数无关** —— 这是必须的真实模型: start() 之后立刻读回
316
+ #: 只能拿到一个前缀, 等待必须靠轮询已记录量。(若让"记录"随读回次数推进,
317
+ #: read_all() 会自己"等到"录满, 测试就变成假通过。)
318
+ #: 0 = 立刻录满 (只有数据内容需求的用例用这个)。
319
+ self.log_hz = 0.0
320
+ self.log_t0 = None
321
+ self.log_target = 0
322
+ self.log_recorded_ticks = -1 # 上次重建 log_bytes 时的拍数 (避免每读回一次重建)
323
+ #: 模拟固件回一个**不前进**的游标 (next_byte == offset) —— 读者必须自己
324
+ #: 判定"无进展"并报错, 否则会死循环。上限次后抛错以免测试挂死。
325
+ self.log_cursor_stuck = False
326
+ self._log_read_calls = 0
327
+ #: KIN_BENCH 回文本 (可改写成空/畸形以测异常路径)
328
+ self.kin_bench_text = KIN_BENCH_TEXT
329
+ #: 模拟**旧固件**: 只发第 1 帧 (耗时帧), 不发 LINK 诊断帧。
330
+ #: 用于钉住"收第 2 帧超时不是错误"这条兼容行为。
331
+ self.kin_bench_one_frame = False
332
+ #: 覆盖 GET_TCP 回读的位置/朝向 (模拟固件的 rpy 规范化 —— 含万向锁强制 yaw=0,
333
+ #: 见 `kin_rot_to_rpy` `kin.c:360-385`, 锁分支在 `:380-384`)
334
+ self.pos_override = None
335
+ self.rpy_override = None
336
+ # ---- [笛卡尔] 0x3A~0x3E + RSP_CART_PLAN ----
337
+ #: 固件的 `#if LITEARM_CART_PLAN` 编译开关。False = 这 5 条整段不在固件里,
338
+ #: 于是落到 `default` 分支回 `ERR{cmd,0x00}` (SDK 的 `UnsupportedByFirmwareError`)。
339
+ self.cart_supported = True
340
+ #: `ok=1` 的 `0x4E` 载荷里的 n_wp / plan_us (确定性常量, 便于断言)。
341
+ self.cart_n_wp = 12
342
+ self.cart_plan_us = 4700
343
+ #: 非 None 时 0x4E 回 `ok=0 + err=该值` —— 造规划失败 (`cart_err_t` 1..6)。
344
+ #: ⚠ 与 `err_override` 不同: 后者回的是 `RSP_ERR` (命令级拒绝), 前者是**规划结果**。
345
+ self.cart_err_override: Optional[int] = None
346
+ #: 交付给 SDK 的状态帧计数 (自最后一条 `0x4E` 起) —— 驱动 CART_BUSY (bit10)。
347
+ #: None = 当前没有在跑的计划, 状态帧照原样发。
348
+ self.cart_busy_seq: Optional[int] = None
349
+ #: `move_path` 最近一条 `0x3D` (ADD) 的位姿 —— `0x3E` (RUN) 的目标就是它。
350
+ #: 与 `pose` 一样是"桩不建模运动过程, 只保证收尾时 TCP 在目标上"的一部分。
351
+ self._path_last_pose = list(self.pose)
352
+ #: **交付给 SDK 的状态帧序号** (`g_arm.seq`) —— 每交付一帧 +1 (u16 回绕)。
353
+ #: 固件那个计数器就是"这一帧什么时候生成的"的唯一线索, 到位判据的**新鲜度闸**
354
+ #: 靠它判"这一帧是不是命令之后生成的" ⇒ 桩必须逐帧递增, 否则每一帧都会被判成
355
+ #: 陈旧帧 (闸恒关)。⚠ 在**交付点**递增 (与 `cart_busy_seq` 同处): 数的是 SDK
356
+ #: 真正看到的帧, 与它来自命令应答还是空闲流无关。
357
+ self.status_seq = 0
358
+ # ---- [DFU] 0x15: 只登记, 不在命令处理里跳 ----
359
+ #: `ctrl_request_enter_dfu` 的**另一半**门禁: 固件判的是
360
+ #: `ctrl_is_armed() = enabled || enable_pending` (定义在 `control_loop.c:1334-1336`;
361
+ #: `:1091` 是 `ctrl_request_enter_dfu` 里把同一谓词**内联**写的那一行), 而
362
+ #: `enable_pending` 在状态帧里**没有位** (SDK 只能看到 `self.enabled`/bit9)。
363
+ #: 置 True = 造"使能在途"那个形状: SDK 预检放行、固件回 `ERR{0x15,0x03}`。
364
+ self.dfu_armed_pending = False
365
+ #: 模拟 `rom_bl_table_valid()` 判失败 ⇒ `ERR{0x15,0x02}` (`control_loop.c:1095`)。
366
+ self.dfu_rom_table_invalid = False
367
+ #: 登记成功后设备**是否真的离开** CDC。
368
+ #: True (默认) = 真跳转: ACK 之后**读路径抛 `TransportError`**, 与真机在
369
+ #: Linux 上的行为同形 (`select` 报就绪而 `read()` 返回空 ⇒ pyserial 抛
370
+ #: `SerialException` ⇒ `transport._read_chunk` 包成 `TransportError`)。
371
+ #: False = 两条**静默撤销**路径的形状 (`control_loop.c:1106` 并发 ENABLE /
372
+ #: `:1141-1144` 交棒前复读失败): 设备还在 CDC 上, 而 ACK 是成功的。
373
+ self.dfu_vanishes = True
374
+ #: 状态: 设备已经离开 (读/写都抛 `TransportError`)。由成功登记 + `dfu_vanishes` 置。
375
+ self.dfu_gone = False
376
+ #: 登记 ACK **之后**、设备离开**之前**还会继续投递的帧 (现实里是 100Hz 状态流与
377
+ #: 别的命令的迟到应答)。给"消失观察窗口不许静默吞帧"那条纪律造形状用。
378
+ self.dfu_post_ack_frames: list = []
379
+
380
+ def stamps_of(self, cmd: int) -> list:
381
+ """某命令各次下行的时刻 (与 `tx_log` 同下标)。"""
382
+ return [t for (c, _p), t in zip(self.tx_log, self.tx_stamps) if c == cmd]
383
+
384
+ @property
385
+ def armed(self) -> bool:
386
+ """固件 `ctrl_is_armed()` (`control_loop.c:1334-1336`) 的桩侧等价物。
387
+
388
+ 固件判的是 `g_arm.enabled || enable_pending`, 而 `enable_pending` 在状态帧里
389
+ **没有位** (SDK 只看得到 `self.enabled` / bit9) ⇒ 桩里由 `dfu_armed_pending`
390
+ 造那个形状。**五处门禁 (0x25/0x36/0x32/0x37/0x23) 与 0x15 共用这一个判据** ——
391
+ 逐条对得上固件这六处 `usb_cmd.c` 的用法 (`:670` / `:684` / `:877` / `:923`
392
+ / `:633` / `:543-555`)。判据只有这一份, 别在任何一处退回 `self.enabled`
393
+ (`test_full_coverage.py::test_all_gates_share_the_one_armed_predicate` 逐处钉住)。
394
+ """
395
+ return self.enabled or self.dfu_armed_pending
396
+
397
+ def _log_recorded(self) -> int:
398
+ """固件此刻已记录多少拍 (按时间推进)。"""
399
+ if self.log_hz <= 0 or self.log_t0 is None:
400
+ return self.log_target
401
+ return min(self.log_target, int((time.monotonic() - self.log_t0) * self.log_hz))
402
+
403
+ def _axis_outside(self, idx: int, qmin: float, qmax: float) -> bool:
404
+ """`0x23` 门禁的核心判据 —— 逐条镜像固件 `ctrl_axis_outside`
405
+ (`litearm-stm32/User/litearm/control/control_loop.c:610-652`)。
406
+
407
+ 固件那六条 (命中任意一条即视为"新限位关不住执行器接下来会走到的地方"):
408
+ ① `idx` 越界 ② `!(qmin < qmax)` (空区间/NaN)
409
+ ③ `cart_state() != IDLE` ④ `sync_playing()`
410
+ ⑤ `g_arm.cmd[idx].q_ref` 越界 ⑥ `g_sc[idx].active && g_sc[idx].qf` 越界
411
+
412
+ **本桩能复现 ①②⑤ 三条**; 其余三条 (笛卡尔在途 / 同步轨迹在途 / 在途 S 曲线的
413
+ 终点 `qf`) **未建模** —— 桩不建模"运动过程", 没有在途状态可查。要在离线用例里
414
+ 造"固件拒绝"的形状, 用既有的 `err_override[cmd] = 0x03` 注入, 效果等价。
415
+
416
+ ⚠ **⑤ 的载体是代理关系, 不是近似 —— 且只在两个命令上精确**: 固件查的是
417
+ `g_arm.cmd[idx].q_ref` (它在命令受理时就更新成新目标), 而桩里没有对应**字段**
418
+ (命令缓存与运动过程都不建模, 只有 `self.q`) —— 这里规定以 `self.q[idx]` 代理它。
419
+ 逐条核对本桩哪些分支**写** `self.q` (实测: 用裸 `write_frame` 打一遍, 看 `q[0]`
420
+ 是否动):
421
+
422
+ 精确: `0x01 MOVE_J` / `0x2A HOME` —— 这两个把 `self.q` 直接置成目标。
423
+ 停在旧值: `0x02 MOVE_P` (只写 `self.pose`) / `0x03 MOVE_JS` / `0x04 MOVE_MIT`
424
+ / `0x05 MOVE_MIT_ALL` (三条只回 ACK) / `0x07 MOVE_J_SYNC` (桩里**没有分支**,
425
+ 落到 `else: _ack`)。
426
+
427
+ ⚠ 于是上面五种情况里代理**失真** (固件 `q_ref` 已到新目标, 桩 `self.q` 还在旧值)。
428
+ 这属**潜伏**, 目前无任何用例能踩到: `0x07` 是最像的同族, 而 `Arm.movej_sync` 到
429
+ 不了位就抛 `MotionTimeoutError` (异目标实测), 控制回不到用例体; 唯一调用它的
430
+ 用例 (`test_full_coverage.py:36`) 给的目标与当前 `q` 相同, 于是立刻判到位、代理
431
+ 仍然准确。其余四条之后现有用例发的 `0x23` 要么根本不在武装态 (门禁整条跳过),
432
+ 要么区间容得下旧值 (`test_full_coverage.py:54` 的 `±3.0`)。
433
+ """
434
+ if not 0 <= idx < self.n:
435
+ return True # ① (固件收 uint8_t, 只有上界那一半会命中)
436
+ if not qmin < qmax:
437
+ return True # ② 空区间/NaN —— 固件注释: 交给 params 层判
438
+ return self.q[idx] < qmin or self.q[idx] > qmax # ⑤ (q_ref 的代理)
439
+
440
+ def arm_gravity(self, q):
441
+ """桩固件 `0x39` 报的「臂本体」项(`model_body` 里 body1..7 的质量和)。
442
+
443
+ 用例要用**同一个口径**造实测力矩时调它 —— 拿别的函数造,差分基线的姿态搬运
444
+ 就搬的是另一份模型,结果会跟着构型偏差漂。
445
+ """
446
+ return fake_model_gravity(q, sum(b[0] for b in self.model_body[1:]))
447
+
448
+ def _status_frame(self, q, dq=None, mode=1):
449
+ """状态帧 —— 力矩走 `tau_provider(q)` (有) 或静态 `self.tau` (无)。"""
450
+ tau = self.tau_provider(q) if self.tau_provider is not None else self.tau
451
+ return _status(q, dq=self.dq if dq is None else dq, mode=mode,
452
+ n=self.n, tau=tau)
453
+
454
+ # ---- SerialTransport 接口 ----
455
+ def close(self) -> None:
456
+ self.closed = True
457
+
458
+ @property
459
+ def is_open(self) -> bool:
460
+ return not self.closed
461
+
462
+ def write_frame(self, cmd: int, payload: bytes = b"") -> None:
463
+ if self.dfu_gone:
464
+ # 设备已进 ROM bootloader: 帧发不出去 (**不记账** —— 现实里它根本没进 CDC)
465
+ raise TransportError("桩: 设备已进 ROM bootloader, CDC 上的写路径失败")
466
+ with self._tx_lock:
467
+ self.tx_log.append((cmd, bytes(payload)))
468
+ self.tx_stamps.append(time.monotonic())
469
+ if cmd in self.unknown_cmds:
470
+ # 固件 usb_cmd.c 的 default 分支: 未实现命令只回 ERR{cmd,0x00}, 无任何副作用
471
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x00])))
472
+ return
473
+ if cmd in self.err_override:
474
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, self.err_override[cmd]])))
475
+ return
476
+ if cmd == P.CMD_ZERO_G:
477
+ self.zg_writes += 1
478
+ if self.zg_slow_write_s and payload == b"\x01" and self.zg_writes > 1:
479
+ time.sleep(self.zg_slow_write_s)
480
+ if self.zg_fail_after is not None and self.zg_writes > self.zg_fail_after:
481
+ raise TransportError("桩: CDC 写失败 (注入)")
482
+ self._push(_ack(cmd))
483
+ elif cmd == P.CMD_GET_FIRMWARE: # 握手
484
+ self._push(P.pack_frame(P.RSP_FIRMWARE, self.fw.encode()))
485
+ self.auto_status = True
486
+ self._push(self._status_frame(self.q, mode=0))
487
+ elif cmd == P.CMD_GET_STATUS:
488
+ self._push(self._status_frame(self.q))
489
+ elif cmd == P.CMD_SET_FF_VEC: # 存下来供 0x2B 读回
490
+ self.ff_vec[payload[0]] = list(P.unpack_f32s(payload, 1, self.n))
491
+ self._push(_ack(cmd))
492
+ elif cmd == P.CMD_SET_FF_SCALAR: # 存下来供 0x2C 读回
493
+ self.ff_scalar[(payload[0], payload[1])] = struct.unpack_from("<f", payload, 2)[0]
494
+ self._push(_ack(cmd))
495
+ elif cmd == P.CMD_SET_FF_FLAGS:
496
+ self.ff_mask = struct.unpack_from("<I", payload, 0)[0]
497
+ self._push(_ack(cmd))
498
+ elif cmd == P.CMD_GET_FF_VEC: # [readback] 载荷首字节 = RSP id
499
+ item = payload[0]
500
+ vals = self.ff_vec.get(item, [0.0] * self.n)
501
+ self._push(P.pack_frame(P.RSP_FF_VEC,
502
+ bytes([P.RSP_FF_VEC, item]) + P.pack_f32s(vals)))
503
+ elif cmd == P.CMD_GET_FF_SCALAR: # item 9 = ff_mask (只读)
504
+ item, sub = payload[0], payload[1]
505
+ val = float(self.ff_mask) if item == 9 else self.ff_scalar.get((item, sub), 0.0)
506
+ self._push(P.pack_frame(P.RSP_FF_SCALAR,
507
+ bytes([P.RSP_FF_SCALAR, item, sub])
508
+ + struct.pack("<f", val)))
509
+ elif cmd == P.CMD_ENABLE: # 跟踪使能态 (0x36 门禁用)
510
+ if not self.activated:
511
+ # 照 `control_loop.c:938-946`: 未激活是 `ctrl_enable()` 的**第一条**判据
512
+ # (优先于 EMERGENCY / joint_fault / CMODE 那些), 重发无用、无旁路。
513
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x08])))
514
+ else:
515
+ self.enabled = True
516
+ self._push(_ack(cmd))
517
+ elif cmd == P.CMD_GET_LICENSE: # 0x2F -> RSP_LICENSE(0x4F) 26B
518
+ # 逐条照 `usb_cmd.c:1016-1030`:
519
+ # · 请求**不校验长度** (有意放宽, 不是为了省事);
520
+ # · **未激活也回 UID**, 而 cust_id/issued/flags 保持 0;
521
+ # · 载荷首字节是 `state`, **不是**冗余帧 id (与 RSP_MODEL_STATUS 那类不同)。
522
+ st = 2 if (self.activated and self.license_flags & 0x1) else (
523
+ 1 if self.activated else 0)
524
+ body = bytearray([st, self.license_ver]) + self.license_uid
525
+ if self.activated:
526
+ body += struct.pack("<III", self.license_cust_id,
527
+ self.license_issued, self.license_flags)
528
+ else:
529
+ body += struct.pack("<III", 0, 0, 0)
530
+ self._push(P.pack_frame(P.RSP_LICENSE, bytes(body)))
531
+ elif cmd == P.CMD_ACTIVATE: # 0x3F: 28B -> ACK{0x3F} / ERR
532
+ # 逐条照 `usb_cmd.c:1031-1081` 的顺序: 长度 -> 武装 -> flash 忙 -> 写。
533
+ if len(payload) < 28:
534
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
535
+ elif self.armed:
536
+ # ⚠ 用统一的 `armed` 判据 (固件 `ctrl_is_armed()`), 不是裸 `self.enabled`
537
+ # —— 由 `test_full_coverage.py::test_all_gates_share_the_one_armed_predicate` 钉住。
538
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x04])))
539
+ elif self.license_fail_next or self.activated:
540
+ # 0x02 是**聚合档** —— "已经激活过"与"MAC 不符/写失败"混在一起。
541
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
542
+ else:
543
+ (self.license_cust_id, self.license_issued,
544
+ self.license_flags) = struct.unpack_from("<III", payload, 0)
545
+ self.activated = True
546
+ self._push(_ack(cmd))
547
+ elif cmd == P.CMD_ENTER_DFU:
548
+ # [DFU] 0x15 —— 逐条照 `usb_cmd.c:543-555` + `control_loop.c:1087-1099`:
549
+ # 长度优先, 再两道门禁 (使能 / ROM 向量表), 最后**只登记**。
550
+ # ⚠ 桩**不模拟跳转本身** (真固件交棒给 main 线程, 上界 100ms); 它模拟的是
551
+ # 主机**看得见**的那一半: ACK 之后设备是否还在 CDC 上 (`dfu_vanishes`)。
552
+ if payload:
553
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
554
+ elif self.armed:
555
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x03])))
556
+ elif self.dfu_rom_table_invalid:
557
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
558
+ else:
559
+ self._push(_ack(cmd)) # ACK{0x15} = "已登记"
560
+ for extra_cmd, extra_payload in self.dfu_post_ack_frames:
561
+ self._push(P.pack_frame(extra_cmd, extra_payload))
562
+ if self.dfu_vanishes:
563
+ self.dfu_gone = True
564
+ elif cmd == P.CMD_DISABLE:
565
+ self.enabled = False
566
+ self._push(_ack(cmd))
567
+ elif cmd in (P.CMD_EMERGENCY_STOP,
568
+ P.CMD_CLEAR_FAULTS, P.CMD_RESET, P.CMD_SET_SPEED_PERCENT,
569
+ P.CMD_SET_MOTION_MODE, P.CMD_MOVE_JS, P.CMD_MOVE_MIT,
570
+ P.CMD_MOVE_MIT_ALL, P.CMD_FF_PRESET):
571
+ self._push(_ack(cmd))
572
+ elif cmd == P.CMD_SET_JOINT_PARAM: # 0x22: idx + kp,kd,tau_max
573
+ if len(payload) < 13:
574
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
575
+ elif payload[0] >= self.n:
576
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
577
+ else:
578
+ kp, kd, tm = struct.unpack_from("<fff", payload, 1)
579
+ self.joint_params[payload[0]].update(kp=kp, kd=kd, tau_max=tm)
580
+ self._push(_ack(cmd))
581
+ elif cmd == P.CMD_SET_JOINT_LIMITS: # 0x23: idx + q_min,q_max
582
+ # ⚠ 分支顺序**逐条照固件** `usb_cmd.c:625-641` 的 case: 长度(0x01) → **门禁
583
+ # (0x03)** → idx/空区间(0x02)。门禁排在 idx 校验**之前**不是可选风格: 固件对
584
+ # "武装 + idx 越界" 回 `0x03` (`ctrl_axis_outside` 第①条命中), 桩把 idx
585
+ # 提前就回 `0x02` —— 这一格与真机**相反**, 而所有走 SDK 入口的用例都看不见
586
+ # (SDK 在本地 `_check_idx` 就拦了)。
587
+ #
588
+ # ⚠ **`0x02` 这一层只建模了 `idx` / 空区间两格, 不是"值校验完整"**。固件那条
589
+ # `0x02` 来自 `params_set_joint_limits` (`params.c:59-92`), 它还有两个桩**没有**
590
+ # 的层:
591
+ # ① **钳位层** (`[N-6 fix]`): 两个值先各自钳到本关节**编译期**软限位
592
+ # (`joint_limit_macros.h` 的 JL_QMIN/MAX), 钳后 `nmin >= nmax` 才回 `0x02`。
593
+ # 桩直接存原值 ⇒ 两格假绿:
594
+ # `set_joint_limits(0, 5.0, 6.0)` (失能): 固件 clamp(5.0)=clamp(6.0)
595
+ # =2.809547 ⇒ **ERR{0x23,0x02}**; 桩 **ACK 并把 (5.0,6.0) 存进参数表**。
596
+ # `set_joint_limits(0, -15.0, 15.0)`: 固件 ACK 但**静默收窄**到 ±2.809547;
597
+ # 桩**原样存 ±15** (放宽 = 等价关掉 safety_check 的位置包络)。
598
+ # ② **`[P2-12]` 层**: `fresh` 且有反馈时, 把**当前实测位置**关在新区间外的
599
+ # 请求也回 `0x02` (该锁存不受 `enabled` 门控 ⇒ "设成功了但臂锁死")。
600
+ # 本段 (段三 §6.4) 只要求门禁一层, 故**不实现**这两层 —— 残余已记在
601
+ # `docs/superpowers/plans/2026-09-19-litearm-python-migration-stage3.md`
602
+ # 的"本段明确不做"表 (含上面两条可失败证据), 别让它悄悄消失。
603
+ if len(payload) < 9:
604
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
605
+ else:
606
+ idx = payload[0]
607
+ lo, hi = struct.unpack_from("<ff", payload, 1)
608
+ if self.armed and self._axis_outside(idx, lo, hi):
609
+ # [B1 fix] 武装中该轴的当前参考必须落在新区间内, 否则 ACK 之后在途
610
+ # 轨迹仍奔向收窄前的旧目标 ⇒ 轴被开出新软限位 ⇒ 位置包络锁存。
611
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x03])))
612
+ elif idx >= self.n or not lo < hi:
613
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
614
+ else:
615
+ self.joint_params[idx].update(q_min=lo, q_max=hi)
616
+ self._push(_ack(cmd))
617
+ elif cmd == P.CMD_GET_JOINT_PARAM: # 0x24 -> RSP_JOINT_PARAM(0x49)
618
+ if len(payload) < 1:
619
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
620
+ elif payload[0] >= self.n:
621
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
622
+ else:
623
+ jp = self.joint_params[payload[0]]
624
+ body = bytes([payload[0]]) + struct.pack(
625
+ "<fffff", jp["kp"], jp["kd"], jp["tau_max"], jp["q_min"], jp["q_max"])
626
+ self._push(P.pack_frame(P.RSP_JOINT_PARAM, body))
627
+ elif cmd == P.CMD_PARAM_SAVE: # 0x25: 须失能 (固件 ctrl_is_armed)
628
+ # [M7] 使能运动中禁止整扇区擦写 (H723 单 bank 无 RWW, 擦写窗口 CPU 停顿,
629
+ # 电机无监督运行): 先 DISABLE 再发 PARAM_SAVE。
630
+ # 门禁判据 = `self.armed` (= 固件 `ctrl_is_armed()`), **含 `enable_pending`
631
+ # 那一半**: 固件为此专门改过一次 (`usb_cmd.c:668-670` [M2 fix] 的理由就是
632
+ # "登记→执行间隙 pending 自动完成也会武装电机"), 且执行侧 (`usb_cmd.c:1274`)
633
+ # 还有一道复查。ACK 只表示"已登记", 擦写在 main 循环里做, 失败另回 0x25/0x03。
634
+ if self.armed:
635
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x04])))
636
+ else:
637
+ self._push(_ack(cmd))
638
+ elif cmd == P.CMD_PARAM_RESET: # 0x36: 须失能
639
+ if self.armed:
640
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x04])))
641
+ else:
642
+ self.joint_params = [dict(self._jp_default) for _ in range(self.n)]
643
+ self._push(_ack(cmd))
644
+ # ---- [2026-09-14] 动力学模型在线导入 ----
645
+ elif cmd == P.CMD_SET_MODEL_PARAM: # 0x30: body_idx + f32[10] -> staging
646
+ if len(payload) < 1 + 4 * 10:
647
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
648
+ elif payload[0] >= self.model_nbody:
649
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
650
+ else:
651
+ vals = list(struct.unpack_from("<" + "f" * 10, payload, 1))
652
+ if any(v != v or abs(v) > 1e6 for v in vals):
653
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
654
+ elif payload[0] == self.model_nbody - 1 and vals[0] != 0.0:
655
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
656
+ else:
657
+ self.model_stage_body[payload[0]] = vals
658
+ self.model_dirty = True
659
+ self._push(_ack(cmd))
660
+ elif cmd == P.CMD_SET_MODEL_JM: # 0x33: f32[7] -> staging
661
+ if len(payload) < 4 * 7:
662
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
663
+ else:
664
+ vals = list(struct.unpack_from("<" + "f" * 7, payload, 0))
665
+ if any(v != v or abs(v) > 1e6 for v in vals):
666
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
667
+ else:
668
+ self.model_stage_jm = vals
669
+ self.model_dirty = True
670
+ self._push(_ack(cmd))
671
+ elif cmd == P.CMD_MODEL_COMMIT: # 0x32: u16 mask LE
672
+ if len(payload) < 2:
673
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
674
+ elif self.armed:
675
+ # ⚠ 与固件同序: 门控优先于掩码检查
676
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x04])))
677
+ else:
678
+ mask = struct.unpack_from("<H", payload, 0)[0]
679
+ staged = 0
680
+ for i in self.model_stage_body:
681
+ staged |= (1 << i)
682
+ if self.model_stage_jm is not None:
683
+ staged |= (1 << 9)
684
+ if mask != staged:
685
+ # 掩码不符 -> 0x07 (与"数值非法"分开的码)
686
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x07])))
687
+ elif mask & ~0x3FF:
688
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
689
+ else:
690
+ for i, v in self.model_stage_body.items():
691
+ self.model_body[i] = v
692
+ if self.model_stage_jm is not None:
693
+ self.model_jm = self.model_stage_jm
694
+ self.model_override = True
695
+ self.model_stage_body = {}
696
+ self.model_stage_jm = None
697
+ self._push(_ack(cmd))
698
+ elif cmd == P.CMD_GET_MODEL_PARAM: # 0x34 -> RSP_MODEL_PARAM(0x54)
699
+ if len(payload) < 1:
700
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
701
+ elif payload[0] >= self.model_nbody:
702
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
703
+ else:
704
+ idx = payload[0]
705
+ body = bytes([P.RSP_MODEL_PARAM, idx]) + struct.pack(
706
+ "<" + "f" * 10, *self.model_body[idx])
707
+ self._push(P.pack_frame(P.RSP_MODEL_PARAM, body))
708
+ elif cmd == P.CMD_GET_MODEL_JM: # 0x35 -> RSP_MODEL_JM(0x55)
709
+ body = bytes([P.RSP_MODEL_JM]) + struct.pack("<" + "f" * 7, *self.model_jm)
710
+ self._push(P.pack_frame(P.RSP_MODEL_JM, body))
711
+ elif cmd == P.CMD_REVERT_MODEL: # 0x37: 只回退模型
712
+ if self.armed:
713
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x04])))
714
+ else:
715
+ self.model_override = False
716
+ self.model_stage_body = {}
717
+ self.model_stage_jm = None
718
+ self.model_dirty = True
719
+ self._push(_ack(cmd))
720
+ elif cmd == P.CMD_GET_MODEL_STATUS: # 0x38 -> RSP_MODEL_STATUS(0x56)
721
+ staged = 0
722
+ for i in self.model_stage_body:
723
+ staged |= (1 << i)
724
+ if self.model_stage_jm is not None:
725
+ staged |= (1 << 9)
726
+ body = bytes([P.RSP_MODEL_STATUS, 1 if self.model_override else 0]) + \
727
+ struct.pack("<H", staged) + bytes([1 if self.model_dirty else 0])
728
+ self._push(P.pack_frame(P.RSP_MODEL_STATUS, body))
729
+ elif cmd == P.CMD_GET_GRAVITY: # 0x39 -> RSP_GRAVITY(0x57)
730
+ if len(payload) < 4 * 7:
731
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
732
+ else:
733
+ # 假固件的 G(q) = 臂本体项 + 「固件里当前 payload」的项。前者是旧桩那个
734
+ # 确定性可预测的式子 (只为断言"帧格式对、值能被 SDK 解出来"), 后者走
735
+ # `fake_payload_gravity`, 与真机同一条契约: payload 改了 G(q) 就跟着改,
736
+ # 且 payload=0 时贡献恒为 0 —— 于是既有用例读到的值逐字节不变。
737
+ q = struct.unpack_from("<" + "f" * 7, payload, 0)
738
+ g = self.arm_gravity(q)
739
+ mass = self.ff_scalar.get((4, 0), 0.0)
740
+ if mass > 0.0:
741
+ com = tuple(self.ff_scalar.get((5, k), 0.0) for k in range(3))
742
+ extra = fake_payload_gravity(q, mass, com)
743
+ g = [a + b for a, b in zip(g, extra)]
744
+ body = bytes([P.RSP_GRAVITY]) + struct.pack("<" + "f" * 7, *g)
745
+ self._push(P.pack_frame(P.RSP_GRAVITY, body))
746
+ elif cmd == P.CMD_KIN_BENCH: # 0x49 -> RSP_KIN_BENCH(0x4A) 文本
747
+ # ⚠ **真机是连续两帧**, 不是一帧 (固件 `[H-9 fix 2026-09-13]`): 单帧载荷
748
+ # 上限 255B 而全文 ~306B, 原单帧会在 253B 处截断, 截断点恰好落在 LINK 行
749
+ # 的 `loop_k=` 之后 ⇒ 链路计数全丢。
750
+ # 桩从前把全文塞一帧, 于是"只收一帧"的 SDK **离线全绿、真机上丢掉全部
751
+ # 链路计数** —— 而丢得静默 (`link.get(k, 0)` 报 0), 与"没出错"分不开。
752
+ for part in _split_kin_bench(self.kin_bench_text,
753
+ one_frame=self.kin_bench_one_frame):
754
+ self._push(P.pack_frame(P.RSP_KIN_BENCH, part.encode()))
755
+ elif cmd == P.CMD_LOG_CTRL: # 0x2D: u32 n_ticks (0=停/清)
756
+ if len(payload) < 4:
757
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
758
+ else:
759
+ n = struct.unpack_from("<I", payload, 0)[0]
760
+ if n > self.log_max:
761
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x02])))
762
+ else:
763
+ self.log_target = int(n)
764
+ self.log_active = bool(n)
765
+ self.log_target = int(n)
766
+ if self.log_hz > 0 and n:
767
+ self.log_t0 = time.monotonic() # 逐拍记录: 此刻还没有数据
768
+ self.log_bytes = b""
769
+ self.log_recorded_ticks = 0
770
+ else:
771
+ self.log_t0 = None
772
+ self.log_bytes = _mk_log(int(n), self.n) if n else b""
773
+ self.log_recorded_ticks = int(n)
774
+ self._push(_ack(cmd))
775
+ elif cmd == P.CMD_LOG_READ: # 0x2E: u32 offset -> RSP_LOG_DATA
776
+ if len(payload) < 4:
777
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
778
+ elif self.log_read_fail_once:
779
+ self.log_read_fail_once = False # 掉帧: 什么都不回
780
+ else:
781
+ self._log_read_calls += 1
782
+ if self.log_cursor_stuck:
783
+ # 第一次正常前进到 245, 此后永远回同一个游标 (next == off != 0)。
784
+ # 读者若无"无进展"保护就会死循环 —— 这里用次数上限把死循环
785
+ # 变成测试失败而不是挂死。
786
+ if self._log_read_calls > 50:
787
+ raise AssertionError(
788
+ "LOG_READ 游标不前进却一直被读 —— 读者缺无进展保护(会死循环)")
789
+ off0 = struct.unpack_from("<I", payload, 0)[0]
790
+ nxt = 245 if off0 == 0 else off0
791
+ n = 245 if off0 == 0 else 0
792
+ body = struct.pack("<IIB", 999999, nxt, n) + b"\x00" * n
793
+ self._push(P.pack_frame(P.RSP_LOG_DATA, body))
794
+ return
795
+ if self.log_hz > 0 and self.log_t0 is not None:
796
+ # 逐拍记录: 按**经过的时间**推进 (与本次读回次数无关)。
797
+ # 只在拍数真的变了才重建缓冲 —— 否则每次读回都 O(全缓冲) 重建,
798
+ # 几百块读回会白白拖慢几秒 (固件侧只是 memcpy 245B)。
799
+ cur = self._log_recorded()
800
+ if cur != self.log_recorded_ticks:
801
+ self.log_recorded_ticks = cur
802
+ self.log_bytes = _mk_log(cur, self.n)
803
+ off = struct.unpack_from("<I", payload, 0)[0]
804
+ total = len(self.log_bytes)
805
+ n = min(total - off, 245) if off < total else 0
806
+ nxt = (off + n) if (off + n) < total else 0
807
+ body = struct.pack("<IIB", total, nxt, n) + self.log_bytes[off:off + n]
808
+ self._push(P.pack_frame(P.RSP_LOG_DATA, body))
809
+ elif cmd == P.CMD_HOME: # 固件: 各轴低速回 URDF 零位
810
+ self._push(_ack(cmd))
811
+ self.q = [0.0] * self.n
812
+ for _ in range(3):
813
+ self._push(self._status_frame(self.q, dq=[1.0] * self.n))
814
+ for _ in range(6):
815
+ self._push(self._status_frame(self.q, dq=[0.0] * self.n))
816
+ elif cmd == P.CMD_MOVE_J:
817
+ self._push(_ack(cmd))
818
+ q = P.unpack_f32s(payload, 0, self.n) # payload: q×N + sp
819
+ self.q = [float(v) for v in q]
820
+ # 先 3 拍"运动中"(dq=1) 再 6 拍到位(dq=0) —— 检验 Arm 到位等待
821
+ for _ in range(3):
822
+ self._push(self._status_frame(self.q, dq=[1.0] * self.n))
823
+ for _ in range(6):
824
+ self._push(self._status_frame(self.q, dq=[0.0] * self.n))
825
+ elif cmd == P.CMD_MOVE_P:
826
+ self._push(_ack(cmd))
827
+ self.pose = [float(v) for v in P.unpack_f32s(payload, 0, 6)]
828
+ self._push(self._status_frame(self.q, dq=[0.0] * self.n)) # get_state refresh 用
829
+ elif cmd == P.CMD_GET_TCP:
830
+ pose = list(self.pose)
831
+ if self.pos_override is not None:
832
+ pose[:3] = list(self.pos_override)
833
+ if self.rpy_override is not None:
834
+ pose[3:] = list(self.rpy_override)
835
+ self._push(P.pack_frame(P.RSP_TCP, P.pack_f32s(pose)))
836
+ elif cmd == P.CMD_GET_IK:
837
+ qs = [0.1, -0.2, 0.3, -0.1, 0.2, -0.05, 0.15]
838
+ self._push(P.pack_frame(P.RSP_IK, P.pack_f32s(qs) + b"\x01"))
839
+ elif cmd in _CART_MIN_LEN: # [笛卡尔] 0x3A~0x3E
840
+ self._cart_cmd(cmd, payload)
841
+ else:
842
+ self._push(_ack(cmd))
843
+
844
+ def _cart_cmd(self, cmd: int, payload: bytes) -> None:
845
+ """[笛卡尔] 0x3A~0x3E 的桩行为 —— 长度校验 -> 受理(ACK) -> 规划结果(0x4E)。
846
+
847
+ ⚠ 桩**不**建模使能/零重力/掉线锁存三道门禁 (那是另一件事): 要在离线用例里造
848
+ "被门禁拒", 用既有的 `err_override[cmd] = 0x03` 注入 `RSP_ERR`, 效果等价。
849
+
850
+ ⚠ **规划成功时桩把 `pose` 直接置成该命令的目标**: 到位之后回读的 TCP 必须与目标
851
+ 对得上 (`Arm._pose_near` 那条判据), 否则每条 `wait=True` 的用例都会拿到
852
+ `settled=False`。桩不建模运动过程 (没有中间位姿), 只保证"收尾时 TCP 在目标上"
853
+ —— 这也是固件的真实行为。要造"**没**走到目标"的形状 (轨迹被别的运动作废), 用
854
+ `pos_override` 覆盖回读值。
855
+ """
856
+ if not self.cart_supported:
857
+ # 固件关掉 LITEARM_CART_PLAN 时这 5 条整段不存在 -> default 分支
858
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x00])))
859
+ return
860
+ if len(payload) < _CART_MIN_LEN[cmd]:
861
+ self._push(P.pack_frame(P.RSP_ERR, bytes([cmd, 0x01])))
862
+ return
863
+ if cmd == P.CMD_CART_ADD: # 记住最后一个路点 (RUN 的目标)
864
+ self._path_last_pose = list(P.unpack_f32s(payload, 1, 6))
865
+ self._push(_ack(cmd)) # 受理即回 ACK (固件 cart_reply)
866
+ if cmd in _CART_PLANNING_CMDS:
867
+ if self.cart_err_override is None: # 规划成功才会真的动
868
+ self.pose = self._cart_goal(cmd, payload)
869
+ # 起规划 -> 稍后一条 0x4E。桩立刻发 (固件要等 10ms~1s, 但配对语义相同)。
870
+ self.cart_busy_seq = 0 # 状态帧开始按写死的帧序带 bit10
871
+ self._push(self._cart_plan_frame())
872
+
873
+ def _cart_goal(self, cmd: int, payload: bytes) -> list:
874
+ """该命令把 TCP 开到哪儿 (载荷里就是目标位置: `move_l` 前 6 f32 / `move_c` 的后
875
+ 6 f32 / `RUN` 取**最后一个 ADD**)。姿态部分桩不建模 rpy 规范化, 原样留着。"""
876
+ if cmd == P.CMD_MOVE_L:
877
+ return list(P.unpack_f32s(payload, 0, 6))
878
+ if cmd == P.CMD_MOVE_C:
879
+ return list(P.unpack_f32s(payload, 24, 6))
880
+ return list(self._path_last_pose) # 0x3E RUN
881
+
882
+ def _cart_plan_frame(self) -> bytes:
883
+ """`RSP_CART_PLAN (0x4E)` 响应帧: `ok u8 + err u8 + n_wp u16 LE + plan_us u32 LE`。
884
+
885
+ 规划失败**也要发**这一条 (固件契约) —— 只回 ACK 的话上位机会以为臂在动。
886
+ """
887
+ if self.cart_err_override is not None:
888
+ # ok=0 时 n_wp / plan_us 一律 0 (固件同款: 失败时那两个数没有意义)
889
+ return P.pack_frame(P.RSP_CART_PLAN,
890
+ bytes([0, self.cart_err_override]) + struct.pack("<HI", 0, 0))
891
+ return P.pack_frame(P.RSP_CART_PLAN, bytes([1, 0]) + struct.pack(
892
+ "<HI", self.cart_n_wp, self.cart_plan_us))
893
+
894
+ def push_frame(self, cmd: int, payload: bytes = b"") -> None:
895
+ """**注帧口**: 往响应队列里塞一帧 (不需要先有一条下行命令)。
896
+
897
+ 给"手工造一条上行帧"的用例用 (例: 往队列里放一条 `RSP_CART_PLAN` 再让读循环认领)。
898
+ """
899
+ self._push(P.pack_frame(cmd, payload))
900
+
901
+ def read_frame(self, timeout: Optional[float] = None) -> Optional[Tuple[int, bytes]]:
902
+ if self.dfu_gone and not self._resp:
903
+ # ⚠ 队列**先投完**再抛: 现实里登记 ACK 必须在跳转之前出 USB
904
+ # (固件为此专门有 `DFU_MIN_DELAY_TICKS`/`usb_cmd_reply_idle()` 两道门控),
905
+ # 桩若先把 ACK 吞掉, SDK 那条 ACK 等待会超时 —— 形状就错了。
906
+ raise TransportError("桩: 设备已进 ROM bootloader (CDC 上的读路径抛错)")
907
+ if self._resp:
908
+ fr = P.unpack_frame(self._resp.pop(0))
909
+ elif self.auto_status:
910
+ # 模拟 100Hz 主动状态流 (空闲帧) —— ⚠ 按 `auto_status_period` 节流:
911
+ # 不节流的话读线程会以桩的最高速率空转 (理由见该字段的声明)。
912
+ now = time.monotonic()
913
+ if now - self._auto_status_last < self.auto_status_period:
914
+ return None
915
+ self._auto_status_last = now
916
+ fr = P.unpack_frame(self._status_frame(self.q))
917
+ else:
918
+ fr = None
919
+ return self._stamp_status(fr)
920
+
921
+ def _stamp_status(self, fr):
922
+ """给**交付给 SDK 的**状态帧盖三样固件属性: 帧序号 `seq`、`enabled` (bit9)、
923
+ `CART_BUSY` (bit10)。
924
+
925
+ * `seq` (`g_arm.seq`, 头部偏移 2..4) **每交付一帧 +1** —— 到位判据的新鲜度闸
926
+ 判的就是它 ("这一帧是不是命令之后生成的")。不递增的话每帧都会被判成陈旧帧。
927
+ * `CART_BUSY` 帧序**写死**: 收到 `0x4E` 之后的第 2 帧置 1、第 8 帧落 0
928
+ (即第 2..7 帧为 1)。不写死会有两种坑: 常亮 -> 到位等待耗满 `move_timeout`
929
+ 才失败 (全量红); 从不置位 -> 只能走"没见过 1"的降级分支, 覆盖不到"先见 1"
930
+ 那条主判据。
931
+ * `enabled` (bit9) —— 固件那一位报的就是 `g_arm.enabled`, 桩必须跟着自己的
932
+ `self.enabled` 走。⚠ 这一位**不是**可选的: `Arm.enter_dfu()` 的本地使能态预检
933
+ 读的就是它 —— 桩不报的话预检恒不触发, 于是"使能中不许进 DFU"那条判据在离线
934
+ 用例里**测不到** (帧会一路发到桩的门禁, 变成 `ERR{0x15,0x03}`, 形状就错了)。
935
+
936
+ 三样都在**交付点** (而不是 `_push`) 盖: 这里盖的才是 SDK 真正看到的那一帧,
937
+ 无论它来自命令应答还是 `auto_status` 的空闲流。
938
+ """
939
+ if fr is None or fr[0] != P.RSP_STATUS:
940
+ return fr
941
+ self.status_seq = (self.status_seq + 1) & 0xFFFF
942
+ c, payload = fr
943
+ payload = payload[:2] + struct.pack("<H", self.status_seq) + payload[4:]
944
+ flags = struct.unpack_from("<H", payload, 0)[0]
945
+ if self.enabled:
946
+ flags |= (1 << P.FLAG_ENABLED_BIT)
947
+ else:
948
+ flags &= ~(1 << P.FLAG_ENABLED_BIT)
949
+ if self.cart_busy_seq is not None:
950
+ self.cart_busy_seq += 1
951
+ n = self.cart_busy_seq
952
+ if n > 8: # 第 8 帧已落 0 -> 这次规划结束, 停止计数
953
+ self.cart_busy_seq = None
954
+ elif 2 <= n <= 7:
955
+ flags |= (1 << 10)
956
+ return (c, struct.pack("<H", flags) + payload[2:])
957
+
958
+ def _push(self, frame: bytes) -> None:
959
+ self._resp.append(frame)