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.
- 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
- Safety built in: fail-closed in child processes, a dedicated emergency stop, and every irreversible command flagged
| 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__)" # verifyLinux needs serial permissions — check what the device node itself says first:
ls -l /dev/ttyACM0If 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 effectcrw-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.
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 disconnectsreset() 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.
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)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 homeDo 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.
# 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 = 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) # tool pose (firmware forward kinematics)
print(arm.ik((0.30, 0.0, 0.35, 3.1416, 0.0, 0.0))) # pose → joint anglesarm.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 disabledclear_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.
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 backwith arm.zero_g():
input("Drag the arm, then press Enter")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 itp = 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)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)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)print(arm.diag.kin_bench().value) # CAN link diagnostic countersAn 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.
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.
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)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 homestatus / 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.
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.
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.
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.
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.
- 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.
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.
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 --gopip install -e ".[dev]"
pytest # full offline run, never touches hardware
LITEARM_LIVE=1 pytest # plus hardware tests, moves a littleRunning 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.
MIT