openvibers/openvibe.bot Commits

Bot: convert OpenVibe.Live's stream controls into relay robots (plan T15 R9 step 5); contracts 0.112.0 (#41)

OP OpenVibers committed 7 Oct 2026 · 24fcc63e6e · 15 files, +740 −25

## Changes (file:line)

- `migrations/0007_live_conversions.sql:10` — new `live_conversions` table (live config id → robot, owner, `detail`, timestamp); makes `--apply` idempotent and keeps the command map durably.
- `server/profiles/relay.generic.json:1` — new catalogue profile: the bare relay robot (capability `relay`, driver/plugin `relay`, `buttons` widget, no drive).
- `server/profiles/index.js:44` — register the `relay` capability; `:51` register the `relay` driver; `:57`,`:206` — the contract's `mapping.driver` enum is closed and does not know `relay`, so Bot's DRIVERS is authoritative and the contract is asked about a driver it knows.
- `scripts/live-controls-export.sh:1` — read-only export (`sqlite3 -readonly -json`); `:42-49` subject resolution (users.subject_id → linked_accounts → subject_projection); `:55` config owners ∪ whitelisted users; `:75` one JSON document.
- `scripts/convert-live-controls.js:104` `buildPlan` (pure dry-run plan), `:198` `formatPlan`, `:239` `applyPlan` (domain writes + `live_conversions` + audit), `:298` `convert`/CLI; `:33` relay profile id.
- `test/live-controls-convert.test.js:1` — new suite (dry-run plan, apply, idempotency, skip).
- `test/helpers/plugins.js:137` relay plugin contract; `:178` wired into `contractFor`.
- `test/profiles.test.js:61,103` — six profiles, relay.generic assertions, `buttons`/`video_click` in `CONTROL_WIDGETS`, `button`/`point` samples.
- `test/local-controls.test.js:29` and `test/api.test.js:30` — profile catalogue now includes `relay.generic`.
- `docs/live-controls-migration.md:53` — item 5 marked built; `:70` "Running the conversion"; `:98` "What each owner does next"; `:120` order/cutover updated.
- `STATUS.json:35` — features list the new profile.

## Dry-run output for the fixture

