litearm-python 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- litearm/__init__.py +116 -0
- litearm/__main__.py +3 -0
- litearm/_protocol.py +513 -0
- litearm/_rot.py +245 -0
- litearm/arm.py +2656 -0
- litearm/cart.py +1403 -0
- litearm/diagnostics.py +197 -0
- litearm/errors.py +509 -0
- litearm/log.py +233 -0
- litearm/model.py +253 -0
- litearm/params.py +120 -0
- litearm/payload.py +718 -0
- litearm/py.typed +0 -0
- litearm/state.py +171 -0
- litearm/testing.py +959 -0
- litearm/tool.py +171 -0
- litearm/transport.py +375 -0
- litearm_python-1.2.0.dist-info/METADATA +508 -0
- litearm_python-1.2.0.dist-info/RECORD +23 -0
- litearm_python-1.2.0.dist-info/WHEEL +5 -0
- litearm_python-1.2.0.dist-info/entry_points.txt +2 -0
- litearm_python-1.2.0.dist-info/licenses/LICENSE +202 -0
- litearm_python-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: litearm-python
|
|
3
|
+
Version: 1.2.0
|
|
4
|
+
Summary: LiteArm STM32 直连后端 —— 薄协议 SDK (固件承担 S曲线/IK/动力学/控制律)
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pyserial>=3.4
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# litearm-python
|
|
15
|
+
|
|
16
|
+
Python SDK for the LiteArm robotic arm: drive the arm from a PC over one USB serial cable,
|
|
17
|
+
straight to its firmware — no server or middleware in between. Trajectory planning, kinematics
|
|
18
|
+
and dynamics are all carried by the firmware.
|
|
19
|
+
|
|
20
|
+
> Full interface reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md).
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **Pure Python**: Python 3.9+, poses are plain lists / tuples, no numpy needed
|
|
25
|
+
- **A single dependency**: `pyserial`, nothing else
|
|
26
|
+
- **Direct over USB**: one cable to the firmware, no server in between
|
|
27
|
+
- **The firmware does the planning**: kinematics and dynamics live there; the PC sends points and decides arrival
|
|
28
|
+
- **Full motion API**: joint moves, Cartesian lines / arcs / waypoints, hand-guiding
|
|
29
|
+
- **Tool point (TCP) offset**: a host-side xyz + rpy layer, so `move_*` and `ik()` take tool
|
|
30
|
+
poses while `get_tcp()` keeps returning the flange
|
|
31
|
+
- **Payload identification**: measure the mass and centre of mass of the end-of-arm load from
|
|
32
|
+
the arm's own torque, then hand the result to `set_payload()`
|
|
33
|
+
- **Safety built in**: fail-closed in child processes, a dedicated emergency stop, and every irreversible command flagged
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
| Item | Requirement |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Python | 3.9 or later |
|
|
40
|
+
| Dependencies | `pyserial >= 3.4` (the only one) |
|
|
41
|
+
| Firmware | `Litearm1.5.0` or later |
|
|
42
|
+
| Connection | USB CDC serial, VID:PID `1d50:606f` |
|
|
43
|
+
|
|
44
|
+
From the repository root:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install -e .
|
|
48
|
+
python3 -c "import litearm; print(litearm.__version__)" # verify
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Linux needs serial permissions — check what the device node itself says first:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
ls -l /dev/ttyACM0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If it is `crw-rw-rw-` you do not need the group at all and can open it as you are. Only when it
|
|
58
|
+
is `crw-rw----` do you need to add yourself to `dialout`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
sudo usermod -aG dialout $USER # log in again for it to take effect
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`crw-rw-rw-` usually comes from a udev rule rather than the `dialout` group:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
# /etc/udev/rules.d/99-litearm.rules
|
|
68
|
+
SUBSYSTEM=="tty", ATTRS{idVendor}=="1d50", ATTRS{idProduct}=="606f", MODE="0666"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That rule belongs to no package — it was dropped in when the machine was set up. `MODE="0666"`
|
|
72
|
+
means **any local user on this machine can drive the arm**. That is deliberate, not an
|
|
73
|
+
oversight.
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
import litearm as pa
|
|
79
|
+
|
|
80
|
+
# connect: find the port and check the firmware version
|
|
81
|
+
with pa.Arm().connect() as arm:
|
|
82
|
+
print(arm.firmware, arm.n) # version string, joint count
|
|
83
|
+
|
|
84
|
+
# after power-up you must reset() first: the arm boots into the
|
|
85
|
+
# EMERGENCY latch, and until that is cleared enable() is refused
|
|
86
|
+
# with ERR{0x10,0x06}
|
|
87
|
+
arm.reset()
|
|
88
|
+
|
|
89
|
+
# enable (required before any motion)
|
|
90
|
+
arm.enable()
|
|
91
|
+
|
|
92
|
+
# joint move: the firmware plans the S-curve and completes it
|
|
93
|
+
# start from a pose with every joint well away from its limits:
|
|
94
|
+
# fully extended (q ≈ 0) is a singular pose and no Cartesian
|
|
95
|
+
# motion can be planned out of it
|
|
96
|
+
arm.movej([0.0, 0.4, -0.8, -1.2, 0.0, 0.6, 0.0], speed=0.3)
|
|
97
|
+
|
|
98
|
+
# Cartesian line: a pose is 3 position values (m) + 3 orientation values (rad)
|
|
99
|
+
# take the measured pose as the start and move position only — copying
|
|
100
|
+
# the orientation saves you from having to work one out
|
|
101
|
+
tcp = arm.get_tcp().value
|
|
102
|
+
arm.move_l((tcp[0], tcp[1], tcp[2] - 0.05, *tcp[3:]), speed=0.5)
|
|
103
|
+
|
|
104
|
+
# read state
|
|
105
|
+
print(arm.get_state().value.q) # current joint angles
|
|
106
|
+
print(arm.get_tcp().value) # current tool pose
|
|
107
|
+
|
|
108
|
+
# inverse kinematics: pose → joint angles
|
|
109
|
+
print(arm.ik((tcp[0], tcp[1], tcp[2] - 0.07, *tcp[3:])))
|
|
110
|
+
|
|
111
|
+
# go home
|
|
112
|
+
arm.home()
|
|
113
|
+
# leaving the with block disconnects
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`reset()` is a step after power-up, not an option: the arm boots into the `EMERGENCY` latch
|
|
117
|
+
(`mode_name == "EMERGENCY"`, `FAULT` in `flags`, a non-zero `joint_fault`), and until that is
|
|
118
|
+
cleared `enable()` throws `ERR{0x10,0x06}` **immediately** — without sending a single retry.
|
|
119
|
+
`reset()` puts `mode` back to `INIT` and clears the fault bits, and the whole quick start runs
|
|
120
|
+
from there.
|
|
121
|
+
|
|
122
|
+
`Arm().connect()` is the only entry point. Every session carries a read thread, so **you must
|
|
123
|
+
`close()` it** — `with` does that for you. Pass `port` to pick the serial port; leave it out and
|
|
124
|
+
the SDK auto-discovers it (VID:PID `1d50:606f`). The SDK **reads no environment variables**.
|
|
125
|
+
|
|
126
|
+
Every `arm` below refers to that connected session object; the snippets show only the steps
|
|
127
|
+
that section is about.
|
|
128
|
+
|
|
129
|
+
## API reference
|
|
130
|
+
|
|
131
|
+
### Connection
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
arm = pa.Arm(port="/dev/ttyACM0").connect() # omit port to auto-discover
|
|
135
|
+
print(arm.firmware) # version string, e.g. "Litearm1.8.0-7J"
|
|
136
|
+
print(arm.n) # joint count
|
|
137
|
+
arm.close() # disconnect (a with block does this for you)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Joint motion
|
|
141
|
+
|
|
142
|
+
All three need `enable()` first: `movej` is a single shot (the firmware plans the S-curve and
|
|
143
|
+
completes it), `movej_sync` is a synchronised point to point, and `home` goes home.
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
arm.enable() # required before motion
|
|
147
|
+
arm.movej([0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], speed=0.3) # single shot
|
|
148
|
+
arm.home() # go home
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Do not** read `get_state().enabled` on the line after `enable()`. It returns before the
|
|
152
|
+
state stream reflects the change — **2.0-3.1 ms** to return, while the flag takes
|
|
153
|
+
**17.6-48.5 ms** to turn true (n=11; one earlier run on the same unit read 7.0-7.8 ms —
|
|
154
|
+
the lag is state-dependent, but always several frames, never one). So the check reads
|
|
155
|
+
`False` and means nothing. **Poll until it flips** — a fixed 10 ms wait is not enough.
|
|
156
|
+
|
|
157
|
+
### Cartesian motion
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
# Offset the *measured* pose. Reachability depends on the arm's geometry, not on the
|
|
161
|
+
# request, so a hard-coded absolute pose can simply have no IK solution.
|
|
162
|
+
p = arm.get_tcp().value
|
|
163
|
+
P1 = (p[0] + 0.02, p[1], p[2], p[3], p[4], p[5])
|
|
164
|
+
P2 = (p[0] + 0.04, p[1], p[2], p[3], p[4], p[5])
|
|
165
|
+
P3 = (p[0] + 0.06, p[1], p[2], p[3], p[4], p[5])
|
|
166
|
+
|
|
167
|
+
arm.move_l(P1, speed=0.3) # straight line
|
|
168
|
+
arm.move_p(P1) # joint-space point to point — not a line
|
|
169
|
+
arm.move_path([P1, P2, P3], speed=0.3) # visit the waypoints in turn, sharp corners
|
|
170
|
+
arm.move_c(arm.get_tcp().value, P1, P2) # arc — start must be the measured TCP
|
|
171
|
+
|
|
172
|
+
plan = arm.move_l(P2, wait=False) # still blocks for the plan reply
|
|
173
|
+
print(plan.settled) # False — we did not wait for the arm to stop
|
|
174
|
+
arm.set_speed(50) # global governor: integer percentage 0..100
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
⚠ **The arm boots at its zero pose, which is the fully-extended singularity.** Cartesian
|
|
178
|
+
commands from there have no usable IK solution and are refused (`IKError`, `err=1`). Bend a
|
|
179
|
+
joint first (`arm.movej([0.0, -0.5, 0.0, -1.0, 0.0, 0.0, 0.0], speed=0.3)`) — see Quick start.
|
|
180
|
+
|
|
181
|
+
### State / kinematics
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
state = arm.get_state().value
|
|
185
|
+
print(state.q, state.dq, state.tau) # joint angles / velocity / torque
|
|
186
|
+
print(state.enabled, state.faulted) # enabled? faulted?
|
|
187
|
+
print(arm.get_tcp().value) # flange pose (firmware forward kinematics)
|
|
188
|
+
print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # pose → joint angles
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Tool point (TCP) offset
|
|
192
|
+
|
|
193
|
+
The firmware's TCP *is* the flange. To control a tool point instead, declare the offset once —
|
|
194
|
+
`move_p` / `move_l` / `move_c` / `move_path` / `ik()` then take **tool** poses, including the
|
|
195
|
+
`move_c` start check and the `move_p` arrival test, while `get_tcp()` keeps returning the flange:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
arm.set_tcp_offset((0.0, 0.0, 0.18)) # metres / radians, in the flange frame
|
|
199
|
+
print(arm.get_tcp().value) # flange — firmware truth, unchanged
|
|
200
|
+
print(arm.get_tool_tcp().value) # the same pose plus the offset
|
|
201
|
+
arm.clear_tcp_offset() # back to controlling the flange
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The weight of the tool is a separate setting — `set_payload()`. Nothing here reaches the firmware.
|
|
205
|
+
|
|
206
|
+
### End-of-arm payload
|
|
207
|
+
|
|
208
|
+
`set_payload(mass, com)` writes the load **you** measured. `litearm.payload` measures it: it holds
|
|
209
|
+
at the poses of an empty-load baseline, subtracts that baseline configuration by configuration,
|
|
210
|
+
and solves for the mass and the centre of mass in the flange frame. Apply the result with
|
|
211
|
+
`litearm.payload.apply(arm, result)`.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# 1) turn a legacy baseline CSV into the JSON this package reads (once)
|
|
215
|
+
python3 -c "
|
|
216
|
+
from litearm.payload import Baseline
|
|
217
|
+
Baseline.from_csv('runs/20260918_151253_空载基准/noload_baseline.csv').save('empty.json')"
|
|
218
|
+
|
|
219
|
+
# 2) hold the load, then measure it (moves the arm through the baseline poses)
|
|
220
|
+
python3 examples/09_payload_identify.py --empty-baseline empty.json --go
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The regression matrix comes from the firmware (`0x39` evaluates `G(q)` at any `q`, and the
|
|
224
|
+
payload is composited in as a point mass), so nothing on the PC side needs a dynamics model and
|
|
225
|
+
nothing new is pulled in as a dependency. The **baseline is not optional**: subtracting it is what
|
|
226
|
+
cancels the arm's own model error — an absolute solve feeds that error straight into the answer.
|
|
227
|
+
The probe's side effects and the acceptance thresholds are in §5.16 of
|
|
228
|
+
[the developer guide](docs/DEVELOPER_GUIDE.md).
|
|
229
|
+
|
|
230
|
+
### Life / safety
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
arm.emergency_stop() # emergency stop: no preconditions at all
|
|
234
|
+
arm.reset() # clear faults + re-anchor (not an MCU reboot)
|
|
235
|
+
arm.clear_faults() # clears the FAULT bit only, not latches
|
|
236
|
+
arm.disable() # the arm is no longer held once disabled
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
`clear_faults()` **cannot clear a latched state**: the `FAULT` bit goes away, `EMERGENCY` mode
|
|
240
|
+
and `FB_STALE` come straight back, and the arm still will not enable. Only `reset()` recovers
|
|
241
|
+
from those two — `clear_faults()` clears the single `ARM_FLAG_FAULT` bit, while the enable gate
|
|
242
|
+
looks at latch conditions that only `reset()` clears.
|
|
243
|
+
|
|
244
|
+
### Feed-forward / dynamics
|
|
245
|
+
|
|
246
|
+
```python
|
|
247
|
+
arm.set_payload(1.0) # always call this after changing payload
|
|
248
|
+
arm.set_gravity_scale([1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0])
|
|
249
|
+
arm.ff_preset(1) # 0 all off / 1 factory / 2 all on
|
|
250
|
+
print(arm.get_ff_scalar(4).value) # read payload_mass back
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Hand-guiding
|
|
254
|
+
|
|
255
|
+
```python
|
|
256
|
+
with arm.zero_g():
|
|
257
|
+
input("Drag the arm, then press Enter")
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Passthrough / servo
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
arm.send_mit(0, 0.0, 0.0, 30.0, 1.0, 0.0) # bypasses planning; keep alive at ≥10 Hz
|
|
264
|
+
arm.joint_follow([0.0]*7, [0.0]*7, [30.0]*7, [1.0]*7) # no tau: the firmware computes it
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Parameters (`arm.params.*`)
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
p = arm.params.get_joint_param(0).value # J1's kp / kd / tau_max / soft limits
|
|
271
|
+
arm.params.set_joint_param(0, 30.0, 1.0, 20.0) # idx, kp, kd, tau_max
|
|
272
|
+
for jp in arm.params.all_joint_params():
|
|
273
|
+
print(jp.idx, jp.kp, jp.q_min, jp.q_max)
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### Dynamics model (`arm.model.*`)
|
|
277
|
+
|
|
278
|
+
```python
|
|
279
|
+
print(arm.model.probe()) # does the firmware support this?
|
|
280
|
+
print(arm.model.status().value) # override / staged_mask / dirty
|
|
281
|
+
print(arm.model.get_gravity([0.0] * 7).value) # G(q)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Data capture (`arm.log.*`)
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
arm.log.start(300) # record 300 ticks, then stop
|
|
288
|
+
r = arm.log.reader()
|
|
289
|
+
print(r.total()) # ticks written so far
|
|
290
|
+
for s in r.samples()[:3]:
|
|
291
|
+
print(s.tick, s.q_ref, s.dq, s.tau)
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### Firmware self-test (`arm.diag.*`)
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
print(arm.diag.kin_bench().value) # CAN link diagnostic counters
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### License and activation
|
|
301
|
+
|
|
302
|
+
An arm that has never been activated rejects `enable()` with `ERR{0x10,0x08}` and answers every
|
|
303
|
+
other command normally, so it stays diagnosable. `arm.license()` reads the record — a bare
|
|
304
|
+
`LicenseInfo`, not a `Msg`; `arm.activate()` submits a vendor-issued credential and needs
|
|
305
|
+
`disable()` first. See [the developer guide §5.15](docs/DEVELOPER_GUIDE.md#515-license-and-activation).
|
|
306
|
+
|
|
307
|
+
### Persistence
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
arm.disable() # required first; enabled calls are refused
|
|
311
|
+
arm.save_params() # writes flash, irreversible
|
|
312
|
+
arm.enable()
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
`save_params()` **requires the arm to be disabled**: called while enabled (or with an `enable`
|
|
316
|
+
in flight) it returns `ERR{0x25,0x04}`. And the moment it is disabled the arm is no longer held
|
|
317
|
+
by the position loop — **side-mounted or under load, support it or move it to a low pose first**,
|
|
318
|
+
or it drops under its own weight.
|
|
319
|
+
|
|
320
|
+
### Read-only properties
|
|
321
|
+
|
|
322
|
+
```python
|
|
323
|
+
print(arm.n, arm.firmware, arm.fw_version) # joint count / version string / version tuple
|
|
324
|
+
print(arm.q_tol, arm.dq_tol, arm.move_timeout) # arrival criteria and motion timeout
|
|
325
|
+
print(arm.zero_g_active, arm.last_reset_reason)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Command line
|
|
329
|
+
|
|
330
|
+
```bash
|
|
331
|
+
litearm-python status # read-only
|
|
332
|
+
litearm-python fw # version string + axis count
|
|
333
|
+
litearm-python tcp # current pose + frame rate
|
|
334
|
+
litearm-python movej -0.1 0 0 0 0 0 0 --speed 0.3
|
|
335
|
+
litearm-python home
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`status` / `fw` / `tcp` are read-only; the rest really drive the arm.
|
|
339
|
+
|
|
340
|
+
**Do not** expect the `litearm-python` name to resolve as written: the console script is
|
|
341
|
+
installed into `~/.local/bin`, which is not on `PATH` by default (see Install), so these
|
|
342
|
+
lines report `command not found`. Use `python3 -m litearm <subcommand>` — it is equivalent —
|
|
343
|
+
or add `~/.local/bin` to `PATH`.
|
|
344
|
+
|
|
345
|
+
## Things to watch out for
|
|
346
|
+
|
|
347
|
+
Start with the [troubleshooting guide](TROUBLESHOOTING.md): it has ready-made commands for
|
|
348
|
+
finding who holds the serial port, what to do about each `enable()` rejection code, and how to
|
|
349
|
+
tell Cartesian failures apart.
|
|
350
|
+
|
|
351
|
+
### Read the payload via `.value`
|
|
352
|
+
|
|
353
|
+
The 11 "read one frame" getters return the envelope `Msg(value, hz, timestamp)` — `.value` is
|
|
354
|
+
the payload itself, while `hz` / `timestamp` are how often frames of that kind arrive and when
|
|
355
|
+
the last one landed. If **both are 0**, no frame of that kind has ever arrived.
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
print(arm.get_state()) # Msg(value=RobotState(...), hz=100.2, timestamp=...)
|
|
359
|
+
print(arm.get_state().value.q) # [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
**That "both are 0" test has an exception**: the **first** frame a process reads counts `hz` as
|
|
363
|
+
0 — `print(arm.get_state())` right after connecting shows `hz=0.0`, but the frame is real and the
|
|
364
|
+
same session reads `hz≈98.7` a few seconds later. To decide whether frames of a kind have ever
|
|
365
|
+
arrived, look at `timestamp` (or read again later), not at a single `hz`.
|
|
366
|
+
|
|
367
|
+
### How to read `mode` / `flags`
|
|
368
|
+
|
|
369
|
+
`RobotState.mode_name` is the firmware's motion mode. It has exactly 8 values:
|
|
370
|
+
|
|
371
|
+
| `mode` | `mode_name` | Meaning |
|
|
372
|
+
| --- | --- | --- |
|
|
373
|
+
| 0 | `INIT` | Booted / not enabled. What you see after `reset()` with no motion |
|
|
374
|
+
| 1 | `MOVE_J` | Joint trajectory in flight (`movej` / `home`) |
|
|
375
|
+
| 2 | `MOVE_P` | Cartesian point to point |
|
|
376
|
+
| 3 | `MOVE_JS` | Joint passthrough / servo in flight |
|
|
377
|
+
| 4 | `MOVE_MIT` | `send_mit` in flight |
|
|
378
|
+
| 5 | `MIT_ALL` | `send_mit_all` in flight |
|
|
379
|
+
| 6 | `EMERGENCY` | Emergency-stop latch. **This is where the arm boots**, see [Quick start](#quick-start) |
|
|
380
|
+
| 7 | `ZERO_G` | Zero-gravity hand-guiding |
|
|
381
|
+
|
|
382
|
+
Values outside the table show up as the raw number. On real hardware you see `INIT` / `MOVE_J` /
|
|
383
|
+
`EMERGENCY` / `ZERO_G`; the firmware documents `MOVE_P` as "the PC computes IK and turns it into
|
|
384
|
+
`move_j` before B3", so you normally never see it.
|
|
385
|
+
|
|
386
|
+
`flags` / `flag_names` are the safety bits: `FAULT` / `WD_TRIPPED` / `FB_STALE` / `TEMP_WARN` /
|
|
387
|
+
`POS_VIOL` / `OVERSPEED`. Two of them are easy to misread:
|
|
388
|
+
|
|
389
|
+
- **`WD_TRIPPED` is normal, not a fault.** It stays lit while the arm is enabled but idle
|
|
390
|
+
(`mode != MOVE_J`) — that is the firmware's command watchdog (0.1 s) with no motion command
|
|
391
|
+
keeping it alive.
|
|
392
|
+
- **`FB_STALE` is a live condition**, and clearing fault bits does not clear it. See
|
|
393
|
+
[Life / safety](#life--safety).
|
|
394
|
+
|
|
395
|
+
`mode == ZERO_G` says the **arm** is in zero-gravity, held by the firmware; `arm.zero_g_active`
|
|
396
|
+
says **this session** is still running the keep-alive. The two can disagree: kill the process and
|
|
397
|
+
the arm stays in `ZERO_G` while a new session reads `zero_g_active` as `False`.
|
|
398
|
+
|
|
399
|
+
### Multiprocessing: a forked child must not use an inherited `Arm`
|
|
400
|
+
|
|
401
|
+
Commands **really go out on the wire**, but the parent's read thread consumes the replies — you
|
|
402
|
+
only see a "no response" timeout, and retrying means **sending the command twice**. Reading
|
|
403
|
+
state is subtler: it does not raise, it just **returns an eternally stale value**.
|
|
404
|
+
|
|
405
|
+
So this library is **fail-closed**: any command in a child process immediately raises
|
|
406
|
+
`ForkedSessionError`, and not a single byte goes out.
|
|
407
|
+
|
|
408
|
+
For a child to use the arm, the **parent must `close()` and release the port first**, then fork,
|
|
409
|
+
then create a new `Arm` inside the child:
|
|
410
|
+
|
|
411
|
+
```python
|
|
412
|
+
import multiprocessing
|
|
413
|
+
|
|
414
|
+
def worker():
|
|
415
|
+
a = pa.Arm().connect() # create it inside the child
|
|
416
|
+
|
|
417
|
+
a = pa.Arm().connect()
|
|
418
|
+
a.close() # without this the child cannot connect
|
|
419
|
+
p = multiprocessing.Process(target=worker)
|
|
420
|
+
p.start()
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
`close()` **is still callable** in the child (it only clears session state and never touches the
|
|
424
|
+
transport, so it does not hang) — but do not expect it to free the serial port.
|
|
425
|
+
|
|
426
|
+
### Safety rules
|
|
427
|
+
|
|
428
|
+
1. **After `disable()` the arm is no longer held by the position loop** — under load it sags.
|
|
429
|
+
2. **`movej` does not check joint limits** — an out-of-range target is clamped by the firmware
|
|
430
|
+
and the arm **still travels the full stroke**.
|
|
431
|
+
3. **`move_js` / `send_mit` need keep-alive at ≥10 Hz** — otherwise the 0.1 s watchdog drops
|
|
432
|
+
stiffness and the arm sags slowly.
|
|
433
|
+
4. **`enter_dfu()` is a terminal-state operation** — afterwards every entry point stops working
|
|
434
|
+
and you need a new `Arm` after flashing.
|
|
435
|
+
|
|
436
|
+
### Irreversible commands: do not run these on a calibrated arm
|
|
437
|
+
|
|
438
|
+
These overwrite or erase that unit's per-arm identified dynamics model, with **no undo**:
|
|
439
|
+
|
|
440
|
+
| Entry point | Effect |
|
|
441
|
+
| --- | --- |
|
|
442
|
+
| `save_params()` | writes the current RAM to flash |
|
|
443
|
+
| `arm.model.commit()` | applies staged dynamics model changes |
|
|
444
|
+
| `arm.model.revert()` | rolls the dynamics model back (does not touch flash, so it returns on power-up) |
|
|
445
|
+
| `arm.params.reset_factory()` | restores factory settings |
|
|
446
|
+
|
|
447
|
+
Only do this on a board whose calibration has no value. **`arm.model.set_jm()` should never be
|
|
448
|
+
called** — a wrong joint mapping can make the arm flail, and there is no dependable way back.
|
|
449
|
+
|
|
450
|
+
When stress-testing the CAN link, run `candump` (read-only) only, never `cangen`: `can0` *is*
|
|
451
|
+
the motor bus.
|
|
452
|
+
|
|
453
|
+
## Examples
|
|
454
|
+
|
|
455
|
+
See [examples/README.md](examples/README.md):
|
|
456
|
+
|
|
457
|
+
- `01_hello.py` — handshake + firmware version + reading state
|
|
458
|
+
- `02_movej.py` — joint motion
|
|
459
|
+
- `03_move_p.py` — Cartesian point to point
|
|
460
|
+
- `04_ik_tcp.py` — inverse kinematics and the current pose
|
|
461
|
+
- `05_ff_tune.py` — dynamics / control-law tuning
|
|
462
|
+
- `06_cartesian.py` — Cartesian lines / arcs / waypoints
|
|
463
|
+
- `07_vel_jitter_trace.py` — 300 Hz per-tick capture
|
|
464
|
+
|
|
465
|
+
The examples are **read-only by default**; anything that moves needs `--go`. Run them from
|
|
466
|
+
the repository root:
|
|
467
|
+
|
|
468
|
+
```bash
|
|
469
|
+
source env.sh # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT
|
|
470
|
+
python3 examples/01_hello.py
|
|
471
|
+
./run_example.sh 02_movej.py --go
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
## Development
|
|
475
|
+
|
|
476
|
+
```bash
|
|
477
|
+
pip install -e ".[dev]"
|
|
478
|
+
pytest # full offline run, never touches hardware
|
|
479
|
+
LITEARM_LIVE=1 pytest # plus hardware tests, moves a little
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Running the tests does not require installing the package; `tests/conftest.py` sets `sys.path`
|
|
483
|
+
itself. Do not set `LITEARM_LIVE` with nobody present, and never call `enter_dfu()` /
|
|
484
|
+
`reset_factory()`.
|
|
485
|
+
|
|
486
|
+
**On a machine with ROS 2 installed, `pytest` dies during collection and none of this repository
|
|
487
|
+
is involved**: pytest auto-loads the plugins under `/opt/ros/...` by entry point, and `launch`
|
|
488
|
+
needs `lark`, which is not installed:
|
|
489
|
+
|
|
490
|
+
```
|
|
491
|
+
ModuleNotFoundError: No module named 'lark'
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
A virtualenv does not stop it — `PYTHONPATH` takes priority over the venv's `site-packages`.
|
|
495
|
+
Either of these works:
|
|
496
|
+
|
|
497
|
+
```bash
|
|
498
|
+
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 pytest
|
|
499
|
+
env -u PYTHONPATH pytest
|
|
500
|
+
```
|
|
501
|
+
|
|
502
|
+
`-p no:launch_testing` is **not** enough: the `launch_ros` entry point gets loaded anyway.
|
|
503
|
+
|
|
504
|
+
On Windows use `env.ps1` / `env.cmd` and `run_example.ps1` / `run_example.cmd`.
|
|
505
|
+
|
|
506
|
+
## License
|
|
507
|
+
|
|
508
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
litearm/__init__.py,sha256=8tCm70g30CsRSqBDa3uqwqwQuSvyT4Z87SlstKk4hPQ,6004
|
|
2
|
+
litearm/__main__.py,sha256=w9hCRg_CvVYA9wtBaA__gm3Jxl9YCHK1IxFQG8OrwBE,55
|
|
3
|
+
litearm/_protocol.py,sha256=-dX-DCO-gE47Mc2XffqiyGKar1gnJfPDFecwkjwahyE,29256
|
|
4
|
+
litearm/_rot.py,sha256=MtG8gmx0WuvEyA3x4FsnYIa6tj2sYhq-CaS4Ut-S1FQ,10576
|
|
5
|
+
litearm/arm.py,sha256=nkd9HMmdakOdEAYtgCPXtH-frMX_s7aBLbzfcFh3PMo,159106
|
|
6
|
+
litearm/cart.py,sha256=2pFTJEtsFMUo7pWjrm9XZXhZCAVWJIwGv0kefebtBq0,96241
|
|
7
|
+
litearm/diagnostics.py,sha256=lbyaFlWgY4TbZIW6AKqgPQUcwKgWb-T1J-NS_OcsaIg,9652
|
|
8
|
+
litearm/errors.py,sha256=s02ineUPP8MCgCnwxwWlu_HoWVt0beD6_qYpO3pjQyk,32270
|
|
9
|
+
litearm/log.py,sha256=2sM5Op7OuZQT1q88L3rfO68ZD97S-Fyc7bukRBXUU1s,9840
|
|
10
|
+
litearm/model.py,sha256=gUMNe-63XWVLUqxIvX41-QDP07JExPwFM6k6Qr7awFI,12164
|
|
11
|
+
litearm/params.py,sha256=epInU50_5Ynf6hR2rZS4TTuNUCRbKgs5w361snY1gUo,5506
|
|
12
|
+
litearm/payload.py,sha256=Fq7Z7cxu__vttnhCYr6KIWTR6BU0vkW1Bm1azh_DD24,34158
|
|
13
|
+
litearm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
litearm/state.py,sha256=VGsdy0whowSRbTPaUM4snVka8o6IUgyFAs5iaCdahno,8862
|
|
15
|
+
litearm/testing.py,sha256=AZ3zWMScd_UNTDgt6NUQI_Svg6Xk9J7XmW_etX2RoFU,55515
|
|
16
|
+
litearm/tool.py,sha256=2PgLJM2olLaWlpMSlCKEYrNhey7ksmgu2Sd8pvNv33k,7582
|
|
17
|
+
litearm/transport.py,sha256=-Id2zlIPJWhGQ5HjnUgOPx4Nb6N0ntrwHMsXfAQ8o6g,21547
|
|
18
|
+
litearm_python-1.2.0.dist-info/licenses/LICENSE,sha256=yVuuHRzgI17MzTVgt3LsHvuX80innw--CmNPDCzO_iw,11358
|
|
19
|
+
litearm_python-1.2.0.dist-info/METADATA,sha256=9qSgbF8n639Xiwi4LHPYz12GsMrMgaaS3vS0O-8znoY,21019
|
|
20
|
+
litearm_python-1.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
21
|
+
litearm_python-1.2.0.dist-info/entry_points.txt,sha256=yFrsF49DqQ-8bHbrksyDuSxQOEER7i-9sWixm07AFDE,52
|
|
22
|
+
litearm_python-1.2.0.dist-info/top_level.txt,sha256=gqkLqmKbrqDYltZitHGepKU9jIMwLPTijtJpTcoWJ1Y,8
|
|
23
|
+
litearm_python-1.2.0.dist-info/RECORD,,
|