cristhian ffbba0f4d1
validate / validate (pull_request) Successful in 58m40s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
update version to 0.2.0 for the new output contract
`twist` and `command` are the ports a consumer should wire from now on, and
`jog` and `request` are deprecated. Nothing breaks yet — both still publish —
but the version should say the contract moved.
2026-09-16 12:24:49 -05:00
2026-09-15 15:42:39 -05:00
2026-09-15 15:42:39 -05:00

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, reads the pad from dora-joy, and talks to the browser through dora-websocket-transceiver. It contains no web server, no robot driver and no gamepad 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. It touches no hardware at all.

The gamepad

The pad itself is dora-joy, which publishes raw axes and buttons and knows nothing about arms. Everything below — the deadzone, the response curve, which button is which, and what any of it means in mm/s — happens here.

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 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 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. The layout lives here rather than in dora-joy for the same reason — dora-joy does not know what a button is for, which is what makes it reusable.

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.

Back latches. It stops the arm, tells the controller to stop, and then keeps the arm still no matter what the sticks do — a stop somebody had to reach for is not undone by the next thumb movement. X is the only way back, and it does both halves: releases the latch here and clears the error on the controller. Neither button is gated on the lease, because 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.

Checking your pad

dora up
dora start dataflow-gamepad.yml --uv --name teleop-bench
uv run dora-teleop-probe    # in another terminal

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; 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
  asking for the arm: 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 unused that 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 velocity() the node runs, on the same readings, so what it prints is what the arm would be sent.

For a pad whose layout is not known at all — which index is even which — use dora-joy-probe from the dora-joy repo, which needs no dataflow and no coordinator.

Speaking to any robot

Nothing above is specific to one arm. Both control sources are reduced to a normalised six-vector — [-1, 1] per degree of freedom, meaning a fraction of full speed — and only then scaled, in one function. A gamepad reaches that vector through the deadzone and the response curve, which exist to correct a worn physical stick; the browser sends it already clean, because a browser has no worn centre.

That is what lets the same node and the same console drive a robot in mm/s and one in m/s: neither source ever knew the difference. It is also why a gamepad and the console asking for the same deflection produce byte-identical output.

twist is SI — m/s and rad/s — because that is unambiguous and 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 rather than grasp, so a robot that swaps a vacuum for a two-finger gripper needs no change at either end.

The full contract, including the reserved joint_jog port for joint-space robots, 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. Nobody should have to reach for a browser to use the thing in their hands.
  • It gives it back after GAMEPAD_IDLE_RELEASE_S untouched, 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. Four layers, outermost last:

  1. Re-send every tick (50 ms) while a source is driving.
  2. The pad's freshness (JOY_TTL_S, 0.2 s). The pad is another process now, and that process dying looks like silence rather than like an unplugged pad — so silence means the same thing. dora-joy publishes on every tick whether or not the pad moved, which is what makes silence unambiguous.
  3. 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 reports itself gone or the last browser disconnects. An unplugged pad is not a centred pad.
  4. The arm's own expiry (VELOCITY_COMMAND_TTL_S in dora-ulite6, 0.5 s), enforced by the controller whether or not this process is alive.

Keep them in that order, each shorter than the one behind it. A backstop that fires before the thing it is backing up backs up nothing. JOY_TTL_S >= JOG_TTL_S stops the node at startup rather than letting that happen quietly.

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 joy One pad reading from dora-joy
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 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.

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

A value that cannot be read, a DEADZONE outside the stick, an EXPO off the curve or a JOY_TTL_S at or above JOG_TTL_S 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.

Usage

The real stack is four nodes: this one, the pad, 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
      joy:
        source: joy/joy
        queue_size: 1
        queue_policy: drop_oldest
      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. Add dora-joy on a 20 ms tick for the pad — queue_size: 1 matters, because it ticks faster than this node does and an arm should be driven by the newest reading or by none.

Leave joy unwired for a browser-only console; the node starts without it.

Requirements: dora 1.0+ (verified with dora-cli 1.0.1), Python 3.11+ and uv. No hardware: the pad, the arm and the browser are all other nodes.

Driving a real arm

dataflow-robot.yml is the one that moves a robot: the pad, this node and dora-ulite6, with no bench and no browser. Both sibling nodes come in by git:, so it runs from a fresh clone with nothing else checked out.

dora build dataflow-robot.yml --uv    # clones and installs both, needs the network
dora run   dataflow-robot.yml --uv

Set ROBOT_IP and TOOL_TYPE in that file to match the arm in front of you; the defaults are dora-ulite6's own, 192.168.1.192 and a vacuum tool. INIT_MODE is none, so nothing moves until somebody pushes a stick.

The four deadman timings have to stay ordered, each below the one behind it, and the file pins all of them so the chain is visible in one place:

Layer Setting Default
Pad reading goes stale JOY_TTL_S (teleop) 0.2 s
Owner goes quiet JOG_TTL_S (teleop) 0.4 s
Controller's own expiry VELOCITY_COMMAND_TTL_S (ulite6) 0.5 s

dora type-checks a dataflow before it fetches a git: source, so it cannot read the arm's manifest from here. The output_types: block on the ulite6 node repeats what dora-ulite6 v0.3.0 annotates, which is also what pinning that tag asserts — re-check it when moving to a newer one.

Adding the web console means adding dora-websocket-transceiver and wiring ui_command, clients and its data_in; the four-node stack under Usage above has that.

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, with no pad: pulling dora-joy in by git: would cost that file the one property it has, which is that it resolves and runs from this checkout alone, with no network and no sibling clones.

dora run dataflow.yml --uv            # bench only, browser path
dora run dataflow-gamepad.yml --uv    # the same bench with a real pad

dataflow-gamepad.yml adds dora-joy by git: and declares the probe. It needs the network on its first build.

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-gamepad.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 joy/joy                      # what the pad is reporting
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.

The console

web/index.html is the browser half: a jog pad for translation and rotation, gripper, freedrive, home, the lease buttons and E-STOP, over a telemetry readout. Plain HTML with no build step — the bridge serves the directory as it stands, and a console you cannot fix without a toolchain is the wrong thing to have between a person and a robot.

Hold a direction to move, release to stop. The page re-sends its jog every 150 ms against the node's 400 ms deadline, so letting go stops the robot even if the release is lost. It sends a fraction of full deflection, never a speed, so it never learns the robot's units; the speed slider scales that fraction. The jog pad is disabled unless the console holds the lease, so a button that would be refused looks refused.

tests/test_console.py checks the page against ui.py — every message type it sends is one the node serves, and every telemetry key it reads is one the node sends. Neither language catches a rename, and the wrong place to discover one is on a machine that moves.

Development

uv sync --group dev
uv run pytest      # 112 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. The pad arrives on joy as a plain Sample.

S
Description
Dora node that hands a robot arm to a human, from a gamepad or a browser
Readme
537 KiB
Languages
Python 89.5%
HTML 10.5%