openvibers/openvibe.bot Commits

Panel: the LOOI head slider repeats while held, stops on release (#350)

OP OpenVibers committed 4 days ago · 8344b3bb9a · 4 files, +52 −4

The LOOI bridge holds the head on short target leases. A slider set to repeat (the panel's own setting by profile and
actuator, looi/head at 90 ms, clamped 75-250) sends its current value again as a new command while the pointer holds
it, through the same send() and the same server gates, skipping a tick right after a move; it stops at once on
pointerup, pointercancel, lostpointercapture, window blur, a hidden page, pagehide and releaseAll (the e-stop). Nothing
is replayed from anywhere and no command goes after release; other sliders are unchanged.

Changes

ddiff --git a/public/panel.js b/public/panel.js
iindex dd8f119..c24c4e4 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -429,6 +429,8 @@
el.addEventListener('contextmenu', (e) => e.preventDefault());
}
function releaseAll() {
++ // Held slider repeats stop too (the e-stop, blur, a hidden page): the slider code listens for this.
++ try { window.dispatchEvent(new Event('ov-bot-release-all')); } catch { /* */ }
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
}
@@@ -785,10 +787,33 @@
if (!timer) timer = setTimeout(() => { timer = null; last = Date.now(); if (pending) fn(...pending); pending = null; }, wait);
};
}
++ // A slider whose profile asks for it (data-hold-repeat, 75-250 ms) sends its current value again as a new command
++ // while the pointer holds it, and stops at once on release, cancel, a lost capture, blur, a hidden page or pagehide:
++ // nothing is replayed from anywhere, and no command goes after release (2026-11-09, the LOOI head's short leases).
++ const repeats = new Set();
$$('[data-actuator]').forEach((el) => {
-- const push = throttled(() => send('actuator', { name: el.dataset.actuator, value: Number(el.value) }));
++ let lastSent = 0;
++ const sendNow = () => { lastSent = Date.now(); send('actuator', { name: el.dataset.actuator, value: Number(el.value) }); };
++ const push = throttled(sendNow);
el.addEventListener('input', push);
++ const every = Number(el.dataset.holdRepeat) || 0;
++ if (!every) return;
++ let timer = null;
++ const stop = () => { if (timer) { clearInterval(timer); timer = null; } repeats.delete(stop); };
++ el.addEventListener('pointerdown', (e) => {
++ if (el.disabled) return;
++ try { el.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
++ stop();
++ timer = setInterval(() => { if (!el.isConnected || el.disabled) return stop(); if (Date.now() - lastSent >= every - 5) sendNow(); }, every);
++ repeats.add(stop);
++ });
++ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop);
});
++ const stopRepeats = () => { [...repeats].forEach((s) => s()); };
++ window.addEventListener('blur', stopRepeats);
++ document.addEventListener('visibilitychange', () => { if (document.hidden) stopRepeats(); });
++ window.addEventListener('pagehide', stopRepeats);
++ window.addEventListener('ov-bot-release-all', stopRepeats);
const rgb = (hex) => ({ r: parseInt(hex.slice(1, 3), 16), g: parseInt(hex.slice(3, 5), 16), b: parseInt(hex.slice(5, 7), 16) });
$$('[data-rgb]').forEach((el) => {
const push = throttled(() => send('actuator', { name: el.dataset.rgb, value: rgb(el.value) }));
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 5c35f80..e802da6 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -136,13 +136,22 @@ function drivePad(axes, enabled) {
const humanName = (name) => { const s = String(name).replace(/_/g, ' '); return s.charAt(0).toUpperCase() + s.slice(1); };
/** One control per actuator the widget drives, by the actuator's declared type. */
--function actuatorControl(name, a, enabled) {
++/**
++ * Sliders that repeat while held (2026-11-09, the LOOI bridge's short head-target leases): by profile and actuator, the
++ * cadence in ms (75-250). The panel's own setting, not the profile contract's (bot.robot-profile@1 takes no extra
++ * fields); a profile field can replace it with the next Contracts release.
++ */
++const HOLD_REPEAT_MS = { looi: { head: 90 } };
++const holdRepeatOf = (profileId, name) => { const v = (HOLD_REPEAT_MS[profileId] || {})[name]; return v ? Math.min(250, Math.max(75, v)) : 0; };
++
++function actuatorControl(name, a, enabled, profileId = '') {
++ const repeat = a.type === 'number' ? holdRepeatOf(profileId, name) : 0;
const off = disabledUnless(enabled);
const label = humanName(name);
if (a.type === 'number') {
const [lo, hi] = a.range;
const start = Math.min(hi, Math.max(lo, 0));
-- return `<label class="control slider"><span class="control-name">${esc(label)}</span> <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${off}></label>`;
++ return `<label class="control slider"><span class="control-name">${esc(label)}</span> <input type="range" min="${esc(lo)}" max="${esc(hi)}" step="${esc((hi - lo) / 40)}" value="${esc(start)}" data-actuator="${esc(name)}"${repeat ? ` data-hold-repeat="${esc(repeat)}"` : ''}${off}></label>`;
}
if (a.type === 'rgb') {
return `<div class="control colour"><label><span class="control-name">${esc(label)}</span> <input type="color" value="#ffffff" data-rgb="${esc(name)}"${off}></label>`
@@@ -221,7 +230,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
}
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
-- inner = `<div class="controls">${(w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled)).join('')}</div>`;
++ inner = `<div class="controls">${(w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled, profile.id)).join('')}</div>`;
break;
}
case 'speaker': {
ddiff --git a/test/panel-inputs.test.js b/test/panel-inputs.test.js
iindex 5a0d4a3..37720d1 100644
---- a/test/panel-inputs.test.js
++++ b/test/panel-inputs.test.js
@@@ -58,6 +58,16 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
assert.ok(!renderWidget(w, { profile: zoomOnly, allowed_commands: ['ptz'] }).includes('data-ptz-stick'));
});
++ await check('panel.js: a held slider repeats its value as new commands and stops on release, cancel, blur, a hidden page and the e-stop', () => {
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
++ assert.ok(js.includes("const every = Number(el.dataset.holdRepeat) || 0;"), 'only a slider that asks for it');
++ assert.ok(js.includes("if (Date.now() - lastSent >= every - 5) sendNow();"), 'each tick is a new send of the current value, skipped right after a move');
++ assert.ok(js.includes("el.addEventListener(ev, stop);"), 'release, cancel and a lost capture stop it');
++ assert.ok(js.includes("['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop)"), 'all three pointer ends');
++ assert.ok(js.includes("window.addEventListener('blur', stopRepeats);") && js.includes("if (document.hidden) stopRepeats();") && js.includes("window.addEventListener('pagehide', stopRepeats);"), 'blur, a hidden page and pagehide stop it');
++ assert.ok(js.includes("window.dispatchEvent(new Event('ov-bot-release-all'))") && js.includes("window.addEventListener('ov-bot-release-all', stopRepeats);"), 'releaseAll (the e-stop) stops it');
++ });
++
await check('panel.js: keys, stick and gamepad press direction buttons; the PTZ stick holds pan and tilt', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.ok(js.includes("const KEY_DIR = { ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right' };"));
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 78a0bfc..5ae429b 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -103,6 +103,10 @@ function samples(profile, kind, names) {
assert.deepStrictEqual(Object.keys(p.commands).sort(), ['actuator', 'drive', 'halt'], 'halt is in every profile');
assert.deepStrictEqual(p.commands.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });
assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });
++ // The head slider repeats while held, every 90 ms (the panel's setting, 2026-11-09).
++ const { renderWidget } = require('../server/web/render');
++ 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');
});