Files
dora-teleop/AGENTS.md
T
cristhian 7250396a1f
validate / validate (pull_request) Failing after 58m48s
validate / test (pull_request) Successful in 58m48s
validate / markdownlint (pull_request) Successful in 58m43s
add the node manifest, dataflow, CI and docs
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. The jog,
request and ui ports stay untyped, because dora treats a port carrying
request_id as pattern-correlated and several Arrow schemas legitimately flow
over each — one per service, one per console message.

dataflow.yml runs the whole operator stack from checkouts: this node, the
WebSocket bridge and the arm.

web/ holds a placeholder page, not the real console. It exists so the dataflow
has something to serve and so the node can be checked end to end before the
Control section is ported across from the monolithic repo. It reads telemetry
and can take, release and stop; nothing else.
2026-09-15 15:43:12 -05:00

6.7 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. It contains no robot driver — the arm is dora-ulite6, reached through that node's ordinary jog and request ports. It contains no web server — the browser is carried by 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 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

dataflow.yml runs the whole stack — this node, the bridge and the arm — from checkouts expected next to this one.

Test

uv run pytest      # 66 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/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/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.
  • dora_teleop/log.py — Timestamped stderr logging. stderr because dora reads a dynamic node's stdout.

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.

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.

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.

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.

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.