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.
224 lines
6.9 KiB
Python
224 lines
6.9 KiB
Python
"""The embedded console server: a real socket, and the deadman it feeds.
|
|
|
|
These start the server for real rather than faking it. The thing worth checking
|
|
is the boundary between the two threads — a browser's message reaching the tick,
|
|
and a browser going away reaching the deadman — and a fake on either side of
|
|
that boundary would check nothing.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import socket
|
|
import time
|
|
|
|
import aiohttp
|
|
import pytest
|
|
|
|
from dora_teleop import main as loop
|
|
from dora_teleop.arbiter import WEB
|
|
from dora_teleop.config import Config, ConsoleConfig
|
|
from dora_teleop.server import Console
|
|
from tests.conftest import RecordingNode
|
|
|
|
|
|
def free_port():
|
|
"""Ask the OS for a port nobody is using."""
|
|
with socket.socket() as held:
|
|
held.bind(("127.0.0.1", 0))
|
|
return held.getsockname()[1]
|
|
|
|
|
|
@pytest.fixture
|
|
def console():
|
|
"""Run a real server on a real port, and stop it however the test ends."""
|
|
running = Console("127.0.0.1", free_port())
|
|
running.start()
|
|
try:
|
|
yield running
|
|
finally:
|
|
running.stop()
|
|
|
|
|
|
def visit(console, exchange):
|
|
"""Open one browser's socket against the server and run `exchange` on it."""
|
|
|
|
async def session():
|
|
async with aiohttp.ClientSession() as client:
|
|
url = f"http://{console.host}:{console.port}"
|
|
async with client.ws_connect(f"{url}/ws") as socket_:
|
|
return await exchange(client, url, socket_)
|
|
|
|
return asyncio.run(session())
|
|
|
|
|
|
def wait_for(predicate):
|
|
"""Block until the server thread catches up, rather than guessing at a sleep."""
|
|
for _ in range(200):
|
|
if predicate():
|
|
return
|
|
time.sleep(0.01)
|
|
raise AssertionError("the server never got there")
|
|
|
|
|
|
def test_the_page_is_served_at_the_root(console):
|
|
async def exchange(client, url, socket_):
|
|
async with client.get(url) as answer:
|
|
return answer.status, await answer.text()
|
|
|
|
status, body = visit(console, exchange)
|
|
|
|
assert status == 200
|
|
assert "teleop console" in body
|
|
|
|
|
|
def test_a_message_from_a_browser_reaches_the_tick(console):
|
|
# The console's half of the boundary: what the page sends has to be waiting
|
|
# on the queue the next tick drains, in the shape the loop reads.
|
|
async def exchange(client, url, socket_):
|
|
await socket_.send_str(json.dumps({"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}))
|
|
await asyncio.sleep(0.05)
|
|
|
|
visit(console, exchange)
|
|
|
|
assert console.take() == [{"type": "jog", "axes": [1.0, 0, 0, 0, 0, 0]}]
|
|
assert console.take() == []
|
|
|
|
|
|
def test_a_snapshot_reaches_the_browser(console):
|
|
# The other half: a telemetry object handed over from the dora thread comes
|
|
# out of the socket as the page's own JSON, with no envelope around it.
|
|
async def exchange(client, url, socket_):
|
|
console.broadcast({"type": "telemetry", "arm_state": "idle"})
|
|
return json.loads(await socket_.receive_str())
|
|
|
|
assert visit(console, exchange) == {"type": "telemetry", "arm_state": "idle"}
|
|
|
|
|
|
def test_a_browser_that_is_not_talking_json_is_ignored(console):
|
|
# A page from a newer build saying something we cannot parse must not take
|
|
# the arm down, and must not stop the messages after it being read.
|
|
async def exchange(client, url, socket_):
|
|
await socket_.send_str("{not json")
|
|
await socket_.send_str(json.dumps({"type": "estop"}))
|
|
await asyncio.sleep(0.05)
|
|
|
|
visit(console, exchange)
|
|
|
|
assert console.take() == [{"type": "estop"}]
|
|
|
|
|
|
def test_the_server_counts_the_browsers_on_it(console):
|
|
assert console.clients == 0
|
|
seen = []
|
|
|
|
async def exchange(client, url, socket_):
|
|
await asyncio.sleep(0.05)
|
|
seen.append(console.clients)
|
|
|
|
visit(console, exchange)
|
|
|
|
assert seen == [1]
|
|
|
|
# And the socket closing is what the deadman below reads.
|
|
wait_for(lambda: console.clients == 0)
|
|
|
|
|
|
def test_a_flood_from_one_browser_cannot_grow_without_bound(console):
|
|
# An unbounded queue would answer a flood by growing until the node died,
|
|
# and the node dying is the one thing that must not stop the arm from being
|
|
# stopped.
|
|
async def exchange(client, url, socket_):
|
|
for index in range(600):
|
|
await socket_.send_str(json.dumps({"type": "jog_stop", "n": index}))
|
|
await asyncio.sleep(0.2)
|
|
|
|
visit(console, exchange)
|
|
|
|
assert 0 < len(console.take()) <= 256
|
|
|
|
|
|
# --- where the server meets the deadman ---
|
|
|
|
|
|
def build():
|
|
config = Config.from_env()
|
|
config.expo = 0.0
|
|
return RecordingNode(), loop.Teleop(config)
|
|
|
|
|
|
def test_the_last_browser_leaving_takes_the_arm_back(console):
|
|
# The web deadman used to arrive as a `clients` message from the bridge. It
|
|
# is now read straight off the server, and it has to mean the same thing.
|
|
node, state = build()
|
|
state.console = console
|
|
state.arbiter.take(WEB, 1000.0)
|
|
|
|
loop._drain_console(node, state)
|
|
|
|
assert state.clients == 0
|
|
assert state.arbiter.owner is None
|
|
assert state.web_axes == [0.0] * 6
|
|
|
|
|
|
def test_a_node_with_no_console_is_left_alone():
|
|
# A pad-only dataflow opens no port, and the tick must not read a lease
|
|
# release out of a server that was never started.
|
|
node, state = build()
|
|
state.clients = 1
|
|
state.arbiter.take(WEB, 1000.0)
|
|
|
|
loop._drain_console(node, state)
|
|
|
|
assert state.arbiter.owner == WEB
|
|
|
|
|
|
# --- the port itself ---
|
|
|
|
|
|
def test_the_console_is_on_by_default(monkeypatch):
|
|
monkeypatch.delenv("WEB_PORT", raising=False)
|
|
settings = ConsoleConfig.from_env()
|
|
|
|
assert settings.enabled is True
|
|
# Pinned because it is the number in the README, the manifest and every
|
|
# dataflow: a default that drifts from the docs sends an operator to a port
|
|
# nothing is listening on.
|
|
assert settings.port == 7000
|
|
|
|
|
|
def test_port_zero_means_no_port_is_opened(monkeypatch):
|
|
# How `dataflow-gamepad.yml` asks for a pad and nothing else.
|
|
monkeypatch.setenv("WEB_PORT", "0")
|
|
assert ConsoleConfig.from_env().enabled is False
|
|
|
|
|
|
def test_a_port_that_is_not_a_port_stops_the_node(monkeypatch):
|
|
monkeypatch.setenv("WEB_PORT", "99999")
|
|
with pytest.raises(ValueError, match="WEB_PORT"):
|
|
ConsoleConfig.from_env()
|
|
|
|
|
|
def test_a_port_that_is_not_a_number_stops_the_node(monkeypatch):
|
|
monkeypatch.setenv("WEB_PORT", "eight-thousand")
|
|
with pytest.raises(ValueError, match="WEB_PORT"):
|
|
ConsoleConfig.from_env()
|
|
|
|
|
|
def test_a_port_already_in_use_stops_the_node(console):
|
|
# Found the hard way: the bind failed on the server thread, `start` waited
|
|
# out its timeout and then announced a console anyway, and the node ran on
|
|
# serving nothing. An operator opening the URL got silence while the log
|
|
# said it was up.
|
|
second = Console(console.host, console.port)
|
|
|
|
with pytest.raises(OSError):
|
|
second.start()
|
|
|
|
second.stop()
|
|
|
|
|
|
def test_stopping_a_console_that_never_started_is_harmless():
|
|
# `main` stops the console in a `finally`, which runs whether or not the
|
|
# server ever came up.
|
|
Console("127.0.0.1", 0).stop()
|