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
+40 -16
View File
@@ -1,8 +1,5 @@
"""The deadman: every way a source can go away, and the stop that follows it."""
import json
import pyarrow as pa
import pytest
from dora_teleop import main as loop
@@ -59,8 +56,7 @@ def drive(node, state, sample, count=1):
def web_jog(node, state, axes):
"""Deliver one console jog message, in the normalised units the page speaks."""
payload = json.dumps({"type": "jog", "axes": axes})
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
loop._accept_ui_command(node, state, {"type": "jog", "axes": axes})
# --- the gamepad ---
@@ -112,7 +108,7 @@ def test_a_joy_node_that_keeps_publishing_keeps_the_arm_moving(clock):
clock.advance(0.05)
sent = drive(node, state, stick(axes=PUSHED))
assert len(sent) == 4
assert sent
assert all(jog[0] > 0 for jog in sent)
@@ -138,15 +134,45 @@ def test_a_stick_at_rest_sends_one_zero_not_a_stream(clock):
assert jogs == [STOP]
def test_a_held_stick_is_resent_every_tick(clock):
# The arm's controller expires a velocity on its own; the re-send is what
# keeps that backstop from firing mid-motion.
def test_a_held_stick_is_not_resent_every_tick(clock):
# A velocity is a set-point the arm holds, not a frame in a sequence.
# Repeating it every tick made the controller re-plan the same motion twenty
# times a second, which an operator feels as the arm stepping rather than
# travelling.
node, state = build()
jogs = drive(node, state, stick(axes=PUSHED), count=4)
assert len(jogs) == 4
assert all(jog[0] > 0 for jog in jogs)
assert len(jogs) == 1
assert jogs[0][0] > 0
def test_a_held_stick_is_repeated_before_the_arm_would_expire_it(clock):
# The repeat exists for one reason: the arm's controller drops a velocity on
# its own, and the repeat is what keeps that backstop from firing mid-motion.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
clock.advance(state.config.deadman.jog_keepalive_s)
jogs = drive(node, state, stick(axes=PUSHED))
assert len(jogs) == 1
assert jogs[0][0] > 0
def test_a_change_of_direction_never_waits_for_the_repeat(clock):
# Sending only on change is worth nothing if the change itself is delayed:
# a press or a release has to be acted on the tick it happens, not up to a
# keepalive later.
node, state = build()
drive(node, state, stick(axes=PUSHED))
node.clear()
jogs = drive(node, state, stick(axes=[0.0, 1.0]))
assert len(jogs) == 1
assert jogs[0][0] < 0
def test_a_pad_that_was_never_wired_up_leaves_the_arm_alone(clock):
@@ -172,7 +198,7 @@ def test_the_last_browser_leaving_stops_the_arm(clock):
ticks(node, state)
node.clear()
loop._accept_clients(state, input_event("clients", pa.array([{"connected": 0, "event": "close"}])))
loop._set_clients(state, 0)
jogs = ticks(node, state)
assert jogs == [STOP]
@@ -231,8 +257,7 @@ def test_estop_is_never_gated_by_the_lease(clock):
state.clients = 1
state.arbiter.take(GAMEPAD, clock.t)
payload = json.dumps({"type": "estop"})
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
loop._accept_ui_command(node, state, {"type": "estop"})
assert node.jogs() == [STOP]
assert "stop" in node.services()
@@ -321,8 +346,7 @@ def test_a_latched_estop_is_cleared_by_reset(clock):
node, state = build()
state.view.estop = True
payload = json.dumps({"type": "reset"})
loop._accept_ui_command(node, state, input_event("ui_command", pa.array([payload])))
loop._accept_ui_command(node, state, {"type": "reset"})
assert state.view.estop is False
assert "clear" in node.services()