Skip to main content

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.

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:

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

Or install from a checkout:

pip install -e .

Linux needs serial permissions — check what the device node itself says first:

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:

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

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

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.

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

# 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

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:

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

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

Life / safety

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

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

with arm.zero_g():
    input("Drag the arm, then press Enter")

Passthrough / servo

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.*)

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.*)

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.*)

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.*)

print(arm.diag.kin_bench().value)               # CAN link diagnostic counters

Persistence

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

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

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

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

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:

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:

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

source env.sh                       # exports PYTHONPATH / PYTHON_BIN / LITEARM_PORT
python3 examples/01_hello.py
./run_example.sh 02_movej.py --go

Development

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:

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.

Metadata

Release files for litearm-python 1.2.4

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for litearm-python 1.2.4
File Size Uploaded Provenance
litearm_python-1.2.4.tar.gz 411.3 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for litearm-python 1.2.4
File Interpreter ABI Platform
litearm_python-1.2.4-py3-none-any.whl Python 3 none any Details

Total release size: 626.1 kB

Release files / litearm_python-1.2.4.tar.gz

Download URL litearm_python-1.2.4.tar.gz
Size 411.3 kB
Tags Source
SHA-256 checksum
How to use checksums
140e16a8a313d335d9c86537eb4d7a6ae2b5323be4648269190da620b6f77689
BLAKE2b-256 checksum
How to use checksums
69f25c34454e20c97cfdda52a941bdf907ab5c67fc2a39f50052d555069575de
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Release files / litearm_python-1.2.4-py3-none-any.whl

Download URL litearm_python-1.2.4-py3-none-any.whl
Size 214.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
55cc1c6e0fddbd67da4a532cf5903a5eaf5faa533b7d391c64c402b256ad5230
BLAKE2b-256 checksum
How to use checksums
dbad63ab2928ca6342497f9c6c249112e489f52ae880cef2691dc18b107349bb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Security for litearm-python 1.2.4

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for litearm_python-1.2.4.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page