Files
dora-teleop/tests/conftest.py
T
cristhian da1b079b7b
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
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.
2026-09-16 14:50:21 -03:00

120 lines
3.7 KiB
Python

"""Shared fakes. Nothing here needs a gamepad, an arm, a browser or a daemon."""
import json
import pyarrow as pa
import pytest
from dora_teleop.config import Config
from dora_teleop.joy import Sample
# The shape `dora-joy` publishes. Declared here rather than inferred for the same
# reason it is declared there: pyarrow reads an empty `axes` list as `list<null>`,
# so a test for an absent pad would otherwise build a message no consumer sees.
JOY = pa.struct(
[
("axes", pa.list_(pa.float32())),
("buttons", pa.list_(pa.bool_())),
("present", pa.bool_()),
]
)
class RecordingNode:
"""A dora node that records outputs *with* their metadata.
`dora.testing.MockNode` drops the metadata dict on `send_output`, and the
metadata is where `request_id` rides. So the tests record it themselves.
"""
def __init__(self):
self.sent = []
def send_output(self, output_id, data, metadata=None):
self.sent.append((output_id, data, dict(metadata or {})))
def outputs(self, output_id):
"""Return every message sent on ``output_id`` as (value, metadata) pairs."""
return [(data[0].as_py(), metadata) for name, data, metadata in self.sent if name == output_id]
def jogs(self):
"""Return every velocity sent on the deprecated `jog` port, in order."""
return [row["velocity"] for row, _ in self.outputs("jog")]
def twists(self):
"""Return every Twist sent on the standard port, in order."""
return [row for row, _ in self.outputs("twist")]
def actions(self):
"""Return the verb of every standard `command`, in order."""
return [row["action"] for row, _ in self.outputs("command")]
def services(self):
"""Return the name of every service called, in order."""
return [row["service"] for row, _ in self.outputs("request")]
def ui(self):
"""Return every object handed to the bridge, parsed."""
return [json.loads(value) for value, _ in self.outputs("ui_state")]
def clear(self):
self.sent.clear()
def stick(axes=None, buttons=None, present=True):
"""Build one gamepad reading, padded to a full six axes."""
values = list(axes or [])
values += [0.0] * (6 - len(values))
# A trigger at rest reads -1, and a test that forgot to say so would look
# like both triggers held down.
for index in (2, 5):
if axes is None or index >= len(axes):
values[index] = -1.0
return Sample(axes=values, buttons=list(buttons or []), present=present)
def joy_message(sample):
"""Build the Arrow row `dora-joy` would publish for one reading."""
return pa.array(
[
{
"axes": [float(value) for value in sample.axes],
"buttons": [bool(down) for down in sample.buttons],
"present": sample.present,
}
],
type=JOY,
)
def input_event(event_id, value, metadata=None):
"""Build the event dict dora hands a node for one input."""
return {"type": "INPUT", "id": event_id, "value": value, "metadata": dict(metadata or {})}
@pytest.fixture(autouse=True)
def clean_env(monkeypatch):
"""Run every test against a bare environment, whatever the shell set."""
for name in (
"SPEED_MM_S",
"SPEED_DEG_S",
"DEADZONE",
"EXPO",
"JOG_TTL_S",
"JOG_KEEPALIVE_S",
"JOY_TTL_S",
"GAMEPAD_IDLE_RELEASE_S",
"STATE_POLL_S",
"WEB_HOST",
"WEB_PORT",
):
monkeypatch.delenv(name, raising=False)
@pytest.fixture
def config():
"""Return the default configuration, with expo off so a test can do the arithmetic."""
built = Config.from_env()
built.expo = 0.0
return built