diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..60d4846 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -0,0 +1,58 @@ +# A pull request would otherwise run this twice on the same commit: once for the +# branch push and once for the PR. + +name: validate + +on: + push: + branches: [main] + pull_request: + +env: + DORA_VERSION: v1.0.1 + DORA_TARGET: x86_64-unknown-linux-gnu + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the dora CLI + run: | + curl -fsSL -o dora-cli.tar.gz \ + "https://github.com/dora-rs/dora/releases/download/${DORA_VERSION}/dora-cli-${DORA_TARGET}.tar.gz" + tar xzf dora-cli.tar.gz + mkdir -p "$HOME/.local/bin" + install -m 755 "dora-cli-${DORA_TARGET}/dora" "$HOME/.local/bin/dora" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + rm -rf dora-cli.tar.gz "dora-cli-${DORA_TARGET}" + + - run: dora --version + + # strict_types: true in the dataflow turns a type warning into a non-zero + # exit, so a mismatched annotation fails here rather than at runtime. + - name: Validate the dataflow + run: dora validate dataflow.yml + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + # --locked fails if uv.lock is out of date with pyproject.toml, so a + # dependency change that was never locked cannot reach main. + - run: uv sync --locked --group dev + - run: uv run ruff check . + - run: uv run ruff format --check . + - run: uv run pytest -q + + markdownlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DavidAnson/markdownlint-cli2-action@v16 + with: + globs: "*.md" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b81bad --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,138 @@ +# 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`](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 +[`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 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 + +```bash +uv sync # runtime deps +uv sync --group dev # plus pytest and ruff +``` + +## Run + +```bash +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 + +```bash +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. diff --git a/README.md b/README.md index 9116a3a..e05b9bd 100644 --- a/README.md +++ b/README.md @@ -1 +1,152 @@ # dora-teleop + +A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the arm, or +open a browser and move it from there — one of the two at a time, decided here. + +It drives [`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6) through that node's +ordinary `jog` and `request` ports, and talks to the browser through +[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver). +It contains no web server and no robot driver: it is the piece in the middle that decides +what the arm should be doing, and that decision is the only thing it owns. + +## The gamepad + +Analog and blended — several axes at once, and how fast depends on how far you push. That +is the whole reason a pad beats the web console's four-sector dial pad, which can only ask +for one axis at a time at one fixed speed. + +| Control | Does | Units | +|-------------|-------------------------------------------|-------| +| Left stick | Move along X (up/down) and Y (left/right) | mm/s | +| Triggers | Lift on the right, lower on the left | mm/s | +| Right stick | Roll (left/right) and pitch (up/down) | deg/s | +| Bumpers | Yaw, left and right | deg/s | +| A | Close the tool | — | +| B | Open the tool | — | +| X | Clear a controller error | — | +| Y | Toggle freedrive | — | +| Back | **E-STOP** | — | + +Up on the stick sends the arm away from you, along **+X** in the robot's base frame — +the same convention the web console's dial uses, so the two agree. + +Indices are SDL's, which is what most Xbox- and PlayStation-style pads report. A pad that +disagrees is a change to `Layout` in `dora_teleop/mapping.py` rather than a new +environment variable: an index nobody can verify from outside is worse than a line of code +somebody can read. + +`home` is deliberately **not** on the pad. It is a large unattended motion and belongs +behind a deliberate click, not a thumb resting on a face button. + +## The lease + +Two sources, one arm. Whoever holds the lease drives; the other is ignored. + +- The **gamepad takes it implicitly**, the first time a stick moves, if nobody holds it. + Nobody should have to reach for a browser to use the thing in their hands. +- It **gives it back** after `GAMEPAD_IDLE_RELEASE_S` untouched, so a pad left on the + bench does not lock the browser out forever. +- The **browser takes it explicitly**, with the console's TAKE CONTROL button, and gives + it back with RELEASE. It can preempt a held arm, because an operator who can see the arm + is better placed than this code to decide they should have it. +- **E-STOP is never gated.** From either source, held lease or not. Somebody reaching for + the stop is the one moment when asking who owns the arm is the wrong question. + +**The lease binds only what goes through this node.** `dora-ulite6` has no idea it exists. +Another node publishing `request` will preempt an operator mid-motion and nothing here can +stop it — if that matters for your dataflow, do not wire one. + +## The deadman + +An arm under a velocity command keeps moving until told otherwise, so the question is +never whether to start but whether anything has happened that means it must stop. Three +layers, outermost last: + +1. **Re-send every tick** (50 ms) while a source is driving. +2. **This node's watchdog** (`JOG_TTL_S`, 0.4 s) stops the arm when the owner goes quiet — + and stops it *immediately*, without waiting out the timer, when the gamepad is unplugged + or the last browser disconnects. An unplugged pad is not a centred pad. +3. **The arm's own expiry** (`VELOCITY_COMMAND_TTL_S` in `dora-ulite6`, 0.5 s), enforced by + the controller whether or not this process is alive. + +Keep them in that order. If this node's timer is longer than the controller's, the backstop +fires before the thing it is backing up. + +On shutdown the node sends one all-zero velocity before exiting. A motion lives in the +controller, not in this process. + +The stop is sent **once**, not every tick. A stream of zeroes would keep `dora-ulite6` +believing a jog is live and hold `robot_state` at `moving` forever. + +## Ports + +| Direction | Name | Description | +|-----------|----------------|------------------------------------------------------| +| in | `tick` | 50 ms — re-send, deadman, telemetry | +| in | `ui_command` | One console message, from the bridge's `data_out` | +| in | `clients` | Connect/disconnect from the bridge — the web deadman | +| in | `response` | An answer from the arm | +| in | `motion_state` | How a motion ended | +| in | `robot_state` | The arm's own state | +| in | `tcp_pose` | Tool-tip pose, six float32, mm and degrees | +| in | `joint_pose` | Joint angles, six float32, degrees | +| out | `jog` | `{velocity: [vx, vy, vz, wx, wy, wz]}`, mm/s + deg/s | +| out | `request` | A service call, `request_id` in the metadata | +| out | `ui_state` | The console's telemetry snapshot, as JSON | + +## Environment Variables + +| Variable | Default | Description | +|--------------------------|---------|--------------------------------------------------------| +| `SPEED_MM_S` | `60.0` | Translation speed at full deflection | +| `SPEED_DEG_S` | `30.0` | Rotation speed at full deflection | +| `DEADZONE` | `0.12` | Stick travel near centre to ignore | +| `EXPO` | `0.4` | How much of the curve is cubic; 0 is linear | +| `JOYSTICK_INDEX` | `0` | Which pad to open | +| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped | +| `GAMEPAD_IDLE_RELEASE_S` | `2.0` | Untouched pad before it hands the arm back | +| `STATE_POLL_S` | `0.5` | How often to ask the arm what telemetry does not carry | + +A value that cannot be read, a `DEADZONE` outside the stick or an `EXPO` off the curve +stops the node at startup rather than the first time somebody picks up the pad. + +## Usage + +See `dataflow.yml` for the whole stack wired together. The short version: + +```yaml + - id: teleop + git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git + tag: v0.1.0 + build: pip install . + path: dora-teleop + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: [jog, request, ui_state] +``` + +```bash +dora run dataflow.yml --uv +``` + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+, `uv`, and a +gamepad if you want one — the node starts without and says so. + +## Development + +```bash +uv sync --group dev +uv run pytest # 66 tests, no gamepad, arm, browser or daemon needed +uv run ruff check . +uv run ruff format --check . +``` + +`dora_teleop/gamepad.py` is the only file that touches pygame, and the only one that +cannot be tested without hardware. Everything else sees a plain `Sample`. diff --git a/dataflow.yml b/dataflow.yml new file mode 100644 index 0000000..ad55fe8 --- /dev/null +++ b/dataflow.yml @@ -0,0 +1,65 @@ +# The whole operator stack, and what CI validates. +# +# Built from checkouts rather than tags, so `dora validate` resolves each node's +# manifest from its `path:` and checks the wiring against the versions in front +# of you. The sibling repos are expected next to this one, which is how they are +# cloned in practice. +# +# Three nodes, one loop: the bridge carries the browser, this node decides who +# drives, and the arm node moves the arm and reports back. + +strict_types: true + +nodes: + - id: teleop + path: dora-teleop + build: pip install . + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: + - jog + - request + - ui_state + env: + SPEED_MM_S: "60" + SPEED_DEG_S: "30" + + - id: ws-transceiver + path: ../dora-websocket-transceiver + build: pip install . + inputs: + data_in: teleop/ui_state + outputs: + - data_out + - clients + env: + WS_PORT: "9000" + STATIC_DIR: "../dora-teleop/web" + HTTP_PORT: "9010" + + - id: ulite6 + path: ../dora-ulite6 + build: pip install . + inputs: + tick: dora/timer/millis/100 + request: teleop/request + jog: teleop/jog + outputs: + - response + - motion_state + - joint_pose + - tcp_pose + - robot_state + env: + ROBOT_IP: "192.168.1.192" + TOOL_TYPE: "vacuum" + # Nothing moves because the node started. An operator asks for the first + # motion, which is the whole point of a teleop stack. + INIT_MODE: "none" diff --git a/dora-node.yml b/dora-node.yml new file mode 100644 index 0000000..bfef78b --- /dev/null +++ b/dora-node.yml @@ -0,0 +1,172 @@ +# Node manifest: the typed contract a consumer wires against. +# +# No source package is shared between node repositories, so this file and its +# type URNs are the only contract a consumer has. `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 two `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 — one per service, one per console message. +# Annotating them would claim a contract neither side keeps. + +apiVersion: 1 +name: dora-teleop +namespace: liria +description: Hands a robot arm to a human, from a gamepad or a browser +categories: [robot, teleop, input] +keywords: [teleop, gamepad, joystick, jog, arm, manipulator, web-console, operator] + +runtime: python +entrypoint: dora-teleop +# `dora build --uv` / `dora run --uv` rewrites this to `uv pip install .` and +# gives the node its own managed venv under `.dora/python-envs/`, so the +# dependencies it sees at runtime are the ones installed here. +build: pip install . +dora: ">=1.0" + +inputs: + tick: + description: >- + Drives everything time-based: re-sending the held velocity, expiring a + source that has gone quiet, and publishing the console's telemetry. Use 50 + ms. Slower than about 100 ms and the arm node's own velocity expiry starts + firing between ticks, which an operator feels as the arm stuttering. + type: std/core/v1/UInt64 + ui_command: + required: false + description: >- + One message from the web console, as the JSON string the WebSocket bridge + publishes on `data_out`. Wire it from the bridge. A message this node does + not recognise is dropped rather than refused, so a page from a newer build + cannot take the arm down. + clients: + required: false + description: >- + Connect and disconnect events from the WebSocket bridge, as + `{connected, event}`. **This is the browser's deadman.** Without it a tab + closing mid-jog leaves the arm travelling until the arm node's own expiry + catches up, because a socket that went away sends nothing to say so. + response: + required: false + description: >- + An answer from the arm node, correlated by `request_id` in the metadata. A + failure is passed to the console as a toast; a `get_state` answer fills in + what telemetry does not carry. + motion_state: + required: false + description: >- + How a motion ended. Needed because this node sends motions with + `wait: false`, and a non-blocking motion reports its outcome here and + nowhere else. + robot_state: + required: false + description: >- + The arm's own state, one of `connecting`, `ready`, `moving`, `recovering` + or `unavailable`. Translated into the vocabulary the console draws. + type: std/core/v1/String + tcp_pose: + required: false + description: >- + Tool-tip pose, six float32 as `[x, y, z, roll, pitch, yaw]` in mm and + degrees, base frame. Shown in the console's pose readout. + type: std/core/v1/Float32 + joint_pose: + required: false + description: >- + Joint angles, six float32 in degrees. Shown in the console's joint readout. + type: std/core/v1/Float32 + +outputs: + jog: + description: >- + A base-frame velocity for the arm, as `{velocity: [vx, vy, vz, wx, wy, wz]}` + in mm/s and deg/s. Re-sent every tick while a source is driving, and + followed by exactly one all-zero vector when it stops — once, not + repeatedly, because a stream of zeroes would keep the arm node believing a + jog is live. + request: + description: >- + A service call on the arm — `stop`, `grasp`, `release`, `clear`, + `freedrive`, `home`, `move_to`, `get_state` — with `request_id` in the + metadata. + ui_state: + description: >- + The console's telemetry snapshot, as a JSON string for the WebSocket + bridge to broadcast. Also carries error toasts. Every key in it is read by + name in the console's `app.js`. + +env: + SPEED_MM_S: + type: float + default: 60.0 + description: >- + Translation speed at full stick deflection, in mm/s. A stick is analog, so + this is a maximum and anything less moves proportionally slower. Defaults + to 60.0. + SPEED_DEG_S: + type: float + default: 30.0 + description: Rotation speed at full deflection, in deg/s. Defaults to 30.0. + DEADZONE: + type: float + default: 0.12 + description: >- + Stick travel near centre to ignore, as a fraction. A worn stick that does + not centre would otherwise drive the arm on its own. What is left is + rescaled, so motion still starts from zero just outside the band. + Defaults to 0.12. + EXPO: + type: float + default: 0.4 + description: >- + How much of the response curve is cubic, from 0 (linear) to 1. Fine + positioning happens near centre and a linear stick gives it almost no + resolution there. Defaults to 0.4. + JOYSTICK_INDEX: + type: int + default: 0 + description: Which pad to open, when more than one is plugged in. Defaults to 0. + JOG_TTL_S: + type: float + default: 0.4 + description: >- + How long the owning source may go quiet before the arm is stopped. Keep it + **below** the arm node's VELOCITY_COMMAND_TTL_S (0.5 s by default), which + is the controller-side backstop behind this one. Defaults to 0.4. + GAMEPAD_IDLE_RELEASE_S: + type: float + default: 2.0 + description: >- + How long a pad may sit untouched before it hands the arm back, so a + browser can take over without anyone pressing anything. Defaults to 2.0. + STATE_POLL_S: + type: float + default: 0.5 + description: >- + How often to call `get_state` for what telemetry does not carry: whether + the tool is engaged and whether freedrive is on. Defaults to 0.5. + +example: | + - id: teleop + git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git + tag: v0.1.0 + build: pip install . + path: dora-teleop + inputs: + tick: dora/timer/millis/50 + ui_command: ws-transceiver/data_out + clients: ws-transceiver/clients + response: ulite6/response + motion_state: ulite6/motion_state + robot_state: ulite6/robot_state + tcp_pose: ulite6/tcp_pose + joint_pose: ulite6/joint_pose + outputs: + - jog + - request + - ui_state + env: + SPEED_MM_S: "60" + SPEED_DEG_S: "30" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ad6a905 --- /dev/null +++ b/web/index.html @@ -0,0 +1,85 @@ + + + + + +teleop — placeholder console + + + +

teleop — placeholder console

+

+ Not the real console. This page 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. +

+ +
+ +
+ + + + +
+ + + + + +