From 5ce83ae9455be80d4d77c4cde44d9c0425870246 Mon Sep 17 00:00:00 2001 From: Cristhian Date: Wed, 16 Sep 2026 18:06:12 -0300 Subject: [PATCH] docs: simplify the README and record the pad layout 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 --- AGENTS.md | 31 ++- README.md | 525 +++++++++++++++++++------------------------------- dora-node.yml | 6 + 3 files changed, 231 insertions(+), 331 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c0ca8d0..31d8340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,16 +46,19 @@ uv sync --group dev # plus pytest and ruff ```bash 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: +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 `dataflow-gamepad.yml`. +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 # 112 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed uv run ruff check . uv run ruff format --check . ``` @@ -74,6 +77,14 @@ a source goes away**. - **`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. @@ -90,7 +101,12 @@ a source goes away**. 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. + 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 @@ -170,6 +186,13 @@ belongs here, and a port nothing wires reads as supported while having never run 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. diff --git a/README.md b/README.md index 0f829c5..1d82855 100644 --- a/README.md +++ b/README.md @@ -4,430 +4,301 @@ 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, reads the pad from -[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and **serves the browser console -itself** on one port. It contains 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. +`jog` and `request` ports, reads the pad from +[`dora-joy`](https://gitea.liria.cl/liria/dora-joy), and serves the browser console itself +on one port. It contains no robot driver and no gamepad driver: it is the piece in the +middle that decides what the arm should be doing, and it touches no hardware at all. + +**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. ## 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. +| 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 | Release the E-STOP and clear the arm's error | — | +| Y | Toggle freedrive | — | +| Back | **E-STOP** — latches until X | — | -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 console, whose ring sectors ask for one direction at -the speed the dial is set to. The console can still hold two at once — a sector and a Z -pill — but it cannot ask for a third of full speed on one axis and full on another. +Up on the stick sends the arm away from you, along **+X** in the base frame — the same +convention the console uses, so the two agree. -| 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 | Release the E-STOP and clear the arm's error | — | -| Y | Toggle freedrive | — | -| 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. - -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. 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. +**Back latches.** It stops the arm, tells the controller to stop, and keeps the arm still +no matter what the sticks do. **X is the only way out**, and it does both halves: releases +the latch here and clears the error on the controller. Neither button is gated on the +lease — 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. `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. +behind a deliberate click. -**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. +### The layout + +These are the indices an Xbox One S pad reports **over Bluetooth**, swept one control at a +time. They are not universal — the same pad on USB numbers its buttons differently — so a +pad that disagrees is an edit to `Layout` in `dora_teleop/mapping.py`, not a new +environment variable. It lives here rather than in `dora-joy` because `dora-joy` does not +know what a button is *for*, which is what makes it reusable. + +| Control | Axis | Control | Button | +|----------------------|------|------------------|--------| +| Left stick X / Y | 0, 1 | A — grasp | 0 | +| Right stick X / Y | 2, 3 | B — release | 1 | +| Left / right trigger | 4, 5 | X — clear | 3 | +| | | Y — freedrive | 4 | +| | | LB / RB — yaw | 6, 7 | +| | | Back — E-STOP | 11 | + +**Sweep a new pad before the arm is powered.** The axes check themselves and the buttons +cannot: + +- **Axes.** Every pad is measured once, on the first reading in which it is sitting still. + A stick must rest near centre and a trigger near the bottom of its travel; an axis + resting anywhere else means the numbering is wrong, something is being held, or the pad + is broken. All three read as **no pad** until it rests, and the log names the axis. A + trigger read as a stick asks for full speed while lying untouched on the desk, so a pad + this node has never seen still never gets the arm. +- **Buttons.** An unpressed button reads the same on every pad, so a wrong button index is + silent until it fires. That is not hypothetical: on this pad, against the indices this + node shipped with, X toggled freedrive and LB was the E-STOP. + +That same measurement makes a worn stick harmless — its resting tilt becomes its zero, so +the tilt is **subtracted** rather than deadzoned away and the travel left still reaches +full speed. A trigger that reads 0 until it first moves, rather than SDL's −1, is handled +the same way instead of reading as half pressed. ### Checking your pad ```bash -dora up -dora start dataflow-gamepad.yml --uv --name teleop-bench +source .venv/bin/activate +dora build dataflow-gamepad.yml +dora run dataflow-gamepad.yml uv run dora-teleop-probe # in another terminal ``` -Prints the raw axes, the buttons it sees, and the velocity this node would send — live, -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. +The probe prints the raw axes, the buttons it sees and the velocity this node would send, +live, 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. ```text axes (6 reported) 0 left stick X +0.600 ----------|-----#---- 1 left stick Y -0.800 --#-------|---------- - 2 left trigger -1.000 #---------|---------- - ... - buttons (6 reported) - 5:yaw right + 4 left trigger -1.000 #---------|---------- + buttons (17 reported) + 7:yaw right velocity this would send to the arm vx +38.89 mm/s forward / back <-- vy +23.53 mm/s left / right <-- - vz +20.39 mm/s up / down <-- 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 -observed ones, and a pad that numbers its axes differently is much better found here than -by watching an arm move the wrong way. Two things to look for: +Two things to look for: an axis marked `unused` that moves when you push a stick, and a +bar that does not sit centred with the stick released. It goes through the same +`velocity()` the node runs, so what it prints is what the arm would be sent. -- **Every axis is listed, including ones the layout does not use** — an axis marked - `unused` that moves when you push a stick means the layout needs changing. -- **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 `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. +For a pad whose numbering is not known at all, `dora-joy-probe` in the `dora-joy` repo +needs no dataflow. ## 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. +Both control sources are reduced to a **normalised** six-vector — `[-1, 1]` per degree of +freedom, a fraction of full speed — and only then scaled, in one function. The gamepad +reaches that vector through the deadzone and the response curve, which correct a worn +physical stick; the browser sends it already clean. -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. +That is what lets one node and one console drive a robot in mm/s and one in m/s. `twist` +is SI, because that 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`, not `grasp` — so swapping a vacuum for +a two-finger gripper needs no change at either end. -`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 full contract, including the reserved `joint_jog` port, is in the vault at +`resources/robotics/teleop-contract.md`. ## 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. + bench does not lock the browser out. +- The **browser takes it explicitly** with TAKE CONTROL and returns it with RELEASE. It + can preempt: an operator who can see the arm is better placed than this code to decide. +- **E-STOP is never gated**, from either source, held lease or not. -**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 lease binds only what goes through this node.** `dora-ulite6` has no idea it exists, +so another node publishing `request` will preempt an operator mid-motion. ## 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. Four -layers, outermost last: +An arm under a velocity command keeps moving until told otherwise. Four layers, each +shorter than the one behind it — a backstop that fires first backs up nothing: -1. **The repeat** (`JOG_KEEPALIVE_S`, 0.2 s) while a source is driving. -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. +| Layer | Setting | Default | +|--------------------------|-------------------------------------|---------| +| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s | +| Pad reading goes stale | `JOY_TTL_S` (teleop) | 0.2 s | +| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s | +| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s | -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. +`JOY_TTL_S >= JOG_TTL_S` stops the node at startup rather than failing quietly later. The +last layer is enforced by `dora-ulite6` whether or not this process is alive. -On shutdown the node sends one all-zero velocity before exiting. A motion lives in the -controller, not in this process. +A pad reporting itself gone, or the last browser disconnecting, stops the arm +**immediately** without waiting out any timer: an unplugged pad is not a centred pad. On +shutdown the node sends one all-zero velocity, because a motion lives in the controller +rather than 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. - -### What goes on the wire is the change - -A velocity is a set-point the arm holds, not a frame in a sequence. This node sends one -when it **changes** — a new direction, or the stop — and otherwise repeats the current one -only every `JOG_KEEPALIVE_S` (0.2 s), which exists solely so the arm's own expiry never -fires mid-motion. - -It used to repeat every tick. On a real xArm that means the controller is handed the same -velocity twenty times a second and re-plans the same motion each time, which an operator -feels as the arm moving in steps rather than travelling. Keep the repeat well under the -arm's `VELOCITY_COMMAND_TTL_S` and no faster than it needs to be. - -**A change never waits for the repeat.** Releasing a button is acted on the tick it -happens, so sending less does not cost anything at the operator's end. +**What goes on the wire is the change.** A velocity is a set-point the arm holds, so one +is sent when it *changes* and otherwise repeated only every `JOG_KEEPALIVE_S`. Repeating +every tick made a real xArm re-plan the same motion twenty times a second, which reads as +stepping. The stop is sent **once** — a stream of zeroes keeps `dora-ulite6` believing a +jog is live. A change never waits for the repeat. ## Ports -| Direction | Name | Description | -|-----------|----------------|------------------------------------------------------| -| in | `tick` | 50 ms — change detection, deadman, telemetry | -| in | `joy` | One pad reading from `dora-joy` | -| 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 | `twist` | **Standard.** `{linear, angular, frame}`, SI | +| Direction | Name | Description | +|-----------|----------------|-------------------------------------------------------| +| in | `tick` | 50 ms — change detection, deadman, telemetry | +| in | `joy` | One pad reading from `dora-joy` | +| 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 | `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 | `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 — see below | +| 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 +Wire `twist` **or** `jog`, never both — they carry the same intent, and a robot reading both is driven twice. Same for `command` and `request`. -The browser is not on this table. It reaches the node over the node's own HTTP and -WebSocket port rather than through the dataflow, so there is nothing to wire for it — -`ui_state` is published for anyone else watching and has no consumer in the stacks below. +The browser is not on this table: it reaches the node over the node's own HTTP and +WebSocket port rather than through the dataflow, so there is nothing to wire for it. -## Environment Variables +## 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 | -| `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 | +| 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 | +| `JOG_TTL_S` | `0.4` | Owner silence before the arm is stopped | +| `JOG_KEEPALIVE_S` | `0.2` | How often an unchanged velocity is repeated | +| `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 | +| `WEB_HOST` | `0.0.0.0`| Address the console binds to | +| `WEB_PORT` | `7000` | Console port; **`0` opens no port at all** | -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. +A value that cannot be read, or one that breaks the deadman order, 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. -`JOYSTICK_INDEX` moved to `dora-joy` along with the pad. +## Running it -## Usage - -The real stack is three nodes: this one, the pad and the arm. The console comes with -this node. - -```yaml - - id: teleop - git: ssh://git@gitea.liria.cl:5001/liria/dora-teleop.git - tag: v0.3.0 - build: pip install . - path: dora-teleop - inputs: - tick: dora/timer/millis/50 - joy: - source: joy/joy - queue_size: 1 - queue_policy: drop_oldest - 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: - WEB_PORT: "7000" -``` - -Wire `ulite6`'s `request` and `jog` from this node, and 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. - -Then open `http://:7000` for the console. Nothing else needs -wiring: the node serves the page and holds the browsers' sockets, and the page finds the -socket on its own origin. - -Leave `joy` unwired for a browser-only console; the node starts without it. Set -`WEB_PORT: "0"` for a pad-only one, and no port is opened. - -**Requirements:** dora 1.0+ (verified with `dora-cli 1.0.1`), Python 3.11+ and `uv`. No -hardware: the pad and the arm are other nodes, and the browser is any browser. - -### 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. +| Dataflow | Nodes | Needs | +|-------------------------|------------------------------|-----------------| +| `dataflow.yml` | teleop + bench stand-in | nothing | +| `dataflow-robot.yml` | joy + teleop + ulite6 | a pad and an arm | +| `dataflow-gamepad.yml` | the same, plus the probe, console shut | a pad and an arm | ```bash -source .venv/bin/activate # see the note below before reaching for --uv -dora build dataflow-robot.yml # clones and installs both, needs the network +dora run dataflow.yml --uv # no pad, no arm, browser path only + +source .venv/bin/activate # a real arm: see the --uv note below +dora build dataflow-robot.yml # clones and installs both siblings dora run dataflow-robot.yml ``` -**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1). `dora build --uv` -installs each `git:` node into a venv inside its own clone directory, while `dora run ---uv` resolves the entrypoint by running `uv run which ` from the *dataflow's* -directory — which only ever sees the dataflow's own venv. The node is installed where the -spawner does not look, and the run fails with `Could not find source path dora-joy within -uv`. Building into one activated environment, as above, puts every node's entrypoint -somewhere the spawner finds it. The venv needs `pip` for that (`uv pip install pip`), -since `uv` does not create one. +`dataflow.yml` is self-contained on purpose — it resolves from this checkout alone, which +is what lets CI validate it. The bench answers services and publishes telemetry but is +**not a simulator**: it never integrates a velocity or moves a pose. -`dataflow.yml` is unaffected and `--uv` is fine there: its only Python node is this repo, -which is already in this repo's venv. +Set `ROBOT_IP` and `TOOL_TYPE` to match the arm in front of you. `INIT_MODE` is `none`, so +nothing moves until somebody pushes a stick. The console comes with the node — open +`http://:7000` for the same arm the pad drives, under the same lease. -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. +**`--uv` does not work for a dataflow with `git:` nodes** (dora 1.0.1): each is installed +into a venv inside its own clone, while the spawner resolves entrypoints from the +dataflow's venv, and the run fails with `Could not find source path dora-joy within uv`. +Build into one activated environment instead, as above; it needs `pip` +(`uv pip install pip`). **`dora start` refuses them outright** — "node joy has git source, +but no `dora build` was run yet", straight after a build that succeeded — so use +`dora run`, which also means `dora topic` is unavailable for those two dataflows. -The 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 | -| Held velocity repeated | `JOG_KEEPALIVE_S` (teleop) | 0.2 s | -| Owner goes quiet | `JOG_TTL_S` (teleop) | 0.4 s | -| Jog sender goes quiet | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 s | - -The last one is enforced by `dora-ulite6` itself from v0.3.1 on, not by the controller: -the velocity is set once and held, so that node stops the arm rather than letting a -controller-side timer do it. - -`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.1 annotates, which is also what pinning that tag asserts — re-check it -when moving to a newer one. - -The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening -`http://:7000` gets you the same arm the pad drives, under the same lease. - -### 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, -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 # 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: - -```text -jog 1 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] -jog 2 [ +38.89, +23.53, +0.00, +0.00, +0.00, +0.00] -jog 3 [ +0.00, +0.00, +0.00, +0.00, +0.00, +0.00] <- stop -``` - -It is **not a simulator**: it never integrates the velocity or moves the pose, and a jog -is printed and forgotten. It closes the loop so this node's behaviour can be watched — -the lease changing hands, the deadman firing when you let go — not so the arm can be -pretended at. +`dora` type-checks a dataflow before fetching a `git:` source, so it cannot read the arm's +manifest. The `output_types:` block on the `ulite6` node repeats what `dora-ulite6` v0.3.1 +annotates — re-check it when moving to a newer tag. ### Watching the wire -`dataflow.yml` sets `debug: enable_debug_inspection: true`, so dora will let you read what -is actually on the wire without the bench printing anything. Run it under a coordinator -rather than `dora run`: +Every dataflow sets `debug: enable_debug_inspection: true`, so dora will let you read the +wire without any node printing it. This needs a coordinator, so it works for the +arm-free dataflow: ```bash dora up -dora start dataflow-gamepad.yml --uv --name teleop-bench -``` - -Then, in another terminal: - -```bash +dora start dataflow.yml --uv --name teleop-bench 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 +dora stop --name teleop-bench && dora down ``` -`hz` is the one that checks the timings this node promises. The `get_state` poll measures -at 1.88 Hz against a configured `STATE_POLL_S` of 0.5, and with a pad held over the -deadzone `teleop/jog` should sit near the 50 ms tick. - -Tear it down with `dora stop --name teleop-bench && dora down`. - ## The console -`dora_teleop/web/index.html` is the browser half: two analog sticks, a lift lane and a -yaw lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry -readout. Plain HTML with no build step — the node serves the file as it stands, and a -console you cannot fix without a toolchain is the wrong thing to have between a person and -a robot. +`dora_teleop/web/index.html` is the browser half: two analog rings, a lift lane and a yaw +lane, plus gripper, freedrive, home, the lease buttons and E-STOP over a telemetry +readout. Plain HTML with no build step — a console you cannot fix without a toolchain is +the wrong thing to have between a person and a robot. -**Laid out like the arm's own pendant.** Each ring is quartered into arc sectors you hold, -with the out-of-plane axis as a pair of pills above it — the same shape as xArm Studio's -jog control on the controller at `:18333`, and deliberately the same directions, because -two consoles driving one arm that disagree about which way is `+Y` is a trap rather than a -preference. +It is laid out like the arm's own pendant: each ring is quartered into sectors you hold, +with the out-of-plane axis as a pair of pills above it — the same shape and the same +directions as xArm Studio's jog control at `:18333`. -| Ring | Sectors | Pills above | -|-----------|------------------------------------------------|----------------| -| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` | -| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` | +| Ring | Sectors | Pills above | +|-----------|------------------------------------------------------------|---------------| +| translate | `+X` north, `−X` south, `Y+` **west**, `Y−` east | `−Z` `+Z` | +| rotate | `+pitch` north, `−pitch` south, `−roll` west, `+roll` east | `−yaw` `+yaw` | **`Y+` is on the left**, matching xArm Studio and the right-hand rule with `+X` away from -the operator and `+Z` up. It is the one direction that does not read as "screen right is -right", and it is that way on purpose. +the operator and `+Z` up. Two consoles that disagree about which way is `+Y` is a trap. -Directions are held, not analog: speed comes from the dial, and every held direction is a -separate pointer, so two fingers work at once — driving `+X` while lifting on `+Z` is one -gesture. +The node serves the page and holds the WebSocket on `/ws` of the same port, so there is +one URL and nothing to keep in step. That runs on its own thread and meets the control +loop at two non-blocking points only, because a browser that stops reading must never +stall the thread that stops the arm. **The browser's deadman is the socket itself** — the +last one closing releases the lease and stops the arm. -The node serves it on `WEB_PORT` (7000) and holds the WebSocket on `/ws` of that same -port, so the console is one URL. That work happens on a thread of its own and meets the -control loop at two points only — the messages the next tick drains, and the snapshot -handed back — because a browser that stops reading must never be able to stall the thread -that stops the arm. - -**The browser's deadman is the socket itself.** The last one closing releases the lease -and stops the arm, which is why the tab closing mid-jog does not leave it travelling. - -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 dial sets that fraction. Every control is dimmed and inert unless the -console holds the lease, so one that would be refused looks refused. - -If `WEB_PORT` is already taken the node **refuses to start** and says so, rather than -carrying on with a console nobody can open. - -`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. +Directions are held, not analog: speed comes from the dial, and each held direction is a +separate pointer, so two fingers work at once. The page re-sends its jog every 150 ms +against the node's 400 ms deadline, and sends a **fraction of full deflection**, never a +speed, so it never learns the robot's units. Controls are dimmed and inert unless the +console holds the lease. If `WEB_PORT` is taken the node **refuses to start**. ## Development ```bash uv sync --group dev -uv run pytest # 112 tests, no gamepad, arm, browser or daemon needed +uv run pytest # 149 tests, no gamepad, arm, browser or daemon needed uv run ruff check . uv run ruff format --check . ``` -Every file here is testable without hardware, because there is no hardware. The pad -arrives on `joy` as a plain `Sample`. +Every file here is testable without hardware, because there is no hardware. `tests/` names +its controls (`axes_for`, `buttons_for`) rather than numbering them — a test that numbers +its axes goes on passing while meaning something else once a pad renumbers them. diff --git a/dora-node.yml b/dora-node.yml index 55c6ac9..9977de6 100644 --- a/dora-node.yml +++ b/dora-node.yml @@ -53,6 +53,12 @@ inputs: pad node is indistinguishable from an unplugged pad as far as the arm is concerned. + Each pad is measured the first time it is seen sitting still, so a stick + that no longer centres has its tilt subtracted rather than deadzoned away. + A pad whose resting axes do not fit the layout this node compiles in — a + trigger where a stick should be, say — reads the same as no pad at all + and is named in the log, rather than being guessed at against a real arm. + Leave it unwired for a browser-only console. The node says so and starts. response: required: false