From 40364086f8abafac9eae40296bb7268fa1587c52 Mon Sep 17 00:00:00 2001 From: luochun <56000204@qq.com> Date: Wed, 23 Sep 2026 22:09:54 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=85=A8=E9=83=A8=208=20=E4=BB=BD?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E9=87=8D=E5=86=99=E4=B8=BA=E9=9D=A2=E5=90=91?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=9A=84=E4=BA=A7=E5=93=81=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 litearm-js README 的排版重排:概述 / 特性 / 安装 / 快速开始 / API 参考 / 注意事项 / 示例 / 开发。描述改成短句直述,去掉括号嵌套补注与警告堆叠。 清除与用户无关的内容:授权/激活整章、pylitearm 提及、旧命名 A1.x-USB、 旧帧布局 4+21N、movel/movec 旧名对照、B1-B4 与"阶段 C"内部代号、 无出处的实测数字(1.48×)。 逐条对着源码与固件修正的实质错误: - 端口优先级写了 LITEARM_PORT,但 SDK 不读任何环境变量(src/ 里零个 os.environ);该变量只有 examples/_common.py 用 - kin_bench 不在"单发请求应答"那一组(回执是连续两帧),是 4 个不是 5 个 - connect() 每条失败路径都先 close() 再抛,不留半开会话(原写反) - 子进程里 close() 仍可调,走轻路径不碰传输层,不会挂死(原写反) - set_speed 收的是整数百分比且全局持续,set_speed(1) 是 1% 速度不是满速 - home() 须先 enable()(固件 usb_cmd.c:311 → ERR{0x2A,0x03}) - revert() 不动 flash,重新上电会复活;此后一次 save_params() 会一并抹掉 - get_status_now(0.0) 会主动发 GET_STATUS,一帧未到时抛 MotionTimeoutError - send_mit/send_mit_all/move_js 的有限性校验在固件侧(usb_cmd.c:485/508/531) - 去掉无依据的"失能态下 capture() 恒录 0 拍",移入未验证清单 示例代码修正:move_c 起点须取 .value(直接传 Msg 会抛 InvalidCommandError)、 补 multiprocessing 导入、消除 p1/p2/p3 与 tcp/via_pose 等未定义占位符、 把"with 之后各片段仍在用 arm"讲清楚。 新增 tests/test_doc_examples.py:抽取 8 份文档的全部 python 块,能跑的接到 离线桩上真执行(未交代的占位符会 NameError),签名清单则逐条比对 inspect.signature(参数名、顺序、关键字专用 *、默认值)。已用 6 类注入错误 做过变异测试,全部命中。 配套代码改动:PYLITEARM_LIVE/PYLITEARM_REPO 改名为 LITEARM_LIVE/ LITEARM_REPO(conftest、test_live、env.sh/.ps1/.cmd、run_example.ps1、CI)。 Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 4 +- README.md | 456 ++++++++++------- README.zh-CN.md | 359 ++++++++----- TROUBLESHOOTING.md | 428 ++++++++-------- TROUBLESHOOTING.zh-CN.md | 327 ++++++------ docs/DEVELOPER_GUIDE.md | 929 ++++++++++++++-------------------- docs/DEVELOPER_GUIDE.zh-CN.md | 635 ++++++++++------------- env.cmd | 6 +- env.ps1 | 6 +- env.sh | 9 +- examples/README.md | 86 ++-- examples/README.zh-CN.md | 71 ++- pyproject.toml | 2 +- run_example.ps1 | 4 +- tests/conftest.py | 8 +- tests/test_doc_examples.py | 351 +++++++++++++ tests/test_live.py | 2 +- 17 files changed, 1985 insertions(+), 1698 deletions(-) create mode 100644 tests/test_doc_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08bb75a..fe27468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ # 测试门禁。 # -# 跑在 PR 与默认分支的 push 上。⚠ CI 里**绝不**设 PYLITEARM_LIVE —— +# 跑在 PR 与默认分支的 push 上。⚠ CI 里**绝不**设 LITEARM_LIVE —— # 那会去开 /dev/ttyACM0 连真机。 # 测试单独成 workflow: 它跑在 PR 上, 而 release workflow 跑在默认分支的 push 上。 # 两者混在一起会让「发布坏了」和「PR 坏了」互相挡住。 @@ -38,7 +38,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - # tests/conftest.py 只在 PYLITEARM_LIVE=1 时才连真机; 不设这个变量即纯离线桩。 + # tests/conftest.py 只在 LITEARM_LIVE=1 时才连真机; 不设这个变量即纯离线桩。 # CI 里**绝不**设它 —— 那会去开 /dev/ttyACM0。 - name: Test run: | diff --git a/README.md b/README.md index b70fc0a..fc784d0 100644 --- a/README.md +++ b/README.md @@ -1,233 +1,319 @@ -# litearm-python — LiteArm STM32 Direct Backend +# litearm-python -Python SDK for the LiteArm robotic arm: it **talks directly to the -`litearm-stm32` firmware** (USB CDC serial), mirroring the common usage of -`pylitearm` with a high-level subset. +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. -This is a **thin protocol binding** — **the PC side does no trajectory -planning, no kinematics, no dynamics**. All of it is carried by the firmware -(B2 S-curve + B3 kinematics + B4 dynamics + B1 control law). The PC side does -exactly three things: encode and decode frames, issue commands, decide arrival. +> 📖 Full interface reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md). -**The `pylitearm` source is not modified.** **Zero dependencies** beyond -`pyserial` (no numpy / pinocchio). +## 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 ## Install +| Item | Requirement | +| --- | --- | +| Python | 3.9 or later | +| Dependencies | `pyserial >= 3.4` (the only one) | +| Firmware | `Litearm1.5.0` or later | +| Connection | USB CDC serial, VID:PID `1d50:606f` | + +```bash +pip install -e . +python3 -c "import litearm; print(litearm.__version__)" # verify +``` + +Linux needs serial permissions: + ```bash -pip install -e . # the only dependency is pyserial +sudo usermod -aG dialout $USER # log in again for it to take effect ``` -## Quick Start +## Quick start ```python import litearm as pa -arm = pa.Arm().connect() # auto-find the CDC port, check the firmware version convention -arm.enable() # motion requires enable first -arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) # single shot: the firmware S-curve completes it -print(arm.get_tcp().value) # current TCP pos[3] + rpy[3] (since 2.0, values are read via .value) -q = arm.ik((0.30, 0.0, 0.35, 3.1416, 0, 0)) # pose → joint angles (asynchronous firmware IK) -arm.close() +# connect: find the port and check the firmware version +with pa.Arm().connect() as arm: + print(arm.firmware, arm.n) # version string, joint count + + # enable (required before any motion) + arm.enable() + + # joint move: the firmware plans the S-curve and completes it + arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) + + # Cartesian line: a pose is 3 position values (m) + 3 orientation values (rad) + arm.move_l((0.30, 0.0, 0.40, 3.1416, 0.0, 0.0), speed=0.5) + + # read state + print(arm.get_state().value.q) # current joint angles + print(arm.get_tcp().value) # current tool pose + + # inverse kinematics: pose → joint angles + print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) + + # go home + arm.home() +# leaving the with block disconnects ``` -`Arm().connect()` is the **only entry point**. A session carries a background -read thread, so every `Arm` needs `close()` — a `with` block saves you that: +`Arm().connect()` is the only entry point. Every session carries a read thread, so **you must +`close()` it** — `with` does that for you. Pass `port` to pick the serial port; leave it out and +the SDK auto-discovers it (VID:PID `1d50:606f`). The SDK **reads no environment variables**. + +Every `arm` below refers to that connected session object; the snippets show only the steps +that section is about. + +## API reference + +### Connection ```python -with pa.Arm().connect() as arm: - print(arm.get_state().value.q) +arm = pa.Arm(port="/dev/ttyACM0").connect() # omit port to auto-discover +print(arm.firmware) # version string, e.g. "Litearm1.8.0-7J" +print(arm.n) # joint count +arm.close() # disconnect (a with block does this for you) +``` + +### Joint motion + +All three need `enable()` first: `movej` is a single shot (the firmware plans the S-curve and +completes it), `movej_sync` is a synchronised point to point, and `home` goes home. + +```python +arm.enable() # required before motion +arm.movej([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], speed=0.3) # single shot +arm.home() # go home +``` + +### Cartesian motion + +```python +P1 = (0.30, 0.0, 0.40, 3.1416, 0.0, 0.0) +P2 = (0.32, 0.0, 0.42, 3.1416, 0.0, 0.0) +P3 = (0.34, 0.0, 0.44, 3.1416, 0.0, 0.0) + +arm.move_l(P2, speed=0.5) # straight line +arm.move_p(P2) # joint-space point to point — not a line +arm.move_path([P1, P2, P3], speed=0.5) # visit the waypoints in turn, sharp corners +arm.move_c(arm.get_tcp().value, P2, P3) # arc — start must be the measured TCP + +plan = arm.move_l(P2, wait=False) # do not block; poll later +print(arm.poll_cart()) +arm.set_speed(50) # global governor: integer percentage 0..100 +``` + +### State / kinematics + +```python +state = arm.get_state().value +print(state.q, state.dq, state.tau) # joint angles / velocity / torque +print(state.enabled, state.faulted) # enabled? faulted? +print(arm.get_tcp().value) # tool pose (firmware forward kinematics) +print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # pose → joint angles +``` + +### Life / safety + +```python +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 +``` + +### Feed-forward / dynamics + +```python +arm.set_payload(1.0) # always call this after changing payload +arm.set_gravity_scale([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) +arm.ff_preset(1) # 0 all off / 1 factory / 2 all on +print(arm.get_ff_scalar(4).value) # read payload_mass back +``` + +### Hand-guiding + +```python +with arm.zero_g(): + input("Drag the arm, then press Enter") +``` + +### Passthrough / servo + +```python +arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # ⚠ bypasses planning; keep alive at ≥10 Hz +``` + +### Parameters (`arm.params.*`) + +```python +p = arm.params.get_joint_param(0).value # J1's kp / kd / tau_max / soft limits +arm.params.set_joint_param(0, 30.0, 1.0, 20.0) # idx, kp, kd, tau_max +for jp in arm.params.all_joint_params(): + print(jp.idx, jp.kp, jp.q_min, jp.q_max) +``` + +### Dynamics model (`arm.model.*`) + +```python +print(arm.model.probe()) # does the firmware support this? +print(arm.model.status().value) # override / staged_mask / dirty +print(arm.model.get_gravity([0.0] * 7).value) # G(q) +``` + +### Data capture (`arm.log.*`) + +```python +arm.log.start(300) # record 300 ticks, then stop +r = arm.log.reader() +print(r.total()) # ticks written so far +for s in r.samples()[:3]: + print(s.tick, s.q_ref, s.dq, s.tau) +``` + +### Firmware self-test (`arm.diag.*`) + +```python +print(arm.diag.kin_bench().value) # CAN link diagnostic counters +``` + +### Persistence + +```python +arm.save_params() # ⚠ writes flash, irreversible ``` -## CLI Inspection +### Read-only properties + +```python +print(arm.n, arm.firmware, arm.fw_version) # joint count / version string / version tuple +print(arm.q_tol, arm.dq_tol, arm.move_timeout) # arrival criteria and motion timeout +print(arm.zero_g_active, arm.last_reset_reason) +``` + +## Command line ```bash -litearm-python status # or python -m litearm ... +litearm-python status # read-only +litearm-python fw # version string + axis count +litearm-python tcp # current pose + frame rate litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 litearm-python home ``` -`status` / `fw` / `tcp` are read-only; `enable` / `disable` / `reset` / -`emergency` / `movej` / `home` really move the arm. +`status` / `fw` / `tcp` are read-only; the rest really drive the arm. `python -m litearm` is +equivalent. + +## Things to watch out for + +### Read the payload via `.value` -## ⚠ Two Must-Reads (These Bite) +The 11 "read one frame" getters return the envelope `Msg(value, hz, timestamp)` — `.value` is +the payload itself, while `hz` / `timestamp` are how often frames of that kind arrive and when +the last one landed. If **both are 0**, no frame of that kind has ever arrived. -### 1. Multiprocessing / `fork()`: a child **must not** use an inherited `Arm` +```python +print(arm.get_state()) # Msg(value=RobotState(...), hz=100.2, timestamp=...) +print(arm.get_state().value.q) # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] +``` + +### Multiprocessing: a forked child must not use an inherited `Arm` -Every session in this package carries **a background read thread** (see -[Architecture](docs/DEVELOPER_GUIDE.md#7-architecture--one-reader-thread)). Threads are **not copied -by `fork`**, but file descriptors are — so in a child process: commands **really -go out on the wire**, yet nothing ever reads the replies; the caller only sees a -"no response" timeout, and a retry means **a duplicate command**. Reading state -is subtler — it **does not error**, it just silently returns the inherited, -**stale** value. +Commands **really go out on the wire**, but the parent's read thread consumes the replies — you +only see a "no response" timeout, and retrying means **sending the command twice**. Reading +state is subtler: it does not raise, it just **returns an eternally stale value**. -So this package is **fail-closed**: any command in a child process immediately -raises `ForkedSessionError` (a subclass of `NotConnectedError`), and **not a -single byte goes out**. +So this library is **fail-closed**: any command in a child process immediately raises +`ForkedSessionError`, and not a single byte goes out. -**⚠ For a child process to use the arm, the parent must release the port -first.** The serial port is exclusive, and while the parent still holds it the -child **cannot connect** — both ends block it: the in-process port registry -(copied by `fork` and still pointing at the parent's live transport), and -pyserial's `flock` on the **inherited fd** from `exclusive=True`. -⇒ The right way is to **`close()` the parent's session first, then `fork`**, and -then **create** a new `Arm` in the child. ("Leave the parent alone and only -`connect()` in the child" **does not work** on real hardware.) +For a child to use the arm, the **parent must `close()` and release the port first**, then fork, +then create a new `Arm` inside the child: ```python -# On Linux multiprocessing defaults to fork ⇒ this is not a rare path +import multiprocessing + def worker(): - a = pa.Arm().connect() # ✅ create it inside the child - ... + a = pa.Arm().connect() # create it inside the child a = pa.Arm().connect() -a.close() # ✅ release the port first — miss this and the child cannot connect +a.close() # without this the child cannot connect p = multiprocessing.Process(target=worker) -p.start() # do not pass a into the child -``` - -⚠ A child process **should not call `close()`**: that takes a lock inherited from -the parent that will never be released — touch it and you **hang forever**. It is -also **not guaranteed safe** in a child: the light path itself takes -`_tx_repeat_lock`. Let that inherited fd be closed by the kernel when the -process exits — it neither reads nor writes, so it is harmless. - -### 2. Unactivated board: `enable()` is rejected (`ERR{0x10,0x08}`) - -Since firmware 1.8.0 the board is **locked from boot**: when unactivated, the -**first** criterion in `ctrl_enable()` is "is it authorized" — **resending does -nothing, there is no bypass**. **Every other command works as usual** (after-sales -and production lines must be able to diagnose), and `license()` returns normally -too — see [License / Activation](#license--activation-firmware-180). - -## Firmware Version Convention - -`firmware` returns `Litearm-{7J|1J}` (e.g. `Litearm1.8.0-7J`). -Checked at connect time: - -| Firmware | Result | -| ----------------------------------------------- | ------------------------------------------------------------------ | -| `Litearm1.5.x-7J` / `Litearm1.5.x-1J` and above | ✅ accepted (minimum **1.5.0**) | -| `Litearm1.4.x-*` or earlier | ❌ `FirmwareMismatchError` | -| `A1.x-*-USB` (old naming) | ❌ does not match the convention (suggest flashing `Litearm1.5.0+`) | - -> The version gate is pinned at 1.5.0, but state-frame parsing **also accepts** -> both layouts, `4+21N` (≤1.4.x) and `6+21N` (≥1.5.0) — that compatibility -> branch is only used for offline / historical frame parsing (e.g. analysing a -> capture), and `connect()` never reaches it. - -## License / Activation (Firmware 1.8.0+) - -The firmware stores one license record in a **separate flash sector** (sector 6), -**written once and never erased**; when unactivated it **locks only `ENABLE`**, -and every other command works as usual. - -```python -lic = arm.license() # 0x2F → LicenseInfo -if not lic.activated: - print(lic.state_name, lic.uid_hex) # uid_hex is the 24-digit hex the issuer wants - arm.disable() # activate requires the disabled state first - arm.activate(cust_id=, issued=, - mac=<16 bytes issued by the vendor>) # 0x3F -``` - -- `license()` → `LicenseInfo`, **does not raise when unactivated** (it is a - **state**), and **returns the UID even when unactivated** — that is the - issuer's only source; do not switch to the USB serial-number string. -- `activate(*, cust_id, issued, flags=0, mac)` — `mac` is issued by the vendor. - **Must be disabled first**, otherwise `ERR{0x3F,0x04}`; a local precheck covers - the `mac` length and the `flags` reserved bits, and a non-conforming frame is - **never sent**. -- ⚠ **This package holds no key and no code that computes a MAC** — issuing - happens in a vendor-side tool. This is a hard spec requirement: if the customer - side has any code that can compute a MAC, this whole mechanism is worth - nothing. -- ⚠⚠ `ERR{0x3F,0x02}` is an **aggregate code**: the firmware folds "already - activated / MAC mismatch / invalid key / write failure" all into one code ⇒ - **the code alone reports a machine that is in fact unlocked as a failure**. - This package **automatically reads back `0x2F`** on this code: if the device - really has `state != 0` it returns success, and only otherwise raises. -- Erasing the license record **is only possible over SWD** - (`pyocd erase -s 0x080C0000`) — the firmware has **no** erase command. - -## API Overview - -| Group | Entry | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Session | `connect` `close` `disconnect` `reconnect` `__enter__` | -| Life / safety | `enable` `disable` `emergency_stop` `reset` `clear_faults` `set_motion_mode` `park` | -| Joint motion | `movej` `movej_sync` `move_js` `home` | -| Cartesian | `move_p` `move_l` `move_c` `move_path` `poll_cart` `set_speed` | -| State / kinematics | `get_state` `get_status_now` `get_tcp` `ik` | -| Feed-forward / dynamics | `set_ff_mask` `ff_preset` `set_ff_vec` `set_ff_scalar` `get_ff_vec` `get_ff_scalar` `get_ff_mask` `set_gravity_scale` `set_inertia_scale` `set_payload` `set_gravity_vector` | -| Passthrough / servo | `send_mit` `send_mit_all` | -| Zero-gravity hand-guiding | `zero_g` (context manager) `zero_g_start` `zero_g_stop` | -| License | `license` `activate` | -| Flashing | `enter_dfu` | -| Persistence | `save_params` | -| Sub-objects | `arm.params.*` (4) · `arm.model.*` (9) · `arm.log.*` (4 + `LogReader`) · `arm.diag.kin_bench` | -| Read-only properties | `n` `firmware` `fw_version` `min_firmware` `q_tol` `dq_tol` `arrive_frames` `move_timeout` `bench_model_axis` `last_reset_reason` `zero_g_active` `zero_g_error` | - -Full signatures, return types and per-entry caveats: see -[docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md). - -### ⚠ Dangerous Entry Points - -Read the warning before the signature. - -**`save_params()` — persist to flash (`0x25`).** -It writes the **current RAM**; there is no undo. - -**`reset_factory()` — factory reset, invalidating flash (`0x36`).** -**The firmware requires the disabled state**; when enabled it returns -`ERR{0x36,0x04}`. - -**`enter_dfu()` — the only terminal-state operation.** -Two-stage (`ACK{0x15}` only means "registered"; you still have to wait for the -device to really disappear from CDC); rejected locally while enabled (the jump -stops TIM3 ⇒ motors release in 100 ms and sag under load). After it returns -successfully **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`**. - -**`send_mit` / `move_js` — bypass planning, and the caller must keep them alive.** -**Requires resending at ≥10 Hz**, otherwise the 0.1 s command watchdog fails -soft. - -**`disable()` — once enable is cut, the arm is no longer held by the position -loop.** - -## Docs - -- [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) — full API reference, return - envelopes, architecture -- [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — field notes: failure modes that are - easy to misdiagnose -- [examples/README.md](examples/README.md) — runnable examples (read-only by - default, motion needs `--go`) +p.start() +``` -## Development +`close()` **is still callable** in the child (it only clears session state and never touches the +transport, so it does not hang) — but do not expect it to free the serial port. -```bash -pip install -e ".[dev]" -pytest # full offline flow (stub transport, never touches real hardware) -PYLITEARM_LIVE=1 pytest # + real-hardware live (needs a Litearm1.5.0+ arm/bench attached; moves a little) -``` +### Safety rules + +1. **After `disable()` the arm is no longer held by the position loop** — under load it sags. +2. **`movej` does not check joint limits** — an out-of-range target is clamped by the firmware + and the arm **still travels the full stroke**. +3. **`move_js` / `send_mit` need keep-alive at ≥10 Hz** — otherwise the 0.1 s watchdog drops + stiffness and the arm sags slowly. +4. **`enter_dfu()` is a terminal-state operation** — afterwards every entry point stops working + and you need a new `Arm` after flashing. + +### Irreversible commands: do not run these on a calibrated arm + +These overwrite or erase that unit's per-arm identified dynamics model, with **no undo**: -⚠ **Do not set `PYLITEARM_LIVE` with nobody present**, and never call -`enter_dfu()` / `reset_factory()`. +| Entry point | Effect | +| --- | --- | +| `save_params()` | writes the current RAM to flash | +| `arm.model.commit()` | applies staged dynamics model changes | +| `arm.model.revert()` | rolls the dynamics model back (does not touch flash, so it returns on power-up) | +| `arm.params.reset_factory()` | restores factory settings | -Examples load the environment first: +Only do this on a board whose calibration has no value. **`arm.model.set_jm()` should never be +called** — a wrong joint mapping can make the arm flail, and there is no dependable way back. + +When stress-testing the CAN link, run `candump` (read-only) only, never `cangen`: `can0` *is* +the motor bus. + +## Examples + +See [examples/README.md](examples/README.md): + +- `01_hello.py` — handshake + firmware version + reading state +- `02_movej.py` — joint motion +- `03_move_p.py` — Cartesian point to point +- `04_ik_tcp.py` — inverse kinematics and the current pose +- `05_ff_tune.py` — dynamics / control-law tuning +- `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`: ```bash -source env.sh # exports PYTHONPATH/PYTHON_BIN/LITEARM_PORT +source env.sh # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT python3 examples/01_hello.py -./run_example.sh 02_movej.py --go # or one-shot via the wrapper script +./run_example.sh 02_movej.py --go +``` + +## Development + +```bash +pip install -e ".[dev]" +pytest # full offline run, never touches hardware +LITEARM_LIVE=1 pytest # plus hardware tests, moves a little ``` -On Windows use `env.ps1` / `env.cmd` and `run_example.ps1` / `run_example.cmd`; -`LITEARM_PORT` can pin `COM5` and the like. +Running the tests does not require installing the package; `tests/conftest.py` sets `sys.path` +itself. Do not set `LITEARM_LIVE` with nobody present, and never call `enter_dfu()` / +`reset_factory()`. + +On Windows use `env.ps1` / `env.cmd` and `run_example.ps1` / `run_example.cmd`. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 9f4cc6a..c96c903 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,18 +1,37 @@ -# litearm-python —— LiteArm STM32 直连后端 +# litearm-python -LiteArm 机械臂的 Python SDK:**直连 `litearm-stm32` 固件**(USB CDC 串口), -用高层子集镜像 `pylitearm` 的常用用法。 +LiteArm 机械臂的 Python SDK。通过 USB 串口直连机械臂固件,即可从任意机器控制机械臂 —— +不需要服务端或中间件。轨迹规划、运动学、动力学全部由固件承担。 -这是一份**薄协议绑定** —— **PC 侧不做轨迹规划、不做运动学、不做动力学**, -这些全部由固件内置承担(B2 S 曲线 + B3 运动学 + B4 动力学 + B1 控制律)。 -PC 侧只干三件事:编解码帧、下发命令、判定到位。 +> 📖 完整接口说明见 [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md)。 -**不修改 `pylitearm` 源码。** 除 `pyserial` 外**零依赖**(不引 numpy / pinocchio)。 +## 特性 + +- 🐍 **纯 Python**:Python 3.9+,位姿就是普通的 list / tuple,不需要 numpy +- 📦 **单一依赖**:只有 `pyserial` +- 🔌 **USB 直连**:一根线接到固件,不经过任何服务端 +- ⚙️ **重活交给固件**:规划、运动学、动力学都在固件里,PC 侧只发点、判到位 +- 🦾 **完整运动接口**:关节运动、笛卡尔直线 / 圆弧 / 多路点、拖动示教 +- 🛡️ **安全兜底**:子进程 fail-closed,急停独立入口,不可逆命令逐个标注 ## 安装 +| 项目 | 要求 | +| --- | --- | +| Python | 3.9 及以上 | +| 依赖 | `pyserial >= 3.4`(唯一依赖) | +| 固件 | `Litearm1.5.0` 及以上 | +| 连接 | USB CDC 串口,VID:PID `1d50:606f` | + ```bash -pip install -e . # 依赖仅 pyserial +pip install -e . +python3 -c "import litearm; print(litearm.__version__)" # 验证 +``` + +Linux 需要串口权限: + +```bash +sudo usermod -aG dialout $USER # 重新登录后生效 ``` ## 快速开始 @@ -20,177 +39,265 @@ pip install -e . # 依赖仅 pyserial ```python import litearm as pa -arm = pa.Arm().connect() # 自动找 CDC,并校验固件版本约定 -arm.enable() # 运动前必须先使能 -arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) # 单发:固件 S 曲线自完成 -print(arm.get_tcp().value) # 当前末端 pos[3] + rpy[3](2.0 起读值走 .value) -q = arm.ik((0.30, 0.0, 0.35, 3.1416, 0, 0)) # pose → 关节角(固件异步 IK) -arm.close() +# 连接:自动找串口 + 校验固件版本 +with pa.Arm().connect() as arm: + print(arm.firmware, arm.n) # 版本串、关节数 + + # 使能(运动之前必须) + arm.enable() + + # 关节运动:固件规划 S 曲线并自己走完 + arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) + + # 笛卡尔直线运动:位姿 = 位置 3 个数(m)+ 姿态 3 个数(rad) + arm.move_l((0.30, 0.0, 0.40, 3.1416, 0.0, 0.0), speed=0.5) + + # 读状态 + print(arm.get_state().value.q) # 当前关节角 + print(arm.get_tcp().value) # 当前末端位姿 + + # 逆解:位姿 → 关节角 + print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) + + # 回零 + arm.home() +# 退出 with 即断开 ``` -`Arm().connect()` 是**唯一入口**。会话带一条后台读线程,所以每个 `Arm` 都要 -`close()` —— 用 `with` 块可以省掉这件事: +`Arm().connect()` 是唯一入口。每个会话背后有一条读线程,**用完必须 `close()`**,`with` 会自动关。 +串口由 `port` 参数指定,不传则自动发现(VID:PID `1d50:606f`)。SDK **不读任何环境变量**。 + +下面各节的 `arm` 都指这个已连好的会话对象,片段只写该节要讲的那几步。 + +## API 参考 + +### 连接管理 ```python -with pa.Arm().connect() as arm: - print(arm.get_state().value.q) +arm = pa.Arm(port="/dev/ttyACM0").connect() # 不传 port 则自动发现 +print(arm.firmware) # 版本串,如 "Litearm1.8.0-7J" +print(arm.n) # 关节数 +arm.close() # 断开(用 with 会自动调) ``` -## CLI 巡检 +### 关节运动 -```bash -litearm-python status # 或 python -m litearm ... -litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 -litearm-python home +三个入口都要先 `enable()`:`movej` 单发(固件规划 S 曲线、自己走完), +`movej_sync` 同步点到点(各轴一起到位),`home` 回零。 + +```python +arm.enable() # 运动前必须先使能 +arm.movej([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], speed=0.3) # 单发 +arm.home() # 回零 ``` -`status` / `fw` / `tcp` 只读;`enable` / `disable` / `reset` / `emergency` / -`movej` / `home` 会真的动。 +### 笛卡尔运动 -## ⚠ 两条必读(会咬人的) +```python +P1 = (0.30, 0.0, 0.40, 3.1416, 0.0, 0.0) +P2 = (0.32, 0.0, 0.42, 3.1416, 0.0, 0.0) +P3 = (0.34, 0.0, 0.44, 3.1416, 0.0, 0.0) + +arm.move_l(P2, speed=0.5) # 直线 +arm.move_p(P2) # 关节空间点到点 —— 末端不走直线 +arm.move_path([P1, P2, P3], speed=0.5) # 依次经过多个路点,转角是尖角 +arm.move_c(arm.get_tcp().value, P2, P3) # 圆弧 —— 起点必须是实测 TCP + +plan = arm.move_l(P2, wait=False) # 不阻塞,稍后查进度 +print(arm.poll_cart()) +arm.set_speed(50) # 全局调速:整数百分比 0..100 +``` -### 1. 多进程 / `fork()`:子进程**不能**用继承来的 `Arm` +### 状态 / 运动学 -本包每个会话带**一条后台读线程**(见[架构](docs/DEVELOPER_GUIDE.zh-CN.md#7-架构--一条读线程))。线程**不被 `fork` 复制**, -而文件描述符会 —— 于是子进程里:命令**真的写出去**,应答却永远没人读,调用方只看到 -"无应答"超时,重试就是**重复下发**;读状态更隐蔽,它**不报错**,只是静默回继承来的**陈旧**值。 +```python +state = arm.get_state().value +print(state.q, state.dq, state.tau) # 关节角 / 速度 / 力矩 +print(state.enabled, state.faulted) # 使能中?有故障? +print(arm.get_tcp().value) # 末端位姿(固件正运动学) +print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # 位姿 → 关节角 +``` -故本包 **fail-closed**:子进程里任何命令立刻抛 `ForkedSessionError` -(`NotConnectedError` 的子类),**一个字节都不下发**。 +### 生命 / 安全 -**⚠ 子进程要用臂,前提是父进程先释放端口。** 串口是独占的,父进程还持有它时 -子进程**连不上** —— 两端都会拦:进程内的端口登记表(随 `fork` 复制且仍指向父进程那个活传输), -以及 pyserial `exclusive=True` 在**继承来的 fd** 上的 `flock`。 -⇒ 正路是**先 `close()` 父进程的会话,再 `fork`**,然后子进程里**新建**一个 `Arm`。 -("父进程不动,只在子进程里 `connect()`" 这条在真机上**走不通**。) +```python +arm.emergency_stop() # 急停:唯一没有前置条件的入口 +arm.reset() # 清故障 + 重锚控制环(不是 MCU 重启) +arm.clear_faults() # 只清 RAM 故障位 +arm.disable() # ⚠ 失能后不再被位置环托住 +``` + +### 前馈 / 动力学调参 ```python -# Linux 上 multiprocessing 默认就是 fork ⇒ 这条不是罕见路径 -def worker(): - a = pa.Arm().connect() # ✅ 在子进程里新建 - ... +arm.set_payload(1.0) # 换负载必须调,否则有恒定偏移 +arm.set_gravity_scale([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]) +arm.ff_preset(1) # 0 全关 / 1 出厂 / 2 全开 +print(arm.get_ff_scalar(4).value) # 读回 payload_mass +``` -a = pa.Arm().connect() -a.close() # ✅ 先释放端口 —— 漏了这步子进程连不上 -p = multiprocessing.Process(target=worker) -p.start() # 别把 a 传进子进程用 +### 拖动示教 + +```python +with arm.zero_g(): + input("拖动机械臂,然后回车") ``` -⚠ 子进程里**不该调 `close()`**:那里要取一把从父进程继承来、永远不会释放的锁,碰了就 -**永久挂死**。而且它在子进程里**并不保证安全**:轻路径自己也会取 `_tx_repeat_lock`。让那个继承来的 fd 随进程退出由内核关闭 —— 它既不读也不写,无害。 +### 透传 / 伺服 -### 2. 未激活的板子:`enable()` 会被拒(`ERR{0x10,0x08}`) +```python +arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # ⚠ 绕过规划,需 ≥10 Hz 自己保活 +``` -固件 1.8.0 起**开机即锁**:未激活时 `ctrl_enable()` 的**第一条**判据就是"有没有被授权", -**重发无用、无旁路**。**其余命令一切照常**(售后/产线要能诊断),`license()` 也正常返回 -—— 见[授权/激活](#授权激活固件-180)。 +### 参数(`arm.params.*`) -## 固件版本约定 +```python +p = arm.params.get_joint_param(0).value # J1 的 kp / kd / tau_max / 软限位 +arm.params.set_joint_param(0, 30.0, 1.0, 20.0) # idx, kp, kd, tau_max +for jp in arm.params.all_joint_params(): + print(jp.idx, jp.kp, jp.q_min, jp.q_max) +``` -`firmware` 返回 `Litearm<主.次.修>-{7J|1J}`(如 `Litearm1.8.0-7J`)。连接时校验: +### 动力学模型(`arm.model.*`) -| 固件 | 结果 | -| --- | --- | -| `Litearm1.5.x-7J` / `Litearm1.5.x-1J` 及以上 | ✅ 接受(最低 **1.5.0**) | -| `Litearm1.4.x-*` 或更早 | ❌ `FirmwareMismatchError` | -| `A1.x-*-USB`(旧命名) | ❌ 不符合约定(提示烧 `Litearm1.5.0+`) | +```python +print(arm.model.probe()) # 固件是否支持 +print(arm.model.status().value) # override / staged_mask / dirty +print(arm.model.get_gravity([0.0] * 7).value) # G(q) +``` -> 版本门卡在 1.5.0,但状态帧解析**同时兼容** `4+21N`(≤1.4.x)与 `6+21N`(≥1.5.0) -> 两种布局 —— 那段兼容分支只用于离线/历史帧解析(例如分析抓包),不会被 `connect()` 走到。 +### 数据采集(`arm.log.*`) -## 授权/激活(固件 1.8.0+) +```python +arm.log.start(300) # 开始录 300 拍后自停 +r = arm.log.reader() +print(r.total()) # 已落盘的拍数 +for s in r.samples()[:3]: + print(s.tick, s.q_ref, s.dq, s.tau) +``` -固件在**独立 flash 扇区**(sector 6)存一条授权记录,**写一次永不擦**; -未激活时**只锁 `ENABLE`**,其余命令一切照常。 +### 固件自检(`arm.diag.*`) ```python -lic = arm.license() # 0x2F → LicenseInfo -if not lic.activated: - print(lic.state_name, lic.uid_hex) # uid_hex 就是签发器要的那 24 位 hex - arm.disable() # activate 要求先失能 - arm.activate(cust_id=, issued=, - mac=<厂商签发的 16 字节>) # 0x3F +print(arm.diag.kin_bench().value) # CAN 链路诊断计数 ``` -- `license()` → `LicenseInfo`,**未激活时不抛异常**(它是一种**状态**), - 而且**未激活也回 UID** —— 那是签发器的唯一来源,别改用 USB 序列号字符串。 -- `activate(*, cust_id, issued, flags=0, mac)` —— `mac` 由厂商侧签发。 - **须先失能**,否则 `ERR{0x3F,0x04}`;本地预检 `mac` 长度与 `flags` 保留位, - 不合规的帧**不下发**。 -- ⚠ **本包不含密钥,也不含任何算 MAC 的代码** —— 签发在厂商侧工具里。这是规格硬要求: - 客户侧只要有一份能算 MAC 的代码,这套机制就归零。 -- ⚠⚠ `ERR{0x3F,0x02}` 是**聚合档**:固件把「已经激活过/MAC 不符/密钥非法/写失败」 - 全折成同一个码 ⇒ **光看码会把一台其实已经解锁的机器报成失败**。 - 本包在这一档**自动回读 `0x2F`**:设备确实 `state != 0` 就当成功返回,否则才抛。 -- 擦除授权记录**只能走 SWD**(`pyocd erase -s 0x080C0000`)—— 固件**没有**擦除命令。 +### 参数持久化 -## API 一览 +```python +arm.save_params() # ⚠ 写入 flash,不可逆 +``` -| 分组 | 入口 | -| --- | --- | -| 会话 | `connect` `close` `disconnect` `reconnect` `__enter__` | -| 生命/安全 | `enable` `disable` `emergency_stop` `reset` `clear_faults` `set_motion_mode` `park` | -| 关节运动 | `movej` `movej_sync` `move_js` `home` | -| 笛卡尔 | `move_p` `move_l` `move_c` `move_path` `poll_cart` `set_speed` | -| 状态/运动学 | `get_state` `get_status_now` `get_tcp` `ik` | -| 前馈/动力学 | `set_ff_mask` `ff_preset` `set_ff_vec` `set_ff_scalar` `get_ff_vec` `get_ff_scalar` `get_ff_mask` `set_gravity_scale` `set_inertia_scale` `set_payload` `set_gravity_vector` | -| 透传/伺服 | `send_mit` `send_mit_all` | -| 零重力拖动示教 | `zero_g`(上下文管理器) `zero_g_start` `zero_g_stop` | -| 授权 | `license` `activate` | -| 烧录 | `enter_dfu` | -| 持久化 | `save_params` | -| 子对象 | `arm.params.*`(4) · `arm.model.*`(9) · `arm.log.*`(4 + `LogReader`) · `arm.diag.kin_bench` | -| 只读属性 | `n` `firmware` `fw_version` `min_firmware` `q_tol` `dq_tol` `arrive_frames` `move_timeout` `bench_model_axis` `last_reset_reason` `zero_g_active` `zero_g_error` | +### 只读属性 -完整签名、返回类型与逐条注意事项见 [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md)。 +```python +print(arm.n, arm.firmware, arm.fw_version) # 关节数 / 版本串 / 版本元组 +print(arm.q_tol, arm.dq_tol, arm.move_timeout) # 到位判据与运动超时 +print(arm.zero_g_active, arm.last_reset_reason) +``` -### ⚠ 危险入口 +## 命令行 -先读警告,再看签名。 +```bash +litearm-python status # 只读 +litearm-python fw # 版本串 + 轴数 +litearm-python tcp # 当前位姿 + 帧率 +litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 +litearm-python home +``` -**`save_params()` —— 持久化到 flash(`0x25`)。** -写的是**当前 RAM**,没有撤销。 +`status` / `fw` / `tcp` 只读,其余会真的驱动机械臂。等价写法是 `python -m litearm`。 -**`reset_factory()` —— 恢复出厂并失效 flash(`0x36`)。** -**固件要求失能态**,已使能时回 `ERR{0x36,0x04}`。 +## 注意事项 -**`enter_dfu()` —— 唯一的终端态操作。** -两段式(`ACK{0x15}` 只表示"已登记",还要等设备真的从 CDC 上消失);使能中本地拒绝 -(跳转会停 TIM3 ⇒ 电机 100 ms 松开、有负载则下垂)。成功返回后**本 `Arm` 不可再用** -(所有入口抛 `ArmIsInDfuError`,`close()` 例外),设备重枚举成 `0483:DF11`, -烧完固件**新建一个 `Arm`**。 +### 读值要走 `.value` -**`send_mit` / `move_js` —— 绕过规划,且需调用方自己保活。** -**需 ≥10 Hz 重发**,否则 0.1 s 命令看门狗 fail-soft。 +11 个"读一帧"接口返回的是信封 `Msg(value, hz, timestamp)` —— `.value` 才是数据本身, +`hz` / `timestamp` 是这类帧的到达频率与最近到达时刻。两者**同时为 0** 表示这类帧从没到过。 -**`disable()` —— 使能一断,臂不再被位置环托住。** +```python +print(arm.get_state()) # Msg(value=RobotState(...), hz=100.2, timestamp=...) +print(arm.get_state().value.q) # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] +``` -## 文档 +### 多进程:`fork` 之后子进程不能用继承来的 `Arm` -- [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md) —— 完整 API 参考、返回信封、架构 -- [TROUBLESHOOTING.zh-CN.md](TROUBLESHOOTING.zh-CN.md) —— 现场笔记:容易误诊的失效模式 -- [examples/README.zh-CN.md](examples/README.zh-CN.md) —— 可运行样例(默认只读,运动需 `--go`) +命令会**真的发出去**,但应答被父进程的读线程吃掉 —— 你只看到"无应答"超时, +一重试就是**重复下发**。读状态更隐蔽:它不报错,只是**永远返回陈旧值**。 -## 开发 +所以本库 **fail-closed**:子进程里任何命令立刻抛 `ForkedSessionError`,一个字节都不下发。 -```bash -pip install -e ".[dev]" -pytest # 离线全流程(桩 transport,不碰真机) -PYLITEARM_LIVE=1 pytest # + 真机 live(需接 Litearm1.5.0+ 整臂/台架,会小幅运动) +子进程要用机械臂,**父进程必须先 `close()` 释放串口**,再 `fork`,然后在子进程里新建 `Arm`: + +```python +import multiprocessing + +def worker(): + a = pa.Arm().connect() # 在子进程里新建 + +a = pa.Arm().connect() +a.close() # 不释放,子进程连不上 +p = multiprocessing.Process(target=worker) +p.start() ``` -⚠ **无人在场时不要设 `PYLITEARM_LIVE`**,也绝不调用 `enter_dfu()` / `reset_factory()`。 +子进程里 `close()` 仍可调(它只清会话状态、不碰传输层,不会挂死),但别指望靠它释放串口。 + +### 安全红线 + +1. **`disable()` 之后机械臂不再被位置环托住** —— 有负载就会往下垂。 +2. **`movej` 不校验关节限位** —— 越限目标会被固件截断后**照走满行程**。 +3. **`move_js` / `send_mit` 需 ≥10 Hz 自己保活** —— 否则 0.1 s 看门狗降刚度,臂缓慢塌下去。 +4. **`enter_dfu()` 是终端态操作** —— 执行后所有入口失效,烧完固件要新建一个 `Arm`。 + +### 不可逆命令:不要在标定过的机械臂上执行 + +下列入口会覆盖或抹掉该台设备逐台辨识的动力学模型,**没有撤销**: + +| 入口 | 作用 | +| --- | --- | +| `save_params()` | 当前 RAM 写入 flash | +| `arm.model.commit()` | 应用暂存的动力学模型改动 | +| `arm.model.revert()` | 回滚动力学模型(不动 flash,重新上电会复活) | +| `arm.params.reset_factory()` | 恢复出厂 | + +只在一台没有标定价值的板子上做。**`arm.model.set_jm()` 建议永不调用** —— +改错关节映射有乱飞风险,而没有可依赖的退路。 + +压测 CAN 链路时只开 `candump`(只读),绝不 `cangen` —— `can0` 就是电机总线。 -样例先加载环境: +## 示例 + +见 [examples/README.zh-CN.md](examples/README.zh-CN.md): + +- `01_hello.py` — 连接握手 + 固件版本 + 读状态 +- `02_movej.py` — 关节运动 +- `03_move_p.py` — 笛卡尔点到点 +- `04_ik_tcp.py` — 逆解与当前位姿 +- `05_ff_tune.py` — 动力学 / 控制律调参 +- `06_cartesian.py` — 笛卡尔直线 / 圆弧 / 多路点 +- `07_vel_jitter_trace.py` — 300 Hz 逐拍采集 + +样例**默认只读**,会运动的必须加 `--go`: ```bash -source env.sh # 导出 PYTHONPATH/PYTHON_BIN/LITEARM_PORT +source env.sh # 导出 PYTHONPATH / PYTHON_BIN / LITEARM_PORT python3 examples/01_hello.py -./run_example.sh 02_movej.py --go # 或包装脚本一键跑 +./run_example.sh 02_movej.py --go +``` + +## 开发 + +```bash +pip install -e ".[dev]" +pytest # 离线全流程,不碰真机 +LITEARM_LIVE=1 pytest # 额外跑真机用例,会小幅运动 ``` -Windows 用 `env.ps1` / `env.cmd` 与 `run_example.ps1` / `run_example.cmd`, -`LITEARM_PORT` 可锁 `COM5` 等。 +跑测试不需要装包,`tests/conftest.py` 会自己设好 `sys.path`。 +无人在场时不要设 `LITEARM_LIVE`,也不要调用 `enter_dfu()` / `reset_factory()`。 + +Windows 用 `env.ps1` / `env.cmd` 与 `run_example.ps1` / `run_example.cmd`。 ## License diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index d0403a4..07e544e 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -1,308 +1,310 @@ -# litearm-python — Troubleshooting - -These are the failure modes that are **easy to misdiagnose**. Each entry gives three -things: the symptom, the real cause, and **how to tell the two causes apart**. - -> There is **no request id** between this package and the firmware, so many problems -> that "look like network/timeout issues" are really protocol-semantics problems. -> Suggested order of investigation: look at the **error code** first (§4 has two code -> spaces, extremely easy to confuse), then the **link diagnostic counters** (§11), and -> only then suspect the wiring. - -## Contents - -1. [`connect()` fails](#1-connect-fails) -2. [`enable()` is refused — each of three codes means something - different](#2-enable-is-refused--each-of-three-codes-means-something-different) -3. [Odd behaviour in a `fork`ed child](#3-odd-behaviour-in-a-forked-child) -4. [`ERR{0x02,0x03}` is ambiguous](#4-err0x020x03-is-ambiguous) -5. [Two different 1~6 code spaces](#5-two-different-16-code-spaces) -6. [A burst of 3+ Cartesian commands always loses a reply](#6-a-burst-of-3-cartesian-commands-always-loses-a-reply) -7. [`movej` returns before the arm has settled](#7-movej-returns-before-the-arm-has-settled) -8. [`move_c()` arc failures](#8-move_c-arc-failures) -9. [Cartesian accuracy is not a single number](#9-cartesian-accuracy-is-not-a-single-number) -10. [`last_reset_reason` is `None` (usually correct)](#10-last_reset_reason-is-none-usually-correct) -11. [`kin_bench`'s five counters read zero — silently](#11-kin_benchs-five-counters-read-zero--silently) -12. [A constant Cartesian offset — check `payload_mass` first](#12-a-constant-cartesian-offset--check-payload_mass-first) -13. [`zero_g` keep-alive and asynchronous teardown](#13-zero_g-keep-alive-and-asynchronous-teardown) -14. [Watchdog fail-soft on `move_js` / `send_mit`](#14-watchdog-fail-soft-on-move_js--send_mit) -15. [After `enter_dfu()`](#15-after-enter_dfu) -16. [Two counter-intuitive parameter writes](#16-two-counter-intuitive-parameter-writes) +# litearm-python field troubleshooting + +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 +only then suspect the cabling. + +## Symptom lookup + +| What you see | Section | +| --- | --- | +| `connect()` cannot open the port / no device found | §1 | +| `connect()` reports a firmware version mismatch | §1 | +| `enable()` is rejected | §2 | +| Commands in a child process time out; a retry "sometimes works" | §3 | +| Readings in a child process never change, but nothing raises | §3 | +| You got `ERR{0x02,0x03}` and cannot tell which meaning applies | §4 | +| The number "3" means two different things in two places | §4 | +| Back-to-back Cartesian commands lose a reply | §5 | +| `move_c()` arc fails | §6 | +| Cartesian accuracy is far from what you expected | §7 | +| A constant Cartesian offset you cannot explain | §8 | +| Reading state right after `movej` returns shows a small residual | §9 | +| `last_reset_reason` is `None` | §10 | +| Every `kin_bench` counter reads 0 | §11 | +| A motion command right after `zero_g` is rejected | §12 | +| After `move_js` / `send_mit` the arm slowly sags | §13 | +| Flashing fails right after `enter_dfu()` | §14 | +| `set_speed` / `set_joint_limits` behave counter-intuitively | §15 | +| You want to know what has not been verified yet | §16 | --- -## 1. `connect()` fails +## 1. `connect()` cannot connect -```text -FirmwareMismatchError: firmware version does not match the convention ... -TransportError: cannot open the serial port / no CDC device found -``` +**Symptom**: `TransportError` (cannot open the port / no device) or `FirmwareMismatchError` +(version mismatch). | Cause | How to confirm | | --- | --- | -| **Device not found** — not plugged in, driver not loaded, not `1d50:606f` | `lsusb` to see whether it is there; call `litearm.find_cdc_port()` on its own and see what it returns | -| **Port already taken** — another process/session still has it open | On Linux, `fuser /dev/ttyACM0`; on Windows endpoint exclusivity is **enforced by the OS**, so if you cannot grab it, it will not open | -| **Version gate** — firmware < 1.5.0, or the older `A1.x-*-USB` naming | Read the `FirmwareMismatchError` message directly; it echoes the version string it read verbatim | +| No device found — not plugged in, no driver, not `1d50:606f` | Check `lsusb`; call `litearm.find_cdc_port()` on its own and see what it returns | +| Port already held — another process or session is still open | On Linux, `fuser /dev/ttyACM0`; on Windows, port exclusivity is enforced by the OS, so if you cannot grab it you cannot open it | +| Firmware too old (below 1.5.0) or non-conforming name | Read the `FirmwareMismatchError` message — it quotes the version string it actually saw | + +**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 restart) -makes `/dev/ttyACM*` **change number**. A script pinned to `LITEARM_PORT` then points at a -port that does not exist — this is the most common reason for "it was fine a moment ago". +⚠ 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". --- -## 2. `enable()` is refused — each of three codes means something different +## 2. `enable()` is rejected -`enable(attempts=12)`'s **retry is a whitelist**: **only `(0x10, 0x03)` is retried**. -Resending any other code is **useless** — it only makes you wait for nothing. +**Symptom**: `enable(attempts=12)` raises `CommandRejectedError`. + +**Cause and handling**: `attempts` retries on a **whitelist** — only the one code marked +"retryable" below is retried. For every other code, resending **does nothing** but waste time. | Code | Meaning | What to do | | --- | --- | --- | -| `ERR{0x10,0x03}` | A retryable transient failure (the only one in the firmware-side whitelist) | Leave it to `attempts`, or resend later | -| `ERR{0x10,0x08}` | **Not activated** — locked from boot as of firmware 1.8.0; the first check in `ctrl_enable()` is the licence | Go through `license()` / `activate()`; see the README section on licensing/activation | -| `ERR{0x10,0x06}` | A **latched** fault (the `joint_fault` family); **resending is useless** | `reset()` first, then find which axis it is (`state.joint_fault` / `state.fault_axes`) | -| `ERR{0x10,0x07}` | Resending is useless | Check the firmware code table | -| `ERR{0x10,0x00}` | **The firmware does not have this command** | The firmware is too old | +| `ERR{0x10,0x03}` | Transient failure (the only retryable one in the firmware whitelist) | Let `attempts` handle it, or resend later | +| `ERR{0x10,0x06}` | A **latched** fault — resending is useless | Call `reset()` first, then find which axis (`state.joint_fault` / `state.fault_axes`) | +| `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 | -⚠ One item unrelated to `enable` that often gets mixed in: **when the arm is not enabled, -`movej` is refused with `ERR[01,3]`**, and its message names **both** possibilities at once -— "not enabled, **or** EMERGENCY latched". Do not read only the first half. +⚠ 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. --- -## 3. Odd behaviour in a `fork`ed child +## 3. Strange behaviour in a forked child + +**Cause**: threads are not copied by `fork`, but file descriptors are. So in the child, +commands really do go out on the wire, while the parent's read thread consumes the replies. +See [README](README.md#multiprocessing-a-forked-child-must-not-use-an-inherited-arm). -See item 1 of the README's "two must-reads". Here we only list **how to recognise it**: +**How to recognise it**: | Symptom | Explanation | | --- | --- | -| The command "times out with no reply", but a retry "sometimes works again" | The command **really was sent** (the fd is inherited); the reply is eaten by the **parent's reader thread** ⇒ the retry is a **duplicate send** | -| `get_state()` raises nothing, but the readings **never move** | It silently returns the inherited **stale** values — the most insidious kind | -| `connect()` in the child raises | The parent still holds the port (see the README: **the parent must `close()` first**) | -| Any command immediately raises `ForkedSessionError` | ✅ The guard is **working properly**; this is not an error | +| 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 | -Real-hardware verification: in the child, `movej` / `get_state` / `get_tcp` / `connect` -**all** raise `ForkedSessionError`, **not one byte is sent**, and the parent session is -unaffected. +**What to do**: `close()` the parent session to release the port, then `fork`, then +**create** a new `Arm` inside the child. --- -## 4. `ERR{0x02,0x03}` is ambiguous - -**The same `(command, code)` has two completely different origins**: +## 4. Two confusing error codes -- `usb_cmd.c` — **not enabled / EMERGENCY latched**; -- `kin_runner.c` — **IK unreachable / invalid solution**. +### `ERR{0x02,0x03}` is ambiguous -⇒ **Receiving it does not prove "the arm is not enabled"**; field attribution will point -the wrong way. Discriminator: look at the **current state** (`state.enabled` / -`state.mode`), not at the code. If the arm is in fact enabled, then it is the IK branch. +**The same `(command, code)` pair has two entirely different origins** in the firmware: it +means both "not enabled / EMERGENCY latched" and "inverse kinematics unreachable / invalid +solution". ---- +**What to do**: receiving it does **not** mean the arm is disabled. Look at the **current +state** (`state.enabled` / `state.mode`) rather than the code — if the arm is enabled, it is +the IK branch. -## 5. Two different 1~6 code spaces +### Two code spaces that both run 1–6 -| Source | Meaning | +| Origin | Meaning | | --- | --- | -| The `err` field in the `0x4E` reply (`CartPlan.err`) | `cart_err_t`: the planner's own verdict (no solution / collinear / over-capacity / out of limits …) | -| The **second byte** of `RSP_ERR` | The **gate reason code**: not enabled `0x03` / in zero-g `0x04` / `drop_hold` `0x06` | - -**Both take values in 1~6, and their meanings have nothing to do with each other.** When -you get a "3", first ask which of the two paths it came out of. +| The `err` field in the `0x4E` reply (`CartPlan.err`) | The **planning result itself**: unreachable / collinear / over capacity / out of range | +| The second byte of `RSP_ERR` | The **gate reason code**: not enabled `0x03` / hand-guiding `0x04` / `drop_hold` `0x06` | -⚠ One related known **comment bug**: `usb_cmd.h` says three-point collinearity is `0x03`, -but **the code is right** — collinear is `err = 2`. +**Both take values 1–6 and the meanings are unrelated.** When you get a "3", first ask which +route it came from. --- -## 6. A burst of 3+ Cartesian commands always loses a reply +## 5. Back-to-back Cartesian commands lose a reply -**Reproduced on real hardware 3/3.** Symptom: send several `move_l` / `move_path` in a -row and one of them reports `CartReplyLostError` ("outcome unknown"), or the later -replies are **shifted wholesale** (this command's answer is picked up by the next one). +**Symptom**: after firing several `move_l` / `move_path` in a row, one raises +`CartReplyLostError` ("outcome unknown"), or subsequent replies are **shifted** (the answer +to one command is collected by the next). Reproduced 3 out of 3 times on hardware. -**The root cause is in the firmware, not the SDK**: `plan_pending` in `cart_exec.c` is a -**single bool plus a single payload, not a queue**. The CANCELED sent by superseded -commands overwrite each other ⇒ one missing reply shifts the entire subsequent FIFO -pairing. +**Cause**: the firmware holds only **one** pending Cartesian plan at a time — it is not a +queue. Cancellation replies from superseded plans overwrite each other, so one missing reply +misaligns the whole pairing sequence. **The root cause is in the firmware, not this library.** -**What to do about it**: send Cartesian commands **serially** — wait for one to finish -before sending the next. (The SDK already serialises calls **within one process** with -`_cart_serial`, so normal usage never hits this; you hit it when going concurrent across -processes/clients.) +**What to do**: send Cartesian commands **serially** — wait for each to finish before sending +the next. This library already serialises calls **within one process**, so normal usage never +hits this; concurrent use across processes or clients does. --- -## 7. `movej` returns before the arm has settled +## 6. `move_c()` arc fails -`movej` is **"return as soon as the arrival criterion is met"**, where the criterion is -"every axis |q−target| < `q_tol` and dq quiescent for `arrive_frames` consecutive frames" -— **at the instant it returns, the arm has not settled yet**. +**Symptom**: `CartesianPlanError` is raised and the arm has not moved at all. -Measured (command J6 to 0.7): the value read the instant it returns is **0.6884**; within -5 s it converges on its own to **0.6991** and then holds steady for 20 s. The difference -is ~0.012 rad (0.7°), inside `q_tol = 0.03` — **a design convention, not drift**. +| `err` | Cause | +| --- | --- | +| `2` | **Three collinear points** (or nearly so) — the circle centre runs off to infinity | +| `1` | No IK solution. Starting from a **fully extended `home` pose** (a singularity) **always** does this; it is the firmware behaving sensibly, not a defect | +| `3` | Over capacity / unreachable | -⇒ **"Read the state immediately after `movej` returns" shows you this residual.** If you -need the exact value, wait a few seconds before reading, or tighten `q_tol` yourself. +**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** +(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. -## 8. `move_c()` arc failures +--- -| Symptom | Cause | -| --- | --- | -| `CartesianPlanError{err=2}` | **Three points collinear** (or nearly so) — the centre runs off to infinity | -| `CartesianPlanError{err=1}` | No IK solution. ⚠ Starting from the **fully extended `home` pose** (a singularity) **necessarily** gives `err=1`; that is **reasonable behaviour** of the firmware IK, not a defect | -| `CartesianPlanError{err=3}` | Over-capacity / unreachable | +## 7. Cartesian accuracy is not a single number -⚠ `move_c(start, via, goal)`'s **`start` must match the measured TCP at call time** -(tolerance 6 mm / 0.03 rad). It is not a free "where to start from" parameter; it is -**validated on receipt** — using the TCP of an arm that is moving as the start point will -be refused. +**The residual end-point error depends on [distance × speed × payload pose]**, not on a fixed +device specification, and it changes markedly with payload. -⚠ `via`'s **orientation is ignored**; only its position takes part in defining the circle. +**What to do**: any accuracy comparison must use **the same pose, the same convention and the +same payload** — otherwise you are measuring a pose or payload difference, not an +accuracy difference. --- -## 9. Cartesian accuracy is not a single number +## 8. Constant Cartesian offset — check `payload_mass` first -**The residual endpoint error depends on all three of [distance × speed × payload -attitude]**; it is not a fixed specification of the device. A change of payload -significantly changes the residual error (the residual error **is payload-dependent**). +**Symptom**: the Cartesian pose carries a constant offset of about 8 mm, unrelated to the +protocol or the planner. -⇒ Any accuracy comparison must use **the same attitude, the same convention, the same -payload**; otherwise what you measure is an "attitude difference / payload difference", -not an "accuracy difference". +**Cause**: a stale `payload_mass = 1.0` was left in the device (the factory default should be +`0.0`). The firmware's dynamics compensation uses that mass, so the tool ends up +systematically offset. + +**What to do**: always call `set_payload()` after changing the payload. When you see an +unexplained constant offset, read `payload_mass` back with `get_ff_scalar(4)` before +suspecting anything else. --- -## 10. `last_reset_reason` is `None` (usually correct) +## 9. `movej` has not settled when it returns + +**Symptom**: reading state immediately after `movej` returns shows a small residual on +each axis. -The boot banner **is sent once, and only after a real MCU reset** (`banner_sent` is static -on the firmware side), and `CMD_RESET` does **not** make it send again. +**Cause**: `movej` returns as soon as the **arrival criterion** is met — every axis within +`q_tol` of the target, with velocity quiet for `arrive_frames` consecutive frames. The arm has +not come to rest at that instant. -⇒ **Getting `None` in normal use is correct behaviour**; it is not "the signature failed -to parse". It only has a value in the one case where you **connect soon after a real -reset** (`"normal"` / `"iwdg-rst"`). +Measured (moving J6 to 0.7): **0.6884** at the moment it returns, converging on its own to +**0.6991** within 5 s and holding there for 20 s. That is about 0.012 rad (0.7°), inside +`q_tol = 0.03` — **by design, not drift**. -⚠ A closely related point, for clarity: **`reset()` is a software state reset, not an MCU -restart** (it ends up in `ctrl_reset()`, and there is no `NVIC_SystemReset` anywhere in -the tree) — so it does **not** trigger USB re-enumeration, **the same `Arm` object remains -usable afterwards**, and the banner is not sent again either. +**What to do**: wait a few seconds before reading if you need the exact value, or tighten +`q_tol` yourself. --- -## 11. `kin_bench`'s five counters read zero — silently +## 10. `last_reset_reason` is `None` -When `arm.diag.kin_bench()`'s link diagnostic counters (`crc_errors` / `reply_dropped` / -`can_tx_fail` …) read **all 0**, that is **not necessarily "a clean link"**: the -firmware's acknowledgement is **two consecutive frames**, and this package only takes one. -The symptom is **silent** — it raises nothing, it just gives you a zero that looks -perfectly healthy. +**This is correct behaviour, not a parsing failure.** The boot signature is sent **once, only +after a real MCU reset**, and `reset()` does not make it repeat. Getting `None` in normal use +is expected. -⇒ Before using `kin_bench` for a link health check, first confirm it **actually read -something** (look at `Msg.hz` / `Msg.timestamp`; if both are `0.0`, this class of frame -has never arrived). +It only has a value when you connect **soon after a real reset** (`"normal"` / `"iwdg-rst"`). -⚠ Among the counters, `crc` is live and exact; `can_tx_fail` may be abnormally large (a -cumulative value self-reported by the firmware, semantics unverified). +⚠ 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. --- -## 12. A constant Cartesian offset — check `payload_mass` first +## 11. Every `kin_bench` counter reads 0 + +**Symptom**: the link diagnostics counters from `arm.diag.kin_bench()` (`crc_errors` / +`reply_dropped` / `can_tx_fail` …) all read 0. -One case was seen in the field: a **~8 mm constant offset** in the Cartesian pose, -unrelated to both the SDK and the firmware planner. +**Cause**: this is **not necessarily a clean link**. All-zero can mean "nothing was actually +read" — and that failure is **silent**: no error, just a healthy-looking 0. -**Cause**: the device had a stale `payload_mass = 1.0` left in it (the factory default -should be `0.0`). The firmware's dynamic compensation is computed for that mass, so the -end effector is systematically off a little. +**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. -⇒ **After changing the payload, always `set_payload()`**; when an "unexplainable constant -offset" appears, first read `payload_mass` back with `get_ff_scalar(4)` and check it, and -only then suspect anything else. +⚠ 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). --- -## 13. `zero_g` keep-alive and asynchronous teardown - -- **Other motion commands are refused during the keep-alive** (firmware watchdog - semantics); **queries are not restricted**, and **e-stop / disable are exceptions** - (they must be able to get in at any time). -- **The keep-alive is re-sent automatically by an SDK background thread every `period`** - (default `0.04 s`). Firmware `0x06` carries its own `watchdog_kick`; **stop re-sending - for 0.10 s and it drops out of fail-soft** ⇒ `period` must be ∈ `[0.005, 0.10)`, and - `period=0.5` is refused locally. -- **Teardown is asynchronous**: after `zero_g_stop()` returns, the firmware side still - needs a little time to really finish. A motion command sent immediately afterwards may - be refused — the conservative move is to wait a moment before moving. -- If the keep-alive is interrupted by a **write failure**, teardown **raises** instead of - going silent. +## 12. `zero_g` keep-alive period and asynchronous exit + +| Symptom | Cause | What to do | +| --- | --- | --- | +| Motion commands rejected during hand-guiding | Firmware watchdog semantics: only queries pass through | Queries are unaffected; **emergency stop / disable are exceptions** and always get through | +| `period=0.5` rejected locally | The keep-alive must be **under 0.10 s**; the firmware drops out of fail-soft if not resent within 0.10 s | Use `period` in `[0.005, 0.10)`; the default `0.04` is fine | +| A motion command right after `zero_g_stop()` is rejected | **The exit is asynchronous** — the firmware needs a moment to wrap up after the call returns | Wait briefly before sending motion commands | + +Keep-alive is resent automatically by a background thread. If it breaks because of a **write +failure**, exiting **raises** rather than failing silently. State is available via the +read-only `zero_g_active` / `zero_g_error`. --- -## 14. Watchdog fail-soft on `move_js` / `send_mit` +## 13. The arm slowly sags after `move_js` / `send_mit` -These three are **continuous servo / pass-through** entry points; they **bypass motion -planning**, and **the caller must do its own keep-alive**: +**Cause**: these are **continuous servo / passthrough** entry points that **bypass motion +planning**, and **the caller must keep them alive**: **resend at ≥10 Hz**. After the 0.1 s +watchdog expires the firmware enters fail-soft (reduced stiffness + τ=0) and the arm sags +slowly under gravity — the measured sag matches a "0.6× stiffness + τ=0" estimate. -- **Must be resent at ≥10 Hz.** After the 0.1 s command watchdog expires, the firmware - enters fail-soft (reduced stiffness + τ=0) and the arm slowly sags under gravity — the - measured sag matches the estimate for "stiffness × 0.6 + τ=0". -- The arrays passed to `send_mit` / `send_mit_all` must be **finite numbers**; `NaN` is - refused locally / by the firmware. -- `move_js`'s `dq` is a **velocity reference**, not a limit. +**What to do**: keep resending at ≥10 Hz for as long as the motion is needed. -⚠ These entry points are **not fully verified** on real hardware (the unverified list in -§17). +⚠ 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. --- -## 15. After `enter_dfu()` +## 14. After `enter_dfu()` - **You cannot flash immediately**: `ACK{0x15}` only means "registered"; the device has to - **re-enumerate** as `0483:DF11`. Running pyocd straight away fails — re-running after - a dozen-odd seconds succeeds. -- To decide that "the device really is gone", use **a read/write that raises**, **not** - `is_open`, and **still less** "reading 0 bytes". -- After it returns successfully, **this `Arm` can no longer be used**: every entry point - raises `ArmIsInDfuError` (`close()` excepted). Once the firmware is flashed, **create a - new `Arm`**. -- Calling it while enabled is **refused locally** (the jump stops TIM3 ⇒ the motors - release within 100 ms, and any payload sags). + **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). --- -## 16. Two counter-intuitive parameter writes +## 15. Two counter-intuitive parameter behaviours -**`set_speed(percent)` is non-linear, and there is an integer trap.** -100 → 50 is only **1.48×** slower (there is fixed overhead), not 2×. -The argument must be an **`int` in 0..100**: `bool` and out-of-range values are refused. +**`set_speed(percent)` takes an integer percentage, not a multiplier.** +`set_speed(1)` means **1% speed** — calling it with 0..1 thinking gives you a crawling arm. It +is also **global and persistent** (it stays in effect until a reset-semantics call), not the +same thing as the per-trajectory factor in `movej(speed=0..1)`. The argument must be an **`int` +in 0..100**; `bool` and out-of-range values are rejected locally. **`set_joint_limits()` is not idempotent.** -Writing back the **current value** is judged by the firmware as a "loosening request" and -refused (`ERR[23,2]`) — the firmware **only permits narrowing**. -⇒ Do not use it for a "read it out and write it back" round-trip check. +The firmware **only allows narrowing**, so writing the **current** values back is judged a +"widening request" and rejected (`ERR[23,2]`). ⇒ Do not use it for a read-modify-write +round-trip check. + +--- -## Explicitly Not Verified +## 16. Not yet verified -Do not pretend these have been verified: +Do not assume any of the following has been verified: -- `enter_dfu()` — the **only terminal-state operation**; recovery means re-flashing the - firmware -- The persistence of `save_params()` (it writes flash) -- `reset_factory()` (it erases the tuned parameters and the flash) -- `activate()` — needs a vendor-issued `mac`; only the `license()` read path was verified -- The **success** path of `move_c()` — no reliably successful arc was ever constructed -- `move_js` / `send_mit` / `send_mit_all` — never run on real hardware -- An **effective narrowing** via `set_joint_limits()` (only "writing back the original - value is refused" was verified) -- **The Windows platform has never been run, not once** +- `enter_dfu()` — the only terminal-state operation; recovering means reflashing the firmware; +- `save_params()` persistence (it writes flash); +- `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; +- **Windows** — not yet verified. -### ⚠ Irreversible commands: never run these on a calibrated arm +### ⚠ Irreversible commands: do not run these on a calibrated arm -All four of these **overwrite/erase the per-unit identified dynamics model of that -device** (whole-sector erase + write of current RAM), with **no undo**: +All four **overwrite or erase that unit's per-arm identified dynamics model**, and **there is +no undo**: | Command | Entry point | | --- | --- | @@ -311,12 +313,12 @@ device** (whole-sector erase + write of current RAM), with **no undo**: | `0x36` | `arm.params.reset_factory()` | | `0x37` | `arm.model.revert()` | -**The only way to unlock this: do it on a board that has no calibration value.** +**The only way to be safe: do it on a board whose calibration has no value.** -⚠ Separately, **`0x33` `model.set_jm()` is best never called** — it changes the joint -mapping (signs included), a mistake there carries a risk of the arm **flying wildly**, and -the only recovery means on this machine (`revert` / `save_params`) happen to be exactly -the ones in the table above ⇒ **there is no fallback you can depend on**. +⚠ 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, **only run `candump` (read-only), never `cangen`** — -`can0` is the motor bus. +⚠ 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 d49f021..ae58cdf 100644 --- a/TROUBLESHOOTING.zh-CN.md +++ b/TROUBLESHOOTING.zh-CN.md @@ -1,267 +1,276 @@ -# litearm-python —— 现场排障 - -这里记的是**容易误诊**的失效模式。每条给三样东西:症状、真正的成因、**怎么把两种成因区分开**。 - -> 本包与固件之间**没有请求 id**,很多"看起来像网络/超时"的问题其实是协议语义问题。 -> 排查顺序建议:先看**错误码**(§4 有两套码空间,极易混淆),再看**链路诊断计数**(§11), -> 最后才怀疑线路。 - -## 目录 - -1. [`connect()` 连不上](#1-connect-连不上) -2. [`enable()` 被拒 —— 三档码各是一个意思](#2-enable-被拒--三档码各是一个意思) -3. [`fork` 之后子进程行为诡异](#3-fork-之后子进程行为诡异) -4. [`ERR{0x02,0x03}` 是二义码](#4-err0x020x03-是二义码) -5. [两套 1~6 的码空间极易混淆](#5-两套-16-的码空间极易混淆) -6. [突发 ≥3 条笛卡尔必然丢应答](#6-突发-3-条笛卡尔必然丢应答) -7. [`movej` 返回时还没停稳](#7-movej-返回时还没停稳) -8. [`move_c()` 圆弧失败](#8-move_c-圆弧失败) -9. [笛卡尔精度不是一个数](#9-笛卡尔精度不是一个数) -10. [`last_reset_reason` 是 `None`(多数时候正确)](#10-last_reset_reason-是-none多数时候正确) -11. [`kin_bench` 的五个计数器全 0 —— 静默 0](#11-kin_bench-的五个计数器全-0--静默-0) -12. [笛卡尔恒定偏移 —— 先查 payload_mass](#12-笛卡尔恒定偏移--先查-payload_mass) -13. [`zero_g` 保活期与异步退出](#13-zero_g-保活期与异步退出) -14. [`move_js` / `send_mit` 的看门狗 fail-soft](#14-move_js--send_mit-的看门狗-fail-soft) -15. [`enter_dfu()` 之后](#15-enter_dfu-之后) -16. [参数写入的两个反直觉行为](#16-参数写入的两个反直觉行为) +# litearm-python 现场排障 + +每条都是三段式:**现象 → 原因 → 怎么办**。先看下面的速查表定位。 + +排查顺序建议:先看**错误码**(§4 提醒了两个容易混淆的码空间),再看**链路诊断计数**(§11), +最后才怀疑线路。 + +## 症状速查 + +| 你看到的现象 | 看哪节 | +| --- | --- | +| `connect()` 打不开串口 / 找不到设备 | §1 | +| `connect()` 报固件版本不符 | §1 | +| `enable()` 被拒 | §2 | +| 子进程里命令超时,重试"有时又好了" | §3 | +| 子进程里读数永远不动,但也不报错 | §3 | +| 收到 `ERR{0x02,0x03}`,不确定是哪个意思 | §4 | +| 同一个数字"3",两处含义对不上 | §4 | +| 连发几条笛卡尔命令就丢应答 | §5 | +| `move_c()` 圆弧失败 | §6 | +| 笛卡尔精度和预期差很多 | §7 | +| 笛卡尔出现说不清的恒定偏移 | §8 | +| `movej` 返回后立刻读状态,数值还差一点 | §9 | +| `last_reset_reason` 是 `None` | §10 | +| `kin_bench` 计数器全是 0 | §11 | +| `zero_g` 之后紧接的动作命令被拒 | §12 | +| `move_js` / `send_mit` 之后臂慢慢塌下去 | §13 | +| `enter_dfu()` 之后刷不进去 | §14 | +| `set_speed` / `set_joint_limits` 行为和直觉不符 | §15 | +| 想知道哪些功能还没验证过 | §16 | --- ## 1. `connect()` 连不上 -```text -FirmwareMismatchError: 固件版本不符约定 ... -TransportError: 串口打不开 / 找不到 CDC 设备 -``` +**现象**:抛 `TransportError`(打不开串口 / 找不到设备)或 `FirmwareMismatchError`(版本不符)。 -| 成因 | 判别 | -|---|---| -| **没找到设备** —— 没插、驱动没上、不是 `1d50:606f` | `lsusb` 看有没有;`litearm.find_cdc_port()` 单独调一次看返回什么 | -| **端口被占** —— 另一个进程/会话还开着 | Linux 上 `fuser /dev/ttyACM0`;Windows 上端点独占是 **OS 强制**的,抢不到就是打不开 | -| **版本门禁** —— 固件 < 1.5.0,或旧命名 `A1.x-*-USB` | 直接看 `FirmwareMismatchError` 的文案,它会把读到的版本串原样带出来 | +| 原因 | 怎么确认 | +| --- | --- | +| 没找到设备——没插好、驱动没装、不是 `1d50:606f` | `lsusb` 看有没有;单独调一次 `litearm.find_cdc_port()` 看返回什么 | +| 端口被占用——另一个进程或会话还开着 | Linux 用 `fuser /dev/ttyACM0`;Windows 的端口独占由系统强制,抢不到就是打不开 | +| 固件太旧(低于 1.5.0)或命名不符约定 | 直接看 `FirmwareMismatchError` 的文案,它会把读到的版本串原样带出来 | -⚠ 设备重枚举(拔插、`enter_dfu()` 之后、真断电重启)会让 `/dev/ttyACM*` **换号**。 -被 `LITEARM_PORT` 锁死的脚本这时会指向一个不存在的端口 —— 这是"刚才还好好的"最常见的原因。 +**怎么办**:`FirmwareMismatchError` 就升级固件到 `Litearm1.5.0` 及以上。端口被占就关掉占用方。 + +⚠ 设备**重新枚举**(拔插、`enter_dfu()` 之后、真断电重启)会让 `/dev/ttyACM*` **换号**。 +被 `LITEARM_PORT` 锁死的脚本这时会指向一个不存在的端口——这是"刚才还好好的"最常见的原因。 --- -## 2. `enable()` 被拒 —— 三档码各是一个意思 +## 2. `enable()` 被拒 + +**现象**:`enable(attempts=12)` 抛 `CommandRejectedError`。 -`enable(attempts=12)` 的**重试是白名单**:**只有 `(0x10, 0x03)` 会重试**。 +**原因与处置**:`attempts` 的**重试是白名单**——只有下表中标记"可重试"的那一个码会重试, 其余码重发**没有用**,只会白等。 | 码 | 含义 | 怎么办 | -|---|---|---| -| `ERR{0x10,0x03}` | 可重试的瞬时失败(固件侧白名单里唯一一个) | 交给 `attempts`,或稍后重发 | -| `ERR{0x10,0x08}` | **未激活** —— 固件 1.8.0 起开机即锁,`ctrl_enable()` 第一条判据就是授权 | 走 `license()` / `activate()`,见 README「授权/激活」 | -| `ERR{0x10,0x06}` | **锁存**故障(`joint_fault` 类),**重发无用** | 先 `reset()`,再查是哪根轴(`state.joint_fault` / `state.fault_axes`) | +| --- | --- | --- | +| `ERR{0x10,0x03}` | 瞬时失败(固件白名单里唯一可重试的) | 交给 `attempts`,或稍后重发 | +| `ERR{0x10,0x06}` | **锁存**故障,重发无用 | 先 `reset()`,再查是哪根轴(`state.joint_fault` / `state.fault_axes`) | | `ERR{0x10,0x07}` | 重发无用 | 查固件码表 | -| `ERR{0x10,0x00}` | **固件没有这条命令** | 固件太旧 | +| `ERR{0x10,0x00}` | **固件没有这条命令** | 固件太旧,升级 | -⚠ 与 `enable` 无关但常被混进来的一条:**未使能时 `movej` 会被拒 `ERR[01,3]`**, -它的文案会**同时**点出两种可能 ——「未使能,**或** EMERGENCY 锁存」。别只看前半句。 +⚠ 一条容易混进来的近亲:**未使能时 `movej` 会被拒 `ERR[01,3]`**, +它的文案会**同时**点出两种可能——「未使能,**或** EMERGENCY 锁存」。别只看前半句。 --- ## 3. `fork` 之后子进程行为诡异 -见 README「两条必读」第 1 条。这里只列**怎么认出来**: +**原因**:线程不被 `fork` 复制,而文件描述符会。于是子进程里命令真的写到了线上, +但应答被父进程的读线程吃掉。详见 [README](README.zh-CN.md#多进程fork-之后子进程不能用继承来的-arm)。 + +**怎么认出来**: -| 症状 | 说明 | -|---|---| -| 命令"超时无应答",但重试"有时又好了" | 命令**真的发出去了**(fd 被继承),应答被**父进程的读线程**吃掉 ⇒ 重试 = **重复下发** | -| `get_state()` 不报错,但读数**永远不动** | 静默返回继承来的**陈旧**值 —— 最隐蔽的一种 | -| 子进程里 `connect()` 抛异常 | 父进程还持有端口(见 README:**父进程必须先 `close()`**) | +| 现象 | 说明 | +| --- | --- | +| 命令"超时无应答",但重试"有时又好了" | 命令真的发出去了,应答被父进程读走 ⇒ 重试 = **重复下发** | +| `get_state()` 不报错,但读数**永远不动** | 静默返回继承来的**陈旧**值——最隐蔽的一种 | +| 子进程里 `connect()` 抛异常 | 父进程还持有端口 ⇒ 父进程必须先 `close()` | | 任何命令立刻抛 `ForkedSessionError` | ✅ 守卫**正常工作**,不是在报错 | -真机验证:子进程里 `movej` / `get_state` / `get_tcp` / `connect` **全部**抛 -`ForkedSessionError`,**一个字节都不下发**,父进程会话不受影响。 +**怎么办**:先 `close()` 父进程的会话释放串口,再 `fork`,然后在子进程里**新建** `Arm`。 --- -## 4. `ERR{0x02,0x03}` 是二义码 +## 4. 两个容易混淆的错误码 -**同一个 `(命令, 码)` 有两个完全不同的来源**: +### `ERR{0x02,0x03}` 是二义码 -- `usb_cmd.c` —— **未使能 / EMERGENCY 锁存**; -- `kin_runner.c` —— **IK 不可达 / 解非法**。 +**同一个 `(命令, 码)` 有两个完全不同的来源**:固件里既表示**未使能 / EMERGENCY 锁存**, +也表示**逆解不可达 / 解非法**。 -⇒ **收到它不能判定"臂没使能"**,现场归因会指错方向。 -判别:看**当前状态**(`state.enabled` / `state.mode`)而不是看码。 -若臂明明使能着,那就是 IK 那一支。 - ---- +**怎么办**:收到它**不能**判定"臂没使能"。看**当前状态**(`state.enabled` / `state.mode`) +而不是看码——臂明明使能着,那就是逆解那一支。 -## 5. 两套 1~6 的码空间极易混淆 +### 两套 1~6 的码空间 | 来源 | 语义 | -|---|---| -| `0x4E` 应答里的 `err` 字段(`CartPlan.err`) | `cart_err_t`:规划本身的结论(无解 / 共线 / 超容量 / 越限…) | -| `RSP_ERR` 的**第二字节** | **门禁原因码**:未使能 `0x03` / 零重力中 `0x04` / `drop_hold` `0x06` | +| --- | --- | +| `0x4E` 应答里的 `err` 字段(`CartPlan.err`) | **规划本身的结论**:无解 / 共线 / 超容量 / 越限 | +| `RSP_ERR` 的第二字节 | **门禁原因码**:未使能 `0x03` / 拖动示教中 `0x04` / `drop_hold` `0x06` | **两者取值都是 1~6,语义毫无关系。** 拿到一个"3"先问它是从哪条路出来的。 -⚠ 附带一条已知的**注释错误**:`usb_cmd.h` 里写三点共线是 `0x03`,**代码是对的** —— -共线是 `err = 2`。 - --- -## 6. 突发 ≥3 条笛卡尔必然丢应答 +## 5. 连发几条笛卡尔命令会丢应答 -**真机 3/3 复现。** 症状:连发几条 `move_l` / `move_path`,其中一条报 -`CartReplyLostError`("结局未知"),或后续应答**整体错位**(这一条的答案被下一条收走)。 +**现象**:连发几条 `move_l` / `move_path`,其中一条报 `CartReplyLostError`("结局未知"), +或后续应答**整体错位**(这一条的答案被下一条收走)。真机 3/3 复现。 -**根因在固件,不在 SDK**:`cart_exec.c` 的 `plan_pending` 是**单个 bool + 单份载荷,不是队列**。 -被取代者发出的 CANCELED 会覆盖彼此 ⇒ 少一条应答就让后续 FIFO 配对整个错位。 +**原因**:固件侧一次只装得下**一条**待执行的笛卡尔规划,不是队列。被取代者的取消应答 +会互相覆盖 ⇒ 少一条应答就让后续配对整体错位。**根因在固件,不在本库。** -**处置**:笛卡尔命令**串行发**,一条等完再发下一条。 -(SDK 已用 `_cart_serial` 把**同一进程内**的调用串起来,所以正常用法碰不到; -跨进程/跨客户端并发时会碰到。) +**怎么办**:笛卡尔命令**串行发**,一条等完再发下一条。 +本库已把**同一进程内**的调用自动串起来,所以正常用法碰不到;跨进程 / 跨客户端并发时会碰到。 --- -## 7. `movej` 返回时还没停稳 +## 6. `move_c()` 圆弧失败 -`movej` 是**"到达判据满足就返回"**,判据是「各轴 |q−目标| < `q_tol` 且 dq 静止连续 `arrive_frames` 帧」, -**它返回的那一刻臂还没停稳**。 +**现象**:抛 `CartesianPlanError`,臂一步没动。 -实测(命令 J6 到 0.7):返回瞬间读到 **0.6884**,之后 5 s 内自己收敛到 **0.6991**,并稳住 20 s 不动。 -差 ~0.012 rad(0.7°),在 `q_tol = 0.03` 之内 —— **是设计口径,不是漂移**。 +| `err` | 原因 | +| --- | --- | +| `2` | **三点共线**(或近乎共线)——圆心跑向无穷远 | +| `1` | 逆解无解。从**完全伸展的 `home` 位姿**(奇异点)出发**必然**如此,这是固件的合理行为,不是缺陷 | +| `3` | 超容量 / 不可达 | -⇒ **"`movej` 返回后立刻读状态"会看到这个残差**。要精确值就等几秒再读,或自己收紧 `q_tol`。 +**怎么办**:换一组不共线的三点;从奇异位姿出发时先离开奇异点再走圆弧。 ---- +⚠ `move_c(start, via, goal)` 的 **`start` 必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)。 +它不是"从哪儿走"的自由参数,是**校验收到的**——拿运动中的 TCP 当起点会被拒。 +⚠ `via` 的**姿态被忽略**,只有位置参与定圆。 -## 8. `move_c()` 圆弧失败 +--- -| 症状 | 成因 | -|---|---| -| `CartesianPlanError{err=2}` | **三点共线**(或近乎共线)—— 圆心跑向无穷远 | -| `CartesianPlanError{err=1}` | IK 无解。⚠ 从**完全伸展的 `home` 位姿**(奇异点)出发**必然** `err=1`,这是固件 IK 的**合理行为**,不是缺陷 | -| `CartesianPlanError{err=3}` | 超容量 / 不可达 | +## 7. 笛卡尔精度不是一个固定数字 -⚠ `move_c(start, via, goal)` 的 **`start` 必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)。 -它不是"从哪儿走"的自由参数,是**校验收到的** —— 拿运动中的 TCP 当起点会被拒。 +**残余终点误差取决于【距离 × 速度 × 负载姿态】三者**,不是设备的固定指标; +负载变化会显著改变它。 -⚠ `via` 的**姿态被忽略**,只有位置参与定圆。 +**怎么办**:任何精度比较必须**同姿态、同约定、同负载**,否则你量到的是"姿态差 / 负载差", +不是"精度差"。 --- -## 9. 笛卡尔精度不是一个数 +## 8. 笛卡尔恒定偏移——先查 `payload_mass` + +**现象**:笛卡尔位姿有约 8 mm 的**恒定偏置**,与协议、与规划器都无关。 -**残余终点误差取决于【距离 × 速度 × 负载姿态】三者**,不是设备的固定指标。 -负载变化会显著改变残余误差(残余误差**与负载相关**)。 +**原因**:设备里残留了一个陈旧的 `payload_mass = 1.0`(出厂默认应为 `0.0`)。 +固件的动力学补偿按这个质量算,末端就系统性偏一点。 -⇒ 任何精度比较必须**同姿态、同约定、同负载**,否则量到的是"姿态差 / 负载差",不是"精度差"。 +**怎么办**:换负载后务必调 `set_payload()`。出现"说不清的恒定偏置"时, +先用 `get_ff_scalar(4)` 读回 `payload_mass` 核对,再怀疑别的。 --- -## 10. `last_reset_reason` 是 `None`(多数时候正确) +## 9. `movej` 返回时还没停稳 -开机 banner **只在真 MCU 复位后发一次**(固件侧 `banner_sent` 是 static), -而 `CMD_RESET` **不会**让它重发。 +**现象**:`movej` 一返回就读状态,各轴和目标还差一点。 -⇒ **常规用法拿到 `None` 是正确行为**,不是"签名没解析出来"。 -它只在**真复位之后尽快连上**这一种情形下才有值(`"normal"` / `"iwdg-rst"`)。 +**原因**:`movej` 是"**到达判据满足就返回**",判据是「各轴 `|q − 目标| < q_tol` +且速度静止连续 `arrive_frames` 帧」。它返回的那一刻臂还没停稳。 + +实测(J6 到 0.7):返回瞬间读到 **0.6884**,5 s 内自己收敛到 **0.6991**,并稳住 20 s 不动。 +差约 0.012 rad(0.7°),在 `q_tol = 0.03` 之内——**是设计口径,不是漂移**。 -⚠ 顺带澄清一个近亲:**`reset()` 是软件状态复位,不是 MCU 重启** -(它走到 `ctrl_reset()`,全树没有 `NVIC_SystemReset`)—— 所以它**不会**触发 USB 重枚举, -**同一个 `Arm` 对象事后照常可用**,banner 也不会重发。 +**怎么办**:要精确值就等几秒再读,或自己收紧 `q_tol`。 --- -## 11. `kin_bench` 的五个计数器全 0 —— 静默 0 +## 10. `last_reset_reason` 是 `None` -`arm.diag.kin_bench()` 的链路诊断计数(`crc_errors` / `reply_dropped` / `can_tx_fail` …) -显示**全 0** 时,**未必是"链路干净"**:固件的回执是**连续两帧**,而本包只收一帧。 -症状是**静默的** —— 不会报错,只会给你一个看起来很健康的 0。 +**这是正确行为,不是解析失败。** 开机签名**只在真 MCU 复位后发一次**, +而 `reset()` 不会让它重发。常规用法拿到 `None` 属于正常。 -⇒ 用 `kin_bench` 做链路体检前,先确认它**真的读到了**(看 `Msg.hz` / `Msg.timestamp`, -两者为 `0.0` 说明这一类帧从没到过)。 +它只在**真复位之后尽快连上**这一种情形下才有值(`"normal"` / `"iwdg-rst"`)。 -⚠ 计数里 `crc` 是活的且精确;`can_tx_fail` 量级可能异常大(固件自述的累计值,口径未核)。 +⚠ 顺带澄清一个近亲:**`reset()` 是软件状态复位,不是 MCU 重启**—— +它**不会**触发 USB 重新枚举,**同一个 `Arm` 对象事后照常可用**,签名也不会重发。 --- -## 12. 笛卡尔恒定偏移 —— 先查 payload_mass +## 11. `kin_bench` 的计数器全是 0 + +**现象**:`arm.diag.kin_bench()` 的链路诊断计数(`crc_errors` / `reply_dropped` / +`can_tx_fail` …)显示全 0。 -现场遇到过一例:笛卡尔位姿有 **~8 mm 恒定偏置**,与 SDK、与固件规划器都无关。 +**原因**:**未必是"链路干净"**。计数全 0 可能是"根本没读到"——这个失败是**静默的**, +不报错,只给你一个看起来很健康的 0。 -**成因**:设备里残留了一个陈旧的 `payload_mass = 1.0`(出厂默认应为 `0.0`)。 -固件的动力学补偿按这个质量算,于是末端系统性偏一点。 +**怎么办**:用它做链路体检前,先确认真的读到了帧——看 `Msg.hz` / `Msg.timestamp`, +两者都是 `0.0` 说明这一类帧从没到过。 -⇒ **换负载后务必 `set_payload()`**;出现"说不清的恒定偏置"时,先用 `get_ff_scalar(4)` -读回 `payload_mass` 核对,再怀疑别的。 +⚠ 计数里 `crc` 是活的且精确;`can_tx_fail` 的量级可能异常大(固件自述的累计值,口径未核)。 --- -## 13. `zero_g` 保活期与异步退出 +## 12. `zero_g` 保活期与异步退出 + +| 现象 | 原因 | 怎么办 | +| --- | --- | --- | +| 保活期内动作命令被拒 | 固件看门狗语义:保活期只放行查询类 | 查询不受限;**急停 / 失能例外**,随时可打进来 | +| `period=0.5` 被本地拒 | 保活必须**小于 0.10 s**。固件 0.10 s 不重发就掉出 fail-soft | `period` 取 `[0.005, 0.10)`,默认 `0.04` 即可 | +| `zero_g_stop()` 返回后紧接着的动作命令被拒 | **退出是异步的**:返回后固件侧还要一点时间收尾 | 稍等一下再发动作命令 | -- **保活期内其它动作命令会被拒**(固件看门狗语义),**查询类不受限**, - **急停 / 失能例外**(它们要能随时打进来)。 -- **保活由 SDK 后台线程按 `period` 自动重发**(默认 `0.04 s`)。 - 固件 `0x06` 自带 `watchdog_kick`,**0.10 s 不重发就掉出 fail-soft** - ⇒ `period` 必须 ∈ `[0.005, 0.10)`,`period=0.5` 会被本地拒。 -- **退出是异步的**:`zero_g_stop()` 返回后,固件侧还要一点时间才真的收尾。 - 紧接着发动作命令可能被拒 —— 保守做法是稍等再动。 -- 保活若因**写失败**中断,退出时**抛异常**而不是静默。 +保活由后台线程自动重发,若因**写失败**中断,退出时会**抛异常**而不是静默。 +状态可查只读属性 `zero_g_active` / `zero_g_error`。 --- -## 14. `move_js` / `send_mit` 的看门狗 fail-soft +## 13. `move_js` / `send_mit` 之后臂慢慢塌下去 -这三个是**连续伺服/透传**入口,**绕过运动规划**,且**调用方必须自己保活**: +**原因**:这几个是**连续伺服 / 透传**入口,**绕过运动规划**,且**调用方必须自己保活**。 +**需 ≥10 Hz 重发**;0.1 s 看门狗超时后固件进 fail-soft(降刚度 + τ=0), +臂在重力下缓慢塌下去——实测塌陷量与"降刚度 0.6× + τ=0"的估算吻合。 -- **需 ≥10 Hz 重发**。0.1 s 命令看门狗超时后,固件进 fail-soft(降刚度 + τ=0), - 臂在重力下缓慢塌下去 —— 实测塌陷量与"降刚度 0.6× + τ=0"的估算吻合。 -- `send_mit` / `send_mit_all` 的数组必须是**有限数**;`NaN` 会被本地/固件拒。 -- `move_js` 的 `dq` 是**速度参考**,不是限位。 +**怎么办**:按 ≥10 Hz 的节奏持续重发,直到不再需要这个动作。 -⚠ 这几个入口在真机上**未经完整验证**(§17 未验证清单)。 +⚠ 数组必须**长度 = `n`**(本地校验)且**是有限数** —— `NaN` / `Inf` 会被固件整帧拒收 +(`ERR{cmd,0x02}`),`send_mit` / `send_mit_all` / `move_js` 都做这个校验。 +⚠ `move_js` 的 `dq` 是**速度参考**,不是限位。 --- -## 15. `enter_dfu()` 之后 +## 14. `enter_dfu()` 之后 -- **不能立刻刷**:`ACK{0x15}` 只表示"已登记",设备要**重枚举**成 `0483:DF11`。 - 立刻跑 pyocd 会失败 —— 隔十几秒重跑就成功。 -- 判定"设备真的消失了"要用**读/写抛错**,**不是** `is_open`,**更不是**"读到 0 字节"。 +- **不能立刻刷**:`ACK{0x15}` 只表示"已登记",设备要**重新枚举**成 `0483:DF11`。 + 立刻跑 pyocd 会失败,隔十几秒重跑就成功。 +- 判定"设备真的消失了"要用**读 / 写抛错**,**不是** `is_open`,**更不是**"读到 0 字节"。 - 成功返回后**本 `Arm` 不可再用**:所有入口抛 `ArmIsInDfuError`(`close()` 例外)。 烧完固件**新建一个 `Arm`**。 - 使能中调用会被**本地拒绝**(跳转会停 TIM3 ⇒ 电机 100 ms 松开、有负载则下垂)。 --- -## 16. 参数写入的两个反直觉行为 +## 15. 参数写入的两个反直觉行为 -**`set_speed(percent)` 是非线性的,且有整数陷阱。** -100 → 50 只慢 **1.48×**(有固定开销),不是 2×。 -入参必须是 **0..100 的 `int`**:`bool` 与越界值会被拒。 +**`set_speed(percent)` 收的是整数百分比,不是倍率。** +`set_speed(1)` 是 **1% 速度** —— 按 0..1 的思维调用会得到"臂爬行"。 +它还是**全局且持续**的(生效到 reset 语义的调用为止),与 `movej(speed=0..1)` 的单条 +轨迹倍率不是一回事。入参必须是 `0..100` 的 `int`,`bool` 与越界值在本地就会被拒。 **`set_joint_limits()` 不幂等。** -写回**当前值**会被固件判成"放宽请求"并拒(`ERR[23,2]`)—— 固件**只许收窄**。 +固件**只许收窄**,写回**当前值**会被判成"放宽请求"并拒(`ERR[23,2]`)。 ⇒ 别用它做"读出来再写回去"的回环校验。 -## 明确**未验证**的部分 +--- + +## 16. 尚未验证的部分 -不要假装这些验证过: +不要假设下面这些验证过: -- `enter_dfu()` —— **唯一终端态操作**,恢复要重新烧固件 -- `save_params()` 的持久化(会写 flash) -- `reset_factory()`(会擦掉调好的参数与 flash) -- `activate()` —— 需厂商签发 `mac`;只验证了 `license()` 读路径 -- `move_c()` 的**成功**路径 —— 未构造出稳定成功的圆弧 -- `move_js` / `send_mit` / `send_mit_all` —— 未上真机 -- `set_joint_limits()` 的**有效收窄**(只验证了"写回原值被拒") -- **Windows 平台一次都没跑过** +- `enter_dfu()` —— 唯一终端态操作,恢复要重新烧固件; +- `save_params()` 的持久化(会写 flash); +- `reset_factory()`(会擦掉调好的参数); +- `move_c()` 的**成功**路径 —— 未构造出稳定成功的圆弧; +- `move_js` / `send_mit` / `send_mit_all` —— 未上真机; +- `set_joint_limits()` 的**有效收窄**(只验证了"写回原值被拒"); +- **失能态下 `capture()` 是否恒录到 0 拍** —— 只在本仓库的现场笔记里出现过,代码里没有 + 对应的判据或测试,未复核; +- **Windows 平台** —— 尚未验证。 ### ⚠ 不可逆命令:不要在标定过的臂上执行 -这四条都会**覆盖/抹掉该台设备逐台辨识的动力学模型**(整扇区擦除 + 写当前 RAM), -**没有撤销**: +下面四条都会**覆盖或抹掉该台设备逐台辨识的动力学模型**,**没有撤销**: | 命令 | 入口 | -|---|---| +| --- | --- | | `0x25` | `save_params()` | | `0x32` | `arm.model.commit()` | | `0x36` | `arm.params.reset_factory()` | @@ -269,8 +278,8 @@ TransportError: 串口打不开 / 找不到 CDC 设备 **唯一解锁方式:在一台没有标定价值的板子上做。** -⚠ 另有 **`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 c346223..16d9097 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -1,83 +1,72 @@ -# litearm-python Developer Guide & API Reference +# litearm-python developer guide -Python SDK for the LiteArm robotic arm — **talks straight to the -`litearm-stm32` firmware** (USB CDC serial). +Python SDK for the LiteArm robotic arm, talking straight to the firmware over USB serial. -This SDK is a **thin protocol binding**: on the PC side it only encodes and -decodes frames, sends commands, and decides whether a move has arrived. -**Trajectory planning, kinematics and dynamics all live in the firmware** -(B2 S-curve + B3 kinematics + B4 dynamics + B1 control law); the PC side -**does none of it** — otherwise the same contract gets written twice, and -fixing one place while missing the other silently becomes two sets of -semantics. +Planning, kinematics and dynamics live in the firmware; the PC side only encodes frames, sends +commands and decides arrival. A pose is 6 numbers (list or tuple), and `pyserial` is the only +dependency. -**Zero dependencies** apart from `pyserial`. Poses are **plain Python lists**; -no numpy needed. +## Contents -## Table of Contents - -1. [Requirements & Installation](#1-requirements--installation) -2. [Quick Start](#2-quick-start) -3. [Connection Management](#3-connection-management) -4. [Reading State — The `Msg` Return Envelope](#4-reading-state--the-msg-return-envelope) -5. [API Reference](#5-api-reference) +1. [Requirements and install](#1-requirements-and-install) +2. [Quick start](#2-quick-start) +3. [Connection management](#3-connection-management) +4. [The read envelope `Msg`](#4-the-read-envelope-msg) +5. [API reference](#5-api-reference) 6. [Exceptions](#6-exceptions) -7. [Architecture — One Reader Thread](#7-architecture--one-reader-thread) -8. [Command Line](#8-command-line) -9. [Testing](#9-testing) -10. [Safety Notes](#10-safety-notes) +7. [Things to watch out for](#7-things-to-watch-out-for) +8. [Architecture](#8-architecture) +9. [Command line](#9-command-line) +10. [Testing](#10-testing) --- -## 1. Requirements & Installation +## 1. Requirements and install -- Python **>= 3.9** +- Python **3.9 or later** - `pyserial >= 3.4` (the only dependency) -- Firmware **`Litearm1.5.0+`**, convention `Litearm-{7J|1J}` +- Firmware **`Litearm1.5.0` or later**, version string `Litearm-{7J|1J}` +- Linux needs serial permissions: `sudo usermod -aG dialout $USER`, then log in again ```bash -pip install -e . - -# development (includes pytest) -pip install -e ".[dev]" +pip install -e . # install +pip install -e ".[dev]" # development (includes pytest) ``` -> ⚠ When `pip` and `python` point at different interpreters, always use -> `python -m pip`, so the package lands in the interpreter you actually run. +When `pip` and `python` point at different interpreters, use `python -m pip`. --- -## 2. Quick Start +## 2. Quick start ```python import litearm as pa -arm = pa.Arm().connect() # auto-find CDC + check the firmware version convention -arm.enable() # must be enabled before motion +arm = pa.Arm().connect() # find the port, check the firmware version +arm.enable() # the arm must be enabled before it moves arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) -print(arm.get_tcp().value) # read values through .value (return envelope since 2.0, see §4) +print(arm.get_tcp().value) # read the payload via .value, see §4 arm.close() ``` -`Arm().connect()` is the **only entry point**. By the time `connect()` returns -the handshake is already done, so `arm.n` / `arm.firmware` are guaranteed -usable. +When `connect()` returns, the handshake is done and `arm.n` / `arm.firmware` are available. -Every session carries **one background reader thread**, so every `Arm` needs -`close()` — `with` saves you the trouble: +Every session carries a read thread, so **you must `close()` it**; `with` does that for you: ```python with pa.Arm().connect() as arm: print(arm.get_state().value.q) -# leaving the with block is close() ``` -> ⚠ **After `fork` a child process must not inherit this session**, see -> [README](../README.md#1-multiprocessing--fork-a-child-must-not-use-an-inherited-arm). +Every `arm` in the sections below refers to this connected session object; the snippets show +only the steps that section is about. + +A child process cannot inherit the session, see +[README](../README.md#multiprocessing-a-forked-child-must-not-use-an-inherited-arm). --- -## 3. Connection Management +## 3. Connection management ```python Arm(port=None, *, transport_factory=None, min_firmware=MIN_FW, @@ -86,104 +75,96 @@ Arm(port=None, *, transport_factory=None, min_firmware=MIN_FW, connect(port=None) -> Arm close() -> None disconnect() -> None # alias for close() -reconnect(port=None) -> Arm # equals close() then connect() -__enter__() / __exit__(*exc) # with usage; __exit__ returns False (does not swallow exceptions) -__del__() # GC fallback, equivalent to close() +reconnect(port=None) -> Arm # close() then connect() +__enter__() # with support +__exit__(*exc) # returns False, never swallows +__del__() # last-resort cleanup ``` -| Parameter | Default | Meaning | -| ------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `port` | `None` | serial path; `None` ⇒ auto-discover `1d50:606f`. Priority: the `port` argument > `LITEARM_PORT` > auto-discovery | -| `transport_factory` | `None` | inject a transport (for tests). ⚠ you must still pass a placeholder `port`, because `connect()` goes through `find_cdc_port()` first | -| `min_firmware` | `(1, 5, 0)` | lower bound of the version gate | -| `q_tol` | `0.03` | arrival criterion: joint-angle tolerance (rad) | -| `dq_tol` | `0.10` | arrival criterion: joint-velocity tolerance | -| `arrive_frames` | `3` | arrival criterion: consecutive frames that must hold | -| `move_timeout` | `15.0` | motion timeout (s) | - -| Method | Notes | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `connect()` | **idempotent** (calling it again for the same target returns `self`). ⚠ when the handshake **write** fails it can leave a half-open session, and reconnecting then **silently reports success** | -| `close()` | idempotent. Stops the reader thread → closes the transport. Afterwards every other entry point raises `NotConnectedError` (`close()` itself excepted) | -| `reconnect()` | **swaps the session**: the reader thread restarts and `Msg.hz` statistics reset | +### Constructor arguments -Module constants: `litearm.MIN_FW`, `litearm.FIRMWARE_PREFIX`. +| Argument | Default | Meaning | +| --- | --- | --- | +| `port` | `None` | Serial port path. `None` auto-discovers (VID:PID `1d50:606f`). The SDK reads **no** environment variables; `LITEARM_PORT` is used only by `examples/_common.py` | +| `transport_factory` | `None` | Inject a transport (tests), needs a placeholder `port` too | +| `min_firmware` | `(1, 5, 0)` | Lower bound of the version gate | +| `q_tol` | `0.03` | Arrival criterion: joint angle tolerance (rad) | +| `dq_tol` | `0.10` | Arrival criterion: joint velocity tolerance | +| `arrive_frames` | `3` | Arrival criterion: consecutive frames required | +| `move_timeout` | `15.0` | Motion timeout (s) | -### Firmware Version Convention +### Session methods -`firmware` returns `Litearm-{7J|1J}` (e.g. `Litearm1.8.0-7J`). +| Method | Caveat | +| --- | --- | +| `connect()` | Idempotent. Any failing step **closes the link before raising**, so no half-open session is left behind | +| `close()` | Idempotent. Stops the read thread, then closes the transport. Every entry point afterwards raises `NotConnectedError` | +| `reconnect()` | Starts a new session: the read thread restarts and `Msg.hz` resets | -| Firmware | Result | -| --------------------------- | -------------------------------- | -| `Litearm1.5.x-*` and above | ✅ accepted | -| `Litearm1.4.x-*` or earlier | ❌ `FirmwareMismatchError` | -| `A1.x-*-USB` (old naming) | ❌ does not match the convention | +Module constants: `litearm.MIN_FW`, `litearm.FIRMWARE_PREFIX`. -> Status-frame parsing is **compatible with both** layouts, `4+21N` (≤1.4.x) and -> `6+21N` (≥1.5.0) — that compatibility branch is only used for offline / -> historical frame parsing (e.g. analysing a capture); `connect()` never -> reaches it. +### Firmware version convention ---- +`firmware` returns `Litearm-{7J|1J}` (for example `Litearm1.8.0-7J`). + +| Firmware | Result | +| --- | --- | +| `Litearm1.5.x-*` or later | Accepted | +| `Litearm1.4.x-*` or earlier | `FirmwareMismatchError` | +| Anything else | `FirmwareMismatchError` | -## 4. Reading State — The `Msg` Return Envelope +--- -### Which Entries Return `Msg` +## 4. The read envelope `Msg` -**The 11 "read one frame" getters** return `Msg[T]` (a breaking change since -2.0): +These 11 "read one frame" getters return `Msg[T]`: -| # | Entry | Frame | -| -- | ----------------------------- | ------------------ | -| 1 | `get_state()` | `RSP_STATUS` | -| 2 | `get_status_now()` | `RSP_STATUS` | -| 3 | `get_tcp()` | `RSP_TCP` | -| 4 | `get_ff_vec(item)` | `RSP_FF_VEC` | -| 5 | `get_ff_scalar(item, sub)` | `RSP_FF_SCALAR` | -| 6 | `params.get_joint_param(idx)` | `RSP_JOINT_PARAM` | -| 7 | `model.get_body(idx)` | `RSP_MODEL_PARAM` | -| 8 | `model.get_jm()` | `RSP_MODEL_JM` | -| 9 | `model.status()` | `RSP_MODEL_STATUS` | -| 10 | `model.get_gravity(q)` | `RSP_GRAVITY` | -| 11 | `diag.kin_bench()` | `RSP_KIN_BENCH` | +| # | Entry point | Frame | +| --- | --- | --- | +| 1 | `get_state()` | `RSP_STATUS` | +| 2 | `get_status_now()` | `RSP_STATUS` | +| 3 | `get_tcp()` | `RSP_TCP` | +| 4 | `get_ff_vec(item)` | `RSP_FF_VEC` | +| 5 | `get_ff_scalar(item, sub)` | `RSP_FF_SCALAR` | +| 6 | `params.get_joint_param(idx)` | `RSP_JOINT_PARAM` | +| 7 | `model.get_body(idx)` | `RSP_MODEL_PARAM` | +| 8 | `model.get_jm()` | `RSP_MODEL_JM` | +| 9 | `model.status()` | `RSP_MODEL_STATUS` | +| 10 | `model.get_gravity(q)` | `RSP_GRAVITY` | +| 11 | `diag.kin_bench()` | `RSP_KIN_BENCH` | ```python @dataclass(frozen=True) class Msg(Generic[T]): - value: T # raw return value (None on entries that cannot get a frame) - hz: float # average arrival rate of this frame class in this session - timestamp: float # local time.monotonic() of the latest frame (0.0 if never received) + value: T # the payload (None if no frame could be obtained) + hz: float # average arrival rate of this frame kind in this session + timestamp: float # local time.monotonic() of the latest frame (0.0 if never seen) ``` -**How `hz` is defined (fixed, not estimated)**: **the average rate of this -frame class since its first arrival in this session**, -`(frames arrived − 1) / (time of the latest frame − time of the first frame)`; -**when fewer than 2 samples exist it is `0.0`**. - -- A passive continuous stream (`RSP_STATUS`, 100 Hz): converges to ~100 after - two or three frames. An idle link does **not** make it decay. -- The 5 request/response ones (`get_joint_param` / `get_body` / `get_jm` / - `get_gravity` / `kin_bench`): one call yields exactly one frame ⇒ **the first - call necessarily has `hz == 0.0`**, and from the second call on it equals - **your own polling rate**. -- After `reconnect()` the statistics reset. - -⇒ **`hz == 0.0` together with `timestamp == 0.0` is the criterion for "this -frame class has never arrived"**, not for "the link is slow". - -### Which Entries Do **Not** Return `Msg` - -- `move_*` and `home()` — they return an "action result" (`RobotState` / - `CartPlan`), not "one frame read" -- `n` / `firmware` / `last_reset_reason` / `zero_g_active` — there is no frame - at all -- `ik()` — a **computation** request -- `license()` — a **request/response device-identity record** (no - firmware-initiated traffic, so `hz` only measures your own polling rate) -- the two **derived** getters: `get_ff_mask()` is still a bare `int` (it is the - scalar projection of `get_ff_scalar(9,0)`); `params.all_joint_params()` is - still a `list[JointParam]` (it is an aggregate of N round trips, and a single - `hz` cannot describe N frames) +`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. +- `reconnect()` resets it. + +**`hz == 0.0` and `timestamp == 0.0` mean no frame of this kind has ever arrived**, not a slow +link. + +### Entry points that do not return `Msg` + +- `move_*` and `home()` — they return an action result (`RobotState` / `CartPlan`) +- `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]` ### `RobotState` @@ -192,42 +173,55 @@ get_state(refresh=False, timeout=0.5) -> Msg[Optional[RobotState]] get_status_now(timeout=0.5) -> Msg[RobotState] ``` -| Field | Meaning | -| ---------------------- | --------------------------------------------------------------------------------------- | -| `mode` / `mode_name` | current mode | -| `flags` / `flag_names` | raw flag bits and their names | -| `seq` | status-frame arrival sequence number — **tells you whether the stream is still moving** | -| `joints` | `list[JointState]` | -| `joint_fault` | firmware G7 per-axis dropout bitmap (since 1.5.0; always 0 in the old layout) | +| Field | Meaning | +| --- | --- | +| `mode` / `mode_name` | Current mode | +| `flags` / `flag_names` | Flag bits and their names | +| `seq` | Arrival sequence number, for telling whether the stream is still moving | +| `joints` | `list[JointState]` | +| `joint_fault` | Per-axis drop-out bitmap | -Derived properties: `n`, `enabled` (flags bit9), `cart_busy` (flags bit10), -`q`, `dq`, `tau`, `fault_axes`, `faulted`, `fault_detail`, -`drop_hold_inferred`. +Derived properties: `n`, `enabled`, `cart_busy`, `q`, `dq`, `tau`, `fault_axes`, `faulted`, +`fault_detail`, `drop_hold_inferred`. `JointState`: `q`, `dq`, `tau`, `t_mos`, `t_coil`, `err`. -> ⚠ `get_status_now(timeout=0.0)` is **not** "probe a frame without blocking"; -> it means **"return the current cache immediately"**. +`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 +cached value immediately**. It raises `MotionTimeoutError` if this session has not received a +single state frame yet. --- -## 5. API Reference +## 5. API reference -A pose = a plain Python list of **6 numbers**: position 3 + RPY 3. +A pose is 6 numbers: 3 of position (m) plus 3 of orientation (rad, RPY). Lists and tuples both +work. ```python -pose = [px, py, pz, rx, ry, rz] +pose = [0.30, 0.0, 0.35, 3.1416, 0, 0] + +arm.move_p(pose) +``` + +A "3 position values + 3×3 rotation matrix" pair is accepted too: -# all four spellings are accepted after normalisation by as_pose() -arm.move_p([0.30, 0.0, 0.35, 3.1416, 0, 0]) +```python +arm.move_p(([0.30, 0.0, 0.35], [[1, 0, 0], [0, 1, 0], [0, 0, 1]])) ``` -> ⚠ **Return shapes are not uniform**: `get_state()` / `get_status_now()` / -> `get_tcp()` give a `Msg` envelope; `movej` / `movej_sync` / `move_p` / -> `home` give `RobotState`; `move_l` / `move_c` / `move_path` give `CartPlan`; -> `ik()` gives `list[float]`. **Every entry's docstring states its own shape.** +Return types are not uniform; each entry point's docstring states which one it uses: + +| Return type | Entry points | +| --- | --- | +| `Msg[...]` | The 11 "read one frame" getters above | +| `RobotState` | `movej` `movej_sync` `move_p` `home` | +| `CartPlan` | `move_l` `move_c` `move_path` | +| `list[float]` | `ik` | -### 5.1 Life / Safety +### 5.1 Life / safety ```python enable(attempts=12) @@ -239,17 +233,17 @@ set_motion_mode(mode) park() ``` -| Method | Notes | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `enable(attempts=12)` | enables all joints. `attempts` is the retry count, and **retrying is whitelisted**: only `(0x10, 0x03)` is retried; resending other codes is useless | -| `disable()` | cuts the position loop. ⚠ once enable is cut, the arm is no longer held up | -| `emergency_stop()` | single frame, one-way, reads no state — **the only entry point with no preconditions** | -| `reset()` | clears faults + re-anchors the control loop. ⚠ it is a **software state reset, not an MCU reboot** (no USB re-enumeration, the same object is still usable) | -| `clear_faults()` | clears RAM fault bits only, **does not write flash** | -| `set_motion_mode(mode)` | **the firmware only accepts `0`**; any other value raises `InvalidCommandError` locally (fail-closed) | -| `park()` | equivalent to `set_motion_mode(0)` | +| Method | Caveat | +| --- | --- | +| `enable(attempts=12)` | Enables all joints. Retries are whitelisted: **only `(0x10, 0x03)` is retried** | +| `disable()` | Cuts the position loop. The arm is no longer held | +| `emergency_stop()` | Single frame, one-way, does not read state — the only entry point with no preconditions | +| `reset()` | A software state reset, **not an MCU reboot**; the same object stays usable | +| `clear_faults()` | Clears RAM fault bits only, does not write flash | +| `set_motion_mode(mode)` | The firmware only accepts `0`; anything else raises `InvalidCommandError` locally | +| `park()` | Equivalent to `set_motion_mode(0)` | -### 5.2 Joint-Space Motion +### 5.2 Joint motion ```python movej(q, speed=1.0) -> RobotState @@ -258,17 +252,16 @@ move_js(q, dq=None, tau_ff=None) -> None home(*, timeout=None) -> RobotState ``` -| Method | Notes | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `movej(q, speed=1.0)` | **single-shot**: the firmware plans the S-curve, completes it on its own, and holds still after arriving (no PC frame-by-frame keep-alive needed). `speed` ∈ `0..1`. ⚠ **joint limits are not checked** — an out-of-limit target is `clampf`-ed by the firmware and then **runs the full travel anyway**. Compare against the limits yourself before commanding motion | -| `movej_sync(q, speed=1.0)` | synchronised PTP: all axes arrive together | -| `move_js(q, dq=None, tau_ff=None)` | low-level joint stream, **bypasses the planner**. ⚠ `dq` is a **velocity reference, not a limit**; **the caller must resend at ≥10 Hz**, or the 0.1 s watchdog fail-softs | -| `home(*, timeout=None)` | firmware `CMD_HOME 0x2A`. ⚠ `timeout` is **keyword-only**; the firmware hard-codes the speed to **0.10 and does not accept speed**. Unlike `movej`, the firmware **explicitly allows** starting `home` from a pose past the soft limits or against an end stop | +| Method | Caveat | +| --- | --- | +| `movej(q, speed=1.0)` | Single shot: the firmware plans an S-curve, completes it and holds position. `speed` ∈ `0..1`. **Joint limits are not checked** — an out-of-range target is clamped and still travels the full stroke | +| `movej_sync(q, speed=1.0)` | Synchronised point to point, all axes arrive together | +| `move_js(q, dq=None, tau_ff=None)` | Low-level joint stream, bypasses planning. `dq` is a velocity reference, not a limit; the caller must resend at ≥10 Hz | +| `home(*, timeout=None)` | Go home. **Requires `enable()` first**, otherwise `ERR{0x2A,0x03}`. `timeout` is keyword-only; the firmware hard-codes the speed at 0.10 and rejects `speed`. The firmware **does** allow homing from a pose beyond the soft limits or against an end stop | -> ⚠ `home(speed=0.3)` raises a `TypeError`. Write `arm.home()` or -> `arm.home(timeout=30.0)`. +`home(speed=0.3)` raises `TypeError`; write `arm.home()` or `arm.home(timeout=30.0)`. -### 5.3 Cartesian (Firmware-Planned) +### 5.3 Cartesian motion ```python move_p(pose, speed=1.0, pos_tol=0.006, rpy_tol=0.03) -> RobotState @@ -279,84 +272,77 @@ poll_cart() -> Optional[CartPlan] set_speed(percent) ``` -**Planning is entirely in the firmware**: the PC only sends points and receives -the `0x4E` result frame. How the three path entry points divide the work: +All planning happens in the firmware; the PC sends points and receives a `0x4E` result frame. -| Method | What the tool tip travels along | -| -------------------------- | --------------------------------------------------------------------------------------------- | -| `move_p(pose)` | **joint-space** interpolation (point-to-point, **not** a straight line) | -| `move_l(pose)` | a **straight line** (position lerp + orientation slerp) | -| `move_c(start, via, goal)` | an **arc** (three points define the circle; `via`'s orientation is ignored) | -| `move_path(poses)` | through the waypoints in order (**sharp corners**, the protocol has no corner-rounding field) | +| Method | What the tool travels along | +| --- | --- | +| `move_p(pose)` | Joint-space interpolation, point to point, **not a straight line** | +| `move_l(pose)` | A straight line (linear position + spherical orientation interpolation) | +| `move_c(start, via, goal)` | An arc (three points define the circle; the orientation of `via` is ignored) | +| `move_path(poses)` | Visits several waypoints in turn, **sharp corners** | -| Method | Notes | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `move_p` | ⚠ **accepts a single pose only**; passing a sequence raises `InvalidCommandError`. Arrival is judged by the TCP tolerance | -| `move_l` / `move_c` / `move_path` | return a `CartPlan`. With `wait=False` they do not block; check progress with `poll_cart()` | -| `move_c` | ⚠ `start` **must match the measured TCP at call time** (tolerance 6 mm / 0.03 rad) — it is a validated value, not a free parameter | -| `poll_cart()` | reads only the collector's unclaimed queue, **never touches the link** | -| `set_speed(percent)` | global speed override. ⚠ **non-linear** (100→50 is only 1.48× slower), and the argument must be an **`int`** in `0..100` | +| Method | Caveat | +| --- | --- | +| `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`. With `wait=False` they do not block; poll with `poll_cart()` | +| `move_c` | `start` must match the measured TCP at call time (tolerance 6 mm / 0.03 rad); write `arm.get_tcp().value` | +| `poll_cart()` | Reads only the collector's unclaimed queue, does not touch the link | +| `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)` | -**Known downgrades (relative to PC-side planning, deliberate since 2.0)**: -**no corner rounding**, **no preview before sending** (the firmware has no -dry-run; the `0x4E` only comes back once it has been sent), **PC-side speed -pre-check removed** (the criterion exists in exactly one place, the firmware). +Capability boundaries: no corner blending, no pre-flight preview, and the speed pre-check exists +only inside the firmware. -⚠ The three failure shapes (all raise `CartesianPlanError`, and **the whole -plan is rejected, the arm does not move a single step**): `err=1` IK has no -solution / `err=2` three collinear points / `err=3` over capacity, unreachable. +Failures raise `CartesianPlanError` and reject the whole move — the arm does not budge: +`err=1` no IK solution / `err=2` three collinear points / `err=3` over capacity, unreachable. -⚠ **`movel` / `movec` / `movep` were renamed** to `move_l` / `move_c` / -`move_p`; the old names do not exist. - -### 5.4 Pose / Kinematics +### 5.4 Pose / kinematics ```python get_tcp(timeout=0.6) -> Msg[Optional[tuple]] ik(pose, q_seed=None, timeout=3.0) -> list[float] ``` -| Method | Notes | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `get_tcp()` | current TCP pose (firmware FK), **6 numbers** (not a rotation matrix) | -| `ik(pose, q_seed=None)` | inverse kinematics. ⚠ it may return **another, equally valid branch** ⇒ being far from the seed is **not necessarily** an error | +| Method | Caveat | +| --- | --- | +| `get_tcp()` | The current tool pose (firmware forward kinematics), 6 numbers | +| `ik(pose, q_seed=None)` | Inverse kinematics. It may return another equally valid branch, so a result far from `q_seed` is not necessarily an error | -> ⚠ **There is no `fk(q)`** — the PC carries no kinematics model, so FK has -> only one route: "the current feedback" (`get_tcp()`). +There is no `fk(q)`. The PC side carries no kinematic model, so forward kinematics is only +`get_tcp()`. -### 5.5 Feed-Forward / Dynamics Tuning +### 5.5 Feed-forward / dynamics tuning ```python set_ff_mask(mask) ff_preset(preset) # 0 / 1 / 2 -set_ff_vec(item, values) # values length must = n +set_ff_vec(item, values) # len(values) must equal n set_ff_scalar(item, sub, value) get_ff_vec(item, timeout=1.0) -> Msg[list[float]] get_ff_scalar(item, sub=0, timeout=1.0) -> Msg[float] -get_ff_mask(timeout=1.0) -> int # ⚠ bare int, not Msg +get_ff_mask(timeout=1.0) -> int # a bare int, not a Msg set_gravity_scale(gs) # length = n set_inertia_scale(isc) # length = n set_payload(mass, com=(0.0, 0.0, 0.0)) set_gravity_vector(g) # length 3 ``` -⚠ **`get_ff_mask()` is a bare `int`** (it is the scalar projection of -`get_ff_scalar(9, 0)`; if you want that frame's envelope, call `get_ff_scalar` -directly). +`get_ff_mask()` returns a bare `int`; call `get_ff_scalar` if you want the envelope. + +Items 12–15 of `set_ff_vec` have only the generic entry point: `12 zg_kp` / `13 zg_kd` / +`14 zg_damping` (used by hand-guiding) and `15 kd_extra` (a software derivative damping that +cures `movej` start-up ringing). **`kd_extra` must stay 0 for the wrist joints J5–J7** (factory +value `[6,6,6,6,0,0,0]`). -⚠ `set_ff_vec` items **12~15** have only the generic entry point, no named -method: `12 zg_kp` / `13 zg_kd` / `14 zg_damping` (for zero-gravity -hand-guiding), `15 kd_extra` (τ-domain software derivative damping, cures the -ringing at the start of `movej`) — **`kd_extra` must stay 0 for wrist J5-J7** -(factory `[6,6,6,6,0,0,0]`). +The item name tables are class attributes on `Arm` and double as the validation whitelist: +`FF_VEC_ITEMS` (1..15), `FF_SCALAR_ITEMS` (1..18, missing 9), `FF_SCALAR_RO_ITEMS` (only 9). -⚠ The item-name tables are class attributes on `Arm` (and the whitelist used -for argument validation): `FF_VEC_ITEMS` (1..15), `FF_SCALAR_ITEMS` (1..18, -missing 9), `FF_SCALAR_RO_ITEMS` (only 9 = `ff_mask`). +These write RAM; call `save_params()` to persist. -⚠ Writes go to **RAM**; to persist them call `save_params()`. +When writing through `set_ff_vec`, **any `NaN` component makes the firmware reject the whole +group** (`ERR{0x26,0x02}`); a magnitude outside the item's range is **silently clamped** +instead of raising. -### 5.6 Zero-Gravity Hand-Guiding +### 5.6 Hand-guiding ```python zero_g(period=0.04) # context manager @@ -364,47 +350,41 @@ zero_g_start(period=0.04) zero_g_stop(raise_on_lost=False) ``` -`zero_g()` is a **client-side composition** (`zero_g_start` + `zero_g_stop`), -**not an RPC**. +`zero_g()` is a client-side composition (`zero_g_start` + `zero_g_stop`), not a single command. ```python with arm.zero_g(): - input("drag the arm, then press Enter") + input("Drag the arm, then press Enter") ``` -- **The SDK's background thread resends the keep-alive automatically** - (default `period=0.04 s`) — firmware `0x06` carries its own `watchdog_kick`, - so **failing to resend for 0.10 s drops out of fail-soft**. `period` must be - ∈ `[0.005, 0.10)`. -- **Other motion commands are refused while the keep-alive is running**; - **queries are unrestricted**, and **e-stop / disable are the exceptions**. -- **Exit is asynchronous**: after `zero_g_stop()` returns, the firmware side - still needs a little time to wind down. -- If the keep-alive is interrupted by a **write failure**, exiting **raises** - instead of staying silent. -- The read-only properties `zero_g_active` / `zero_g_error` report the state. +- 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. +- 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. -### 5.7 Passthrough / Servo (**The Second Channel**) +### 5.7 Passthrough / servo ```python -move_js(q, dq=None, tau_ff=None) send_mit(idx, q, dq, kp, kd, tau) send_mit_all(q, dq, kp, kd, tau) ``` -⚠ **These three bypass motion planning, and the caller must keep them alive -itself**: **resend at ≥10 Hz**, otherwise the 0.1 s command watchdog -fail-softs (drops stiffness + τ=0) and the arm slowly sags under gravity. +These bypass motion planning, and **the caller must keep them alive**: resend at ≥10 Hz, or the +0.1 s command watchdog drops into fail-soft (reduced stiffness + τ=0) and the arm sags slowly +under gravity. -⚠ The five arrays of `send_mit_all` must each have length = `n`, and every -value must be **finite**. +The five arrays of `send_mit_all` must all have length `n` (checked locally), and they **must be +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. -⚠ **This group of entry points is not fully verified on real hardware** (see -[TROUBLESHOOTING](../TROUBLESHOOTING.md#explicitly-not-verified)). +This group has not been fully verified on hardware, see +[troubleshooting §16](../TROUBLESHOOTING.md#16-not-yet-verified). -### 5.8 Sub-Objects +### 5.8 Sub-objects -#### `arm.params.*` — Joint-Level Parameters (4) +#### `arm.params.*` — per-joint parameters ```python set_joint_param(idx, kp, kd, tau_max) @@ -416,14 +396,11 @@ reset_factory() `JointParam`: `idx`, `kp`, `kd`, `tau_max`, `q_min`, `q_max`. -- ⚠ `set_joint_limits()` **may only narrow**: writing back the **current - values** is judged a "widening request" and rejected (`ERR[23,2]`) ⇒ **this - entry point is not idempotent**, so don't use it for a write/read-back - consistency check. -- ⚠ `reset_factory()` requires the **disabled state**; while enabled it - returns `ERR{0x36,0x04}`. **Irreversible**. +`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. -#### `arm.model.*` — Online Dynamics-Model Import (9) +#### `arm.model.*` — online dynamics model import ```python probe() -> bool @@ -439,17 +416,23 @@ revert() `ModelStatus`: `override`, `staged_mask`, `dirty`. -- Writes go into the **staging layer** and **do not take effect** — the - criterion should be `staged_mask`, not "read back the live layer". -- Only `commit(expected_mask)` applies them; `revert()` discards them. -- ⚠ **`set_jm()` should never be called**: it changes the joint mapping - (including signs), a wrong change risks the arm **flying off**, and on this - machine the only recovery means (`revert` / `save_params`) are **themselves - irreversible** ⇒ there is no fallback you can rely on. -- ⚠ `commit()` / `revert()` both **overwrite the per-unit identified dynamics - model** stored on this machine, see §10. +Writes land in a staging layer and do not take effect immediately; judge by `staged_mask`. +`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 +know: -#### `arm.log.*` — 300 Hz Control-Tick Capture (4 + `LogReader`) +1. the imported model in flash is still there, so it **comes back on the next power cycle**; +2. any subsequent `save_params()` **wipes it as well** (whole-sector erase, unrecoverable); +3. after the rollback `status().dirty == 1` (RAM ≠ flash), but do **not** read that as "needs + committing". + +**`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 are themselves +irreversible. + +#### `arm.log.*` — 300 Hz control-tick capture ```python start(n_ticks) @@ -459,128 +442,83 @@ capture(n_ticks, timeout=1.0, retries=3, record_timeout=None) -> list[LogSample] dump(path, wait=True, timeout=1.0, retries=3, record_timeout=None) -> int ``` +The `LogReader` you get from `r = arm.log.reader()` provides: + ```python -r = arm.log.reader() -r.total() # ticks already on disk in this session +r.total() # ticks written so far r.wait_for(n_ticks, timeout=None, poll=0.05) -> int -r.read_all() -> bytes # read back the whole buffer +r.read_all() -> bytes r.samples() -> list[LogSample] r.iter_chunks() -> Iterator[bytes] r.total_bytes # property ``` -`LogSample`: `tick`, `q_ref`, `dq`, `tau`. Module constants: -`LOG_MAX_SAMPLES = 2400` (≈8 s@300 Hz, **stops by itself when full**), -`CTRL_HZ = 300`. +`LogSample`: `tick`, `q_ref`, `dq`, `tau`. +Constants: `LOG_MAX_SAMPLES = 2400` (about 8 s @300 Hz, stops when full), `CTRL_HZ = 300`. -- `reader()` is a **factory**: it reads back in chunks following the firmware - cursor `next_byte`, and **retries by cursor when a frame is dropped**. -- `dump(path)` writes the raw byte stream to disk (full scale ≈ 863 round - trips; for a large buffer, dump to disk first and parse afterwards). -- ⚠ **In the disabled state `capture()` records 0 ticks, always** — that is - **firmware behaviour**, not a defect. +`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 (1) +#### `arm.diag.*` — firmware self-test ```python kin_bench(timeout=8.0) -> Msg[KinBenchResult] ``` -`KinBenchResult` methods/properties: `raw`, `timings`, `link`, plus -`crc_errors`, `reply_dropped`, `can_tx_fail`, `loop_max_kcycle`, -`loop_overruns`, `rx_fifo_lost_motor`, `rx_fifo_lost_bridge`, +`KinBenchResult`: `raw`, `timings`, `link`, plus `crc_errors`, `reply_dropped`, `can_tx_fail`, +`loop_max_kcycle`, `loop_overruns`, `rx_fifo_lost_motor`, `rx_fifo_lost_bridge`, `gsusb_ring_drops`. -⚠ **It is the only source of diagnostic counters for the back link** (`crc` / -`reply_dropped` / `can_tx_fail` / `loop_max_kcycle` / `loop_overruns`). -⚠ But **all five counters reading 0 may be a "silent 0"**, see -[TROUBLESHOOTING §11](../TROUBLESHOOTING.md#11-kin_benchs-five-counters-read-zero--silently). - -### 5.9 License / Activation (Firmware 1.8.0+) - -```python -license(timeout=1.0) -> LicenseInfo -activate(*, cust_id, issued, flags=0, mac, timeout=2.0) -> None -``` +It is the only source of return-link diagnostic counters, but **all-zero counters may mean +nothing was actually read** — see +[troubleshooting §11](../TROUBLESHOOTING.md#11-every-kin_bench-counter-reads-0). -`LicenseInfo`: `state`, `ver`, `uid`, `cust_id`, `issued`, `flags` + the -derived `activated`, `factory_mode`, `state_name`, `uid_hex`. - -- The firmware stores one record in a **dedicated flash sector** (sector 6), - **written once and never erased**; while unactivated it **only locks - `ENABLE`** (`ERR{0x10,0x08}`), and every other command behaves as usual. -- `license()` **does not raise while unactivated** (it is a **state**), and it - **returns the UID even while unactivated** — that is the issuer's only - source, so don't switch to the USB serial-number string. -- `activate()` takes **all arguments keyword-only**, and `mac` has no default. - **The arm must be disabled first**, otherwise `ERR{0x3F,0x04}`. -- ⚠ **This package contains no key and no code that computes a MAC** — issuing - happens in the vendor-side tool. -- ⚠⚠ `ERR{0x3F,0x02}` is an **aggregate code** (already activated / MAC - mismatch / illegal key / write failure all share it). In this case the - package **automatically reads back `0x2F`**: if the device really has - `state != 0` it returns success, and only otherwise does it raise. -- Erasing the license record **is possible only over SWD** - (`pyocd erase -s 0x080C0000`) — the firmware has **no** erase command. - -### 5.10 Flashing DFU +### 5.9 Firmware update (DFU) ```python enter_dfu(timeout=0.3) -> None ``` -**The only terminal-state operation.** Enters the ROM system bootloader -without a probe (`CMD_ENTER_DFU 0x15`). +The only terminal-state operation: enters the ROM bootloader without a probe. -- **Two-stage**: `ACK{0x15}` only means "registered"; you still have to wait - for the device to really disappear from CDC. -- While enabled it is **rejected locally** (the jump stops TIM3 ⇒ the motors - release after 100 ms and sag under load). -- After it returns successfully **this `Arm` can no longer be used** (every - entry point raises `ArmIsInDfuError`, `close()` excepted); the device - re-enumerates as `0483:DF11`, and after flashing the firmware you **create a - new `Arm`**. -- If it has not disappeared before the timeout, it raises "registration - withdrawn / not executed", and the object stays usable as before. +- 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`. +- If the device does not disappear before the timeout it raises, and the object stays usable. -> ⚠ **Once in DFU you cannot flash immediately** — wait for USB -> re-enumeration. **First prove you can rescue it, then break it on purpose.** +You cannot flash immediately after entering DFU; wait for USB re-enumeration. -### 5.11 Persistence +### 5.10 Persistence ```python save_params() -> None ``` -Writes flash (`0x25`). ⚠ **Irreversible**, see §10. +Writes flash, irreversible. -### 5.12 Read-Only Properties +### 5.11 Read-only properties ```python params / model / log / diag # sub-objects last_reset_reason # "normal" / "iwdg-rst" / None -zero_g_active # bool -zero_g_error # Optional[BaseException] - -n # joint count (usable after the handshake) -firmware # version string, e.g. "Litearm1.8.0-7J" -fw_version # tuple, e.g. (1, 8, 0) -min_firmware # the lower bound this package requires -q_tol / dq_tol / arrive_frames # arrival criterion -move_timeout # motion timeout -bench_model_axis # the axis used for bench calibration +zero_g_active / zero_g_error + +n # joint count +firmware / fw_version # version string / tuple +min_firmware / q_tol / dq_tol / arrive_frames / move_timeout +bench_model_axis # bench calibration axis ``` -> ⚠ **`last_reset_reason` is normally `None`, and that is correct -> behaviour** — the boot banner **is sent only once, after a real MCU reset**, -> and `reset()` does not make it repeat. See -> [TROUBLESHOOTING §10](../TROUBLESHOOTING.md#10-last_reset_reason-is-none-usually-correct). +`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. --- ## 6. Exceptions -All derive from `LiteArmError`. +All inherit from `LiteArmError`. ```python from litearm import ( @@ -588,167 +526,128 @@ from litearm import ( FirmwareMismatchError, InvalidCommandError, MotorFaultError, MotionTimeoutError, IKError, CommandRejectedError, UnsupportedByFirmwareError, CartesianPlanError, MotionSupersededError, CartReplyLostError, - ArmIsInDfuError, NotRemoteable, NotSupportedOnThisBackend, - TeleopLockedError, TeleopBusyError, + ArmIsInDfuError, ) ``` -| Exception | Raised when | -| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NotConnectedError` | called while not connected; called after `close()` | -| `ForkedSessionError` | using an inherited session in a **child process that came out of `fork`** (a subclass of `NotConnectedError`). fail-closed: **not a single byte goes out** | -| `TransportError` | serial read/write failure, or a frame fails its CRC check | -| `FirmwareMismatchError` | the firmware does not match the `Litearm-{7J\|1J}` convention, or is below the lower bound | -| `InvalidCommandError` | invalid argument (length, range, type) — most are caught **locally** | -| `MotorFaultError` | status-frame FAULT flag or EMERGENCY (including G7 single-axis fault degradation) | -| `MotionTimeoutError` | the motion did not arrive within `move_timeout` | -| `IKError` | inverse kinematics failed / target unreachable | -| `CommandRejectedError` | the firmware explicitly returned `ERR`. Carries `.cmd` / `.code` (**`.cmd` is echoed by the firmware, and is more trustworthy than what I just sent**) | -| `UnsupportedByFirmwareError` | the firmware **does not implement** this command (`code == 0x00`). **Its superclass is `CommandRejectedError`** | -| `CartesianPlanError` | a firmware-planned move was rejected (no IK solution / three collinear points / over capacity / out of limits). ⚠ it **hangs directly off `LiteArmError`** and does **not** inherit `InvalidCommandError` | -| `MotionSupersededError` | a Cartesian request was superseded by a new one — **an expected takeover, not a failure**. **Deliberately not** a subclass of `CartesianPlanError` | -| `CartReplyLostError` | the `0x4E` reply was lost ⇒ **the outcome is unknown** (an SDK-invented semantic, not a firmware code) | -| `ArmIsInDfuError` | this `Arm` has handed the device to the ROM bootloader — **a terminal state**, it cannot come back. **Deliberately not** a `NotConnectedError` | -| `NotRemoteable` | that entry point cannot be exposed remotely | -| `NotSupportedOnThisBackend` | the current backend does not implement that capability | -| `TeleopLockedError` / `TeleopBusyError` | the teleop state refuses that command | - -> ⚠ **A deliberate change in what you can catch**: since 2.0 -> `CartesianPlanError` hangs directly off `LiteArmError`, so -> `except InvalidCommandError` **no longer covers it** — what the firmware -> returns is a **planning result**, not "this command was rejected". - -**Error codes**: in `ERR{cmd, code}`, `code == 0x00` **always means "the -firmware has no such command"**; it is a stable and unique capability-probe -sentinel. +| Exception | Raised when | +| --- | --- | +| `NotConnectedError` | Called while not connected, or after `close()` | +| `ForkedSessionError` | An inherited session is used in a child process (`NotConnectedError` subclass), sends nothing | +| `TransportError` | Serial read/write failure, or a frame fails its CRC check | +| `FirmwareMismatchError` | The firmware does not match the naming convention, or is below the lower bound | +| `InvalidCommandError` | Invalid argument (length, range, type), mostly caught locally | +| `MotorFaultError` | A FAULT flag or EMERGENCY appears in the state frames | +| `MotionTimeoutError` | The motion did not arrive within `move_timeout` | +| `IKError` | Inverse kinematics failed / the target is unreachable | +| `CommandRejectedError` | The firmware explicitly replied `ERR`, carries `.cmd` / `.code` | +| `UnsupportedByFirmwareError` | The firmware does not implement this command (`code == 0x00`), a subclass of `CommandRejectedError` | +| `CartesianPlanError` | The firmware rejected the plan. Hangs directly off `LiteArmError`, and does **not** inherit `InvalidCommandError` | +| `MotionSupersededError` | A Cartesian request was superseded — expected takeover, not a failure | +| `CartReplyLostError` | The `0x4E` reply was lost, outcome unknown | +| `ArmIsInDfuError` | This `Arm` has handed the device to the ROM bootloader, a terminal state | + +`except InvalidCommandError` does not cover `CartesianPlanError`; what the firmware returned is +a planning result, not a rejected command. + +In `ERR{cmd, code}`, `code == 0x00` always means the firmware does not have this command. --- -## 7. Architecture — One Reader Thread +## 7. Things to watch out for + +1. **The arm must never fly off.** This is the hard line, and the acceptance criterion for every + safety-related change. +2. **A child process must not use an inherited session** — fail-closed, nothing sent. The parent + must release the port first. +3. **`movej` returning does not mean it has settled** — a residual of about 0.012 rad, within + `q_tol`. +4. **`movej` / `movej_sync` do not check joint limits** — compare against them yourself. +5. **After `disable()` the arm is no longer held.** +6. **`save_params()` cannot be undone.** + +### Irreversible commands: do not run these on a calibrated arm + +All four overwrite or erase that unit's per-arm identified dynamics model. The only way to be +safe is to do it on a board whose calibration has no value: + +| Command | Entry point | +| --- | --- | +| `0x25` | `save_params()` | +| `0x32` | `arm.model.commit()` | +| `0x36` | `arm.params.reset_factory()` | +| `0x37` | `arm.model.revert()` | + +`model.set_jm()` should never be called; a wrong joint mapping can make the arm flail, and there +is no dependable way back. + +When stress-testing the CAN link, run `candump` (read-only) only, never `cangen`: `can0` is the +motor bus. -### Shape +What has not been verified is listed in +[troubleshooting §16](../TROUBLESHOOTING.md#16-not-yet-verified). -Each session starts **one** background reader thread at `connect()` -(`litearm-reader`, daemon). It is the **only** place in the whole package that -touches `transport.read_frame`, and it does exactly two things: **deliver a -frame to the queue it belongs to, and die loudly.** +--- + +## 8. Architecture + +Each session starts one background read thread at `connect()` (`litearm-reader`, daemon). It is +the only place in the package that touches the transport's read side, and it does two things: +**deliver each frame to its queue, and be loud when it dies.** ```text -one reader thread: a frame arrives → put it on the right queue by (frame id, echoed code) -everyone else: send a command → wait on their own queues +One read thread: a frame arrives → file it into the matching queue by (frame id, echo code) +Everyone else: send a command → wait on their own queues ``` -**A frame's ownership = which queue it lands on**, and is **not decided by the -thread**. `ACK{0x10}` and `ACK{0x11}` naturally land on **two** queues ⇒ -concurrent commands cannot eat each other's replies. - -- `RSP_STATUS` (a 100 Hz continuous stream) **does not go into a queue**: it - goes into a **single slot** plus an arrival sequence number, and waiters wait - for "the sequence number to advance". -- Stale replies are blocked by **clearing the queue before sending** (inside - the **single write gate** `_raw_write`); `echo_cmd` is **mandatory** for - `ACK`/`ERR` ⇒ same-id mutual eating is **structurally impossible**. - -### Only Two Rules - -1. **The reader thread only delivers, it never judges.** -2. **If the reader thread dies, it must die loudly.** A transport exception is - stored in `_reader_error` → all waiters are woken → they raise it. **It - never exits silently.** - -### Three Implementation Constraints (None of Which May Be Wrong) - -- **`close()` must stop the thread first, then close the transport.** - Otherwise the reader thread raises `TransportError` from an already-closed - transport, and a **normal shutdown** gets recorded as "link lost". Order: - `zero_g_stop()` → stop the reader thread + `join(1.0)` → close the - transport. -- **The reader thread is pinned to "the `_Ack` at the moment the thread - started"** and does not re-read `self._a` inside the loop — otherwise the - window during `reconnect()` would deliver frames to a mixed old/new object. -- **`_Ack` holds a weak reference to `Arm`.** The thread's target is a bound - method ⇒ the thread strongly references `_Ack`; if `_Ack` then strongly - referenced `Arm`, **an `Arm` whose `close()` you forgot could never be - collected**, `__del__`'s fallback cleanup would never fire ⇒ the port would - never be released. - -### Cost - -⚠ **One extra thread per session**. The reader thread's back-off uses -`Event.wait` rather than `time.sleep` (the latter would be caught tens of -thousands of times by the "how long did we wait" probes in the tests). - -⚠ **A child process cannot be used after `fork`** — this is the **only** -system-level cost of this architecture, and corresponds to item 1 of the -README's "two must-reads". - -### What This Mechanism Does **Not** Solve (Don't Expect It To) - -| Not solved | Root cause | Which layer | -| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------- | -| pairing two **completely identical** commands issued concurrently | there is **no request id** on the wire | wire protocol | -| firmware `plan_pending` is a single slot ⇒ Cartesian concurrency depth 1 | device-side capacity | firmware | -| a stale reply still in a transport buffer / on the wire escaping the queue clear | same as above (no request id) — **a known residual window that can no longer be contracted** | wire protocol | - -> 📌 Measured: under long-running concurrency the queue depth **peaks at 1** -> (the cap of 64 is never approached), and the firmware-side `crc` / `rxfifo` -> counter deltas are all 0 — **no frames are lost, structurally**. - -### Two Performance Facts About the Transport Layer - -- **Reads are "read as much as is available"** (request by `in_waiting`, - capped at 4096 bytes), **not byte-by-byte**. The cost of byte-by-byte reading - is proportional to the number of bytes, and this board's idle status stream - alone is ~14 kB/s ⇒ **it would burn a whole core**. -- **CRC16 goes through `binascii.crc_hqx`** (poly `0x1021` / init `0xFFFF`), - not a pure-Python double loop — **232×** faster. - -Measured gains (idle CPU of a bare SDK session): byte-by-byte + pure-Python -CRC **98.8%** → read-as-much-as-available **21.4%** → CRC switched to the C -implementation **6.8%**. +A frame's ownership is decided by which queue it lands in, not by any thread. `ACK{0x10}` and +`ACK{0x11}` land in two different queues, so concurrent commands cannot eat each other's +replies. `RSP_STATUS` (a 100 Hz stream) does not go into a queue; it goes into a single slot +plus an arrival sequence number. + +Two rules: **the read thread only delivers, it never judges**; and **the read thread must die +loudly** — the exception goes into `_reader_error`, waking every waiter, never exiting silently. + +The cost is one extra thread per session, which is also why a child process cannot inherit the +session. + +What it does not solve: there is no request id on the wire, so two identical concurrent commands +cannot be paired; and the firmware holds only one pending Cartesian plan, so Cartesian +concurrency depth is 1. ### Files ```text src/litearm/ - _protocol.py frame codec (0xA5 CMD LEN PAYLOAD CRC16-CCITT-FALSE) + command/reply constants - + status-frame parsing + boot-banner parsing + the COMMAND_COVERAGE contract - ⚠ the two license entries are here too (0x2F/0x3F/0x4F/0x50); no MAC-computing code - transport.py pyserial CDC read/write + auto-discovery (VID:PID 1d50:606f); - one lock each for read and write (the zero_g keep-alive thread is the first concurrent writer) + _protocol.py frame codec + command/reply constants + state parsing + coverage contract + transport.py pyserial CDC read/write / auto-discovery state.py RobotState / JointState - errors.py error hierarchy + ERR_TEXT code table - arm.py Arm core + CLI + reader thread `_Ack` + fork guard - cart.py Cartesian: 0x4E pairing + arrival criterion (bit10) + capability probe - ⚠ no PC-side planning — planning is in the firmware - params.py arm.params.* joint-level parameters - model.py arm.model.* online dynamics-model import - log.py arm.log.* 300Hz capture + LogReader cursor read-back - diagnostics.py arm.diag.* KIN_BENCH self-test - _rot.py pure rotation/pose math (rpy⇄matrix, as_pose normalisation) - testing.py the public offline stub (FakeTransport) — builds frames and replies from the real firmware layout + errors.py exception hierarchy + error-code text + arm.py Arm core + CLI + read thread + fork guard + cart.py Cartesian: result-frame pairing + arrival criterion + params.py arm.params.* + model.py arm.model.* + log.py arm.log.* + diagnostics.py arm.diag.* + _rot.py pure rotation / pose math + testing.py offline stub (FakeTransport) ``` -### Coverage Contract - -Every **implemented** downstream command in the firmware's `hal/usb_cmd.h` has -a matching entry point. The contract lives in `_protocol.COMMAND_COVERAGE` -(command id → SDK entry point) and is enforced in both directions by -`tests/test_protocol_sync.py` **parsing the firmware header directly** — if -the firmware adds a command and the SDK doesn't follow, or a status-frame -layout is changed, the test fails immediately. +Every implemented downstream command in the firmware has an entry point. The contract lives in +`_protocol.COMMAND_COVERAGE` and is enforced in both directions by `tests/test_protocol_sync.py`, +which parses the firmware headers directly. --- -## 8. Command Line +## 9. Command line ```bash litearm-python [--port PORT] [ACTION] [TARGETS...] [--speed SPEED] python -m litearm ... # equivalent ``` -`ACTION` ∈ `status` (default) / `fw` / `enable` / `disable` / `reset` / -`emergency` / `movej` / `home` / `tcp`. +`ACTION` ∈ `status` (default) / `fw` / `enable` / `disable` / `reset` / `emergency` / +`movej` / `home` / `tcp`. ```bash litearm-python status # read-only @@ -757,84 +656,34 @@ litearm-python tcp # current pose + frame rate litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 ``` -⚠ `movej` requires the number of `TARGETS` to be **exactly `arm.n`**; `home`'s -`--speed` is **ignored** (the firmware hard-codes 0.10). +`movej` requires exactly `arm.n` values in `TARGETS`; `home` ignores `--speed` (the firmware +hard-codes 0.10). --- -## 9. Testing +## 10. Testing ```bash -pytest # full offline flow (stub transport, no real hardware) -PYLITEARM_LIVE=1 pytest # + live hardware (needs a Litearm1.5.0+ full arm/bench connected; it moves slightly) -python tests/test_offline.py # works without pytest too (script-style assertions) +pytest # full offline run, never touches hardware +LITEARM_LIVE=1 pytest # plus hardware tests +python tests/test_offline.py # runs without pytest too ``` -**Running the tests does not require installing the package** — -`tests/conftest.py` puts `src/` and `tests/` on `sys.path` itself. - -⚠ **Do not set `PYLITEARM_LIVE` when nobody is present**, and never call +Running the tests does not require installing the package; `tests/conftest.py` puts `src/` and +`tests/` on `sys.path`. Do not set `LITEARM_LIVE` with nobody present, and never call `enter_dfu()` / `reset_factory()`. -The critical groups: - -| Test | What it guards | -| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `test_protocol_sync.py` | **protocol-drift protection** — parses the firmware repo's `usb_cmd.h` / `usb_cmd.c` / `joint_cfg.h` directly, compares command sets and IDs in both directions, and pins the status-frame layout. The firmware repo location is given by `LITEARM_FW_DIR`; **when it cannot be found it skips rather than passing** | -| `test_protocol_crc.py` | CRC16 — uses an **external authoritative check value** (`"123456789"` → `0x29B1`) plus an independent in-file reference implementation, deliberately not depending on the implementation under test | -| `test_frame_ownership.py` | the frame-ownership contract: **the frame was not silently destroyed, its owner got it** | -| `test_transport.py` | real byte-stream parsing (consecutive frames / noise / bad frames / half frames / both arrival methods decoding the same frame) | -| `test_ack_echo.py` | an ACK must echo the original command (a late old ACK must not make a new command "succeed" falsely) | -| `test_capability.py` | `ERR{cmd,0x00}` → `UnsupportedByFirmwareError` ("a real rejection" must not be misjudged) | -| `test_fork_guard.py` | the fork guard: a child process sends nothing | -| `test_zero_g.py` | zero-gravity keep-alive period / exit wind-down / thread reclamation / command gating | -| `test_status_layout.py` | both status-frame layouts, `6+21N` and `4+21N` | -| `test_live.py` | real-hardware smoke test (only runs with `PYLITEARM_LIVE=1`) | - -⚠ **The stub in `tests/fake_serial.py` must match the real firmware layout** — -if the stub disagrees with the real firmware, offline tests go "falsely green" -while real hardware is guaranteed to fail. That lesson has a dedicated test -guarding it. - ---- - -## 10. Safety Notes - -1. **The arm must never fly away.** This is the product's hard line, and the - acceptance criterion for every safety change. -2. **After `fork` a child process must not use an inherited session** — - fail-closed, zero bytes sent. The parent **must release the port first**. -3. **`movej` returning ≠ settled** (residual ~0.012 rad, inside `q_tol`). -4. **`movej` / `movej_sync` do not currently check joint limits** — an - out-of-limit target runs the full travel. Compare against the limits - yourself before sending. -5. **After `disable()` the arm is no longer held up** — once the position loop - is cut, it will move. -6. **`save_params()` has no undo.** - -### ⚠ Irreversible Commands — Do Not Run on a Calibrated Arm - -These four **overwrite/erase the per-unit identified dynamics model** of that -device (whole-sector erase + write of the current RAM): - -| Command | Entry point | -| ------- | ---------------------------- | -| `0x25` | `save_params()` | -| `0x32` | `arm.model.commit()` | -| `0x36` | `arm.params.reset_factory()` | -| `0x37` | `arm.model.revert()` | - -**The only way to unlock this: do it on a board with no calibration value.** - -⚠ **`model.set_jm()` should never be called** — a wrong joint mapping risks the -arm **flying off**, and there is no fallback you can rely on. - -⚠ When stress-testing the CAN link, **run only `candump` (read-only), never -`cangen`** — `can0` is the motor bus. - -**The explicitly unverified parts** are in -[TROUBLESHOOTING](../TROUBLESHOOTING.md#explicitly-not-verified). - -## License - -MIT +| Test | What it guards | +| --- | --- | +| `test_protocol_sync.py` | Protocol drift protection: parses the firmware headers and compares command sets and IDs. The firmware repo location comes from the `LITEARM_FW_DIR` environment variable (default `~/litearm-stm32`); when it cannot be found the test **skips rather than passes** | +| `test_protocol_crc.py` | CRC16 against an external authoritative value plus an independent reference implementation | +| `test_frame_ownership.py` | A frame is not silently destroyed, and its owner receives it | +| `test_transport.py` | Real byte-stream parsing (back-to-back frames / noise / bad frames / partial frames) | +| `test_ack_echo.py` | Replies must echo the original command, so a late old ACK cannot make a new command succeed falsely | +| `test_capability.py` | "The firmware does not have this command" → `UnsupportedByFirmwareError` | +| `test_fork_guard.py` | The fork guard: a child sends nothing | +| `test_zero_g.py` | Hand-guiding keep-alive period / exit cleanup / thread reclamation / command gating | +| `test_live.py` | Hardware smoke test, only with `LITEARM_LIVE=1` | + +The stub in `tests/fake_serial.py` must match the real firmware layout, or the offline run gives +a false green and fails on hardware. diff --git a/docs/DEVELOPER_GUIDE.zh-CN.md b/docs/DEVELOPER_GUIDE.zh-CN.md index d6a702c..7364f31 100644 --- a/docs/DEVELOPER_GUIDE.zh-CN.md +++ b/docs/DEVELOPER_GUIDE.zh-CN.md @@ -1,43 +1,38 @@ -# litearm-python 开发者指南 · API 参考 +# litearm-python 开发者指南 -LiteArm 机械臂的 Python SDK —— **直连 `litearm-stm32` 固件**(USB CDC 串口)。 +LiteArm 机械臂的 Python SDK,USB 串口直连固件。 -本 SDK 是一份**薄协议绑定**:PC 侧只编解码帧、下发命令、判定到位。 -**轨迹规划、运动学、动力学全在固件里**(B2 S 曲线 + B3 运动学 + B4 动力学 + B1 控制律), -PC 侧**不做**这些 —— 不然同一个约定写两遍,改一处漏另一处就静默变成两套语义。 - -除 `pyserial` 外**零依赖**。位姿是**纯 Python list**,不需要 numpy。 +规划、运动学、动力学都在固件里,PC 侧只编解码帧、下发命令、判到位。 +位姿是 6 个数(list 或 tuple),只依赖 `pyserial`。 ## 目录 -1. [要求与安装](#1-要求与安装) +1. [环境要求与安装](#1-环境要求与安装) 2. [快速开始](#2-快速开始) 3. [连接管理](#3-连接管理) -4. [读状态 —— 返回信封 `Msg`](#4-读状态--返回信封-msg) +4. [读一帧的返回值 `Msg`](#4-读一帧的返回值-msg) 5. [API 参考](#5-api-参考) 6. [异常](#6-异常) -7. [架构 —— 一条读线程](#7-架构--一条读线程) -8. [命令行](#8-命令行) -9. [测试](#9-测试) -10. [安全须知](#10-安全须知) +7. [注意事项](#7-注意事项) +8. [架构](#8-架构) +9. [命令行](#9-命令行) +10. [测试](#10-测试) --- -## 1. 要求与安装 +## 1. 环境要求与安装 -- Python **>= 3.9** +- Python **3.9 及以上** - `pyserial >= 3.4`(唯一依赖) -- 固件 **`Litearm1.5.0+`**,约定为 `Litearm<主.次.修>-{7J|1J}` +- 固件 **`Litearm1.5.0` 及以上**,版本串为 `Litearm<主.次.修>-{7J|1J}` +- Linux 需串口权限:`sudo usermod -aG dialout $USER`(重新登录生效) ```bash -pip install -e . - -# 开发(含 pytest) -pip install -e ".[dev]" +pip install -e . # 安装 +pip install -e ".[dev]" # 开发(含 pytest) ``` -> ⚠ `pip` 与 `python` 指向不同解释器时,一律用 `python -m pip`, -> 让包装进你真正运行的那个解释器。 +`pip` 与 `python` 指向不同解释器时用 `python -m pip`。 --- @@ -46,25 +41,25 @@ pip install -e ".[dev]" ```python import litearm as pa -arm = pa.Arm().connect() # 自动找 CDC + 校验固件版本约定 +arm = pa.Arm().connect() # 找串口 + 校验固件版本 arm.enable() # 运动前必须先使能 arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) -print(arm.get_tcp().value) # 读值走 .value(2.0 起的返回信封,见 §4) +print(arm.get_tcp().value) # 读值要走 .value,见 §4 arm.close() ``` -`Arm().connect()` 是**唯一入口**。`connect()` 返回时握手已完成,`arm.n` / `arm.firmware` -一定可用。 +`connect()` 返回时握手已完成,`arm.n` / `arm.firmware` 一定可用。 -每个会话带**一条后台读线程**,所以每个 `Arm` 都要 `close()` —— 用 `with` 可以省掉: +每个会话有后台读线程,**用完必须 `close()`**,`with` 会自动关: ```python with pa.Arm().connect() as arm: print(arm.get_state().value.q) -# 退出 with 即 close() ``` -> ⚠ **`fork` 之后子进程不能继承这个会话**,详见 [README](../README.zh-CN.md#1-多进程--fork子进程不能用继承来的-arm)。 +下面各节出现的 `arm` 都指这个已连好的会话对象;片段只写该节要讲的那几步。 + +子进程不能用继承来的会话,见 [README](../README.zh-CN.md#多进程fork-之后子进程不能用继承来的-arm)。 --- @@ -78,25 +73,30 @@ connect(port=None) -> Arm close() -> None disconnect() -> None # close() 的别名 reconnect(port=None) -> Arm # 等于 close() 再 connect() -__enter__() / __exit__(*exc) # with 用法;__exit__ 返回 False(不吞异常) -__del__() # GC 兜底,等价于 close() +__enter__() # with 用法 +__exit__(*exc) # 返回 False,不吞异常 +__del__() # 回收兜底 ``` +### 构造参数 + | 参数 | 默认 | 含义 | -|---|---|---| -| `port` | `None` | 串口路径;`None` ⇒ 自动发现 `1d50:606f`。优先级:`port` 参数 > `LITEARM_PORT` > 自动发现 | -| `transport_factory` | `None` | 注入传输(测试用)。⚠ 还要传一个占位 `port`,因为 `connect()` 会先走 `find_cdc_port()` | +| --- | --- | --- | +| `port` | `None` | 串口路径。`None` 表示自动发现(VID:PID `1d50:606f`)。SDK **不读环境变量**;`LITEARM_PORT` 只被 `examples/_common.py` 使用 | +| `transport_factory` | `None` | 注入传输层(测试用),需同时传占位 `port` | | `min_firmware` | `(1, 5, 0)` | 版本门下限 | | `q_tol` | `0.03` | 到位判据:关节角容差(rad) | | `dq_tol` | `0.10` | 到位判据:关节速度容差 | | `arrive_frames` | `3` | 到位判据:连续满足的帧数 | | `move_timeout` | `15.0` | 运动超时(s) | +### 会话方法 + | 方法 | 注意 | -|---|---| -| `connect()` | **幂等**(同目标重复调直接返回 `self`)。⚠ 握手**写**失败时可能留下半开会话,重连会**静默报成功** | -| `close()` | 幂等。停读线程 → 关传输。之后所有入口抛 `NotConnectedError`(`close()` 自己例外) | -| `reconnect()` | 会**换会话**:读线程重起、`Msg.hz` 统计归零 | +| --- | --- | +| `connect()` | 幂等。任何一步失败都**先关链路再抛**,不会留下半开的会话 | +| `close()` | 幂等。停读线程 → 关传输。之后所有入口抛 `NotConnectedError` | +| `reconnect()` | 换会话:读线程重起,`Msg.hz` 统计归零 | 模块常量:`litearm.MIN_FW`、`litearm.FIRMWARE_PREFIX`。 @@ -106,23 +106,18 @@ __del__() # GC 兜底,等价于 close() | 固件 | 结果 | | --- | --- | -| `Litearm1.5.x-*` 及以上 | ✅ 接受 | -| `Litearm1.4.x-*` 或更早 | ❌ `FirmwareMismatchError` | -| `A1.x-*-USB`(旧命名) | ❌ 不符合约定 | - -> 状态帧解析**同时兼容** `4+21N`(≤1.4.x)与 `6+21N`(≥1.5.0)两种布局 —— -> 那段兼容分支只用于离线/历史帧解析(例如分析抓包),`connect()` 走不到。 +| `Litearm1.5.x-*` 及以上 | 接受 | +| `Litearm1.4.x-*` 或更早 | `FirmwareMismatchError` | +| 其它命名 | `FirmwareMismatchError` | --- -## 4. 读状态 —— 返回信封 `Msg` - -### 哪些入口返回 `Msg` +## 4. 读一帧的返回值 `Msg` -**11 个「读一帧」型 getter** 返回 `Msg[T]`(2.0 起的破坏性变更): +下面 11 个「读一帧」接口返回 `Msg[T]`: | # | 入口 | 帧 | -|---|---|---| +| --- | --- | --- | | 1 | `get_state()` | `RSP_STATUS` | | 2 | `get_status_now()` | `RSP_STATUS` | | 3 | `get_tcp()` | `RSP_TCP` | @@ -138,29 +133,30 @@ __del__() # GC 兜底,等价于 close() ```python @dataclass(frozen=True) class Msg(Generic[T]): - value: T # 原返回值(取不到帧的入口这里是 None) - hz: float # 该类帧在本会话的平均到达频率 - timestamp: float # 最近一帧的本地 time.monotonic()(从没收到过则为 0.0) + value: T # 原始返回值(取不到帧时是 None) + hz: float # 这类帧在本会话的平均到达频率 + timestamp: float # 最近一帧的本地 time.monotonic()(从未收到为 0.0) ``` -**`hz` 的口径(写死,不是估的)**:**该类帧自本会话首次到达起的平均频率** -`(到达条数 − 1) / (最近一帧时刻 − 首帧时刻)`,**样本不足 2 条时是 `0.0`**。 +`hz` = 该类帧自本会话首次到达起的平均频率,**样本不足 2 条时为 `0.0`**。 -- 被动连续流(`RSP_STATUS`,100 Hz):两三帧后收敛到 ~100。链路空闲**不会**让它衰减。 -- 单发请求/应答式那 5 个(`get_joint_param` / `get_body` / `get_jm` / `get_gravity` / `kin_bench`): - 一次调用只到一个帧 ⇒ **第一次调用必然 `hz == 0.0`**,第二次起它等于**你自己的轮询频率**。 -- `reconnect()` 后统计归零。 +- 被动连续流(`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`。 +- `reconnect()` 后归零。 -⇒ **`hz == 0.0` 且 `timestamp == 0.0` 是"这一类帧从没到过"的判据**,不是"链路慢"。 +**`hz == 0.0` 且 `timestamp == 0.0` 表示这类帧从没到过**,不是链路慢。 -### 哪些入口**不**返回 `Msg` +### 不返回 `Msg` 的入口 -- `move_*` 与 `home()` —— 返回的是「动作结果」(`RobotState` / `CartPlan`),不是「读一帧」 -- `n` / `firmware` / `last_reset_reason` / `zero_g_active` —— 根本没有帧 -- `ik()` —— 一次**计算**请求 -- `license()` —— **请求/应答式的设备身份记录**(没有固件发起的流量,`hz` 只会度量你自己轮询的频率) -- 两个**派生** getter:`get_ff_mask()` 仍是裸 `int`(它是 `get_ff_scalar(9,0)` 的标量投影); - `params.all_joint_params()` 仍是 `list[JointParam]`(它是 N 次往返的聚合,一个 `hz` 描述不了 N 帧) +- `move_*` 与 `home()` —— 返回动作结果(`RobotState` / `CartPlan`) +- `n` / `firmware` / `last_reset_reason` / `zero_g_active` —— 没有帧 +- `ik()` —— 计算请求 +- `get_ff_mask()` 返回裸 `int`;`params.all_joint_params()` 返回 `list[JointParam]` ### `RobotState` @@ -170,38 +166,50 @@ get_status_now(timeout=0.5) -> Msg[RobotState] ``` | 字段 | 说明 | -|---|---| +| --- | --- | | `mode` / `mode_name` | 当前模式 | -| `flags` / `flag_names` | 原始标志位与名字 | -| `seq` | 状态帧到达序号 —— **判别"流是否还在动"** | +| `flags` / `flag_names` | 标志位与名字 | +| `seq` | 状态帧到达序号,用来判断数据流是否还在动 | | `joints` | `list[JointState]` | -| `joint_fault` | 固件 G7 逐轴掉线位图(1.5.0 起;旧布局恒 0) | +| `joint_fault` | 逐轴掉线位图 | -派生属性:`n`、`enabled`(flags bit9)、`cart_busy`(flags bit10)、`q`、`dq`、`tau`、 -`fault_axes`、`faulted`、`fault_detail`、`drop_hold_inferred`。 +派生属性:`n`、`enabled`、`cart_busy`、`q`、`dq`、`tau`、`fault_axes`、`faulted`、 +`fault_detail`、`drop_hold_inferred`。 `JointState`:`q`、`dq`、`tau`、`t_mos`、`t_coil`、`err`。 -> ⚠ `get_status_now(timeout=0.0)` **不是**"非阻塞探一帧",它的意思是 -> **"立刻返回当前缓存"**。 +`get_status_now()` 会**主动发一条 `GET_STATUS`**,与只消费被动流的 `get_state()` 不同, +可用来确认链路活性。 + +⚠ `get_status_now(timeout=0.0)` **不是**"非阻塞探一帧"——它的意思是**立刻返回当前缓存**。 +本会话还**一帧都没收到过**时它抛 `MotionTimeoutError`。 --- ## 5. API 参考 -位姿 = **6 个数**的纯 Python list:位置 3 + RPY 3。 +位姿是 6 个数:位置 3(m)+ 姿态 3(rad,RPY)。list 和 tuple 都收。 ```python -pose = [px, py, pz, rx, ry, rz] +pose = [0.30, 0.0, 0.35, 3.1416, 0, 0] -# 四种写法都会被 as_pose() 归一化接受 -arm.move_p([0.30, 0.0, 0.35, 3.1416, 0, 0]) +arm.move_p(pose) ``` -> ⚠ **返回形状不统一**:`get_state()` / `get_status_now()` / `get_tcp()` 给 `Msg` 信封; -> `movej` / `movej_sync` / `move_p` / `home` 给 `RobotState`; -> `move_l` / `move_c` / `move_path` 给 `CartPlan`;`ik()` 给 `list[float]`。 -> **每个入口的 docstring 都写明了自己的形状。** +也接受「位置 3 个数 + 3×3 旋转矩阵」这种写法: + +```python +arm.move_p(([0.30, 0.0, 0.35], [[1, 0, 0], [0, 1, 0], [0, 0, 1]])) +``` + +返回类型不统一,每个入口的 docstring 都写明了自己是哪一种: + +| 返回类型 | 入口 | +| --- | --- | +| `Msg[...]` | 上面 11 个「读一帧」接口 | +| `RobotState` | `movej` `movej_sync` `move_p` `home` | +| `CartPlan` | `move_l` `move_c` `move_path` | +| `list[float]` | `ik` | ### 5.1 生命 / 安全 @@ -216,13 +224,13 @@ park() ``` | 方法 | 注意 | -|---|---| -| `enable(attempts=12)` | 使能全部关节。`attempts` 是重试次数,**重试是白名单**:只有 `(0x10, 0x03)` 会重试,其余码重发无用 | -| `disable()` | 切断位置环。⚠ 使能一断,臂不再被托住 | -| `emergency_stop()` | 单帧、单向、不读状态 —— **唯一没有前置条件的入口** | -| `reset()` | 清故障 + 重锚控制环。⚠ 它是**软件状态复位,不是 MCU 重启**(无 USB 重枚举,同一对象仍可用) | -| `clear_faults()` | 只清 RAM 故障位,**不写 flash** | -| `set_motion_mode(mode)` | **固件只认 `0`**,其它值本地抛 `InvalidCommandError`(fail-closed) | +| --- | --- | +| `enable(attempts=12)` | 使能全部关节。重试是白名单,**只有 `(0x10, 0x03)` 会重试** | +| `disable()` | 切断位置环。此后机械臂不再被托住 | +| `emergency_stop()` | 单帧、单向、不读状态,唯一没有前置条件的入口 | +| `reset()` | 软件状态复位,**不是 MCU 重启**,同一对象仍可用 | +| `clear_faults()` | 只清 RAM 故障位,不写 flash | +| `set_motion_mode(mode)` | 固件只认 `0`,其它值本地抛 `InvalidCommandError` | | `park()` | 等价于 `set_motion_mode(0)` | ### 5.2 关节运动 @@ -235,15 +243,15 @@ home(*, timeout=None) -> RobotState ``` | 方法 | 注意 | -|---|---| -| `movej(q, speed=1.0)` | **单发**:固件规划 S 曲线、自完成、到位后静止保持(无需 PC 逐帧保活)。`speed` ∈ `0..1`。⚠ **不校验关节限位** —— 越限目标会被固件 `clampf` 后**照走满行程**。发之前自己比对限位 | -| `movej_sync(q, speed=1.0)` | 同步 PTP,各轴一起到位 | -| `move_js(q, dq=None, tau_ff=None)` | 低层关节流,**绕过规划**。⚠ `dq` 是**速度参考,不是限位**;**需调用方 ≥10 Hz 重发**,否则 0.1 s 看门狗 fail-soft | -| `home(*, timeout=None)` | 固件 `CMD_HOME 0x2A`。⚠ `timeout` 是**关键字专用**;固件把速度**写死 0.10,不接受 speed**。与 `movej` 不同,固件**明确允许**从越软限/贴端位姿发起 `home` | +| --- | --- | +| `movej(q, speed=1.0)` | 单发:固件规划 S 曲线并走完,到位后静止保持。`speed` ∈ `0..1`。**不校验关节限位**,越限目标会被截断后走满行程 | +| `movej_sync(q, speed=1.0)` | 同步点到点,各轴一起到位 | +| `move_js(q, dq=None, tau_ff=None)` | 低层关节流,绕过规划。`dq` 是速度参考不是限位;需调用方 ≥10 Hz 重发 | +| `home(*, timeout=None)` | 回零。**须先 `enable()`**,否则 `ERR{0x2A,0x03}`。`timeout` 关键字专用;固件把速度写死 0.10,不接受 `speed`。固件**允许**从越软限 / 贴端位姿发起 | -> ⚠ `home(speed=0.3)` 会抛 `TypeError`。写 `arm.home()` 或 `arm.home(timeout=30.0)`。 +`home(speed=0.3)` 会抛 `TypeError`,写 `arm.home()` 或 `arm.home(timeout=30.0)`。 -### 5.3 笛卡尔(**固件规划**) +### 5.3 笛卡尔运动 ```python move_p(pose, speed=1.0, pos_tol=0.006, rpy_tol=0.03) -> RobotState @@ -254,31 +262,27 @@ poll_cart() -> Optional[CartPlan] set_speed(percent) ``` -**规划全在固件里**:PC 只发点、收 `0x4E` 结果帧。三条路径入口的分工: +规划全在固件里,PC 只发点、收 `0x4E` 结果帧。 | 方法 | 末端走什么 | -|---|---| -| `move_p(pose)` | **关节空间**插值(点到点,**不是**直线) | -| `move_l(pose)` | **直线**(位置线性 + 姿态 slerp) | -| `move_c(start, via, goal)` | **圆弧**(三点定圆;`via` 的姿态被忽略) | -| `move_path(poses)` | 依次经过多路点(**尖角**,协议无倒角字段) | +| --- | --- | +| `move_p(pose)` | 关节空间插值,点到点,**不是直线** | +| `move_l(pose)` | 直线(位置线性 + 姿态球面插值) | +| `move_c(start, via, goal)` | 圆弧(三点定圆,`via` 的姿态被忽略) | +| `move_path(poses)` | 依次经过多个路点,**尖角** | | 方法 | 注意 | -|---|---| -| `move_p` | ⚠ **只收单个位姿**,传序列抛 `InvalidCommandError`。到位判据是 TCP 容差 | +| --- | --- | +| `move_p` | 只收单个位姿,传序列抛 `InvalidCommandError`。到位判据是 TCP 容差 | | `move_l` / `move_c` / `move_path` | 返回 `CartPlan`。`wait=False` 时不阻塞,用 `poll_cart()` 查进度 | -| `move_c` | ⚠ `start` **必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)—— 它是校验收到的,不是自由参数 | -| `poll_cart()` | 只读收集器的待认领队列,**不碰链路** | -| `set_speed(percent)` | 全局调速。⚠ **非线性**(100→50 只慢 1.48×),且入参必须是 `0..100` 的 **`int`** | +| `move_c` | `start` 必须与调用时的实测 TCP 一致(容差 6 mm / 0.03 rad),写成 `arm.get_tcp().value` | +| `poll_cart()` | 只读收集器的待认领队列,不碰链路 | +| `set_speed(percent)` | 全局、**持续**的调速器。`percent` 是 **0..100 的整数百分比**——`set_speed(1)` 就是 **1% 速度**,不是"满速";它和 `movej(speed=0..1)` 的单条轨迹倍率不是一回事 | -**已知的降级(相对 PC 侧规划,2.0 起有意为之)**:**无拐角倒角**、 -**无下发前预览**(固件没有 dry-run,`0x4E` 要发出去才回)、 -**PC 侧速度预检取消**(判据只在固件手里一份)。 +能力边界:无拐角倒角,无下发前预览,速度预检只在固件里。 -⚠ 失败的三种形态(都抛 `CartesianPlanError`,**整条拒绝、臂一步没动**): -`err=1` IK 无解 / `err=2` 三点共线 / `err=3` 超容量、不可达。 - -⚠ **`movel` / `movec` / `movep` 已更名**为 `move_l` / `move_c` / `move_p`,旧名不存在。 +失败都抛 `CartesianPlanError`,整条拒绝、臂一步没动: +`err=1` 逆解无解 / `err=2` 三点共线 / `err=3` 超容量、不可达。 ### 5.4 位姿 / 运动学 @@ -288,11 +292,11 @@ ik(pose, q_seed=None, timeout=3.0) -> list[float] ``` | 方法 | 注意 | -|---|---| -| `get_tcp()` | 当前末端位姿(固件 FK),**6 个数**(不是旋转矩阵) | -| `ik(pose, q_seed=None)` | 反解。⚠ 可能返回**另一个同样有效的分支** ⇒ 与 seed 差得远**未必**是错误 | +| --- | --- | +| `get_tcp()` | 当前末端位姿(固件正运动学),6 个数 | +| `ik(pose, q_seed=None)` | 反解。可能返回另一个同样有效的分支,与 `q_seed` 差得远未必是错误 | -> ⚠ **没有 `fk(q)`** —— PC 不带运动学模型,FK 只有"当前反馈"这一条路(`get_tcp()`)。 +没有 `fk(q)`。PC 侧不带运动学模型,正运动学只有 `get_tcp()` 这一条路。 ### 5.5 前馈 / 动力学调参 @@ -303,27 +307,28 @@ set_ff_vec(item, values) # values 长度必须 = n set_ff_scalar(item, sub, value) get_ff_vec(item, timeout=1.0) -> Msg[list[float]] get_ff_scalar(item, sub=0, timeout=1.0) -> Msg[float] -get_ff_mask(timeout=1.0) -> int # ⚠ 裸 int,不是 Msg +get_ff_mask(timeout=1.0) -> int # 裸 int,不是 Msg set_gravity_scale(gs) # 长度 = n set_inertia_scale(isc) # 长度 = n set_payload(mass, com=(0.0, 0.0, 0.0)) set_gravity_vector(g) # 长度 3 ``` -⚠ **`get_ff_mask()` 是裸 `int`**(它是 `get_ff_scalar(9, 0)` 的标量投影; -要那一帧的信封就直接调 `get_ff_scalar`)。 +`get_ff_mask()` 返回裸 `int`,要信封就调 `get_ff_scalar`。 + +`set_ff_vec` 的 item 12~15 只有通用入口:`12 zg_kp` / `13 zg_kd` / `14 zg_damping` +(拖动示教用)、`15 kd_extra`(软件微分阻尼,可治 `movej` 起步振铃)。 +**`kd_extra` 的腕部 J5–J7 必须留 0**(出厂 `[6,6,6,6,0,0,0]`)。 -⚠ `set_ff_vec` 的 **item 12~15** 只有通用入口,没有具名方法: -`12 zg_kp` / `13 zg_kd` / `14 zg_damping`(零重力拖动示教用)、 -`15 kd_extra`(τ 域软件微分阻尼,治 `movej` 起步振铃)—— -**`kd_extra` 的腕部 J5-J7 必须留 0**(出厂 `[6,6,6,6,0,0,0]`)。 +item 名表是 `Arm` 的类属性,也是入参校验白名单:`FF_VEC_ITEMS`(1..15)、 +`FF_SCALAR_ITEMS`(1..18,缺 9)、`FF_SCALAR_RO_ITEMS`(只有 9)。 -⚠ item 名表是 `Arm` 上的类属性(也是入参校验用的白名单): -`FF_VEC_ITEMS`(1..15)、`FF_SCALAR_ITEMS`(1..18,缺 9)、`FF_SCALAR_RO_ITEMS`(只有 9 = `ff_mask`)。 +写的是 RAM,要持久化得调 `save_params()`。 -⚠ 写的是 **RAM**,要持久化得调 `save_params()`。 +`set_ff_vec` 写入时,**任一分量是 `NaN` 会被固件整组拒绝**(`ERR{0x26,0x02}`); +幅值超限则**静默钳制**到该 item 的合法区间,不报错。 -### 5.6 零重力拖动示教 +### 5.6 拖动示教 ```python zero_g(period=0.04) # 上下文管理器 @@ -331,38 +336,37 @@ zero_g_start(period=0.04) zero_g_stop(raise_on_lost=False) ``` -`zero_g()` 是**客户端组合**(`zero_g_start` + `zero_g_stop`),**不是 RPC**。 +`zero_g()` 是客户端组合(`zero_g_start` + `zero_g_stop`),不是单条命令。 ```python with arm.zero_g(): input("拖动机械臂,然后回车") ``` -- **保活由 SDK 后台线程自动重发**(默认 `period=0.04 s`)—— 固件 `0x06` 自带 - `watchdog_kick`,**0.10 s 不重发即掉出 fail-soft**。`period` 必须 ∈ `[0.005, 0.10)`。 -- **保活期拒绝其它动作命令**;**查询类不受限**,**急停/失能例外**。 -- **退出是异步的**:`zero_g_stop()` 返回后固件侧还要一点时间收尾。 -- 保活若因**写失败**中断,退出时**抛异常**而非静默。 +- 保活由 SDK 后台线程自动重发,`period` 必须在 `[0.005, 0.10)` 内。 +- 保活期拒绝其它动作命令,查询类不受限,急停 / 失能例外。 +- 退出是异步的,`zero_g_stop()` 返回后固件侧还要一点时间收尾。 +- 保活因写失败中断时,退出抛异常而不是静默。 - 只读属性 `zero_g_active` / `zero_g_error` 可查状态。 -### 5.7 透传 / 伺服(**第二条通路**) +### 5.7 透传 / 伺服 ```python -move_js(q, dq=None, tau_ff=None) send_mit(idx, q, dq, kp, kd, tau) send_mit_all(q, dq, kp, kd, tau) ``` -⚠ **这三个绕过运动规划,且调用方必须自己保活**:**需 ≥10 Hz 重发**, -否则 0.1 s 命令看门狗 fail-soft(降刚度 + τ=0),臂在重力下缓慢塌下去。 +绕过运动规划,**调用方必须自己保活**:需 ≥10 Hz 重发,否则 0.1 s 看图门狗进入 fail-soft +(降刚度 + τ=0),机械臂在重力下缓慢塌下去。 -⚠ `send_mit_all` 的五个数组长度必须 = `n`,且必须是**有限数**。 +`send_mit_all` 的五个数组长度必须都等于 `n`(本地校验),且**必须是有限数** —— +`NaN` / `Inf` 会被**固件**整帧拒收,回 `ERR{cmd,0x02}`。`send_mit` / `move_js` 同样校验有限性。 -⚠ **本组入口在真机上未经完整验证**(见[TROUBLESHOOTING](../TROUBLESHOOTING.zh-CN.md#明确未验证的部分))。 +本组入口在真机上未经完整验证,见[排障指南 §16](../TROUBLESHOOTING.zh-CN.md#16-尚未验证的部分)。 ### 5.8 子对象 -#### `arm.params.*` —— 关节级参数(4) +#### `arm.params.*` —— 关节级参数 ```python set_joint_param(idx, kp, kd, tau_max) @@ -374,11 +378,10 @@ reset_factory() `JointParam`:`idx`、`kp`、`kd`、`tau_max`、`q_min`、`q_max`。 -- ⚠ `set_joint_limits()` **只许收窄**:写回**当前值**会被判成"放宽请求"并拒 - (`ERR[23,2]`)⇒ **该入口不幂等**,别用它做读写回环校验。 -- ⚠ `reset_factory()` 要求**失能态**,已使能回 `ERR{0x36,0x04}`。**不可逆**。 +`set_joint_limits()` **只许收窄**,写回当前值会被判成放宽请求并拒(`ERR[23,2]`), +所以它不幂等,别拿它做读写回环。`reset_factory()` 要求失能态,且不可逆。 -#### `arm.model.*` —— 动力学模型在线导入(9) +#### `arm.model.*` —— 动力学模型在线导入 ```python probe() -> bool @@ -394,13 +397,19 @@ revert() `ModelStatus`:`override`、`staged_mask`、`dirty`。 -- 写入进 **staging 层**,**不生效** —— 判据应是 `staged_mask`,不是"生效层读回"。 -- `commit(expected_mask)` 才应用,`revert()` 丢弃。 -- ⚠ **`set_jm()` 建议永不调用**:它改关节映射(含符号),改错有**乱飞**风险, - 而本机唯一的恢复手段(`revert` / `save_params`)**本身也不可逆** ⇒ 没有可依赖的退路。 -- ⚠ `commit()` / `revert()` 都会**覆盖本机逐台辨识的动力学模型**,见 §10。 +写入进 staging 层,不立即生效,判据看 `staged_mask`。`commit(expected_mask)` 才应用, +`revert()` 丢弃。**两者都要求失能态**,已使能时分别回 `ERR{0x32,0x04}` / `ERR{0x37,0x04}`。 + +⚠ `revert()` **只回退 RAM,不动 flash**,三条后果必须知道: + +1. flash 里那份导入模型还在,**重新上电会复活**; +2. 此后任何一次 `save_params()` 会把 flash 里那份**一并抹掉**(整扇区擦除,不可恢复); +3. 回退后 `status().dirty == 1`(RAM ≠ flash),但**别**据此提示"补固化"。 -#### `arm.log.*` —— 300 Hz 控制拍采集(4 + `LogReader`) +**`set_jm()` 建议永不调用**:它改关节映射(含符号),改错有乱飞风险, +而本机唯一的恢复手段本身也不可逆。 + +#### `arm.log.*` —— 300 Hz 控制拍采集 ```python start(n_ticks) @@ -410,100 +419,74 @@ capture(n_ticks, timeout=1.0, retries=3, record_timeout=None) -> list[LogSample] dump(path, wait=True, timeout=1.0, retries=3, record_timeout=None) -> int ``` +`r = arm.log.reader()` 拿到的 `LogReader` 提供: + ```python -r = arm.log.reader() -r.total() # 本会话已落盘的拍数 +r.total() # 已落盘的拍数 r.wait_for(n_ticks, timeout=None, poll=0.05) -> int -r.read_all() -> bytes # 读回全部缓冲 +r.read_all() -> bytes r.samples() -> list[LogSample] r.iter_chunks() -> Iterator[bytes] r.total_bytes # 属性 ``` -`LogSample`:`tick`、`q_ref`、`dq`、`tau`。模块常量:`LOG_MAX_SAMPLES = 2400`(≈8 s@300 Hz,**记满自停**)、`CTRL_HZ = 300`。 +`LogSample`:`tick`、`q_ref`、`dq`、`tau`。 +常量:`LOG_MAX_SAMPLES = 2400`(约 8 s @300 Hz,记满自停)、`CTRL_HZ = 300`。 -- `reader()` 是**工厂**:按固件游标 `next_byte` 分块读回,**掉帧自动按游标重试**。 -- `dump(path)` 落盘原始字节流(满量程 ≈ 863 次往返,大缓冲建议先落盘再解析)。 -- ⚠ **失能态下 `capture()` 恒录到 0 拍** —— 这是**固件行为**,不是缺陷。 +`reader()` 按固件游标分块读回,掉帧自动重试。`dump()` 落盘原始字节流。 -#### `arm.diag.*` —— 固件自检(1) +#### `arm.diag.*` —— 固件自检 ```python kin_bench(timeout=8.0) -> Msg[KinBenchResult] ``` -`KinBenchResult` 的方法/属性:`raw`、`timings`、`link`,以及 -`crc_errors`、`reply_dropped`、`can_tx_fail`、`loop_max_kcycle`、`loop_overruns`、 -`rx_fifo_lost_motor`、`rx_fifo_lost_bridge`、`gsusb_ring_drops`。 +`KinBenchResult`:`raw`、`timings`、`link`,以及 `crc_errors`、`reply_dropped`、 +`can_tx_fail`、`loop_max_kcycle`、`loop_overruns`、`rx_fifo_lost_motor`、 +`rx_fifo_lost_bridge`、`gsusb_ring_drops`。 -⚠ **它是回链路的唯一诊断计数来源**(`crc` / `reply_dropped` / `can_tx_fail` / -`loop_max_kcycle` / `loop_overruns`)。 -⚠ 但**五个计数器全 0 可能是"静默 0"**,见[TROUBLESHOOTING §11](../TROUBLESHOOTING.zh-CN.md#11-kin_bench-的五个计数器全-0--静默-0)。 +它是回链路诊断计数的唯一来源,但**计数器全 0 可能是根本没读到**, +见[排障 §11](../TROUBLESHOOTING.zh-CN.md#11-kin_bench-的计数器全是-0)。 -### 5.9 授权 / 激活(固件 1.8.0+) - -```python -license(timeout=1.0) -> LicenseInfo -activate(*, cust_id, issued, flags=0, mac, timeout=2.0) -> None -``` - -`LicenseInfo`:`state`、`ver`、`uid`、`cust_id`、`issued`、`flags` -+ 派生 `activated`、`factory_mode`、`state_name`、`uid_hex`。 - -- 固件在**独立 flash 扇区**(sector 6)存一条记录,**写一次永不擦**; - 未激活时**只锁 `ENABLE`**(`ERR{0x10,0x08}`),其余命令一切照常。 -- `license()` **未激活时不抛异常**(它是一种**状态**),且**未激活也回 UID** —— - 那是签发器的唯一来源,别改用 USB 序列号字符串。 -- `activate()` **全部参数关键字专用**,`mac` 无默认值。**须先失能**,否则 `ERR{0x3F,0x04}`。 -- ⚠ **本包不含密钥,也不含任何算 MAC 的代码** —— 签发在厂商侧工具里。 -- ⚠⚠ `ERR{0x3F,0x02}` 是**聚合档**(已激活/MAC 不符/密钥非法/写失败同码)。 - 本包在这一档**自动回读 `0x2F`**:设备确实 `state != 0` 就当成功返回,否则才抛。 -- 擦除授权记录**只能走 SWD**(`pyocd erase -s 0x080C0000`)—— 固件**没有**擦除命令。 - -### 5.10 烧录 DFU +### 5.9 固件升级(DFU) ```python enter_dfu(timeout=0.3) -> None ``` -**唯一的终端态操作。** 免探针进 ROM 系统 bootloader(`CMD_ENTER_DFU 0x15`)。 +唯一的终端态操作,免探针进 ROM bootloader。 -- **两段式**:`ACK{0x15}` 只表示"已登记",还要等设备真的从 CDC 上消失。 -- 使能中**本地拒绝**(跳转会停 TIM3 ⇒ 电机 100 ms 松开、有负载则下垂)。 -- 成功返回后**本 `Arm` 不可再用**(所有入口抛 `ArmIsInDfuError`,`close()` 例外), - 设备重枚举成 `0483:DF11`,烧完固件**新建一个 `Arm`**。 -- 超时未消失则抛"登记被撤销/未执行",且对象照旧可用。 +- 两段式:`ACK{0x15}` 只表示已登记,还要等设备真的从 CDC 上消失。 +- 使能中本地拒绝(跳转会停 TIM3,电机 100 ms 松开)。 +- 成功后本 `Arm` 不可再用(所有入口抛 `ArmIsInDfuError`,`close()` 例外), + 设备重新枚举成 `0483:DF11`,烧完固件新建一个 `Arm`。 +- 超时未消失则抛异常,对象照旧可用。 -> ⚠ **进 DFU 后不能立刻刷** —— 要等 USB 重枚举。**先证明能救,再故意弄坏。** +进 DFU 后不能立刻刷,要等 USB 重新枚举。 -### 5.11 持久化 +### 5.10 参数持久化 ```python save_params() -> None ``` -写 flash(`0x25`)。⚠ **不可逆**,见 §10。 +写 flash,不可逆。 -### 5.12 只读属性 +### 5.11 只读属性 ```python params / model / log / diag # 子对象 last_reset_reason # "normal" / "iwdg-rst" / None -zero_g_active # bool -zero_g_error # Optional[BaseException] - -n # 关节数(握手后可用) -firmware # 版本串,如 "Litearm1.8.0-7J" -fw_version # 元组,如 (1, 8, 0) -min_firmware # 本包要求的下限 -q_tol / dq_tol / arrive_frames # 到位判据 -move_timeout # 运动超时 +zero_g_active / zero_g_error + +n # 关节数 +firmware / fw_version # 版本串 / 元组 +min_firmware / q_tol / dq_tol / arrive_frames / move_timeout bench_model_axis # 台架标定轴 ``` -> ⚠ **`last_reset_reason` 常态是 `None`,那是正确行为** —— -> 开机 banner **只在真 MCU 复位后发一次**,`reset()` 不会让它重发。 -> 见[TROUBLESHOOTING §10](../TROUBLESHOOTING.zh-CN.md#10-last_reset_reason-是-none多数时候正确)。 +`last_reset_reason` 常态是 `None`,那是正确行为:开机签名只在真 MCU 复位后发一次, +`reset()` 不会让它重发。 --- @@ -517,138 +500,107 @@ from litearm import ( FirmwareMismatchError, InvalidCommandError, MotorFaultError, MotionTimeoutError, IKError, CommandRejectedError, UnsupportedByFirmwareError, CartesianPlanError, MotionSupersededError, CartReplyLostError, - ArmIsInDfuError, NotRemoteable, NotSupportedOnThisBackend, - TeleopLockedError, TeleopBusyError, + ArmIsInDfuError, ) ``` | 异常 | 何时抛 | -|---|---| -| `NotConnectedError` | 未连接时调用;`close()` 之后调用 | -| `ForkedSessionError` | **`fork` 出来的子进程**里使用继承来的会话(`NotConnectedError` 的子类)。fail-closed:**一个字节都不下发** | +| --- | --- | +| `NotConnectedError` | 未连接时调用,或 `close()` 之后调用 | +| `ForkedSessionError` | 子进程里使用继承来的会话(`NotConnectedError` 子类),零下发 | | `TransportError` | 串口读写失败,或帧 CRC 校验不过 | -| `FirmwareMismatchError` | 固件不符合 `Litearm<主.次.修>-{7J\|1J}` 约定,或低于下限 | -| `InvalidCommandError` | 参数非法(长度、越界、类型)—— 大多在**本地**就拦下 | -| `MotorFaultError` | 状态帧 FAULT 标志或 EMERGENCY(含 G7 单轴故障降级) | +| `FirmwareMismatchError` | 固件不符合命名约定,或低于下限 | +| `InvalidCommandError` | 参数非法(长度、越界、类型),大多在本地就拦下 | +| `MotorFaultError` | 状态帧出现 FAULT 标志或 EMERGENCY | | `MotionTimeoutError` | 运动在 `move_timeout` 内未到位 | | `IKError` | 反解失败 / 目标不可达 | -| `CommandRejectedError` | 固件明确回 `ERR`。带 `.cmd` / `.code`(**`.cmd` 是固件回显的,比我刚发出去的可信**) | -| `UnsupportedByFirmwareError` | 固件**没有实现**这条命令(`code == 0x00`)。**子类是 `CommandRejectedError`** | -| `CartesianPlanError` | 固件规划被拒(IK 无解 / 三点共线 / 超容量 / 越限)。⚠ 它**直挂 `LiteArmError`**,**不**继承 `InvalidCommandError` | -| `MotionSupersededError` | 笛卡尔请求被新请求取代 —— **预期内的接管,不是失败**。**刻意不**继承 `CartesianPlanError` | -| `CartReplyLostError` | `0x4E` 应答丢失 ⇒ **结局未知**(SDK 自造语义,不是固件码) | -| `ArmIsInDfuError` | 本 `Arm` 已把设备交给 ROM bootloader —— **终端态**,不可复活。**刻意不**是 `NotConnectedError` | -| `NotRemoteable` | 该入口不可远程化 | -| `NotSupportedOnThisBackend` | 当前后端未实现该能力 | -| `TeleopLockedError` / `TeleopBusyError` | 遥操状态拒绝该命令 | - -> ⚠ **捕获面的一个有意的变化**:`CartesianPlanError` 2.0 起直挂 `LiteArmError`, -> 所以 `except InvalidCommandError` **不再覆盖它** —— 固件回的是**规划结果**, -> 不是"这条命令被拒绝"。 - -**错误码**:`ERR{cmd, code}` 里 `code == 0x00` **恒表示"固件没有这条命令"**, -是稳定且唯一的能力探测哨兵。 +| `CommandRejectedError` | 固件明确回 `ERR`,带 `.cmd` / `.code` | +| `UnsupportedByFirmwareError` | 固件没实现这条命令(`code == 0x00`),是 `CommandRejectedError` 的子类 | +| `CartesianPlanError` | 固件规划被拒。直挂 `LiteArmError`,**不**继承 `InvalidCommandError` | +| `MotionSupersededError` | 笛卡尔请求被新请求取代,是预期内的接管,不是失败 | +| `CartReplyLostError` | `0x4E` 应答丢失,结局未知 | +| `ArmIsInDfuError` | 本 `Arm` 已把设备交给 ROM bootloader,终端态 | ---- +`except InvalidCommandError` 不覆盖 `CartesianPlanError`,固件回的是规划结果, +不是命令被拒绝。 -## 7. 架构 —— 一条读线程 +错误码 `ERR{cmd, code}` 里 `code == 0x00` 恒表示固件没有这条命令。 -### 形状 +--- -每个会话在 `connect()` 时起**一条**后台读线程(`litearm-reader`,daemon)。 -它是全包**唯一**碰 `transport.read_frame` 的地方,只做两件事: -**把帧投递到它该去的队列、死了要响亮。** +## 7. 注意事项 -```text -一条读线程: 帧来了 → 按 (帧号, 回显码) 放进对应的队列 -其余所有人: 发命令 → 在自己那几条队列上等 -``` - -**帧的归属 = 它落在哪条队列**,**不由线程决定**。 -`ACK{0x10}` 与 `ACK{0x11}` 天然落在**两条**队列 ⇒ 并发命令不会互吃应答。 +1. **臂绝不能飞出去。** 这是硬线,也是每次安全改动的验收判据。 +2. **子进程不能用继承来的会话**,fail-closed,零下发。父进程必须先释放端口。 +3. **`movej` 返回 ≠ 停稳**,残差约 0.012 rad,在 `q_tol` 内。 +4. **`movej` / `movej_sync` 不校验关节限位**,发之前自己比对。 +5. **`disable()` 之后臂不再被托住。** +6. **`save_params()` 没有撤销。** -- `RSP_STATUS`(100 Hz 连续流)**不进队列**:它进**单槽** + 到达序号, - 等待方等的是"序号前进"。 -- 陈旧应答靠**发帧前清队**挡(在**唯一写口** `_raw_write` 里); - `echo_cmd` 对 `ACK`/`ERR` 是**必填** ⇒ 同 id 互吃在**结构上不可能**。 +### 不可逆命令:不要在标定过的臂上执行 -### 只有两条规矩 +下面四条会覆盖或抹掉该台设备逐台辨识的动力学模型,唯一解锁方式是在一台没有标定价值的板子上做: -1. **读线程只投递,不判定。** -2. **读线程死了要响亮。** 传输层异常存进 `_reader_error` → 唤醒所有等待者 → - 由它们抛出。**绝不静默退出。** +| 命令 | 入口 | +| --- | --- | +| `0x25` | `save_params()` | +| `0x32` | `arm.model.commit()` | +| `0x36` | `arm.params.reset_factory()` | +| `0x37` | `arm.model.revert()` | -### 三条实现约束(都是错不起的) +`model.set_jm()` 建议永不调用,改错关节映射有乱飞风险,而没有可依赖的退路。 -- **`close()` 必须先停线程,再关传输。** 否则读线程会从已关掉的传输上抛 `TransportError`, - 一次**正常关闭**会被记成"链路丢失"。顺序:`zero_g_stop()` → 停读线程 + `join(1.0)` → 关传输。 -- **读线程绑死"起线程那一刻的 `_Ack`"**,不在循环里再读 `self._a` —— - 否则 `reconnect()` 的窗口期会把帧投递到新旧混杂的对象上。 -- **`_Ack` 持有 `Arm` 的弱引用。** 线程的 target 是绑定方法 ⇒ 线程强引用 `_Ack`; - `_Ack` 再强引 `Arm` 的话,**忘了 `close()` 的 `Arm` 再也回收不掉**, - `__del__` 的兜底收尾永不触发 ⇒ 端口不释放。 +压测 CAN 链路只开 `candump`(只读),绝不 `cangen`:`can0` 就是电机总线。 -### 代价 +尚未验证的部分见[排障指南 §16](../TROUBLESHOOTING.zh-CN.md#16-尚未验证的部分)。 -⚠ 每个会话**多一条线程**。读线程的退避用 `Event.wait` 而非 `time.sleep` -(后者会被测试里"等了多久"的探针抓走成千上万次)。 +--- -⚠ **`fork` 之后子进程不可用** —— 这是这套架构**唯一**的系统级代价, -对应 README「两条必读」第 1 条。 +## 8. 架构 -### 本机制**不解决**的(别指望) +每个会话在 `connect()` 时起一条后台读线程(`litearm-reader`,daemon)。 +它是全包唯一碰传输层读口的地方,只做两件事:**把帧投递到该去的队列、死了要响亮。** -| 不解决 | 根因 | 在哪一层 | -|---|---|---| -| 两条**完全相同**的命令并发时的配对 | 线上**没有请求 id** | 线协议 | -| 固件 `plan_pending` 单槽 ⇒ 笛卡尔并发深度 1 | 设备侧容量 | 固件 | -| 一条还在传输缓冲/线上的陈旧应答躲过清队 | 同上(无请求 id)——**知情的、不可再约的残余窗口** | 线协议 | +```text +一条读线程: 帧来了 → 按 (帧号, 回显码) 放进对应的队列 +其余所有人: 发命令 → 在自己那几条队列上等 +``` -> 📌 实测:长时并发下队列深度**峰值 = 1**(封顶 64 从未接近), -> 固件侧 `crc` / `rxfifo` 计数增量全 0 —— **结构化不丢帧**。 +帧的归属由它落在哪条队列决定,不由线程决定。`ACK{0x10}` 与 `ACK{0x11}` 天然落在两条队列, +所以并发命令不会互吃应答。`RSP_STATUS`(100 Hz 连续流)不进队列,它进单槽 + 到达序号。 -### 传输层的两条性能事实 +两条规矩:**读线程只投递不判定**;**读线程死了要响亮**,异常存进 `_reader_error` +唤醒所有等待者,绝不静默退出。 -- **读是"有多少读多少"**(按 `in_waiting` 要、封顶 4096 字节),**不是逐字节读**。 - 逐字节读的代价与字节数成正比,而这块板子空闲时状态流就有 ~14 kB/s - ⇒ **常驻一整核**。 -- **CRC16 走 `binascii.crc_hqx`**(poly `0x1021` / init `0xFFFF`),不是纯 Python 双循环 —— **232×** 快。 +代价是每个会话多一条线程,这也是子进程不能继承会话的原因。 -实测收益(裸 SDK 会话空闲 CPU):逐字节 + 纯 Python CRC **98.8%** -→ 有多少读多少 **21.4%** → CRC 换 C 实现 **6.8%**。 +不解决的:线上没有请求 id,两条完全相同的命令并发时无法配对;固件只装一条待执行的 +笛卡尔规划,所以笛卡尔并发深度是 1。 ### 文件 ```text src/litearm/ - _protocol.py 帧编解码 (0xA5 CMD LEN PAYLOAD CRC16-CCITT-FALSE) + 命令/应答常量 - + 状态帧解析 + 开机签名解析 + COMMAND_COVERAGE 覆盖契约 - ⚠ 授权那两条也在这里 (0x2F/0x3F/0x4F/0x50); 没有算 MAC 的代码 - transport.py pyserial CDC 读写/自动发现 (VID:PID 1d50:606f); - 读写各一把锁 (zero_g 保活线程是第一个并发写入者) + _protocol.py 帧编解码 + 命令/应答常量 + 状态帧解析 + 命令覆盖契约 + transport.py pyserial CDC 读写 / 自动发现 state.py RobotState / JointState - errors.py 错误层级 + ERR_TEXT 码表 - arm.py Arm 核心 + CLI + 读线程 `_Ack` + fork 守卫 - cart.py 笛卡尔: 0x4E 配对 + 到位判据 (bit10) + 能力探测 - ⚠ PC 侧不做规划 —— 规划在固件里 - params.py arm.params.* 关节级参数 - model.py arm.model.* 动力学模型在线导入 - log.py arm.log.* 300Hz 采集 + LogReader 游标读回 - diagnostics.py arm.diag.* KIN_BENCH 自检 - _rot.py 旋转/位姿纯数学 (rpy⇄矩阵, as_pose 归一化) - testing.py 公开的离线桩 (FakeTransport) —— 按固件真实布局造帧与应答 + errors.py 异常体系 + 错误码文案 + arm.py Arm 核心 + 命令行 + 读线程 + fork 守卫 + cart.py 笛卡尔:结果帧配对 + 到位判据 + params.py arm.params.* + model.py arm.model.* + log.py arm.log.* + diagnostics.py arm.diag.* + _rot.py 旋转 / 位姿纯数学 + testing.py 离线桩(FakeTransport) ``` -### 覆盖契约 - -固件 `hal/usb_cmd.h` 里每条**已实现**的下行命令都有对应入口。 -契约落在 `_protocol.COMMAND_COVERAGE`(命令 id → SDK 入口), -由 `tests/test_protocol_sync.py` **直接解析固件头文件**双向强制 —— -固件加了命令而 SDK 没跟上、或状态帧布局被改动,测试立刻失败。 +固件里每条已实现的下行命令都有对应入口,契约落在 `_protocol.COMMAND_COVERAGE`, +由 `tests/test_protocol_sync.py` 直接解析固件头文件双向强制。 --- -## 8. 命令行 +## 9. 命令行 ```bash litearm-python [--port PORT] [ACTION] [TARGETS...] [--speed SPEED] @@ -665,72 +617,31 @@ litearm-python tcp # 当前位姿 + 帧率 litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 ``` -⚠ `movej` 要求 `TARGETS` 个数**正好等于 `arm.n`**;`home` 的 `--speed` **被忽略** -(固件写死 0.10)。 +`movej` 要求 `TARGETS` 个数正好等于 `arm.n`;`home` 的 `--speed` 被忽略(固件写死 0.10)。 --- -## 9. 测试 +## 10. 测试 ```bash -pytest # 离线全流程(桩 transport,不碰真机) -PYLITEARM_LIVE=1 pytest # + 真机 live(需接 Litearm1.5.0+ 整臂/台架,会小幅运动) -python tests/test_offline.py # 无 pytest 也可(脚本式断言) +pytest # 离线全流程,不碰真机 +LITEARM_LIVE=1 pytest # 额外跑真机用例 +python tests/test_offline.py # 不装 pytest 也能跑 ``` -**跑测试不需要装包** —— `tests/conftest.py` 会把 `src/` 与 `tests/` 自己塞进 `sys.path`。 - -⚠ **无人在场时不要设 `PYLITEARM_LIVE`**,也绝不调用 `enter_dfu()` / `reset_factory()`。 - -关键的几组: +跑测试不需要装包,`tests/conftest.py` 会把 `src/` 与 `tests/` 塞进 `sys.path`。 +无人在场时不要设 `LITEARM_LIVE`,也绝不调用 `enter_dfu()` / `reset_factory()`。 | 测试 | 守什么 | -|---|---| -| `test_protocol_sync.py` | **协议漂移防护** —— 直接解析固件仓库的 `usb_cmd.h` / `usb_cmd.c` / `joint_cfg.h`,双向比对命令集合与 ID、钉死状态帧布局。固件仓库位置由 `LITEARM_FW_DIR` 指定;**找不到时 skip 而不是 pass** | -| `test_protocol_crc.py` | CRC16 —— 用**外部权威检查值**(`"123456789"` → `0x29B1`)+ 文件内独立参考实现,刻意不依赖被测实现 | -| `test_frame_ownership.py` | 帧归属契约:**帧没被静默销毁,它的主人拿到了它** | -| `test_transport.py` | 真实字节流解析(连续帧/噪声/坏帧/半帧/两种到达方式解出同样的帧) | -| `test_ack_echo.py` | ACK 必须回显原命令(迟到旧 ACK 不得让新命令"假成功") | -| `test_capability.py` | `ERR{cmd,0x00}` → `UnsupportedByFirmwareError`("真拒绝"不得误判) | +| --- | --- | +| `test_protocol_sync.py` | 协议漂移防护,直接解析固件头文件比对命令集合与 ID。固件仓库位置由环境变量 `LITEARM_FW_DIR` 指定(默认 `~/litearm-stm32`),**找不到时 skip 而不是 pass** | +| `test_protocol_crc.py` | CRC16,用外部权威检查值 + 独立参考实现 | +| `test_frame_ownership.py` | 帧没被静默销毁,它的主人拿到了它 | +| `test_transport.py` | 真实字节流解析(连续帧 / 噪声 / 坏帧 / 半帧) | +| `test_ack_echo.py` | 应答必须回显原命令,迟到的旧应答不得让新命令假成功 | +| `test_capability.py` | 固件没有这条命令 → `UnsupportedByFirmwareError` | | `test_fork_guard.py` | fork 守卫:子进程零下发 | -| `test_zero_g.py` | 零重力保活周期/退出收尾/线程回收/命令门禁 | -| `test_status_layout.py` | `6+21N` 与 `4+21N` 两种状态帧布局 | -| `test_live.py` | 真机冒烟(`PYLITEARM_LIVE=1` 才跑) | - -⚠ **`tests/fake_serial.py` 的桩必须与固件真实布局一致** —— -桩若与真固件不一致,离线会"假绿"而真机必挂。这个教训有专门测试守着。 - ---- - -## 10. 安全须知 - -1. **臂绝不能飞出去。** 这是产品的硬线,也是每一次安全改动的验收判据。 -2. **`fork` 之后子进程不能用继承来的会话** —— fail-closed,零下发。父进程**必须先释放端口**。 -3. **`movej` 返回 ≠ 停稳**(残差 ~0.012 rad,在 `q_tol` 内)。 -4. **`movej` / `movej_sync` 目前不校验关节限位** —— 越限目标会被走满行程。发之前自己比对限位。 -5. **`disable()` 之后臂不再被托住** —— 位置环一断,它会动。 -6. **`save_params()` 没有撤销。** - -### ⚠ 不可逆命令 —— 不要在标定过的臂上执行 - -这四条会**覆盖/抹掉该台设备逐台辨识的动力学模型**(整扇区擦除 + 写当前 RAM): - -| 命令 | 入口 | -|---|---| -| `0x25` | `save_params()` | -| `0x32` | `arm.model.commit()` | -| `0x36` | `arm.params.reset_factory()` | -| `0x37` | `arm.model.revert()` | - -**唯一解锁方式:在一台没有标定价值的板子上做。** - -⚠ **`model.set_jm()` 建议永不调用** —— 改错关节映射有**乱飞**风险, -而没有可依赖的退路。 - -⚠ 压测 CAN 链路**只开 `candump`(只读),绝不 `cangen`** —— `can0` 就是电机总线。 - -**明确未验证的部分**见 [TROUBLESHOOTING](../TROUBLESHOOTING.zh-CN.md#明确未验证的部分)。 - -## License +| `test_zero_g.py` | 拖动示教保活周期 / 退出收尾 / 线程回收 / 命令门禁 | +| `test_live.py` | 真机冒烟,`LITEARM_LIVE=1` 才跑 | -MIT +`tests/fake_serial.py` 的桩必须与固件真实布局一致,否则离线假绿而真机必挂。 diff --git a/env.cmd b/env.cmd index 7b84f2c..9d36db4 100644 --- a/env.cmd +++ b/env.cmd @@ -7,11 +7,11 @@ rem call env.cmd rem python examples\01_hello.py rem set LITEARM_PORT=COM5 & call env.cmd & python examples\01_hello.py rem =========================================================================== -set "PYLITEARM_REPO=%~dp0" -set "PYTHONPATH=%PYLITEARM_REPO%src;%PYTHONPATH%" +set "LITEARM_REPO=%~dp0" +set "PYTHONPATH=%LITEARM_REPO%src;%PYTHONPATH%" if not defined PYTHON_BIN set "PYTHON_BIN=python" if not defined LITEARM_PORT set "LITEARM_PORT=" -echo [litearm-python env] repo=%PYLITEARM_REPO% +echo [litearm-python env] repo=%LITEARM_REPO% echo PYTHON_BIN = %PYTHON_BIN% echo LITEARM_PORT = '%LITEARM_PORT%' ^(空=自动发现 1d50:606f^) echo PYTHONPATH = %PYTHONPATH% diff --git a/env.ps1 b/env.ps1 index ef945d5..83048e7 100644 --- a/env.ps1 +++ b/env.ps1 @@ -13,10 +13,10 @@ # ============================================================================ $ErrorActionPreference = "Stop" -$env:PYLITEARM_REPO = Split-Path -Parent $MyInvocation.MyCommand.Path +$env:LITEARM_REPO = Split-Path -Parent $MyInvocation.MyCommand.Path # Windows 分隔符 ';' 合并已有的 PYTHONPATH -$src = Join-Path $env:PYLITEARM_REPO "src" +$src = Join-Path $env:LITEARM_REPO "src" if (-not $env:PYTHONPATH) { $env:PYTHONPATH = $src } else { @@ -29,7 +29,7 @@ if (-not $env:PYTHON_BIN) { $env:PYTHON_BIN = "python" } # CDC 端口: 留空 = 自动发现 if (-not $env:LITEARM_PORT) { $env:LITEARM_PORT = "" } -Write-Host "[litearm-python env] repo=$($env:PYLITEARM_REPO)" +Write-Host "[litearm-python env] repo=$($env:LITEARM_REPO)" Write-Host " PYTHON_BIN = $($env:PYTHON_BIN)" Write-Host " LITEARM_PORT = '$($env:LITEARM_PORT)' (空=自动发现 1d50:606f)" Write-Host " PYTHONPATH = $($env:PYTHONPATH)" diff --git a/env.sh b/env.sh index d374d05..9ab24e7 100755 --- a/env.sh +++ b/env.sh @@ -7,15 +7,14 @@ # ./run_example.sh 01_hello.py # 更省事 # # 作用: -# 1) PYTHONPATH 指向 src —— 即使未 `pip install -e .` 也能 import litearm -# (仅依赖 pyserial, 无需 pylitearm/pinocchio/numpy); +# 1) PYTHONPATH 指向 src —— 即使未 `pip install -e .` 也能 import litearm; # 2) 选一个装了 pyserial 的解释器 (见下, 无需手改); # 3) LITEARM_PORT 可覆盖 CDC 端口; 不设则自动发现 (VID:PID 1d50:606f)。 # ============================================================================ set -euo pipefail -export PYLITEARM_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -export PYTHONPATH="${PYLITEARM_REPO}/src${PYTHONPATH:+:${PYTHONPATH}}" +export LITEARM_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONPATH="${LITEARM_REPO}/src${PYTHONPATH:+:${PYTHONPATH}}" # 解释器: 默认 python3; 已设 PYTHON_BIN 则尊重之。否则若默认解释器 import 不到 # pyserial, 就在常见的 conda 位置里找一个能 import 的 —— 这样你不必手改本文件。 @@ -35,7 +34,7 @@ fi # CDC 端口: 留空=自动发现; 想锁定就 `LITEARM_PORT=/dev/ttyACM1 ./run_example.sh 01_hello.py` export LITEARM_PORT="${LITEARM_PORT:-}" -echo "[litearm-python env] repo=${PYLITEARM_REPO}" +echo "[litearm-python env] repo=${LITEARM_REPO}" echo " PYTHON_BIN = ${PYTHON_BIN}" echo " LITEARM_PORT = '${LITEARM_PORT}' (空=自动发现 1d50:606f)" echo " PYTHONPATH = ${PYTHONPATH}" diff --git a/examples/README.md b/examples/README.md index 97991cd..0b7858b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,30 +1,28 @@ # litearm-python examples -Each script runs standalone. **Read it before you point it at real hardware.** +Each script runs on its own. **Read it before you run it on real hardware.** -**Read-only by default** — anything that enables, moves, or writes parameters -requires an explicit `--go`, so a stray run cannot move the arm. +**Read-only by default** — any example that enables, moves or retunes parameters requires an +explicit `--go`, so nothing moves by accident. ## Prerequisites -1. This package is installed (or `PYTHONPATH=src`). -2. The arm is connected, firmware `Litearm1.5.0+`. -3. The serial port is free — close anything else holding `/dev/ttyACM*`. +1. This package installed (or `PYTHONPATH` pointing at `src`); +2. The arm connected, firmware `Litearm1.5.0` or later; +3. The serial port available for exclusive use — shut down anything else holding + `/dev/ttyACM*`. -Port priority: `--port` > `LITEARM_PORT` env var > auto-discovery (`1d50:606f`). - -```bash -source env.sh # exports PYTHONPATH/PYTHON_BIN/LITEARM_PORT -``` - -On Windows use `env.ps1` / `env.cmd`; `LITEARM_PORT` can pin e.g. `COM5`. +Port priority: `--port` > the `LITEARM_PORT` environment variable > auto-discovery +(`1d50:606f`). ## Running ```bash +source env.sh # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT + python3 examples/01_hello.py # read-only, no --go needed python3 examples/02_movej.py --go # moves the arm -./run_example.sh 02_movej.py --go # or one-shot wrapper +./run_example.sh 02_movej.py --go # or one-shot via the wrapper LITEARM_PORT=/dev/ttyACM0 ./run_example.sh 03_move_p.py --go ``` @@ -41,52 +39,40 @@ call env.cmd python examples\01_hello.py ``` -## The examples +On Windows, `LITEARM_PORT` pins a port such as `COM5`. -| Example | Shows | Needs `--go` | -|---|---|---| -| [01_hello.py](01_hello.py) | connect handshake + firmware convention + state / TCP pose | no | -| [02_movej.py](02_movej.py) | `movej` single-shot → firmware S-curve + hold at target | yes | -| [03_move_p.py](03_move_p.py) | `move_p` single pose → firmware IK + S-curve (TCP arrival) | yes | -| [04_ik_tcp.py](04_ik_tcp.py) | `ik(pose)` solve + `get_tcp()` current pose (self-consistency) | no | -| [05_ff_tune.py](05_ff_tune.py) | dynamics / control-law tuning (`ff_preset`, gravity, inertia, payload, save) | yes | -| [06_cartesian.py](06_cartesian.py) | Cartesian paths `move_l` / `move_c` / `move_path` (firmware-planned, `CartPlan`) | yes | -| [07_vel_jitter_trace.py](07_vel_jitter_trace.py) | per-tick capture of a slow `movej` (300 Hz firmware log + 100 Hz live stream) | yes | - -Each script's docstring carries its own run and safety notes. - -## ⚠ Safety +## The examples -The moving examples (02 / 03 / 05 / 06 / 07) **drive the real arm**: +| Example | What it shows | Needs `--go` | +| --- | --- | --- | +| [01_hello.py](01_hello.py) | Handshake + firmware version + reading state and tool pose | No | +| [02_movej.py](02_movej.py) | `movej` as a single shot: firmware plans and completes it, then holds position | Yes | +| [03_move_p.py](03_move_p.py) | `move_p` with one pose: firmware IK + S-curve, arrival judged by TCP | Yes | +| [04_ik_tcp.py](04_ik_tcp.py) | `ik(pose)` plus `get_tcp()` for a self-consistency check | No | +| [05_ff_tune.py](05_ff_tune.py) | Dynamics / control-law tuning (`ff_preset` / gravity / inertia / payload / persist) | Yes | +| [06_cartesian.py](06_cartesian.py) | Cartesian paths: `move_l` / `move_c` / `move_path` | Yes | +| [07_vel_jitter_trace.py](07_vel_jitter_trace.py) | Per-tick capture of a slow `movej` (300 Hz firmware log + 100 Hz live stream) | Yes | -- keep `speed` at 0.1~0.3 the first time -- stand at the e-stop, keep the workspace clear -- read [TROUBLESHOOTING.md](../TROUBLESHOOTING.md) first -- ⚠ **`movej` does not currently check joint limits** — an out-of-range target - is driven the full way +Every script's docstring states how to run it and what to watch out for. -## About 06_cartesian.py +## Safety notes -This is the **Cartesian path** entry point: `move_l` goes straight, `move_c` goes -around an arc, `move_path` visits waypoints in turn (**sharp corners** — the -protocol has no blend field). Planning (sampling / per-point IK / playback) all -happens **in the firmware**: the PC sends points and collects the `0x4E` result -frame, returned as a `CartPlan`. +The examples that move the arm (02 / 03 / 05 / 06 / 07) **really drive it**: -Known degradations (no corner blending / no pre-send preview / speed pre-check -delegated to firmware) are in the script docstring and in the -[Developer Guide](../docs/DEVELOPER_GUIDE.md#53-cartesian-firmware-planned). -For kinematics and impedance identification, see `pylitearm`'s own `examples/`. +- 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. ## Pose format -A pose is a plain Python list of **6 numbers**: position 3 + RPY 3. No numpy. +A pose is **6 numbers**: 3 of position (m) plus 3 of orientation (rad, RPY). Lists and tuples +both work, and no numpy is needed. ```python -pose = [px, py, pz, rx, ry, rz] - m = arm.get_tcp() # Msg envelope -p = m.value # 6 numbers (or None) -p[:3] # position, m -p[3:6] # RPY, rad +p = m.value # 6 numbers, or None if no frame could be obtained + +p[:3] # position, in metres +p[3:6] # orientation RPY, in radians ``` diff --git a/examples/README.zh-CN.md b/examples/README.zh-CN.md index 209789b..14c9d26 100644 --- a/examples/README.zh-CN.md +++ b/examples/README.zh-CN.md @@ -2,28 +2,24 @@ 每个脚本可独立运行,**先读懂再上真机**。 -**默认只读** —— 会 `enable` / 运动 / 改参的样例必须显式加 `--go`,防误动。 +**默认只读**——会 `enable` / 运动 / 改参的样例必须显式加 `--go`,防误动。 ## 前提 -1. 已装本包(或 `PYTHONPATH=src`)。 -2. 机械臂已连接,固件 `Litearm1.5.0+`。 -3. 串口可被独占 —— 关掉其它占着 `/dev/ttyACM*` 的进程。 +1. 已装本包(或让 `PYTHONPATH` 指向 `src`); +2. 机械臂已连接,固件 `Litearm1.5.0` 及以上; +3. 串口可被独占——关掉其它占着 `/dev/ttyACM*` 的进程。 端口优先级:`--port` > 环境变量 `LITEARM_PORT` > 自动发现(`1d50:606f`)。 -```bash -source env.sh # 导出 PYTHONPATH/PYTHON_BIN/LITEARM_PORT -``` - -Windows 用 `env.ps1` / `env.cmd`;`LITEARM_PORT` 可锁 `COM5` 等。 - ## 运行 ```bash +source env.sh # 导出 PYTHONPATH / PYTHON_BIN / LITEARM_PORT + python3 examples/01_hello.py # 只读,不需要 --go python3 examples/02_movej.py --go # 会运动 -./run_example.sh 02_movej.py --go # 或包装脚本一键跑 +./run_example.sh 02_movej.py --go # 或用包装脚本一键跑 LITEARM_PORT=/dev/ttyACM0 ./run_example.sh 03_move_p.py --go ``` @@ -40,48 +36,39 @@ call env.cmd python examples\01_hello.py ``` +Windows 上 `LITEARM_PORT` 可以锁定 `COM5` 这类端口。 + ## 样例列表 -| 样例 | 演示 | 需 `--go` | -|---|---|---| -| [01_hello.py](01_hello.py) | 连接握手 + 固件版本约定 + 状态/末端位姿 | 否 | -| [02_movej.py](02_movej.py) | `movej` 单发 → 固件 S 曲线自完成 + 静止保持 | 是 | -| [03_move_p.py](03_move_p.py) | `move_p` 单 pose → 固件内置 IK + S 曲线(TCP 到位判定) | 是 | -| [04_ik_tcp.py](04_ik_tcp.py) | `ik(pose)` 反解 + `get_tcp()` 当前位姿(自洽校验) | 否 | -| [05_ff_tune.py](05_ff_tune.py) | 内置动力学/控制律调参(`ff_preset` / 重力 / 惯量 / payload / save) | 是 | -| [06_cartesian.py](06_cartesian.py) | 笛卡尔路径 `move_l` / `move_c` / `move_path`(固件规划 + `CartPlan`) | 是 | -| [07_vel_jitter_trace.py](07_vel_jitter_trace.py) | 慢速 `movej` 的逐拍采集(300 Hz 固件日志 + 100 Hz 实测流双通道) | 是 | +| 样例 | 演示什么 | 需要 `--go` | +| --- | --- | --- | +| [01_hello.py](01_hello.py) | 连接握手 + 固件版本 + 读状态与末端位姿 | 否 | +| [02_movej.py](02_movej.py) | `movej` 单发:固件规划并走完,到位后静止保持 | 是 | +| [03_move_p.py](03_move_p.py) | `move_p` 单个位姿:固件内置逆解 + S 曲线,按 TCP 判到位 | 是 | +| [04_ik_tcp.py](04_ik_tcp.py) | `ik(pose)` 反解 + `get_tcp()` 读当前位姿(自洽校验) | 否 | +| [05_ff_tune.py](05_ff_tune.py) | 动力学 / 控制律调参(`ff_preset` / 重力 / 惯量 / 负载 / 持久化) | 是 | +| [06_cartesian.py](06_cartesian.py) | 笛卡尔路径:`move_l` / `move_c` / `move_path` | 是 | +| [07_vel_jitter_trace.py](07_vel_jitter_trace.py) | 慢速 `movej` 的逐拍采集(300 Hz 固件日志 + 100 Hz 实测流) | 是 | -每个脚本的 docstring 含运行与安全说明。 +每个脚本的 docstring 都写明了运行方式与安全注意事项。 -## ⚠ 安全提示 +## 安全提示 会运动的样例(02 / 03 / 05 / 06 / 07)**真实驱动机械臂**: -- 首次运行 `speed` 保持 0.1~0.3 -- 人站在急停旁,确保周围无人无障碍 -- 跑之前先读 [TROUBLESHOOTING.zh-CN.md](../TROUBLESHOOTING.zh-CN.md) -- ⚠ **`movej` 目前不校验关节限位** —— 越限目标会被走满行程 - -## 关于 06_cartesian.py - -它是**笛卡尔路径**入口:`move_l` 走直线、`move_c` 走圆弧、`move_path` 依次经过多路点 -(**尖角**,协议无倒角字段)。规划(采样 / 逐点 IK / 播放)全在**固件**里 —— -PC 只发点、收 `0x4E` 结果帧,返回 `CartPlan`。 - -已知降级(无倒角 / 无下发前预览 / 速度预检改由固件做)见脚本 docstring 与 -[开发者指南](../docs/DEVELOPER_GUIDE.zh-CN.md#53-笛卡尔固件规划)。 -运动学 / 阻抗辨识等其余高级场景仍参考 `pylitearm` 本体 `examples/`。 +- 首次运行把 `--speed` 保持在 0.1~0.3; +- 人站在急停旁,确保周围无人无障碍; +- 跑之前先读 [排障指南](../TROUBLESHOOTING.zh-CN.md); +- ⚠ **`movej` 不校验关节限位**——越限目标会被走满行程。 ## 位姿格式 -位姿是 **6 个数**的纯 Python list:位置 3 + RPY 3。不需要 numpy。 +位姿是 **6 个数**:位置 3(m)+ 姿态 3(rad,RPY)。list 和 tuple 都收,不需要 numpy。 ```python -pose = [px, py, pz, rx, ry, rz] - m = arm.get_tcp() # Msg 信封 -p = m.value # 6 个数(或 None) -p[:3] # 位置,m -p[3:6] # RPY,rad +p = m.value # 6 个数,取不到帧时为 None + +p[:3] # 位置,单位 m +p[3:6] # 姿态 RPY,单位 rad ``` diff --git a/pyproject.toml b/pyproject.toml index 605fd37..ba35cda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.9" license = { text = "MIT" } dependencies = ["pyserial>=3.4"] -# 刻意不依赖 pylitearm/pinocchio/numpy (唯一例外仍是 pyserial): +# 唯一依赖是 pyserial —— 刻意不引 numpy: # 运动学(FK/JAC/IK)全问固件 (`CMD_GET_TCP 0x43` / `CMD_GET_IK 0x42`), 笛卡尔规划 # (`move_l`/`move_c`/`move_path`) 也全在固件里 —— 不引模型, 也就不会和固件模型漂移。 # PC 侧只用 `math` (`_rot.py` 的位姿形态归一化与到位姿态判据)。 diff --git a/run_example.ps1 b/run_example.ps1 index 6760050..3897aa7 100644 --- a/run_example.ps1 +++ b/run_example.ps1 @@ -16,12 +16,12 @@ $ErrorActionPreference = "Stop" if (-not $Name) { Write-Host "用法: $PSCommandPath [args...]" - Get-ChildItem (Join-Path $env:PYLITEARM_REPO "examples") -Filter *.py | + Get-ChildItem (Join-Path $env:LITEARM_REPO "examples") -Filter *.py | ForEach-Object { Write-Host " $($_.Name)" } exit 1 } -$example = Join-Path $env:PYLITEARM_REPO ("examples\" + $Name) +$example = Join-Path $env:LITEARM_REPO ("examples\" + $Name) if (-not (Test-Path $example)) { Write-Error "找不到样例: $example" exit 1 diff --git a/tests/conftest.py b/tests/conftest.py index feb2a91..20b342b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ 用法: pytest # 只跑离线 (桩 transport), 不碰真机 - PYLITEARM_LIVE=1 pytest # 额外跑真机 live (需接 Litearm1.5.0+ 整臂/台架) + LITEARM_LIVE=1 pytest # 额外跑真机 live (需接 Litearm1.5.0+ 整臂/台架) """ from __future__ import annotations @@ -18,7 +18,7 @@ from fake_serial import FakeTransport # noqa: E402 -LIVE = bool(os.environ.get("PYLITEARM_LIVE")) +LIVE = bool(os.environ.get("LITEARM_LIVE")) @pytest.fixture(autouse=True) @@ -172,6 +172,6 @@ def offline_arm_1j(fake_transport_factory): @pytest.fixture def live_only(): - """真机用例: 未设 PYLITEARM_LIVE=1 时跳过。""" + """真机用例: 未设 LITEARM_LIVE=1 时跳过。""" if not LIVE: - pytest.skip("真机用例需 PYLITEARM_LIVE=1 (并接好 Litearm1.5.0+ 固件)") + pytest.skip("真机用例需 LITEARM_LIVE=1 (并接好 Litearm1.5.0+ 固件)") diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py new file mode 100644 index 0000000..42dd814 --- /dev/null +++ b/tests/test_doc_examples.py @@ -0,0 +1,351 @@ +"""文档里的代码必须是对的 —— 逐块校验 `*.md` 的 python 代码块。 + +文档示例写错是**静默**故障: 不跑就发现不了。本用例把 8 份文档里所有 ```python 块 +抽出来, 按**内容**分三类处理 (不按块序号 —— 序号会随文档编辑错位): + +* **可执行示例** —— 接到离线桩 `Arm` 上真跑一遍, 所以不会碰真机。 + 块里出现任何没交代的名字 (例如留了 `p1` / `tcp` / `via_pose` 这种占位符) 都会 + `NameError` 而失败。 +* **签名清单** —— `foo(a, b=1) -> T` 这种不执行, 改为**逐条比对 `inspect.signature`**: + 参数名、位置顺序、关键字专用性 (`*`)、默认值全都要对得上。 + 只读属性名单 (`n` / `q_tol` / …) 也走这条, 逐个确认成员真的存在。 +* **说明性片段** —— 会 fork、会阻塞、或本身就是类型定义示意, 见 `ILLUSTRATIVE`, + 只做语法检查。 + +**新增示例若跑不起来, 本用例直接失败。** 别把只是写错的块塞进 `ILLUSTRATIVE` 蒙混过去 —— +那条正则只匹配"本来就不是可执行代码"的块。 +""" +from __future__ import annotations + +import ast +import inspect +import os +import re + +import pytest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_ROOT = os.path.dirname(_HERE) + +from litearm.arm import Arm # noqa: E402 - conftest 已在更早处设好 sys.path + +#: ⚠ `conftest.py` 那条 autouse 的读线程哨兵会把 `Arm.__init__` 换成 +#: `(self, *args, **kwargs)` 的包装, 于是**测试期里 `inspect.signature(Arm.__init__)` +#: 再也读不到真实签名**。这里在补丁生效前先把原始函数抓住。 +_ARM_INIT_PRISTINE = Arm.__init__ + +DOCS = [ + "README.md", + "README.zh-CN.md", + "TROUBLESHOOTING.md", + "TROUBLESHOOTING.zh-CN.md", + "docs/DEVELOPER_GUIDE.md", + "docs/DEVELOPER_GUIDE.zh-CN.md", + "examples/README.md", + "examples/README.zh-CN.md", +] + +#: 只做语法检查的块 —— 按内容判, 不按序号。 +#: ⚠ 别加 `re.X` / 行尾 `#` 注释: `re.X` 下第一个 `#` 会把**后面所有分支**注释掉。 +ILLUSTRATIVE = re.compile( + r"\bmultiprocessing\b" # fork 示例, 会真的开子进程 + r"|\binput\s*\(" # 交互示例, 会阻塞 + r"|^\s*@\w+", # 类型定义示意 (Msg 的 dataclass) + re.M, +) + +#: `foo(...)` / `group.foo(...)`, 允许尾随的 `-> 返回类型` +_SIG = re.compile(r"^(?:([A-Za-z_]\w*)\.)?([A-Za-z_]\w*)\((.*)\)\s*(?:->\s*.+)?$", re.S) + +#: 裸属性引用 —— `r.total_bytes` / `n` +_ATTR = re.compile(r"^(?:([A-Za-z_]\w*)\.)?([A-Za-z_]\w*)$") + +#: 一个"像参数"的形参: 裸标识符 / `*` 或 `/` 分隔符 / `**name` / `name=任何字面量` +_PARAM_OK = re.compile(r"^(\*$|/$|\*{0,2}[A-Za-z_]\w*(=.*)?)$", re.S) + +_EVAL_NS: dict = {} + + +def _blocks(): + for rel in DOCS: + path = os.path.join(_ROOT, rel) + if not os.path.exists(path): + continue + text = open(path, encoding="utf-8").read() + for i, body in enumerate(re.findall(r"```python\n(.*?)```", text, re.S), 1): + yield rel, i, body + + +ALL_BLOCKS = list(_blocks()) +IDS = [f"{r}#{i}" for r, i, _ in ALL_BLOCKS] + + +# ---------------------------------------------------------------- 文本切分 + +def _strip_comment(line: str) -> str: + """去掉行尾注释 —— 文档里的 `# 需 10 个值` 之类不算代码。""" + out, quote = [], None + for ch in line: + if quote: + out.append(ch) + if ch == quote: + quote = None + continue + if ch in "\"'": + quote = ch + out.append(ch) + continue + if ch == "#": + break + out.append(ch) + return "".join(out).rstrip() + + +def _entries(body: str): + """把块切成逻辑条目: 括号未闭合的续行并进上一条。""" + buf, depth = "", 0 + for raw in body.splitlines(): + line = _strip_comment(raw) + if not line and not buf: + continue + buf = f"{buf} {line}".strip() if buf else line + depth += line.count("(") - line.count(")") + if depth <= 0: + yield buf + buf, depth = "", 0 + if buf: + yield buf + + +def _split_params(text: str): + """按顶层逗号切参数, 保住嵌套结构。""" + parts, buf, depth = [], "", 0 + for ch in text: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if ch == "," and depth == 0: + parts.append(buf.strip()) + buf = "" + else: + buf += ch + if buf.strip(): + parts.append(buf.strip()) + return [p for p in parts if p] + + +# ---------------------------------------------------------------- 名字解析 + +def _sub_objects(): + from litearm.diagnostics import Diagnostics + from litearm.log import ArmLog, LogReader + from litearm.model import ModelParams + from litearm.params import JointParams + + return (JointParams, ModelParams, ArmLog, LogReader, Diagnostics) + + +def _group_of(selector: str | None): + """`params.` / `model.` / `log.` / `diag.` / `r.` 前缀 → 对应的子对象类。""" + from litearm.diagnostics import Diagnostics + from litearm.log import ArmLog, LogReader + from litearm.model import ModelParams + from litearm.params import JointParams + + return { + "params": JointParams, "model": ModelParams, + "log": ArmLog, "diag": Diagnostics, + "r": LogReader, "reader": LogReader, + }.get(selector or "") + + +def _has_member(cls, name: str) -> bool: + """类上有这个成员吗 —— **含 `__init__` 里赋的实例属性** (`total_bytes` 就是这种)。 + + `hasattr(cls, ...)` 看不见实例属性, 所以要再扫一遍类源码里的 `self.`。 + """ + if hasattr(cls, name): + return True + try: + src = inspect.getsource(cls) + except (OSError, TypeError): # pragma: no cover + return False + return re.search(rf"self\.{re.escape(name)}\s*[:=]", src) is not None + + +def _owner_of(name: str, selector: str | None): + """这个成员属于哪个类。带前缀就认前缀; 裸名字先找 `Arm`, 再在各子对象里唯一命中。""" + if selector: + return _group_of(selector) or Arm + if _has_member(Arm, name): + return Arm + hits = [c for c in _sub_objects() if _has_member(c, name)] + return hits[0] if len(hits) == 1 else None + + +def _verify_member(name: str, selector: str | None, rel: str, idx: int): + owner = _owner_of(name, selector) + assert owner is not None and _has_member(owner, name), ( + f"{rel}#{idx}: 文档写了 {name},但真实 API 里没有") + + +def _resolve_call(name: str, selector: str | None, rel: str, idx: int): + if name == "Arm": + return _ARM_INIT_PRISTINE + _verify_member(name, selector, rel, idx) + owner = _owner_of(name, selector) + return getattr(owner, name) + + +# ---------------------------------------------------------------- 签名核对 + +def _check_signature_entry(entry: str, rel: str, idx: int) -> bool: + """核对一条签名/属性条目。返回 True 表示这一条确实是签名条目。""" + # 只读属性名单可以一行写几个: `n` / `q_tol` / `dq_tol` … 用 ` / ` 分隔 + if "/" in entry and not _SIG.match(entry): + for piece in entry.split("/"): + piece = piece.strip() + m = _ATTR.match(piece) + assert m, f"{rel}#{idx}: 认不出的条目 {entry!r}" + _verify_member(m.group(2), m.group(1), rel, idx) + return True + + attr = _ATTR.match(entry) + if attr: + _verify_member(attr.group(2), attr.group(1), rel, idx) + return True + + m = _SIG.match(entry) + if not m: + return False + selector, name, raw_params = m.group(1), m.group(2), m.group(3) + real = inspect.signature(_resolve_call(name, selector, rel, idx)) + real_params = [p for p in real.parameters.values() if p.name != "self"] + real_by_name = {p.name: p for p in real_params} + + kw_only_from, doc_names = None, [] + for pos, item in enumerate(_split_params(raw_params)): + if item == "*": + kw_only_from = len(doc_names) + continue + pname = item.split("=", 1)[0].strip().lstrip("*") + doc_names.append(pname) + + assert pname in real_by_name, ( + f"{rel}#{idx}: 文档写了 {name}({pname}=…),但真实签名没有这个参数" + f"(真实参数:{list(real_by_name)})") + + real_p = real_by_name[pname] + if kw_only_from is not None and pos >= kw_only_from: + assert real_p.kind is inspect.Parameter.KEYWORD_ONLY, ( + f"{rel}#{idx}: 文档把 {pname} 写成关键字专用(`*` 之后)," + f"但真实签名里它是 {real_p.kind.name}") + elif real_p.kind is inspect.Parameter.KEYWORD_ONLY: + # 反方向: 真实是关键字专用, 文档却没写 `*` ⇒ 读者会以为能按位置传 + assert item.startswith("*"), ( + f"{rel}#{idx}: {name}() 的 {pname!r} 是**关键字专用**," + f"文档的签名清单漏了 `*`,读者会以为能按位置传") + + if "=" in item: + doc_default = item.split("=", 1)[1].strip() + try: + expected = eval(doc_default, dict(_EVAL_NS)) # noqa: S307 - 文档字面量 + except Exception: # noqa: BLE001 - 求不出来就只查名字 + continue + assert real_p.default == expected, ( + f"{rel}#{idx}: {name}(… {pname}={doc_default}) 文档写的默认值是 " + f"{expected!r},真实是 {real_p.default!r}") + + for p in real_params: + if p.name in doc_names: + continue + assert p.default is not inspect.Parameter.empty or p.kind in ( + inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD), ( + f"{rel}#{idx}: {name}() 有个必填参数 {p.name!r},文档的签名清单里没写出来") + return True + + +def _looks_like_signature(entry: str) -> bool: + """一条条目是不是"签名"而非"代码"。 + + ⚠ 判据的关键在**形参必须是标识符**: `movej(q, speed=1.0)` 是签名, 而 + `movej([0.1, 0, ...], speed=0.3)` 是**真调用** —— 后者要拿去执行。 + """ + if _ATTR.match(entry): + return True + if "/" in entry and not _SIG.match(entry): + return all(_ATTR.match(p.strip()) for p in entry.split("/")) + m = _SIG.match(entry) + return bool(m) and all(_PARAM_OK.match(p) for p in _split_params(m.group(3))) + + +def _is_signature_block(body: str) -> bool: + """块里**每条**逻辑条目都是签名/属性 → 当成签名清单。""" + entries = list(_entries(body)) + return bool(entries) and all(_looks_like_signature(e) for e in entries) + + +# ---------------------------------------------------------------- 可执行示例 + +def _make_arm(): + """连上离线桩固件 —— 与 conftest 的 offline_arm 同构。""" + from fake_serial import FakeTransport + + return Arm(port="fake", transport_factory=lambda port="fake", timeout=0.2, **k: + FakeTransport(port=port, timeout=timeout, fw="Litearm1.7.0-7J", **k)).connect() + + +@pytest.fixture +def _stub_port_discovery(monkeypatch): + """让 `pa.Arm().connect()`(不带 port)在**没接真机**的机器上也能跑。 + + ⚠ 必须打这个补丁: `connect()` 里 `find_cdc_port()` 排在传输工厂**之前** + (`arm.py:919`), 它只枚举 USB 不开设备 —— 但机器上没接臂时它回 `None`, + 紧跟的 `raise TransportError` 会让"最小完整程序"那类示例在 CI 上假失败。 + """ + import litearm.arm as arm_mod + + monkeypatch.setattr(arm_mod, "find_cdc_port", lambda: "fake") + + +@pytest.fixture(scope="module", autouse=True) +def _eval_ns(): + import litearm + from litearm.arm import FIRMWARE_PREFIX, MIN_FW + + _EVAL_NS.update({"MIN_FW": MIN_FW, "FIRMWARE_PREFIX": FIRMWARE_PREFIX, + "litearm": litearm}) + + +@pytest.mark.parametrize("rel,idx,body", ALL_BLOCKS, ids=IDS) +def test_doc_block(rel, idx, body, fake_transport_factory, _stub_port_discovery): + fake_transport_factory() # 把 SerialTransport 换成桩(monkeypatch,自动还原) + + # ⚠ 签名清单要**先**判, 不能放在语法检查后面: 签名清单本来就不是合法 Python + # (`f(a, *, b=1)` 当语句写是语法错误), 放在后面会被 SyntaxError 直接 return 掉, + # 于是核对整个被跳过 —— 实测踩过, 变异测试立刻显形。 + if _is_signature_block(body): + for e in _entries(body): + _check_signature_entry(e, rel, idx) + return + + try: + ast.parse(body) + except SyntaxError as e: + pytest.fail(f"{rel}#{idx} 语法错误: {e}") + + if ILLUSTRATIVE.search(body): + return + + # 真跑。只注入 `arm` 与 `pa` —— 别的名字未定义就会 NameError, + # 这正是要抓的"示例里留了没交代的占位符"。 + import litearm as pa + + arm = _make_arm() + try: + exec(compile(body, f"{rel}#{idx}", "exec"), {"pa": pa, "arm": arm}) + except Exception as e: # noqa: BLE001 - 示例跑不起来就是失败 + pytest.fail(f"{rel}#{idx} 执行失败:\n{body}\n→ {type(e).__name__}: {e}") + finally: + arm.close() diff --git a/tests/test_live.py b/tests/test_live.py index a9aaa4c..b41a283 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -1,4 +1,4 @@ -"""真机 live 用例 —— 需 PYLITEARM_LIVE=1 + 接好 Litearm1.5.0+ 整臂(或台架)。 +"""真机 live 用例 —— 需 LITEARM_LIVE=1 + 接好 Litearm1.5.0+ 整臂(或台架)。 风险自担: 会 enable 并小幅 move_j (当前姿态 +小量, 不影响他人/障碍时跑)。 """