openvibers/openvibe.bot Commits
Add signed-in robot pages, the profile-rendered panel and a sim device
Changes
@@@ -36,6 +36,7 @@"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","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record"],"updated": "2026-10-04"@@@ -119,6 +119,23 @@ server {proxy_pass http://127.0.0.1:4630;}++ # ── Signed-in pages: robots, pairing and the panel (server/web/routes.js) ──++ location = /robots {++ limit_req zone=ovbot_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4630;++ }++ location ^~ /pair/ {++ limit_req zone=ovbot_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4630;++ }++ location ^~ /panel/ {++ limit_req zone=ovbot_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4630;++ }++# ── Health and readiness: host-local probes only ────────# The app treats /api/health and /api/ready as loopback-only (README); keep them off the public# vhost so a probe uses port 4630 directly. Exact matches outrank the /api/ prefix below.nnew file mode 100644@@@ -0,0 +1,48 @@++/* The panel's one sheet: mobile-first, every control at least 44 px, the e-stop banner always in view.++ Functional only; the visual design is done separately. */++*, *::before, *::after { box-sizing: border-box; }++body { margin: 0; font: 16px/1.4 system-ui, sans-serif; color: #111; background: #f6f6f6; }++main { padding: 1rem; max-width: 64rem; margin: 0 auto; }++h1 { font-size: 1.4rem; margin: 0.5rem 0; }++h2 { font-size: 1rem; margin: 0 0 0.5rem; }++button, input, select { font: inherit; min-height: 44px; min-width: 44px; }++button { padding: 0 0.9rem; border: 1px solid #888; border-radius: 6px; background: #fff; touch-action: manipulation; }++button:disabled, input:disabled, select:disabled { opacity: 0.45; }++input[type="range"] { width: 100%; }++label, .control { display: block; margin: 0.4rem 0; }++++/* The e-stop banner: fixed to the top of every panel, red when latched. */++.estop-banner { position: sticky; top: 0; z-index: 10; display: flex; gap: 0.75rem; align-items: center; justify-content: space-between;++ padding: 0.5rem 1rem; background: #222; color: #fff; }++.estop-banner[data-latched="true"] { background: #b00020; }++.estop-banner .estop { min-height: 56px; min-width: 96px; font-weight: 700; background: #d50000; color: #fff; border-color: #fff; }++.estop-banner .estop-clear { background: #fff; color: #111; }++++.meta { color: #555; margin: 0 0 1rem; }++.notice, .error { padding: 0.5rem 0.75rem; background: #fff3cd; border: 1px solid #e0c060; border-radius: 6px; }++++/* Widgets: one column on a phone, more as the screen allows. */++.widgets { display: grid; gap: 0.75rem; grid-template-columns: 1fr; }++@media (min-width: 40rem) { .widgets { grid-template-columns: repeat(2, 1fr); } }++@media (min-width: 64rem) { .widgets { grid-template-columns: repeat(3, 1fr); } }++.widget { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem; }++.widget-camera { grid-column: 1 / -1; }++.camera-placeholder { aspect-ratio: 16 / 9; display: grid; place-items: center; background: #222; color: #ccc; border-radius: 6px; }++++/* Hold-to-move pads. */++.pad { display: grid; gap: 0.5rem; }++.axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 0.5rem; align-items: center; }++.axis .hold { min-height: 64px; font-size: 1.5rem; user-select: none; -webkit-user-select: none; touch-action: none; }++.axis-name { color: #666; font-size: 0.85rem; }++.hold.held { background: #1a73e8; color: #fff; }++.widget-drive .stop { width: 100%; margin-top: 0.5rem; }++.readout { font-variant-numeric: tabular-nums; font-size: 1.2rem; }++meter { width: 100%; height: 1.25rem; }++++/* Robots and pairing pages. */++.robots { list-style: none; padding: 0; }++.robots li { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }++.robots a { display: inline-flex; align-items: center; min-height: 44px; }++.add-robot { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }++.installer { overflow-x: auto; padding: 0.75rem; background: #111; color: #eee; border-radius: 6px; }++.code { font-family: ui-monospace, monospace; font-size: 1.3rem; letter-spacing: 0.1em; }nnew file mode 100644@@@ -0,0 +1,248 @@++'use strict';++++/**++ * The panel client (plain ES, no build step, no framework). The server rendered every widget from the robot's++ * profile (server/web/render.js); this wires them to wss://<host>/control with the session cookie: `join`,++ * paint the robot's state, and send each command with a fresh id (an id is an idempotency key, so a re-send++ * with the same one would never reach the robot).++ *++ * A held control (data-hold, data-tone) is re-sent every holdResendMs while it is held, and releasing it sends++ * the stop (a zero drive, a zero axis, a silent tone) at once: the device's deadline is the last line of++ * defence, never the way a panel stops a robot. Losing the socket, the window or the page releases everything.++ *++ * Also on the pairing page: [data-copy] copies the installer command.++ */++(() => {++ for (const b of document.querySelectorAll('[data-copy]')) {++ b.addEventListener('click', () => {++ const src = document.querySelector('[data-copy-source]');++ if (src && navigator.clipboard) navigator.clipboard.writeText(src.textContent).then(() => { b.dataset.copied = 'true'; }, () => {});++ });++ }++++ const main = document.getElementById('panel');++ if (!main) return;++ const ROBOT_ID = main.dataset.robotId;++ const HOLD_MS = Number(main.dataset.holdMs) || 150;++ let allowed = JSON.parse(main.dataset.allowed || '[]');++ let ws = null;++ let joined = false;++ let retry = 0;++ let gone = false; // refused for good (no access): no reconnect++++ const $ = (sel, root = document) => root.querySelector(sel);++ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];++ const kindOf = (el) => { const s = el.closest('[data-command]'); return s ? JSON.parse(s.dataset.command).kind : null; };++ const newId = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;++++ function notice(text) {++ const n = $('[data-notice]');++ if (!n) return;++ n.textContent = text || '';++ n.hidden = !text;++ }++ function raw(frame) {++ if (!ws || ws.readyState !== WebSocket.OPEN) return false;++ ws.send(JSON.stringify({ v: 1, ...frame }));++ return true;++ }++ function send(kind, value) {++ if (!joined || !allowed.includes(kind)) return false;++ return raw({ type: 'command', id: newId(), kind, value });++ }++++ // ── Held controls ─────────────────────────────────────────────────────────────────────────────++ // kind → { els: Map<element, value>, timer }: several held axes of one kind are sent as one value.++ const holds = new Map();++ const combined = (h) => Object.assign({}, ...h.els.values());++ function pressAxis(el) {++ const kind = kindOf(el);++ if (!kind || el.disabled) return;++ let h = holds.get(kind);++ if (!h) { h = { els: new Map(), timer: null }; holds.set(kind, h); }++ if (h.els.has(el)) return;++ h.els.set(el, JSON.parse(el.dataset.hold));++ el.classList.add('held');++ send(kind, combined(h));++ if (!h.timer) h.timer = setInterval(() => send(kind, combined(h)), HOLD_MS);++ }++ function releaseAxis(el) {++ const kind = kindOf(el);++ const h = kind && holds.get(kind);++ if (!h || !h.els.has(el)) return;++ const released = h.els.get(el);++ h.els.delete(el);++ el.classList.remove('held');++ const zero = {};++ for (const axis of Object.keys(released)) zero[axis] = 0;++ send(kind, { ...zero, ...combined(h) });++ if (!h.els.size) { clearInterval(h.timer); holds.delete(kind); }++ }++ // A held tone: { el → timer }.++ const tones = new Map();++ function pressTone(el) {++ if (el.disabled || tones.has(el)) return;++ const value = { name: el.dataset.tone, value: { note: 'A4' } };++ el.classList.add('held');++ send('actuator', value);++ tones.set(el, setInterval(() => send('actuator', value), HOLD_MS));++ }++ function releaseTone(el) {++ if (!tones.has(el)) return;++ clearInterval(tones.get(el));++ tones.delete(el);++ el.classList.remove('held');++ send('actuator', { name: el.dataset.tone, value: null });++ }++ function releaseAll() {++ for (const h of [...holds.values()]) for (const el of [...h.els.keys()]) releaseAxis(el);++ for (const el of [...tones.keys()]) releaseTone(el);++ }++++ function holdable(el, press, release) {++ el.addEventListener('pointerdown', (e) => { e.preventDefault(); try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ } press(el); });++ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, () => release(el));++ el.addEventListener('contextmenu', (e) => e.preventDefault());++ }++ $$('[data-hold]').forEach((el) => holdable(el, pressAxis, releaseAxis));++ $$('[data-tone]').forEach((el) => holdable(el, pressTone, releaseTone));++ window.addEventListener('blur', releaseAll);++ document.addEventListener('visibilitychange', () => { if (document.hidden) releaseAll(); });++ window.addEventListener('pagehide', releaseAll);++++ // Keyboard: arrows (and WASD) hold the drive's buttons; Q/E rotate; Space stops.++ const KEYS = {++ ArrowUp: [['throttle', 'y'], 1], KeyW: [['throttle', 'y'], 1], ArrowDown: [['throttle', 'y'], 0], KeyS: [['throttle', 'y'], 0],++ ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],++ KeyQ: [['rotation'], 0], KeyE: [['rotation'], 1],++ };++ function keyButton(code) {++ const k = KEYS[code];++ if (!k) return null;++ for (const axis of k[0]) { const btns = $$(`.widget-drive [data-axis="${axis}"] [data-hold]`); if (btns.length) return btns[k[1]]; }++ return null;++ }++ const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);++ document.addEventListener('keydown', (e) => {++ if (typing(e)) return;++ if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }++ const b = keyButton(e.code);++ if (b) { e.preventDefault(); if (!e.repeat) pressAxis(b); }++ });++ document.addEventListener('keyup', (e) => { const b = keyButton(e.code); if (b) releaseAxis(b); });++++ function stopDrive() {++ const h = holds.get('drive');++ if (h) for (const el of [...h.els.keys()]) releaseAxis(el);++ send('drive', {}); // every declared axis absent → 0++ }++ $$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));++++ // ── One-shot controls ─────────────────────────────────────────────────────────────────────────++ /** At most one send per HOLD_MS while a slider moves; the last value is always sent. */++ function throttled(fn) {++ let last = 0; let timer = null; let pending = null;++ return (...args) => {++ pending = args;++ const wait = HOLD_MS - (Date.now() - last);++ if (wait <= 0) { last = Date.now(); fn(...pending); pending = null; return; }++ if (!timer) timer = setTimeout(() => { timer = null; last = Date.now(); if (pending) fn(...pending); pending = null; }, wait);++ };++ }++ $$('[data-actuator]').forEach((el) => {++ const push = throttled(() => send('actuator', { name: el.dataset.actuator, value: Number(el.value) }));++ el.addEventListener('input', push);++ });++ const rgb = (hex) => ({ r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16) });++ $$('[data-rgb]').forEach((el) => {++ const push = throttled(() => send('actuator', { name: el.dataset.rgb, value: rgb(el.value) }));++ el.addEventListener('input', push);++ });++ $$('[data-rgb-off]').forEach((el) => el.addEventListener('click', () => send('actuator', { name: el.dataset.rgbOff, value: null })));++ $$('[data-bool]').forEach((el) => el.addEventListener('change', () => send('actuator', { name: el.dataset.bool, value: el.checked })));++ $$('form[data-say]').forEach((f) => f.addEventListener('submit', (e) => {++ e.preventDefault();++ const text = f.elements.text.value.trim();++ if (text && send('say', { text })) f.elements.text.value = '';++ }));++ $$('form[data-display]').forEach((f) => f.addEventListener('submit', (e) => {++ e.preventDefault();++ const face = f.elements.face ? f.elements.face.value : '';++ const text = f.elements.text ? f.elements.text.value.trim() : '';++ if (face) send('display', { face });++ else if (text) send('display', { text });++ }));++ $$('[data-estop]').forEach((el) => el.addEventListener('click', () => { releaseAll(); raw({ type: 'estop' }); }));++ $$('[data-estop-clear]').forEach((el) => el.addEventListener('click', () => raw({ type: 'estop_clear' })));++++ // ── State ─────────────────────────────────────────────────────────────────────────────────────++ function enableControls() {++ for (const section of $$('[data-command]')) {++ const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);++ for (const el of $$('button, input, select', section)) el.disabled = !ok;++ }++ }++ function readout(v) {++ 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(' · ') || '—';++ }++ function paint(state) {++ if (!state) return;++ const latched = !!(state.estop && state.estop.latched);++ const banner = $('[data-estop-banner]');++ if (banner) banner.dataset.latched = String(latched);++ const label = $('[data-estop-state]');++ if (label) label.textContent = latched ? 'E-stop latched' : 'E-stop clear';++ const clear = $('[data-estop-clear]');++ if (clear) clear.hidden = !latched;++ if (latched) releaseAll();++ const online = $('[data-online]');++ if (online) online.textContent = state.online ? 'online' : 'offline';++ main.dataset.online = String(!!state.online);++ for (const el of $$('[data-latency]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';++ for (const el of $$('[data-battery]')) el.value = state.battery != null ? state.battery : 0;++ for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';++ const sensors = state.telemetry && state.telemetry.sensors;++ for (const el of $$('[data-telemetry]')) {++ const key = el.dataset.telemetry.replace(/^sensor\./, '');++ el.textContent = readout(sensors ? (sensors[key] !== undefined ? sensors[key] : sensors) : null);++ }++ }++++ function onFrame(m) {++ if (m.type === 'joined') {++ joined = true; retry = 0;++ allowed = m.allowed_commands || [];++ const r = $('[data-role-label]');++ if (r) r.textContent = m.role;++ notice('');++ enableControls();++ paint(m.state);++ } else if (m.type === 'robot_state') {++ paint(m.state);++ } else if (m.type === 'command_result') {++ if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);++ } else if (m.type === 'error') {++ if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }++ notice(m.detail || m.code);++ }++ }++++ function connect() {++ ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`);++ ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });++ ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };++ ws.onclose = (e) => {++ releaseAll();++ joined = false;++ enableControls();++ const online = $('[data-online]');++ if (online) online.textContent = 'disconnected';++ if (e.code === 4002) { notice('Sign in again to drive this robot.'); return; }++ if (!gone) setTimeout(connect, Math.min(10000, 500 * 2 ** retry++));++ };++ }++ enableControls();++ connect();++})();@@@ -7,7 +7,9 @@* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)* /api/v1/* the API (service tokens + user tokens, see api/v1.js)* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)-- * GET / a one-line text placeholder; the panel and the site are designed separately++ * GET / a one-line text placeholder++ * /robots, /pair/:id, /panel/:id the signed-in pages (web/routes.js); a `sim` robot is driven by the++ * in-process simulator (sim/index.js), which server/index.js starts for existing ones at boot** createApp({ config, db, valkey, registry, keys, hub, outbox, usage, nodes, openre, now, fetchImpl, log }) — everything* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket@@@ -26,6 +28,7 @@ const { createBotOutbox } = require('./events/outbox');const { createUsageRelay } = require('./jobs/metering');const { createDomain } = require('./domain');const { createRealtime } = require('./realtime');++const { createSimulator } = require('./sim');const { createApiAuth } = require('./api/auth');const { v1Router } = require('./api/v1');const { createActorLimits } = require('./api/actor-limits');@@@ -53,6 +56,7 @@ function createApp(opts = {}) {const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });hub.bindDomain(domain);++ const sim = opts.sim || createSimulator({ config, domain, hub, now, log });const apiAuth = createApiAuth({ config, keys, userAuth });const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });@@@ -85,7 +89,7 @@ function createApp(opts = {}) {app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));app.use(viewerMiddleware(userAuth));-- app.use(createWebRoutes(config));++ app.use(createWebRoutes(config, { domain, sim, log }));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -104,7 +108,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, userAuth, metrics });++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, sim, userAuth, metrics });return app;}@@@ -31,7 +31,7 @@ async function main() {const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });-- const { domain, keys, outbox, usage, hub } = app.locals;++ const { domain, keys, outbox, usage, hub, sim } = app.locals;keys.start();const timers = [];@@@ -50,6 +50,8 @@ async function main() {});server.keepAliveTimeout = 65_000;server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));++ // The robots whose profile's driver is `sim` get their in-process device back (server/sim).++ sim.startAll().then((n) => n && console.log(`[Bot] simulator: ${n} sim robots`), (e) => console.warn(`[Bot] simulator: ${e.message}`));// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits. The stop steps@@@ -66,6 +68,7 @@ async function main() {stop: [() => timers.forEach(clearInterval),() => keys.stop(),++ () => sim.stopAll(),() => hub.close().catch(() => {}),() => outbox.stop(),() => usage.stop(),@@@ -80,6 +80,32 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nsocket.destroy();}++ /**++ * An in-process device (server/sim): no socket and no credential, so it is attached here and never through++ * /device. `device` is the device as the hub sees it ({ id, robot_ids, kind }); `onFrame(frame)` gets what Bot++ * sends it, on a later tick as a real socket would; its frames come back through `deliver`, the same path a++ * socket's frames take. A real device online for the same robot is preferred over a simulated one.++ */++ function attachSim(device, { onFrame, onClose = () => {} }) {++ if (closed) return null;++ const ws = {++ readyState: OPEN,++ send(data) { setImmediate(() => { if (ws.readyState === OPEN) onFrame(JSON.parse(data)); }); },++ close() { if (ws.readyState !== OPEN) return; ws.readyState = 3; onClose(); },++ };++ const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };++ attachDevice(conn, device);++ sendHello(conn);++ const ready = sendConfig(conn).then(() => bringOnline(conn)).catch((e) => log.warn(`[Bot] simulator ${device.id}: ${e.message}`));++ return { deliver: (frame) => (ws.readyState === OPEN ? onDeviceFrame(conn, JSON.stringify(frame)) : undefined), ready };++ }++ function detachSim(deviceId) {++ const c = deviceConns.get(deviceId);++ if (!c || !c.sim) return;++ c.ws.close();++ dropDevice(c);++ }++// ── Device side ───────────────────────────────────────────────────────────────────────────────async function onDeviceSocket(ws, req) {// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled@@@ -458,8 +484,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n// ── State and helpers ─────────────────────────────────────────────────────────────────────────function deviceForRobot(robotId) {-- for (const conn of deviceConns.values()) if (conn.online && deviceRobotIds(conn.device).includes(robotId)) return conn;-- return null;++ let sim = null;++ for (const conn of deviceConns.values()) {++ if (!conn.online || !deviceRobotIds(conn.device).includes(robotId)) continue;++ if (!conn.sim) return conn;++ sim = sim || conn;++ }++ return sim;}function isOnline(deviceId) { const c = deviceConns.get(deviceId); return !!(c && c.online); }function sendToDevice(deviceId, fields) { const c = deviceConns.get(deviceId); return c ? sendFrame(c, fields.type, fields) : false; }@@@ -531,11 +562,12 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, nreturn {bindDomain(d) { domain = d; },-- handleUpgrade,++ handleUpgrade, attachSim, detachSim,isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop,jobStdout: (jobId) => jobs.stdout(jobId),-- onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online) n++; return n; },-- devices() { return [...deviceConns.values()].map((c) => ({ device_id: c.device.id, online: c.online })); },++ // Real devices only: a simulator is not a machine anyone runs.++ onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },++ devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },async close() {closed = true;for (const t of timers) clearInterval(t);nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++++/**++ * The simulated device (ADR-043 decision 3): an in-process stand-in for a robot whose profile's driver is++ * `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the++ * hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers++ * `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself++ * at a motion command's `deadline_ms` unless a newer command arrived first (the Node's deadman).++ *++ * attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent) → true/false++ * startAll() every existing sim robot (boot)++ * stop(robotId), stopAll(), running(robotId)++ *++ * 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.++ */++const { listProfiles, getProfile } = require('../profiles');++++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++++function createSimulator({ config, domain, hub, now = () => Date.now(), log = console }) {++ const sims = new Map(); // robot_id → sim++ const telemetryMs = Math.ceil(1000 / Math.max(1, config.control.telemetryHz)) + 20; // just above the hub's sample window++ const heartbeatMs = Math.max(50, Math.floor(config.device.heartbeatMs / 2));++ const every = (ms, fn) => { const t = setInterval(() => { try { fn(); } catch (e) { log.warn(`[Bot] simulator: ${e.message}`); } }, ms); if (t.unref) t.unref(); return t; };++++ 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 };++ 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)));++ return sim;++ }++ function connect(sim) {++ sim.link = hub.attachSim(sim.device, { onFrame: (f) => onFrame(sim, f), onClose: () => halt(sim) });++ if (sim.link) sim.link.ready.then(() => telemetry(sim));++ }++++ function halt(sim) {++ if (sim.stopTimer) clearTimeout(sim.stopTimer);++ sim.stopTimer = null;++ for (const axis of Object.keys(sim.drive)) sim.drive[axis] = 0;++ }++++ function onFrame(sim, f) {++ if (f.type === 'config') { sim.estop = !!f.estop_latched; if (sim.estop) halt(sim); return; }++ if (f.type === 'estop') { sim.estop = !!f.latched; if (sim.estop) halt(sim); return; }++ if (f.type !== 'command') return;++ const reply = (fields) => sim.link && sim.link.deliver({ id: f.id, ...fields });++ if (f.kind === 'halt') { halt(sim); return reply({ type: 'ack' }); }++ if (sim.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });++ if (f.kind === 'drive') {++ sim.drive = { ...f.value };++ if (sim.stopTimer) clearTimeout(sim.stopTimer);++ // 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 {++ sim.other[f.kind] = f.value;++ }++ return reply({ type: 'ack' });++ }++++ function telemetry(sim) {++ if (!sim.link) return;++ 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 },++ });++ }++++ function tick(sim) {++ 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 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);++ if (++sim.ticks % EXISTS_EVERY === 0) {++ domain.robots.get(sim.robotId).then((r) => { if (!r) stop(sim.robotId); }, () => {});++ }++ }++++ function stop(robotId) {++ const sim = sims.get(robotId);++ if (!sim) return;++ sims.delete(robotId);++ for (const t of sim.timers) clearInterval(t);++ halt(sim);++ sim.link = null;++ hub.detachSim(sim.device.id);++ }++++ /** Start the robot's simulator when its profile's driver is `sim`; anything else is left alone. */++ async function attach(robotOrId) {++ const robot = typeof robotOrId === 'string' ? await domain.robots.get(robotOrId) : robotOrId;++ if (!robot) return false;++ if (sims.has(robot.id)) return true;++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ if (!isSimProfile(row && row.profile)) return false;++ start(robot);++ return true;++ }++++ async function startAll() {++ const ids = (await listProfiles(domain.db)).filter((r) => isSimProfile(r.profile)).map((r) => r.id);++ if (!ids.length) return 0;++ const robots = await domain.db.many('SELECT * FROM robots WHERE profile_id = ANY($1)', [ids]);++ for (const robot of robots) await attach(robot);++ return robots.length;++ }++++ function stopAll() { for (const id of [...sims.keys()]) stop(id); }++++ return { attach, startAll, stop, stopAll, running: (robotId) => sims.has(robotId), isSimProfile };++}++++module.exports = { createSimulator, isSimProfile };nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++++/**++ * The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).++ * The panel is a function of the robot's profile (ADR-043 decision 7): every widget comes from++ * `profile.widgets` and every control's name, shape and range from `profile.commands` — no robot-specific++ * code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client++ * (public/panel.js) reads what it needs from data-* attributes.++ *++ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs })++ * renderWidget(widget, { profile, allowed_commands })++ * renderRobotsPage({ robots, profiles, error, values })++ * renderPairingPage({ robot, pairing, profile })++ * esc(value)++ */++const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);++/** A data-* attribute's JSON, escaped for a double-quoted attribute. */++const data = (v) => esc(JSON.stringify(v));++++/** The e-stop is the owner's and an operator's; only the owner clears it (the gate enforces both). */++const ESTOP_ROLES = new Set(['owner', 'operator']);++/** Hold-to-move arrows by axis: [negative, positive]. */++const AXIS_ARROWS = { throttle: ['▼', '▲'], y: ['▼', '▲'], steer: ['◀', '▶'], x: ['◀', '▶'], rotation: ['⟲', '⟳'], pan: ['◀', '▶'], tilt: ['▼', '▲'], zoom: ['−', '+'] };++const POLICIES = ['private', 'invite', 'queue'];++++function page(title, body, { scripts = [] } = {}) {++ return `<!doctype html>++<html lang="en">++<head>++<meta charset="utf-8">++<meta name="viewport" content="width=device-width, initial-scale=1">++<title>${esc(title)} — OpenVibe.Bot</title>++<link rel="stylesheet" href="/panel/panel.css">++</head>++<body>++${body}++${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}++</body>++</html>++`;++}++++const disabledUnless = (ok) => (ok ? '' : ' disabled');++++/** Hold buttons, one pair per axis: pressed sends the axis at its end of the range, released sends 0. */++function axisButtons(kind, axes, enabled) {++ return Object.entries(axes).map(([axis, [lo, hi]]) => {++ const [neg, pos] = AXIS_ARROWS[axis] || ['−', '+'];++ return `<div class="axis" data-axis="${esc(axis)}">`++ + `<button type="button" class="hold" data-hold="${data({ [axis]: lo })}" aria-label="${esc(`${axis} ${lo}`)}"${disabledUnless(enabled)}>${esc(neg)}</button>`++ + `<span class="axis-name">${esc(axis)}</span>`++ + `<button type="button" class="hold" data-hold="${data({ [axis]: hi })}" aria-label="${esc(`${axis} ${hi}`)}"${disabledUnless(enabled)}>${esc(pos)}</button>`++ + '</div>';++ }).join('');++}++++/** One control per actuator the widget drives, by the actuator's declared type. */++function actuatorControl(name, a, enabled) {++ const off = disabledUnless(enabled);++ if (a.type === 'number') {++ const [lo, hi] = a.range;++ const start = Math.min(hi, Math.max(lo, 0));++ return `<label class="control">${esc(name)} <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${off}></label>`;++ }++ if (a.type === 'rgb') {++ return `<div class="control"><label>${esc(name)} <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`++ + ` <button type="button" data-rgb-off="${esc(name)}"${off}>Off</button></div>`;++ }++ if (a.type === 'tone') return `<button type="button" class="hold" data-tone="${esc(name)}"${off}>${esc(name)}</button>`;++ return `<label class="control">${esc(name)} <input type="checkbox" data-bool="${esc(name)}"${off}></label>`;++}++++function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {++ const commands = profile.commands || {};++ const kind = w.command ? w.command.kind : null;++ const enabled = !!kind && allowed_commands.includes(kind);++ const label = w.label || w.type;++ let inner;++ switch (w.type) {++ case 'drive':++ case 'ptz': {++ const axes = (commands[kind] && commands[kind].axes) || {};++ inner = `<div class="pad">${axisButtons(kind, axes, enabled)}</div>`;++ if (w.type === 'drive') inner += `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>Stop</button>`;++ break;++ }++ case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {++ const names = (commands.actuator && commands.actuator.names) || {};++ inner = (w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled)).join('');++ break;++ }++ case 'speaker': {++ const max = commands.say ? commands.say.max_chars : 200;++ inner = `<form class="control" data-say><input type="text" name="text" maxlength="${esc(max)}" aria-label="${esc(label)}"${disabledUnless(enabled)}>`++ + ` <button type="submit"${disabledUnless(enabled)}>Say</button></form>`;++ break;++ }++ case 'display': {++ const d = commands.display || { modes: [] };++ let fields = '';++ if (d.modes.includes('face')) fields += `<select name="face" aria-label="face"${disabledUnless(enabled)}><option value="">—</option>${(d.faces || []).map((f) => `<option value="${esc(f)}">${esc(f)}</option>`).join('')}</select>`;++ if (d.modes.includes('text')) fields += ` <input type="text" name="text" maxlength="${esc(d.max_chars)}" aria-label="text"${disabledUnless(enabled)}>`;++ inner = `<form class="control" data-display>${fields} <button type="submit"${disabledUnless(enabled)}>Show</button></form>`;++ break;++ }++ case 'telemetry':++ inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;++ break;++ case 'battery':++ inner = '<meter min="0" max="1" low="0.25" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output>';++ break;++ case 'latency':++ inner = '<output class="readout" data-latency>—</output>';++ break;++ case 'camera':++ // R8b brings the viewer URL; until then the slot is held.++ inner = '<div class="camera-placeholder" data-camera>Video is not connected yet.</div>';++ break;++ default:++ inner = '';++ }++ const cmd = w.command ? ` data-command="${data(w.command)}"` : '';++ return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;++}++++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150 }) {++ const latched = !!(robot.estop && robot.estop.latched);++ const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))++ .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))++ .map(({ w }) => renderWidget(w, { profile, allowed_commands }));++ const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>Stop</button>` : '';++ const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';++ const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}" role="status" aria-live="assertive">++<span class="estop-state" data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span>${estop}${clear}++</header>++<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">++<h1>${esc(robot.name)}</h1>++<p class="meta"><span class="profile">${esc(profile.name || robot.profile_id)}</span> · <span class="role" data-role-label>${esc(role)}</span> · <span class="online" data-online>connecting…</span></p>++<p class="notice" data-notice role="alert" hidden></p>++<div class="widgets">++${widgets.join('\n')}++</div>++</main>`;++ return page(robot.name, body, { scripts: ['/panel/panel.js'] });++}++++function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {++ const names = new Map(profiles.map((p) => [p.id, p.name]));++ const list = robots.length++ ? `<ul class="robots">${robots.map((r) => `<li><a href="/panel/${esc(r.id)}">${esc(r.name)}</a> <span class="profile">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="policy">${esc(r.access_policy)}</span> <a class="pair" href="/pair/${esc(r.id)}">Pair a device</a></li>`).join('')}</ul>`++ : '<p class="empty">No robots yet.</p>';++ const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;++ const form = `<form class="add-robot" method="post" action="/robots">++<h2>Add a robot</h2>++${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}++<label>Name <input type="text" name="name" maxlength="80" required value="${esc(values.name || '')}"></label>++<label>Robot <select name="profile_id">${profiles.map((p) => option(p.id, p.name, p.id === values.profile_id)).join('')}</select></label>++<label>Who may drive <select name="access_policy">${POLICIES.map((p) => option(p, p, p === (values.access_policy || 'private'))).join('')}</select></label>++<button type="submit">Add</button>++</form>`;++ return page('Your robots', `<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);++}++++function renderPairingPage({ robot, pairing, profile = null }) {++ const body = `<main class="page pairing">++<h1>Pair ${esc(robot.name)}</h1>++<p>Run this on the machine that serves the robot${profile ? ` (${esc(profile.name)})` : ''}:</p>++<pre class="installer"><code data-copy-source>${esc(pairing.installer)}</code></pre>++<button type="button" data-copy>Copy the command</button>++<p>Or enter the pairing code <strong class="code">${esc(pairing.code)}</strong>, valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>.</p>++<p class="waiting">Waiting for the device. <a href="/panel/${esc(robot.id)}">Open the panel</a></p>++</main>`;++ return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });++}++++module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc };@@@ -1,19 +1,120 @@'use strict';/**-- * The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and-- * the site are designed separately), and `/install`, a 302 to OpenVibe.Node's installer script (the-- * one-paste command, ADR-043). No pages, no CSS, no copy beyond this.++ * The non-API surface: a one-line text placeholder at `/` for the health of the process, `/install`, a 302 to++ * OpenVibe.Node's installer script (the one-paste command, ADR-043), and the signed-in pages (the five-minute++ * path and the profile-rendered panel, plan T15 step 3):++ *++ * GET /robots the signed-in person's robots and the "add a robot" form++ * POST /robots add one (form post) → 303 to its panel (a `sim` robot) or its pairing page++ * GET /pair/:id the owner only: a fresh pairing code and the installer command++ * GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile++ * GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)++ *++ * Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).++ * Pages are never cached: a pairing page carries a live code.*/++const path = require('path');const express = require('express');++const { getProfile, listProfiles } = require('../profiles');++const { BotError } = require('../util');++const { renderPanel, renderRobotsPage, renderPairingPage } = require('./render');const VERSION = require('../../package.json').version;--function createWebRoutes(config) {++const PUBLIC = path.join(__dirname, '..', '..', 'public');++const HOLD_RESEND_MS = 150;++++function createWebRoutes(config, { domain = null, sim = null, log = console } = {}) {const r = express.Router();r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));++ if (!domain) return r;++++ const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.type(type).sendFile(path.join(PUBLIC, file)); };++ r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));++ r.get('/panel/panel.css', asset('panel.css', 'text/css'));++++ const requireUser = (req) => {++ if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');++ return req.viewer;++ };++ const profileOf = async (robot) => {++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ return row ? row.profile : null;++ };++ const startSim = (robot) => (sim ? sim.attach(robot).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; }) : Promise.resolve(false));++ /** A page handler: 401 → sign in and come back, 403/404 → a plain answer, anything else → the app's 500. */++ const page = (fn) => async (req, res, next) => {++ res.setHeader('Cache-Control', 'no-store');++ try { await fn(req, res); } catch (e) {++ if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (e.status === 403 || e.status === 404) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);++ return next(e);++ }++ return undefined;++ };++ // A form post must come from this site (the session cookie is SameSite=Lax; this is the second fence).++ const sameOrigin = (req) => {++ const origin = req.headers.origin;++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin || new URL(origin).host === req.headers.host; } catch { return false; }++ };++++ async function robotsPage(req, res, me, extra = {}) {++ const robots = (await domain.robots.list(me.subject)).map(domain.present.robot);++ const profiles = (await listProfiles(domain.db)).map((p) => p.profile);++ res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));++ }++++ r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));++++ r.post('/robots', express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const body = req.body || {};++ const values = { name: typeof body.name === 'string' ? body.name : '', profile_id: String(body.profile_id || ''), access_policy: String(body.access_policy || 'private') };++ let robot;++ try {++ ({ robot } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));++ } catch (e) {++ if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }++ throw e;++ }++ // A simulated robot needs no machine: straight to its panel.++ return res.redirect(303, (await startSim(robot)) ? `/panel/${robot.id}` : `/pair/${robot.id}`);++ }));++++ r.get('/pair/:id', page(async (req, res) => {++ const me = requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may pair a device');++ const pairing = await domain.pairing.create(robot.id, me.subject, undefined, robot);++ res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));++ }));++++ r.get('/panel/:id', page(async (req, res) => {++ const me = requireUser(req);++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ // As the /control join decides: a member's role, or a place in the queue on a `queue` robot.++ let role = await domain.members.roleOf(robot.id, me.subject);++ if (!role && robot.access_policy === 'queue') role = 'queue';++ if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');++ const profile = await profileOf(robot);++ if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');++ await startSim(robot);++ const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);++ res.type('html').send(renderPanel({++ robot: domain.present.robot(robot), profile, role,++ allowed_commands: domain.control.allowedFor(robot, role, profile),++ // A held control is re-sent well inside the device's deadline, so it never stops between two frames.++ holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),++ }));++ }));++return r;}nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++// The five-minute path and the profile-rendered panel (plan T15 step 3): every profile's widgets render from++// the profile alone, the e-stop is the owner's and an operator's, the signed-in pages answer as the gate does,++// and a `sim` robot is driven by the in-process simulator (telemetry, acks, the deadman).++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { loadProfiles } = require('../server/profiles');++const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc } = require('../server/web/render');++const { DEFAULT_ALLOW } = require('../server/domain');++++const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });++const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profile.commands[k]), ...(DEFAULT_ALLOW[role] ? ['halt'] : [])];++++(async () => {++ const profiles = [...loadProfiles().values()];++++ await check('every profile renders each of its widgets, controls enabled only for an allowed kind', () => {++ assert.ok(profiles.length >= 4);++ for (const p of profiles) {++ const html = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner') });++ for (const w of p.widgets) assert.ok(html.includes(`data-widget="${w.type}"`), `${p.id}: no ${w.type} widget`);++ for (const w of p.widgets.filter((x) => x.command)) {++ assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);++ assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);++ }++ assert.ok(html.includes('<script src="/panel/panel.js" defer></script>'));++ assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');++ }++ });++++ await check('the e-stop is shown to the owner and an operator; only the owner gets the clear', () => {++ const p = profiles.find((x) => x.id === 'sim.rover');++ const html = (role) => renderPanel({ robot: robotFor(p, { estop: { latched: true } }), profile: p, role, allowed_commands: allowedFor(p, role) });++ assert.ok(html('owner').includes('data-estop>') && html('owner').includes('data-estop-clear'));++ assert.ok(html('operator').includes('data-estop>') && !html('operator').includes('data-estop-clear'));++ for (const role of ['viewer', 'queue']) {++ 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/);++ });++++ await check('every value is escaped', () => {++ const p = profiles.find((x) => x.id === 'sim.rover');++ const evil = '<img src=x onerror=alert(1)>"\'&';++ const panel = renderPanel({ robot: robotFor(p, { name: evil, id: 'rob_"x' }), profile: { ...p, name: evil, widgets: [{ ...p.widgets[0], label: evil }] }, role: evil, allowed_commands: [] });++ const robots = renderRobotsPage({ robots: [{ id: 'rob_"x', name: evil, profile_id: evil, access_policy: 'private' }], profiles: [{ id: evil, name: evil }], error: evil, values: { name: evil } });++ const pair = renderPairingPage({ robot: { id: 'rob_"x', name: evil }, pairing: { code: evil, installer: evil, expires_at: evil }, profile: { name: evil } });++ for (const html of [panel, robots, pair]) {++ assert.ok(!html.includes('<img') && !html.includes('rob_"x'), 'an unescaped value');++ assert.ok(html.includes(esc(evil)));++ }++ });++++ const t = await boot();++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const carol = t.network.newUser('carol');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });++ const post = (p, user, form, headers = {}) => fetch(t.base + p, {++ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },++ });++ const control = (user) => t.ws('/control', { headers: 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('/, /robots.txt and /install are unchanged; the pages need a session', async () => {++ assert.match(await (await get('/')).text(), /^OpenVibe\.Bot .* — ok/);++ assert.strictEqual(await (await get('/robots.txt')).text(), 'User-agent: *\nDisallow: /\n');++ assert.strictEqual((await get('/install')).status, 302);++ for (const p of ['/robots', '/panel/rob_x', '/pair/rob_x']) {++ const r = await get(p);++ assert.strictEqual(r.status, 302, p);++ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);++ }++ const js = await get('/panel/panel.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ assert.match((await get('/panel/panel.css')).headers.get('content-type'), /text\/css/);++ });++++ let simId;++ await check('POST /robots (sim.rover) → its panel, 200 with the profile\'s widgets', async () => {++ const r = await post('/robots', alex, { name: 'Sim', profile_id: 'sim.rover', access_policy: 'private' });++ assert.strictEqual(r.status, 303);++ const m = /^\/panel\/(rob_[0-9A-Za-z]+)$/.exec(r.headers.get('location'));++ assert.ok(m, r.headers.get('location'));++ simId = m[1];++ const page = await get(`/panel/${simId}`, alex);++ assert.strictEqual(page.status, 200);++ assert.strictEqual(page.headers.get('cache-control'), 'no-store');++ const html = await page.text();++ for (const w of ['drive', 'camera', 'latency']) assert.ok(html.includes(`data-widget="${w}"`), w);++ assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"') && html.includes('data-estop>'));++ const list = await (await get('/robots', alex)).text();++ assert.ok(list.includes(`/panel/${simId}`));++ });++++ await check('a sim robot has telemetry within 1 s and acks a command; the deadman stops it', async () => {++ const { robot } = await t.robot(alex, { name: 'Sim 2' });++ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);++ assert.ok(await poll(() => t.hub.deviceState(`dev_sim_${robot.id}`).telemetry, 1000), 'no telemetry within 1 s');++ assert.strictEqual(t.hub.onlineCount(), 0, 'a simulator is not counted as a device');++ const c = await control(alex);++ c.send({ type: 'join', robot_id: robot.id });++ const joined = await c.waitFor((m) => m.type === 'joined');++ assert.ok(joined.allowed_commands.includes('drive'));++ c.send({ type: 'command', id: 'd1', kind: 'drive', value: { throttle: 0.5 } });++ const res = await c.waitFor((m) => m.type === 'command_result' && m.id === 'd1');++ assert.strictEqual(res.result, 'ack');++ // Held as the panel holds it (a fresh id every 100 ms): moving; let go: the deadman stops it by itself.++ const moving = (m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.drive.throttle === 0.5;++ let n = 0;++ const hold = setInterval(() => c.send({ type: 'command', id: `h${++n}`, kind: 'drive', value: { throttle: 0.5 } }), 100);++ try { assert.ok(await c.waitFor(moving), 'moving'); } finally { clearInterval(hold); }++ const after = c.messages.length;++ assert.ok(await c.waitFor((m) => c.messages.indexOf(m) >= after && m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.drive.throttle === 0), 'stopped at the deadline');++ c.close();++ t.app.locals.sim.stop(robot.id);++ assert.strictEqual(t.hub.isOnline(`dev_sim_${robot.id}`), false);++ });++++ await check('a signed-in person with no role gets 403 and join answers bot.not_an_operator', async () => {++ const r = await get(`/panel/${simId}`, bob);++ assert.strictEqual(r.status, 403);++ const c = await control(bob);++ c.send({ type: 'join', robot_id: simId });++ const e = await c.waitFor((m) => m.type === 'joined' || m.type === 'error');++ assert.strictEqual(e.type, 'error');++ assert.strictEqual(e.code, 'bot.not_an_operator');++ c.close();++ assert.strictEqual((await get('/panel/rob_nothing', bob)).status, 404);++ });++++ await check('a viewer member gets the panel with nothing to drive; a stranger on a queue robot gets the queue role', async () => {++ await t.call('POST', `/api/v1/robots/${simId}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });++ const html = await (await get(`/panel/${simId}`, carol)).text();++ assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"') && !html.includes('data-estop>'));++ const { robot } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });++ const q = await (await get(`/panel/${robot.id}`, bob)).text();++ assert.ok(q.includes('data-role="queue"'));++ });++++ await check('POST /robots (a hardware profile) → the owner\'s pairing page; others get 403', async () => {++ const r = await post('/robots', alex, { name: 'Arm', profile_id: 'adeept.adr036' });++ assert.strictEqual(r.status, 303);++ const loc = r.headers.get('location');++ assert.match(loc, /^\/pair\/rob_/);++ const page = await get(loc, alex);++ assert.strictEqual(page.status, 200);++ const html = await page.text();++ assert.match(html, /class="code">[0-9A-Z]{4}-[0-9A-Z]{4}</);++ assert.match(html, /--driver adeept</);++ assert.ok(html.includes('data-copy'));++ assert.strictEqual((await get(loc, bob)).status, 403);++ });++++ await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {++ const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });++ assert.strictEqual(bad.status, 422);++ assert.match(await bad.text(), /class="error"/);++ const unknown = await post('/robots', alex, { name: 'X', profile_id: 'nope' });++ assert.strictEqual(unknown.status, 422);++ const cross = await post('/robots', alex, { name: 'X', profile_id: 'sim.rover' }, { Origin: 'https://evil.test' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual((await post('/robots', null, { name: 'X', profile_id: 'sim.rover' })).status, 302);++ });++++ t.app.locals.sim.stopAll();++ await t.close();++ done();++})();
@@ -36,6 +36,7 @@
@@ -36,6 +36,7 @@
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
+ "signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record"
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record"
],
],
"updated": "2026-10-04"
"updated": "2026-10-04"
@@ -119,6 +119,23 @@ server {
@@ -119,6 +119,23 @@ server {
proxy_pass http://127.0.0.1:4630;
proxy_pass http://127.0.0.1:4630;
}
}
+ # ── Signed-in pages: robots, pairing and the panel (server/web/routes.js) ──
+ location = /robots {
+ limit_req zone=ovbot_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4630;
+ }
+ location ^~ /pair/ {
+ limit_req zone=ovbot_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4630;
+ }
+ location ^~ /panel/ {
+ limit_req zone=ovbot_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4630;
+ }
+
# ── Health and readiness: host-local probes only ────────
# ── Health and readiness: host-local probes only ────────
# The app treats /api/health and /api/ready as loopback-only (README); keep them off the public
# The app treats /api/health and /api/ready as loopback-only (README); keep them off the public
# vhost so a probe uses port 4630 directly. Exact matches outrank the /api/ prefix below.
# vhost so a probe uses port 4630 directly. Exact matches outrank the /api/ prefix below.
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+/* The panel's one sheet: mobile-first, every control at least 44 px, the e-stop banner always in view.
+ Functional only; the visual design is done separately. */
+*, *::before, *::after { box-sizing: border-box; }
+body { margin: 0; font: 16px/1.4 system-ui, sans-serif; color: #111; background: #f6f6f6; }
+main { padding: 1rem; max-width: 64rem; margin: 0 auto; }
+h1 { font-size: 1.4rem; margin: 0.5rem 0; }
+h2 { font-size: 1rem; margin: 0 0 0.5rem; }
+button, input, select { font: inherit; min-height: 44px; min-width: 44px; }
+button { padding: 0 0.9rem; border: 1px solid #888; border-radius: 6px; background: #fff; touch-action: manipulation; }
+button:disabled, input:disabled, select:disabled { opacity: 0.45; }
+input[type="range"] { width: 100%; }
+label, .control { display: block; margin: 0.4rem 0; }
+
+/* The e-stop banner: fixed to the top of every panel, red when latched. */
+.estop-banner { position: sticky; top: 0; z-index: 10; display: flex; gap: 0.75rem; align-items: center; justify-content: space-between;
+ padding: 0.5rem 1rem; background: #222; color: #fff; }
+.estop-banner[data-latched="true"] { background: #b00020; }
+.estop-banner .estop { min-height: 56px; min-width: 96px; font-weight: 700; background: #d50000; color: #fff; border-color: #fff; }
+.estop-banner .estop-clear { background: #fff; color: #111; }
+
+.meta { color: #555; margin: 0 0 1rem; }
+.notice, .error { padding: 0.5rem 0.75rem; background: #fff3cd; border: 1px solid #e0c060; border-radius: 6px; }
+
+/* Widgets: one column on a phone, more as the screen allows. */
+.widgets { display: grid; gap: 0.75rem; grid-template-columns: 1fr; }
+@media (min-width: 40rem) { .widgets { grid-template-columns: repeat(2, 1fr); } }
+@media (min-width: 64rem) { .widgets { grid-template-columns: repeat(3, 1fr); } }
+.widget { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 0.75rem; }
+.widget-camera { grid-column: 1 / -1; }
+.camera-placeholder { aspect-ratio: 16 / 9; display: grid; place-items: center; background: #222; color: #ccc; border-radius: 6px; }
+
+/* Hold-to-move pads. */
+.pad { display: grid; gap: 0.5rem; }
+.axis { display: grid; grid-template-columns: 1fr auto 1fr; gap: 0.5rem; align-items: center; }
+.axis .hold { min-height: 64px; font-size: 1.5rem; user-select: none; -webkit-user-select: none; touch-action: none; }
+.axis-name { color: #666; font-size: 0.85rem; }
+.hold.held { background: #1a73e8; color: #fff; }
+.widget-drive .stop { width: 100%; margin-top: 0.5rem; }
+.readout { font-variant-numeric: tabular-nums; font-size: 1.2rem; }
+meter { width: 100%; height: 1.25rem; }
+
+/* Robots and pairing pages. */
+.robots { list-style: none; padding: 0; }
+.robots li { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.5rem 0; border-bottom: 1px solid #ddd; }
+.robots a { display: inline-flex; align-items: center; min-height: 44px; }
+.add-robot { background: #fff; border: 1px solid #ddd; border-radius: 8px; padding: 1rem; }
+.installer { overflow-x: auto; padding: 0.75rem; background: #111; color: #eee; border-radius: 6px; }
+.code { font-family: ui-monospace, monospace; font-size: 1.3rem; letter-spacing: 0.1em; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -0,0 +1,248 @@
+'use strict';
+
+/**
+ * The panel client (plain ES, no build step, no framework). The server rendered every widget from the robot's
+ * profile (server/web/render.js); this wires them to wss://<host>/control with the session cookie: `join`,
+ * paint the robot's state, and send each command with a fresh id (an id is an idempotency key, so a re-send
+ * with the same one would never reach the robot).
+ *
+ * A held control (data-hold, data-tone) is re-sent every holdResendMs while it is held, and releasing it sends
+ * the stop (a zero drive, a zero axis, a silent tone) at once: the device's deadline is the last line of
+ * defence, never the way a panel stops a robot. Losing the socket, the window or the page releases everything.
+ *
+ * Also on the pairing page: [data-copy] copies the installer command.
+ */
+(() => {
+ for (const b of document.querySelectorAll('[data-copy]')) {
+ b.addEventListener('click', () => {
+ const src = document.querySelector('[data-copy-source]');
+ if (src && navigator.clipboard) navigator.clipboard.writeText(src.textContent).then(() => { b.dataset.copied = 'true'; }, () => {});
+ });
+ }
+
+ const main = document.getElementById('panel');
+ if (!main) return;
+ const ROBOT_ID = main.dataset.robotId;
+ const HOLD_MS = Number(main.dataset.holdMs) || 150;
+ let allowed = JSON.parse(main.dataset.allowed || '[]');
+ let ws = null;
+ let joined = false;
+ let retry = 0;
+ let gone = false; // refused for good (no access): no reconnect
+
+ const $ = (sel, root = document) => root.querySelector(sel);
+ const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
+ const kindOf = (el) => { const s = el.closest('[data-command]'); return s ? JSON.parse(s.dataset.command).kind : null; };
+ const newId = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
+
+ function notice(text) {
+ const n = $('[data-notice]');
+ if (!n) return;
+ n.textContent = text || '';
+ n.hidden = !text;
+ }
+ function raw(frame) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false;
+ ws.send(JSON.stringify({ v: 1, ...frame }));
+ return true;
+ }
+ function send(kind, value) {
+ if (!joined || !allowed.includes(kind)) return false;
+ return raw({ type: 'command', id: newId(), kind, value });
+ }
+
+ // ── Held controls ─────────────────────────────────────────────────────────────────────────────
+ // kind → { els: Map<element, value>, timer }: several held axes of one kind are sent as one value.
+ const holds = new Map();
+ const combined = (h) => Object.assign({}, ...h.els.values());
+ function pressAxis(el) {
+ const kind = kindOf(el);
+ if (!kind || el.disabled) return;
+ let h = holds.get(kind);
+ if (!h) { h = { els: new Map(), timer: null }; holds.set(kind, h); }
+ if (h.els.has(el)) return;
+ h.els.set(el, JSON.parse(el.dataset.hold));
+ el.classList.add('held');
+ send(kind, combined(h));
+ if (!h.timer) h.timer = setInterval(() => send(kind, combined(h)), HOLD_MS);
+ }
+ function releaseAxis(el) {
+ const kind = kindOf(el);
+ const h = kind && holds.get(kind);
+ if (!h || !h.els.has(el)) return;
+ const released = h.els.get(el);
+ h.els.delete(el);
+ el.classList.remove('held');
+ const zero = {};
+ for (const axis of Object.keys(released)) zero[axis] = 0;
+ send(kind, { ...zero, ...combined(h) });
+ if (!h.els.size) { clearInterval(h.timer); holds.delete(kind); }
+ }
+ // A held tone: { el → timer }.
+ const tones = new Map();
+ function pressTone(el) {
+ if (el.disabled || tones.has(el)) return;
+ const value = { name: el.dataset.tone, value: { note: 'A4' } };
+ el.classList.add('held');
+ send('actuator', value);
+ tones.set(el, setInterval(() => send('actuator', value), HOLD_MS));
+ }
+ function releaseTone(el) {
+ if (!tones.has(el)) return;
+ clearInterval(tones.get(el));
+ tones.delete(el);
+ el.classList.remove('held');
+ send('actuator', { name: el.dataset.tone, value: null });
+ }
+ function releaseAll() {
+ for (const h of [...holds.values()]) for (const el of [...h.els.keys()]) releaseAxis(el);
+ for (const el of [...tones.keys()]) releaseTone(el);
+ }
+
+ function holdable(el, press, release) {
+ el.addEventListener('pointerdown', (e) => { e.preventDefault(); try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ } press(el); });
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, () => release(el));
+ el.addEventListener('contextmenu', (e) => e.preventDefault());
+ }
+ $$('[data-hold]').forEach((el) => holdable(el, pressAxis, releaseAxis));
+ $$('[data-tone]').forEach((el) => holdable(el, pressTone, releaseTone));
+ window.addEventListener('blur', releaseAll);
+ document.addEventListener('visibilitychange', () => { if (document.hidden) releaseAll(); });
+ window.addEventListener('pagehide', releaseAll);
+
+ // Keyboard: arrows (and WASD) hold the drive's buttons; Q/E rotate; Space stops.
+ const KEYS = {
+ ArrowUp: [['throttle', 'y'], 1], KeyW: [['throttle', 'y'], 1], ArrowDown: [['throttle', 'y'], 0], KeyS: [['throttle', 'y'], 0],
+ ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
+ KeyQ: [['rotation'], 0], KeyE: [['rotation'], 1],
+ };
+ function keyButton(code) {
+ const k = KEYS[code];
+ if (!k) return null;
+ for (const axis of k[0]) { const btns = $$(`.widget-drive [data-axis="${axis}"] [data-hold]`); if (btns.length) return btns[k[1]]; }
+ return null;
+ }
+ const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
+ document.addEventListener('keydown', (e) => {
+ if (typing(e)) return;
+ if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }
+ const b = keyButton(e.code);
+ if (b) { e.preventDefault(); if (!e.repeat) pressAxis(b); }
+ });
+ document.addEventListener('keyup', (e) => { const b = keyButton(e.code); if (b) releaseAxis(b); });
+
+ function stopDrive() {
+ const h = holds.get('drive');
+ if (h) for (const el of [...h.els.keys()]) releaseAxis(el);
+ send('drive', {}); // every declared axis absent → 0
+ }
+ $$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));
+
+ // ── One-shot controls ─────────────────────────────────────────────────────────────────────────
+ /** At most one send per HOLD_MS while a slider moves; the last value is always sent. */
+ function throttled(fn) {
+ let last = 0; let timer = null; let pending = null;
+ return (...args) => {
+ pending = args;
+ const wait = HOLD_MS - (Date.now() - last);
+ if (wait <= 0) { last = Date.now(); fn(...pending); pending = null; return; }
+ if (!timer) timer = setTimeout(() => { timer = null; last = Date.now(); if (pending) fn(...pending); pending = null; }, wait);
+ };
+ }
+ $$('[data-actuator]').forEach((el) => {
+ const push = throttled(() => send('actuator', { name: el.dataset.actuator, value: Number(el.value) }));
+ el.addEventListener('input', push);
+ });
+ const rgb = (hex) => ({ r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16) });
+ $$('[data-rgb]').forEach((el) => {
+ const push = throttled(() => send('actuator', { name: el.dataset.rgb, value: rgb(el.value) }));
+ el.addEventListener('input', push);
+ });
+ $$('[data-rgb-off]').forEach((el) => el.addEventListener('click', () => send('actuator', { name: el.dataset.rgbOff, value: null })));
+ $$('[data-bool]').forEach((el) => el.addEventListener('change', () => send('actuator', { name: el.dataset.bool, value: el.checked })));
+ $$('form[data-say]').forEach((f) => f.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const text = f.elements.text.value.trim();
+ if (text && send('say', { text })) f.elements.text.value = '';
+ }));
+ $$('form[data-display]').forEach((f) => f.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const face = f.elements.face ? f.elements.face.value : '';
+ const text = f.elements.text ? f.elements.text.value.trim() : '';
+ if (face) send('display', { face });
+ else if (text) send('display', { text });
+ }));
+ $$('[data-estop]').forEach((el) => el.addEventListener('click', () => { releaseAll(); raw({ type: 'estop' }); }));
+ $$('[data-estop-clear]').forEach((el) => el.addEventListener('click', () => raw({ type: 'estop_clear' })));
+
+ // ── State ─────────────────────────────────────────────────────────────────────────────────────
+ function enableControls() {
+ for (const section of $$('[data-command]')) {
+ const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
+ for (const el of $$('button, input, select', section)) el.disabled = !ok;
+ }
+ }
+ function readout(v) {
+ 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(' · ') || '—';
+ }
+ function paint(state) {
+ if (!state) return;
+ const latched = !!(state.estop && state.estop.latched);
+ const banner = $('[data-estop-banner]');
+ if (banner) banner.dataset.latched = String(latched);
+ const label = $('[data-estop-state]');
+ if (label) label.textContent = latched ? 'E-stop latched' : 'E-stop clear';
+ const clear = $('[data-estop-clear]');
+ if (clear) clear.hidden = !latched;
+ if (latched) releaseAll();
+ const online = $('[data-online]');
+ if (online) online.textContent = state.online ? 'online' : 'offline';
+ main.dataset.online = String(!!state.online);
+ for (const el of $$('[data-latency]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
+ for (const el of $$('[data-battery]')) el.value = state.battery != null ? state.battery : 0;
+ for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';
+ const sensors = state.telemetry && state.telemetry.sensors;
+ for (const el of $$('[data-telemetry]')) {
+ const key = el.dataset.telemetry.replace(/^sensor\./, '');
+ el.textContent = readout(sensors ? (sensors[key] !== undefined ? sensors[key] : sensors) : null);
+ }
+ }
+
+ function onFrame(m) {
+ if (m.type === 'joined') {
+ joined = true; retry = 0;
+ allowed = m.allowed_commands || [];
+ const r = $('[data-role-label]');
+ if (r) r.textContent = m.role;
+ notice('');
+ enableControls();
+ paint(m.state);
+ } else if (m.type === 'robot_state') {
+ paint(m.state);
+ } else if (m.type === 'command_result') {
+ if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
+ } else if (m.type === 'error') {
+ if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
+ notice(m.detail || m.code);
+ }
+ }
+
+ function connect() {
+ ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/control`);
+ ws.onopen = () => raw({ type: 'join', robot_id: ROBOT_ID });
+ ws.onmessage = (e) => { let m; try { m = JSON.parse(e.data); } catch { return; } onFrame(m); };
+ ws.onclose = (e) => {
+ releaseAll();
+ joined = false;
+ enableControls();
+ const online = $('[data-online]');
+ if (online) online.textContent = 'disconnected';
+ if (e.code === 4002) { notice('Sign in again to drive this robot.'); return; }
+ if (!gone) setTimeout(connect, Math.min(10000, 500 * 2 ** retry++));
+ };
+ }
+ enableControls();
+ connect();
+})();
@@ -7,7 +7,9 @@
@@ -7,7 +7,9 @@
* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)
* GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)
* /api/v1/* the API (service tokens + user tokens, see api/v1.js)
* /api/v1/* the API (service tokens + user tokens, see api/v1.js)
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
* /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
- * GET / a one-line text placeholder; the panel and the site are designed separately
+ * GET / a one-line text placeholder
+ * /robots, /pair/:id, /panel/:id the signed-in pages (web/routes.js); a `sim` robot is driven by the
+ * in-process simulator (sim/index.js), which server/index.js starts for existing ones at boot
*
*
* createApp({ config, db, valkey, registry, keys, hub, outbox, usage, nodes, openre, now, fetchImpl, log }) — everything
* createApp({ config, db, valkey, registry, keys, hub, outbox, usage, nodes, openre, now, fetchImpl, log }) — everything
* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
* injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
@@ -26,6 +28,7 @@ const { createBotOutbox } = require('./events/outbox');
@@ -26,6 +28,7 @@ const { createBotOutbox } = require('./events/outbox');
const { createUsageRelay } = require('./jobs/metering');
const { createUsageRelay } = require('./jobs/metering');
const { createDomain } = require('./domain');
const { createDomain } = require('./domain');
const { createRealtime } = require('./realtime');
const { createRealtime } = require('./realtime');
+const { createSimulator } = require('./sim');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
const { createActorLimits } = require('./api/actor-limits');
const { createActorLimits } = require('./api/actor-limits');
@@ -53,6 +56,7 @@ function createApp(opts = {}) {
@@ -53,6 +56,7 @@ function createApp(opts = {}) {
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
hub.bindDomain(domain);
hub.bindDomain(domain);
+ const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
const apiAuth = createApiAuth({ config, keys, userAuth });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
@@ -85,7 +89,7 @@ function createApp(opts = {}) {
@@ -85,7 +89,7 @@ function createApp(opts = {}) {
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config));
+ app.use(createWebRoutes(config, { domain, sim, log }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -104,7 +108,7 @@ function createApp(opts = {}) {
@@ -104,7 +108,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, userAuth, metrics });
+ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, sim, userAuth, metrics });
return app;
return app;
}
}
@@ -31,7 +31,7 @@ async function main() {
@@ -31,7 +31,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { domain, keys, outbox, usage, hub } = app.locals;
+ const { domain, keys, outbox, usage, hub, sim } = app.locals;
keys.start();
keys.start();
const timers = [];
const timers = [];
@@ -50,6 +50,8 @@ async function main() {
@@ -50,6 +50,8 @@ async function main() {
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));
server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));
+ // The robots whose profile's driver is `sim` get their in-process device back (server/sim).
+ sim.startAll().then((n) => n && console.log(`[Bot] simulator: ${n} sim robots`), (e) => console.warn(`[Bot] simulator: ${e.message}`));
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits. The stop steps
// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits. The stop steps
@@ -66,6 +68,7 @@ async function main() {
@@ -66,6 +68,7 @@ async function main() {
stop: [
stop: [
() => timers.forEach(clearInterval),
() => timers.forEach(clearInterval),
() => keys.stop(),
() => keys.stop(),
+ () => sim.stopAll(),
() => hub.close().catch(() => {}),
() => hub.close().catch(() => {}),
() => outbox.stop(),
() => outbox.stop(),
() => usage.stop(),
() => usage.stop(),
@@ -80,6 +80,32 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -80,6 +80,32 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
socket.destroy();
socket.destroy();
}
}
+ /**
+ * An in-process device (server/sim): no socket and no credential, so it is attached here and never through
+ * /device. `device` is the device as the hub sees it ({ id, robot_ids, kind }); `onFrame(frame)` gets what Bot
+ * sends it, on a later tick as a real socket would; its frames come back through `deliver`, the same path a
+ * socket's frames take. A real device online for the same robot is preferred over a simulated one.
+ */
+ function attachSim(device, { onFrame, onClose = () => {} }) {
+ if (closed) return null;
+ const ws = {
+ readyState: OPEN,
+ send(data) { setImmediate(() => { if (ws.readyState === OPEN) onFrame(JSON.parse(data)); }); },
+ close() { if (ws.readyState !== OPEN) return; ws.readyState = 3; onClose(); },
+ };
+ const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
+ attachDevice(conn, device);
+ sendHello(conn);
+ const ready = sendConfig(conn).then(() => bringOnline(conn)).catch((e) => log.warn(`[Bot] simulator ${device.id}: ${e.message}`));
+ return { deliver: (frame) => (ws.readyState === OPEN ? onDeviceFrame(conn, JSON.stringify(frame)) : undefined), ready };
+ }
+ function detachSim(deviceId) {
+ const c = deviceConns.get(deviceId);
+ if (!c || !c.sim) return;
+ c.ws.close();
+ dropDevice(c);
+ }
+
// ── Device side ───────────────────────────────────────────────────────────────────────────────
// ── Device side ───────────────────────────────────────────────────────────────────────────────
async function onDeviceSocket(ws, req) {
async function onDeviceSocket(ws, req) {
// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled
// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled
@@ -458,8 +484,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -458,8 +484,13 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
// ── State and helpers ─────────────────────────────────────────────────────────────────────────
function deviceForRobot(robotId) {
function deviceForRobot(robotId) {
- for (const conn of deviceConns.values()) if (conn.online && deviceRobotIds(conn.device).includes(robotId)) return conn;
- return null;
+ let sim = null;
+ for (const conn of deviceConns.values()) {
+ if (!conn.online || !deviceRobotIds(conn.device).includes(robotId)) continue;
+ if (!conn.sim) return conn;
+ sim = sim || conn;
+ }
+ return sim;
}
}
function isOnline(deviceId) { const c = deviceConns.get(deviceId); return !!(c && c.online); }
function isOnline(deviceId) { const c = deviceConns.get(deviceId); return !!(c && c.online); }
function sendToDevice(deviceId, fields) { const c = deviceConns.get(deviceId); return c ? sendFrame(c, fields.type, fields) : false; }
function sendToDevice(deviceId, fields) { const c = deviceConns.get(deviceId); return c ? sendFrame(c, fields.type, fields) : false; }
@@ -531,11 +562,12 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
@@ -531,11 +562,12 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
return {
return {
bindDomain(d) { domain = d; },
bindDomain(d) { domain = d; },
- handleUpgrade,
+ handleUpgrade, attachSim, detachSim,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop,
jobStdout: (jobId) => jobs.stdout(jobId),
jobStdout: (jobId) => jobs.stdout(jobId),
- onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online) n++; return n; },
- devices() { return [...deviceConns.values()].map((c) => ({ device_id: c.device.id, online: c.online })); },
+ // Real devices only: a simulator is not a machine anyone runs.
+ onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
+ devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
async close() {
async close() {
closed = true;
closed = true;
for (const t of timers) clearInterval(t);
for (const t of timers) clearInterval(t);
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+
+/**
+ * The simulated device (ADR-043 decision 3): an in-process stand-in for a robot whose profile's driver is
+ * `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the
+ * hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers
+ * `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
+ * at a motion command's `deadline_ms` unless a newer command arrived first (the Node's deadman).
+ *
+ * attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent) → true/false
+ * startAll() every existing sim robot (boot)
+ * stop(robotId), stopAll(), running(robotId)
+ *
+ * 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.
+ */
+const { listProfiles, getProfile } = require('../profiles');
+
+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
+
+function createSimulator({ config, domain, hub, now = () => Date.now(), log = console }) {
+ const sims = new Map(); // robot_id → sim
+ const telemetryMs = Math.ceil(1000 / Math.max(1, config.control.telemetryHz)) + 20; // just above the hub's sample window
+ const heartbeatMs = Math.max(50, Math.floor(config.device.heartbeatMs / 2));
+ const every = (ms, fn) => { const t = setInterval(() => { try { fn(); } catch (e) { log.warn(`[Bot] simulator: ${e.message}`); } }, ms); if (t.unref) t.unref(); return t; };
+
+ 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 };
+ 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)));
+ return sim;
+ }
+ function connect(sim) {
+ sim.link = hub.attachSim(sim.device, { onFrame: (f) => onFrame(sim, f), onClose: () => halt(sim) });
+ if (sim.link) sim.link.ready.then(() => telemetry(sim));
+ }
+
+ function halt(sim) {
+ if (sim.stopTimer) clearTimeout(sim.stopTimer);
+ sim.stopTimer = null;
+ for (const axis of Object.keys(sim.drive)) sim.drive[axis] = 0;
+ }
+
+ function onFrame(sim, f) {
+ if (f.type === 'config') { sim.estop = !!f.estop_latched; if (sim.estop) halt(sim); return; }
+ if (f.type === 'estop') { sim.estop = !!f.latched; if (sim.estop) halt(sim); return; }
+ if (f.type !== 'command') return;
+ const reply = (fields) => sim.link && sim.link.deliver({ id: f.id, ...fields });
+ if (f.kind === 'halt') { halt(sim); return reply({ type: 'ack' }); }
+ if (sim.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });
+ if (f.kind === 'drive') {
+ sim.drive = { ...f.value };
+ if (sim.stopTimer) clearTimeout(sim.stopTimer);
+ // 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 {
+ sim.other[f.kind] = f.value;
+ }
+ return reply({ type: 'ack' });
+ }
+
+ function telemetry(sim) {
+ if (!sim.link) return;
+ 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 },
+ });
+ }
+
+ function tick(sim) {
+ 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 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);
+ if (++sim.ticks % EXISTS_EVERY === 0) {
+ domain.robots.get(sim.robotId).then((r) => { if (!r) stop(sim.robotId); }, () => {});
+ }
+ }
+
+ function stop(robotId) {
+ const sim = sims.get(robotId);
+ if (!sim) return;
+ sims.delete(robotId);
+ for (const t of sim.timers) clearInterval(t);
+ halt(sim);
+ sim.link = null;
+ hub.detachSim(sim.device.id);
+ }
+
+ /** Start the robot's simulator when its profile's driver is `sim`; anything else is left alone. */
+ async function attach(robotOrId) {
+ const robot = typeof robotOrId === 'string' ? await domain.robots.get(robotOrId) : robotOrId;
+ if (!robot) return false;
+ if (sims.has(robot.id)) return true;
+ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ if (!isSimProfile(row && row.profile)) return false;
+ start(robot);
+ return true;
+ }
+
+ async function startAll() {
+ const ids = (await listProfiles(domain.db)).filter((r) => isSimProfile(r.profile)).map((r) => r.id);
+ if (!ids.length) return 0;
+ const robots = await domain.db.many('SELECT * FROM robots WHERE profile_id = ANY($1)', [ids]);
+ for (const robot of robots) await attach(robot);
+ return robots.length;
+ }
+
+ function stopAll() { for (const id of [...sims.keys()]) stop(id); }
+
+ return { attach, startAll, stop, stopAll, running: (robotId) => sims.has(robotId), isSimProfile };
+}
+
+module.exports = { createSimulator, isSimProfile };
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+
+/**
+ * The signed-in pages, rendered on the server from data alone (pure functions, testable without a server).
+ * The panel is a function of the robot's profile (ADR-043 decision 7): every widget comes from
+ * `profile.widgets` and every control's name, shape and range from `profile.commands` — no robot-specific
+ * code here. Every value is HTML-escaped; nothing is inline (the CSP is `default-src 'self'`), so the client
+ * (public/panel.js) reads what it needs from data-* attributes.
+ *
+ * renderPanel({ robot, profile, role, allowed_commands, holdResendMs })
+ * renderWidget(widget, { profile, allowed_commands })
+ * renderRobotsPage({ robots, profiles, error, values })
+ * renderPairingPage({ robot, pairing, profile })
+ * esc(value)
+ */
+const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
+/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
+const data = (v) => esc(JSON.stringify(v));
+
+/** The e-stop is the owner's and an operator's; only the owner clears it (the gate enforces both). */
+const ESTOP_ROLES = new Set(['owner', 'operator']);
+/** Hold-to-move arrows by axis: [negative, positive]. */
+const AXIS_ARROWS = { throttle: ['▼', '▲'], y: ['▼', '▲'], steer: ['◀', '▶'], x: ['◀', '▶'], rotation: ['⟲', '⟳'], pan: ['◀', '▶'], tilt: ['▼', '▲'], zoom: ['−', '+'] };
+const POLICIES = ['private', 'invite', 'queue'];
+
+function page(title, body, { scripts = [] } = {}) {
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)} — OpenVibe.Bot</title>
+<link rel="stylesheet" href="/panel/panel.css">
+</head>
+<body>
+${body}
+${scripts.map((s) => `<script src="${esc(s)}" defer></script>`).join('\n')}
+</body>
+</html>
+`;
+}
+
+const disabledUnless = (ok) => (ok ? '' : ' disabled');
+
+/** Hold buttons, one pair per axis: pressed sends the axis at its end of the range, released sends 0. */
+function axisButtons(kind, axes, enabled) {
+ return Object.entries(axes).map(([axis, [lo, hi]]) => {
+ const [neg, pos] = AXIS_ARROWS[axis] || ['−', '+'];
+ return `<div class="axis" data-axis="${esc(axis)}">`
+ + `<button type="button" class="hold" data-hold="${data({ [axis]: lo })}" aria-label="${esc(`${axis} ${lo}`)}"${disabledUnless(enabled)}>${esc(neg)}</button>`
+ + `<span class="axis-name">${esc(axis)}</span>`
+ + `<button type="button" class="hold" data-hold="${data({ [axis]: hi })}" aria-label="${esc(`${axis} ${hi}`)}"${disabledUnless(enabled)}>${esc(pos)}</button>`
+ + '</div>';
+ }).join('');
+}
+
+/** One control per actuator the widget drives, by the actuator's declared type. */
+function actuatorControl(name, a, enabled) {
+ const off = disabledUnless(enabled);
+ if (a.type === 'number') {
+ const [lo, hi] = a.range;
+ const start = Math.min(hi, Math.max(lo, 0));
+ return `<label class="control">${esc(name)} <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${off}></label>`;
+ }
+ if (a.type === 'rgb') {
+ return `<div class="control"><label>${esc(name)} <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
+ + ` <button type="button" data-rgb-off="${esc(name)}"${off}>Off</button></div>`;
+ }
+ if (a.type === 'tone') return `<button type="button" class="hold" data-tone="${esc(name)}"${off}>${esc(name)}</button>`;
+ return `<label class="control">${esc(name)} <input type="checkbox" data-bool="${esc(name)}"${off}></label>`;
+}
+
+function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
+ const commands = profile.commands || {};
+ const kind = w.command ? w.command.kind : null;
+ const enabled = !!kind && allowed_commands.includes(kind);
+ const label = w.label || w.type;
+ let inner;
+ switch (w.type) {
+ case 'drive':
+ case 'ptz': {
+ const axes = (commands[kind] && commands[kind].axes) || {};
+ inner = `<div class="pad">${axisButtons(kind, axes, enabled)}</div>`;
+ if (w.type === 'drive') inner += `<button type="button" class="stop" data-stop${disabledUnless(enabled)}>Stop</button>`;
+ break;
+ }
+ case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
+ const names = (commands.actuator && commands.actuator.names) || {};
+ inner = (w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled)).join('');
+ break;
+ }
+ case 'speaker': {
+ const max = commands.say ? commands.say.max_chars : 200;
+ inner = `<form class="control" data-say><input type="text" name="text" maxlength="${esc(max)}" aria-label="${esc(label)}"${disabledUnless(enabled)}>`
+ + ` <button type="submit"${disabledUnless(enabled)}>Say</button></form>`;
+ break;
+ }
+ case 'display': {
+ const d = commands.display || { modes: [] };
+ let fields = '';
+ if (d.modes.includes('face')) fields += `<select name="face" aria-label="face"${disabledUnless(enabled)}><option value="">—</option>${(d.faces || []).map((f) => `<option value="${esc(f)}">${esc(f)}</option>`).join('')}</select>`;
+ if (d.modes.includes('text')) fields += ` <input type="text" name="text" maxlength="${esc(d.max_chars)}" aria-label="text"${disabledUnless(enabled)}>`;
+ inner = `<form class="control" data-display>${fields} <button type="submit"${disabledUnless(enabled)}>Show</button></form>`;
+ break;
+ }
+ case 'telemetry':
+ inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
+ break;
+ case 'battery':
+ inner = '<meter min="0" max="1" low="0.25" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output>';
+ break;
+ case 'latency':
+ inner = '<output class="readout" data-latency>—</output>';
+ break;
+ case 'camera':
+ // R8b brings the viewer URL; until then the slot is held.
+ inner = '<div class="camera-placeholder" data-camera>Video is not connected yet.</div>';
+ break;
+ default:
+ inner = '';
+ }
+ const cmd = w.command ? ` data-command="${data(w.command)}"` : '';
+ return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;
+}
+
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150 }) {
+ const latched = !!(robot.estop && robot.estop.latched);
+ const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
+ .sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i))
+ .map(({ w }) => renderWidget(w, { profile, allowed_commands }));
+ const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>Stop</button>` : '';
+ const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
+ const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}" role="status" aria-live="assertive">
+<span class="estop-state" data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span>${estop}${clear}
+</header>
+<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
+<h1>${esc(robot.name)}</h1>
+<p class="meta"><span class="profile">${esc(profile.name || robot.profile_id)}</span> · <span class="role" data-role-label>${esc(role)}</span> · <span class="online" data-online>connecting…</span></p>
+<p class="notice" data-notice role="alert" hidden></p>
+<div class="widgets">
+${widgets.join('\n')}
+</div>
+</main>`;
+ return page(robot.name, body, { scripts: ['/panel/panel.js'] });
+}
+
+function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
+ const names = new Map(profiles.map((p) => [p.id, p.name]));
+ const list = robots.length
+ ? `<ul class="robots">${robots.map((r) => `<li><a href="/panel/${esc(r.id)}">${esc(r.name)}</a> <span class="profile">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="policy">${esc(r.access_policy)}</span> <a class="pair" href="/pair/${esc(r.id)}">Pair a device</a></li>`).join('')}</ul>`
+ : '<p class="empty">No robots yet.</p>';
+ const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
+ const form = `<form class="add-robot" method="post" action="/robots">
+<h2>Add a robot</h2>
+${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
+<label>Name <input type="text" name="name" maxlength="80" required value="${esc(values.name || '')}"></label>
+<label>Robot <select name="profile_id">${profiles.map((p) => option(p.id, p.name, p.id === values.profile_id)).join('')}</select></label>
+<label>Who may drive <select name="access_policy">${POLICIES.map((p) => option(p, p, p === (values.access_policy || 'private'))).join('')}</select></label>
+<button type="submit">Add</button>
+</form>`;
+ return page('Your robots', `<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);
+}
+
+function renderPairingPage({ robot, pairing, profile = null }) {
+ const body = `<main class="page pairing">
+<h1>Pair ${esc(robot.name)}</h1>
+<p>Run this on the machine that serves the robot${profile ? ` (${esc(profile.name)})` : ''}:</p>
+<pre class="installer"><code data-copy-source>${esc(pairing.installer)}</code></pre>
+<button type="button" data-copy>Copy the command</button>
+<p>Or enter the pairing code <strong class="code">${esc(pairing.code)}</strong>, valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>.</p>
+<p class="waiting">Waiting for the device. <a href="/panel/${esc(robot.id)}">Open the panel</a></p>
+</main>`;
+ return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
+}
+
+module.exports = { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc };
@@ -1,19 +1,120 @@
@@ -1,19 +1,120 @@
'use strict';
'use strict';
/**
/**
- * The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and
- * the site are designed separately), and `/install`, a 302 to OpenVibe.Node's installer script (the
- * one-paste command, ADR-043). No pages, no CSS, no copy beyond this.
+ * The non-API surface: a one-line text placeholder at `/` for the health of the process, `/install`, a 302 to
+ * OpenVibe.Node's installer script (the one-paste command, ADR-043), and the signed-in pages (the five-minute
+ * path and the profile-rendered panel, plan T15 step 3):
+ *
+ * GET /robots the signed-in person's robots and the "add a robot" form
+ * POST /robots add one (form post) → 303 to its panel (a `sim` robot) or its pairing page
+ * GET /pair/:id the owner only: a fresh pairing code and the installer command
+ * GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile
+ * GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
+ *
+ * Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).
+ * Pages are never cached: a pairing page carries a live code.
*/
*/
+const path = require('path');
const express = require('express');
const express = require('express');
+const { getProfile, listProfiles } = require('../profiles');
+const { BotError } = require('../util');
+const { renderPanel, renderRobotsPage, renderPairingPage } = require('./render');
const VERSION = require('../../package.json').version;
const VERSION = require('../../package.json').version;
-function createWebRoutes(config) {
+const PUBLIC = path.join(__dirname, '..', '..', 'public');
+const HOLD_RESEND_MS = 150;
+
+function createWebRoutes(config, { domain = null, sim = null, log = console } = {}) {
const r = express.Router();
const r = express.Router();
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
// `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
+ if (!domain) return r;
+
+ const asset = (file, type) => (req, res) => { res.setHeader('Cache-Control', 'no-cache'); res.type(type).sendFile(path.join(PUBLIC, file)); };
+ r.get('/panel/panel.js', asset('panel.js', 'application/javascript'));
+ r.get('/panel/panel.css', asset('panel.css', 'text/css'));
+
+ const requireUser = (req) => {
+ if (!req.viewer) throw new BotError(401, 'bot.sign_in', 'sign in first');
+ return req.viewer;
+ };
+ const profileOf = async (robot) => {
+ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ return row ? row.profile : null;
+ };
+ const startSim = (robot) => (sim ? sim.attach(robot).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; }) : Promise.resolve(false));
+ /** A page handler: 401 → sign in and come back, 403/404 → a plain answer, anything else → the app's 500. */
+ const page = (fn) => async (req, res, next) => {
+ res.setHeader('Cache-Control', 'no-store');
+ try { await fn(req, res); } catch (e) {
+ if (e.status === 401) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (e.status === 403 || e.status === 404) return res.status(e.status).type('text/plain').send(`${e.detail || e.code}\n`);
+ return next(e);
+ }
+ return undefined;
+ };
+ // A form post must come from this site (the session cookie is SameSite=Lax; this is the second fence).
+ const sameOrigin = (req) => {
+ const origin = req.headers.origin;
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin || new URL(origin).host === req.headers.host; } catch { return false; }
+ };
+
+ async function robotsPage(req, res, me, extra = {}) {
+ const robots = (await domain.robots.list(me.subject)).map(domain.present.robot);
+ const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
+ res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));
+ }
+
+ r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
+
+ r.post('/robots', express.urlencoded({ extended: false, limit: '8kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const body = req.body || {};
+ const values = { name: typeof body.name === 'string' ? body.name : '', profile_id: String(body.profile_id || ''), access_policy: String(body.access_policy || 'private') };
+ let robot;
+ try {
+ ({ robot } = await domain.robots.create({ owner: me.subject, name: values.name, profile_id: values.profile_id, access_policy: values.access_policy }));
+ } catch (e) {
+ if (e.status === 422) { res.status(422); return robotsPage(req, res, me, { error: e.detail || e.code, values }); }
+ throw e;
+ }
+ // A simulated robot needs no machine: straight to its panel.
+ return res.redirect(303, (await startSim(robot)) ? `/panel/${robot.id}` : `/pair/${robot.id}`);
+ }));
+
+ r.get('/pair/:id', page(async (req, res) => {
+ const me = requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may pair a device');
+ const pairing = await domain.pairing.create(robot.id, me.subject, undefined, robot);
+ res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));
+ }));
+
+ r.get('/panel/:id', page(async (req, res) => {
+ const me = requireUser(req);
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ // As the /control join decides: a member's role, or a place in the queue on a `queue` robot.
+ let role = await domain.members.roleOf(robot.id, me.subject);
+ if (!role && robot.access_policy === 'queue') role = 'queue';
+ if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
+ const profile = await profileOf(robot);
+ if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
+ await startSim(robot);
+ const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
+ res.type('html').send(renderPanel({
+ robot: domain.present.robot(robot), profile, role,
+ allowed_commands: domain.control.allowedFor(robot, role, profile),
+ // A held control is re-sent well inside the device's deadline, so it never stops between two frames.
+ holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
+ }));
+ }));
+
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+// The five-minute path and the profile-rendered panel (plan T15 step 3): every profile's widgets render from
+// the profile alone, the e-stop is the owner's and an operator's, the signed-in pages answer as the gate does,
+// and a `sim` robot is driven by the in-process simulator (telemetry, acks, the deadman).
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { loadProfiles } = require('../server/profiles');
+const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, esc } = require('../server/web/render');
+const { DEFAULT_ALLOW } = require('../server/domain');
+
+const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });
+const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profile.commands[k]), ...(DEFAULT_ALLOW[role] ? ['halt'] : [])];
+
+(async () => {
+ const profiles = [...loadProfiles().values()];
+
+ await check('every profile renders each of its widgets, controls enabled only for an allowed kind', () => {
+ assert.ok(profiles.length >= 4);
+ for (const p of profiles) {
+ const html = renderPanel({ robot: robotFor(p), profile: p, role: 'owner', allowed_commands: allowedFor(p, 'owner') });
+ for (const w of p.widgets) assert.ok(html.includes(`data-widget="${w.type}"`), `${p.id}: no ${w.type} widget`);
+ for (const w of p.widgets.filter((x) => x.command)) {
+ assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
+ assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);
+ }
+ assert.ok(html.includes('<script src="/panel/panel.js" defer></script>'));
+ assert.doesNotMatch(html, /<script>|style="/, 'nothing inline (CSP default-src self)');
+ }
+ });
+
+ await check('the e-stop is shown to the owner and an operator; only the owner gets the clear', () => {
+ const p = profiles.find((x) => x.id === 'sim.rover');
+ const html = (role) => renderPanel({ robot: robotFor(p, { estop: { latched: true } }), profile: p, role, allowed_commands: allowedFor(p, role) });
+ assert.ok(html('owner').includes('data-estop>') && html('owner').includes('data-estop-clear'));
+ assert.ok(html('operator').includes('data-estop>') && !html('operator').includes('data-estop-clear'));
+ for (const role of ['viewer', 'queue']) {
+ 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/);
+ });
+
+ await check('every value is escaped', () => {
+ const p = profiles.find((x) => x.id === 'sim.rover');
+ const evil = '<img src=x onerror=alert(1)>"\'&';
+ const panel = renderPanel({ robot: robotFor(p, { name: evil, id: 'rob_"x' }), profile: { ...p, name: evil, widgets: [{ ...p.widgets[0], label: evil }] }, role: evil, allowed_commands: [] });
+ const robots = renderRobotsPage({ robots: [{ id: 'rob_"x', name: evil, profile_id: evil, access_policy: 'private' }], profiles: [{ id: evil, name: evil }], error: evil, values: { name: evil } });
+ const pair = renderPairingPage({ robot: { id: 'rob_"x', name: evil }, pairing: { code: evil, installer: evil, expires_at: evil }, profile: { name: evil } });
+ for (const html of [panel, robots, pair]) {
+ assert.ok(!html.includes('<img') && !html.includes('rob_"x'), 'an unescaped value');
+ assert.ok(html.includes(esc(evil)));
+ }
+ });
+
+ const t = await boot();
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const carol = t.network.newUser('carol');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });
+ const post = (p, user, form, headers = {}) => fetch(t.base + p, {
+ method: 'POST', redirect: 'manual', body: new URLSearchParams(form).toString(),
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
+ });
+ const control = (user) => t.ws('/control', { headers: 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('/, /robots.txt and /install are unchanged; the pages need a session', async () => {
+ assert.match(await (await get('/')).text(), /^OpenVibe\.Bot .* — ok/);
+ assert.strictEqual(await (await get('/robots.txt')).text(), 'User-agent: *\nDisallow: /\n');
+ assert.strictEqual((await get('/install')).status, 302);
+ for (const p of ['/robots', '/panel/rob_x', '/pair/rob_x']) {
+ const r = await get(p);
+ assert.strictEqual(r.status, 302, p);
+ assert.strictEqual(r.headers.get('location'), `/auth/login?next=${encodeURIComponent(p)}`);
+ }
+ const js = await get('/panel/panel.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ assert.match((await get('/panel/panel.css')).headers.get('content-type'), /text\/css/);
+ });
+
+ let simId;
+ await check('POST /robots (sim.rover) → its panel, 200 with the profile\'s widgets', async () => {
+ const r = await post('/robots', alex, { name: 'Sim', profile_id: 'sim.rover', access_policy: 'private' });
+ assert.strictEqual(r.status, 303);
+ const m = /^\/panel\/(rob_[0-9A-Za-z]+)$/.exec(r.headers.get('location'));
+ assert.ok(m, r.headers.get('location'));
+ simId = m[1];
+ const page = await get(`/panel/${simId}`, alex);
+ assert.strictEqual(page.status, 200);
+ assert.strictEqual(page.headers.get('cache-control'), 'no-store');
+ const html = await page.text();
+ for (const w of ['drive', 'camera', 'latency']) assert.ok(html.includes(`data-widget="${w}"`), w);
+ assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"') && html.includes('data-estop>'));
+ const list = await (await get('/robots', alex)).text();
+ assert.ok(list.includes(`/panel/${simId}`));
+ });
+
+ await check('a sim robot has telemetry within 1 s and acks a command; the deadman stops it', async () => {
+ const { robot } = await t.robot(alex, { name: 'Sim 2' });
+ assert.strictEqual(await t.app.locals.sim.attach(robot.id), true);
+ assert.ok(await poll(() => t.hub.deviceState(`dev_sim_${robot.id}`).telemetry, 1000), 'no telemetry within 1 s');
+ assert.strictEqual(t.hub.onlineCount(), 0, 'a simulator is not counted as a device');
+ const c = await control(alex);
+ c.send({ type: 'join', robot_id: robot.id });
+ const joined = await c.waitFor((m) => m.type === 'joined');
+ assert.ok(joined.allowed_commands.includes('drive'));
+ c.send({ type: 'command', id: 'd1', kind: 'drive', value: { throttle: 0.5 } });
+ const res = await c.waitFor((m) => m.type === 'command_result' && m.id === 'd1');
+ assert.strictEqual(res.result, 'ack');
+ // Held as the panel holds it (a fresh id every 100 ms): moving; let go: the deadman stops it by itself.
+ const moving = (m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.drive.throttle === 0.5;
+ let n = 0;
+ const hold = setInterval(() => c.send({ type: 'command', id: `h${++n}`, kind: 'drive', value: { throttle: 0.5 } }), 100);
+ try { assert.ok(await c.waitFor(moving), 'moving'); } finally { clearInterval(hold); }
+ const after = c.messages.length;
+ assert.ok(await c.waitFor((m) => c.messages.indexOf(m) >= after && m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.drive.throttle === 0), 'stopped at the deadline');
+ c.close();
+ t.app.locals.sim.stop(robot.id);
+ assert.strictEqual(t.hub.isOnline(`dev_sim_${robot.id}`), false);
+ });
+
+ await check('a signed-in person with no role gets 403 and join answers bot.not_an_operator', async () => {
+ const r = await get(`/panel/${simId}`, bob);
+ assert.strictEqual(r.status, 403);
+ const c = await control(bob);
+ c.send({ type: 'join', robot_id: simId });
+ const e = await c.waitFor((m) => m.type === 'joined' || m.type === 'error');
+ assert.strictEqual(e.type, 'error');
+ assert.strictEqual(e.code, 'bot.not_an_operator');
+ c.close();
+ assert.strictEqual((await get('/panel/rob_nothing', bob)).status, 404);
+ });
+
+ await check('a viewer member gets the panel with nothing to drive; a stranger on a queue robot gets the queue role', async () => {
+ await t.call('POST', `/api/v1/robots/${simId}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });
+ const html = await (await get(`/panel/${simId}`, carol)).text();
+ assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"') && !html.includes('data-estop>'));
+ const { robot } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
+ const q = await (await get(`/panel/${robot.id}`, bob)).text();
+ assert.ok(q.includes('data-role="queue"'));
+ });
+
+ await check('POST /robots (a hardware profile) → the owner\'s pairing page; others get 403', async () => {
+ const r = await post('/robots', alex, { name: 'Arm', profile_id: 'adeept.adr036' });
+ assert.strictEqual(r.status, 303);
+ const loc = r.headers.get('location');
+ assert.match(loc, /^\/pair\/rob_/);
+ const page = await get(loc, alex);
+ assert.strictEqual(page.status, 200);
+ const html = await page.text();
+ assert.match(html, /class="code">[0-9A-Z]{4}-[0-9A-Z]{4}</);
+ assert.match(html, /--driver adeept</);
+ assert.ok(html.includes('data-copy'));
+ assert.strictEqual((await get(loc, bob)).status, 403);
+ });
+
+ await check('a bad form answers 422 with the form; a cross-site post is refused', async () => {
+ const bad = await post('/robots', alex, { name: '', profile_id: 'sim.rover' });
+ assert.strictEqual(bad.status, 422);
+ assert.match(await bad.text(), /class="error"/);
+ const unknown = await post('/robots', alex, { name: 'X', profile_id: 'nope' });
+ assert.strictEqual(unknown.status, 422);
+ const cross = await post('/robots', alex, { name: 'X', profile_id: 'sim.rover' }, { Origin: 'https://evil.test' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual((await post('/robots', null, { name: 'X', profile_id: 'sim.rover' })).status, 302);
+ });
+
+ t.app.locals.sim.stopAll();
+ await t.close();
+ done();
+})();