Files
dora-teleop/AGENTS.md
T
cristhian ebd3b3e493
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m41s
docs: describe the standard ports and the console
2026-09-16 12:19:05 -05:00

12 KiB

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 nothing else, and it touches no hardware at all. The arm is dora-ulite6, reached through that node's ordinary jog and request ports. The browser is carried by dora-websocket-transceiver. The gamepad is 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, tasks, chat, ROI, settings and health belong to other nodes; porting them here would rebuild the monolith inside a node.

Build

uv sync              # runtime deps
uv sync --group dev  # plus pytest and ruff

Run

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 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

uv run pytest      # 112 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. 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 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.
  • examples/bench.py — A stand-in for the arm and the browser, 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

  • 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.
  • 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

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.