openvibers/openvibe.bot Commits

Bot: drive keys are reserved only on robots that drive; converted robots take Live's config name; empty configs make no robot (#42)

OP OpenVibers committed 7 Oct 2026 · 7cf0a7cd90 · 7 files, +90 −24

Changes

ddiff --git a/scripts/convert-live-controls.js b/scripts/convert-live-controls.js
iindex c14cc44..78c1b56 100755
---- a/scripts/convert-live-controls.js
++++ b/scripts/convert-live-controls.js
@@@ -27,10 +27,11 @@ 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 keys a button may take are decided against the profile it lands on (server/profiles reservedKeyReason): the
++// relay profile does not drive, so Live's W/A/S/D/Q/E bindings survive; Space and Escape never do.
++const { reservedKeyReason } = require('../server/profiles');
++const RELAY_PROFILE_DOC = require('../server/profiles/relay.generic.json');
// ── The plan (pure: built from the export document alone; no database, no writes) ────────────────
const asArray = (v) => (Array.isArray(v) ? v : []);
@@@ -67,7 +68,8 @@ function labelFor(raw, fallback, notes) {
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; }
++ const why = reservedKeyReason(key, RELAY_PROFILE_DOC);
++ if (why) { notes.push(`key ${JSON.stringify(key)} dropped: ${why}`); return null; }
if (key.length > 16) { notes.push(`key ${JSON.stringify(key)} dropped: at most 16 characters`); return null; }
return key;
}
@@@ -110,6 +112,7 @@ function buildPlan(doc) {
const subjectOf = (userId) => { const o = owners.get(userId); return o && o.subject_id ? o.subject_id : null; };
const plan = [];
++ const namesByOwner = new Map(); // owner subject → robot names given in this plan
for (const config of configs) {
const owner = owners.get(config.user_id) || null;
const cfg = {
@@@ -126,7 +129,13 @@ function buildPlan(doc) {
plan.push(cfg);
continue;
}
-- cfg.robot = { name: `${owner.username || ownerSubject}'s controls`, profile_id: RELAY_PROFILE, access_policy: 'private' };
++ // Named after the Live config (an owner may have several), else after the owner; unique per owner.
++ const base = (strOr(config.name).trim() || `${owner.username || ownerSubject}'s controls`).slice(0, 80);
++ const taken = namesByOwner.get(ownerSubject) || new Set();
++ let robotName = base;
++ for (let n = 2; taken.has(robotName); n++) robotName = `${base.slice(0, 75)} (${n})`;
++ taken.add(robotName); namesByOwner.set(ownerSubject, taken);
++ cfg.robot = { name: robotName, 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);
@@@ -166,6 +175,16 @@ function buildPlan(doc) {
cfg.dropped.push({ command: strOr(extra.command), label: strOr(extra.label), reason: 'on the stream but not in the config' });
}
++ // A config with nothing left to convert makes no robot (its name goes back to the owner's pool).
++ if (!cfg.buttons.length) {
++ namesByOwner.get(ownerSubject).delete(cfg.robot.name);
++ cfg.status = 'empty';
++ cfg.reason = 'no button to convert (none enabled in Live, or every one dropped)';
++ cfg.robot = null;
++ plan.push(cfg);
++ continue;
++ }
++
// 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)) {
@@@ -341,4 +360,4 @@ 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 };
++module.exports = { buildPlan, formatPlan, applyPlan, buttonsObject, convert, normaliseName, uniqueName, RELAY_PROFILE };
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 76d96b2..f07b8b6 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -17,7 +17,7 @@
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
--const { getProfile, validateProfile } = require('../profiles');
++const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
const { ENVELOPE } = require('../events/outbox');
const DEFAULT_ALLOW = {
@@@ -65,9 +65,6 @@ function noteToHz(note) {
}
--/** The panel's drive and stop keys (public/panel.js KEYS, Space and Escape): a profile button never takes one. */
--const RESERVED_BUTTON_KEYS = new Set(['space', ' ', 'escape', 'w', 'a', 's', 'd', 'q', 'e', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright',
-- 'keyw', 'keya', 'keys', 'keyd', 'keyq', 'keye']);
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
@@@ -187,14 +184,14 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const robot = await getRobot(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (!actor || actor.subject !== robot.owner_subject) fail(403, 'bot.forbidden', 'only the owner may edit buttons');
-- for (const [name, b] of Object.entries(buttons || {})) {
-- if (b.key && RESERVED_BUTTON_KEYS.has(String(b.key).toLowerCase()))
-- fail(422, 'bot.invalid_input', `${b.key} drives or stops the robot; give ${name} another key`);
-- }
const local = await localProfile(robotId);
const sourceId = local ? local.source_profile_id : robot.profile_id;
const source = await getProfile(db, sourceId);
if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');
++ for (const [name, b] of Object.entries(buttons || {})) {
++ const why = b.key && reservedKeyReason(b.key, source.profile);
++ if (why) fail(422, 'bot.invalid_input', `${b.key}: ${why}; give ${name} another key`);
++ }
// Profile IDs are lowercase by contract; robot ULIDs are uppercase.
const id = `local.${robotId.toLowerCase()}`;
const base = source.profile;
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 0929aa1..3e60834 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -246,4 +246,21 @@ async function listProfiles(db) {
return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles WHERE robot_id IS NULL ORDER BY id, version DESC');
}
--module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
++/** Space stops and Escape releases everything on every panel (public/panel.js): no button ever takes them. */
++const ALWAYS_RESERVED_KEYS = new Set(['space', ' ', 'escape']);
++/** The panel's drive keys (public/panel.js KEYS): reserved only on a profile that drives. */
++const DRIVE_KEYS = new Set(['w', 'a', 's', 'd', 'q', 'e', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright',
++ 'keyw', 'keya', 'keys', 'keyd', 'keyq', 'keye']);
++const drives = (profile) => Boolean(profile && ((profile.commands && profile.commands.drive) || (profile.widgets || []).some((w) => w.type === 'drive')));
++
++/** Why a button may not take `key` on `profile` (null when it may): the panel's stop/release keys, and its drive keys on a robot that drives. */
++function reservedKeyReason(key, profile) {
++ const raw = String(key == null ? '' : key);
++ const k = raw === ' ' ? ' ' : raw.trim().toLowerCase(); // a bare space is the Space key, not "no key"
++ if (!k) return null;
++ if (ALWAYS_RESERVED_KEYS.has(k)) return 'the panel stops the robot with it';
++ if (DRIVE_KEYS.has(k) && drives(profile)) return 'the panel drives the robot with it';
++ return null;
++}
++
++module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, reservedKeyReason, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 23c29b0..fd8ecfa 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -292,7 +292,7 @@ function localProfileForm(robot, profile, settings = {}) {
</div>
</fieldset>`).join('');
return `<section class="setting-card" aria-labelledby="buttons-h"><h2 id="buttons-h">Buttons</h2>
--<p class="setting-note">Each button sends its name to the robot. A key works while the panel has focus; the drive keys and Space stay with driving.</p>
++<p class="setting-note">Each button sends its name to the robot. A key works while the panel has focus. Space always stops, and on a robot that drives its drive keys stay with driving.</p>
${settings.error ? `<p class="error" role="alert">${esc(settings.error)}</p>` : ''}
<form method="post" action="/robots/${esc(robot.id)}/profile" data-profile-form>
<input type="hidden" name="count" value="${shown.length}">
ddiff --git a/test/live-controls-convert.test.js b/test/live-controls-convert.test.js
iindex 500513f..6bd7ed5 100644
---- a/test/live-controls-convert.test.js
++++ b/test/live-controls-convert.test.js
@@@ -63,7 +63,7 @@ const EXPORT = {
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.name, 'Camp controls', 'named after the Live config');
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']);
@@@ -78,11 +78,31 @@ const EXPORT = {
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', () => {
++ await check('Space never becomes a key; a config with nothing to convert makes no robot; one owner\'s configs get distinct names', () => {
++ const owners = [{ id: 7, username: 'erin', subject_id: 'usr_01J8Z4M2Q0R7T9YV3K6N8P1W3A' }];
++ const doc = {
++ exported_at: '2026-10-07T12:00:00Z', owners, whitelist: [], latest_stream_controls: [],
++ configs: [{ id: 21, user_id: 7, name: 'Rig' }, { id: 22, user_id: 7, name: 'Rig' }, { id: 23, user_id: 7, name: 'Empty' }],
++ buttons: [
++ { id: 31, config_id: 21, label: 'Honk', command: 'honk', key_binding: 'Space', cooldown_ms: 0, is_enabled: 1, sort_order: 0 },
++ { id: 32, config_id: 22, label: 'Wave', command: 'wave', key_binding: 'g', cooldown_ms: 0, is_enabled: 1, sort_order: 0 },
++ { id: 33, config_id: 23, label: 'Off', command: 'off', key_binding: 'o', cooldown_ms: 0, is_enabled: 0, sort_order: 0 },
++ ],
++ };
++ const p = buildPlan(doc);
++ const [a, b, c] = p.configs;
++ assert.strictEqual(a.buttons[0].key, null);
++ assert.ok(a.buttons[0].notes.some((n) => /dropped: the panel stops the robot/.test(n)));
++ assert.deepStrictEqual([a.robot.name, b.robot.name], ['Rig', 'Rig (2)']);
++ assert.deepStrictEqual([c.status, c.robot], ['empty', null]);
++ assert.strictEqual(p.summary.to_convert, 2);
++ });
++
++ await check('a hold key becomes hold, a drive key survives on the relay profile, 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');
++ assert.strictEqual(grip.key, 'w', 'relay.generic does not drive, so W stays the button\'s key');
++ assert.ok(!grip.notes.some((n) => /dropped/.test(n)));
const spin = cfg.buttons[2];
assert.strictEqual(spin.label.length, 40);
assert.ok(spin.notes.some((n) => /truncated/.test(n)));
@@@ -105,7 +125,7 @@ const EXPORT = {
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, /robot "Camp controls"/);
assert.match(text, /SKIPPED: Live user 80 \(dave\) has no Network subject/);
});
@@@ -124,7 +144,7 @@ const EXPORT = {
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.name, 'Camp controls');
assert.strictEqual(robot.access_policy, 'private');
assert.ok(robot.profile_id.startsWith('local.rob_'), 'the robot carries a robot-local profile');
@@@ -133,7 +153,7 @@ const EXPORT = {
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');
++ assert.strictEqual(local.profile.commands.button.names.grip_it.key, 'w');
const members = await t.domain.members.list(robotId);
assert.deepStrictEqual(members.map((m) => [m.subject, m.role]).sort(), [[ALICE, 'owner'], [BOB, 'operator']]);
ddiff --git a/test/local-controls.test.js b/test/local-controls.test.js
iindex 0d1c7cf..1fde701 100644
---- a/test/local-controls.test.js
++++ b/test/local-controls.test.js
@@@ -39,7 +39,7 @@ const { renderPanel } = require('../server/web/render');
// The drive and stop keys stay with driving: a button never takes Space, WASD, Q/E or an arrow.
for (const key of ['Space', ' ', 'w', 'KeyD', 'ArrowUp', 'e', 'Escape'])
await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go', key } } }),
-- (e) => e.status === 422 && /drives or stops/.test(e.detail || e.message), `key ${JSON.stringify(key)}`);
++ (e) => e.status === 422 && /stops the robot|drives the robot/.test(e.detail || e.message), `key ${JSON.stringify(key)}`);
assert.strictEqual(await domain.robots.localProfile(id), null);
assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');
});
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 3ba498e..533d09b 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -207,3 +207,16 @@ function samples(profile, kind, names) {
done();
})();
++
++// Which keys a profile button may take (server/profiles reservedKeyReason): Space and Escape never; the drive keys
++// only where the profile drives (sim.rover does, relay.generic does not).
++{
++ const { reservedKeyReason } = require('../server/profiles');
++ const rover = require('../server/profiles/sim.rover.json');
++ const relay = require('../server/profiles/relay.generic.json');
++ const assert2 = require('assert');
++ assert2.ok(reservedKeyReason('Space', relay) && reservedKeyReason(' ', relay) && reservedKeyReason('Escape', rover), 'stop/release keys everywhere');
++ assert2.strictEqual(reservedKeyReason('w', relay), null, 'no drive, so W is free');
++ assert2.ok(reservedKeyReason('w', rover) && reservedKeyReason('KeyD', rover) && reservedKeyReason('ArrowUp', rover), 'drive keys on a driving profile');
++ assert2.strictEqual(reservedKeyReason('g', rover), null);
++}