Files
dora-teleop/README.md
T
cristhian e3fc538e5d
validate / validate (pull_request) Successful in 58m44s
validate / test (pull_request) Successful in 58m45s
validate / markdownlint (pull_request) Successful in 58m40s
docs: simplify the README and record the pad layout
The README carried a lot of rationale that AGENTS.md already holds; what is
left is the tables a consumer wires against. It gains the swept axis and
button indices, and says plainly that the axes check themselves at runtime
and the buttons cannot.
2026-09-16 18:06:12 -03:00

16 KiB
Raw Blame History

dora-teleop

A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or open a browser and move it from there — one of the two at a time, decided here.

It drives dora-ulite6 through that node's jog and request ports, reads the pad from dora-joy, and serves the browser console itself on one port. It contains no robot driver and no gamepad driver: it is the piece in the middle that decides what the arm should be doing, and it touches no hardware at all.

Requirements: dora 1.0+ (verified with dora-cli 1.0.1), Python 3.11+ and uv.

The gamepad

Control Does Units
Left stick Move along X (up/down) and Y (left/right) mm/s
Triggers Lift on the right, lower on the left mm/s
Right stick Roll (left/right) and pitch (up/down) deg/s
Bumpers Yaw, left and right deg/s
A Close the tool —
B Open the tool —
X Release the E-STOP and clear the arm's error —
Y Toggle freedrive —
Back E-STOP — latches until X —

Up on the stick sends the arm away from you, along +X in the base frame — the same convention the console uses, so the two agree.

Back latches. It stops the arm, tells the controller to stop, and keeps the arm still no matter what the sticks do. X is the only way out, and it does both halves: releases the latch here and clears the error on the controller. Neither button is gated on the lease — an idle pad hands the arm back two seconds after the stop it just made, and a latch you must first take the arm back to release is a trap.

home is deliberately not on the pad. It is a large unattended motion and belongs behind a deliberate click.

The layout

These are the indices an Xbox One S pad reports over Bluetooth, swept one control at a time. They are not universal — the same pad on USB numbers its buttons differently — so a pad that disagrees is an edit to Layout in dora_teleop/mapping.py, not a new environment variable. It lives here rather than in dora-joy because dora-joy does not know what a button is for, which is what makes it reusable.

Control Axis Control Button
Left stick X / Y 0, 1 A — grasp 0
Right stick X / Y 2, 3 B — release 1
Left / right trigger 4, 5 X — clear 3
Y — freedrive 4
LB / RB — yaw 6, 7
Back — E-STOP 11

Sweep a new pad before the arm is powered. The axes check themselves and the buttons cannot:

  • Axes. Every pad is measured once, on the first reading in which it is sitting still. A stick must rest near centre and a trigger near the bottom of its travel; an axis resting anywhere else means the numbering is wrong, something is being held, or the pad is broken. All three read as no pad until it rests, and the log names the axis. A trigger read as a stick asks for full speed while lying untouched on the desk, so a pad this node has never seen still never gets the arm.
  • Buttons. An unpressed button reads the same on every pad, so a wrong button index is silent until it fires. That is not hypothetical: on this pad, against the indices this node shipped with, X toggled freedrive and LB was the E-STOP.

That same measurement makes a worn stick harmless — its resting tilt becomes its zero, so the tilt is subtracted rather than deadzoned away and the travel left still reaches full speed. A trigger that reads 0 until it first moves, rather than SDL's −1, is handled the same way instead of reading as half pressed.

Checking your pad

source .venv/bin/activate
dora build dataflow-gamepad.yml
dora run   dataflow-gamepad.yml
uv run dora-teleop-probe    # in another terminal

The probe prints the raw axes, the buttons it sees and the velocity this node would send, live, from the same joy stream the node reads. It cannot move anything — it attaches as a dynamic node, publishes nothing and never talks to the arm.

  axes (6 reported)
    0  left stick X    +0.600  ----------|-----#----
    1  left stick Y    -0.800  --#-------|----------
    4  left trigger    -1.000  #---------|----------
  buttons (17 reported)
    7:yaw right

  velocity this would send to the arm
    vx    +38.89 mm/s   forward / back  <--
    vy    +23.53 mm/s   left / right    <--
    wz    +30.00 deg/s  yaw             <--

  moving: yes
  asking for the arm: yes

