cristhian 7250396a1f
validate / validate (pull_request) Failing after 58m48s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s
add the node manifest, dataflow, CI and docs
dora-node.yml is the typed contract a consumer wires against, and the only one
they get: no source package is shared between node repositories. The jog,
request and ui ports stay untyped, because dora treats a port carrying
request_id as pattern-correlated and several Arrow schemas legitimately flow
over each — one per service, one per console message.

dataflow.yml runs the whole operator stack from checkouts: this node, the
WebSocket bridge and the arm.

web/ holds a placeholder page, not the real console. It exists so the dataflow
has something to serve and so the node can be checked end to end before the
Control section is ported across from the monolithic repo. It reads telemetry
and can take, release and stop; nothing else.
2026-09-15 15:43:12 -05:00
2026-09-15 15:43:06 -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, 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.

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. Three layers, outermost last:

  1. Re-send every tick (50 ms) while a source is driving.
  2. 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.
  3. 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. 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

See dataflow.yml for the whole stack wired together. The short version:

  - 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]
dora run dataflow.yml --uv

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.

Development

uv sync --group dev
uv run pytest      # 66 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.

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%