Installs as robot-motion with the API and the shared web UI (src/web) included by default. Optional extras are [xarm], [ur], and [mg400] (reserved; MG400 is not implemented yet). Run robot-motion serve for the UR service, or robot-motion drivers for an offline capability inventory. UR model profiles are ur3e, ur5e and ur5_cb3. The UR service serves the shared panel and maps RTDE telemetry into the status keys that panel reads. Physical UR control (arm motion, the Robotiq gripper, manual mode and force zeroing) is config-gated, identity-checked and hard-claimed; without the local control config the service has no /control routes (see the packaged ARM_MOTION.md and GRIPPER_CONTROL.md). Machine configuration, control limits and calibrated graphs belong in *.local.json.
The existing xArm5 application remains available through pyxarm and the explicit robot-motion legacy-xarm command after installing [xarm]. Its controller, API, hard claims, UI, graph interlocks and settings are unchanged.
main carries the unified UI and API. Each deployed robot runs its own branch, and main reaches a robot only when it is merged into that branch with someone at the robot.
| Branch | Robot | Checkout and service |
|---|---|---|
| debug-xarm5 | xArm5 on sdl2-pc-03-cytation | C:\Users\sdl2\Projects\xarm-translocation, NSSM xarm |
| debug-ur5e | ligand_ur5e on sdl2-pc-05 | C:\Users\sdl2\Projects\robot-motion, NSSM robot-motion-prototype |
| debug-ur3e | ur3e_gibbie on sdl2-pc-04 (observer since 2026-10-12, src/docs/UR3E_GIBBIE_PLAN.md) |
C:\Users\sdl2\Projects\robot-motion, NSSM robot-motion-ur3e |
| debug-ur5cb3 | lle_ur5_arm (UR5 CB3, Process Chemistry) on sdl2-pc-00-lle (since 2026-10-13, src/docs/UR5_CB3_LLE_PLAN.md) |
C:\Users\sdl2\Projects\robot-motion, NSSM robot-motion-ur5cb3 |
xarm-python-sdk is in the [xarm] extra. A service started with uv run
re-syncs the environment on every start and removes extras it was not asked
for, so when debug-xarm5 first takes this layout, the xarm service's
AppParameters must change from run pyxarm web to
run --extra xarm pyxarm web. The UR service runs the checkout's Python
directly and does not re-sync.
See the packaged operator guide. This prototype implements the STATUS_SPEC v1.2 read-only profile; the legacy xArm application's conformance and behavior are described below.
UFACTORY xArm control for the AC Organic Self-Driving Lab — arm, gripper, linear track, and force-torque sensor, driven through one Python controller, a web UI, and a STATUS_SPEC v1.2 REST API. The Docker simulator is the supported path for off-hardware testing. Installs as the pyxarm package.
This repo conforms to lab status spec v1.2. Wire-contract types come from the shared sdl-lab-contract package (pinned to v1.2.0) rather than a vendored models.py. The FastAPI service exposes the standard read surface so the AC Organic Self-Driving Lab dashboard can poll it like any other device:
GET /--ProbeResponse(equipment_id,equipment_name,protocol_version)GET /health--HealthResponse(status="healthy")GET /status--EquipmentStatusenvelope (snake_case, side-effect-free)GET /positions-- read-only snapshot of joint angles, Cartesian pose, track position, and gripper position (no movement)GET /kinematics/config,GET /kinematics/limits-- read-only controller configuration, DH parameters, and reduced limits with explicit availability/provenancePOST /kinematics/fk,POST /kinematics/ik-- controller calculations without motion; IK supports reference angles on firmware >=2.7.103 (SDK >=1.18.4)GET /openapi.json-- generated by FastAPI
The v1.1 claim surface (POST /control/claim, /control/heartbeat, /control/release) is implemented with hard enforcement: every mutating endpoint requires the active claim's X-Claim-Token or returns HTTP 423 — including when no claim is held at all. /move/stop, /clear/errors, /connect, and /disconnect stay ungated as the safety floor. Set XARM_ENFORCE_CLAIMS falsy to drop back to advisory mode for demos.
The browser UI lives at /web/. The xArm-specific control endpoints (/move/*, /gripper/*, /track/*, /force-torque/*, /connect, /disconnect) are unchanged; programming the arm continues to happen via these or the XArmController Python SDK. The SDK in ac-organic-lab keeps do_not_call_connect: true for this device — a robot arm is never auto-connected.
v1.2 splits "is this arm healthy" (equipment_status) from "is it working right now" (activity). For this device the primary operation is a commanded arm or linear-track motion: activity is running from the moment a motion primitive dispatches its SDK call until that call returns, and idle otherwise. It is observed from the controller's motion bookkeeping, never derived from equipment_status.
Consequences worth knowing as a reader:
- Gripper actuation is not primary operation. Grip / release happens while parked at a graph node with the arm stationary, and takes well under a second. It shows in
components.gripperanddetails.gripper, not on the activity axis. activity_sinceis the transition instant, latched when the motion flag flips — not the time of the poll that observed it. Two polls of the same in-flight move report the same value, so elapsed move time is recoverable.degraded+runningis expressible and does occur. A connected-but-not-fully-alive controller mid-move reports both; neither fact suppresses the other. Consequentlybusyanddegradednever co-occur (spec §2.3).- Polling cannot see every move. The dashboard polls at 60 s while a graph hop can take a few seconds, so a sampled
activityseries is not usage accounting (spec §2.3.1). This device does not yet publishmetrics["cycles_total"]; thestate_transitionrows the events exporter pushes toPOST /api/ingest/eventsare the timing-accurate record.
Only one motion may be in flight. A move arriving while another is running is refused with HTTP 409 and a body distinguishable by shape:
{"detail": {"error": "motion_in_progress",
"message": "A motion is already in flight. Wait for it to finish, or POST /move/stop to abort it."}}This is 409 rather than 412 because it is a device-state conflict, not a precondition the caller could have satisfied in advance (spec §6.1). allowed_actions mirrors it: while activity is running, every move.<node_id> target and every gripper.<state> target is withheld and only stop remains, so a client that reads /status and immediately POSTs a listed action is never refused for being busy (§6.2). (The gripper is withheld for a different reason than the motion slot below — the stroke is invariant during arm motion, so set_gripper_state requires a parked arm.)
Covered: /move/{position,joints,relative,location,home,plate_linear}, /track/move, /track/move/location, /control/graph/{move_to,travel_to}, /assistant/execute, and both /force-torque/move-* endpoints. A composite move holds the slot for its whole duration — a cross-rail graph edge is two sub-moves, a travel is N hops, and an assistant run is a whole step list, none of which release between parts.
Not covered, deliberately: /move/stop and /clear/errors (the safety floor must always be reachable), /control/graph/recover_to (a bookkeeping re-pin, not a motion), and the gripper endpoints (set_gripper_state already refuses while the arm is moving, and gripper actuation is not primary operation).
/xarm5/web/ (or /web/ directly on the device) is the shared control
interface. The Camera tile switches between Tapo Camera and Stereo Camera.
The workspace below switches between Graph Drive, Direct Drive, and Edit
Graph; gripper/rail controls sit above the drive controls, with the console
alongside. Graph Drive defaults to Reachable Node; Travel to Node has its own tab.
The legacy graph.html link redirects to #edit-graph in this workspace.
All tabs use one control claim, heartbeat, and status connection. Switching views never acquires or releases control. The graph editor uses the main panel's current claim token, and graph viewing remains available during motion or while another operator holds control. The manual-mode switch retains its existing server-side motion guards.
Manual motion defaults are 20 mm/s for rail and linear arm motion, and 5 degrees/s for joints. Empty speed fields use the same defaults. These are defaults, not hard caps; graph moves retain their edge limits. Backend profile defaults are loaded when the controller connects.
UI composition and responsive styles live in workspace.js / workspace.css;
main.js owns robot controls and the shared claim, graph.js owns topology
editing, and the existing camera modules own their players.
Explicit graph moves (/control/graph/move_to, /control/graph/travel_to,
and assistant move steps) always enforce the edge's movement mode and speed
cap, including during an OFF or ADVISORY override. Linear edge speeds are
TCP translation speeds in mm/s; joint edge speeds are degrees/s.
They are not percentages or interchangeable units. Cross-rail graph moves
use the edge speed for the arm and the track configuration's mm/s speed
for the rail. Linear acceleration is mm/s², joint acceleration degrees/s².
Saving an edge in the graph editor updates the server's in-memory graph;
the next move uses the saved settings. It does not retime a move already
sent to the robot. Direct YAML edits require a server restart. GET /graph
on the same server as the UI reports the loaded values; a different checkout's
YAML may differ. Reload an already-open graph tab to fetch other clients' edits.
Administrator one-click OFF: POST /control/admin/graph/off with an
authenticated administrator session/API key; no body or claim is required.
Optional reason is recorded for audit. OFF remains active for all users until
POST /control/admin/graph/restore, including across claim changes,
disconnects and service restarts. Freehand still requires the caller's motion
claim and passes the existing device safety checks. Ordinary mode switches
cannot replace this admin setting. Authentication is mandatory even when the
general login gate is disabled.
This state is stored separately from graph topology in ignored
src/settings/graph_admin_override_*.json files, scoped to device/profile.
Set XARM_GRAPH_ADMIN_STATE_DIR to place them in a persistent service-writable
directory. Preserve these files during deployment. The status override reports
scope: admin, the owner and reason, and null expiry; the device panel shows
"OFF until an administrator restores it". The timed workflow below remains
available when the administrator switch is inactive.
Without a saved administrator OFF setting, the motion graph boots STRICT
whenever motion_graph.yaml loads: moves
must follow whitelisted edges, and the graph-bypassing /control/freehand/*
family (raw Cartesian, raw joints, jog, velocity streaming, raw rail, freehand
gripper) is refused outright with 409 graph_mode_strict.
Real Cartesian work — camera surveys, teaching a pose, calibration — needs
that family, which means lowering the mode. Lowering is administrator-only
(since 2026-09-23): an administrator turns enforcement OFF with
POST /control/admin/graph/off and back ON with
POST /control/admin/graph/restore, or with the Administrator button on the
/web/ Motion Graph card. That OFF has no expiry and needs no claim; it
persists across claims, disconnects and restarts until restored. Claim
holders, including agents, cannot lower it: POST /control/graph/mode below
strict and POST /control/graph/off return 403 admin_required.
Setting strict stays open to any claim holder.
While an administrator has enforcement OFF:
- Acquire a claim with
POST /control/claimand use itsclaim_tokenasX-Claim-Tokenon control requests. - Read
/status. When idle and available,allowed_actionsincludesfreehand.position,freehand.relative, andfreehand.joints. These map toPOST /control/freehand/position,/control/freehand/relative, and/control/freehand/joints. UseContent-Type: application/json. - Release the claim when finished. Enforcement comes back only when an administrator restores it.
(The node-anchored POST /control/freehand/nudge, once the one freehand
route allowed in STRICT, was removed on 2026-09-23.)
Absolute Cartesian requests take {x,y,z,roll?,pitch?,yaw?,speed?};
relative requests take {dx?,dy?,dz?,droll?,dpitch?,dyaw?,speed?}.
The agent's tool catalog must register the matching freehand.* names.
Claims, motion concurrency, and configured controller/interlock checks still
apply. A raw move clears the named pose pin; graph motion may require recovery
to a known node afterward. A move response means the command was accepted;
poll status to follow completion.
Disabled 2026-09-21 (
enabled: falseinsrc/settings/interlocks.yaml), and thehoodtag has been removed from the seven hood graph nodes. The arm will now drive into the hood against a closed sash and nothing in this repo will stop it, on entry or mid-move — the sash is an operator responsibility until the replacement lands. The reason is the unsettled question at the end of this section: it was never confirmed whether the fume hood device'smetrics.sash_positionis a true readback or the last commanded preset, so what the guard enforced was not known to be a collision guard, while it did reliably refuse bench work. The replacement is an explicit xyz safe/danger volume owned by this device, which needs no second device to be reachable and no assumption about another firmware's fields.Re-enabling means restoring both the master switch and
tags: [hood, …]on thehood_*nodes (see the note inmotion_graph.yaml). Thegated_rails: [Hood]clause still covers all seven on its own, so a half-restore over-blocks rather than silently under-blocking. The rest of this section describes the behaviour as designed, and is accurate again the moment the switch goes back.
The arm reaches into the fume hood (7 hood-tagged graph nodes) and over the Opentrons deck (8 opentrons-tagged nodes). The sash that closes over that space belongs to a different device — fume_hood_actuator — so this service reads that device's /status and refuses motion the sash could collide with. Configuration and the full rationale live in src/settings/interlocks.yaml; the implementation is src/core/sash_interlock.py.
Two behaviours, and the second is why this is more than a precondition check:
- Entry is refused unless the sash reports its required preset. A move whose target is a gated node returns HTTP 412 (spec §6.1 — the request is well-formed and this arm is healthy; an external precondition is unmet):
{"detail": {"error": "interlock_not_satisfied", "interlock": "fume_hood_sash",
"action": "graph.move_to", "required": "sash_position=5", "observed": 3,
"state": "blocked", "retry_after_s": null,
"hint": "Move the sash to preset 5 (POST /control/sash/move on fume_hood_actuator), then retry. Or POST /control/interlocks/sash/override.",
"message": "'graph.move_to' to 'hood_shaker_low' is refused: fume hood sash is at position 3, required 5"}}- The arm is stopped if the sash leaves that preset while the arm is already inside the region — a watchdog thread calls the same
stop_motion()that backs/control/stop. After that, all motion is refused, egress included: there is deliberately no automatic retreat, because a blind retreat could drag the arm through a descending sash, and the interlock cannot see where the sash is along that path.
retry_after_s is null because recovery is operator-driven. allowed_actions mirrors the refusal (§6.2): gated move.<node_id> targets are withheld while the sash is not parked, and every target is withheld while the arm is stuck inside. A claim conflict still surfaces first — 423 ahead of the 412.
The override is the only way out, so it is a first-class control rather than a debug flag: POST /control/interlocks/sash/override {reason, ttl_seconds} (claim- and login-gated, capped, re-issuable, audited), surfaced as a button in the /web/ banner. GET /interlocks/sash (+?refresh=true) reads the current state without commanding a move, and answers before /connect.
It fails open. If the fume hood device cannot be reached, moves are allowed with a loud warning — a deliberate choice not to halt arm work on another device's uptime. Because that means the guard protects nothing during an outage, every bypass is logged at WARNING, counted in details.interlocks.fume_hood_sash.moves_allowed_while_blind, emitted as an interlock_bypass event, and shown as an amber SASH INTERLOCK BLIND banner with a [SASH-BLIND] prefix on message. Two carve-outs keep faith with that choice while closing what it did not cover: a device never reached since startup is misconfiguration, not an outage, and fails closed; so does one that answers with an unparseable body.
equipment_status deliberately does not go degraded when the interlock is blind: §2.2 scopes degraded to an unhealthy subsystem of this device, and a neighbouring Pi being unreachable is not this arm's ill health — under fail-open its capability is not reduced, only its supervision. details.interlocks plus the message prefix carry that instead.
Two limits worth knowing:
- Freehand moves cannot be gated on entry.
/move/{position,joints,relative},/velocity/cartesianand raw/track/movecarry no node id, so there is no way to know where they end. They are refused while the arm is inside the region, and in STRICT modestrict_graph_guardalready refuses them outright. Velocity streaming continues after its request returns, so a pre-flight check there is only a snapshot — the watchdog is the backstop. - Gripper actions are not gated, on purpose. The hazard is the arm envelope against the sash glass; a jaw stroke does not extend that envelope. Gating them would add a way to trap a plate for no safety gain, and setting a plate down is the one useful thing left to an arm that cannot move.
Unverified assumption, and the reason the check is composite. It is not yet confirmed whether the fume hood device's
metrics.sash_positionis a true readback or the last commanded preset. If it is the latter,position == 5alone would mean "someone last asked for 5" — satisfied even with the sash pulled down by hand. So "satisfied" additionally requirescomponents.sash.state,components.sash.connected,components.actuator.state == "idle"(a last-commanded metric reports the target for the whole trip, so an idle actuator is what rules out a sash in motion) and the device's ownequipment_status == "ready". Settle it at the bench — command the sash to 5, move it by hand, re-read/status— before treating this as a hard collision guard.
When the service is connected via the docker profile (the UFACTORY Docker simulator), it says so on every surface: healthy states report equipment_status: "dry_run" instead of ready/busy (with activity still observed — dry_run permits any activity per spec §2.3), every message carries a [SIMULATION] prefix, details.simulated: true is set, and the web panel shows a persistent amber banner. Fault states keep their honest values so recovery paths can be exercised against the sim. The events exporter is suppressed while simulated, so sim sessions never write into the lab's history DB as the real device. Deliberate consequence: anything gating on equipment_status == "ready" will not run against a simulator by accident.
The contract is defined in ac-organic-lab/docs/STATUS_SPEC.md.
- Multi-Model Support: xArm5, xArm6, xArm7, and xArm850 with auto-detection
- Unified Control: Single controller for arm, gripper, linear track, and force torque sensor
- Docker Simulator Support: Drive the official UFACTORY Docker simulator through the same controller path used for hardware
- REST API: FastAPI server for web-based control and monitoring
- Flexible Configuration: Model-specific configs with component auto-enable
- 6-Axis Force Torque Sensor: Safety monitoring, force-controlled movement, and torque-controlled joint operations
- Intel RealSense depth cameras (optional): colour + metric depth from USB cameras on the device PC, each addressed by a device-local id — snapshots, live MJPEG preview, pixel → metres, and health on
/status. See Depth camera.
# Prepare and activate your python enviroment, and install PyxArm in editable mode
pip install -e .
# Or, install PyxArm in development mode with pytests
pip install -e ".[dev]"from src.core.xarm_controller import XArmController
# Auto-detect and connect
controller = XArmController(
gripper_type='bio',
enable_track=True,
auto_enable=True
)
if controller.initialize():
# Move robot
controller.move_to_position(x=300, y=0, z=300)
controller.open_gripper()
controller.disconnect()# Start the simulator (e.g. "5" for xArm5)
src/docker/docker_setup.sh start 5
# Drive it with the demo using the `docker` profile
python src/examples/demo_docker_sim.pyThe in-process software simulation has been removed; use the Docker simulator for all simulator-mode work. See PYXARM_TESTING.md for setup details.
# Start the web UI (also auto-starts the API server on port 8000)
pyxarm web
# Or specify a custom host/port for the web UI
pyxarm web --host 0.0.0.0 --port 6001
# Run only the API server (this is how it is deployed as a device service)
pyxarm api --host 0.0.0.0 --port 8000
# Alternative method (without installing package)
python -m src.cli.main webAccess Points:
- 🌐 Web UI: http://localhost:6001/web/
- 📖 API Docs: http://localhost:8000/docs
- 📡 API Server: http://localhost:8000
The service can own an Intel RealSense (D435i etc.) plugged into this PC over USB 3 — a local depth camera, distinct from the network PTZ "Lab Camera" the panel pans across the motion graph. It is the primitive later vision work (arrival verification, plate location) builds on; this release ships capture, health, images and pixel → metres.
C:\SDL_Tools\uv.exe sync --extra realsense # pyrealsense2 + numpy + Pillow, once per venvThen set enabled: true in src/settings/realsense.yaml and list each camera under
cameras: with a device-local id (the first is rs435i), and restart. The panel grows a
Depth Camera card per camera (Color / Depth preview, Start / Stop, click-to-measure).
GET /realsense/cameras lists the cameras and hands back a URL per route; everything else
lives under /realsense/<id>/ — status, start, stop, snapshot.jpg, depth.png,
stream.mjpg, depth?x=&y=, intrinsics — and /status carries one
components.realsense_<id> per camera plus details.realsense while configured. A camera
never changes equipment_status: arm motion does not depend on it. Full reference, gating
policy and troubleshooting: src/docs/REALSENSE_CAMERA.md.
PyxArm includes a convenient command-line interface:
# Show help
pyxarm --help
# Start web interface
pyxarm web
# Start on custom host/port
pyxarm web --host 127.0.0.1 --port 8080
# Show version
pyxarm --version- Docker Simulator → run the official UFACTORY simulator via the
dockerprofile for off-hardware validation - Real Hardware → production validation against the configured
robotprofile
For detailed guides on specific topics, please see the project root:
- Features Overview: A high-level overview of the controller's features.
- API Reference: Detailed documentation of the
XArmControllermethods and parameters. - Simulation & Testing Guide: A comprehensive guide to simulation modes and the project's testing strategy.
- Depth Camera: Intel RealSense integration — setup,
/realsense/<id>/*API,/statusblocks, troubleshooting. - Network Access: Tailscale port-forwarding setup for remote access to both the PyxArm API and the official UFactory Studio UI.
- Multi-gripper support: BioGripper Gen1, BioGripper Gen2 (stroke + force control), Standard, RobotIQ, or none
- Named locations: Predefined positions with easy recall
- Real-time monitoring: WebSocket updates and status tracking
- Error handling: Comprehensive error history and recovery
- Remote deployment: Docker containers on web servers
- Configuration: All settings are now in
src/settings/. Modify the.yamlfiles to match your hardware. - Examples: Run demo scripts in
src/examples/to see different functionalities. - API Server: Start the web server with
pyxarm weboruvicorn src.core.xarm_api_server:app --reload.
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass:
pytest - Submit a pull request
This project is licensed under the MIT License - see the LICENSE file for details.
- Issues: Create GitHub issues for bugs and feature requests
- Documentation: Check root directory for comprehensive guides
- Examples: Run demo scripts in
src/examples/ - CLI: Use
pyxarm --helpfor command-line interface - API: Access interactive docs at
http://localhost:8000/docs
Get started in minutes against the Docker simulator, then move to real hardware when ready.
