diff --git a/AGENTS.md b/AGENTS.md index 7bec0de..879df1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,12 +7,21 @@ This file provides guidance to AI coding assistants when working with code in th 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 +It is **the piece in the middle and nothing else**, 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 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 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 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, @@ -29,16 +38,17 @@ uv sync --group dev # plus pytest and ruff ## Run ```bash -dora run dataflow.yml --uv +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` runs the whole stack — this node, the bridge and the arm — from checkouts -expected next to this one. +`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 ```bash -uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed uv run ruff check . uv run ruff format --check . ``` @@ -54,14 +64,26 @@ a source goes away**. 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/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 @@ -74,7 +96,8 @@ a source goes away**. 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. + 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` @@ -82,23 +105,59 @@ a source goes away**. 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`: shows the raw axes, the buttons and the - velocity that would be sent, with no dataflow and no arm. It exists because the indices - in `mapping.py` are SDL's documented layout rather than an observed one, and a pad that +- **`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 `Gamepad` and `velocity()` the node runs, so what it prints is what the arm - would get — keep it that way, or it stops being evidence. + 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.** 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. +- **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 @@ -116,22 +175,19 @@ about except through `robot_state` and telemetry. 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 +`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, and several Arrow schemas legitimately flow over each. Annotating them would -claim a contract neither side keeps. +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`. -- **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. +- **The `joy` message** (`dora-joy`): the Arrow struct + `{axes: list, buttons: list, 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 diff --git a/README.md b/README.md index 380f338..ef1816b 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,19 @@ A dora-rs node that hands a robot arm to a human. Pick up a gamepad and move the 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 +ordinary `jog` and `request` ports, reads the pad from +[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), 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. +It contains no web server, no robot driver and no gamepad 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. It touches no hardware at all. ## The gamepad +The pad itself is [`dora-joy`](https://gitea.liria.cl/liria/dora-joy), which publishes raw +axes and buttons and knows nothing about arms. Everything below — the deadzone, the +response curve, which button is which, and what any of it means in mm/s — happens here. + 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. @@ -23,9 +29,9 @@ for one axis at a time at one fixed speed. | Bumpers | Yaw, left and right | deg/s | | A | Close the tool | — | | B | Open the tool | — | -| X | Clear a controller error | — | +| X | Release the E-STOP and clear the arm's error | — | | Y | Toggle freedrive | — | -| Back | **E-STOP** | — | +| Back | **E-STOP** — latches until X | — | 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. @@ -33,20 +39,30 @@ 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. +somebody can read. The layout lives here rather than in `dora-joy` for the same reason — +`dora-joy` does not know what a button is for, which is what makes it reusable. `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. +**Back latches.** It stops the arm, tells the controller to stop, and then keeps the arm +still no matter what the sticks do — a stop somebody had to reach for is not undone by the +next thumb movement. X is the only way back, and it does both halves: releases the latch +here and clears the error on the controller. Neither button is gated on the lease, because +an idle pad hands the arm back two seconds after the stop it just made, and a latch you +must first take the arm back to release is a trap. + ### Checking your pad ```bash -uv run dora-teleop-probe +dora up +dora start dataflow-gamepad.yml --uv --name teleop-bench +uv run dora-teleop-probe # in another terminal ``` Prints the raw axes, the buttons it sees, and the velocity this node would send — live, -with no dataflow and no arm. **It cannot move anything.** There is no dora node and no -connection to the robot; it only reads. +from the same `joy` stream the node reads. **It cannot move anything.** It attaches as a +dynamic node, publishes nothing and never talks to the arm; it only reads. ```text axes (6 reported) @@ -64,6 +80,7 @@ connection to the robot; it only reads. wz +30.00 deg/s yaw <-- moving: yes + asking for the arm: yes ``` Run this before trusting the table above. The indices are SDL's documented layout, not @@ -75,8 +92,33 @@ by watching an arm move the wrong way. Two things to look for: - **A bar that does not sit centred with the stick released** is a worn pad announcing itself, and a reason to raise `DEADZONE`. -It goes through the same `Gamepad` and `velocity()` the node runs, so what it prints is -what the arm would be sent. +It goes through the same `velocity()` the node runs, on the same readings, so what it +prints is what the arm would be sent. + +For a pad whose layout is not known at all — which index is even which — use +`dora-joy-probe` from the `dora-joy` repo, which needs no dataflow and no coordinator. + +## Speaking to any robot + +Nothing above is specific to one arm. Both control sources are reduced to a **normalised** +six-vector — `[-1, 1]` per degree of freedom, meaning a fraction of full speed — and only +then scaled, in one function. A gamepad reaches that vector through the deadzone and the +response curve, which exist to correct a worn physical stick; the browser sends it already +clean, because a browser has no worn centre. + +That is what lets the same node and the same console drive a robot in mm/s and one in m/s: +neither source ever knew the difference. It is also why a gamepad and the console asking +for the same deflection produce byte-identical output. + +`twist` is SI — m/s and rad/s — because that is unambiguous and is what a UR or MoveIt +takes. Configuration stays in mm/s and deg/s, because `60 mm/s` is a number a person +reading a dataflow can judge. + +`command` carries verbs rather than service names: `gripper_close` rather than `grasp`, +so a robot that swaps a vacuum for a two-finger gripper needs no change at either end. + +The full contract, including the reserved `joint_jog` port for joint-space robots, is in +the vault at `resources/robotics/teleop-contract.md`. ## The lease @@ -99,18 +141,23 @@ 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 +never whether to start but whether anything has happened that means it must stop. Four 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 +2. **The pad's freshness** (`JOY_TTL_S`, 0.2 s). The pad is another process now, and that + process dying looks like silence rather than like an unplugged pad — so silence means + the same thing. `dora-joy` publishes on every tick whether or not the pad moved, which + is what makes silence unambiguous. +3. **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 reports + itself gone or the last browser disconnects. An unplugged pad is not a centred pad. +4. **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. +Keep them in that order, each shorter than the one behind it. A backstop that fires before +the thing it is backing up backs up nothing. `JOY_TTL_S >= JOG_TTL_S` stops the node at +startup rather than letting that happen quietly. On shutdown the node sends one all-zero velocity before exiting. A motion lives in the controller, not in this process. @@ -123,6 +170,7 @@ believing a jog is live and hold `robot_state` at `moving` forever. | Direction | Name | Description | |-----------|----------------|------------------------------------------------------| | in | `tick` | 50 ms — re-send, deadman, telemetry | +| in | `joy` | One pad reading from `dora-joy` | | 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 | @@ -130,10 +178,15 @@ believing a jog is live and hold `robot_state` at `moving` forever. | 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 | `twist` | **Standard.** `{linear, angular, frame}`, SI | +| out | `command` | **Standard.** `{action, value}`, robot-agnostic verbs | +| out | `jog` | *Deprecated.* mm/s + deg/s, for `dora-ulite6` | +| out | `request` | *Deprecated.* A service call, `request_id` in metadata | | out | `ui_state` | The console's telemetry snapshot, as JSON | +Wire `twist` **or** `jog`, never both: they carry the same intent, and a robot reading +both is driven twice. Same for `command` and `request`. + ## Environment Variables | Variable | Default | Description | @@ -142,17 +195,20 @@ believing a jog is live and hold `robot_state` at `moving` forever. | `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 | +| `JOY_TTL_S` | `0.2` | `joy` silence before the pad reads as gone | | `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. +A value that cannot be read, a `DEADZONE` outside the stick, an `EXPO` off the curve or a +`JOY_TTL_S` at or above `JOG_TTL_S` stops the node at startup rather than the first time +somebody picks up the pad. + +`JOYSTICK_INDEX` moved to `dora-joy` along with the pad. ## Usage -The real stack is three nodes: this one, the WebSocket bridge and the arm. +The real stack is four nodes: this one, the pad, the WebSocket bridge and the arm. ```yaml - id: teleop @@ -162,6 +218,10 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm. path: dora-teleop inputs: tick: dora/timer/millis/50 + joy: + source: joy/joy + queue_size: 1 + queue_policy: drop_oldest ui_command: ws-transceiver/data_out clients: ws-transceiver/clients response: ulite6/response @@ -173,20 +233,63 @@ The real stack is three nodes: this one, the WebSocket bridge and the arm. ``` Point `ws-transceiver`'s `data_in` at `teleop/ui_state` and its `STATIC_DIR` at this -repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node. +repo's `web/`, and wire `ulite6`'s `request` and `jog` from this node. Add `dora-joy` on a +20 ms tick for the pad — `queue_size: 1` matters, because it ticks faster than this node +does and an arm should be driven by the newest reading or by none. -**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. +Leave `joy` unwired for a browser-only console; the node starts without it. + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No +hardware: the pad, the arm and the browser are all other nodes. + +### Driving a real arm + +`dataflow-robot.yml` is the one that moves a robot: the pad, this node and +[`dora-ulite6`](https://gitea.liria.cl/liria/dora-ulite6), with no bench and no browser. +Both sibling nodes come in by `git:`, so it runs from a fresh clone with nothing else +checked out. + +```bash +dora build dataflow-robot.yml --uv # clones and installs both, needs the network +dora run dataflow-robot.yml --uv +``` + +Set `ROBOT_IP` and `TOOL_TYPE` in that file to match the arm in front of you; the defaults +are `dora-ulite6`'s own, `192.168.1.192` and a vacuum tool. `INIT_MODE` is `none`, so +nothing moves until somebody pushes a stick. + +The four deadman timings have to stay ordered, each below the one behind it, and the file +pins all of them so the chain is visible in one place: + +| Layer | Setting | Default | +|---|---|---| +| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s | +| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s | +| Controller's own expiry | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s | + +`dora` type-checks a dataflow before it fetches a `git:` source, so it cannot read the +arm's manifest from here. The `output_types:` block on the `ulite6` node repeats what +`dora-ulite6` v0.3.0 annotates, which is also what pinning that tag asserts — re-check it +when moving to a newer one. + +Adding the web console means adding `dora-websocket-transceiver` and wiring `ui_command`, +`clients` and its `data_in`; the four-node stack under **Usage** above has that. ### Running it with no arm and no browser `dataflow.yml` in this repo is **not** that stack. It is this node plus a bench stand-in, -so you can drive the real thing from a real gamepad with no robot in the room: +with **no pad**: pulling `dora-joy` in by `git:` would cost that file the one property it +has, which is that it resolves and runs from this checkout alone, with no network and no +sibling clones. ```bash -dora run dataflow.yml --uv +dora run dataflow.yml --uv # bench only, browser path +dora run dataflow-gamepad.yml --uv # the same bench with a real pad ``` +`dataflow-gamepad.yml` adds `dora-joy` by `git:` and declares the probe. It needs the +network on its first build. + The bench answers every service, publishes plausible telemetry, and prints each velocity it is sent: @@ -209,13 +312,14 @@ rather than `dora run`: ```bash dora up -dora start dataflow.yml --uv --name teleop-bench +dora start dataflow-gamepad.yml --uv --name teleop-bench ``` Then, in another terminal: ```bash dora topic list -d teleop-bench # every edge in the graph +dora topic echo -d teleop-bench joy/joy # what the pad is reporting dora topic echo -d teleop-bench teleop/jog # every velocity, live dora topic echo -d teleop-bench teleop/ui_state # what the console would draw dora topic hz -d teleop-bench teleop/jog --duration 10 # the re-send cadence @@ -227,14 +331,31 @@ deadzone `teleop/jog` should sit near the 50 ms tick. Tear it down with `dora stop --name teleop-bench && dora down`. +## The console + +`web/index.html` is the browser half: a jog pad for translation and rotation, gripper, +freedrive, home, the lease buttons and E-STOP, over a telemetry readout. Plain HTML with +no build step — the bridge serves the directory as it stands, and a console you cannot +fix without a toolchain is the wrong thing to have between a person and a robot. + +Hold a direction to move, release to stop. The page re-sends its jog every 150 ms against +the node's 400 ms deadline, so letting go stops the robot even if the release is lost. It +sends a **fraction of full deflection**, never a speed, so it never learns the robot's +units; the speed slider scales that fraction. The jog pad is disabled unless the console +holds the lease, so a button that would be refused looks refused. + +`tests/test_console.py` checks the page against `ui.py` — every message type it sends is +one the node serves, and every telemetry key it reads is one the node sends. Neither +language catches a rename, and the wrong place to discover one is on a machine that moves. + ## Development ```bash uv sync --group dev -uv run pytest # 76 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 112 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`. +Every file here is testable without hardware, because there is no hardware. The pad +arrives on `joy` as a plain `Sample`.