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.
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
<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‑STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="decks">
|
||||
<div class="deck">
|
||||
<h2>Translate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="2" data-sign="-1">−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,−X,Y+,Y−" data-hub="XYZ"></g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="deck">
|
||||
<h2>Rotate</h2>
|
||||
<div class="pills">
|
||||
<button data-axis="5" data-sign="-1">−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,−pitch,−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>
|
||||
Reference in New Issue
Block a user