openvibers/openvibe.bot Commits

Add joystick, gamepad, latency meter, camera tiles and panel design

OP OpenVibers committed 4 Oct 2026 · 1fbb31482f · 6 files, +735 −151

Asked: T15 step R8b — OpenVibe.Bot: panel interactions and the panel's visual design, on top of R8 (merged as Bot #20, 'Add signed-in robot pages, the profile-rendered panel and a sim device'). ONE pull request from current origin/main (git fetch first; the local checkout may be stale, read with `git show…
Harness job bot-t15-step-r8b-openvibe-bot-173444 (plan T15).

Changes

ddiff --git a/README.md b/README.md
iindex 858d115..d3e9a87 100644
---- a/README.md
++++ b/README.md
@@@ -12,7 +12,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
--- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox.
++- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/dispatch.js`, no HTTP route) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
ddiff --git a/STATUS.json b/STATUS.json
iindex a6b9c6b..37b3c49 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -37,6 +37,7 @@
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
++ "panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record"
],
"updated": "2026-10-04"
ddiff --git a/public/panel.css b/public/panel.css
iindex 97b998f..3cbd489 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -1,48 +1,205 @@
--/* The panel's one sheet: mobile-first, every control at least 44 px, the e-stop banner always in view.
-- Functional only; the visual design is done separately. */
++/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at
++ least 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */
++:root {
++ --bg: #0d1117; --surface: #161b22; --raised: #1f2630; --line: #2d3540; --text: #e6edf3; --muted: #8b96a3;
++ --accent: #3d8bfd; --accent-ink: #fff; --ok: #3fb950; --warn: #d29922; --danger: #e5484d; --danger-deep: #a3191e;
++ --radius: 12px; --tap: 44px;
++ color-scheme: dark;
++}
*, *::before, *::after { box-sizing: border-box; }
--body { margin: 0; font: 16px/1.4 system-ui, sans-serif; color: #111; background: #f6f6f6; }
--main { padding: 1rem; max-width: 64rem; margin: 0 auto; }
--h1 { font-size: 1.4rem; margin: 0.5rem 0; }
--h2 { font-size: 1rem; margin: 0 0 0.5rem; }
--button, input, select { font: inherit; min-height: 44px; min-width: 44px; }
--button { padding: 0 0.9rem; border: 1px solid #888; border-radius: 6px; background: #fff; touch-action: manipulation; }
--button:disabled, input:disabled, select:disabled { opacity: 0.45; }
--input[type="range"] { width: 100%; }
--label, .control { display: block; margin: 0.4rem 0; }
--
--/* The e-stop banner: fixed to the top of every panel, red when latched. */
--.estop-banner { position: sticky; top: 0; z-index: 10; display: flex; gap: 0.75rem; align-items: center; justify-content: space-between;
-- padding: 0.5rem 1rem; background: #222; color: #fff; }
--.estop-banner[data-latched="true"] { background: #b00020; }
--.estop-banner .estop { min-height: 56px; min-width: 96px; font-weight: 700; background: #d50000; color: #fff; border-color: #fff; }
--.estop-banner .estop-clear { background: #fff; color: #111; }
--
--.meta { color: #555; margin: 0 0 1rem; }
--.notice, .error { padding: 0.5rem 0.75rem; background: #fff3cd; border: 1px solid #e0c060; border-radius: 6px; }
--
--/* Widgets: one column on a phone, more as the screen allows. */
--.widgets { display: grid; gap: 0.75rem; grid-template-columns: 1fr; }
--@media (min-width: 40rem) { .widgets { grid-template-columns: repeat(2, 1fr); } }
--@media (min-width: 64rem) { .widgets { grid-template-columns: repeat(3, 1fr); } }
--.widget { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem; }
--.widget-camera { grid-column: 1 / -1; }
--.camera-placeholder { aspect-ratio: 16 / 9; display: grid; place-items: center; background: #222; color: #ccc; border-radius: 6px; }
--
--/* Hold-to-move pads. */
--.pad { display: grid; gap: 0.5rem; }
++[hidden] { display: none !important; }
++html { -webkit-text-size-adjust: 100%; }
++body { margin: 0; min-height: 100vh; font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: var(--text); background: var(--bg); }
++a { color: var(--accent); }
++h1 { font-size: 1.5rem; line-height: 1.2; margin: 0 0 0.25rem; letter-spacing: -0.01em; }
++h2 { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 0.75rem; }
++.icon { width: 1.25em; height: 1.25em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
++.muted { color: var(--muted); }
++kbd { font: 0.8em ui-monospace, monospace; padding: 0.05em 0.35em; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; background: var(--raised); }
++
++/* Controls */
++button, .button, input, select { font: inherit; min-height: var(--tap); min-width: var(--tap); border-radius: 10px; }
++button, .button {
++ display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; padding: 0 1rem;
++ border: 1px solid var(--line); background: var(--raised); color: var(--text); text-decoration: none; cursor: pointer;
++ touch-action: manipulation; -webkit-tap-highlight-color: transparent; transition: background 0.12s, border-color 0.12s, transform 0.06s;
++}
++button:hover:not(:disabled), .button:hover { border-color: #46505d; }
++button:active:not(:disabled) { transform: scale(0.97); }
++button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 600; }
++button.quiet, .button.quiet { background: transparent; }
++button:disabled, input:disabled, select:disabled { opacity: 0.4; cursor: not-allowed; }
++:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
++input[type="text"], select { width: 100%; padding: 0 0.75rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); }
++input[type="color"] { width: 3.5rem; padding: 0.2rem; border: 1px solid var(--line); background: var(--bg); }
++input[type="range"] { width: 100%; accent-color: var(--accent); }
++input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width: 0; accent-color: var(--accent); }
++
++.pill { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 1.75rem; padding: 0 0.65rem; border-radius: 999px; background: var(--raised); border: 1px solid var(--line); color: var(--muted); font-size: 0.85rem; white-space: nowrap; }
++.dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--muted); flex: none; }
++.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
++.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.5); color: #f0d48a; }
++
++/* Top bar */
++.topbar { display: flex; align-items: center; gap: 0.75rem; min-height: 3rem; padding: 0 max(1rem, env(safe-area-inset-left)); }
++.brand { display: inline-flex; align-items: center; gap: 0.5rem; min-height: var(--tap); color: var(--text); text-decoration: none; font-weight: 600; }
++.brand b { color: var(--accent); font-weight: 600; }
++.crumb { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }
++
++/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */
++.estop-banner { position: sticky; top: 0; z-index: 10; background: rgba(13, 17, 23, 0.94); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
++.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }
++.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }
++.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }
++.estop-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(63, 185, 80, 0.18); flex: none; }
++.estop {
++ min-height: 56px; min-width: 112px; padding: 0 1.1rem; border: 2px solid #ff8a8d; border-radius: 14px;
++ background: var(--danger); color: #fff; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase;
++ box-shadow: 0 4px 0 var(--danger-deep), 0 6px 16px rgba(229, 72, 77, 0.35);
++}
++.estop .icon { width: 1.5em; height: 1.5em; stroke-width: 2.4; }
++.estop:active:not(:disabled) { transform: translateY(3px); box-shadow: 0 1px 0 var(--danger-deep); }
++.estop-clear { background: #fff; color: #111; border-color: #fff; font-weight: 700; }
++.estop-banner[data-latched="true"] { background: var(--danger-deep); border-bottom-color: var(--danger); }
++.estop-banner[data-latched="true"] .crumb, .estop-banner[data-latched="true"] .brand b { color: #ffd1d2; }
++.estop-banner[data-latched="true"] .estop-dot { background: #fff; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.25); animation: pulse 1.2s ease-in-out infinite; }
++.estop-banner[data-latched="true"] .estop { background: #7a1014; border-color: #ff8a8d; box-shadow: none; }
++@keyframes pulse { 50% { opacity: 0.35; } }
++
++/* ── The panel ──────────────────────────────────────────────────────────────────────────────────────── */
++main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); max-width: 72rem; margin: 0 auto; }
++.panel-head { margin-bottom: 0.75rem; }
++.meta { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.4rem 0 0; }
++.online[data-state="online"] { color: var(--text); }
++.online[data-state="online"] .dot { background: var(--ok); }
++.online[data-state="offline"] .dot { background: var(--warn); }
++.online[data-state="disconnected"] .dot { background: var(--danger); }
++.online[data-state="connecting"] .dot { animation: pulse 1s ease-in-out infinite; }
++.notice { margin: 0 0 0.75rem; }
++
++/* Widgets: one column on a phone held upright, with the camera first and the drive pad last (thumb reach). */
++.widgets { display: grid; gap: 0.75rem; grid-template-columns: minmax(0, 1fr); grid-auto-flow: dense; }
++.widget { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }
++.widget-camera { order: -1; padding: 0; overflow: hidden; }
++.widget-camera h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
++.widget-drive { order: 1; }
++
++/* Camera tiles: one per camera the profile lists. */
++.cameras { display: grid; gap: 2px; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); background: var(--line); }
++.camera { margin: 0; background: #000; position: relative; }
++.camera-screen {
++ aspect-ratio: 16 / 9; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.35rem;
++ padding: 1rem; text-align: center; color: var(--muted);
++ background: radial-gradient(ellipse at center, #1a2029 0%, #07090c 75%);
++}
++.camera-icon { width: 2.5rem; height: 2.5rem; stroke-width: 1.5; opacity: 0.6; }
++.camera-title { margin: 0; color: var(--text); font-weight: 600; }
++.camera-sub { margin: 0; font-size: 0.85rem; max-width: 26rem; }
++.camera figcaption { position: absolute; left: 0.6rem; top: 0.6rem; padding: 0.15rem 0.5rem; border-radius: 6px; background: rgba(0, 0, 0, 0.55); color: #cfd6de; font-size: 0.75rem; letter-spacing: 0.03em; }
++
++/* The drive pad: a joystick for two axes, hold buttons for the rest, a full-width stop. */
++.drive-pad { display: grid; justify-items: center; gap: 0.9rem; }
++.stick {
++ position: relative; width: min(15rem, 72vw); aspect-ratio: 1; border-radius: 50%; display: grid; place-items: center;
++ background: radial-gradient(circle at 50% 45%, #232b36 0%, #151a21 70%); border: 1px solid var(--line);
++ box-shadow: inset 0 2px 12px rgba(0, 0, 0, 0.6); touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab;
++}
++.stick-ring { position: absolute; inset: 18%; border-radius: 50%; border: 1px dashed #3a4452; pointer-events: none; }
++.stick-cross { position: absolute; inset: 8%; pointer-events: none;
++ background: linear-gradient(var(--line), var(--line)) center / 1px 100% no-repeat, linear-gradient(var(--line), var(--line)) center / 100% 1px no-repeat; opacity: 0.7; }
++.stick-knob {
++ position: relative; width: 40%; aspect-ratio: 1; border-radius: 50%; pointer-events: none;
++ background: radial-gradient(circle at 40% 35%, #5d6b7c, #2b333e 70%); border: 1px solid #5b6675;
++ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.55); transition: transform 0.12s ease-out;
++}
++.stick.held { cursor: grabbing; }
++.stick.held .stick-knob { transition: none; }
++.stick.active .stick-knob { background: radial-gradient(circle at 40% 35%, #7fb2ff, var(--accent) 70%); border-color: #8fbaff; }
++.stick[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed; }
++.pad { display: grid; gap: 0.5rem; width: 100%; max-width: 22rem; }
.axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 0.5rem; align-items: center; }
--.axis .hold { min-height: 64px; font-size: 1.5rem; user-select: none; -webkit-user-select: none; touch-action: none; }
--.axis-name { color: #666; font-size: 0.85rem; }
--.hold.held { background: #1a73e8; color: #fff; }
--.widget-drive .stop { width: 100%; margin-top: 0.5rem; }
--.readout { font-variant-numeric: tabular-nums; font-size: 1.2rem; }
--meter { width: 100%; height: 1.25rem; }
--
--/* Robots and pairing pages. */
--.robots { list-style: none; padding: 0; }
--.robots li { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
--.robots a { display: inline-flex; align-items: center; min-height: 44px; }
--.add-robot { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
--.installer { overflow-x: auto; padding: 0.75rem; background: #111; color: #eee; border-radius: 6px; }
--.code { font-family: ui-monospace, monospace; font-size: 1.3rem; letter-spacing: 0.1em; }
++.axis .hold { min-height: 56px; user-select: none; -webkit-user-select: none; touch-action: none; }
++.axis .hold .icon { width: 1.6em; height: 1.6em; }
++.axis-name { color: var(--muted); font-size: 0.8rem; text-align: center; }
++.hold.held { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
++.drive-pad .stop { width: 100%; max-width: 22rem; min-height: 52px; font-weight: 600; }
++.hint { margin: 0.9rem 0 0; color: var(--muted); font-size: 0.8rem; text-align: center; }
++.pad-hint { display: none; }
++#panel[data-gamepad="true"] .pad-hint { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text); }
++@media (hover: none) and (pointer: coarse) { .hint { display: none; } #panel[data-gamepad="true"] .hint { display: block; } }
++
++/* Actuators, speech, display */
++.controls { display: grid; gap: 0.6rem; }
++.control { display: flex; align-items: center; gap: 0.6rem; margin: 0; }
++.control-name { color: var(--muted); font-size: 0.9rem; min-width: 3.5rem; }
++.slider { flex-wrap: wrap; } .slider input { flex: 1; min-width: 8rem; }
++.colour label { display: flex; align-items: center; gap: 0.6rem; }
++.tone { width: 100%; min-height: 56px; user-select: none; -webkit-user-select: none; touch-action: none; }
++.inline-form { display: flex; gap: 0.5rem; flex-wrap: wrap; } .inline-form > input, .inline-form > select { flex: 1; min-width: 8rem; }
++
++/* Readouts and the latency meter */
++.readout { font-variant-numeric: tabular-nums; font-size: 1.35rem; font-weight: 600; }
++.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }
++.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
++.stat { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.55rem 0.6rem; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); min-width: 0; }
++.stat .readout { font-size: 1.15rem; }
++.stat-label { color: var(--muted); font-size: 0.72rem; }
++[data-quality="good"] { color: var(--ok); } [data-quality="fair"] { color: var(--warn); } [data-quality="poor"] { color: var(--danger); }
++
++/* Phone on its side: the camera on the left, the drive pad on the right, both in view without scrolling. */
++@media (orientation: landscape) and (max-height: 32rem) {
++ .estop-bar { padding-top: 0.3rem; padding-bottom: 0.35rem; } .topbar { min-height: 2.5rem; }
++ .estop { min-height: 48px; }
++ .panel-head h1 { font-size: 1.15rem; display: inline; margin-right: 0.5rem; } .meta { display: inline-flex; }
++ .widgets { grid-template-columns: minmax(0, 1fr) minmax(14rem, 38%); align-items: start; }
++ .widget-camera { grid-column: 1; grid-row: span 3; order: 0; }
++ .widget-drive { grid-column: 2; order: 0; }
++ .stick { width: min(11rem, 46vh); }
++ .hint { display: none; }
++}
++/* Tablet and up: more columns, the camera large and the drive pad beside it. */
++@media (min-width: 44rem) and (min-height: 32.01rem), (min-width: 44rem) and (orientation: portrait) {
++ .widgets { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++ .widget-camera { grid-column: 1 / -1; order: 0; }
++ .widget-drive { order: 0; }
++}
++@media (min-width: 64rem) and (min-height: 32.01rem) {
++ .widgets { grid-template-columns: repeat(3, minmax(0, 1fr)); }
++ .widget-camera { grid-column: 1 / span 2; grid-row: span 2; }
++ .widget-drive { grid-column: 3; }
++}
++@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
++
++/* ── Robots and pairing pages ───────────────────────────────────────────────────────────────────────── */
++.page { max-width: 44rem; }
++.page > h1 { margin: 0.5rem 0 1.25rem; }
++.robots { list-style: none; padding: 0; margin: 0 0 1.5rem; display: grid; gap: 0.6rem; }
++.robot-card { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0.5rem; padding: 0.85rem 1rem; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
++.robot-name { display: inline-flex; align-items: center; gap: 0.6rem; min-height: var(--tap); color: var(--text); font-weight: 600; font-size: 1.1rem; text-decoration: none; }
++.robot-name .icon { color: var(--accent); }
++.robot-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; }
++.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
++@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } }
++.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
++.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
++.empty-title { margin: 0.5rem 0 0.25rem; font-size: 1.15rem; font-weight: 600; color: var(--text); }
++.empty p { margin: 0.25rem 0; }
++.add-robot { display: grid; gap: 0.85rem; }
++.add-robot h2 { margin: 0; }
++.field { display: grid; gap: 0.35rem; }
++.field-label { font-size: 0.9rem; font-weight: 600; }
++.add-robot button[type="submit"] { justify-self: start; }
++
++.pair-status { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; padding: 0.85rem 1rem; margin: 0 0 1rem; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--line); font-weight: 600; }
++.pair-status .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
++.pair-status[data-state="online"] { border-color: rgba(63, 185, 80, 0.6); background: rgba(63, 185, 80, 0.1); }
++.pair-status[data-state="online"] .dot { background: var(--ok); animation: none; }
++.pair-status .button { margin-left: auto; }
++.steps { list-style: none; padding: 0; margin: 0 0 1rem; display: grid; gap: 0.75rem; counter-reset: step; }
++.steps > li { counter-increment: step; }
++.steps h2 { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; text-transform: none; letter-spacing: 0; font-size: 1rem; color: var(--text); }
++.steps h2::before { content: counter(step); display: inline-grid; place-items: center; width: 1.6rem; height: 1.6rem; border-radius: 50%; background: var(--accent); color: var(--accent-ink); font-size: 0.85rem; }
++.installer { margin: 0 0 0.75rem; overflow-x: auto; padding: 0.85rem; background: var(--bg); border: 1px solid var(--line); border-radius: 10px; font-size: 0.85rem; }
++.copy[data-copied="true"] { border-color: var(--ok); color: var(--ok); }
++.code { font-family: ui-monospace, monospace; font-size: 1.75rem; letter-spacing: 0.15em; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 3e2428f..ee56550 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -6,34 +6,83 @@
* paint the robot's state, and send each command with a fresh id (an id is an idempotency key, so a re-send
* with the same one would never reach the robot).
*
-- * A held control (data-hold, data-tone) is re-sent every holdResendMs while it is held, and releasing it sends
-- * the stop (a zero drive, a zero axis, a silent tone) at once: the device's deadline is the last line of
++ * Every held input — a hold button, the touch joystick, a key, a gamepad's stick or d-pad — is one source of a
++ * command kind; the sources of one kind are merged into one value, re-sent every holdResendMs while any is
++ * held, and releasing a source sends its axes at zero at once: the device's deadline is the last line of
* defence, never the way a panel stops a robot. Losing the socket, the window or the page releases everything.
*
-- * Also on the pairing page: [data-copy] copies the installer command.
++ * joystick pointer events with pointer capture, a deadzone; pointerup/cancel → a zero drive at once
++ * keyboard arrows/WASD drive, Q/E rotate, Space stops (unchanged)
++ * gamepad polled on requestAnimationFrame only while a pad is connected and the page has focus: left stick
++ * (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
++ * B = stop, Start = e-stop; gamepaddisconnected releases it all
++ * latency round trip from a command's id to its result on /control (rolling median) + telemetry age
++ *
++ * Also on the pairing page: [data-copy] copies the installer command, and [data-pair-robot] joins the robot on
++ * /control (the owner's own, read-only use of it) to flip "Waiting for the device." when the device connects.
*/
(() => {
-- for (const b of document.querySelectorAll('[data-copy]')) {
++ const $ = (sel, root = document) => root.querySelector(sel);
++ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
++ const wsUrl = () => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`;
++
++ for (const b of $$('[data-copy]')) {
b.addEventListener('click', () => {
-- const src = document.querySelector('[data-copy-source]');
-- if (src && navigator.clipboard) navigator.clipboard.writeText(src.textContent).then(() => { b.dataset.copied = 'true'; }, () => {});
++ const src = $('[data-copy-source]');
++ const label = $('[data-copy-label]', b);
++ if (src && navigator.clipboard) {
++ navigator.clipboard.writeText(src.textContent).then(() => {
++ b.dataset.copied = 'true';
++ if (label) label.textContent = 'Copied';
++ setTimeout(() => { delete b.dataset.copied; if (label) label.textContent = 'Copy the command'; }, 2000);
++ }, () => {});
++ }
});
}
++ // ── Pairing page: a live online indicator ─────────────────────────────────────────────────────────
++ const pair = $('[data-pair-robot]');
++ if (pair) {
++ const status = $('[data-pair-status]');
++ const text = $('[data-pair-text]');
++ const open = $('[data-pair-open]');
++ let tries = 0;
++ const show = (online) => {
++ status.dataset.state = online ? 'online' : 'waiting';
++ text.textContent = online ? 'The device is connected.' : 'Waiting for the device.';
++ open.hidden = !online;
++ };
++ const watch = () => {
++ const ws = new WebSocket(wsUrl());
++ ws.onopen = () => ws.send(JSON.stringify({ v: 1, type: 'join', robot_id: pair.dataset.pairRobot }));
++ ws.onmessage = (e) => {
++ let m; try { m = JSON.parse(e.data); } catch { return; }
++ if ((m.type === 'joined' || m.type === 'robot_state') && m.state) { tries = 0; show(!!m.state.online); }
++ if (m.type === 'error' && /not_an_operator|robot_not_found/.test(m.code)) { tries = -1; ws.close(); }
++ };
++ ws.onclose = (e) => { if (tries >= 0 && e.code !== 4002) setTimeout(watch, Math.min(10000, 1000 * 2 ** tries++)); };
++ };
++ watch();
++ return;
++ }
++
const main = document.getElementById('panel');
if (!main) return;
const ROBOT_ID = main.dataset.robotId;
const HOLD_MS = Number(main.dataset.holdMs) || 150;
++ const MIN_GAP_MS = 40; // a moving stick sends at most this often between the hold re-sends
++ const STICK_DEADZONE = 0.12; // touch joystick, fraction of its radius
++ const PAD_DEADZONE = 0.18; // gamepad sticks drift more than a finger
let allowed = JSON.parse(main.dataset.allowed || '[]');
let ws = null;
let joined = false;
let retry = 0;
let gone = false; // refused for good (no access): no reconnect
-- const $ = (sel, root = document) => root.querySelector(sel);
-- const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
-- const kindOf = (el) => { const s = el.closest('[data-command]'); return s ? JSON.parse(s.dataset.command).kind : null; };
++ const commandOf = (el) => { const s = el.closest('[data-command]'); return s ? JSON.parse(s.dataset.command) : null; };
++ const kindOf = (el) => { const c = commandOf(el); return c ? c.kind : null; };
const newId = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
++ const round = (v) => Math.round(v * 100) / 100;
function notice(text) {
const n = $('[data-notice]');
@@@ -46,38 +95,85 @@
ws.send(JSON.stringify({ v: 1, ...frame }));
return true;
}
++
++ // ── Latency: a command's id → its result ──────────────────────────────────────────────────────────
++ const sentAt = new Map(); // id → performance.now() at send
++ const samples = []; // the last round trips, ms
++ let lastTelemetryAt = 0;
function send(kind, value) {
if (!joined || !allowed.includes(kind)) return false;
-- return raw({ type: 'command', id: newId(), kind, value });
++ const id = newId();
++ if (!raw({ type: 'command', id, kind, value })) return false;
++ sentAt.set(id, performance.now());
++ if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);
++ return true;
++ }
++ const quality = (ms, ok, poor) => (ms == null ? 'none' : ms <= ok ? 'good' : ms <= poor ? 'fair' : 'poor');
++ function roundTrip(id, result) {
++ const t0 = sentAt.get(id);
++ if (t0 == null) return;
++ sentAt.delete(id);
++ if (result !== 'ack' && result !== 'nack') return; // refused/expired never reached the robot and back
++ samples.push(performance.now() - t0);
++ if (samples.length > 21) samples.shift();
++ const sorted = [...samples].sort((a, b) => a - b);
++ const median = Math.round(sorted[Math.floor(sorted.length / 2)]);
++ for (const el of $$('[data-latency]')) { el.textContent = `${median} ms`; el.dataset.quality = quality(median, 120, 300); }
++ }
++ function paintAge() {
++ const age = lastTelemetryAt ? performance.now() - lastTelemetryAt : null;
++ for (const el of $$('[data-telemetry-age]')) {
++ el.textContent = age == null ? '—' : age < 1000 ? `${Math.round(age / 100) * 100} ms` : `${(age / 1000).toFixed(age < 10000 ? 1 : 0)} s`;
++ el.dataset.quality = quality(age, 1500, 4000);
++ }
}
++ if ($('[data-telemetry-age]')) setInterval(paintAge, 250);
-- // ── Held controls ─────────────────────────────────────────────────────────────────────────────
-- // kind → { els: Map<element, value>, timer }: several held axes of one kind are sent as one value.
++ // ── Held sources ──────────────────────────────────────────────────────────────────────────────────
++ // kind → { src: Map<source, value>, timer, last, sent }: the sources of one kind are sent as one value.
const holds = new Map();
-- const combined = (h) => Object.assign({}, ...h.els.values());
-- function pressAxis(el) {
-- const kind = kindOf(el);
-- if (!kind || el.disabled) return;
++ const combined = (h) => Object.assign({}, ...h.src.values());
++ function sendHeld(kind, h) {
++ clearTimeout(h.trail);
++ h.trail = null;
++ const value = combined(h);
++ h.last = performance.now();
++ h.sent = JSON.stringify(value);
++ send(kind, value);
++ paintStick(kind, value);
++ }
++ /** Press or move a source: sent at once when new, else when it changed and MIN_GAP_MS has passed. */
++ function holdSet(kind, source, value) {
++ if (!kind) return;
let h = holds.get(kind);
-- if (!h) { h = { els: new Map(), timer: null }; holds.set(kind, h); }
-- if (h.els.has(el)) return;
-- h.els.set(el, JSON.parse(el.dataset.hold));
-- el.classList.add('held');
-- send(kind, combined(h));
-- if (!h.timer) h.timer = setInterval(() => send(kind, combined(h)), HOLD_MS);
++ if (!h) { h = { src: new Map(), timer: null, trail: null, last: 0, sent: null }; holds.set(kind, h); }
++ const fresh = !h.src.has(source);
++ h.src.set(source, value);
++ const wait = MIN_GAP_MS - (performance.now() - h.last);
++ if (fresh || (JSON.stringify(combined(h)) !== h.sent && wait <= 0)) sendHeld(kind, h);
++ else if (JSON.stringify(combined(h)) !== h.sent && !h.trail) h.trail = setTimeout(() => sendHeld(kind, h), wait);
++ if (!h.timer) h.timer = setInterval(() => sendHeld(kind, h), HOLD_MS);
}
-- function releaseAxis(el) {
-- const kind = kindOf(el);
++ /** Let a source go: its axes are sent at zero at once (merged with what is still held). */
++ function holdRelease(kind, source) {
const h = kind && holds.get(kind);
-- if (!h || !h.els.has(el)) return;
-- const released = h.els.get(el);
-- h.els.delete(el);
-- el.classList.remove('held');
++ if (!h || !h.src.has(source)) return;
++ const released = h.src.get(source);
++ h.src.delete(source);
const zero = {};
for (const axis of Object.keys(released)) zero[axis] = 0;
-- send(kind, { ...zero, ...combined(h) });
-- if (!h.els.size) { clearInterval(h.timer); holds.delete(kind); }
++ const value = { ...zero, ...combined(h) };
++ send(kind, value);
++ if (!h.src.size) { clearInterval(h.timer); clearTimeout(h.trail); holds.delete(kind); paintStick(kind, {}); } else paintStick(kind, value);
}
++
++ // Hold buttons (the axes the joystick does not cover, the ptz pad).
++ function pressButton(el) {
++ if (el.disabled) return;
++ el.classList.add('held');
++ holdSet(kindOf(el), el, JSON.parse(el.dataset.hold));
++ }
++ function releaseButton(el) { el.classList.remove('held'); holdRelease(kindOf(el), el); }
// A held tone: { el → timer }.
const tones = new Map();
function pressTone(el) {
@@@ -95,8 +191,13 @@
send('actuator', { name: el.dataset.tone, value: null });
}
function releaseAll() {
-- for (const h of [...holds.values()]) for (const el of [...h.els.keys()]) releaseAxis(el);
++ for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
++ if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
++ }
for (const el of [...tones.keys()]) releaseTone(el);
++ for (const s of sticks) s.release();
++ pad.reset();
++ for (const el of $$('.held')) el.classList.remove('held');
}
function holdable(el, press, release) {
@@@ -104,41 +205,184 @@
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, () => release(el));
el.addEventListener('contextmenu', (e) => e.preventDefault());
}
-- $$('[data-hold]').forEach((el) => holdable(el, pressAxis, releaseAxis));
++ $$('[data-hold]').forEach((el) => holdable(el, pressButton, releaseButton));
$$('[data-tone]').forEach((el) => holdable(el, pressTone, releaseTone));
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseAll(); });
window.addEventListener('pagehide', releaseAll);
-- // Keyboard: arrows (and WASD) hold the drive's buttons; Q/E rotate; Space stops.
++ // ── The drive's axes, read from the page (the profile's ranges) ───────────────────────────────────
++ const driveSection = $('.widget-drive[data-command]');
++ const DRIVE = driveSection ? JSON.parse(driveSection.dataset.command).kind : 'drive';
++ const drivePad = driveSection && $('[data-axes]', driveSection);
++ const driveAxes = drivePad ? JSON.parse(drivePad.dataset.axes) : {};
++ const stickEl = driveSection && $('[data-joystick]', driveSection);
++ const STICK = stickEl ? JSON.parse(stickEl.dataset.joystick) : {};
++ /** A unit value on an axis → the axis's range (negative to its low end, positive to its high end). */
++ const scale = (axis, u) => { const r = driveAxes[axis]; return r ? round(u >= 0 ? u * r[1] : -u * r[0]) : 0; };
++ /** A stick position (x right, y up, both -1…1) past a radial deadzone → the drive value for the stick's axes. */
++ function stickValue(x, y, deadzone) {
++ const r = Math.hypot(x, y);
++ const k = r <= deadzone ? 0 : Math.min(1, (r - deadzone) / (1 - deadzone)) / r;
++ const v = {};
++ if (STICK.x) v[STICK.x] = scale(STICK.x, x * k);
++ if (STICK.y) v[STICK.y] = scale(STICK.y, y * k);
++ return v;
++ }
++
++ // ── Touch joystick ────────────────────────────────────────────────────────────────────────────────
++ const sticks = [];
++ function paintStick(kind, value) {
++ if (kind !== DRIVE || !stickEl) return;
++ const knob = $('[data-knob]', stickEl);
++ const unit = (axis) => { const v = Number(value[axis]) || 0; const r = driveAxes[axis]; return !r ? 0 : v >= 0 ? (r[1] ? v / r[1] : 0) : (r[0] ? -v / r[0] : 0); };
++ const x = STICK.x ? unit(STICK.x) : 0;
++ const y = STICK.y ? unit(STICK.y) : 0;
++ const m = Math.hypot(x, y) > 1 ? Math.hypot(x, y) : 1;
++ knob.style.transform = `translate(${(x / m) * 75}%, ${(-y / m) * 75}%)`; // the knob is 40 % of the stick: 75 % of it reaches the rim
++ stickEl.classList.toggle('active', x !== 0 || y !== 0);
++ }
++ if (stickEl) {
++ let pointer = null;
++ const source = 'stick';
++ const at = (e) => {
++ const r = stickEl.getBoundingClientRect();
++ const radius = r.width / 2;
++ let x = (e.clientX - (r.left + radius)) / radius;
++ let y = -(e.clientY - (r.top + r.height / 2)) / radius;
++ const m = Math.hypot(x, y);
++ if (m > 1) { x /= m; y /= m; }
++ return stickValue(x, y, STICK_DEADZONE);
++ };
++ const release = () => {
++ if (pointer == null) return;
++ pointer = null;
++ stickEl.classList.remove('held');
++ holdRelease(DRIVE, source);
++ };
++ stickEl.addEventListener('pointerdown', (e) => {
++ if (stickEl.getAttribute('aria-disabled') === 'true' || pointer != null) return;
++ e.preventDefault();
++ pointer = e.pointerId;
++ try { stickEl.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
++ stickEl.classList.add('held');
++ holdSet(DRIVE, source, at(e));
++ });
++ stickEl.addEventListener('pointermove', (e) => { if (e.pointerId === pointer) holdSet(DRIVE, source, at(e)); });
++ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) stickEl.addEventListener(ev, (e) => { if (e.pointerId === pointer) release(); });
++ stickEl.addEventListener('contextmenu', (e) => e.preventDefault());
++ sticks.push({ release });
++ }
++
++ // ── Keyboard: arrows (and WASD) drive; Q/E rotate; Space stops ────────────────────────────────────
const KEYS = {
ArrowUp: [['throttle', 'y'], 1], KeyW: [['throttle', 'y'], 1], ArrowDown: [['throttle', 'y'], 0], KeyS: [['throttle', 'y'], 0],
ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
KeyQ: [['rotation'], 0], KeyE: [['rotation'], 1],
};
-- function keyButton(code) {
++ /** A key → { axis: its end of the range } on the drive, or null when the drive has none of its axes. */
++ function keyValue(code) {
const k = KEYS[code];
if (!k) return null;
-- for (const axis of k[0]) { const btns = $$(`.widget-drive [data-axis="${axis}"] [data-hold]`); if (btns.length) return btns[k[1]]; }
-- return null;
++ const axis = k[0].find((a) => driveAxes[a]);
++ return axis ? { [axis]: driveAxes[axis][k[1]] } : null;
++ }
++ const driveEnabled = () => joined && allowed.includes(DRIVE);
++ function keyPress(code, source) {
++ const v = keyValue(code);
++ if (!v || !driveEnabled()) return false;
++ holdSet(DRIVE, source, v);
++ const btn = driveSection && $(`[data-hold='${JSON.stringify(v)}']`, driveSection);
++ if (btn) btn.classList.add('held');
++ return true;
++ }
++ function keyRelease(code, source) {
++ const v = keyValue(code);
++ const btn = v && driveSection && $(`[data-hold='${JSON.stringify(v)}']`, driveSection);
++ if (btn) btn.classList.remove('held');
++ holdRelease(DRIVE, source);
}
const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
document.addEventListener('keydown', (e) => {
if (typing(e)) return;
if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }
-- const b = keyButton(e.code);
-- if (b) { e.preventDefault(); if (!e.repeat) pressAxis(b); }
++ if (keyValue(e.code)) { e.preventDefault(); if (!e.repeat) keyPress(e.code, `key:${e.code}`); }
});
-- document.addEventListener('keyup', (e) => { const b = keyButton(e.code); if (b) releaseAxis(b); });
++ document.addEventListener('keyup', (e) => { if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`); });
function stopDrive() {
-- const h = holds.get('drive');
-- if (h) for (const el of [...h.els.keys()]) releaseAxis(el);
-- send('drive', {}); // every declared axis absent → 0
++ const h = holds.get(DRIVE);
++ if (h) for (const source of [...h.src.keys()]) { if (source instanceof Element) releaseButton(source); else holdRelease(DRIVE, source); }
++ for (const s of sticks) s.release();
++ send(DRIVE, {}); // every declared axis absent → 0
}
$$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));
++ function estop() { releaseAll(); raw({ type: 'estop' }); }
-- // ── One-shot controls ─────────────────────────────────────────────────────────────────────────
++ // ── Gamepad ───────────────────────────────────────────────────────────────────────────────────────
++ const PAD_DPAD = { 12: 'ArrowUp', 13: 'ArrowDown', 14: 'ArrowLeft', 15: 'ArrowRight' };
++ /** A stick axis past its deadzone, rescaled to -1…1. */
++ const dead = (v, dz) => (Math.abs(v) <= dz ? 0 : Math.sign(v) * Math.min(1, (Math.abs(v) - dz) / (1 - dz)));
++ const ROTATE = STICK.x !== 'rotation' && driveAxes.rotation ? 'rotation' : null;
++ const pad = {
++ loop: 0,
++ down: new Set(), // buttons held at the last poll
++ tone: null, // the tone button A holds
++ // After a reset (blur, a hidden page, the e-stop) nothing is pressed again until the pad is let go of
++ // entirely: a stick still pushed or a button still held does not undo a release.
++ latched: false,
++ reset() {
++ holdRelease(DRIVE, 'pad');
++ for (const [b, code] of Object.entries(PAD_DPAD)) if (this.down.has(Number(b))) keyRelease(code, `pad:${b}`);
++ if (this.tone) { releaseTone(this.tone); this.tone = null; }
++ this.latched = true;
++ },
++ };
++ function padPoll() {
++ pad.loop = 0;
++ const p = [...(navigator.getGamepads ? navigator.getGamepads() : [])].find((g) => g && g.connected);
++ if (!p) { pad.reset(); pad.down.clear(); return; } // the last pad went: stop polling
++ const ax = (i) => Number(p.axes[i]) || 0;
++ const v = stickValue(ax(0), -ax(1), PAD_DEADZONE);
++ if (ROTATE) v[ROTATE] = scale(ROTATE, dead(ax(2), PAD_DEADZONE));
++ const moving = Object.values(v).some((x) => x !== 0);
++ const now = new Set(p.buttons.map((b, i) => (b && b.pressed ? i : -1)).filter((i) => i >= 0));
++ if (!document.hasFocus() || document.hidden || !driveEnabled()) {
++ if (!pad.latched) pad.reset();
++ } else if (pad.latched) {
++ if (!moving && !now.size) pad.latched = false;
++ } else {
++ if (moving) holdSet(DRIVE, 'pad', v);
++ else holdRelease(DRIVE, 'pad');
++ for (const b of now) if (!pad.down.has(b)) {
++ if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);
++ else if (b === 0) { pad.tone = $$('[data-tone]').find((el) => !el.disabled) || null; if (pad.tone) pressTone(pad.tone); }
++ else if (b === 1) { stopDrive(); pad.reset(); } // a stick still pushed does not drive on
++ else if (b === 9 && $('[data-estop]')) estop();
++ }
++ for (const b of pad.down) if (!now.has(b)) {
++ if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
++ else if (b === 0 && pad.tone) { releaseTone(pad.tone); pad.tone = null; }
++ }
++ }
++ pad.down = now;
++ pad.loop = requestAnimationFrame(padPoll);
++ }
++ const padStart = () => {
++ main.dataset.gamepad = 'true';
++ if (!pad.loop) { pad.latched = false; pad.down.clear(); pad.loop = requestAnimationFrame(padPoll); } // a fresh pad has nothing to undo
++ };
++ window.addEventListener('gamepadconnected', padStart);
++ window.addEventListener('gamepaddisconnected', () => {
++ pad.reset();
++ pad.down.clear();
++ if (![...(navigator.getGamepads ? navigator.getGamepads() : [])].some((g) => g && g.connected)) {
++ cancelAnimationFrame(pad.loop); pad.loop = 0; delete main.dataset.gamepad;
++ }
++ });
++ if ([...(navigator.getGamepads ? navigator.getGamepads() : [])].some((g) => g && g.connected)) padStart();
++
++ // ── One-shot controls ─────────────────────────────────────────────────────────────────────────────
/** At most one send per HOLD_MS while a slider moves; the last value is always sent. */
function throttled(fn) {
let last = 0; let timer = null; let pending = null;
@@@ -172,14 +416,22 @@
if (face) send('display', { face });
else if (text) send('display', { text });
}));
-- $$('[data-estop]').forEach((el) => el.addEventListener('click', () => { releaseAll(); raw({ type: 'estop' }); }));
++ $$('[data-estop]').forEach((el) => el.addEventListener('click', estop));
$$('[data-estop-clear]').forEach((el) => el.addEventListener('click', () => raw({ type: 'estop_clear' })));
-- // ── State ─────────────────────────────────────────────────────────────────────────────────────
++ // Each camera tile takes its camera's shape (CSSOM, so the CSP's no-inline rule holds).
++ for (const cam of $$('[data-resolution]')) {
++ const [w, h] = cam.dataset.resolution.split('x').map(Number);
++ const screen = $('[data-camera-screen]', cam);
++ if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;
++ }
++
++ // ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
for (const section of $$('[data-command]')) {
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
for (const el of $$('button, input, select', section)) el.disabled = !ok;
++ for (const el of $$('[data-joystick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
}
}
function readout(v) {
@@@ -187,6 +439,12 @@
if (typeof v !== 'object') return String(v);
return Object.entries(v).map(([k, x]) => `${k}: ${typeof x === 'object' ? JSON.stringify(x) : x}`).join(' · ') || '—';
}
++ function setOnline(state, label) {
++ const pill = $('[data-online-pill]');
++ if (pill) pill.dataset.state = state;
++ const online = $('[data-online-label]');
++ if (online) online.textContent = label;
++ }
function paint(state) {
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
@@@ -194,13 +452,17 @@
if (banner) banner.dataset.latched = String(latched);
const label = $('[data-estop-state]');
if (label) label.textContent = latched ? 'E-stop latched' : 'E-stop clear';
++ const note = $('.estop-note');
++ if (note) note.textContent = latched ? ' — the robot stays still until the owner clears it.' : '';
const clear = $('[data-estop-clear]');
if (clear) clear.hidden = !latched;
if (latched) releaseAll();
-- const online = $('[data-online]');
-- if (online) online.textContent = state.online ? 'online' : 'offline';
-- main.dataset.online = String(!!state.online);
-- for (const el of $$('[data-latency]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
++ setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
++ main.dataset.deviceOnline = String(!!state.online);
++ if (state.telemetry) lastTelemetryAt = performance.now();
++ else if (!state.online) lastTelemetryAt = 0;
++ paintAge();
++ for (const el of $$('[data-link]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
for (const el of $$('[data-battery]')) el.value = state.battery != null ? state.battery : 0;
for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';
const sensors = state.telemetry && state.telemetry.sensors;
@@@ -214,14 +476,13 @@
if (m.type === 'joined') {
joined = true; retry = 0;
allowed = m.allowed_commands || [];
-- const r = $('[data-role-label]');
-- if (r) r.textContent = m.role;
notice('');
enableControls();
paint(m.state);
} else if (m.type === 'robot_state') {
paint(m.state);
} else if (m.type === 'command_result') {
++ roundTrip(m.id, m.result);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
} else if (m.type === 'error') {
if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
@@@ -230,16 +491,15 @@
}
function connect() {
-- ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`);
++ ws = new WebSocket(wsUrl());
ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onclose = (e) => {
releaseAll();
joined = false;
enableControls();
-- const online = $('[data-online]');
-- if (online) online.textContent = 'disconnected';
-- if (e.code === 4002) { notice('Sign in again to drive this robot.'); return; }
++ setOnline('disconnected', 'Reconnecting…');
++ if (e.code === 4002) { setOnline('disconnected', 'Signed out'); notice('Sign in again to drive this robot.'); return; }
if (!gone) setTimeout(connect, Math.min(10000, 500 * 2 ** retry++));
};
}
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 56000cd..b159e6f 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -5,12 +5,13 @@
* The panel is a function of the robot's profile (ADR-043 decision 7): every widget comes from
* `profile.widgets` and every control's name, shape and range from `profile.commands` — no robot-specific
* code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
-- * (public/panel.js) reads what it needs from data-* attributes.
++ * (public/panel.js) reads what it needs from data-* attributes, and the icons are inline SVG markup.
*
* renderPanel({ robot, profile, role, allowed_commands, holdResendMs })
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
* renderPairingPage({ robot, pairing, profile })
++ * camerasOf(profile)
* esc(value)
*/
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@@ -20,20 +21,49 @@ const data = (v) => esc(JSON.stringify(v));
/** The e-stop is the owner's and an operator's; only the owner clears it (the gate enforces both). */
const ESTOP_ROLES = new Set(['owner', 'operator']);
--/** Hold-to-move arrows by axis: [negative, positive]. */
--const AXIS_ARROWS = { throttle: ['▼', '▲'], y: ['▼', '▲'], steer: ['◀', '▶'], x: ['◀', '▶'], rotation: ['⟲', '⟳'], pan: ['◀', '▶'], tilt: ['▼', '▲'], zoom: ['−', '+'] };
--const POLICIES = ['private', 'invite', 'queue'];
++/** The drive joystick's two axes, by preference: horizontal and vertical. The rest stay hold buttons. */
++const STICK_X = ['steer', 'x', 'rotation'];
++const STICK_Y = ['throttle', 'y'];
++const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];
++const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));
++const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' };
--function page(title, body, { scripts = [] } = {}) {
++// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────
++const PATHS = {
++ up: '<path d="M12 5v14M5 12l7-7 7 7"/>',
++ down: '<path d="M12 19V5M5 12l7 7 7-7"/>',
++ left: '<path d="M5 12h14M12 5l-7 7 7 7"/>',
++ right: '<path d="M19 12H5M12 5l7 7-7 7"/>',
++ ccw: '<path d="M4 4v6h6"/><path d="M5.5 15a7.5 7.5 0 1 0 1.8-7.8L4 10"/>',
++ cw: '<path d="M20 4v6h-6"/><path d="M18.5 15a7.5 7.5 0 1 1-1.8-7.8L20 10"/>',
++ plus: '<path d="M12 5v14M5 12h14"/>',
++ minus: '<path d="M5 12h14"/>',
++ stop: '<path d="M8.2 2h7.6L22 8.2v7.6L15.8 22H8.2L2 15.8V8.2z"/><path d="M8 12h8"/>',
++ square: '<rect x="6" y="6" width="12" height="12" rx="1.5"/>',
++ camera: '<path d="M3 7h3l2-3h8l2 3h3v12H3z"/><circle cx="12" cy="13" r="3.5"/>',
++ horn: '<path d="M3 10v4h4l6 5V5L7 10z"/><path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12"/>',
++ copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
++ robot: '<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4v4M9 13h.01M15 13h.01M2 13v2M22 13v2"/>',
++ plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
++ gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',
++};
++const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
++/** Hold-button icons by axis: [negative, positive]. */
++const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
++const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
++
++function page(title, body, { scripts = [], bodyClass = '' } = {}) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
++<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
++<meta name="color-scheme" content="dark">
++<meta name="theme-color" content="#11151c">
<title>${esc(title)} — OpenVibe.Bot</title>
<link rel="stylesheet" href="/panel/panel.css">
</head>
--<body>
++<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
${body}
${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
</body>
@@@ -42,33 +72,80 @@ ${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
}
const disabledUnless = (ok) => (ok ? '' : ' disabled');
++const topbar = (crumb = '') => `<nav class="topbar"><a class="brand" href="/robots">${icon('robot')}<span>OpenVibe<b>.Bot</b></span></a>${crumb}</nav>`;
/** Hold buttons, one pair per axis: pressed sends the axis at its end of the range, released sends 0. */
--function axisButtons(kind, axes, enabled) {
++function axisButtons(axes, enabled) {
return Object.entries(axes).map(([axis, [lo, hi]]) => {
-- const [neg, pos] = AXIS_ARROWS[axis] || ['−', '+'];
++ const [neg, pos] = AXIS_ICONS[axis] || ['minus', 'plus'];
++ const name = AXIS_NAME[axis] || axis;
return `<div class="axis" data-axis="${esc(axis)}">`
-- + `<button type="button" class="hold" data-hold="${data({ [axis]: lo })}" aria-label="${esc(`${axis} ${lo}`)}"${disabledUnless(enabled)}>${esc(neg)}</button>`
-- + `<span class="axis-name">${esc(axis)}</span>`
-- + `<button type="button" class="hold" data-hold="${data({ [axis]: hi })}" aria-label="${esc(`${axis} ${hi}`)}"${disabledUnless(enabled)}>${esc(pos)}</button>`
++ + `<button type="button" class="hold" data-hold="${data({ [axis]: lo })}" aria-label="${esc(`${name} ${lo}`)}"${disabledUnless(enabled)}>${icon(neg)}</button>`
++ + `<span class="axis-name">${esc(name)}</span>`
++ + `<button type="button" class="hold" data-hold="${data({ [axis]: hi })}" aria-label="${esc(`${name} ${hi}`)}"${disabledUnless(enabled)}>${icon(pos)}</button>`
+ '</div>';
}).join('');
}
++/** The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest. */
++function drivePad(axes, enabled) {
++ const x = STICK_X.find((a) => axes[a]);
++ const y = STICK_Y.find((a) => axes[a]);
++ const stick = {};
++ if (x) stick.x = x;
++ if (y) stick.y = y;
++ const rest = Object.fromEntries(Object.entries(axes).filter(([a]) => a !== x && a !== y));
++ const label = [y && AXIS_NAME[y], x && AXIS_NAME[x]].filter(Boolean).join(' and ');
++ const joystick = x || y
++ ? `<div class="stick" data-joystick="${data(stick)}" role="img" aria-label="${esc(`Joystick: ${label}. Drag and hold to drive.`)}"${enabled ? '' : ' aria-disabled="true"'}>`
++ + '<span class="stick-ring"></span><span class="stick-cross"></span>'
++ + `<span class="stick-knob" data-knob></span></div>`
++ : '';
++ return `<div class="drive-pad" data-axes="${data(axes)}">${joystick}${Object.keys(rest).length ? `<div class="pad">${axisButtons(rest, enabled)}</div>` : ''}`
++ + `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
++}
++
/** One control per actuator the widget drives, by the actuator's declared type. */
function actuatorControl(name, a, enabled) {
const off = disabledUnless(enabled);
if (a.type === 'number') {
const [lo, hi] = a.range;
const start = Math.min(hi, Math.max(lo, 0));
-- return `<label class="control">${esc(name)} <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${off}></label>`;
++ return `<label class="control slider"><span class="control-name">${esc(name)}</span> <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${off}></label>`;
}
if (a.type === 'rgb') {
-- return `<div class="control"><label>${esc(name)} <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
-- + ` <button type="button" data-rgb-off="${esc(name)}"${off}>Off</button></div>`;
++ return `<div class="control colour"><label><span class="control-name">${esc(name)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
++ + ` <button type="button" class="quiet" data-rgb-off="${esc(name)}"${off}>Off</button></div>`;
}
-- if (a.type === 'tone') return `<button type="button" class="hold" data-tone="${esc(name)}"${off}>${esc(name)}</button>`;
-- return `<label class="control">${esc(name)} <input type="checkbox" data-bool="${esc(name)}"${off}></label>`;
++ if (a.type === 'tone') return `<button type="button" class="hold tone" data-tone="${esc(name)}"${off}>${icon('horn')}<span>${esc(name)}</span></button>`;
++ return `<label class="control toggle"><input type="checkbox" data-bool="${esc(name)}"${off}> <span class="control-name">${esc(name)}</span></label>`;
++}
++
++/**
++ * The cameras a profile lists: `camera` is one camera ({ transport, resolution, name? }) or a list of them.
++ * A profile with a camera widget and no camera entry still gets one slot.
++ */
++function camerasOf(profile = {}) {
++ const c = profile.camera;
++ const list = Array.isArray(c) ? c.filter((x) => x && typeof x === 'object') : (c && typeof c === 'object' ? [c] : []);
++ return list.length ? list : [{}];
++}
++
++/** One tile per camera. A camera widget naming one (`camera`: its name or index) shows only that one. */
++function cameraTiles(w, profile) {
++ const all = camerasOf(profile).map((c, i) => ({ c, i }));
++ const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;
++ return (pick.length ? pick : all).map(({ c, i }) => {
++ const res = typeof c.resolution === 'string' && /^\d+x\d+$/.test(c.resolution) ? c.resolution : '';
++ const title = c.name || (all.length > 1 ? `Camera ${i + 1}` : '');
++ const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
++ return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}>`
++ + `<div class="camera-screen" data-camera-screen>${icon('camera', 'icon camera-icon')}`
++ + '<p class="camera-title">Video is not connected yet.</p>'
++ + '<p class="camera-sub">The robot can already send its camera; watching it on this page comes in a later release.</p></div>'
++ + (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
++ + '</figure>';
++ }).join('');
}
function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@@ -78,44 +155,52 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
const label = w.label || w.type;
let inner;
switch (w.type) {
-- case 'drive':
++ case 'drive': {
++ const axes = (commands[kind] && commands[kind].axes) || {};
++ inner = drivePad(axes, enabled)
++ + '<p class="hint"><kbd>W A S D</kbd> or arrows to drive, <kbd>Q</kbd> <kbd>E</kbd> to turn, <kbd>Space</kbd> to stop. '
++ + `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;
++ break;
++ }
case 'ptz': {
const axes = (commands[kind] && commands[kind].axes) || {};
-- inner = `<div class="pad">${axisButtons(kind, axes, enabled)}</div>`;
-- if (w.type === 'drive') inner += `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>Stop</button>`;
++ inner = `<div class="pad">${axisButtons(axes, enabled)}</div>`;
break;
}
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
-- inner = (w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled)).join('');
++ inner = `<div class="controls">${(w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled)).join('')}</div>`;
break;
}
case 'speaker': {
const max = commands.say ? commands.say.max_chars : 200;
-- inner = `<form class="control" data-say><input type="text" name="text" maxlength="${esc(max)}" aria-label="${esc(label)}"${disabledUnless(enabled)}>`
++ inner = `<form class="control inline-form" data-say><input type="text" name="text" maxlength="${esc(max)}" placeholder="Something to say" aria-label="${esc(label)}"${disabledUnless(enabled)}>`
+ ` <button type="submit"${disabledUnless(enabled)}>Say</button></form>`;
break;
}
case 'display': {
const d = commands.display || { modes: [] };
let fields = '';
-- if (d.modes.includes('face')) fields += `<select name="face" aria-label="face"${disabledUnless(enabled)}><option value="">—</option>${(d.faces || []).map((f) => `<option value="${esc(f)}">${esc(f)}</option>`).join('')}</select>`;
-- if (d.modes.includes('text')) fields += ` <input type="text" name="text" maxlength="${esc(d.max_chars)}" aria-label="text"${disabledUnless(enabled)}>`;
-- inner = `<form class="control" data-display>${fields} <button type="submit"${disabledUnless(enabled)}>Show</button></form>`;
++ if (d.modes.includes('face')) fields += `<select name="face" aria-label="Face"${disabledUnless(enabled)}><option value="">Face…</option>${(d.faces || []).map((f) => `<option value="${esc(f)}">${esc(f)}</option>`).join('')}</select>`;
++ if (d.modes.includes('text')) fields += ` <input type="text" name="text" maxlength="${esc(d.max_chars)}" placeholder="Text" aria-label="Text"${disabledUnless(enabled)}>`;
++ inner = `<form class="control inline-form" data-display>${fields} <button type="submit"${disabledUnless(enabled)}>Show</button></form>`;
break;
}
case 'telemetry':
inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
break;
case 'battery':
-- inner = '<meter min="0" max="1" low="0.25" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output>';
++ inner = '<div class="battery"><meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output></div>';
break;
case 'latency':
-- inner = '<output class="readout" data-latency>—</output>';
++ inner = '<div class="stats">'
++ + '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Round trip, median</span></div>'
++ + '<div class="stat"><output class="readout" data-telemetry-age data-quality="none">—</output><span class="stat-label">Telemetry age</span></div>'
++ + '<div class="stat"><output class="readout" data-link>—</output><span class="stat-label">Device link</span></div>'
++ + '</div>';
break;
case 'camera':
-- // R8b brings the viewer URL; until then the slot is held.
-- inner = '<div class="camera-placeholder" data-camera>Video is not connected yet.</div>';
++ inner = `<div class="cameras">${cameraTiles(w, profile)}</div>`;
break;
default:
inner = '';
@@@ -129,49 +214,60 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
.map(({ w }) => renderWidget(w, { profile, allowed_commands }));
-- const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>Stop</button>` : '';
++ const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>` : '';
const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
-- const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}" role="status" aria-live="assertive">
--<span class="estop-state" data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span>${estop}${clear}
++ const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
++${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
++<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
++<div class="panel-head">
<h1>${esc(robot.name)}</h1>
--<p class="meta"><span class="profile">${esc(profile.name || robot.profile_id)}</span> · <span class="role" data-role-label>${esc(role)}</span> · <span class="online" data-online>connecting…</span></p>
++<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
++</div>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
${widgets.join('\n')}
</div>
</main>`;
-- return page(robot.name, body, { scripts: ['/panel/panel.js'] });
++ return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: 'panel-page' });
}
function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
const names = new Map(profiles.map((p) => [p.id, p.name]));
const list = robots.length
-- ? `<ul class="robots">${robots.map((r) => `<li><a href="/panel/${esc(r.id)}">${esc(r.name)}</a> <span class="profile">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="policy">${esc(r.access_policy)}</span> <a class="pair" href="/pair/${esc(r.id)}">Pair a device</a></li>`).join('')}</ul>`
-- : '<p class="empty">No robots yet.</p>';
++ ? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`
++ + `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`
++ + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span></li>`).join('')}</ul>`
++ : `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;
const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
-- const form = `<form class="add-robot" method="post" action="/robots">
++ const form = `<form class="add-robot card" method="post" action="/robots">
<h2>Add a robot</h2>
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
--<label>Name <input type="text" name="name" maxlength="80" required value="${esc(values.name || '')}"></label>
--<label>Robot <select name="profile_id">${profiles.map((p) => option(p.id, p.name, p.id === values.profile_id)).join('')}</select></label>
--<label>Who may drive <select name="access_policy">${POLICIES.map((p) => option(p, p, p === (values.access_policy || 'private'))).join('')}</select></label>
--<button type="submit">Add</button>
++<label class="field"><span class="field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
++<label class="field"><span class="field-label">Model</span><select name="profile_id">${profiles.map((p) => option(p.id, p.name, p.id === values.profile_id)).join('')}</select></label>
++<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
++<button type="submit" class="primary">Add robot</button>
</form>`;
-- return page('Your robots', `<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);
++ return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);
}
function renderPairingPage({ robot, pairing, profile = null }) {
-- const body = `<main class="page pairing">
++ const body = `${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
++<main class="page pairing" data-pair-robot="${esc(robot.id)}">
<h1>Pair ${esc(robot.name)}</h1>
--<p>Run this on the machine that serves the robot${profile ? ` (${esc(profile.name)})` : ''}:</p>
++<p class="pair-status" data-pair-status data-state="waiting" role="status" aria-live="polite"><span class="dot"></span><span data-pair-text>Waiting for the device.</span> <a class="button primary" href="/panel/${esc(robot.id)}" data-pair-open hidden>Open the panel</a></p>
++<ol class="steps">
++<li class="card"><h2>Run this on the machine that serves the robot${profile ? ` <span class="pill">${esc(profile.name)}</span>` : ''}</h2>
<pre class="installer"><code data-copy-source>${esc(pairing.installer)}</code></pre>
--<button type="button" data-copy>Copy the command</button>
--<p>Or enter the pairing code <strong class="code">${esc(pairing.code)}</strong>, valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>.</p>
--<p class="waiting">Waiting for the device. <a href="/panel/${esc(robot.id)}">Open the panel</a></p>
++<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button></li>
++<li class="card"><h2>Or enter the pairing code</h2>
++<p><strong class="code">${esc(pairing.code)}</strong></p>
++<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li>
++</ol>
++<p class="muted">This page turns green as soon as the device connects. <a href="/panel/${esc(robot.id)}">Open the panel</a> any time.</p>
</main>`;
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
--module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc };
++module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex b06da6a..7eb0a1a 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -5,7 +5,7 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { loadProfiles } = require('../server/profiles');
--const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc } = require('../server/web/render');
++const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc } = require('../server/web/render');
const { DEFAULT_ALLOW } = require('../server/domain');
const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });
@@@ -40,6 +40,57 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.match(renderWidget(p.widgets[0], { profile: p, allowed_commands: [] }), /disabled/);
});
++ await check('one camera tile per camera the profile lists, each the placeholder with its shape', () => {
++ const tiles = (html) => (html.match(/<figure class="camera" data-camera="/g) || []).length;
++ for (const p of profiles.filter((x) => x.widgets.some((w) => w.type === 'camera'))) {
++ const html = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner') });
++ assert.strictEqual(tiles(html), camerasOf(p).length, p.id);
++ assert.ok(html.includes('Video is not connected yet.'), p.id);
++ if (p.camera && p.camera.resolution) assert.ok(html.includes(`data-resolution="${p.camera.resolution}"`), p.id);
++ assert.doesNotMatch(html, /<video|whip_url|publish_key/, `${p.id}: no stream address on the page`);
++ }
++ const p = profiles.find((x) => x.id === 'sim.rover');
++ const two = { ...p, camera: [{ name: 'Front', transport: 'whip', resolution: '640x360' }, { name: 'Arm', transport: 'whip', resolution: '320x240' }] };
++ const html = renderPanel({ robot: robotFor(two), profile: two, role: 'owner', allowed_commands: [] });
++ assert.strictEqual(tiles(html), 2);
++ assert.ok(html.includes('data-resolution="320x240"') && html.includes('Arm · 320×240'));
++ const one = renderWidget({ type: 'camera', camera: 'Arm' }, { profile: two });
++ assert.strictEqual(tiles(one), 1);
++ assert.ok(one.includes('data-camera="1"'));
++ // A camera widget with no camera entry still holds one slot.
++ assert.strictEqual(tiles(renderWidget({ type: 'camera' }, { profile: { ...p, camera: null } })), 1);
++ });
++
++ await check('a drive widget is a joystick for two of its axes, hold buttons for the rest, and the latency meter', () => {
++ const attr = (html, name) => JSON.parse(new RegExp(`${name}="([^"]+)"`).exec(html)[1].replace(/&quot;/g, '"'));
++ for (const p of profiles) {
++ for (const w of p.widgets.filter((x) => x.type === 'drive')) {
++ const html = renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') });
++ const axes = p.commands[w.command.kind].axes;
++ const stick = Object.values(attr(html, 'data-joystick'));
++ assert.ok(stick.length >= 1 && stick.every((a) => axes[a]), p.id);
++ assert.deepStrictEqual(attr(html, 'data-axes'), axes, p.id);
++ for (const a of Object.keys(axes)) assert.strictEqual(html.includes(`data-axis="${a}"`), !stick.includes(a), `${p.id}: ${a}`);
++ assert.ok(html.includes('data-stop'));
++ assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /data-joystick="[^"]+" [^>]*aria-disabled="true"/);
++ }
++ }
++ const mecanum = profiles.find((x) => x.id === 'adeept.adr036.mecanum');
++ const html = renderWidget(mecanum.widgets.find((w) => w.type === 'drive'), { profile: mecanum, allowed_commands: ['drive'] });
++ assert.ok(html.includes('data-axis="rotation"') && !html.includes('data-axis="x"') && !html.includes('data-axis="y"'));
++ const latency = renderWidget({ type: 'latency' }, { profile: mecanum });
++ for (const slot of ['data-latency', 'data-telemetry-age', 'data-link']) assert.ok(latency.includes(slot), slot);
++ });
++
++ await check('the robots page has an empty state and labelled fields; the pairing page waits for the device', () => {
++ const empty = renderRobotsPage({ robots: [], profiles: profiles.map((p) => ({ id: p.id, name: p.name })) });
++ assert.ok(empty.includes('No robots yet.'));
++ for (const label of ['Name', 'Model', 'Who may drive']) assert.ok(empty.includes(`<span class="field-label">${label}</span>`), label);
++ for (const v of ['private', 'invite', 'queue']) assert.ok(empty.includes(`value="${v}"`), v);
++ const pair = renderPairingPage({ robot: { id: 'rob_1', name: 'Rover' }, pairing: { code: 'ABCD-EFGH', installer: 'curl x', expires_at: 'soon' } });
++ assert.ok(pair.includes('data-pair-robot="rob_1"') && pair.includes('Waiting for the device.') && pair.includes('data-pair-open hidden'));
++ });
++
await check('every value is escaped', () => {
const p = profiles.find((x) => x.id === 'sim.rover');
const evil = '<img src=x onerror=alert(1)>"\'&';
@@@ -166,6 +217,25 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual((await get(loc, bob)).status, 403);
});
++ await check('the pairing page\'s indicator: the owner\'s /control join reports the device and flips when it connects', async () => {
++ const { robot } = await t.robot(alex, { name: 'Indicator', profile_id: 'adeept.adr036' });
++ const html = await (await get(`/pair/${robot.id}`, alex)).text();
++ assert.ok(html.includes(`data-pair-robot="${robot.id}"`) && html.includes('Waiting for the device.'));
++ const code = /class="code">([0-9A-Z]{4}-[0-9A-Z]{4})</.exec(html)[1];
++ const c = await control(alex);
++ c.send({ type: 'join', robot_id: robot.id });
++ const joined = await c.waitFor((m) => m.type === 'joined');
++ assert.strictEqual(joined.state.online, false);
++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code } });
++ assert.strictEqual(p.status, 201, p.text);
++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });
++ const up = await c.waitFor((m) => m.type === 'robot_state' && m.state && m.state.online === true);
++ assert.ok(up, 'no online flip');
++ dev.close();
++ assert.ok(await c.waitFor((m) => c.messages.indexOf(m) > c.messages.indexOf(up) && m.type === 'robot_state' && m.state.online === false), 'no offline flip');
++ c.close();
++ });
++
await check('/pair/:id mints nothing for a cross-site navigation or a prefetch', async () => {
const { robot } = await t.robot(alex, { name: 'Fenced', profile_id: 'adeept.adr036' });
const live = async () => (await t.db.many('SELECT id FROM pairing_codes WHERE robot_id = $1 AND used_at IS NULL', [robot.id])).map((x) => x.id);