openvibers/openvibe.bot Commits
Q and E tilt the head; a restart says so (1012) (#381)
Changes
@@@ -29,7 +29,10 @@ credential in `?credential=` is ignored and the socket stays unpaired). A bad orcloses the socket with code **4002**. A device that has no credential yet connects **without** theheader; 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` thepairing code is minted by OpenVibe.Network (Bot calls `POST /internal/link-pairings` with its own service@@@ -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];@@@ -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)),@@@ -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;}nnew file mode 100644@@@ -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); });@@@ -86,6 +86,26 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widgetassert.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 };"));
@@ -29,7 +29,10 @@ credential in `?credential=` is ignored and the socket stays unpaired). A bad or
@@ -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
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`
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**),
`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 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
pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/link-pairings` with its own service
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* defence, never the way a panel stops a robot. Losing the socket, the window or the page releases everything.
* 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
* 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
* 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,
* (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
* B = stop, Start = e-stop; gamepaddisconnected releases it all
@@ -634,6 +634,45 @@
@@ -634,6 +634,45 @@
ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
ArrowLeft: [['steer', 'x'], 0], KeyA: [['steer', 'x'], 0], ArrowRight: [['steer', 'x'], 1], KeyD: [['steer', 'x'], 1],
KeyQ: [['rotation'], 0], KeyE: [['rotation'], 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. */
/** A key → { axis: its end of the range } on the drive, or null when the drive has none of its axes. */
function keyValue(code) {
function keyValue(code) {
const k = KEYS[code];
const k = KEYS[code];
@@ -661,6 +700,12 @@
@@ -661,6 +700,12 @@
const profileKey = (e) => (e.remote || main.contains(document.activeElement)) &&
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()));
$$('[data-button-key]').find((el) => !el.disabled && (el.dataset.buttonKey === e.code || el.dataset.buttonKey.toLowerCase() === e.key.toLowerCase()));
function onKeyDown(e) {
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;
if (typing(e)) return;
// Escape hands the keyboard back to the page (Live focuses its player), so the overlay never traps it.
// 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') {
if (e.code === 'Escape' && BOT_VIEW !== 'panel') {
@@ -682,6 +727,7 @@
@@ -682,6 +727,7 @@
if (button) { e.preventDefault(); if (!e.repeat) pressProfileButton(button, `key:${e.code}`); }
if (button) { e.preventDefault(); if (!e.repeat) pressProfileButton(button, `key:${e.code}`); }
}
}
function onKeyUp(e) {
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}`);
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 (KEYS[e.code]) keyRelease(e.code, `key:${e.code}`);
if (KEY_DIR[e.code]) dirRelease(KEY_DIR[e.code], `key:${e.code}`);
if (KEY_DIR[e.code]) dirRelease(KEY_DIR[e.code], `key:${e.code}`);
@@ -690,6 +736,7 @@
@@ -690,6 +736,7 @@
document.addEventListener('keyup', onKeyUp);
document.addEventListener('keyup', onKeyUp);
function stopDrive() {
function stopDrive() {
+ headReleaseAll();
const h = holds.get(DRIVE);
const h = holds.get(DRIVE);
if (h) for (const source of [...h.src.keys()]) { if (source instanceof Element) releaseButton(source); else holdRelease(DRIVE, source); }
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();
for (const s of sticks) s.release();
@@ -896,6 +943,7 @@
@@ -896,6 +943,7 @@
function controlKeys() {
function controlKeys() {
const keys = new Set();
const keys = new Set();
if (driveEnabled()) { for (const code of Object.keys(KEYS)) if (keyValue(code)) keys.add(code); keys.add('Space'); }
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);
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);
for (const el of $$('[data-button-key]')) if (!el.disabled) keys.add(el.dataset.buttonKey);
return [...keys];
return [...keys];
@@ -1131,9 +1131,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1131,9 +1131,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
pendingCmds.clear();
pendingCmds.clear();
inflight.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();
deviceConns.clear(); subsByRobot.clear(); watchConns.clear(); watchersByAddr.clear();
await Promise.all([
await Promise.all([
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)), new Promise((r) => watchWss.close(r)),
@@ -218,8 +218,12 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -218,8 +218,12 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
switch (w.type) {
switch (w.type) {
case 'drive': {
case 'drive': {
const axes = (commands[kind] && commands[kind].axes) || {};
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)
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>`;
+ `<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;
break;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -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); });
@@ -86,6 +86,26 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
@@ -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)'));
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', () => {
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');
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 };"));
assert.ok(js.includes("const msg = { type: 'ov-bot-controls', robot_id: ROBOT_ID, active: keys.length > 0, keys, pad: keys.length > 0 };"));