Skip to content

About

The Python SDK for the LiteArm robotic manipulator series.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

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

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

MIT

About

The Python SDK for the LiteArm robotic manipulator series.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages