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_*andik()take tool poses whileget_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 the repository root:
pip install -e .
python3 -c "import litearm; print(litearm.__version__)" # verify
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
License and activation
An arm that has never been activated rejects enable() with ERR{0x10,0x08} and answers every
other command normally, so it stays diagnosable. arm.license() reads the record — a bare
LicenseInfo, not a Msg; arm.activate() submits a vendor-issued credential and needs
disable() first. See the developer guide §5.15.
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_TRIPPEDis 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_STALEis 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
- After
disable()the arm is no longer held by the position loop — under load it sags. movejdoes not check joint limits — an out-of-range target is clamped by the firmware and the arm still travels the full stroke.move_js/send_mitneed keep-alive at ≥10 Hz — otherwise the 0.1 s watchdog drops stiffness and the arm sags slowly.enter_dfu()is a terminal-state operation — afterwards every entry point stops working and you need a newArmafter 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 state02_movej.py— joint motion03_move_p.py— Cartesian point to point04_ik_tcp.py— inverse kinematics and the current pose05_ff_tune.py— dynamics / control-law tuning06_cartesian.py— Cartesian lines / arcs / waypoints07_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.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| litearm_python-1.2.2.tar.gz | 410.6 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| litearm_python-1.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 625.3 kB
Release files / litearm_python-1.2.2.tar.gz
| Download URL | litearm_python-1.2.2.tar.gz |
|---|---|
| Size | 410.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a56fcef81cbde92ddad0eaa21b5f90c3af2bf14738bd39924cccee35e45b8f63
|
|
BLAKE2b-256 checksum How to use checksums |
0f2642e4938ee45280814eb3499253675ced31f4dd52a9dc7e9a7b64fab17a78
|
| 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 logRelease files / litearm_python-1.2.2-py3-none-any.whl
| Download URL | litearm_python-1.2.2-py3-none-any.whl |
|---|---|
| Size | 214.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
c93c06ea156f30bc144a12b3c0383d47580aa2f55931c561bceee6323d684563
|
|
BLAKE2b-256 checksum How to use checksums |
3535037cc3e002b777905d0ebaff74f32c99a0357f4c0ec884605c23effe871b
|
| 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 logSecurity for litearm-python 1.2.2
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.
Attestation for litearm_python-1.2.2.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 logA 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.