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>
252 lines
15 KiB
Markdown
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.
|