add the console's jog pad, gripper and freedrive controls

The page was a status readout: it sent three of the nine commands the node
serves, so the browser could take the lease and stop the arm but not move it.

It now holds a direction to move and releases to stop, with pads for
translation and rotation, gripper, freedrive, home and a speed slider. It sends
a fraction of full deflection rather than a speed, so it never learns the
robot's units — the same vector a stick produces.

Plain HTML and no build step: the bridge serves the directory as it stands, and
a console that cannot be fixed without a toolchain is the wrong thing to have
between a person and a robot.

Details that matter on a machine that moves: the pointer is captured so sliding
off a button still releases it; releasing sends jog_stop rather than relying on
the deadman; blur, tab-hide and a closed socket release everything; and the pad
is disabled unless the console holds the lease, so a button that would be
refused looks refused.

test_console.py checks the page against ui.py both ways — every message type it
sends is one the node serves, every telemetry key it reads is one the node
sends. Neither language catches a rename.
This commit is contained in:
2026-09-16 12:18:57 -05:00
parent d336f8d499
commit 177511af1f
2 changed files with 307 additions and 17 deletions
+89
View File
@@ -0,0 +1,89 @@
"""The page and the node are one contract, checked here rather than by eye.
`ui.py` translates for a console it cannot import, and the page reads telemetry
by key. Nothing in either language catches a rename, so a typo would surface as
a dead button on a machine that can move — which is the wrong place to find it.
"""
import json
import re
from pathlib import Path
from dora_teleop import ui
PAGE = Path(__file__).resolve().parent.parent / "web" / "index.html"
def page():
return PAGE.read_text()
def sent_types():
"""Return every message type the page can send."""
return set(re.findall(r'type:\s*"([a-z_]+)"', page()))
def read_keys():
"""Return the telemetry keys the page renders by name."""
listed = re.search(r"const KEYS = \[(.*?)\]", page(), re.DOTALL).group(1)
return set(re.findall(r'"([a-z_]+)"', listed))
def snapshot_keys():
view = ui.ArmView()
view.pose = [1.0] * 6
return set(ui.snapshot(view, "web", False, 0.0))
def test_every_message_the_page_sends_is_one_the_node_serves():
# `data` is the bridge's own envelope, not a console command.
for kind in sent_types() - {"data"}:
assert ui.command_of({"type": kind}) is not None or kind == "jog", kind
def test_every_telemetry_key_the_page_reads_is_one_the_node_sends():
missing = read_keys() - snapshot_keys()
assert not missing, f"the page reads keys the node does not send: {sorted(missing)}"
def test_the_page_reads_the_pose_units_from_the_node():
# Rather than assuming mm, which is only true of one robot.
assert "units.position" in page().replace("telemetry.units && telemetry.units.position", "units.position")
assert "units" in snapshot_keys()
def test_the_jog_the_page_sends_is_normalised_and_accepted():
# Six axes, each a fraction of full deflection. If the page ever sent a
# velocity instead, it would have to know the robot's units.
command = ui.command_of(json.loads('{"type": "jog", "axes": [0, -0.6, 0, 0, 0, 0]}'))
assert command is not None
assert command.axes == [0.0, -0.6, 0.0, 0.0, 0.0, 0.0]
def test_the_page_can_ask_for_everything_the_node_serves():
# `move` is the one command with no button: it is a pose in the robot's own
# units, which is exactly what this page is built not to know.
served = {"jog", "jog_stop", "estop", "reset", "home", "control", "gripper", "freedrive"}
assert served <= sent_types()
def test_the_page_gates_its_jog_pad_on_holding_the_lease():
# The node refuses a jog from a non-owner and answers with a toast; a pad
# that looks live but is refused teaches an operator to distrust the screen.
assert 'owner === "web"' in page()
def test_the_published_schemas_do_not_depend_on_the_value_sent():
# pyarrow reads a null `value` as its own type, so an inferred schema would
# change the type of the `command` port between one verb and the next.
from dora_teleop import message
assert message.pack_command("stop", None).type == message.COMMAND
assert message.pack_command("freedrive", 1.0).type == message.COMMAND
assert (
message.pack_twist({"linear": [0.0] * 3, "angular": [0.0] * 3, "frame": "base"}).type == message.TWIST
)
assert message.pack_jog([0.0] * 6).type == message.JOG