Files
dora-teleop/AGENTS.md
T
cristhian e3fc538e5d
validate / validate (pull_request) Successful in 58m44s
validate / test (pull_request) Successful in 58m45s
validate / markdownlint (pull_request) Successful in 58m40s
docs: simplify the README and record the pad layout
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.
2026-09-16 18:06:12 -03:00

252 lines
15 KiB
Markdown

# AGENTS.md
This file provides guidance to AI coding assistants when working with code in this repository.
## What this is
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 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 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 either here: 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.
**The web console is the exception, and it is deliberate.** This node serves the page and
holds the browsers' sockets itself (`dora_teleop/server.py`), rather than going through
`dora-websocket-transceiver` as it did up to v0.2.0. A bridge node for one page meant a
second node, a second port and a round trip through the dataflow for every button press,
to carry a UI that only this node can interpret anyway. That is not the monolith
`dora-ulite6` was rewritten to escape — the monolith was one process owning the robot
driver, perception and the UI at once. What is forbidden is the hardware, and that has not
moved.
**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,
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
rebuild the monolith inside a node. That boundary is the one that matters, and the
built-in server does not move it: the console can ask for motion and nothing else.
## Build
```bash
uv sync # runtime deps
uv sync --group dev # plus pytest and ruff
```
## Run
```bash
dora run dataflow.yml --uv # this node plus a bench stand-in, no pad
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
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
```bash
uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed
uv run ruff check .
uv run ruff format --check .
```
## Architecture
Two things carry the design: **what may touch the hardware**, and **what must happen when
a source goes away**.
- **`dora_teleop/main.py`** — The dora event loop, and the one decision this node exists
to make: whether a velocity reaches the arm this tick, and from whom. **It never
blocks.** `_drive` and `_wanted_velocity` are the safety-critical pair — every path that
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/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.
**`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
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. `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",
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
`t.owner === "web"` and draws an owner chip. A simpler last-writer-wins scheme would mean
rewriting the page to match the node rather than the other way round.
- **`dora_teleop/ui.py`** — Translation both ways. The console's telemetry keys are a
contract: `app.js` reads `arm_state`, `owner`, `estop`, `vacuum`, `freedrive`,
`position`, `joints` by name, so renaming one breaks the page silently. `arm_state()` is
where the arm's vocabulary (`connecting|ready|moving|recovering|unavailable`) is
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. 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.
- **`dora_teleop/server.py`** — The console's HTTP and WebSocket server, on a thread of
its own. Serves `dora_teleop/web/index.html` at `/` and holds the browsers at `/ws`, on
one port so the page finds its socket on its own origin. Both queues are bounded: a
flood of messages is dropped rather than grown, and a slow browser loses stale snapshots
rather than the newest one. **The browser's deadman lives here** — `clients` counts open
sockets, and the tick reads it.
- **`examples/bench.py`** — A stand-in for the arm, so `dataflow.yml`
is self-contained and CI can validate it with no sibling clones. **It is not a
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`: 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 `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
- **Send the change, not the state.** A velocity is a set-point the arm holds. `_drive`
sends when it changes and otherwise repeats on `JOG_KEEPALIVE_S`, which exists only to
beat the arm's own expiry. Repeating every tick made a real xArm re-plan the same motion
twenty times a second, which reads as stepping. Do not restore the every-tick re-send.
A change must never be made to wait for the repeat.
- **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, 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.
- **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
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 add a robot driver or a gamepad driver here.** See above. In particular: no
`pygame`, no `evdev`, and no environment variable naming a device. The web server is
the one deliberate exception and is scoped to the Control console; anything the console
cannot ask the arm to do does not belong on it.
- **Nothing on the server thread may block the control loop.** `server.py` meets the loop
at two non-blocking points only — the queue the tick drains and the snapshot handed
back. A browser that stops reading must never stall the thread that stops the arm.
## What this node cannot do
The lease binds only what goes through this node. `dora-ulite6` has no concept of an owner,
so another node publishing `request` will preempt an operator mid-motion and nothing here
can prevent it. Say so rather than implying otherwise.
`jog` has no feedback channel at all — a malformed one is logged and dropped by the arm
node, with no response and no error output. What this node sends, it learns nothing back
about except through `robot_state` and telemetry.
## Two manifest rules worth knowing
`dora-node.yml` is the typed contract a consumer wires against, and the only one they get:
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.
`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, 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`.
- **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
- **Issue tracker:** Gitea. Use the Gitea MCP tools (`mcp__gitea__*`) to read and manage
issues, pull requests, and other repository operations.
## Code style
- Write docstrings in NumPy style (summary line, then `Parameters`, `Returns`, `Raises`
sections as needed).
- Run `ruff check .` and `ruff format .` before committing. CI enforces both.
## Rules
- Do not mention AI tools in commit messages or documentation.