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/tool.py ADDED
@@ -0,0 +1,171 @@
1
+ """工具点 (TCP 偏移) —— 法兰 ↔ 工具点的刚体换算、入参校验与本地持久化。
2
+
3
+ **为什么这一层在宿主侧**: 固件的 TCP 恒为 `ee_link` 的 FK (`get_tcp()`), `move_*` /
4
+ `ik()` 的入参与到位判据也全按法兰算 —— 协议里没有工具坐标系命令 (0x01–0x49 无此项)。
5
+ 要让"我控的是工具尖"成立, 只能在 SDK 里加这一层; 且**收发两侧都得加**: 只加下发侧时,
6
+ 纯旋转会让工具尖跟着法兰平移, 而不是绕尖原地转 (litearm-vr 的 `tcp_offset` 注释里
7
+ 记的是同一件事)。
8
+
9
+ 语义 (与 litearm-vr 一致, 另补姿态)::
10
+
11
+ p_tool = p_ee + R_ee · xyz R_tool = R_ee · R_off(rpy)
12
+ p_ee = p_tool − R_ee · xyz R_ee = R_tool · R_offᵀ
13
+
14
+ `xyz` / `rpy` 定义在**法兰系** (`ee_link`), 与固件的 `payload_com` 同一参考系 ——
15
+ 换工具时两者要一起改。`rpy = (0, 0, 0)` 时退化成 litearm-vr 那套纯平移。
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ from typing import Dict, List, NamedTuple, Optional, Sequence, Tuple
23
+
24
+ from litearm import _rot
25
+ from litearm.errors import InvalidCommandError, LiteArmError
26
+
27
+ #: 偏移量的模长上限 (米)。超过它几乎只可能是单位/小数点填错 (`0.18` 写成 `180`) ——
28
+ #: 直接拒, 而不是让臂按一个离谱的工具点去够。1 m 已远超任何手持工具。
29
+ MAX_OFFSET_M = 1.0
30
+
31
+ #: `rpy` 的合法范围没有物理限制 (旋转本来就在球面上), 但 `|rpy| > 10π` 说明填的是**度数**
32
+ #: 或别的量纲 —— 与 `MAX_OFFSET_M` 一样按"量纲错误"拒掉。
33
+ MAX_RPY_RAD = 10.0 * math.pi
34
+
35
+
36
+ class TcpOffset(NamedTuple):
37
+ """工具点相对法兰的偏移 (xyz 米 / rpy 弧度, 均在**法兰系**)。"""
38
+
39
+ xyz: Tuple[float, float, float]
40
+ rpy: Tuple[float, float, float]
41
+
42
+ @property
43
+ def is_identity(self) -> bool:
44
+ """全零 = 直接控法兰 —— 各入口走"原样返回"的快路径, 行为与没有这层时一致。"""
45
+ return not any(self.xyz) and not any(self.rpy)
46
+
47
+
48
+ #: 默认偏移: 全零。litearm-vr 出厂 `(0, 0, 0.18)` 是**那台台架**的量测值, 不该进 SDK 默认值。
49
+ ZERO = TcpOffset((0.0, 0.0, 0.0), (0.0, 0.0, 0.0))
50
+
51
+
52
+ def _triple(value, name: str) -> Tuple[float, float, float]:
53
+ """3 个有限数的归一化 —— 形状/量纲问题一律在发帧之前报出来。"""
54
+ if value is None:
55
+ return (0.0, 0.0, 0.0)
56
+ try:
57
+ vals = [float(v) for v in value]
58
+ except (TypeError, ValueError) as e:
59
+ raise InvalidCommandError(f"{name} 需 3 个数字: {e}") from None
60
+ if len(vals) != 3:
61
+ raise InvalidCommandError(f"{name} 需 3 个数 (收到 {len(vals)} 个)")
62
+ if not all(math.isfinite(v) for v in vals):
63
+ raise InvalidCommandError(f"{name} 不能含 NaN/Inf: {vals}")
64
+ return (vals[0], vals[1], vals[2])
65
+
66
+
67
+ def as_offset(xyz=None, rpy=None) -> Tuple[TcpOffset, List[List[float]]]:
68
+ """归一化并校验偏移量, 返回 `(TcpOffset, R_off[3][3])`。
69
+
70
+ `xyz` / `rpy` 任一为 `None` 时按 0 处理 (只改平移时不必重述姿态)。
71
+ """
72
+ x = _triple(xyz, "tcp_xyz")
73
+ r = _triple(rpy, "tcp_rpy")
74
+ norm = math.sqrt(sum(v * v for v in x))
75
+ if norm > MAX_OFFSET_M:
76
+ raise InvalidCommandError(
77
+ f"tcp_xyz 模长 {norm:.3f} m 超过上限 {MAX_OFFSET_M} m —— 单位是**米**"
78
+ f" (0.18, 不是 180) 且定义在法兰系")
79
+ if any(abs(v) > MAX_RPY_RAD for v in r):
80
+ raise InvalidCommandError(
81
+ f"tcp_rpy 超过 {MAX_RPY_RAD:.1f} rad —— 单位是**弧度**, 度数请先换算")
82
+ return TcpOffset(x, r), _rot.rpy_to_mat(r)
83
+
84
+
85
+ def _pose(pose, label: str) -> Tuple[List[float], List[List[float]]]:
86
+ """任意位姿写法 -> `([x,y,z], R[3x3])`; 形状/数值畸形一律 `InvalidCommandError`。
87
+
88
+ 形态判定整个委托给 :func:`litearm._rot.as_pose` (收 `(pos[3], R[3x3])` /
89
+ `[x,y,z,r,p,y]` / 4x4 齐次矩阵); 这里只补两件事: 加上 `label` 前缀, 以及把
90
+ `as_pose` 契约之外的数值畸形 (`float("x")` 的 `ValueError`、容器类型不对的
91
+ `TypeError`) 一并收进 `LiteArmError` 体系 —— 与 `cart._as_pose6` 同一条理由。
92
+ """
93
+ try:
94
+ pos, R = _rot.as_pose(pose)
95
+ except InvalidCommandError as e:
96
+ raise InvalidCommandError(f"{label}: {e}") from None
97
+ except (TypeError, ValueError) as e:
98
+ raise InvalidCommandError(f"{label}: {e}") from None
99
+ return pos, R
100
+
101
+
102
+ def to_tool(pose, offset: TcpOffset, R_off: Sequence[Sequence[float]],
103
+ label: str = "pose") -> List[float]:
104
+ """法兰位姿 → 工具位姿 (`[x,y,z,r,p,y]`)。读回侧用。"""
105
+ pos, R_ee = _pose(pose, label)
106
+ R_tool = _rot.mat_mul(R_ee, R_off)
107
+ t = _rot.mat_vec(R_ee, offset.xyz)
108
+ return [pos[i] + t[i] for i in range(3)] + _rot.mat_to_rpy(R_tool)
109
+
110
+
111
+ def to_flange(pose, offset: TcpOffset, R_off: Sequence[Sequence[float]],
112
+ label: str = "pose") -> List[float]:
113
+ """工具位姿 → 法兰位姿 (`[x,y,z,r,p,y]`)。**下发 `move_*` / `ik` 前必须过这一步。**"""
114
+ pos, R_tool = _pose(pose, label)
115
+ R_ee = _rot.mat_mul(R_tool, _rot.mat_T(R_off))
116
+ t = _rot.mat_vec(R_ee, offset.xyz)
117
+ return [pos[i] - t[i] for i in range(3)] + _rot.mat_to_rpy(R_ee)
118
+
119
+
120
+ # ---------------------------------------------------------------- 本地持久化
121
+
122
+ def default_path() -> str:
123
+ """默认配置文件: `$LITEARM_TOOL_FILE` 优先, 否则放平台用户配置目录。
124
+
125
+ 只存"这把工具"的属性 (偏移 + 负载), 不改固件参数 —— 固件里没有这两者的写口组合。
126
+ """
127
+ env = os.environ.get("LITEARM_TOOL_FILE")
128
+ if env:
129
+ return os.path.expanduser(env)
130
+ if os.name == "nt":
131
+ base = os.environ.get("APPDATA") or os.path.expanduser("~")
132
+ else:
133
+ base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
134
+ return os.path.join(base, "litearm", "tool.json")
135
+
136
+
137
+ def save(path: str, offset: TcpOffset, payload: Optional[Dict[str, object]] = None) -> str:
138
+ """写配置 (原子替换: 半截文件比没有文件更糟)。返回实际写入的路径。"""
139
+ doc: Dict[str, object] = {
140
+ "tcp_offset": {"xyz": list(offset.xyz), "rpy": list(offset.rpy),
141
+ "units": "m / rad, flange (ee_link) frame"},
142
+ }
143
+ if payload:
144
+ doc["payload"] = payload
145
+ full = os.path.abspath(os.path.expanduser(path))
146
+ os.makedirs(os.path.dirname(full), exist_ok=True)
147
+ tmp = full + ".tmp"
148
+ with open(tmp, "w", encoding="utf-8") as f:
149
+ json.dump(doc, f, ensure_ascii=False, indent=2)
150
+ f.write("\n")
151
+ os.replace(tmp, full)
152
+ return full
153
+
154
+
155
+ def load(path: str) -> Tuple[TcpOffset, Dict[str, object]]:
156
+ """读配置 → `(offset, payload)`。文件不存在抛 `FileNotFoundError` (由调用方决定口径)。"""
157
+ full = os.path.abspath(os.path.expanduser(path))
158
+ with open(full, encoding="utf-8") as f:
159
+ text = f.read()
160
+ try:
161
+ doc = json.loads(text)
162
+ except ValueError as e:
163
+ raise LiteArmError(f"{full} 不是合法 JSON: {e}") from None
164
+ if not isinstance(doc, dict):
165
+ raise LiteArmError(f"{full} 顶层应为对象, 收到 {type(doc).__name__}")
166
+ block = doc.get("tcp_offset") or {}
167
+ if not isinstance(block, dict):
168
+ raise LiteArmError(f"{full} 的 tcp_offset 应为对象")
169
+ offset, _R = as_offset(block.get("xyz"), block.get("rpy"))
170
+ payload = doc.get("payload")
171
+ return offset, payload if isinstance(payload, dict) else {}
litearm/transport.py ADDED
@@ -0,0 +1,375 @@
1
+ """串口传输 —— USB CDC 帧读写 (含自动发现/自愈)。
2
+
3
+ **端点独占是两层的, 缺一层都不够** —— 实测 (E5): Linux 下 CDC 口**不**独占, 第二个进程
4
+ `open` **成功**且**分吃同一字节流** (双方拿残帧、静默失败); Windows 反而由 OS 强制独占
5
+ ⇒ 独占性因平台而异, 不能当常量依赖。固件那侧无从提供 (`CDC_SET_CONTROL_LINE_STATE`
6
+ 是空 break), 只能主机侧收口:
7
+
8
+ * **跨进程**: `serial.Serial(..., exclusive=True)` —— posix 侧是内核的
9
+ `flock(LOCK_EX|LOCK_NB)` (它比的是 open file description, 故**同进程内两次 open 也会
10
+ 互斥**), 失败抛 `SerialException`, 由 :meth:`SerialTransport.__init__` 翻成
11
+ `TransportError`; serialwin32 侧本来就只支持独占 (传 `exclusive=False` 才 `ValueError`)。
12
+ * **进程内**: :func:`_claim_port` 的 `port -> 持有者` 登记表。它与 flock **不是重复的**:
13
+ 它排在 `serial.Serial()` **之前** (无副作用、fail-fast), 且那一刻**分得清**是"自己人
14
+ 重复开"还是"别的进程占着" —— 等 pyserial 的 flock 失败时只剩一句
15
+ "Resource temporarily unavailable", 两者无从分辨。
16
+
17
+ ⚠ 两层挡的都是**端口**级的互斥, 与上层开了几个 `Arm` 无关: 两个 `Arm` 各指一个口是合法的
18
+ (照样全开), 要挡的是**同一个口**被两条链路同时开 —— 不论那两条来自两个 `Arm`、同一个
19
+ `Arm` 的两次构造, 还是绕过 `Arm` 直接 new 出来的传输。故这道门挂在**传输**这一层。
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ import weakref
25
+ from typing import Optional, Tuple
26
+
27
+ from litearm import _protocol as P
28
+ from litearm.errors import TransportError
29
+
30
+ _CDC_VID = 0x1D50
31
+ _CDC_PID = 0x606F
32
+
33
+ #: 以 SOF 起算但未收齐的"残缺帧"最多允许等待的时间。
34
+ #: 真帧在 921600 baud 下 260B 上限约 2.8ms 即收齐; 噪声里凑出的**假帧头**
35
+ #: (0xA5 + 声称长度大于实际数据) 则永远收不齐 —— 超过本窗口即丢弃该 SOF 重扫,
36
+ #: 否则它会把它**后面**的真帧永久挡住 (源不再来数据 = 永不返回)。
37
+ _PARTIAL_MAX_S = 0.25
38
+
39
+ #: 读超时**之后**那段"非阻塞补读"的时间上限 (秒)。见 `SerialTransport._read_frame_locked`:
40
+ #: 超时已到仍要把**已经到达**的字节读进来 (否则 `timeout=0` 一个字节都不碰), 但数据
41
+ #: **持续**到达时不能因此变成没有界的循环 —— 取 20ms: 库里已有的字节全是 `read(1)`
42
+ #: 同步返回, 20ms 读到**数千~一万字节量级** (本站 pty @921600 实测跑满 `_read_frame_locked`
43
+ #: 那条路: 20.0ms / 3 952~4 106 B ≈ **5.0 µs 每字节**; 评审另一次实测 20.0ms / 9 210 B
44
+ #: ≈ 2.2 µs 每字节 —— 同一量级, 随机器与负载浮动), 相对一帧上限 260B 仍有 **~15× 以上**
45
+ #: 余量; 而噪声灌满的线上最多多耗 20ms。
46
+ #: ⚠ **别把数量级往上写**: 这里是"**已经到达的字节**要读完花多久", 不是吞吐上限 ——
47
+ #: 旧措辞写"20ms 够读几万个字节", 比实测**高约一个量级**, 会把余量感夸大。
48
+ #: ⚠ 它与 `read_frame` 的 `timeout` **无关**: 后者决定"等多久才有新数据", 这个是
49
+ #: "已经到达的字节最多花多久读完"。
50
+ _NONBLOCK_EXTRA_S = 0.02
51
+
52
+ #: 写超时 —— **必须有界**。pyserial 默认 `write_timeout=None` 即无限阻塞, 一次卡住的
53
+ #: 保活写会长时间占着写锁, 把 `emergency_stop()`/`close()` 一起拖住; 而"急停必须
54
+ #: 永远可达"是本包的安全底线。921600 baud 下 260B 帧约 2.8ms, 0.5s 余量足够。
55
+ _WRITE_TIMEOUT_S = 0.5
56
+
57
+ #: 一次最多向串口索取的字节数 (见 `_read_chunk` / `_want_bytes`)。
58
+ #:
59
+ #: **2026-09-22 真机实测的由来** —— 从前这里是"每次 `read(1)`", 于是 CPU 代价与
60
+ #: **字节数**成正比: 每字节一次 `select()` + 一次 `os.read()`。这块板子**空闲时**
61
+ #: 状态流就有 ~14 kB/s (100Hz × 79B) ⇒ ~1.4 万次 syscall/秒 ⇒ 在 2 核机上
62
+ #: **一整核** (实测 server 无客户端时也占 100.0% CPU)。
63
+ #: 同字节量、同时长的微基准: 逐字节 **98.6%** vs 按到达量取走 **3.0%** (33×)。
64
+ #:
65
+ #: ⚠ **为什么不干脆 `read(4096)`**: pyserial 3.5 的 `read(size)` 是
66
+ #: `while len(read) < size` —— 它会**等到凑满 size 或超时**才返回。所以"一次要很多"
67
+ #: 本身**不省延迟、反而给每帧加最多一个 timeout 窗口**。必须**按"已经到达的字节数"**
68
+ #: (`in_waiting`) 要, 第一轮 `os.read` 就满足 `size` ⇒ 立即返回、不等待。
69
+ _READ_CHUNK_MAX = 4096
70
+
71
+
72
+ def find_cdc_port() -> Optional[str]:
73
+ try:
74
+ from serial.tools import list_ports
75
+ except Exception:
76
+ return None
77
+ for p in list_ports.comports():
78
+ if p.vid == _CDC_VID and p.pid == _CDC_PID:
79
+ return p.device
80
+ return None
81
+
82
+
83
+ #: **进程内**端口登记表: `port -> 持有者的弱引用`。
84
+ #:
85
+ #: 判据是持有者"**还活着**"而不是"登记过": 值持**弱引用**, 于是持有者一被回收这条登记
86
+ #: 自动消失。这不是图省事 —— 持有者一死, 它那个 pyserial 句柄也被一并回收 (pyserial 在
87
+ #: `__del__` 里关 fd), 端口**真的**空了: 弱引用消失与 fd 消失是同一件事。用**强**引用则
88
+ #: 相反 —— 一个忘了 `close()` 的旧对象会**永久**占着这个端口名, 把此后每一次连接都挡掉。
89
+ #:
90
+ #: ⚠ 它**不摆脱 gc 依赖**: 那条 `weakref` 要等**持有者被回收**才失效, 而 `SerialTransport`
91
+ #: 在本仓常被挂进 `Arm` (`arm._tr = tr`), 而 `Arm` **有环** (`_CartPending(arm=self)`)
92
+ #: ⇒ 挂**哪一层**都一样, 同样要等**全量 gc** —— 关键是**持有者要 `close()`**
93
+ #: (`_release_port` 当场生效, 与 gc 无关)。选**传输**这一层 (而非 `Arm`) 的**真实**理由是:
94
+ #: `Arm.close()`/`disconnect()`/`__del__` 三条收尾都落到 `transport.close()`, 且顺带盖住绕过 `Arm` 直接 new 传输的调用方。
95
+ #:
96
+ #: ⚠ 它是按**端口字符串**比的 —— 同一台设备的两种写法 (`/dev/ttyACM1` 与某个软链) 撞不上;
97
+ #: 那种情况由上面那层 flock 兜住 (内核比的是 open file description, 与字符串无关)。
98
+ _PORT_OWNERS: dict = {}
99
+ _PORT_OWNERS_LOCK = threading.Lock()
100
+
101
+
102
+ def _claim_port(port: str, owner: object) -> None:
103
+ """把 `port` 记到 `owner` 名下; 已被**另一个活着的**持有者占着时**响亮失败**。
104
+
105
+ ⚠ 排在 `serial.Serial()` **之前** (见模块顶部): 那一刻还没有任何副作用, 且错里能直接
106
+ 写清"是进程内自己人占用" —— 等 pyserial 的 flock 失败时就只剩一句 "Resource
107
+ temporarily unavailable", 进程内/进程外再也分不出来。
108
+ """
109
+ with _PORT_OWNERS_LOCK:
110
+ cur = _PORT_OWNERS.get(port)
111
+ holder = cur() if cur is not None else None
112
+ if holder is not None and holder is not owner:
113
+ raise TransportError(
114
+ f"端口 {port} 已被本进程内另一个传输占用 —— 同一端口同时只能有一条链路 "
115
+ f"(跨进程那半由打开时的 flock 保证)。先把它关掉 "
116
+ f"(持有者是 Arm 的话是 arm.close() / arm.disconnect()), 或改用另一个端口。")
117
+ _PORT_OWNERS[port] = weakref.ref(owner)
118
+
119
+
120
+ def _release_port(port: str, owner: object) -> None:
121
+ """撤掉 `port` 上**属于 `owner`** 的那条登记 (不是无条件 `del`)。
122
+
123
+ ⚠ `is owner` 这个判据今天是**防御性**的, 到不了"该登记已经属于别人"那一步: 登记只在
124
+ 持有者**死后**才被别人替换, 而死者不会再来撤登记 (活着的持有者撤完自己那条之后,
125
+ `close()` 的幂等早退也排在前面)。变异实测: 把它改成无条件 `del`, 全套 **483** 条
126
+ **照样全绿** ⇒ 别把它当成在挡某个今天跑得出来的场景。⚠ "483" 是**当时的规模**、
127
+ 不是现值 (现值看 `pytest --collect-only`), 别拿它核现在的全量输出 —— 与
128
+ `state.py:50` 的 "374" 同一种标注口径。留着是因为**本函数的契约应当是自明
129
+ 的** ("只撤自己的"), 而不是靠上面那句推理**永远**成立。
130
+ """
131
+ with _PORT_OWNERS_LOCK:
132
+ cur = _PORT_OWNERS.get(port)
133
+ if cur is not None and cur() is owner:
134
+ del _PORT_OWNERS[port]
135
+
136
+
137
+ class SerialTransport:
138
+ """帧读写传输。**读写各一把锁**, 读写之间不互斥。
139
+
140
+ 读者之间互斥: `_buf` / `_partial_since` / `_ser.timeout` 都是非线程安全的共享态。
141
+ 写者之间互斥: 一帧的字节必须原子地交给串口, 否则两帧交错 -> 上位机侧 CRC 全坏。
142
+ 读写之间**刻意不互斥**: pyserial 的 `os.read`/`os.write` 并发本就安全, 且
143
+ `_ser.timeout` 只被读者改写 —— 若图省事用单把 RLock 罩住读写, 主线程一次最长
144
+ 几十 ms 的阻塞读会把 50ms 周期的保活写(见 `Arm.zero_g`)拖过 0.1s 看门狗窗口。
145
+ """
146
+
147
+ def __init__(self, port: str, timeout: float = 0.2):
148
+ import serial
149
+ #: 这两个先落 —— 失败路径 (`_release_port`) 与 `close()` 都要用 `port`。
150
+ self.port = port
151
+ self.timeout = timeout
152
+ #: ⚠ 进程内那道门 (见模块顶部) —— 排在 `serial.Serial()` **之前**。
153
+ _claim_port(port, self)
154
+ try:
155
+ self._ser = serial.Serial(port, 921600, timeout=timeout,
156
+ write_timeout=_WRITE_TIMEOUT_S,
157
+ exclusive=True)
158
+ except Exception as e: # noqa: BLE001
159
+ #: ⚠ **构造处是 `exclusive` 真正抛错的地方** (posix 的 flock 失败 →
160
+ #: `SerialException`), 而今天只有 `_read_chunk`/`write_frame` 两处包了
161
+ #: `TransportError` ⇒ 不补这一处的话, 裸 `serial` 异常会从 `Arm.connect()`
162
+ #: 一路逃到调用方 (`except LiteArmError` 那类分支全接不住, 它连本包定义的
163
+ #: 异常都不是)。`from e` + 原文进 message: 出错原因不许在这里丢掉。
164
+ #: 那一格进程内登记也要撤 —— 否则这次失败会把端口**占死**。
165
+ _release_port(port, self)
166
+ raise TransportError(f"打开串口 {port} 失败: {e}") from e
167
+ self._buf = bytearray()
168
+ self._partial_since = None # 残缺帧计时起点 (见 read_frame)
169
+ self._rlock = threading.RLock() # 读者互斥
170
+ self._wlock = threading.RLock() # 写者互斥
171
+ self._text = bytearray() # 被丢弃的噪声字节留痕 (含开机签名), 有上限
172
+ #: 本传输是否已关 —— `close()` 的幂等**靠这一格**, 不靠 pyserial 的内部守卫
173
+ #: (它的 `close()` 确实有 `if self.is_open:` 兜底, 但那是它的实现细节:
174
+ #: `os.close()` 中途抛 (fd 已被别人关掉) 时它的 `is_open` 会停在 True, 于是
175
+ #: "再关一次"会去重试一串已经关掉的 fd)。契约必须由**我们这一层**说了算。
176
+ self._closed = False
177
+ #: `flush()` (tcdrain) 失败的**累计**次数 —— 只做可观测性, 不改变任何行为:
178
+ #: 那一下失败**不算**发送失败 (帧已经交进驱动了, 收不回来), 见 `write_frame`。
179
+ self.flush_failures = 0
180
+
181
+ #: 噪声留痕上限 —— 固件开机签名 (banner) 不是帧, 会被当噪声丢弃; 留痕是为了
182
+ #: 解析出「本次是否 IWDG 复位」。噪声可能无限多, 故必须封顶。
183
+ TEXT_LOG_MAX = 2048
184
+
185
+ @property
186
+ def text_log(self) -> str:
187
+ """最近被跳过的非帧字节 (解码后), 用于解析固件开机签名。
188
+
189
+ `_text` 与 `_buf`/`_partial_since`/`_ser.timeout` 同属读者线程的共享态,
190
+ 故读它也要持读锁 (free-threaded 构建下不加锁是真数据竞争)。
191
+ """
192
+ with self._rlock:
193
+ return bytes(self._text).decode("utf-8", errors="replace")
194
+
195
+ @property
196
+ def is_open(self) -> bool:
197
+ if self._closed:
198
+ return False
199
+ try:
200
+ return self._ser.is_open
201
+ except Exception:
202
+ return False
203
+
204
+ def close(self) -> None:
205
+ """关链路 (**幂等**: 重复调用是 no-op, 不抛)。与在途读/写互斥 —— 否则
206
+ `os.close(fd)` 与 pyserial 置 `fd=None` 之间 `is_open` 仍为真, 落在这个窗口
207
+ 的读者会对**已被释放的 fd 号**做 termios 配置 (fd 复用后即操作到无关对象)。
208
+
209
+ ⚠ 幂等的判据是 `_closed` 那一格, **不是**"关两次恰好也没事": 上层有**多条**
210
+ 收尾路径 (`Arm.close()` / `Arm.disconnect()` / `Arm.__del__` 的兜底), 它们
211
+ 全落在这里 —— 幂等是它们能互相叠加的前提。
212
+
213
+ ⚠ **进程内登记必须在这里撤** (在幂等早退**之后**): 不撤的话, `close()`/
214
+ `disconnect()` 之后连同一个口会被自己的登记挡住 —— `Arm.reconnect()` 走的正是
215
+ "先关再连"这条路。上面那三条收尾路径因此全都自带释放 (它们都落在这一个方法上),
216
+ 这正是"收口一处"要买的东西。
217
+ """
218
+ with self._rlock, self._wlock:
219
+ if self._closed:
220
+ return
221
+ self._closed = True
222
+ try:
223
+ self._ser.close()
224
+ except Exception:
225
+ pass
226
+ # 放在实例锁**外**: 它取的是模块级那把锁, 而全仓没有第二处会**持着模块锁再去取
227
+ # 实例锁** ⇒ 两把锁不嵌套, 就不必去论证"没有谁按相反顺序取它们"。
228
+ _release_port(self.port, self)
229
+
230
+ def write_frame(self, cmd: int, payload: bytes = b"") -> None:
231
+ """写一帧。**`write()` 与 `flush()` 的失败必须分开报** —— 别顺手合并两个 try。
232
+
233
+ ⚠ **"抛 `TransportError` ⟹ 整帧未送达"是一条载重不变量**:
234
+ :meth:`_CartPending.request` 靠"写失败就摘掉自己那个 token"成立, 而那条只在
235
+ "帧没出去"时才安全 —— 帧其实送到了却摘掉 token, 固件随后那条 `0x4E` 会配给
236
+ 队列里**后面那条活 token**, 于是报"成功"而实际没跑 (**假成功**)。
237
+
238
+ 而两半的物理事实不同:
239
+
240
+ * `write()` 抛 → 帧没送达 (残缺帧被固件按 CRC 丢) ⇒ 抛 `TransportError`;
241
+ * `flush()` (tcdrain) 抛 → **整帧已经交进驱动、会被送达**, 失败收不回来
242
+ ⇒ **不算发送失败**, 只计进 `flush_failures` (静默是另一条底线)。
243
+
244
+ 刻意不为此新增异常类型: 真链路故障会在**下一次写**上由 `write()` 抛出, 藏不住。
245
+ """
246
+ with self._wlock:
247
+ try:
248
+ self._ser.write(P.pack_frame(cmd, payload))
249
+ except Exception as e: # noqa: BLE001
250
+ raise TransportError(f"写失败: {e}") from e
251
+ try:
252
+ self._ser.flush()
253
+ except Exception: # noqa: BLE001
254
+ self.flush_failures += 1
255
+
256
+ def read_frame(self, timeout: Optional[float] = None) -> Optional[Tuple[int, bytes]]:
257
+ """读一帧; 超时返回 None。跳过噪声字节, 丢弃 CRC 坏帧。
258
+
259
+ 缓冲自持完整流 (含候选帧头 SOF): 读超时时**已到达的半帧留在缓冲里**,
260
+ 下次调用续读即可 —— 旧实现在超时时已把 SOF 丢掉, 余下字节会被当新流扫描,
261
+ 导致整帧丢失(半帧超时 = 丢一帧)。
262
+
263
+ ⚠ **`timeout=0` 是"有就给我", 不是"一个字节都不准碰"**: 超时已到时仍要把
264
+ **已经到达**的字节**非阻塞**地读进来, 否则"数据早已躺在驱动缓冲里"这一支永远
265
+ 取不到 —— 真机上
266
+ `Arm.poll_cart()` (它走的就是本方法的 `timeout=0`) 会**恒**返回 `None`, 而离线用例
267
+ 全绿 (桩把 `timeout` 参数整个忽略掉了)。见 `_read_frame_locked` 里那条分支。
268
+ """
269
+ end = _now() + (timeout if timeout is not None else self.timeout)
270
+ with self._rlock:
271
+ return self._read_frame_locked(end)
272
+
273
+ def _note_text(self, byte: int) -> None:
274
+ """留存一个被当作噪声丢掉的字节 (滚动窗口, 见 `text_log`)。"""
275
+ self._text.append(byte)
276
+ if len(self._text) > self.TEXT_LOG_MAX:
277
+ del self._text[:len(self._text) - self.TEXT_LOG_MAX]
278
+
279
+ def _read_frame_locked(self, end: float) -> Optional[Tuple[int, bytes]]:
280
+ #: 超时之后那段**非阻塞**取字节的截止 (见下面那条分支); `None` = 还没进那一支。
281
+ nb_end: Optional[float] = None
282
+ while True:
283
+ while self._buf:
284
+ if self._buf[0] != P.SOF:
285
+ self._note_text(self._buf[0])
286
+ del self._buf[0] # 不是帧头 -> 丢一个字节继续找
287
+ continue
288
+ if len(self._buf) < 3:
289
+ break # CMD/LEN 未齐, 等更多字节
290
+ ln = self._buf[2]
291
+ total = 3 + ln + 2 # SOF+CMD+LEN + payload + crc2
292
+ if len(self._buf) < total:
293
+ break # 帧体未齐, 保留(含 SOF)等下一轮
294
+ got = P.unpack_frame(bytes(self._buf[:total]))
295
+ if got is not None:
296
+ del self._buf[:total]
297
+ self._partial_since = None
298
+ return got
299
+ del self._buf[0] # CRC/长度坏 -> 丢这个 SOF 重找
300
+ now = _now()
301
+ if self._buf and self._buf[0] == P.SOF:
302
+ if self._partial_since is None:
303
+ self._partial_since = now # 残缺帧开始计时
304
+ elif now - self._partial_since > _PARTIAL_MAX_S:
305
+ del self._buf[0] # 收不齐 -> 假帧头, 丢掉重扫
306
+ self._partial_since = None
307
+ continue
308
+ else:
309
+ self._partial_since = None
310
+ if now >= end:
311
+ # 窗口已经用尽 —— 但**仍要**把**已经到达**的字节读进来: `timeout=0` 的
312
+ # 语义是"有就给我", 旧实现直接 `return None` 是"一个字节都不碰", 于是
313
+ # "数据早已在驱动缓冲里"这一支永远取不到 (`Arm.poll_cart()` 在真机上恒
314
+ # `None`)。这一步**不会等新数据**: 传进去的 `end` 就是此刻, `_read_chunk`
315
+ # 会把它夹到 0 (pyserial 的非阻塞档) ⇒ 没数据立刻 `b""`。
316
+ # ⚠ **必须有时间上限** (`_NONBLOCK_EXTRA_S`): 数据**持续**到达时, 光靠
317
+ # "读到凑出一帧"是**没有界**的 (阻塞档有 `end` 兜底, 这一支没有) ——
318
+ # 噪声灌满的线上不能把本调用变成不返回的循环。
319
+ if nb_end is None:
320
+ nb_end = now + _NONBLOCK_EXTRA_S
321
+ elif now > nb_end:
322
+ return None
323
+ chunk = self._read_chunk(now)
324
+ if chunk:
325
+ self._buf += chunk
326
+ continue # 有新字节 -> 回上面重试装配
327
+ return None
328
+ chunk = self._read_chunk(end)
329
+ if chunk:
330
+ self._buf += chunk
331
+
332
+ def _want_bytes(self) -> int:
333
+ """这次 `read` 该要几个字节 = **已经到达的字节数**(封顶 `_READ_CHUNK_MAX`)。
334
+
335
+ * `in_waiting > 1` ⇒ 按它要 —— 一次把这批取走, 这是本改动的全部收益所在;
336
+ * 否则(`0`/`1`/拿不到)⇒ 要 `1`:**阻塞等第一个字节**, 与从前逐字节读
337
+ 的**行为完全一致**。
338
+
339
+ ⚠ **拿不到就必须退化成 1, 不能抛**: 测试桩没有 `in_waiting`, 某些平台也可能
340
+ 不支持。退化成 1 **只慢不错** —— 装配循环本来就按"来多少字节都可能"写的。
341
+ ⚠ 反过来 `in_waiting` 报多了也不会丢字节: pyserial 在超时到点时把**已经读到的**
342
+ 部分返回 (`read` 的 `while len(read) < size` 以 `timeout.expired()` 收尾)。
343
+ """
344
+ try:
345
+ n = int(self._ser.in_waiting)
346
+ except Exception: # noqa: BLE001 - 桩/平台不支持
347
+ return 1
348
+ return min(n, _READ_CHUNK_MAX) if n > 1 else 1
349
+
350
+ def _read_chunk(self, end: float):
351
+ """读一块。`end <= now` 时是**非阻塞**读 (pyserial `timeout=0`)。
352
+
353
+ ⚠ 旧实现在 `remain <= 0` 时**直接返回 `None`** (一个字节都不碰) —— 那是
354
+ "非阻塞读"被实现成了"不读"。现在负值夹到 `0`: pyserial 在 `timeout=0` 下
355
+ **不等待、但照取样**, 这才是 `read_frame(0)` 要的语义 (见那里)。
356
+
357
+ **一次读多少** 由 `_want_bytes()` 决定 —— 见 `_READ_CHUNK_MAX` 那条。
358
+ """
359
+ import time
360
+ remain = end - time.monotonic()
361
+ if remain < 0:
362
+ remain = 0.0
363
+ try:
364
+ self._ser.timeout = min(remain, 0.05)
365
+ got = self._ser.read(self._want_bytes())
366
+ if not got:
367
+ return None
368
+ return got
369
+ except Exception as e: # noqa: BLE001
370
+ raise TransportError(f"读失败: {e}") from e
371
+
372
+
373
+ def _now() -> float:
374
+ import time
375
+ return time.monotonic()