openvibers/openvibe.bot Commits
Add joystick, gamepad, latency meter, camera tiles and panel design
Changes
@@@ -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).@@@ -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"@@@ -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; }@@@ -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 fingerlet 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++));};}@@@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };@@@ -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 = [], holdResendMsconst 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 };@@@ -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(/"/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);
@@ -12,7 +12,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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.
- 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.
- 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 `/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.
- 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).
- 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).
@@ -37,6 +37,7 @@
@@ -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",
"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)",
"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",
"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"
"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"
"updated": "2026-10-04"
@@ -1,48 +1,205 @@
@@ -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; }
*, *::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 { 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; }
@@ -6,34 +6,83 @@
@@ -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
* 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).
* 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.
* 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', () => {
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');
const main = document.getElementById('panel');
if (!main) return;
if (!main) return;
const ROBOT_ID = main.dataset.robotId;
const ROBOT_ID = main.dataset.robotId;
const HOLD_MS = Number(main.dataset.holdMs) || 150;
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 allowed = JSON.parse(main.dataset.allowed || '[]');
let ws = null;
let ws = null;
let joined = false;
let joined = false;
let retry = 0;
let retry = 0;
let gone = false; // refused for good (no access): no reconnect
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 newId = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
+ const round = (v) => Math.round(v * 100) / 100;
function notice(text) {
function notice(text) {
const n = $('[data-notice]');
const n = $('[data-notice]');
@@ -46,38 +95,85 @@
@@ -46,38 +95,85 @@
ws.send(JSON.stringify({ v: 1, ...frame }));
ws.send(JSON.stringify({ v: 1, ...frame }));
return true;
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) {
function send(kind, value) {
if (!joined || !allowed.includes(kind)) return false;
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 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);
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);
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 = {};
const zero = {};
for (const axis of Object.keys(released)) zero[axis] = 0;
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 }.
// A held tone: { el → timer }.
const tones = new Map();
const tones = new Map();
function pressTone(el) {
function pressTone(el) {
@@ -95,8 +191,13 @@
@@ -95,8 +191,13 @@
send('actuator', { name: el.dataset.tone, value: null });
send('actuator', { name: el.dataset.tone, value: null });
}
}
function releaseAll() {
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 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) {
function holdable(el, press, release) {
@@ -104,41 +205,184 @@
@@ -104,41 +205,184 @@
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, () => release(el));
for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, () => release(el));
el.addEventListener('contextmenu', (e) => e.preventDefault());
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));
$$('[data-tone]').forEach((el) => holdable(el, pressTone, releaseTone));
window.addEventListener('blur', releaseAll);
window.addEventListener('blur', releaseAll);
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseAll(); });
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseAll(); });
window.addEventListener('pagehide', 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 = {
const KEYS = {
ArrowUp: [['throttle', 'y'], 1], KeyW: [['throttle', 'y'], 1], ArrowDown: [['throttle', 'y'], 0], KeyS: [['throttle', 'y'], 0],
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],
ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
KeyQ: [['rotation'], 0], KeyE: [['rotation'], 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];
const k = KEYS[code];
if (!k) return null;
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);
const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (typing(e)) return;
if (typing(e)) return;
if (e.code === 'Space') { e.preventDefault(); stopDrive(); 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() {
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));
$$('[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. */
/** At most one send per HOLD_MS while a slider moves; the last value is always sent. */
function throttled(fn) {
function throttled(fn) {
let last = 0; let timer = null; let pending = null;
let last = 0; let timer = null; let pending = null;
@@ -172,14 +416,22 @@
@@ -172,14 +416,22 @@
if (face) send('display', { face });
if (face) send('display', { face });
else if (text) send('display', { text });
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' })));
$$('[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() {
function enableControls() {
for (const section of $$('[data-command]')) {
for (const section of $$('[data-command]')) {
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
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 $$('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) {
function readout(v) {
@@ -187,6 +439,12 @@
@@ -187,6 +439,12 @@
if (typeof v !== 'object') return String(v);
if (typeof v !== 'object') return String(v);
return Object.entries(v).map(([k, x]) => `${k}: ${typeof x === 'object' ? JSON.stringify(x) : x}`).join(' · ') || '—';
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) {
function paint(state) {
if (!state) return;
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
const latched = !!(state.estop && state.estop.latched);
@@ -194,13 +452,17 @@
@@ -194,13 +452,17 @@
if (banner) banner.dataset.latched = String(latched);
if (banner) banner.dataset.latched = String(latched);
const label = $('[data-estop-state]');
const label = $('[data-estop-state]');
if (label) label.textContent = latched ? 'E-stop latched' : 'E-stop clear';
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]');
const clear = $('[data-estop-clear]');
if (clear) clear.hidden = !latched;
if (clear) clear.hidden = !latched;
if (latched) releaseAll();
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]')) 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)} %` : '—';
for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';
const sensors = state.telemetry && state.telemetry.sensors;
const sensors = state.telemetry && state.telemetry.sensors;
@@ -214,14 +476,13 @@
@@ -214,14 +476,13 @@
if (m.type === 'joined') {
if (m.type === 'joined') {
joined = true; retry = 0;
joined = true; retry = 0;
allowed = m.allowed_commands || [];
allowed = m.allowed_commands || [];
- const r = $('[data-role-label]');
- if (r) r.textContent = m.role;
notice('');
notice('');
enableControls();
enableControls();
paint(m.state);
paint(m.state);
} else if (m.type === 'robot_state') {
} else if (m.type === 'robot_state') {
paint(m.state);
paint(m.state);
} else if (m.type === 'command_result') {
} else if (m.type === 'command_result') {
+ roundTrip(m.id, m.result);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
} else if (m.type === 'error') {
} else if (m.type === 'error') {
if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
@@ -230,16 +491,15 @@
@@ -230,16 +491,15 @@
}
}
function connect() {
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.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
ws.onclose = (e) => {
ws.onclose = (e) => {
releaseAll();
releaseAll();
joined = false;
joined = false;
enableControls();
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++));
if (!gone) setTimeout(connect, Math.min(10000, 500 * 2 ** retry++));
};
};
}
}
@@ -5,12 +5,13 @@
@@ -5,12 +5,13 @@
* The panel is a function of the robot's profile (ADR-043 decision 7): every widget comes from
* 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
* `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
* 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 })
* renderPanel({ robot, profile, role, allowed_commands, holdResendMs })
* renderWidget(widget, { profile, allowed_commands })
* renderWidget(widget, { profile, allowed_commands })
* renderRobotsPage({ robots, profiles, error, values })
* renderRobotsPage({ robots, profiles, error, values })
* renderPairingPage({ robot, pairing, profile })
* renderPairingPage({ robot, pairing, profile })
+ * camerasOf(profile)
* esc(value)
* esc(value)
*/
*/
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
@@ -20,20 +21,49 @@ const data = (v) => esc(JSON.stringify(v));
@@ -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). */
/** 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']);
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>
return `<!doctype html>
<html lang="en">
<html lang="en">
<head>
<head>
<meta charset="utf-8">
<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>
<title>${esc(title)} — OpenVibe.Bot</title>
<link rel="stylesheet" href="/panel/panel.css">
<link rel="stylesheet" href="/panel/panel.css">
</head>
</head>
-<body>
+<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
${body}
${body}
${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
</body>
</body>
@@ -42,33 +72,80 @@ ${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
@@ -42,33 +72,80 @@ ${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
}
}
const disabledUnless = (ok) => (ok ? '' : ' disabled');
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. */
/** 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]]) => {
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)}">`
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>';
+ '</div>';
}).join('');
}).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. */
/** One control per actuator the widget drives, by the actuator's declared type. */
function actuatorControl(name, a, enabled) {
function actuatorControl(name, a, enabled) {
const off = disabledUnless(enabled);
const off = disabledUnless(enabled);
if (a.type === 'number') {
if (a.type === 'number') {
const [lo, hi] = a.range;
const [lo, hi] = a.range;
const start = Math.min(hi, Math.max(lo, 0));
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') {
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 = [] } = {}) {
function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@ -78,44 +155,52 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@ -78,44 +155,52 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
const label = w.label || w.type;
const label = w.label || w.type;
let inner;
let inner;
switch (w.type) {
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': {
case 'ptz': {
const axes = (commands[kind] && commands[kind].axes) || {};
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;
break;
}
}
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
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;
break;
}
}
case 'speaker': {
case 'speaker': {
const max = commands.say ? commands.say.max_chars : 200;
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>`;
+ ` <button type="submit"${disabledUnless(enabled)}>Say</button></form>`;
break;
break;
}
}
case 'display': {
case 'display': {
const d = commands.display || { modes: [] };
const d = commands.display || { modes: [] };
let fields = '';
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;
break;
}
}
case 'telemetry':
case 'telemetry':
inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
break;
break;
case 'battery':
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;
break;
case 'latency':
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;
break;
case 'camera':
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;
break;
default:
default:
inner = '';
inner = '';
@@ -129,49 +214,60 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -129,49 +214,60 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
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))
.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 }));
.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 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>
</header>
<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
<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>
<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>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
</main>`;
</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 = {} }) {
function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
const names = new Map(profiles.map((p) => [p.id, p.name]));
const names = new Map(profiles.map((p) => [p.id, p.name]));
const list = robots.length
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 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>
<h2>Add a robot</h2>
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
${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>`;
</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 }) {
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>
<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>
<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>`;
</main>`;
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
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 };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { loadProfiles } = require('../server/profiles');
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 { DEFAULT_ALLOW } = require('../server/domain');
const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });
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)
@@ -40,6 +40,57 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.match(renderWidget(p.widgets[0], { profile: p, allowed_commands: [] }), /disabled/);
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(/"/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', () => {
await check('every value is escaped', () => {
const p = profiles.find((x) => x.id === 'sim.rover');
const p = profiles.find((x) => x.id === 'sim.rover');
const evil = '<img src=x onerror=alert(1)>"\'&';
const evil = '<img src=x onerror=alert(1)>"\'&';
@@ -166,6 +217,25 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -166,6 +217,25 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual((await get(loc, bob)).status, 403);
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 () => {
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 { 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);
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);