# 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`](https://gitea.liria.cl/liria/dora-ulite6) through that node's ordinary `jog` and `request` ports, reads the pad from [`dora-joy`](https://gitea.liria.cl/liria/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 that decision is the only thing it owns. It touches no hardware at all. ## The gamepad The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/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 console, whose ring sectors ask for one direction at the speed the dial is set to. The console can still hold two at once — a sector and a Z pill — but it cannot ask for a third of full speed on one axis and full on another. | 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 ```bash 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. ```text 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. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) 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. ### What goes on the wire is the change A velocity is a set-point the arm holds, not a frame in a sequence. This node sends one when it **changes** — a new direction, or the stop — and otherwise repeats the current one only every `JOG_KEEPALIVE_S` (0.2 s), which exists solely so the arm's own expiry never fires mid-motion. It used to repeat every tick. On a real xArm that means the controller is handed the same velocity twenty times a second and re-plans the same motion each time, which an operator feels as the arm moving in steps rather than travelling. Keep the repeat well under the arm's `VELOCITY_COMMAND_TTL_S` and no faster than it needs to be. **A change never waits for the repeat.** Releasing a button is acted on the tick it happens, so sending less does not cost anything at the operator's end. ## 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 — see below | 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 — `ui_state` is published for anyone else watching and has no consumer in the stacks below. ## 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 three nodes: this one, the pad and the arm. The console comes with this node. ```yaml - id: teleop git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git tag: v0.3.0 build: pip install . path: dora-teleop inputs: tick: dora/timer/millis/50 joy: source: joy/joy queue_size: 1 queue_policy: drop_oldest 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] env: WEB_PORT: "7000" ``` Wire `ulite6`'s `request` and `jog` from this node, and 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. Then open `http://:7000` for the console. Nothing else needs wiring: the node serves the page and holds the browsers' sockets, and the page finds the socket on its own origin. Leave `joy` unwired for a browser-only console; the node starts without it. Set `WEB_PORT: "0"` for a pad-only one, and no port is opened. **Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No hardware: the pad and the arm are other nodes, and the browser is any browser. ### Driving a real arm `dataflow-robot.yml` is the one that moves a robot: the pad, this node and [`dora-ulite6`](https://gitea.liria.cl/liria/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. ```bash source .venv/bin/activate # see the note below before reaching for --uv dora build dataflow-robot.yml # clones and installs both, needs the network dora run dataflow-robot.yml ``` **`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv` installs each `git:` node into a venv inside its own clone directory, while `dora run --uv` resolves the entrypoint by running `uv run which ` from the *dataflow's* directory — which only ever sees the dataflow's own venv. The node is installed where the spawner does not look, and the run fails with `Could not find source path dora-joy within uv`. Building into one activated environment, as above, puts every node's entrypoint somewhere the spawner finds it. The venv needs `pip` for that (`uv pip install pip`), since `uv` does not create one. `dataflow.yml` is unaffected and `--uv` is fine there: its only Python node is this repo, which is already in this repo's venv. 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 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 | | Held velocity repeated | `JOG_KEEPALIVE_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 | The last one is enforced by `dora-ulite6` itself from v0.3.1 on, not by the controller: the velocity is set once and held, so that node stops the arm rather than letting a controller-side timer do it. `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.1 annotates, which is also what pinning that tag asserts — re-check it when moving to a newer one. The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening `http://:7000` gets you the same arm the pad drives, under the same lease. ### 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. ```bash 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: ```text 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`: ```bash dora up dora start dataflow-gamepad.yml --uv --name teleop-bench ``` Then, in another terminal: ```bash 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 `dora_teleop/web/index.html` is the browser half: two analog sticks, 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 — the node serves the file as it stands, and a console you cannot fix without a toolchain is the wrong thing to have between a person and a robot. **Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold, with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's jog control on the controller at `:18333`, and deliberately the same directions, because two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a preference. | 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. It is the one direction that does not read as "screen right is right", and it is that way on purpose. Directions are held, not analog: speed comes from the dial, and every held direction is a separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one gesture. The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same port, so the console is one URL. That work happens on a thread of its own and meets the control loop at two points only — the messages the next tick drains, and the snapshot handed back — because a browser that stops reading must never be able to 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, which is why the tab closing mid-jog does not leave it travelling. 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 dial sets that fraction. Every control is dimmed and inert unless the console holds the lease, so one that would be refused looks refused. If `WEB_PORT` is already taken the node **refuses to start** and says so, rather than carrying on with a console nobody can open. `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 ```bash 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`.