The pad moves to dora-joy, so this node keeps no hardware dependency and the pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read` treats silence past JOY_TTL_S as absence, because that node dying looks like silence rather than like an unplugged pad and the arm cannot tell them apart. Both control sources are now normalised to a [-1, 1] six-vector before anything is scaled, and the scaling happens in one function. The web path used to pass a velocity straight through without going near mapping, which is why the console had to speak the arm's units. It no longer does. `twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog` and `request` still carry the same intent in dora-ulite6's units and service names so the two repositories can move independently; LEGACY_REQUEST is the only place that knows what that node calls a verb. Wire one or the other, never both. `joint_jog` is specified in the manifest and deliberately not built. Schemas are declared rather than inferred: pyarrow reads a null `value` as its own type, so an inferred `command` would change its port's type between one verb and the next. Three bugs found while moving this code, each with a test: - `Sample.active` compared raw axes against a threshold, but SDL rests a trigger at -1.0, so every connected pad read as in use for ever: the pad took the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired. - The e-stop latch was never consulted when deciding a velocity, so Back stopped the arm for one tick and the next stick nudge drove it again. Nothing on the pad could clear it either. It is now checked above the lease, and X releases it and clears the controller's error, as the console's RESET does. - Buttons were read before the arbiter granted the lease, so the first press after the pad had been idle was swallowed: not owned yet when read, and no longer a rising edge by the next tick.
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
ordinary jog and request ports, and talks to the browser through
dora-websocket-transceiver.
It contains no web server and no robot driver: it is the piece in the middle that decides
what the arm should be doing, and that decision is the only thing it owns.
The gamepad
Analog and blended — several axes at once, and how fast depends on how far you push. That is the whole reason a pad beats the web console's four-sector dial pad, which can only ask for one axis at a time at one fixed speed.
| 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 | Clear a controller error | — |
| Y | Toggle freedrive | — |
| Back | E-STOP | — |
Up on the stick sends the arm away from you, along +X in the robot's base frame — the same convention the web console's dial uses, so the two agree.
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that
disagrees is a change to Layout in dora_teleop/mapping.py rather than a new
environment variable: an index nobody can verify from outside is worse than a line of code
somebody can read.
home is deliberately not on the pad. It is a large unattended motion and belongs
behind a deliberate click, not a thumb resting on a face button.
Checking your pad
uv run dora-teleop-probe
Prints the raw axes, the buttons it sees, and the velocity this node would send — live, with no dataflow and no arm. It cannot move anything. There is no dora node and no connection to the robot; it only reads.
axes (6 reported)
0 left stick X +0.600 ----------|-----#----
1 left stick Y -0.800 --#-------|----------
2 left trigger -1.000 #---------|----------
...
buttons (6 reported)
5:yaw right
velocity this would send to the arm
vx +38.89 mm/s forward / back <--
vy +23.53 mm/s left / right <--
vz +20.39 mm/s up / down <--
wz +30.00 deg/s yaw <--
moving: yes
Run this before trusting the table above. The indices are SDL's documented layout, not observed ones, and a pad that numbers its axes differently is much better found here than by watching an arm move the wrong way. Two things to look for:
- Every axis is listed, including ones the layout does not use — an axis marked
unusedthat moves when you push a stick means the layout needs changing. - A bar that does not sit centred with the stick released is a worn pad announcing
itself, and a reason to raise
DEADZONE.
It goes through the same Gamepad and velocity() the node runs, so what it prints is
what the arm would be sent.
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. Nobody should have to reach for a browser to use the thing in their hands.
- It gives it back after
GAMEPAD_IDLE_RELEASE_Suntouched, so a pad left on the bench does not lock the browser out forever. - The browser takes it explicitly, with the console's TAKE CONTROL button, and gives it back with RELEASE. It can preempt a held arm, because an operator who can see the arm is better placed than this code to decide they should have it.
- E-STOP is never gated. From either source, held lease or not. Somebody reaching for the stop is the one moment when asking who owns the arm is the wrong question.
The lease binds only what goes through this node. dora-ulite6 has no idea it exists.
Another node publishing request will preempt an operator mid-motion and nothing here can
stop it — if that matters for your dataflow, do not wire one.
The deadman
An arm under a velocity command keeps moving until told otherwise, so the question is never whether to start but whether anything has happened that means it must stop. Three layers, outermost last:
- Re-send every tick (50 ms) while a source is driving.
- This node's watchdog (
JOG_TTL_S, 0.4 s) stops the arm when the owner goes quiet — and stops it immediately, without waiting out the timer, when the gamepad is unplugged or the last browser disconnects. An unplugged pad is not a centred pad. - The arm's own expiry (
VELOCITY_COMMAND_TTL_Sindora-ulite6, 0.5 s), enforced by the controller whether or not this process is alive.
Keep them in that order. If this node's timer is longer than the controller's, the backstop fires before the thing it is backing up.
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the controller, not in this process.
The stop is sent once, not every tick. A stream of zeroes would keep dora-ulite6
believing a jog is live and hold robot_state at moving forever.
Ports
| Direction | Name | Description |
|---|---|---|
| in | tick |
50 ms — re-send, deadman, telemetry |
| in | ui_command |
One console message, from the bridge's data_out |
| in | clients |
Connect/disconnect from the bridge — the web deadman |
| 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 | jog |
{velocity: [vx, vy, vz, wx, wy, wz]}, mm/s + deg/s |
| out | request |
A service call, request_id in the metadata |
| out | ui_state |
The console's telemetry snapshot, as JSON |
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 |
JOYSTICK_INDEX |
0 |
Which pad to open |
JOG_TTL_S |
0.4 |
Owner silence before the arm is stopped |
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 |
A value that cannot be read, a DEADZONE outside the stick or an EXPO off the curve
stops the node at startup rather than the first time somebody picks up the pad.
Usage
The real stack is three nodes: this one, the WebSocket bridge and the arm.
- id: teleop
git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git
tag: v0.1.0
build: pip install .
path: dora-teleop
inputs:
tick: dora/timer/millis/50
ui_command: ws-transceiver/data_out
clients: ws-transceiver/clients
response: ulite6/response
motion_state: ulite6/motion_state
robot_state: ulite6/robot_state
tcp_pose: ulite6/tcp_pose
joint_pose: ulite6/joint_pose
outputs: [jog, request, ui_state]
Point ws-transceiver's data_in at teleop/ui_state and its STATIC_DIR at this
repo's web/, and wire ulite6's request and jog from this node.
Requirements: dora 1.0+ (verified with dora-cli 1.0.1), Python 3.11+, uv, and a
gamepad if you want one — the node starts without and says so.
Running it with no arm and no browser
dataflow.yml in this repo is not that stack. It is this node plus a bench stand-in,
so you can drive the real thing from a real gamepad with no robot in the room:
dora run dataflow.yml --uv
The bench answers every service, publishes plausible telemetry, and prints each velocity it is sent:
jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00]
jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop
It is not a simulator: it never integrates the velocity or moves the pose, and a jog is printed and forgotten. It closes the loop so this node's behaviour can be watched — the lease changing hands, the deadman firing when you let go — not so the arm can be pretended at.
Watching the wire
dataflow.yml sets debug: enable_debug_inspection: true, so dora will let you read what
is actually on the wire without the bench printing anything. Run it under a coordinator
rather than dora run:
dora up
dora start dataflow.yml --uv --name teleop-bench
Then, in another terminal:
dora topic list -d teleop-bench # every edge in the graph
dora topic echo -d teleop-bench teleop/jog # every velocity, live
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
hz is the one that checks the timings this node promises. The get_state poll measures
at 1.88 Hz against a configured STATE_POLL_S of 0.5, and with a pad held over the
deadzone teleop/jog should sit near the 50 ms tick.
Tear it down with dora stop --name teleop-bench && dora down.
Development
uv sync --group dev
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
dora_teleop/gamepad.py is the only file that touches pygame, and the only one that
cannot be tested without hardware. Everything else sees a plain Sample.