Read the gamepad from dora-joy, and speak a robot-agnostic contract #2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
Reference in New Issue
Block a user