openvibers/openvibe.bot Commits
Bot: convert OpenVibe.Live's stream controls into relay robots (plan T15 R9 step 5); contracts 0.112.0 (#41)
Changes
@@@ -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@@@ -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",@@@ -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 anexisting Pi script keeps working with a one-line change of where it connects. Local policy and the kill switchapply 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 arobot; then delete `server/controls/*`, the hardware WebSocket path and its stream-key auth, the controls tablesand `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 cutoverContracts → 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.nnew file mode 100644@@@ -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++);@@@ -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",@@@ -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",nnew file mode 100755@@@ -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 };nnew file mode 100755@@@ -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:-[]}"@@@ -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']);nnew file mode 100644@@@ -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 }++}@@@ -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 });@@@ -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 };nnew file mode 100644@@@ -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; });@@@ -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 () => {@@@ -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 = []; // displayif (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);
@@ -43,7 +43,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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).
- 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.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
## Capabilities
@@ -5,9 +5,9 @@
@@ -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",
"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.110.0",
+ "contracts": "openvibe-contracts v0.112.0",
"packages": [
"packages": [
- "openvibe-contracts v0.110.0",
+ "openvibe-contracts v0.112.0",
"openvibe-sdk v0.35.0",
"openvibe-sdk v0.35.0",
"openvibe-shared v2.13.0"
"openvibe-shared v2.13.0"
],
],
@@ -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",
- "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)",
"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,7 @@
@@ -1,6 +1,7 @@
# 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: 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
## What Live has today
@@ -49,17 +50,65 @@ lines) let viewers drive a streamer's hardware from the channel page:
@@ -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
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
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.
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
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
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).
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
## Order and cutover
Contracts → robot-local profiles → gate/panel → relay plugin → conversion run (owners notified) → Live embed on →
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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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
+);
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -844,9 +844,9 @@
@@ -844,9 +844,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
new file mode 100755
new file mode 100755
@@ -0,0 +1,344 @@
@@ -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 };
new file mode 100755
new file mode 100755
@@ -0,0 +1,77 @@
@@ -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:-[]}"
@@ -39,13 +39,16 @@ const CAPABILITIES = new Set([
@@ -39,13 +39,16 @@ const CAPABILITIES = new Set([
'display.text',
'display.text',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'battery', 'ptz', 'camera',
'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). */
/** 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']);
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 ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const KINDS = new Set(['onboard', 'bridge', 'server']);
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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 }
+}
@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
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 });
const list = await t.call('GET', '/api/v1/profiles', { token: null });
assert.strictEqual(list.status, 200);
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 });
const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });
assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
@@ -8,6 +8,8 @@
@@ -8,6 +8,8 @@
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__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
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
* value table in docs/protocol.md.
* value table in docs/protocol.md.
*/
*/
@@ -127,6 +129,26 @@ function cozmo({ maxSay = 200 } = {}) {
@@ -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). */
/** The server-side drivers (docs/protocol.md, command values). */
const server = {
const server = {
'sim.rover': {
'sim.rover': {
@@ -153,7 +175,8 @@ function contractFor(profile) {
@@ -153,7 +175,8 @@ function contractFor(profile) {
const m = profile.mapping || {};
const m = profile.mapping || {};
if (m.plugin === 'adeept_adr036') return adeept(m.wheels);
if (m.plugin === 'adeept_adr036') return adeept(m.wheels);
if (m.plugin === 'cozmo') return cozmo();
if (m.plugin === 'cozmo') return cozmo();
+ if (m.plugin === 'relay') return relay;
return server[profile.id] || null;
return server[profile.id] || null;
}
}
-module.exports = { adeept, cozmo, server, contractFor, COZMO_FACES };
+module.exports = { adeept, cozmo, relay, server, contractFor, COZMO_FACES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -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; });
@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');
@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');
try {
try {
await check('every existing catalogue profile still loads under the contract', () => {
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 () => {
await check('an operator cannot edit or switch the robot-owned profile', async () => {
@@ -11,7 +11,7 @@ const plugins = require('./helpers/plugins');
@@ -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 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);
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.
// 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.
// A 1x1 PNG.
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
@@ -39,6 +39,15 @@ function samples(profile, kind, names) {
@@ -39,6 +39,15 @@ function samples(profile, kind, names) {
return out;
return out;
}
}
if (kind === 'say') return [{ text: 'hello there' }, { text: ' padded ' }];
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
const out = []; // display
if (spec.modes.includes('text')) out.push({ text: 'hi' });
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() });
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) {
@@ -49,8 +58,8 @@ function samples(profile, kind, names) {
(async () => {
(async () => {
const profiles = loadProfiles();
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()) {
for (const p of profiles.values()) {
assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
assert.ok(p.widgets.length > 0, `${p.id} widgets`);
assert.ok(p.widgets.length > 0, `${p.id} widgets`);
@@ -91,6 +100,15 @@ function samples(profile, kind, names) {
@@ -91,6 +100,15 @@ function samples(profile, kind, names) {
assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
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', () => {
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' }] });
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);
assert.strictEqual(v.limits.max_command_ms, 300);