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/cart.py
ADDED
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
"""笛卡尔固件规划的**应答配对** —— `0x4E` 与在途请求的 FIFO 配对。
|
|
2
|
+
|
|
3
|
+
固件原生笛卡尔 (`0x3A/0x3B/0x3E`) 的应答载荷里**没有命令 id**, 只能按**受理顺序**
|
|
4
|
+
FIFO 配对; 而固件那份应答只有**一个槽位** (`plan_pending` 单 bool + 单份载荷):
|
|
5
|
+
同一个 main 排空窗口内登记 ≥3 条时, **中段请求零应答** —— N 条只存活
|
|
6
|
+
「第一条被取代者的 CANCELED」+「最后一条的结果」= 2 条。于是两条守卫**缺一不可**:
|
|
7
|
+
|
|
8
|
+
* **多了一条** 分两支。清队**销毁在途记账**, 于是后来那条 `0x4E` 归属不明 —— 固件的应答
|
|
9
|
+
是在**规划完成时**就推上 USB 的, 而主机清队发生在**我们写那条清队 opcode 的那一刻**;
|
|
10
|
+
两者之间隔着 USB 延迟。所以"清队那一刻固件那条规划**已经跑完**、应答早躺在主机 RX
|
|
11
|
+
缓冲里"是**可达**的一支, 它属于**刚被我们清掉的那条请求**。而 `0x4E` 载荷里**没有命令
|
|
12
|
+
id**, "这条应答属于被作废那条还是新发那条"**在原理上无法区分** ⇒ 任何 FIFO 方案必有
|
|
13
|
+
一支判错, **只能选判错得安全的那一支: 宁可报"结局未知", 绝不报"成功"**:
|
|
14
|
+
- **清队后的不可归属应答** (吸收额度 > 0) → 计数 + **吸收** (`absorbed_replies`),
|
|
15
|
+
不报错 (报错会让 `LiteArmError` 从毫不相干的读路径里炸出来, 并把一条**已经成功**
|
|
16
|
+
的运动报成"结局未知" —— abort/急停路径天生是"清队紧跟笛卡尔命令"的形状);
|
|
17
|
+
- **真·脱同步** (额度耗尽且队列空) → 计数 + **报错**, **不静默丢弃**。
|
|
18
|
+
* **少了一条** (token 超时没收尾) → 摘除该 token 并抛 :class:`CartReplyLostError`
|
|
19
|
+
—— 缺了这条, 后续的 `0x4E` 会被错配给一条早已被吞掉的请求。
|
|
20
|
+
|
|
21
|
+
⚠ **串行是强制的 (不是建议)**, 由 `Arm._cart_serial` 那把锁实现 —— 上面两条守卫
|
|
22
|
+
**都只在串行下成立**, 同时 ≥2 条在途时它们会一起判错:
|
|
23
|
+
|
|
24
|
+
* **顺序必须有唯一来源** (最根本的一条, 也是锁不可省的理由): `_request_and_wait` 是
|
|
25
|
+
**先登记、后写**, 而配对按**受理顺序** FIFO ⇒ 没有锁时"两条线程的登记顺序"与"固件
|
|
26
|
+
的受理顺序"可以**倒置** (线程在登记之后、写之前被抢占) ⇒ 两条应答各自配给**对方**
|
|
27
|
+
那条 token; `0x4E` 载荷里没有命令 id, 原理上不可区分。一侧失败、另一侧被报成成功
|
|
28
|
+
(即**假成功**), 或两条都报成功而规划结果互换。**这一条 `err_waits_for_ack` 挡不住**
|
|
29
|
+
(它挡的是"认错帧", 不是"顺序倒置")。
|
|
30
|
+
* **两条 `expect` 会互相吃帧**: `RSP_ERR`/`RSP_ACK` 的回显只有**命令码**
|
|
31
|
+
(`expect(echo_cmd=...)` 只能按码比), 而 `0x3A/0x3B/0x3E` **共用码空间** ⇒ 后受理那条
|
|
32
|
+
被门禁拒的 `ERR` 可能被**先受理那条**的 `expect` 读走, 而先受理者的 `ACK` 也被后受理者
|
|
33
|
+
读走 ⇒ **被拒那条报的不是它自己那条 `ERR`**, 而是耗满窗口的 `MotionTimeoutError` /
|
|
34
|
+
`CartReplyLostError` (报告与物理事实相反; 实测见
|
|
35
|
+
`test_cart_protocol.py::test_two_concurrent_requests_cannot_cross_deliver`, 那条用例
|
|
36
|
+
现在**靠"逐线程归因"那一条判据咬住** —— 前两条在 `err_waits_for_ack` 之后已不再区分)。
|
|
37
|
+
⚠ 这一支的**旧**形态 ("先受理者摘掉自己的 token ⇒ 后受理者拿到它那条 `0x4E` 而报成功")
|
|
38
|
+
被 `err_waits_for_ack` 消掉了, 别再照旧文档复述。
|
|
39
|
+
|
|
40
|
+
单槽 pending 决定了"流水线发多条"还会额外踩上丢应答路径。三条入口的
|
|
41
|
+
`_request_and_wait` **全程持锁** (登记 → 等 ACK → 等 `0x4E` → (`wait=True` 时) 等停稳),
|
|
42
|
+
代价是持锁期间可能阻塞到 `move_timeout` —— `move_path` 另把 BEGIN/ADD 也圈进同一把锁
|
|
43
|
+
(见 `move_path`)。
|
|
44
|
+
|
|
45
|
+
⚠ **线程安全**: 零重力保活线程 (`Arm._zg_keepalive`) 会并发调用
|
|
46
|
+
`clear_pending()` —— 本模块的全部状态变更都在自己的锁内。
|
|
47
|
+
|
|
48
|
+
本模块的两半:
|
|
49
|
+
|
|
50
|
+
* **配对** (`_CartPending` / `_CartToken`) —— 只持有"谁在等哪条应答"这一个事实;
|
|
51
|
+
* **结果语义** (`CartPlan` / `cart_err_t` 映射 / 三条入口 / 能力探测) —— 见文件末。
|
|
52
|
+
三条入口 (`move_l` / `move_c` / `move_path`) 的**全部守卫都排在 `request()` 之外**
|
|
53
|
+
(理由见 `_CartPending.request` 的 docstring: `request()` 只在 `TransportError` 上
|
|
54
|
+
摘 token, 其余异常一律原样 re-raise 且**不动队列** —— 写之前抛的非 `TransportError`
|
|
55
|
+
异常会把 token 留在队里)。
|
|
56
|
+
"""
|
|
57
|
+
from __future__ import annotations
|
|
58
|
+
|
|
59
|
+
import struct
|
|
60
|
+
import threading
|
|
61
|
+
import time
|
|
62
|
+
from dataclasses import dataclass, field
|
|
63
|
+
from typing import Callable, List, Optional
|
|
64
|
+
|
|
65
|
+
from litearm import _protocol as P
|
|
66
|
+
from litearm._rot import as_pose, mat_to_rpy
|
|
67
|
+
from litearm.errors import (CartReplyLostError, CartesianPlanError,
|
|
68
|
+
CommandRejectedError, IKError,
|
|
69
|
+
InvalidCommandError, LiteArmError,
|
|
70
|
+
MotionSupersededError, MotionTimeoutError,
|
|
71
|
+
MotorFaultError, TransportError,
|
|
72
|
+
UnsupportedByFirmwareError)
|
|
73
|
+
|
|
74
|
+
__all__ = ["CartPlan"]
|
|
75
|
+
|
|
76
|
+
#: 会**静默作废**在途笛卡尔规划的 opcode (固件侧走 `cart_invalidate_before_motion()`
|
|
77
|
+
#: → `cart_abort()`, **一条 `0x4E` 都不发**)。
|
|
78
|
+
#:
|
|
79
|
+
#: 逐条依据 (固件运行路径的调用点): `ctrl_accept_move_j` (0x01, 且 0x2A home 复用同一处)
|
|
80
|
+
#: / `kin_runner_request_move_p` (0x02) / `ctrl_accept_move_js` (0x03) /
|
|
81
|
+
#: `ctrl_accept_move_mit` (0x04) / `ctrl_accept_move_mit_all` (0x05) /
|
|
82
|
+
#: `ctrl_accept_zero_g` (0x06) / `ctrl_accept_move_j_sync` (0x07) / `ctrl_disable` (0x11) /
|
|
83
|
+
#: `ctrl_emergency_stop` (0x12) / `ctrl_clear_faults` (0x13) / `ctrl_reset` (0x14)。
|
|
84
|
+
#:
|
|
85
|
+
#: ⚠ `0x3A/0x3B/0x3E` **不在**此列 —— 它们欠应答, 而且**欠两条**: 被取代的那条在
|
|
86
|
+
#: PLANNING 期由 `cart_begin_request` 补一条 CANCELED, 新受理那条随后还有自己的结果。
|
|
87
|
+
#: 清队会让报告与物理事实**相反**, 且**两个方向都反** (机制由
|
|
88
|
+
#: `tests/test_cart_queue.py::test_the_clear_set_must_not_swallow_the_two_known_replies`
|
|
89
|
+
#: 实地摆出来):
|
|
90
|
+
#:
|
|
91
|
+
#: ① **被摘掉的不止在飞那条**: `_request_and_wait` 是**先登记、后写**, 而清队钩子挂在
|
|
92
|
+
#: 写口 (`_raw_write`) 上 ⇒ 新受理那条的 token 刚登记、还没写出去就被**一起摘掉**
|
|
93
|
+
#: (实测 `clear_pending` 返回 2)。于是两条**都**被判"结局未知"
|
|
94
|
+
#: (`CartReplyLostError`) —— 而真相是"A 被 B **预期内**地接管 (不是失败)"与
|
|
95
|
+
#: "B 马上就要跑";
|
|
96
|
+
#: ② **随后那两条应答全被吸收**: 2 格额度正对 2 条应答 (A 的 CANCELED + B 的结果)
|
|
97
|
+
#: ⇒ 它们被当成"清队前就已发出的迟到应答"吞掉, **B 那条本该交付的规划结果被丢弃**。
|
|
98
|
+
#:
|
|
99
|
+
#: 一句话: 这 3 条 opcode 的应答是**已知**的, 清队把已知的东西报成了未知。
|
|
100
|
+
#:
|
|
101
|
+
#: ⚠ 上面这 ①② **不是**"调用方会收到 `MotionSupersededError`" —— 那个类型唯一的产出
|
|
102
|
+
#: 点是**读到** `err=5` 的 `0x4E` (见 :func:`raise_for_plan`), 而这里两条应答都被吸收,
|
|
103
|
+
#: 谁也没读到它。写"B 会收到接管错误、于是不去纠正"是**后果链说反** (旧版本文档就是
|
|
104
|
+
#: 这么写的); 真实代价是"**两条已知结局都退化成未知** + B 的结果被丢"。
|
|
105
|
+
#:
|
|
106
|
+
#: ⚠ 这张表只解释"**少发**"的一半: 突发下 N≥3 的吞应答**不经过**
|
|
107
|
+
#: `cart_invalidate_before_motion()` (是 `cart_begin_request` 的 `unreported` 分支吞的),
|
|
108
|
+
#: 那半边由 :meth:`_CartPending.wait` 的"少了一条"守卫兜住 ——
|
|
109
|
+
#: **别以为"没在这张表里 ⟹ 必有应答"**。
|
|
110
|
+
#:
|
|
111
|
+
#: ⚠ `0x2A` home 最易漏 (它经 `ctrl_accept_move_j` 受理, 与 `0x01` 同一处调用点)。
|
|
112
|
+
#:
|
|
113
|
+
#: ⚠ **为什么是 12 而不是 13** (2026-09-19 复核): 按**代码位置**数 `cart_invalidate_before_motion()`
|
|
114
|
+
#: 的调用点, `control_loop.c` 有 11 处 + `kin_runner.c:176` 共 12 处, 可其中
|
|
115
|
+
#: `control_loop.c:655` 那处在 `ctrl_accept_move_p` (`:654`) 里 —— 该函数**在固件运行
|
|
116
|
+
#: 路径上无人调用** (0x02 走的是 `kin_runner_request_move_p`, `usb_cmd.c:313`; 该函数的
|
|
117
|
+
#: 全部引用只有声明 `control_api.h:38`、定义本身、以及宿主自检工具
|
|
118
|
+
#: `tools/ctl_loop_check_host.c:1750` 的直调 —— 那是测试夹具, 不是运行路径), 即**不对应
|
|
119
|
+
#: 任何 opcode**; 而 `0x2A` 复用 `0x01` 的调用点、**没有自己的调用点**。
|
|
120
|
+
#: 两个 off-by-one 相抵 ⇒ 12 处调用点 ↔ **12** 个 opcode (不是 12+1=13, 也不是 12-1=11)。
|
|
121
|
+
#: 两条易错梗都记在这里, 免得后来者再数一遍得出 13。
|
|
122
|
+
_CART_CLEARS_UPON = frozenset({
|
|
123
|
+
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, # move_j/P/js/mit/mit_all/zero_g/j_sync
|
|
124
|
+
0x11, 0x12, 0x13, 0x14, 0x2A, # disable/estop/clear_faults/reset/home
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
#: "已收到应答但没人认领"那一列 (`_CartPending._unclaimed`) 的上界 —— 超出即丢**最旧**的。
|
|
129
|
+
#:
|
|
130
|
+
#: **为什么必须有界**: 调用方放弃 token 的那条路径 (ACK 超时, 见
|
|
131
|
+
#: `_request_and_wait`) 每放弃一条就在那一列永久留下一条 —— 固件其实已受理, 迟到的那条
|
|
132
|
+
#: `0x4E` 会把它配进去, 而它的调用方再也不会来 `wait`。
|
|
133
|
+
#:
|
|
134
|
+
#: **为什么这个界是安全的** (三条, 缺一不可):
|
|
135
|
+
#: · 那一列里的结果**只**能由 `Arm.poll_cart()` 认领, 而它一次只认领一条 —— 积压超过
|
|
136
|
+
#: 几条本身就说明没有人在取 (三条入口成功时各 `wait` 一次, 正常用法下那一列恒为空);
|
|
137
|
+
#: · 被丢的只是那些请求的**规划结果** (`CartPlan`), 它们对应的调用方**早已**拿到异常
|
|
138
|
+
#: 放弃了; 真要知道臂现在什么样, 该回读 `get_state()`/`get_tcp()`, 而不是拿一条陈旧
|
|
139
|
+
#: 规划去推断;
|
|
140
|
+
#: · 丢弃**不碰** `wait` 的路径 (它走 token 自身的应答, 与这一列无关) ⇒ **不制造假成功**。
|
|
141
|
+
#: 越界条数记进 `_CartPending.evicted_unclaimed` (丢弃本身不报错, 见 `on_reply` 的理由)。
|
|
142
|
+
_UNCLAIMED_MAX = 8
|
|
143
|
+
|
|
144
|
+
#: 吸收额度的**存活下限** (秒) —— `Arm` 传进来的 `absorb_ttl` 取
|
|
145
|
+
#: `max(move_timeout, 本值)`, 而不是直接拿 `move_timeout`。
|
|
146
|
+
#:
|
|
147
|
+
#: **为什么必须有下限**: 额度是"清队/放弃那一刻, 那条请求**可能**还欠一条应答"的通行证。
|
|
148
|
+
#: 额度**过期**而那条应答**还在路上**时, 它一到就撞上 `on_reply`: 队列若**非空** (清队后
|
|
149
|
+
#: 又登记了新请求), 它被**配给那条新 token** ⇒ 调用方拿到**别人那条**的规划结果并**报
|
|
150
|
+
#: 成功** (E3 实测, 见 `test_a_reply_that_outlives_move_timeout_is_not_paired_to_the_next_request`);
|
|
151
|
+
#: 队列若空, 报的是 `LiteArmError` "固件与主机已错配" (**归因误导** —— 真相只是"我们自己
|
|
152
|
+
#: 放弃了一条请求, 而额度先过期了")。故额度的存活时间必须覆盖
|
|
153
|
+
#: "**固件还可能发出那条应答**"的最长时间。
|
|
154
|
+
#:
|
|
155
|
+
#: **固件依据** (不是拍脑袋的常数) —— 判据要覆盖的是**应答到达**的时刻, 所以是三段相加,
|
|
156
|
+
#: 每段各自有出处。⚠ 只算第 ① 段是不够的 (这条注释**曾经**就只算第 ① 段):
|
|
157
|
+
#:
|
|
158
|
+
#: ① **生成** (`≤ 写帧 + 3 s`): `RSP_CART_PLAN` 的**唯一发射点**是 `cart_report_if_done()`
|
|
159
|
+
#: (`usb_cmd.c:1246`; `usb_cmd_reply(RSP_CART_PLAN, out, 8)` 在 **`:1261`**), 由
|
|
160
|
+
#: **main 循环**每圈调一次 (`Core/Src/main.c:187`) —— **不在** `cart_publish()` 里
|
|
161
|
+
#: (`cart_exec.c` 不引用 `usb_cmd`, 它只置 `plan_pending`)。
|
|
162
|
+
#: 规划期的硬上界是 `CART_PLAN_MAX_TICKS` = `LITEARM_CTRL_HZ * 3u` = **3 s**
|
|
163
|
+
#: (`cart_exec.h:42` —— 固件刻意把"心跳豁免窗口"与"强制 abort 点"收口在这**一处**
|
|
164
|
+
#: 定义), 到点 `cart_tick_timeout` 会 `cart_abort()` (`cart_exec.c:890`) 并补发一条
|
|
165
|
+
#: 终态应答 (**`:917`** 那句 `plan_pending = true`, 整段见 `:889-917`) ⇒ 此后不会
|
|
166
|
+
#: 再有应答。请求的受理时刻 ≈ 我们写下那一帧的时刻 ⇒ 结果**生成**于 `写帧 + 3s` 内。
|
|
167
|
+
#: ⚠ `plan_pending` 一共**三处**置位, 别只记 `cart_publish`: `cart_begin_request`
|
|
168
|
+
#: (`cart_exec.c:256`, 为被取代者补一条 CANCELED)、`cart_publish` (`:414`)、
|
|
169
|
+
#: `cart_tick_timeout` (`:917`)。
|
|
170
|
+
#: ② **发射时延** (`≤ ~8 s`): "结果生成" ≠ "已上 USB" —— 它要等 main 循环轮到
|
|
171
|
+
#: 那一句, 而**同一圈里排在它前面**的 `usb_cmd_report()` (`Core/Src/main.c:179`) 可能
|
|
172
|
+
#: 正卡在参数保存的阻塞擦写上: 固件自述"擦写 **~1s** CPU 全停"(`usb_cmd.c:1272`,
|
|
173
|
+
#: 并专门给它开了心跳监督豁免 `g_param_save_active` (`usb_cmd.c:1277`)),
|
|
174
|
+
#: 同一件事在另两处按 **~1.86s** 记 (`usb_cmd.c:690` / `usb_cmd.h:94`)。
|
|
175
|
+
#: ⚠⚠ 这两个都只是**典型值, 不是上界** —— 拿典型值当上界, 余量就留在了**假成功**
|
|
176
|
+
#: 那一侧 (见下面"两个方向")。同一个现象 (整扇区擦除期间 CPU 取指停顿 ⇒ 排在它后面的
|
|
177
|
+
#: 那一句发不出去) 在固件里还有**第三个量纲的自述, 而且它是唯一的上界**:
|
|
178
|
+
#: `hw_watchdog.h:28` —— "单 bank H723 整扇区擦除期间 CPU 取指停顿, TIM3 无法喂狗;
|
|
179
|
+
#: 擦写前放宽到 **~8 s**, 完成后恢复" (实现: `hw_watchdog.c:63-66` 把 IWDG 重装成
|
|
180
|
+
#: `IWDG_RELOAD_PARK_8S` = 2000 拍 @250Hz = **~8 s**, 见 `hw_watchdog.c:18`;
|
|
181
|
+
#: 由 `flash_store.c:332` 在**擦除前**调、`:351` 在**编程后**恢复
|
|
182
|
+
#: ⇒ 它覆盖的正是一整次阻塞擦写)。
|
|
183
|
+
#: ⚠ 取舍 (为什么敢把 IWDG 窗口当**时长上界**用): 它确实**不是**擦写时长, 而是"允许
|
|
184
|
+
#: 多长的停顿才不复位"的窗口。但它对**本条判据要覆盖的那一支**恰好成立: 请求的应答
|
|
185
|
+
#: "还会到达" ⟹ 那个停顿必然短于它 (超过就 IWDG 复位, 应答**永远不会来** —— 那是
|
|
186
|
+
#: 另一支, 本来就不需要额度兜)。而 1s/1.86s 只是典型值, 不是上界。
|
|
187
|
+
#: ⇒ 取证方向取**保守侧**: 取大只赔"静默", 取小赔"假成功" (见下), 故站 8s 这一侧。
|
|
188
|
+
#: ③ **链路 + 主机侧取帧余量**: ~1 s 量级 (CDC 上行间歇低吞吐是已知现象)。
|
|
189
|
+
#:
|
|
190
|
+
#: ⇒ 下限 = **12.0 s** ≈ 3 + 8 + 1, 写成整秒。
|
|
191
|
+
#:
|
|
192
|
+
#: ⚠⚠ **下限的两个方向都会出错, 别只记一侧** (本注释曾经只写了后一半):
|
|
193
|
+
#: · **取大** ⇒ "真·脱同步"的那条应答被静默吸收掉, 硬错误被降级成**静默** (只是窗口
|
|
194
|
+
#: 被拉长, 见 `_CartPending.__init__` 里那段"额度有效期偏长的后果");
|
|
195
|
+
#: · **取小 / 过期** ⇒ 一条**还在路上**的应答撞上"队列**非空**"而被配给**新登记**的
|
|
196
|
+
#: token ⇒ 调用方拿到**别人那条**的规划结果并**报成功** = **假成功** (E3 实测, 见
|
|
197
|
+
#: `test_a_reply_that_outlives_move_timeout_is_not_paired_to_the_next_request`);
|
|
198
|
+
#: 撞上"队列空"则报 `LiteArmError` (归因误导)。这半写在上面"为什么必须有下限"那段。
|
|
199
|
+
#: ⇒ 两害相权**必须偏向取大**: "静默"是少报一次错, "假成功"是报了个**错的结果**。
|
|
200
|
+
#: (G1 实测的另一半仍成立: 额度本身不会把"被拒"变成"成功"。)
|
|
201
|
+
#:
|
|
202
|
+
#: ⚠ **为什么是"取较大者"而不是直接钉死在下限**: 就本判据而言下限已经够; 保留
|
|
203
|
+
#: `move_timeout` 那一支是为了**不缩小**既有行为 —— `move_timeout` 更大时今天本来就有
|
|
204
|
+
#: 更长的额度, 不该被一条**新加**的兜底砍短 (那会把本来能吸收的迟到应答变成硬错误)。
|
|
205
|
+
#: 方向是"额度**只增不减**"。
|
|
206
|
+
_ABSORB_TTL_FLOOR = 12.0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cart_absorb_ttl(move_timeout: float) -> float:
|
|
210
|
+
"""`Arm` 要传给 `_CartPending` 的 `absorb_ttl` —— **两个构造点共用这一处**
|
|
211
|
+
(`Arm.__init__` ① 与 `Arm.connect()` ②), 免得两处各写一遍 `max` 而漏掉一处。
|
|
212
|
+
|
|
213
|
+
⚠ 它**不是**"`move_timeout` 换个名字": 传 `move_timeout` 进去只是不想把大
|
|
214
|
+
`move_timeout` 那支的额度砍短, 下限才是这条判据的**正确性条件** (见 `_ABSORB_TTL_FLOOR`)。
|
|
215
|
+
"""
|
|
216
|
+
return max(move_timeout, _ABSORB_TTL_FLOOR)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _CartToken:
|
|
220
|
+
"""一条**在途**笛卡尔请求的票据 (SDK 自造, 固件不知道它的存在)。
|
|
221
|
+
|
|
222
|
+
`reply` 是原始 `0x4E` 载荷 —— 解成规划结果**不由本类做**: `CartPlan` 与它的
|
|
223
|
+
`from_reply` 都在同文件里 (`CartPlan.from_reply` 已交付并被覆盖), 本类只负责
|
|
224
|
+
"这条请求收尾了没有、怎么收的"。
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
__slots__ = ("_done", "reply", "lost", "absorbed_at")
|
|
228
|
+
|
|
229
|
+
def __init__(self) -> None:
|
|
230
|
+
self._done = threading.Event()
|
|
231
|
+
self.reply: Optional[bytes] = None
|
|
232
|
+
#: 非 None = **结局未知** (被清队 / 超时无应答), 值即给调用方看的原因
|
|
233
|
+
self.lost: Optional[str] = None
|
|
234
|
+
#: 登记那一刻的 `_CartPending.absorbed_replies` 水位线 —— 用来判"本条请求的等待
|
|
235
|
+
#: 窗口里有没有吸收额度被消费掉"(见 `drop_and_absorb`: 消费过就等于固件已经把它
|
|
236
|
+
#: 那条应答交出来了, 于是**不许**再补额度, 否则额度永 ≥1 = 自持)。
|
|
237
|
+
self.absorbed_at = 0
|
|
238
|
+
|
|
239
|
+
def wait(self, timeout: float) -> bool:
|
|
240
|
+
"""等收尾; `timeout=0` = 只探一下。返回值只表示"有没有收尾", 不表示收得好。"""
|
|
241
|
+
return self._done.wait(timeout)
|
|
242
|
+
|
|
243
|
+
def resolve(self, payload: bytes) -> None:
|
|
244
|
+
self.reply = payload
|
|
245
|
+
self._done.set()
|
|
246
|
+
|
|
247
|
+
def fail(self, reason: str) -> None:
|
|
248
|
+
"""结局未知 —— 唤醒等待者 (不设 `reply`)。"""
|
|
249
|
+
self.lost = reason
|
|
250
|
+
self._done.set()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _CartPending:
|
|
254
|
+
"""在途笛卡尔请求的 FIFO 队列 + `0x4E` 收集器。
|
|
255
|
+
|
|
256
|
+
三种角色落在同一个对象上 (它们必须共享同一份队列状态):
|
|
257
|
+
|
|
258
|
+
* :meth:`register` / :meth:`request` / :meth:`drop` —— **记账** (发出命令的入口用);
|
|
259
|
+
* :meth:`on_reply` —— **收集器**: `Arm._read_one` 认领到 `0x4E` 就交给它;
|
|
260
|
+
* :meth:`clear_pending` —— **清队**: 被 `_CART_CLEARS_UPON` 里的命令作废时调用,
|
|
261
|
+
并把被清掉的条数记成**吸收额度** (见模块 docstring "多了一条" 那一支)。
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
def __init__(self, *, absorb_ttl: float, absorb: int = 0,
|
|
265
|
+
arm: Optional["Arm"] = None) -> None:
|
|
266
|
+
"""
|
|
267
|
+
`absorb_ttl` 是**吸收额度的存活时间**, 由 `Arm` 传
|
|
268
|
+
`max(Arm.move_timeout, _ABSORB_TTL_FLOOR)` —— 沿用既有旋钮, 外加一个**固件依据的
|
|
269
|
+
下限** (为什么必须有下限、为什么是"取较大者", 见那个常量的定义)。
|
|
270
|
+
|
|
271
|
+
`arm` 是**反向引用**, 不是图省事 (与 `_Ack.__init__` 里那处同源): `wait` 里那个
|
|
272
|
+
pump 循环是本包**第六处**读循环, 它丢掉的"别人的帧"必须计进 `_Ack.foreign_frames`
|
|
273
|
+
(`_note_foreign`), 而计数按设计留在 `_Ack` 上 ⇒ 两边必须互相够得着。
|
|
274
|
+
⚠ `arm=None` 只给**直接构造本类**的单元用例用 (它们验的是配对/吸收语义, 不经
|
|
275
|
+
`Arm`) —— 那种情形下 pump 循环**不计数**, 与它压根没有 `_Ack` 是一致的。
|
|
276
|
+
⚠ `Arm.__init__` 建本对象时 `_a` 还不存在 (它在 `connect()` 里建), 所以这里存的是
|
|
277
|
+
**`Arm` 而不是 `_Ack`**, 取用点在调用时刻 (`_note_foreign` 内部现读 `arm._a`)。
|
|
278
|
+
|
|
279
|
+
额度**两个方向**都会出错, 别只记一侧:
|
|
280
|
+
|
|
281
|
+
* **永不过期** ⇒ 它会把此后一条**真**脱同步的应答也吞掉 (硬错误降级成**静默**);
|
|
282
|
+
* **到期太早** ⇒ 一条**还在路上**的应答会撞上"队列非空"而被配给**新登记**的
|
|
283
|
+
token ⇒ 调用方拿到别人那条的结果并**报成功** (**假成功**)。
|
|
284
|
+
|
|
285
|
+
⚠ **它是构造时的一次性快照, 不跟随 `Arm.move_timeout` 的后续改动**: `wait` 每次
|
|
286
|
+
现读 `arm.move_timeout` 当等待窗口, 而额度有效期固定为**本对象建成那一刻**的值
|
|
287
|
+
(建成点是 `Arm.__init__` 与 `Arm.connect()`, 后者会重建本对象 —— 所以 connect
|
|
288
|
+
之后才改 `move_timeout` 属于"两个窗口分叉": 等待窗口跟着变, 额度有效期不变)。
|
|
289
|
+
额度有效期**偏长**的后果是"真·脱同步被静默吸收掉的时间窗变长" (那条本应报
|
|
290
|
+
`LiteArmError` 的应答被当成清队的迟到应答吞掉), 方向是**静默**而不是假成功;
|
|
291
|
+
`Arm.move_timeout` 的 docstring 里记了这条耦合 —— 要改就 `reconnect()`。
|
|
292
|
+
"""
|
|
293
|
+
self._lock = threading.Lock()
|
|
294
|
+
self._arm = arm
|
|
295
|
+
self._q: List[_CartToken] = []
|
|
296
|
+
#: 已收到 `0x4E`、但**还没有人 `wait` 过**的 token (按到达顺序)。
|
|
297
|
+
#: `on_reply` 配对成功后在这里留一份: 结果已经到手, 若调用方没阻塞取走, 就只能靠
|
|
298
|
+
#: `Arm.poll_cart()` 显式认领。`wait` 取走结果时把它摘掉 (`_unclaim`)。
|
|
299
|
+
#:
|
|
300
|
+
#: ⚠ **它不恒为空**: 三条入口**正常成功**时确实各 `wait` 一次 (于是留不下东西),
|
|
301
|
+
#: 但有一条**放弃路径**会永久滞留 —— `_request_and_wait` 的 ACK 超时
|
|
302
|
+
#: (`MotionTimeoutError`) 刻意**不**摘 token (摘了会让"固件其实已受理"那条应答配给
|
|
303
|
+
#: 后面的活 token = 假成功), 而它的调用方已带着异常返回、再也不会来 `wait`;
|
|
304
|
+
#: 固件那条 `0x4E` 一到就落进这里, 且**只有 `poll_cart`** 能摘。故本列必须有界
|
|
305
|
+
#: (见 `_UNCLAIMED_MAX` 与 `on_reply`)。
|
|
306
|
+
self._unclaimed: List[_CartToken] = []
|
|
307
|
+
#: 因越界被丢弃的"待认领"条数 (**累计**) —— 丢弃不报错 (理由同 `extra_replies` 那
|
|
308
|
+
#: 段的"不静默"), 故至少要看得见。与 `absorbed_replies`/`extra_replies` 并列。
|
|
309
|
+
self.evicted_unclaimed = 0
|
|
310
|
+
#: 队列空却收到 `0x4E` 的**累计**条数 —— "多了一条"的可观测面 (同
|
|
311
|
+
#: `_Ack.unexpected_frames`: 计数不替代报错, 报错也不替代计数)。
|
|
312
|
+
self.extra_replies = 0
|
|
313
|
+
#: 被**吸收**掉的不可归属应答的**累计**条数 —— 与 `extra_replies` 并列的另一个
|
|
314
|
+
#: 可观测面: 吸收是"清队后的副作用", 静默与否要能看见。**不报错**。
|
|
315
|
+
self.absorbed_replies = 0
|
|
316
|
+
self._absorb_ttl = absorb_ttl
|
|
317
|
+
#: 剩余吸收额度 (仅在 `_absorb_deadline` 之前有效)。
|
|
318
|
+
#: `absorb` 是**建对象时就带进来的**额度 —— 只给 `Arm.connect()` 那一处用:
|
|
319
|
+
#: 会话重建销毁了旧对象的账, 而旧会话在途那几条**仍可能欠着应答** (`0x4E` 已在
|
|
320
|
+
#: USB 上, 或至多 `CART_PLAN_MAX_TICKS` 之后才发出) ⇒ 新对象必须把那份额度**继承**
|
|
321
|
+
#: 过来, 否则那条迟到应答一到就撞上"队列为空"判据, 从毫不相干的读路径里炸出
|
|
322
|
+
#: `LiteArmError` (归因指向"固件与主机已错配", 真相是"我们自己重建了会话")。
|
|
323
|
+
#: ⚠ 只搬**条数**: 旧对象里那几条的调用方由 `clear_pending` 唤醒, 不在这里管。
|
|
324
|
+
#: ⚠⚠ 继承来的额度**至多影响新会话的一条命令, 且不被超时路径续期**: 它若先于配对
|
|
325
|
+
#: 吃掉新会话**自己那条**应答 (两只在原理上无法区分), 那条命令报"结局未知", 而
|
|
326
|
+
#: 它的超时路径**不再补额度** ⇒ 下一条起恢复正常 (见 `drop_and_absorb` 的例外段与
|
|
327
|
+
#: `test_an_inherited_credit_cannot_perpetuate_itself_across_a_reconnect`)。
|
|
328
|
+
self._absorb = absorb
|
|
329
|
+
#: 额度截止 (monotonic 秒); `_absorb == 0` 时无意义。
|
|
330
|
+
#: 带进来的额度**从建对象这一刻起算** `absorb_ttl` (与 `clear_pending`/
|
|
331
|
+
#: `drop_and_absorb` 给额度时的口径一致: 额度总是"从现在起再活 `absorb_ttl`")。
|
|
332
|
+
self._absorb_deadline = (time.monotonic() + absorb_ttl) if absorb else 0.0
|
|
333
|
+
|
|
334
|
+
# ---- 记账 ----
|
|
335
|
+
|
|
336
|
+
def register(self) -> _CartToken:
|
|
337
|
+
"""登记一条在途请求。**必须在写之前调** —— 否则窗口内到来的 `0x4E` 会落空。"""
|
|
338
|
+
tok = _CartToken()
|
|
339
|
+
with self._lock:
|
|
340
|
+
tok.absorbed_at = self.absorbed_replies # 水位线: 见 `drop_and_absorb`
|
|
341
|
+
self._q.append(tok)
|
|
342
|
+
return tok
|
|
343
|
+
|
|
344
|
+
def request(self, write: Callable[[], None]) -> _CartToken:
|
|
345
|
+
"""登记 token 并**立刻**发帧 —— 登记与写在同一个 try 内。
|
|
346
|
+
|
|
347
|
+
`write` 是零参可调用 (通常是 ``lambda: arm._raw_write(cmd, payload)``)。
|
|
348
|
+
|
|
349
|
+
⚠ **`write` 只许包含"写"** —— 校验必须由调用方放在 `request()` **之外**:
|
|
350
|
+
校验失败根本**不该**登记 token (登记了又要摘, 就多出一段"这个异常到底发生在写之前
|
|
351
|
+
还是写之后"的推断, 而这段推断正是下面那条区分要消灭的)。三条入口 (Task 4) 按此约定
|
|
352
|
+
调用 —— 先校验, 过了再进这里。具体要挡在门外的两个 (它们都在写之前就会抛, 却**不是**
|
|
353
|
+
`TransportError`, 从 `request()` 里看不出"没写"): 零重力守卫的 `InvalidCommandError`
|
|
354
|
+
(`arm._write_cmd`) 与未连接的 `NotConnectedError` (`arm._require`)。
|
|
355
|
+
|
|
356
|
+
⚠ **写失败 (`TransportError`) 才摘除自己那个 token** 并原样抛出。摘除**本身不是**
|
|
357
|
+
"比滞留更安全", 它是有前提的: `TransportError` ⟹ **整帧未送达** (载重不变量, 见
|
|
358
|
+
`transport.write_frame` —— 它把"`write()` 抛"与"`flush()` 抛"**分开报**, 后者
|
|
359
|
+
**不抛**)。固件从没收到这条请求, 也就**永远不欠**它的应答, 于是摘掉 token 恰好
|
|
360
|
+
让"在途条数"与"应答条数"重新相等。
|
|
361
|
+
|
|
362
|
+
⚠ 两个方向**别记反** (本例正是"假成功"的高发区):
|
|
363
|
+
|
|
364
|
+
* **滞留**一条未送达的 token ⇒ 在途比应答**多**一条 ⇒ 应答顺次前移一格, 队列
|
|
365
|
+
**末尾**那条活 token 拿不到应答 ⇒ 报"结局未知" (`CartReplyLostError`), 安全;
|
|
366
|
+
* **丢弃**一条其实**已送达**的 token ⇒ 在途比应答**少**一条 ⇒ 固件那条应答配给
|
|
367
|
+
接在其后的活 token ⇒ 报"成功"而实际没跑 = **假成功**, 危险。
|
|
368
|
+
|
|
369
|
+
(上面这组对照按本文档要求的**串行用法**成立 —— 同时 ≥2 条在途时两支都会错配,
|
|
370
|
+
所以"串行"与这条不变量**两条都要守**。)
|
|
371
|
+
|
|
372
|
+
⚠ **其余任何异常 (`KeyboardInterrupt` 及 `write` 体内别的东西) 一律 re-raise 且
|
|
373
|
+
不动队列** —— 它们**证明不了**帧没送达。最现实的一支是 `KeyboardInterrupt` 落在
|
|
374
|
+
`flush()` 里: 那时 `write()` 早已返回、整帧**已在驱动里会被送达**, 固件真会回一条
|
|
375
|
+
`0x4E`; 此时摘 token 就把它配给后面的活 token = **假成功**。反过来滞留最坏也只是
|
|
376
|
+
报"结局未知"(安全方向)。
|
|
377
|
+
**别为"简洁"把下面两个 except 合成一个 `BaseException`** —— 那会静默重造假成功。
|
|
378
|
+
|
|
379
|
+
⚠ 只给 `0x3A/0x3B/0x3E` 用: 清队 opcode 绝不该走这里 (登记完就立刻被
|
|
380
|
+
`Arm._raw_write` 自己的清队钩子摘掉)。
|
|
381
|
+
"""
|
|
382
|
+
tok = self.register()
|
|
383
|
+
try:
|
|
384
|
+
write()
|
|
385
|
+
except TransportError:
|
|
386
|
+
# 契约: write_frame 抛 TransportError ⟹ 整帧未送达 (残缺帧被固件按 CRC 丢)
|
|
387
|
+
# ⇒ 摘下自己那个 token 才是干净的 (在途条数 ⟷ 应答条数重新相等)。
|
|
388
|
+
self.drop(tok)
|
|
389
|
+
raise
|
|
390
|
+
except BaseException:
|
|
391
|
+
# ⚠ 其余异常**证明不了**帧没送达 (最现实的是 KeyboardInterrupt 落在 flush 之后,
|
|
392
|
+
# 那时整帧已在驱动里) ⇒ **留在队里**: 最坏是后续请求报"结局未知" (安全方向),
|
|
393
|
+
# 而摘掉它会让那条应答配给后面的活 token = 假成功 (危险方向)。
|
|
394
|
+
raise
|
|
395
|
+
return tok
|
|
396
|
+
|
|
397
|
+
def drop(self, token: _CartToken) -> None:
|
|
398
|
+
"""摘除**指定的**那条 token (按对象身份, 不是队尾 —— 并发下队尾是别人的)。
|
|
399
|
+
|
|
400
|
+
两个地方都要摘: 在途队列 (`_q`) 与"已收到应答但没人认领"那一列 (`_unclaimed`)
|
|
401
|
+
—— 一条被判死的请求不该还能被 `poll_cart` 认领出结果。
|
|
402
|
+
"""
|
|
403
|
+
with self._lock:
|
|
404
|
+
self._unclaim_locked(token)
|
|
405
|
+
|
|
406
|
+
def drop_and_absorb(self, token: _CartToken, *, timed_out: bool) -> None:
|
|
407
|
+
"""摘除 token, **并留一格吸收额度** —— 摘 token 与给额度必须在同一把锁内。
|
|
408
|
+
|
|
409
|
+
`timed_out` 说明**为什么要放弃**这条请求 (两条调用点各传一个值, 别统一):
|
|
410
|
+
超时支传 `True`、读链路炸 (pump) 支传 `False`。它**只**决定下面那个"例外段"
|
|
411
|
+
生不生效, **不影响**"摘 token"这一半。
|
|
412
|
+
|
|
413
|
+
与 `clear_pending` **同构** (理由逐字相同, 见那里): 摘掉 token 的那一刻, 固件那条
|
|
414
|
+
应答可能**已经在路上** (它受理了这条命令, 只是我们等不到/读不到)。不留额度的话,
|
|
415
|
+
它一到就撞上"队列空"那条判据 ⇒ `LiteArmError` 从**毫不相干的读路径**
|
|
416
|
+
(`get_state()`/`get_tcp()`) 炸出来, 把一次本可解释的迟到报成"固件与主机已脱同步"。
|
|
417
|
+
|
|
418
|
+
额度 = 1 (这条请求**至多**欠一条应答)。两支:
|
|
419
|
+
|
|
420
|
+
* 固件其实**会**发 ⇒ 那条应答被吸收 (计数 `absorbed_replies`), 不报错;
|
|
421
|
+
* 固件其实**不会**发 ⇒ 额度白给一格 —— 最坏情形是它吞掉此后的**一条**真脱同步
|
|
422
|
+
应答 (报"结局未知"而不是硬错), 且额度受 `absorb_ttl` 过期约束。
|
|
423
|
+
|
|
424
|
+
⚠⚠ **给额度只把误炸挡在"额度还活着"那一段时间里, 不是"这类误炸不会再发生"** ——
|
|
425
|
+
额度一过期, 那条迟到应答就回到原样: 撞上"队列非空"被配给**新** token (**假成功**,
|
|
426
|
+
见 `_ABSORB_TTL_FLOOR` 与本文件顶部那段), 撞上"队列空"照样炸 `LiteArmError`。
|
|
427
|
+
所以那个下限是**正确性条件**, 不是余量; 而"窗口外"那一支的归因仍然是错的: 报出来
|
|
428
|
+
的是"固件与主机已错配", 真相只是"**我们自己的**额度到期了" (E3 实测)。
|
|
429
|
+
|
|
430
|
+
方向与"多了一条"那条守卫一致: **宁可多吸收一条, 绝不假成功**。反过来 (不给额度)
|
|
431
|
+
是**归因误导**: 用户看到的是"配对已错位", 真相是"我们自己放弃了一条请求"。
|
|
432
|
+
|
|
433
|
+
⚠⚠ **例外 (唯一一条, 必须先于"给一格"判断, 且**只对超时支生效**):
|
|
434
|
+
`timed_out` 且 本条请求的窗口里已经消费过额度 ⟹ 不补。** 吸收**先于**配对
|
|
435
|
+
(`on_reply`), 所以一条在途 token 的应答到了、而额度还有存量时, 被吃掉的就是
|
|
436
|
+
**它自己那条** —— 那条应答**已经交出来了**, 固件不再欠它什么。此时再补一格,
|
|
437
|
+
补出来的那格会去吃**下一条**命令的应答, 那条又超时、又补一格 … **自持**:
|
|
438
|
+
实测 (改前) `connect()` 继承来的一格额度足以让新会话**此后每一条**笛卡尔命令都
|
|
439
|
+
报"结局未知" (`#1..#6` 连报, `absorbed_replies` 一路涨)。
|
|
440
|
+
F5 之前同一场景分两支 (两支都实测过):
|
|
441
|
+
· 旧会话那条应答**始终没到** ⇒ **全绿** —— 即 F5 把一条本来健康的会话变成了永久
|
|
442
|
+
错位 (那格额度当时不存在, 链条根本没起点);
|
|
443
|
+
· 它**确实会到** ⇒ "炸一次 `LiteArmError` (无关读路径) 然后恢复正常"。
|
|
444
|
+
|
|
445
|
+
⚠⚠ **为什么例外只许挂在 `timed_out` 上, 不许两条放弃路径共用**: 上面那句推断
|
|
446
|
+
("被吸收的那条就是本 token 自己的") 靠的是**等满了整个等待窗口** —— 窗口够长时,
|
|
447
|
+
固件那边该发的早该发完了, 于是"还有**别人的**应答在路上"不再可达。
|
|
448
|
+
**pump 那一支根本没等过窗口** (读链路当场就炸了), 这条前提在它身上**没有依据**
|
|
449
|
+
⇒ 它照旧补一格。⚠ 别再把这一支的判据写成"只在 `move_timeout < 3s` 时才可达":
|
|
450
|
+
pump 支**完全不经过超时**, 默认 `move_timeout = 15.0` 下照样可达。
|
|
451
|
+
|
|
452
|
+
⚠⚠ **例外判错的方向是假成功, 不是"宁可让一条报结局未知"** (别只记后者): 若该补的
|
|
453
|
+
那格没补, 而固件**真还欠**本 token 一条应答, 它到达时队列非空 ⇒ `on_reply` 直接
|
|
454
|
+
配给**下一条** token ⇒ 调用方拿到**别人那条**的规划结果并**报成功**; 撞上队列空则
|
|
455
|
+
炸 `LiteArmError` (归因仍是错的)。`0x4E` 载荷里没有命令 id, 它无从分辨。
|
|
456
|
+
⚠ pump 支走例外时正是这一侧: 被吸收的可能是**清队前遗留的**那条, 而本 token 自己
|
|
457
|
+
那条还在路上 —— `test_a_pump_failure_must_not_suppress_the_credit` 钉的就是它。
|
|
458
|
+
(超时支若同样判错, 代价也是假成功; 但那一支至少还有"窗口够长"可依, 且**没有它
|
|
459
|
+
自持就回不来** —— 实测 `#1..#6` 全红, 那是本例外唯一的立论。)
|
|
460
|
+
|
|
461
|
+
判"已经消费过"用的是 `_CartToken.absorbed_at` 水位线 (登记时快照)。⚠ 这只用
|
|
462
|
+
"**有**消费"这一个事实, 不去猜消费掉的是谁那条 —— 判错的那一侧 (被吃掉的其实是
|
|
463
|
+
旧会话/别的进程遗留的一条, 而固件真还欠本 token 一条) 的代价是: 那条迟到应答
|
|
464
|
+
落回"额度过期"那一支 (撞队列非空 ⇒ 错配 ⇒ **假成功** / 撞队列空 ⇒ `LiteArmError`)。
|
|
465
|
+
|
|
466
|
+
小结 (两个方向都在, 别只记一侧):
|
|
467
|
+
· **补**额度 ⇒ 可能**静默** (吞掉一条真脱同步的应答, 降级成"结局未知");
|
|
468
|
+
· **不补** ⇒ 可能**假成功** (错配)。
|
|
469
|
+
故只在有"窗口够长"依据的那一支 (超时支) 才敢不补。
|
|
470
|
+
"""
|
|
471
|
+
with self._lock:
|
|
472
|
+
self._unclaim_locked(token)
|
|
473
|
+
if timed_out and self.absorbed_replies != token.absorbed_at:
|
|
474
|
+
# 超时支 + 窗口里已经吸收过一条 —— 那条就是本条请求的应答 (见 docstring
|
|
475
|
+
# 的例外段): 固件不再欠, 所以**不补额度**(补了就是自持)。两个字段都不动。
|
|
476
|
+
# ⚠ pump 支**不走这里**: 它没等过窗口, "被吃掉的是自己那条"没有依据。
|
|
477
|
+
return
|
|
478
|
+
now = time.monotonic()
|
|
479
|
+
self._absorb = self._absorbance_locked(now) + 1
|
|
480
|
+
self._absorb_deadline = now + self._absorb_ttl
|
|
481
|
+
|
|
482
|
+
def _unclaim_locked(self, token: _CartToken) -> None:
|
|
483
|
+
"""**调用方必须持 `self._lock`** —— 两列里各摘一次 (不在的忽略)。"""
|
|
484
|
+
try:
|
|
485
|
+
self._q.remove(token)
|
|
486
|
+
except ValueError: # 已被清队摘走/已配对消费
|
|
487
|
+
pass
|
|
488
|
+
try:
|
|
489
|
+
self._unclaimed.remove(token)
|
|
490
|
+
except ValueError:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def claim_resolved(self) -> Optional[_CartToken]:
|
|
494
|
+
"""**非阻塞**认领: 队首 token 若已收尾就摘掉并返回它, 否则 `None`。
|
|
495
|
+
|
|
496
|
+
FIFO 配对下队首就是"最早那条" —— 它被解答之前, 后面的 token 不可能被解答
|
|
497
|
+
(应答按受理顺序发出), 所以只看队首是对的。
|
|
498
|
+
|
|
499
|
+
用途只有一个: `Arm.poll_cart()` 显式认领一条**没有被 `wait` 取走**的结果。
|
|
500
|
+
|
|
501
|
+
⚠ 判据必须含 **`_done.is_set()`**, 不能只看"那一列非空": `on_reply` 是**先登记
|
|
502
|
+
进那一列、后 `resolve`** (顺序是承重的, 见那里), 两者之间有一条缝 —— 并发读者
|
|
503
|
+
恰好落进这条缝就会拿到一个**还没收尾**的 token, `CartPlan.from_reply(None)`
|
|
504
|
+
当场 `TypeError` (单线程不可达, 故此前没暴露)。缝里返回 `None` 是对的语义:
|
|
505
|
+
"此刻还没有可取的结果", `poll_cart` 本来就以 `None` 表示这个。
|
|
506
|
+
"""
|
|
507
|
+
with self._lock:
|
|
508
|
+
if self._unclaimed and self._unclaimed[0]._done.is_set():
|
|
509
|
+
return self._unclaimed.pop(0)
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
def _unclaim(self, token: _CartToken) -> None:
|
|
513
|
+
"""`wait` 把结果取走之后, 从"待认领"那一列移除 —— 否则 `poll_cart` 会**重复交付**。"""
|
|
514
|
+
with self._lock:
|
|
515
|
+
try:
|
|
516
|
+
self._unclaimed.remove(token)
|
|
517
|
+
except ValueError:
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
# ---- 收集器 ----
|
|
521
|
+
|
|
522
|
+
def on_reply(self, payload: bytes) -> None:
|
|
523
|
+
"""收集器 —— `Arm._read_one` 见到 `0x4E` (`P.RSP_CART_PLAN`, 见那里的常量分支)
|
|
524
|
+
时调它。
|
|
525
|
+
|
|
526
|
+
⚠ **先消费吸收额度, 再配对** —— 顺序是决定性的: 清队后立刻登记的新 token 会让队列
|
|
527
|
+
**非空**, 而那条清队前就已到达的迟到应答若被拿去配对, 就**配错了** (报成功而实际
|
|
528
|
+
没跑完, 即**假成功**)。故额度那一步必须排在"队列空/非空"判断之**前**。
|
|
529
|
+
|
|
530
|
+
⚠ 队列空**且额度为 0** 才是真 "多了一条": 计数**并报错**, 不静默丢弃。这里比
|
|
531
|
+
"未识别帧计数"更硬气是有理由的 —— 应答比请求多说明固件与主机**已经**错位,
|
|
532
|
+
吞掉它就把错位藏了起来。
|
|
533
|
+
|
|
534
|
+
⚠ **为什么"多了一条"抛基类 `LiteArmError` 而不给它一个专门的异常类型**
|
|
535
|
+
(与"少了一条"配 `CartReplyLostError` 形成对照, 别顺手补一个子类):
|
|
536
|
+
两者的**性质不同** —— "少了一条"是**调用方面对的处境** (这条运动的结局未知,
|
|
537
|
+
必须能与"规划失败"/"被接管"分开, 调用方要据此决定回读还是报警), 所以它得是
|
|
538
|
+
独立成型、可被 `except` 的类型; "多了一条"是 **SDK 的配对模型与固件脱同步**
|
|
539
|
+
—— 内部不变量被破坏, 没有任何调用方能据它做出正确决定, 也**不该被专门 catch**
|
|
540
|
+
(catch 了就等于把"配对已错位"当成可恢复的常规分支)。给它一个专用类型反而在
|
|
541
|
+
邀请后续代码去 `except` 它。
|
|
542
|
+
|
|
543
|
+
⚠ 配对成功的那条进"待认领"列时**封顶** (超出丢最旧, 见 `_UNCLAIMED_MAX`) ——
|
|
544
|
+
那一列在"调用方放弃 token"的路径上无界增长。封顶**只丢**"没人来取的陈旧结果",
|
|
545
|
+
不改变本方法的任何分支, 也不影响 `wait`。
|
|
546
|
+
"""
|
|
547
|
+
with self._lock:
|
|
548
|
+
if self._absorbance_locked(time.monotonic()) > 0:
|
|
549
|
+
# 清队后的不可归属应答 —— 属于**刚被清掉的那条请求**, 与"真·脱同步"是
|
|
550
|
+
# 两回事: 消费一格额度, 计数, 直接返回 (不配对、不报错、队列不动)。
|
|
551
|
+
self._absorb -= 1
|
|
552
|
+
self.absorbed_replies += 1
|
|
553
|
+
return
|
|
554
|
+
if not self._q:
|
|
555
|
+
self.extra_replies += 1
|
|
556
|
+
raise LiteArmError(
|
|
557
|
+
f"收到 0x4E (笛卡尔规划应答) 但队列为空 —— 应答比请求多, "
|
|
558
|
+
f"固件与主机已错配 (已计数 extra_replies={self.extra_replies}); "
|
|
559
|
+
f"这不是可以忽略的噪声")
|
|
560
|
+
tok = self._q.pop(0)
|
|
561
|
+
# 先登记"待认领"再唤醒等待者: `wait` 醒来后会把它从这一列移走, 顺序反过来的话
|
|
562
|
+
# 那次移除会扑空, 于是同一条结果被 `poll_cart` 再交付一次。
|
|
563
|
+
self._unclaimed.append(tok)
|
|
564
|
+
# 给那一列封顶 (见 `_UNCLAIMED_MAX`): 无界的唯一现实来源是"调用方放弃 token"
|
|
565
|
+
# (ACK 超时路径), 每放弃一条就永久留下一条。丢**最旧**的, 并计数 —— 静默丢弃
|
|
566
|
+
# 正是本模块一直在防的东西。
|
|
567
|
+
while len(self._unclaimed) > _UNCLAIMED_MAX:
|
|
568
|
+
self._unclaimed.pop(0)
|
|
569
|
+
self.evicted_unclaimed += 1
|
|
570
|
+
tok.resolve(payload)
|
|
571
|
+
|
|
572
|
+
# ---- 清队 ----
|
|
573
|
+
|
|
574
|
+
def clear_pending(self, reason: str = "") -> int:
|
|
575
|
+
"""清队 —— 由 `Arm._raw_write` 在**发出清队 opcode 之前**调用。返回摘除条数。
|
|
576
|
+
|
|
577
|
+
这几条请求在固件侧已经**静默作废**, 永远等不到 `0x4E` 了: 它们被标成
|
|
578
|
+
"结局未知"并唤醒等待者 —— 不能留着让调用方白等到超时, 更不能让下一个
|
|
579
|
+
`0x4E` 配给它们。
|
|
580
|
+
|
|
581
|
+
⚠ **记账被销毁了, 但被清掉的条数留下一份额度**: 清队那一刻固件那条规划可能
|
|
582
|
+
**已经跑完、应答早躺在主机 RX 缓冲里** (两支在原理上无法区分, 见模块 docstring)。
|
|
583
|
+
故 `n = 本次清掉的条数`, **仅当 `n > 0`** 时 `额度 = 当前有效额度 + n` 且
|
|
584
|
+
`截止 = now + absorb_ttl`; **`n == 0` 时两个字段都不动** —— 这不是优化, 是正确性:
|
|
585
|
+
零重力保活线程每 40ms 发一条清队 opcode (`0x06`, 而它几乎永远是空清队), 若空清队
|
|
586
|
+
也刷新截止, 额度就**永远不过期**, 此后一条**真**脱同步的应答会被永久静默吞掉。
|
|
587
|
+
|
|
588
|
+
额度**跨多次清队累加**: 写成"重置为本次条数"的话, 上一次那条待吸收的迟到应答
|
|
589
|
+
会退化成硬错误。
|
|
590
|
+
"""
|
|
591
|
+
with self._lock:
|
|
592
|
+
tokens, self._q = self._q, []
|
|
593
|
+
n = len(tokens)
|
|
594
|
+
if n:
|
|
595
|
+
now = time.monotonic()
|
|
596
|
+
self._absorb = self._absorbance_locked(now) + n
|
|
597
|
+
self._absorb_deadline = now + self._absorb_ttl
|
|
598
|
+
why = (reason or "在途规划被其它命令作废") + \
|
|
599
|
+
" —— 固件侧不会再发 0x4E, 结局未知 (只能回读状态判定)"
|
|
600
|
+
for t in tokens:
|
|
601
|
+
t.fail(why)
|
|
602
|
+
return n
|
|
603
|
+
|
|
604
|
+
def _absorbance_locked(self, now: float) -> int:
|
|
605
|
+
"""当前**有效**吸收额度 —— 惰性过期: `now > 截止` 即视为 0。
|
|
606
|
+
|
|
607
|
+
**调用方必须持 `self._lock`**。惰性 (而不是起个定时器) 是有意的: 额度本来就只有
|
|
608
|
+
收集器一带在查, 没有别的读者需要被"及时通知"。
|
|
609
|
+
|
|
610
|
+
⚠ 判据是**严格大于**: `now == 截止` 算**未**过期 (刻意的 —— 规格只写了
|
|
611
|
+
"`move_timeout` 后惰性归零", 没定这一侧; 本实现取"到期时刻仍有效")。
|
|
612
|
+
"""
|
|
613
|
+
if self._absorb and now > self._absorb_deadline:
|
|
614
|
+
self._absorb = 0
|
|
615
|
+
return self._absorb
|
|
616
|
+
|
|
617
|
+
# ---- 等待 ----
|
|
618
|
+
|
|
619
|
+
def wait(self, token: _CartToken, timeout: float) -> bytes:
|
|
620
|
+
"""等这条 token 收尾; **超时按"少了一条"处理**。返回原始 `0x4E` 载荷。
|
|
621
|
+
|
|
622
|
+
`timeout` 由调用方给 (惯例是 `Arm.move_timeout` —— 沿用既有旋钮, 不新造字段)。
|
|
623
|
+
|
|
624
|
+
⚠⚠ **[2026-09-22] `pump` 参数已删除。** 它存在的唯一理由是「本包**没有读线程**,
|
|
625
|
+
所以'等应答'与'读应答'必须是同一件事」—— 现在 `litearm-python` **有**读线程
|
|
626
|
+
(`_Ack._reader_loop`, 唯一读者), `0x4E` 由它在 `_Ack._deliver` 里直接交给收集器
|
|
627
|
+
(`on_reply`) ⇒ 本方法**只在事件上阻塞**即可。连带删掉的还有 `_count_pumped`
|
|
628
|
+
(逐条计 `foreign_frames`): "读到了却不认领"这个动作不存在了。
|
|
629
|
+
|
|
630
|
+
收尾不成功一律抛 :class:`CartReplyLostError` (**不是** `MotionTimeoutError`:
|
|
631
|
+
这里要表达的是"未知结局", 混进通用超时会让调用方按"没生效"去重发)。
|
|
632
|
+
|
|
633
|
+
⚠ 两条**放弃**路径 (超时 / 读线程死) 摘 token 时都留**一格吸收额度**
|
|
634
|
+
(`drop_and_absorb`) —— 与 `clear_pending` 同构: 固件**真欠**的那条 `0x4E` 可能已经
|
|
635
|
+
上路了, 不留额度它就会撞上"队列空"那条判据, 把 `LiteArmError` 从毫不相干的读路径
|
|
636
|
+
(`get_state()`) 里炸出来。详见 `drop_and_absorb` 的两支分析。
|
|
637
|
+
⚠ 但两条路径传给 `drop_and_absorb` 的 `timed_out` **不同** (超时 `True` / 读线程死
|
|
638
|
+
`False`): 那个函数的"例外段" (窗口里吸收过 ⟹ 不补额度) **只对超时支成立** ——
|
|
639
|
+
线程死那一支没等过窗口, 走了例外就会把固件真欠的那条应答变成下一条命令的假成功。
|
|
640
|
+
|
|
641
|
+
⚠ 读线程死了要**当场响亮**: 等满 `timeout` 才报"结局未知"会把"链路断了"说成
|
|
642
|
+
"固件没回"。所以每一拍醒来看一眼 `_Ack._reader_error`, 置了就立刻收场。
|
|
643
|
+
"""
|
|
644
|
+
end = time.monotonic() + timeout
|
|
645
|
+
link_dead = False
|
|
646
|
+
while token.reply is None and token.lost is None:
|
|
647
|
+
left = end - time.monotonic()
|
|
648
|
+
if left <= 0.0:
|
|
649
|
+
break
|
|
650
|
+
token.wait(left)
|
|
651
|
+
a_ = self._arm._a if self._arm is not None else None
|
|
652
|
+
if a_ is not None and a_._reader_error is not None:
|
|
653
|
+
# 链路没了 (读线程已退出): 这条请求的结局已不可知, 而且**没人再来收它** ——
|
|
654
|
+
# 留着 token 会让下一次会话的 0x4E 配给一条早就无人认领的请求。
|
|
655
|
+
link_dead = True
|
|
656
|
+
break
|
|
657
|
+
if token.lost is not None:
|
|
658
|
+
raise CartReplyLostError(token.lost)
|
|
659
|
+
if link_dead:
|
|
660
|
+
# ⚠ **把病因原样抛出去**(与旧 `pump` 支一致):报 `CartReplyLostError`
|
|
661
|
+
# ("结局未知") 会把"链路断了"说成"固件没回",排查方向直接反。
|
|
662
|
+
# ⚠ `timed_out=False`:这一支**没等过窗口** ⇒ 照旧补额度。走成 `True` 会让
|
|
663
|
+
# 固件真欠的那条应答被"例外段"吞掉 ⇒ **下一条命令假成功**。
|
|
664
|
+
self.drop_and_absorb(token, timed_out=False)
|
|
665
|
+
token.fail("读线程已退出 (链路没了) —— 结局未知")
|
|
666
|
+
a_ = self._arm._a if self._arm is not None else None
|
|
667
|
+
raise TransportError(
|
|
668
|
+
f"等待 0x4E 期间读线程已退出: {getattr(a_, '_reader_error', None)}")
|
|
669
|
+
if token.reply is None: # 超时 —— "少了一条"
|
|
670
|
+
self.drop_and_absorb(token, timed_out=True)
|
|
671
|
+
token.fail(f"超时 {timeout:.1f}s 没等到 0x4E 应答 (固件单槽 pending 在突发下"
|
|
672
|
+
f"会吞掉中段应答) —— 结局未知 (只能回读状态判定)")
|
|
673
|
+
raise CartReplyLostError(token.lost)
|
|
674
|
+
self._unclaim(token) # 结果已交付, 不许再被 poll_cart 认领
|
|
675
|
+
return token.reply
|
|
676
|
+
|
|
677
|
+
# ---- 只读 ----
|
|
678
|
+
|
|
679
|
+
@property
|
|
680
|
+
def pending(self) -> int:
|
|
681
|
+
"""在途条数。"""
|
|
682
|
+
with self._lock:
|
|
683
|
+
return len(self._q)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ===========================================================================
|
|
687
|
+
# 规划结果的语义 —— `RSP_CART_PLAN (0x4E)` 载荷 / 异常映射 / 能力探测 / 三条入口
|
|
688
|
+
# ===========================================================================
|
|
689
|
+
|
|
690
|
+
#: 固件 `cart_err_t` (`cart_plan.h`) 的取值 —— `0x4E` 载荷第 2 字节。
|
|
691
|
+
#: ⚠ 这是**规划层**的码, 与 `RSP_ERR` 第二字节的**门禁原因码** (未使能 0x03 /
|
|
692
|
+
#: 零重力 0x04 / 掉线锁存 0x06) **共用 1~6 的数值区间却语义完全不同**, 别互推。
|
|
693
|
+
#: 整套枚举 (含 0) 都镜像在这里, 免得读的人为了确认"2 到底是哪个"去翻 cart_plan.h;
|
|
694
|
+
#: `CART_ERR_OK` 没有别的引用点 —— "成功"在代码里由 `CartPlan.ok` 表达。
|
|
695
|
+
CART_ERR_OK = 0
|
|
696
|
+
CART_ERR_IK = 1 # 某路点 IK 无解, 或相邻解跳变超阈值
|
|
697
|
+
CART_ERR_COLLINEAR = 2 # move_c 三点共线, 定不出圆
|
|
698
|
+
CART_ERR_TOO_LONG = 3 # 路点超容量 —— **整条拒绝, 臂一步没动**
|
|
699
|
+
CART_ERR_LIMIT = 4 # 路点本身或段中插值越关节限位
|
|
700
|
+
CART_ERR_CANCELED = 5 # 被新请求取代 (预期内的接管, 不是故障)
|
|
701
|
+
CART_ERR_BADARG = 6 # 入参非法
|
|
702
|
+
|
|
703
|
+
#: move_c 的起点校验容差 —— 与 `Arm.move_p` 的默认值同源 (6mm / 0.03rad), 不是**抄**来的
|
|
704
|
+
#: 巧合: `move_c` 校验的就是"实际 TCP 是否在 `pose_start` 附近", 判据必须与 `move_p` 的
|
|
705
|
+
#: 到位判据一致 (同一把尺子), 否则 move_p 收工的位置会被 move_c 判成"起点不一致"。
|
|
706
|
+
#: ⚠ 同源关系由 `test_cart_protocol.py::test_cart_start_tolerances_track_move_p_defaults`
|
|
707
|
+
#: 钉住 —— 只靠这两个数字与 `Arm.move_p` 的默认值**字形相同**是守不住的: 改了 move_p 默认
|
|
708
|
+
#: 而忘了这里, `move_c` 的起点判据会**静默不跟随**。
|
|
709
|
+
CART_START_POS_TOL = 0.006
|
|
710
|
+
CART_START_RPY_TOL = 0.03
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@dataclass
|
|
714
|
+
class CartPlan:
|
|
715
|
+
"""一条笛卡尔命令的**规划结果** (固件 `RSP_CART_PLAN 0x4E` 的载荷)。
|
|
716
|
+
|
|
717
|
+
三个入口 (:meth:`Arm.move_l` / :meth:`Arm.move_c` / :meth:`Arm.move_path`)
|
|
718
|
+
都返回它。`ok=False` 时入口**不返回**而是抛对应异常 (见 :func:`raise_for_plan`)。
|
|
719
|
+
|
|
720
|
+
⚠ **`started_busy` / `settled` / `q_final` / `settle_err_rad` 四个字段由 `wait`
|
|
721
|
+
决定** (三条入口的 `wait` 参数, 默认 `True`):
|
|
722
|
+
|
|
723
|
+
| `wait` | 这四个字段 |
|
|
724
|
+
|---|---|
|
|
725
|
+
| `True` | 填**真值** —— 入口等到状态帧的 `CART_BUSY` (bit10) 落 0 且 `q` 静止, **再回读一次 TCP 与目标比对**才返回 |
|
|
726
|
+
| `False` | 一律"未等待"默认值 (`False` / `False` / `[]` / `0.0`), 只等到**规划结果** |
|
|
727
|
+
|
|
728
|
+
`wait=False` 时这四个字段**不是**"没到位", 而是"**没等**" —— 想知道臂现在什么样,
|
|
729
|
+
该 `get_state()` 回读, 而不是拿这份陈旧的规划去推断。
|
|
730
|
+
|
|
731
|
+
`started_busy` = 是否在**可信**状态帧上见过 `bit10=1`; `q_final` = **收尾帧**的 `st.q`。
|
|
732
|
+
|
|
733
|
+
⚠⚠ `settled=True` 要**两条一起**成立 (缺一不可, 见 `_tcp_reached`):
|
|
734
|
+
① 到位判据 (先见 `bit10=1` 再见 `0` 且 `q` 静止) 收的尾; **且** ② 到位判据满足**之后**
|
|
735
|
+
回读的**实际 TCP** 与**本次请求的目标位姿**对得上 (容差 6mm / 0.03rad)。
|
|
736
|
+
|
|
737
|
+
⚠⚠ **`ok=True` 而 `settled=False` 是一个必须存在的结局**, 有**三条**成因 —— 现场请**先**
|
|
738
|
+
排除第 ③ 条, 它最常见:
|
|
739
|
+
① 这条笛卡尔轨迹被**别的运动作废** (锁外/进程外的 `movej`/`home`/`zero_g`: 固件一收到就
|
|
740
|
+
`cart_invalidate_before_motion()` 把这条轨迹作废, 而收尾照样是 `bit10=0` + `q` 静止);
|
|
741
|
+
② `get_tcp()` 取不到 (超时/断连 —— 保守回"没到位");
|
|
742
|
+
③ **臂停稳了, 但回读的实际 TCP 落在 6mm / 0.03rad 容差之外** —— 这条**不在异常名单**
|
|
743
|
+
里, 是**普通结局**: ① ② 都满足、`settle_err_rad` 也可以很小, 只要 TCP 精度不够就是
|
|
744
|
+
`settled=False`。真机实测 (yd 机整臂, `Litearm1.10.0-7J`) 20mm 的 `move_l`:
|
|
745
|
+
**5 轮里 4 轮**落在这一条 (回读位置差 7.9~9.9mm, 容差 6mm), 第 5 轮 2.7mm 才 True。
|
|
746
|
+
⇒ **`settled=False` 既不是"被作废"也不是"没停稳"** —— 判据是**回读 `get_tcp()` 与目标
|
|
747
|
+
比一次**, 别从这两个字段猜。
|
|
748
|
+
**`ok` 只说明"固件受理并规划出来了"**, 不说明臂停在目标上。
|
|
749
|
+
⇒ 现场**必须回读 `get_tcp()` 看真实落点**, 不要把它读成"只是没停稳"。
|
|
750
|
+
(`wait=True` 的**超时/故障**不是这个形状 —— 那两条会抛 `MotionTimeoutError` /
|
|
751
|
+
`MotorFaultError`, `settled` 保持默认 `False` 且调用方拿不到 `CartPlan`。)
|
|
752
|
+
|
|
753
|
+
⚠ **降级分支**: 可信帧上**始终**没见过 `bit10=1` 但 `q` 静止判据成立时, 同样
|
|
754
|
+
`settled=True` 而 `started_busy=False`, **不抛异常** ("不能判失败" —— 规划短到
|
|
755
|
+
两帧之间就跑完、或 `bit10` 这一位没上报, 都会落在这里)。
|
|
756
|
+
|
|
757
|
+
⚠ `settle_err_rad` 的名字沿用旧 PC 侧规划器的字段, 但**语义已经变了**: 从前是
|
|
758
|
+
"收尾后与**终点指令**的最大关节偏差", 而在固件原生路径下**该量物理不可得**
|
|
759
|
+
(PC 侧没有目标关节向量, 也不许 IK)。现在它是"**结束时各轴 `q` 与 `q_final` 的差**"
|
|
760
|
+
—— 即判到位那 `arrive_frames` 帧的抖动幅度 (与**终点指令**无关)。
|
|
761
|
+
"""
|
|
762
|
+
|
|
763
|
+
ok: bool = False
|
|
764
|
+
err: int = 0
|
|
765
|
+
n_wp: int = 0
|
|
766
|
+
plan_us: int = 0
|
|
767
|
+
started_busy: bool = False
|
|
768
|
+
settled: bool = False
|
|
769
|
+
q_final: List[float] = field(default_factory=list)
|
|
770
|
+
settle_err_rad: float = 0.0
|
|
771
|
+
|
|
772
|
+
#: SDK 自造的 `err` 档 (**不是**固件 `cart_err_t` 的取值): 这条请求的**结局未知**。
|
|
773
|
+
#: 与 :class:`CartReplyLostError` 说的是同一件事, 只是从 `CartPlan` 的角度表述。
|
|
774
|
+
#: ⚠ 当前**没有产出者** —— 三条入口在这个情形下直接抛 `CartReplyLostError`
|
|
775
|
+
#: (见 `_CartPending.wait` 的"少了一条"守卫), 于是不会构造出带本档的 `CartPlan`。
|
|
776
|
+
ERR_REPLY_LOST = -1
|
|
777
|
+
|
|
778
|
+
@classmethod
|
|
779
|
+
def from_reply(cls, payload: bytes) -> "CartPlan":
|
|
780
|
+
"""`0x4E` 载荷 -> `CartPlan`。
|
|
781
|
+
|
|
782
|
+
载荷 = `ok u8 + err u8 + n_wp u16 LE + plan_us u32 LE` (8B)。
|
|
783
|
+
"""
|
|
784
|
+
if len(payload) < 8:
|
|
785
|
+
raise TransportError(
|
|
786
|
+
f"RSP_CART_PLAN 帧短: {len(payload)}B (应 8B) —— 帧布局漂移?")
|
|
787
|
+
return cls(ok=payload[0] != 0, err=payload[1],
|
|
788
|
+
n_wp=struct.unpack_from("<H", payload, 2)[0],
|
|
789
|
+
plan_us=struct.unpack_from("<I", payload, 4)[0])
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def raise_for_plan(plan: CartPlan) -> None:
|
|
793
|
+
"""`ok=False` 时按 `err` 抛对应异常 (`ok=True` 时什么都不做)。
|
|
794
|
+
|
|
795
|
+
| err | 抛 |
|
|
796
|
+
|---|---|
|
|
797
|
+
| 1 `IK` | `IKError` |
|
|
798
|
+
| 2 `COLLINEAR` / 3 `TOO_LONG` / 4 `LIMIT` | `CartesianPlanError` |
|
|
799
|
+
| 5 `CANCELED` | `MotionSupersededError` (**独立成型, 不是失败**) |
|
|
800
|
+
| 6 `BADARG` | `InvalidCommandError` |
|
|
801
|
+
|
|
802
|
+
⚠ `CANCELED` 单独成型是刻意的: 接管是正常用法 (新目标来了就接管旧的), 混进
|
|
803
|
+
"规划失败"会让调用方走故障恢复。反过来 `CartesianPlanError` 的三档都是
|
|
804
|
+
**"这条轨迹本身不成立, 臂一步没动"**。
|
|
805
|
+
|
|
806
|
+
⚠ 未登记的 `err` 值也归到 `CartesianPlanError` (ok=0 就是"规划没成"), 但异常消息里
|
|
807
|
+
带上原始码 —— 固件新增一档时不该被静默吞掉。
|
|
808
|
+
"""
|
|
809
|
+
if plan.ok:
|
|
810
|
+
return
|
|
811
|
+
err = plan.err
|
|
812
|
+
if err == CART_ERR_IK:
|
|
813
|
+
raise IKError(f"固件笛卡尔规划失败: 路点 IK 无解或解跳变 (err={err})")
|
|
814
|
+
if err == CART_ERR_CANCELED:
|
|
815
|
+
raise MotionSupersededError(
|
|
816
|
+
"这条笛卡尔请求被固件的另一条请求取代 (err=5 CANCELED) —— "
|
|
817
|
+
"预期内的接管, 不是失败")
|
|
818
|
+
if err == CART_ERR_BADARG:
|
|
819
|
+
raise InvalidCommandError(f"固件拒绝这条笛卡尔命令: 入参非法 (err={err})")
|
|
820
|
+
detail = {CART_ERR_COLLINEAR: "三点共线, 定不出圆 (move_c)",
|
|
821
|
+
CART_ERR_TOO_LONG: "路点超容量 —— 整条拒绝, 臂一步没动",
|
|
822
|
+
CART_ERR_LIMIT: "路点或段中插值越关节限位"}.get(err, f"未登记的 err={err}")
|
|
823
|
+
raise CartesianPlanError(f"固件笛卡尔规划失败: {detail} (err={err})")
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
# ---------------------------------------------------------------------------
|
|
827
|
+
# 能力探测
|
|
828
|
+
# ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
#: 探测的**总**尝试次数 (首次 + 重试)。见 `probe` 的 docstring: 读超时只可能是丢帧。
|
|
831
|
+
_CART_PROBE_ATTEMPTS = 3
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def probe(arm) -> bool:
|
|
835
|
+
"""固件是否支持笛卡尔 (受 `#if LITEARM_CART_PLAN` 编译开关约束)。
|
|
836
|
+
|
|
837
|
+
⚠ **只发空载荷的 `0x3A`**: `0x3A~0x3E` 里**没有只读命令** —— 发一条合法载荷就是
|
|
838
|
+
"connect 之后臂自己动一下"。空载荷撞的是固件的**长度校验** (`usb_cmd.c` 的
|
|
839
|
+
`len < 28`), 它排在 `cart_gate_ok` 与一切副作用**之前**, 于是:
|
|
840
|
+
|
|
841
|
+
| 固件 | 空载荷 0x3A 的回码 | 判读 |
|
|
842
|
+
|---|---|---|
|
|
843
|
+
| 有笛卡尔 (开关 ON) | `ERR{0x3A, 0x01}` (长度不足) | **能力在** |
|
|
844
|
+
| 无笛卡尔 (开关 OFF) | `ERR{0x3A, 0x00}` (`default` 分支) | **能力不在** |
|
|
845
|
+
|
|
846
|
+
(`0x3B`/`0x3C`/`0x3D` 的长度校验同样排在一切副作用之前, 所以它们也能当探针;
|
|
847
|
+
选 `0x3A` 只是因为它是笛卡尔里语义最直白的那条。)
|
|
848
|
+
|
|
849
|
+
⚠ **探测帧不进 `_CartPending` 队列** (走 `_write_query`, 不登记 token): 登记了就会
|
|
850
|
+
造出一个永远等不到 `0x4E` 的悬挂态 —— `0x4E` 只在**受理了**一条规划之后才发。
|
|
851
|
+
|
|
852
|
+
⚠⚠ **"一片安静" ≠ "不支持" —— 故本探测有界重试** (`_CART_PROBE_ATTEMPTS` 次):
|
|
853
|
+
固件的 `default` 分支保证**任何**固件都会对 `0x3A` 回一条 `ERR{cmd,0x00}`, 所以
|
|
854
|
+
读超时只可能是**这一帧丢了** (本硬件的 USB CDC 上行有已知丢帧), 而不是"固件没有
|
|
855
|
+
这条命令"; 而本函数的结果在 `connect()` 里缓存**整个会话**, 一次丢帧就会让这个
|
|
856
|
+
会话的笛卡尔能力被误判成"固件没编进去"。两种回应的处置**不同**:
|
|
857
|
+
|
|
858
|
+
* **`ERR{0x3A, 0x00}` = 确定结论** (固件确实没有这条命令) ⇒ **立刻返回 `False`,
|
|
859
|
+
不重试**;
|
|
860
|
+
* **读超时 (一片安静)** ⇒ 重试, 至多 `_CART_PROBE_ATTEMPTS` 次; 仍安静才返回
|
|
861
|
+
`False` (**fail-closed** —— 未确认支持就不发能起规划的命令), 并把这一情形
|
|
862
|
+
记进 `arm._cart_probe_silent`, 让 `_require_cart_support` 的报错能与"固件确报
|
|
863
|
+
不支持"**分别措辞** (否则现场无法归因: 该换固件还是该查链路)。
|
|
864
|
+
|
|
865
|
+
⚠ **迟到的探测 ERR 会从无关读路径里冒出来** (已知的归因误导, 不是新缺陷):
|
|
866
|
+
`expect(..., raise_on_err=False)` 只认**读到**的那一条; 若三次都判成"一片安静"
|
|
867
|
+
(本函数已 fail-closed 返回), 而固件那条 `ERR{0x3A,0x01}` 其实只是**晚到** —— 它就会在
|
|
868
|
+
之后某次读里被别的读循环认下, 而 `_Ack.expect`/`Arm._read_status` 匹配 `RSP_ERR`
|
|
869
|
+
时**只看命令码** ⇒ `get_state()`/`get_tcp()` 会抛 `CommandRejectedError{0x3A,0x01}`。
|
|
870
|
+
方向是安全的 (fail-closed: 一条能起规划的命令都没发出去, 臂不会因它而动), 但现场读到
|
|
871
|
+
的是"笛卡尔命令被拒", 与真相 ("探测的应答迟到") 不符。
|
|
872
|
+
⚠ 重试把这种机会从 1 次变成 `_CART_PROBE_ATTEMPTS` 次 —— 换来的好处见上 (一次丢帧
|
|
873
|
+
不该把整个会话的笛卡尔能力判成"固件没编进去"), 这个代价是知情的。
|
|
874
|
+
"""
|
|
875
|
+
arm._cart_probe_silent = False
|
|
876
|
+
for _ in range(_CART_PROBE_ATTEMPTS):
|
|
877
|
+
arm._write_query(P.CMD_MOVE_L, b"")
|
|
878
|
+
try:
|
|
879
|
+
_, payload = arm._require().expect(
|
|
880
|
+
P.RSP_ERR, 1.0, "笛卡尔能力探测 (0x3A 空载荷)",
|
|
881
|
+
echo_cmd=P.CMD_MOVE_L, raise_on_err=False)
|
|
882
|
+
except MotionTimeoutError:
|
|
883
|
+
continue # 安静 = 这一帧丢了, 再试一次
|
|
884
|
+
# 判据就是能力判定那一条: **0x00 恒等于固件的 `default` 分支** (未实现该命令),
|
|
885
|
+
# 其余任何非 0 的错误码都说明这条命令在固件里存在 (`test_capability.py` 与
|
|
886
|
+
# `test_protocol_sync.py::test_err_code_zero_only_comes_from_default_branch` 守它)。
|
|
887
|
+
return (payload[1] if len(payload) > 1 else 0) != 0x00
|
|
888
|
+
arm._cart_probe_silent = True # 3 次均无应答 —— 探测未获确认 (不是"确报不支持")
|
|
889
|
+
return False
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
# ---------------------------------------------------------------------------
|
|
893
|
+
# 三条入口 —— ⚠ 守卫顺序是承重的: 见本文件顶部 docstring 与下面 `_request_and_wait`
|
|
894
|
+
# ---------------------------------------------------------------------------
|
|
895
|
+
|
|
896
|
+
def _require_cart_support(arm) -> None:
|
|
897
|
+
"""② 能力检查 —— 排在 `_require()` 之后、零重力守卫之前。
|
|
898
|
+
|
|
899
|
+
⚠ 缓存在 `Arm._cart_supported` (私有名)。写成公开的 `arm.cart_supported` 或加一层
|
|
900
|
+
`arm.cart` 门面会让 `tests/test_full_coverage.py::test_public_api_surface_is_all_exercised`
|
|
901
|
+
变红 —— 它会要求每个公开成员都被练习过。
|
|
902
|
+
|
|
903
|
+
⚠ 报错**分两支措辞** —— 探测那一步把两种"不支持"分开了 (`arm._cart_probe_silent`),
|
|
904
|
+
在这里混成一句话会让现场无法归因: 一支该换固件, 另一支该查链路。
|
|
905
|
+
两支的处置方向相同 (都不发帧, fail-closed), 分开的只是**说法**。
|
|
906
|
+
"""
|
|
907
|
+
if arm._cart_supported:
|
|
908
|
+
return
|
|
909
|
+
if arm._cart_probe_silent:
|
|
910
|
+
why = (f"探测未获确认: connect() 时的空载荷 0x3A 探测 {_CART_PROBE_ATTEMPTS} 次"
|
|
911
|
+
f"都没等到应答 —— 固件的 default 分支保证任何固件都会回一条 ERR, "
|
|
912
|
+
f"所以这只能是上行丢帧 (本硬件 USB CDC 有已知丢帧), 不是'固件没有这条命令'")
|
|
913
|
+
else:
|
|
914
|
+
why = ("固件确报不支持: connect() 时的空载荷 0x3A 探测回了 ERR{0x3A,0x00} "
|
|
915
|
+
"(default 分支 ⇒ 固件没编进 LITEARM_CART_PLAN)")
|
|
916
|
+
raise UnsupportedByFirmwareError(
|
|
917
|
+
f"{why} —— 故不发帧 (未确认支持就不发能起规划的命令)。"
|
|
918
|
+
f"当前固件: {arm.firmware or '?'}", cmd=P.CMD_MOVE_L, code=0x00)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _reject_in_zero_g(arm) -> None:
|
|
922
|
+
"""③ 零重力守卫 —— **必须在 `request()` 之前**跑完。
|
|
923
|
+
|
|
924
|
+
`Arm._write_cmd` 里有一道判据完全相同的守卫 (`if guarded and self._zg_active`),
|
|
925
|
+
它在这里**重复**出现, 是为了让"零重力保活期间不许发动作命令"这条判定发生在
|
|
926
|
+
**登记 token 之前** —— 否则 `InvalidCommandError` 会在写失败的位置抛出, 而
|
|
927
|
+
`_CartPending.request` 只对 `TransportError` 摘 token, 于是 token 留在队里。
|
|
928
|
+
|
|
929
|
+
保留 `_write_cmd` 里那道不是冗余: 它是**兜底**, 覆盖"检查与写之间 `_zg_active`
|
|
930
|
+
翻转"的极端竞态 (那时 token 滞留 —— 方向安全, 后续报"结局未知"而不是假成功)。
|
|
931
|
+
|
|
932
|
+
文案取自 `arm.ZERO_G_GUARD_MESSAGE` (与 `_write_cmd` 那道**同一个常量** —— 从前是各抄
|
|
933
|
+
一份逐字节相同的字符串, 改一侧就静默漂移; 调用方按文案归因, 两句不一样会被读成两种
|
|
934
|
+
不同的拒绝)。⚠ 该常量只能在**函数内**导入: `arm` 在模块级导入 `cart`, 反过来在模块级
|
|
935
|
+
导入会让两边都在半初始化状态 (同 `_rot.as_pose` 里那处 `errors` 的延迟导入)。
|
|
936
|
+
"""
|
|
937
|
+
if arm._zg_active:
|
|
938
|
+
from litearm.arm import ZERO_G_GUARD_MESSAGE
|
|
939
|
+
raise InvalidCommandError(ZERO_G_GUARD_MESSAGE)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _as_pose6(pose, label: str) -> List[float]:
|
|
943
|
+
"""位姿入参 -> `[x,y,z,r,p,y]` (固件 `0x4E` 家族的载荷形态)。
|
|
944
|
+
|
|
945
|
+
形态判定整个委托给 :func:`litearm._rot.as_pose` —— 它收
|
|
946
|
+
`(pos[3], R[3x3])` / `[x,y,z,roll,pitch,yaw]` / 4x4 齐次矩阵三种写法。
|
|
947
|
+
这**不是**放宽: 三条入口替代的旧 `movel/movec` 本来就收两种写法
|
|
948
|
+
("固件的 `[x,y,z,r,p,y]` **或** pylitearm 的 `(position[3], rotation[3x3])`"),
|
|
949
|
+
只收 6 标量是**公开 API 的能力倒退**。
|
|
950
|
+
|
|
951
|
+
⚠ 不合法时 `as_pose` 抛的 `InvalidCommandError` 文案里带**实际收到的形状** ——
|
|
952
|
+
这里只把 `label` (哪条入口的哪个位姿) 前缀上去, 原文**原样保留**, 不重写、
|
|
953
|
+
不换成笼统的"pose 非法": 只说非法会让调用方在 6 向量与位姿对之间反复猜。
|
|
954
|
+
|
|
955
|
+
⚠ **`ValueError`/`TypeError` 也必须收进 `LiteArmError` 体系** —— 它们是**入参畸形**
|
|
956
|
+
(`move_l([0.1,...,"x"])` 的 `float("x")`、`move_l((pos, [1,2,3]))` 的容器类型不对),
|
|
957
|
+
与上一条是同一种处境, 只是发生在 `as_pose` 的**数值转换**里而不是形态判定里。
|
|
958
|
+
从前 (`_as_pose6` 的老版本) 这两类**正是** `InvalidCommandError`; 把形态判定整个委托
|
|
959
|
+
给 `as_pose` 之后漏了这一步 ⇒ **回归**: 调用方按 `LiteArmError` 做分支的代码对 5 种
|
|
960
|
+
畸形入参全部失效 (帧未发出, 方向安全, 但异常类型契约破了)。
|
|
961
|
+
故这里原样转包, 文案仍保留 `as_pose` 那句的原文 (它带实际收到的形状/值)。
|
|
962
|
+
"""
|
|
963
|
+
try:
|
|
964
|
+
pos, R = as_pose(pose)
|
|
965
|
+
except InvalidCommandError as e:
|
|
966
|
+
raise InvalidCommandError(f"{label}: {e}") from None
|
|
967
|
+
except (TypeError, ValueError) as e:
|
|
968
|
+
raise InvalidCommandError(f"{label}: {e}") from None
|
|
969
|
+
return list(pos) + mat_to_rpy(R)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _as_speed(speed, label: str) -> float:
|
|
973
|
+
sp = float(speed)
|
|
974
|
+
if not 0.0 <= sp <= 1.0:
|
|
975
|
+
raise InvalidCommandError(f"{label}: speed 需 0..1 (给的是 {speed})")
|
|
976
|
+
return sp
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _seq_now(arm) -> Optional[int]:
|
|
980
|
+
"""此刻**已知**的最高状态帧序号 (`RobotState.seq`, `_on_status` 每帧填)。
|
|
981
|
+
|
|
982
|
+
它是到位判据的**新鲜度闸**水位线: 见 :func:`_wait_settled`。还没见过任何状态帧时
|
|
983
|
+
返回 `None` (那次等待会先拿第一帧当起算点)。
|
|
984
|
+
|
|
985
|
+
⚠ "已知的最高" = **已解码的最后一帧**, 不是"固件此刻的计数器" —— 主机没有后者的
|
|
986
|
+
读法 (状态帧里那个 `seq` 就是全部信息)。
|
|
987
|
+
"""
|
|
988
|
+
st = arm._require().state
|
|
989
|
+
return None if st is None else st.seq
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _tcp_reached(arm, goal) -> bool:
|
|
993
|
+
"""到位判据满足**之后**, 回读一次**实际 TCP** 与本次请求的目标位姿比对。
|
|
994
|
+
|
|
995
|
+
⚠⚠ **这是"假成功"方向唯一的一道防线**: `bit10 1→0` + `q` 静止 这个观察, **同样**
|
|
996
|
+
由"这条轨迹被**别的运动**作废之后的收尾"产生 —— 固件 `ctrl_accept_move_j` 的**第一条
|
|
997
|
+
语句**就是 `cart_invalidate_before_motion()` ⇒ `cart_abort()`, 于是下一帧 `bit10=0`,
|
|
998
|
+
臂转去执行**那条**命令; 它跑完也静止 ⇒ 只看 `bit10` 会把"中途被扯断、TCP 根本不在
|
|
999
|
+
目标上"报成到位。跨进程同样成立 (本机 CDC 口不独占, 别的进程发一条 `0x01` 就能作废
|
|
1000
|
+
我们这条规划), **SDK 侧拦不住**。
|
|
1001
|
+
⚠ 处置刻意**不是**去拦 `movej`: 它是**受控接管** (新 S 曲线从当前状态收口), 拦它等于
|
|
1002
|
+
把人推去用更重的 `estop`; 而 `zero_g` 丢掉位置环、靠摩擦滑停, 拦它才是推向更好的动作
|
|
1003
|
+
—— 两根方向相反的判断, 别抄成一条 (见 §5.3)。
|
|
1004
|
+
|
|
1005
|
+
容差复用 `CART_START_POS_TOL`/`CART_START_RPY_TOL`: 与 `Arm.move_p` 的到位判据、
|
|
1006
|
+
`move_c` 的起点校验是**同一把尺子** (这三处的同源关系由
|
|
1007
|
+
`test_cart_protocol.py::test_cart_start_tolerances_track_move_p_defaults` 钉住)。
|
|
1008
|
+
|
|
1009
|
+
⚠ **取不到就报"没到位"** (超时 / 断连 / 被别的 `ERR` 串台): 保守方向, 与"宁可报未知"
|
|
1010
|
+
同向。故障不归这里管 —— 它由到位等待里的 `st.faulted` 立刻抛 `MotorFaultError`。
|
|
1011
|
+
"""
|
|
1012
|
+
try:
|
|
1013
|
+
tcp = arm.get_tcp().value
|
|
1014
|
+
except LiteArmError:
|
|
1015
|
+
return False
|
|
1016
|
+
if tcp is None:
|
|
1017
|
+
return False
|
|
1018
|
+
return arm._pose_near(tcp, goal, CART_START_POS_TOL, CART_START_RPY_TOL)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _wait_settled(arm, label: str, seq0: Optional[int]):
|
|
1022
|
+
"""等"臂真的停下来" —— 返回 `(started_busy, q_final, settle_err_rad)`。
|
|
1023
|
+
|
|
1024
|
+
⚠ **判据是新写的, 不能复用 `_Ack.pump` / `Arm._arrive`**: 两者的 `done` 判据都要
|
|
1025
|
+
**目标关节向量**, 而笛卡尔路径的目标 `q` 在 PC 侧**不存在** (规划在固件里, PC 不发
|
|
1026
|
+
也不做 IK) ⇒ 这里的"静止"只能比**相邻两帧** (位置差 < `dq_tol / STATUS_HZ`,
|
|
1027
|
+
见下条), 没有目标可比。取帧走 `Arm._read_status` (它内部就是 `_read_one`), 上限沿用
|
|
1028
|
+
`Arm.move_timeout` (不新造旋钮)。
|
|
1029
|
+
|
|
1030
|
+
⚠ **[2026-10-10] 静止判据只有一条**: 相邻两帧的 q 逐轴差 < `dq_tol / STATUS_HZ`,
|
|
1031
|
+
连续 `arrive_frames` 帧 (与 `Arm._arrive` 的 `done()` 同款, **两处必须同步改**)。
|
|
1032
|
+
旧写法是"`dq` 的 max-norm < `dq_tol` **且** 帧间差 < `q_tol`", 其中 **`dq` 那条
|
|
1033
|
+
是绑定项** (0.1 rad/s 在 100 Hz 下蕴含帧间差 < 0.001 rad, 远小于 0.03), 故换成
|
|
1034
|
+
等价位移后**严格程度不变**, 唯一变化是不再相信上报的 `dq`。
|
|
1035
|
+
理由: 实测 (yd 机整臂, `Litearm1.10.0-7J`) 臂静止 800 帧、位置漂移 0.00000 rad,
|
|
1036
|
+
而 **J7 上报的 `dq` 仍摆到 0.256 rad/s, 66% 的帧超 `dq_tol`** —— 叠着它会让
|
|
1037
|
+
"停稳"永远不成立 (臂停在目标上却报 `未到位, 超时`)。
|
|
1038
|
+
|
|
1039
|
+
⚠⚠ **新鲜度闸** (`seq0` + `(st.seq - seq0) & 0xFFFF ∈ (0, 32768)`, u16 回绕安全):
|
|
1040
|
+
只信"命令发出之后生成"的状态帧。**陈旧帧直接丢弃: 既不判到位, 也不判故障** ——
|
|
1041
|
+
故障是锁存的, 真故障会在可信帧上重现, 用陈旧帧判故障只会造成假中止。
|
|
1042
|
+
|
|
1043
|
+
⚠ **这道闸的射程要说清** (别把它读成"运动前的帧都是它挡住的"): 命令之前生成的状态帧
|
|
1044
|
+
在流里一律排在**那条命令的 ACK 之前**, 而 `_request_and_wait` 必读 ACK
|
|
1045
|
+
(`expect(RSP_ACK)` 会一路读到它) ⇒ 它们**到不了本循环**。真正挡住"把运动前的位置报成
|
|
1046
|
+
到位"的是**那次 ACK 排水**; 本闸守的是另一支: 万一有帧的 `seq` 落在水线之前
|
|
1047
|
+
(重放 / 换取帧口 / 水线取错), 它必须被丢掉而不是拿去判到位或判故障。
|
|
1048
|
+
|
|
1049
|
+
⚠ **必须能退出**: 一旦臂在**安全包络触发**下锁存 (`control_loop.c:1713-1719` 置
|
|
1050
|
+
`mode = ARM_MODE_EMERGENCY` + `enabled = false`, **不动** cart FSM —— `safety_check.c`
|
|
1051
|
+
里一个 `cart` 字样都没有), `cart_advance()` 又排在控制拍那两个早退分支**之后**
|
|
1052
|
+
(`control_loop.c:1743` EMERGENCY / `:1770` 未使能 vs `:1890`), 于是 **RUNNING/READY**
|
|
1053
|
+
期的状态机推不动、`CART_BUSY` **常亮** (`cart_tick_timeout` 不判这两个态 ——
|
|
1054
|
+
`cart_exec.c:881` 只判 PLANNING)。只等 `bit10` 落 0 会一直等到超时 —— 这就是上限存在
|
|
1055
|
+
的理由 (另外 `st.faulted` 那条会立刻抛: EMERGENCY 置 mode=6)。
|
|
1056
|
+
⚠ **但"常亮"别读成三条路径都成立**:
|
|
1057
|
+
· **`PLANNING` 是例外, ≤3s 自解** —— `cart_tick_timeout` 排在两个早退分支**之前**
|
|
1058
|
+
(`control_loop.c:1666` vs `:1743`/`:1770`), 它的 `plan_left` 到点走 `cart_abort()`
|
|
1059
|
+
(`cart_exec.c:889-890`) 回 `IDLE` ⇒ `bit10` 最多亮 `CART_PLAN_MAX_TICKS` = 3s
|
|
1060
|
+
(`cart_exec.h:42`);
|
|
1061
|
+
· **命令**引起的失能/急停反而**会**清掉它 —— 全固件只有四处写 `enabled = false`,
|
|
1062
|
+
其中三处是命令路径 (`ctrl_disable()` `control_loop.c:1207` / `ctrl_emergency_stop()`
|
|
1063
|
+
`:1225` / `ctrl_reset()` `:1302`), 三处都**先**调 `cart_invalidate_before_motion()`
|
|
1064
|
+
(`:1208` / `:1226` / `:1303`) → `cart_abort()`; 第四处就是上面那条安全锁存
|
|
1065
|
+
(`:1718`)。所以"常亮"只对 **安全锁存 + RUNNING/READY** 成立。
|
|
1066
|
+
|
|
1067
|
+
⚠ 断连 (`Arm.close()`) 时本循环在**下一次取帧**就拿到 `NotConnectedError` 退出
|
|
1068
|
+
(远小于 `move_timeout`), 不会挂到超时 —— 归因必须是"链路断了"而不是"没到位"。
|
|
1069
|
+
故取帧**必须**经 `arm._read_status` / `arm._require()`: 直接摸 `arm._a` 在 `close()`
|
|
1070
|
+
之后是 `None`, 抛出来的会是 `AttributeError` 而不是 `NotConnectedError`。
|
|
1071
|
+
|
|
1072
|
+
⚠ **帧被别的读者抢走不算异常**: `get_state()` / `get_tcp()` 不持 `Arm._cart_serial`,
|
|
1073
|
+
并发调用会把状态帧从本循环的取帧口抢走。所以本循环**不假设帧是自己读到的** —— 只要
|
|
1074
|
+
`arm._require().state` 的 `seq` 变了就重新评估判据 (同一帧用 `last_seq` 去重, 免得
|
|
1075
|
+
被数成 `arrive_frames` 帧)。只认"自己读到的帧"的实现会被并发读者**饿到超时**
|
|
1076
|
+
(假失败)。
|
|
1077
|
+
⚠ 状态帧被抢走无所谓 (上面这条兜住了), **`ACK`/`0x4E` 被抢走则不可恢复** —— 那条
|
|
1078
|
+
请求的主人再也拿不到自己那条应答。这类"认识、但不是本次要的"帧记在
|
|
1079
|
+
`_Ack.foreign_frames` 上 (**八处逐处**计: 七处读循环 `_Ack.expect` / `_Ack.pump` /
|
|
1080
|
+
`_read_status` / `get_status_now` / `_arrive` / 本地那处 pump / `Arm._wait_until_link_lost`
|
|
1081
|
+
的 DFU 消失观察窗口, 外加 `Arm.poll_cart()`), 从前**连计数都没有**。
|
|
1082
|
+
⚠ `Arm.poll_cart()` 也在这张计数清单里 (它同样是抢帧者, 见下面那份清单) —— 它**不是读
|
|
1083
|
+
循环** (只取一帧就返回), 但被它吃掉的帧同样计: "是不是循环"与"计不计"无关, 而那条帧的
|
|
1084
|
+
主人照样只会看到"无应答"。
|
|
1085
|
+
|
|
1086
|
+
⚠ **别的命令被拒会从本循环里炸出来**: 取帧走 `Arm._read_status`, 而它对**任意**
|
|
1087
|
+
`RSP_ERR` 都是直接抛 (`_err_from` 只按命令码归因, 不判"这条 ERR 是不是我这道命令的")
|
|
1088
|
+
—— `wait=True` 期间别人的命令被拒 (例如保活 `0x06`) 会抛成
|
|
1089
|
+
`CommandRejectedError` 而不是"没到位"。这是 `Arm._read_status` 的既有行为
|
|
1090
|
+
(`Arm._arrive` 同款), **不是本步引入的**: 串行锁只挡住笛卡尔入口, 挡不住所有下行。
|
|
1091
|
+
"""
|
|
1092
|
+
budget = arm.move_timeout
|
|
1093
|
+
end = time.monotonic() + budget
|
|
1094
|
+
#: 静止阈的**位移**形式 = 速度阈 × 状态帧周期 (见 `_protocol.STATUS_HZ`,
|
|
1095
|
+
#: 固定 100 Hz)。与 `Arm._arrive` 同款 —— 旧写法里 `dq < dq_tol` 本就蕴含
|
|
1096
|
+
#: 每帧位移 < 这个值, 故换过来严格程度不变。
|
|
1097
|
+
dstep = arm.dq_tol / P.STATUS_HZ
|
|
1098
|
+
started_busy = False
|
|
1099
|
+
prev_q: Optional[List[float]] = None
|
|
1100
|
+
#: 判到位的那几帧的 q (`arrive_frames` 帧为窗, 静止判据一破就清空重来) ——
|
|
1101
|
+
#: 收尾时用它算 `settle_err_rad` (最后几帧的抖动幅度)。
|
|
1102
|
+
window: List[List[float]] = []
|
|
1103
|
+
#: 上一帧的 `seq` —— 去重: 采样是靠"等 `status_seq` 前进"驱动的, 但**等超时**时
|
|
1104
|
+
#: `state` 可能还是同一帧; 数两次就等于窗里少判了一帧。
|
|
1105
|
+
last_seq: Optional[int] = None
|
|
1106
|
+
while time.monotonic() < end:
|
|
1107
|
+
# ⚠ **[2026-09-22] 采样方式改了**: 从前是"自己取帧 + 容忍并发读者抢走", 现在
|
|
1108
|
+
# 读线程一直在填 `_Ack.state`, 这里只负责**等下一拍**再采它 —— 原来那段
|
|
1109
|
+
# "被别人读走了就采它留下的成果"整块消失(帧不会被抢)。判据仍认"帧"。
|
|
1110
|
+
a_ = arm._require()
|
|
1111
|
+
_cur = a_.status_seq
|
|
1112
|
+
left = end - time.monotonic()
|
|
1113
|
+
arm._pump_until(min(0.05, max(left, 0.0)),
|
|
1114
|
+
lambda a, c=_cur: a.status_seq != c, label)
|
|
1115
|
+
st = arm._require().state
|
|
1116
|
+
if st is None:
|
|
1117
|
+
continue
|
|
1118
|
+
if st.seq == last_seq:
|
|
1119
|
+
continue # 同一帧: 不重复计数
|
|
1120
|
+
last_seq = st.seq
|
|
1121
|
+
if seq0 is None: # 还没有水位线: 第一帧只当起算点
|
|
1122
|
+
seq0 = st.seq
|
|
1123
|
+
continue
|
|
1124
|
+
if not 0 < ((st.seq - seq0) & 0xFFFF) < 32768:
|
|
1125
|
+
continue # 陈旧帧: 不判到位, **也不判故障**
|
|
1126
|
+
if st.faulted:
|
|
1127
|
+
raise MotorFaultError(f"{label}: 未到位即故障 FAULT {st.fault_detail}")
|
|
1128
|
+
if st.cart_busy:
|
|
1129
|
+
started_busy = True
|
|
1130
|
+
q = list(st.q)
|
|
1131
|
+
# ⚠ [2026-10-10] 静止判据**只剩相邻两帧的位置差**, 不再叠加 `dq` ——
|
|
1132
|
+
# 与 `Arm._arrive` 的 `done()` 同款, 两处必须同步改 (理由见那里的长注释:
|
|
1133
|
+
# 实测 J7 静止时上报的 `dq` 会摆到 0.256 rad/s、66% 的帧超 `dq_tol`,
|
|
1134
|
+
# 叠着它"停稳"永远不成立 ⇒ 臂停在目标上却报超时)。
|
|
1135
|
+
# 起点那帧没有"上一帧"可差, 但仍算一帧 (与 `_arrive` 的 `n_ok` 同款)。
|
|
1136
|
+
still = (prev_q is None or (len(prev_q) == len(q)
|
|
1137
|
+
and all(abs(q[i] - prev_q[i]) < dstep for i in range(len(q)))))
|
|
1138
|
+
# 窗 = 本段**连续静止**的帧 (起点那帧没有"上一帧"可差, 但它不破坏连续性: 与
|
|
1139
|
+
# `_arrive` 的 `n_ok` 同款 —— 第一帧就算数); 一动就清空重来。
|
|
1140
|
+
window = (window + [q])[-arm.arrive_frames:] if still else []
|
|
1141
|
+
prev_q = q
|
|
1142
|
+
# 主判据: 见过 bit10=1 ⇒ 要的正是 1→0 那一刻; 降级分支: 始终没见过 1 也收
|
|
1143
|
+
if len(window) >= arm.arrive_frames and not st.cart_busy:
|
|
1144
|
+
q_final = window[-1]
|
|
1145
|
+
err = max((abs(v - f) for w in window for v, f in zip(w, q_final)),
|
|
1146
|
+
default=0.0)
|
|
1147
|
+
return started_busy, q_final, err
|
|
1148
|
+
raise MotionTimeoutError(f"{label} 未到位, 超时 {budget}s")
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _request_and_wait(arm, cmd: int, payload: bytes, label: str, goal,
|
|
1152
|
+
seq0: Optional[int] = None, wait: bool = True) -> CartPlan:
|
|
1153
|
+
"""⑤ 登记 token + 写帧, 然后等 `0x4E` 并映射结果。**全程持 `arm._cart_serial`。**
|
|
1154
|
+
|
|
1155
|
+
`request()` 的 callable **只包含那一次帧写** —— `_write_cmd` (而不是 `_raw_write`):
|
|
1156
|
+
它带零重力守卫, 是既有约定; 绕开它会让"零重力中发笛卡尔"变成只能在固件侧被拒。
|
|
1157
|
+
|
|
1158
|
+
⚠ **串行是强制的** (见模块 docstring): 持锁范围是**登记 → 等 ACK/ERR → 等 `0x4E`**
|
|
1159
|
+
—— 少一段都不行, 因为假成功正是"两条在途交错"造成的 (`ERR` 只能按命令码回显,
|
|
1160
|
+
`0x3A/0x3B/0x3E` 共用码空间: 先受理者的 `expect` 会认下后受理者那条被拒的 `ERR`,
|
|
1161
|
+
随后先受理者那条真 `0x4E` 配给后受理者的活 token ⇒ **后受理者报成功而一步没跑**)。
|
|
1162
|
+
单槽 pending 也决定了流水线发多条必然丢应答。
|
|
1163
|
+
|
|
1164
|
+
⚠ **持锁期间可能阻塞**, 上限**约** `move_timeout` (默认 15s; `move_path` 另含每帧
|
|
1165
|
+
BEGIN/ADD 的 1.2s ACK 窗口 —— 32 路点时最坏 ≈ `move_timeout + 33×1.2s`, 因为那一段
|
|
1166
|
+
也在同一把锁里)。这是"串行"的应有语义, 不是缺陷: 并发调用笛卡尔入口的第二个线程
|
|
1167
|
+
**排队**等待, 而不是与第一条交错。锁序
|
|
1168
|
+
(`Arm._cart_serial` → `_CartPending._lock` → `transport._wlock`) 见
|
|
1169
|
+
`Arm.__init__` 里 `_cart_serial` 的注释 —— 反向无环。
|
|
1170
|
+
⚠ `seq0` 由调用方给 (见 :func:`_seq_now`): 它必须是**命令发出之前**的水位线,
|
|
1171
|
+
所以只能由"知道那一次帧写在哪"的调用方取 —— 在本函数里取就已经晚了
|
|
1172
|
+
(登记/写帧已经在跑)。`None` = 调用方没取到 (此前一帧状态帧都没见过),
|
|
1173
|
+
那就交给到位等待按"先等第一帧再起算"处理 (见 `_wait_settled`)。
|
|
1174
|
+
|
|
1175
|
+
⚠ **等 ACK/ERR 不是可选的**: 固件受理后立刻回一条 `RSP_ACK{cmd}`, 被门禁或长度校验
|
|
1176
|
+
拒掉时回的是 `RSP_ERR{cmd,code}` 且**不会有 `0x4E`**。不读这一条的话, 一次
|
|
1177
|
+
"臂未使能被拒" 会表现成耗满 `move_timeout` 之后的 `CartReplyLostError` (报"结局未知"),
|
|
1178
|
+
把用户推向"臂可能正在动"的错误结论。
|
|
1179
|
+
|
|
1180
|
+
⚠⚠ **这一等必须按"受理必回 ACK、拒绝绝不回 ACK"的形状读** (`err_waits_for_ack=True`,
|
|
1181
|
+
理由见 `_Ack.expect`): 一条**匹配命令码**的 `ERR` **证明不了**"我这条被拒了" ——
|
|
1182
|
+
`0x3A/0x3B/0x3E` **共用码空间**, 而流里可以有**别人的** `ERR` (最现实的一条: `probe()`
|
|
1183
|
+
的迟到探测应答, 见它自己的 docstring; 另一条: 上一会话/别的进程遗留)。认错的代价是
|
|
1184
|
+
**双向**的, 两侧都实测过:
|
|
1185
|
+
|
|
1186
|
+
* 我这条**其实已被受理** ⇒ 调用方拿到假的"被拒"(臂真的会动);
|
|
1187
|
+
* 因此摘掉的那条活 token 让"在途 ⟹ 应答"**少一格** ⇒ 固件为它发的那条 `0x4E` 配给
|
|
1188
|
+
**下一条**命令 ⇒ 下一条**报成功, 而它的命令被拒、一步没跑** = **假成功**。
|
|
1189
|
+
|
|
1190
|
+
故本入口**只在窗口里出现过 `ACK{cmd}` 时**才把它当"已受理"收场, 窗口耗尽仍只有 ERR
|
|
1191
|
+
才判"被拒"。代价: **真被拒时本入口要等满那个 ACK 窗口 (1.0s) 才返回** —— 知情的,
|
|
1192
|
+
换来的是"回 ERR ⟹ 本命令确实没被受理"这句话**在生成侧**成立, ⚠ **但到达侧不是**:
|
|
1193
|
+
受理那条 `ACK{cmd}` **自己也会丢** (固件应答 FIFO 满时丢**最新** —— 散文在
|
|
1194
|
+
`usb_cmd.c:63-64`, 计数点是 `:1126` 的 `rf_dropped++`; 别的读循环也能把它抢走,
|
|
1195
|
+
见下面的残余风险段)。那时
|
|
1196
|
+
窗口里只剩一条**别人的** `ERR` ⇒ 本入口判"被拒", 而本命令其实**已被受理**: 调用方
|
|
1197
|
+
拿到假的"被拒" (臂真的会动), 且那条真 `0x4E` 稍后到达时 —— 队列空 ⇒ `LiteArmError`
|
|
1198
|
+
从无关读路径炸出; 队列非空 ⇒ 配给**下一条**命令 = **假成功**。
|
|
1199
|
+
⚠ 这一支**两树同形、非本次回归**: 只要"读帧在别处可以并发发生"这条前提不变就消不掉。
|
|
1200
|
+
|
|
1201
|
+
⚠ **固件显式拒绝 (`CommandRejectedError`) 时摘掉自己那条 token**: 受理前的每一道
|
|
1202
|
+
校验/门禁都在 `cart_req_*` 之前 `break`, 所以"**本命令的** ERR ⟹ 永远不会有 `0x4E`"
|
|
1203
|
+
(上面那条闸把它的成立范围缩到"**且**那条 ACK 没丢/没被抢", 见上一段)。
|
|
1204
|
+
留着它会占住 FIFO 队首, 让**下一条**合法笛卡尔命令的应答配给这条死 token
|
|
1205
|
+
(下一条报"结局未知", 死 token 却被"报成功" —— 正是本模块最防的那一侧)。
|
|
1206
|
+
⚠ **这里只 `drop`, 不给吸收额度** (`clear_pending`/超时那两处才给): 本命令**确实没被
|
|
1207
|
+
受理** ⇒ 固件**一格应答都不欠** ⇒ 留额度只会去吞**下一条**命令自己的 `0x4E`
|
|
1208
|
+
(它一到就撞上额度 ⇒ 那条报"结局未知")。判"被拒"与"给额度"这两件事**不能同时做**。
|
|
1209
|
+
⚠ 其它异常 (ACK 超时 / `TransportError`) **不摘**: 它们证明不了"固件不会回 `0x4E`",
|
|
1210
|
+
滞留在此是安全方向; 代价是超时路径每轮 `drop_and_absorb` 补一格额度, 而额度**先于**
|
|
1211
|
+
配对吃掉此后到达的应答 ⇒ 积压几格就有**几条**后续命令被误报"结局未知" (E5 实测)。
|
|
1212
|
+
⚠ **恢复路径有三条, 别写成"只有 `reconnect()`"**:
|
|
1213
|
+
① 积压的额度**被消费光** —— 每吃掉一格就不再补 (见 `drop_and_absorb` 的例外段);
|
|
1214
|
+
② 一次**长过 `absorb_ttl` 的空闲** —— 额度惰性过期 (见 `_absorbance_locked`);
|
|
1215
|
+
③ `reconnect()` —— 重建 `_CartPending`, 账目归零。
|
|
1216
|
+
⚠ 但"不摘"的那个 token 会**永久**留在 `_unclaimed` 里 (调用方已带着异常返回) ——
|
|
1217
|
+
界由 `_UNCLAIMED_MAX` 兜住, 见那里。
|
|
1218
|
+
|
|
1219
|
+
⚠ `wait=True` 时**到位等待也在 `with arm._cart_serial` 之内** (不是本函数返回之后
|
|
1220
|
+
再跑): 一条笛卡尔命令的"整条运动"才是串行的单位 —— 释放锁再等的话, 另一个线程可以
|
|
1221
|
+
在持锁者还没停稳时就发下一条 (固件那条会**接管**它), 于是持锁者的到位判据观测到的
|
|
1222
|
+
是**别人那条**运动的收尾 = 假到位。代价是持锁期从"到 `0x4E`"拉长到"到停稳",
|
|
1223
|
+
上限仍是 `move_timeout` (见 `_wait_settled`)。
|
|
1224
|
+
⚠ 但那把锁**挡不住锁外/进程外的行为者** (`movej`/`home`/`zero_g` 与别的进程都在
|
|
1225
|
+
`_cart_serial` 之外): 固件收到它们时会 `cart_invalidate_before_motion()` 把这条轨迹
|
|
1226
|
+
作废 —— 于是"停稳"也可能是**别人那条**命令的收尾。故 `settled` 不能只看 `bit10`,
|
|
1227
|
+
还要 `goal` 对得上 (见 :func:`_tcp_reached`); `goal` 由调用方给 (它知道本次请求的
|
|
1228
|
+
终点: `move_l`/`move_c` 用终点, `move_path` 用**最后一个**路点)。
|
|
1229
|
+
|
|
1230
|
+
⚠ **目标位姿 (`goal`) 是必填的**: 没有它就只剩"看 `bit10`"这一条判据 —— 那是本模块
|
|
1231
|
+
最防的**假成功**方向 (一条被作废的轨迹, 收尾照样是 `bit10=0` + `q` 静止)。留个
|
|
1232
|
+
`None` 默认值等于给后人留一个静默关掉校验的开关。
|
|
1233
|
+
|
|
1234
|
+
⚠⚠ **残余风险 (锁只管笛卡尔入口之间, 不是"读帧是安全的")**: 本锁只在**同一进程里
|
|
1235
|
+
并发的三个笛卡尔入口**之间强制串行。**读帧**这个动作本身在别处仍然可以并发发生 ——
|
|
1236
|
+
其它线程的 `get_state()`/`get_tcp()`、`movej` 的到位等待、**别的进程**(CDC 口不独占),
|
|
1237
|
+
以及 `clear_pending` 这类写口钩子, 都能把在跑那条的 `ACK{cmd}`/`0x4E` 抢走。本包**没有
|
|
1238
|
+
读线程** (决策 12) ⇒ 帧被谁读走就是谁的, 缓存不了、也退不回来; 症状是"受害者报
|
|
1239
|
+
`MotionTimeoutError` / `CartReplyLostError`, 而它的命令其实已生效"。
|
|
1240
|
+
⚠ **这份清单里也包括 `Arm.poll_cart()`** (实测: 把一条在飞的 `ACK{0x3A}` 放进内核
|
|
1241
|
+
缓冲再调它, 那条 ACK **被它吃掉** —— 而它的主人随后报"无应答", 与 F4 修的同类)。
|
|
1242
|
+
它**已经**改成与笛卡尔入口互斥 (非阻塞 `acquire(blocking=False)`, 拿不到就返回 `None`),
|
|
1243
|
+
所以抢不走**入口**的帧; 但清单里其余那些读者都不持那把锁 ⇒ `poll_cart` 仍可能把
|
|
1244
|
+
**它们**的帧吃掉。这一条是**实现里检查过的**: `test_poll_cart_does_not_steal_a_frame_from_a_running_entry`。
|
|
1245
|
+
⚠ 故**别**把这里读成"串行是强制的"这种无条件说法: 强制只覆盖"**入口与入口**之间"。
|
|
1246
|
+
⚠ 段三又添了一个抢帧者: `Arm.enter_dfu()` 的**消失观察窗口**
|
|
1247
|
+
(`Arm._wait_until_link_lost`, 在 ACK 之后读满 0.3s 等设备离开) —— 它同样不持本锁,
|
|
1248
|
+
窗口里撞上在飞那条的 `ACK{cmd}` 就会把它吃掉 (那条请求的 `0x4E` 不受影响:
|
|
1249
|
+
`_read_one` 会把它交给收集器)。与 `poll_cart` 不同的是它**不能**与入口互斥:
|
|
1250
|
+
它要观察的是"链路还在不在", 拿不到锁就不该继续。
|
|
1251
|
+
(可观测性: 被别的读者丢掉的这类帧计在 `_Ack.foreign_frames` 上 —— **八处逐处**计,
|
|
1252
|
+
含本文件 `wait` 里那处 pump 与上面点名的 `poll_cart`; 从前**连计数都没有**。
|
|
1253
|
+
⚠ `poll_cart` 不是读循环, 但被它吃掉的帧同样计 —— "是不是循环"与"计不计"无关。)
|
|
1254
|
+
"""
|
|
1255
|
+
with arm._cart_serial:
|
|
1256
|
+
tok = arm._cart.request(lambda: arm._write_cmd(cmd, payload))
|
|
1257
|
+
try:
|
|
1258
|
+
# ⚠ `err_waits_for_ack=True` 是**载重**的 (理由见 docstring): 只按命令码比对的
|
|
1259
|
+
# `ERR` 会把别人的拒绝认成本条的, 而认错的代价是"假失败 + 下一条假成功"。
|
|
1260
|
+
arm._require().expect(P.RSP_ACK, 1.0, label, echo_cmd=cmd,
|
|
1261
|
+
err_waits_for_ack=True)
|
|
1262
|
+
except CommandRejectedError:
|
|
1263
|
+
arm._cart.drop(tok)
|
|
1264
|
+
raise
|
|
1265
|
+
# ⚠ **[2026-09-22] 这里不再传 `pump`**:`0x4E` 由读线程在 `_Ack._deliver` 里
|
|
1266
|
+
# 直接交给收集器, 本方法只在 token 上阻塞 (理由见 `_CartPending.wait`)。
|
|
1267
|
+
reply = arm._cart.wait(tok, arm.move_timeout)
|
|
1268
|
+
plan = CartPlan.from_reply(reply)
|
|
1269
|
+
raise_for_plan(plan)
|
|
1270
|
+
if wait:
|
|
1271
|
+
plan.started_busy, plan.q_final, plan.settle_err_rad = \
|
|
1272
|
+
_wait_settled(arm, label, seq0)
|
|
1273
|
+
# 到位判据满足**之后**才回读 TCP: "停稳"不等于"停在了目标上" (见 `_tcp_reached`)
|
|
1274
|
+
plan.settled = _tcp_reached(arm, goal)
|
|
1275
|
+
return plan
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def move_l(arm, pose, speed: float = 1.0, wait: bool = True) -> CartPlan:
|
|
1279
|
+
"""`Arm.move_l` 的实现 —— 笛卡尔直线 (`0x3A`)。守卫顺序见 `_request_and_wait`。
|
|
1280
|
+
|
|
1281
|
+
`wait=True` (默认) 时会等到臂**停稳**才返回 (见 `_wait_settled`); 水位线 `seq0`
|
|
1282
|
+
取在**那一次 `0x3A` 帧写之前** —— 按"只信命令发出之后生成的状态帧"取
|
|
1283
|
+
(这道闸的**射程**见 `_wait_settled`, 别当成"运动前的帧全靠它挡")。
|
|
1284
|
+
"""
|
|
1285
|
+
arm._require() # ① 未连接 -> NotConnectedError (早于登记)
|
|
1286
|
+
_require_cart_support(arm) # ②
|
|
1287
|
+
_reject_in_zero_g(arm) # ③
|
|
1288
|
+
vec = _as_pose6(pose, "move_l") # ④ (无副作用的参数校验)
|
|
1289
|
+
sp = _as_speed(speed, "move_l")
|
|
1290
|
+
return _request_and_wait(
|
|
1291
|
+
arm, P.CMD_MOVE_L, P.pack_f32s(vec) + struct.pack("<f", sp), "move_l",
|
|
1292
|
+
vec, seq0=_seq_now(arm), wait=wait)
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def move_c(arm, pose_start, pose_via, pose_goal, speed: float = 1.0,
|
|
1296
|
+
wait: bool = True) -> CartPlan:
|
|
1297
|
+
"""`Arm.move_c` 的实现 —— 笛卡尔圆弧 (`0x3B`)。
|
|
1298
|
+
|
|
1299
|
+
⚠ **先校验后发帧** (`0x3B` 受理即起跑, 没有"发出去再判"的安全顺序): 校验的是
|
|
1300
|
+
"**实际 TCP** 是否在 `pose_start` 附近", 容差复用 `Arm._pose_near`
|
|
1301
|
+
(**连"旋转等价"那条判据一起** —— 只抄逐分量会在 `|pitch|≈π/2` 万向锁时永远判失败)。
|
|
1302
|
+
|
|
1303
|
+
⚠ 水位线 `seq0` 取在 `get_tcp()` **之后**: 那次读已经把 TCP 校验要用的状态帧
|
|
1304
|
+
吃进来了, 取在它之前只会把水位线白白放低 (闸更松), 不会有别的效果。
|
|
1305
|
+
|
|
1306
|
+
⚠⚠ **起点校验那次 `get_tcp()` 在 `_cart_serial` 之内** (不是"取锁之前"): 它读的就是
|
|
1307
|
+
"此刻 TCP", 与正在跑的那条运动**本来就是同一个临界区**。放在锁外实测是这样的 (8/8):
|
|
1308
|
+
另一个线程正跑着一条笛卡尔轨迹时, 这一次读会把它的 `ACK{0x3A}` 抢走
|
|
1309
|
+
⇒ **受害者报 `MotionTimeoutError`"无应答", 而它的命令早已写进固件并被受理** ——
|
|
1310
|
+
报告与物理事实相反; 而它留在队里的 token 随后还会吃掉**下一条**命令的 `0x4E`。
|
|
1311
|
+
⚠ [2026-09-21] 括号里那句"(被抢走就**再也找不回来**)"**已经过时**: `_read_one` 现在
|
|
1312
|
+
会把本读口不要的已知帧**存进 `_Ack._pending`**,`expect` 下一次取帧就能认领回来
|
|
1313
|
+
(实测经 server 的 `reset`/`disable`/`emergency_stop` 由 8/12、3/8、9/12 变 12/12)。
|
|
1314
|
+
但**本段结论不变、这条锁也不能撤**:暂存区只解决"帧被同进程另一个读者抢走",
|
|
1315
|
+
而这里要保的是**顺序**(登记 vs 固件受理)—— 那是 FIFO 配对的唯一来源,
|
|
1316
|
+
与帧丢不丢无关。
|
|
1317
|
+
⚠ 参数校验 (①②③④) 留在锁外: 它们不读帧、不写帧, 进锁只会平白拉长持锁时间。
|
|
1318
|
+
"""
|
|
1319
|
+
arm._require() # ①
|
|
1320
|
+
_require_cart_support(arm) # ②
|
|
1321
|
+
_reject_in_zero_g(arm) # ③
|
|
1322
|
+
start = _as_pose6(pose_start, "move_c pose_start") # ④
|
|
1323
|
+
via = _as_pose6(pose_via, "move_c pose_via")
|
|
1324
|
+
goal = _as_pose6(pose_goal, "move_c pose_goal")
|
|
1325
|
+
sp = _as_speed(speed, "move_c")
|
|
1326
|
+
with arm._cart_serial: # ⑤ 起点校验 + 发帧 + 等应答 = 一个临界区
|
|
1327
|
+
tcp = arm.get_tcp().value
|
|
1328
|
+
if tcp is None:
|
|
1329
|
+
raise TransportError("move_c 需要校验起点, 但取不到当前 TCP (get_tcp 无应答)")
|
|
1330
|
+
if not arm._pose_near(tcp, start, CART_START_POS_TOL, CART_START_RPY_TOL):
|
|
1331
|
+
raise InvalidCommandError(
|
|
1332
|
+
f"move_c: pose_start 与当前 TCP 不一致 —— 固件圆弧的起点恒为**实测 TCP**, "
|
|
1333
|
+
f"给的 start={['%.4f' % v for v in start]}, "
|
|
1334
|
+
f"实际 tcp={['%.4f' % v for v in tcp]}")
|
|
1335
|
+
return _request_and_wait(
|
|
1336
|
+
arm, P.CMD_MOVE_C,
|
|
1337
|
+
P.pack_f32s(via) + P.pack_f32s(goal) + struct.pack("<f", sp),
|
|
1338
|
+
"move_c", goal, seq0=_seq_now(arm), wait=wait)
|
|
1339
|
+
|
|
1340
|
+
|
|
1341
|
+
def move_path(arm, poses, speed: float = 1.0, wait: bool = True) -> CartPlan:
|
|
1342
|
+
"""`Arm.move_path` 的实现 —— 多路点 (`0x3C` BEGIN / `0x3D` ADD×n / `0x3E` RUN)。
|
|
1343
|
+
|
|
1344
|
+
⚠ **token 只登记在 `0x3E` (RUN) 上**: 只有 RUN 会起规划, 也只有它会产出 `0x4E`。
|
|
1345
|
+
`0x3C`/`0x3D` 既不在清队集合里, 也不会产生 `0x4E`。
|
|
1346
|
+
|
|
1347
|
+
⚠ **BEGIN + ADD×n + RUN 整段持 `arm._cart_serial`** —— 只圈 RUN 是不够的: 固件的
|
|
1348
|
+
`RECV` 收集态**只从 `IDLE` 开** (`cart_exec.c:304`: `state != CART_ST_IDLE` ⇒
|
|
1349
|
+
`CART_REQ_STATE`)。两条并发 `move_path` 里**后到的那条会在 BEGIN 就被拒**
|
|
1350
|
+
(`ERR{0x3C,0x04}`, 注释见 `cart_exec.c:299-303`), 于是它的 `_cmd` 在
|
|
1351
|
+
`expect(RSP_ACK, echo_cmd=0x3C)` 上抛出, **根本发不出自己的 ADD** —— 这是一次
|
|
1352
|
+
**响亮失败**, 不是"静默拼成一条谁也不是的路径"。持锁的作用是**把它变成排队**
|
|
1353
|
+
(两条都跑完, 而不是一条被拒), 并把 `BEGIN→RUN` 整段与其它笛卡尔入口互斥。
|
|
1354
|
+
⚠ 本段原写"`RECV` 缓冲按**到达顺序**收路点 ⇒ 并发会拼进同一条收集态" —— **归因错**:
|
|
1355
|
+
缓冲按帧里的 `idx` 索引而非到达顺序 (`cart_exec.c:321`, 且重复 idx 被去重,
|
|
1356
|
+
`cart_exec.c:320`), 而同一时刻只可能存在**一条**收集会话 (上面那条 `IDLE` 判据)。
|
|
1357
|
+
⚠ 这一段里的 `_request_and_wait` 会**再次**获取同一把锁 ⇒ `Arm._cart_serial` 是
|
|
1358
|
+
`RLock` (见 `Arm.__init__` 里那处注释)。
|
|
1359
|
+
|
|
1360
|
+
⚠ **BEGIN/ADD 中途失败时不做任何"清状态"的收尾**: 抛错即可 —— 固件侧 `RECV` 态带
|
|
1361
|
+
**2s 倒计时** (无 `RUN` 就回 `IDLE` 并丢弃已收的路点, `cart_exec.c:873-877`), 会自行
|
|
1362
|
+
收敛; 补发一条清状态的命令是多余的副作用。
|
|
1363
|
+
⚠ 但**别拿"不占用臂"当这个结论的论据** —— `RECV` **占用臂**: BEGIN/ADD 在
|
|
1364
|
+
`cart_req_begin`/`cart_req_add` **之前**无条件调 `ctrl_cart_hold_begin()`
|
|
1365
|
+
(`usb_cmd.c:410` / `usb_cmd.c:436`), 后者 `scurve_abort_all()` + 把
|
|
1366
|
+
`target_q/target_dq` 锚到当前 `q_ref` + 清 `park_requested` + 置 `mode=ARM_MODE_MOVE_J`
|
|
1367
|
+
+ `watchdog_kick()` (`control_loop.c:1547-1625`, 关键语句在 `:1606-1611` 与 `:1622-1624`)
|
|
1368
|
+
⇒ 一次 `move_path` **哪怕随后被 `0x04` 拒、或中途抛错**, 也已经**当场作废了在途的逐
|
|
1369
|
+
关节轨迹并把参考锚住** (一次进行中的 `movej` 会被它停掉)。不占用的是 `CART_BUSY`
|
|
1370
|
+
(状态帧 bit10) **那一位** —— 固件只在 PLANNING/READY/RUNNING 置它, `RECV` 不置
|
|
1371
|
+
(`usb_cmd.c:1204-1210`)。⚠ 固件侧口径**并非全树统一**: `usb_cmd.h:156-159` 已明写这条
|
|
1372
|
+
"RECV 不占臂"的豁免**被撤销**, 而 `usb_cmd.c:1204` 仍留着旧口径「RECV 不占臂(只是收
|
|
1373
|
+
缓冲), 不该报忙」—— 那是**固件侧的陈旧注释**(它的 bit10 代码本身是对的: 只置
|
|
1374
|
+
PLANNING/READY/RUNNING)。`usb_cmd.h` 才是现行口径。
|
|
1375
|
+
⚠ **重试窗口是 2s**: `cart_req_begin` 只在 `IDLE` 受理 (`cart_exec.c:304`), 所以一次
|
|
1376
|
+
BEGIN 受理之后 (哪怕随后失败), **2s 内任何新的 BEGIN (含本 SDK 的重试) 都会拿到
|
|
1377
|
+
`ERR{0x3C,0x04}`** —— "会自行收敛"成立, 但要等满那个 2s 倒计时。
|
|
1378
|
+
"""
|
|
1379
|
+
arm._require() # ①
|
|
1380
|
+
_require_cart_support(arm) # ②
|
|
1381
|
+
_reject_in_zero_g(arm) # ③
|
|
1382
|
+
pts = [_as_pose6(p, "move_path") for p in poses] # ④
|
|
1383
|
+
if not pts:
|
|
1384
|
+
raise InvalidCommandError("move_path: 路径为空")
|
|
1385
|
+
if len(pts) > 32: # 固件 CART_MAX_GOAL = 32
|
|
1386
|
+
raise InvalidCommandError(
|
|
1387
|
+
f"move_path: 路点数 {len(pts)} 超固件上限 32 (CART_MAX_GOAL)")
|
|
1388
|
+
sp = _as_speed(speed, "move_path")
|
|
1389
|
+
# 水位线取在 **BEGIN 之前** —— 理由是**窗口完整性**: "命令"是这一整段
|
|
1390
|
+
# (BEGIN+ADD×n+RUN), 而闸只信 `seq > seq0` 的帧 ⇒ 水位线取得**越晚、被排除的帧越多**,
|
|
1391
|
+
# 取在这一段之前才让整段的帧都落在可信窗口内。
|
|
1392
|
+
# ⚠ 别把方向写反 (本注释原文写的是"取在中间就会把前面几帧**算成可信帧**" —— 那是反的,
|
|
1393
|
+
# 闸是"只信更大的 seq")。
|
|
1394
|
+
# ⚠ 也不要把它说成"否则会误判到位": 真正挡住"运动前位置"的是 RUN 那次 ACK 的**排水**
|
|
1395
|
+
# (`expect` 一路读到 ACK), 见 `_wait_settled`; 这里的水位线是**不变量守卫**。
|
|
1396
|
+
seq0 = _seq_now(arm)
|
|
1397
|
+
with arm._cart_serial:
|
|
1398
|
+
arm._cmd(P.CMD_CART_BEGIN, bytes([len(pts)]) + struct.pack("<f", sp),
|
|
1399
|
+
"move_path BEGIN")
|
|
1400
|
+
for i, p in enumerate(pts):
|
|
1401
|
+
arm._cmd(P.CMD_CART_ADD, bytes([i]) + P.pack_f32s(p), f"move_path ADD {i}")
|
|
1402
|
+
return _request_and_wait(arm, P.CMD_CART_RUN, b"", "move_path RUN",
|
|
1403
|
+
pts[-1], seq0=seq0, wait=wait)
|