Read the gamepad from dora-joy, and speak a robot-agnostic contract #2

Merged
cristhian merged 6 commits from feat/robot-agnostic-teleop into main 2026-09-16 17:52:07 +00:00
2 changed files with 249 additions and 72 deletions
Showing only changes of commit ebd3b3e493 - Show all commits
+95 -39
View File
@@ -7,12 +7,21 @@ This file provides guidance to AI coding assistants when working with code in th
A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a A dora-rs Python node that gives a human control of a robot arm, from a gamepad or from a
web browser, and decides which of the two is driving. web browser, and decides which of the two is driving.
It is **the piece in the middle and nothing else**. It contains no robot driver — the arm It is **the piece in the middle and nothing else**, and it **touches no hardware at all**.
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's The arm is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by node's ordinary `jog` and `request` ports. The browser is carried by
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver). [`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
Do not add either here. A teleop node that grew its own HTTP server is exactly the The gamepad is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
monolith `dora-ulite6` was rewritten to get out of. axes and buttons on `joy` and knows nothing about arms. Do not add any of the three here.
A teleop node that grew its own HTTP server is exactly the monolith `dora-ulite6` was
rewritten to get out of, and the pad was split out of this repo for the same reason: a
node whose job is policy should not carry a hardware dependency, and a pad nobody else can
reach is a pad that gets reimplemented.
**What stays here is everything about what a reading *means***: the deadzone, the response
curve, the layout (which button is grasp), and the mapping to base-frame mm/s and deg/s.
`dora-joy` publishes raw SDL and nothing else, so there is exactly one place shaping each
number.
The web console it serves is ported from `Littlehand/monolitic-littlehand-python` The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration, (`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
@@ -29,16 +38,17 @@ uv sync --group dev # plus pytest and ruff
## Run ## Run
```bash ```bash
dora run dataflow.yml --uv dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
dora run dataflow-gamepad.yml --uv # the same, with dora-joy pulled in by git:
``` ```
`dataflow.yml` runs the whole stack — this node, the bridge and the arm — from checkouts `dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
expected next to this one. is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`.
## Test ## Test
```bash ```bash
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
uv run ruff check . uv run ruff check .
uv run ruff format --check . uv run ruff format --check .
``` ```
@@ -54,14 +64,26 @@ a source goes away**.
is not an owner actively asking for motion returns a stop, so a case nobody thought of is not an owner actively asking for motion returns a stop, so a case nobody thought of
fails by halting rather than by carrying on. Keep it that way: the default has to be fails by halting rather than by carrying on. Keep it that way: the default has to be
"still". "still".
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this - **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic. operator let go" and "the pad fell off the desk" stay distinguishable.
`Sample.present` is the load-bearing field: an unplugged pad reads as *absent*, not as **`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
centred, so "the operator let go" and "the pad fell off the desk" stay distinguishable. into another process added a way for it to go away that did not exist before — that
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. Pure functions, process dying — and from here that looks like silence rather than like an unplugged pad.
no state, no hardware, because this is the part that has to be right and the cheapest to So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
check. Output is always the arm node's units: mm/s and deg/s, base frame. because `dora-joy` publishes on every tick whether or not the pad moved; if that ever
changes, this layer is gone and nothing here will say so.
- **`dora_teleop/message.py`** — The wire shapes, **declared and never inferred**.
pyarrow types a struct from its first row, so `{"value": None}` is `null` and
`{"value": 1.0}` is `double` — an inferred `command` would change its port's type
between one verb and the next.
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity, and the layout
that says which index is which. Pure functions, no state, no hardware, because this is
the part that has to be right and the cheapest to check. Output is always the arm node's
units: mm/s and deg/s, base frame. `is_active` lives here rather than on `Sample`
because it needs the layout: **a trigger rests at -1.0**, so the pad-is-in-use test has
to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires.
- **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move", - **`dora_teleop/arbiter.py`** — The lease. One place that answers "why did it not move",
rather than one answer per input path. Ported from the monolithic repo's `RobotService` rather than one answer per input path. Ported from the monolithic repo's `RobotService`
lease because the console's `app.js` is already built on it — it gates its jog pad on lease because the console's `app.js` is already built on it — it gates its jog pad on
@@ -74,7 +96,8 @@ a source goes away**.
reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`). reconciled with the console's (`idle|moving|jogging|freedrive|estop|stopped|disconnected`).
- **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an - **`dora_teleop/config.py`** — Every environment variable, parsed once at startup, so an
unusable setting stops the node before it joins the dataflow instead of surfacing the unusable setting stops the node before it joins the dataflow instead of surfacing the
first time somebody picks up the pad. Errors name the variable. first time somebody picks up the pad. Errors name the variable. It also refuses deadman
layers that are out of order, which is otherwise invisible until the day it matters.
- **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a - **`dora_teleop/log.py`** — Timestamped stderr logging. stderr because dora reads a
dynamic node's stdout. dynamic node's stdout.
- **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml` - **`examples/bench.py`** — A stand-in for the arm *and* the browser, so `dataflow.yml`
@@ -82,23 +105,59 @@ a source goes away**.
simulator** and must not become one: it never integrates a velocity or moves a pose. Its simulator** and must not become one: it never integrates a velocity or moves a pose. Its
job is to close the loop so this node can be watched, not to stand in for job is to close the loop so this node can be watched, not to stand in for
`dora-ulite6` — anything needing the arm to actually respond needs the arm. `dora-ulite6` — anything needing the arm to actually respond needs the arm.
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the - **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
velocity that would be sent, with no dataflow and no arm. It exists because the indices running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that the buttons and the velocity that would be sent. It exists because the indices in
`mapping.py` are SDL's documented layout rather than an observed one, and a pad that
disagrees is otherwise found by watching a real arm move the wrong way. It goes through disagrees is otherwise found by watching a real arm move the wrong way. It goes through
the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm the same `velocity()` the node runs, on the same readings, so what it prints is what the
would get — keep it that way, or it stops being evidence. arm would get — keep it that way, or it stops being evidence. `dataflow-gamepad.yml`
declares it as `path: dynamic`. For a pad whose layout is unknown, `dora-joy-probe`
needs no dataflow at all.
## The standard, and why it is one
This node publishes `twist` (SI) and `command` (robot-agnostic verbs), not one robot's
vocabulary. The contract is in the vault at `resources/robotics/teleop-contract.md`, which
is where it lives because no source package is shared between node repositories.
**Normalise first, scale once.** Every control source becomes a `[-1, 1]` six-vector
before anything is scaled, and the scaling happens in exactly one function. The deadzone
and the response curve are applied only to the gamepad, because they correct a worn
physical stick and a browser has none. Break this and the console silently acquires the
robot's units, which is what it used to have and the reason it fitted only one arm.
`jog` and `request` still carry the same intent in `dora-ulite6`'s units and service
names. They are deprecated, exist so the two repositories can move independently, and go
away once the arm node takes the standard ports. `LEGACY_REQUEST` in `main.py` is the only
place that knows what that node calls a verb.
`joint_jog` is specified in `dora-node.yml` and deliberately **not implemented**. A
joint-space robot needs IK and position integration against joint limits, neither of which
belongs here, and a port nothing wires reads as supported while having never run.
## Rules that are not negotiable ## Rules that are not negotiable
- **The stop is sent once, not every tick.** A stream of zero velocities keeps - **The stop is sent once, not every tick.** A stream of zero velocities keeps
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever. `dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
- **E-STOP is never gated on the lease.** From either source, at any time. - **E-STOP is never gated on the lease, and neither is its release.** From either
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The source, at any time. The latch (`view.estop`) is checked first in
controller-side expiry is the backstop; a backstop that fires first backs up nothing. `_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out not a stop. `_clear` is the only way out and does both halves, this node's latch and the
any timer. controller's error; the pad's X and the console's RESET both go through it, so a
- **Do not reintroduce a web server or a robot driver here.** See above. pad-only dataflow is not left with a latch nothing can release.
- **The deadman layers must stay in order**: `JOY_TTL_S` (0.2) < `JOG_TTL_S` (0.4) < the
arm node's `VELOCITY_COMMAND_TTL_S` (0.5). Each is the backstop for the one inside it,
and a backstop that fires first backs up nothing. `Config.from_env` enforces the first
pair; nothing can enforce the second from here.
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**,
without waiting out any timer beyond the one that detects it.
- **Both control sources go through `mapping`.** The web path used to bypass it and pass
a velocity straight through, which is why the console had to speak mm/s. One conversion
point or none.
- **Declare every published Arrow schema.** See `message.py`.
- **Do not reintroduce a web server, a robot driver or a gamepad driver here.** See above.
In particular: no `pygame`, no `evdev`, and no environment variable naming a device.
## What this node cannot do ## What this node cannot do
@@ -116,22 +175,19 @@ about except through `robot_state` and telemetry.
no source package is shared between node repositories. `dora validate` checks a dataflow no source package is shared between node repositories. `dora validate` checks a dataflow
against it without running the node, so CI needs neither a gamepad nor a robot. against it without running the node, so CI needs neither a gamepad nor a robot.
`jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately `joy`, `jog`, `request`, `response`, `motion_state` and the `ui_` ports are deliberately
**untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type **untyped**. dora treats a port carrying `request_id` as pattern-correlated and skips type
checks on it, and several Arrow schemas legitimately flow over each. Annotating them would checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
claim a contract neither side keeps. standard joystick type. Annotating them would claim a contract neither side keeps.
## Key APIs ## Key APIs
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event - **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
iteration via `for event in node`. iteration via `for event in node`.
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux - **The `joy` message** (`dora-joy`): the Arrow struct
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for `{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
the display and joystick subsystems are initialised: `pygame.init()` also opens audio, and no timestamp: freshness is arrival time here.
which on a headless robot fills stderr with ALSA errors for a device never used. The
display is needed despite there being no window, because SDL refreshes joystick state
during the event pump and the pump needs it.
## Tools ## Tools
+154 -33
View File
@@ -4,13 +4,19 @@ A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the
open a browser and move it from there — one of the two at a time, decided here. 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 It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's
ordinary `jog` and `request` ports, and talks to the browser through ordinary `jog` and `request` ports, reads the pad from
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and talks to the browser through
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver). [`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
It contains no web server and no robot driver: it is the piece in the middle that decides It contains no web server, no robot driver and no gamepad driver: it is the piece in the
what the arm should be doing, and that decision is the only thing it owns. 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 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 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 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. for one axis at a time at one fixed speed.
@@ -23,9 +29,9 @@ for one axis at a time at one fixed speed.
| Bumpers | Yaw, left and right | deg/s | | Bumpers | Yaw, left and right | deg/s |
| A | Close the tool | — | | A | Close the tool | — |
| B | Open the tool | — | | B | Open the tool | — |
| X | Clear a controller error | — | | X | Release the E-STOP and clear the arm's error | — |
| Y | Toggle freedrive | — | | Y | Toggle freedrive | — |
| Back | **E-STOP** | — | | Back | **E-STOP** — latches until X | — |
Up on the stick sends the arm away from you, along **+X** in the robot's base frame — 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. the same convention the web console's dial uses, so the two agree.
@@ -33,20 +39,30 @@ 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 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 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 environment variable: an index nobody can verify from outside is worse than a line of code
somebody can read. 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 `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. 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 ### Checking your pad
```bash ```bash
uv run dora-teleop-probe 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, 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 from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a
connection to the robot; it only reads. dynamic node, publishes nothing and never talks to the arm; it only reads.
```text ```text
axes (6 reported) axes (6 reported)
@@ -64,6 +80,7 @@ connection to the robot; it only reads.
wz +30.00 deg/s yaw <-- wz +30.00 deg/s yaw <--
moving: yes moving: yes
asking for the arm: yes
``` ```
Run this before trusting the table above. The indices are SDL's documented layout, not Run this before trusting the table above. The indices are SDL's documented layout, not
@@ -75,8 +92,33 @@ by watching an arm move the wrong way. Two things to look for:
- **A bar that does not sit centred with the stick released** is a worn pad announcing - **A bar that does not sit centred with the stick released** is a worn pad announcing
itself, and a reason to raise `DEADZONE`. itself, and a reason to raise `DEADZONE`.
It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is It goes through the same `velocity()` the node runs, on the same readings, so what it
what the arm would be sent. 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 ## The lease
@@ -99,18 +141,23 @@ stop it — if that matters for your dataflow, do not wire one.
## The deadman ## The deadman
An arm under a velocity command keeps moving until told otherwise, so the question is 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 never whether to start but whether anything has happened that means it must stop. Four
layers, outermost last: layers, outermost last:
1. **Re-send every tick** (50 ms) while a source is driving. 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 — 2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that
and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged process dying looks like silence rather than like an unplugged pad — so silence means
or the last browser disconnects. An unplugged pad is not a centred pad. the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which
3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by 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. 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 Keep them in that order, each shorter than the one behind it. A backstop that fires before
fires before the thing it is backing up. 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 On shutdown the node sends one all-zero velocity before exiting. A motion lives in the
controller, not in this process. controller, not in this process.
@@ -123,6 +170,7 @@ believing a jog is live and hold `robot_state` at `moving` forever.
| Direction | Name | Description | | Direction | Name | Description |
|-----------|----------------|------------------------------------------------------| |-----------|----------------|------------------------------------------------------|
| in | `tick` | 50 ms — re-send, deadman, telemetry | | 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 | `ui_command` | One console message, from the bridge's `data_out` |
| in | `clients` | Connect/disconnect from the bridge — the web deadman | | in | `clients` | Connect/disconnect from the bridge — the web deadman |
| in | `response` | An answer from the arm | | in | `response` | An answer from the arm |
@@ -130,10 +178,15 @@ believing a jog is live and hold `robot_state` at `moving` forever.
| in | `robot_state` | The arm's own state | | in | `robot_state` | The arm's own state |
| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees | | in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees |
| in | `joint_pose` | Joint angles, six float32, degrees | | in | `joint_pose` | Joint angles, six float32, degrees |
| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s | | out | `twist` | **Standard.** `{linear, angular, frame}`, SI |
| out | `request` | A service call, `request_id` in the metadata | | 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 | | 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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
@@ -142,17 +195,20 @@ believing a jog is live and hold `robot_state` at `moving` forever.
| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection | | `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection |
| `DEADZONE` | `0.12` | Stick travel near centre to ignore | | `DEADZONE` | `0.12` | Stick travel near centre to ignore |
| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear | | `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 | | `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 | | `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 | | `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 A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a
stops the node at startup rather than the first time somebody picks up the pad. `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 ## Usage
The real stack is three nodes: this one, the WebSocket bridge and the arm. The real stack is four nodes: this one, the pad, the WebSocket bridge and the arm.
```yaml ```yaml
- id: teleop - id: teleop
@@ -162,6 +218,10 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm.
path: dora-teleop path: dora-teleop
inputs: inputs:
tick: dora/timer/millis/50 tick: dora/timer/millis/50
joy:
source: joy/joy
queue_size: 1
queue_policy: drop_oldest
ui_command: ws-transceiver/data_out ui_command: ws-transceiver/data_out
clients: ws-transceiver/clients clients: ws-transceiver/clients
response: ulite6/response response: ulite6/response
@@ -173,20 +233,63 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm.
``` ```
Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this 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. 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.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a Leave `joy` unwired for a browser-only console; the node starts without it.
gamepad if you want one — the node starts without and says so.
**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`](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
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 ### 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, `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: 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 ```bash
dora run dataflow.yml --uv 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 The bench answers every service, publishes plausible telemetry, and prints each velocity
it is sent: it is sent:
@@ -209,13 +312,14 @@ rather than `dora run`:
```bash ```bash
dora up dora up
dora start dataflow.yml --uv --name teleop-bench dora start dataflow-gamepad.yml --uv --name teleop-bench
``` ```
Then, in another terminal: Then, in another terminal:
```bash ```bash
dora topic list -d teleop-bench # every edge in the graph 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/jog # every velocity, live
dora topic echo -d teleop-bench teleop/ui_state # what the console would draw 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 dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
@@ -227,14 +331,31 @@ deadzone `teleop/jog` should sit near the 50 ms tick.
Tear it down with `dora stop --name teleop-bench && dora down`. 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 ## Development
```bash ```bash
uv sync --group dev uv sync --group dev
uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed
uv run ruff check . uv run ruff check .
uv run ruff format --check . uv run ruff format --check .
``` ```
`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that Every file here is testable without hardware, because there is no hardware. The pad
cannot be tested without hardware. Everything else sees a plain `Sample`. arrives on `joy` as a plain `Sample`.