Two changes that turn this from the Lite6's teleop node into one that any of our robots
can be driven by, from a gamepad or a browser.
The pad moves out. It is now dora-joyv0.1,
arriving on a joy input. This node keeps no hardware dependency at all — pygame-ce is
gone — and the pad becomes reusable by anything else in a dataflow.
The output becomes a standard.twist (SI) and command (robot-agnostic verbs)
replace jog and request, which stay published and deprecated so this repository and dora-ulite6 can move independently. The contract is written up in the vault at resources/robotics/teleop-contract.md, which is where it lives because no source package
is shared between node repositories.
The console can now actually drive. It sent three of the nine commands the node
serves, so a browser could take the lease and stop the arm but not move it.
#3 — remove the deprecated ports, once the above lands
#4 — joint_jog for joint-space robots, deliberately deferred
Changes
Five commits, in order:
read the gamepad from dora-joy and publish twist and command — the node itself,
its manifest, the dataflows and their tests.
add the console's jog pad, gripper and freedrive controls — the page and a test
that checks it against ui.py both ways.
validate the gamepad and robot dataflows in CI
docs: describe the standard ports and the console
update version to 0.2.0 for the new output contract
The one thing worth reading closely
Both control sources are now normalised to a [-1, 1] six-vector — a fraction of full
speed — before anything is scaled, and the scaling happens in exactly one function.
The web path previously did state.web_velocity = command.velocity, passing a velocity
straight through without going near mapping. That is why the console had to speak mm/s,
and why nothing here could be robot-agnostic whatever the ports were called. A test pins
the property this buys: a gamepad and the console asking for the same deflection now
produce byte-identical output.
twist is SI (m/s, rad/s) because that is unambiguous and 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.
Three bugs found while moving this code
Each has a test, and each was invisible until the code moved:
Sample.active reported every connected pad as in use, for ever. It compared raw
axes against a threshold, but SDL rests a trigger at -1.0. So the pad took the lease
the instant it was plugged in and GAMEPAD_IDLE_RELEASE_S never fired — a pad on the
bench locked the browser out permanently.
The E-STOP did not stop.view.estop was set but never consulted when deciding a
velocity, so Back halted the arm for one tick and the next stick nudge drove it again.
Nothing on the pad could clear it either — only the console's RESET, which a pad-only
dataflow has not got. The latch is now checked above the lease, and X releases it and
clears the controller's error, as RESET does. Neither button is gated on the lease: an
idle pad hands the arm back two seconds after the stop it just made.
The first button press after the pad had been idle was swallowed. Buttons were read
before the arbiter granted the lease, so the press arrived while nobody owned the arm,
and by the next tick it was held rather than newly down — the edge was gone for good.
Also
A fourth deadman layer, 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. Config.from_env refuses layers that are out of order.
dora_teleop/message.py declares every published schema. pyarrow reads a null value
as its own type, so an inferred command would change its port's type between one verb
and the next.
dataflow-robot.yml: pad → teleop → dora-ulite6, both siblings by git:. Its output_types: block exists because dora type-checks before it fetches a git: source
and so cannot read the arm's manifest.
dora-teleop-probe is now a dynamic node reading live joy, still through the real velocity().
joint_jog is specified in dora-node.yml and not implemented — see #4.
Validation
130 tests pass (from 76), none needing a gamepad, arm, browser or daemon
ruff check and ruff format --check clean
CI green on this branch
All three dataflows validate; dora build dataflow-robot.yml resolves both pinned
tags and installs them. --uv no longer applies — see "What changed since" below
dora_teleop imports no pygame
Every message this node emits parses with dora-ulite6's own parsers — the jog
velocity, all eight services, and request_id correlation
Gamepad path, bench-verified with a synthetic pad built from dora_joy.message.pack: twist on the wire as {"linear":[0.06,0.0,0.0],...,"frame":"base"}, command as stop then clear
Console path, now against the node's own server (the transceiver is gone): the
page served over HTTP, and over the socket — take control → owner web; hold +X
→ jogs at +60.00 mm/s; release → one stop; gripper → grasp; socket closed → one
stop and owner none. Telemetry carries gripper and units: {position: mm, rotation: deg} — no vacuum
Every console direction, driven as real pointer presses through the Chrome
DevTools Protocol: all nine sectors and pills produce the right axis and sign, and
reach the bench end to end
Deadman, at runtime: killing the joy node mid-jog stops the arm 0.211 s later,
once; the e-stop latch held a fully deflected stick for 0.85 s with nothing reaching
the arm; the lease returns 2.0 s after the stick centres
Verified against a real Lite6 at 192.168.1.162 — jogging, gripper, E-STOP and
RESET, through the console
The console opened in an actual browser, on the machine and on a tablet
Verified against a physical gamepad (no pad on the machine this was built on)
Two things to know before merging
This does not break dora-ulite6. Both the standard and the deprecated ports publish
the same intent, and I checked every message against that node's own parsers. Version is 0.2.0 to say the contract moved; liria/dora-ulite6#3 and #3 carry the rest.
The split and the standardisation are not separable in this branch. They were built
one on top of the other before either was committed, so main.py and mapping.py carry
both. I would rather say so than reconstruct an intermediate state I never actually ran.
The one unticked box needs a gamepad, which the machine this was built on has not got.
Everything else has been run, the arm included.
What changed since the review above
The branch grew three things after the first round. The commit is serve the web console from the node and drop the websocket bridge.
The bridge is gone
dora-websocket-transceiver was a second node, a second port and a round trip through the
dataflow for every button press, carrying a UI only this node can interpret. The node now
serves the page and holds the sockets itself on one port.
dora_teleop/server.py runs aiohttp on its own thread and meets the control loop at two
non-blocking points only — a bounded queue the tick drains, and a snapshot handed back —
because nothing a browser does may stall the thread that stops the arm. Both queues are
bounded on purpose: a flood is dropped rather than grown, and a slow client loses stale
snapshots rather than the newest.
Contract, so 0.2.0 → 0.3.0:ui_command and clients are gone. ui_state stays
published — no browser reads it now, but dora topic echo and the bench do. New WEB_HOST and WEB_PORT (7000; 0 opens no port, which is what dataflow-gamepad.yml
wants).
The web deadman moved without changing meaning: it was a clients message from the
bridge, and is now the open-socket count read each tick. One hop fewer.
The console 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 — the shape of xArm Studio's jog control on the controller at :18333.
Ring
Sectors
Pills
translate
+X N · −X S · Y+W · Y− E
−Z+Z
rotate
+pitch N · −pitch S · −roll W · +roll E
−yaw+yaw
Y+ is on the left, matching xArm Studio and the right-hand rule. It is the one
direction that does not read as "screen right is right", and it is deliberate: two
consoles driving one arm that disagree about which way is +Y is a trap.
An earlier revision of this used analog sticks — drag distance as speed. It was dropped
after testing on the arm: Z as a drag lane reads wrong, and the rings match the pendant an
operator already knows.
Sending the change rather than the state
_drive now sends a velocity when it changes, repeating an unchanged one on JOG_KEEPALIVE_S (0.2 s) only to beat the consumer's own expiry. A release is a change and
never waits for the repeat. Measured on the wire: 83 messages → 18 for the same
four-second hold.
This began as a fix for an arm that moved in steps rather than travelling. It was not the
cause — cutting the rate 4.6× changed nothing on the arm, and that negative result is
what pointed at dora-ulite6 re-arming a timed velocity segment per message. Fixed there
and released as v0.3.1, which this branch now pins. The reduction is kept on its own
merits.
Two bugs found while building this, each with a test
The console server failed silently. A port already in use killed the server thread, start() sat out its full 10 s timeout and then logged console on http://… anyway —
node running, nothing serving, and the block backed up the input queue. It now raises
and the node refuses to start. Found by accident when two test runs collided.
Knob travel was a third of intended in the analog revision: a percentage inside a CSS translate is a fraction of the knob, not of the circle it crosses. Moot now the sticks
are gone, but it is why the rings are positioned in user units.
One thing a reviewer should know
dora run --uv cannot spawn this dataflow (dora 1.0.1). dora build --uv installs each git: node into a venv inside its own clone, while the spawner resolves the entrypoint
with uv run which <name> from the dataflow's directory — so the node is installed where
the spawner never looks. README.md and dataflow-robot.yml now document building into a
single activated environment instead. dataflow.yml is unaffected and --uv is fine there.
Unrelated to this branch, but it is why dora-joy moves to v0.1.1: v0.1 is an
annotated tag, and dora records the tag object's id as the commit hash and then rejects its
own checkout for sitting on the peeled commit. Neither git: dataflow could be built at
all before that.
# Summary
Two changes that turn this from the Lite6's teleop node into one that any of our robots
can be driven by, from a gamepad or a browser.
**The pad moves out.** It is now [`dora-joy`](https://gitea.liria.cl/liria/dora-joy) `v0.1`,
arriving on a `joy` input. This node keeps no hardware dependency at all — `pygame-ce` is
gone — and the pad becomes reusable by anything else in a dataflow.
**The output becomes a standard.** `twist` (SI) and `command` (robot-agnostic verbs)
replace `jog` and `request`, which stay published and deprecated so this repository and
`dora-ulite6` can move independently. The contract is written up in the vault at
`resources/robotics/teleop-contract.md`, which is where it lives because no source package
is shared between node repositories.
**The console can now actually drive.** It sent three of the nine commands the node
serves, so a browser could take the lease and stop the arm but not move it.
## Related Issues
Follow-ups, not closed by this PR:
- liria/dora-ulite6#3 — the arm node accepts `twist` and `command`
- #3 — remove the deprecated ports, once the above lands
- #4 — `joint_jog` for joint-space robots, deliberately deferred
## Changes
Five commits, in order:
1. **`read the gamepad from dora-joy and publish twist and command`** — the node itself,
its manifest, the dataflows and their tests.
2. **`add the console's jog pad, gripper and freedrive controls`** — the page and a test
that checks it against `ui.py` both ways.
3. **`validate the gamepad and robot dataflows in CI`**
4. **`docs: describe the standard ports and the console`**
5. **`update version to 0.2.0 for the new output contract`**
### The one thing worth reading closely
Both control sources are now normalised to a `[-1, 1]` six-vector — a fraction of full
speed — **before** anything is scaled, and the scaling happens in exactly one function.
The web path previously did `state.web_velocity = command.velocity`, passing a velocity
straight through without going near `mapping`. That is why the console had to speak mm/s,
and why nothing here could be robot-agnostic whatever the ports were called. A test pins
the property this buys: a gamepad and the console asking for the same deflection now
produce byte-identical output.
`twist` is SI (m/s, rad/s) because that is unambiguous and 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.
### Three bugs found while moving this code
Each has a test, and each was invisible until the code moved:
- **`Sample.active` reported every connected pad as in use, for ever.** It compared raw
axes against a threshold, but SDL rests a trigger at **-1.0**. So the pad took the lease
the instant it was plugged in and `GAMEPAD_IDLE_RELEASE_S` never fired — a pad on the
bench locked the browser out permanently.
- **The E-STOP did not stop.** `view.estop` was set but never consulted when deciding a
velocity, so Back halted the arm for one tick and the next stick nudge drove it again.
Nothing on the pad could clear it either — only the console's RESET, which a pad-only
dataflow has not got. The latch is now checked above the lease, and X releases it and
clears the controller's error, as RESET does. Neither button is gated on the lease: an
idle pad hands the arm back two seconds after the stop it just made.
- **The first button press after the pad had been idle was swallowed.** Buttons were read
before the arbiter granted the lease, so the press arrived while nobody owned the arm,
and by the next tick it was held rather than newly down — the edge was gone for good.
### Also
- A fourth deadman layer, `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. `Config.from_env` refuses layers that are out of order.
- `dora_teleop/message.py` declares every published schema. pyarrow reads a null `value`
as its own type, so an inferred `command` would change its port's type between one verb
and the next.
- `dataflow-robot.yml`: pad → teleop → `dora-ulite6`, both siblings by `git:`. Its
`output_types:` block exists because dora type-checks before it fetches a `git:` source
and so cannot read the arm's manifest.
- `dora-teleop-probe` is now a dynamic node reading live `joy`, still through the real
`velocity()`.
- `joint_jog` is specified in `dora-node.yml` and **not implemented** — see #4.
## Validation
- [x] 130 tests pass (from 76), none needing a gamepad, arm, browser or daemon
- [x] `ruff check` and `ruff format --check` clean
- [x] CI green on this branch
- [x] All three dataflows validate; `dora build dataflow-robot.yml` resolves both pinned
tags and installs them. **`--uv` no longer applies** — see "What changed since" below
- [x] `dora_teleop` imports no pygame
- [x] Every message this node emits parses with `dora-ulite6`'s own parsers — the jog
velocity, all eight services, and `request_id` correlation
- [x] **Gamepad path**, bench-verified with a synthetic pad built from
`dora_joy.message.pack`: `twist` on the wire as
`{"linear":[0.06,0.0,0.0],...,"frame":"base"}`, `command` as `stop` then `clear`
- [x] **Console path**, now against the node's own server (the transceiver is gone): the
page served over HTTP, and over the socket — take control → `owner web`; hold `+X`
→ jogs at `+60.00 mm/s`; release → one stop; gripper → `grasp`; socket closed → one
stop and `owner none`. Telemetry carries `gripper` and
`units: {position: mm, rotation: deg}` — no `vacuum`
- [x] **Every console direction**, driven as real pointer presses through the Chrome
DevTools Protocol: all nine sectors and pills produce the right axis and sign, and
reach the bench end to end
- [x] **Deadman**, at runtime: killing the joy node mid-jog stops the arm 0.211 s later,
once; the e-stop latch held a fully deflected stick for 0.85 s with nothing reaching
the arm; the lease returns 2.0 s after the stick centres
- [x] Verified against a **real Lite6** at 192.168.1.162 — jogging, gripper, E-STOP and
RESET, through the console
- [x] The console opened in an actual browser, on the machine and on a tablet
- [ ] Verified against a physical gamepad (no pad on the machine this was built on)
## Two things to know before merging
**This does not break `dora-ulite6`.** Both the standard and the deprecated ports publish
the same intent, and I checked every message against that node's own parsers. Version is
`0.2.0` to say the contract moved; liria/dora-ulite6#3 and #3 carry the rest.
**The split and the standardisation are not separable in this branch.** They were built
one on top of the other before either was committed, so `main.py` and `mapping.py` carry
both. I would rather say so than reconstruct an intermediate state I never actually ran.
The one unticked box needs a gamepad, which the machine this was built on has not got.
Everything else has been run, the arm included.
---
## What changed since the review above
The branch grew three things after the first round. The commit is
`serve the web console from the node and drop the websocket bridge`.
### The bridge is gone
`dora-websocket-transceiver` was a second node, a second port and a round trip through the
dataflow for every button press, carrying a UI only this node can interpret. The node now
serves the page and holds the sockets itself on one port.
`dora_teleop/server.py` runs aiohttp on its own thread and meets the control loop at two
non-blocking points only — a bounded queue the tick drains, and a snapshot handed back —
because nothing a browser does may stall the thread that stops the arm. Both queues are
bounded on purpose: a flood is dropped rather than grown, and a slow client loses stale
snapshots rather than the newest.
**Contract, so `0.2.0` → `0.3.0`:** `ui_command` and `clients` are gone. `ui_state` stays
published — no browser reads it now, but `dora topic echo` and the bench do. New
`WEB_HOST` and `WEB_PORT` (**7000**; `0` opens no port, which is what `dataflow-gamepad.yml`
wants).
The web deadman moved without changing meaning: it was a `clients` message from the
bridge, and is now the open-socket count read each tick. One hop fewer.
### The console 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 — the shape of xArm Studio's jog control on the controller at `:18333`.
| Ring | Sectors | Pills |
|---|---|---|
| translate | `+X` N · `−X` S · `Y+` **W** · `Y−` E | `−Z` `+Z` |
| rotate | `+pitch` N · `−pitch` S · `−roll` W · `+roll` E | `−yaw` `+yaw` |
**`Y+` is on the left**, matching xArm Studio and the right-hand rule. It is the one
direction that does not read as "screen right is right", and it is deliberate: two
consoles driving one arm that disagree about which way is `+Y` is a trap.
An earlier revision of this used analog sticks — drag distance as speed. It was dropped
after testing on the arm: Z as a drag lane reads wrong, and the rings match the pendant an
operator already knows.
### Sending the change rather than the state
`_drive` now sends a velocity when it **changes**, repeating an unchanged one on
`JOG_KEEPALIVE_S` (0.2 s) only to beat the consumer's own expiry. A release is a change and
never waits for the repeat. Measured on the wire: **83 messages → 18** for the same
four-second hold.
This began as a fix for an arm that moved in steps rather than travelling. **It was not the
cause** — cutting the rate 4.6× changed nothing on the arm, and that negative result is
what pointed at `dora-ulite6` re-arming a timed velocity segment per message. Fixed there
and released as **v0.3.1**, which this branch now pins. The reduction is kept on its own
merits.
### Two bugs found while building this, each with a test
- **The console server failed silently.** A port already in use killed the server thread,
`start()` sat out its full 10 s timeout and then logged `console on http://…` anyway —
node running, nothing serving, and the block backed up the input queue. It now raises
and the node refuses to start. Found by accident when two test runs collided.
- **Knob travel was a third of intended** in the analog revision: a percentage inside a CSS
`translate` is a fraction of the knob, not of the circle it crosses. Moot now the sticks
are gone, but it is why the rings are positioned in user units.
### One thing a reviewer should know
**`dora run --uv` cannot spawn this dataflow** (dora 1.0.1). `dora build --uv` installs each
`git:` node into a venv inside its own clone, while the spawner resolves the entrypoint
with `uv run which <name>` from the *dataflow's* directory — so the node is installed where
the spawner never looks. `README.md` and `dataflow-robot.yml` now document building into a
single activated environment instead. `dataflow.yml` is unaffected and `--uv` is fine there.
Unrelated to this branch, but it is why `dora-joy` moves to **v0.1.1**: `v0.1` is an
annotated tag, and dora records the tag object's id as the commit hash and then rejects its
own checkout for sitting on the peeled commit. Neither `git:` dataflow could be built at
all before that.
The pad moves to dora-joy, so this node keeps no hardware dependency and the
pad becomes reusable. It arrives on `joy` as a plain reading; `JoyInput.read`
treats silence past JOY_TTL_S as absence, because that node dying looks like
silence rather than like an unplugged pad and the arm cannot tell them apart.
Both control sources are now normalised to a [-1, 1] six-vector before anything
is scaled, and the scaling happens in one function. The web path used to pass a
velocity straight through without going near mapping, which is why the console
had to speak the arm's units. It no longer does.
`twist` (SI) and `command` (robot-agnostic verbs) are the standard ports. `jog`
and `request` still carry the same intent in dora-ulite6's units and service
names so the two repositories can move independently; LEGACY_REQUEST is the
only place that knows what that node calls a verb. Wire one or the other, never
both. `joint_jog` is specified in the manifest and deliberately not built.
Schemas are declared rather than inferred: pyarrow reads a null `value` as its
own type, so an inferred `command` would change its port's type between one
verb and the next.
Three bugs found while moving this code, each with a test:
- `Sample.active` compared raw axes against a threshold, but SDL rests a
trigger at -1.0, so every connected pad read as in use for ever: the pad took
the lease on plug-in and GAMEPAD_IDLE_RELEASE_S never fired.
- The e-stop latch was never consulted when deciding a velocity, so Back
stopped the arm for one tick and the next stick nudge drove it again. Nothing
on the pad could clear it either. It is now checked above the lease, and X
releases it and clears the controller's error, as the console's RESET does.
- Buttons were read before the arbiter granted the lease, so the first press
after the pad had been idle was swallowed: not owned yet when read, and no
longer a rising edge by the next tick.
The page was a status readout: it sent three of the nine commands the node
serves, so the browser could take the lease and stop the arm but not move it.
It now holds a direction to move and releases to stop, with pads for
translation and rotation, gripper, freedrive, home and a speed slider. It sends
a fraction of full deflection rather than a speed, so it never learns the
robot's units — the same vector a stick produces.
Plain HTML and no build step: the bridge serves the directory as it stands, and
a console that cannot be fixed without a toolchain is the wrong thing to have
between a person and a robot.
Details that matter on a machine that moves: the pointer is captured so sliding
off a button still releases it; releasing sends jog_stop rather than relying on
the deadman; blur, tab-hide and a closed socket release everything; and the pad
is disabled unless the console holds the lease, so a button that would be
refused looks refused.
test_console.py checks the page against ui.py both ways — every message type it
sends is one the node serves, every telemetry key it reads is one the node
sends. Neither language catches a rename.
Both pull sibling nodes by `git:`, but dora resolves the wiring without
fetching them, so this stays offline. It is what catches a drift between the
robot dataflow's output_types block and the tag it pins.
Also ignore `git/`, where dora build leaves those clones.
`twist` and `command` are the ports a consumer should wire from now on, and
`jog` and `request` are deprecated. Nothing breaks yet — both still publish —
but the version should say the contract moved.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Two changes that turn this from the Lite6's teleop node into one that any of our robots
can be driven by, from a gamepad or a browser.
The pad moves out. It is now
dora-joyv0.1,arriving on a
joyinput. This node keeps no hardware dependency at all —pygame-ceisgone — and the pad becomes reusable by anything else in a dataflow.
The output becomes a standard.
twist(SI) andcommand(robot-agnostic verbs)replace
jogandrequest, which stay published and deprecated so this repository anddora-ulite6can move independently. The contract is written up in the vault atresources/robotics/teleop-contract.md, which is where it lives because no source packageis shared between node repositories.
The console can now actually drive. It sent three of the nine commands the node
serves, so a browser could take the lease and stop the arm but not move it.
Related Issues
Follow-ups, not closed by this PR:
twistandcommandjoint_jogfor joint-space robots, deliberately deferredChanges
Five commits, in order:
read the gamepad from dora-joy and publish twist and command— the node itself,its manifest, the dataflows and their tests.
add the console's jog pad, gripper and freedrive controls— the page and a testthat checks it against
ui.pyboth ways.validate the gamepad and robot dataflows in CIdocs: describe the standard ports and the consoleupdate version to 0.2.0 for the new output contractThe one thing worth reading closely
Both control sources are now normalised to a
[-1, 1]six-vector — a fraction of fullspeed — before anything is scaled, and the scaling happens in exactly one function.
The web path previously did
state.web_velocity = command.velocity, passing a velocitystraight through without going near
mapping. That is why the console had to speak mm/s,and why nothing here could be robot-agnostic whatever the ports were called. A test pins
the property this buys: a gamepad and the console asking for the same deflection now
produce byte-identical output.
twistis SI (m/s, rad/s) because that is unambiguous and what a UR or MoveIt takes.Configuration stays in mm/s and deg/s, because
60 mm/sis a number a person reading adataflow can judge.
Three bugs found while moving this code
Each has a test, and each was invisible until the code moved:
Sample.activereported every connected pad as in use, for ever. It compared rawaxes against a threshold, but SDL rests a trigger at -1.0. So the pad took the lease
the instant it was plugged in and
GAMEPAD_IDLE_RELEASE_Snever fired — a pad on thebench locked the browser out permanently.
view.estopwas set but never consulted when deciding avelocity, so Back halted the arm for one tick and the next stick nudge drove it again.
Nothing on the pad could clear it either — only the console's RESET, which a pad-only
dataflow has not got. The latch is now checked above the lease, and X releases it and
clears the controller's error, as RESET does. Neither button is gated on the lease: an
idle pad hands the arm back two seconds after the stop it just made.
before the arbiter granted the lease, so the press arrived while nobody owned the arm,
and by the next tick it was held rather than newly down — the edge was gone for good.
Also
JOY_TTL_S(0.2 s). The pad is another process now, and thatprocess dying looks like silence rather than like an unplugged pad, so silence means the
same thing.
Config.from_envrefuses layers that are out of order.dora_teleop/message.pydeclares every published schema. pyarrow reads a nullvalueas its own type, so an inferred
commandwould change its port's type between one verband the next.
dataflow-robot.yml: pad → teleop →dora-ulite6, both siblings bygit:. Itsoutput_types:block exists because dora type-checks before it fetches agit:sourceand so cannot read the arm's manifest.
dora-teleop-probeis now a dynamic node reading livejoy, still through the realvelocity().joint_jogis specified indora-node.ymland not implemented — see #4.Validation
ruff checkandruff format --checkcleandora build dataflow-robot.ymlresolves both pinnedtags and installs them.
--uvno longer applies — see "What changed since" belowdora_teleopimports no pygamedora-ulite6's own parsers — the jogvelocity, all eight services, and
request_idcorrelationdora_joy.message.pack:twiston the wire as{"linear":[0.06,0.0,0.0],...,"frame":"base"},commandasstopthenclearpage served over HTTP, and over the socket — take control →
owner web; hold+X→ jogs at
+60.00 mm/s; release → one stop; gripper →grasp; socket closed → onestop and
owner none. Telemetry carriesgripperandunits: {position: mm, rotation: deg}— novacuumDevTools Protocol: all nine sectors and pills produce the right axis and sign, and
reach the bench end to end
once; the e-stop latch held a fully deflected stick for 0.85 s with nothing reaching
the arm; the lease returns 2.0 s after the stick centres
RESET, through the console
Two things to know before merging
This does not break
dora-ulite6. Both the standard and the deprecated ports publishthe same intent, and I checked every message against that node's own parsers. Version is
0.2.0to say the contract moved; liria/dora-ulite6#3 and #3 carry the rest.The split and the standardisation are not separable in this branch. They were built
one on top of the other before either was committed, so
main.pyandmapping.pycarryboth. I would rather say so than reconstruct an intermediate state I never actually ran.
The one unticked box needs a gamepad, which the machine this was built on has not got.
Everything else has been run, the arm included.
What changed since the review above
The branch grew three things after the first round. The commit is
serve the web console from the node and drop the websocket bridge.The bridge is gone
dora-websocket-transceiverwas a second node, a second port and a round trip through thedataflow for every button press, carrying a UI only this node can interpret. The node now
serves the page and holds the sockets itself on one port.
dora_teleop/server.pyruns aiohttp on its own thread and meets the control loop at twonon-blocking points only — a bounded queue the tick drains, and a snapshot handed back —
because nothing a browser does may stall the thread that stops the arm. Both queues are
bounded on purpose: a flood is dropped rather than grown, and a slow client loses stale
snapshots rather than the newest.
Contract, so
0.2.0→0.3.0:ui_commandandclientsare gone.ui_statestayspublished — no browser reads it now, but
dora topic echoand the bench do. NewWEB_HOSTandWEB_PORT(7000;0opens no port, which is whatdataflow-gamepad.ymlwants).
The web deadman moved without changing meaning: it was a
clientsmessage from thebridge, and is now the open-socket count read each tick. One hop fewer.
The console 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 — the shape of xArm Studio's jog control on the controller at
:18333.+XN ·−XS ·Y+W ·Y−E−Z+Z+pitchN ·−pitchS ·−rollW ·+rollE−yaw+yawY+is on the left, matching xArm Studio and the right-hand rule. It is the onedirection that does not read as "screen right is right", and it is deliberate: two
consoles driving one arm that disagree about which way is
+Yis a trap.An earlier revision of this used analog sticks — drag distance as speed. It was dropped
after testing on the arm: Z as a drag lane reads wrong, and the rings match the pendant an
operator already knows.
Sending the change rather than the state
_drivenow sends a velocity when it changes, repeating an unchanged one onJOG_KEEPALIVE_S(0.2 s) only to beat the consumer's own expiry. A release is a change andnever waits for the repeat. Measured on the wire: 83 messages → 18 for the same
four-second hold.
This began as a fix for an arm that moved in steps rather than travelling. It was not the
cause — cutting the rate 4.6× changed nothing on the arm, and that negative result is
what pointed at
dora-ulite6re-arming a timed velocity segment per message. Fixed thereand released as v0.3.1, which this branch now pins. The reduction is kept on its own
merits.
Two bugs found while building this, each with a test
start()sat out its full 10 s timeout and then loggedconsole on http://…anyway —node running, nothing serving, and the block backed up the input queue. It now raises
and the node refuses to start. Found by accident when two test runs collided.
translateis a fraction of the knob, not of the circle it crosses. Moot now the sticksare gone, but it is why the rings are positioned in user units.
One thing a reviewer should know
dora run --uvcannot spawn this dataflow (dora 1.0.1).dora build --uvinstalls eachgit:node into a venv inside its own clone, while the spawner resolves the entrypointwith
uv run which <name>from the dataflow's directory — so the node is installed wherethe spawner never looks.
README.mdanddataflow-robot.ymlnow document building into asingle activated environment instead.
dataflow.ymlis unaffected and--uvis fine there.Unrelated to this branch, but it is why
dora-joymoves to v0.1.1:v0.1is anannotated tag, and dora records the tag object's id as the commit hash and then rejects its
own checkout for sitting on the peeled commit. Neither
git:dataflow could be built atall before that.