Read the gamepad from dora-joy, and speak a robot-agnostic contract #2
@@ -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
@@ -3,47 +3,137 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>teleop — placeholder console</title>
|
<title>teleop console</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
body { margin: 0; padding: 24px; background: #15151c; color: #e6e6ef;
|
||||||
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
h1 { font-size: 16px; margin: 0 0 4px; }
|
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;
|
.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 { display: flex; justify-content: space-between; gap: 16px; padding: 3px 0; }
|
||||||
.row span:last-child { color: #7fd1e8; }
|
.row span:last-child { color: #7fd1e8; }
|
||||||
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
button { background: #2c2c3a; color: #e6e6ef; border: 1px solid #3c3c4e;
|
||||||
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer; }
|
border-radius: 6px; padding: 8px 14px; font: inherit; cursor: pointer;
|
||||||
button:hover { background: #363648; }
|
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 { 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; }
|
#link { color: #8a8a9e; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>teleop — placeholder console</h1>
|
<h1>teleop console</h1>
|
||||||
<p class="note">
|
<p class="note">
|
||||||
Not the real console. This page exists so the dataflow has something to serve and so the
|
Hold a direction to move; release to stop. The page never sends a speed in the robot's
|
||||||
node can be checked end to end before the Control section is ported across from the
|
units — it sends a fraction of full deflection, exactly as a gamepad stick does, and the
|
||||||
monolithic repo. It reads telemetry and can take, release and stop — nothing else.
|
node scales it. That is why this console does not need to know what robot is on the
|
||||||
|
other end.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card" id="state"></div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<button onclick="send({type:'control', action:'take', preempt:true})">Take control</button>
|
<h2>State</h2>
|
||||||
<button onclick="send({type:'control', action:'release'})">Release</button>
|
<div id="state"></div>
|
||||||
<button class="stop" onclick="send({type:'estop'})">E-STOP</button>
|
|
||||||
<button onclick="send({type:'reset'})">Reset</button>
|
|
||||||
</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">−Y</button>
|
||||||
|
<button data-axis="0" data-sign="-1">−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">−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">−roll</button>
|
||||||
|
<button data-axis="4" data-sign="-1">−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">−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>
|
<div id="link"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
"use strict";
|
"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 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) {
|
function send(payload) {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
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) {
|
function render(telemetry) {
|
||||||
|
current = telemetry;
|
||||||
const rows = KEYS.map((key) =>
|
const rows = KEYS.map((key) =>
|
||||||
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
`<div class="row"><span>${key}</span><span>${telemetry[key]}</span></div>`);
|
||||||
const pose = telemetry.position;
|
const pose = telemetry.position;
|
||||||
if (pose) {
|
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(", ");
|
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("");
|
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() {
|
function connect() {
|
||||||
@@ -72,13 +268,18 @@ function connect() {
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
if (message.type !== "data" || !message.payload) return;
|
if (message.type !== "data" || !message.payload) return;
|
||||||
if (message.payload.type === "telemetry") render(message.payload);
|
if (message.payload.type === "telemetry") render(message.payload);
|
||||||
|
if (message.payload.type === "error") {
|
||||||
|
document.getElementById("toast").textContent = message.payload.detail;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
releaseEverything();
|
||||||
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
|
||||||
setTimeout(connect, 2000);
|
setTimeout(connect, 2000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wire();
|
||||||
connect();
|
connect();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user