diff --git a/README.md b/README.md index fc784d0..5e99703 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,19 @@ # litearm-python -Python SDK for the LiteArm robotic arm. Connect over USB serial straight to the arm's firmware -and control it from any machine — no server or middleware in between. Trajectory planning, -kinematics and dynamics are all carried by the firmware. +Python SDK for the LiteArm robotic arm: drive the arm from a PC over one USB serial cable, +straight to its firmware — no server or middleware in between. Trajectory planning, kinematics +and dynamics are all carried by the firmware. -> 📖 Full interface reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md). +> Full interface reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md). ## Features -- 🐍 **Pure Python**: Python 3.9+, poses are plain lists / tuples, no numpy needed -- 📦 **A single dependency**: `pyserial`, nothing else -- 🔌 **Direct over USB**: one cable to the firmware, no server in between -- ⚙️ **The firmware does the heavy lifting**: planning, kinematics and dynamics live there; the - PC side just sends points and decides arrival -- 🦾 **Full motion API**: joint moves, Cartesian lines / arcs / waypoints, hand-guiding -- 🛡️ **Safety built in**: fail-closed in child processes, a dedicated emergency stop, and every - irreversible command flagged +- **Pure Python**: Python 3.9+, poses are plain lists / tuples, no numpy needed +- **A single dependency**: `pyserial`, nothing else +- **Direct over USB**: one cable to the firmware, no server in between +- **The firmware does the planning**: kinematics and dynamics live there; the PC sends points and decides arrival +- **Full motion API**: joint moves, Cartesian lines / arcs / waypoints, hand-guiding +- **Safety built in**: fail-closed in child processes, a dedicated emergency stop, and every irreversible command flagged ## Install @@ -26,6 +24,8 @@ kinematics and dynamics are all carried by the firmware. | Firmware | `Litearm1.5.0` or later | | Connection | USB CDC serial, VID:PID `1d50:606f` | +From the repository root: + ```bash pip install -e . python3 -c "import litearm; print(litearm.__version__)" # verify @@ -129,7 +129,7 @@ print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # pose → joint angles arm.emergency_stop() # emergency stop: no preconditions at all arm.reset() # clear faults + re-anchor (not an MCU reboot) arm.clear_faults() # clears RAM fault bits only -arm.disable() # ⚠ the arm is no longer held once disabled +arm.disable() # the arm is no longer held once disabled ``` ### Feed-forward / dynamics @@ -151,7 +151,8 @@ with arm.zero_g(): ### Passthrough / servo ```python -arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # ⚠ bypasses planning; keep alive at ≥10 Hz +arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # bypasses planning; keep alive at ≥10 Hz +arm.joint_follow([0.0]*7, [0.0]*7, [30.0]*7, [1.0]*7) # no tau: the firmware computes it ``` ### Parameters (`arm.params.*`) @@ -187,10 +188,17 @@ for s in r.samples()[:3]: print(arm.diag.kin_bench().value) # CAN link diagnostic counters ``` +### License and activation + +An arm that has never been activated rejects `enable()` with `ERR{0x10,0x08}` and answers every +other command normally, so it stays diagnosable. `arm.license()` reads the record — a bare +`LicenseInfo`, not a `Msg`; `arm.activate()` submits a vendor-issued credential and needs +`disable()` first. See [the developer guide §5.15](docs/DEVELOPER_GUIDE.md#515-license-and-activation). + ### Persistence ```python -arm.save_params() # ⚠ writes flash, irreversible +arm.save_params() # writes flash, irreversible ``` ### Read-only properties @@ -293,7 +301,8 @@ See [examples/README.md](examples/README.md): - `06_cartesian.py` — Cartesian lines / arcs / waypoints - `07_vel_jitter_trace.py` — 300 Hz per-tick capture -The examples are **read-only by default**; anything that moves needs `--go`: +The examples are **read-only by default**; anything that moves needs `--go`. Run them from +the repository root: ```bash source env.sh # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT diff --git a/README.zh-CN.md b/README.zh-CN.md index c96c903..d1305c0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,18 +1,18 @@ # litearm-python -LiteArm 机械臂的 Python SDK。通过 USB 串口直连机械臂固件,即可从任意机器控制机械臂 —— +LiteArm 机械臂的 Python SDK:从 PC 经一根 USB 串口线直连固件驱动机械臂 —— 不需要服务端或中间件。轨迹规划、运动学、动力学全部由固件承担。 -> 📖 完整接口说明见 [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md)。 +> 完整接口说明见 [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md)。 ## 特性 -- 🐍 **纯 Python**:Python 3.9+,位姿就是普通的 list / tuple,不需要 numpy -- 📦 **单一依赖**:只有 `pyserial` -- 🔌 **USB 直连**:一根线接到固件,不经过任何服务端 -- ⚙️ **重活交给固件**:规划、运动学、动力学都在固件里,PC 侧只发点、判到位 -- 🦾 **完整运动接口**:关节运动、笛卡尔直线 / 圆弧 / 多路点、拖动示教 -- 🛡️ **安全兜底**:子进程 fail-closed,急停独立入口,不可逆命令逐个标注 +- **纯 Python**:Python 3.9+,位姿就是普通的 list / tuple,不需要 numpy +- **单一依赖**:只有 `pyserial` +- **USB 直连**:一根线接到固件,不经过任何服务端 +- **规划在固件里**:运动学、动力学都在固件里,PC 侧只发点、判到位 +- **完整运动接口**:关节运动、笛卡尔直线 / 圆弧 / 多路点、拖动示教 +- **安全兜底**:子进程 fail-closed,急停独立入口,不可逆命令逐个标注 ## 安装 @@ -23,6 +23,8 @@ LiteArm 机械臂的 Python SDK。通过 USB 串口直连机械臂固件,即 | 固件 | `Litearm1.5.0` 及以上 | | 连接 | USB CDC 串口,VID:PID `1d50:606f` | +在仓库根目录执行: + ```bash pip install -e . python3 -c "import litearm; print(litearm.__version__)" # 验证 @@ -124,7 +126,7 @@ print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # 位姿 → 关节角 arm.emergency_stop() # 急停:唯一没有前置条件的入口 arm.reset() # 清故障 + 重锚控制环(不是 MCU 重启) arm.clear_faults() # 只清 RAM 故障位 -arm.disable() # ⚠ 失能后不再被位置环托住 +arm.disable() # 失能后不再被位置环托住 ``` ### 前馈 / 动力学调参 @@ -146,7 +148,8 @@ with arm.zero_g(): ### 透传 / 伺服 ```python -arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # ⚠ 绕过规划,需 ≥10 Hz 自己保活 +arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # 绕过规划,需 ≥10 Hz 自己保活 +arm.joint_follow([0.0]*7, [0.0]*7, [30.0]*7, [1.0]*7) # 帧里没有 tau,前馈由固件算 ``` ### 参数(`arm.params.*`) @@ -182,10 +185,16 @@ for s in r.samples()[:3]: print(arm.diag.kin_bench().value) # CAN 链路诊断计数 ``` +### 授权与激活 + +未激活的臂 `enable()` 回 `ERR{0x10,0x08}`,其余命令一律照常,所以现场仍可诊断。 +`arm.license()` 读授权记录(裸 `LicenseInfo`,不是 `Msg`);`arm.activate()` 提交厂商签发的凭据, +须先 `disable()`。见[开发者指南 §5.15](docs/DEVELOPER_GUIDE.zh-CN.md#515-授权与激活)。 + ### 参数持久化 ```python -arm.save_params() # ⚠ 写入 flash,不可逆 +arm.save_params() # 写入 flash,不可逆 ``` ### 只读属性 @@ -278,7 +287,7 @@ p.start() - `06_cartesian.py` — 笛卡尔直线 / 圆弧 / 多路点 - `07_vel_jitter_trace.py` — 300 Hz 逐拍采集 -样例**默认只读**,会运动的必须加 `--go`: +样例**默认只读**,会运动的必须加 `--go`。都在仓库根目录执行: ```bash source env.sh # 导出 PYTHONPATH / PYTHON_BIN / LITEARM_PORT diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 07e544e..8f6f0ef 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -1,7 +1,8 @@ # litearm-python field troubleshooting -Every entry follows the same three beats: **symptom → cause → what to do**. Start with -the lookup table below. +For anyone debugging a LiteArm driven by this SDK: you have a symptom and no cause. Every +entry follows the same three beats: **symptom → cause → what to do**. Start with the lookup +table below. Suggested order of investigation: check the **error code** first (§4 warns about two code spaces that are easy to confuse), then the **link diagnostics counters** (§11), and @@ -47,7 +48,7 @@ only then suspect the cabling. **What to do**: for `FirmwareMismatchError`, flash `Litearm1.5.0` or later. For a busy port, shut down whatever is holding it. -⚠ Device **re-enumeration** (unplug/replug, after `enter_dfu()`, a real power cycle) makes +Device **re-enumeration** (unplug/replug, after `enter_dfu()`, a real power cycle) makes `/dev/ttyACM*` **change number**. A script pinned to `LITEARM_PORT` now points at a port that does not exist — the most common reason for "it worked a minute ago". @@ -67,7 +68,7 @@ that does not exist — the most common reason for "it worked a minute ago". | `ERR{0x10,0x07}` | Resending is useless | Consult the firmware code table | | `ERR{0x10,0x00}` | **The firmware does not have this command** | Firmware too old — update it | -⚠ A close cousin that often gets mixed in: **with the arm not enabled, `movej` is rejected +A close cousin that often gets mixed in: **with the arm not enabled, `movej` is rejected with `ERR[01,3]`**, and its message points at **two** possibilities at once — "not enabled, **or** EMERGENCY latched". Do not read only the first half. @@ -86,7 +87,7 @@ See [README](README.md#multiprocessing-a-forked-child-must-not-use-an-inherited- | A command "times out", but a retry "sometimes works" | The command did go out; the reply was read by the parent ⇒ the retry is a **duplicate command** | | `get_state()` does not raise, but the numbers **never change** | It silently returns the inherited, **stale** value — the subtlest case | | `connect()` raises in the child | The parent still holds the port ⇒ the parent must `close()` first | -| Any command immediately raises `ForkedSessionError` | ✅ The guard is **working**, not failing | +| Any command immediately raises `ForkedSessionError` | The guard is **working**, not failing | **What to do**: `close()` the parent session to release the port, then `fork`, then **create** a new `Arm` inside the child. @@ -146,10 +147,10 @@ hits this; concurrent use across processes or clients does. **What to do**: pick three points that are not collinear; when starting from a singular pose, leave the singularity first. -⚠ In `move_c(start, via, goal)` the **`start` must match the measured TCP at call time** +In `move_c(start, via, goal)` the **`start` must match the measured TCP at call time** (tolerance 6 mm / 0.03 rad). It is not a free "start from here" parameter — it is **validated against reality**, so a mid-motion TCP as the start point is rejected. -⚠ The **orientation of `via` is ignored**; only its position defines the circle. +The **orientation of `via` is ignored**; only its position defines the circle. --- @@ -205,7 +206,7 @@ is expected. It only has a value when you connect **soon after a real reset** (`"normal"` / `"iwdg-rst"`). -⚠ A related clarification: **`reset()` is a software state reset, not an MCU reboot.** It does +A related clarification: **`reset()` is a software state reset, not an MCU reboot.** It does **not** trigger USB re-enumeration, **the same `Arm` object keeps working afterwards**, and the signature is not resent. @@ -222,7 +223,7 @@ read" — and that failure is **silent**: no error, just a healthy-looking 0. **What to do**: before using it as a link health check, confirm frames are really arriving — look at `Msg.hz` / `Msg.timestamp`. If both are `0.0`, no frame of that kind has ever arrived. -⚠ Among the counters, `crc` is live and exact; `can_tx_fail` can be surprisingly large (a +Among the counters, `crc` is live and exact; `can_tx_fail` can be surprisingly large (a firmware-reported cumulative value whose exact definition has not been verified). --- @@ -250,24 +251,23 @@ slowly under gravity — the measured sag matches a "0.6× stiffness + τ=0" est **What to do**: keep resending at ≥10 Hz for as long as the motion is needed. -⚠ Arrays must be **length `n`** (checked locally) and **finite** — a `NaN` / `Inf` makes the +Arrays must be **length `n`** (checked locally) and **finite** — a `NaN` / `Inf` makes the firmware reject the whole frame (`ERR{cmd,0x02}`). `send_mit`, `send_mit_all` and `move_js` all get this check. -⚠ In `move_js`, `dq` is a **velocity reference**, not a limit. +In `move_js`, `dq` is a **velocity reference**, not a limit. --- ## 14. After `enter_dfu()` -- **You cannot flash immediately**: `ACK{0x15}` only means "registered"; the device has to - **re-enumerate** as `0483:DF11`. Running pyocd right away fails; retry after ten-odd seconds - and it succeeds. -- To tell that the device has really gone, use a **read or write raising an error** — **not** - `is_open`, and **not** "we read 0 bytes". -- After it returns successfully, **this `Arm` is unusable**: every entry point raises - `ArmIsInDfuError` (`close()` excepted). After flashing, **create a new `Arm`**. -- Calling it while enabled is **rejected locally** (the jump stops TIM3 ⇒ the motors release - within 100 ms and sag under load). +- **You cannot flash immediately**: `ACK{0x15}` only means "registered"; the device has to **re-enumerate** as `0483:DF11` first. +- To tell that the device has really gone, use a **read or write raising an error** — not `is_open` and not "we read 0 bytes". +- After it returns successfully, **this `Arm` is unusable**: every entry point raises `ArmIsInDfuError`, `close()` excepted. +- After flashing, **create a new `Arm`**. +- Calling it while enabled is **rejected locally** (the jump stops TIM3 ⇒ the motors release within 100 ms and sag under load). + +Running pyocd right after the `ACK` fails; retrying ten-odd seconds later succeeds. "We read 0 +bytes" is not evidence that the device is gone — only a read or a write that raises is. --- @@ -295,13 +295,11 @@ Do not assume any of the following has been verified: - `reset_factory()` (it wipes tuned parameters); - The **success** path of `move_c()` — no reliably successful arc was constructed; - `move_js` / `send_mit` / `send_mit_all` — never run on hardware; -- The **effective narrowing** of `set_joint_limits()` (only "writing the old value back is - rejected" was verified); -- Whether **`capture()` always records 0 ticks with the arm disabled** — this appears only in - this repo's field notes, with no matching check or test in the code; not re-verified; +- The **effective narrowing** of `set_joint_limits()` (only "writing the old value back is rejected" was verified); +- Whether **`capture()` always records 0 ticks with the arm disabled** — field notes only, no test in the code; - **Windows** — not yet verified. -### ⚠ Irreversible commands: do not run these on a calibrated arm +### Irreversible commands: do not run these on a calibrated arm All four **overwrite or erase that unit's per-arm identified dynamics model**, and **there is no undo**: @@ -315,10 +313,10 @@ no undo**: **The only way to be safe: do it on a board whose calibration has no value.** -⚠ Separately, `0x33` `model.set_jm()` **should never be called** — it rewrites the joint +Separately, `0x33` `model.set_jm()` **should never be called** — it rewrites the joint mapping (including signs), a mistake there can make the arm **flail**, and the only local recovery options (`revert` / `save_params`) are both in the table above ⇒ **there is no reliable way back**. -⚠ When stress-testing the CAN link, run **`candump` (read-only) only — never `cangen`**: +When stress-testing the CAN link, run **`candump` (read-only) only — never `cangen`**: `can0` *is* the motor bus. diff --git a/TROUBLESHOOTING.zh-CN.md b/TROUBLESHOOTING.zh-CN.md index ae58cdf..b94a47d 100644 --- a/TROUBLESHOOTING.zh-CN.md +++ b/TROUBLESHOOTING.zh-CN.md @@ -1,6 +1,7 @@ # litearm-python 现场排障 -每条都是三段式:**现象 → 原因 → 怎么办**。先看下面的速查表定位。 +给调试本 SDK 所驱动机械臂的人:你有现象,但不知道原因。每条都是三段式: +**现象 → 原因 → 怎么办**。先看下面的速查表定位。 排查顺序建议:先看**错误码**(§4 提醒了两个容易混淆的码空间),再看**链路诊断计数**(§11), 最后才怀疑线路。 @@ -43,7 +44,7 @@ **怎么办**:`FirmwareMismatchError` 就升级固件到 `Litearm1.5.0` 及以上。端口被占就关掉占用方。 -⚠ 设备**重新枚举**(拔插、`enter_dfu()` 之后、真断电重启)会让 `/dev/ttyACM*` **换号**。 +设备**重新枚举**(拔插、`enter_dfu()` 之后、真断电重启)会让 `/dev/ttyACM*` **换号**。 被 `LITEARM_PORT` 锁死的脚本这时会指向一个不存在的端口——这是"刚才还好好的"最常见的原因。 --- @@ -62,7 +63,7 @@ | `ERR{0x10,0x07}` | 重发无用 | 查固件码表 | | `ERR{0x10,0x00}` | **固件没有这条命令** | 固件太旧,升级 | -⚠ 一条容易混进来的近亲:**未使能时 `movej` 会被拒 `ERR[01,3]`**, +一条容易混进来的近亲:**未使能时 `movej` 会被拒 `ERR[01,3]`**, 它的文案会**同时**点出两种可能——「未使能,**或** EMERGENCY 锁存」。别只看前半句。 --- @@ -79,7 +80,7 @@ | 命令"超时无应答",但重试"有时又好了" | 命令真的发出去了,应答被父进程读走 ⇒ 重试 = **重复下发** | | `get_state()` 不报错,但读数**永远不动** | 静默返回继承来的**陈旧**值——最隐蔽的一种 | | 子进程里 `connect()` 抛异常 | 父进程还持有端口 ⇒ 父进程必须先 `close()` | -| 任何命令立刻抛 `ForkedSessionError` | ✅ 守卫**正常工作**,不是在报错 | +| 任何命令立刻抛 `ForkedSessionError` | 守卫**正常工作**,不是在报错 | **怎么办**:先 `close()` 父进程的会话释放串口,再 `fork`,然后在子进程里**新建** `Arm`。 @@ -131,9 +132,9 @@ **怎么办**:换一组不共线的三点;从奇异位姿出发时先离开奇异点再走圆弧。 -⚠ `move_c(start, via, goal)` 的 **`start` 必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)。 +`move_c(start, via, goal)` 的 **`start` 必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)。 它不是"从哪儿走"的自由参数,是**校验收到的**——拿运动中的 TCP 当起点会被拒。 -⚠ `via` 的**姿态被忽略**,只有位置参与定圆。 +`via` 的**姿态被忽略**,只有位置参与定圆。 --- @@ -180,7 +181,7 @@ 它只在**真复位之后尽快连上**这一种情形下才有值(`"normal"` / `"iwdg-rst"`)。 -⚠ 顺带澄清一个近亲:**`reset()` 是软件状态复位,不是 MCU 重启**—— +顺带澄清一个近亲:**`reset()` 是软件状态复位,不是 MCU 重启**—— 它**不会**触发 USB 重新枚举,**同一个 `Arm` 对象事后照常可用**,签名也不会重发。 --- @@ -196,7 +197,7 @@ **怎么办**:用它做链路体检前,先确认真的读到了帧——看 `Msg.hz` / `Msg.timestamp`, 两者都是 `0.0` 说明这一类帧从没到过。 -⚠ 计数里 `crc` 是活的且精确;`can_tx_fail` 的量级可能异常大(固件自述的累计值,口径未核)。 +计数里 `crc` 是活的且精确;`can_tx_fail` 的量级可能异常大(固件自述的累计值,口径未核)。 --- @@ -221,19 +222,19 @@ **怎么办**:按 ≥10 Hz 的节奏持续重发,直到不再需要这个动作。 -⚠ 数组必须**长度 = `n`**(本地校验)且**是有限数** —— `NaN` / `Inf` 会被固件整帧拒收 +数组必须**长度 = `n`**(本地校验)且**是有限数** —— `NaN` / `Inf` 会被固件整帧拒收 (`ERR{cmd,0x02}`),`send_mit` / `send_mit_all` / `move_js` 都做这个校验。 -⚠ `move_js` 的 `dq` 是**速度参考**,不是限位。 +`move_js` 的 `dq` 是**速度参考**,不是限位。 --- ## 14. `enter_dfu()` 之后 -- **不能立刻刷**:`ACK{0x15}` 只表示"已登记",设备要**重新枚举**成 `0483:DF11`。 - 立刻跑 pyocd 会失败,隔十几秒重跑就成功。 +- **不能立刻刷**:`ACK{0x15}` 只表示"已登记",要等设备**重新枚举**成 `0483:DF11`。 +- 立刻跑 pyocd 会失败,隔十几秒重跑就成功。 - 判定"设备真的消失了"要用**读 / 写抛错**,**不是** `is_open`,**更不是**"读到 0 字节"。 - 成功返回后**本 `Arm` 不可再用**:所有入口抛 `ArmIsInDfuError`(`close()` 例外)。 - 烧完固件**新建一个 `Arm`**。 +- 烧完固件**新建一个 `Arm`**。 - 使能中调用会被**本地拒绝**(跳转会停 TIM3 ⇒ 电机 100 ms 松开、有负载则下垂)。 --- @@ -261,11 +262,10 @@ - `move_c()` 的**成功**路径 —— 未构造出稳定成功的圆弧; - `move_js` / `send_mit` / `send_mit_all` —— 未上真机; - `set_joint_limits()` 的**有效收窄**(只验证了"写回原值被拒"); -- **失能态下 `capture()` 是否恒录到 0 拍** —— 只在本仓库的现场笔记里出现过,代码里没有 - 对应的判据或测试,未复核; +- **失能态下 `capture()` 是否恒录到 0 拍** —— 只在本仓库的现场笔记里出现过,代码里没有 对应的判据或测试,未复核; - **Windows 平台** —— 尚未验证。 -### ⚠ 不可逆命令:不要在标定过的臂上执行 +### 不可逆命令:不要在标定过的臂上执行 下面四条都会**覆盖或抹掉该台设备逐台辨识的动力学模型**,**没有撤销**: @@ -278,8 +278,8 @@ **唯一解锁方式:在一台没有标定价值的板子上做。** -⚠ 另有 `0x33` `model.set_jm()` **建议永不调用**——它改关节映射(含符号), +另有 `0x33` `model.set_jm()` **建议永不调用**——它改关节映射(含符号), 改错有**乱飞**风险,而本机唯一的恢复手段(`revert` / `save_params`)恰好都在上面那张表里 ⇒ **没有可依赖的退路**。 -⚠ 压测 CAN 链路时**只开 `candump`(只读),绝不 `cangen`**——`can0` 就是电机总线。 +压测 CAN 链路时**只开 `candump`(只读),绝不 `cangen`**——`can0` 就是电机总线。 diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 16d9097..11c88d3 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -144,18 +144,16 @@ class Msg(Generic[T]): `hz` is the average rate since the first frame of that kind arrived in this session, and it is **`0.0` with fewer than 2 samples**. -- Passive streams (`RSP_STATUS`, 100 Hz): converge to about 100 after two or three frames; an - idle link does not make it decay. -- The 4 request/response ones (`params.get_joint_param` / `model.get_body` / `model.get_jm` / - `model.get_gravity`): one call yields one frame, so **the first call is always `hz == 0.0`**, - and from the second call on it equals **your own polling rate**. -- ⚠ `diag.kin_bench()` is **not** in that group: its reply is **two consecutive frames** (a - timing frame plus a LINK frame), so one call delivers 2 frames — `hz` is non-zero on the very - first call, and that number is **meaningless** (the numerator is inflated by the split frame - while the denominator is still the call interval). Use `timestamp` to tell whether a frame - ever arrived. +- Passive streams (`RSP_STATUS`, 100 Hz): converge to about 100 after two or three frames; an idle link does not make it decay. +- The 4 request/response getters: one call yields one frame, so **the first call is always `hz == 0.0`**. +- `diag.kin_bench()` is **not** in that group: its reply is **two consecutive frames**, so `hz` is meaningless on it. - `reconnect()` resets it. +The 4 request/response getters are `params.get_joint_param`, `model.get_body`, `model.get_jm` and +`model.get_gravity`; from the second call on, `hz` reports **your own polling rate**. For +`diag.kin_bench()` the numerator is inflated by the split frame while the denominator is still +the call interval, so read `timestamp` to tell whether a frame ever arrived. + **`hz == 0.0` and `timestamp == 0.0` mean no frame of this kind has ever arrived**, not a slow link. @@ -165,6 +163,7 @@ link. - `n` / `firmware` / `last_reset_reason` / `zero_g_active` — no frame at all - `ik()` — a computation request - `get_ff_mask()` returns a bare `int`; `params.all_joint_params()` returns `list[JointParam]` +- `license()` returns a bare `LicenseInfo` (see §5.15): no firmware-initiated traffic to measure, so `hz` is your own polling rate ### `RobotState` @@ -189,7 +188,7 @@ Derived properties: `n`, `enabled`, `cart_busy`, `q`, `dq`, `tau`, `fault_axes`, `get_status_now()` **actively sends a `GET_STATUS`**, unlike `get_state()` which only consumes the passive stream — that makes it useful for confirming the link is alive. -⚠ `get_status_now(timeout=0.0)` is **not** a non-blocking poll; it means **return the current +`get_status_now(timeout=0.0)` is **not** a non-blocking poll; it means **return the current cached value immediately**. It raises `MotionTimeoutError` if this session has not received a single state frame yet. @@ -358,8 +357,7 @@ with arm.zero_g(): ``` - Keep-alive is resent by a background SDK thread; `period` must be within `[0.005, 0.10)`. -- Other motion commands are rejected while keep-alive runs; queries are unaffected, and - emergency stop / disable are exceptions. +- Other motion commands are rejected while keep-alive runs; queries are unaffected, and emergency stop / disable are exceptions. - The exit is asynchronous; the firmware needs a moment after `zero_g_stop()` returns. - If keep-alive breaks on a write failure, the exit raises rather than failing silently. - The read-only `zero_g_active` / `zero_g_error` report the state. @@ -369,6 +367,7 @@ with arm.zero_g(): ```python send_mit(idx, q, dq, kp, kd, tau) send_mit_all(q, dq, kp, kd, tau) +joint_follow(q, dq, kp, kd) ``` These bypass motion planning, and **the caller must keep them alive**: resend at ≥10 Hz, or the @@ -379,12 +378,34 @@ The five arrays of `send_mit_all` must all have length `n` (checked locally), an finite** — a `NaN` / `Inf` makes the **firmware** reject the whole frame with `ERR{cmd,0x02}`. `send_mit` and `move_js` get the same finiteness check. +`joint_follow` is `send_mit_all` with the feedforward left out of the frame: the firmware +computes `tau_ff = clamp(G(q_measured) + wall, ±tau_max)` on every control tick, so a follow +loop does not spend one `get_gravity()` round trip per tick. Four arrays travel instead of five. + +| Array | Meaning | +| --- | --- | +| `q` | Position target. The firmware clamps it to the joint's soft limits | +| `dq` | Velocity reference for the firmware's MIT loop, **not** a rate limit | +| `kp` / `kd` | MIT gains, clamped by the firmware to its own `MIT_KP_MAX` / `MIT_KD_MAX` | + +All four arrays must have length `n`, checked locally. Being finite is the firmware's call, as +for the rest of this group. + +The firmware slews the position reference toward `q` at its own joint-follow speed table +(J1..J7 = 2.8 / 3.4 / 5.0 / 5.0 / 10.0 / 8.0 / 13.0 rad/s, `control_loop.c`) and clamps `dq` to +the same table. That table is separate from the one the other passthrough commands use, so a +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. +- 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`. + This group has not been fully verified on hardware, see [troubleshooting §16](../TROUBLESHOOTING.md#16-not-yet-verified). -### 5.8 Sub-objects - -#### `arm.params.*` — per-joint parameters +### 5.8 `arm.params.*` — per-joint parameters ```python set_joint_param(idx, kp, kd, tau_max) @@ -400,7 +421,7 @@ reset_factory() widening request and rejected (`ERR[23,2]`), so it is not idempotent. `reset_factory()` requires the disabled state and is irreversible. -#### `arm.model.*` — online dynamics model import +### 5.9 `arm.model.*` — online dynamics model import ```python probe() -> bool @@ -420,7 +441,7 @@ Writes land in a staging layer and do not take effect immediately; judge by `sta `commit(expected_mask)` applies them, `revert()` discards them. **Both require the disabled state**; while enabled they return `ERR{0x32,0x04}` / `ERR{0x37,0x04}` respectively. -⚠ `revert()` **rolls back RAM only — it does not touch flash.** Three consequences you need to +`revert()` **rolls back RAM only — it does not touch flash.** Three consequences you need to know: 1. the imported model in flash is still there, so it **comes back on the next power cycle**; @@ -432,7 +453,7 @@ know: mistake there can make the arm flail, and the only local recovery options are themselves irreversible. -#### `arm.log.*` — 300 Hz control-tick capture +### 5.10 `arm.log.*` — 300 Hz control-tick capture ```python start(n_ticks) @@ -459,7 +480,7 @@ Constants: `LOG_MAX_SAMPLES = 2400` (about 8 s @300 Hz, stops when full), `CTRL_ `reader()` reads back in chunks following the firmware cursor, retrying automatically on a dropped chunk. `dump()` writes the raw byte stream to disk. -#### `arm.diag.*` — firmware self-test +### 5.11 `arm.diag.*` — firmware self-test ```python kin_bench(timeout=8.0) -> Msg[KinBenchResult] @@ -473,7 +494,7 @@ It is the only source of return-link diagnostic counters, but **all-zero counter nothing was actually read** — see [troubleshooting §11](../TROUBLESHOOTING.md#11-every-kin_bench-counter-reads-0). -### 5.9 Firmware update (DFU) +### 5.12 Firmware update (DFU) ```python enter_dfu(timeout=0.3) -> None @@ -481,16 +502,15 @@ enter_dfu(timeout=0.3) -> None The only terminal-state operation: enters the ROM bootloader without a probe. -- Two-stage: `ACK{0x15}` only means registered; you still wait for the device to disappear from - CDC. +- Two-stage: `ACK{0x15}` only means registered; you still wait for the device to disappear from CDC. - Rejected locally while enabled (the jump stops TIM3, motors release within 100 ms). -- Afterwards this `Arm` is unusable (every entry point raises `ArmIsInDfuError`, `close()` - excepted); the device re-enumerates as `0483:DF11`, and after flashing you create a new `Arm`. +- Afterwards this `Arm` is unusable: every entry point raises `ArmIsInDfuError`, `close()` excepted. +- The device re-enumerates as `0483:DF11`; after flashing you create a new `Arm`. - If the device does not disappear before the timeout it raises, and the object stays usable. You cannot flash immediately after entering DFU; wait for USB re-enumeration. -### 5.10 Persistence +### 5.13 Persistence ```python save_params() -> None @@ -498,7 +518,7 @@ save_params() -> None Writes flash, irreversible. -### 5.11 Read-only properties +### 5.14 Read-only properties ```python params / model / log / diag # sub-objects @@ -514,6 +534,48 @@ bench_model_axis # bench calibration axis `last_reset_reason` is `None` in normal use, and that is correct: the boot signature is sent once, only after a real MCU reset, and `reset()` does not make it repeat. +### 5.15 License and activation + +```python +license(timeout=1.0) -> LicenseInfo +activate(*, cust_id, issued, flags=0, mac, timeout=2.0) -> None +``` + +A unit ships locked until it is activated: `enable()` answers `ERR{0x10,0x08}` and every other +command keeps working, so the arm stays diagnosable in the field. The activation record is +written once and is never erased. + +`license()` reads that record: + +| Field | Meaning | +| --- | --- | +| `state` / `state_name` | `0` not activated, `1` activated, `2` activated with a factory code | +| `activated` | `state != 0` | +| `factory_mode` | The factory bit. It does **not** mean "activated"; use `activated` | +| `ver` | Record version | +| `uid` / `uid_hex` | The 12-byte MCU UID in raw register order. `uid_hex` is the form the issuing tool needs | +| `cust_id` / `issued` / `flags` | Customer number, issue date `YYYYMMDD`, flags — all `0` while unactivated | + +**Do not** read `activated` as "this arm is usable": an unactivated arm answers `ACK` to +everything except `enable()`, so a follow loop that skips the enable step looks healthy until it +tries to move. + +`activate()` submits a vendor-issued credential. `mac` is 16 bytes (two SipHash-2-4 tags) +produced by the vendor-side issuing tool; this package neither produces nor needs the signing +key. + +- The arm must be disabled first, otherwise the firmware answers `ERR{0x3F,0x04}`. +- `ERR{0x3F,0x02}` is an **aggregate code**, not a specific failure. +- Verify with `license()`, not with the `ACK`: only the record read back proves the write landed. + +The `0x02` aggregate covers a reserved flag bit, an already-activated unit, a MAC mismatch, a +bad key and a failed write. A resend after a lost `ACK` lands in it too, with the unit already +unlocked, so `activate()` re-reads `license()` on `0x02` and raises only if `state` is still +`0` — which is also what the vendor-side tool does. + +Disabling first is the same rule as `save_params()`: flash must not be written while the motors +hold the arm under supervision. + --- ## 6. Exceptions diff --git a/docs/DEVELOPER_GUIDE.zh-CN.md b/docs/DEVELOPER_GUIDE.zh-CN.md index 7364f31..802f823 100644 --- a/docs/DEVELOPER_GUIDE.zh-CN.md +++ b/docs/DEVELOPER_GUIDE.zh-CN.md @@ -141,14 +141,14 @@ class Msg(Generic[T]): `hz` = 该类帧自本会话首次到达起的平均频率,**样本不足 2 条时为 `0.0`**。 - 被动连续流(`RSP_STATUS`,100 Hz):两三帧后收敛到约 100,链路空闲不会衰减。 -- 单发请求/应答式的 4 个(`params.get_joint_param` / `model.get_body` / `model.get_jm` / - `model.get_gravity`):一次调用只到一帧,**第一次调用必然 `hz == 0.0`**,第二次起等于 - **你自己的轮询频率**。 -- ⚠ `diag.kin_bench()` **不在**上面那一组:它的回执是**连续两帧**(耗时帧 + LINK 帧), - 一次调用到 2 帧,所以第一次调用 `hz` 就非 0,而且那个数**没有意义**(分子被拆帧放大、 - 分母还是调用间隔)。判"有没有读到"要看 `timestamp`。 +- 单发请求/应答式的 4 个:一次调用只到一帧,**第一次调用必然 `hz == 0.0`**。 +- `diag.kin_bench()` **不在**上面那一组:它的回执是**连续两帧**(耗时帧 + LINK 帧),所以 `hz` 在它上面没有意义。 - `reconnect()` 后归零。 +那 4 个请求/应答式入口是 `params.get_joint_param`、`model.get_body`、`model.get_jm` 与 +`model.get_gravity`;第二次起 `hz` 等于**你自己的轮询频率**。`diag.kin_bench()` 则是分子被拆帧放大、 +分母还是调用间隔,判"有没有读到"要看 `timestamp`。 + **`hz == 0.0` 且 `timestamp == 0.0` 表示这类帧从没到过**,不是链路慢。 ### 不返回 `Msg` 的入口 @@ -157,6 +157,7 @@ class Msg(Generic[T]): - `n` / `firmware` / `last_reset_reason` / `zero_g_active` —— 没有帧 - `ik()` —— 计算请求 - `get_ff_mask()` 返回裸 `int`;`params.all_joint_params()` 返回 `list[JointParam]` +- `license()` 返回裸 `LicenseInfo`(见 §5.15)—— 它是请求/应答式的**设备身份记录**,没有固件发起的流量,逐类 `hz` / `timestamp` 只会度量你自己轮询的频率 ### `RobotState` @@ -181,7 +182,7 @@ get_status_now(timeout=0.5) -> Msg[RobotState] `get_status_now()` 会**主动发一条 `GET_STATUS`**,与只消费被动流的 `get_state()` 不同, 可用来确认链路活性。 -⚠ `get_status_now(timeout=0.0)` **不是**"非阻塞探一帧"——它的意思是**立刻返回当前缓存**。 +`get_status_now(timeout=0.0)` **不是**"非阻塞探一帧"——它的意思是**立刻返回当前缓存**。 本会话还**一帧都没收到过**时它抛 `MotionTimeoutError`。 --- @@ -354,6 +355,7 @@ with arm.zero_g(): ```python send_mit(idx, q, dq, kp, kd, tau) send_mit_all(q, dq, kp, kd, tau) +joint_follow(q, dq, kp, kd) ``` 绕过运动规划,**调用方必须自己保活**:需 ≥10 Hz 重发,否则 0.1 s 看图门狗进入 fail-soft @@ -362,11 +364,31 @@ send_mit_all(q, dq, kp, kd, tau) `send_mit_all` 的五个数组长度必须都等于 `n`(本地校验),且**必须是有限数** —— `NaN` / `Inf` 会被**固件**整帧拒收,回 `ERR{cmd,0x02}`。`send_mit` / `move_js` 同样校验有限性。 -本组入口在真机上未经完整验证,见[排障指南 §16](../TROUBLESHOOTING.zh-CN.md#16-尚未验证的部分)。 +`joint_follow` 就是**帧里不带前馈**的 `send_mit_all`:前馈由固件每个控制拍自己算 +`tau_ff = clamp(G(q_measured) + wall, ±tau_max)`,跟随环因此省掉每拍一次 `get_gravity()` 往返。 +帧里只有四段(少一段 `tau`)。 + +| 段 | 含义 | +| --- | --- | +| `q` | 位置目标。固件会钳到本关节软限位内 | +| `dq` | 送进固件 MIT 环的**速度参考**,不是速率上限 | +| `kp` / `kd` | MIT 增益,固件按自己的 `MIT_KP_MAX` / `MIT_KD_MAX` 钳制 | + +四段长度都必须等于 `n`(本地校验);有限性同本组其余入口,由固件判。 -### 5.8 子对象 +固件用**自己的跟随速度表**(J1..J7 = 2.8 / 3.4 / 5.0 / 5.0 / 10.0 / 8.0 / 13.0 rad/s, +`control_loop.c`)对位置参考做斜率限制,并同样钳 `dq`。那张表与其余透传入口用的表 +**不是同一张**,所以跟随会话允许比 `send_mit_all` 跑得快。 -#### `arm.params.*` —— 关节级参数 +- `enable()` 是前提:否则受理路径回 `ERR{0x08,0x03}`,EMERGENCY 锁存时同样是这个码。 +- 拖动示教(零重力)进行中回 `ERR{0x08,0x04}`;掉线刚性持位(drop_hold)锁存中回 `ERR{0x08,0x06}`。 +- 早于本命令的固件回 `ERR{0x08,0x00}` —— 不会静默。 +- **任何其它运动/停止命令都会结束本会话** —— 下一条 `movej` 或 `move_js` 接管。 +- 状态帧仍报 `mode = MOVE_MIT_ALL`,**不要**从 `state.mode` 判断跟随是否在进行。 + +本组入口在真机上未经完整验证,见[排障指南 §16](../TROUBLESHOOTING.zh-CN.md#16-尚未验证的部分)。 + +### 5.8 `arm.params.*` —— 关节级参数 ```python set_joint_param(idx, kp, kd, tau_max) @@ -381,7 +403,7 @@ reset_factory() `set_joint_limits()` **只许收窄**,写回当前值会被判成放宽请求并拒(`ERR[23,2]`), 所以它不幂等,别拿它做读写回环。`reset_factory()` 要求失能态,且不可逆。 -#### `arm.model.*` —— 动力学模型在线导入 +### 5.9 `arm.model.*` —— 动力学模型在线导入 ```python probe() -> bool @@ -400,7 +422,7 @@ revert() 写入进 staging 层,不立即生效,判据看 `staged_mask`。`commit(expected_mask)` 才应用, `revert()` 丢弃。**两者都要求失能态**,已使能时分别回 `ERR{0x32,0x04}` / `ERR{0x37,0x04}`。 -⚠ `revert()` **只回退 RAM,不动 flash**,三条后果必须知道: +`revert()` **只回退 RAM,不动 flash**,三条后果必须知道: 1. flash 里那份导入模型还在,**重新上电会复活**; 2. 此后任何一次 `save_params()` 会把 flash 里那份**一并抹掉**(整扇区擦除,不可恢复); @@ -409,7 +431,7 @@ revert() **`set_jm()` 建议永不调用**:它改关节映射(含符号),改错有乱飞风险, 而本机唯一的恢复手段本身也不可逆。 -#### `arm.log.*` —— 300 Hz 控制拍采集 +### 5.10 `arm.log.*` —— 300 Hz 控制拍采集 ```python start(n_ticks) @@ -435,7 +457,7 @@ r.total_bytes # 属性 `reader()` 按固件游标分块读回,掉帧自动重试。`dump()` 落盘原始字节流。 -#### `arm.diag.*` —— 固件自检 +### 5.11 `arm.diag.*` —— 固件自检 ```python kin_bench(timeout=8.0) -> Msg[KinBenchResult] @@ -448,7 +470,7 @@ kin_bench(timeout=8.0) -> Msg[KinBenchResult] 它是回链路诊断计数的唯一来源,但**计数器全 0 可能是根本没读到**, 见[排障 §11](../TROUBLESHOOTING.zh-CN.md#11-kin_bench-的计数器全是-0)。 -### 5.9 固件升级(DFU) +### 5.12 固件升级(DFU) ```python enter_dfu(timeout=0.3) -> None @@ -458,13 +480,13 @@ enter_dfu(timeout=0.3) -> None - 两段式:`ACK{0x15}` 只表示已登记,还要等设备真的从 CDC 上消失。 - 使能中本地拒绝(跳转会停 TIM3,电机 100 ms 松开)。 -- 成功后本 `Arm` 不可再用(所有入口抛 `ArmIsInDfuError`,`close()` 例外), - 设备重新枚举成 `0483:DF11`,烧完固件新建一个 `Arm`。 +- 成功后本 `Arm` 不可再用:所有入口抛 `ArmIsInDfuError`,`close()` 例外。 +- 设备重新枚举成 `0483:DF11`,烧完固件新建一个 `Arm`。 - 超时未消失则抛异常,对象照旧可用。 进 DFU 后不能立刻刷,要等 USB 重新枚举。 -### 5.10 参数持久化 +### 5.13 参数持久化 ```python save_params() -> None @@ -472,7 +494,7 @@ save_params() -> None 写 flash,不可逆。 -### 5.11 只读属性 +### 5.14 只读属性 ```python params / model / log / diag # 子对象 @@ -488,6 +510,38 @@ bench_model_axis # 台架标定轴 `last_reset_reason` 常态是 `None`,那是正确行为:开机签名只在真 MCU 复位后发一次, `reset()` 不会让它重发。 +### 5.15 授权与激活 + +```python +license(timeout=1.0) -> LicenseInfo +activate(*, cust_id, issued, flags=0, mac, timeout=2.0) -> None +``` + +设备出厂即锁定,直到被激活:`enable()` 回 `ERR{0x10,0x08}`,**其余命令一律照常**, +这样现场仍然可诊断。激活记录**写一次、永不擦除**。 + +`license()` 读这条记录: + +| 字段 | 说明 | +| --- | --- | +| `state` / `state_name` | `0` 未激活、`1` 已激活、`2` 已激活且产线模式 | +| `activated` | `state != 0` | +| `factory_mode` | 产线码位。它**不表示**"已激活",判断请用 `activated` | +| `ver` | 记录版本 | +| `uid` / `uid_hex` | 12 字节 MCU UID(原始寄存器序)。签发工具要的是 `uid_hex` 这个形态 | +| `cust_id` / `issued` / `flags` | 客户号、签发日 `YYYYMMDD`、标志位 —— 未激活时全为 `0` | + +**不要**把 `activated` 当成"这台臂能用":未激活的臂除 `enable()` 外全部回 `ACK`, +所以一个跳过了使能步骤的跟随环看起来一切正常,直到它试图运动。 + +`activate()` 提交厂商签发的凭据。`mac` 是 16 字节(两个 SipHash-2-4 标签),由**厂商侧** +签发工具产出;本包既不产生也不需要签名密钥。 + +- 必须先失能,否则固件回 `ERR{0x3F,0x04}` —— 与 `save_params()` 同一个理由:写 flash 期间电机不得在无监督下保持使能。 +- `ERR{0x3F,0x02}` 是**聚合档**:固件把"flags 保留位非 0 / 已经激活过 / MAC 不符 / 密钥非法 / 写或读回失败"全折进它。 +- 因此 `activate()` 见到 `0x02` 会**回读一次 `license()`**,只有 state 确实仍是 `0` 才抛 —— ACK 丢失后重发不该被当成失败。 +- 判据用 `license()` 而不是 `ACK`:`ACK` 只说明固件收下了帧,读回来的记录才是"已落位"的证据。 + --- ## 6. 异常 diff --git a/examples/README.md b/examples/README.md index 0b7858b..485839e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,7 @@ # litearm-python examples -Each script runs on its own. **Read it before you run it on real hardware.** +Runnable examples for the litearm-python SDK, one script per feature. Each runs on its own; +**read it before you run it on real hardware.** **Read-only by default** — any example that enables, moves or retunes parameters requires an explicit `--go`, so nothing moves by accident. @@ -62,7 +63,7 @@ The examples that move the arm (02 / 03 / 05 / 06 / 07) **really drive it**: - keep `--speed` at 0.1–0.3 the first time; - stand by the emergency stop, and make sure nobody and nothing is in the workspace; - read the [troubleshooting guide](../TROUBLESHOOTING.md) first; -- ⚠ **`movej` does not check joint limits** — an out-of-range target travels the full stroke. +- **`movej` does not check joint limits** — an out-of-range target travels the full stroke. ## Pose format diff --git a/examples/README.zh-CN.md b/examples/README.zh-CN.md index 14c9d26..1aeb76c 100644 --- a/examples/README.zh-CN.md +++ b/examples/README.zh-CN.md @@ -1,6 +1,6 @@ # litearm-python 样例 -每个脚本可独立运行,**先读懂再上真机**。 +litearm-python SDK 的可运行样例,一个脚本一项功能。每个都能独立运行,**先读懂再上真机**。 **默认只读**——会 `enable` / 运动 / 改参的样例必须显式加 `--go`,防误动。 @@ -59,7 +59,7 @@ Windows 上 `LITEARM_PORT` 可以锁定 `COM5` 这类端口。 - 首次运行把 `--speed` 保持在 0.1~0.3; - 人站在急停旁,确保周围无人无障碍; - 跑之前先读 [排障指南](../TROUBLESHOOTING.zh-CN.md); -- ⚠ **`movej` 不校验关节限位**——越限目标会被走满行程。 +- **`movej` 不校验关节限位**——越限目标会被走满行程。 ## 位姿格式