```
OpenVibe.Live controls → Bot robots — 1 to convert, 1 skipped
exported 2026-10-07T12:00:00Z

config 1  "Camp controls"  owner alice <usr_01J8Z4M2Q0R7T9YV3K6N8P1W2X>
  robot "alice's controls"  profile relay.generic  access private
  - wave  "Wave harder"  ← "wave"  key KeyG  cooldown 250ms
      note: the latest stream overrides label, cooldown_ms; the override wins
  - grip_it  "Grip"  ← "grip it"  cooldown 800ms  hold
      note: key "w" dropped: the panel drives and stops with it
  - spin_wheel  "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"  ← "Spin Wheel"  cooldown 0ms
      note: label truncated to 40 characters
  - DROPPED "retired" "Retired": disabled in Live (is_enabled 0)
  - DROPPED "pan_left" "Pan left": an ONVIF control: ONVIF moves go to Bot's server-side camera (camera.onvif), not a relay button

Changes

ddiff --git a/README.md b/README.md
iindex 1a136ce..b1b43fe 100644
---- a/README.md
++++ b/README.md
@@@ -43,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.110.0, `openvibe-sdk` v0.35.0 and `openvibe-shared` v2.13.0 (package.json).
++- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.35.0 and `openvibe-shared` v2.13.0 (package.json).
## Capabilities
ddiff --git a/STATUS.json b/STATUS.json
iindex 50283b0..6d96c8c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,9 +5,9 @@
"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.110.0",
++ "contracts": "openvibe-contracts v0.112.0",
"packages": [
-- "openvibe-contracts v0.110.0",
++ "openvibe-contracts v0.112.0",
"openvibe-sdk v0.35.0",
"openvibe-shared v2.13.0"
],
@@@ -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",
-- "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",
++ "catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover and relay.generic (the bare relay robot OpenVibe.Live's controls convert to) 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",
ddiff --git a/docs/live-controls-migration.md b/docs/live-controls-migration.md
iindex 9ab2dad..817384f 100644
---- a/docs/live-controls-migration.md
++++ b/docs/live-controls-migration.md
@@@ -1,6 +1,7 @@
# Moving OpenVibe.Live's stream controls into Bot (plan T15, row R9)
--Status: steps 1–3 built in Contracts 0.109.0 and Bot, 2026-10-07. Live still serves its controls; steps 4–6 remain.
++Status: steps 1–3 built in Contracts 0.109.0 and Bot, and step 5 (the conversion) built in Bot, 2026-10-07.
++Live still serves its controls; step 4 (the Node relay plugin) and step 6 (Live's deletion) remain.
## What Live has today
@@@ -49,17 +50,65 @@ lines) let viewers drive a streamer's hardware from the channel page:
loopback WebSocket that speaks Live's `{type:'command'|'key_held'|'key_released'|'video_click'}` messages — so an
existing Pi script keeps working with a one-line change of where it connects. Local policy and the kill switch
apply as for every plugin.
--5. **The conversion** (a script in Bot, run once by the operator with a JSON export from Live's database): for each
-- `control_configs` row, create the robot for the owner's Network subject (Live `users.subject_id`), a robot-local
-- profile from its buttons, operators from the whitelist, and the `stream_controls` overrides of that owner's most
-- recent stream where they differ. Idempotent on the Live config id. The owner then pairs a Node (the five-minute
-- path) and moves the script onto the relay plugin.
++5. **Built — the conversion**, `scripts/live-controls-export.sh` and `scripts/convert-live-controls.js` in Bot:
++ an operator runs the export on the Live host (read-only) and the converter here — dry-run first, then
++ `--apply`. For each `control_configs` row it creates the robot for the owner's Network subject (Live's
++ `linked_accounts`/`subject_projection`, the person's `usr_…`), a robot-local profile from its buttons
++ (profile `relay.generic`, the catalogue's bare relay robot: buttons and video clicks, no drive), operators
++ from the channel's whitelist, and the `stream_controls` overrides of that owner's most recent stream where
++ they differ. A Bot button name is Live's command normalised to `^[a-z][a-z0-9_]{0,31}$` and deduplicated,
++ and the Live command → Bot name map is kept in `live_conversions.detail` and the robot's audit so the relay
++ plugin can map back. A key the panel reserves (drive/stop) is dropped, a label is cut to 40 characters, an
++ ONVIF control and a disabled button are reported and left out; an owner with no Network subject is reported
++ and skipped (they must sign in to Network once). Idempotent on the Live config id (`live_conversions`,
++ migration 0007): a second `--apply` changes nothing and says "already converted". The owner then pairs a
++ Node (the five-minute path) and moves the script onto the relay plugin.
6. **Live**: `LIVE_BOT_EMBED=1`; the channel page shows the robot's embedded panel for a stream whose owner has a
robot; then delete `server/controls/*`, the hardware WebSocket path and its stream-key auth, the controls tables
and `public/js/controls.js` / `onvif-dashboard.js` (the embed stays).
++## Running the conversion
++
++On the Live host, where `DB_PATH` points at Live's SQLite file (read-only — no journal is written, nothing in
++Live changes):
++
++```sh
++scripts/live-controls-export.sh /path/to/live.sqlite > export.json
++```
++
++That one document carries only what the conversion needs: `configs`, `buttons`, `whitelist`,
++`latest_stream_controls` (each config owner's most recent stream only) and `owners` — only the config owners
++and whitelisted users, by `id`, `username` and Network `subject_id`. No other personal data.
++
++Then in Bot (dry run first; it prints the plan and writes nothing):
++
++```sh
++node scripts/convert-live-controls.js --input export.json
++node scripts/convert-live-controls.js --input export.json --apply
++```
++
++`--apply` opens Bot's database and domain from the environment exactly as `server/index.js` does and writes
++every robot, robot-local profile and operator through the domain layer — never raw SQL that would bypass the
++profile and role validation. Re-running `--apply` is safe: each Live config id already converted is reported
++`already converted` and nothing changes. A config whose owner has no Network subject is skipped with that
++reason; so is a whitelisted user with no subject. The plan also reports every button it cannot convert (an
++ONVIF control, a disabled button, a reserved key, a truncated label) so the operator can tell the owner what,
++if anything, needs redoing by hand.
++
++### What each owner does next
++
++1. Bot robot page → **Pair a Node** (the five-minute path): run the installer command Bot prints for the new
++ robot on the machine that drives the hardware.
++2. Move the local script onto the **relay plugin**: it forwards `button` (`{name, state}` for a hold) and
++ `point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
++ command behind each Bot button name is in the robot's audit (`live_conversions.detail` too), so the mapping
++ is one line.
++3. Point the Pi script at the paired Node instead of Live's stream-key socket. The stream key is no longer a
++ robot credential.
++
## Order and cutover
Contracts → robot-local profiles → gate/panel → relay plugin → conversion run (owners notified) → Live embed on →
--owners re-pair → Live deletion. The deletion waits for the three converted owners' robots to be paired, because the
--hardware clients are physical devices only their owners can move; it does not wait on a date.
++owners re-pair → Live deletion. The conversion (step 5) is built and runs once; the deletion waits for the three
++converted owners' robots to be paired, because the hardware clients are physical devices only their owners can
++move; it does not wait on a date.
ddiff --git a/migrations/0007_live_conversions.sql b/migrations/0007_live_conversions.sql
nnew file mode 100644
iindex 0000000..2637bfc
---- /dev/null
++++ b/migrations/0007_live_conversions.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++--
++-- The one-time conversion of OpenVibe.Live's stream controls into Bot robots (plan T15 R9 step 5):
++-- one row per Live control_configs id, so a second conversion run changes nothing and reports it.
++-- The row disappears with the robot it records (converting the config again re-creates both).
++--
++-- detail keeps what the relay plugin needs and the plan reported: the Live command → Bot button-name map
++-- (a Bot button name is a normalised, deduplicated form of Live's command string, so the plugin maps back),
++-- plus the notes for anything the run could not convert as it stood.
++CREATE TABLE live_conversions (
++ live_config_id integer PRIMARY KEY, -- OpenVibe.Live control_configs.id
++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
++ owner_subject text NOT NULL, -- the Live owner's Network subject (usr_…)
++ detail jsonb NOT NULL DEFAULT '{}',
++ converted_at timestamptz NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex f891d2f..6442949 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -14,7 +14,7 @@
"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.110.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.0",
"pg": "^8.23.0",
@@@ -844,9 +844,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "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==",
++ "version": "0.112.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
++ "integrity": "sha512-ujIdCjv+oHfwg/+Hw0tyt2erVnornjDiKVPN8snHvybfysR/DBZQ2G32bQj8oAaK90s12ADEpdrXT1eust3eAw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 43d9b70..c6314e4 100644
---- a/package.json
++++ b/package.json
@@@ -26,7 +26,7 @@
"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.110.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.0",
"pg": "^8.23.0",
ddiff --git a/scripts/convert-live-controls.js b/scripts/convert-live-controls.js
nnew file mode 100755
iindex 0000000..c14cc44
---- /dev/null
++++ b/scripts/convert-live-controls.js
@@@ -0,0 +1,344 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * One-time conversion of OpenVibe.Live's stream controls into Bot robots (plan T15 R9 step 5).
++ *
++ * scripts/live-controls-export.sh <Live SQLite> > export.json (on the Live host, read-only)
++ * node scripts/convert-live-controls.js --input export.json (dry run: prints the plan)
++ * node scripts/convert-live-controls.js --input export.json --apply (writes robots through the domain)
++ *
++ * For each Live control_configs row: a robot owned by the owner's Network subject (Live users →
++ * linked_accounts/ subject_projection), a robot-local profile built from its buttons, and the channel's
++ * whitelisted users as operators. Where the owner's most recent stream's copy of the buttons overrides a
++ * label, command or cooldown, the override wins. Everything the run cannot convert is reported with the
++ * reason, and an owner with no Network subject is skipped (they must sign in to Network once first).
++ *
++ * Idempotent on the Live config id: live_conversions (migration 0007) records each converted config, so a
++ * second --apply changes nothing and reports "already converted". The robots are always written through the
++ * domain layer (createRobot / saveLocalProfile / members.add), never raw SQL, so every validation holds.
++ *
++ * A Bot button name must match ^[a-z][a-z0-9_]{0,31}$ and is not Live's command string (which is what the
++ * owner's script understands). Each button's Live command is kept in the live_conversions detail and in the
++ * robot's audit, as a command → name map, so the relay plugin can map a panel button back.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const NAME_RE = /^[a-z][a-z0-9_]{0,31}$/;
++const MAX_LABEL = 40;
++// The panel's drive and stop keys: a converted Live key that is one of these is dropped (server/domain).
++const RESERVED_KEYS = new Set(['space', ' ', 'escape', 'w', 'a', 's', 'd', 'q', 'e', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright',
++ 'keyw', 'keya', 'keys', 'keyd', 'keyq', 'keye']);
++const RELAY_PROFILE = 'relay.generic';
++
++// ── The plan (pure: built from the export document alone; no database, no writes) ────────────────
++const asArray = (v) => (Array.isArray(v) ? v : []);
++const strOr = (v, d = '') => (typeof v === 'string' ? v : d);
++
++/** Live's command string → a Bot button name: lowercase, every run of other characters becomes `_`. */
++function normaliseName(command) {
++ const s = String(command == null ? '' : command).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+/, '').replace(/_+$/, '');
++ if (!s) return null;
++ const cut = s.length > 32 ? s.slice(0, 32).replace(/_+$/, '') : s;
++ return NAME_RE.test(cut) ? cut : null;
++}
++
++/** A unique name among `used`, suffixing _2, _3, … within the 32-character budget. */
++function uniqueName(name, used) {
++ if (!used.has(name)) return name;
++ for (let n = 2; ; n++) {
++ const suffix = `_${n}`;
++ const candidate = `${name.slice(0, 32 - suffix.length).replace(/_+$/, '')}${suffix}`;
++ if (!used.has(candidate)) return candidate;
++ }
++}
++
++/** The label: trimmed, never empty, at most 40 characters (truncated, with a note). */
++function labelFor(raw, fallback, notes) {
++ let label = String(raw == null ? '' : raw).trim();
++ if (!label) { label = String(fallback || '').trim(); if (label) notes.push(`label was empty; used ${JSON.stringify(label.slice(0, MAX_LABEL))}`); }
++ if (!label) { label = 'Button'; notes.push('label was empty; used "Button"'); }
++ if (label.length > MAX_LABEL) { notes.push(`label truncated to ${MAX_LABEL} characters`); label = label.slice(0, MAX_LABEL); }
++ return label;
++}
++
++/** The Live key, kept as-is unless Bot's panel reserves it (drive/stop) or it is too long. */
++function keyFor(raw, notes) {
++ const key = String(raw == null ? '' : raw).trim();
++ if (!key) return null;
++ if (RESERVED_KEYS.has(key.toLowerCase())) { notes.push(`key ${JSON.stringify(key)} dropped: the panel drives and stops with it`); return null; }
++ if (key.length > 16) { notes.push(`key ${JSON.stringify(key)} dropped: at most 16 characters`); return null; }
++ return key;
++}
++
++const cooldownFor = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
++
++/**
++ * One Live button (possibly the stream's overridden copy) → a Bot button, or a drop with the reason.
++ * `overridden` names the fields the stream copy changed (for the plan's report).
++ */
++function convertButton({ button, stream, notes, overridden }) {
++ const command = strOr(stream ? stream.command : button.command).trim();
++ const type = strOr(stream ? stream.control_type : button.control_type, 'button') || 'button';
++ if (type === 'onvif') return { dropped: { command, label: strOr(button.label), reason: 'an ONVIF control: ONVIF moves go to Bot\'s server-side camera (camera.onvif), not a relay button' } };
++ const raw = normaliseName(command);
++ if (!raw) return { dropped: { command, label: strOr(button.label), reason: 'no button name can be made from this command' } };
++ const hold = type === 'keyboard';
++ const out = {
++ command, name: raw, hold,
++ label: labelFor(stream ? stream.label : button.label, command, notes),
++ key: keyFor(stream ? stream.key_binding : button.key_binding, notes),
++ cooldown_ms: cooldownFor(stream ? stream.cooldown_ms : button.cooldown_ms),
++ };
++ if (!hold && type !== 'button') notes.push(`Live type ${JSON.stringify(type)} converted as a plain button`);
++ if (overridden.length) notes.push(`the latest stream overrides ${overridden.join(', ')}; the override wins`);
++ return { button: out };
++}
++
++/**
++ * Build the plan from an export document. Pure: no I/O. Returns { exported_at, configs: [...], summary },
++ * each config carrying its robot, its buttons (name, label, command, key, cooldown_ms, hold), the drops
++ * with their reasons, its operators and the Live command → Bot name map.
++ */
++function buildPlan(doc) {
++ const configs = asArray(doc.configs);
++ const buttons = asArray(doc.buttons);
++ const whitelist = asArray(doc.whitelist);
++ const streams = asArray(doc.latest_stream_controls);
++ const owners = new Map(asArray(doc.owners).map((o) => [o.id, o]));
++ const subjectOf = (userId) => { const o = owners.get(userId); return o && o.subject_id ? o.subject_id : null; };
++
++ const plan = [];
++ for (const config of configs) {
++ const owner = owners.get(config.user_id) || null;
++ const cfg = {
++ live_config_id: config.id, live_name: strOr(config.name),
++ owner: owner ? { id: owner.id, username: owner.username || null, subject_id: owner.subject_id || null } : { id: config.user_id, username: null, subject_id: null },
++ robot: null, buttons: [], dropped: [], overrides: [], operators: [], skipped_operators: [], notes: [],
++ command_to_name: {}, name_to_command: {}, status: 'convert',
++ };
++
++ const ownerSubject = owner && owner.subject_id;
++ if (!ownerSubject) {
++ cfg.status = 'owner_unlinked';
++ cfg.reason = owner ? `Live user ${owner.id} (${owner.username || 'unknown'}) has no Network subject: they must sign in to Network once` : `no row for Live user ${config.user_id}`;
++ plan.push(cfg);
++ continue;
++ }
++ cfg.robot = { name: `${owner.username || ownerSubject}'s controls`, profile_id: RELAY_PROFILE, access_policy: 'private' };
++
++ const mine = buttons.filter((b) => b.config_id === config.id);
++ const enabled = mine.filter((b) => b.is_enabled === undefined || Number(b.is_enabled) !== 0);
++ for (const b of mine) if (!enabled.includes(b)) cfg.dropped.push({ command: strOr(b.command), label: strOr(b.label), reason: 'disabled in Live (is_enabled 0)' });
++
++ // The owner's most recent stream, and whether its copy of the buttons belongs to this config.
++ const ownerStreams = streams.filter((s) => s.owner_user_id === config.user_id);
++ const boundElsewhere = ownerStreams.length && ownerStreams[0].stream_config_id != null && ownerStreams[0].stream_config_id !== config.id;
++ let copies = [];
++ if (boundElsewhere) {
++ cfg.notes.push(`the latest stream (id ${ownerStreams[0].stream_id}) uses Live config ${ownerStreams[0].stream_config_id}, so its overrides do not apply here`);
++ } else {
++ copies = ownerStreams.filter((s) => s.stream_config_id == null || s.stream_config_id === config.id)
++ .slice().sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0) || a.id - b.id);
++ }
++
++ const used = new Set();
++ enabled.forEach((button, i) => {
++ const stream = copies[i] || null;
++ const notes = [];
++ const overridden = stream ? ['label', 'command', 'cooldown_ms'].filter((f) => {
++ const a = f === 'cooldown_ms' ? cooldownFor(button[f]) : (button[f] == null ? null : String(button[f]));
++ const b = f === 'cooldown_ms' ? cooldownFor(stream[f]) : (stream[f] == null ? null : String(stream[f]));
++ return a !== b;
++ }) : [];
++ const r = convertButton({ button, stream, notes, overridden });
++ if (r.dropped) { cfg.dropped.push(r.dropped); return; }
++ const b = r.button;
++ b.name = uniqueName(b.name, used);
++ used.add(b.name);
++ if (overridden.length) cfg.overrides.push({ name: b.name, command: b.command, fields: overridden });
++ cfg.command_to_name[b.command] = b.name;
++ cfg.name_to_command[b.name] = b.command;
++ cfg.buttons.push({ ...b, notes });
++ });
++ for (const extra of copies.slice(enabled.length)) {
++ cfg.dropped.push({ command: strOr(extra.command), label: strOr(extra.label), reason: 'on the stream but not in the config' });
++ }
++
++ // The whitelist of the owner's channel, by Network subject.
++ const seen = new Set();
++ for (const w of whitelist.filter((x) => x.owner_user_id === config.user_id)) {
++ if (seen.has(w.user_id)) continue;
++ seen.add(w.user_id);
++ const user = owners.get(w.user_id);
++ const subject = subjectOf(w.user_id);
++ if (!subject) {
++ cfg.skipped_operators.push({ id: w.user_id, username: user ? user.username || null : null, reason: 'no Network subject: they must sign in to Network once' });
++ continue;
++ }
++ cfg.operators.push({ id: w.user_id, username: user ? user.username || null : null, subject_id: subject });
++ }
++ plan.push(cfg);
++ }
++ return {
++ exported_at: strOr(doc.exported_at, null),
++ configs: plan,
++ summary: {
++ configs: plan.length,
++ to_convert: plan.filter((c) => c.status === 'convert').length,
++ skipped: plan.filter((c) => c.status !== 'convert').length,
++ buttons: plan.reduce((n, c) => n + c.buttons.length, 0),
++ operators: plan.reduce((n, c) => n + c.operators.length, 0),
++ },
++ };
++}
++
++/** The plan as the operator reads it: one block per Live config. */
++function formatPlan(plan) {
++ const out = [];
++ out.push(`OpenVibe.Live controls → Bot robots — ${plan.summary.to_convert} to convert, ${plan.summary.skipped} skipped`);
++ if (plan.exported_at) out.push(`exported ${plan.exported_at}`);
++ out.push('');
++ for (const c of plan.configs) {
++ out.push(`config ${c.live_config_id} ${JSON.stringify(c.live_name)} owner ${c.owner.username || c.owner.id} <${c.owner.subject_id || 'no Network subject'}>`);
++ if (c.status !== 'convert') { out.push(` SKIPPED: ${c.reason}`); out.push(''); continue; }
++ out.push(` robot ${JSON.stringify(c.robot.name)} profile ${c.robot.profile_id} access ${c.robot.access_policy}`);
++ if (!c.buttons.length) out.push(' buttons: none');
++ for (const b of c.buttons) {
++ const key = b.key ? ` key ${b.key}` : '';
++ const cd = b.cooldown_ms != null ? ` cooldown ${b.cooldown_ms}ms` : '';
++ out.push(` - ${b.name} "${b.label}" ← ${JSON.stringify(b.command)}${key}${cd}${b.hold ? ' hold' : ''}`);
++ for (const n of b.notes) out.push(` note: ${n}`);
++ }
++ for (const d of c.dropped) out.push(` - DROPPED ${JSON.stringify(d.command)} "${d.label}": ${d.reason}`);
++ for (const o of c.operators) out.push(` operator ${o.username || o.id} <${o.subject_id}>`);
++ for (const o of c.skipped_operators) out.push(` operator SKIPPED ${o.username || o.id}: ${o.reason}`);
++ for (const n of c.notes) out.push(` note: ${n}`);
++ out.push('');
++ }
++ return `${out.join('\n')}\n`;
++}
++
++// ── Apply (through the domain layer) ─────────────────────────────────────────────────────────────
++/** The profile's `buttons` object for one plan config. */
++function buttonsObject(cfg) {
++ return Object.fromEntries(cfg.buttons.map((b) => [b.name, {
++ label: b.label,
++ ...(b.key ? { key: b.key } : {}),
++ ...(b.cooldown_ms != null ? { cooldown_ms: b.cooldown_ms } : {}),
++ ...(b.hold ? { hold: true } : {}),
++ }]));
++}
++
++/**
++ * Write the plan: for each convertible config whose Live id is not already in live_conversions, create the
++ * robot, save its local profile, add the operators, then record the conversion and the command map. Returns
++ * the results (one per config) with `outcome` converted | already_converted | skipped.
++ */
++async function applyPlan(plan, { db, domain, log = console }) {
++ const results = [];
++ for (const cfg of plan.configs) {
++ if (cfg.status !== 'convert') { results.push({ ...cfg, outcome: 'skipped' }); continue; }
++ const done = await db.maybe('SELECT robot_id FROM live_conversions WHERE live_config_id = $1', [cfg.live_config_id]);
++ if (done) { results.push({ ...cfg, outcome: 'already_converted', robot_id: done.robot_id }); continue; }
++
++ let robot = null;
++ try {
++ const actor = { subject: cfg.owner.subject_id, kind: 'service' };
++ const buttons = buttonsObject(cfg);
++ ({ robot } = await domain.robots.create({ owner: cfg.owner.subject_id, name: cfg.robot.name, profile_id: cfg.robot.profile_id, access_policy: cfg.robot.access_policy }));
++ await domain.robots.saveLocalProfile(robot.id, actor, Object.keys(buttons).length ? { buttons } : { buttons: {}, point: null });
++ for (const o of cfg.operators) await domain.members.add(robot.id, o.subject_id, 'operator', cfg.owner.subject_id);
++
++ const detail = { source: 'openvibe.live', live_config_id: cfg.live_config_id, live_name: cfg.live_name,
++ command_to_name: cfg.command_to_name, name_to_command: cfg.name_to_command,
++ dropped: cfg.dropped, skipped_operators: cfg.skipped_operators };
++ await db.query(`INSERT INTO live_conversions (live_config_id, robot_id, owner_subject, detail, converted_at)
++ VALUES ($1, $2, $3, $4::jsonb, $5) ON CONFLICT (live_config_id) DO NOTHING`,
++ [cfg.live_config_id, robot.id, cfg.owner.subject_id, JSON.stringify(detail), new Date().toISOString()]);
++ // The audit row the relay plugin reads: the Live command behind every Bot button name. (The audit
++ // prunes after 30 days; live_conversions.detail above keeps the same map for good.)
++ await domain.audit.record({ robotId: robot.id, subject: cfg.owner.subject_id, operatorKind: 'service', role: 'owner',
++ kind: 'profile.local', value: { source: 'openvibe.live', live_config_id: cfg.live_config_id, commands: cfg.command_to_name }, result: 'ack' });
++ log.log(`[convert] config ${cfg.live_config_id}: robot ${robot.id} (${cfg.robot.name}) with ${cfg.buttons.length} buttons, ${cfg.operators.length} operators`);
++ results.push({ ...cfg, outcome: 'converted', robot_id: robot.id });
++ } catch (e) {
++ // One bad config does not stop the run; the robot it may have made is reported so the operator can remove it.
++ log.error(`[convert] config ${cfg.live_config_id} failed: ${e.message}`);
++ results.push({ ...cfg, outcome: 'failed', robot_id: robot ? robot.id : null, reason: e.message });
++ }
++ }
++ return results;
++}
++
++// ── Entry ────────────────────────────────────────────────────────────────────────────────────────
++function parseArgs(argv) {
++ const args = { input: null, apply: false };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--input') args.input = argv[++i];
++ else if (a.startsWith('--input=')) args.input = a.slice(8);
++ else if (a === '--apply') args.apply = true;
++ else if (a === '--help' || a === '-h') args.help = true;
++ else throw new Error(`unknown argument ${a}`);
++ }
++ return args;
++}
++
++const USAGE = `usage: node scripts/convert-live-controls.js --input export.json [--apply]
++
++ --input FILE the JSON document from scripts/live-controls-export.sh
++ --apply write the robots (default: dry run, prints the plan and stops)`;
++
++/**
++ * Read the export, build the plan and (with apply) write it. `db`/`domain` may be injected (tests); a real
++ * run opens Bot's database and domain from the environment, exactly as server/index.js does.
++ */
++async function convert({ input, apply = false, db = null, domain = null, log = console } = {}) {
++ const doc = JSON.parse(fs.readFileSync(input, 'utf8'));
++ const plan = buildPlan(doc);
++ if (!apply) { log.log(formatPlan(plan)); return { plan, results: null }; }
++
++ let own = null;
++ if (!db || !domain) {
++ const { loadConfig } = require('../server/config');
++ const { openDb, migrate } = require('../server/db');
++ const { seedProfiles } = require('../server/profiles');
++ const { createApp } = require('../server/app');
++ const config = loadConfig();
++ own = openDb(config);
++ const quiet = { log() {}, warn() {}, error: (...a) => log.error(...a) };
++ await migrate(config, { serving: own, log: quiet });
++ await seedProfiles(own, { log: quiet });
++ const app = createApp({ config, db: own, log: quiet });
++ db = own; domain = app.locals.domain;
++ }
++ try {
++ const results = await applyPlan(plan, { db, domain, log });
++ for (const r of results) {
++ if (r.outcome === 'converted') log.log(`[convert] config ${r.live_config_id}: converted → ${r.robot_id}`);
++ else if (r.outcome === 'already_converted') log.log(`[convert] config ${r.live_config_id}: already converted → ${r.robot_id} (nothing changed)`);
++ else if (r.outcome === 'failed') log.log(`[convert] config ${r.live_config_id}: FAILED — ${r.reason}${r.robot_id ? ` (robot ${r.robot_id} was made; remove it before retrying)` : ''}`);
++ else log.log(`[convert] config ${r.live_config_id}: skipped — ${r.reason}`);
++ }
++ const count = (o) => results.filter((r) => r.outcome === o).length;
++ log.log(`[convert] ${count('converted')} converted, ${count('already_converted')} already converted, ${count('skipped')} skipped, ${count('failed')} failed`);
++ return { plan, results };
++ } finally {
++ if (own) await own.close().catch(() => {});
++ }
++}
++
++async function main(argv = process.argv.slice(2)) {
++ const args = parseArgs(argv);
++ if (args.help || !args.input) { console.log(USAGE); return args.help ? 0 : 2; }
++ await convert({ input: path.resolve(args.input), apply: args.apply });
++ return 0;
++}
++
++if (require.main === module) {
++ main().then((code) => { if (code) process.exit(code); }).catch((e) => { console.error(`convert-live-controls: ${e.message}`); process.exit(1); });
++}
++
++module.exports = { buildPlan, formatPlan, applyPlan, buttonsObject, convert, normaliseName, uniqueName, RELAY_PROFILE, RESERVED_KEYS };
ddiff --git a/scripts/live-controls-export.sh b/scripts/live-controls-export.sh
nnew file mode 100755
iindex 0000000..3838c9c
---- /dev/null
++++ b/scripts/live-controls-export.sh
@@@ -0,0 +1,77 @@
++#!/bin/sh
++# Export OpenVibe.Live's stream controls for the one-time conversion into Bot robots (plan T15 R9 step 5).
++#
++# scripts/live-controls-export.sh <path to Live's SQLite file> > export.json
++#
++# Read-only: every read is `sqlite3 -readonly -json`, so the file is never written and no journal is made.
++# The one document carries only what the conversion needs:
++#
++# configs every control_configs row (id, user_id, name)
++# buttons every control_config_buttons row of those configs
++# whitelist control_whitelist rows for the config owners' channels (user_id, owner_user_id)
++# latest_stream_controls the stream_controls rows of each config owner's most recent stream only
++# owners only the config owners and whitelisted users: id, username, subject_id
++#
++# No other personal data (no email, no password hash, no addresses). Run it on the Live host, where DB_PATH
++# points at the file; it prints one JSON document to stdout and nothing else.
++set -eu
++
++DB=${1:-}
++if [ -z "$DB" ]; then
++ echo "usage: live-controls-export.sh <path to Live's SQLite file>" >&2
++ exit 2
++fi
++if [ ! -f "$DB" ]; then
++ echo "live-controls-export: no such database file: $DB" >&2
++ exit 1
++fi
++command -v sqlite3 >/dev/null 2>&1 || { echo "live-controls-export: sqlite3 is not installed" >&2; exit 1; }
++
++# Rows as a JSON array (sqlite3's -json; an empty result prints nothing, normalised below).
++rows() { sqlite3 -readonly -json "$DB" "$1"; }
++# One scalar.
++one() { sqlite3 -readonly "$DB" "$1"; }
++
++# Which columns/tables this Live database has: the Network subject of a Live account lives in
++# linked_accounts (service 'network'), with subject_projection as the fallback for an account whose link
++# row is missing the subject; a future users.subject_id is honoured when present.
++users_has_subject=$(one "SELECT COUNT(*) FROM pragma_table_info('users') WHERE name = 'subject_id';")
++has_linked=$(one "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'linked_accounts';")
++has_projection=$(one "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'subject_projection';")
++
++SUBJECT='NULL'
++if [ "$users_has_subject" = "1" ]; then SUBJECT="NULLIF(u.subject_id, '')"; fi
++if [ "$has_linked" = "1" ]; then
++ LINKED="(SELECT la.subject_id FROM linked_accounts la WHERE la.service = 'network' AND la.user_id = u.id AND substr(la.subject_id, 1, 4) = 'usr_' LIMIT 1)"
++ if [ "$SUBJECT" = 'NULL' ]; then SUBJECT="$LINKED"; else SUBJECT="COALESCE($SUBJECT, $LINKED)"; fi
++ if [ "$has_projection" = "1" ]; then
++ PROJECTED="(SELECT sp.subject_id FROM subject_projection sp WHERE sp.network_user_id = CAST((SELECT la.service_user_id FROM linked_accounts la WHERE la.service = 'network' AND la.user_id = u.id LIMIT 1) AS INTEGER) AND substr(sp.subject_id, 1, 4) = 'usr_' LIMIT 1)"
++ SUBJECT="COALESCE($SUBJECT, $PROJECTED)"
++ fi
++fi
++
++# The config owners: an owner's channel is unique (channels.user_id UNIQUE), which is what the whitelist is
++# keyed by. Whitelisted users are only those on a config owner's channel.
++OWNERS="SELECT user_id FROM control_configs UNION SELECT w.user_id FROM control_whitelist w JOIN channels c ON c.id = w.channel_id WHERE c.user_id IN (SELECT user_id FROM control_configs)"
++
++CONFIGS=$(rows "SELECT id, user_id, name FROM control_configs ORDER BY id;")
++BUTTONS=$(rows "SELECT b.id, b.config_id, b.label, b.command, b.control_type, b.key_binding, b.cooldown_ms, b.is_enabled, b.sort_order FROM control_config_buttons b JOIN control_configs c ON c.id = b.config_id ORDER BY b.config_id, b.sort_order, b.id;")
++WHITELIST=$(rows "SELECT w.id, w.user_id, c.user_id AS owner_user_id FROM control_whitelist w JOIN channels c ON c.id = w.channel_id WHERE c.user_id IN (SELECT user_id FROM control_configs) ORDER BY w.id;")
++# The most recent stream per config owner (a live session's started_at, else when the row was created),
++# then that stream's controls. stream_config_id says which config the copy came from, so the conversion can
++# tell an override of this config from a stream bound to another one.
++LATEST=$(rows "WITH latest AS (
++ SELECT s.user_id, s.id AS stream_id, s.control_config_id,
++ ROW_NUMBER() OVER (PARTITION BY s.user_id ORDER BY COALESCE(s.started_at, s.created_at) DESC, s.id DESC) AS rn
++ FROM streams s WHERE s.user_id IN (SELECT user_id FROM control_configs)
++ )
++ SELECT sc.id, sc.stream_id, latest.user_id AS owner_user_id, latest.control_config_id AS stream_config_id,
++ sc.label, sc.command, sc.control_type, sc.key_binding, sc.cooldown_ms, sc.is_enabled, sc.sort_order
++ FROM latest JOIN stream_controls sc ON sc.stream_id = latest.stream_id
++ WHERE latest.rn = 1
++ ORDER BY sc.stream_id, sc.sort_order, sc.id;")
++OWNER_ROWS=$(rows "SELECT u.id, u.username, $SUBJECT AS subject_id FROM users u WHERE u.id IN ($OWNERS) ORDER BY u.id;")
++
++printf '{"exported_at":"%s","configs":%s,"buttons":%s,"whitelist":%s,"latest_stream_controls":%s,"owners":%s}\n' \
++ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
++ "${CONFIGS:-[]}" "${BUTTONS:-[]}" "${WHITELIST:-[]}" "${LATEST:-[]}" "${OWNER_ROWS:-[]}"
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 25a4e9c..0929aa1 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -39,13 +39,16 @@ const CAPABILITIES = new Set([
'display.text',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'battery', 'ptz', 'camera',
++ // A robot that carries no hardware of its own: the owner's Node forwards the panel's commands to a
++ // local script with the relay plugin (relay.generic, plan T15 R9).
++ 'relay',
]);
/** 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', 'buttons', 'video_click']);
--/** Device drivers a mapping may name. */
--const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim']);
++/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
++const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim', 'relay']);
const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
ddiff --git a/server/profiles/relay.generic.json b/server/profiles/relay.generic.json
nnew file mode 100644
iindex 0000000..7231b92
---- /dev/null
++++ b/server/profiles/relay.generic.json
@@@ -0,0 +1,18 @@
++{
++ "id": "relay.generic",
++ "version": 1,
++ "name": "Generic relay",
++ "vendor": "OpenVibe",
++ "kind": "bridge",
++ "description": "A robot with no hardware of its own: the owner's Node runs the relay plugin and forwards the panel's button and video-click commands to a local script. The shape OpenVibe.Live's camp controls convert to (plan T15 R9 step 5); the buttons live in the robot's local profile.",
++ "capabilities": ["relay"],
++ "mapping": { "driver": "relay", "plugin": "relay" },
++ "commands": {
++ "button": { "names": { "relay": { "label": "Relay" } } }
++ },
++ "widgets": [
++ { "type": "buttons", "label": "Buttons", "order": 1, "command": { "kind": "button" } },
++ { "type": "latency", "label": "Latency", "order": 2 }
++ ],
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 9fd0ed6..e9dc418 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/app');
await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
const list = await t.call('GET', '/api/v1/profiles', { token: null });
assert.strictEqual(list.status, 200);
-- assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'sim.rover']);
++ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });
assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
ddiff --git a/test/helpers/plugins.js b/test/helpers/plugins.js
iindex 0315665..54644e9 100644
---- a/test/helpers/plugins.js
++++ b/test/helpers/plugins.js
@@@ -8,6 +8,8 @@
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__init__.py
*
++ * RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.
++ *
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
* value table in docs/protocol.md.
*/
@@@ -127,6 +129,26 @@ function cozmo({ maxSay = 200 } = {}) {
};
}
++/**
++ * RELAY (relay.generic): the Node plugin that forwards a robot's button and video-click commands to the
++ * owner's local script (plan T15 R9 step 4). It takes every declared button name and the point layer as the
++ * gate builds them (server/domain buildValue); it holds no hardware of its own.
++ */
++const relay = {
++ name: 'relay (Node plugin)',
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (!isObj(v)) return `${kind} value must be an object`;
++ if (kind === 'button') {
++ if (typeof v.name !== 'string' || !v.name) return 'button is {name[, state]}';
++ if (!only(v, ['name', 'state'])) return 'button takes only name and state';
++ return v.state == null || v.state === 'down' || v.state === 'up' ? null : 'state is down or up';
++ }
++ if (kind === 'point') return num(v.x, 0, 1) && num(v.y, 0, 1) && only(v, ['x', 'y']) ? null : 'point is {x, y} 0..1';
++ return `kind ${kind} not supported`;
++ },
++};
++
/** The server-side drivers (docs/protocol.md, command values). */
const server = {
'sim.rover': {
@@@ -153,7 +175,8 @@ function contractFor(profile) {
const m = profile.mapping || {};
if (m.plugin === 'adeept_adr036') return adeept(m.wheels);
if (m.plugin === 'cozmo') return cozmo();
++ if (m.plugin === 'relay') return relay;
return server[profile.id] || null;
}
--module.exports = { adeept, cozmo, server, contractFor, COZMO_FACES };
++module.exports = { adeept, cozmo, relay, server, contractFor, COZMO_FACES };
ddiff --git a/test/live-controls-convert.test.js b/test/live-controls-convert.test.js
nnew file mode 100644
iindex 0000000..500513f
---- /dev/null
++++ b/test/live-controls-convert.test.js
@@@ -0,0 +1,167 @@
++'use strict';
++// The one-time OpenVibe.Live controls conversion (plan T15 R9 step 5): the dry-run plan, the writes through
++// the domain, idempotency on the Live config id, and the owner/whitelist people rules. The export document
++// is a fixture exactly as scripts/live-controls-export.sh emits it.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++const { buildPlan, formatPlan, convert } = require('../scripts/convert-live-controls');
++
++const ALICE = 'usr_01J8Z4M2Q0R7T9YV3K6N8P1W2X';
++const BOB = 'usr_01J8Z4M2Q0R7T9YV3K6N8P1W2Y';
++
++// Two configs: one owner has a Network subject, the other (user 80) does not. Buttons cover a plain button,
++// a hold key with a reserved binding, an over-long label, an ONVIF control and a disabled one; the owner's
++// latest stream overrides the first button's label and cooldown; the whitelist has one linked and one
++// unlinked user.
++const EXPORT = {
++ exported_at: '2026-10-07T12:00:00Z',
++ configs: [
++ { id: 1, user_id: 1, name: 'Camp controls' },
++ { id: 2, user_id: 80, name: 'No Network yet' },
++ ],
++ buttons: [
++ { id: 1, config_id: 1, label: 'Wave', command: 'wave', control_type: 'button', key_binding: 'KeyG', cooldown_ms: 500, is_enabled: 1, sort_order: 0 },
++ { id: 2, config_id: 1, label: 'Grip', command: 'grip it', control_type: 'keyboard', key_binding: 'w', cooldown_ms: 800, is_enabled: 1, sort_order: 1 },
++ { id: 3, config_id: 1, label: 'x'.repeat(50), command: 'Spin Wheel', control_type: 'button', key_binding: null, cooldown_ms: 0, is_enabled: 1, sort_order: 2 },
++ { id: 4, config_id: 1, label: 'Pan left', command: 'pan_left', control_type: 'onvif', key_binding: null, cooldown_ms: 100, is_enabled: 1, sort_order: 3 },
++ { id: 5, config_id: 1, label: 'Retired', command: 'retired', control_type: 'button', key_binding: null, cooldown_ms: 100, is_enabled: 0, sort_order: 4 },
++ { id: 6, config_id: 2, label: 'Go', command: 'go', control_type: 'button', key_binding: null, cooldown_ms: 100, is_enabled: 1, sort_order: 0 },
++ ],
++ whitelist: [
++ { id: 1, user_id: 5, owner_user_id: 1 },
++ { id: 2, user_id: 6, owner_user_id: 1 },
++ ],
++ latest_stream_controls: [
++ { id: 11, stream_id: 10, owner_user_id: 1, stream_config_id: 1, label: 'Wave harder', command: 'wave', control_type: 'button', key_binding: 'KeyG', cooldown_ms: 250, is_enabled: 1, sort_order: 0 },
++ { id: 12, stream_id: 10, owner_user_id: 1, stream_config_id: 1, label: 'Grip', command: 'grip it', control_type: 'keyboard', key_binding: 'w', cooldown_ms: 800, is_enabled: 1, sort_order: 1 },
++ { id: 13, stream_id: 10, owner_user_id: 1, stream_config_id: 1, label: 'x'.repeat(50), command: 'Spin Wheel', control_type: 'button', key_binding: null, cooldown_ms: 0, is_enabled: 1, sort_order: 2 },
++ { id: 14, stream_id: 10, owner_user_id: 1, stream_config_id: 1, label: 'Pan left', command: 'pan_left', control_type: 'onvif', key_binding: null, cooldown_ms: 100, is_enabled: 1, sort_order: 3 },
++ ],
++ owners: [
++ { id: 1, username: 'alice', subject_id: ALICE },
++ { id: 5, username: 'bob', subject_id: BOB },
++ { id: 6, username: 'carol', subject_id: null },
++ { id: 80, username: 'dave', subject_id: null },
++ ],
++};
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-live-'));
++ const input = path.join(dir, 'export.json');
++ fs.writeFileSync(input, JSON.stringify(EXPORT));
++ const log = { log() {}, warn() {}, error() {} };
++ let t = null;
++
++ try {
++ const plan = buildPlan(EXPORT);
++ const cfg = plan.configs[0];
++ const skipped = plan.configs[1];
++
++ await check('the dry-run plan names each robot, its buttons, operators and drops', () => {
++ assert.deepStrictEqual(plan.summary, { configs: 2, to_convert: 1, skipped: 1, buttons: 3, operators: 1 });
++ assert.strictEqual(cfg.status, 'convert');
++ assert.strictEqual(cfg.robot.name, "alice's controls");
++ assert.strictEqual(cfg.robot.profile_id, 'relay.generic');
++ assert.strictEqual(cfg.robot.access_policy, 'private');
++ assert.deepStrictEqual(cfg.buttons.map((b) => b.name), ['wave', 'grip_it', 'spin_wheel']);
++ assert.deepStrictEqual(cfg.command_to_name, { wave: 'wave', 'grip it': 'grip_it', 'Spin Wheel': 'spin_wheel' });
++ assert.deepStrictEqual(cfg.name_to_command, { wave: 'wave', grip_it: 'grip it', spin_wheel: 'Spin Wheel' });
++ });
++
++ await check('the latest stream override wins for label and cooldown', () => {
++ assert.deepStrictEqual(cfg.overrides, [{ name: 'wave', command: 'wave', fields: ['label', 'cooldown_ms'] }]);
++ assert.strictEqual(cfg.buttons[0].label, 'Wave harder');
++ assert.strictEqual(cfg.buttons[0].cooldown_ms, 250);
++ assert.ok(cfg.buttons[0].notes.some((n) => /overrides/.test(n)));
++ });
++
++ await check('a hold key becomes hold, a reserved key is dropped, an over-long label truncated', () => {
++ const grip = cfg.buttons[1];
++ assert.strictEqual(grip.hold, true);
++ assert.strictEqual(grip.key, null);
++ assert.ok(grip.notes.some((n) => /drives and stops/.test(n)), 'the reserved key is reported');
++ const spin = cfg.buttons[2];
++ assert.strictEqual(spin.label.length, 40);
++ assert.ok(spin.notes.some((n) => /truncated/.test(n)));
++ });
++
++ await check('an ONVIF control and a disabled button are reported, not converted', () => {
++ assert.deepStrictEqual(cfg.dropped.map((d) => d.command).sort(), ['pan_left', 'retired']);
++ assert.ok(cfg.dropped.find((d) => d.command === 'pan_left').reason.includes('ONVIF'));
++ assert.ok(cfg.dropped.find((d) => d.command === 'retired').reason.includes('disabled'));
++ });
++
++ await check('an owner with no Network subject is reported and skipped; so is an unlinked operator', () => {
++ assert.strictEqual(skipped.status, 'owner_unlinked');
++ assert.ok(/sign in to Network/.test(skipped.reason));
++ assert.deepStrictEqual(cfg.operators, [{ id: 5, username: 'bob', subject_id: BOB }]);
++ assert.deepStrictEqual(cfg.skipped_operators, [{ id: 6, username: 'carol', reason: 'no Network subject: they must sign in to Network once' }]);
++ });
++
++ await check('the printed plan is human-readable', () => {
++ const text = formatPlan(plan);
++ assert.match(text, /1 to convert, 1 skipped/);
++ assert.match(text, /- wave "Wave harder" ← "wave" key KeyG cooldown 250ms/);
++ assert.match(text, /robot "alice's controls"/);
++ assert.match(text, /SKIPPED: Live user 80 \(dave\) has no Network subject/);
++ });
++
++ await check('a dry run writes nothing', async () => {
++ t = await boot({ openre: false });
++ const { results } = await convert({ input, apply: false, db: t.db, domain: t.domain, log });
++ assert.strictEqual(results, null);
++ assert.strictEqual(await t.db.value('SELECT count(*)::int FROM robots'), 0);
++ assert.strictEqual(await t.db.value('SELECT count(*)::int FROM live_conversions'), 0);
++ });
++
++ let robotId = null;
++ await check('--apply creates the robot, its local buttons and the operators', async () => {
++ const { results } = await convert({ input, apply: true, db: t.db, domain: t.domain, log });
++ assert.deepStrictEqual(results.map((r) => r.outcome), ['converted', 'skipped']);
++ robotId = results[0].robot_id;
++ const robot = await t.domain.robots.get(robotId);
++ assert.strictEqual(robot.owner_subject, ALICE);
++ assert.strictEqual(robot.name, "alice's controls");
++ assert.strictEqual(robot.access_policy, 'private');
++ assert.ok(robot.profile_id.startsWith('local.rob_'), 'the robot carries a robot-local profile');
++
++ const local = await t.domain.robots.localProfile(robotId);
++ assert.strictEqual(local.source_profile_id, 'relay.generic');
++ assert.deepStrictEqual(Object.keys(local.profile.commands.button.names), ['wave', 'grip_it', 'spin_wheel']);
++ assert.strictEqual(local.profile.commands.button.names.wave.cooldown_ms, 250, 'the stream override is stored');
++ assert.strictEqual(local.profile.commands.button.names.grip_it.hold, true);
++ assert.strictEqual(local.profile.commands.button.names.grip_it.key, undefined, 'the reserved key was dropped');
++
++ const members = await t.domain.members.list(robotId);
++ assert.deepStrictEqual(members.map((m) => [m.subject, m.role]).sort(), [[ALICE, 'owner'], [BOB, 'operator']]);
++ // The converted operator can press a converted button through the real gate.
++ assert.strictEqual((await t.domain.control.prepare({ robotId, principal: { subject: BOB, kind: 'user' }, kind: 'button', value: { name: 'wave' }, online: true })).ok, true);
++ assert.strictEqual(await t.db.value('SELECT count(*)::int FROM robots'), 1, 'the unlinked config made no robot');
++ });
++
++ await check('the conversion is recorded, with the Live command → Bot name map in the audit', async () => {
++ const row = await t.db.maybe('SELECT robot_id, owner_subject, detail FROM live_conversions WHERE live_config_id = 1');
++ assert.ok(row && row.robot_id === robotId && row.owner_subject === ALICE);
++ assert.deepStrictEqual(row.detail.command_to_name, { wave: 'wave', 'grip it': 'grip_it', 'Spin Wheel': 'spin_wheel' });
++ const audit = (await t.domain.audit.list(robotId, { limit: 10 })).find((a) => a.value && a.value.source === 'openvibe.live');
++ assert.ok(audit, 'the conversion wrote an audit row');
++ assert.deepStrictEqual(audit.value.commands, { wave: 'wave', 'grip it': 'grip_it', 'Spin Wheel': 'spin_wheel' });
++ });
++
++ await check('a second --apply changes nothing and reports "already converted"', async () => {
++ const before = await t.db.many('SELECT id FROM robots ORDER BY id');
++ const { results } = await convert({ input, apply: true, db: t.db, domain: t.domain, log });
++ assert.deepStrictEqual(results.map((r) => r.outcome), ['already_converted', 'skipped']);
++ assert.strictEqual(results[0].robot_id, robotId);
++ assert.deepStrictEqual(await t.db.many('SELECT id FROM robots ORDER BY id'), before);
++ assert.strictEqual(await t.db.value('SELECT count(*)::int FROM live_conversions'), 1);
++ });
++ } finally {
++ if (t) await t.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ done();
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/local-controls.test.js b/test/local-controls.test.js
iindex 4627a05..0d1c7cf 100644
---- a/test/local-controls.test.js
++++ b/test/local-controls.test.js
@@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');
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']);
++ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
});
await check('an operator cannot edit or switch the robot-owned profile', async () => {
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex ff745c4..3ba498e 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -11,7 +11,7 @@ const plugins = require('./helpers/plugins');
const domain = createDomain({ db: null, outbox: null, config: { control: { maxCommandMs: 300, cooldownMs: 0 }, device: { heartbeatMs: 1000 }, media: {} } });
const build = (profile, kind, value, limits = {}) => domain.control.buildValue(kind, value, domain.control.effectiveLimits({ limits }, profile), profile);
// The panel widgets that send commands; each must say which.
--const CONTROL_WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'ptz', 'head', 'lift']);
++const CONTROL_WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'ptz', 'head', 'lift', 'buttons', 'video_click']);
// A 1x1 PNG.
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
@@@ -39,6 +39,15 @@ function samples(profile, kind, names) {
return out;
}
if (kind === 'say') return [{ text: 'hello there' }, { text: ' padded ' }];
++ if (kind === 'button') {
++ const out = [];
++ for (const [name, b] of Object.entries(spec.names)) {
++ out.push({ name });
++ if (b.hold) out.push({ name, state: 'down' }, { name, state: 'up' });
++ }
++ return out;
++ }
++ if (kind === 'point') return [{ x: 0.5, y: 0.5 }, { x: 0, y: 1 }];
const out = []; // display
if (spec.modes.includes('text')) out.push({ text: 'hi' });
if (spec.modes.includes('face')) for (const face of spec.faces) out.push({ face }, { face: face.toUpperCase() });
@@@ -49,8 +58,8 @@ function samples(profile, kind, names) {
(async () => {
const profiles = loadProfiles();
-- await check('the five shipped profiles load and validate', () => {
-- assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'sim.rover']);
++ await check('the six shipped profiles load and validate', () => {
++ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
for (const p of profiles.values()) {
assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
assert.ok(p.widgets.length > 0, `${p.id} widgets`);
@@@ -91,6 +100,15 @@ function samples(profile, kind, names) {
assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
});
++ await check('relay.generic is the bare relay robot: buttons only, no drive, no hardware of its own', () => {
++ const p = profiles.get('relay.generic');
++ assert.deepStrictEqual(p.capabilities, ['relay']);
++ assert.strictEqual(p.mapping.driver, 'relay');
++ assert.strictEqual(p.mapping.plugin, 'relay');
++ assert.ok(!p.commands.drive && !p.commands.ptz && !p.commands.actuator, 'no drive of its own');
++ assert.ok(p.commands.button.names.relay, 'the catalogue carries one button the local profile replaces');
++ });
++
await check('limits default to max_command_ms 300 and heartbeat_ms 1000', () => {
const v = validateProfile({ id: 'x.y', version: 1, name: 'X', capabilities: ['camera'], mapping: { driver: 'sim' }, widgets: [{ type: 'camera', capability: 'camera' }] });
assert.strictEqual(v.limits.max_command_ms, 300);