Files
dora-teleop/dora_teleop/web/index.html
cristhian da1b079b7b
validate / validate (pull_request) Successful in 58m41s
validate / test (pull_request) Successful in 58m44s
validate / markdownlint (pull_request) Successful in 58m40s
serve the web console from the node and drop the websocket bridge
The browser reached this node through `dora-websocket-transceiver`: a second
node, a second port, and a round trip through the dataflow for every button
press, to carry a UI only this node can interpret. The node now serves the
page and holds the browsers' sockets itself on one port, so the console is
one URL and a consumer wires nothing for it.

`dora_teleop/server.py` runs aiohttp on a thread of its own and meets the
control loop at two non-blocking points: a bounded queue the tick drains, and
a snapshot handed back. Both queues are bounded deliberately — a flood is
dropped rather than grown, and a slow browser loses stale snapshots rather
than the newest — because nothing a browser does may stall the thread that
stops the arm. A port already in use stops the node rather than leaving it
announcing a console nobody can open.

The web deadman moved but did not change: it was a `clients` message from the
bridge and is now the open-socket count read each tick. One hop fewer, same
meaning.

Contract, so 0.2.0 -> 0.3.0: `ui_command` and `clients` are gone. `ui_state`
stays published for `dora topic echo` and the bench, with no browser reading
it. New WEB_HOST and WEB_PORT (7000; 0 opens no port).

The console itself is laid out like the arm's own pendant — two rings
quartered into arc sectors you hold, each with its out-of-plane axis as pills
above. Directions and signs match xArm Studio on the controller, `Y+` on the
left included: two consoles driving one arm that disagree about which way is
+Y is a trap, not a preference.

Also sends a velocity only when it changes, repeating an unchanged one on
JOG_KEEPALIVE_S (0.2 s) purely to beat the consumer's own expiry. Repeating
every tick made `dora-ulite6` re-plan the same motion twenty times a second.
That turned out not to be the whole cause of the stepping an operator felt —
the rest was fixed in dora-ulite6 v0.3.1, pinned here — but sending 18
messages where 83 went before is worth keeping on its own.

`dora-joy` moves to v0.1.1: v0.1 is an annotated tag, and dora 1.0.1 records
the tag object's id as the commit hash and then rejects its own checkout for
sitting on the peeled commit. Neither dataflow could be built before this.
2026-09-16 14:50:21 -03:00

450 lines
14 KiB
HTML

