openvibers/openvibe.bot Commits

Held drive keeps going through Wi-Fi jitter; a locked control takes the controls (#391)

OP OpenVibers committed 2 days ago · f9a1db949f · 7 files, +42 −16

Maticus, 2026-11-11: held drive input was re-sent every 150 ms against LOOI's 300 ms deadline,
about 90 ms of slack; the tray logged four expiries in 9 minutes (gaps of 171-257 ms). And on the
stream the controls looked live but stayed locked until Drive was found.

- A held control is re-sent at a sixth of the device's deadline (50 ms at the least; was half).
- LOOI's own deadline is 500 ms (Maticus asked for about 500, within the profile; the profile is
  ours). With the new resend that is about 83 ms between frames and 400 ms of slack.
- Release, blur, a hidden tab and STOP are unchanged; permissions are unchanged.
- A press on a locked control (the stick, the slider, a button) by someone who may take the
  controls asks for them as Drive does; locked controls carry a small lock at their corner.

Changes

ddiff --git a/public/panel.css b/public/panel.css
iindex f05d62b..12dcc1d 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -666,3 +666,14 @@ body.bot-mode-compact .bot-compact :is(.bot-more, .bot-estop) { background: colo
body.bot-mode-compact .bot-compact .bot-estop { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 70%, transparent); }
body.bot-mode-compact .bot-compact .bot-drive { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }
++
++/* A control still locked (no Drive yet): it takes the press (its inputs pass it to their box, panel.js asks for the
++ controls) and says so with a small lock at its corner. */
++#panel[data-role="watcher"] [data-command] input:disabled,
++#panel[data-role="watcher"] [data-command] button:disabled { pointer-events: none; }
++#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .bot-slider, .widget-drive .drive-pad) { cursor: pointer; position: relative; }
++#panel[data-role="watcher"]:has([data-drive]:not([hidden])) :is(.bot-stick, .widget-drive .drive-pad)::after {
++ content: ""; position: absolute; right: 6%; top: 6%; width: 18px; height: 18px; border-radius: 50%; pointer-events: none;
++ background: color-mix(in srgb, var(--fill-accent) 85%, transparent) center / 11px 11px no-repeat
++ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round'%3E%3Crect x='5' y='11' width='14' height='9' rx='2'/%3E%3Cpath d='M8 11V8a4 4 0 0 1 8 0v3'/%3E%3C/svg%3E");
++}
ddiff --git a/public/panel.js b/public/panel.js
iindex 661e61d..bfd651b 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -927,6 +927,18 @@
send('point', { x: (e.clientX - left) / width, y: (e.clientY - top) / height });
});
++ // A press on a control that is still locked, by someone who may take the controls here, asks for them as Drive does
++ // (the locked control dims with a lock; public/panel.css). Their next press drives.
++ main.addEventListener('pointerdown', (e) => {
++ if (!watcher || driving) return;
++ const t = e.target && e.target.closest ? e.target.closest('[data-command], .bot-stick, .bot-slider') : null;
++ if (!t || (e.target.closest && e.target.closest('[data-drive], [data-bot-more], [data-estop]'))) return;
++ const drive = $('[data-drive]:not([hidden])');
++ if (!drive) return;
++ e.preventDefault();
++ drive.click();
++ }, true);
++
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
for (const section of $$('[data-command]')) {
ddiff --git a/server/profiles/looi.json b/server/profiles/looi.json
iindex a239cda..85df2e2 100644
---- a/server/profiles/looi.json
++++ b/server/profiles/looi.json
@@@ -17,6 +17,6 @@
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
{ "type": "latency", "label": "Latency", "order": 4 }
],
-- "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 },
++ "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 500, "heartbeat_ms": 1000 },
"ceiling": { "max_speed": 1, "max_turn": 1 }
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ea17c42..e3809c7 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -950,8 +950,10 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
parentOrigins: mode === 'embed' ? ((config.embed && config.embed.origins) || []) : [],
liveUrl: role === 'owner' && mode === 'page' ? liveOrigin : null,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
-- // A held control is re-sent well inside the device's deadline, so it never stops between two frames.
-- holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
++ // A held control is re-sent well inside the device's deadline, so it never stops between two frames: at a
++ // sixth of it (50 ms at the least). Half left about 90 ms of slack on LOOI, and ordinary Wi-Fi jitter expired
++ // commands mid-hold (Maticus, 2026-11-11: four in 9 minutes, gaps of 171–257 ms).
++ holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 6))),
}));
}
ddiff --git a/test/commission.test.js b/test/commission.test.js
iindex 2f69152..6ffc871 100644
---- a/test/commission.test.js
++++ b/test/commission.test.js
@@@ -28,7 +28,7 @@ const { loadProfiles } = require('../server/profiles');
await check('without commissioning, values above the profile default clamp to the default exactly as today', async () => {
const { robot } = await t.robot(alex, { profile_id: 'looi', limits: { max_speed: 0.9, max_turn: 0.9 } });
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 500, cooldownMs: 0 });
});
await check('commissioning needs the owner, a person and an explicit confirm', async () => {
@@@ -66,7 +66,7 @@ const { loadProfiles } = require('../server/profiles');
assert.strictEqual(r.json.robot.commissioned.by, alex.subject);
assert.ok(r.json.robot.commissioned.at);
assert.strictEqual((await patch(robot.id, { max_speed: 0.8, max_turn: 0.9 })).status, 200);
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.8, maxTurn: 0.9, maxCommandMs: 300, cooldownMs: 0 });
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.8, maxTurn: 0.9, maxCommandMs: 500, cooldownMs: 0 });
const audit = await t.domain.audit.list(robot.id, { limit: 20 });
const row = audit.find((a) => a.kind === 'robot.commission');
assert.ok(row && row.operator_subject === alex.subject && row.result === 'ack');
@@@ -76,14 +76,14 @@ const { loadProfiles } = require('../server/profiles');
const { robot } = await t.robot(alex, { profile_id: 'looi' });
await commission(robot.id);
await patch(robot.id, { max_speed: 5, max_turn: 5 });
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 1, maxTurn: 1, maxCommandMs: 300, cooldownMs: 0 });
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 1, maxTurn: 1, maxCommandMs: 500, cooldownMs: 0 });
});
await check('max_command_ms is never raised, even when commissioned', async () => {
const { robot } = await t.robot(alex, { profile_id: 'looi' });
await commission(robot.id);
await patch(robot.id, { max_command_ms: 5000 });
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 500, cooldownMs: 0 });
});
await check('uncommissioning drops back to the defaults at once, and the audit says so', async () => {
@@@ -94,7 +94,7 @@ const { loadProfiles } = require('../server/profiles');
const r = await uncommission(robot.id);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.robot.commissioned, null);
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 300, cooldownMs: 0 });
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.5, maxTurn: 0.6, maxCommandMs: 500, cooldownMs: 0 });
const audit = await t.domain.audit.list(robot.id, { limit: 20 });
assert.ok(audit.some((a) => a.kind === 'robot.uncommission' && a.result === 'ack'));
});
@@@ -105,16 +105,16 @@ const { loadProfiles } = require('../server/profiles');
const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });
await dev.waitFor((m) => m.type === 'hello');
const first = await dev.waitFor((m) => m.type === 'config');
-- assert.deepStrictEqual(first.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'clamped before commissioning');
++ assert.deepStrictEqual(first.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 500, heartbeat_ms: 1000 }, 'clamped before commissioning');
await commission(robot.id);
await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq);
const high = await patch(robot.id, { max_speed: 0.9, max_turn: 0.9 });
assert.strictEqual(high.status, 200);
const second = await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq && m.limits.max_turn === 0.9);
-- assert.deepStrictEqual(second.limits, { max_speed: 0.9, max_turn: 0.9, max_command_ms: 300, heartbeat_ms: 1000 });
++ assert.deepStrictEqual(second.limits, { max_speed: 0.9, max_turn: 0.9, max_command_ms: 500, heartbeat_ms: 1000 });
await uncommission(robot.id);
const third = await dev.waitFor((m) => m.type === 'config' && m.seq > second.seq && m.limits.max_speed === 0.5);
-- assert.deepStrictEqual(third.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 }, 'at once, on the device');
++ assert.deepStrictEqual(third.limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 500, heartbeat_ms: 1000 }, 'at once, on the device');
dev.close();
});
@@@ -134,7 +134,7 @@ const { loadProfiles } = require('../server/profiles');
assert.strictEqual((await post(`/robots/${robot.id}/limits`, bob, { max_speed: '1', max_turn: '1' })).status, 403, 'others cannot set them');
assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '2', max_turn: '1' })).status, 422, 'above 1 is refused');
assert.strictEqual((await post(`/robots/${robot.id}/limits`, alex, { max_speed: '0.9', max_turn: '0.8' })).status, 303);
-- assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.9, maxTurn: 0.8, maxCommandMs: 300, cooldownMs: (await eff(robot.id)).cooldownMs }, 'saved and in force');
++ assert.deepStrictEqual(await eff(robot.id), { maxSpeed: 0.9, maxTurn: 0.8, maxCommandMs: 500, cooldownMs: (await eff(robot.id)).cooldownMs }, 'saved and in force');
assert.strictEqual((await post(`/robots/${robot.id}/uncommission`, alex, {})).status, 303);
const back = await eff(robot.id);
assert.deepStrictEqual([back.maxSpeed, back.maxTurn], [0.5, 0.6], 'uncommissioned: the defaults at once, whatever is stored');
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 5ae429b..7afe34d 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -108,7 +108,8 @@ function samples(profile, kind, names) {
const head = renderWidget(p.widgets.find((w) => w.type === 'head'), { profile: p, allowed_commands: ['actuator'] });
assert.match(head, /data-actuator="head" data-hold-repeat="90"/, 'the slider carries its repeat');
assert.ok(!p.camera, 'no camera of its own');
-- assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');
++ // 500 ms since 2026-11-11 (Maticus: held drive expired on ordinary Wi-Fi jitter at 300); still short.
++ assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 500, 'a desk robot starts slow, with short commands');
});
await check('camera.onvif is PTZ only; sim.rover is a whole rover (drive, camera, range and line sensors, battery, headlights, horn)', () => {
ddiff --git a/test/robot-limits.test.js b/test/robot-limits.test.js
iindex af99098..87277c3 100644
---- a/test/robot-limits.test.js
++++ b/test/robot-limits.test.js
@@@ -32,8 +32,8 @@ const { boot, check, done } = require('./helpers/app');
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.robot.owner_subject, alex.subject);
-- assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.4, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
-- assert.deepStrictEqual(r.json.robot.limits_source.profile, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
++ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.4, max_turn: 0.6, max_command_ms: 500, heartbeat_ms: 1000 });
++ assert.deepStrictEqual(r.json.robot.limits_source.profile, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 500, heartbeat_ms: 1000 });
assert.deepStrictEqual(r.json.robot.limits_source.robot, { max_speed: 0.4 }, 'only the owner\'s overrides');
});
@@@ -41,7 +41,7 @@ const { boot, check, done } = require('./helpers/app');
const { robot } = await t.robot(alex, { profile_id: 'looi' });
const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
assert.deepStrictEqual(r.json.robot.limits, {});
-- assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 300, heartbeat_ms: 1000 });
++ assert.deepStrictEqual(r.json.robot.effective_limits, { max_speed: 0.5, max_turn: 0.6, max_command_ms: 500, heartbeat_ms: 1000 });
assert.deepStrictEqual(r.json.robot.limits_source.robot, {});
});