Two things to look for: an axis marked unused that moves when you push a stick, and a bar that does not sit centred with the stick released. It goes through the same velocity() the node runs, so what it prints is what the arm would be sent.

For a pad whose numbering is not known at all, dora-joy-probe in the dora-joy repo needs no dataflow.

Speaking to any robot

Both control sources are reduced to a normalised six-vector — [-1, 1] per degree of freedom, a fraction of full speed — and only then scaled, in one function. The gamepad reaches that vector through the deadzone and the response curve, which correct a worn physical stick; the browser sends it already clean.

That is what lets one node and one console drive a robot in mm/s and one in m/s. twist is SI, because that is what a UR or MoveIt takes; configuration stays in mm/s and deg/s, because 60 mm/s is a number a person reading a dataflow can judge. command carries verbs rather than service names — gripper_close, not grasp — so swapping a vacuum for a two-finger gripper needs no change at either end.

The full contract, including the reserved joint_jog port, is in the vault at resources/robotics/teleop-contract.md.

The lease

Two sources, one arm. Whoever holds the lease drives; the other is ignored.

  • The gamepad takes it implicitly, the first time a stick moves, if nobody holds it.
  • It gives it back after GAMEPAD_IDLE_RELEASE_S untouched, so a pad left on the bench does not lock the browser out.
  • The browser takes it explicitly with TAKE CONTROL and returns it with RELEASE. It can preempt: an operator who can see the arm is better placed than this code to decide.
  • E-STOP is never gated, from either source, held lease or not.

The lease binds only what goes through this node. dora-ulite6 has no idea it exists, so another node publishing request will preempt an operator mid-motion.

The deadman

An arm under a velocity command keeps moving until told otherwise. Four layers, each shorter than the one behind it — a backstop that fires first backs up nothing:

Layer Setting Default
Held velocity repeated JOG_KEEPALIVE_S (teleop) 0.2 s
Pad reading goes stale JOY_TTL_S (teleop) 0.2 s
Owner goes quiet JOG_TTL_S (teleop) 0.4 s
Jog sender goes quiet VELOCITY_COMMAND_TTL_S (ulite6) 0.5 s

JOY_TTL_S >= JOG_TTL_S stops the node at startup rather than failing quietly later. The last layer is enforced by dora-ulite6 whether or not this process is alive.

A pad reporting itself gone, or the last browser disconnecting, stops the arm immediately without waiting out any timer: an unplugged pad is not a centred pad. On shutdown the node sends one all-zero velocity, because a motion lives in the controller rather than in this process.

What goes on the wire is the change. A velocity is a set-point the arm holds, so one is sent when it changes and otherwise repeated only every JOG_KEEPALIVE_S. Repeating every tick made a real xArm re-plan the same motion twenty times a second, which reads as stepping. The stop is sent once — a stream of zeroes keeps dora-ulite6 believing a jog is live. A change never waits for the repeat.

Ports

Direction Name Description
in tick 50 ms — change detection, deadman, telemetry
in joy One pad reading from dora-joy
in response An answer from the arm
in motion_state How a motion ended
in robot_state The arm's own state
in tcp_pose Tool-tip pose, six float32, mm and degrees
in joint_pose Joint angles, six float32, degrees
out twist Standard. {linear, angular, frame}, SI
out command Standard. {action, value}, robot-agnostic verbs
out jog Deprecated. mm/s + deg/s, for dora-ulite6
out request Deprecated. A service call, request_id in metadata
out ui_state The console's telemetry snapshot, as JSON

Wire twist or jog, never both — they carry the same intent, and a robot reading both is driven twice. Same for command and request.

The browser is not on this table: it reaches the node over the node's own HTTP and WebSocket port rather than through the dataflow, so there is nothing to wire for it.

Environment variables

Variable Default Description
SPEED_MM_S 60.0 Translation speed at full deflection
SPEED_DEG_S 30.0 Rotation speed at full deflection
DEADZONE 0.12 Stick travel near centre to ignore
EXPO 0.4 How much of the curve is cubic; 0 is linear
JOG_TTL_S 0.4 Owner silence before the arm is stopped
JOG_KEEPALIVE_S 0.2 How often an unchanged velocity is repeated
JOY_TTL_S 0.2 joy silence before the pad reads as gone
GAMEPAD_IDLE_RELEASE_S 2.0 Untouched pad before it hands the arm back
STATE_POLL_S 0.5 How often to ask the arm what telemetry does not carry
WEB_HOST 0.0.0.0 Address the console binds to
WEB_PORT 7000 Console port; 0 opens no port at all

