diff --git a/README.md b/README.md index 5e99703..e266cd1 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,30 @@ pip install -e . python3 -c "import litearm; print(litearm.__version__)" # verify ``` -Linux needs serial permissions: +Linux needs serial permissions — check what the device node itself says first: + +```bash +ls -l /dev/ttyACM0 +``` + +If it is `crw-rw-rw-` you do not need the group at all and can open it as you are. Only when it +is `crw-rw----` do you need to add yourself to `dialout`: ```bash sudo usermod -aG dialout $USER # log in again for it to take effect ``` +`crw-rw-rw-` usually comes from a udev rule rather than the `dialout` group: + +``` +# /etc/udev/rules.d/99-litearm.rules +SUBSYSTEM=="tty", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="606f", MODE="0666" +``` + +That rule belongs to no package — it was dropped in when the machine was set up. `MODE="0666"` +means **any local user on this machine can drive the arm**. That is deliberate, not an +oversight. + ## Quick start ```python @@ -46,27 +64,44 @@ import litearm as pa with pa.Arm().connect() as arm: print(arm.firmware, arm.n) # version string, joint count + # after power-up you must reset() first: the arm boots into the + # EMERGENCY latch, and until that is cleared enable() is refused + # with ERR{0x10,0x06} + arm.reset() + # 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) + # start from a pose with every joint well away from its limits: + # fully extended (q ≈ 0) is a singular pose and no Cartesian + # motion can be planned out of it + arm.movej([0.0, 0.4, -0.8, -1.2, 0.0, 0.6, 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) + # take the measured pose as the start and move position only — copying + # the orientation saves you from having to work one out + tcp = arm.get_tcp().value + arm.move_l((tcp[0], tcp[1], tcp[2] - 0.05, *tcp[3:]), 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))) + print(arm.ik((tcp[0], tcp[1], tcp[2] - 0.07, *tcp[3:]))) # go home arm.home() # leaving the with block disconnects ``` +`reset()` is a step after power-up, not an option: the arm boots into the `EMERGENCY` latch +(`mode_name == "EMERGENCY"`, `FAULT` in `flags`, a non-zero `joint_fault`), and until that is +cleared `enable()` throws `ERR{0x10,0x06}` **immediately** — without sending a single retry. +`reset()` puts `mode` back to `INIT` and clears the fault bits, and the whole quick start runs +from there. + `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**. @@ -96,23 +131,34 @@ 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 ``` +**Do not** read `get_state().enabled` on the line after `enable()`. It returns before the +state stream reflects the change — measured 2.0-2.7 ms to return against 7.0-7.8 ms for the +flag to turn true — so the check reads `False` and means nothing. Wait a frame before judging. + ### 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()) +# Offset the *measured* pose. Reachability depends on the arm's geometry, not on the +# request, so a hard-coded absolute pose can simply have no IK solution. +p = arm.get_tcp().value +P1 = (p[0] + 0.02, p[1], p[2], p[3], p[4], p[5]) +P2 = (p[0] + 0.04, p[1], p[2], p[3], p[4], p[5]) +P3 = (p[0] + 0.06, p[1], p[2], p[3], p[4], p[5]) + +arm.move_l(P1, speed=0.3) # straight line +arm.move_p(P1) # joint-space point to point — not a line +arm.move_path([P1, P2, P3], speed=0.3) # visit the waypoints in turn, sharp corners +arm.move_c(arm.get_tcp().value, P1, P2) # arc — start must be the measured TCP + +plan = arm.move_l(P2, wait=False) # still blocks for the plan reply +print(plan.settled) # False — we did not wait for the arm to stop arm.set_speed(50) # global governor: integer percentage 0..100 ``` +⚠ **The arm boots at its zero pose, which is the fully-extended singularity.** Cartesian +commands from there have no usable IK solution and are refused (`IKError`, `err=1`). Bend a +joint first (`arm.movej([0.0, -0.5, 0.0, -1.0, 0.0, 0.0, 0.0], speed=0.3)`) — see Quick start. + ### State / kinematics ```python @@ -128,10 +174,15 @@ print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # pose → joint angles ```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.clear_faults() # clears the FAULT bit only, not latches arm.disable() # the arm is no longer held once disabled ``` +`clear_faults()` **cannot clear a latched state**: the `FAULT` bit goes away, `EMERGENCY` mode +and `FB_STALE` come straight back, and the arm still will not enable. Only `reset()` recovers +from those two — `clear_faults()` clears the single `ARM_FLAG_FAULT` bit, while the enable gate +looks at latch conditions that only `reset()` clears. + ### Feed-forward / dynamics ```python @@ -198,9 +249,16 @@ other command normally, so it stays diagnosable. `arm.license()` reads the recor ### Persistence ```python +arm.disable() # required first; enabled calls are refused arm.save_params() # writes flash, irreversible +arm.enable() ``` +`save_params()` **requires the arm to be disabled**: called while enabled (or with an `enable` +in flight) it returns `ERR{0x25,0x04}`. And the moment it is disabled the arm is no longer held +by the position loop — **side-mounted or under load, support it or move it to a low pose first**, +or it drops under its own weight. + ### Read-only properties ```python @@ -219,11 +277,19 @@ litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 litearm-python home ``` -`status` / `fw` / `tcp` are read-only; the rest really drive the arm. `python -m litearm` is -equivalent. +`status` / `fw` / `tcp` are read-only; the rest really drive the arm. + +**Do not** expect the `litearm-python` name to resolve as written: the console script is +installed into `~/.local/bin`, which is not on `PATH` by default (see Install), so these +lines report `command not found`. Use `python3 -m litearm ` — it is equivalent — +or add `~/.local/bin` to `PATH`. ## Things to watch out for +Start with the [troubleshooting guide](TROUBLESHOOTING.md): it has ready-made commands for +finding who holds the serial port, what to do about each `enable()` rejection code, and how to +tell Cartesian failures apart. + ### Read the payload via `.value` The 11 "read one frame" getters return the envelope `Msg(value, hz, timestamp)` — `.value` is @@ -235,6 +301,43 @@ print(arm.get_state()) # Msg(value=RobotState(...), hz=100.2, times print(arm.get_state().value.q) # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] ``` +**That "both are 0" test has an exception**: the **first** frame a process reads counts `hz` as +0 — `print(arm.get_state())` right after connecting shows `hz=0.0`, but the frame is real and the +same session reads `hz≈98.7` a few seconds later. To decide whether frames of a kind have ever +arrived, look at `timestamp` (or read again later), not at a single `hz`. + +### How to read `mode` / `flags` + +`RobotState.mode_name` is the firmware's motion mode. It has exactly 8 values: + +| `mode` | `mode_name` | Meaning | +| --- | --- | --- | +| 0 | `INIT` | Booted / not enabled. What you see after `reset()` with no motion | +| 1 | `MOVE_J` | Joint trajectory in flight (`movej` / `home`) | +| 2 | `MOVE_P` | Cartesian point to point | +| 3 | `MOVE_JS` | Joint passthrough / servo in flight | +| 4 | `MOVE_MIT` | `send_mit` in flight | +| 5 | `MIT_ALL` | `send_mit_all` in flight | +| 6 | `EMERGENCY` | Emergency-stop latch. **This is where the arm boots**, see [Quick start](#quick-start) | +| 7 | `ZERO_G` | Zero-gravity hand-guiding | + +Values outside the table show up as the raw number. On real hardware you see `INIT` / `MOVE_J` / +`EMERGENCY` / `ZERO_G`; the firmware documents `MOVE_P` as "the PC computes IK and turns it into +`move_j` before B3", so you normally never see it. + +`flags` / `flag_names` are the safety bits: `FAULT` / `WD_TRIPPED` / `FB_STALE` / `TEMP_WARN` / +`POS_VIOL` / `OVERSPEED`. Two of them are easy to misread: + +- **`WD_TRIPPED` is normal, not a fault.** It stays lit while the arm is enabled but idle + (`mode != MOVE_J`) — that is the firmware's command watchdog (0.1 s) with no motion command + keeping it alive. +- **`FB_STALE` is a live condition**, and clearing fault bits does not clear it. See + [Life / safety](#life--safety). + +`mode == ZERO_G` says the **arm** is in zero-gravity, held by the firmware; `arm.zero_g_active` +says **this session** is still running the keep-alive. The two can disagree: kill the process and +the arm stays in `ZERO_G` while a new session reads `zero_g_active` as `False`. + ### Multiprocessing: a forked child must not use an inherited `Arm` Commands **really go out on the wire**, but the parent's read thread consumes the replies — you @@ -322,6 +425,24 @@ Running the tests does not require installing the package; `tests/conftest.py` s itself. Do not set `LITEARM_LIVE` with nobody present, and never call `enter_dfu()` / `reset_factory()`. +**On a machine with ROS 2 installed, `pytest` dies during collection and none of this repository +is involved**: pytest auto-loads the plugins under `/opt/ros/...` by entry point, and `launch` +needs `lark`, which is not installed: + +``` +ModuleNotFoundError: No module named 'lark' +``` + +A virtualenv does not stop it — `PYTHONPATH` takes priority over the venv's `site-packages`. +Either of these works: + +```bash +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest +env -u PYTHONPATH pytest +``` + +`-p no:launch_testing` is **not** enough: the `launch_ros` entry point gets loaded anyway. + 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 d1305c0..72f224c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,12 +30,28 @@ pip install -e . python3 -c "import litearm; print(litearm.__version__)" # 验证 ``` -Linux 需要串口权限: +Linux 需要串口权限 —— 先看设备节点本身怎么说: + +```bash +ls -l /dev/ttyACM0 +``` + +是 `crw-rw-rw-` 就不必再加组,直接能开。是 `crw-rw----` 才需要把自己加进 `dialout`: ```bash sudo usermod -aG dialout $USER # 重新登录后生效 ``` +`crw-rw-rw-` 一般来自一条 udev 规则,而不是 `dialout` 组: + +``` +# /etc/udev/rules.d/99-litearm.rules +SUBSYSTEM=="tty", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="606f", MODE="0666" +``` + +这条规则**不属于任何软件包**,是装机时放进去的。`MODE="0666"` 意味着 +**这台机器上任何本地用户都能驱动机械臂** —— 这是刻意的选择,不是疏漏。 + ## 快速开始 ```python @@ -45,27 +61,40 @@ import litearm as pa with pa.Arm().connect() as arm: print(arm.firmware, arm.n) # 版本串、关节数 + # 上电后第一次必须先 reset():臂上电就停在 EMERGENCY 锁存, + # 不解开它,enable() 一律被拒 ERR{0x10,0x06} + arm.reset() + # 使能(运动之前必须) arm.enable() # 关节运动:固件规划 S 曲线并自己走完 - arm.movej([0.1, 0, 0, 0, 0, 0, 0], speed=0.3) + # 起手摆到一个各关节都远离限位的位形:全伸直(q ≈ 0)是奇异位形, + # 从那里出发的笛卡尔运动一条都规划不出来 + arm.movej([0.0, 0.4, -0.8, -1.2, 0.0, 0.6, 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) + # 拿刚读到的实测位姿当起点,只改位置、姿态照抄 —— 省得自己配一套姿态 + tcp = arm.get_tcp().value + arm.move_l((tcp[0], tcp[1], tcp[2] - 0.05, *tcp[3:]), 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))) + print(arm.ik((tcp[0], tcp[1], tcp[2] - 0.07, *tcp[3:]))) # 回零 arm.home() # 退出 with 即断开 ``` +`reset()` 是上电后的前置步骤,不是可选项:臂一上电就处于 `EMERGENCY` 锁存 +(`mode_name == "EMERGENCY"`、`flags` 含 `FAULT`、`joint_fault` 非 0), +不先解开它,`enable()` 会**立刻**抛 `ERR{0x10,0x06}`,一次重试都不发。 +`reset()` 之后 `mode` 回到 `INIT`、故障位清零,整段快速开始即可跑通。 + `Arm().connect()` 是唯一入口。每个会话背后有一条读线程,**用完必须 `close()`**,`with` 会自动关。 串口由 `port` 参数指定,不传则自动发现(VID:PID `1d50:606f`)。SDK **不读任何环境变量**。 @@ -93,23 +122,34 @@ arm.movej([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], speed=0.3) # 单发 arm.home() # 回零 ``` +⚠ **别在 `enable()` 的下一行读 `get_state().enabled`。** 它在状态帧反映之前就返回—— +实测返回耗时 2.0~2.7 ms,而那个标志变真要到 7.0~7.8 ms——所以读到的是 `False`, +那个值没有任何意义。判定前先等一拍。 + ### 笛卡尔运动 ```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()) +# 在**实测**位姿上做偏移。可达性由臂的几何决定,不由请求决定, +# 写死的绝对位姿可能根本没有逆解。 +p = arm.get_tcp().value +P1 = (p[0] + 0.02, p[1], p[2], p[3], p[4], p[5]) +P2 = (p[0] + 0.04, p[1], p[2], p[3], p[4], p[5]) +P3 = (p[0] + 0.06, p[1], p[2], p[3], p[4], p[5]) + +arm.move_l(P1, speed=0.3) # 直线 +arm.move_p(P1) # 关节空间点到点 —— 末端不走直线 +arm.move_path([P1, P2, P3], speed=0.3) # 依次经过多个路点,转角是尖角 +arm.move_c(arm.get_tcp().value, P1, P2) # 圆弧 —— 起点必须是实测 TCP + +plan = arm.move_l(P2, wait=False) # 仍会阻塞到规划应答到达 +print(plan.settled) # False —— 我们没等臂停稳 arm.set_speed(50) # 全局调速:整数百分比 0..100 ``` +⚠ **机械臂开机停在零位,那是完全伸展的奇异位形。** 从那里发笛卡尔命令没有可用逆解, +会被拒成 `IKError`(`err=1`)。先弯一个关节 +(`arm.movej([0.0, -0.5, 0.0, -1.0, 0.0, 0.0, 0.0], speed=0.3)`)—— 见「快速开始」。 + ### 状态 / 运动学 ```python @@ -125,10 +165,15 @@ print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # 位姿 → 关节角 ```python arm.emergency_stop() # 急停:唯一没有前置条件的入口 arm.reset() # 清故障 + 重锚控制环(不是 MCU 重启) -arm.clear_faults() # 只清 RAM 故障位 +arm.clear_faults() # 只清 FAULT 一位,清不掉锁存态 arm.disable() # 失能后不再被位置环托住 ``` +`clear_faults()` **清不掉锁存态**:`FAULT` 位会被清掉,`EMERGENCY` 模式与 +`FB_STALE` 立刻回来,臂仍然使能不了。这两种情况只有 `reset()` 能恢复 —— +`clear_faults()` 只清 `ARM_FLAG_FAULT` 这一位,使能门禁看的是 `reset()` 才清的 +锁存判据。 + ### 前馈 / 动力学调参 ```python @@ -194,9 +239,15 @@ print(arm.diag.kin_bench().value) # CAN 链路诊断计数 ### 参数持久化 ```python +arm.disable() # 须先失能,使能中调用会被固件拒 arm.save_params() # 写入 flash,不可逆 +arm.enable() ``` +`save_params()` **要求先失能**:使能中(或 `enable` 在途)调用回 `ERR{0x25,0x04}`。 +而 `disable()` 的那一刻臂不再被位置环托住 —— **侧装或带负载时先托住臂,或先走到低位**, +否则它会因自重落下。 + ### 只读属性 ```python @@ -215,10 +266,17 @@ litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3 litearm-python home ``` -`status` / `fw` / `tcp` 只读,其余会真的驱动机械臂。等价写法是 `python -m litearm`。 +`status` / `fw` / `tcp` 只读,其余会真的驱动机械臂。 + +⚠ **别指望照抄的 `litearm-python` 这个名字能解析得到**:控制台脚本装在 +`~/.local/bin`,那个目录默认**不在 `PATH` 上**(见「安装」),照抄会报"未找到命令"。 +用 `python3 -m litearm <子命令>`(等价写法),或把 `~/.local/bin` 加进 `PATH`。 ## 注意事项 +先看一份[现场排障](TROUBLESHOOTING.zh-CN.md):串口被占用怎么定位占用方、 +`enable()` 被拒的每个码怎么处置、笛卡尔报错怎么分辨,那里都有现成命令。 + ### 读值要走 `.value` 11 个"读一帧"接口返回的是信封 `Msg(value, hz, timestamp)` —— `.value` 才是数据本身, @@ -229,6 +287,39 @@ print(arm.get_state()) # Msg(value=RobotState(...), hz=100.2, times print(arm.get_state().value.q) # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] ``` +**"同时为 0"这条判据有例外**:进程内读到的**第一帧**,`hz` 按 0 算 —— +刚连上就 `print(arm.get_state())` 会看到 `hz=0.0`,但那帧是真的,几秒后同一个会话再读就是 +`hz≈98.7`。要判断"这类帧有没有到过",看 `timestamp`(或者隔一会儿再读一次),别看单次 `hz`。 + +### `mode` / `flags` 怎么读 + +`RobotState.mode_name` 是固件的运动模式,取值只有这 8 个: + +| `mode` | `mode_name` | 含义 | +| --- | --- | --- | +| 0 | `INIT` | 启动 / 未使能。`reset()` 之后没有运动时就是它 | +| 1 | `MOVE_J` | 关节轨迹在途(`movej` / `home`) | +| 2 | `MOVE_P` | 笛卡尔点到点 | +| 3 | `MOVE_JS` | 关节透传 / 伺服在途 | +| 4 | `MOVE_MIT` | `send_mit` 在途 | +| 5 | `MIT_ALL` | `send_mit_all` 在途 | +| 6 | `EMERGENCY` | 急停锁存。**臂上电就停在这里**,见[快速开始](#快速开始) | +| 7 | `ZERO_G` | 零重力拖动示教中 | + +表外的值直接显示成数字。真机上常见的是 `INIT` / `MOVE_J` / `EMERGENCY` / `ZERO_G`; +`MOVE_P` 在固件里标注为"B3 前由 PC 算 IK 转 `move_j`",所以通常看不到它。 + +`flags` / `flag_names` 是安全位,取值 `FAULT` / `WD_TRIPPED` / `FB_STALE` / +`TEMP_WARN` / `POS_VIOL` / `OVERSPEED`。其中两个别读错: + +- **`WD_TRIPPED` 是常态,不是故障。** 臂使能但空闲(`mode != MOVE_J`)时它一直亮着 —— + 那是固件的命令看门狗(0.1 s)在"没有运动命令续命"时的正常状态。 +- **`FB_STALE` 是活条件**,只清故障位清不掉,见[生命 / 安全](#生命--安全)。 + +`mode == ZERO_G` 说的是**臂**在零重力里,由固件持有;`arm.zero_g_active` 说的是 +**本进程**还在不在保活。两者可以不一致:进程被硬杀之后臂仍在 `ZERO_G`, +而新进程读到的 `zero_g_active` 是 `False`。 + ### 多进程:`fork` 之后子进程不能用继承来的 `Arm` 命令会**真的发出去**,但应答被父进程的读线程吃掉 —— 你只看到"无应答"超时, @@ -306,6 +397,22 @@ LITEARM_LIVE=1 pytest # 额外跑真机用例,会小幅运动 跑测试不需要装包,`tests/conftest.py` 会自己设好 `sys.path`。 无人在场时不要设 `LITEARM_LIVE`,也不要调用 `enter_dfu()` / `reset_factory()`。 +**本机装了 ROS 2 时 `pytest` 会在收集阶段崩掉,与本仓库无关**:pytest 按 entry point +自动加载 `/opt/ros/...` 里的插件,其中 `launch` 需要未安装的 `lark`: + +``` +ModuleNotFoundError: No module named 'lark' +``` + +venv 拦不住它 —— `PYTHONPATH` 的优先级高于 venv 的 `site-packages`。加下面任一个即可: + +```bash +PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest +env -u PYTHONPATH pytest +``` + +`-p no:launch_testing` **不够**:`launch_ros` 那个入口照样会被加载。 + Windows 用 `env.ps1` / `env.cmd` 与 `run_example.ps1` / `run_example.cmd`。 ## License diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 8f6f0ef..3cdfd6e 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -27,6 +27,7 @@ only then suspect the cabling. | `last_reset_reason` is `None` | §10 | | Every `kin_bench` counter reads 0 | §11 | | A motion command right after `zero_g` is rejected | §12 | +| The arm is stuck in `ZERO_G` and `zero_g_stop()` does nothing | §17 | | 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 | @@ -141,12 +142,17 @@ hits this; concurrent use across processes or clients does. | `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 | +| `1` | No IK solution, **or** an adjacent-solution jump past the firmware's `CART_MAX_IK_STEP` gate (0.35 rad). A **fully extended `home` pose** (a singularity) is the usual way to get here; it is the firmware behaving sensibly, not a defect | | `3` | Over capacity / unreachable | **What to do**: pick three points that are not collinear; when starting from a singular pose, leave the singularity first. +**Do not** read a singular start as "always refused". Measured on real hardware, a cartesian +step of **1-4 mm** from the home pose is **accepted** and reconstructs the arm by **13-60°** +while the tool moves barely 1-3 mm. The gate catches large steps; it is not a singularity +detector. Bend a joint before any cartesian command if you want a pose you can reason about. + 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. @@ -320,3 +326,37 @@ reliable way back**. When stress-testing the CAN link, run **`candump` (read-only) only — never `cangen`**: `can0` *is* the motor bus. + +--- + +## 17. The arm is stuck in `ZERO_G` and will not come out + +**Symptom**: a new session connects, `arm.zero_g_stop()` returns normally, but `mode` stays at +`ZERO_G` and every motion command is rejected: + +``` +CommandRejectedError: ERR [01,4] —— zero-gravity (hand-guiding) in progress —— +you must turn zero_g off explicitly, the firmware does not exit on its own +``` + +**Cause**: zero-gravity is held by the **firmware**; a dropped link does not exit it. And +`zero_g_stop()` only writes the exit frame if **this session entered** zero-gravity +(`if was_session`) — kill the process, lose the port, or open a new session and it sends not a +single byte. The one exit the firmware asks for is exactly the one the SDK blocks. + +**How to recognise it**: `mode_name == "ZERO_G"` (usually with `WD_TRIPPED` in `flags`) while +`arm.zero_g_active` reads `False`. The latter only says **this session** is not running the +keep-alive, not that the arm is out of zero-gravity — if the arm pushes around easily and `tau` +is still holding it up, it is still in there. + +**What to do**: pair an enter with an exit, and `zero_g_stop()` will really write the exit frame: + +```python +arm.zero_g_start() +arm.zero_g_stop() +arm.reset() # disables the arm; support it or move it to a low pose first +arm.enable() +``` + +`arm.reset()` on its own also gets you out, but it disables the arm too — side-mounted or under +load, it sags. diff --git a/TROUBLESHOOTING.zh-CN.md b/TROUBLESHOOTING.zh-CN.md index b94a47d..2fd6f17 100644 --- a/TROUBLESHOOTING.zh-CN.md +++ b/TROUBLESHOOTING.zh-CN.md @@ -25,6 +25,7 @@ | `last_reset_reason` 是 `None` | §10 | | `kin_bench` 计数器全是 0 | §11 | | `zero_g` 之后紧接的动作命令被拒 | §12 | +| 臂卡在 `ZERO_G`,`zero_g_stop()` 不起作用 | §17 | | `move_js` / `send_mit` 之后臂慢慢塌下去 | §13 | | `enter_dfu()` 之后刷不进去 | §14 | | `set_speed` / `set_joint_limits` 行为和直觉不符 | §15 | @@ -127,11 +128,15 @@ | `err` | 原因 | | --- | --- | | `2` | **三点共线**(或近乎共线)——圆心跑向无穷远 | -| `1` | 逆解无解。从**完全伸展的 `home` 位姿**(奇异点)出发**必然**如此,这是固件的合理行为,不是缺陷 | +| `1` | 逆解无解,**或**相邻解跳变超过固件的 `CART_MAX_IK_STEP` 闸门(0.35 rad)。从**完全伸展的 `home` 位姿**(奇异点)出发是常见的成因;这是固件的合理行为,不是缺陷 | | `3` | 超容量 / 不可达 | **怎么办**:换一组不共线的三点;从奇异位姿出发时先离开奇异点再走圆弧。 +⚠ **别把"起点奇异"读成"必然被拒"**。真机实测:从零位发 **1~4 mm** 的笛卡尔小步是**会被接受**的, +臂会重构 **13~60°**,而末端只动 1~3 mm。那道闸门拦的是**大位移**,它不是奇异检测器。 +想拿到一个自己能推理的位形,发笛卡尔之前**先弯一个关节离开奇异位形**。 + `move_c(start, via, goal)` 的 **`start` 必须与调用时的实测 TCP 一致**(容差 6 mm / 0.03 rad)。 它不是"从哪儿走"的自由参数,是**校验收到的**——拿运动中的 TCP 当起点会被拒。 `via` 的**姿态被忽略**,只有位置参与定圆。 @@ -283,3 +288,35 @@ ⇒ **没有可依赖的退路**。 压测 CAN 链路时**只开 `candump`(只读),绝不 `cangen`**——`can0` 就是电机总线。 + +--- + +## 17. 臂卡在 `ZERO_G` 里出不来 + +**现象**:新会话连上,`arm.zero_g_stop()` 正常返回,但 `mode` 原地停在 `ZERO_G`, +之后每条运动命令都被拒: + +``` +CommandRejectedError: ERR [01,4] —— 零重力(拖动示教)进行中 —— +须显式 zero_g off 退出, 固件不隐式退出 +``` + +**原因**:零重力由**固件**持有,链路断了它也不会自己退出。而 `zero_g_stop()` 只在 +**本会话曾经进入过**零重力时才写退出帧(`if was_session`,见 `arm.py`)—— +进程被硬杀、串口掉过、换了新会话,它一个字节都不发。于是"须显式退出"指的正是 +SDK 堵死的那条路。 + +**怎么认出来**:`mode_name == "ZERO_G"`(`flags` 通常带 `WD_TRIPPED`), +而 `arm.zero_g_active` 报 `False`。后者只说**本会话**没在保活,不代表臂不在零重力里 —— +臂在手推很轻、很跟手,`tau` 仍在托着,就是它还在。 + +**怎么办**:进入 + 退出发一对,`zero_g_stop()` 就会真的写退出帧: + +```python +arm.zero_g_start() +arm.zero_g_stop() +arm.reset() # 会失能,先托住臂或走到低位 +arm.enable() +``` + +`arm.reset()` 单独也能出得来,但同样会失能 —— 侧装或带负载时臂会因自重落下。 diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 11c88d3..5ee59c2 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -234,7 +234,7 @@ park() | Method | Caveat | | --- | --- | -| `enable(attempts=12)` | Enables all joints. Retries are whitelisted: **only `(0x10, 0x03)` is retried** | +| `enable(attempts=12)` | Enables all joints. Retries are whitelisted: **only `(0x10, 0x03)` is retried**. ⚠ **It returns before the state stream confirms it** — measured 2.0-2.7 ms to return against 7.0-7.8 ms for `get_state().enabled` to turn true, so `enable(); get_state().value.enabled` reads `False` and means nothing. Wait a frame (or ~10 ms) before judging | | `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 | @@ -283,9 +283,9 @@ All planning happens in the firmware; the PC sends points and receives a `0x4E` | 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_l` / `move_c` / `move_path` | Return a `CartPlan`. `wait` decides **only** whether to wait for the arm to *stop*: `wait=True` (default) blocks until it has settled and fills `settled` / `q_final` / `settle_err_rad`; `wait=False` returns as soon as the firmware's plan reply arrives — the arm is still moving and those three fields are left as "not waited for", **not** as "did not arrive". **Both block until that reply**; `wait=False` is not a non-blocking call | | `move_c` | `start` must match the measured TCP at call time (tolerance 6 mm / 0.03 rad); write `arm.get_tcp().value` | -| `poll_cart()` | Reads only the collector's unclaimed queue, does not touch the link | +| `poll_cart()` | Reads only the collector's unclaimed queue, does not touch the link. ⚠ **It is not how you collect a `wait=False` result** — there is none to collect: both `wait` values consume the reply before returning, so `poll_cart()` returns `None` after them. Its one real use is recovering the result of a call whose **ACK timed out** (`MotionTimeoutError`) and which therefore left its token unclaimed | | `set_speed(percent)` | A global, **persistent** governor. `percent` is an **integer percentage 0..100** — `set_speed(1)` means **1% speed**, not "full speed"; it is not the same thing as the per-trajectory factor in `movej(speed=0..1)` | Capability boundaries: no corner blending, no pre-flight preview, and the speed pre-check exists diff --git a/docs/DEVELOPER_GUIDE.zh-CN.md b/docs/DEVELOPER_GUIDE.zh-CN.md index 802f823..ff6e3f6 100644 --- a/docs/DEVELOPER_GUIDE.zh-CN.md +++ b/docs/DEVELOPER_GUIDE.zh-CN.md @@ -226,7 +226,7 @@ park() | 方法 | 注意 | | --- | --- | -| `enable(attempts=12)` | 使能全部关节。重试是白名单,**只有 `(0x10, 0x03)` 会重试** | +| `enable(attempts=12)` | 使能全部关节。重试是白名单,**只有 `(0x10, 0x03)` 会重试**。⚠ **它在状态帧反映之前就返回**——实测返回耗时 2.0~2.7 ms,而 `get_state().enabled` 变真要到 7.0~7.8 ms,所以 `enable(); get_state().value.enabled` 读到的是 `False`,那个值没有任何意义。判定前先等一拍(约 10 ms) | | `disable()` | 切断位置环。此后机械臂不再被托住 | | `emergency_stop()` | 单帧、单向、不读状态,唯一没有前置条件的入口 | | `reset()` | 软件状态复位,**不是 MCU 重启**,同一对象仍可用 | @@ -275,9 +275,9 @@ set_speed(percent) | 方法 | 注意 | | --- | --- | | `move_p` | 只收单个位姿,传序列抛 `InvalidCommandError`。到位判据是 TCP 容差 | -| `move_l` / `move_c` / `move_path` | 返回 `CartPlan`。`wait=False` 时不阻塞,用 `poll_cart()` 查进度 | +| `move_l` / `move_c` / `move_path` | 返回 `CartPlan`。`wait` **只决定要不要等臂"停稳"**:`wait=True`(默认)阻塞到停稳并填好 `settled` / `q_final` / `settle_err_rad`;`wait=False` 在固件的规划应答一到就返回——此时臂**还在动**,那三个字段是"**没等**",不是"没到位"。**两种取值都会阻塞到那条应答**,`wait=False` 不是非阻塞调用 | | `move_c` | `start` 必须与调用时的实测 TCP 一致(容差 6 mm / 0.03 rad),写成 `arm.get_tcp().value` | -| `poll_cart()` | 只读收集器的待认领队列,不碰链路 | +| `poll_cart()` | 只读收集器的待认领队列,不碰链路。⚠ **它不是"取 `wait=False` 结果"的手段**——压根没有可取的结果:两种 `wait` 取值都在返回前把应答消费掉了,所以调用它只会拿到 `None`。它唯一的真实用途是取回**入口 ACK 超时**(`MotionTimeoutError`)那条命令的结局——那条路径刻意留下了没人认领的 token | | `set_speed(percent)` | 全局、**持续**的调速器。`percent` 是 **0..100 的整数百分比**——`set_speed(1)` 就是 **1% 速度**,不是"满速";它和 `movej(speed=0..1)` 的单条轨迹倍率不是一回事 | 能力边界:无拐角倒角,无下发前预览,速度预检只在固件里。 diff --git a/examples/03_move_p.py b/examples/03_move_p.py index cf08381..3bad980 100644 --- a/examples/03_move_p.py +++ b/examples/03_move_p.py @@ -11,7 +11,15 @@ 要末端走直线/圆弧/多路点见 06_cartesian.py (`move_l`/`move_c`/`move_path`, 固件规划); 多路点传进 move_p 会抛 InvalidCommandError (旧 movep 的序列重载已移除)。 -安全: 需 --go; 默认在当前 TCP 上沿 +X 平移 1cm (小步可回退)。 +⚠⚠ **move_p 没有"相邻解跳变"闸门, 别在近奇异位形上用它。** `move_l`/`move_c`/`move_path` +走 `cart_plan.c`, 那里有 `CART_MAX_IK_STEP = 0.35 rad` 一道闸; `move_p` 走 +`kin_runner_request_move_p` -> 后台 DLS-IK, **一个判据都没有**。于是在近奇异位形(开机 +零位就是)上, 一个 1cm 的目标位姿可以让 IK 选到远支 —— 真机实测 (Litearm1.10.0-7J): +**J1 转 74°、J3 转 52°, 而末端只动了 1cm, 脚本照样报"到位"**。 +健康位形下同一个调用是 5° 量级 (实测 dq 0.095 rad / 20mm), 所以这不是"move_p 不能用", +是"**别从奇异位形发**"。本样例因此**先离开奇异位形**再发目标。 + +安全: 需 --go; 先把臂送到一个明显弯折的位形, 再在当前 TCP 上沿 +X 平移 1cm。 运行: python3 examples/03_move_p.py --go @@ -22,6 +30,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import make_arm, parse_args +#: 发笛卡尔命令前的安全起点 —— 明显弯折, 远离零位(伸直)那个奇异位形。 +#: 与 README 的 Quick start 同一处置 (那里也是先 `movej` 弯一个关节再发笛卡尔)。 +AWAY_FROM_SINGULAR = [0.0, -0.5, 0.0, -1.0, 0.0, 0.0, 0.0] + def main(): args = parse_args(__doc__) @@ -33,6 +45,8 @@ def main(): print("只读连接: 加 --go 才会执行 move_p (跳过)") return arm.enable() + arm.movej(AWAY_FROM_SINGULAR, speed=0.3) # 先离开奇异位形 + tcp = arm.get_tcp().value target = list(tcp) target[0] += 0.01 # 世界 +X 1cm 小步 print("move_p 目标:", [round(v, 4) for v in target]) diff --git a/examples/06_cartesian.py b/examples/06_cartesian.py index 3871ed4..ca899ab 100644 --- a/examples/06_cartesian.py +++ b/examples/06_cartesian.py @@ -44,6 +44,12 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from _common import make_arm, parse_args +#: 发笛卡尔命令前的安全起点 —— 明显弯折, 远离零位(伸直)那个奇异位形。 +#: ⚠ 零位上 `move_l`/`move_c`/`move_path` 会被固件的 `CART_MAX_IK_STEP` 闸门拒 +#: (`IKError: err=1`, 臂一步没动 —— 安全, 但这个样例就白跑了)。详见 03_move_p.py +#: 的 docstring: `move_p` 那条路**没有**这道闸门, 更要先离开奇异位形。 +AWAY_FROM_SINGULAR = [0.0, -0.5, 0.0, -1.0, 0.0, 0.0, 0.0] + def show(label, plan): """`CartPlan` 没有 `summary()` —— 自己挑要打的字段。""" @@ -66,6 +72,11 @@ def main(): arm.enable() + # 先离开奇异位形, 再发笛卡尔 (理由见文件顶部 `AWAY_FROM_SINGULAR`)。 + arm.movej(AWAY_FROM_SINGULAR, speed=0.3) + tcp = arm.get_tcp().value # 起点 TCP 变了 —— 必须重量一次 + pos, R = tcp[:3], tcp[3:] + # ---- move_l: 直线 1cm ---- goal = [pos[0] + 0.010, pos[1], pos[2]] + list(R) print("\n--- move_l 直线 +1cm ---") diff --git a/src/litearm/arm.py b/src/litearm/arm.py index ec459c7..d60a3fb 100644 --- a/src/litearm/arm.py +++ b/src/litearm/arm.py @@ -1473,6 +1473,12 @@ def _cmd(self, cmd: int, payload: bytes, label: str, def enable(self, attempts: int = 12) -> None: """使能全关节 (`CMD_ENABLE 0x10`), 必要时重试。 + ⚠⚠ **本方法在状态帧反映之前就返回。** 真机实测 (yd 机整臂, `Litearm1.10.0-7J`): + 返回耗时 **2.0~2.7 ms**, 而 `get_state().enabled` 首次为真要到 **7.0~7.8 ms**。 + ⇒ **`enable(); get_state().value.enabled` 读到的是 `False`, 那个值没有任何意义** + (6 轮实测每次都这样, 不是偶发)。要判定使能是否成立就**隔一拍再读** + (约 10 ms, 或等一条新的状态帧)。 + ⚠ **只有固件明说"可重试"的码才重试** —— 判据是白名单 :data:`litearm.errors.ENABLE_RETRYABLE_CODES` (= `{0x03}`), 不是 "除锁存外都重试"。固件对 `0x10` 的返回码是**并列**的四个, 其中三个都明确