docs: simplify the README and record the pad layout
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s

The README carried a lot of rationale that AGENTS.md already holds; what is
left is the tables a consumer wires against. It gains the swept axis and
button indices, and says plainly that the axes check themselves at runtime
and the buttons cannot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 18:06:12 -03:00
co-authored by Claude Opus 5
parent f7257ab903
commit 5ce83ae945
3 changed files with 231 additions and 331 deletions
+27 -4
View File
@@ -46,16 +46,19 @@ uv sync --group dev # plus pytest and ruff
```bash ```bash
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad 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: dora run dataflow-robot.yml # the pad and a real arm, both pulled in by git:
dora run dataflow-gamepad.yml # the same, with the probe declared and no console
``` ```
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which `dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
is what lets CI validate it. Keep it that way; the pad belongs in `dataflow-gamepad.yml`. is what lets CI validate it. Keep it that way; the pad belongs in the other two. Neither
of those takes `--uv`: dora 1.0.1 installs a `git:` node into a venv inside its own clone
and then looks for its entrypoint in the dataflow's venv. README.md has the detail.
## Test ## Test
```bash ```bash
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed uv run pytest # 149 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 .
``` ```
@@ -74,6 +77,14 @@ a source goes away**.
- **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is - **`dora_teleop/joy.py`** — The pad as it arrives from `dora-joy`. `Sample.present` is
the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the the load-bearing field: an unplugged pad reads as *absent*, not as centred, so "the
operator let go" and "the pad fell off the desk" stay distinguishable. operator let go" and "the pad fell off the desk" stay distinguishable.
**`Calibration` is the other half of that seam.** It measures a pad the first time it is
seen at rest and hands the rest of the node a reading as the layout's ideal pad would
have sent it, so a worn stick's tilt is subtracted rather than deadzoned and a trigger
that rests at 0 stops being a special case. A pad whose resting axes do not fit `Layout`
reads as *absent* and is never corrected: one reading cannot tell a trigger at -1.0 from
a stick held hard over, and guessing wrong drives a real arm. It is not re-measured
while the pad is in use — doing that under a held stick would record the deflection as
centre, and the arm would move when the operator let go.
**`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad **`JoyInput.read` is the other safety-critical function in this repo.** Moving the pad
into another process added a way for it to go away that did not exist before — that into another process added a way for it to go away that did not exist before — that
process dying — and from here that looks like silence rather than like an unplugged pad. process dying — and from here that looks like silence rather than like an unplugged pad.
@@ -90,7 +101,12 @@ a source goes away**.
units: mm/s and deg/s, base frame. `is_active` lives here rather than on `Sample` 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 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 to normalise triggers exactly as `velocity` does, or every connected pad reads as in use
for ever and `GAMEPAD_IDLE_RELEASE_S` never fires. for ever and `GAMEPAD_IDLE_RELEASE_S` never fires. `rest_zeros` and `canonical` are the
same concern one layer out: they check a resting pad against `Layout` and map it onto
the ideal one. They are the only defence against a wrong index, which is not a
theoretical failure — an Xbox One S pad reporting its triggers on 4 and 5 against a
layout expecting them on 2 and 5 read as a full-speed pitch command from an untouched
pad, held the lease for ever, and moved a real arm into a controller error.
- **`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 is already built on it — it gates its controls on lease because the console is already built on it — it gates its controls on
@@ -170,6 +186,13 @@ belongs here, and a port nothing wires reads as supported while having never run
pair; nothing can enforce the second from here. pair; nothing can enforce the second from here.
- **An unplugged pad, a dead `dora-joy` or a departed browser stops the arm immediately**, - **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. without waiting out any timer beyond the one that detects it.
- **A pad is driven from only after it has been seen at rest**, and a pad whose rest does
not fit `Layout` is not driven from at all. This covers the axes and **cannot cover the
buttons**: an unpressed button reads the same on every pad, so a wrong button index is
silent until somebody presses it and gets another verb. Sweeping a new pad with the
probe is the only check, and it is worth doing before the arm is powered. The cost is that a pad held when it appears
is ignored until it is let go; the alternative is guessing a numbering from one reading,
against a real arm.
- **Both control sources go through `mapping`.** The web path used to bypass it and pass - **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 a velocity straight through, which is why the console had to speak mm/s. One conversion
point or none. point or none.
+166 -295
View File
@@ -4,25 +4,17 @@ 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, reads the pad from `jog` and `request` ports, reads the pad from
[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and **serves the browser console [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and serves the browser console itself
itself** on one port. It contains no robot driver and no gamepad driver: it is the piece on one port. It contains no robot driver and no gamepad driver: it is the piece in the
in the middle that decides what the arm should be doing, and that decision is the only middle that decides what the arm should be doing, and it touches no hardware at all.
thing it owns. It touches no hardware at all.
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`.
## 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
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 | | Control | Does | Units |
|-------------|-------------------------------------------|-------| |-------------|----------------------------------------------|-------|
| Left stick | Move along X (up/down) and Y (left/right) | mm/s | | Left stick | Move along X (up/down) and Y (left/right) | mm/s |
| Triggers | Lift on the right, lower on the left | mm/s | | Triggers | Lift on the right, lower on the left | mm/s |
| Right stick | Roll (left/right) and pitch (up/down) | deg/s | | Right stick | Roll (left/right) and pitch (up/down) | deg/s |
@@ -33,157 +25,150 @@ pill — but it cannot ask for a third of full speed on one axis and full on ano
| Y | Toggle freedrive | — | | Y | Toggle freedrive | — |
| Back | **E-STOP** — latches until X | — | | 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 base frame — the same
the same convention the web console's dial uses, so the two agree. convention the console uses, so the two agree.
Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that **Back latches.** It stops the arm, tells the controller to stop, and keeps the arm still
disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new no matter what the sticks do. **X is the only way out**, and it does both halves: releases
environment variable: an index nobody can verify from outside is worse than a line of code the latch here and clears the error on the controller. Neither button is gated on the
somebody can read. The layout lives here rather than in `dora-joy` for the same reason — lease — an idle pad hands the arm back two seconds after the stop it just made, and a
`dora-joy` does not know what a button is for, which is what makes it reusable. latch you must first take the arm back to release is a trap.
`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.
**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm ### The layout
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 These are the indices an Xbox One S pad reports **over Bluetooth**, swept one control at a
here and clears the error on the controller. Neither button is gated on the lease, because time. They are not universal — the same pad on USB numbers its buttons differently — so a
an idle pad hands the arm back two seconds after the stop it just made, and a latch you pad that disagrees is an edit to `Layout` in `dora_teleop/mapping.py`, not a new
must first take the arm back to release is a trap. environment variable. It lives here rather than in `dora-joy` because `dora-joy` does not
know what a button is *for*, which is what makes it reusable.
| Control | Axis | Control | Button |
|----------------------|------|------------------|--------|
| Left stick X / Y | 0, 1 | A — grasp | 0 |
| Right stick X / Y | 2, 3 | B — release | 1 |
| Left / right trigger | 4, 5 | X — clear | 3 |
| | | Y — freedrive | 4 |
| | | LB / RB — yaw | 6, 7 |
| | | Back — E-STOP | 11 |
**Sweep a new pad before the arm is powered.** The axes check themselves and the buttons
cannot:
- **Axes.** Every pad is measured once, on the first reading in which it is sitting still.
A stick must rest near centre and a trigger near the bottom of its travel; an axis
resting anywhere else means the numbering is wrong, something is being held, or the pad
is broken. All three read as **no pad** until it rests, and the log names the axis. A
trigger read as a stick asks for full speed while lying untouched on the desk, so a pad
this node has never seen still never gets the arm.
- **Buttons.** An unpressed button reads the same on every pad, so a wrong button index is
silent until it fires. That is not hypothetical: on this pad, against the indices this
node shipped with, X toggled freedrive and LB was the E-STOP.
That same measurement makes a worn stick harmless — its resting tilt becomes its zero, so
the tilt is **subtracted** rather than deadzoned away and the travel left still reaches
full speed. A trigger that reads 0 until it first moves, rather than SDL's −1, is handled
the same way instead of reading as half pressed.
### Checking your pad ### Checking your pad
```bash ```bash
dora up source .venv/bin/activate
dora start dataflow-gamepad.yml --uv --name teleop-bench dora build dataflow-gamepad.yml
dora run dataflow-gamepad.yml
uv run dora-teleop-probe # in another terminal uv run dora-teleop-probe # in another terminal
``` ```
Prints the raw axes, the buttons it sees, and the velocity this node would send — live, The probe prints the raw axes, the buttons it sees and the velocity this node would send,
from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a live, from the same `joy` stream the node reads. **It cannot move anything** — it attaches
dynamic node, publishes nothing and never talks to the arm; it only reads. as a dynamic node, publishes nothing and never talks to the arm.
```text ```text
axes (6 reported) axes (6 reported)
0 left stick X +0.600 ----------|-----#---- 0 left stick X +0.600 ----------|-----#----
1 left stick Y -0.800 --#-------|---------- 1 left stick Y -0.800 --#-------|----------
2 left trigger -1.000 #---------|---------- 4 left trigger -1.000 #---------|----------
... buttons (17 reported)
buttons (6 reported) 7:yaw right
5:yaw right
velocity this would send to the arm velocity this would send to the arm
vx +38.89 mm/s forward / back <-- vx +38.89 mm/s forward / back <--
vy +23.53 mm/s left / right <-- vy +23.53 mm/s left / right <--
vz +20.39 mm/s up / down <--
wz +30.00 deg/s yaw <-- wz +30.00 deg/s yaw <--
moving: yes moving: yes
asking for the arm: yes asking for the arm: yes
``` ```
Run this before trusting the table above. The indices are SDL's documented layout, not Two things to look for: an axis marked `unused` that moves when you push a stick, and a
observed ones, and a pad that numbers its axes differently is much better found here than bar that does not sit centred with the stick released. It goes through the same
by watching an arm move the wrong way. Two things to look for: `velocity()` the node runs, so what it prints is what the arm would be sent.
- **Every axis is listed, including ones the layout does not use** — an axis marked For a pad whose numbering is not known at all, `dora-joy-probe` in the `dora-joy` repo
`unused` that moves when you push a stick means the layout needs changing. needs no dataflow.
- **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 ## Speaking to any robot
Nothing above is specific to one arm. Both control sources are reduced to a **normalised** Both control sources are reduced to a **normalised** six-vector — `[-1, 1]` per degree of
six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only freedom, a fraction of full speed — and only then scaled, in one function. The gamepad
then scaled, in one function. A gamepad reaches that vector through the deadzone and the reaches that vector through the deadzone and the response curve, which correct a worn
response curve, which exist to correct a worn physical stick; the browser sends it already physical stick; the browser sends it already clean.
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: That is what lets one node and one console drive a robot in mm/s and one in m/s. `twist`
neither source ever knew the difference. It is also why a gamepad and the console asking is SI, because that is what a UR or MoveIt takes; configuration stays in mm/s and deg/s,
for the same deflection produce byte-identical output. because `60 mm/s` is a number a person reading a dataflow can judge. `command` carries
verbs rather than service names — `gripper_close`, not `grasp` — so swapping a vacuum for
a two-finger gripper needs no change at either end.
`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt The full contract, including the reserved `joint_jog` port, is in the vault at
takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person `resources/robotics/teleop-contract.md`.
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
Two sources, one arm. Whoever holds the lease drives; the other is ignored. 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. - 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 - It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
bench does not lock the browser out forever. bench does not lock the browser out.
- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives - The **browser takes it explicitly** with TAKE CONTROL and returns it with RELEASE. It
it back with RELEASE. It can preempt a held arm, because an operator who can see the arm can preempt: an operator who can see the arm is better placed than this code to decide.
is better placed than this code to decide they should have it. - **E-STOP is never gated**, from either source, held lease or not.
- **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. **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 so another node publishing `request` will preempt an operator mid-motion.
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. Four layers, each
never whether to start but whether anything has happened that means it must stop. Four shorter than the one behind it — a backstop that fires first backs up nothing:
layers, outermost last:
1. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) while a source is driving. | Layer | Setting | Default |
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 | Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which | Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s |
is what makes silence unambiguous. | Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s |
3. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet — | Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s |
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 `JOY_TTL_S >= JOG_TTL_S` stops the node at startup rather than failing quietly later. The
the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at last layer is enforced by `dora-ulite6` whether or not this process is alive.
startup rather than letting that happen quietly.
On shutdown the node sends one all-zero velocity before exiting. A motion lives in the A pad reporting itself gone, or the last browser disconnecting, stops the arm
controller, not in this process. **immediately** without waiting out any timer: an unplugged pad is not a centred pad. On
shutdown the node sends one all-zero velocity, because a motion lives in the controller
rather than in this process.
The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6` **What goes on the wire is the change.** A velocity is a set-point the arm holds, so one
believing a jog is live and hold `robot_state` at `moving` forever. is sent when it *changes* and otherwise repeated only every `JOG_KEEPALIVE_S`. Repeating
every tick made a real xArm re-plan the same motion twenty times a second, which reads as
### What goes on the wire is the change stepping. The stop is sent **once** — a stream of zeroes keeps `dora-ulite6` believing a
jog is live. A change never waits for the repeat.
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 ## Ports
| Direction | Name | Description | | Direction | Name | Description |
|-----------|----------------|------------------------------------------------------| |-----------|----------------|-------------------------------------------------------|
| in | `tick` | 50 ms — change detection, deadman, telemetry | | in | `tick` | 50 ms — change detection, deadman, telemetry |
| in | `joy` | One pad reading from `dora-joy` | | in | `joy` | One pad reading from `dora-joy` |
| in | `response` | An answer from the arm | | in | `response` | An answer from the arm |
@@ -195,239 +180,125 @@ happens, so sending less does not cost anything at the operator's end.
| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs | | out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs |
| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` | | out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` |
| out | `request` | *Deprecated.* A service call, `request_id` in metadata | | out | `request` | *Deprecated.* A service call, `request_id` in metadata |
| out | `ui_state` | The console's telemetry snapshot, as JSON — see below | | 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 Wire `twist` **or** `jog`, never both — they carry the same intent, and a robot reading
both is driven twice. Same for `command` and `request`. 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 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 — 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 ## Environment variables
| Variable | Default | Description | | Variable | Default | Description |
|--------------------------|---------|--------------------------------------------------------| |--------------------------|----------|--------------------------------------------------------|
| `SPEED_MM_S` | `60.0` | Translation speed at full deflection | | `SPEED_MM_S` | `60.0` | Translation speed at full deflection |
| `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 |
| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped | | `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped |
| `JOG_KEEPALIVE_S` | `0.2` | How often an unchanged velocity is repeated |
| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone | | `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 |
| `WEB_HOST` | `0.0.0.0`| Address the console binds to |
| `WEB_PORT` | `7000` | Console port; **`0` opens no port at all** |
A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a A value that cannot be read, or one that breaks the deadman order, stops the node at
`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time startup rather than the first time somebody picks up the pad. `JOYSTICK_INDEX` moved to
somebody picks up the pad. `dora-joy` along with the pad.
`JOYSTICK_INDEX` moved to `dora-joy` along with the pad. ## Running it
## Usage | Dataflow | Nodes | Needs |
|-------------------------|------------------------------|-----------------|
The real stack is three nodes: this one, the pad and the arm. The console comes with | `dataflow.yml` | teleop + bench stand-in | nothing |
this node. | `dataflow-robot.yml` | joy + teleop + ulite6 | a pad and an arm |
| `dataflow-gamepad.yml` | the same, plus the probe, console shut | a pad and an arm |
```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://<the machine running teleop>: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 ```bash
source .venv/bin/activate # see the note below before reaching for --uv dora run dataflow.yml --uv # no pad, no arm, browser path only
dora build dataflow-robot.yml # clones and installs both, needs the network
source .venv/bin/activate # a real arm: see the --uv note below
dora build dataflow-robot.yml # clones and installs both siblings
dora run dataflow-robot.yml dora run dataflow-robot.yml
``` ```
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv` `dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
installs each `git:` node into a venv inside its own clone directory, while `dora run is what lets CI validate it. The bench answers services and publishes telemetry but is
--uv` resolves the entrypoint by running `uv run which <name>` from the *dataflow's* **not a simulator**: it never integrates a velocity or moves a pose.
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, Set `ROBOT_IP` and `TOOL_TYPE` to match the arm in front of you. `INIT_MODE` is `none`, so
which is already in this repo's venv. nothing moves until somebody pushes a stick. The console comes with the node — open
`http://<that machine>:7000` for the same arm the pad drives, under the same lease.
Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults **`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1): each is installed
are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so into a venv inside its own clone, while the spawner resolves entrypoints from the
nothing moves until somebody pushes a stick. dataflow's venv, and the run fails with `Could not find source path dora-joy within uv`.
Build into one activated environment instead, as above; it needs `pip`
(`uv pip install pip`). **`dora start` refuses them outright** — "node joy has git source,
but no `dora build` was run yet", straight after a build that succeeded — so use
`dora run`, which also means `dora topic` is unavailable for those two dataflows.
The deadman timings have to stay ordered, each below the one behind it, and the file pins `dora` type-checks a dataflow before fetching a `git:` source, so it cannot read the arm's
all of them so the chain is visible in one place: manifest. The `output_types:` block on the `ulite6` node repeats what `dora-ulite6` v0.3.1
annotates — re-check it when moving to a newer tag.
| 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://<that machine>: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 ### Watching the wire
`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what Every dataflow sets `debug: enable_debug_inspection: true`, so dora will let you read the
is actually on the wire without the bench printing anything. Run it under a coordinator wire without any node printing it. This needs a coordinator, so it works for the
rather than `dora run`: arm-free dataflow:
```bash ```bash
dora up dora up
dora start dataflow-gamepad.yml --uv --name teleop-bench dora start dataflow.yml --uv --name teleop-bench
```
Then, in another terminal:
```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 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
dora stop --name teleop-bench && dora down
``` ```
`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 ## The console
`dora_teleop/web/index.html` is the browser half: two analog sticks, a lift lane and a `dora_teleop/web/index.html` is the browser half: two analog rings, a lift lane and a yaw
yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry 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 readout. Plain HTML with no build step — a console you cannot fix without a toolchain is
console you cannot fix without a toolchain is the wrong thing to have between a person and the wrong thing to have between a person and a robot.
a robot.
**Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold, It is laid out like the arm's own pendant: each ring is quartered into sectors you hold,
with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's with the out-of-plane axis as a pair of pills above it — the same shape and the same
jog control on the controller at `:18333`, and deliberately the same directions, because directions as xArm Studio's jog control at `:18333`.
two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a
preference.
| Ring | Sectors | Pills above | | Ring | Sectors | Pills above |
|-----------|------------------------------------------------|----------------| |-----------|------------------------------------------------------------|---------------|
| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` | | translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` |
| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` | | 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 **`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 the operator and `+Z` up. Two consoles that disagree about which way is `+Y` is a trap.
right", and it is that way on purpose.
Directions are held, not analog: speed comes from the dial, and every held direction is a The node serves the page and holds the WebSocket on `/ws` of the same port, so there is
separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one one URL and nothing to keep in step. That runs on its own thread and meets the control
gesture. loop at two non-blocking points only, because a browser that stops reading must never
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.
The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same Directions are held, not analog: speed comes from the dial, and each held direction is a
port, so the console is one URL. That work happens on a thread of its own and meets the separate pointer, so two fingers work at once. The page re-sends its jog every 150 ms
control loop at two points only — the messages the next tick drains, and the snapshot against the node's 400 ms deadline, and sends a **fraction of full deflection**, never a
handed back — because a browser that stops reading must never be able to stall the thread speed, so it never learns the robot's units. Controls are dimmed and inert unless the
that stops the arm. console holds the lease. If `WEB_PORT` is taken the node **refuses to start**.
**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 ## Development
```bash ```bash
uv sync --group dev uv sync --group dev
uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed uv run pytest # 149 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 .
``` ```
Every file here is testable without hardware, because there is no hardware. The pad Every file here is testable without hardware, because there is no hardware. `tests/` names
arrives on `joy` as a plain `Sample`. its controls (`axes_for`, `buttons_for`) rather than numbering them — a test that numbers
its axes goes on passing while meaning something else once a pad renumbers them.
+6
View File
@@ -53,6 +53,12 @@ inputs:
pad node is indistinguishable from an unplugged pad as far as the arm is pad node is indistinguishable from an unplugged pad as far as the arm is
concerned. concerned.
Each pad is measured the first time it is seen sitting still, so a stick
that no longer centres has its tilt subtracted rather than deadzoned away.
A pad whose resting axes do not fit the layout this node compiles in — a
trigger where a stick should be, say — reads the same as no pad at all
and is named in the log, rather than being guessed at against a real arm.
Leave it unwired for a browser-only console. The node says so and starts. Leave it unwired for a browser-only console. The node says so and starts.
response: response:
required: false required: false