openvibers/openvibe.bot Commits

Q and E tilt the head; a restart says so (1012) (#381)

OP OpenVibers committed 3 days ago · f2c9b21fa6 · 6 files, +209 −6

Q tilts up and E down on a robot with a head (LOOI), through the head
slider's own command at its hold cadence, the slider following; release
sends nothing (head 0 is Center). They were bound to a rotation axis LOOI's
drive does not have, so they did nothing. The legend says what they do.

Bot closes every socket with 1012 Service Restart on shutdown instead of
1001 Going Away, and the protocol says clients reconnect at once and back
off.

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 650a2df..8cd35e8 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -29,7 +29,10 @@ credential in `?credential=` is ignored and the socket stays unpaired). A bad or
closes the socket with code **4002**. A device that has no credential yet connects **without** the
header; such a socket may only send `pair` until it is answered (anything else → `error`
`bot.not_paired`). One socket per device: a second connection replaces the first (close code **4000**),
--so a reconnecting agent cannot be shadowed by a stale socket.
++so a reconnecting agent cannot be shadowed by a stale socket. When Bot restarts (a deploy: it is back within about a
++second) it closes every socket with **1012** (Service Restart, reason `restarting: reconnect now`). A device or panel
++reconnects at once on 1012, 1001, 1006 and any other close it did not ask for, then backs off (0.5 s, 1 s, 2 s … at most
++30 s) and keeps trying for as long as it runs; only **4000** (replaced by a newer socket) and an auth refusal stop it.
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/link-pairings` with its own service
ddiff --git a/public/panel.js b/public/panel.js
iindex 9c51181..661e61d 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -12,7 +12,7 @@
* defence, never the way a panel stops a robot. Losing the socket, the window or the page releases everything.
*
* joystick pointer events with pointer capture, a deadzone; pointerup/cancel → a zero drive at once
-- * keyboard arrows/WASD drive, Q/E rotate, Space stops (unchanged)
++ * keyboard arrows/WASD drive, Space stops; Q/E tilt the head where the robot has one (else they rotate)
* gamepad polled on requestAnimationFrame only while a pad is connected and the page has focus: left stick
* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
* B = stop, Start = e-stop; gamepaddisconnected releases it all
@@@ -634,6 +634,45 @@
ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
KeyQ: [['rotation'], 0], KeyE: [['rotation'], 1],
};
++ // Q and E tilt the head on a robot that has one (Maticus, 2026-11-10: on LOOI they were bound to a turn axis its
++ // drive does not have, so they did nothing). Q is up, E is down. They go through the head slider's own command
++ // (send('actuator', { name: 'head', value })): the same socket, identity, operator stamp, commissioned limits and
++ // per-command deadline as a drag. While a key is held a fresh command goes at the slider's hold cadence (LOOI: 90 ms),
++ // each one step on from the slider's own value, clamped to its range, and the slider moves with it, so a drag after the
++ // keys starts where the head is going (the slider sends absolute targets: no jump). Release sends nothing: head 0 is
++ // Center, not stop, and the last command's lease bounds what is left of the move. Q and E together: nothing. Blur, a
++ // hidden page, pagehide, STOP, the e-stop and a lost socket stop it (releaseAll and stopDrive).
++ const HEAD_KEYS = { KeyQ: 1, KeyE: -1 };
++ const headSlider = () => $('[data-actuator="head"]');
++ const head = { down: new Set(), timer: null };
++ const headEnabled = () => { const el = headSlider(); return !!el && !el.disabled && joined && allowed.includes('actuator'); };
++ function headStop() { if (head.timer) { clearInterval(head.timer); head.timer = null; } }
++ function headStep() {
++ const el = headSlider();
++ if (!el || !headEnabled()) { head.down.clear(); return headStop(); }
++ const up = head.down.has('KeyQ'), down = head.down.has('KeyE');
++ if (up === down) return headStop();
++ const lo = Number(el.min), hi = Number(el.max), step = Number(el.step) || (hi - lo) / 40;
++ const next = Math.round(Math.min(hi, Math.max(lo, Number(el.value) + (up ? step : -step))) * 1000) / 1000;
++ el.value = String(next);
++ send('actuator', { name: el.dataset.actuator, value: next });
++ }
++ function headRun() {
++ headStop();
++ const up = head.down.has('KeyQ'), down = head.down.has('KeyE');
++ if (up === down) return; // none, or both: nothing moves and nothing is sent
++ headStep();
++ const el = headSlider();
++ head.timer = setInterval(headStep, Number(el && el.dataset.holdRepeat) || HOLD_MS);
++ }
++ function headPress(code) { if (!headEnabled()) return false; head.down.add(code); headRun(); return true; }
++ function headRelease(code) { if (!head.down.delete(code)) return; headRun(); }
++ function headReleaseAll() { head.down.clear(); headStop(); }
++ window.addEventListener('ov-bot-release-all', headReleaseAll);
++ /** A field the person types into (chat, a name): keys there are words, never controls. A slider or a button is not. */
++ const textEntry = (t) => !!t && (t.isContentEditable === true || /^(TEXTAREA|SELECT)$/.test(t.tagName || '')
++ || (t.tagName === 'INPUT' && !/^(range|checkbox|radio|button|submit|color)$/i.test(t.type || '')));
++
/** A key → { axis: its end of the range } on the drive, or null when the drive has none of its axes. */
function keyValue(code) {
const k = KEYS[code];
@@@ -661,6 +700,12 @@
const profileKey = (e) => (e.remote || main.contains(document.activeElement)) &&
$$('[data-button-key]').find((el) => !el.disabled && (el.dataset.buttonKey === e.code || el.dataset.buttonKey.toLowerCase() === e.key.toLowerCase()));
function onKeyDown(e) {
++ if (HEAD_KEYS[e.code] && headSlider()) {
++ if (textEntry(e.target)) return;
++ e.preventDefault();
++ if (!e.repeat) headPress(e.code);
++ return;
++ }
if (typing(e)) return;
// Escape hands the keyboard back to the page (Live focuses its player), so the overlay never traps it.
if (e.code === 'Escape' && BOT_VIEW !== 'panel') {
@@@ -682,6 +727,7 @@
if (button) { e.preventDefault(); if (!e.repeat) pressProfileButton(button, `key:${e.code}`); }
}
function onKeyUp(e) {
++ if (HEAD_KEYS[e.code]) headRelease(e.code);
for (const [el, h] of buttonHolds) if (h.sources.has(`key:${e.code}`)) releaseProfileButton(el, `key:${e.code}`);
if (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`);
if (KEY_DIR[e.code]) dirRelease(KEY_DIR[e.code], `key:${e.code}`);
@@@ -690,6 +736,7 @@
document.addEventListener('keyup', onKeyUp);
function stopDrive() {
++ headReleaseAll();
const h = holds.get(DRIVE);
if (h) for (const source of [...h.src.keys()]) { if (source instanceof Element) releaseButton(source); else holdRelease(DRIVE, source); }
for (const s of sticks) s.release();
@@@ -896,6 +943,7 @@
function controlKeys() {
const keys = new Set();
if (driveEnabled()) { for (const code of Object.keys(KEYS)) if (keyValue(code)) keys.add(code); keys.add('Space'); }
++ if (headSlider() && headEnabled()) { keys.add('KeyQ'); keys.add('KeyE'); keys.add('Space'); }
if (buttonDrive && joined && allowed.includes('button')) for (const [code, dir] of Object.entries(KEY_DIR)) if (dirButton(dir)) keys.add(code);
for (const el of $$('[data-button-key]')) if (!el.disabled) keys.add(el.dataset.buttonKey);
return [...keys];
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 8538a25..67ddb99 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -1131,9 +1131,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
pendingCmds.clear();
inflight.clear();
-- for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
-- for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
-- for (const conn of watchConns) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
++ // 1012 Service Restart (RFC 6455's registry), not 1001 Going Away: Bot stops only to start again (a deploy takes
++ // about a second), so every client should reconnect at once and then back off. LOOI's node read the old 1001
++ // as link_lost and stayed down after a deploy on 2026-11-10 at 12:24:05 UTC.
++ for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
++ for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
++ for (const conn of watchConns) { try { conn.ws.close(1012, 'restarting: reconnect now'); } catch { /* gone */ } }
deviceConns.clear(); subsByRobot.clear(); watchConns.clear(); watchersByAddr.clear();
await Promise.all([
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 7854936..6737ded 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -218,8 +218,12 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
switch (w.type) {
case 'drive': {
const axes = (commands[kind] && commands[kind].axes) || {};
++ // What Q and E do on this robot (public/panel.js): tilt the head where it has one, else turn where the
++ // drive has a rotation axis, else nothing, and then the legend does not offer them.
++ const headName = ((commands.actuator && commands.actuator.names) || {}).head;
++ const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';
inner = drivePad(axes, enabled)
-- + '<p class="hint"><kbd>W A S D</kbd> or arrows to drive, <kbd>Q</kbd> <kbd>E</kbd> to turn, <kbd>Space</kbd> to stop. '
++ + `<p class="hint"><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `
+ `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;
break;
}
ddiff --git a/test/panel-head-keys-chrome.test.js b/test/panel-head-keys-chrome.test.js
nnew file mode 100644
iindex 0000000..cc21b1c
---- /dev/null
++++ b/test/panel-head-keys-chrome.test.js
@@@ -0,0 +1,125 @@
++'use strict';
++// Q and E tilt LOOI's head (Maticus, 2026-11-10: "the legend under the virtual joystick says 'Q, E to turn,' but pressing Q
++// or E does nothing … I want Q and E to tilt LOOI's head"). They were bound to the drive's `rotation` axis, which LOOI's
++// drive (throttle, steer) does not have, so the handler found nothing to send. In a real headless Chrome, against LOOI's
++// own panel (renderPanel with the looi profile) and a stand-in for /control that records every frame:
++// - Q tilts up, E tilts down, through the head slider's command (actuator, name head), one step a hold tick (90 ms);
++// - the slider follows, so a drag after the keys starts where the head is going; the value stays in the range;
++// - release sends nothing more (no head 0: that is Center); Q and E together send nothing;
++// - typing in a text field never tilts; blur, Space (stop) and a lost socket stop the repeats;
++// - with no drive allowed (a camera-only mode) the keys still tilt, and the page around the frame is offered Q and E;
++// - a viewer who may not send actuator commands tilts nothing;
++// - the legend says "Q / E to tilt" on LOOI.
++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const http = require('http');
++const { loadProfiles } = require('../server/profiles');
++const { renderPanel, page, panelScripts } = require('../server/web/render');
++
++let chromium;
++try { ({ chromium } = require('playwright')); } catch { chromium = null; }
++const CHROME = [process.env.CHROME_BIN, '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((p) => p && fs.existsSync(p));
++if (process.env.OV_SKIP_BROWSER === '1' || !chromium || !CHROME) { console.log('panel-head-keys-chrome: skipped (no Chrome or Playwright)'); process.exit(0); }
++
++const profile = loadProfiles().get('looi');
++const robot = { id: 'rob_test', name: 'LOOI', profile_id: 'looi', online: true };
++const html = (allowed) => { const body = renderPanel({ robot, profile, role: 'operator', allowed_commands: allowed, holdResendMs: 150 }); return /panel\.js/.test(body) ? body : page('LOOI', body, { scripts: panelScripts(profile) }); };
++// A stand-in WebSocket: answers join with the allowed commands and records every command frame.
++const FAKE_WS = (allowed) => `<script>
++window.__sent = []; window.__sockets = [];
++window.WebSocket = class { constructor() { this.readyState = 0; window.__sockets.push(this);
++ setTimeout(() => { this.readyState = 1; this.onopen && this.onopen(); }, 5); }
++ send(t) { const m = JSON.parse(t); if (m.type === 'join') setTimeout(() => this.onmessage && this.onmessage({ data: JSON.stringify({ type: 'joined', role: 'operator', allowed_commands: ${JSON.stringify(allowed)}, state: { online: true } }) }), 5);
++ else window.__sent.push({ at: performance.now(), ...m }); }
++ close() { this.readyState = 3; this.onclose && this.onclose({ code: 1006, reason: '' }); } addEventListener() {} removeEventListener() {} };
++</script>`;
++
++const root = path.join(__dirname, '..', 'public');
++let allowedNow = ['drive', 'actuator'];
++const server = http.createServer((req, res) => {
++ const u = req.url.split('?')[0];
++ if (u === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(html(allowedNow).replace('<head>', '<head>' + FAKE_WS(allowedNow))); }
++ const f = u.startsWith('/panel/') ? path.join(root, u.slice('/panel/'.length)) : null;
++ if (f && fs.existsSync(f)) { res.writeHead(200, { 'content-type': u.endsWith('.css') ? 'text/css' : 'application/javascript' }); return res.end(fs.readFileSync(f)); }
++ res.writeHead(404); res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}/`;
++ const browser = await chromium.launch({ executablePath: CHROME });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const heads = (pg) => pg.evaluate(() => window.__sent.filter((m) => m.type === 'command' && m.kind === 'actuator' && m.value && m.value.name === 'head').map((m) => m.value.value));
++ try {
++ const open = async (allowed) => {
++ allowedNow = allowed;
++ const pg = await browser.newPage();
++ await pg.goto(base);
++ await pg.waitForFunction(() => document.querySelector('[data-actuator="head"]') && !document.querySelector('[data-actuator="head"]').disabled, null, { timeout: 4000 });
++ await pg.evaluate(() => { document.body.focus(); window.__sent.length = 0; });
++ return pg;
++ };
++ // Q up, E down, a step a tick, the slider follows, nothing after release.
++ let pg = await open(['drive', 'actuator']);
++ assert.match(await pg.evaluate(() => document.querySelector('.hint').textContent), /W A S D or arrows to drive · Q \/ E to tilt · Space to stop\./, 'the legend says what Q and E do on LOOI');
++ await pg.keyboard.down('KeyQ'); await wait(470); await pg.keyboard.up('KeyQ');
++ const up = await heads(pg);
++ assert.ok(up.length >= 5 && up.length <= 7, `Q held ~470 ms sends a head command about every 90 ms (${up.length})`);
++ assert.ok(up.every((v, i) => i === 0 || v > up[i - 1]), `each one higher: tilt up (${up.join(' ')})`);
++ assert.strictEqual(Number(await pg.evaluate(() => document.querySelector('[data-actuator="head"]').value)), up[up.length - 1], 'the slider shows where the head is going');
++ const n = up.length; await wait(300);
++ assert.strictEqual((await heads(pg)).length, n, 'nothing is sent after release, not even head 0');
++ await pg.evaluate(() => { window.__sent.length = 0; });
++ await pg.keyboard.down('KeyE'); await wait(200); await pg.keyboard.up('KeyE');
++ const down = await heads(pg);
++ assert.ok(down.length >= 2 && down[0] < up[up.length - 1] && down.every((v, i) => i === 0 || v < down[i - 1]), `E tilts down from where Q left it (${down.join(' ')})`);
++ // The range holds.
++ await pg.evaluate(() => { window.__sent.length = 0; });
++ await pg.keyboard.down('KeyQ'); await wait(4500); await pg.keyboard.up('KeyQ');
++ const top = await heads(pg);
++ assert.ok(top.every((v) => v <= 1) && top[top.length - 1] === 1, `clamped to the head's range (${top.slice(-3).join(' ')})`);
++ // Q and E together: nothing; letting go of one moves with the other.
++ await pg.evaluate(() => { window.__sent.length = 0; });
++ await pg.keyboard.down('KeyE'); await pg.keyboard.down('KeyQ'); const both = (await heads(pg)).length; await wait(300);
++ assert.strictEqual((await heads(pg)).length, both, 'Q and E held together send nothing');
++ await pg.keyboard.up('KeyQ'); await wait(200); await pg.keyboard.up('KeyE');
++ assert.ok((await heads(pg)).length > both, 'letting go of Q leaves E tilting down');
++ // Typing in a text field never tilts.
++ await pg.evaluate(() => { const i = document.createElement('input'); i.type = 'text'; i.id = 'chat'; document.body.append(i); i.focus(); window.__sent.length = 0; });
++ await pg.keyboard.down('KeyQ'); await wait(250); await pg.keyboard.up('KeyQ');
++ assert.strictEqual((await heads(pg)).length, 0, 'Q in a text field is a letter');
++ assert.strictEqual(await pg.evaluate(() => document.getElementById('chat').value), 'q', 'and it is typed');
++ // Blur, Space and a lost socket stop the repeats.
++ await pg.evaluate(() => { document.getElementById('chat').blur(); document.body.focus(); });
++ for (const stopper of ['blur', 'space', 'socket']) {
++ await pg.evaluate(() => { window.__sent.length = 0; });
++ await pg.keyboard.down('KeyQ'); await wait(150);
++ if (stopper === 'blur') await pg.evaluate(() => window.dispatchEvent(new Event('blur')));
++ if (stopper === 'space') await pg.keyboard.press('Space');
++ if (stopper === 'socket') await pg.evaluate(() => window.__sockets[window.__sockets.length - 1].close());
++ const at = (await heads(pg)).length; await wait(300);
++ assert.strictEqual((await heads(pg)).length, at, `${stopper} stops the repeats`);
++ await pg.keyboard.up('KeyQ');
++ if (stopper === 'socket') break;
++ }
++ await pg.close();
++ // No drive allowed (a camera-only mode): Q and E still tilt, and the page around the frame is offered them.
++ pg = await open(['actuator']);
++ await pg.keyboard.down('KeyQ'); await wait(200); await pg.keyboard.up('KeyQ');
++ assert.ok((await heads(pg)).length >= 2, 'the head tilts with no drive allowed');
++ await pg.close();
++ // A viewer who may not send actuator commands: nothing.
++ pg = await browser.newPage(); allowedNow = ['drive'];
++ await pg.goto(base); await wait(300);
++ await pg.evaluate(() => { document.body.focus(); window.__sent.length = 0; });
++ await pg.keyboard.down('KeyQ'); await wait(250); await pg.keyboard.up('KeyQ');
++ assert.strictEqual((await heads(pg)).length, 0, 'no head command without actuator permission');
++ await pg.close();
++ console.log('panel-head-keys-chrome: Q tilts up and E down at the hold cadence through the head command, the slider follows, release sends nothing, both held send nothing, text fields and a viewer never tilt, blur/Space/a lost socket stop it, camera-only tilts, the legend says so');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/panel-inputs.test.js b/test/panel-inputs.test.js
iindex 37720d1..fafc576 100644
---- a/test/panel-inputs.test.js
++++ b/test/panel-inputs.test.js
@@@ -86,6 +86,26 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
assert.ok(branch.includes('now.has(1) && !pad.down.has(1)') && branch.includes('now.has(9) && !pad.down.has(9)'));
});
++ await check('Q and E tilt the head where the robot has one: Q up, E down, through the head command, nothing on release', () => {
++ // Maticus, 2026-11-10: on LOOI they were bound to a `rotation` drive axis LOOI does not have, so they did nothing.
++ // The behaviour itself runs in a real browser in panel-head-keys-chrome.test.js (where Chrome and Playwright exist).
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
++ assert.ok(js.includes("const HEAD_KEYS = { KeyQ: 1, KeyE: -1 };"), 'Q is up, E is down');
++ assert.ok(js.includes("send('actuator', { name: el.dataset.actuator, value: next });"), 'the head slider\'s own command, with its identity, limits and deadline');
++ assert.ok(js.includes("el.value = String(next);"), 'the slider follows, so a drag starts where the keys left it');
++ assert.ok(js.includes("if (up === down) return headStop();") && js.includes("if (up === down) return; // none, or both"), 'both held: nothing');
++ assert.ok(js.includes("head.timer = setInterval(headStep, Number(el && el.dataset.holdRepeat) || HOLD_MS);"), 'the slider\'s hold cadence (LOOI: 90 ms)');
++ assert.ok(js.includes("function headRelease(code) { if (!head.down.delete(code)) return; headRun(); }") && !/headRelease[\s\S]{0,200}send\('actuator'/.test(js), 'release sends nothing: head 0 is Center, not stop');
++ assert.ok(js.includes("window.addEventListener('ov-bot-release-all', headReleaseAll);") && js.includes("function stopDrive() {\n headReleaseAll();"), 'blur, a hidden page, a lost socket, the e-stop and STOP stop it');
++ assert.ok(js.includes("if (textEntry(e.target)) return;"), 'never while typing');
++ assert.ok(js.includes("if (headSlider() && headEnabled()) { keys.add('KeyQ'); keys.add('KeyE'); keys.add('Space'); }"), 'the page around the frame is offered Q and E, also with no drive');
++ const looi = loadProfiles().get('looi');
++ const legend = renderWidget(looi.widgets.find((w) => w.type === 'drive'), { profile: looi, allowed_commands: ['drive', 'actuator'] });
++ assert.ok(legend.includes('<kbd>W A S D</kbd> or arrows to drive · <kbd>Q</kbd> / <kbd>E</kbd> to tilt · <kbd>Space</kbd> to stop.'), 'the legend on LOOI');
++ const sim = [...loadProfiles().values()].find((p) => p.commands && p.commands.drive && p.commands.drive.axes && p.commands.drive.axes.rotation && !((p.commands.actuator || {}).names || {}).head);
++ if (sim) assert.ok(renderWidget(sim.widgets.find((w) => w.type === 'drive'), { profile: sim, allowed_commands: ['drive'] }).includes('/ <kbd>E</kbd> to turn'), 'a drive with a rotation axis keeps Q / E to turn');
++ });
++
await check('panel.js: the page around the frame gets the controls state and may send keys and its gamepad, nothing else', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.ok(js.includes("const msg = { type: 'ov-bot-controls', robot_id: ROBOT_ID, active: keys.length > 0, keys, pad: keys.length > 0 };"));