openvibers/openvibe.bot Commits

Add signed-in robot pages, the profile-rendered panel and a sim device

OP OpenVibers committed 4 Oct 2026 · a36cfc04ba · 11 files, +951 −13

Asked: T15 step 3 (R8) — OpenVibe.Bot: the five-minute path and the profile-rendered panel, first slice. R7 (Contracts #28, Bot registration) merged, so this is unblocked. Follow the groundwork dossier /home/workstation/openvibe/agents/ds/prep/T15.md section 'T15 step 3' (lines 215-319); read it first, th…
Harness job bot-t15-step-3-r8-160241 (plan T15).

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 2103161..a6b9c6b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex 9ead063..70c91e3 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -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.
ddiff --git a/public/panel.css b/public/panel.css
nnew file mode 100644
iindex 0000000..97b998f
---- /dev/null
++++ b/public/panel.css
@@@ -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; }
ddiff --git a/public/panel.js b/public/panel.js
nnew file mode 100644
iindex 0000000..3e2428f
---- /dev/null
++++ b/public/panel.js
@@@ -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();
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 5ceb4da..5c9906c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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;
}
ddiff --git a/server/index.js b/server/index.js
iindex 1ba614e..5be90ea 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(),
ddiff --git a/server/realtime.js b/server/realtime.js
iindex bb952cd..e2a5a3c 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -80,6 +80,32 @@ function createRealtime({ config, keys, userAuth, usage = null, log = console, n
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 ───────────────────────────────────────────────────────────────────────────────
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, n
return {
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);
ddiff --git a/server/sim/index.js b/server/sim/index.js
nnew file mode 100644
iindex 0000000..27cefaf
---- /dev/null
++++ b/server/sim/index.js
@@@ -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 };
ddiff --git a/server/web/render.js b/server/web/render.js
nnew file mode 100644
iindex 0000000..56000cd
---- /dev/null
++++ b/server/web/render.js
@@@ -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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
++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 };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex a5af8e0..91ecd76 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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;
}
ddiff --git a/test/panel.test.js b/test/panel.test.js
nnew file mode 100644
iindex 0000000..77805cd
---- /dev/null
++++ b/test/panel.test.js
@@@ -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();
++})();