docs: describe the standard ports and the console
This commit is contained in:
@@ -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
|
||||
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
|
||||
is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that node's
|
||||
ordinary `jog` and `request` ports. It contains no web server — the browser is carried by
|
||||
It is **the piece in the middle and nothing else**, and it **touches no hardware at all**.
|
||||
The arm is [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), reached through that
|
||||
node's ordinary `jog` and `request` ports. The browser is carried by
|
||||
[`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
|
||||
monolith `dora-ulite6` was rewritten to get out of.
|
||||
The gamepad is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw
|
||||
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`
|
||||
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
|
||||
@@ -29,16 +38,17 @@ uv sync --group dev # plus pytest and ruff
|
||||
## Run
|
||||
|
||||
```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
|
||||
expected next to this one.
|
||||
`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`.
|
||||
|
||||
## Test
|
||||
|
||||
```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 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
|
||||
fails by halting rather than by carrying on. Keep it that way: the default has to be
|
||||
"still".
|
||||
- **`dora_teleop/gamepad.py`** — The pad, on a thread of its own. **Nothing outside this
|
||||
file may touch pygame.** Everything else sees a plain immutable `Sample`, which is what
|
||||
lets the rest be tested with no hardware and keeps SDL's quirks out of the arm logic.
|
||||
`Sample.present` is 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.
|
||||
- **`dora_teleop/mapping.py`** — Stick deflection to base-frame velocity. 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.
|
||||
- **`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
|
||||
operator let go" and "the pad fell off the desk" stay distinguishable.
|
||||
**`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
|
||||
process dying — and from here that looks like silence rather than like an unplugged pad.
|
||||
So silence past `JOY_TTL_S` reads as absent, which is the same answer. This works only
|
||||
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",
|
||||
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
|
||||
@@ -74,7 +96,8 @@ a source goes away**.
|
||||
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
|
||||
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
|
||||
dynamic node's stdout.
|
||||
- **`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
|
||||
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_teleop/probe.py`** — `dora-teleop-probe`: shows the raw axes, the buttons and the
|
||||
velocity that would be sent, with no dataflow and no arm. It exists because the indices
|
||||
in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that
|
||||
- **`dora_teleop/probe.py`** — `dora-teleop-probe`: a **dynamic node** that attaches to a
|
||||
running dataflow, reads the same `joy` stream this node reads, and shows the raw axes,
|
||||
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
|
||||
the same `Gamepad` and `velocity()` the node runs, so what it prints is what the arm
|
||||
would get — keep it that way, or it stops being evidence.
|
||||
the same `velocity()` the node runs, on the same readings, so what it prints is what the
|
||||
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
|
||||
|
||||
- **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.
|
||||
- **E-STOP is never gated on the lease.** From either source, at any time.
|
||||
- **`JOG_TTL_S` must stay below the arm node's `VELOCITY_COMMAND_TTL_S`** (0.5 s). The
|
||||
controller-side expiry is the backstop; a backstop that fires first backs up nothing.
|
||||
- **An unplugged pad or a departed browser stops the arm immediately**, without waiting out
|
||||
any timer.
|
||||
- **Do not reintroduce a web server or a robot driver here.** See above.
|
||||
- **E-STOP is never gated on the lease, and neither is its release.** From either
|
||||
source, at any time. The latch (`view.estop`) is checked first in
|
||||
`_wanted_velocity`, above the owner — a stop that the next stick movement overrides is
|
||||
not a stop. `_clear` is the only way out and does both halves, this node's latch and the
|
||||
controller's error; the pad's X and the console's RESET both go through it, so a
|
||||
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
|
||||
|
||||
@@ -116,22 +175,19 @@ about except through `robot_state` and telemetry.
|
||||
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.
|
||||
|
||||
`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
|
||||
checks on it, and several Arrow schemas legitimately flow over each. Annotating them would
|
||||
claim a contract neither side keeps.
|
||||
checks on it, several Arrow schemas legitimately flow over each, and dora 1.0 ships no
|
||||
standard joystick type. Annotating them would claim a contract neither side keeps.
|
||||
|
||||
## Key APIs
|
||||
|
||||
- **dora Python API** (`dora`): `Node()`, `node.send_output(id, array, metadata)`, event
|
||||
iteration via `for event in node`.
|
||||
- **pygame-ce** (`pygame.joystick`): chosen over evdev because Littlehand targets Linux
|
||||
*and* macOS and evdev is Linux-only, and because it ships prebuilt wheels for
|
||||
linux-x86_64, linux-aarch64 and macos-arm64 — nothing here compiles from source. Only
|
||||
the display and joystick subsystems are initialised: `pygame.init()` also opens audio,
|
||||
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.
|
||||
- **The `joy` message** (`dora-joy`): the Arrow struct
|
||||
`{axes: list<float32>, buttons: list<bool>, present: bool}`, published on every tick.
|
||||
Raw SDL — a stick centres near 0, but **a trigger rests at -1.0**. There is no header
|
||||
and no timestamp: freshness is arrival time here.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
Reference in New Issue
Block a user