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:
@@ -5,7 +5,7 @@ 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 old web console is built on it: its jog pad is gated on owning the
|
||||
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.
|
||||
|
||||
|
||||
+70
-8
@@ -20,22 +20,72 @@ def env_float(name: str, default: float) -> float:
|
||||
raise ValueError(f"{name} must be a number, not {value!r}") from None
|
||||
|
||||
|
||||
def env_int(name: str, default: int) -> int:
|
||||
"""Read a whole number, naming the variable if it cannot be read as one."""
|
||||
value = os.environ.get(name, "").strip()
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
raise ValueError(f"{name} must be a whole number, not {value!r}") from None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsoleConfig:
|
||||
"""Where the built-in web console listens.
|
||||
|
||||
The node serves the page and holds the browsers' sockets itself, so there is
|
||||
one address and one port rather than a bridge node and two. `0.0.0.0` is the
|
||||
default because the operator is rarely sitting at the machine driving the
|
||||
arm; bind it to `127.0.0.1` where that is not true.
|
||||
"""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 7000
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether to open the port at all. Port 0 means a pad-only dataflow."""
|
||||
return self.port != 0
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "ConsoleConfig":
|
||||
"""Build the console's address from the environment."""
|
||||
port = env_int("WEB_PORT", 7000)
|
||||
if not 0 <= port <= 65535:
|
||||
raise ValueError(f"WEB_PORT must be a port number, not {port}")
|
||||
return cls(host=os.environ.get("WEB_HOST", "").strip() or "0.0.0.0", port=port)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadmanConfig:
|
||||
"""How long a source may go quiet before the arm is told to stop.
|
||||
|
||||
Four layers, outermost last. `joy_ttl_s` is how long a pad reading stays
|
||||
worth acting on; this node re-sends the current velocity every tick;
|
||||
`jog_ttl_s` is how long it will keep doing so without hearing from the
|
||||
source; and behind all of them sits the arm node's own
|
||||
`VELOCITY_COMMAND_TTL_S`, which the controller enforces whether or not this
|
||||
process is alive.
|
||||
worth acting on; `jog_keepalive_s` is how often an unchanged velocity is
|
||||
repeated; `jog_ttl_s` is how long this node keeps driving without hearing
|
||||
from the source; and behind all of them sits the arm node's own
|
||||
`VELOCITY_COMMAND_TTL_S`, enforced there whether or not this process is
|
||||
alive.
|
||||
|
||||
Keep them in that order — each shorter than the one behind it — or a
|
||||
backstop fires before the thing it is backing up.
|
||||
"""
|
||||
|
||||
jog_ttl_s: float = 0.4
|
||||
# How often to repeat a velocity that has not changed. A velocity is a
|
||||
# set-point, not a sequence: the arm holds the last one until its own expiry
|
||||
# or a new one, so repeating it 20 times a second only makes the controller
|
||||
# re-plan the same motion 20 times, which an operator feels as stepping.
|
||||
# Low enough that the arm's VELOCITY_COMMAND_TTL_S never fires mid-motion,
|
||||
# and no lower. A change — a new direction, or the stop — never waits for it.
|
||||
#
|
||||
# It is checked on the tick, so the period actually achieved is this rounded
|
||||
# *up* to the next tick: 0.2 with a 50 ms tick repeats every 250 ms, because
|
||||
# the tick at 200 ms lands a hair early and does not qualify. Budget against
|
||||
# that number, not this one.
|
||||
jog_keepalive_s: float = 0.2
|
||||
# How long the `joy` stream may go quiet before the pad reads as gone. The
|
||||
# pad lives in another node now, and that node dying looks like silence
|
||||
# rather than like an unplugged pad, so silence has to mean the same thing.
|
||||
@@ -53,6 +103,7 @@ class DeadmanConfig:
|
||||
"""Build the timings from the environment."""
|
||||
return cls(
|
||||
jog_ttl_s=env_float("JOG_TTL_S", 0.4),
|
||||
jog_keepalive_s=env_float("JOG_KEEPALIVE_S", 0.2),
|
||||
joy_ttl_s=env_float("JOY_TTL_S", 0.2),
|
||||
gamepad_idle_release_s=env_float("GAMEPAD_IDLE_RELEASE_S", 2.0),
|
||||
state_poll_s=env_float("STATE_POLL_S", 0.5),
|
||||
@@ -63,9 +114,9 @@ class DeadmanConfig:
|
||||
class Config:
|
||||
"""The node's settings."""
|
||||
|
||||
# Full-deflection speeds. A stick is analog, so these are the maximum and
|
||||
# anything less moves proportionally slower — which is the whole reason a
|
||||
# gamepad beats the console's dial pad.
|
||||
# Full-deflection speeds. Both sources are analog — a gamepad stick and the
|
||||
# console's on-screen ones — so these are the maximum and anything less
|
||||
# moves proportionally slower.
|
||||
speed_mm_s: float
|
||||
speed_deg_s: float
|
||||
# Stick travel to ignore. A worn stick that does not centre would otherwise
|
||||
@@ -75,6 +126,7 @@ class Config:
|
||||
# positioning happens near centre, and expo is what buys resolution there.
|
||||
expo: float
|
||||
deadman: DeadmanConfig = field(default_factory=DeadmanConfig)
|
||||
console: ConsoleConfig = field(default_factory=ConsoleConfig)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Config":
|
||||
@@ -93,10 +145,20 @@ class Config:
|
||||
raise ValueError(
|
||||
f"JOY_TTL_S must be below JOG_TTL_S ({deadman.jog_ttl_s}), not {deadman.joy_ttl_s}"
|
||||
)
|
||||
# The repeat has to be quicker than the arm's own velocity expiry or the
|
||||
# arm stops between repeats. That expiry cannot be read from here, but
|
||||
# JOG_TTL_S is already required to sit below it, so staying under
|
||||
# JOG_TTL_S keeps the repeat under both.
|
||||
if not 0.0 < deadman.jog_keepalive_s < deadman.jog_ttl_s:
|
||||
raise ValueError(
|
||||
f"JOG_KEEPALIVE_S must be above 0 and below JOG_TTL_S ({deadman.jog_ttl_s}), "
|
||||
f"not {deadman.jog_keepalive_s}"
|
||||
)
|
||||
return cls(
|
||||
speed_mm_s=env_float("SPEED_MM_S", 60.0),
|
||||
speed_deg_s=env_float("SPEED_DEG_S", 30.0),
|
||||
deadzone=deadzone,
|
||||
expo=expo,
|
||||
deadman=deadman,
|
||||
console=ConsoleConfig.from_env(),
|
||||
)
|
||||
|
||||
+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"})
|
||||
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ def from_pad(axes: list[float], buttons: list[bool], config, layout: Layout = LA
|
||||
|
||||
Translation comes from the left stick and the triggers, rotation from the
|
||||
right stick and the bumpers. Several axes move at once on purpose: a stick
|
||||
is analog and blended, which is exactly what a four-sector dial pad cannot do.
|
||||
is analog and blended, which is exactly what the console's ring sectors cannot do.
|
||||
|
||||
The deadzone and the response curve are applied here and nowhere else,
|
||||
because they exist to correct a physical stick. A browser has no worn
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""The console's web server, running inside this node.
|
||||
|
||||
The browser used to reach the node through `dora-websocket-transceiver`, which
|
||||
meant a second node, a second port and a round trip through the dataflow for
|
||||
every button press. This serves the page and holds the sockets directly, so the
|
||||
console is one URL and the operator wires nothing.
|
||||
|
||||
**Nothing here runs on the dora thread.** aiohttp wants an asyncio loop and the
|
||||
node's loop is a blocking `for event in node`, so the server owns a thread of
|
||||
its own and the two sides meet at two places only: :meth:`Console.take`, which
|
||||
the tick drains, and :meth:`Console.broadcast`, which hands a snapshot the other
|
||||
way. Both are non-blocking by construction — a browser that stops reading must
|
||||
never be able to stall the thread that stops the arm.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import WSMsgType, web
|
||||
|
||||
from dora_teleop.log import log
|
||||
|
||||
INDEX = Path(__file__).parent / "web" / "index.html"
|
||||
|
||||
# How many console messages may pile up between two ticks before we start
|
||||
# dropping them. The console sends a jog every 150 ms and the tick runs every
|
||||
# 50, so reaching this at all means something is flooding us; an unbounded
|
||||
# queue would answer that by growing until the node died.
|
||||
INBOX_LIMIT = 256
|
||||
|
||||
# How many snapshots may wait on one socket. Telemetry is a snapshot, not a
|
||||
# log, so a client too slow to keep up wants the newest one and not a backlog
|
||||
# of the arm's recent past.
|
||||
OUTBOX_LIMIT = 8
|
||||
|
||||
|
||||
class _Client:
|
||||
"""One connected browser, and the snapshots waiting to reach it."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.outbox: asyncio.Queue[str] = asyncio.Queue(maxsize=OUTBOX_LIMIT)
|
||||
|
||||
def offer(self, text: str) -> None:
|
||||
"""Queue one snapshot, discarding the stalest if this client is behind."""
|
||||
if self.outbox.full():
|
||||
self.outbox.get_nowait()
|
||||
self.outbox.put_nowait(text)
|
||||
|
||||
|
||||
class Console:
|
||||
"""The page, the sockets, and the thread they live on."""
|
||||
|
||||
def __init__(self, host: str, port: int):
|
||||
self.host = host
|
||||
self.port = port
|
||||
# Read every tick by the deadman: the last browser leaving has to stop
|
||||
# the arm, and a closed socket sends nothing to say so.
|
||||
self.clients = 0
|
||||
self._inbox: queue.Queue[dict] = queue.Queue(maxsize=INBOX_LIMIT)
|
||||
self._sockets: set[_Client] = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._ready = threading.Event()
|
||||
# Whatever stopped the server coming up, kept so `start` can raise it on
|
||||
# the caller's thread instead of leaving a traceback on stderr and a
|
||||
# node that believes it is serving a console.
|
||||
self._failure: BaseException | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Bring the server up, or raise saying why it could not.
|
||||
|
||||
Refusing to start is the point. The port being taken used to leave a
|
||||
traceback on the server thread and a node that carried on announcing a
|
||||
console nobody could open — an operator typing the URL and getting
|
||||
nothing, with the log saying everything was fine.
|
||||
"""
|
||||
self._thread = threading.Thread(target=self._run, name="teleop-console", daemon=True)
|
||||
self._thread.start()
|
||||
if not self._ready.wait(timeout=10.0):
|
||||
raise RuntimeError(f"the console did not come up on {self.host}:{self.port} within 10s")
|
||||
if self._failure is not None:
|
||||
raise self._failure
|
||||
log(f"console on http://{self.host}:{self.port}")
|
||||
|
||||
def take(self) -> list[dict]:
|
||||
"""Return every console message that arrived since the last call."""
|
||||
drained = []
|
||||
while True:
|
||||
try:
|
||||
drained.append(self._inbox.get_nowait())
|
||||
except queue.Empty:
|
||||
return drained
|
||||
|
||||
def broadcast(self, payload: dict) -> None:
|
||||
"""Hand one object to every connected browser."""
|
||||
if self._loop is None:
|
||||
return
|
||||
text = json.dumps(payload)
|
||||
self._loop.call_soon_threadsafe(self._offer, text)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Close the sockets and stop the thread."""
|
||||
if self._loop is None:
|
||||
return
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
|
||||
# --- everything below runs on the server thread ---
|
||||
|
||||
def _run(self) -> None:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self._loop = loop
|
||||
try:
|
||||
runner = loop.run_until_complete(self._listen())
|
||||
except BaseException as error: # noqa: BLE001 — handed to `start` to raise
|
||||
self._failure = error
|
||||
self._loop = None
|
||||
loop.close()
|
||||
# Releases `start` at once rather than making it wait out its
|
||||
# timeout for a server that is never coming up.
|
||||
self._ready.set()
|
||||
return
|
||||
try:
|
||||
loop.run_forever()
|
||||
finally:
|
||||
loop.run_until_complete(runner.cleanup())
|
||||
loop.close()
|
||||
|
||||
async def _listen(self) -> web.AppRunner:
|
||||
app = web.Application()
|
||||
app.router.add_get("/", self._page)
|
||||
app.router.add_get("/ws", self._socket)
|
||||
runner = web.AppRunner(app)
|
||||
await runner.setup()
|
||||
await web.TCPSite(runner, self.host, self.port).start()
|
||||
self._ready.set()
|
||||
return runner
|
||||
|
||||
async def _page(self, request: web.Request) -> web.FileResponse:
|
||||
"""Serve the console itself."""
|
||||
return web.FileResponse(INDEX)
|
||||
|
||||
async def _socket(self, request: web.Request) -> web.WebSocketResponse:
|
||||
"""Hold one browser's socket for as long as it stays."""
|
||||
socket = web.WebSocketResponse(heartbeat=20)
|
||||
await socket.prepare(request)
|
||||
client = _Client()
|
||||
self._sockets.add(client)
|
||||
self.clients = len(self._sockets)
|
||||
writer = asyncio.create_task(self._write(socket, client))
|
||||
try:
|
||||
async for received in socket:
|
||||
if received.type == WSMsgType.TEXT:
|
||||
self._accept(received.data)
|
||||
finally:
|
||||
writer.cancel()
|
||||
self._sockets.discard(client)
|
||||
self.clients = len(self._sockets)
|
||||
return socket
|
||||
|
||||
def _accept(self, text: str) -> None:
|
||||
"""Park one message from a browser for the next tick to read."""
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
log("ignoring a console message that was not JSON")
|
||||
return
|
||||
try:
|
||||
self._inbox.put_nowait(payload)
|
||||
except queue.Full:
|
||||
log("console inbox full, dropping a message")
|
||||
|
||||
def _offer(self, text: str) -> None:
|
||||
for client in self._sockets:
|
||||
client.offer(text)
|
||||
|
||||
async def _write(self, socket: web.WebSocketResponse, client: _Client) -> None:
|
||||
while True:
|
||||
await socket.send_str(await client.outbox.get())
|
||||
@@ -0,0 +1,449 @@
|
||||
<title>teleop console</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #101016;
|
||||
--card: #1a1a23;
|
||||
--edge: #2b2b38;
|
||||
--ink: #e8e8f0;
|
||||
--dim: #8a8a9e;
|
||||
--live: #7fd1e8;
|
||||
--warn: #e8a37f;
|
||||
--stop: #e2564a;
|
||||
--go: #43c98b;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
padding-block: 16px;
|
||||
padding-inline: 16px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; }
|
||||
|
||||
header {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
header h1 { font-size: 15px; margin: 0; margin-right: auto; letter-spacing: .04em; }
|
||||
.chip {
|
||||
padding: 5px 12px; border-radius: 999px; border: 1px solid var(--edge);
|
||||
background: var(--card); font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.chip b { color: var(--live); font-weight: 600; }
|
||||
.chip.estop { border-color: var(--stop); color: var(--stop); }
|
||||
.chip.mine { border-color: var(--go); }
|
||||
.chip.off { color: var(--dim); }
|
||||
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
|
||||
padding: 14px; margin-bottom: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
|
||||
color: var(--dim); margin: 0 0 12px; font-weight: 600;
|
||||
}
|
||||
|
||||
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
button {
|
||||
flex: 1 1 auto; min-height: 48px; min-width: 104px;
|
||||
background: #272733; color: var(--ink); border: 1px solid #383848;
|
||||
border-radius: 9px; padding: 10px 16px; font: inherit; cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
button:active:not(:disabled) { background: #33334a; }
|
||||
button:disabled { opacity: .3; cursor: not-allowed; }
|
||||
button.stop { background: #55202a; border-color: var(--stop); color: #ffd9d4; }
|
||||
button.on { background: #1f4a5a; border-color: var(--live); }
|
||||
|
||||
/* --- the jog rings --- */
|
||||
|
||||
.decks { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
.deck {
|
||||
flex: 1 1 340px;
|
||||
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.deck h2 {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
|
||||
color: var(--dim); margin: 0 0 12px; font-weight: 600;
|
||||
}
|
||||
.pills { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
|
||||
.ring { display: block; width: 100%; max-width: 330px; margin-inline: auto; touch-action: none; }
|
||||
.ring .sector {
|
||||
fill: #23232f; stroke: var(--edge); stroke-width: 1.5; cursor: pointer;
|
||||
}
|
||||
.ring .sector.holding { fill: var(--live); stroke: #bfe9f5; }
|
||||
.ring .label {
|
||||
fill: #a6a6bd; font-size: 15px; text-anchor: middle; dominant-baseline: middle;
|
||||
pointer-events: none; font-family: inherit;
|
||||
}
|
||||
.ring .sector.holding + .label { fill: #0b1013; font-weight: 700; }
|
||||
.ring .hub {
|
||||
fill: none; stroke: var(--edge); stroke-width: 1.5;
|
||||
}
|
||||
.ring .hubtext {
|
||||
fill: var(--dim); font-size: 13px; text-anchor: middle; dominant-baseline: middle;
|
||||
pointer-events: none; font-family: inherit; letter-spacing: .08em;
|
||||
}
|
||||
.ring[data-off="true"] { opacity: .32; }
|
||||
.ring[data-off="true"] .sector { cursor: not-allowed; }
|
||||
|
||||
label.speed { display: flex; align-items: center; gap: 12px; color: var(--dim); }
|
||||
label.speed input { flex: 1; height: 34px; }
|
||||
label.speed b { color: var(--live); min-width: 4ch; text-align: right; }
|
||||
|
||||
.readout { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 2px 18px; }
|
||||
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
||||
.row span:last-child { color: var(--live); }
|
||||
|
||||
#toast { color: var(--warn); min-height: 1.5em; padding: 4px 2px; }
|
||||
#link { color: var(--dim); font-size: 12px; }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { padding-inline: 10px; }
|
||||
.deck { padding: 10px; }
|
||||
button { min-width: 84px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header>
|
||||
<h1>teleop console</h1>
|
||||
<span class="chip" id="chip-state">—</span>
|
||||
<span class="chip" id="chip-owner">—</span>
|
||||
</header>
|
||||
|
||||
<div class="card">
|
||||
<h2>Control</h2>
|
||||
<div class="bar">
|
||||
<button id="take">Take control</button>
|
||||
<button id="release">Release</button>
|
||||
<button id="reset">Reset</button>
|
||||
<button class="stop" id="estop">E‑STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decks">
|
||||
<div class="deck">
|
||||
<h2>Translate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="2" data-sign="-1">−Z</button>
|
||||
<button data-axis="2" data-sign="1">+Z</button>
|
||||
</div>
|
||||
<svg class="ring" id="translate" viewBox="0 0 200 200" aria-label="translate">
|
||||
<g data-north="0,1" data-south="0,-1" data-west="1,1" data-east="1,-1"
|
||||
data-labels="+X,−X,Y+,Y−" data-hub="XYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="deck">
|
||||
<h2>Rotate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="5" data-sign="-1">−yaw</button>
|
||||
<button data-axis="5" data-sign="1">+yaw</button>
|
||||
</div>
|
||||
<svg class="ring" id="rotate" viewBox="0 0 200 200" aria-label="rotate">
|
||||
<g data-north="4,1" data-south="4,-1" data-west="3,-1" data-east="3,1"
|
||||
data-labels="+pitch,−pitch,−roll,+roll" data-hub="RXYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Speed</h2>
|
||||
<label class="speed">
|
||||
<input id="speed" type="range" min="10" max="100" step="5" value="60">
|
||||
<b id="speedout">60%</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Tool</h2>
|
||||
<div class="bar">
|
||||
<button id="close">Close gripper</button>
|
||||
<button id="open">Open gripper</button>
|
||||
<button id="freedrive">Freedrive</button>
|
||||
<button id="home">Home</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>State</h2>
|
||||
<div class="readout" id="state"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<div id="link"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
// Read by name out of the node's telemetry snapshot. Robot-agnostic: `gripper`,
|
||||
// not `vacuum`, because a page that says vacuum only fits a robot with one.
|
||||
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "gripper", "freedrive"];
|
||||
|
||||
// The node stops the robot if the jog stream dries up (JOG_TTL_S, 0.4 s).
|
||||
// Resending faster than that is what makes letting go a stop, with nothing
|
||||
// needing to be sent to say so.
|
||||
const REFRESH_MS = 150;
|
||||
|
||||
// The ring, laid out like the arm's own pendant so the two do not disagree:
|
||||
// a quarter each, north at the top, and a hub that is label rather than button.
|
||||
const GAP_DEG = 3;
|
||||
const OUTER = 95;
|
||||
const INNER = 46;
|
||||
const LABEL_R = 71;
|
||||
const COMPASS = [
|
||||
["north", 225, 315],
|
||||
["east", 315, 405],
|
||||
["south", 45, 135],
|
||||
["west", 135, 225],
|
||||
];
|
||||
|
||||
let socket = null;
|
||||
let owner = "none";
|
||||
let current = {};
|
||||
let axes = [0, 0, 0, 0, 0, 0];
|
||||
let refresh = null;
|
||||
|
||||
// Every direction currently held, as element -> {axis, sign}. A map rather than
|
||||
// one value because two fingers may hold two directions, and lifting one must
|
||||
// not cancel the other.
|
||||
const held = new Map();
|
||||
// Which element each finger is on, so a finger that slides off still releases
|
||||
// the direction it started on.
|
||||
const fingers = new Map();
|
||||
|
||||
function send(payload) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
function scale() {
|
||||
return Number(document.getElementById("speed").value) / 100;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
axes = [0, 0, 0, 0, 0, 0];
|
||||
const dial = scale();
|
||||
for (const [, { axis, sign }] of held) axes[axis] += sign * dial;
|
||||
axes = axes.map((v) => Math.max(-1, Math.min(1, v)));
|
||||
}
|
||||
|
||||
function moving() {
|
||||
return axes.some((value) => value !== 0);
|
||||
}
|
||||
|
||||
function pump() {
|
||||
if (moving()) send({ type: "jog", axes });
|
||||
}
|
||||
|
||||
// --- drawing the ring ---
|
||||
|
||||
function onCircle(radius, degrees) {
|
||||
const radians = (degrees * Math.PI) / 180;
|
||||
return [100 + radius * Math.cos(radians), 100 + radius * Math.sin(radians)];
|
||||
}
|
||||
|
||||
function sectorPath(from, to) {
|
||||
const [ax, ay] = onCircle(OUTER, from);
|
||||
const [bx, by] = onCircle(OUTER, to);
|
||||
const [cx, cy] = onCircle(INNER, to);
|
||||
const [dx, dy] = onCircle(INNER, from);
|
||||
return `M${ax} ${ay}A${OUTER} ${OUTER} 0 0 1 ${bx} ${by}`
|
||||
+ `L${cx} ${cy}A${INNER} ${INNER} 0 0 0 ${dx} ${dy}Z`;
|
||||
}
|
||||
|
||||
function svg(name, attributes) {
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
|
||||
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildRing(ring) {
|
||||
const group = ring.querySelector("g");
|
||||
const labels = group.dataset.labels.split(",");
|
||||
// Labelled in compass order so the markup reads the way the ring looks:
|
||||
// north, south, west, east.
|
||||
const naming = { north: labels[0], south: labels[1], west: labels[2], east: labels[3] };
|
||||
|
||||
for (const [point, from, to] of COMPASS) {
|
||||
const [axis, sign] = group.dataset[point].split(",").map(Number);
|
||||
const sector = svg("path", {
|
||||
d: sectorPath(from + GAP_DEG, to - GAP_DEG),
|
||||
class: "sector",
|
||||
});
|
||||
sector.dataset.axis = axis;
|
||||
sector.dataset.sign = sign;
|
||||
group.appendChild(sector);
|
||||
|
||||
const middle = (from + to) / 2;
|
||||
const [lx, ly] = onCircle(LABEL_R, middle);
|
||||
const text = svg("text", { x: lx, y: ly, class: "label" });
|
||||
text.textContent = naming[point];
|
||||
group.appendChild(text);
|
||||
}
|
||||
|
||||
group.appendChild(svg("circle", { cx: 100, cy: 100, r: INNER - 6, class: "hub" }));
|
||||
const hub = svg("text", { x: 100, y: 100, class: "hubtext" });
|
||||
hub.textContent = group.dataset.hub;
|
||||
group.appendChild(hub);
|
||||
}
|
||||
|
||||
// --- holding a direction ---
|
||||
|
||||
function grab(element) {
|
||||
if (held.has(element) || element.closest("[data-off=\"true\"]")) return;
|
||||
held.set(element, { axis: Number(element.dataset.axis), sign: Number(element.dataset.sign) });
|
||||
element.classList.add("holding");
|
||||
recompute();
|
||||
pump();
|
||||
// Resend while held. The node's deadman expects a stream, not one message.
|
||||
if (refresh === null) refresh = setInterval(pump, REFRESH_MS);
|
||||
}
|
||||
|
||||
function letGo(element) {
|
||||
if (!held.delete(element)) return;
|
||||
element.classList.remove("holding");
|
||||
recompute();
|
||||
if (moving()) return;
|
||||
// Say so rather than going quiet: the deadman would stop it either way, but a
|
||||
// direction released is an intention and 400 ms of travel is not.
|
||||
send({ type: "jog_stop" });
|
||||
clearInterval(refresh);
|
||||
refresh = null;
|
||||
}
|
||||
|
||||
function releaseEverything() {
|
||||
for (const element of [...held.keys()]) letGo(element);
|
||||
fingers.clear();
|
||||
}
|
||||
|
||||
function wireDirection(element) {
|
||||
element.addEventListener("pointerdown", (event) => {
|
||||
// Keeps this finger's events coming even once it slides off, so letting go
|
||||
// always reaches letGo().
|
||||
element.setPointerCapture(event.pointerId);
|
||||
fingers.set(event.pointerId, element);
|
||||
grab(element);
|
||||
});
|
||||
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
|
||||
element.addEventListener(name, (event) => {
|
||||
fingers.delete(event.pointerId);
|
||||
letGo(element);
|
||||
});
|
||||
}
|
||||
element.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
}
|
||||
|
||||
function wire() {
|
||||
for (const ring of document.querySelectorAll(".ring")) buildRing(ring);
|
||||
for (const element of document.querySelectorAll(".sector, .pills button[data-axis]")) {
|
||||
wireDirection(element);
|
||||
}
|
||||
|
||||
// A tab that goes away mid-jog must not leave the robot travelling. The node
|
||||
// covers this too; doing it here costs one line.
|
||||
window.addEventListener("blur", releaseEverything);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) releaseEverything();
|
||||
});
|
||||
|
||||
const speed = document.getElementById("speed");
|
||||
speed.addEventListener("input", () => {
|
||||
document.getElementById("speedout").textContent = `${speed.value}%`;
|
||||
recompute();
|
||||
});
|
||||
|
||||
const click = {
|
||||
take: { type: "control", action: "take", preempt: true },
|
||||
release: { type: "control", action: "release" },
|
||||
estop: { type: "estop" },
|
||||
reset: { type: "reset" },
|
||||
close: { type: "gripper", closed: true },
|
||||
open: { type: "gripper", closed: false },
|
||||
home: { type: "home" },
|
||||
};
|
||||
for (const [id, payload] of Object.entries(click)) {
|
||||
document.getElementById(id).addEventListener("click", () => send(payload));
|
||||
}
|
||||
document.getElementById("freedrive").addEventListener("click", () => {
|
||||
send({ type: "freedrive", enabled: !current.freedrive });
|
||||
});
|
||||
}
|
||||
|
||||
function render(telemetry) {
|
||||
current = telemetry;
|
||||
const rows = KEYS.map((key) =>
|
||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||
const pose = telemetry.position;
|
||||
if (pose) {
|
||||
// The node says what the pose is in rather than the page assuming it.
|
||||
const unit = (telemetry.units && telemetry.units.position) || "";
|
||||
const shown = ["x", "y", "z"].map((axis) => pose[axis].toFixed(1)).join(", ");
|
||||
rows.push(`<div class="row"><span>position</span><span>${shown} ${unit}</span></div>`);
|
||||
}
|
||||
document.getElementById("state").innerHTML = rows.join("");
|
||||
|
||||
const state = document.getElementById("chip-state");
|
||||
state.innerHTML = `arm <b>${telemetry.arm_state}</b>`;
|
||||
state.className = `chip${telemetry.estop ? " estop" : ""}`;
|
||||
|
||||
owner = telemetry.owner;
|
||||
const mine = owner === "web";
|
||||
const chip = document.getElementById("chip-owner");
|
||||
chip.innerHTML = mine ? "<b>you have the arm</b>" : `held by <b>${owner}</b>`;
|
||||
chip.className = `chip ${mine ? "mine" : "off"}`;
|
||||
|
||||
document.getElementById("freedrive").classList.toggle("on", !!telemetry.freedrive);
|
||||
document.getElementById("close").classList.toggle("on", !!telemetry.gripper);
|
||||
|
||||
// Every control is gated on holding the lease, so one that would be refused
|
||||
// looks refused. E-STOP and Reset never are.
|
||||
for (const id of ["close", "open", "freedrive", "home"]) {
|
||||
document.getElementById(id).disabled = !mine;
|
||||
}
|
||||
for (const ring of document.querySelectorAll(".ring")) {
|
||||
ring.dataset.off = String(!mine);
|
||||
}
|
||||
for (const pill of document.querySelectorAll(".pills button[data-axis]")) {
|
||||
pill.disabled = !mine;
|
||||
}
|
||||
if (!mine) releaseEverything();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
// The node serves this page and holds the socket, so the socket is on this
|
||||
// page's own origin — nothing to configure and nothing to keep in step.
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${scheme}//${location.host}/ws`;
|
||||
document.getElementById("link").textContent = `socket: ${url}`;
|
||||
socket = new WebSocket(url);
|
||||
socket.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "telemetry") render(message);
|
||||
if (message.type === "error") {
|
||||
document.getElementById("toast").textContent = message.detail;
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
releaseEverything();
|
||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
||||
wire();
|
||||
connect();
|
||||
</script>
|
||||
Reference in New Issue
Block a user