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
+74 -38
View File
@@ -4,15 +4,20 @@ The loop owns the one decision this node exists to make — whether a velocity
reaches the arm this tick, and from whom. Everything else is delegated:
:mod:`dora_teleop.joy` takes the pad off the wire, :mod:`dora_teleop.mapping`
turns a reading into a velocity, :mod:`dora_teleop.arbiter` says who is allowed
to, and :mod:`dora_teleop.ui` speaks the console's language. **No hardware is
touched here at all** — the pad is `dora-joy`, the arm is `dora-ulite6` and the
browser is the WebSocket bridge.
to, :mod:`dora_teleop.ui` speaks the console's language and
:mod:`dora_teleop.server` serves it. **No hardware is touched here at all** —
the pad is `dora-joy` and the arm is `dora-ulite6`.
**The safety-critical part is `_drive`.** An arm moving under a velocity command
keeps moving until told otherwise, so the question is never "should it start"
but "has anything happened that means it must stop". Three things mean that: the
source went quiet, the source went away, or this process is going away. All
three end in the same place — one all-zero velocity, sent once.
What goes out is the *change*: a velocity is a set-point the arm holds, not a
frame in a sequence, so an unchanged one is repeated only as often as the arm's
own expiry demands. A change — a new direction, or the stop — is never made to
wait for that timer.
"""
import argparse
@@ -22,7 +27,7 @@ import time
import pyarrow as pa
from dora import Node
from dora_teleop import joy, mapping, message, ui
from dora_teleop import joy, mapping, message, server, ui
from dora_teleop.arbiter import GAMEPAD, WEB, Arbiter
from dora_teleop.config import Config
from dora_teleop.joy import JoyInput, now
@@ -68,11 +73,20 @@ class Teleop:
# pad's — the console does not know the robot's units.
self.web_axes = STOP_AXES
self.web_seen_at = 0.0
# How many browsers are on the console right now, read from the server
# every tick. The last one leaving has to stop the arm, and a socket
# that went away sends nothing to say so.
self.clients = 0
self.console: server.Console | None = None
# Whether the arm is currently being driven by us. Used to send the stop
# once rather than every tick: a stream of zeroes would keep the arm
# node believing a jog is live and hold `robot_state` at `moving`.
self.jogging = False
# The last velocity actually put on the wire, and when. `_drive` sends
# only when what it wants differs from this, so the arm is told about
# changes rather than told the same thing 20 times a second.
self.sent_axes = STOP_AXES
self.sent_at = 0.0
self.buttons: list[bool] = []
self.requests = 0
self.polled_at = 0.0
@@ -92,6 +106,9 @@ def main() -> None:
# dora spawns it, the id from the dataflow wins.
node = Node(args.name)
state = Teleop(config)
if config.console.enabled:
state.console = server.Console(config.console.host, config.console.port)
state.console.start()
try:
for event in node:
@@ -106,6 +123,8 @@ def main() -> None:
# controller's own expiry catches up, which is the one moment nobody is
# watching for it.
_send_motion(node, state, STOP_AXES)
if state.console is not None:
state.console.stop()
def _parse_args() -> argparse.Namespace:
@@ -126,10 +145,6 @@ def _handle(node: Node, state: Teleop, event: dict) -> None:
_tick(node, state)
elif event_id == "joy":
state.joy.accept(joy.decode(event), now())
elif event_id == "ui_command":
_accept_ui_command(node, state, event)
elif event_id == "clients":
_accept_clients(state, event)
elif event_id == "robot_state":
state.view.state = event["value"][0].as_py()
elif event_id == "tcp_pose":
@@ -150,6 +165,10 @@ def _handle(node: Node, state: Teleop, event: dict) -> None:
def _tick(node: Node, state: Teleop) -> None:
"""Decide what the arm should be doing, and say so."""
moment = now()
# Before anything else: what the browser asked for since the last tick is
# part of this tick's picture, and the client count it implies is what the
# web deadman reads below.
_drain_console(node, state)
# Absent covers three things now: no pad plugged in, a pad unplugged
# mid-jog, and `dora-joy` no longer sending. The arm cannot tell them apart
# and does not need to.
@@ -179,22 +198,27 @@ def _tick(node: Node, state: Teleop) -> None:
def _drive(node: Node, state: Teleop, sample, moment: float) -> None:
"""Send the velocity the owner is asking for, or the stop that ends it.
A velocity is only re-sent while it is non-zero: the arm node treats a
stream of jogs as one continuous one, and the re-send exists so the
controller's own expiry never fires mid-motion.
Changes go out at once; an unchanged velocity is repeated only on
`jog_keepalive_s`, which exists solely so the arm's own velocity expiry
never fires mid-motion. Repeating it every tick instead made the controller
re-plan the same motion twenty times a second, which an operator feels as
the arm moving in steps rather than travelling.
The stop is a change like any other, so releasing a button is acted on the
tick it happens and never waits for the repeat.
"""
axes = _wanted_axes(state, sample, moment)
if mapping.is_moving(axes):
state.jogging = True
if axes != state.sent_axes:
# Includes the stop: going to all-zero is a change, sent once, and the
# `jogging` flag then keeps it from being repeated. A stream of zeroes
# would hold the arm node's `robot_state` at `moving` forever.
state.jogging = mapping.is_moving(axes)
_send_motion(node, state, axes)
return
if state.jogging:
# Once, not every tick. Repeating it would keep the arm node believing a
# jog is live and hold `robot_state` at `moving` forever.
state.jogging = False
_send_motion(node, state, STOP_AXES)
if state.jogging and moment - state.sent_at >= state.config.deadman.jog_keepalive_s:
_send_motion(node, state, axes)
def _wanted_axes(state: Teleop, sample, moment: float) -> list[float]:
@@ -278,17 +302,17 @@ def _poll_state(node: Node, state: Teleop, moment: float) -> None:
# --- what the console asks for ---
def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
"""Take one console message and do what it says."""
raw = event["value"][0].as_py() if len(event["value"]) else None
if raw is None:
return
try:
payload = json.loads(raw) if isinstance(raw, str) else raw
except json.JSONDecodeError:
log("ignoring a console message that was not JSON")
def _drain_console(node: Node, state: Teleop) -> None:
"""Act on everything the browsers sent, then note how many are still there."""
if state.console is None:
return
for payload in state.console.take():
_accept_ui_command(node, state, payload)
_set_clients(state, state.console.clients)
def _accept_ui_command(node: Node, state: Teleop, payload: dict) -> None:
"""Take one console message and do what it says."""
command = ui.command_of(payload)
if command is None:
return
@@ -305,7 +329,7 @@ def _accept_ui_command(node: Node, state: Teleop, event: dict) -> None:
return
if not state.arbiter.may_drive(WEB):
_post_ui(node, ui.error("take control first"))
_post_ui(node, state, ui.error("take control first"))
return
if command.type == "jog":
@@ -332,14 +356,13 @@ def _control(node: Node, state: Teleop, command, moment: float) -> None:
state.web_axes = STOP_AXES
return
if not state.arbiter.take(WEB, moment, preempt=command.preempt):
_post_ui(node, ui.error(f"the {state.arbiter.owner} has the arm"))
_post_ui(node, state, ui.error(f"the {state.arbiter.owner} has the arm"))
def _accept_clients(state: Teleop, event: dict) -> None:
def _set_clients(state: Teleop, count: int) -> None:
"""Track who is watching, because the last one leaving has to stop the arm."""
row = event["value"][0].as_py()
state.clients = int(row.get("connected", 0))
if state.clients == 0:
state.clients = count
if count == 0:
state.arbiter.release(WEB)
state.web_axes = STOP_AXES
@@ -356,14 +379,14 @@ def _accept_response(node: Node, state: Teleop, event: dict) -> None:
if not row.get("ok", True):
message = row.get("message", "")
log(f"{row.get('service')} failed: {message}")
_post_ui(node, ui.error(message))
_post_ui(node, state, ui.error(message))
def _accept_motion_state(node: Node, state: Teleop, event: dict) -> None:
"""Report a motion that failed, which a non-blocking request learns no other way."""
row = event["value"][0].as_py()
if row.get("state") == "failed":
_post_ui(node, ui.error(row.get("message", "the motion failed")))
_post_ui(node, state, ui.error(row.get("message", "the motion failed")))
# --- talking to the dataflow ---
@@ -402,10 +425,16 @@ def _send_motion(node: Node, state: Teleop, axes: list[float]) -> None:
is `dora-ulite6`'s mm/s and deg/s, published alongside until that node
learns the standard. Both carry the same intent, so wiring either is safe;
wiring both would drive the arm twice.
This always sends. The decision not to is `_drive`'s, so that the e-stop and
the shutdown stop — neither of which goes through it — can never be
suppressed by having sent the same thing already.
"""
config = state.config
node.send_output("twist", message.pack_twist(mapping.twist(axes, config)))
node.send_output("jog", message.pack_jog(mapping.scale(axes, config)))
state.sent_axes = list(axes)
state.sent_at = now()
def _act(node: Node, state: Teleop, action: str, value: float | None = None) -> None:
@@ -434,11 +463,18 @@ def _request(node: Node, state: Teleop, row: dict) -> None:
def _publish(node: Node, state: Teleop) -> None:
"""Send the console its picture of the arm."""
_post_ui(node, ui.snapshot(state.view, state.arbiter.owner, state.jogging, time.time()))
_post_ui(node, state, ui.snapshot(state.view, state.arbiter.owner, state.jogging, time.time()))
def _post_ui(node: Node, payload: dict) -> None:
"""Hand one object to the bridge, which broadcasts it to every browser."""
def _post_ui(node: Node, state: Teleop, payload: dict) -> None:
"""Send one object to every browser, and out on the wire for anyone watching.
`ui_state` carries no browser any more — the sockets are ours. It stays
published because it is the one port that says what an operator is being
shown, which is what `dora topic echo` and the bench read it for.
"""
if state.console is not None:
state.console.broadcast(payload)
node.send_output("ui_state", pa.array([json.dumps(payload)]), metadata={"encoding": "json"})