A value that cannot be read, or one that breaks the deadman order, stops the node at startup rather than the first time somebody picks up the pad. JOYSTICK_INDEX moved to dora-joy along with the pad.

Running it

Dataflow Nodes Needs
dataflow.yml teleop + bench stand-in nothing
dataflow-robot.yml joy + teleop + ulite6 a pad and an arm
dataflow-gamepad.yml the same, plus the probe, console shut a pad and an arm
dora run dataflow.yml --uv            # no pad, no arm, browser path only

source .venv/bin/activate             # a real arm: see the --uv note below
dora build dataflow-robot.yml         # clones and installs both siblings
dora run   dataflow-robot.yml

dataflow.yml is self-contained on purpose — it resolves from this checkout alone, which is what lets CI validate it. The bench answers services and publishes telemetry but is not a simulator: it never integrates a velocity or moves a pose.

Set ROBOT_IP and TOOL_TYPE to match the arm in front of you. INIT_MODE is none, so nothing moves until somebody pushes a stick. The console comes with the node — open http://<that machine>:7000 for the same arm the pad drives, under the same lease.

--uv does not work for a dataflow with git: nodes (dora 1.0.1): each is installed into a venv inside its own clone, while the spawner resolves entrypoints from the dataflow's venv, and the run fails with Could not find source path dora-joy within uv. Build into one activated environment instead, as above; it needs pip (uv pip install pip). dora start refuses them outright — "node joy has git source, but no dora build was run yet", straight after a build that succeeded — so use dora run, which also means dora topic is unavailable for those two dataflows.

dora type-checks a dataflow before fetching a git: source, so it cannot read the arm's manifest. The output_types: block on the ulite6 node repeats what dora-ulite6 v0.3.1 annotates — re-check it when moving to a newer tag.

Watching the wire

Every dataflow sets debug: enable_debug_inspection: true, so dora will let you read the wire without any node printing it. This needs a coordinator, so it works for the arm-free dataflow:

dora up
dora start dataflow.yml --uv --name teleop-bench
dora topic list -d teleop-bench                              # every edge in the graph
dora topic echo -d teleop-bench teleop/jog                   # every velocity, live
dora topic hz   -d teleop-bench teleop/jog --duration 10     # the re-send cadence
dora stop --name teleop-bench && dora down

The console

dora_teleop/web/index.html is the browser half: two analog rings, a lift lane and a yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry readout. Plain HTML with no build step — a console you cannot fix without a toolchain is the wrong thing to have between a person and a robot.

It is laid out like the arm's own pendant: each ring is quartered into sectors you hold, with the out-of-plane axis as a pair of pills above it — the same shape and the same directions as xArm Studio's jog control at :18333.

Ring Sectors Pills above
translate +X north, −X south, Y+ west, Y− east −Z +Z
rotate +pitch north, −pitch south, −roll west, +roll east −yaw +yaw

Y+ is on the left, matching xArm Studio and the right-hand rule with +X away from the operator and +Z up. Two consoles that disagree about which way is +Y is a trap.

The node serves the page and holds the WebSocket on /ws of the same port, so there is one URL and nothing to keep in step. That runs on its own thread and meets the control loop at two non-blocking points only, because a browser that stops reading must never stall the thread that stops the arm. The browser's deadman is the socket itself — the last one closing releases the lease and stops the arm.

Directions are held, not analog: speed comes from the dial, and each held direction is a separate pointer, so two fingers work at once. The page re-sends its jog every 150 ms against the node's 400 ms deadline, and sends a fraction of full deflection, never a speed, so it never learns the robot's units. Controls are dimmed and inert unless the console holds the lease. If WEB_PORT is taken the node refuses to start.

Development

uv sync --group dev
uv run pytest      # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .

Every file here is testable without hardware, because there is no hardware. tests/ names its controls (axes_for, buttons_for) rather than numbering them — a test that numbers its axes goes on passing while meaning something else once a pad renumbers them.