serve the web console from the node and drop the websocket bridge
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:
@@ -5,11 +5,10 @@ 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 talks to the browser through
|
||||
[`dora-websocket-transceiver`](https://gitea.liria.cl/liria/dora-websocket-transceiver).
|
||||
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.
|
||||
[`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.
|
||||
|
||||
## The gamepad
|
||||
|
||||
@@ -18,8 +17,9 @@ axes and buttons and knows nothing about arms. Everything below — the deadzone
|
||||
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.
|
||||
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.
|
||||
|
||||
| Control | Does | Units |
|
||||
|-------------|-------------------------------------------|-------|
|
||||
@@ -144,7 +144,7 @@ An arm under a velocity command keeps moving until told otherwise, so the questi
|
||||
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.
|
||||
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
|
||||
@@ -165,14 +165,27 @@ 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Ports
|
||||
|
||||
| Direction | Name | Description |
|
||||
|-----------|----------------|------------------------------------------------------|
|
||||
| in | `tick` | 50 ms — re-send, deadman, telemetry |
|
||||
| in | `tick` | 50 ms — change detection, 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 |
|
||||
| in | `motion_state` | How a motion ended |
|
||||
| in | `robot_state` | The arm's own state |
|
||||
@@ -182,11 +195,15 @@ believing a jog is live and hold `robot_state` at `moving` forever.
|
||||
| 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 |
|
||||
| out | `ui_state` | The console's telemetry snapshot, as JSON — see below |
|
||||
|
||||
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.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -208,12 +225,13 @@ somebody picks up the pad.
|
||||
|
||||
## Usage
|
||||
|
||||
The real stack is four nodes: this one, the pad, the WebSocket bridge and the arm.
|
||||
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.1.0
|
||||
tag: v0.3.0
|
||||
build: pip install .
|
||||
path: dora-teleop
|
||||
inputs:
|
||||
@@ -222,25 +240,29 @@ The real stack is four nodes: this one, the pad, the WebSocket bridge and the ar
|
||||
source: joy/joy
|
||||
queue_size: 1
|
||||
queue_policy: drop_oldest
|
||||
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:
|
||||
WEB_PORT: "7000"
|
||||
```
|
||||
|
||||
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. 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.
|
||||
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.
|
||||
|
||||
Leave `joy` unwired for a browser-only console; the node starts without it.
|
||||
Then open `http://<the machine running teleop>: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, the arm and the browser are all other nodes.
|
||||
hardware: the pad and the arm are other nodes, and the browser is any browser.
|
||||
|
||||
### Driving a real arm
|
||||
|
||||
@@ -250,30 +272,48 @@ Both sibling nodes come in by `git:`, so it runs from a fresh clone with nothing
|
||||
checked out.
|
||||
|
||||
```bash
|
||||
dora build dataflow-robot.yml --uv # clones and installs both, needs the network
|
||||
dora run dataflow-robot.yml --uv
|
||||
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-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 <name>` 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 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` 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:
|
||||
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 |
|
||||
| Controller's own expiry | `VELOCITY_COMMAND_TTL_S` (ulite6) | 0.5 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.0 annotates, which is also what pinning that tag asserts — re-check it
|
||||
`dora-ulite6` v0.3.1 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.
|
||||
The console is already there — `dataflow-robot.yml` sets `WEB_PORT` to 7000, so opening
|
||||
`http://<that machine>:7000` gets you the same arm the pad drives, under the same lease.
|
||||
|
||||
### Running it with no arm and no browser
|
||||
|
||||
@@ -333,16 +373,48 @@ 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.
|
||||
`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.
|
||||
|
||||
**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.
|
||||
|
||||
| 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.
|
||||
|
||||
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 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 slider scales that fraction. The jog pad is disabled unless the console
|
||||
holds the lease, so a button that would be refused looks refused.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user