From 3e6e01bea17e80136f39feb05e94fb6cc2734f27 Mon Sep 17 00:00:00 2001 From: YOLO-DT <3199429837@qq.com> Date: Sun, 11 Oct 2026 14:35:35 +0800 Subject: [PATCH] docs: correct the section 5 claims hardware contradicted Walked every entry point in DEVELOPER_GUIDE section 5 (15 subsections, about 60 entry points) against a LiteArm 7-DOF running Litearm1.10.0-7J, calling only what the documents say. 186 of 190 checks held. Six claims did not. set_joint_limits does not reject widening. The firmware intersects the request with its compile-time default and answers ACK, so writing the current values back is accepted with no error and no change -- the call IS idempotent, and it cannot be used to confirm that a write landed. The doc said the opposite, which sends anyone trying to verify a limit write in the wrong direction. ERR{0x23,0x02} is for a NaN, an empty intersection or an out-of-range idx. Narrowing is also irreversible in RAM, which is worth saying out loud. CartPlan.settled lists two causes for ok=True, settled=False; the commonest one is missing. settled also requires the TCP read back after arrival to sit inside 6 mm / 0.03 rad, and this arm's TCP repeatability is 3-10 mm -- right at that edge. Measured on a 20 mm move_l: 4 of 5 runs were settled=False purely from 7.9-9.9 mm of position error, with settle_err_rad down at one quantisation step. Read from the doc alone, that looks like "another motion invalidated your trajectory". Two names do not exist on the SDK side: the state frame renders ARM_MODE_MOVE_MIT_ALL as MIT_ALL, so mode_name == "MOVE_MIT_ALL" is never true; and LOG_MAX_SAMPLES / CTRL_HZ live in litearm.log without being re-exported at package level. Two smaller ones in the same walk: move_c's start-mismatch check is local and raises InvalidCommandError, not the CartesianPlanError used for the firmware's own err 1/2/3; and its collinear check has a scale threshold -- three collinear points 20 mm apart are accepted and planned, 100 mm apart is rejected. ERR{0x08,0x04} is also unreachable from this SDK when zero_g() belongs to this client, because the guard rejects locally before the frame is sent. Two docstrings (cart.py, params.py) plus both developer guides. No behaviour change. --- docs/DEVELOPER_GUIDE.md | 22 +++++++++++++++------- docs/DEVELOPER_GUIDE.zh-CN.md | 13 +++++++------ src/litearm/cart.py | 15 +++++++++++---- src/litearm/params.py | 11 ++++++++++- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 5ee59c2..1036fda 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -284,7 +284,7 @@ All planning happens in the firmware; the PC sends points and receives a `0x4E` | --- | --- | | `move_p` | Accepts a single pose only; a sequence raises `InvalidCommandError`. Arrival is judged by TCP tolerance | | `move_l` / `move_c` / `move_path` | Return a `CartPlan`. `wait` decides **only** whether to wait for the arm to *stop*: `wait=True` (default) blocks until it has settled and fills `settled` / `q_final` / `settle_err_rad`; `wait=False` returns as soon as the firmware's plan reply arrives — the arm is still moving and those three fields are left as "not waited for", **not** as "did not arrive". **Both block until that reply**; `wait=False` is not a non-blocking call | -| `move_c` | `start` must match the measured TCP at call time (tolerance 6 mm / 0.03 rad); write `arm.get_tcp().value` | +| `move_c` | `start` must match the measured TCP at call time (tolerance 6 mm / 0.03 rad); write `arm.get_tcp().value`. A mismatch is checked **locally** and raises `InvalidCommandError` — the `CartesianPlanError` above is for the firmware's own rejections (err 1/2/3). ⚠ The collinear check (`err=2`) has a **scale threshold**: three collinear points 20 mm apart are accepted and planned; 100 mm apart is rejected | | `poll_cart()` | Reads only the collector's unclaimed queue, does not touch the link. ⚠ **It is not how you collect a `wait=False` result** — there is none to collect: both `wait` values consume the reply before returning, so `poll_cart()` returns `None` after them. Its one real use is recovering the result of a call whose **ACK timed out** (`MotionTimeoutError`) and which therefore left its token unclaimed | | `set_speed(percent)` | A global, **persistent** governor. `percent` is an **integer percentage 0..100** — `set_speed(1)` means **1% speed**, not "full speed"; it is not the same thing as the per-trajectory factor in `movej(speed=0..1)` | @@ -397,10 +397,10 @@ the same table. That table is separate from the one the other passthrough comman follow session may run faster than `send_mit_all`. - `enable()` is required: otherwise the accept path answers `ERR{0x08,0x03}`, also when EMERGENCY is latched. -- `ERR{0x08,0x04}` while hand-guiding runs; `ERR{0x08,0x06}` while the drop-hold latch is set. +- `ERR{0x08,0x04}` while hand-guiding runs; `ERR{0x08,0x06}` while the drop-hold latch is set. ⚠ `0x08,0x04` is **unreachable from this SDK** when the hand-guiding was started by this client: `zero_g()`'s own guard rejects every other downstream command locally with `InvalidCommandError` **before the frame is sent**. Seeing it needs another host or process to have entered zero-g. - A firmware that predates this command answers `ERR{0x08,0x00}` — it does not fail silently. - **Any other motion or stop command ends the session** — the next `movej` or `move_js` takes over. -- The state frame still reports `mode = MOVE_MIT_ALL`, so **do not** detect joint following from `state.mode`. +- The state frame still reports `mode = MOVE_MIT_ALL` **in firmware terms**; the SDK renders mode 5 as **`MIT_ALL`** (`_protocol.MODE_NAMES`), so `state.mode_name == "MOVE_MIT_ALL"` is never true. `send_mit_all` reports `MIT_ALL` as well — the two are **indistinguishable** from the state frame, which is the point: **do not** detect joint following from `state.mode`. (`send_mit` is a different mode, `MOVE_MIT`.) This group has not been fully verified on hardware, see [troubleshooting §16](../TROUBLESHOOTING.md#16-not-yet-verified). @@ -417,9 +417,15 @@ reset_factory() `JointParam`: `idx`, `kp`, `kd`, `tau_max`, `q_min`, `q_max`. -`set_joint_limits()` **only allows narrowing**: writing the current values back is judged a -widening request and rejected (`ERR[23,2]`), so it is not idempotent. `reset_factory()` requires -the disabled state and is irreversible. +`set_joint_limits()` **only narrows**, and it does so **silently**: the firmware intersects the +request with its compile-time default and answers `ACK`. A widening request — including +**writing the current values back** — is therefore accepted with **no error and no change**. +So the call *is* idempotent, but it also **cannot be used to confirm that a write landed**; +read back with `get_joint_param()`. `ERR{0x23,0x02}` is for a NaN, an empty intersection +(`q_min >= q_max`) or an out-of-range `idx` — **not** for widening. +⚠ Narrowing is **irreversible in RAM**: the intersection only ever shrinks, so there is no way +to widen back without a power cycle. `reset_factory()` requires the disabled state and is +irreversible. ### 5.9 `arm.model.*` — online dynamics model import @@ -475,7 +481,9 @@ r.total_bytes # property ``` `LogSample`: `tick`, `q_ref`, `dq`, `tau`. -Constants: `LOG_MAX_SAMPLES = 2400` (about 8 s @300 Hz, stops when full), `CTRL_HZ = 300`. +Constants: `LOG_MAX_SAMPLES = 2400` (about 8 s @300 Hz, stops when full) and `CTRL_HZ = 300`, +both in **`litearm.log`** — unlike `LogSample` / `LogReader` / `parse_samples` they are **not** +re-exported at package level, so `litearm.LOG_MAX_SAMPLES` raises `AttributeError`. `reader()` reads back in chunks following the firmware cursor, retrying automatically on a dropped chunk. `dump()` writes the raw byte stream to disk. diff --git a/docs/DEVELOPER_GUIDE.zh-CN.md b/docs/DEVELOPER_GUIDE.zh-CN.md index ff6e3f6..d5d8cfc 100644 --- a/docs/DEVELOPER_GUIDE.zh-CN.md +++ b/docs/DEVELOPER_GUIDE.zh-CN.md @@ -276,7 +276,7 @@ set_speed(percent) | --- | --- | | `move_p` | 只收单个位姿,传序列抛 `InvalidCommandError`。到位判据是 TCP 容差 | | `move_l` / `move_c` / `move_path` | 返回 `CartPlan`。`wait` **只决定要不要等臂"停稳"**:`wait=True`(默认)阻塞到停稳并填好 `settled` / `q_final` / `settle_err_rad`;`wait=False` 在固件的规划应答一到就返回——此时臂**还在动**,那三个字段是"**没等**",不是"没到位"。**两种取值都会阻塞到那条应答**,`wait=False` 不是非阻塞调用 | -| `move_c` | `start` 必须与调用时的实测 TCP 一致(容差 6 mm / 0.03 rad),写成 `arm.get_tcp().value` | +| `move_c` | `start` 必须与调用时的实测 TCP 一致(容差 6 mm / 0.03 rad),写成 `arm.get_tcp().value`。超差是**本地**校验,抛 `InvalidCommandError` —— 上面那句 `CartesianPlanError` 只管固件自己的拒绝(err 1/2/3)。⚠ 共线判据(`err=2`)有**尺度门槛**:三点相距 20 mm 会被接受并规划出来,相距 100 mm 才拒 | | `poll_cart()` | 只读收集器的待认领队列,不碰链路。⚠ **它不是"取 `wait=False` 结果"的手段**——压根没有可取的结果:两种 `wait` 取值都在返回前把应答消费掉了,所以调用它只会拿到 `None`。它唯一的真实用途是取回**入口 ACK 超时**(`MotionTimeoutError`)那条命令的结局——那条路径刻意留下了没人认领的 token | | `set_speed(percent)` | 全局、**持续**的调速器。`percent` 是 **0..100 的整数百分比**——`set_speed(1)` 就是 **1% 速度**,不是"满速";它和 `movej(speed=0..1)` 的单条轨迹倍率不是一回事 | @@ -381,10 +381,10 @@ joint_follow(q, dq, kp, kd) **不是同一张**,所以跟随会话允许比 `send_mit_all` 跑得快。 - `enable()` 是前提:否则受理路径回 `ERR{0x08,0x03}`,EMERGENCY 锁存时同样是这个码。 -- 拖动示教(零重力)进行中回 `ERR{0x08,0x04}`;掉线刚性持位(drop_hold)锁存中回 `ERR{0x08,0x06}`。 +- 拖动示教(零重力)进行中回 `ERR{0x08,0x04}`;掉线刚性持位(drop_hold)锁存中回 `ERR{0x08,0x06}`。⚠ 若零重力是**本客户端**开的,`0x08,0x04` **从 SDK 不可达** —— `zero_g()` 自己的守卫会在**发帧之前**就在本地抛 `InvalidCommandError` 拒掉其它下行命令。要看到它,得有**另一个主机/进程**进入了零重力。 - 早于本命令的固件回 `ERR{0x08,0x00}` —— 不会静默。 - **任何其它运动/停止命令都会结束本会话** —— 下一条 `movej` 或 `move_js` 接管。 -- 状态帧仍报 `mode = MOVE_MIT_ALL`,**不要**从 `state.mode` 判断跟随是否在进行。 +- 状态帧仍报 `mode = MOVE_MIT_ALL`,但那是**固件枚举名**;SDK 把 mode 5 渲染成 **`MIT_ALL`**(`_protocol.MODE_NAMES`),所以 `state.mode_name == "MOVE_MIT_ALL"` **永远为假**。`send_mit_all` 报的也是 `MIT_ALL` —— 两者从状态帧**分不开**,这正是本条警告的意义:**不要**从 `state.mode` 判断跟随是否在进行。(`send_mit` 是另一个 mode,`MOVE_MIT`。) 本组入口在真机上未经完整验证,见[排障指南 §16](../TROUBLESHOOTING.zh-CN.md#16-尚未验证的部分)。 @@ -400,8 +400,9 @@ reset_factory() `JointParam`:`idx`、`kp`、`kd`、`tau_max`、`q_min`、`q_max`。 -`set_joint_limits()` **只许收窄**,写回当前值会被判成放宽请求并拒(`ERR[23,2]`), -所以它不幂等,别拿它做读写回环。`reset_factory()` 要求失能态,且不可逆。 +`set_joint_limits()` **只许收窄**,而且是**静默地收窄**:固件把请求**照编译期默认值取交集**后回 `ACK`。所以放宽请求——**包括写回当前值**——会被**接受、不报错、值也不变**。 +也就是说它**是幂等的**,但**不能拿它当写口自检**:想确认写没写进去,只能 `get_joint_param()` 回读。`ERR{0x23,0x02}` 的触发条件是 NaN / 交集为空(`q_min >= q_max`)/ `idx` 越界,**不含放宽**。 +⚠ **收窄在 RAM 里不可逆**:交集只会越取越小,不重启回不去。`reset_factory()` 要求失能态,且不可逆。 ### 5.9 `arm.model.*` —— 动力学模型在线导入 @@ -453,7 +454,7 @@ r.total_bytes # 属性 ``` `LogSample`:`tick`、`q_ref`、`dq`、`tau`。 -常量:`LOG_MAX_SAMPLES = 2400`(约 8 s @300 Hz,记满自停)、`CTRL_HZ = 300`。 +常量:`LOG_MAX_SAMPLES = 2400`(约 8 s @300 Hz,记满自停)、`CTRL_HZ = 300`——两者都在 **`litearm.log`**。与 `LogSample` / `LogReader` / `parse_samples` 不同,它们**没有**在包级再导出,所以 `litearm.LOG_MAX_SAMPLES` 会抛 `AttributeError`。 `reader()` 按固件游标分块读回,掉帧自动重试。`dump()` 落盘原始字节流。 diff --git a/src/litearm/cart.py b/src/litearm/cart.py index f595dd1..28c2d26 100644 --- a/src/litearm/cart.py +++ b/src/litearm/cart.py @@ -734,10 +734,17 @@ class CartPlan: ① 到位判据 (先见 `bit10=1` 再见 `0` 且 `q` 静止) 收的尾; **且** ② 到位判据满足**之后** 回读的**实际 TCP** 与**本次请求的目标位姿**对得上 (容差 6mm / 0.03rad)。 - ⚠⚠ **`ok=True` 而 `settled=False` 是一个必须存在的结局** —— 它的含义是"这条笛卡尔 - 轨迹被**别的运动作废**了" (锁外/进程外的 `movej`/`home`/`zero_g`: 固件一收到就 - `cart_invalidate_before_motion()` 把这条轨迹作废, 而收尾照样是 `bit10=0` + `q` 静止), - 或者 `get_tcp()` 取不到 (超时/断连 —— 保守回"没到位")。 + ⚠⚠ **`ok=True` 而 `settled=False` 是一个必须存在的结局**, 有**三条**成因 —— 现场请**先** + 排除第 ③ 条, 它最常见: + ① 这条笛卡尔轨迹被**别的运动作废** (锁外/进程外的 `movej`/`home`/`zero_g`: 固件一收到就 + `cart_invalidate_before_motion()` 把这条轨迹作废, 而收尾照样是 `bit10=0` + `q` 静止); + ② `get_tcp()` 取不到 (超时/断连 —— 保守回"没到位"); + ③ **臂停稳了, 但回读的实际 TCP 落在 6mm / 0.03rad 容差之外** —— 这条**不在异常名单** + 里, 是**普通结局**: ① ② 都满足、`settle_err_rad` 也可以很小, 只要 TCP 精度不够就是 + `settled=False`。真机实测 (yd 机整臂, `Litearm1.10.0-7J`) 20mm 的 `move_l`: + **5 轮里 4 轮**落在这一条 (回读位置差 7.9~9.9mm, 容差 6mm), 第 5 轮 2.7mm 才 True。 + ⇒ **`settled=False` 既不是"被作废"也不是"没停稳"** —— 判据是**回读 `get_tcp()` 与目标 + 比一次**, 别从这两个字段猜。 **`ok` 只说明"固件受理并规划出来了"**, 不说明臂停在目标上。 ⇒ 现场**必须回读 `get_tcp()` 看真实落点**, 不要把它读成"只是没停稳"。 (`wait=True` 的**超时/故障**不是这个形状 —— 那两条会抛 `MotionTimeoutError` / diff --git a/src/litearm/params.py b/src/litearm/params.py index 52426f9..460a2d2 100644 --- a/src/litearm/params.py +++ b/src/litearm/params.py @@ -69,7 +69,16 @@ def set_joint_param(self, idx: int, kp: float, kd: float, tau_max: float) -> Non # ---- 0x23 ---- def set_joint_limits(self, idx: int, q_min: float, q_max: float) -> None: - """改写单关节软限位 (RAM)。固件要求 `q_min < q_max`。""" + """改写单关节软限位 (RAM)。固件要求 `q_min < q_max`。 + + ⚠⚠ **写入是"照编译期默认值取交集", 而且静默** —— 固件只收窄: 放宽请求 + (**包括写回当前值**) 会被**接受、回 ACK、且值不变**, 不报错。 + 所以本方法**是幂等的**, 但**不能拿它当写口自检** —— 想确认写没写进去只能 + :meth:`get_joint_param` 回读。 + `ERR{0x23,0x02}` 的触发条件是 NaN / 交集为空 (`q_min >= q_max`) / idx 越界, + **不含放宽** (固件原文: "固件只许收窄, 照编译期默认值取交集")。 + ⚠ **收窄在 RAM 里不可逆**: 交集只会越取越小, 不重启回不去。 + """ idx = self._check_idx(idx) if not float(q_min) < float(q_max): raise InvalidCommandError(f"软限位需 q_min < q_max (给的是 {q_min}, {q_max})")