<title>teleop console</title>
<style>
:root {
color-scheme: dark;
--bg: #101016;
--card: #1a1a23;
--edge: #2b2b38;
--ink: #e8e8f0;
--dim: #8a8a9e;
--live: #7fd1e8;
--warn: #e8a37f;
--stop: #e2564a;
--go: #43c98b;
}
* { box-sizing: border-box; }
body {
margin: 0;
padding-block: 16px;
padding-inline: 16px;
background: var(--bg);
color: var(--ink);
font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.wrap { max-width: 980px; margin: 0 auto; }
header {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
margin-bottom: 14px;
}
header h1 { font-size: 15px; margin: 0; margin-right: auto; letter-spacing: .04em; }
.chip {
padding: 5px 12px; border-radius: 999px; border: 1px solid var(--edge);
background: var(--card); font-size: 13px; white-space: nowrap;
}
.chip b { color: var(--live); font-weight: 600; }
.chip.estop { border-color: var(--stop); color: var(--stop); }
.chip.mine { border-color: var(--go); }
.chip.off { color: var(--dim); }
.card {
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
padding: 14px; margin-bottom: 12px;
}
.card h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
color: var(--dim); margin: 0 0 12px; font-weight: 600;
}
.bar { display: flex; flex-wrap: wrap; gap: 8px; }
button {
flex: 1 1 auto; min-height: 48px; min-width: 104px;
background: #272733; color: var(--ink); border: 1px solid #383848;
border-radius: 9px; padding: 10px 16px; font: inherit; cursor: pointer;
touch-action: manipulation;
}
button:active:not(:disabled) { background: #33334a; }
button:disabled { opacity: .3; cursor: not-allowed; }
button.stop { background: #55202a; border-color: var(--stop); color: #ffd9d4; }
button.on { background: #1f4a5a; border-color: var(--live); }
/* --- the jog rings --- */
.decks { display: flex; flex-wrap: wrap; gap: 14px; }
.deck {
flex: 1 1 340px;
background: var(--card); border: 1px solid var(--edge); border-radius: 12px;
padding: 14px;
}
.deck h2 {
font-size: 11px; text-transform: uppercase; letter-spacing: .1em;
color: var(--dim); margin: 0 0 12px; font-weight: 600;
}
.pills { display: flex; gap: 8px; margin-bottom: 12px; }
.ring { display: block; width: 100%; max-width: 330px; margin-inline: auto; touch-action: none; }
.ring .sector {
fill: #23232f; stroke: var(--edge); stroke-width: 1.5; cursor: pointer;
}
.ring .sector.holding { fill: var(--live); stroke: #bfe9f5; }
.ring .label {
fill: #a6a6bd; font-size: 15px; text-anchor: middle; dominant-baseline: middle;
pointer-events: none; font-family: inherit;
}
.ring .sector.holding + .label { fill: #0b1013; font-weight: 700; }
.ring .hub {
fill: none; stroke: var(--edge); stroke-width: 1.5;
}
.ring .hubtext {
fill: var(--dim); font-size: 13px; text-anchor: middle; dominant-baseline: middle;
pointer-events: none; font-family: inherit; letter-spacing: .08em;
}
.ring[data-off="true"] { opacity: .32; }
.ring[data-off="true"] .sector { cursor: not-allowed; }
label.speed { display: flex; align-items: center; gap: 12px; color: var(--dim); }
label.speed input { flex: 1; height: 34px; }
label.speed b { color: var(--live); min-width: 4ch; text-align: right; }
.readout { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 2px 18px; }
.row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.row span:last-child { color: var(--live); }
#toast { color: var(--warn); min-height: 1.5em; padding: 4px 2px; }
#link { color: var(--dim); font-size: 12px; }
@media (max-width: 560px) {
body { padding-inline: 10px; }
.deck { padding: 10px; }
button { min-width: 84px; }
}
</style>
<div class="wrap">
<header>
<h1>teleop console</h1>
<span class="chip" id="chip-state">—</span>
<span class="chip" id="chip-owner">—</span>
</header>
<div class="card">
<h2>Control</h2>
<div class="bar">
<button id="take">Take control</button>
<button id="release">Release</button>
<button id="reset">Reset</button>
<button class="stop" id="estop">E&#8209;STOP</button>
</div>
</div>
<div class="decks">
<div class="deck">
<h2>Translate</h2>
<div class="pills">
<button data-axis="2" data-sign="-1">&minus;Z</button>
<button data-axis="2" data-sign="1">+Z</button>
</div>
<svg class="ring" id="translate" viewBox="0 0 200 200" aria-label="translate">
<g data-north="0,1" data-south="0,-1" data-west="1,1" data-east="1,-1"
data-labels="+X,&#8722;X,Y+,Y&#8722;" data-hub="XYZ"></g>
</svg>
</div>
<div class="deck">
<h2>Rotate</h2>
<div class="pills">
<button data-axis="5" data-sign="-1">&minus;yaw</button>
<button data-axis="5" data-sign="1">+yaw</button>
</div>
<svg class="ring" id="rotate" viewBox="0 0 200 200" aria-label="rotate">
<g data-north="4,1" data-south="4,-1" data-west="3,-1" data-east="3,1"
data-labels="+pitch,&#8722;pitch,&#8722;roll,+roll" data-hub="RXYZ"></g>
</svg>
</div>
</div>
<div class="card">
<h2>Speed</h2>
<label class="speed">
<input id="speed" type="range" min="10" max="100" step="5" value="60">
<b id="speedout">60%</b>
</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 class="card">
<h2>State</h2>
<div class="readout" id="state"></div>
</div>
<div id="toast"></div>
<div id="link"></div>
</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 stops the robot if the jog stream dries up (JOG_TTL_S, 0.4 s).
// Resending faster than that is what makes letting go a stop, with nothing
// needing to be sent to say so.
const REFRESH_MS = 150;
// The ring, laid out like the arm's own pendant so the two do not disagree:
// a quarter each, north at the top, and a hub that is label rather than button.
const GAP_DEG = 3;
const OUTER = 95;
const INNER = 46;
const LABEL_R = 71;
const COMPASS = [
["north", 225, 315],
["east", 315, 405],
["south", 45, 135],
["west", 135, 225],
];
let socket = null;
let owner = "none";
let current = {};
let axes = [0, 0, 0, 0, 0, 0];
let refresh = null;
// Every direction currently held, as element -> {axis, sign}. A map rather than
// one value because two fingers may hold two directions, and lifting one must
// not cancel the other.
const held = new Map();
// Which element each finger is on, so a finger that slides off still releases
// the direction it started on.
const fingers = new Map();
function send(payload) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(payload));
}
}
function scale() {
return Number(document.getElementById("speed").value) / 100;
}
function recompute() {
axes = [0, 0, 0, 0, 0, 0];
const dial = scale();
for (const [, { axis, sign }] of held) axes[axis] += sign * dial;
axes = axes.map((v) => Math.max(-1, Math.min(1, v)));
}
function moving() {
return axes.some((value) => value !== 0);
}
function pump() {
if (moving()) send({ type: "jog", axes });
}
// --- drawing the ring ---
function onCircle(radius, degrees) {
const radians = (degrees * Math.PI) / 180;
return [100 + radius * Math.cos(radians), 100 + radius * Math.sin(radians)];
}
function sectorPath(from, to) {
const [ax, ay] = onCircle(OUTER, from);
const [bx, by] = onCircle(OUTER, to);
const [cx, cy] = onCircle(INNER, to);
const [dx, dy] = onCircle(INNER, from);
return `M${ax} ${ay}A${OUTER} ${OUTER} 0 0 1 ${bx} ${by}`
+ `L${cx} ${cy}A${INNER} ${INNER} 0 0 0 ${dx} ${dy}Z`;
}
function svg(name, attributes) {
const node = document.createElementNS("http://www.w3.org/2000/svg", name);
for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
return node;
}
function buildRing(ring) {
const group = ring.querySelector("g");
const labels = group.dataset.labels.split(",");
// Labelled in compass order so the markup reads the way the ring looks:
// north, south, west, east.
const naming = { north: labels[0], south: labels[1], west: labels[2], east: labels[3] };
for (const [point, from, to] of COMPASS) {
const [axis, sign] = group.dataset[point].split(",").map(Number);
const sector = svg("path", {
d: sectorPath(from + GAP_DEG, to - GAP_DEG),
class: "sector",
});
sector.dataset.axis = axis;
sector.dataset.sign = sign;
group.appendChild(sector);
const middle = (from + to) / 2;
const [lx, ly] = onCircle(LABEL_R, middle);
const text = svg("text", { x: lx, y: ly, class: "label" });
text.textContent = naming[point];
group.appendChild(text);
}
group.appendChild(svg("circle", { cx: 100, cy: 100, r: INNER - 6, class: "hub" }));
const hub = svg("text", { x: 100, y: 100, class: "hubtext" });
hub.textContent = group.dataset.hub;
group.appendChild(hub);
}
// --- holding a direction ---
function grab(element) {
if (held.has(element) || element.closest("[data-off=\"true\"]")) return;
held.set(element, { axis: Number(element.dataset.axis), sign: Number(element.dataset.sign) });
element.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(element) {
if (!held.delete(element)) return;
element.classList.remove("holding");
recompute();
if (moving()) return;
// Say so rather than going quiet: the deadman would stop it either way, but a
// direction released is an intention and 400 ms of travel is not.
send({ type: "jog_stop" });
clearInterval(refresh);
refresh = null;
}
function releaseEverything() {
for (const element of [...held.keys()]) letGo(element);
fingers.clear();
}
function wireDirection(element) {
element.addEventListener("pointerdown", (event) => {
// Keeps this finger's events coming even once it slides off, so letting go
// always reaches letGo().
element.setPointerCapture(event.pointerId);
fingers.set(event.pointerId, element);
grab(element);
});
for (const name of ["pointerup", "pointercancel", "lostpointercapture"]) {
element.addEventListener(name, (event) => {
fingers.delete(event.pointerId);
letGo(element);
});
}
element.addEventListener("contextmenu", (event) => event.preventDefault());
}
function wire() {
for (const ring of document.querySelectorAll(".ring")) buildRing(ring);
for (const element of document.querySelectorAll(".sector, .pills button[data-axis]")) {
wireDirection(element);
}
// A tab that goes away mid-jog must not leave the robot travelling. The node
// covers this too; doing it here 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 });
});
}
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("");
const state = document.getElementById("chip-state");
state.innerHTML = `arm <b>${telemetry.arm_state}</b>`;
state.className = `chip${telemetry.estop ? " estop" : ""}`;
owner = telemetry.owner;
const mine = owner === "web";
const chip = document.getElementById("chip-owner");
chip.innerHTML = mine ? "<b>you have the arm</b>" : `held by <b>${owner}</b>`;
chip.className = `chip ${mine ? "mine" : "off"}`;
document.getElementById("freedrive").classList.toggle("on", !!telemetry.freedrive);
document.getElementById("close").classList.toggle("on", !!telemetry.gripper);
// Every control is gated on holding the lease, so one that would be refused
// looks refused. E-STOP and Reset never are.
for (const id of ["close", "open", "freedrive", "home"]) {
document.getElementById(id).disabled = !mine;
}
for (const ring of document.querySelectorAll(".ring")) {
ring.dataset.off = String(!mine);
}
for (const pill of document.querySelectorAll(".pills button[data-axis]")) {
pill.disabled = !mine;
}
if (!mine) releaseEverything();
}
function connect() {
// The node serves this page and holds the socket, so the socket is on this
// page's own origin — nothing to configure and nothing to keep in step.
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${scheme}//${location.host}/ws`;
document.getElementById("link").textContent = `socket: ${url}`;
socket = new WebSocket(url);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "telemetry") render(message);
if (message.type === "error") {
document.getElementById("toast").textContent = message.detail;
}
};
socket.onclose = () => {
releaseEverything();
document.getElementById("link").textContent = `socket: ${url} — closed, retrying`;
setTimeout(connect, 2000);
};
}
wire();
connect();
</script>