Read the gamepad from dora-joy, and speak a robot-agnostic contract #2

Merged
cristhian merged 6 commits from feat/robot-agnostic-teleop into main 2026-09-16 17:52:07 +00:00
2 changed files with 307 additions and 17 deletions
Showing only changes of commit 177511af1f - Show all commits
+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
+218 -17
View File
@@ -3,47 +3,137 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>teleop — placeholder console</title>
<title>teleop console</title>
<style>
:root { color-scheme: dark; }
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
h1 { font-size: 16px; margin: 0 0 4px; }
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 60ch; }
p.note { color: #8a8a9e; margin: 0 0 20px; max-width: 62ch; }
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
padding: 16px; margin-bottom: 16px; max-width: 60ch; }
padding: 16px; margin-bottom: 16px; max-width: 62ch; }
.card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em;
color: #8a8a9e; margin: 0 0 12px; font-weight: 600; }
.row { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
.row span:last-child { color: #7fd1e8; }
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
button:hover { background: #363648; }
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer;
touch-action: manipulation; user-select: none; }
button:hover:not(:disabled) { background: #363648; }
button:disabled { opacity: .35; cursor: not-allowed; }
button.stop { background: #5a1f27; border-color: #7d2b35; }
button.stop:hover { background: #6d2630; }
button.holding { background: #1f4a5a; border-color: #2b6d7d; }
.pad { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; max-width: 280px; }
.pad button { padding: 14px 0; }
.pad .blank { visibility: hidden; }
.pads { display: flex; flex-wrap: wrap; gap: 24px; }
.pads figure { margin: 0; }
.pads figcaption { color: #8a8a9e; padding-bottom: 8px; }
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
label.speed { display: flex; align-items: center; gap: 10px; color: #8a8a9e; }
label.speed input { flex: 1; }
#owner { color: #7fd1e8; }
#toast { color: #e8a37f; min-height: 1.5em; }
#link { color: #8a8a9e; }
</style>
</head>
<body>
<h1>teleop — placeholder console</h1>
<h1>teleop console</h1>
<p class="note">
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.
</p>
<div class="card" id="state"></div>
<div class="card">
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
<button onclick="send({type:'control', action:'release'})">Release</button>
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
<button onclick="send({type:'reset'})">Reset</button>
<h2>State</h2>
<div id="state"></div>
</div>
<div class="card">
<h2>Control</h2>
<div class="bar">
<button id="take">Take control</button>
<button id="release">Release</button>
<button class="stop" id="estop">E-STOP</button>
<button id="reset">Reset</button>
</div>
</div>
<div class="card" id="drive">
<h2>Move</h2>
<div class="pads">
<figure>
<figcaption>translation</figcaption>
<div class="pad">
<button class="blank"></button>
<button data-axis="0" data-sign="1">+X</button>
<button data-axis="2" data-sign="1">+Z</button>
<button data-axis="1" data-sign="-1">&minus;Y</button>
<button data-axis="0" data-sign="-1">&minus;X</button>
<button data-axis="1" data-sign="1">+Y</button>
<button class="blank"></button>
<button class="blank"></button>
<button data-axis="2" data-sign="-1">&minus;Z</button>
</div>
</figure>
<figure>
<figcaption>rotation</figcaption>
<div class="pad">
<button class="blank"></button>
<button data-axis="4" data-sign="1">+pitch</button>
<button data-axis="5" data-sign="1">+yaw</button>
<button data-axis="3" data-sign="-1">&minus;roll</button>
<button data-axis="4" data-sign="-1">&minus;pitch</button>
<button data-axis="3" data-sign="1">+roll</button>
<button class="blank"></button>
<button class="blank"></button>
<button data-axis="5" data-sign="-1">&minus;yaw</button>
</div>
</figure>
</div>
<p></p>
<label class="speed">
speed
<input id="speed" type="range" min="10" max="100" step="5" value="60">
<span id="speedout">60%</span>
</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 id="toast"></div>
<div id="link"></div>
<script>
"use strict";
const KEYS = ["arm_state", "owner", "connected", "ready", "estop", "vacuum", "freedrive"];
// 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 refreshes a jog every tick and stops the robot if the stream dries
// up (JOG_TTL_S, 0.4 s). Resending faster than that is what makes releasing a
// button a stop, with nothing needing to be sent to say so.
const REFRESH_MS = 150;
let socket = null;
let owner = "none";
// One axis per degree of freedom: [x, y, z, roll, pitch, yaw], each -1..1. A
// fraction of full speed, never a velocity.
let axes = [0, 0, 0, 0, 0, 0];
const held = new Map();
let refresh = null;
function send(payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
@@ -51,15 +141,121 @@ function send(payload) {
}
}
function scale() {
return Number(document.getElementById("speed").value) / 100;
}
function recompute() {
axes = [0, 0, 0, 0, 0, 0];
for (const [, { axis, sign }] of held) axes[axis] = sign * scale();
}
function moving() {
return axes.some((value) => value !== 0);
}
function pump() {
if (moving()) send({ type: "jog", axes });
}
function press(button) {
if (button.disabled) return;
held.set(button, { axis: Number(button.dataset.axis), sign: Number(button.dataset.sign) });
button.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(button) {
if (!held.has(button)) return;
held.delete(button);
button.classList.remove("holding");
recompute();
if (!moving()) {
// Say so rather than going quiet: the deadman would stop it either way,
// but a button released is an intention and 400 ms of travel is not.
send({ type: "jog_stop" });
clearInterval(refresh);
refresh = null;
}
}
function releaseEverything() {
for (const button of [...held.keys()]) letGo(button);
}
function wire() {
for (const button of document.querySelectorAll(".pad button[data-axis]")) {
button.addEventListener("pointerdown", (event) => {
// Keeps the pointer's events coming to this button even if the finger
// slides off it, so letting go always reaches letGo().
button.setPointerCapture(event.pointerId);
press(button);
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
button.addEventListener(name, () => letGo(button));
}
button.addEventListener("contextmenu", (event) => event.preventDefault());
}
// A tab that goes away mid-jog must not leave the robot travelling. The node
// and the bridge both cover this; doing it here too 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 });
});
}
let current = {};
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} mm</span></div>`);
rows.push(`<div class="row"><span>position</span><span>${shown} ${unit}</span></div>`);
}
document.getElementById("state").innerHTML = rows.join("");
// The jog pad is gated on holding the lease, so a button that would be
// refused looks refused. E-STOP and Reset never are.
owner = telemetry.owner;
const mine = owner === "web";
for (const id of ["close", "open", "freedrive", "home"]) {
document.getElementById(id).disabled = !mine;
}
for (const button of document.querySelectorAll(".pad button[data-axis]")) {
button.disabled = !mine;
}
if (!mine) releaseEverything();
}
function connect() {
@@ -72,13 +268,18 @@ function connect() {
const message = JSON.parse(event.data);
if (message.type !== "data" || !message.payload) return;
if (message.payload.type === "telemetry") render(message.payload);
if (message.payload.type === "error") {
document.getElementById("toast").textContent = message.payload.detail;
}
};
socket.onclose = () => {
releaseEverything();
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
setTimeout(connect, 2000);
};
}
wire();
connect();
</script>
</body>