|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8" /> |
|
|
<title>Joint stream</title> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
|
|
<style> |
|
|
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 16px; } |
|
|
#status { margin-bottom: 8px; } |
|
|
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; } |
|
|
th, td { border: 1px solid #ddd; padding: 6px 8px; text-align: left; } |
|
|
th { background: #f4f4f4; position: sticky; top: 0; } |
|
|
pre { background: #f8f8f8; border: 1px solid #eee; padding: 8px; overflow: auto; max-height: 30vh; } |
|
|
.ok { color: #0a0; } |
|
|
.err { color: #a00; } |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<h3>Joint stream</h3> |
|
|
<div id="status">Connecting…</div> |
|
|
|
|
|
<table id="joint-table" aria-label="Joint table"> |
|
|
<thead></thead> |
|
|
<tbody></tbody> |
|
|
</table> |
|
|
|
|
|
<h4>Last payload</h4> |
|
|
<pre id="raw"></pre> |
|
|
|
|
|
<script> |
|
|
|
|
|
const WS_URL = new URLSearchParams(location.search).get("ws") |
|
|
|| "ws://127.0.0.1:8000/api/state/ws/full"; |
|
|
|
|
|
const statusEl = document.getElementById("status"); |
|
|
const rawEl = document.getElementById("raw"); |
|
|
const thead = document.querySelector("#joint-table thead"); |
|
|
const tbody = document.querySelector("#joint-table tbody"); |
|
|
|
|
|
let ws; |
|
|
let latestPayload = null; |
|
|
let rafScheduled = false; |
|
|
let columns = ["Joint", "position", "velocity", "torque"]; |
|
|
|
|
|
function setStatus(text, cls="") { |
|
|
statusEl.className = cls; |
|
|
statusEl.textContent = text; |
|
|
} |
|
|
|
|
|
function connect() { |
|
|
try { |
|
|
ws = new WebSocket(WS_URL); |
|
|
} catch (e) { |
|
|
setStatus("Invalid WebSocket URL: " + e.message, "err"); |
|
|
return; |
|
|
} |
|
|
|
|
|
ws.onopen = () => setStatus("Connected: " + WS_URL, "ok"); |
|
|
ws.onclose = (e) => setStatus(`Disconnected (${e.code}) — reconnecting in 1s…`, "err"); |
|
|
ws.onerror = () => setStatus("WebSocket error — see console", "err"); |
|
|
ws.onmessage = (event) => { |
|
|
try { |
|
|
latestPayload = JSON.parse(event.data); |
|
|
scheduleRender(); |
|
|
} catch (e) { |
|
|
setStatus("Bad JSON from server", "err"); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
ws.addEventListener("close", () => setTimeout(connect, 1000), { once: true }); |
|
|
} |
|
|
|
|
|
function scheduleRender() { |
|
|
if (rafScheduled) return; |
|
|
rafScheduled = true; |
|
|
requestAnimationFrame(() => { |
|
|
rafScheduled = false; |
|
|
if (latestPayload) render(latestPayload); |
|
|
}); |
|
|
} |
|
|
|
|
|
function render(payload) { |
|
|
rawEl.textContent = JSON.stringify(payload, null, 2); |
|
|
const joints = pickJoints(payload); |
|
|
const rows = normalizeJoints(joints); |
|
|
if (!rows.length) { |
|
|
thead.innerHTML = ""; |
|
|
tbody.innerHTML = ""; |
|
|
return; |
|
|
} |
|
|
|
|
|
const fieldSet = new Set(["Joint", ...columns.slice(1)]); |
|
|
for (const r of rows) for (const k of Object.keys(r)) fieldSet.add(k); |
|
|
|
|
|
const cols = ["Joint", ...[...fieldSet].filter(c => c !== "Joint")]; |
|
|
if (JSON.stringify(cols) !== JSON.stringify(columns)) { |
|
|
columns = cols; |
|
|
renderHeader(columns); |
|
|
} |
|
|
renderBody(rows, columns); |
|
|
} |
|
|
|
|
|
function renderHeader(cols) { |
|
|
const tr = document.createElement("tr"); |
|
|
for (const c of cols) { |
|
|
const th = document.createElement("th"); |
|
|
th.textContent = c; |
|
|
tr.appendChild(th); |
|
|
} |
|
|
thead.innerHTML = ""; |
|
|
thead.appendChild(tr); |
|
|
} |
|
|
|
|
|
function renderBody(rows, cols) { |
|
|
const frag = document.createDocumentFragment(); |
|
|
for (const r of rows) { |
|
|
const tr = document.createElement("tr"); |
|
|
for (const c of cols) { |
|
|
const td = document.createElement("td"); |
|
|
const v = r[c]; |
|
|
td.textContent = v === undefined ? "" : (typeof v === "number" ? Number(v).toFixed(4) : String(v)); |
|
|
tr.appendChild(td); |
|
|
} |
|
|
frag.appendChild(tr); |
|
|
} |
|
|
tbody.innerHTML = ""; |
|
|
tbody.appendChild(frag); |
|
|
} |
|
|
|
|
|
function pickJoints(obj) { |
|
|
if (!obj || typeof obj !== "object") return null; |
|
|
|
|
|
if (obj.joints) return obj.joints; |
|
|
if (obj.state && obj.state.joints) return obj.state.joints; |
|
|
if (obj.motors) return obj.motors; |
|
|
if (obj.body && obj.body.joints) return obj.body.joints; |
|
|
return obj.joints_state || null; |
|
|
} |
|
|
|
|
|
function normalizeJoints(joints) { |
|
|
if (!joints) return []; |
|
|
const out = []; |
|
|
|
|
|
const preferredKeys = ["position","pos","angle","velocity","vel","speed","torque","effort","current","temperature","temp","value"]; |
|
|
|
|
|
const extractFields = (o) => { |
|
|
const row = {}; |
|
|
for (const k of preferredKeys) { |
|
|
if (o && typeof o === "object" && k in o && isFinite(o[k])) row[k] = Number(o[k]); |
|
|
} |
|
|
|
|
|
for (const [k,v] of Object.entries(o || {})) { |
|
|
if (!(k in row) && isFinite(v)) row[k] = Number(v); |
|
|
} |
|
|
return row; |
|
|
}; |
|
|
|
|
|
if (Array.isArray(joints)) { |
|
|
for (const item of joints) { |
|
|
if (item == null) continue; |
|
|
let name = item.name ?? item.id ?? item.label ?? ""; |
|
|
|
|
|
if (!name && typeof item === "object" && !Array.isArray(item)) { |
|
|
const keys = Object.keys(item); |
|
|
if (keys.length === 1) { |
|
|
name = keys[0]; |
|
|
const fields = extractFields(item[name]); |
|
|
out.push({ Joint: name, ...fields }); |
|
|
continue; |
|
|
} |
|
|
} |
|
|
const fields = extractFields(item); |
|
|
out.push({ Joint: String(name || out.length), ...fields }); |
|
|
} |
|
|
return out; |
|
|
} |
|
|
|
|
|
if (typeof joints === "object") { |
|
|
for (const [name, val] of Object.entries(joints)) { |
|
|
if (val != null && typeof val === "object") { |
|
|
out.push({ Joint: name, ...extractFields(val) }); |
|
|
} else if (isFinite(val)) { |
|
|
out.push({ Joint: name, value: Number(val) }); |
|
|
} |
|
|
} |
|
|
return out; |
|
|
} |
|
|
|
|
|
return out; |
|
|
} |
|
|
|
|
|
connect(); |
|
|
</script> |
|
|
</body> |
|
|
</html> |