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.
79 lines
3.0 KiB
Python
79 lines
3.0 KiB
Python
"""Who is allowed to drive the arm.
|
|
|
|
Two sources want one arm, and the arm cannot take both. This is the whole of
|
|
that decision, in one place, so there is exactly one answer to "why did it not
|
|
move" rather than one per input path.
|
|
|
|
The model comes from the monolithic repo's `RobotService` lease, and is kept
|
|
because the web console is built on it: its controls are gated on owning the
|
|
arm and it renders an owner chip. A simpler last-writer-wins scheme would mean
|
|
rewriting the console to match the node rather than the other way round.
|
|
|
|
The arm node itself has no idea any of this exists. A lease held here binds only
|
|
what goes through this node — another node publishing `request` to the arm will
|
|
preempt an operator mid-motion, and nothing here can stop it.
|
|
"""
|
|
|
|
from dora_teleop.log import log
|
|
|
|
GAMEPAD = "gamepad"
|
|
WEB = "web"
|
|
|
|
|
|
class Arbiter:
|
|
"""The lease on the arm, and the rules for handing it between sources."""
|
|
|
|
def __init__(self, idle_release_s: float):
|
|
self.idle_release_s = idle_release_s
|
|
self._owner: str | None = None
|
|
self._last_activity = 0.0
|
|
|
|
@property
|
|
def owner(self) -> str | None:
|
|
"""Who holds the arm, or None if it is free."""
|
|
return self._owner
|
|
|
|
def take(self, who: str, now: float, preempt: bool = False) -> bool:
|
|
"""Give ``who`` the arm if it is theirs to take, and say whether it worked.
|
|
|
|
A free arm goes to whoever asks. A held one stays held unless the caller
|
|
asked to preempt, which the console's TAKE CONTROL button offers
|
|
deliberately: an operator who can see the arm is better placed than this
|
|
code to decide they should have it.
|
|
"""
|
|
if self._owner not in (None, who) and not preempt:
|
|
return False
|
|
if self._owner != who:
|
|
log(f"{who} has the arm" + (f", preempting {self._owner}" if self._owner else ""))
|
|
self._owner = who
|
|
self._last_activity = now
|
|
return True
|
|
|
|
def release(self, who: str) -> None:
|
|
"""Give the arm back, if this source had it."""
|
|
if self._owner == who:
|
|
log(f"{who} released the arm")
|
|
self._owner = None
|
|
|
|
def note_activity(self, who: str, now: float) -> None:
|
|
"""Record that the holder is still there, so the idle timer does not fire."""
|
|
if self._owner == who:
|
|
self._last_activity = now
|
|
|
|
def expire_idle_gamepad(self, now: float) -> None:
|
|
"""Hand the arm back after the pad has sat untouched long enough.
|
|
|
|
Only the gamepad expires. It takes the arm implicitly, on the first
|
|
nudge of a stick, so it has to give it back the same way or a pad left
|
|
on the bench would lock the browser out forever. The web takes the arm
|
|
by pressing a button and gives it back by pressing another.
|
|
"""
|
|
if self._owner != GAMEPAD:
|
|
return
|
|
if now - self._last_activity >= self.idle_release_s:
|
|
self.release(GAMEPAD)
|
|
|
|
def may_drive(self, who: str) -> bool:
|
|
"""Whether this source's jog should reach the arm."""
|
|
return self._owner == who
|