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)
Changes
@@@ -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 planfor (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 };@@@ -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;@@@ -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 };@@@ -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}">@@@ -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']]);@@@ -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');});@@@ -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);++}
@@ -27,10 +27,11 @@ const path = require('path');
@@ -27,10 +27,11 @@ const path = require('path');
const NAME_RE = /^[a-z][a-z0-9_]{0,31}$/;
const NAME_RE = /^[a-z][a-z0-9_]{0,31}$/;
const MAX_LABEL = 40;
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';
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) ────────────────
// ── The plan (pure: built from the export document alone; no database, no writes) ────────────────
const asArray = (v) => (Array.isArray(v) ? v : []);
const asArray = (v) => (Array.isArray(v) ? v : []);
@@ -67,7 +68,8 @@ function labelFor(raw, fallback, notes) {
@@ -67,7 +68,8 @@ function labelFor(raw, fallback, notes) {
function keyFor(raw, notes) {
function keyFor(raw, notes) {
const key = String(raw == null ? '' : raw).trim();
const key = String(raw == null ? '' : raw).trim();
if (!key) return null;
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; }
if (key.length > 16) { notes.push(`key ${JSON.stringify(key)} dropped: at most 16 characters`); return null; }
return key;
return key;
}
}
@@ -110,6 +112,7 @@ function buildPlan(doc) {
@@ -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 subjectOf = (userId) => { const o = owners.get(userId); return o && o.subject_id ? o.subject_id : null; };
const plan = [];
const plan = [];
+ const namesByOwner = new Map(); // owner subject → robot names given in this plan
for (const config of configs) {
for (const config of configs) {
const owner = owners.get(config.user_id) || null;
const owner = owners.get(config.user_id) || null;
const cfg = {
const cfg = {
@@ -126,7 +129,13 @@ function buildPlan(doc) {
@@ -126,7 +129,13 @@ function buildPlan(doc) {
plan.push(cfg);
plan.push(cfg);
continue;
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 mine = buttons.filter((b) => b.config_id === config.id);
const enabled = mine.filter((b) => b.is_enabled === undefined || Number(b.is_enabled) !== 0);
const enabled = mine.filter((b) => b.is_enabled === undefined || Number(b.is_enabled) !== 0);
@@ -166,6 +175,16 @@ function buildPlan(doc) {
@@ -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' });
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.
// The whitelist of the owner's channel, by Network subject.
const seen = new Set();
const seen = new Set();
for (const w of whitelist.filter((x) => x.owner_user_id === config.user_id)) {
for (const w of whitelist.filter((x) => x.owner_user_id === config.user_id)) {
@@ -341,4 +360,4 @@ if (require.main === module) {
@@ -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); });
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 };
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
const {
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
} = require('../util');
} = require('../util');
-const { getProfile, validateProfile } = require('../profiles');
+const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
const DEFAULT_ALLOW = {
const DEFAULT_ALLOW = {
@@ -65,9 +65,6 @@ function noteToHz(note) {
@@ -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 }) {
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
@@ -187,14 +184,14 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -187,14 +184,14 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const robot = await getRobot(robotId);
const robot = await getRobot(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
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');
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 local = await localProfile(robotId);
const sourceId = local ? local.source_profile_id : robot.profile_id;
const sourceId = local ? local.source_profile_id : robot.profile_id;
const source = await getProfile(db, sourceId);
const source = await getProfile(db, sourceId);
if (!source) fail(422, 'bot.unknown_profile', 'catalogue profile is missing');
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.
// Profile IDs are lowercase by contract; robot ULIDs are uppercase.
const id = `local.${robotId.toLowerCase()}`;
const id = `local.${robotId.toLowerCase()}`;
const base = source.profile;
const base = source.profile;
@@ -246,4 +246,21 @@ async function listProfiles(db) {
@@ -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');
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 };
@@ -292,7 +292,7 @@ function localProfileForm(robot, profile, settings = {}) {
@@ -292,7 +292,7 @@ function localProfileForm(robot, profile, settings = {}) {
</div>
</div>
</fieldset>`).join('');
</fieldset>`).join('');
return `<section class="setting-card" aria-labelledby="buttons-h"><h2 id="buttons-h">Buttons</h2>
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>` : ''}
${settings.error ? `<p class="error" role="alert">${esc(settings.error)}</p>` : ''}
<form method="post" action="/robots/${esc(robot.id)}/profile" data-profile-form>
<form method="post" action="/robots/${esc(robot.id)}/profile" data-profile-form>
<input type="hidden" name="count" value="${shown.length}">
<input type="hidden" name="count" value="${shown.length}">
@@ -63,7 +63,7 @@ const EXPORT = {
@@ -63,7 +63,7 @@ const EXPORT = {
await check('the dry-run plan names each robot, its buttons, operators and drops', () => {
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.deepStrictEqual(plan.summary, { configs: 2, to_convert: 1, skipped: 1, buttons: 3, operators: 1 });
assert.strictEqual(cfg.status, 'convert');
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.profile_id, 'relay.generic');
assert.strictEqual(cfg.robot.access_policy, 'private');
assert.strictEqual(cfg.robot.access_policy, 'private');
assert.deepStrictEqual(cfg.buttons.map((b) => b.name), ['wave', 'grip_it', 'spin_wheel']);
assert.deepStrictEqual(cfg.buttons.map((b) => b.name), ['wave', 'grip_it', 'spin_wheel']);
@@ -78,11 +78,31 @@ const EXPORT = {
@@ -78,11 +78,31 @@ const EXPORT = {
assert.ok(cfg.buttons[0].notes.some((n) => /overrides/.test(n)));
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];
const grip = cfg.buttons[1];
assert.strictEqual(grip.hold, true);
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];
const spin = cfg.buttons[2];
assert.strictEqual(spin.label.length, 40);
assert.strictEqual(spin.label.length, 40);
assert.ok(spin.notes.some((n) => /truncated/.test(n)));
assert.ok(spin.notes.some((n) => /truncated/.test(n)));
@@ -105,7 +125,7 @@ const EXPORT = {
@@ -105,7 +125,7 @@ const EXPORT = {
const text = formatPlan(plan);
const text = formatPlan(plan);
assert.match(text, /1 to convert, 1 skipped/);
assert.match(text, /1 to convert, 1 skipped/);
assert.match(text, /- wave "Wave harder" ← "wave" key KeyG cooldown 250ms/);
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/);
assert.match(text, /SKIPPED: Live user 80 \(dave\) has no Network subject/);
});
});
@@ -124,7 +144,7 @@ const EXPORT = {
@@ -124,7 +144,7 @@ const EXPORT = {
robotId = results[0].robot_id;
robotId = results[0].robot_id;
const robot = await t.domain.robots.get(robotId);
const robot = await t.domain.robots.get(robotId);
assert.strictEqual(robot.owner_subject, ALICE);
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.strictEqual(robot.access_policy, 'private');
assert.ok(robot.profile_id.startsWith('local.rob_'), 'the robot carries a robot-local profile');
assert.ok(robot.profile_id.startsWith('local.rob_'), 'the robot carries a robot-local profile');
@@ -133,7 +153,7 @@ const EXPORT = {
@@ -133,7 +153,7 @@ const EXPORT = {
assert.deepStrictEqual(Object.keys(local.profile.commands.button.names), ['wave', 'grip_it', 'spin_wheel']);
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.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.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);
const members = await t.domain.members.list(robotId);
assert.deepStrictEqual(members.map((m) => [m.subject, m.role]).sort(), [[ALICE, 'owner'], [BOB, 'operator']]);
assert.deepStrictEqual(members.map((m) => [m.subject, m.role]).sort(), [[ALICE, 'owner'], [BOB, 'operator']]);
@@ -39,7 +39,7 @@ const { renderPanel } = require('../server/web/render');
@@ -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.
// 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'])
for (const key of ['Space', ' ', 'w', 'KeyD', 'ArrowUp', 'e', 'Escape'])
await assert.rejects(domain.robots.saveLocalProfile(id, owner, { buttons: { go: { label: 'Go', key } } }),
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.localProfile(id), null);
assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');
assert.strictEqual((await domain.robots.get(id)).profile_id, 'sim.rover');
});
});
@@ -207,3 +207,16 @@ function samples(profile, kind, names) {
@@ -207,3 +207,16 @@ function samples(profile, kind, names) {
done();
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);
+}