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.
305 lines
16 KiB
Markdown
305 lines
16 KiB
Markdown
# 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
|
||
`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 it touches no hardware at all.
|
||
|
||
**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`.
|
||
|
||
## The gamepad
|
||
|
||
| 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 base frame — the same
|
||
convention the console uses, so the two agree.
|
||
|
||
**Back latches.** It stops the arm, tells the controller to stop, and keeps the arm still
|
||
no matter what the sticks do. **X is the only way out**, and it does both halves: releases
|
||
the latch here and clears the error on the controller. Neither button is gated on the
|
||
lease — 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.
|
||
|
||
`home` is deliberately **not** on the pad. It is a large unattended motion and belongs
|
||
behind a deliberate click.
|
||
|
||
### The layout
|
||
|
||
These are the indices an Xbox One S pad reports **over Bluetooth**, swept one control at a
|
||
time. They are not universal — the same pad on USB numbers its buttons differently — so a
|
||
pad that disagrees is an edit to `Layout` in `dora_teleop/mapping.py`, not a new
|
||
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
|
||
|
||
```bash
|
||
source .venv/bin/activate
|
||
dora build dataflow-gamepad.yml
|
||
dora run dataflow-gamepad.yml
|
||
uv run dora-teleop-probe # in another terminal
|
||
```
|
||
|
||
The probe 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.
|
||
|
||
```text
|
||
axes (6 reported)
|
||
0 left stick X +0.600 ----------|-----#----
|
||
1 left stick Y -0.800 --#-------|----------
|
||
4 left trigger -1.000 #---------|----------
|
||
buttons (17 reported)
|
||
7:yaw right
|
||
|
||
velocity this would send to the arm
|
||
vx +38.89 mm/s forward / back <--
|
||
vy +23.53 mm/s left / right <--
|
||
wz +30.00 deg/s yaw <--
|
||
|
||
moving: yes
|
||
asking for the arm: yes
|
||
```
|
||
|
||
Two things to look for: an axis marked `unused` that moves when you push a stick, and a
|
||
bar that does not sit centred with the stick released. It goes through the same
|
||
`velocity()` the node runs, so what it prints is what the arm would be sent.
|
||
|
||
For a pad whose numbering is not known at all, `dora-joy-probe` in the `dora-joy` repo
|
||
needs no dataflow.
|
||
|
||
## Speaking to any robot
|
||
|
||
Both control sources are reduced to a **normalised** six-vector — `[-1, 1]` per degree of
|
||
freedom, a fraction of full speed — and only then scaled, in one function. The gamepad
|
||
reaches that vector through the deadzone and the response curve, which correct a worn
|
||
physical stick; the browser sends it already clean.
|
||
|
||
That is what lets one node and one console drive a robot in mm/s and one in m/s. `twist`
|
||
is SI, because that 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`, not `grasp` — so swapping a vacuum for
|
||
a two-finger gripper needs no change at either end.
|
||
|
||
The full contract, including the reserved `joint_jog` port, 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.
|
||
- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the
|
||
bench does not lock the browser out.
|
||
- The **browser takes it explicitly** with TAKE CONTROL and returns it with RELEASE. It
|
||
can preempt: an operator who can see the arm is better placed than this code to decide.
|
||
- **E-STOP is never gated**, from either source, held lease or not.
|
||
|
||
**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists,
|
||
so another node publishing `request` will preempt an operator mid-motion.
|
||
|
||
## The deadman
|
||
|
||
An arm under a velocity command keeps moving until told otherwise. Four layers, each
|
||
shorter than the one behind it — a backstop that fires first backs up nothing:
|
||
|
||
| Layer | Setting | Default |
|
||
|--------------------------|-------------------------------------|---------|
|
||
| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s |
|
||
| Pad reading goes stale | `JOY_TTL_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 |
|
||
|
||
`JOY_TTL_S >= JOG_TTL_S` stops the node at startup rather than failing quietly later. The
|
||
last layer is enforced by `dora-ulite6` whether or not this process is alive.
|
||
|
||
A pad reporting itself gone, or the last browser disconnecting, stops the arm
|
||
**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.
|
||
|
||
**What goes on the wire is the change.** A velocity is a set-point the arm holds, so one
|
||
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
|
||
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.
|
||
|
||
## 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 |
|
||
|
||
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.
|
||
|
||
## 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 |
|
||
| `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 |
|
||
| `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 |
|
||
| `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, or one that breaks the deadman order, 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.
|
||
|
||
## Running it
|
||
|
||
| Dataflow | Nodes | Needs |
|
||
|-------------------------|------------------------------|-----------------|
|
||
| `dataflow.yml` | teleop + bench stand-in | nothing |
|
||
| `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 |
|
||
|
||
```bash
|
||
dora run dataflow.yml --uv # no pad, no arm, browser path only
|
||
|
||
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
|
||
```
|
||
|
||
`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which
|
||
is what lets CI validate it. The bench answers services and publishes telemetry but is
|
||
**not a simulator**: it never integrates a velocity or moves a pose.
|
||
|
||
Set `ROBOT_IP` and `TOOL_TYPE` to match the arm in front of you. `INIT_MODE` is `none`, so
|
||
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.
|
||
|
||
**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1): each is installed
|
||
into a venv inside its own clone, while the spawner resolves entrypoints from the
|
||
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.
|
||
|
||
`dora` type-checks a dataflow before fetching a `git:` source, so it cannot read the arm's
|
||
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.
|
||
|
||
### Watching the wire
|
||
|
||
Every dataflow sets `debug: enable_debug_inspection: true`, so dora will let you read the
|
||
wire without any node printing it. This needs a coordinator, so it works for the
|
||
arm-free dataflow:
|
||
|
||
```bash
|
||
dora up
|
||
dora start dataflow.yml --uv --name teleop-bench
|
||
dora topic list -d teleop-bench # every edge in the graph
|
||
dora topic echo -d teleop-bench teleop/jog # every velocity, live
|
||
dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence
|
||
dora stop --name teleop-bench && dora down
|
||
```
|
||
|
||
## The console
|
||
|
||
`dora_teleop/web/index.html` is the browser half: two analog rings, 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 — a console you cannot fix without a toolchain is
|
||
the wrong thing to have between a person and a robot.
|
||
|
||
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 and the same
|
||
directions as xArm Studio's jog control at `:18333`.
|
||
|
||
| 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. Two consoles that disagree about which way is `+Y` is a trap.
|
||
|
||
The node serves the page and holds the WebSocket on `/ws` of the same port, so there is
|
||
one URL and nothing to keep in step. That runs on its own thread and meets the control
|
||
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.
|
||
|
||
Directions are held, not analog: speed comes from the dial, and each held direction is a
|
||
separate pointer, so two fingers work at once. The page re-sends its jog every 150 ms
|
||
against the node's 400 ms deadline, and sends a **fraction of full deflection**, never a
|
||
speed, so it never learns the robot's units. Controls are dimmed and inert unless the
|
||
console holds the lease. If `WEB_PORT` is taken the node **refuses to start**.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
uv sync --group dev
|
||
uv run pytest # 149 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. `tests/` names
|
||
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.
|