Files
dora-teleop/web/index.html
T
cristhian 177511af1f 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.
2026-09-16 12:18:57 -05:00

287 lines
9.8 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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: 62ch; }
.card { background: #1d1d26; border: 1px solid #2c2c3a; border-radius: 8px;
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;
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 console</h1>
<p class="note">
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">
<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";
// 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) {
socket.send(JSON.stringify({ type: "data", 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} ${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() {
// The socket is the bridge's port, not this page's — the page is served over
// HTTP on a different one.
const url = `ws://${location.hostname}:9000`;
document.getElementById("link").textContent = `socket: ${url}`;
socket = new WebSocket(url);
socket.onmessage = (event) => {
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>
</html>