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)

OP OpenVibers committed 13 Oct 2026 · dd56f2f2ed · 21 files, +965 −66

Plan T15 asks that "a simulated robot in the browser lets anyone try the panel before owning hardware (a real, working robot model, not a mock-up)". Until now the simulated rover needed an account and an added robot, its panel showed a "No live video" tile, and it reported no sensors.

**`/try`: public, no account**
- The real panel (`public/panel.js`) runs on a rover that lives in the visitor's own browser.
- `public/try-device.js` answers the panel's `/control` frames locally with the gate's rules:
  - only the profile's command kinds;
  - drive axes and actuator values shaped to the profile's ranges;
  - a deadman, so a motion command lapses after `max_command_ms`;
  - a latched e-stop that refuses everything but halt until it is cleared.
- Nothing reaches the server after the page loads: no socket, no robot row, nothing counted against a limit. The page runs under the site-wide CSP, with no outside source.
- `/try` is listed in the sitemap and llms.txt, and has a description and a canonical URL. The home page's main action for signed-out visitors is now "Try it now, no account".

**One arena for both ends (`public/sim-world.js`)**
- It loads as a CommonJS module and as a browser global. It holds walls, crates and a loop of yellow tape.
- `step()` runs the kinematics with collisions; a bump stops the wheels.
- `distanceCm()` is the range finder: a 12° cone, up to 4 m.
- `line()` reads three line sensors.
- Bot's in-process simulator (`server/sim`) now moves in this arena at 20 Hz.
  - Its telemetry carries `sensors.distance_cm` and `sensors.line`, the keys the Adeept plugin uses.
  - It also carries `sim` (pose, drive, headlights, horn, bumped).
- Watchers on `/watch` get `sim` with numbers only; the raw drive and pose stay hidden.

**The simulated camera (`public/sim-view.js`)**
- Every camera tile of a `sim` robot draws the rover's view of the arena on a canvas: the floor, the tape, and walls and crates drawn far to near.
- The headlights light the scene, and a map of the arena sits in the corner.
- On `/try` it reads the local rover. For a server sim robot it moves the rover on with the same kinematics between the 2 Hz reports, and eases toward each new one.
- The horn plays through Web Audio once the page has been touched.

**sim.rover v2**
- Camera, drive, range finder, line sensors, battery, RGB headlights and a horn.

**Fixes found on the way**
- **Video:** `adeept.adr036`, its mecanum variant and `cozmo` declared a WHIP camera but had **no camera widget**, so their panels could never show video. They have one now, and Adeept gets its line-tracking readout.
- **Readouts:** a readout widget looked up its capability's name (`ultrasonic`, `cliff`) while the Node's drivers report `distance_cm`, `line`, `cliff` and `picked_up`. Owners saw the whole sensor object, and watchers saw nothing. Both the panel and `/watch` now map capabilities to the drivers' keys.
- **Labels:** actuator controls are labelled in words ("Backpack lights", not `backpack_lights`).
- **Layout:** the camera widget no longer stretches past its picture on wide screens.

**Tests**
- New `test/try-sim.test.js`:
  - the arena: start, bumps, sensors, turn direction;
  - `/try`: public cache, no cookie, script order, the profile as data, sitemap and llms;
  - the server simulator moving and reporting;
  - a watcher's `sim` view.
- Updated `panel`, `profiles`, `embed-panel` and `helpers/plugins` (the sim driver's actuators).
- The full suite passes: 33/33 test files.

**Checked in headless Chrome at 1280×900 and 390×844 (phone):**
- `/try` goes online and draws the camera and the map.
- The keyboard drives and the pose moves.
- The latched e-stop refuses driving, and clearing it works.
- No console errors and no horizontal overflow.
- A server sim robot's panel draws its camera from telemetry.

Changes

ddiff --git a/README.md b/README.md
iindex d459892..84a7a67 100644
---- a/README.md
++++ b/README.md
@@@ -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`).
ddiff --git a/STATUS.json b/STATUS.json
iindex 6ba9271..018ccd6 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/public/panel.css b/public/panel.css
iindex 31a558b..4343012 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 02d8870..028a3b2 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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 alike
if (watcher && frame.type !== 'join') return false; // /watch is read-only
ws.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;
ddiff --git a/public/sim-view.js b/public/sim-view.js
nnew file mode 100644
iindex 0000000..f132923
---- /dev/null
++++ b/public/sim-view.js
@@@ -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);
++})();
ddiff --git a/public/sim-world.js b/public/sim-world.js
nnew file mode 100644
iindex 0000000..c1714f8
---- /dev/null
++++ b/public/sim-world.js
@@@ -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 };
++}));
ddiff --git a/public/try-device.js b/public/try-device.js
nnew file mode 100644
iindex 0000000..06ace89
---- /dev/null
++++ b/public/try-device.js
@@@ -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(); });
++ }
++})();
ddiff --git a/server/profiles/adeept.adr036.json b/server/profiles/adeept.adr036.json
iindex 0088dcb..7c8ad93 100644
---- a/server/profiles/adeept.adr036.json
++++ b/server/profiles/adeept.adr036.json
@@@ -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 }
],
ddiff --git a/server/profiles/adeept.adr036.mecanum.json b/server/profiles/adeept.adr036.mecanum.json
iindex 7351bb2..ebece72 100644
---- a/server/profiles/adeept.adr036.mecanum.json
++++ b/server/profiles/adeept.adr036.mecanum.json
@@@ -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 }
],
ddiff --git a/server/profiles/cozmo.json b/server/profiles/cozmo.json
iindex b4f062b..68915b6 100644
---- a/server/profiles/cozmo.json
++++ b/server/profiles/cozmo.json
@@@ -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"] } },
ddiff --git a/server/profiles/sim.rover.json b/server/profiles/sim.rover.json
iindex 0897bd4..f939521 100644
---- a/server/profiles/sim.rover.json
++++ b/server/profiles/sim.rover.json
@@@ -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 }
ddiff --git a/server/realtime.js b/server/realtime.js
iindex da0dc14..30a9b56 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 = () => Dat
if (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 = () => Dat
const 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) {
ddiff --git a/server/sim/index.js b/server/sim/index.js
iindex a76bb4a..b014d61 100644
---- a/server/sim/index.js
++++ b/server/sim/index.js
@@@ -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 below
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 }) {
const sims = new Map(); // robot_id → sim
@@@ -30,11 +36,11 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
function 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 = co
sim.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 = co
const 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);
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 2255b42..eff6752 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -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>
ddiff --git a/server/web/render.js b/server/web/render.js
iindex eca8edb..fcc97b3 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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_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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ce37fae..40b9a3c 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
r.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');
ddiff --git a/test/embed-panel.test.js b/test/embed-panel.test.js
iindex 6854d78..ee72f08 100644
---- a/test/embed-panel.test.js
++++ b/test/embed-panel.test.js
@@@ -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();
});
ddiff --git a/test/helpers/plugins.js b/test/helpers/plugins.js
iindex 93d29de..bc489a0 100644
---- a/test/helpers/plugins.js
++++ b/test/helpers/plugins.js
@@@ -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';
},
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 3f0d4ee..9adcd28 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -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;
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 6081b6a..44f7126 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -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', () => {
ddiff --git a/test/try-sim.test.js b/test/try-sim.test.js
nnew file mode 100644
iindex 0000000..bd342a7
---- /dev/null
++++ b/test/try-sim.test.js
@@@ -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();
++})();