serve the web console from the node and drop the websocket bridge
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s

The browser reached this node through `dora-websocket-transceiver`: a second
node, a second port, and a round trip through the dataflow for every button
press, to carry a UI only this node can interpret. The node now serves the
page and holds the browsers' sockets itself on one port, so the console is
one URL and a consumer wires nothing for it.

`dora_teleop/server.py` runs aiohttp on a thread of its own and meets the
control loop at two non-blocking points: a bounded queue the tick drains, and
a snapshot handed back. Both queues are bounded deliberately — a flood is
dropped rather than grown, and a slow browser loses stale snapshots rather
than the newest — because nothing a browser does may stall the thread that
stops the arm. A port already in use stops the node rather than leaving it
announcing a console nobody can open.

The web deadman moved but did not change: it was a `clients` message from the
bridge and is now the open-socket count read each tick. One hop fewer, same
meaning.

Contract, so 0.2.0 -> 0.3.0: `ui_command` and `clients` are gone. `ui_state`
stays published for `dora topic echo` and the bench, with no browser reading
it. New WEB_HOST and WEB_PORT (7000; 0 opens no port).

The console itself is laid out like the arm's own pendant — two rings
quartered into arc sectors you hold, each with its out-of-plane axis as pills
above. Directions and signs match xArm Studio on the controller, `Y+` on the
left included: two consoles driving one arm that disagree about which way is
+Y is a trap, not a preference.

Also sends a velocity only when it changes, repeating an unchanged one on
JOG_KEEPALIVE_S (0.2 s) purely to beat the consumer's own expiry. Repeating
every tick made `dora-ulite6` re-plan the same motion twenty times a second.
That turned out not to be the whole cause of the stepping an operator felt —
the rest was fixed in dora-ulite6 v0.3.1, pinned here — but sending 18
messages where 83 went before is worth keeping on its own.

`dora-joy` moves to v0.1.1: v0.1 is an annotated tag, and dora 1.0.1 records
the tag object's id as the commit hash and then rejects its own checkout for
sitting on the peeled commit. Neither dataflow could be built before this.
This commit is contained in:
2026-09-16 14:50:21 -03:00
parent ffbba0f4d1
commit da1b079b7b
22 changed files with 1984 additions and 454 deletions
+38 -15
View File
@@ -7,16 +7,22 @@ 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**, 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).
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.
It is **the piece in the middle**, 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 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 either here: 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.
**The web console is the exception, and it is deliberate.** This node serves the page and
holds the browsers' sockets itself (`dora_teleop/server.py`), rather than going through
`dora-websocket-transceiver` as it did up to v0.2.0. A bridge node for one page meant a
second node, a second port and a round trip through the dataflow for every button press,
to carry a UI that only this node can interpret anyway. That is not the monolith
`dora-ulite6` was rewritten to escape — the monolith was one process owning the robot
driver, perception and the UI at once. What is forbidden is the hardware, and that has not
moved.
**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.
@@ -26,7 +32,8 @@ number.
The web console it serves is ported from `Littlehand/monolitic-littlehand-python`
(`littlehand/console/static/`), and **only its Control section**. Perception, calibration,
tasks, chat, ROI, settings and health belong to other nodes; porting them here would
rebuild the monolith inside a node.
rebuild the monolith inside a node. That boundary is the one that matters, and the
built-in server does not move it: the console can ask for motion and nothing else.
## Build
@@ -86,7 +93,7 @@ a source goes away**.
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
lease because the console is already built on it — it gates its controls 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
@@ -100,7 +107,13 @@ a source goes away**.
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`
- **`dora_teleop/server.py`** — The console's HTTP and WebSocket server, on a thread of
its own. Serves `dora_teleop/web/index.html` at `/` and holds the browsers at `/ws`, on
one port so the page finds its socket on its own origin. Both queues are bounded: a
flood of messages is dropped rather than grown, and a slow browser loses stale snapshots
rather than the newest one. **The browser's deadman lives here** — `clients` counts open
sockets, and the tick reads it.
- **`examples/bench.py`** — A stand-in for the arm, so `dataflow.yml`
is self-contained and CI can validate it with no sibling clones. **It is not a
simulator** and must not become one: it never integrates a velocity or moves a pose. Its
job is to close the loop so this node can be watched, not to stand in for
@@ -138,6 +151,11 @@ belongs here, and a port nothing wires reads as supported while having never run
## Rules that are not negotiable
- **Send the change, not the state.** A velocity is a set-point the arm holds. `_drive`
sends when it changes and otherwise repeats on `JOG_KEEPALIVE_S`, which exists only to
beat the arm's own expiry. Repeating every tick made a real xArm re-plan the same motion
twenty times a second, which reads as stepping. Do not restore the every-tick re-send.
A change must never be made to wait for the repeat.
- **The stop is sent once, not every tick.** A stream of zero velocities keeps
`dora-ulite6` believing a jog is live and holds `robot_state` at `moving` forever.
- **E-STOP is never gated on the lease, and neither is its release.** From either
@@ -156,8 +174,13 @@ belongs here, and a port nothing wires reads as supported while having never run
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.
- **Do not add a robot driver or a gamepad driver here.** See above. In particular: no
`pygame`, no `evdev`, and no environment variable naming a device. The web server is
the one deliberate exception and is scoped to the Control console; anything the console
cannot ask the arm to do does not belong on it.
- **Nothing on the server thread may block the control loop.** `server.py` meets the loop
at two non-blocking points only — the queue the tick drains and the snapshot handed
back. A browser that stops reading must never stall the thread that stops the arm.
## What this node cannot do