From 177511af1f0187313509fbbd035650cd001d35a7 Mon Sep 17 00:00:00 2001 From: fondef Date: Wed, 16 Sep 2026 12:18:57 -0500 Subject: [PATCH] add the console's jog pad, gripper and freedrive controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_console.py | 89 ++++++++++++++++ web/index.html | 235 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 307 insertions(+), 17 deletions(-) create mode 100644 tests/test_console.py diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..b138ab8 --- /dev/null +++ b/tests/test_console.py @@ -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 diff --git a/web/index.html b/web/index.html index ad6a905..6a4a165 100644 --- a/web/index.html +++ b/web/index.html @@ -3,47 +3,137 @@ -teleop — placeholder console +teleop console -

teleop — placeholder console

+

teleop console

- Not the real console. This page exists so the dataflow has something to serve and so the - node can be checked end to end before the Control section is ported across from the - monolithic repo. It reads telemetry and can take, release and stop — nothing else. + Hold a direction to move; release to stop. The page never sends a speed in the robot's + units — it sends a fraction of full deflection, exactly as a gamepad stick does, and the + node scales it. That is why this console does not need to know what robot is on the + other end.

-
-
- - - - +

State

+
+
+

Control

+
+ + + + +
+
+ +
+

Move

+
+
+
translation
+
+ + + + + + + + + +
+
+
+
rotation
+
+ + + + + + + + + +
+
+
+

+ +
+ +
+

Tool

+
+ + + + +
+
+ +