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:
+74
-38
@@ -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"})
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user