openvibers/openvibe.bot Commits
Bot: /try drives a simulated rover in the browser with no account; sim robots get an arena, sensors and a drawn camera; Adeept and Cozmo panels show their video (#89)
Changes
@@@ -14,7 +14,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- 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 front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.--- 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.++- 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 rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.++- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).@@@ -41,14 +41,15 @@"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events","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",++ "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 in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",++ "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt","the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",-- "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 OpenRestream can play a WHIP stream back); a live online indicator on /pair/:id read from /control",++ "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, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); 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","embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)","embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot","streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once","server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"],-- "updated": "2026-10-12"++ "updated": "2026-10-13"}@@@ -177,7 +177,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf}@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-camera { grid-column: 1 / span 2; grid-row: span 2; align-self: start; }.widget-drive { grid-column: 3; }}@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }@@@ -279,3 +279,19 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.drive-page { max-width: 28rem; }.drive-page h1 { margin: 0 0 0.5rem; font-size: 1.2rem; }.embed-sign-in { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }++++/* A simulated rover's camera (public/sim-view.js): the drawn view fills the tile, the arena map sits in a corner. */++.camera-screen > .sim-camera { position: absolute; inset: 0; width: 100%; height: 100%; display: block; background: #000; }++.camera-screen > .sim-map { position: absolute; right: 0.5rem; bottom: 0.5rem; width: clamp(6.5rem, 26%, 12rem); height: auto; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.18); }++.camera-screen > .sim-hud { position: absolute; left: 0.6rem; bottom: 0.6rem; padding: 0.15rem 0.5rem; border-radius: 6px; background: rgba(0, 0, 0, 0.55); color: #e6edf3; font: 600 0.8rem ui-monospace, monospace; font-variant-numeric: tabular-nums; }++.camera-screen.sim-live > .camera-icon, .camera-screen.sim-live > .camera-title, .camera-screen.sim-live > .camera-sub { display: none; }++++/* /try: what this is before the panel, and the way to a real robot after it. */++.try-intro { grid-column: 1 / -1; margin: 0 0 1rem; max-width: 52rem; }++.try-intro h2 { margin: 0 0 0.35rem; font-size: 1.15rem; }++.try-intro p { margin: 0.35rem 0; color: var(--muted); line-height: 1.55; }++.try-keys { display: flex; flex-wrap: wrap; gap: 0.4rem 1.25rem; margin: 0.5rem 0; padding: 0; list-style: none; color: var(--muted); font-size: 0.9rem; }++.try-next { margin-top: 1.25rem; max-width: 52rem; }++.try-next h2 { margin: 0 0 0.35rem; font-size: 1.1rem; }++.try-next p { color: var(--muted); line-height: 1.55; }++.try-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0; }@@@ -103,7 +103,7 @@n.hidden = !text;}function raw(frame) {-- if (!ws || ws.readyState !== WebSocket.OPEN) return false;++ if (!ws || ws.readyState !== 1) return false; // OPEN, for a WebSocket and /try's local socket alikeif (watcher && frame.type !== 'join') return false; // /watch is read-onlyws.send(JSON.stringify({ v: 1, ...frame }));return true;@@@ -117,6 +117,7 @@if (!joined || !allowed.includes(kind)) return false;const id = newId();if (!raw({ type: 'command', id, kind, value })) return false;++ window.dispatchEvent(new CustomEvent('ov-bot-command', { detail: { kind, value } }));sentAt.set(id, performance.now());if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);return true;@@@ -669,10 +670,23 @@el.textContent = text ? `Device fault: ${text}` : '';el.hidden = !text;}-- function readout(v) {++ // A readout's sensor keys by the widget's capability, as the Node's drivers report them (Adeept: distance_cm and++ // line {left, middle, right}; Cozmo: cliff and picked_up), else the capability's own name. One key found shows its++ // value; several show each by name.++ const SENSOR_KEYS = { ultrasonic: ['distance_cm', 'ultrasonic'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };++ function sensorValue(sensors, capability) {++ const name = capability.replace(/^sensor\./, '');++ const found = (SENSOR_KEYS[name] || [name]).filter((key) => sensors[key] !== undefined);++ if (!found.length) return [null, undefined];++ if (found.length === 1) return [found[0], sensors[found[0]]];++ return ['', Object.fromEntries(found.map((key) => [key, sensors[key]]))];++ }++ const flag = (x) => (x === true ? 'on' : x === false ? 'off' : x);++ function readout(v, key = null) {if (v == null) return '—';-- if (typeof v !== 'object') return String(v);-- return Object.entries(v).map(([k, x]) => `${k}: ${typeof x === 'object' ? JSON.stringify(x) : x}`).join(' · ') || '—';++ if (key === 'distance_cm' && typeof v === 'number') return v >= 400 ? 'over 4 m' : `${Math.round(v)} cm`;++ if (typeof v !== 'object') return String(flag(v));++ return Object.entries(v).map(([k, x]) => `${k.replace(/_/g, ' ')}: ${typeof x === 'object' ? JSON.stringify(x) : flag(x)}`).join(' · ') || '—';}function setOnline(state, label) {const pill = $('[data-online-pill]');@@@ -714,9 +728,10 @@for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';const sensors = state.telemetry && state.telemetry.sensors;for (const el of $$('[data-telemetry]')) {-- const key = el.dataset.telemetry.replace(/^sensor\./, '');-- el.textContent = readout(sensors ? (sensors[key] !== undefined ? sensors[key] : sensors) : null);++ const [key, value] = sensors ? sensorValue(sensors, el.dataset.telemetry) : [null, null];++ el.textContent = readout(key != null ? value : null, key);}++ window.dispatchEvent(new CustomEvent('ov-bot-state', { detail: state }));}// ── The turn queue strip (a `queue` robot's signed-in non-member) ─────────────────────────────────@@@ -774,8 +789,17 @@}}++ // /try's rover lives in the page (public/try-device.js): its socket answers /control's frames locally.++ const LOCAL = main.dataset.transport === 'local' ? window.OVBotLocalSocket : null;function connect() {const path = watcher ? '/watch' : '/control';++ if (LOCAL) {++ ws = new LOCAL(wsUrl(path));++ 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 = () => { releaseAll(); joined = false; enableControls(); };++ return;++ }if (driving && ticket) {ws = new WebSocket(wsUrl(path), ['ov.ticket', `ov.ticket.${ticket}`]);ticket = null;nnew file mode 100644@@@ -0,0 +1,275 @@++'use strict';++++/**++ * The simulated rover's camera: every camera tile the server marked `data-sim-camera` (a robot whose profile's++ * driver is `sim`) draws the rover's view of the shared arena (public/sim-world.js) instead of waiting for video,++ * with a top-down map in its corner. Plain ES, no build step; canvas only, so the page's CSP is untouched.++ *++ * Where the pose comes from:++ * - /try: the rover runs in this page (public/try-device.js sets window.OVSimLocal), so the view reads it each frame;++ * - any other sim robot: Bot's in-process simulator reports `telemetry.sim` ({ pose, drive, headlights, horn,++ * bumped }) about twice a second (panel.js re-dispatches each state as `ov-bot-state`). Between reports the view++ * moves the rover on by its last drive with the same kinematics, and eases toward each new report.++ *++ * The horn plays through Web Audio once the page has been touched (browsers allow sound only after a gesture).++ */++(() => {++ const W = window.OVSimWorld;++ const tiles = [...document.querySelectorAll('[data-sim-camera]')];++ if (!W || !tiles.length) return;++++ const RW = 400; const RH = 225; // the rendered picture; drawn scaled up, smoothed++ const FOV = 74; // degrees across++ const F = (RW / 2) / Math.tan((FOV / 2) * Math.PI / 180);++ const HORIZON = Math.round(RH * 0.46);++ const CAM = W.CAMERA_HEIGHT;++++ // Colours (linear-ish 0..255 RGB).++ const SOLID = { '#': [96, 112, 138], r: [214, 84, 66], b: [59, 130, 246], g: [34, 160, 107], y: [226, 178, 36] };++ const FLOOR = { tile: [182, 190, 201], tile2: [164, 173, 186], line: [244, 192, 38] };++ const FOG = [196, 205, 218];++ const SKY_TOP = [58, 68, 86]; const SKY_LOW = [150, 162, 180];++++ // ── Pose: local (the /try rover) or predicted from the server's reports ──────────────────────────++ const remote = { pose: null, drive: {}, headlights: null, horn: null, online: false };++ let shown = null; // the pose drawn last++ let correction = null; // { dx, dy, dh } still to blend in++++ window.addEventListener('ov-bot-state', (e) => {++ const state = e.detail || {};++ remote.online = !!state.online;++ const sim = state.telemetry && state.telemetry.sim;++ if (!sim || !sim.pose) return;++ remote.drive = sim.drive || {};++ remote.headlights = sim.headlights || null;++ remote.horn = sim.horn || null;++ if (!shown) { shown = { ...sim.pose }; return; }++ let dh = (sim.pose.heading - shown.heading) % 360;++ if (dh > 180) dh -= 360; if (dh < -180) dh += 360;++ const far = Math.hypot(sim.pose.x - shown.x, sim.pose.y - shown.y) > 0.6 || Math.abs(dh) > 60;++ if (far) { shown = { ...sim.pose }; correction = null; } else correction = { dx: sim.pose.x - shown.x, dy: sim.pose.y - shown.y, dh };++ });++ // The operator's own drive moves the picture at once (the server's report confirms it a moment later).++ window.addEventListener('ov-bot-command', (e) => {++ const c = e.detail || {};++ if (c.kind === 'drive' && c.value && typeof c.value === 'object') remote.drive = { ...c.value };++ if (c.kind === 'halt') remote.drive = {};++ });++++ function frameState(dt) {++ const local = window.OVSimLocal;++ if (local) return { pose: local.pose(), headlights: local.headlights(), horn: local.horn(), online: true };++ if (!shown || !remote.online) return { pose: shown, headlights: null, horn: null, online: remote.online && !!shown };++ shown = W.step(shown, remote.drive, dt).pose;++ if (correction) {++ const k = Math.min(1, dt * 6);++ shown = { x: shown.x + correction.dx * k, y: shown.y + correction.dy * k, heading: (shown.heading + correction.dh * k + 360) % 360 };++ correction = { dx: correction.dx * (1 - k), dy: correction.dy * (1 - k), dh: correction.dh * (1 - k) };++ if (Math.abs(correction.dx) + Math.abs(correction.dy) < 0.002 && Math.abs(correction.dh) < 0.2) correction = null;++ }++ return { pose: shown, headlights: remote.headlights, horn: remote.horn, online: true };++ }++++ // ── The renderer: floor casting, then each column's solids far to near ───────────────────────────++ const buffer = document.createElement('canvas');++ buffer.width = RW; buffer.height = RH;++ const bctx = buffer.getContext('2d');++ const img = bctx.createImageData(RW, RH);++ const px = img.data;++ const lerp = (a, b, t) => a + (b - a) * t;++ function put(i, c, fog, light) {++ px[i] = Math.min(255, lerp(c[0], FOG[0], fog) + light[0]);++ px[i + 1] = Math.min(255, lerp(c[1], FOG[1], fog) + light[1]);++ px[i + 2] = Math.min(255, lerp(c[2], FOG[2], fog) + light[2]);++ px[i + 3] = 255;++ }++ const NO_LIGHT = [0, 0, 0];++ /** The headlights' added colour at a point `d` metres ahead and `lateral` (screen fraction) off the axis. */++ function lit(lights, d, lateral) {++ if (!lights) return NO_LIGHT;++ const cone = Math.max(0, 1 - Math.abs(lateral) / 0.55);++ const k = (0.75 * cone * cone) / (1 + d * d * 1.6);++ return [lights.r * k, lights.g * k, lights.b * k];++ }++++ function render({ pose, headlights }) {++ const a = (pose.heading * Math.PI) / 180;++ const fx = Math.sin(a); const fy = Math.cos(a); // forward++ const rx = Math.cos(a); const ry = -Math.sin(a); // right++ // Above the horizon: the room beyond the arena.++ for (let y = 0; y <= HORIZON; y++) {++ const t = y / HORIZON;++ const c = [lerp(SKY_TOP[0], SKY_LOW[0], t), lerp(SKY_TOP[1], SKY_LOW[1], t), lerp(SKY_TOP[2], SKY_LOW[2], t)];++ for (let x = 0; x < RW; x++) put((y * RW + x) * 4, c, 0, NO_LIGHT);++ }++ // The floor: each row is one distance; each pixel one point on it.++ for (let y = HORIZON + 1; y < RH; y++) {++ const d = (CAM * F) / (y - HORIZON);++ const fog = 1 - Math.exp(-d * 0.16);++ for (let x = 0; x < RW; x++) {++ const s = (x - RW / 2 + 0.5) / F;++ const wx = pose.x + (fx + rx * s) * d; const wy = pose.y + (fy + ry * s) * d;++ put((y * RW + x) * 4, FLOOR[W.floorAt(wx, wy)], fog, lit(headlights, d, s));++ }++ }++ // Solids: arena walls and crates, far first so nearer ones cover them.++ for (let x = 0; x < RW; x++) {++ const s = (x - RW / 2 + 0.5) / F;++ const off = Math.atan(s);++ const hits = W.cast(pose.x, pose.y, pose.heading + (off * 180) / Math.PI, 14);++ for (let h = hits.length - 1; h >= 0; h--) {++ const hit = hits[h];++ const pd = Math.max(0.02, hit.dist * Math.cos(off));++ const height = W.heights[hit.cell] || 0.4;++ const top = HORIZON - ((height - CAM) * F) / pd;++ const bottom = HORIZON + (CAM * F) / pd;++ const y0 = Math.max(0, Math.ceil(top)); const y1 = Math.min(RH - 1, Math.floor(bottom));++ const base = SOLID[hit.cell] || SOLID['#'];++ const shade = hit.side === 'x' ? 0.8 : 1;++ const fog = 1 - Math.exp(-pd * 0.16);++ const light = lit(headlights, pd, s);++ const crate = hit.cell !== '#';++ const edgeU = hit.u < 0.06 || hit.u > 0.94;++ for (let y = y0; y <= y1; y++) {++ const v = (y - top) / Math.max(1, bottom - top); // 0 at the top edge, 1 at the floor++ let k = shade;++ if (crate) { if (edgeU || v < 0.07 || v > 0.93) k *= 0.72; else if (Math.abs(v - 0.5) < 0.04) k *= 0.86; }++ else { if (v < 0.08) k *= 1.18; else if (v > 0.9) k *= 0.7; if (hit.u < 0.02 || Math.abs(hit.u - 0.5) < 0.012) k *= 0.85; }++ put((y * RW + x) * 4, [base[0] * k, base[1] * k, base[2] * k], fog, light);++ }++ }++ }++ bctx.putImageData(img, 0, 0);++ }++++ /** The top-down map: the arena, the tape, the rover and its heading (+y up). */++ function renderMap(ctx, w, h, { pose, headlights }) {++ const s = Math.min(w / (W.COLS * W.CELL), h / (W.ROWS * W.CELL));++ const X = (x) => x * s; const Y = (y) => h - y * s;++ ctx.clearRect(0, 0, w, h);++ ctx.fillStyle = 'rgba(17, 21, 28, 0.72)';++ ctx.fillRect(0, 0, w, h);++ for (let cy = 0; cy < W.ROWS; cy++) for (let cx = 0; cx < W.COLS; cx++) {++ const cell = W.cellAt(cx, cy);++ if (cell === '.') continue;++ const c = SOLID[cell];++ ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;++ ctx.fillRect(X(cx * W.CELL), Y((cy + 1) * W.CELL), W.CELL * s + 0.5, W.CELL * s + 0.5);++ }++ const L = W.LOOP;++ ctx.strokeStyle = 'rgba(242, 194, 48, 0.9)';++ ctx.lineWidth = Math.max(1, s * 0.05);++ ctx.beginPath();++ if (ctx.roundRect) ctx.roundRect(X(L.x0), Y(L.y1), (L.x1 - L.x0) * s, (L.y1 - L.y0) * s, L.r * s);++ else ctx.rect(X(L.x0), Y(L.y1), (L.x1 - L.x0) * s, (L.y1 - L.y0) * s);++ ctx.stroke();++ if (!pose) return;++ const a = (pose.heading * Math.PI) / 180;++ const r = Math.max(4, W.ROVER_RADIUS * s * 1.4);++ const cx = X(pose.x); const cy = Y(pose.y);++ if (headlights) {++ const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r * 5);++ g.addColorStop(0, `rgba(${headlights.r}, ${headlights.g}, ${headlights.b}, 0.55)`);++ g.addColorStop(1, `rgba(${headlights.r}, ${headlights.g}, ${headlights.b}, 0)`);++ ctx.fillStyle = g;++ ctx.beginPath();++ ctx.moveTo(cx, cy);++ ctx.arc(cx, cy, r * 5, a - Math.PI / 2 - 0.5, a - Math.PI / 2 + 0.5); // canvas angles: 0 is +x, clockwise++ ctx.fill();++ }++ ctx.fillStyle = '#f8fafc';++ ctx.strokeStyle = '#11151c';++ ctx.lineWidth = 1.5;++ ctx.beginPath();++ // Screen angle: heading 0 is up (−y on the canvas), clockwise positive.++ const pt = (ang, rad) => [cx + Math.sin(a + ang) * rad, cy - Math.cos(a + ang) * rad];++ ctx.moveTo(...pt(0, r * 1.3));++ ctx.lineTo(...pt(2.5, r));++ ctx.lineTo(...pt(Math.PI, r * 0.45));++ ctx.lineTo(...pt(-2.5, r));++ ctx.closePath();++ ctx.fill();++ ctx.stroke();++ }++++ // ── The horn ──────────────────────────────────────────────────────────────────────────────────────++ let audio = null; let osc = null; let gain = null; let toneHz = null;++ const unlock = () => {++ if (audio) return;++ const Ctx = window.AudioContext || window.webkitAudioContext;++ if (!Ctx) return;++ try { audio = new Ctx(); } catch { audio = null; }++ };++ window.addEventListener('pointerdown', unlock, { capture: true });++ window.addEventListener('keydown', unlock, { capture: true });++ function horn(h) {++ const hz = h && Number.isFinite(h.hz) ? h.hz : null;++ if (hz === toneHz || !audio) return;++ toneHz = hz;++ if (osc) { try { gain.gain.setTargetAtTime(0, audio.currentTime, 0.015); osc.stop(audio.currentTime + 0.08); } catch { /* stopped */ } osc = null; }++ if (hz == null) return;++ osc = audio.createOscillator();++ gain = audio.createGain();++ osc.type = 'square';++ osc.frequency.value = hz;++ gain.gain.value = 0;++ gain.gain.setTargetAtTime(0.05, audio.currentTime, 0.01);++ osc.connect(gain).connect(audio.destination);++ osc.start();++ if (audio.state === 'suspended') audio.resume().catch(() => {});++ }++++ // ── One canvas per tile ───────────────────────────────────────────────────────────────────────────++ const views = tiles.map((tile) => {++ const screen = tile.querySelector('[data-camera-screen]');++ if (!screen) return null;++ const placeholder = [...screen.children];++ const canvas = document.createElement('canvas');++ canvas.className = 'sim-camera';++ canvas.width = RW * 2; canvas.height = RH * 2;++ canvas.setAttribute('role', 'img');++ canvas.setAttribute('aria-label', 'The simulated rover\'s front camera');++ canvas.hidden = true;++ const map = document.createElement('canvas');++ map.className = 'sim-map';++ map.width = 192; map.height = 144;++ map.setAttribute('role', 'img');++ map.setAttribute('aria-label', 'Map of the arena with the rover');++ map.hidden = true;++ const hud = document.createElement('span');++ hud.className = 'sim-hud';++ hud.hidden = true;++ screen.append(canvas, map, hud);++ return { screen, placeholder, canvas, ctx: canvas.getContext('2d'), map, mctx: map.getContext('2d'), hud, live: false };++ }).filter(Boolean);++++ function show(v, on) {++ if (v.live === on) return;++ v.live = on;++ v.canvas.hidden = !on; v.map.hidden = !on; v.hud.hidden = !on;++ for (const el of v.placeholder) el.hidden = on;++ v.screen.classList.toggle('sim-live', on);++ }++++ let last = performance.now();++ function frame(now) {++ const dt = Math.min(0.1, (now - last) / 1000);++ last = now;++ const st = frameState(dt);++ horn(st.online ? st.horn : null);++ if (!document.hidden && st.online && st.pose) {++ render(st);++ for (const v of views) {++ show(v, true);++ v.ctx.imageSmoothingEnabled = true;++ v.ctx.drawImage(buffer, 0, 0, v.canvas.width, v.canvas.height);++ renderMap(v.mctx, v.map.width, v.map.height, st);++ const cm = W.distanceCm(st.pose);++ const text = `${cm >= 400 ? '> 4 m' : `${cm} cm`} ahead`;++ if (v.hud.textContent !== text) v.hud.textContent = text;++ }++ } else if (!st.online) for (const v of views) show(v, false);++ requestAnimationFrame(frame);++ }++ requestAnimationFrame(frame);++})();nnew file mode 100644@@@ -0,0 +1,157 @@++'use strict';++++/**++ * The simulated rover's world (sim.rover): one small arena that Bot's in-process simulator (server/sim) and the++ * browser (the /try page's local rover, and the camera of every sim robot's panel) share, so the picture a panel++ * draws and the pose the server keeps always agree. No DOM, no Node API: it loads as a CommonJS module on the++ * server and as `window.OVSimWorld` in a page.++ *++ * Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and++ * 90 faces +x, so a positive steer turns right (the simulator's convention since Bot#20): forward at full throttle is++ * 0.5 m/s and a full steer turns 90 °/s, as before. MAP's text rows run from y = 0 at the top of the text.++ *++ * step(pose, drive, dt) → { pose, bumped } one kinematic step with collisions (a bumper stops the move)++ * distanceCm(pose) → the ultrasonic reading ahead, in cm (≤ 400), from a 12° cone++ * line(pose) → { left, middle, right }: whether each line sensor is over the tape++ * floorAt(x, y) → 'line' | 'tile' | 'tile2'++ * cast(x, y, angle, max) → the cells a ray crosses, nearest first ({ dist, cell, side, u })++ * solid(cx, cy), cellAt(cx, cy), MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, heights++ */++(function (root, factory) {++ if (typeof module === 'object' && module.exports) module.exports = factory();++ else root.OVSimWorld = factory();++}(typeof self !== 'undefined' ? self : this, () => {++ const CELL = 0.5;++ // '#' arena wall, 'r' 'b' 'g' 'y' crates, '.' floor. Row r covers y ∈ [r·CELL, (r+1)·CELL); column c covers x.++ const MAP = [++ '################',++ '#..............#',++ '#..r.......b...#',++ '#..............#',++ '#.....##.......#',++ '#.....##....g..#',++ '#..............#',++ '#..y...........#',++ '#..........b...#',++ '#......r.......#',++ '#..............#',++ '################',++ ];++ const COLS = MAP[0].length;++ const ROWS = MAP.length;++ const ROVER_RADIUS = 0.14;++ const CAMERA_HEIGHT = 0.12;++ const SPEED = 0.5; // m/s at full throttle++ const TURN = 90; // °/s at full steer++ /** How tall each solid is, in metres: the arena wall stands above the rover's camera, crates are lower. */++ const heights = { '#': 0.42, r: 0.26, b: 0.3, g: 0.22, y: 0.26 };++ // Parked on the tape at the near left corner, facing along it (+y).++ const START = Object.freeze({ x: 1.0, y: 1.5, heading: 0 });++++ // The tape loop: a rectangle with rounded corners, 5 cm wide, around the crates.++ const LOOP = { x0: 1.0, y0: 0.75, x1: 7.0, y1: 5.25, r: 0.45, half: 0.025 };++++ const cellAt = (cx, cy) => (cy < 0 || cy >= ROWS || cx < 0 || cx >= COLS ? '#' : MAP[cy][cx]);++ const solid = (cx, cy) => cellAt(cx, cy) !== '.';++ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };++ const rad = (deg) => (deg * Math.PI) / 180;++++ /** Distance from (x, y) to the loop's centre line. */++ function loopDistance(x, y) {++ const { x0, y0, x1, y1, r } = LOOP;++ // Distance to a rounded rectangle's outline: clamp to the inner rectangle, then subtract the radius.++ const ix0 = x0 + r; const iy0 = y0 + r; const ix1 = x1 - r; const iy1 = y1 - r;++ const dx = Math.max(ix0 - x, 0, x - ix1);++ const dy = Math.max(iy0 - y, 0, y - iy1);++ if (dx > 0 || dy > 0) return Math.abs(Math.hypot(dx, dy) - r);++ // Inside the inner rectangle: the nearest straight edge.++ return Math.min(x - ix0, ix1 - x, y - iy0, iy1 - y) + r;++ }++ function floorAt(x, y) {++ if (loopDistance(x, y) <= LOOP.half) return 'line';++ return (Math.floor(x / CELL) + Math.floor(y / CELL)) % 2 ? 'tile2' : 'tile';++ }++++ /** Does a rover of radius `r` at (x, y) touch a solid cell? */++ function blocked(x, y, r = ROVER_RADIUS) {++ for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {++ for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {++ if (!solid(cx, cy)) continue;++ const nx = Math.max(cx * CELL, Math.min(x, (cx + 1) * CELL));++ const ny = Math.max(cy * CELL, Math.min(y, (cy + 1) * CELL));++ if ((x - nx) ** 2 + (y - ny) ** 2 < r * r) return true;++ }++ }++ return false;++ }++++ /**++ * One kinematic step: forward speed from throttle (or y), turn rate from steer (or rotation), sideways from x++ * (a holonomic drive). Each axis of the move is tried on its own, so the rover slides along a wall; a move a++ * solid stops is `bumped`.++ */++ function step(pose, drive = {}, dt = 0) {++ const speed = num(drive.throttle != null ? drive.throttle : drive.y);++ const turn = num(drive.steer != null ? drive.steer : drive.rotation);++ const strafe = num(drive.x);++ const t = Math.max(0, Math.min(1, dt));++ const heading = (((num(pose.heading) + turn * TURN * t) % 360) + 360) % 360;++ const a = rad(heading);++ const dx = (speed * Math.sin(a) + strafe * Math.cos(a)) * SPEED * t;++ const dy = (speed * Math.cos(a) - strafe * Math.sin(a)) * SPEED * t;++ let x = num(pose.x); let y = num(pose.y);++ let bumped = false;++ if (dx) { if (blocked(x + dx, y)) bumped = true; else x += dx; }++ if (dy) { if (blocked(x, y + dy)) bumped = true; else y += dy; }++ return { pose: { x, y, heading }, bumped };++ }++++ /**++ * Every cell a ray from (x, y) at `angle` (degrees, the heading convention) crosses until an arena wall or++ * `max` metres, nearest first: { dist (along the ray), cell, side ('x' | 'y'), u (0..1 across the face) }.++ */++ function cast(x, y, angle, max = 12) {++ const a = rad(angle);++ const rx = Math.sin(a); const ry = Math.cos(a);++ let cx = Math.floor(x / CELL); let cy = Math.floor(y / CELL);++ const stepX = rx > 0 ? 1 : -1; const stepY = ry > 0 ? 1 : -1;++ const dX = rx ? Math.abs(CELL / rx) : Infinity; const dY = ry ? Math.abs(CELL / ry) : Infinity;++ let tX = rx ? ((rx > 0 ? (cx + 1) * CELL - x : x - cx * CELL) / Math.abs(rx)) : Infinity;++ let tY = ry ? ((ry > 0 ? (cy + 1) * CELL - y : y - cy * CELL) / Math.abs(ry)) : Infinity;++ const hits = [];++ for (let i = 0; i < 64; i++) {++ let dist; let side;++ if (tX < tY) { dist = tX; tX += dX; cx += stepX; side = 'x'; } else { dist = tY; tY += dY; cy += stepY; side = 'y'; }++ if (dist > max) break;++ const cell = cellAt(cx, cy);++ if (cell === '.') continue;++ const hx = x + rx * dist; const hy = y + ry * dist;++ const u = side === 'x' ? (hy / CELL) % 1 : (hx / CELL) % 1;++ hits.push({ dist, cell, side, u, cx, cy });++ if (cell === '#') break;++ }++ return hits;++ }++++ /** The ultrasonic sensor 0.1 m ahead of the centre: the nearest solid inside a 12° cone, in whole cm (≤ 400). */++ function distanceCm(pose) {++ const a = rad(pose.heading);++ const sx = pose.x + Math.sin(a) * 0.1; const sy = pose.y + Math.cos(a) * 0.1;++ let best = 4;++ for (const off of [-6, -3, 0, 3, 6]) {++ const h = cast(sx, sy, pose.heading + off, 4)[0];++ if (h && h.dist < best) best = h.dist;++ }++ return Math.round(best * 100);++ }++++ /** Three line sensors 0.12 m ahead, 4 cm apart: whether each is over the tape. */++ function line(pose) {++ const a = rad(pose.heading);++ const fx = pose.x + Math.sin(a) * 0.12; const fy = pose.y + Math.cos(a) * 0.12;++ const at = (side) => floorAt(fx + Math.cos(a) * side, fy - Math.sin(a) * side) === 'line';++ return { left: at(-0.04), middle: at(0), right: at(0.04) };++ }++++ return { MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, SPEED, TURN, LOOP, heights, cellAt, solid, blocked, floorAt, step, cast, distanceCm, line };++}));nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++++/**++ * /try: a simulated rover that lives in this page, so anyone can drive the real panel with no account and no++ * hardware. It stands in for both ends of a robot's link: `window.OVBotLocalSocket` answers the panel's /control++ * frames (public/panel.js uses it when the page says data-transport="local"), and the rover runs on the shared++ * arena's kinematics (public/sim-world.js) at the page's frame rate.++ *++ * It keeps the gate's rules that a driver would feel: only the profile's command kinds; drive axes and actuator++ * values shaped to the profile's ranges (as server/domain does); a latched e-stop that refuses everything but halt++ * until it is cleared; and the device's deadman: a motion command lapses max_command_ms after it was sent unless++ * a newer one arrives (the panel re-sends a held control every 150 ms). Bumping into something stops the wheels.++ *++ * window.OVSimLocal exposes the pose, the headlights and the horn to the camera view (public/sim-view.js).++ */++(() => {++ const W = window.OVSimWorld;++ const main = document.getElementById('panel');++ const source = document.getElementById('try-profile');++ if (!W || !main || main.dataset.transport !== 'local' || !source) return;++ const profile = JSON.parse(source.textContent);++ const commands = profile.commands || {};++ const limits = profile.limits || {};++ const DEADLINE_MS = limits.max_command_ms || 300;++ const ALLOWED = [...Object.keys(commands).filter((k) => k !== 'halt'), 'halt'];++ const TELEMETRY_MS = 200;++ const BATTERY_FLOOR = 20;++++ const rover = {++ pose: { ...W.START }, drive: {}, deadline: 0, headlights: null, horn: null, hornUntil: 0,++ battery: 100, estop: false, bumped: false,++ };++ const clamp = (v, lo, hi) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; };++ const noteHz = (note) => {++ const m = /^([A-Ga-g])([#b]?)(-?\d)$/.exec(String(note));++ if (!m) return null;++ const semis = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[m[1].toUpperCase()] + (m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0);++ return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);++ };++++ /** The value the device gets, or a refusal reason (server/domain buildValue, for the kinds this profile has). */++ function shape(kind, value) {++ const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};++ if (kind === 'halt') return { value: {} };++ if (kind === 'drive') {++ const out = {};++ for (const [axis, [lo, hi]] of Object.entries(commands.drive.axes)) out[axis] = clamp(v[axis], lo, hi);++ return { value: out };++ }++ if (kind === 'actuator') {++ const spec = commands.actuator.names[v.name];++ if (!spec) return { refused: 'this robot has no such actuator' };++ const x = v.value;++ if (spec.type === 'rgb') return { value: { name: v.name, value: x ? { r: Math.round(clamp(x.r, 0, 255)), g: Math.round(clamp(x.g, 0, 255)), b: Math.round(clamp(x.b, 0, 255)) } : null } };++ if (spec.type === 'tone') {++ if (!x) return { value: { name: v.name, value: null } };++ const hz = x.note != null ? noteHz(x.note) : Number(x.hz);++ if (!Number.isFinite(hz)) return { refused: `${v.name} takes {note} or {hz}` };++ return { value: { name: v.name, value: hz === 0 ? null : { hz: Math.round(clamp(hz, spec.hz[0], spec.hz[1])) } } };++ }++ if (spec.type === 'number') return { value: { name: v.name, value: clamp(x, spec.range[0], spec.range[1]) } };++ return { value: { name: v.name, value: !!x } };++ }++ return { refused: `${kind} is not something this robot does` };++ }++++ function halt() { rover.drive = {}; rover.deadline = 0; }++ function apply(kind, value) {++ const now = performance.now();++ if (kind === 'halt') { halt(); return; }++ if (kind === 'drive') { rover.drive = value; rover.deadline = now + DEADLINE_MS; return; }++ if (kind === 'actuator') {++ if (value.name === 'headlights') rover.headlights = value.value;++ else if (value.name === 'horn') { rover.horn = value.value; rover.hornUntil = value.value ? now + DEADLINE_MS : 0; }++ }++ }++++ function state() {++ return {++ robot_id: main.dataset.robotId, online: true,++ estop: { latched: rover.estop, by: null, at: null },++ latency_ms: 12, battery: rover.battery / 100,++ telemetry: {++ battery: { percent: Math.round(rover.battery) },++ sensors: { distance_cm: W.distanceCm(rover.pose), line: W.line(rover.pose) },++ sim: { pose: { ...rover.pose }, drive: { ...rover.drive }, headlights: rover.headlights, horn: rover.horn, bumped: rover.bumped },++ },++ status: null, device_estop: null, queue: null,++ };++ }++++ // ── The rover's clock ─────────────────────────────────────────────────────────────────────────────++ let last = performance.now();++ function tick() {++ const now = performance.now();++ const dt = Math.min(0.1, (now - last) / 1000);++ last = now;++ if (rover.deadline && now > rover.deadline) halt(); // the deadman++ if (rover.hornUntil && now > rover.hornUntil) { rover.horn = null; rover.hornUntil = 0; }++ const moving = Object.values(rover.drive).some((x) => x);++ if (moving && !rover.estop) {++ const r = W.step(rover.pose, rover.drive, dt);++ rover.pose = r.pose;++ if (r.bumped && !rover.bumped) halt(); // the bumper stops the wheels++ rover.bumped = r.bumped;++ rover.battery = Math.max(BATTERY_FLOOR, rover.battery - 0.25 * dt);++ } else rover.bumped = false;++ requestAnimationFrame(tick);++ }++ requestAnimationFrame(tick);++ window.OVSimLocal = { pose: () => rover.pose, headlights: () => rover.headlights, horn: () => rover.horn };++++ // ── The panel's socket ────────────────────────────────────────────────────────────────────────────++ const sockets = new Set();++ class LocalSocket {++ constructor(url) {++ this.url = url;++ this.readyState = 0;++ this.onopen = null; this.onmessage = null; this.onclose = null;++ setTimeout(() => {++ if (this.readyState !== 0) return;++ this.readyState = 1;++ sockets.add(this);++ if (this.onopen) this.onopen({});++ }, 40);++ }++ deliver(frame, delay = 0) {++ const out = () => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify({ v: 1, ...frame }) }); };++ if (delay) setTimeout(out, delay); else out();++ }++ send(raw) {++ if (this.readyState !== 1) return;++ let m; try { m = JSON.parse(raw); } catch { return; }++ if (m.type === 'join') {++ this.joined = true;++ this.deliver({ type: 'joined', robot_id: m.robot_id, role: 'owner', allowed_commands: ALLOWED, state: state() }, 20);++ return;++ }++ if (!this.joined) return this.deliver({ type: 'error', code: 'bot.not_joined', detail: 'join first' });++ if (m.type === 'estop') { rover.estop = true; halt(); rover.horn = null; return broadcast(); }++ if (m.type === 'estop_clear') { rover.estop = false; return broadcast(); }++ if (m.type !== 'command') return;++ const id = String(m.id || '');++ const kind = String(m.kind || '');++ const reply = (fields) => this.deliver({ type: 'command_result', id, ...fields }, 10 + Math.round(Math.random() * 14));++ if (!ALLOWED.includes(kind)) return reply({ result: 'refused', code: 'bot.command_not_allowed', reason: `${kind} is not something this robot does` });++ if (rover.estop && kind !== 'halt') return reply({ result: 'refused', code: 'bot.estopped', reason: 'The e-stop is latched. Clear it to drive again.' });++ const shaped = shape(kind, m.value);++ if (shaped.refused) return reply({ result: 'refused', code: 'bot.invalid_input', reason: shaped.refused });++ apply(kind, shaped.value);++ return reply({ result: 'ack' });++ }++ close() {++ if (this.readyState === 3) return;++ this.readyState = 3;++ sockets.delete(this);++ if (this.onclose) this.onclose({ code: 1000 });++ }++ addEventListener() { /* the panel's /control socket uses on* handlers only */ }++ removeEventListener() {}++ }++ LocalSocket.CONNECTING = 0; LocalSocket.OPEN = 1; LocalSocket.CLOSING = 2; LocalSocket.CLOSED = 3;++ function broadcast() { const s = state(); for (const ws of sockets) if (ws.joined) ws.deliver({ type: 'robot_state', state: s }); }++ setInterval(() => { if (!document.hidden) broadcast(); }, TELEMETRY_MS);++ window.OVBotLocalSocket = LocalSocket;++++ // "Back to the start": put the rover where it began (a page-only control, not a robot command).++ for (const b of document.querySelectorAll('[data-try-reset]')) {++ b.addEventListener('click', () => { halt(); rover.pose = { ...W.START }; rover.bumped = false; rover.battery = 100; broadcast(); });++ }++})();@@@ -12,7 +12,8 @@"speaker.horn","sensor.ultrasonic","sensor.line",-- "battery"++ "battery",++ "camera"],"mapping": {"driver": "pca9685",@@@ -38,11 +39,13 @@}},"widgets": [++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },++ { "type": "telemetry", "capability": "sensor.line", "label": "Line tracking", "order": 5 },{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },{ "type": "latency", "label": "Latency", "order": 7 }],@@@ -12,7 +12,8 @@"speaker.horn","sensor.ultrasonic","sensor.line",-- "battery"++ "battery",++ "camera"],"mapping": {"driver": "pca9685",@@@ -38,11 +39,13 @@}},"widgets": [++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },{ "type": "drive", "capability": "drive.mecanum", "label": "Drive", "order": 1, "command": { "kind": "drive" } },{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },++ { "type": "telemetry", "capability": "sensor.line", "label": "Line tracking", "order": 5 },{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },{ "type": "latency", "label": "Latency", "order": 7 }],@@@ -36,6 +36,7 @@"display": { "modes": ["text", "face", "image_png_b64"], "faces": ["neutral", "happy", "sad", "surprised", "sleepy", "angry"], "max_chars": 200 }},"widgets": [++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },{ "type": "head", "capability": "head", "label": "Head", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },{ "type": "lift", "capability": "lift", "label": "Lift", "order": 3, "command": { "kind": "actuator", "names": ["lift"] } },@@@ -1,19 +1,30 @@{"id": "sim.rover",-- "version": 1,++ "version": 2,"name": "Simulated rover","vendor": "OpenVibe","kind": "server",-- "description": "The browser simulator on the openvibe.bot home: a differential-drive rover with a camera, driven by the same gate and profile as real hardware.",-- "capabilities": ["drive.differential", "camera"],++ "description": "A differential-drive rover in a small arena, simulated by OpenVibe.Bot (or in your browser at /try): a front camera, an ultrasonic range finder, three line sensors over a strip of tape, a battery, RGB headlights and a horn, driven through the same gate and profile as real hardware.",++ "capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "sensor.line", "battery", "lights.rgb", "speaker.horn"],"mapping": { "driver": "sim" },"commands": {-- "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } }++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },++ "actuator": {++ "names": {++ "headlights": { "type": "rgb" },++ "horn": { "type": "tone", "hz": [220, 880] }++ }++ }},"widgets": [-- { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },-- { "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },-- { "type": "latency", "label": "Latency", "order": 3 }++ { "type": "camera", "capability": "camera", "label": "Front camera", "order": 1 },++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 2, "command": { "kind": "drive" } },++ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance ahead", "order": 3 },++ { "type": "telemetry", "capability": "sensor.line", "label": "Line sensors", "order": 4 },++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 5 },++ { "type": "lights", "capability": "lights.rgb", "label": "Headlights", "order": 6, "command": { "kind": "actuator", "names": ["headlights"] } },++ { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 7, "command": { "kind": "actuator", "names": ["horn"] } },++ { "type": "latency", "label": "Latency", "order": 8 }],"camera": { "transport": "whip", "resolution": "640x360" },"limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }@@@ -45,6 +45,20 @@ function parseCookies(header) {}const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);++// The keys the Node's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).++const SENSOR_KEYS = { ultrasonic: ['distance_cm'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };++const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);++const rgbOrNull = (c) => (c && typeof c === 'object' ? { r: finite(c.r), g: finite(c.g), b: finite(c.b) } : null);++/** A simulated rover's view for a watcher: its pose, drive, lights and horn as numbers, nothing else. */++function simView(sim) {++ const drive = {};++ for (const [k, v] of Object.entries(sim.drive && typeof sim.drive === 'object' ? sim.drive : {}).slice(0, 5)) drive[String(k).slice(0, 16)] = finite(v);++ return {++ pose: { x: finite(sim.pose.x), y: finite(sim.pose.y), heading: finite(sim.pose.heading) },++ drive, headlights: rgbOrNull(sim.headlights),++ horn: sim.horn && typeof sim.horn === 'object' ? { hz: finite(sim.horn.hz) } : null, bumped: !!sim.bumped,++ };++}function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {let domain = null;@@@ -685,11 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);return state;}-- /** The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities). */++ /**++ * The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities): the++ * capability's name, and the keys the Node's drivers report it under (public/panel.js SENSOR_KEYS).++ */function readoutKeys(profile) {const keys = new Set();for (const w of (profile && profile.widgets) || []) {-- if (w && w.type === 'telemetry' && typeof w.capability === 'string' && w.capability) keys.add(w.capability.replace(/^sensor\./, ''));++ if (!w || w.type !== 'telemetry' || typeof w.capability !== 'string' || !w.capability) continue;++ const name = w.capability.replace(/^sensor\./, '');++ for (const k of [name, ...(SENSOR_KEYS[name] || [])]) keys.add(k);}return [...keys];}@@@ -702,11 +721,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};const picked = {};for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];++ // A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.++ const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;return {robot_id: state.robot_id, online: state.online,estop: { latched: !!(state.estop && state.estop.latched) },latency_ms: state.latency_ms, battery: state.battery,-- telemetry: t ? { sensors: picked } : null,++ telemetry: t ? { sensors: picked, ...(sim ? { sim } : {}) } : null,};}function broadcast(robotId) {@@@ -13,13 +13,19 @@** No device row is written: the simulator's id is `dev_sim_<robot id>`, and a real device online for the same* robot takes precedence over it.++ *++ * The rover lives in the arena public/sim-world.js describes (the same module the browser draws its camera from):++ * walls and crates stop it, and its telemetry carries what a real rover's would (`sensors.distance_cm`,++ * `sensors.line`) plus `sim` ({ pose, drive, headlights, horn, bumped }) for the panel's simulated camera.*/const { getProfile } = require('../profiles');++const world = require('../../public/sim-world');const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');const BATTERY_FLOOR = 20; // % the simulated pack never drains belowconst DRAIN_PER_S = 0.05; // % per second while moving--const EXISTS_EVERY = 20; // telemetry ticks between checks that the robot still exists++const EXISTS_EVERY = 20; // telemetry frames between checks that the robot still exists++const PHYSICS_MS = 50; // the rover moves at 20 Hz; telemetry goes out at the hub's sample ratefunction createSimulator({ config, domain, hub, now = () => Date.now(), log = console }) {const sims = new Map(); // robot_id → sim@@@ -30,11 +36,11 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = cofunction start(robot) {if (sims.has(robot.id)) return sims.get(robot.id);const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };-- const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { x: 0, y: 0, heading: 0 }, at: now(), ticks: 0 };++ const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };sims.set(robot.id, sim);connect(sim);sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));-- sim.timers.push(every(telemetryMs, () => tick(sim)));++ sim.timers.push(every(PHYSICS_MS, () => tick(sim)));return sim;}function connect(sim) {@@@ -61,6 +67,12 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co// The deadman: no newer command by the deadline → stop, as the Node does.sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();++ } else if (f.kind === 'actuator' && f.value && f.value.name === 'headlights') {++ sim.headlights = f.value.value || null;++ } else if (f.kind === 'actuator' && f.value && f.value.name === 'horn') {++ // A tone sounds while it is held: the panel re-sends it, and its deadline ends it like a motion command's.++ sim.horn = f.value.value || null;++ sim.hornUntil = sim.horn && Number.isFinite(f.deadline_ms) ? f.deadline_ms : 0;} else {sim.other[f.kind] = f.value;}@@@ -72,7 +84,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = cosim.link.deliver({type: 'telemetry',battery: { volts: Math.round((6.4 + (2 * sim.battery) / 100) * 100) / 100, percent: Math.round(sim.battery) },-- sensors: {}, rtt_ms: 1, drive: { ...sim.drive }, pose: { ...sim.pose },++ sensors: { distance_cm: world.distanceCm(sim.pose), line: world.line(sim.pose) }, rtt_ms: 1, drive: { ...sim.drive }, pose: { ...sim.pose },++ sim: { pose: { ...sim.pose }, drive: { ...sim.drive }, headlights: sim.headlights, horn: sim.horn, bumped: sim.bumped },});}@@@ -80,15 +93,23 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = coconst t = now();const dt = Math.max(0, (t - sim.at) / 1000);sim.at = t;-- // A simple kinematic model: forward speed from throttle (or y), turn rate from steer (or rotation).-- const speed = Number(sim.drive.throttle != null ? sim.drive.throttle : sim.drive.y) || 0;-- const turn = Number(sim.drive.steer != null ? sim.drive.steer : sim.drive.rotation) || 0;-- const strafe = Number(sim.drive.x) || 0;-- sim.pose.heading = (sim.pose.heading + turn * 90 * dt + 360) % 360;-- const rad = (sim.pose.heading * Math.PI) / 180;-- sim.pose.x = Math.round((sim.pose.x + (speed * Math.sin(rad) + strafe * Math.cos(rad)) * 0.5 * dt) * 1000) / 1000;-- sim.pose.y = Math.round((sim.pose.y + (speed * Math.cos(rad) - strafe * Math.sin(rad)) * 0.5 * dt) * 1000) / 1000;-- if (speed || turn || strafe) sim.battery = Math.max(BATTERY_FLOOR, sim.battery - DRAIN_PER_S * dt);++ // The arena's kinematics (public/sim-world.js); a stalled loop's long gap is cut into short steps so a bump is never jumped over.++ const moving = Object.values(sim.drive).some((v) => Number(v));++ if (moving) {++ let bumped = false;++ for (let left = Math.min(dt, 2); left > 0; left -= 0.05) {++ const r = world.step(sim.pose, sim.drive, Math.min(0.05, left));++ sim.pose = r.pose;++ if (r.bumped) { bumped = true; break; }++ }++ if (bumped && !sim.bumped) halt(sim); // the bumper stops the wheels, as the deadman does++ sim.bumped = bumped;++ sim.battery = Math.max(BATTERY_FLOOR, sim.battery - DRAIN_PER_S * dt);++ } else sim.bumped = false;++ if (sim.hornUntil && t > sim.hornUntil) { sim.horn = null; sim.hornUntil = 0; }++ if (t - sim.sentAt < telemetryMs) return;++ sim.sentAt = t;++ sim.pose = { x: Math.round(sim.pose.x * 1000) / 1000, y: Math.round(sim.pose.y * 1000) / 1000, heading: Math.round(sim.pose.heading * 10) / 10 };// The hub marks a silent device offline for good; a simulator that fell behind (a stalled loop) reconnects.if (!hub.isOnline(sim.device.id)) connect(sim);else telemetry(sim);@@@ -46,15 +46,15 @@ function sections({ signedIn }) {lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '+ 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',actions: signedIn-- ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]-- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots', primary: true }, { label: 'How it works', href: '#how' }],++ ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }, { label: 'How it works', href: '#how' }]++ : [{ label: 'Try it now, no account', href: '/try', primary: true }, { label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots' }, { label: 'How it works', href: '#how' }],note: 'Alpha. A paired robot sends its camera over WebRTC to OpenRestream and the panel plays it live; the first supported kit is the Adeept 4WD Smart Car for Raspberry Pi.',}) + showcase.steps({id: 'how',title: 'Online in three steps',lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',items: [-- { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists.' },++ { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists, and the one at /try needs no account at all.' },{ title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },],@@@ -73,7 +73,7 @@ function sections({ signedIn }) {items: [{ icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },{ icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },-- { icon: 'ov:check', title: 'A simulated rover', text: 'Runs inside Bot, so you can try the whole panel with no hardware.' },++ { icon: 'ov:check', title: 'A simulated rover', text: 'A rover in a small arena with a camera, a range finder, line sensors, headlights and a horn. Drive it at /try in your browser, with no account and no hardware.' },],}) + `<section class="sc-sec" id="build"><h2>Build for OpenVibe.Bot</h2><p class="sc-lede">Write a driver for a robot, or describe one with a profile and let the panel render it. The driver and profile spec, with a validator and the simulated rover, lives on the build pages.</p>@@@ -56,7 +56,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" arconst 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 = '' } = {}) {++function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null } = {}) {return `<!doctype html><html lang="en"><head>@@@ -64,7 +64,11 @@ function page(title, body, { scripts = [], bodyClass = '' } = {}) {<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>${description ? `++<meta name="description" content="${esc(description)}">++<meta property="og:title" content="${esc(title)} — OpenVibe.Bot">++<meta property="og:description" content="${esc(description)}">` : ''}${canonical ? `++<link rel="canonical" href="https://openvibe.bot${esc(canonical)}">` : ''}<link rel="stylesheet" href="/panel/panel.css"></head><body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>@@@ -76,7 +80,7 @@ ${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>`;++const topbar = (crumb = '', home = '/robots') => `<nav class="topbar"><a class="brand" href="${esc(home)}">${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(axes, enabled) {@@@ -109,19 +113,23 @@ function drivePad(axes, enabled) {+ `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;}++/** An actuator's name as a label: backpack_lights → Backpack lights. */++const humanName = (name) => { const s = String(name).replace(/_/g, ' '); return s.charAt(0).toUpperCase() + s.slice(1); };++/** One control per actuator the widget drives, by the actuator's declared type. */function actuatorControl(name, a, enabled) {const off = disabledUnless(enabled);++ const label = humanName(name);if (a.type === 'number') {const [lo, hi] = a.range;const start = Math.min(hi, Math.max(lo, 0));-- 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>`;++ return `<label class="control slider"><span class="control-name">${esc(label)}</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 colour"><label><span class="control-name">${esc(name)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`++ return `<div class="control colour"><label><span class="control-name">${esc(label)}</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 tone" data-tone="${esc(name)}"${off}>${icon('horn')}<span>${esc(name)}</span></button>`;++ if (a.type === 'tone') return `<button type="button" class="hold tone" data-tone="${esc(name)}"${off}>${icon('horn')}<span>${esc(label)}</span></button>`;return `<label class="control toggle"><input type="checkbox" data-bool="${esc(name)}"${off}> <span class="control-name">${esc(name)}</span></label>`;}@@@ -129,6 +137,14 @@ function actuatorControl(name, a, enabled) {* 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.*/++const isSim = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');++++/** The panel's scripts: the client, plus the shared arena and its camera for a simulated robot (and /try's local rover). */++function panelScripts(profile, local = false) {++ if (!isSim(profile)) return ['/panel/panel.js'];++ return ['/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];++}++function camerasOf(profile = {}) {const c = profile.camera;const list = Array.isArray(c) ? c.filter((x) => x && typeof x === 'object') : (c && typeof c === 'object' ? [c] : []);@@@ -142,16 +158,18 @@ function camerasOf(profile = {}) {*/function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {const all = camerasOf(profile).map((c, i) => ({ c, i }));++ // A simulated robot's camera is drawn in the page from its pose (public/sim-view.js), never streamed.++ const simulated = isSim(profile);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)}"` : ''}`-- + `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`++ return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport && !simulated ? ` data-transport="${esc(c.transport)}"` : ''}`++ + `${simulated ? ' data-sim-camera' : signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`+ `<div class="camera-screen" data-camera-screen${pointEnabled ? ' data-video-click' : ''}>${icon('camera', 'icon camera-icon')}`-- + '<p class="camera-title">No live video right now.</p>'-- + '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'++ + `<p class="camera-title">${simulated ? 'The rover is starting.' : 'No live video right now.'}</p>`++ + `<p class="camera-sub">${simulated ? 'Its camera shows here once the simulator is running.' : 'The camera shows here while the robot is online and sending video.'}</p></div>`+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')+ '</figure>';}).join('');@@@ -345,7 +363,7 @@ function turnStrip(robot, role) {return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {} }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -356,24 +374,51 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${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}">--${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}++${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : 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" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">++<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}><div class="panel-head"><h1>${esc(robot.name)}</h1><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>${turnStrip(robot, role)}</div>++${tryIt ? tryIntro() : ''}${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}<p class="notice" data-device-fault role="status" hidden></p><p class="notice" data-notice role="alert" hidden></p><div class="widgets">${widgets.join('\n')}</div>--${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;-- return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });++ return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {++ scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',++ description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,++ });++}++++const TRY_DESCRIPTION = 'Drive a simulated rover from your browser, no account and no hardware: the same OpenVibe.Bot panel, '++ + 'safety gate and e-stop a real robot gets, with a live camera, a distance sensor, line sensors, headlights and a horn.';++++/** /try's opening lines: what this is, and that nothing here is real hardware. */++function tryIntro() {++ return `<section class="try-intro" aria-labelledby="try-h">++<h2 id="try-h">Drive a rover, right here</h2>++<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls, the same limits, and an e-stop that holds until it is cleared.</p>++<ul class="try-keys"><li><kbd>W A S D</kbd> or the arrows, or drag the joystick</li><li><kbd>Space</kbd> stops</li><li>Follow the yellow tape, or find the crates</li></ul>++<p><button type="button" class="button quiet" data-try-reset>Back to the start</button></p>++</section>`;++}++++/** /try's closing card: from the simulator to a real robot, and the profile this panel was drawn from. */++function tryOutro(profile) {++ return `<section class="try-next card" aria-labelledby="try-next-h">++<h2 id="try-next-h">Ready for a real one?</h2>++<p>Add a robot, pair its computer with one pasted command, and this panel drives it. A Raspberry Pi rover, a Cozmo through a nearby computer, an ONVIF camera or a phone are all a few minutes away.</p>++<p class="try-actions"><a class="button primary" href="/robots#add-robot">Add a robot</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p>++</section>++<script type="application/json" id="try-profile">${JSON.stringify(profile).replace(/</g, '\\u003c')}</script>`;}/**@@@ -457,4 +502,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });}--module.exports = { page, renderPanel, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };++module.exports = { page, renderPanel, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };@@@ -32,7 +32,7 @@ const path = require('path');const express = require('express');const seo = require('openvibe-shared/seo');const appIcon = require('openvibe-shared/app-icon');--const { getProfile, listProfiles } = require('../profiles');++const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');@@@ -43,11 +43,13 @@ const { renderHome, HOME_CSP } = require('./home');const PUBLIC = path.join(__dirname, '..', '..', 'public');const HOLD_RESEND_MS = 150;++// The rover /try drives, as shipped (server/profiles/sim.rover.json, validated).++const TRY_PROFILE = loadProfiles().get('sim.rover');// The tagline stated for openvibe.bot (openvibe-contracts manifests/services/bot.json site.tagline).const SITE_TAGLINE = 'An open control panel for robots';// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and// are Disallowed in robots.txt; the API and sign-in are not pages.--const PUBLIC_PAGES = ['/', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];++const PUBLIC_PAGES = ['/', '/try', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,@@@ -61,6 +63,11 @@ const LLMS_PAGES = [note: 'What Bot is, and how a robot goes from added to driving.',text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that only the owner clears; a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',},++ {++ path: '/try', title: 'Try the panel with a simulated rover',++ note: 'Drive a simulated rover in the browser, no account and no hardware.',++ text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',++ },{path: '/docs', title: 'Build for OpenVibe.Bot',note: 'The build index: drivers and robot profiles.',@@@ -140,6 +147,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limir.get('/panel/panel.js', asset('panel.js', 'application/javascript'));r.get('/panel/panel.css', asset('panel.css', 'text/css'));r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));++ r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));++ r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));++ r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));++++ // /try: the real panel driving a rover simulated in the visitor's own browser (public/try-device.js), so anyone++ // can try it with no account and no hardware (plan T15 "a simulated robot in the browser"). Nothing reaches the++ // server after the page: no socket, no robot row, no limit to spend. The site-wide CSP holds (no outside source).++ const tryAllowed = Object.keys(TRY_PROFILE.commands);++ r.get('/try', (req, res) => {++ res.setHeader('Cache-Control', 'public, max-age=300');++ res.type('html').send(renderPanel({++ robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private', estop: { latched: false } },++ profile: TRY_PROFILE, role: 'owner', allowed_commands: tryAllowed, holdResendMs: HOLD_RESEND_MS, tryIt: true,++ }));++ });const requireUser = (req) => {if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');@@@ -96,6 +96,9 @@ const { renderPanel } = require('../server/web/render');});await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {++ // A camera that streams (a simulated rover's camera is drawn in the page, never played).++ const { robot: open } = await t.robot(alex, { name: 'Public car', profile_id: 'adeept.adr036' });++ await t.domain.robots.setEmbedPublic(open.id, true);const code = (await t.call('POST', `/api/v1/robots/${open.id}/pairing-code`, { user: alex })).json;const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: open.id, code: code.code } });assert.strictEqual(paired.status, 201, paired.text);@@@ -128,7 +131,9 @@ const { renderPanel } = require('../server/web/render');assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);-- assert.deepStrictEqual(s.state.telemetry, { sensors: {} }, 'the drive and pose telemetry reached a watcher');++ // The profile's readouts by the driver's keys, and a simulated rover's camera view; never the raw drive or pose.++ assert.deepStrictEqual(Object.keys(s.state.telemetry).sort(), ['sensors', 'sim'], 'the drive and pose telemetry reached a watcher');++ assert.deepStrictEqual(Object.keys(s.state.telemetry.sensors).sort(), ['distance_cm', 'line']);assert.ok(s.state.online && s.state.battery > 0);w.close();});@@@ -156,6 +156,13 @@ const server = {name: 'sim (server)',accepts(kind, v) {if (kind === 'halt') return halt(v);++ if (kind === 'actuator') {++ // server/sim: headlights take {r,g,b} integers 0..255 or null; the horn {hz} 220..880 or null.++ if (!isObj(v) || !only(v, ['name', 'value'])) return 'actuator is {name, value}';++ if (v.name === 'headlights') return v.value === null || (isObj(v.value) && only(v.value, ['r', 'g', 'b']) && ['r', 'g', 'b'].every((k) => Number.isInteger(v.value[k]) && num(v.value[k], 0, 255))) ? null : 'headlights is {r,g,b} 0..255 or null';++ if (v.name === 'horn') return v.value === null || (isObj(v.value) && only(v.value, ['hz']) && num(v.value.hz, 220, 880)) ? null : 'horn is {hz} 220..880 or null';++ return `no actuator ${v.name}`;++ }if (kind !== 'drive') return `kind ${kind} not supported`;return isObj(v) && only(v, ['throttle', 'steer']) && num(v.throttle, -1, 1) && num(v.steer, -1, 1) ? null : 'drive is {throttle, steer} -1..1';},@@@ -37,7 +37,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)assert.ok(!html(role).includes('data-estop>') && !html(role).includes('data-estop-clear'), `${role} sees an e-stop button`);assert.ok(html(role).includes('data-estop-banner data-latched="true"'), `${role} does not see the latched banner`);}-- assert.match(renderWidget(p.widgets[0], { profile: p, allowed_commands: [] }), /disabled/);++ assert.match(renderWidget(p.widgets.find((w) => w.command), { profile: p, allowed_commands: [] }), /disabled/);});await check('one camera tile per camera the profile lists, each the placeholder with its shape', () => {@@@ -45,11 +45,13 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)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('No live video right now.'), p.id);++ // A simulated rover's camera is drawn in the page (public/sim-view.js); a real one waits for video.++ if (p.mapping.driver === 'sim') assert.ok(html.includes('data-sim-camera') && html.includes('The rover is starting.') && html.includes('/panel/sim-view.js'), p.id);++ else assert.ok(html.includes('No live video right now.') && !html.includes('data-sim-camera') && !html.includes('sim-view.js'), 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 p = profiles.find((x) => x.id === 'adeept.adr036');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);@@@ -62,7 +64,9 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)});await check('a live OpenRestream session rides on the camera tile as its signaling URL, escaped; none given, no attribute', () => {-- const p = profiles.find((x) => x.id === 'sim.rover');++ const sim = profiles.find((x) => x.id === 'sim.rover');++ assert.ok(!renderPanel({ robot: robotFor(sim), profile: sim, role: 'owner', allowed_commands: [], signaling_url: 'wss://x/w/1' }).includes('data-signaling-url'), 'a simulated camera never plays a stream');++ const p = profiles.find((x) => x.id === 'adeept.adr036');const url = 'wss://ingest.openre.stream/w/ses_01J5Z000000000000000000000';const owner = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner'), signaling_url: url });assert.ok(owner.includes(`data-signaling-url="${url}"`), 'the owner\'s tile carries the URL');@@@ -191,7 +195,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)});await check('a live OpenRestream WebRTC session: the owner\'s and a member\'s panel carry its signaling URL, never the key', async () => {-- const { robot, pairing } = await t.robot(alex, { name: 'Live cam' });++ const { robot, pairing } = await t.robot(alex, { name: 'Live cam', profile_id: 'adeept.adr036' });const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });assert.strictEqual(paired.status, 201, paired.text);const streamId = (await t.db.maybe('SELECT openre_stream_id FROM robots WHERE id = $1', [robot.id])).openre_stream_id;@@@ -107,9 +107,11 @@ function samples(profile, kind, names) {assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');});-- await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {++ await check('camera.onvif is PTZ only; sim.rover is a whole rover (drive, camera, range and line sensors, battery, headlights, horn)', () => {assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);-- assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);++ assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(),++ ['battery', 'camera', 'drive.differential', 'lights.rgb', 'sensor.line', 'sensor.ultrasonic', 'speaker.horn']);++ assert.strictEqual(profiles.get('sim.rover').version, 2);});await check('relay.generic is the bare relay robot: buttons only, no drive, no hardware of its own', () => {nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++// The simulated rover (plan T15 "a simulated robot in the browser lets anyone try the panel before owning hardware"):++// the shared arena (public/sim-world.js) both ends use, /try's page (the real panel on a rover that runs in the++// visitor's browser), and Bot's in-process simulator moving in the same arena and reporting what a real rover would.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const world = require('../public/sim-world');++const { loadProfiles } = require('../server/profiles');++++(async () => {++ await check('the arena: the rover starts free on the tape, a wall stops it, sensors read what is ahead and below', () => {++ assert.strictEqual(world.blocked(world.START.x, world.START.y), false, 'the start is clear');++ assert.deepStrictEqual(world.line(world.START), { left: false, middle: true, right: false }, 'parked centred on the tape');++ let pose = { ...world.START };++ let bumped = false;++ const before = world.distanceCm(pose);++ for (let i = 0; i < 200 && !bumped; i++) ({ pose, bumped } = world.step(pose, { throttle: 1, steer: 0 }, 0.05));++ assert.ok(bumped, 'driving straight on ends at a wall');++ assert.ok(pose.y > world.START.y + 3, `it got across the arena (${pose.y})`);++ assert.ok(world.distanceCm(pose) < 15 && world.distanceCm(pose) < before, 'the range finder reads the wall it touches');++ assert.strictEqual(world.blocked(pose.x, pose.y), false, 'a bump never leaves the rover inside a solid');++ const turned = world.step(world.START, { throttle: 0, steer: 1 }, 0.5).pose;++ assert.ok(Math.abs(turned.heading - 45) < 1e-9 && turned.x === world.START.x, 'a right steer turns right on the spot (heading 0 → 45 in 0.5 s)');++ assert.strictEqual(world.floorAt(world.START.x, world.START.y), 'line');++ assert.ok(['tile', 'tile2'].includes(world.floorAt(4, 1)), 'off the tape is floor');++ const ray = world.cast(4, 1, 0, 20);++ assert.ok(ray.length >= 1 && ray.at(-1).cell === '#', 'every ray ends at the arena wall');++ });++++ await check('sim.rover v2 has what /try drives: drive, camera, range and line sensors, battery, headlights, horn', () => {++ const p = loadProfiles().get('sim.rover');++ assert.deepStrictEqual(p.widgets.map((w) => w.type), ['camera', 'drive', 'telemetry', 'telemetry', 'battery', 'lights', 'horn', 'latency']);++ assert.deepStrictEqual(Object.keys(p.commands.actuator.names).sort(), ['headlights', 'horn']);++ });++++ const t = await boot();++ const alex = t.network.newUser('alex');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ const poll = async (fn, ms = 3000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(20); } return null; };++++ await check('GET /try: the real panel on a rover in the visitor\'s browser, public, no session, nothing from outside', async () => {++ const r = await get('/try');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('cache-control'), /^public/);++ assert.strictEqual(r.headers.get('set-cookie'), null);++ assert.doesNotMatch(r.headers.get('content-security-policy') || '', /esm\.sh|openre/, 'no outside script or socket');++ const html = await r.text();++ assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));++ const scripts = [...html.matchAll(/<script src="([^"]+)" defer><\/script>/g)].map((m) => m[1]);++ assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');++ const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);++ assert.ok(json, 'the profile rides along as data');++ assert.deepStrictEqual(JSON.parse(json[1]), loadProfiles().get('sim.rover'));++ assert.ok(html.includes('<link rel="canonical" href="https://openvibe.bot/try">') && html.includes('<meta name="description"'));++ assert.ok(html.includes('href="/robots#add-robot"'), 'the way on to a real robot');++ for (const s of scripts) {++ const js = await get(s);++ assert.strictEqual(js.status, 200, s);++ assert.match(js.headers.get('content-type'), /javascript/, s);++ }++ assert.ok((await (await get('/sitemap.xml')).text()).includes('<loc>http://bot.test/try</loc>'));++ assert.ok((await (await get('/llms.txt')).text()).includes('http://bot.test/try'));++ assert.doesNotMatch(await (await get('/robots.txt')).text(), /Disallow: \/try/);++ });++++ await check('the in-process simulator moves in the arena and reports range, line and its pose for the camera', async () => {++ const { robot } = await t.robot(alex, { name: 'Arena', profile_id: 'sim.rover' });++ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);++ const tel = await poll(() => t.hub.deviceState(`dev_sim_${robot.id}`).telemetry, 2000);++ assert.ok(tel, 'telemetry');++ assert.deepStrictEqual(tel.sim.pose, { ...world.START }, 'it starts where /try starts');++ assert.ok(Number.isInteger(tel.sensors.distance_cm) && tel.sensors.line.middle === true);++ const c = await t.ws('/control', { headers: cookie(alex) });++ c.send({ type: 'join', robot_id: robot.id });++ await c.waitFor((m) => m.type === 'joined');++ c.send({ type: 'command', id: 'l1', kind: 'actuator', value: { name: 'headlights', value: { r: 255, g: 200, b: 40 } } });++ assert.strictEqual((await c.waitFor((m) => m.type === 'command_result' && m.id === 'l1')).result, 'ack');++ let n = 0;++ const hold = setInterval(() => c.send({ type: 'command', id: `f${++n}`, kind: 'drive', value: { throttle: 1 } }), 100);++ let moved;++ try {++ moved = await poll(() => { const s = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return s && s.sim.pose.y > world.START.y + 0.3 ? s : null; }, 4000);++ } finally { clearInterval(hold); }++ assert.ok(moved, 'held forward, it drives up the tape');++ assert.deepStrictEqual(moved.sim.headlights, { r: 255, g: 200, b: 40 }, 'the camera knows its lights');++ assert.ok(moved.sensors.distance_cm < tel.sensors.distance_cm, 'closer to what is ahead');++ c.close();++ t.app.locals.sim.stop(robot.id);++ });++++ await check('a watcher sees a sim robot\'s pose (numbers only) and a real robot\'s readouts by the driver\'s keys', async () => {++ const { robot } = await t.robot(alex, { name: 'Shown', profile_id: 'sim.rover' });++ await t.domain.robots.setEmbedPublic(robot.id, true);++ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);++ const w = await t.ws('/watch', { headers: { 'X-Forwarded-For': '10.9.0.1' } });++ w.send({ type: 'join', robot_id: robot.id });++ const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sim);++ const tel = s.state.telemetry;++ assert.deepStrictEqual(Object.keys(tel.sim).sort(), ['bumped', 'drive', 'headlights', 'horn', 'pose']);++ assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Node reports');++ assert.ok(!('drive' in tel) && !('pose' in tel), 'nothing else of the telemetry');++ w.close();++ t.app.locals.sim.stop(robot.id);++ });++++ await t.close();++ done();++})();
@@ -14,7 +14,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -14,7 +14,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- 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 front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
-- 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
+- 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 rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
+- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. 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; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
@@ -41,14 +41,15 @@
@@ -41,14 +41,15 @@
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"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 in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",
+ "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
- "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 OpenRestream can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
+ "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, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); 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",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
],
],
- "updated": "2026-10-12"
+ "updated": "2026-10-13"
}
}
@@ -177,7 +177,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -177,7 +177,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
}
}
@media (min-width: 64rem) and (min-height: 32.01rem) {
@media (min-width: 64rem) and (min-height: 32.01rem) {
.widgets { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.widgets { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .widget-camera { grid-column: 1 / span 2; grid-row: span 2; }
+ .widget-camera { grid-column: 1 / span 2; grid-row: span 2; align-self: start; }
.widget-drive { grid-column: 3; }
.widget-drive { grid-column: 3; }
}
}
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
@@ -279,3 +279,19 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -279,3 +279,19 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.drive-page { max-width: 28rem; }
.drive-page { max-width: 28rem; }
.drive-page h1 { margin: 0 0 0.5rem; font-size: 1.2rem; }
.drive-page h1 { margin: 0 0 0.5rem; font-size: 1.2rem; }
.embed-sign-in { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.embed-sign-in { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
+
+/* A simulated rover's camera (public/sim-view.js): the drawn view fills the tile, the arena map sits in a corner. */
+.camera-screen > .sim-camera { position: absolute; inset: 0; width: 100%; height: 100%; display: block; background: #000; }
+.camera-screen > .sim-map { position: absolute; right: 0.5rem; bottom: 0.5rem; width: clamp(6.5rem, 26%, 12rem); height: auto; border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.18); }
+.camera-screen > .sim-hud { position: absolute; left: 0.6rem; bottom: 0.6rem; padding: 0.15rem 0.5rem; border-radius: 6px; background: rgba(0, 0, 0, 0.55); color: #e6edf3; font: 600 0.8rem ui-monospace, monospace; font-variant-numeric: tabular-nums; }
+.camera-screen.sim-live > .camera-icon, .camera-screen.sim-live > .camera-title, .camera-screen.sim-live > .camera-sub { display: none; }
+
+/* /try: what this is before the panel, and the way to a real robot after it. */
+.try-intro { grid-column: 1 / -1; margin: 0 0 1rem; max-width: 52rem; }
+.try-intro h2 { margin: 0 0 0.35rem; font-size: 1.15rem; }
+.try-intro p { margin: 0.35rem 0; color: var(--muted); line-height: 1.55; }
+.try-keys { display: flex; flex-wrap: wrap; gap: 0.4rem 1.25rem; margin: 0.5rem 0; padding: 0; list-style: none; color: var(--muted); font-size: 0.9rem; }
+.try-next { margin-top: 1.25rem; max-width: 52rem; }
+.try-next h2 { margin: 0 0 0.35rem; font-size: 1.1rem; }
+.try-next p { color: var(--muted); line-height: 1.55; }
+.try-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0; }
@@ -103,7 +103,7 @@
@@ -103,7 +103,7 @@
n.hidden = !text;
n.hidden = !text;
}
}
function raw(frame) {
function raw(frame) {
- if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ if (!ws || ws.readyState !== 1) return false; // OPEN, for a WebSocket and /try's local socket alike
if (watcher && frame.type !== 'join') return false; // /watch is read-only
if (watcher && frame.type !== 'join') return false; // /watch is read-only
ws.send(JSON.stringify({ v: 1, ...frame }));
ws.send(JSON.stringify({ v: 1, ...frame }));
return true;
return true;
@@ -117,6 +117,7 @@
@@ -117,6 +117,7 @@
if (!joined || !allowed.includes(kind)) return false;
if (!joined || !allowed.includes(kind)) return false;
const id = newId();
const id = newId();
if (!raw({ type: 'command', id, kind, value })) return false;
if (!raw({ type: 'command', id, kind, value })) return false;
+ window.dispatchEvent(new CustomEvent('ov-bot-command', { detail: { kind, value } }));
sentAt.set(id, performance.now());
sentAt.set(id, performance.now());
if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);
if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);
return true;
return true;
@@ -669,10 +670,23 @@
@@ -669,10 +670,23 @@
el.textContent = text ? `Device fault: ${text}` : '';
el.textContent = text ? `Device fault: ${text}` : '';
el.hidden = !text;
el.hidden = !text;
}
}
- function readout(v) {
+ // A readout's sensor keys by the widget's capability, as the Node's drivers report them (Adeept: distance_cm and
+ // line {left, middle, right}; Cozmo: cliff and picked_up), else the capability's own name. One key found shows its
+ // value; several show each by name.
+ const SENSOR_KEYS = { ultrasonic: ['distance_cm', 'ultrasonic'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
+ function sensorValue(sensors, capability) {
+ const name = capability.replace(/^sensor\./, '');
+ const found = (SENSOR_KEYS[name] || [name]).filter((key) => sensors[key] !== undefined);
+ if (!found.length) return [null, undefined];
+ if (found.length === 1) return [found[0], sensors[found[0]]];
+ return ['', Object.fromEntries(found.map((key) => [key, sensors[key]]))];
+ }
+ const flag = (x) => (x === true ? 'on' : x === false ? 'off' : x);
+ function readout(v, key = null) {
if (v == null) return '—';
if (v == null) return '—';
- if (typeof v !== 'object') return String(v);
- return Object.entries(v).map(([k, x]) => `${k}: ${typeof x === 'object' ? JSON.stringify(x) : x}`).join(' · ') || '—';
+ if (key === 'distance_cm' && typeof v === 'number') return v >= 400 ? 'over 4 m' : `${Math.round(v)} cm`;
+ if (typeof v !== 'object') return String(flag(v));
+ return Object.entries(v).map(([k, x]) => `${k.replace(/_/g, ' ')}: ${typeof x === 'object' ? JSON.stringify(x) : flag(x)}`).join(' · ') || '—';
}
}
function setOnline(state, label) {
function setOnline(state, label) {
const pill = $('[data-online-pill]');
const pill = $('[data-online-pill]');
@@ -714,9 +728,10 @@
@@ -714,9 +728,10 @@
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;
for (const el of $$('[data-telemetry]')) {
for (const el of $$('[data-telemetry]')) {
- const key = el.dataset.telemetry.replace(/^sensor\./, '');
- el.textContent = readout(sensors ? (sensors[key] !== undefined ? sensors[key] : sensors) : null);
+ const [key, value] = sensors ? sensorValue(sensors, el.dataset.telemetry) : [null, null];
+ el.textContent = readout(key != null ? value : null, key);
}
}
+ window.dispatchEvent(new CustomEvent('ov-bot-state', { detail: state }));
}
}
// ── The turn queue strip (a `queue` robot's signed-in non-member) ─────────────────────────────────
// ── The turn queue strip (a `queue` robot's signed-in non-member) ─────────────────────────────────
@@ -774,8 +789,17 @@
@@ -774,8 +789,17 @@
}
}
}
}
+ // /try's rover lives in the page (public/try-device.js): its socket answers /control's frames locally.
+ const LOCAL = main.dataset.transport === 'local' ? window.OVBotLocalSocket : null;
function connect() {
function connect() {
const path = watcher ? '/watch' : '/control';
const path = watcher ? '/watch' : '/control';
+ if (LOCAL) {
+ ws = new LOCAL(wsUrl(path));
+ 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 = () => { releaseAll(); joined = false; enableControls(); };
+ return;
+ }
if (driving && ticket) {
if (driving && ticket) {
ws = new WebSocket(wsUrl(path), ['ov.ticket', `ov.ticket.${ticket}`]);
ws = new WebSocket(wsUrl(path), ['ov.ticket', `ov.ticket.${ticket}`]);
ticket = null;
ticket = null;
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+'use strict';
+
+/**
+ * The simulated rover's camera: every camera tile the server marked `data-sim-camera` (a robot whose profile's
+ * driver is `sim`) draws the rover's view of the shared arena (public/sim-world.js) instead of waiting for video,
+ * with a top-down map in its corner. Plain ES, no build step; canvas only, so the page's CSP is untouched.
+ *
+ * Where the pose comes from:
+ * - /try: the rover runs in this page (public/try-device.js sets window.OVSimLocal), so the view reads it each frame;
+ * - any other sim robot: Bot's in-process simulator reports `telemetry.sim` ({ pose, drive, headlights, horn,
+ * bumped }) about twice a second (panel.js re-dispatches each state as `ov-bot-state`). Between reports the view
+ * moves the rover on by its last drive with the same kinematics, and eases toward each new report.
+ *
+ * The horn plays through Web Audio once the page has been touched (browsers allow sound only after a gesture).
+ */
+(() => {
+ const W = window.OVSimWorld;
+ const tiles = [...document.querySelectorAll('[data-sim-camera]')];
+ if (!W || !tiles.length) return;
+
+ const RW = 400; const RH = 225; // the rendered picture; drawn scaled up, smoothed
+ const FOV = 74; // degrees across
+ const F = (RW / 2) / Math.tan((FOV / 2) * Math.PI / 180);
+ const HORIZON = Math.round(RH * 0.46);
+ const CAM = W.CAMERA_HEIGHT;
+
+ // Colours (linear-ish 0..255 RGB).
+ const SOLID = { '#': [96, 112, 138], r: [214, 84, 66], b: [59, 130, 246], g: [34, 160, 107], y: [226, 178, 36] };
+ const FLOOR = { tile: [182, 190, 201], tile2: [164, 173, 186], line: [244, 192, 38] };
+ const FOG = [196, 205, 218];
+ const SKY_TOP = [58, 68, 86]; const SKY_LOW = [150, 162, 180];
+
+ // ── Pose: local (the /try rover) or predicted from the server's reports ──────────────────────────
+ const remote = { pose: null, drive: {}, headlights: null, horn: null, online: false };
+ let shown = null; // the pose drawn last
+ let correction = null; // { dx, dy, dh } still to blend in
+
+ window.addEventListener('ov-bot-state', (e) => {
+ const state = e.detail || {};
+ remote.online = !!state.online;
+ const sim = state.telemetry && state.telemetry.sim;
+ if (!sim || !sim.pose) return;
+ remote.drive = sim.drive || {};
+ remote.headlights = sim.headlights || null;
+ remote.horn = sim.horn || null;
+ if (!shown) { shown = { ...sim.pose }; return; }
+ let dh = (sim.pose.heading - shown.heading) % 360;
+ if (dh > 180) dh -= 360; if (dh < -180) dh += 360;
+ const far = Math.hypot(sim.pose.x - shown.x, sim.pose.y - shown.y) > 0.6 || Math.abs(dh) > 60;
+ if (far) { shown = { ...sim.pose }; correction = null; } else correction = { dx: sim.pose.x - shown.x, dy: sim.pose.y - shown.y, dh };
+ });
+ // The operator's own drive moves the picture at once (the server's report confirms it a moment later).
+ window.addEventListener('ov-bot-command', (e) => {
+ const c = e.detail || {};
+ if (c.kind === 'drive' && c.value && typeof c.value === 'object') remote.drive = { ...c.value };
+ if (c.kind === 'halt') remote.drive = {};
+ });
+
+ function frameState(dt) {
+ const local = window.OVSimLocal;
+ if (local) return { pose: local.pose(), headlights: local.headlights(), horn: local.horn(), online: true };
+ if (!shown || !remote.online) return { pose: shown, headlights: null, horn: null, online: remote.online && !!shown };
+ shown = W.step(shown, remote.drive, dt).pose;
+ if (correction) {
+ const k = Math.min(1, dt * 6);
+ shown = { x: shown.x + correction.dx * k, y: shown.y + correction.dy * k, heading: (shown.heading + correction.dh * k + 360) % 360 };
+ correction = { dx: correction.dx * (1 - k), dy: correction.dy * (1 - k), dh: correction.dh * (1 - k) };
+ if (Math.abs(correction.dx) + Math.abs(correction.dy) < 0.002 && Math.abs(correction.dh) < 0.2) correction = null;
+ }
+ return { pose: shown, headlights: remote.headlights, horn: remote.horn, online: true };
+ }
+
+ // ── The renderer: floor casting, then each column's solids far to near ───────────────────────────
+ const buffer = document.createElement('canvas');
+ buffer.width = RW; buffer.height = RH;
+ const bctx = buffer.getContext('2d');
+ const img = bctx.createImageData(RW, RH);
+ const px = img.data;
+ const lerp = (a, b, t) => a + (b - a) * t;
+ function put(i, c, fog, light) {
+ px[i] = Math.min(255, lerp(c[0], FOG[0], fog) + light[0]);
+ px[i + 1] = Math.min(255, lerp(c[1], FOG[1], fog) + light[1]);
+ px[i + 2] = Math.min(255, lerp(c[2], FOG[2], fog) + light[2]);
+ px[i + 3] = 255;
+ }
+ const NO_LIGHT = [0, 0, 0];
+ /** The headlights' added colour at a point `d` metres ahead and `lateral` (screen fraction) off the axis. */
+ function lit(lights, d, lateral) {
+ if (!lights) return NO_LIGHT;
+ const cone = Math.max(0, 1 - Math.abs(lateral) / 0.55);
+ const k = (0.75 * cone * cone) / (1 + d * d * 1.6);
+ return [lights.r * k, lights.g * k, lights.b * k];
+ }
+
+ function render({ pose, headlights }) {
+ const a = (pose.heading * Math.PI) / 180;
+ const fx = Math.sin(a); const fy = Math.cos(a); // forward
+ const rx = Math.cos(a); const ry = -Math.sin(a); // right
+ // Above the horizon: the room beyond the arena.
+ for (let y = 0; y <= HORIZON; y++) {
+ const t = y / HORIZON;
+ const c = [lerp(SKY_TOP[0], SKY_LOW[0], t), lerp(SKY_TOP[1], SKY_LOW[1], t), lerp(SKY_TOP[2], SKY_LOW[2], t)];
+ for (let x = 0; x < RW; x++) put((y * RW + x) * 4, c, 0, NO_LIGHT);
+ }
+ // The floor: each row is one distance; each pixel one point on it.
+ for (let y = HORIZON + 1; y < RH; y++) {
+ const d = (CAM * F) / (y - HORIZON);
+ const fog = 1 - Math.exp(-d * 0.16);
+ for (let x = 0; x < RW; x++) {
+ const s = (x - RW / 2 + 0.5) / F;
+ const wx = pose.x + (fx + rx * s) * d; const wy = pose.y + (fy + ry * s) * d;
+ put((y * RW + x) * 4, FLOOR[W.floorAt(wx, wy)], fog, lit(headlights, d, s));
+ }
+ }
+ // Solids: arena walls and crates, far first so nearer ones cover them.
+ for (let x = 0; x < RW; x++) {
+ const s = (x - RW / 2 + 0.5) / F;
+ const off = Math.atan(s);
+ const hits = W.cast(pose.x, pose.y, pose.heading + (off * 180) / Math.PI, 14);
+ for (let h = hits.length - 1; h >= 0; h--) {
+ const hit = hits[h];
+ const pd = Math.max(0.02, hit.dist * Math.cos(off));
+ const height = W.heights[hit.cell] || 0.4;
+ const top = HORIZON - ((height - CAM) * F) / pd;
+ const bottom = HORIZON + (CAM * F) / pd;
+ const y0 = Math.max(0, Math.ceil(top)); const y1 = Math.min(RH - 1, Math.floor(bottom));
+ const base = SOLID[hit.cell] || SOLID['#'];
+ const shade = hit.side === 'x' ? 0.8 : 1;
+ const fog = 1 - Math.exp(-pd * 0.16);
+ const light = lit(headlights, pd, s);
+ const crate = hit.cell !== '#';
+ const edgeU = hit.u < 0.06 || hit.u > 0.94;
+ for (let y = y0; y <= y1; y++) {
+ const v = (y - top) / Math.max(1, bottom - top); // 0 at the top edge, 1 at the floor
+ let k = shade;
+ if (crate) { if (edgeU || v < 0.07 || v > 0.93) k *= 0.72; else if (Math.abs(v - 0.5) < 0.04) k *= 0.86; }
+ else { if (v < 0.08) k *= 1.18; else if (v > 0.9) k *= 0.7; if (hit.u < 0.02 || Math.abs(hit.u - 0.5) < 0.012) k *= 0.85; }
+ put((y * RW + x) * 4, [base[0] * k, base[1] * k, base[2] * k], fog, light);
+ }
+ }
+ }
+ bctx.putImageData(img, 0, 0);
+ }
+
+ /** The top-down map: the arena, the tape, the rover and its heading (+y up). */
+ function renderMap(ctx, w, h, { pose, headlights }) {
+ const s = Math.min(w / (W.COLS * W.CELL), h / (W.ROWS * W.CELL));
+ const X = (x) => x * s; const Y = (y) => h - y * s;
+ ctx.clearRect(0, 0, w, h);
+ ctx.fillStyle = 'rgba(17, 21, 28, 0.72)';
+ ctx.fillRect(0, 0, w, h);
+ for (let cy = 0; cy < W.ROWS; cy++) for (let cx = 0; cx < W.COLS; cx++) {
+ const cell = W.cellAt(cx, cy);
+ if (cell === '.') continue;
+ const c = SOLID[cell];
+ ctx.fillStyle = `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
+ ctx.fillRect(X(cx * W.CELL), Y((cy + 1) * W.CELL), W.CELL * s + 0.5, W.CELL * s + 0.5);
+ }
+ const L = W.LOOP;
+ ctx.strokeStyle = 'rgba(242, 194, 48, 0.9)';
+ ctx.lineWidth = Math.max(1, s * 0.05);
+ ctx.beginPath();
+ if (ctx.roundRect) ctx.roundRect(X(L.x0), Y(L.y1), (L.x1 - L.x0) * s, (L.y1 - L.y0) * s, L.r * s);
+ else ctx.rect(X(L.x0), Y(L.y1), (L.x1 - L.x0) * s, (L.y1 - L.y0) * s);
+ ctx.stroke();
+ if (!pose) return;
+ const a = (pose.heading * Math.PI) / 180;
+ const r = Math.max(4, W.ROVER_RADIUS * s * 1.4);
+ const cx = X(pose.x); const cy = Y(pose.y);
+ if (headlights) {
+ const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r * 5);
+ g.addColorStop(0, `rgba(${headlights.r}, ${headlights.g}, ${headlights.b}, 0.55)`);
+ g.addColorStop(1, `rgba(${headlights.r}, ${headlights.g}, ${headlights.b}, 0)`);
+ ctx.fillStyle = g;
+ ctx.beginPath();
+ ctx.moveTo(cx, cy);
+ ctx.arc(cx, cy, r * 5, a - Math.PI / 2 - 0.5, a - Math.PI / 2 + 0.5); // canvas angles: 0 is +x, clockwise
+ ctx.fill();
+ }
+ ctx.fillStyle = '#f8fafc';
+ ctx.strokeStyle = '#11151c';
+ ctx.lineWidth = 1.5;
+ ctx.beginPath();
+ // Screen angle: heading 0 is up (−y on the canvas), clockwise positive.
+ const pt = (ang, rad) => [cx + Math.sin(a + ang) * rad, cy - Math.cos(a + ang) * rad];
+ ctx.moveTo(...pt(0, r * 1.3));
+ ctx.lineTo(...pt(2.5, r));
+ ctx.lineTo(...pt(Math.PI, r * 0.45));
+ ctx.lineTo(...pt(-2.5, r));
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+ }
+
+ // ── The horn ──────────────────────────────────────────────────────────────────────────────────────
+ let audio = null; let osc = null; let gain = null; let toneHz = null;
+ const unlock = () => {
+ if (audio) return;
+ const Ctx = window.AudioContext || window.webkitAudioContext;
+ if (!Ctx) return;
+ try { audio = new Ctx(); } catch { audio = null; }
+ };
+ window.addEventListener('pointerdown', unlock, { capture: true });
+ window.addEventListener('keydown', unlock, { capture: true });
+ function horn(h) {
+ const hz = h && Number.isFinite(h.hz) ? h.hz : null;
+ if (hz === toneHz || !audio) return;
+ toneHz = hz;
+ if (osc) { try { gain.gain.setTargetAtTime(0, audio.currentTime, 0.015); osc.stop(audio.currentTime + 0.08); } catch { /* stopped */ } osc = null; }
+ if (hz == null) return;
+ osc = audio.createOscillator();
+ gain = audio.createGain();
+ osc.type = 'square';
+ osc.frequency.value = hz;
+ gain.gain.value = 0;
+ gain.gain.setTargetAtTime(0.05, audio.currentTime, 0.01);
+ osc.connect(gain).connect(audio.destination);
+ osc.start();
+ if (audio.state === 'suspended') audio.resume().catch(() => {});
+ }
+
+ // ── One canvas per tile ───────────────────────────────────────────────────────────────────────────
+ const views = tiles.map((tile) => {
+ const screen = tile.querySelector('[data-camera-screen]');
+ if (!screen) return null;
+ const placeholder = [...screen.children];
+ const canvas = document.createElement('canvas');
+ canvas.className = 'sim-camera';
+ canvas.width = RW * 2; canvas.height = RH * 2;
+ canvas.setAttribute('role', 'img');
+ canvas.setAttribute('aria-label', 'The simulated rover\'s front camera');
+ canvas.hidden = true;
+ const map = document.createElement('canvas');
+ map.className = 'sim-map';
+ map.width = 192; map.height = 144;
+ map.setAttribute('role', 'img');
+ map.setAttribute('aria-label', 'Map of the arena with the rover');
+ map.hidden = true;
+ const hud = document.createElement('span');
+ hud.className = 'sim-hud';
+ hud.hidden = true;
+ screen.append(canvas, map, hud);
+ return { screen, placeholder, canvas, ctx: canvas.getContext('2d'), map, mctx: map.getContext('2d'), hud, live: false };
+ }).filter(Boolean);
+
+ function show(v, on) {
+ if (v.live === on) return;
+ v.live = on;
+ v.canvas.hidden = !on; v.map.hidden = !on; v.hud.hidden = !on;
+ for (const el of v.placeholder) el.hidden = on;
+ v.screen.classList.toggle('sim-live', on);
+ }
+
+ let last = performance.now();
+ function frame(now) {
+ const dt = Math.min(0.1, (now - last) / 1000);
+ last = now;
+ const st = frameState(dt);
+ horn(st.online ? st.horn : null);
+ if (!document.hidden && st.online && st.pose) {
+ render(st);
+ for (const v of views) {
+ show(v, true);
+ v.ctx.imageSmoothingEnabled = true;
+ v.ctx.drawImage(buffer, 0, 0, v.canvas.width, v.canvas.height);
+ renderMap(v.mctx, v.map.width, v.map.height, st);
+ const cm = W.distanceCm(st.pose);
+ const text = `${cm >= 400 ? '> 4 m' : `${cm} cm`} ahead`;
+ if (v.hud.textContent !== text) v.hud.textContent = text;
+ }
+ } else if (!st.online) for (const v of views) show(v, false);
+ requestAnimationFrame(frame);
+ }
+ requestAnimationFrame(frame);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -0,0 +1,157 @@
+'use strict';
+
+/**
+ * The simulated rover's world (sim.rover): one small arena that Bot's in-process simulator (server/sim) and the
+ * browser (the /try page's local rover, and the camera of every sim robot's panel) share, so the picture a panel
+ * draws and the pose the server keeps always agree. No DOM, no Node API: it loads as a CommonJS module on the
+ * server and as `window.OVSimWorld` in a page.
+ *
+ * Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and
+ * 90 faces +x, so a positive steer turns right (the simulator's convention since Bot#20): forward at full throttle is
+ * 0.5 m/s and a full steer turns 90 °/s, as before. MAP's text rows run from y = 0 at the top of the text.
+ *
+ * step(pose, drive, dt) → { pose, bumped } one kinematic step with collisions (a bumper stops the move)
+ * distanceCm(pose) → the ultrasonic reading ahead, in cm (≤ 400), from a 12° cone
+ * line(pose) → { left, middle, right }: whether each line sensor is over the tape
+ * floorAt(x, y) → 'line' | 'tile' | 'tile2'
+ * cast(x, y, angle, max) → the cells a ray crosses, nearest first ({ dist, cell, side, u })
+ * solid(cx, cy), cellAt(cx, cy), MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, heights
+ */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.OVSimWorld = factory();
+}(typeof self !== 'undefined' ? self : this, () => {
+ const CELL = 0.5;
+ // '#' arena wall, 'r' 'b' 'g' 'y' crates, '.' floor. Row r covers y ∈ [r·CELL, (r+1)·CELL); column c covers x.
+ const MAP = [
+ '################',
+ '#..............#',
+ '#..r.......b...#',
+ '#..............#',
+ '#.....##.......#',
+ '#.....##....g..#',
+ '#..............#',
+ '#..y...........#',
+ '#..........b...#',
+ '#......r.......#',
+ '#..............#',
+ '################',
+ ];
+ const COLS = MAP[0].length;
+ const ROWS = MAP.length;
+ const ROVER_RADIUS = 0.14;
+ const CAMERA_HEIGHT = 0.12;
+ const SPEED = 0.5; // m/s at full throttle
+ const TURN = 90; // °/s at full steer
+ /** How tall each solid is, in metres: the arena wall stands above the rover's camera, crates are lower. */
+ const heights = { '#': 0.42, r: 0.26, b: 0.3, g: 0.22, y: 0.26 };
+ // Parked on the tape at the near left corner, facing along it (+y).
+ const START = Object.freeze({ x: 1.0, y: 1.5, heading: 0 });
+
+ // The tape loop: a rectangle with rounded corners, 5 cm wide, around the crates.
+ const LOOP = { x0: 1.0, y0: 0.75, x1: 7.0, y1: 5.25, r: 0.45, half: 0.025 };
+
+ const cellAt = (cx, cy) => (cy < 0 || cy >= ROWS || cx < 0 || cx >= COLS ? '#' : MAP[cy][cx]);
+ const solid = (cx, cy) => cellAt(cx, cy) !== '.';
+ const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
+ const rad = (deg) => (deg * Math.PI) / 180;
+
+ /** Distance from (x, y) to the loop's centre line. */
+ function loopDistance(x, y) {
+ const { x0, y0, x1, y1, r } = LOOP;
+ // Distance to a rounded rectangle's outline: clamp to the inner rectangle, then subtract the radius.
+ const ix0 = x0 + r; const iy0 = y0 + r; const ix1 = x1 - r; const iy1 = y1 - r;
+ const dx = Math.max(ix0 - x, 0, x - ix1);
+ const dy = Math.max(iy0 - y, 0, y - iy1);
+ if (dx > 0 || dy > 0) return Math.abs(Math.hypot(dx, dy) - r);
+ // Inside the inner rectangle: the nearest straight edge.
+ return Math.min(x - ix0, ix1 - x, y - iy0, iy1 - y) + r;
+ }
+ function floorAt(x, y) {
+ if (loopDistance(x, y) <= LOOP.half) return 'line';
+ return (Math.floor(x / CELL) + Math.floor(y / CELL)) % 2 ? 'tile2' : 'tile';
+ }
+
+ /** Does a rover of radius `r` at (x, y) touch a solid cell? */
+ function blocked(x, y, r = ROVER_RADIUS) {
+ for (let cy = Math.floor((y - r) / CELL); cy <= Math.floor((y + r) / CELL); cy++) {
+ for (let cx = Math.floor((x - r) / CELL); cx <= Math.floor((x + r) / CELL); cx++) {
+ if (!solid(cx, cy)) continue;
+ const nx = Math.max(cx * CELL, Math.min(x, (cx + 1) * CELL));
+ const ny = Math.max(cy * CELL, Math.min(y, (cy + 1) * CELL));
+ if ((x - nx) ** 2 + (y - ny) ** 2 < r * r) return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * One kinematic step: forward speed from throttle (or y), turn rate from steer (or rotation), sideways from x
+ * (a holonomic drive). Each axis of the move is tried on its own, so the rover slides along a wall; a move a
+ * solid stops is `bumped`.
+ */
+ function step(pose, drive = {}, dt = 0) {
+ const speed = num(drive.throttle != null ? drive.throttle : drive.y);
+ const turn = num(drive.steer != null ? drive.steer : drive.rotation);
+ const strafe = num(drive.x);
+ const t = Math.max(0, Math.min(1, dt));
+ const heading = (((num(pose.heading) + turn * TURN * t) % 360) + 360) % 360;
+ const a = rad(heading);
+ const dx = (speed * Math.sin(a) + strafe * Math.cos(a)) * SPEED * t;
+ const dy = (speed * Math.cos(a) - strafe * Math.sin(a)) * SPEED * t;
+ let x = num(pose.x); let y = num(pose.y);
+ let bumped = false;
+ if (dx) { if (blocked(x + dx, y)) bumped = true; else x += dx; }
+ if (dy) { if (blocked(x, y + dy)) bumped = true; else y += dy; }
+ return { pose: { x, y, heading }, bumped };
+ }
+
+ /**
+ * Every cell a ray from (x, y) at `angle` (degrees, the heading convention) crosses until an arena wall or
+ * `max` metres, nearest first: { dist (along the ray), cell, side ('x' | 'y'), u (0..1 across the face) }.
+ */
+ function cast(x, y, angle, max = 12) {
+ const a = rad(angle);
+ const rx = Math.sin(a); const ry = Math.cos(a);
+ let cx = Math.floor(x / CELL); let cy = Math.floor(y / CELL);
+ const stepX = rx > 0 ? 1 : -1; const stepY = ry > 0 ? 1 : -1;
+ const dX = rx ? Math.abs(CELL / rx) : Infinity; const dY = ry ? Math.abs(CELL / ry) : Infinity;
+ let tX = rx ? ((rx > 0 ? (cx + 1) * CELL - x : x - cx * CELL) / Math.abs(rx)) : Infinity;
+ let tY = ry ? ((ry > 0 ? (cy + 1) * CELL - y : y - cy * CELL) / Math.abs(ry)) : Infinity;
+ const hits = [];
+ for (let i = 0; i < 64; i++) {
+ let dist; let side;
+ if (tX < tY) { dist = tX; tX += dX; cx += stepX; side = 'x'; } else { dist = tY; tY += dY; cy += stepY; side = 'y'; }
+ if (dist > max) break;
+ const cell = cellAt(cx, cy);
+ if (cell === '.') continue;
+ const hx = x + rx * dist; const hy = y + ry * dist;
+ const u = side === 'x' ? (hy / CELL) % 1 : (hx / CELL) % 1;
+ hits.push({ dist, cell, side, u, cx, cy });
+ if (cell === '#') break;
+ }
+ return hits;
+ }
+
+ /** The ultrasonic sensor 0.1 m ahead of the centre: the nearest solid inside a 12° cone, in whole cm (≤ 400). */
+ function distanceCm(pose) {
+ const a = rad(pose.heading);
+ const sx = pose.x + Math.sin(a) * 0.1; const sy = pose.y + Math.cos(a) * 0.1;
+ let best = 4;
+ for (const off of [-6, -3, 0, 3, 6]) {
+ const h = cast(sx, sy, pose.heading + off, 4)[0];
+ if (h && h.dist < best) best = h.dist;
+ }
+ return Math.round(best * 100);
+ }
+
+ /** Three line sensors 0.12 m ahead, 4 cm apart: whether each is over the tape. */
+ function line(pose) {
+ const a = rad(pose.heading);
+ const fx = pose.x + Math.sin(a) * 0.12; const fy = pose.y + Math.cos(a) * 0.12;
+ const at = (side) => floorAt(fx + Math.cos(a) * side, fy - Math.sin(a) * side) === 'line';
+ return { left: at(-0.04), middle: at(0), right: at(0.04) };
+ }
+
+ return { MAP, CELL, COLS, ROWS, START, ROVER_RADIUS, CAMERA_HEIGHT, SPEED, TURN, LOOP, heights, cellAt, solid, blocked, floorAt, step, cast, distanceCm, line };
+}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+
+/**
+ * /try: a simulated rover that lives in this page, so anyone can drive the real panel with no account and no
+ * hardware. It stands in for both ends of a robot's link: `window.OVBotLocalSocket` answers the panel's /control
+ * frames (public/panel.js uses it when the page says data-transport="local"), and the rover runs on the shared
+ * arena's kinematics (public/sim-world.js) at the page's frame rate.
+ *
+ * It keeps the gate's rules that a driver would feel: only the profile's command kinds; drive axes and actuator
+ * values shaped to the profile's ranges (as server/domain does); a latched e-stop that refuses everything but halt
+ * until it is cleared; and the device's deadman: a motion command lapses max_command_ms after it was sent unless
+ * a newer one arrives (the panel re-sends a held control every 150 ms). Bumping into something stops the wheels.
+ *
+ * window.OVSimLocal exposes the pose, the headlights and the horn to the camera view (public/sim-view.js).
+ */
+(() => {
+ const W = window.OVSimWorld;
+ const main = document.getElementById('panel');
+ const source = document.getElementById('try-profile');
+ if (!W || !main || main.dataset.transport !== 'local' || !source) return;
+ const profile = JSON.parse(source.textContent);
+ const commands = profile.commands || {};
+ const limits = profile.limits || {};
+ const DEADLINE_MS = limits.max_command_ms || 300;
+ const ALLOWED = [...Object.keys(commands).filter((k) => k !== 'halt'), 'halt'];
+ const TELEMETRY_MS = 200;
+ const BATTERY_FLOOR = 20;
+
+ const rover = {
+ pose: { ...W.START }, drive: {}, deadline: 0, headlights: null, horn: null, hornUntil: 0,
+ battery: 100, estop: false, bumped: false,
+ };
+ const clamp = (v, lo, hi) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; };
+ const noteHz = (note) => {
+ const m = /^([A-Ga-g])([#b]?)(-?\d)$/.exec(String(note));
+ if (!m) return null;
+ const semis = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[m[1].toUpperCase()] + (m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0);
+ return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
+ };
+
+ /** The value the device gets, or a refusal reason (server/domain buildValue, for the kinds this profile has). */
+ function shape(kind, value) {
+ const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ if (kind === 'halt') return { value: {} };
+ if (kind === 'drive') {
+ const out = {};
+ for (const [axis, [lo, hi]] of Object.entries(commands.drive.axes)) out[axis] = clamp(v[axis], lo, hi);
+ return { value: out };
+ }
+ if (kind === 'actuator') {
+ const spec = commands.actuator.names[v.name];
+ if (!spec) return { refused: 'this robot has no such actuator' };
+ const x = v.value;
+ if (spec.type === 'rgb') return { value: { name: v.name, value: x ? { r: Math.round(clamp(x.r, 0, 255)), g: Math.round(clamp(x.g, 0, 255)), b: Math.round(clamp(x.b, 0, 255)) } : null } };
+ if (spec.type === 'tone') {
+ if (!x) return { value: { name: v.name, value: null } };
+ const hz = x.note != null ? noteHz(x.note) : Number(x.hz);
+ if (!Number.isFinite(hz)) return { refused: `${v.name} takes {note} or {hz}` };
+ return { value: { name: v.name, value: hz === 0 ? null : { hz: Math.round(clamp(hz, spec.hz[0], spec.hz[1])) } } };
+ }
+ if (spec.type === 'number') return { value: { name: v.name, value: clamp(x, spec.range[0], spec.range[1]) } };
+ return { value: { name: v.name, value: !!x } };
+ }
+ return { refused: `${kind} is not something this robot does` };
+ }
+
+ function halt() { rover.drive = {}; rover.deadline = 0; }
+ function apply(kind, value) {
+ const now = performance.now();
+ if (kind === 'halt') { halt(); return; }
+ if (kind === 'drive') { rover.drive = value; rover.deadline = now + DEADLINE_MS; return; }
+ if (kind === 'actuator') {
+ if (value.name === 'headlights') rover.headlights = value.value;
+ else if (value.name === 'horn') { rover.horn = value.value; rover.hornUntil = value.value ? now + DEADLINE_MS : 0; }
+ }
+ }
+
+ function state() {
+ return {
+ robot_id: main.dataset.robotId, online: true,
+ estop: { latched: rover.estop, by: null, at: null },
+ latency_ms: 12, battery: rover.battery / 100,
+ telemetry: {
+ battery: { percent: Math.round(rover.battery) },
+ sensors: { distance_cm: W.distanceCm(rover.pose), line: W.line(rover.pose) },
+ sim: { pose: { ...rover.pose }, drive: { ...rover.drive }, headlights: rover.headlights, horn: rover.horn, bumped: rover.bumped },
+ },
+ status: null, device_estop: null, queue: null,
+ };
+ }
+
+ // ── The rover's clock ─────────────────────────────────────────────────────────────────────────────
+ let last = performance.now();
+ function tick() {
+ const now = performance.now();
+ const dt = Math.min(0.1, (now - last) / 1000);
+ last = now;
+ if (rover.deadline && now > rover.deadline) halt(); // the deadman
+ if (rover.hornUntil && now > rover.hornUntil) { rover.horn = null; rover.hornUntil = 0; }
+ const moving = Object.values(rover.drive).some((x) => x);
+ if (moving && !rover.estop) {
+ const r = W.step(rover.pose, rover.drive, dt);
+ rover.pose = r.pose;
+ if (r.bumped && !rover.bumped) halt(); // the bumper stops the wheels
+ rover.bumped = r.bumped;
+ rover.battery = Math.max(BATTERY_FLOOR, rover.battery - 0.25 * dt);
+ } else rover.bumped = false;
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+ window.OVSimLocal = { pose: () => rover.pose, headlights: () => rover.headlights, horn: () => rover.horn };
+
+ // ── The panel's socket ────────────────────────────────────────────────────────────────────────────
+ const sockets = new Set();
+ class LocalSocket {
+ constructor(url) {
+ this.url = url;
+ this.readyState = 0;
+ this.onopen = null; this.onmessage = null; this.onclose = null;
+ setTimeout(() => {
+ if (this.readyState !== 0) return;
+ this.readyState = 1;
+ sockets.add(this);
+ if (this.onopen) this.onopen({});
+ }, 40);
+ }
+ deliver(frame, delay = 0) {
+ const out = () => { if (this.readyState === 1 && this.onmessage) this.onmessage({ data: JSON.stringify({ v: 1, ...frame }) }); };
+ if (delay) setTimeout(out, delay); else out();
+ }
+ send(raw) {
+ if (this.readyState !== 1) return;
+ let m; try { m = JSON.parse(raw); } catch { return; }
+ if (m.type === 'join') {
+ this.joined = true;
+ this.deliver({ type: 'joined', robot_id: m.robot_id, role: 'owner', allowed_commands: ALLOWED, state: state() }, 20);
+ return;
+ }
+ if (!this.joined) return this.deliver({ type: 'error', code: 'bot.not_joined', detail: 'join first' });
+ if (m.type === 'estop') { rover.estop = true; halt(); rover.horn = null; return broadcast(); }
+ if (m.type === 'estop_clear') { rover.estop = false; return broadcast(); }
+ if (m.type !== 'command') return;
+ const id = String(m.id || '');
+ const kind = String(m.kind || '');
+ const reply = (fields) => this.deliver({ type: 'command_result', id, ...fields }, 10 + Math.round(Math.random() * 14));
+ if (!ALLOWED.includes(kind)) return reply({ result: 'refused', code: 'bot.command_not_allowed', reason: `${kind} is not something this robot does` });
+ if (rover.estop && kind !== 'halt') return reply({ result: 'refused', code: 'bot.estopped', reason: 'The e-stop is latched. Clear it to drive again.' });
+ const shaped = shape(kind, m.value);
+ if (shaped.refused) return reply({ result: 'refused', code: 'bot.invalid_input', reason: shaped.refused });
+ apply(kind, shaped.value);
+ return reply({ result: 'ack' });
+ }
+ close() {
+ if (this.readyState === 3) return;
+ this.readyState = 3;
+ sockets.delete(this);
+ if (this.onclose) this.onclose({ code: 1000 });
+ }
+ addEventListener() { /* the panel's /control socket uses on* handlers only */ }
+ removeEventListener() {}
+ }
+ LocalSocket.CONNECTING = 0; LocalSocket.OPEN = 1; LocalSocket.CLOSING = 2; LocalSocket.CLOSED = 3;
+ function broadcast() { const s = state(); for (const ws of sockets) if (ws.joined) ws.deliver({ type: 'robot_state', state: s }); }
+ setInterval(() => { if (!document.hidden) broadcast(); }, TELEMETRY_MS);
+ window.OVBotLocalSocket = LocalSocket;
+
+ // "Back to the start": put the rover where it began (a page-only control, not a robot command).
+ for (const b of document.querySelectorAll('[data-try-reset]')) {
+ b.addEventListener('click', () => { halt(); rover.pose = { ...W.START }; rover.bumped = false; rover.battery = 100; broadcast(); });
+ }
+})();
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
"speaker.horn",
"speaker.horn",
"sensor.ultrasonic",
"sensor.ultrasonic",
"sensor.line",
"sensor.line",
- "battery"
+ "battery",
+ "camera"
],
],
"mapping": {
"mapping": {
"driver": "pca9685",
"driver": "pca9685",
@@ -38,11 +39,13 @@
@@ -38,11 +39,13 @@
}
}
},
},
"widgets": [
"widgets": [
+ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },
{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
+ { "type": "telemetry", "capability": "sensor.line", "label": "Line tracking", "order": 5 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "latency", "label": "Latency", "order": 7 }
{ "type": "latency", "label": "Latency", "order": 7 }
],
],
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
"speaker.horn",
"speaker.horn",
"sensor.ultrasonic",
"sensor.ultrasonic",
"sensor.line",
"sensor.line",
- "battery"
+ "battery",
+ "camera"
],
],
"mapping": {
"mapping": {
"driver": "pca9685",
"driver": "pca9685",
@@ -38,11 +39,13 @@
@@ -38,11 +39,13 @@
}
}
},
},
"widgets": [
"widgets": [
+ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },
{ "type": "drive", "capability": "drive.mecanum", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "drive", "capability": "drive.mecanum", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
{ "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
{ "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
{ "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
+ { "type": "telemetry", "capability": "sensor.line", "label": "Line tracking", "order": 5 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "latency", "label": "Latency", "order": 7 }
{ "type": "latency", "label": "Latency", "order": 7 }
],
],
@@ -36,6 +36,7 @@
@@ -36,6 +36,7 @@
"display": { "modes": ["text", "face", "image_png_b64"], "faces": ["neutral", "happy", "sad", "surprised", "sleepy", "angry"], "max_chars": 200 }
"display": { "modes": ["text", "face", "image_png_b64"], "faces": ["neutral", "happy", "sad", "surprised", "sleepy", "angry"], "max_chars": 200 }
},
},
"widgets": [
"widgets": [
+ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },
{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "head", "capability": "head", "label": "Head", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },
{ "type": "head", "capability": "head", "label": "Head", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },
{ "type": "lift", "capability": "lift", "label": "Lift", "order": 3, "command": { "kind": "actuator", "names": ["lift"] } },
{ "type": "lift", "capability": "lift", "label": "Lift", "order": 3, "command": { "kind": "actuator", "names": ["lift"] } },
@@ -1,19 +1,30 @@
@@ -1,19 +1,30 @@
{
{
"id": "sim.rover",
"id": "sim.rover",
- "version": 1,
+ "version": 2,
"name": "Simulated rover",
"name": "Simulated rover",
"vendor": "OpenVibe",
"vendor": "OpenVibe",
"kind": "server",
"kind": "server",
- "description": "The browser simulator on the openvibe.bot home: a differential-drive rover with a camera, driven by the same gate and profile as real hardware.",
- "capabilities": ["drive.differential", "camera"],
+ "description": "A differential-drive rover in a small arena, simulated by OpenVibe.Bot (or in your browser at /try): a front camera, an ultrasonic range finder, three line sensors over a strip of tape, a battery, RGB headlights and a horn, driven through the same gate and profile as real hardware.",
+ "capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "sensor.line", "battery", "lights.rgb", "speaker.horn"],
"mapping": { "driver": "sim" },
"mapping": { "driver": "sim" },
"commands": {
"commands": {
- "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } }
+ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
+ "actuator": {
+ "names": {
+ "headlights": { "type": "rgb" },
+ "horn": { "type": "tone", "hz": [220, 880] }
+ }
+ }
},
},
"widgets": [
"widgets": [
- { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
- { "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },
- { "type": "latency", "label": "Latency", "order": 3 }
+ { "type": "camera", "capability": "camera", "label": "Front camera", "order": 1 },
+ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 2, "command": { "kind": "drive" } },
+ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance ahead", "order": 3 },
+ { "type": "telemetry", "capability": "sensor.line", "label": "Line sensors", "order": 4 },
+ { "type": "battery", "capability": "battery", "label": "Battery", "order": 5 },
+ { "type": "lights", "capability": "lights.rgb", "label": "Headlights", "order": 6, "command": { "kind": "actuator", "names": ["headlights"] } },
+ { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 7, "command": { "kind": "actuator", "names": ["horn"] } },
+ { "type": "latency", "label": "Latency", "order": 8 }
],
],
"camera": { "transport": "whip", "resolution": "640x360" },
"camera": { "transport": "whip", "resolution": "640x360" },
"limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
"limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
@@ -45,6 +45,20 @@ function parseCookies(header) {
@@ -45,6 +45,20 @@ function parseCookies(header) {
}
}
const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };
const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };
const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
+// The keys the Node's drivers report a sensor capability under (the Adeept and Cozmo plugins: distance_cm, line).
+const SENSOR_KEYS = { ultrasonic: ['distance_cm'], line: ['line'], cliff: ['cliff', 'picked_up'], pickup: ['picked_up', 'pickup'] };
+const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
+const rgbOrNull = (c) => (c && typeof c === 'object' ? { r: finite(c.r), g: finite(c.g), b: finite(c.b) } : null);
+/** A simulated rover's view for a watcher: its pose, drive, lights and horn as numbers, nothing else. */
+function simView(sim) {
+ const drive = {};
+ for (const [k, v] of Object.entries(sim.drive && typeof sim.drive === 'object' ? sim.drive : {}).slice(0, 5)) drive[String(k).slice(0, 16)] = finite(v);
+ return {
+ pose: { x: finite(sim.pose.x), y: finite(sim.pose.y), heading: finite(sim.pose.heading) },
+ drive, headlights: rgbOrNull(sim.headlights),
+ horn: sim.horn && typeof sim.horn === 'object' ? { hz: finite(sim.horn.hz) } : null, bumped: !!sim.bumped,
+ };
+}
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
let domain = null;
let domain = null;
@@ -685,11 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -685,11 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);
if (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);
return state;
return state;
}
}
- /** The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities). */
+ /**
+ * The telemetry keys a profile's readout widgets show (`telemetry` widgets, `sensor.<key>` capabilities): the
+ * capability's name, and the keys the Node's drivers report it under (public/panel.js SENSOR_KEYS).
+ */
function readoutKeys(profile) {
function readoutKeys(profile) {
const keys = new Set();
const keys = new Set();
for (const w of (profile && profile.widgets) || []) {
for (const w of (profile && profile.widgets) || []) {
- if (w && w.type === 'telemetry' && typeof w.capability === 'string' && w.capability) keys.add(w.capability.replace(/^sensor\./, ''));
+ if (!w || w.type !== 'telemetry' || typeof w.capability !== 'string' || !w.capability) continue;
+ const name = w.capability.replace(/^sensor\./, '');
+ for (const k of [name, ...(SENSOR_KEYS[name] || [])]) keys.add(k);
}
}
return [...keys];
return [...keys];
}
}
@@ -702,11 +721,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -702,11 +721,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const picked = {};
const picked = {};
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
+ // A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.
+ const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;
return {
return {
robot_id: state.robot_id, online: state.online,
robot_id: state.robot_id, online: state.online,
estop: { latched: !!(state.estop && state.estop.latched) },
estop: { latched: !!(state.estop && state.estop.latched) },
latency_ms: state.latency_ms, battery: state.battery,
latency_ms: state.latency_ms, battery: state.battery,
- telemetry: t ? { sensors: picked } : null,
+ telemetry: t ? { sensors: picked, ...(sim ? { sim } : {}) } : null,
};
};
}
}
function broadcast(robotId) {
function broadcast(robotId) {
@@ -13,13 +13,19 @@
@@ -13,13 +13,19 @@
*
*
* No device row is written: the simulator's id is `dev_sim_<robot id>`, and a real device online for the same
* No device row is written: the simulator's id is `dev_sim_<robot id>`, and a real device online for the same
* robot takes precedence over it.
* robot takes precedence over it.
+ *
+ * The rover lives in the arena public/sim-world.js describes (the same module the browser draws its camera from):
+ * walls and crates stop it, and its telemetry carries what a real rover's would (`sensors.distance_cm`,
+ * `sensors.line`) plus `sim` ({ pose, drive, headlights, horn, bumped }) for the panel's simulated camera.
*/
*/
const { getProfile } = require('../profiles');
const { getProfile } = require('../profiles');
+const world = require('../../public/sim-world');
const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
const BATTERY_FLOOR = 20; // % the simulated pack never drains below
const BATTERY_FLOOR = 20; // % the simulated pack never drains below
const DRAIN_PER_S = 0.05; // % per second while moving
const DRAIN_PER_S = 0.05; // % per second while moving
-const EXISTS_EVERY = 20; // telemetry ticks between checks that the robot still exists
+const EXISTS_EVERY = 20; // telemetry frames between checks that the robot still exists
+const PHYSICS_MS = 50; // the rover moves at 20 Hz; telemetry goes out at the hub's sample rate
function createSimulator({ config, domain, hub, now = () => Date.now(), log = console }) {
function createSimulator({ config, domain, hub, now = () => Date.now(), log = console }) {
const sims = new Map(); // robot_id → sim
const sims = new Map(); // robot_id → sim
@@ -30,11 +36,11 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -30,11 +36,11 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
function start(robot) {
function start(robot) {
if (sims.has(robot.id)) return sims.get(robot.id);
if (sims.has(robot.id)) return sims.get(robot.id);
const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };
const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };
- const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { x: 0, y: 0, heading: 0 }, at: now(), ticks: 0 };
+ const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };
sims.set(robot.id, sim);
sims.set(robot.id, sim);
connect(sim);
connect(sim);
sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));
sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));
- sim.timers.push(every(telemetryMs, () => tick(sim)));
+ sim.timers.push(every(PHYSICS_MS, () => tick(sim)));
return sim;
return sim;
}
}
function connect(sim) {
function connect(sim) {
@@ -61,6 +67,12 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -61,6 +67,12 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
// The deadman: no newer command by the deadline → stop, as the Node does.
// The deadman: no newer command by the deadline → stop, as the Node does.
sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;
sim.stopTimer = Number.isFinite(f.deadline_ms) ? setTimeout(() => halt(sim), Math.max(0, f.deadline_ms - now())) : null;
if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();
if (sim.stopTimer && sim.stopTimer.unref) sim.stopTimer.unref();
+ } else if (f.kind === 'actuator' && f.value && f.value.name === 'headlights') {
+ sim.headlights = f.value.value || null;
+ } else if (f.kind === 'actuator' && f.value && f.value.name === 'horn') {
+ // A tone sounds while it is held: the panel re-sends it, and its deadline ends it like a motion command's.
+ sim.horn = f.value.value || null;
+ sim.hornUntil = sim.horn && Number.isFinite(f.deadline_ms) ? f.deadline_ms : 0;
} else {
} else {
sim.other[f.kind] = f.value;
sim.other[f.kind] = f.value;
}
}
@@ -72,7 +84,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -72,7 +84,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
sim.link.deliver({
sim.link.deliver({
type: 'telemetry',
type: 'telemetry',
battery: { volts: Math.round((6.4 + (2 * sim.battery) / 100) * 100) / 100, percent: Math.round(sim.battery) },
battery: { volts: Math.round((6.4 + (2 * sim.battery) / 100) * 100) / 100, percent: Math.round(sim.battery) },
- sensors: {}, rtt_ms: 1, drive: { ...sim.drive }, pose: { ...sim.pose },
+ sensors: { distance_cm: world.distanceCm(sim.pose), line: world.line(sim.pose) }, rtt_ms: 1, drive: { ...sim.drive }, pose: { ...sim.pose },
+ sim: { pose: { ...sim.pose }, drive: { ...sim.drive }, headlights: sim.headlights, horn: sim.horn, bumped: sim.bumped },
});
});
}
}
@@ -80,15 +93,23 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -80,15 +93,23 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
const t = now();
const t = now();
const dt = Math.max(0, (t - sim.at) / 1000);
const dt = Math.max(0, (t - sim.at) / 1000);
sim.at = t;
sim.at = t;
- // A simple kinematic model: forward speed from throttle (or y), turn rate from steer (or rotation).
- const speed = Number(sim.drive.throttle != null ? sim.drive.throttle : sim.drive.y) || 0;
- const turn = Number(sim.drive.steer != null ? sim.drive.steer : sim.drive.rotation) || 0;
- const strafe = Number(sim.drive.x) || 0;
- sim.pose.heading = (sim.pose.heading + turn * 90 * dt + 360) % 360;
- const rad = (sim.pose.heading * Math.PI) / 180;
- sim.pose.x = Math.round((sim.pose.x + (speed * Math.sin(rad) + strafe * Math.cos(rad)) * 0.5 * dt) * 1000) / 1000;
- sim.pose.y = Math.round((sim.pose.y + (speed * Math.cos(rad) - strafe * Math.sin(rad)) * 0.5 * dt) * 1000) / 1000;
- if (speed || turn || strafe) sim.battery = Math.max(BATTERY_FLOOR, sim.battery - DRAIN_PER_S * dt);
+ // The arena's kinematics (public/sim-world.js); a stalled loop's long gap is cut into short steps so a bump is never jumped over.
+ const moving = Object.values(sim.drive).some((v) => Number(v));
+ if (moving) {
+ let bumped = false;
+ for (let left = Math.min(dt, 2); left > 0; left -= 0.05) {
+ const r = world.step(sim.pose, sim.drive, Math.min(0.05, left));
+ sim.pose = r.pose;
+ if (r.bumped) { bumped = true; break; }
+ }
+ if (bumped && !sim.bumped) halt(sim); // the bumper stops the wheels, as the deadman does
+ sim.bumped = bumped;
+ sim.battery = Math.max(BATTERY_FLOOR, sim.battery - DRAIN_PER_S * dt);
+ } else sim.bumped = false;
+ if (sim.hornUntil && t > sim.hornUntil) { sim.horn = null; sim.hornUntil = 0; }
+ if (t - sim.sentAt < telemetryMs) return;
+ sim.sentAt = t;
+ sim.pose = { x: Math.round(sim.pose.x * 1000) / 1000, y: Math.round(sim.pose.y * 1000) / 1000, heading: Math.round(sim.pose.heading * 10) / 10 };
// The hub marks a silent device offline for good; a simulator that fell behind (a stalled loop) reconnects.
// The hub marks a silent device offline for good; a simulator that fell behind (a stalled loop) reconnects.
if (!hub.isOnline(sim.device.id)) connect(sim);
if (!hub.isOnline(sim.device.id)) connect(sim);
else telemetry(sim);
else telemetry(sim);
@@ -46,15 +46,15 @@ function sections({ signedIn }) {
@@ -46,15 +46,15 @@ function sections({ signedIn }) {
lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '
lede: 'An open control panel for robots. Add a robot, pair it with one pasted command, and drive it with a touch joystick, '
+ 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',
+ 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',
actions: signedIn
actions: signedIn
- ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'How it works', href: '#how' }]
- : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots', primary: true }, { label: 'How it works', href: '#how' }],
+ ? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }, { label: 'How it works', href: '#how' }]
+ : [{ label: 'Try it now, no account', href: '/try', primary: true }, { label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Frobots' }, { label: 'How it works', href: '#how' }],
note: 'Alpha. A paired robot sends its camera over WebRTC to OpenRestream and the panel plays it live; the first supported kit is the Adeept 4WD Smart Car for Raspberry Pi.',
note: 'Alpha. A paired robot sends its camera over WebRTC to OpenRestream and the panel plays it live; the first supported kit is the Adeept 4WD Smart Car for Raspberry Pi.',
}) + showcase.steps({
}) + showcase.steps({
id: 'how',
id: 'how',
title: 'Online in three steps',
title: 'Online in three steps',
lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',
lede: 'The robot connects out to OpenVibe, so there are no ports to open on your router.',
items: [
items: [
- { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists.' },
+ { title: 'Add a robot', text: 'Name it and pick its profile. A simulated rover works straight away, before any hardware exists, and the one at /try needs no account at all.' },
{ title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },
{ title: 'Pair it', text: 'Paste one command on the robot\'s computer. It installs the OpenVibe device agent with a one-time code; the robot\'s credential is stored hashed and can be revoked any time.' },
{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },
{ title: 'Drive it', text: 'Open the robot\'s panel in any browser, phone included.' },
],
],
@@ -73,7 +73,7 @@ function sections({ signedIn }) {
@@ -73,7 +73,7 @@ function sections({ signedIn }) {
items: [
items: [
{ icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },
{ icon: 'ov:check', title: 'Adeept 4WD Smart Car', text: 'The Raspberry Pi kit, with its parts list and build guide; a mecanum-wheel build has its own profile. The first robot on OpenVibe.Bot.' },
{ icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },
{ icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },
- { icon: 'ov:check', title: 'A simulated rover', text: 'Runs inside Bot, so you can try the whole panel with no hardware.' },
+ { icon: 'ov:check', title: 'A simulated rover', text: 'A rover in a small arena with a camera, a range finder, line sensors, headlights and a horn. Drive it at /try in your browser, with no account and no hardware.' },
],
],
}) + `<section class="sc-sec" id="build"><h2>Build for OpenVibe.Bot</h2>
}) + `<section class="sc-sec" id="build"><h2>Build for OpenVibe.Bot</h2>
<p class="sc-lede">Write a driver for a robot, or describe one with a profile and let the panel render it. The driver and profile spec, with a validator and the simulated rover, lives on the build pages.</p>
<p class="sc-lede">Write a driver for a robot, or describe one with a profile and let the panel render it. The driver and profile spec, with a validator and the simulated rover, lives on the build pages.</p>
@@ -56,7 +56,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
@@ -56,7 +56,7 @@ const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" ar
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_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' };
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 = '' } = {}) {
+function page(title, body, { scripts = [], bodyClass = '', description = null, canonical = null } = {}) {
return `<!doctype html>
return `<!doctype html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -64,7 +64,11 @@ function page(title, body, { scripts = [], bodyClass = '' } = {}) {
@@ -64,7 +64,11 @@ function page(title, body, { scripts = [], bodyClass = '' } = {}) {
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#11151c">
<meta name="theme-color" content="#11151c">
-<title>${esc(title)} — OpenVibe.Bot</title>
+<title>${esc(title)} — OpenVibe.Bot</title>${description ? `
+<meta name="description" content="${esc(description)}">
+<meta property="og:title" content="${esc(title)} — OpenVibe.Bot">
+<meta property="og:description" content="${esc(description)}">` : ''}${canonical ? `
+<link rel="canonical" href="https://openvibe.bot${esc(canonical)}">` : ''}
<link rel="stylesheet" href="/panel/panel.css">
<link rel="stylesheet" href="/panel/panel.css">
</head>
</head>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
<body${bodyClass ? ` class="${esc(bodyClass)}"` : ''}>
@@ -76,7 +80,7 @@ ${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
@@ -76,7 +80,7 @@ ${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>`;
+const topbar = (crumb = '', home = '/robots') => `<nav class="topbar"><a class="brand" href="${esc(home)}">${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(axes, enabled) {
function axisButtons(axes, enabled) {
@@ -109,19 +113,23 @@ function drivePad(axes, enabled) {
@@ -109,19 +113,23 @@ function drivePad(axes, enabled) {
+ `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
+ `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
}
}
+/** An actuator's name as a label: backpack_lights → Backpack lights. */
+const humanName = (name) => { const s = String(name).replace(/_/g, ' '); return s.charAt(0).toUpperCase() + s.slice(1); };
+
/** 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);
+ const label = humanName(name);
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 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>`;
+ return `<label class="control slider"><span class="control-name">${esc(label)}</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 colour"><label><span class="control-name">${esc(name)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
+ return `<div class="control colour"><label><span class="control-name">${esc(label)}</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>`;
+ ` <button type="button" class="quiet" data-rgb-off="${esc(name)}"${off}>Off</button></div>`;
}
}
- if (a.type === 'tone') return `<button type="button" class="hold tone" data-tone="${esc(name)}"${off}>${icon('horn')}<span>${esc(name)}</span></button>`;
+ if (a.type === 'tone') return `<button type="button" class="hold tone" data-tone="${esc(name)}"${off}>${icon('horn')}<span>${esc(label)}</span></button>`;
return `<label class="control toggle"><input type="checkbox" data-bool="${esc(name)}"${off}> <span class="control-name">${esc(name)}</span></label>`;
return `<label class="control toggle"><input type="checkbox" data-bool="${esc(name)}"${off}> <span class="control-name">${esc(name)}</span></label>`;
}
}
@@ -129,6 +137,14 @@ function actuatorControl(name, a, enabled) {
@@ -129,6 +137,14 @@ function actuatorControl(name, a, enabled) {
* The cameras a profile lists: `camera` is one camera ({ transport, resolution, name? }) or a list of them.
* 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.
* A profile with a camera widget and no camera entry still gets one slot.
*/
*/
+const isSim = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
+
+/** The panel's scripts: the client, plus the shared arena and its camera for a simulated robot (and /try's local rover). */
+function panelScripts(profile, local = false) {
+ if (!isSim(profile)) return ['/panel/panel.js'];
+ return ['/panel/sim-world.js', ...(local ? ['/panel/try-device.js'] : []), '/panel/panel.js', '/panel/sim-view.js'];
+}
+
function camerasOf(profile = {}) {
function camerasOf(profile = {}) {
const c = profile.camera;
const c = profile.camera;
const list = Array.isArray(c) ? c.filter((x) => x && typeof x === 'object') : (c && typeof c === 'object' ? [c] : []);
const list = Array.isArray(c) ? c.filter((x) => x && typeof x === 'object') : (c && typeof c === 'object' ? [c] : []);
@@ -142,16 +158,18 @@ function camerasOf(profile = {}) {
@@ -142,16 +158,18 @@ function camerasOf(profile = {}) {
*/
*/
function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {
function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {
const all = camerasOf(profile).map((c, i) => ({ c, i }));
const all = camerasOf(profile).map((c, i) => ({ c, i }));
+ // A simulated robot's camera is drawn in the page from its pose (public/sim-view.js), never streamed.
+ const simulated = isSim(profile);
const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;
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 }) => {
return (pick.length ? pick : all).map(({ c, i }) => {
const res = typeof c.resolution === 'string' && /^\d+x\d+$/.test(c.resolution) ? c.resolution : '';
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 title = c.name || (all.length > 1 ? `Camera ${i + 1}` : '');
const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
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)}"` : ''}`
- + `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`
+ return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport && !simulated ? ` data-transport="${esc(c.transport)}"` : ''}`
+ + `${simulated ? ' data-sim-camera' : signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`
+ `<div class="camera-screen" data-camera-screen${pointEnabled ? ' data-video-click' : ''}>${icon('camera', 'icon camera-icon')}`
+ `<div class="camera-screen" data-camera-screen${pointEnabled ? ' data-video-click' : ''}>${icon('camera', 'icon camera-icon')}`
- + '<p class="camera-title">No live video right now.</p>'
- + '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'
+ + `<p class="camera-title">${simulated ? 'The rover is starting.' : 'No live video right now.'}</p>`
+ + `<p class="camera-sub">${simulated ? 'Its camera shows here once the simulator is running.' : 'The camera shows here while the robot is online and sending video.'}</p></div>`
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
+ '</figure>';
+ '</figure>';
}).join('');
}).join('');
@@ -345,7 +363,7 @@ function turnStrip(robot, role) {
@@ -345,7 +363,7 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {} }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@ -356,24 +374,51 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -356,24 +374,51 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${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}">
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
-${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
+${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : 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>
<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" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
+<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''}>
<div class="panel-head">
<div class="panel-head">
<h1>${esc(robot.name)}</h1>
<h1>${esc(robot.name)}</h1>
<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>
<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>
${turnStrip(robot, role)}
${turnStrip(robot, role)}
</div>
</div>
+${tryIt ? tryIntro() : ''}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
<p class="notice" data-device-fault role="status" hidden></p>
<p class="notice" data-device-fault role="status" hidden></p>
<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>
-${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
- return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
+ return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
+ scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
+ description: tryIt ? TRY_DESCRIPTION : null, canonical: tryIt ? '/try' : null,
+ });
+}
+
+const TRY_DESCRIPTION = 'Drive a simulated rover from your browser, no account and no hardware: the same OpenVibe.Bot panel, '
+ + 'safety gate and e-stop a real robot gets, with a live camera, a distance sensor, line sensors, headlights and a horn.';
+
+/** /try's opening lines: what this is, and that nothing here is real hardware. */
+function tryIntro() {
+ return `<section class="try-intro" aria-labelledby="try-h">
+<h2 id="try-h">Drive a rover, right here</h2>
+<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls, the same limits, and an e-stop that holds until it is cleared.</p>
+<ul class="try-keys"><li><kbd>W A S D</kbd> or the arrows, or drag the joystick</li><li><kbd>Space</kbd> stops</li><li>Follow the yellow tape, or find the crates</li></ul>
+<p><button type="button" class="button quiet" data-try-reset>Back to the start</button></p>
+</section>`;
+}
+
+/** /try's closing card: from the simulator to a real robot, and the profile this panel was drawn from. */
+function tryOutro(profile) {
+ return `<section class="try-next card" aria-labelledby="try-next-h">
+<h2 id="try-next-h">Ready for a real one?</h2>
+<p>Add a robot, pair its computer with one pasted command, and this panel drives it. A Raspberry Pi rover, a Cozmo through a nearby computer, an ONVIF camera or a phone are all a few minutes away.</p>
+<p class="try-actions"><a class="button primary" href="/robots#add-robot">Add a robot</a> <a class="button quiet" href="/docs/profiles">How a profile draws this panel</a> <a class="button quiet" href="/docs/drivers">Write a driver</a></p>
+</section>
+<script type="application/json" id="try-profile">${JSON.stringify(profile).replace(/</g, '\\u003c')}</script>`;
}
}
/**
/**
@@ -457,4 +502,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
@@ -457,4 +502,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
}
-module.exports = { page, renderPanel, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { page, renderPanel, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
@@ -32,7 +32,7 @@ const path = require('path');
@@ -32,7 +32,7 @@ const path = require('path');
const express = require('express');
const express = require('express');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const appIcon = require('openvibe-shared/app-icon');
const appIcon = require('openvibe-shared/app-icon');
-const { getProfile, listProfiles } = require('../profiles');
+const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
@@ -43,11 +43,13 @@ const { renderHome, HOME_CSP } = require('./home');
@@ -43,11 +43,13 @@ const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
const HOLD_RESEND_MS = 150;
+// The rover /try drives, as shipped (server/profiles/sim.rover.json, validated).
+const TRY_PROFILE = loadProfiles().get('sim.rover');
// The tagline stated for openvibe.bot (openvibe-contracts manifests/services/bot.json site.tagline).
// The tagline stated for openvibe.bot (openvibe-contracts manifests/services/bot.json site.tagline).
const SITE_TAGLINE = 'An open control panel for robots';
const SITE_TAGLINE = 'An open control panel for robots';
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// are Disallowed in robots.txt; the API and sign-in are not pages.
// are Disallowed in robots.txt; the API and sign-in are not pages.
-const PUBLIC_PAGES = ['/', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
+const PUBLIC_PAGES = ['/', '/try', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the
// The public pages an AI crawler can read, for /llms.txt and /llms-full.txt (llmstxt.org). Same pages as the
// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing
// sitemap minus /install (a 302 to the installer script, not a page); no signed-in or per-robot page, nothing
// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,
// under the API or /internal. `note` is the one-line llms.txt entry, `text` the page's full text in llms-full.txt,
@@ -61,6 +63,11 @@ const LLMS_PAGES = [
@@ -61,6 +63,11 @@ const LLMS_PAGES = [
note: 'What Bot is, and how a robot goes from added to driving.',
note: 'What Bot is, and how a robot goes from added to driving.',
text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that only the owner clears; a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',
text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that only the owner clears; a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',
},
},
+ {
+ path: '/try', title: 'Try the panel with a simulated rover',
+ note: 'Drive a simulated rover in the browser, no account and no hardware.',
+ text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
+ },
{
{
path: '/docs', title: 'Build for OpenVibe.Bot',
path: '/docs', title: 'Build for OpenVibe.Bot',
note: 'The build index: drivers and robot profiles.',
note: 'The build index: drivers and robot profiles.',
@@ -140,6 +147,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -140,6 +147,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/panel.css', asset('panel.css', 'text/css'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
r.get('/panel/drive.js', asset('drive.js', 'application/javascript'));
+ r.get('/panel/sim-world.js', asset('sim-world.js', 'application/javascript'));
+ r.get('/panel/sim-view.js', asset('sim-view.js', 'application/javascript'));
+ r.get('/panel/try-device.js', asset('try-device.js', 'application/javascript'));
+
+ // /try: the real panel driving a rover simulated in the visitor's own browser (public/try-device.js), so anyone
+ // can try it with no account and no hardware (plan T15 "a simulated robot in the browser"). Nothing reaches the
+ // server after the page: no socket, no robot row, no limit to spend. The site-wide CSP holds (no outside source).
+ const tryAllowed = Object.keys(TRY_PROFILE.commands);
+ r.get('/try', (req, res) => {
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ res.type('html').send(renderPanel({
+ robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private', estop: { latched: false } },
+ profile: TRY_PROFILE, role: 'owner', allowed_commands: tryAllowed, holdResendMs: HOLD_RESEND_MS, tryIt: true,
+ }));
+ });
const requireUser = (req) => {
const requireUser = (req) => {
if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');
if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');
@@ -96,6 +96,9 @@ const { renderPanel } = require('../server/web/render');
@@ -96,6 +96,9 @@ const { renderPanel } = require('../server/web/render');
});
});
await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {
await check('a live session\'s signaling URL reaches the anonymous public embed; a private robot is refused before it', async () => {
+ // A camera that streams (a simulated rover's camera is drawn in the page, never played).
+ const { robot: open } = await t.robot(alex, { name: 'Public car', profile_id: 'adeept.adr036' });
+ await t.domain.robots.setEmbedPublic(open.id, true);
const code = (await t.call('POST', `/api/v1/robots/${open.id}/pairing-code`, { user: alex })).json;
const code = (await t.call('POST', `/api/v1/robots/${open.id}/pairing-code`, { user: alex })).json;
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: open.id, code: code.code } });
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: open.id, code: code.code } });
assert.strictEqual(paired.status, 201, paired.text);
assert.strictEqual(paired.status, 201, paired.text);
@@ -128,7 +131,9 @@ const { renderPanel } = require('../server/web/render');
@@ -128,7 +131,9 @@ const { renderPanel } = require('../server/web/render');
assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);
assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);
assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);
assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
- assert.deepStrictEqual(s.state.telemetry, { sensors: {} }, 'the drive and pose telemetry reached a watcher');
+ // The profile's readouts by the driver's keys, and a simulated rover's camera view; never the raw drive or pose.
+ assert.deepStrictEqual(Object.keys(s.state.telemetry).sort(), ['sensors', 'sim'], 'the drive and pose telemetry reached a watcher');
+ assert.deepStrictEqual(Object.keys(s.state.telemetry.sensors).sort(), ['distance_cm', 'line']);
assert.ok(s.state.online && s.state.battery > 0);
assert.ok(s.state.online && s.state.battery > 0);
w.close();
w.close();
});
});
@@ -156,6 +156,13 @@ const server = {
@@ -156,6 +156,13 @@ const server = {
name: 'sim (server)',
name: 'sim (server)',
accepts(kind, v) {
accepts(kind, v) {
if (kind === 'halt') return halt(v);
if (kind === 'halt') return halt(v);
+ if (kind === 'actuator') {
+ // server/sim: headlights take {r,g,b} integers 0..255 or null; the horn {hz} 220..880 or null.
+ if (!isObj(v) || !only(v, ['name', 'value'])) return 'actuator is {name, value}';
+ if (v.name === 'headlights') return v.value === null || (isObj(v.value) && only(v.value, ['r', 'g', 'b']) && ['r', 'g', 'b'].every((k) => Number.isInteger(v.value[k]) && num(v.value[k], 0, 255))) ? null : 'headlights is {r,g,b} 0..255 or null';
+ if (v.name === 'horn') return v.value === null || (isObj(v.value) && only(v.value, ['hz']) && num(v.value.hz, 220, 880)) ? null : 'horn is {hz} 220..880 or null';
+ return `no actuator ${v.name}`;
+ }
if (kind !== 'drive') return `kind ${kind} not supported`;
if (kind !== 'drive') return `kind ${kind} not supported`;
return isObj(v) && only(v, ['throttle', 'steer']) && num(v.throttle, -1, 1) && num(v.steer, -1, 1) ? null : 'drive is {throttle, steer} -1..1';
return isObj(v) && only(v, ['throttle', 'steer']) && num(v.throttle, -1, 1) && num(v.steer, -1, 1) ? null : 'drive is {throttle, steer} -1..1';
},
},
@@ -37,7 +37,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -37,7 +37,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.ok(!html(role).includes('data-estop>') && !html(role).includes('data-estop-clear'), `${role} sees an e-stop button`);
assert.ok(!html(role).includes('data-estop>') && !html(role).includes('data-estop-clear'), `${role} sees an e-stop button`);
assert.ok(html(role).includes('data-estop-banner data-latched="true"'), `${role} does not see the latched banner`);
assert.ok(html(role).includes('data-estop-banner data-latched="true"'), `${role} does not see the latched banner`);
}
}
- assert.match(renderWidget(p.widgets[0], { profile: p, allowed_commands: [] }), /disabled/);
+ assert.match(renderWidget(p.widgets.find((w) => w.command), { profile: p, allowed_commands: [] }), /disabled/);
});
});
await check('one camera tile per camera the profile lists, each the placeholder with its shape', () => {
await check('one camera tile per camera the profile lists, each the placeholder with its shape', () => {
@@ -45,11 +45,13 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -45,11 +45,13 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
for (const p of profiles.filter((x) => x.widgets.some((w) => w.type === 'camera'))) {
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') });
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.strictEqual(tiles(html), camerasOf(p).length, p.id);
- assert.ok(html.includes('No live video right now.'), p.id);
+ // A simulated rover's camera is drawn in the page (public/sim-view.js); a real one waits for video.
+ if (p.mapping.driver === 'sim') assert.ok(html.includes('data-sim-camera') && html.includes('The rover is starting.') && html.includes('/panel/sim-view.js'), p.id);
+ else assert.ok(html.includes('No live video right now.') && !html.includes('data-sim-camera') && !html.includes('sim-view.js'), p.id);
if (p.camera && p.camera.resolution) assert.ok(html.includes(`data-resolution="${p.camera.resolution}"`), 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`);
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 p = profiles.find((x) => x.id === 'adeept.adr036');
const two = { ...p, camera: [{ name: 'Front', transport: 'whip', resolution: '640x360' }, { name: 'Arm', transport: 'whip', resolution: '320x240' }] };
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: [] });
const html = renderPanel({ robot: robotFor(two), profile: two, role: 'owner', allowed_commands: [] });
assert.strictEqual(tiles(html), 2);
assert.strictEqual(tiles(html), 2);
@@ -62,7 +64,9 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -62,7 +64,9 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
});
});
await check('a live OpenRestream session rides on the camera tile as its signaling URL, escaped; none given, no attribute', () => {
await check('a live OpenRestream session rides on the camera tile as its signaling URL, escaped; none given, no attribute', () => {
- const p = profiles.find((x) => x.id === 'sim.rover');
+ const sim = profiles.find((x) => x.id === 'sim.rover');
+ assert.ok(!renderPanel({ robot: robotFor(sim), profile: sim, role: 'owner', allowed_commands: [], signaling_url: 'wss://x/w/1' }).includes('data-signaling-url'), 'a simulated camera never plays a stream');
+ const p = profiles.find((x) => x.id === 'adeept.adr036');
const url = 'wss://ingest.openre.stream/w/ses_01J5Z000000000000000000000';
const url = 'wss://ingest.openre.stream/w/ses_01J5Z000000000000000000000';
const owner = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner'), signaling_url: url });
const owner = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner'), signaling_url: url });
assert.ok(owner.includes(`data-signaling-url="${url}"`), 'the owner\'s tile carries the URL');
assert.ok(owner.includes(`data-signaling-url="${url}"`), 'the owner\'s tile carries the URL');
@@ -191,7 +195,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -191,7 +195,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
});
});
await check('a live OpenRestream WebRTC session: the owner\'s and a member\'s panel carry its signaling URL, never the key', async () => {
await check('a live OpenRestream WebRTC session: the owner\'s and a member\'s panel carry its signaling URL, never the key', async () => {
- const { robot, pairing } = await t.robot(alex, { name: 'Live cam' });
+ const { robot, pairing } = await t.robot(alex, { name: 'Live cam', profile_id: 'adeept.adr036' });
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
assert.strictEqual(paired.status, 201, paired.text);
assert.strictEqual(paired.status, 201, paired.text);
const streamId = (await t.db.maybe('SELECT openre_stream_id FROM robots WHERE id = $1', [robot.id])).openre_stream_id;
const streamId = (await t.db.maybe('SELECT openre_stream_id FROM robots WHERE id = $1', [robot.id])).openre_stream_id;
@@ -107,9 +107,11 @@ function samples(profile, kind, names) {
@@ -107,9 +107,11 @@ function samples(profile, kind, names) {
assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');
assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');
});
});
- await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {
+ await check('camera.onvif is PTZ only; sim.rover is a whole rover (drive, camera, range and line sensors, battery, headlights, horn)', () => {
assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);
assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);
- assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
+ assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(),
+ ['battery', 'camera', 'drive.differential', 'lights.rgb', 'sensor.line', 'sensor.ultrasonic', 'speaker.horn']);
+ assert.strictEqual(profiles.get('sim.rover').version, 2);
});
});
await check('relay.generic is the bare relay robot: buttons only, no drive, no hardware of its own', () => {
await check('relay.generic is the bare relay robot: buttons only, no drive, no hardware of its own', () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+// The simulated rover (plan T15 "a simulated robot in the browser lets anyone try the panel before owning hardware"):
+// the shared arena (public/sim-world.js) both ends use, /try's page (the real panel on a rover that runs in the
+// visitor's browser), and Bot's in-process simulator moving in the same arena and reporting what a real rover would.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const world = require('../public/sim-world');
+const { loadProfiles } = require('../server/profiles');
+
+(async () => {
+ await check('the arena: the rover starts free on the tape, a wall stops it, sensors read what is ahead and below', () => {
+ assert.strictEqual(world.blocked(world.START.x, world.START.y), false, 'the start is clear');
+ assert.deepStrictEqual(world.line(world.START), { left: false, middle: true, right: false }, 'parked centred on the tape');
+ let pose = { ...world.START };
+ let bumped = false;
+ const before = world.distanceCm(pose);
+ for (let i = 0; i < 200 && !bumped; i++) ({ pose, bumped } = world.step(pose, { throttle: 1, steer: 0 }, 0.05));
+ assert.ok(bumped, 'driving straight on ends at a wall');
+ assert.ok(pose.y > world.START.y + 3, `it got across the arena (${pose.y})`);
+ assert.ok(world.distanceCm(pose) < 15 && world.distanceCm(pose) < before, 'the range finder reads the wall it touches');
+ assert.strictEqual(world.blocked(pose.x, pose.y), false, 'a bump never leaves the rover inside a solid');
+ const turned = world.step(world.START, { throttle: 0, steer: 1 }, 0.5).pose;
+ assert.ok(Math.abs(turned.heading - 45) < 1e-9 && turned.x === world.START.x, 'a right steer turns right on the spot (heading 0 → 45 in 0.5 s)');
+ assert.strictEqual(world.floorAt(world.START.x, world.START.y), 'line');
+ assert.ok(['tile', 'tile2'].includes(world.floorAt(4, 1)), 'off the tape is floor');
+ const ray = world.cast(4, 1, 0, 20);
+ assert.ok(ray.length >= 1 && ray.at(-1).cell === '#', 'every ray ends at the arena wall');
+ });
+
+ await check('sim.rover v2 has what /try drives: drive, camera, range and line sensors, battery, headlights, horn', () => {
+ const p = loadProfiles().get('sim.rover');
+ assert.deepStrictEqual(p.widgets.map((w) => w.type), ['camera', 'drive', 'telemetry', 'telemetry', 'battery', 'lights', 'horn', 'latency']);
+ assert.deepStrictEqual(Object.keys(p.commands.actuator.names).sort(), ['headlights', 'horn']);
+ });
+
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ const poll = async (fn, ms = 3000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(20); } return null; };
+
+ await check('GET /try: the real panel on a rover in the visitor\'s browser, public, no session, nothing from outside', async () => {
+ const r = await get('/try');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('cache-control'), /^public/);
+ assert.strictEqual(r.headers.get('set-cookie'), null);
+ assert.doesNotMatch(r.headers.get('content-security-policy') || '', /esm\.sh|openre/, 'no outside script or socket');
+ const html = await r.text();
+ assert.ok(html.includes('data-transport="local"') && html.includes('data-role="owner"') && html.includes('data-sim-camera'));
+ const scripts = [...html.matchAll(/<script src="([^"]+)" defer><\/script>/g)].map((m) => m[1]);
+ assert.deepStrictEqual(scripts, ['/panel/sim-world.js', '/panel/try-device.js', '/panel/panel.js', '/panel/sim-view.js'], 'the rover and its socket before the panel, the camera after');
+ const json = /<script type="application\/json" id="try-profile">([^<]*)<\/script>/.exec(html);
+ assert.ok(json, 'the profile rides along as data');
+ assert.deepStrictEqual(JSON.parse(json[1]), loadProfiles().get('sim.rover'));
+ assert.ok(html.includes('<link rel="canonical" href="https://openvibe.bot/try">') && html.includes('<meta name="description"'));
+ assert.ok(html.includes('href="/robots#add-robot"'), 'the way on to a real robot');
+ for (const s of scripts) {
+ const js = await get(s);
+ assert.strictEqual(js.status, 200, s);
+ assert.match(js.headers.get('content-type'), /javascript/, s);
+ }
+ assert.ok((await (await get('/sitemap.xml')).text()).includes('<loc>http://bot.test/try</loc>'));
+ assert.ok((await (await get('/llms.txt')).text()).includes('http://bot.test/try'));
+ assert.doesNotMatch(await (await get('/robots.txt')).text(), /Disallow: \/try/);
+ });
+
+ await check('the in-process simulator moves in the arena and reports range, line and its pose for the camera', async () => {
+ const { robot } = await t.robot(alex, { name: 'Arena', profile_id: 'sim.rover' });
+ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);
+ const tel = await poll(() => t.hub.deviceState(`dev_sim_${robot.id}`).telemetry, 2000);
+ assert.ok(tel, 'telemetry');
+ assert.deepStrictEqual(tel.sim.pose, { ...world.START }, 'it starts where /try starts');
+ assert.ok(Number.isInteger(tel.sensors.distance_cm) && tel.sensors.line.middle === true);
+ const c = await t.ws('/control', { headers: cookie(alex) });
+ c.send({ type: 'join', robot_id: robot.id });
+ await c.waitFor((m) => m.type === 'joined');
+ c.send({ type: 'command', id: 'l1', kind: 'actuator', value: { name: 'headlights', value: { r: 255, g: 200, b: 40 } } });
+ assert.strictEqual((await c.waitFor((m) => m.type === 'command_result' && m.id === 'l1')).result, 'ack');
+ let n = 0;
+ const hold = setInterval(() => c.send({ type: 'command', id: `f${++n}`, kind: 'drive', value: { throttle: 1 } }), 100);
+ let moved;
+ try {
+ moved = await poll(() => { const s = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return s && s.sim.pose.y > world.START.y + 0.3 ? s : null; }, 4000);
+ } finally { clearInterval(hold); }
+ assert.ok(moved, 'held forward, it drives up the tape');
+ assert.deepStrictEqual(moved.sim.headlights, { r: 255, g: 200, b: 40 }, 'the camera knows its lights');
+ assert.ok(moved.sensors.distance_cm < tel.sensors.distance_cm, 'closer to what is ahead');
+ c.close();
+ t.app.locals.sim.stop(robot.id);
+ });
+
+ await check('a watcher sees a sim robot\'s pose (numbers only) and a real robot\'s readouts by the driver\'s keys', async () => {
+ const { robot } = await t.robot(alex, { name: 'Shown', profile_id: 'sim.rover' });
+ await t.domain.robots.setEmbedPublic(robot.id, true);
+ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);
+ const w = await t.ws('/watch', { headers: { 'X-Forwarded-For': '10.9.0.1' } });
+ w.send({ type: 'join', robot_id: robot.id });
+ const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sim);
+ const tel = s.state.telemetry;
+ assert.deepStrictEqual(Object.keys(tel.sim).sort(), ['bumped', 'drive', 'headlights', 'horn', 'pose']);
+ assert.deepStrictEqual(Object.keys(tel.sensors).sort(), ['distance_cm', 'line'], 'the range finder and line readouts under the keys the Node reports');
+ assert.ok(!('drive' in tel) && !('pose' in tel), 'nothing else of the telemetry');
+ w.close();
+ t.app.locals.sim.stop(robot.id);
+ });
+
+ await t.close();
+ done();
+})();