openvibers/openvibe.bot Commits
Bot: robot-owned button profiles, button and point commands in the gate and the panel (plan T15 R9 steps 2-3) (#39)
Changes
@@@ -33,6 +33,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).++- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.++- Profiles declare `button` commands by name and `point` commands for normalised video coordinates in `[0, 1]`. A hold button sends `down` while pressed and `up` on release; each `down` has the device deadline used for held controls. The command gate checks declarations, roles, robot limits and control specific cooldowns. The panel and read-only embed render the profile's labels, with buttons disabled for anyone who cannot drive.## Depends on@@@ -41,7 +43,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.--- `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.11.0 (package.json).++- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.12.0 (package.json).## Capabilities@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.106.0",++ "contracts": "openvibe-contracts v0.110.0","packages": [-- "openvibe-contracts v0.106.0",++ "openvibe-contracts v0.110.0","openvibe-sdk v0.26.0",-- "openvibe-shared v2.11.0"++ "openvibe-shared v2.12.0"],"capabilities": ["bot.robot.read",@@@ -32,7 +32,7 @@"operator WebSocket /control and the REST API (/api/v1, including POST /robots/:id/commands so a bound channel's chat can forward a command over HTTP) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget","pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s","device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",-- "profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",++ "catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, camera.onvif and sim.rover seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot","curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)","a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds","command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",@@@ -1,6 +1,6 @@# Moving OpenVibe.Live's stream controls into Bot (plan T15, row R9)--Status: design, 2026-10-07. Live still serves its controls; nothing here has been built yet.++Status: steps 1–3 built in Contracts 0.109.0 and Bot, 2026-10-07. Live still serves its controls; steps 4–6 remain.## What Live has today@@@ -35,13 +35,13 @@ lines) let viewers drive a streamer's hardware from the channel page:## What has to be built--1. **Contracts** (minor release): `bot.command@1` gains the kinds `button` (`name`, optional `state: down|up`) and++1. **Built — Contracts 0.109.0** (minor release): `bot.command@1` gains the kinds `button` (`name`, optional `state: down|up`) and`point` (`x`, `y` in `[0, 1]`); `bot.robot-profile@1` gains `commands.button.names` and `commands.point`, and thewidget types `buttons` and `video_click`. Additive; existing profiles stay valid.--2. **Bot — robot-local profiles**: a robot may carry its own profile (`robot_profiles` row owned by the robot,++2. **Built — Bot robot-local profiles**: a robot may carry its own profile (`robot_profiles` row owned by the robot,`profile_id = 'local.<rob_…>'`) instead of a catalogue one; the owner edits its buttons on the robot page (aplain form: name, label, key, cooldown, hold). Validated by the same profile schema and registries.--3. **Bot — the gate and the panel**: the gate accepts `button`/`point` only for names and shapes the profile++3. **Built — Bot gate and panel**: the gate accepts `button`/`point` only for names and shapes the profiledeclares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on thecamera tile, normalised coordinates).4. **Node — the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live'snnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- Robot-owned profiles share the validated profile store with shipped catalogue profiles.++-- A local row belongs to exactly one robot and disappears with it.++ALTER TABLE robot_profiles ADD COLUMN robot_id text REFERENCES robots(id) ON DELETE CASCADE;++ALTER TABLE robot_profiles ADD COLUMN source_profile_id text;++ALTER TABLE robot_profiles ADD CONSTRAINT robot_profiles_local_id CHECK++ (robot_id IS NULL OR (id = 'local.' || lower(robot_id) AND source_profile_id IS NOT NULL));++CREATE UNIQUE INDEX robot_profiles_one_local ON robot_profiles (robot_id) WHERE robot_id IS NOT NULL;@@@ -14,9 +14,9 @@"express": "^4.21.2","iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0","pg": "^8.23.0","ws": "^8.18.0"},@@@ -844,9 +844,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.106.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",-- "integrity": "sha512-w3HV+CmqtJYTUT97FsWziSOzM0KnA81PnAJCG9w68T8jJMRIDk96FJJKFckFQbFLFfZWF7jAINf0OSEzalabaQ==",++ "version": "0.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",++ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -889,9 +889,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.11.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",-- "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",++ "version": "2.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",++ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -26,9 +26,9 @@"express": "^4.21.2","iovalkey": "^0.4.0","jsonwebtoken": "^9.0.2",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0","pg": "^8.23.0","ws": "^8.18.0"},@@@ -139,6 +139,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.slider { flex-wrap: wrap; } .slider input { flex: 1; min-width: 8rem; }.colour label { display: flex; align-items: center; gap: 0.6rem; }.tone { width: 100%; min-height: 56px; user-select: none; -webkit-user-select: none; touch-action: none; }++.button-grid { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }++.pad-button { min-height: 64px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.3rem;++ font-weight: 600; user-select: none; -webkit-user-select: none; touch-action: none; }++.pad-button kbd { font-weight: 400; color: var(--muted); }++.pad-button.held kbd { color: inherit; border-color: currentColor; background: transparent; }++.widget-video_click { order: -1; padding: 0; overflow: hidden; }++.widget-video_click h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }++[data-video-click] { cursor: crosshair; }.inline-form { display: flex; gap: 0.5rem; flex-wrap: wrap; } .inline-form > input, .inline-form > select { flex: 1; min-width: 8rem; }/* Readouts and the latency meter */@@@ -213,6 +221,15 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.setting-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }.setting-card form { display: grid; gap: 0.6rem; }.setting-card button[type="submit"] { justify-self: start; }++.button-def { margin: 0; padding: 0.7rem; display: grid; gap: 0.6rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); min-width: 0; }++.button-def legend { padding: 0 0.35rem; font-size: 0.85rem; font-weight: 600; color: var(--muted); }++.button-def-grid { display: grid; gap: 0.5rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }++.field { display: grid; gap: 0.25rem; min-width: 0; color: var(--muted); font-size: 0.82rem; }++.field input { width: 100%; padding: 0 0.65rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); font-size: 0.95rem; }++.field input::placeholder { color: var(--muted); opacity: 0.6; }++.button-def-foot { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; }++.check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.92rem; cursor: pointer; }++.form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }.setting-note a { color: var(--accent); }.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);@@@ -198,11 +198,49 @@el.classList.remove('held');send('actuator', { name: el.dataset.tone, value: null });}++ // A hold button may have both a pointer and a key down. Only the first starts it; the last release++ // sends up. Re-sending down keeps the device's deadline alive while the operator holds it.++ const buttonHolds = new Map();++ function pressProfileButton(el, source) {++ if (el.disabled || !joined || !allowed.includes('button')) return;++ const name = el.dataset.buttonName;++ if (el.dataset.buttonHold !== 'true') { send('button', { name }); return; }++ let h = buttonHolds.get(el);++ if (!h) {++ h = { sources: new Set(), timer: null };++ buttonHolds.set(el, h);++ el.classList.add('held');++ send('button', { name, state: 'down' });++ h.timer = setInterval(() => send('button', { name, state: 'down' }), HOLD_MS);++ }++ h.sources.add(source);++ }++ function releaseProfileButton(el, source) {++ const h = buttonHolds.get(el);++ if (!h) return;++ h.sources.delete(source);++ if (h.sources.size) return;++ clearInterval(h.timer);++ buttonHolds.delete(el);++ el.classList.remove('held');++ send('button', { name: el.dataset.buttonName, state: 'up' });++ }++ for (const el of $$('[data-button-name]')) {++ el.addEventListener('pointerdown', (e) => {++ e.preventDefault();++ try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ }++ pressProfileButton(el, `pointer:${e.pointerId}`);++ });++ for (const event of ['pointerup', 'pointercancel', 'lostpointercapture'])++ el.addEventListener(event, (e) => releaseProfileButton(el, `pointer:${e.pointerId}`));++ el.addEventListener('contextmenu', (e) => e.preventDefault());++ }function releaseAll() {for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);}for (const el of [...tones.keys()]) releaseTone(el);++ for (const [el, h] of [...buttonHolds]) for (const source of [...h.sources]) releaseProfileButton(el, source);for (const s of sticks) s.release();pad.reset();for (const el of $$('.held')) el.classList.remove('held');@@@ -311,12 +349,24 @@holdRelease(DRIVE, source);}const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);++ const profileKey = (e) => main.contains(document.activeElement) &&++ $$('[data-button-key]').find((el) => !el.disabled && (el.dataset.buttonKey === e.code || el.dataset.buttonKey.toLowerCase() === e.key.toLowerCase()));document.addEventListener('keydown', (e) => {if (typing(e)) return;++ const focusedButton = e.target.closest && e.target.closest('[data-button-name]');++ if (focusedButton && (e.code === 'Enter' || e.code === 'Space')) {++ e.preventDefault(); if (!e.repeat) pressProfileButton(focusedButton, `key:${e.code}`); return;++ }++ // Stop and the drive keys first: a button's key never shadows them.if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }-- if (keyValue(e.code)) { e.preventDefault(); if (!e.repeat) keyPress(e.code, `key:${e.code}`); }++ if (keyValue(e.code)) { e.preventDefault(); if (!e.repeat) keyPress(e.code, `key:${e.code}`); return; }++ const button = profileKey(e);++ if (button) { e.preventDefault(); if (!e.repeat) pressProfileButton(button, `key:${e.code}`); }++ });++ document.addEventListener('keyup', (e) => {++ for (const [el, h] of buttonHolds) if (h.sources.has(`key:${e.code}`)) releaseProfileButton(el, `key:${e.code}`);++ if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`);});-- document.addEventListener('keyup', (e) => { if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`); });function stopDrive() {const h = holds.get(DRIVE);@@@ -433,6 +483,20 @@const screen = $('[data-camera-screen]', cam);if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;}++ for (const screen of $$('[data-video-click]')) screen.addEventListener('click', (e) => {++ if (!joined || !allowed.includes('point')) return;++ const box = screen.getBoundingClientRect();++ const video = $('video', screen);++ let left = box.left; let top = box.top; let width = box.width; let height = box.height;++ // object-fit: contain leaves letterboxing. The command coordinates name the displayed pixels.++ if (video && !video.hidden && video.videoWidth && video.videoHeight) {++ const scale = Math.min(width / video.videoWidth, height / video.videoHeight);++ width = video.videoWidth * scale; height = video.videoHeight * scale;++ left += (box.width - width) / 2; top += (box.height - height) / 2;++ }++ if (!width || !height || e.clientX < left || e.clientX > left + width || e.clientY < top || e.clientY > top + height) return;++ send('point', { x: (e.clientX - left) / width, y: (e.clientY - top) / height });++ });// ── Live camera: the robot's OpenRe.Stream WebRTC session ────────────────────────────────────────// A tile the server marked with a live session's viewer signaling URL plays it: the mediasoup-client@@@ -17,15 +17,15 @@const {BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,} = require('../util');--const { getProfile } = require('../profiles');++const { getProfile, validateProfile } = require('../profiles');const { ENVELOPE } = require('../events/outbox');const DEFAULT_ALLOW = {-- owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],-- operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],-- queue: ['drive', 'halt'],++ owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],++ operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],++ queue: ['drive', 'button', 'point', 'halt'],};--const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);++const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt']);const MOTION = new Set(['drive', 'actuator', 'ptz']);const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.@@@ -64,6 +64,10 @@ function noteToHz(note) {return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);}++++/** The panel's drive and stop keys (public/panel.js KEYS, Space and Escape): a profile button never takes one. */++const RESERVED_BUTTON_KEYS = new Set(['space', ' ', 'escape', 'w', 'a', 's', 'd', 'q', 'e', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright',++ 'keyw', 'keya', 'keys', 'keyd', 'keyq', 'keye']);function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {// ── Presenters ────────────────────────────────────────────────────────────────────────────────const presentRobot = (r) => (r ? {@@@ -131,6 +135,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return db.many('SELECT * FROM robots WHERE owner_subject = $1 ORDER BY created_at DESC', [owner]);}async function createRobot({ owner, name, profile_id, access_policy = 'private', limits = {}, installerUrl }) {++ if (String(profile_id).startsWith('local.')) fail(422, 'bot.unknown_profile', 'choose a catalogue profile');const profile = await getProfile(db, profile_id);if (!profile) fail(422, 'bot.unknown_profile', `no profile ${profile_id}`);const cleanName = text(name, 'name', 80);@@@ -175,6 +180,67 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);return getRobot(id);}++ async function localProfile(robotId) {++ return db.maybe('SELECT source_profile_id, profile FROM robot_profiles WHERE robot_id = $1', [robotId]);++ }++ async function saveLocalProfile(robotId, actor, { buttons, point }) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!actor || actor.subject !== robot.owner_subject) fail(403, 'bot.forbidden', 'only the owner may edit buttons');++ for (const [name, b] of Object.entries(buttons || {})) {++ if (b.key && RESERVED_BUTTON_KEYS.has(String(b.key).toLowerCase()))++ fail(422, 'bot.invalid_input', `${b.key} drives or stops the robot; give ${name} another key`);++ }++ const local = await localProfile(robotId);++ const sourceId = local ? local.source_profile_id : robot.profile_id;++ const source = await getProfile(db, sourceId);++ if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');++ // Profile IDs are lowercase by contract; robot ULIDs are uppercase.++ const id = `local.${robotId.toLowerCase()}`;++ const base = source.profile;++ const commands = { ...base.commands };++ delete commands.button; delete commands.point;++ const widgets = base.widgets.filter((w) => w.type !== 'buttons' && w.type !== 'video_click');++ if (buttons && Object.keys(buttons).length) {++ commands.button = { names: buttons };++ widgets.push({ type: 'buttons', command: { kind: 'button' } });++ }++ if (point) {++ commands.point = point.cooldown_ms == null ? {} : { cooldown_ms: point.cooldown_ms };++ if (!widgets.some((w) => w.type === 'camera')) widgets.push({ type: 'camera' });++ widgets.push({ type: 'video_click', command: { kind: 'point' } });++ }++ let profile;++ try { profile = validateProfile({ ...base, id, version: 1, name: `${robot.name} controls`, commands, widgets }); }++ catch (e) { if (e.code === 'bot.profile_invalid') fail(422, 'bot.profile_invalid', e.detail || e.message); throw e; }++ await db.tx(async (t) => {++ await t.query(`INSERT INTO robot_profiles (id, version, profile, created_at, robot_id, source_profile_id)++ VALUES ($1, 1, $2::jsonb, $3, $4, $5)++ ON CONFLICT (id, version) DO UPDATE SET profile = EXCLUDED.profile`,++ [id, JSON.stringify(profile), iso(now()), robotId, sourceId]);++ await t.query('UPDATE robots SET profile_id = $2, profile_version = 1, updated_at = $3 WHERE id = $1', [robotId, id, iso(now())]);++ });++ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'profile.local',++ value: { buttons: Object.keys(buttons || {}), point: !!point }, result: 'ack' });++ if (link && link.refreshConfig) await link.refreshConfig(robotId);++ if (link && link.broadcast) link.broadcast(robotId);++ return profile;++ }++ async function useCatalogueProfile(robotId, actor) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ if (!actor || actor.subject !== robot.owner_subject) fail(403, 'bot.forbidden', 'only the owner may change the profile');++ const local = await localProfile(robotId);++ if (!local || robot.profile_id !== `local.${robotId.toLowerCase()}`) fail(422, 'bot.invalid_profile', 'this robot is using its catalogue profile');++ const source = await getProfile(db, local.source_profile_id);++ if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');++ await db.query('UPDATE robots SET profile_id = $2, profile_version = $3, updated_at = $4 WHERE id = $1', [robotId, source.id, source.version, iso(now())]);++ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'profile.catalogue',++ value: { profile_id: source.id }, result: 'ack' });++ if (link && link.refreshConfig) await link.refreshConfig(robotId);++ if (link && link.broadcast) link.broadcast(robotId);++ return source.profile;++ }/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */async function setEmbedPublic(id, value) {await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);@@@ -202,7 +268,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const networkPairing = () => config.pairing.authority === 'network';async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {const robot = robotRow || await getRobot(robotId);-- const driver = driverForProfile(robot && robot.profile_id);++ const local = robot && robot.profile_id.startsWith('local.') ? await localProfile(robotId) : null;++ const driver = driverForProfile(local ? local.source_profile_id : robot && robot.profile_id);if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);const code = newCode();const at = iso(now());@@@ -739,6 +806,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =// Cooldown is measured from the last command sent (not from an acknowledgement), per operator and// kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.const lastCommandAt = new Map();++ const activeButtons = new Map();const markCooldown = (robotId, subject, kind) => {lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);@@@ -801,6 +869,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (kind === 'halt') return {};const spec = profile.commands[kind];const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};++ if (kind === 'button') {++ const name = typeof v.name === 'string' ? v.name : '';++ if (!Object.hasOwn(spec.names, name)) fail(422, 'bot.command_not_allowed', 'this robot has no such button');++ if (Object.keys(v).some((k) => k !== 'name' && k !== 'state')) fail(422, 'bot.invalid_input', 'button takes only name and state');++ const hold = !!spec.names[name].hold;++ if (hold && !['down', 'up'].includes(v.state)) fail(422, 'bot.invalid_input', 'a hold button needs down or up');++ if (!hold && Object.hasOwn(v, 'state')) fail(422, 'bot.invalid_input', 'a plain button takes no state');++ return hold ? { name, state: v.state } : { name };++ }++ if (kind === 'point') {++ if (Object.keys(v).some((k) => k !== 'x' && k !== 'y') ||++ typeof v.x !== 'number' || !Number.isFinite(v.x) || v.x < 0 || v.x > 1 ||++ typeof v.y !== 'number' || !Number.isFinite(v.y) || v.y < 0 || v.y > 1) {++ fail(422, 'bot.invalid_input', 'point needs x and y between 0 and 1');++ }++ return { x: v.x, y: v.y };++ }if (kind === 'drive') {// Every declared axis is sent; an absent one is 0, so a drive frame always says the whole motion.const out = {};@@@ -873,7 +958,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };const eff = effectiveLimits(robot, profile);-- const cooled = eff.cooldownMs > 0 && role !== 'owner' && !halt;++ const release = kind === 'button' && value && value.state === 'up';++ const activeKey = kind === 'button' && value && typeof value.name === 'string' ? `${robotId}|${subject}|${value.name}` : null;++ const repeatedHold = !!(activeKey && value.state === 'down' && activeButtons.get(activeKey) > now());++ const cooled = eff.cooldownMs > 0 && role !== 'owner' && !halt && !release && !repeatedHold;if (cooled) {const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);if (last && now() - last < eff.cooldownMs) {@@@ -882,7 +970,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}if (role === 'queue') {// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.-- if (halt) {++ if (halt || release || repeatedHold) {const turn = await currentTurn(robotId);if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };} else {@@@ -892,17 +980,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}let built;try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }++ const specificMs = kind === 'button' ? profile.commands.button.names[built.name].cooldown_ms || 0++ : kind === 'point' ? profile.commands.point.cooldown_ms || 0 : 0;++ const specificKey = kind === 'button' ? `button:${built.name}` : 'point';++ if (specificMs > 0 && !release && !repeatedHold) {++ const last = lastCommandAt.get(`${robotId}|${subject}|${specificKey}`);++ if (last && now() - last < specificMs) return { ok: false, code: 'bot.cooldown', reason: `wait ${specificMs} ms between ${specificKey} commands`, robot, role };++ }if (cooled) markCooldown(robotId, subject, kind);-- const deadlineMs = MOTION.has(kind)++ if (specificMs > 0 && !release && !repeatedHold) markCooldown(robotId, subject, specificKey);++ const deadlineMs = (MOTION.has(kind) || (kind === 'button' && built.state === 'down'))? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs): null;++ if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);++ if (activeKey && built.state === 'up') activeButtons.delete(activeKey);++ if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };}return {db, now, config, log, outbox, link,present: { robot: presentRobot, device: presentDevice, video: presentVideo },-- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot },++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot,++ localProfile, saveLocalProfile, useCatalogueProfile },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },@@@ -24,7 +24,7 @@* URL, on that one host, with a timeout and `redirect: 'error'` — a camera cannot steer Bot elsewhere.*/const crypto = require('crypto');--const { getProfile, listProfiles } = require('../profiles');++const { getProfile } = require('../profiles');const isOnvifProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'onvif');@@@ -130,9 +130,8 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol}async function startAll() {-- const ids = (await listProfiles(domain.db)).filter((r) => isOnvifProfile(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]);++ const robots = await domain.db.many(`SELECT r.* FROM robots r JOIN robot_profiles p++ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'onvif'`);let n = 0;for (const robot of robots) if (await attach(robot)) n++;return n;@@@ -26,6 +26,7 @@const fs = require('fs');const path = require('path');const { BotError } = require('../util');++const { validate } = require('openvibe-contracts');const DIR = path.join(__dirname);@@@ -41,7 +42,7 @@ const CAPABILITIES = new Set([]);/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */--const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift']);++const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);/** Device drivers a mapping may name. */const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim']);@@@ -68,8 +69,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);const DISPLAY_MODES = new Set(['text', 'face', 'image_png_b64']);--const COMMAND_KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);++const COMMAND_KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt', 'button', 'point']);const NAME_RE = /^[a-z][a-z0-9_]{0,23}$/;++const BUTTON_RE = /^[a-z][a-z0-9_]{0,31}$/;function validateAxes(where, axes, allowed) {if (!isObject(axes) || !Object.keys(axes).length) bad(`${where}.axes must name at least one axis`);@@@ -129,6 +131,22 @@ function validateCommands(id, commands) {d.faces = spec.faces.slice();}out.display = d;++ } else if (kind === 'button') {++ if (!isObject(spec.names) || !Object.keys(spec.names).length) bad(`${where}.names must name at least one button`);++ const names = {};++ for (const [name, button] of Object.entries(spec.names)) {++ if (!BUTTON_RE.test(name)) bad(`${where}: button name ${name} is not valid`);++ if (!isObject(button) || typeof button.label !== 'string' || !button.label.trim() || button.label.length > 40) bad(`${where}.names.${name}.label is invalid`);++ if (button.key != null && (typeof button.key !== 'string' || button.key.length > 16)) bad(`${where}.names.${name}.key is invalid`);++ if (button.cooldown_ms != null && !(int(button.cooldown_ms) >= 0)) bad(`${where}.names.${name}.cooldown_ms is invalid`);++ if (button.hold != null && typeof button.hold !== 'boolean') bad(`${where}.names.${name}.hold is invalid`);++ names[name] = { label: button.label, ...(button.key ? { key: button.key } : {}),++ ...(button.cooldown_ms != null ? { cooldown_ms: button.cooldown_ms } : {}), ...(button.hold ? { hold: true } : {}) };++ }++ out.button = { names };++ } else if (kind === 'point') {++ if (spec.cooldown_ms != null && !(int(spec.cooldown_ms) >= 0)) bad(`${where}.cooldown_ms is invalid`);++ out.point = spec.cooldown_ms != null ? { cooldown_ms: spec.cooldown_ms } : {};} else if (Object.keys(spec).length) bad(`${where} takes no options`);}out.halt = {};@@@ -176,6 +194,8 @@ function validateProfile(p) {};if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);++ const contract = validate('bot.robot-profile@1', p);++ if (!contract.valid) bad(`bot.robot-profile@1: ${JSON.stringify(contract.errors)}`);return {id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,@@@ -220,7 +240,7 @@ async function getProfile(db, id, version = null) {}async function listProfiles(db) {-- return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles ORDER BY id, version DESC');++ return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles WHERE robot_id IS NULL ORDER BY id, version DESC');}module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };@@@ -14,7 +14,7 @@* 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 { getProfile } = require('../profiles');const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');const BATTERY_FLOOR = 20; // % the simulated pack never drains below@@@ -125,9 +125,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co}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]);++ const robots = await domain.db.many(`SELECT r.* FROM robots r JOIN robot_profiles p++ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'sim'`);for (const robot of robots) await attach(robot);return robots.length;}@@@ -140,7 +140,7 @@ function camerasOf(profile = {}) {* `signaling_url` is the robot's live OpenRe WebRTC session, when one is; the tile carries it (escaped)* so public/panel.js can play it — the server itself emits no script.*/--function cameraTiles(w, profile, signaling_url = null) {++function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {const all = camerasOf(profile).map((c, i) => ({ c, i }));const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;return (pick.length ? pick : all).map(({ c, i }) => {@@@ -149,7 +149,7 @@ function cameraTiles(w, profile, signaling_url = null) {const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}`+ `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`-- + `<div class="camera-screen" data-camera-screen>${icon('camera', 'icon camera-icon')}`++ + `<div class="camera-screen" data-camera-screen${pointEnabled ? ' data-video-click' : ''}>${icon('camera', 'icon camera-icon')}`+ '<p class="camera-title">No live video right now.</p>'+ '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')@@@ -209,7 +209,19 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =+ '</div>';break;case 'camera':-- inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url)}</div>`;++ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url,++ (profile.widgets || []).some((widget) => widget.type === 'video_click') && allowed_commands.includes('point'))}</div>`;++ break;++ case 'buttons': {++ const names = (commands.button && commands.button.names) || {};++ inner = `<div class="button-grid">${Object.entries(names).map(([name, button]) =>++ `<button type="button" class="hold pad-button" data-button-name="${esc(name)}" data-button-hold="${button.hold ? 'true' : 'false'}"${button.key ? ` data-button-key="${esc(button.key)}"` : ''}${disabledUnless(enabled)}>`++ + `<span>${esc(button.label)}</span>${button.key ? `<kbd>${esc(button.key)}</kbd>` : ''}</button>`).join('')}</div>`;++ break;++ }++ case 'video_click':++ if ((profile.widgets || []).some((widget) => widget.type === 'camera')) return '';++ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url, enabled)}</div>`;break;default:inner = '';@@@ -258,6 +270,43 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets</form></section>`;}++/** Robot-owned button profile editor. Add and remove submit the form and redraw it without saving. */++function localProfileForm(robot, profile, settings = {}) {++ const rows = settings.rows || Object.entries((profile.commands.button && profile.commands.button.names) || {})++ .map(([name, b]) => ({ name, label: b.label, key: b.key || '', cooldown: b.cooldown_ms == null ? '' : String(b.cooldown_ms), hold: !!b.hold }));++ const shown = rows.length ? rows : [{ name: '', label: '', key: '', cooldown: '', hold: false }];++ const point = settings.point !== undefined ? settings.point : !!profile.commands.point;++ const pointCooldown = settings.pointCooldown !== undefined ? settings.pointCooldown :++ (profile.commands.point && profile.commands.point.cooldown_ms != null ? String(profile.commands.point.cooldown_ms) : '');++ const fields = shown.map((row, i) => `<fieldset class="button-def">++<legend>Button ${i + 1}</legend>++<div class="button-def-grid">++<label class="field"><span>Name</span><input name="name_${i}" value="${esc(row.name)}" maxlength="32" autocomplete="off" pattern="[a-z][a-z0-9_]{0,31}" placeholder="horn"></label>++<label class="field"><span>Label</span><input name="label_${i}" value="${esc(row.label)}" maxlength="40" autocomplete="off" placeholder="Horn"></label>++<label class="field"><span>Key</span><input name="key_${i}" value="${esc(row.key)}" maxlength="16" autocomplete="off" placeholder="h"></label>++<label class="field"><span>Cooldown (ms)</span><input name="cooldown_${i}" type="number" min="0" step="1" value="${esc(row.cooldown)}" placeholder="0"></label>++</div>++<div class="button-def-foot">++<label class="check"><input name="hold_${i}" type="checkbox" value="on"${row.hold ? ' checked' : ''}> Hold while pressed</label>++<button type="submit" class="quiet" name="action" value="remove:${i}">Remove</button>++</div>++</fieldset>`).join('');++ return `<section class="setting-card" aria-labelledby="buttons-h"><h2 id="buttons-h">Buttons</h2>++<p class="setting-note">Each button sends its name to the robot. A key works while the panel has focus; the drive keys and Space stay with driving.</p>++${settings.error ? `<p class="error" role="alert">${esc(settings.error)}</p>` : ''}++<form method="post" action="/robots/${esc(robot.id)}/profile" data-profile-form>++<input type="hidden" name="count" value="${shown.length}">++${fields}++<button type="submit" class="quiet" name="action" value="add">Add a button</button>++<label class="toggle"><input type="checkbox" name="point" value="on"${point ? ' checked' : ''}><span><b>Video click</b><small>A click on the camera sends where it landed, for drivers.</small></span></label>++<label class="field"><span>Video click cooldown (ms)</span><input type="number" name="point_cooldown" min="0" step="1" value="${esc(pointCooldown)}" placeholder="0"></label>++<div class="form-actions">++<button type="submit" class="primary" name="action" value="save">Save buttons</button>++${settings.catalogueId && robot.profile_id.startsWith('local.') ? `<button type="submit" class="quiet" name="action" value="catalogue">Use ${esc(settings.catalogueId)} instead</button>` : ''}++</div>++</form></section>`;++}++/** A link out of a frame: always a new tab, never with a handle back to the framing page. */const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;@@@ -296,7 +345,7 @@ function turnStrip(robot, role) {return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {} }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -308,7 +357,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div></header>--<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">++<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"><div class="panel-head"><h1>${esc(robot.name)}</h1><p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>@@@ -319,7 +368,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink<div class="widgets">${widgets.join('\n')}</div>--${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });}@@@ -250,7 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst role = await domain.members.roleOf(robot.id, me.subject);return role || (robot.access_policy === 'queue' ? 'queue' : null);}-- async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {++ async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {await attachInProcess(robot, profile);const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);// Two OpenRe reads, run together. Only the owner's own page shows the streaming switches, greyed out@@@ -270,12 +270,69 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,++ profileForm,allowed_commands: role === 'watcher' ? [] : 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))),}));}++ // The owner can redraw button rows without JavaScript, save a validated robot-local profile, or return++ // to the catalogue source. The domain checks ownership too, so the write cannot bypass this route.++ r.post('/robots/:id/profile', ...manage, express.urlencoded({ extended: false, limit: '16kb' }), page(async (req, res) => {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ 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 edit buttons');++ const action = String(req.body.action || '');++ if (action === 'catalogue') {++ await domain.robots.useCatalogueProfile(robot.id, me);++ return res.redirect(303, `/panel/${robot.id}`);++ }++ const count = Number(req.body.count);++ if (!Number.isInteger(count) || count < 1 || count > 32) throw new BotError(422, 'bot.invalid_input', 'invalid button rows');++ const one = (key) => typeof req.body[key] === 'string' ? req.body[key].trim() : '';++ const rows = Array.from({ length: count }, (_, i) => ({++ name: one(`name_${i}`), label: one(`label_${i}`), key: one(`key_${i}`),++ cooldown: one(`cooldown_${i}`), hold: req.body[`hold_${i}`] === 'on',++ }));++ const state = { rows, point: req.body.point === 'on', pointCooldown: one('point_cooldown') };++ if (action === 'add') rows.push({ name: '', label: '', key: '', cooldown: '', hold: false });++ else if (/^remove:\d+$/.test(action)) {++ const i = Number(action.slice(7));++ if (i >= rows.length) throw new BotError(422, 'bot.invalid_input', 'invalid button row');++ rows.splice(i, 1);++ } else if (action !== 'save') throw new BotError(422, 'bot.invalid_input', 'invalid profile action');++ if (action !== 'save') {++ const local = await domain.robots.localProfile(robot.id);++ state.catalogueId = local ? local.source_profile_id : robot.profile_id;++ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);++ }++ try {++ const buttons = {};++ for (const row of rows) {++ if (!row.name && !row.label && !row.key && !row.cooldown && !row.hold) continue;++ if (!/^[a-z][a-z0-9_]{0,31}$/.test(row.name) || !row.label || Object.hasOwn(buttons, row.name))++ throw new BotError(422, 'bot.invalid_input', 'each button needs a unique valid name and a label');++ if (row.cooldown && !/^(0|[1-9]\d*)$/.test(row.cooldown)) throw new BotError(422, 'bot.invalid_input', 'button cooldown must be whole milliseconds');++ buttons[row.name] = { label: row.label, ...(row.key ? { key: row.key } : {}),++ ...(row.cooldown ? { cooldown_ms: Number(row.cooldown) } : {}), ...(row.hold ? { hold: true } : {}) };++ }++ if (state.pointCooldown && !/^(0|[1-9]\d*)$/.test(state.pointCooldown)) throw new BotError(422, 'bot.invalid_input', 'video click cooldown must be whole milliseconds');++ await domain.robots.saveLocalProfile(robot.id, me, { buttons,++ point: state.point ? { ...(state.pointCooldown ? { cooldown_ms: Number(state.pointCooldown) } : {}) } : null });++ } catch (e) {++ if (e.status !== 422) throw e;++ res.status(422);++ const local = await domain.robots.localProfile(robot.id);++ state.catalogueId = local ? local.source_profile_id : robot.profile_id;++ state.error = e.detail || e.message;++ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);++ }++ return res.redirect(303, `/panel/${robot.id}`);++ }));++/** The non-owner members with their Network names, or null names when Network cannot be asked. */async function peopleFor(robot) {const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);@@@ -301,7 +358,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiif (!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 sendPanel(res, robot, profile, role);++ const local = role === 'owner' ? await domain.robots.localProfile(robot.id) : null;++ await sendPanel(res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });}));// The embed's own CSP replaces the app's for this route only; it sets no cookie and needs none to watch.nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++// Robot-owned profiles, the button/point gate and the server-rendered controls. No HTTP listener.++const assert = require('assert');++const { check, done } = require('./helpers/app');++const { testDb } = require('./helpers/db');++const { seedProfiles, loadProfiles, listProfiles, validateProfile } = require('../server/profiles');++const { createDomain } = require('../server/domain');++const { prefixedId } = require('../server/util');++const { renderPanel } = require('../server/web/render');++++(async () => {++ const store = await testDb();++ const db = store.db;++ await seedProfiles(db, { log: { log() {} } });++ let clock = Date.now();++ const id = prefixedId('rob', clock);++ const owner = { subject: 'usr_owner', kind: 'user' };++ const operator = { subject: 'usr_operator', kind: 'user' };++ const at = new Date(clock).toISOString();++ await db.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)++ VALUES ($1, $2, 'Test robot', 'sim.rover', 1, 'invite', '{"cooldown_ms":0}', $3, $3)`, [id, owner.subject, at]);++ await db.query(`INSERT INTO robot_operators (robot_id, subject, role, created_at) VALUES ($1, $2, 'owner', $4), ($1, $3, 'operator', $4)`,++ [id, owner.subject, operator.subject, at]);++ const domain = createDomain({ db, outbox: null, now: () => clock,++ config: { control: { cooldownMs: 0, maxCommandMs: 300 }, device: { heartbeatMs: 1000 }, media: {} } });++++ try {++ await check('every existing catalogue profile still loads under the contract', () => {++ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'sim.rover']);++ });++++ await check('an operator cannot edit or switch the robot-owned profile', async () => {++ await assert.rejects(domain.robots.saveLocalProfile(id, operator, { buttons: { go: { label: 'Go' } } }), (e) => e.status === 403);++ assert.strictEqual(await domain.robots.localProfile(id), null);++ });++++ await check('schema-invalid local profiles are never stored', async () => {++ await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'x'.repeat(41) } } }), (e) => e.status === 422);++ // The drive and stop keys stay with driving: a button never takes Space, WASD, Q/E or an arrow.++ for (const key of ['Space', ' ', 'w', 'KeyD', 'ArrowUp', 'e', 'Escape'])++ await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go', key } } }),++ (e) => e.status === 422 && /drives or stops/.test(e.detail || e.message), `key ${JSON.stringify(key)}`);++ assert.strictEqual(await domain.robots.localProfile(id), null);++ assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');++ });++++ await check('owner saves a local profile; its contract, ownership and audit are stored', async () => {++ const p = await domain.robots.saveLocalProfile(id, owner, { buttons: {++ go: { label: 'Go', key: 'KeyG', cooldown_ms: 1000 },++ grab: { label: 'Grab', hold: true, cooldown_ms: 800 },++ }, point: { cooldown_ms: 500 } });++ assert.ok(p.id.startsWith('local.rob_'));++ assert.ok(validateProfile(p));++ const row = await db.maybe('SELECT robot_id, source_profile_id FROM robot_profiles WHERE id = $1', [p.id]);++ assert.strictEqual(row.robot_id, id);++ assert.strictEqual(row.source_profile_id, 'sim.rover');++ assert.strictEqual((await domain.robots.get(id)).profile_id, p.id);++ assert.strictEqual((await domain.audit.list(id, { limit: 1 }))[0].kind, 'profile.local');++ assert.ok(!(await listProfiles(db)).some((x) => x.id === p.id), 'the local profile is absent from the public catalogue');++ const atBoot = await db.many(`SELECT r.id FROM robots r JOIN robot_profiles p++ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'sim'`);++ assert.ok(atBoot.some((x) => x.id === id), 'the local simulator profile attaches at boot');++ });++++ const gate = (principal, kind, value) => domain.control.prepare({ robotId: id, principal, kind, value, online: true });++ await check('button names and states, hold deadline, and per-button cooldown', async () => {++ assert.strictEqual((await gate(owner, 'button', { name: 'missing' })).code, 'bot.command_not_allowed');++ assert.strictEqual((await gate(owner, 'button', { name: 'go', state: 'down' })).code, 'bot.invalid_input');++ assert.strictEqual((await gate(owner, 'button', { name: 'go', state: null })).code, 'bot.invalid_input');++ assert.strictEqual((await gate(owner, 'button', { name: 'grab' })).code, 'bot.invalid_input');++ assert.strictEqual((await gate(owner, 'button', { name: 'grab', state: 'sideways' })).code, 'bot.invalid_input');++ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).ok, true);++ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).code, 'bot.cooldown');++ const down = await gate(operator, 'button', { name: 'grab', state: 'down' });++ assert.ok(down.ok && down.deadlineMs > clock);++ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'down' })).ok, true, 'held resend renews the deadman');++ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'up' })).ok, true, 'release passes cooldown');++ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'down' })).code, 'bot.cooldown');++ clock += 1001;++ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).ok, true);++ });++++ await check('point requires declaration and coordinates inside the displayed box', async () => {++ for (const value of [{ x: -0.1, y: 0 }, { x: 1.1, y: 0 }, { x: 0, y: NaN }, { x: 0.5 }, { x: '0.5', y: 0.5 }])++ assert.strictEqual((await gate(operator, 'point', value)).code, 'bot.invalid_input');++ assert.strictEqual((await gate(operator, 'point', { x: 0, y: 1 })).ok, true);++ assert.strictEqual((await gate(operator, 'point', { x: 1, y: 0 })).code, 'bot.cooldown');++ await domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go' } }, point: null });++ assert.strictEqual((await gate(operator, 'point', { x: 0.5, y: 0.5 })).code, 'bot.command_not_allowed');++ });++++ await check('panel and embed show declared labels; viewers have disabled buttons', async () => {++ const p = await domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go' }, grab: { label: 'Grab', hold: true } }, point: {} });++ const robot = domain.present.robot(await domain.robots.get(id));++ const ownerHtml = renderPanel({ robot, profile: p, role: 'owner', allowed_commands: ['button', 'point'] });++ assert.match(ownerHtml, /data-button-name="go"[^>]*><span>Go<\/span><\/button>/);++ assert.match(ownerHtml, /data-button-name="grab"[^>]*><span>Grab<\/span><\/button>/);++ assert.match(ownerHtml, /<section class="widget widget-buttons" data-widget="buttons"[^>]*><h2>buttons<\/h2>/, 'a titled card like every widget');++ assert.ok(ownerHtml.includes('data-video-click'));++ assert.ok(ownerHtml.includes('data-profile-form'));++ const viewerHtml = renderPanel({ robot, profile: p, role: 'watcher', allowed_commands: [], mode: 'embed' });++ assert.match(viewerHtml, /data-button-name="go"[^>]*disabled><span>Go<\/span><\/button>/);++ assert.ok(!viewerHtml.includes('data-video-click') && !viewerHtml.includes('data-profile-form'));++ });++++ await check('only the owner can return to the catalogue profile, with an audit row', async () => {++ await assert.rejects(domain.robots.useCatalogueProfile(id, operator), (e) => e.status === 403);++ await domain.robots.useCatalogueProfile(id, owner);++ assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');++ assert.strictEqual((await domain.audit.list(id, { limit: 1 }))[0].kind, 'profile.catalogue');++ });++ } finally {++ await store.close();++ }++ done();++})().catch((e) => { console.error(e); process.exitCode = 1; });
@@ -33,6 +33,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -33,6 +33,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
+- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
+- Profiles declare `button` commands by name and `point` commands for normalised video coordinates in `[0, 1]`. A hold button sends `down` while pressed and `up` on release; each `down` has the device deadline used for held controls. The command gate checks declarations, roles, robot limits and control specific cooldowns. The panel and read-only embed render the profile's labels, with buttons disabled for anyone who cannot drive.
## Depends on
## Depends on
@@ -41,7 +43,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -41,7 +43,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenVibe.Network: user and service tokens, node tokens, pairing when the authority is `network`, and identity resolution (a `@username` to a subject, and subjects to names, for the panel's People card).
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
- OpenVibe.Billing: job usage readings go to `billing.usage.record` (`BOT_BILLING_URL`; Bot mints its own Network service token for audience `openvibe.billing` with `billing.usage.record`, and `BOT_BILLING_TOKEN` overrides it); unset, they wait in `run_usage_outbox`.
-- `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.11.0 (package.json).
+- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.12.0 (package.json).
## Capabilities
## Capabilities
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.106.0",
+ "contracts": "openvibe-contracts v0.110.0",
"packages": [
"packages": [
- "openvibe-contracts v0.106.0",
+ "openvibe-contracts v0.110.0",
"openvibe-sdk v0.26.0",
"openvibe-sdk v0.26.0",
- "openvibe-shared v2.11.0"
+ "openvibe-shared v2.12.0"
],
],
"capabilities": [
"capabilities": [
"bot.robot.read",
"bot.robot.read",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"operator WebSocket /control and the REST API (/api/v1, including POST /robots/:id/commands so a bound channel's chat can forward a command over HTTP) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",
"operator WebSocket /control and the REST API (/api/v1, including POST /robots/:id/commands so a bound channel's chat can forward a command over HTTP) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
- "profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
+ "catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, camera.onvif and sim.rover seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"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",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Moving OpenVibe.Live's stream controls into Bot (plan T15, row R9)
# Moving OpenVibe.Live's stream controls into Bot (plan T15, row R9)
-Status: design, 2026-10-07. Live still serves its controls; nothing here has been built yet.
+Status: steps 1–3 built in Contracts 0.109.0 and Bot, 2026-10-07. Live still serves its controls; steps 4–6 remain.
## What Live has today
## What Live has today
@@ -35,13 +35,13 @@ lines) let viewers drive a streamer's hardware from the channel page:
@@ -35,13 +35,13 @@ lines) let viewers drive a streamer's hardware from the channel page:
## What has to be built
## What has to be built
-1. **Contracts** (minor release): `bot.command@1` gains the kinds `button` (`name`, optional `state: down|up`) and
+1. **Built — Contracts 0.109.0** (minor release): `bot.command@1` gains the kinds `button` (`name`, optional `state: down|up`) and
`point` (`x`, `y` in `[0, 1]`); `bot.robot-profile@1` gains `commands.button.names` and `commands.point`, and the
`point` (`x`, `y` in `[0, 1]`); `bot.robot-profile@1` gains `commands.button.names` and `commands.point`, and the
widget types `buttons` and `video_click`. Additive; existing profiles stay valid.
widget types `buttons` and `video_click`. Additive; existing profiles stay valid.
-2. **Bot — robot-local profiles**: a robot may carry its own profile (`robot_profiles` row owned by the robot,
+2. **Built — Bot robot-local profiles**: a robot may carry its own profile (`robot_profiles` row owned by the robot,
`profile_id = 'local.<rob_…>'`) instead of a catalogue one; the owner edits its buttons on the robot page (a
`profile_id = 'local.<rob_…>'`) instead of a catalogue one; the owner edits its buttons on the robot page (a
plain form: name, label, key, cooldown, hold). Validated by the same profile schema and registries.
plain form: name, label, key, cooldown, hold). Validated by the same profile schema and registries.
-3. **Bot — the gate and the panel**: the gate accepts `button`/`point` only for names and shapes the profile
+3. **Built — Bot gate and panel**: the gate accepts `button`/`point` only for names and shapes the profile
declares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on the
declares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on the
camera tile, normalised coordinates).
camera tile, normalised coordinates).
4. **Node — the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live's
4. **Node — the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live's
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- Robot-owned profiles share the validated profile store with shipped catalogue profiles.
+-- A local row belongs to exactly one robot and disappears with it.
+ALTER TABLE robot_profiles ADD COLUMN robot_id text REFERENCES robots(id) ON DELETE CASCADE;
+ALTER TABLE robot_profiles ADD COLUMN source_profile_id text;
+ALTER TABLE robot_profiles ADD CONSTRAINT robot_profiles_local_id CHECK
+ (robot_id IS NULL OR (id = 'local.' || lower(robot_id) AND source_profile_id IS NOT NULL));
+CREATE UNIQUE INDEX robot_profiles_one_local ON robot_profiles (robot_id) WHERE robot_id IS NOT NULL;
@@ -14,9 +14,9 @@
@@ -14,9 +14,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -844,9 +844,9 @@
@@ -844,9 +844,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.106.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
- "integrity": "sha512-w3HV+CmqtJYTUT97FsWziSOzM0KnA81PnAJCG9w68T8jJMRIDk96FJJKFckFQbFLFfZWF7jAINf0OSEzalabaQ==",
+ "version": "0.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
+ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -889,9 +889,9 @@
@@ -889,9 +889,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
- "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
+ "version": "2.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
+ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -26,9 +26,9 @@
@@ -26,9 +26,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.106.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -139,6 +139,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -139,6 +139,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.slider { flex-wrap: wrap; } .slider input { flex: 1; min-width: 8rem; }
.slider { flex-wrap: wrap; } .slider input { flex: 1; min-width: 8rem; }
.colour label { display: flex; align-items: center; gap: 0.6rem; }
.colour label { display: flex; align-items: center; gap: 0.6rem; }
.tone { width: 100%; min-height: 56px; user-select: none; -webkit-user-select: none; touch-action: none; }
.tone { width: 100%; min-height: 56px; user-select: none; -webkit-user-select: none; touch-action: none; }
+.button-grid { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }
+.pad-button { min-height: 64px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.3rem;
+ font-weight: 600; user-select: none; -webkit-user-select: none; touch-action: none; }
+.pad-button kbd { font-weight: 400; color: var(--muted); }
+.pad-button.held kbd { color: inherit; border-color: currentColor; background: transparent; }
+.widget-video_click { order: -1; padding: 0; overflow: hidden; }
+.widget-video_click h2 { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+[data-video-click] { cursor: crosshair; }
.inline-form { display: flex; gap: 0.5rem; flex-wrap: wrap; } .inline-form > input, .inline-form > select { flex: 1; min-width: 8rem; }
.inline-form { display: flex; gap: 0.5rem; flex-wrap: wrap; } .inline-form > input, .inline-form > select { flex: 1; min-width: 8rem; }
/* Readouts and the latency meter */
/* Readouts and the latency meter */
@@ -213,6 +221,15 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -213,6 +221,15 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.setting-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }
.setting-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 0.9rem; min-width: 0; }
.setting-card form { display: grid; gap: 0.6rem; }
.setting-card form { display: grid; gap: 0.6rem; }
.setting-card button[type="submit"] { justify-self: start; }
.setting-card button[type="submit"] { justify-self: start; }
+.button-def { margin: 0; padding: 0.7rem; display: grid; gap: 0.6rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); min-width: 0; }
+.button-def legend { padding: 0 0.35rem; font-size: 0.85rem; font-weight: 600; color: var(--muted); }
+.button-def-grid { display: grid; gap: 0.5rem; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.field { display: grid; gap: 0.25rem; min-width: 0; color: var(--muted); font-size: 0.82rem; }
+.field input { width: 100%; padding: 0 0.65rem; border: 1px solid var(--line); background: var(--bg); color: var(--text); font-size: 0.95rem; }
+.field input::placeholder { color: var(--muted); opacity: 0.6; }
+.button-def-foot { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; }
+.check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.92rem; cursor: pointer; }
+.form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }
.setting-note { color: var(--muted); font-size: 0.88rem; margin: -0.35rem 0 0.7rem; }
.setting-note a { color: var(--accent); }
.setting-note a { color: var(--accent); }
.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);
.toggle { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: start; min-height: var(--tap);
@@ -198,11 +198,49 @@
@@ -198,11 +198,49 @@
el.classList.remove('held');
el.classList.remove('held');
send('actuator', { name: el.dataset.tone, value: null });
send('actuator', { name: el.dataset.tone, value: null });
}
}
+ // A hold button may have both a pointer and a key down. Only the first starts it; the last release
+ // sends up. Re-sending down keeps the device's deadline alive while the operator holds it.
+ const buttonHolds = new Map();
+ function pressProfileButton(el, source) {
+ if (el.disabled || !joined || !allowed.includes('button')) return;
+ const name = el.dataset.buttonName;
+ if (el.dataset.buttonHold !== 'true') { send('button', { name }); return; }
+ let h = buttonHolds.get(el);
+ if (!h) {
+ h = { sources: new Set(), timer: null };
+ buttonHolds.set(el, h);
+ el.classList.add('held');
+ send('button', { name, state: 'down' });
+ h.timer = setInterval(() => send('button', { name, state: 'down' }), HOLD_MS);
+ }
+ h.sources.add(source);
+ }
+ function releaseProfileButton(el, source) {
+ const h = buttonHolds.get(el);
+ if (!h) return;
+ h.sources.delete(source);
+ if (h.sources.size) return;
+ clearInterval(h.timer);
+ buttonHolds.delete(el);
+ el.classList.remove('held');
+ send('button', { name: el.dataset.buttonName, state: 'up' });
+ }
+ for (const el of $$('[data-button-name]')) {
+ el.addEventListener('pointerdown', (e) => {
+ e.preventDefault();
+ try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
+ pressProfileButton(el, `pointer:${e.pointerId}`);
+ });
+ for (const event of ['pointerup', 'pointercancel', 'lostpointercapture'])
+ el.addEventListener(event, (e) => releaseProfileButton(el, `pointer:${e.pointerId}`));
+ el.addEventListener('contextmenu', (e) => e.preventDefault());
+ }
function releaseAll() {
function releaseAll() {
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
}
}
for (const el of [...tones.keys()]) releaseTone(el);
for (const el of [...tones.keys()]) releaseTone(el);
+ for (const [el, h] of [...buttonHolds]) for (const source of [...h.sources]) releaseProfileButton(el, source);
for (const s of sticks) s.release();
for (const s of sticks) s.release();
pad.reset();
pad.reset();
for (const el of $$('.held')) el.classList.remove('held');
for (const el of $$('.held')) el.classList.remove('held');
@@ -311,12 +349,24 @@
@@ -311,12 +349,24 @@
holdRelease(DRIVE, source);
holdRelease(DRIVE, source);
}
}
const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
const typing = (e) => /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
+ const profileKey = (e) => main.contains(document.activeElement) &&
+ $$('[data-button-key]').find((el) => !el.disabled && (el.dataset.buttonKey === e.code || el.dataset.buttonKey.toLowerCase() === e.key.toLowerCase()));
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (typing(e)) return;
if (typing(e)) return;
+ const focusedButton = e.target.closest && e.target.closest('[data-button-name]');
+ if (focusedButton && (e.code === 'Enter' || e.code === 'Space')) {
+ e.preventDefault(); if (!e.repeat) pressProfileButton(focusedButton, `key:${e.code}`); return;
+ }
+ // Stop and the drive keys first: a button's key never shadows them.
if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }
if (e.code === 'Space') { e.preventDefault(); stopDrive(); return; }
- if (keyValue(e.code)) { e.preventDefault(); if (!e.repeat) keyPress(e.code, `key:${e.code}`); }
+ if (keyValue(e.code)) { e.preventDefault(); if (!e.repeat) keyPress(e.code, `key:${e.code}`); return; }
+ const button = profileKey(e);
+ if (button) { e.preventDefault(); if (!e.repeat) pressProfileButton(button, `key:${e.code}`); }
+ });
+ document.addEventListener('keyup', (e) => {
+ for (const [el, h] of buttonHolds) if (h.sources.has(`key:${e.code}`)) releaseProfileButton(el, `key:${e.code}`);
+ if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`);
});
});
- document.addEventListener('keyup', (e) => { if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`); });
function stopDrive() {
function stopDrive() {
const h = holds.get(DRIVE);
const h = holds.get(DRIVE);
@@ -433,6 +483,20 @@
@@ -433,6 +483,20 @@
const screen = $('[data-camera-screen]', cam);
const screen = $('[data-camera-screen]', cam);
if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;
if (w > 0 && h > 0 && screen) screen.style.aspectRatio = `${w} / ${h}`;
}
}
+ for (const screen of $$('[data-video-click]')) screen.addEventListener('click', (e) => {
+ if (!joined || !allowed.includes('point')) return;
+ const box = screen.getBoundingClientRect();
+ const video = $('video', screen);
+ let left = box.left; let top = box.top; let width = box.width; let height = box.height;
+ // object-fit: contain leaves letterboxing. The command coordinates name the displayed pixels.
+ if (video && !video.hidden && video.videoWidth && video.videoHeight) {
+ const scale = Math.min(width / video.videoWidth, height / video.videoHeight);
+ width = video.videoWidth * scale; height = video.videoHeight * scale;
+ left += (box.width - width) / 2; top += (box.height - height) / 2;
+ }
+ if (!width || !height || e.clientX < left || e.clientX > left + width || e.clientY < top || e.clientY > top + height) return;
+ send('point', { x: (e.clientX - left) / width, y: (e.clientY - top) / height });
+ });
// ── Live camera: the robot's OpenRe.Stream WebRTC session ────────────────────────────────────────
// ── Live camera: the robot's OpenRe.Stream WebRTC session ────────────────────────────────────────
// A tile the server marked with a live session's viewer signaling URL plays it: the mediasoup-client
// A tile the server marked with a live session's viewer signaling URL plays it: the mediasoup-client
@@ -17,15 +17,15 @@
@@ -17,15 +17,15 @@
const {
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
} = require('../util');
-const { getProfile } = require('../profiles');
+const { getProfile, validateProfile } = require('../profiles');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
const DEFAULT_ALLOW = {
const DEFAULT_ALLOW = {
- owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],
- operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],
- queue: ['drive', 'halt'],
+ owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
+ operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
+ queue: ['drive', 'button', 'point', 'halt'],
};
};
-const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
+const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt']);
const MOTION = new Set(['drive', 'actuator', 'ptz']);
const MOTION = new Set(['drive', 'actuator', 'ptz']);
const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);
const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);
// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.
// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.
@@ -64,6 +64,10 @@ function noteToHz(note) {
@@ -64,6 +64,10 @@ function noteToHz(note) {
return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
}
}
+
+/** The panel's drive and stop keys (public/panel.js KEYS, Space and Escape): a profile button never takes one. */
+const RESERVED_BUTTON_KEYS = new Set(['space', ' ', 'escape', 'w', 'a', 's', 'd', 'q', 'e', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright',
+ 'keyw', 'keya', 'keys', 'keyd', 'keyq', 'keye']);
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
@@ -131,6 +135,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -131,6 +135,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return db.many('SELECT * FROM robots WHERE owner_subject = $1 ORDER BY created_at DESC', [owner]);
return db.many('SELECT * FROM robots WHERE owner_subject = $1 ORDER BY created_at DESC', [owner]);
}
}
async function createRobot({ owner, name, profile_id, access_policy = 'private', limits = {}, installerUrl }) {
async function createRobot({ owner, name, profile_id, access_policy = 'private', limits = {}, installerUrl }) {
+ if (String(profile_id).startsWith('local.')) fail(422, 'bot.unknown_profile', 'choose a catalogue profile');
const profile = await getProfile(db, profile_id);
const profile = await getProfile(db, profile_id);
if (!profile) fail(422, 'bot.unknown_profile', `no profile ${profile_id}`);
if (!profile) fail(422, 'bot.unknown_profile', `no profile ${profile_id}`);
const cleanName = text(name, 'name', 80);
const cleanName = text(name, 'name', 80);
@@ -175,6 +180,67 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -175,6 +180,67 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
return getRobot(id);
return getRobot(id);
}
}
+ async function localProfile(robotId) {
+ return db.maybe('SELECT source_profile_id, profile FROM robot_profiles WHERE robot_id = $1', [robotId]);
+ }
+ async function saveLocalProfile(robotId, actor, { buttons, point }) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!actor || actor.subject !== robot.owner_subject) fail(403, 'bot.forbidden', 'only the owner may edit buttons');
+ for (const [name, b] of Object.entries(buttons || {})) {
+ if (b.key && RESERVED_BUTTON_KEYS.has(String(b.key).toLowerCase()))
+ fail(422, 'bot.invalid_input', `${b.key} drives or stops the robot; give ${name} another key`);
+ }
+ const local = await localProfile(robotId);
+ const sourceId = local ? local.source_profile_id : robot.profile_id;
+ const source = await getProfile(db, sourceId);
+ if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');
+ // Profile IDs are lowercase by contract; robot ULIDs are uppercase.
+ const id = `local.${robotId.toLowerCase()}`;
+ const base = source.profile;
+ const commands = { ...base.commands };
+ delete commands.button; delete commands.point;
+ const widgets = base.widgets.filter((w) => w.type !== 'buttons' && w.type !== 'video_click');
+ if (buttons && Object.keys(buttons).length) {
+ commands.button = { names: buttons };
+ widgets.push({ type: 'buttons', command: { kind: 'button' } });
+ }
+ if (point) {
+ commands.point = point.cooldown_ms == null ? {} : { cooldown_ms: point.cooldown_ms };
+ if (!widgets.some((w) => w.type === 'camera')) widgets.push({ type: 'camera' });
+ widgets.push({ type: 'video_click', command: { kind: 'point' } });
+ }
+ let profile;
+ try { profile = validateProfile({ ...base, id, version: 1, name: `${robot.name} controls`, commands, widgets }); }
+ catch (e) { if (e.code === 'bot.profile_invalid') fail(422, 'bot.profile_invalid', e.detail || e.message); throw e; }
+ await db.tx(async (t) => {
+ await t.query(`INSERT INTO robot_profiles (id, version, profile, created_at, robot_id, source_profile_id)
+ VALUES ($1, 1, $2::jsonb, $3, $4, $5)
+ ON CONFLICT (id, version) DO UPDATE SET profile = EXCLUDED.profile`,
+ [id, JSON.stringify(profile), iso(now()), robotId, sourceId]);
+ await t.query('UPDATE robots SET profile_id = $2, profile_version = 1, updated_at = $3 WHERE id = $1', [robotId, id, iso(now())]);
+ });
+ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'profile.local',
+ value: { buttons: Object.keys(buttons || {}), point: !!point }, result: 'ack' });
+ if (link && link.refreshConfig) await link.refreshConfig(robotId);
+ if (link && link.broadcast) link.broadcast(robotId);
+ return profile;
+ }
+ async function useCatalogueProfile(robotId, actor) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ if (!actor || actor.subject !== robot.owner_subject) fail(403, 'bot.forbidden', 'only the owner may change the profile');
+ const local = await localProfile(robotId);
+ if (!local || robot.profile_id !== `local.${robotId.toLowerCase()}`) fail(422, 'bot.invalid_profile', 'this robot is using its catalogue profile');
+ const source = await getProfile(db, local.source_profile_id);
+ if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');
+ await db.query('UPDATE robots SET profile_id = $2, profile_version = $3, updated_at = $4 WHERE id = $1', [robotId, source.id, source.version, iso(now())]);
+ await auditCommand({ robotId, subject: actor.subject, operatorKind: actor.kind, role: 'owner', kind: 'profile.catalogue',
+ value: { profile_id: source.id }, result: 'ack' });
+ if (link && link.refreshConfig) await link.refreshConfig(robotId);
+ if (link && link.broadcast) link.broadcast(robotId);
+ return source.profile;
+ }
/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */
/** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */
async function setEmbedPublic(id, value) {
async function setEmbedPublic(id, value) {
await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);
await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);
@@ -202,7 +268,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -202,7 +268,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const networkPairing = () => config.pairing.authority === 'network';
const networkPairing = () => config.pairing.authority === 'network';
async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
const robot = robotRow || await getRobot(robotId);
const robot = robotRow || await getRobot(robotId);
- const driver = driverForProfile(robot && robot.profile_id);
+ const local = robot && robot.profile_id.startsWith('local.') ? await localProfile(robotId) : null;
+ const driver = driverForProfile(local ? local.source_profile_id : robot && robot.profile_id);
if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);
if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);
const code = newCode();
const code = newCode();
const at = iso(now());
const at = iso(now());
@@ -739,6 +806,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -739,6 +806,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// Cooldown is measured from the last command sent (not from an acknowledgement), per operator and
// Cooldown is measured from the last command sent (not from an acknowledgement), per operator and
// kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.
// kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.
const lastCommandAt = new Map();
const lastCommandAt = new Map();
+ const activeButtons = new Map();
const markCooldown = (robotId, subject, kind) => {
const markCooldown = (robotId, subject, kind) => {
lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
@@ -801,6 +869,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -801,6 +869,23 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (kind === 'halt') return {};
if (kind === 'halt') return {};
const spec = profile.commands[kind];
const spec = profile.commands[kind];
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ if (kind === 'button') {
+ const name = typeof v.name === 'string' ? v.name : '';
+ if (!Object.hasOwn(spec.names, name)) fail(422, 'bot.command_not_allowed', 'this robot has no such button');
+ if (Object.keys(v).some((k) => k !== 'name' && k !== 'state')) fail(422, 'bot.invalid_input', 'button takes only name and state');
+ const hold = !!spec.names[name].hold;
+ if (hold && !['down', 'up'].includes(v.state)) fail(422, 'bot.invalid_input', 'a hold button needs down or up');
+ if (!hold && Object.hasOwn(v, 'state')) fail(422, 'bot.invalid_input', 'a plain button takes no state');
+ return hold ? { name, state: v.state } : { name };
+ }
+ if (kind === 'point') {
+ if (Object.keys(v).some((k) => k !== 'x' && k !== 'y') ||
+ typeof v.x !== 'number' || !Number.isFinite(v.x) || v.x < 0 || v.x > 1 ||
+ typeof v.y !== 'number' || !Number.isFinite(v.y) || v.y < 0 || v.y > 1) {
+ fail(422, 'bot.invalid_input', 'point needs x and y between 0 and 1');
+ }
+ return { x: v.x, y: v.y };
+ }
if (kind === 'drive') {
if (kind === 'drive') {
// Every declared axis is sent; an absent one is 0, so a drive frame always says the whole motion.
// Every declared axis is sent; an absent one is 0, so a drive frame always says the whole motion.
const out = {};
const out = {};
@@ -873,7 +958,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -873,7 +958,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };
if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };
const eff = effectiveLimits(robot, profile);
const eff = effectiveLimits(robot, profile);
- const cooled = eff.cooldownMs > 0 && role !== 'owner' && !halt;
+ const release = kind === 'button' && value && value.state === 'up';
+ const activeKey = kind === 'button' && value && typeof value.name === 'string' ? `${robotId}|${subject}|${value.name}` : null;
+ const repeatedHold = !!(activeKey && value.state === 'down' && activeButtons.get(activeKey) > now());
+ const cooled = eff.cooldownMs > 0 && role !== 'owner' && !halt && !release && !repeatedHold;
if (cooled) {
if (cooled) {
const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);
const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);
if (last && now() - last < eff.cooldownMs) {
if (last && now() - last < eff.cooldownMs) {
@@ -882,7 +970,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -882,7 +970,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
if (role === 'queue') {
if (role === 'queue') {
// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.
// A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.
- if (halt) {
+ if (halt || release || repeatedHold) {
const turn = await currentTurn(robotId);
const turn = await currentTurn(robotId);
if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };
if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };
} else {
} else {
@@ -892,17 +980,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -892,17 +980,29 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
let built;
let built;
try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
+ const specificMs = kind === 'button' ? profile.commands.button.names[built.name].cooldown_ms || 0
+ : kind === 'point' ? profile.commands.point.cooldown_ms || 0 : 0;
+ const specificKey = kind === 'button' ? `button:${built.name}` : 'point';
+ if (specificMs > 0 && !release && !repeatedHold) {
+ const last = lastCommandAt.get(`${robotId}|${subject}|${specificKey}`);
+ if (last && now() - last < specificMs) return { ok: false, code: 'bot.cooldown', reason: `wait ${specificMs} ms between ${specificKey} commands`, robot, role };
+ }
if (cooled) markCooldown(robotId, subject, kind);
if (cooled) markCooldown(robotId, subject, kind);
- const deadlineMs = MOTION.has(kind)
+ if (specificMs > 0 && !release && !repeatedHold) markCooldown(robotId, subject, specificKey);
+ const deadlineMs = (MOTION.has(kind) || (kind === 'button' && built.state === 'down'))
? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
: null;
: null;
+ if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);
+ if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
+ if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);
return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };
return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };
}
}
return {
return {
db, now, config, log, outbox, link,
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot },
+ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot,
+ localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* URL, on that one host, with a timeout and `redirect: 'error'` — a camera cannot steer Bot elsewhere.
* URL, on that one host, with a timeout and `redirect: 'error'` — a camera cannot steer Bot elsewhere.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
-const { getProfile, listProfiles } = require('../profiles');
+const { getProfile } = require('../profiles');
const isOnvifProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'onvif');
const isOnvifProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'onvif');
@@ -130,9 +130,8 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
@@ -130,9 +130,8 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
}
}
async function startAll() {
async function startAll() {
- const ids = (await listProfiles(domain.db)).filter((r) => isOnvifProfile(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]);
+ const robots = await domain.db.many(`SELECT r.* FROM robots r JOIN robot_profiles p
+ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'onvif'`);
let n = 0;
let n = 0;
for (const robot of robots) if (await attach(robot)) n++;
for (const robot of robots) if (await attach(robot)) n++;
return n;
return n;
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { BotError } = require('../util');
const { BotError } = require('../util');
+const { validate } = require('openvibe-contracts');
const DIR = path.join(__dirname);
const DIR = path.join(__dirname);
@@ -41,7 +42,7 @@ const CAPABILITIES = new Set([
@@ -41,7 +42,7 @@ const CAPABILITIES = new Set([
]);
]);
/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */
/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */
-const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift']);
+const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);
/** Device drivers a mapping may name. */
/** Device drivers a mapping may name. */
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim']);
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim']);
@@ -68,8 +69,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
@@ -68,8 +69,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
const DISPLAY_MODES = new Set(['text', 'face', 'image_png_b64']);
const DISPLAY_MODES = new Set(['text', 'face', 'image_png_b64']);
-const COMMAND_KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
+const COMMAND_KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt', 'button', 'point']);
const NAME_RE = /^[a-z][a-z0-9_]{0,23}$/;
const NAME_RE = /^[a-z][a-z0-9_]{0,23}$/;
+const BUTTON_RE = /^[a-z][a-z0-9_]{0,31}$/;
function validateAxes(where, axes, allowed) {
function validateAxes(where, axes, allowed) {
if (!isObject(axes) || !Object.keys(axes).length) bad(`${where}.axes must name at least one axis`);
if (!isObject(axes) || !Object.keys(axes).length) bad(`${where}.axes must name at least one axis`);
@@ -129,6 +131,22 @@ function validateCommands(id, commands) {
@@ -129,6 +131,22 @@ function validateCommands(id, commands) {
d.faces = spec.faces.slice();
d.faces = spec.faces.slice();
}
}
out.display = d;
out.display = d;
+ } else if (kind === 'button') {
+ if (!isObject(spec.names) || !Object.keys(spec.names).length) bad(`${where}.names must name at least one button`);
+ const names = {};
+ for (const [name, button] of Object.entries(spec.names)) {
+ if (!BUTTON_RE.test(name)) bad(`${where}: button name ${name} is not valid`);
+ if (!isObject(button) || typeof button.label !== 'string' || !button.label.trim() || button.label.length > 40) bad(`${where}.names.${name}.label is invalid`);
+ if (button.key != null && (typeof button.key !== 'string' || button.key.length > 16)) bad(`${where}.names.${name}.key is invalid`);
+ if (button.cooldown_ms != null && !(int(button.cooldown_ms) >= 0)) bad(`${where}.names.${name}.cooldown_ms is invalid`);
+ if (button.hold != null && typeof button.hold !== 'boolean') bad(`${where}.names.${name}.hold is invalid`);
+ names[name] = { label: button.label, ...(button.key ? { key: button.key } : {}),
+ ...(button.cooldown_ms != null ? { cooldown_ms: button.cooldown_ms } : {}), ...(button.hold ? { hold: true } : {}) };
+ }
+ out.button = { names };
+ } else if (kind === 'point') {
+ if (spec.cooldown_ms != null && !(int(spec.cooldown_ms) >= 0)) bad(`${where}.cooldown_ms is invalid`);
+ out.point = spec.cooldown_ms != null ? { cooldown_ms: spec.cooldown_ms } : {};
} else if (Object.keys(spec).length) bad(`${where} takes no options`);
} else if (Object.keys(spec).length) bad(`${where} takes no options`);
}
}
out.halt = {};
out.halt = {};
@@ -176,6 +194,8 @@ function validateProfile(p) {
@@ -176,6 +194,8 @@ function validateProfile(p) {
};
};
if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);
if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);
if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);
if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);
+ const contract = validate('bot.robot-profile@1', p);
+ if (!contract.valid) bad(`bot.robot-profile@1: ${JSON.stringify(contract.errors)}`);
return {
return {
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
@@ -220,7 +240,7 @@ async function getProfile(db, id, version = null) {
@@ -220,7 +240,7 @@ async function getProfile(db, id, version = null) {
}
}
async function listProfiles(db) {
async function listProfiles(db) {
- return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles ORDER BY id, version DESC');
+ return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles WHERE robot_id IS NULL ORDER BY id, version DESC');
}
}
module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
* No device row is written: the simulator's id is `dev_sim_<robot id>`, and a real device online for the same
* No device row is written: the simulator's id is `dev_sim_<robot id>`, and a real device online for the same
* robot takes precedence over it.
* robot takes precedence over it.
*/
*/
-const { listProfiles, getProfile } = require('../profiles');
+const { getProfile } = require('../profiles');
const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
const isSimProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'sim');
const BATTERY_FLOOR = 20; // % the simulated pack never drains below
const BATTERY_FLOOR = 20; // % the simulated pack never drains below
@@ -125,9 +125,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -125,9 +125,8 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
}
}
async function startAll() {
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]);
+ const robots = await domain.db.many(`SELECT r.* FROM robots r JOIN robot_profiles p
+ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'sim'`);
for (const robot of robots) await attach(robot);
for (const robot of robots) await attach(robot);
return robots.length;
return robots.length;
}
}
@@ -140,7 +140,7 @@ function camerasOf(profile = {}) {
@@ -140,7 +140,7 @@ function camerasOf(profile = {}) {
* `signaling_url` is the robot's live OpenRe WebRTC session, when one is; the tile carries it (escaped)
* `signaling_url` is the robot's live OpenRe WebRTC session, when one is; the tile carries it (escaped)
* so public/panel.js can play it — the server itself emits no script.
* so public/panel.js can play it — the server itself emits no script.
*/
*/
-function cameraTiles(w, profile, signaling_url = null) {
+function cameraTiles(w, profile, signaling_url = null, pointEnabled = false) {
const all = camerasOf(profile).map((c, i) => ({ c, i }));
const all = camerasOf(profile).map((c, i) => ({ c, i }));
const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;
const pick = w.camera != null ? all.filter(({ c, i }) => c.name === w.camera || i === w.camera) : all;
return (pick.length ? pick : all).map(({ c, i }) => {
return (pick.length ? pick : all).map(({ c, i }) => {
@@ -149,7 +149,7 @@ function cameraTiles(w, profile, signaling_url = null) {
@@ -149,7 +149,7 @@ function cameraTiles(w, profile, signaling_url = null) {
const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
const caption = [title, res.replace('x', '×'), c.transport ? String(c.transport).toUpperCase() : ''].filter(Boolean).join(' · ');
return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}`
return `<figure class="camera" data-camera="${esc(i)}"${res ? ` data-resolution="${esc(res)}"` : ''}${c.transport ? ` data-transport="${esc(c.transport)}"` : ''}`
+ `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`
+ `${signaling_url ? ` data-signaling-url="${esc(signaling_url)}"` : ''}>`
- + `<div class="camera-screen" data-camera-screen>${icon('camera', 'icon camera-icon')}`
+ + `<div class="camera-screen" data-camera-screen${pointEnabled ? ' data-video-click' : ''}>${icon('camera', 'icon camera-icon')}`
+ '<p class="camera-title">No live video right now.</p>'
+ '<p class="camera-title">No live video right now.</p>'
+ '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'
+ '<p class="camera-sub">The camera shows here while the robot is online and sending video.</p></div>'
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
+ (caption ? `<figcaption>${esc(caption)}</figcaption>` : '')
@@ -209,7 +209,19 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -209,7 +209,19 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
+ '</div>';
+ '</div>';
break;
break;
case 'camera':
case 'camera':
- inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url)}</div>`;
+ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url,
+ (profile.widgets || []).some((widget) => widget.type === 'video_click') && allowed_commands.includes('point'))}</div>`;
+ break;
+ case 'buttons': {
+ const names = (commands.button && commands.button.names) || {};
+ inner = `<div class="button-grid">${Object.entries(names).map(([name, button]) =>
+ `<button type="button" class="hold pad-button" data-button-name="${esc(name)}" data-button-hold="${button.hold ? 'true' : 'false'}"${button.key ? ` data-button-key="${esc(button.key)}"` : ''}${disabledUnless(enabled)}>`
+ + `<span>${esc(button.label)}</span>${button.key ? `<kbd>${esc(button.key)}</kbd>` : ''}</button>`).join('')}</div>`;
+ break;
+ }
+ case 'video_click':
+ if ((profile.widgets || []).some((widget) => widget.type === 'camera')) return '';
+ inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url, enabled)}</div>`;
break;
break;
default:
default:
inner = '';
inner = '';
@@ -258,6 +270,43 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets
@@ -258,6 +270,43 @@ ${toggle('live', s.live && s.live.on, 'Show on my OpenVibe.Live channel', 'Lets
</form></section>`;
</form></section>`;
}
}
+/** Robot-owned button profile editor. Add and remove submit the form and redraw it without saving. */
+function localProfileForm(robot, profile, settings = {}) {
+ const rows = settings.rows || Object.entries((profile.commands.button && profile.commands.button.names) || {})
+ .map(([name, b]) => ({ name, label: b.label, key: b.key || '', cooldown: b.cooldown_ms == null ? '' : String(b.cooldown_ms), hold: !!b.hold }));
+ const shown = rows.length ? rows : [{ name: '', label: '', key: '', cooldown: '', hold: false }];
+ const point = settings.point !== undefined ? settings.point : !!profile.commands.point;
+ const pointCooldown = settings.pointCooldown !== undefined ? settings.pointCooldown :
+ (profile.commands.point && profile.commands.point.cooldown_ms != null ? String(profile.commands.point.cooldown_ms) : '');
+ const fields = shown.map((row, i) => `<fieldset class="button-def">
+<legend>Button ${i + 1}</legend>
+<div class="button-def-grid">
+<label class="field"><span>Name</span><input name="name_${i}" value="${esc(row.name)}" maxlength="32" autocomplete="off" pattern="[a-z][a-z0-9_]{0,31}" placeholder="horn"></label>
+<label class="field"><span>Label</span><input name="label_${i}" value="${esc(row.label)}" maxlength="40" autocomplete="off" placeholder="Horn"></label>
+<label class="field"><span>Key</span><input name="key_${i}" value="${esc(row.key)}" maxlength="16" autocomplete="off" placeholder="h"></label>
+<label class="field"><span>Cooldown (ms)</span><input name="cooldown_${i}" type="number" min="0" step="1" value="${esc(row.cooldown)}" placeholder="0"></label>
+</div>
+<div class="button-def-foot">
+<label class="check"><input name="hold_${i}" type="checkbox" value="on"${row.hold ? ' checked' : ''}> Hold while pressed</label>
+<button type="submit" class="quiet" name="action" value="remove:${i}">Remove</button>
+</div>
+</fieldset>`).join('');
+ return `<section class="setting-card" aria-labelledby="buttons-h"><h2 id="buttons-h">Buttons</h2>
+<p class="setting-note">Each button sends its name to the robot. A key works while the panel has focus; the drive keys and Space stay with driving.</p>
+${settings.error ? `<p class="error" role="alert">${esc(settings.error)}</p>` : ''}
+<form method="post" action="/robots/${esc(robot.id)}/profile" data-profile-form>
+<input type="hidden" name="count" value="${shown.length}">
+${fields}
+<button type="submit" class="quiet" name="action" value="add">Add a button</button>
+<label class="toggle"><input type="checkbox" name="point" value="on"${point ? ' checked' : ''}><span><b>Video click</b><small>A click on the camera sends where it landed, for drivers.</small></span></label>
+<label class="field"><span>Video click cooldown (ms)</span><input type="number" name="point_cooldown" min="0" step="1" value="${esc(pointCooldown)}" placeholder="0"></label>
+<div class="form-actions">
+<button type="submit" class="primary" name="action" value="save">Save buttons</button>
+${settings.catalogueId && robot.profile_id.startsWith('local.') ? `<button type="submit" class="quiet" name="action" value="catalogue">Use ${esc(settings.catalogueId)} instead</button>` : ''}
+</div>
+</form></section>`;
+}
+
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
/** A link out of a frame: always a new tab, never with a handle back to the framing page. */
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(href)}" target="_blank" rel="noopener">${esc(text)}</a>`;
@@ -296,7 +345,7 @@ function turnStrip(robot, role) {
@@ -296,7 +345,7 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {} }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@ -308,7 +357,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -308,7 +357,7 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
</header>
-<main id="panel" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
+<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}">
<div class="panel-head">
<div class="panel-head">
<h1>${esc(robot.name)}</h1>
<h1>${esc(robot.name)}</h1>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
@@ -319,7 +368,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink
@@ -319,7 +368,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in">${outLink
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${role === 'owner' && !embed ? `<div class="owner-settings">${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${role === 'owner' && !embed ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: embed ? 'embed-page' : 'panel-page' });
}
}
@@ -250,7 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -250,7 +250,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const role = await domain.members.roleOf(robot.id, me.subject);
const role = await domain.members.roleOf(robot.id, me.subject);
return role || (robot.access_policy === 'queue' ? 'queue' : null);
return role || (robot.access_policy === 'queue' ? 'queue' : null);
}
}
- async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
+ async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true, profileForm = {}) {
await attachInProcess(robot, profile);
await attachInProcess(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
// Two OpenRe reads, run together. Only the owner's own page shows the streaming switches, greyed out
// Two OpenRe reads, run together. Only the owner's own page shows the streaming switches, greyed out
@@ -270,12 +270,69 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -270,12 +270,69 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
+ profileForm,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
allowed_commands: role === 'watcher' ? [] : 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.
// 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))),
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
}));
}));
}
}
+ // The owner can redraw button rows without JavaScript, save a validated robot-local profile, or return
+ // to the catalogue source. The domain checks ownership too, so the write cannot bypass this route.
+ r.post('/robots/:id/profile', ...manage, express.urlencoded({ extended: false, limit: '16kb' }), page(async (req, res) => {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ 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 edit buttons');
+ const action = String(req.body.action || '');
+ if (action === 'catalogue') {
+ await domain.robots.useCatalogueProfile(robot.id, me);
+ return res.redirect(303, `/panel/${robot.id}`);
+ }
+ const count = Number(req.body.count);
+ if (!Number.isInteger(count) || count < 1 || count > 32) throw new BotError(422, 'bot.invalid_input', 'invalid button rows');
+ const one = (key) => typeof req.body[key] === 'string' ? req.body[key].trim() : '';
+ const rows = Array.from({ length: count }, (_, i) => ({
+ name: one(`name_${i}`), label: one(`label_${i}`), key: one(`key_${i}`),
+ cooldown: one(`cooldown_${i}`), hold: req.body[`hold_${i}`] === 'on',
+ }));
+ const state = { rows, point: req.body.point === 'on', pointCooldown: one('point_cooldown') };
+ if (action === 'add') rows.push({ name: '', label: '', key: '', cooldown: '', hold: false });
+ else if (/^remove:\d+$/.test(action)) {
+ const i = Number(action.slice(7));
+ if (i >= rows.length) throw new BotError(422, 'bot.invalid_input', 'invalid button row');
+ rows.splice(i, 1);
+ } else if (action !== 'save') throw new BotError(422, 'bot.invalid_input', 'invalid profile action');
+ if (action !== 'save') {
+ const local = await domain.robots.localProfile(robot.id);
+ state.catalogueId = local ? local.source_profile_id : robot.profile_id;
+ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);
+ }
+ try {
+ const buttons = {};
+ for (const row of rows) {
+ if (!row.name && !row.label && !row.key && !row.cooldown && !row.hold) continue;
+ if (!/^[a-z][a-z0-9_]{0,31}$/.test(row.name) || !row.label || Object.hasOwn(buttons, row.name))
+ throw new BotError(422, 'bot.invalid_input', 'each button needs a unique valid name and a label');
+ if (row.cooldown && !/^(0|[1-9]\d*)$/.test(row.cooldown)) throw new BotError(422, 'bot.invalid_input', 'button cooldown must be whole milliseconds');
+ buttons[row.name] = { label: row.label, ...(row.key ? { key: row.key } : {}),
+ ...(row.cooldown ? { cooldown_ms: Number(row.cooldown) } : {}), ...(row.hold ? { hold: true } : {}) };
+ }
+ if (state.pointCooldown && !/^(0|[1-9]\d*)$/.test(state.pointCooldown)) throw new BotError(422, 'bot.invalid_input', 'video click cooldown must be whole milliseconds');
+ await domain.robots.saveLocalProfile(robot.id, me, { buttons,
+ point: state.point ? { ...(state.pointCooldown ? { cooldown_ms: Number(state.pointCooldown) } : {}) } : null });
+ } catch (e) {
+ if (e.status !== 422) throw e;
+ res.status(422);
+ const local = await domain.robots.localProfile(robot.id);
+ state.catalogueId = local ? local.source_profile_id : robot.profile_id;
+ state.error = e.detail || e.message;
+ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, state);
+ }
+ return res.redirect(303, `/panel/${robot.id}`);
+ }));
+
/** The non-owner members with their Network names, or null names when Network cannot be asked. */
/** The non-owner members with their Network names, or null names when Network cannot be asked. */
async function peopleFor(robot) {
async function peopleFor(robot) {
const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);
const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);
@@ -301,7 +358,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -301,7 +358,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
if (!role) throw new BotError(403, 'bot.not_an_operator', 'you have no access to this robot');
const profile = await profileOf(robot);
const profile = await profileOf(robot);
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
if (!profile) throw new BotError(404, 'bot.profile_not_found', 'this robot has no profile');
- await sendPanel(res, robot, profile, role);
+ const local = role === 'owner' ? await domain.robots.localProfile(robot.id) : null;
+ await sendPanel(res, robot, profile, role, 'page', true, { catalogueId: local ? local.source_profile_id : robot.profile_id });
}));
}));
// The embed's own CSP replaces the app's for this route only; it sets no cookie and needs none to watch.
// The embed's own CSP replaces the app's for this route only; it sets no cookie and needs none to watch.
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+// Robot-owned profiles, the button/point gate and the server-rendered controls. No HTTP listener.
+const assert = require('assert');
+const { check, done } = require('./helpers/app');
+const { testDb } = require('./helpers/db');
+const { seedProfiles, loadProfiles, listProfiles, validateProfile } = require('../server/profiles');
+const { createDomain } = require('../server/domain');
+const { prefixedId } = require('../server/util');
+const { renderPanel } = require('../server/web/render');
+
+(async () => {
+ const store = await testDb();
+ const db = store.db;
+ await seedProfiles(db, { log: { log() {} } });
+ let clock = Date.now();
+ const id = prefixedId('rob', clock);
+ const owner = { subject: 'usr_owner', kind: 'user' };
+ const operator = { subject: 'usr_operator', kind: 'user' };
+ const at = new Date(clock).toISOString();
+ await db.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
+ VALUES ($1, $2, 'Test robot', 'sim.rover', 1, 'invite', '{"cooldown_ms":0}', $3, $3)`, [id, owner.subject, at]);
+ await db.query(`INSERT INTO robot_operators (robot_id, subject, role, created_at) VALUES ($1, $2, 'owner', $4), ($1, $3, 'operator', $4)`,
+ [id, owner.subject, operator.subject, at]);
+ const domain = createDomain({ db, outbox: null, now: () => clock,
+ config: { control: { cooldownMs: 0, maxCommandMs: 300 }, device: { heartbeatMs: 1000 }, media: {} } });
+
+ try {
+ await check('every existing catalogue profile still loads under the contract', () => {
+ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'sim.rover']);
+ });
+
+ await check('an operator cannot edit or switch the robot-owned profile', async () => {
+ await assert.rejects(domain.robots.saveLocalProfile(id, operator, { buttons: { go: { label: 'Go' } } }), (e) => e.status === 403);
+ assert.strictEqual(await domain.robots.localProfile(id), null);
+ });
+
+ await check('schema-invalid local profiles are never stored', async () => {
+ await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'x'.repeat(41) } } }), (e) => e.status === 422);
+ // The drive and stop keys stay with driving: a button never takes Space, WASD, Q/E or an arrow.
+ for (const key of ['Space', ' ', 'w', 'KeyD', 'ArrowUp', 'e', 'Escape'])
+ await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go', key } } }),
+ (e) => e.status === 422 && /drives or stops/.test(e.detail || e.message), `key ${JSON.stringify(key)}`);
+ assert.strictEqual(await domain.robots.localProfile(id), null);
+ assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');
+ });
+
+ await check('owner saves a local profile; its contract, ownership and audit are stored', async () => {
+ const p = await domain.robots.saveLocalProfile(id, owner, { buttons: {
+ go: { label: 'Go', key: 'KeyG', cooldown_ms: 1000 },
+ grab: { label: 'Grab', hold: true, cooldown_ms: 800 },
+ }, point: { cooldown_ms: 500 } });
+ assert.ok(p.id.startsWith('local.rob_'));
+ assert.ok(validateProfile(p));
+ const row = await db.maybe('SELECT robot_id, source_profile_id FROM robot_profiles WHERE id = $1', [p.id]);
+ assert.strictEqual(row.robot_id, id);
+ assert.strictEqual(row.source_profile_id, 'sim.rover');
+ assert.strictEqual((await domain.robots.get(id)).profile_id, p.id);
+ assert.strictEqual((await domain.audit.list(id, { limit: 1 }))[0].kind, 'profile.local');
+ assert.ok(!(await listProfiles(db)).some((x) => x.id === p.id), 'the local profile is absent from the public catalogue');
+ const atBoot = await db.many(`SELECT r.id FROM robots r JOIN robot_profiles p
+ ON p.id = r.profile_id AND p.version = r.profile_version WHERE p.profile->'mapping'->>'driver' = 'sim'`);
+ assert.ok(atBoot.some((x) => x.id === id), 'the local simulator profile attaches at boot');
+ });
+
+ const gate = (principal, kind, value) => domain.control.prepare({ robotId: id, principal, kind, value, online: true });
+ await check('button names and states, hold deadline, and per-button cooldown', async () => {
+ assert.strictEqual((await gate(owner, 'button', { name: 'missing' })).code, 'bot.command_not_allowed');
+ assert.strictEqual((await gate(owner, 'button', { name: 'go', state: 'down' })).code, 'bot.invalid_input');
+ assert.strictEqual((await gate(owner, 'button', { name: 'go', state: null })).code, 'bot.invalid_input');
+ assert.strictEqual((await gate(owner, 'button', { name: 'grab' })).code, 'bot.invalid_input');
+ assert.strictEqual((await gate(owner, 'button', { name: 'grab', state: 'sideways' })).code, 'bot.invalid_input');
+ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).ok, true);
+ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).code, 'bot.cooldown');
+ const down = await gate(operator, 'button', { name: 'grab', state: 'down' });
+ assert.ok(down.ok && down.deadlineMs > clock);
+ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'down' })).ok, true, 'held resend renews the deadman');
+ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'up' })).ok, true, 'release passes cooldown');
+ assert.strictEqual((await gate(operator, 'button', { name: 'grab', state: 'down' })).code, 'bot.cooldown');
+ clock += 1001;
+ assert.strictEqual((await gate(operator, 'button', { name: 'go' })).ok, true);
+ });
+
+ await check('point requires declaration and coordinates inside the displayed box', async () => {
+ for (const value of [{ x: -0.1, y: 0 }, { x: 1.1, y: 0 }, { x: 0, y: NaN }, { x: 0.5 }, { x: '0.5', y: 0.5 }])
+ assert.strictEqual((await gate(operator, 'point', value)).code, 'bot.invalid_input');
+ assert.strictEqual((await gate(operator, 'point', { x: 0, y: 1 })).ok, true);
+ assert.strictEqual((await gate(operator, 'point', { x: 1, y: 0 })).code, 'bot.cooldown');
+ await domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go' } }, point: null });
+ assert.strictEqual((await gate(operator, 'point', { x: 0.5, y: 0.5 })).code, 'bot.command_not_allowed');
+ });
+
+ await check('panel and embed show declared labels; viewers have disabled buttons', async () => {
+ const p = await domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go' }, grab: { label: 'Grab', hold: true } }, point: {} });
+ const robot = domain.present.robot(await domain.robots.get(id));
+ const ownerHtml = renderPanel({ robot, profile: p, role: 'owner', allowed_commands: ['button', 'point'] });
+ assert.match(ownerHtml, /data-button-name="go"[^>]*><span>Go<\/span><\/button>/);
+ assert.match(ownerHtml, /data-button-name="grab"[^>]*><span>Grab<\/span><\/button>/);
+ assert.match(ownerHtml, /<section class="widget widget-buttons" data-widget="buttons"[^>]*><h2>buttons<\/h2>/, 'a titled card like every widget');
+ assert.ok(ownerHtml.includes('data-video-click'));
+ assert.ok(ownerHtml.includes('data-profile-form'));
+ const viewerHtml = renderPanel({ robot, profile: p, role: 'watcher', allowed_commands: [], mode: 'embed' });
+ assert.match(viewerHtml, /data-button-name="go"[^>]*disabled><span>Go<\/span><\/button>/);
+ assert.ok(!viewerHtml.includes('data-video-click') && !viewerHtml.includes('data-profile-form'));
+ });
+
+ await check('only the owner can return to the catalogue profile, with an audit row', async () => {
+ await assert.rejects(domain.robots.useCatalogueProfile(id, operator), (e) => e.status === 403);
+ await domain.robots.useCatalogueProfile(id, owner);
+ assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');
+ assert.strictEqual((await domain.audit.list(id, { limit: 1 }))[0].kind, 'profile.catalogue');
+ });
+ } finally {
+ await store.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exitCode = 1; });