openvibers/openvibe.bot Commits
Panel: the LOOI head slider repeats while held, stops on release (#350)
Changes
@@@ -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) }));@@@ -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': {@@@ -58,6 +58,16 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widgetassert.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' };"));@@@ -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');});
@@ -429,6 +429,8 @@
@@ -429,6 +429,8 @@
el.addEventListener('contextmenu', (e) => e.preventDefault());
el.addEventListener('contextmenu', (e) => e.preventDefault());
}
}
function releaseAll() {
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()]) {
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
}
}
@@ -785,10 +787,33 @@
@@ -785,10 +787,33 @@
if (!timer) timer = setTimeout(() => { timer = null; last = Date.now(); if (pending) fn(...pending); pending = null; }, wait);
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) => {
$$('[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);
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) });
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) => {
$$('[data-rgb]').forEach((el) => {
const push = throttled(() => send('actuator', { name: el.dataset.rgb, value: rgb(el.value) }));
const push = throttled(() => send('actuator', { name: el.dataset.rgb, value: rgb(el.value) }));
@@ -136,13 +136,22 @@ function drivePad(axes, enabled) {
@@ -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); };
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. */
/** 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 off = disabledUnless(enabled);
const label = humanName(name);
const label = humanName(name);
if (a.type === 'number') {
if (a.type === 'number') {
const [lo, hi] = a.range;
const [lo, hi] = a.range;
const start = Math.min(hi, Math.max(lo, 0));
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') {
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>`
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 =
@@ -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': {
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
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;
break;
}
}
case 'speaker': {
case 'speaker': {
@@ -58,6 +58,16 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
@@ -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'));
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', () => {
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');
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' };"));
assert.ok(js.includes("const KEY_DIR = { ArrowUp: 'up', KeyW: 'up', ArrowDown: 'down', KeyS: 'down', ArrowLeft: 'left', KeyA: 'left', ArrowRight: 'right', KeyD: 'right' };"));
@@ -103,6 +103,10 @@ function samples(profile, kind, names) {
@@ -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(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.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });
assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-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.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');
assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');
});
});