Metadata-Version: 2.4
Name: litearm-python
Version: 1.2.4
Summary: Python SDK for the LiteArm robotic arm
Author: NEXFORM ROBOTICS
License-Expression: Apache-2.0
Project-URL: Repository, https://github.com/nexform-tech/litearm-python
Project-URL: Issues, https://github.com/nexform-tech/litearm-python/issues
Project-URL: Documentation, https://github.com/nexform-tech/litearm-python/blob/main/docs/DEVELOPER_GUIDE.md
Keywords: litearm,robotic-arm,robot-arm,robotics,serial,stm32,sdk
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyserial>=3.4
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# litearm-python

Python SDK for the LiteArm robotic arm: drive the arm from a PC over one USB serial cable,
straight to its firmware — no server or middleware in between. Trajectory planning, kinematics
and dynamics are all carried by the firmware.

> Full interface reference: [docs/DEVELOPER_GUIDE.md](https://github.com/nexform-tech/litearm-python/blob/main/docs/DEVELOPER_GUIDE.md).

## Features

- **Pure Python**: Python 3.9+, poses are plain lists / tuples, no numpy needed
- **A single dependency**: `pyserial`, nothing else
- **Direct over USB**: one cable to the firmware, no server in between
- **The firmware does the planning**: kinematics and dynamics live there; the PC sends points and decides arrival
- **Full motion API**: joint moves, Cartesian lines / arcs / waypoints, hand-guiding
- **Tool point (TCP) offset**: a host-side xyz + rpy layer, so `move_*` and `ik()` take tool
  poses while `get_tcp()` keeps returning the flange
- **Payload identification**: measure the mass and centre of mass of the end-of-arm load from
  the arm's own torque, then hand the result to `set_payload()`
- **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` |

From PyPI:

```bash
pip install litearm-python
python3 -c "import litearm; print(litearm.__version__)"    # verify
```

Or install from a checkout:

```bash
pip install -e .
```

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
import litearm as pa

# connect: find the port and check the firmware version
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
    # 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)
    # 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((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**.

Every `arm` below refers to that connected session object; the snippets show only the steps
that section is about.

## API reference

### Connection

```python
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
```

**Do not** read `get_state().enabled` on the line after `enable()`. It returns before the
state stream reflects the change — **2.0-3.1 ms** to return, while the flag takes
**17.6-48.5 ms** to turn true (n=11; one earlier run on the same unit read 7.0-7.8 ms —
the lag is state-dependent, but always several frames, never one). So the check reads
`False` and means nothing. **Poll until it flips** — a fixed 10 ms wait is not enough.

### Cartesian motion

```python
# 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
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)                      # flange pose (firmware forward kinematics)
print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0)))   # pose → joint angles
```

### Tool point (TCP) offset

The firmware's TCP *is* the flange. To control a tool point instead, declare the offset once —
`move_p` / `move_l` / `move_c` / `move_path` / `ik()` then take **tool** poses, including the
`move_c` start check and the `move_p` arrival test, while `get_tcp()` keeps returning the flange:

```python
arm.set_tcp_offset((0.0, 0.0, 0.18))            # metres / radians, in the flange frame
print(arm.get_tcp().value)                      # flange — firmware truth, unchanged
print(arm.get_tool_tcp().value)                 # the same pose plus the offset
arm.clear_tcp_offset()                          # back to controlling the flange
```

The weight of the tool is a separate setting — `set_payload()`. Nothing here reaches the firmware.

### End-of-arm payload

`set_payload(mass, com)` writes the load **you** measured. `litearm.payload` measures it: it holds
at the poses of an empty-load baseline, subtracts that baseline configuration by configuration,
and solves for the mass and the centre of mass in the flange frame. Apply the result with
`litearm.payload.apply(arm, result)`.

```bash
# 1) turn a legacy baseline CSV into the JSON this package reads (once)
python3 -c "
from litearm.payload import Baseline
Baseline.from_csv('runs/20260918_151253_空载基准/noload_baseline.csv').save('empty.json')"

# 2) hold the load, then measure it (moves the arm through the baseline poses)
python3 examples/09_payload_identify.py --empty-baseline empty.json --go
```

The regression matrix comes from the firmware (`0x39` evaluates `G(q)` at any `q`, and the
payload is composited in as a point mass), so nothing on the PC side needs a dynamics model and
nothing new is pulled in as a dependency. The **baseline is not optional**: subtracting it is what
cancels the arm's own model error — an absolute solve feeds that error straight into the answer.
The probe's side effects and the acceptance thresholds are in §5.16 of
[the developer guide](https://github.com/nexform-tech/litearm-python/blob/main/docs/DEVELOPER_GUIDE.md).

### 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 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
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
arm.joint_follow([0.0]*7, [0.0]*7, [30.0]*7, [1.0]*7)   # no tau: the firmware computes it
```

### 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.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
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                          # 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; 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 <subcommand>` — it is equivalent —
or add `~/.local/bin` to `PATH`.

## Things to watch out for

Start with the [troubleshooting guide](https://github.com/nexform-tech/litearm-python/blob/main/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
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.

```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]
```

**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
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 library is **fail-closed**: any command in a child process immediately raises
`ForkedSessionError`, and not a single byte goes out.

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
import multiprocessing

def worker():
    a = pa.Arm().connect()        # create it inside the child

a = pa.Arm().connect()
a.close()                          # without this the child cannot connect
p = multiprocessing.Process(target=worker)
p.start()
```

`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.

### 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**:

| 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 |

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](https://github.com/nexform-tech/litearm-python/blob/main/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`. Run them from
the repository root:

```bash
source env.sh                       # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT
python3 examples/01_hello.py
./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
```

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 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

Apache License 2.0 — see [LICENSE](https://github.com/nexform-tech/litearm-python/blob/main/LICENSE).
