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.
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_Suntouched, 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.