openvibers/openvibe.bot Commits

LOOI panel: polled battery, the head's mounting, no offline blink on a Bot restart (#424)

OP OpenVibers committed 12 h ago · e5fce05ec0 · 4 files, +108 −7

From Maticus (11-12):
- A battery sample the tray polls (sensors.p7_battery source poll) reads "provisional, updated 7s ago" from its at; a read at connect still says so.
- The head slider names its mounting from sensors.p7_head { orientation, min, max, center }: "Portrait · 0-200, centre 90" or "Landscape · 0-255, centre 90"; display only, the tray clamps every head command.
- Every Bot deploy restarts it (WebSocket close 1012) and Link dials back in about 2 s, which showed streamers "Device offline" each time. A robot online moments ago now reads "Reconnecting…" for up to 8 s first; the controls still follow the truth.

Changes

ddiff --git a/public/panel.css b/public/panel.css
iindex 878e757..4c79a01 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -789,6 +789,9 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
#panel:not(.bot-surface) .slider-head .control-name { min-width: 0; color: var(--text); font-weight: 600; font-size: 0.9rem; }
++/* The head's mounting beside its name (sensors.p7_head): Portrait and its range, quiet; display only. */
++.slider-head .slider-note { flex: 1 1 auto; min-width: 0; margin-left: 0.4rem; color: var(--muted, #8a96a8); font-size: 0.78rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.slider-head .slider-note[data-orientation="portrait"] { color: var(--accent, #60a5fa); }
#panel:not(.bot-surface) .control-value { font-variant-numeric: tabular-nums; font-size: 0.85rem; color: var(--muted); }
#panel:not(.bot-surface) .slider-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.5rem; }
#panel:not(.bot-surface) .slider-row .step { width: 40px; height: 40px; min-width: 40px; min-height: 40px; padding: 0; border-radius: 50%; display: grid; place-items: center; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 99997ad..5ef4a30 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -315,6 +315,23 @@
const sentAt = new Map(); // id → performance.now() at send
const samples = []; // the last round trips, ms
let lastTelemetryAt = 0;
++ // A robot that was online moments ago reads "Reconnecting…" for a short grace before "Device offline": every Bot
++ // deploy restarts it (WebSocket close 1012) and Link dials back in about 2 s, which showed streamers "offline" for
++ // a blink each time (Maticus, 2026-11-12). The controls still follow the truth: they are off while it is away.
++ const RECONNECT_GRACE_MS = 8000;
++ let lastOnlineAt = 0;
++ let graceTimer = null;
++ let lastPainted = null;
++ function onlineNow(state) {
++ if (state.online) { lastOnlineAt = performance.now(); clearTimeout(graceTimer); graceTimer = null; return 'online'; }
++ const since = performance.now() - lastOnlineAt;
++ if (lastOnlineAt && since < RECONNECT_GRACE_MS) {
++ clearTimeout(graceTimer);
++ graceTimer = setTimeout(() => { graceTimer = null; if (lastPainted && !lastPainted.online) paint(lastPainted); }, RECONNECT_GRACE_MS - since + 50);
++ return 'reconnecting';
++ }
++ return 'offline';
++ }
function send(kind, value) {
if (!joined || !allowed.includes(kind)) return false;
const id = newId();
@@@ -1416,7 +1433,9 @@
if (!pill) return;
const queue = state.queue;
let label = 'Online'; let dataState = 'online';
-- if (!state.online) { label = 'Offline'; dataState = 'offline'; }
++ const now = onlineNow(state);
++ if (now === 'reconnecting') { label = 'Reconnecting…'; dataState = 'busy'; }
++ else if (now === 'offline') { label = 'Offline'; dataState = 'offline'; }
else if (queue && queue.active) { label = 'Your turn'; dataState = 'turn'; }
else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
const text = $('[data-bot-state]');
@@@ -1432,13 +1451,32 @@
const ms = Math.max(0, Date.now() - then);
return ms < 60000 ? `${Math.floor(ms / 1000)}s` : `${Math.floor(ms / 60000)}m`;
}
++ // The head's mounting, as the tray reports it in sensors.p7_head { orientation, min, max, center } (raw 0-255, 255
++ // is down): Portrait adds a down-travel floor so a phone mounted upright cannot hit the chassis. Display only; the
++ // tray clamps every head command itself (Maticus, 2026-11-12).
++ function paintHeadRange(state) {
++ const sensors = state.telemetry && state.telemetry.sensors;
++ const h = sensors && sensors.p7_head && typeof sensors.p7_head === 'object' ? sensors.p7_head : null;
++ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null);
++ const orientation = h && typeof h.orientation === 'string' ? h.orientation.toLowerCase() : '';
++ const [min, max, center] = h ? [num(h.min), num(h.max), num(h.center)] : [null, null, null];
++ let text = '';
++ if (orientation === 'portrait' || orientation === 'landscape') {
++ const name = orientation === 'portrait' ? 'Portrait' : 'Landscape';
++ const range = min != null && max != null ? ` · ${min}–${max}` : '';
++ text = `${name}${range}${center != null ? `, centre ${center}` : ''}`;
++ }
++ for (const el of $$('[data-head-range]')) { el.textContent = text; el.hidden = !text; el.dataset.orientation = orientation || ''; }
++ }
function paintBattery(state) {
const fraction = state.battery;
const sensors = state.telemetry && state.telemetry.sensors;
const p7 = sensors && sensors.p7_battery && typeof sensors.p7_battery === 'object' ? sensors.p7_battery : null;
let freshness = '';
if (fraction != null && p7) {
-- if (p7.unverified === true) freshness = 'read at connect';
++ // The tray polls it now (source 'poll'): still provisional, but as fresh as its `at` (Maticus, 2026-11-12).
++ if (p7.source === 'poll' && p7.at != null) { const age = batteryAge(p7.at); freshness = age ? `provisional, updated ${age} ago` : 'provisional'; }
++ else if (p7.unverified === true) freshness = 'read at connect';
else if (p7.at != null) {
const age = batteryAge(p7.at);
if (age) freshness = `updated ${age} ago`;
@@@ -1454,8 +1492,10 @@
}
function paint(state) {
if (!state) return;
++ lastPainted = state;
paintFaults(state);
-- setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
++ const now = onlineNow(state);
++ setOnline(now === 'online' ? 'online' : now === 'reconnecting' ? 'disconnected' : 'offline', now === 'online' ? 'Online' : now === 'reconnecting' ? 'Reconnecting…' : 'Device offline');
paintBotPill(state);
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
@@@ -1467,6 +1507,7 @@
el.dataset.quality = quality(state.latency_ms, 120, 300);
}
paintBattery(state);
++ paintHeadRange(state);
const sensors = state.telemetry && state.telemetry.sensors;
headlightTelemetry(sensors);
for (const el of $$('[data-telemetry]')) {
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 98e810c..20e8017 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -208,7 +208,9 @@ function actuatorControl(name, a, enabled, profileId = '') {
const off = disabledUnless(enabled);
const label = humanName(name);
if (a.type === 'number') {
-- return `<div class="control slider"><label class="slider-head"><span class="control-name">${esc(label)}</span><output class="control-value" data-value-of="${esc(name)}">0</output></label>`
++ // The head's mounting (Portrait or Landscape and its range), painted from sensors.p7_head when the robot sends it.
++ const note = name === 'head' ? '<span class="slider-note" data-head-range hidden></span>' : '';
++ return `<div class="control slider"><label class="slider-head"><span class="control-name">${esc(label)}</span>${note}<output class="control-value" data-value-of="${esc(name)}">0</output></label>`
+ `<div class="slider-row"><button type="button" class="step" data-step-for="${esc(name)}" data-step="-1" aria-label="${esc(`${label} down`)}"${off}>${icon('minus')}</button>`
+ `${numberRange(name, a, enabled, profileId)}`
+ `<button type="button" class="step" data-step-for="${esc(name)}" data-step="1" aria-label="${esc(`${label} up`)}"${off}>${icon('plus')}</button></div></div>`;
ddiff --git a/test/looi-panel-notes.test.js b/test/looi-panel-notes.test.js
iindex 9585834..c67a6c7 100644
---- a/test/looi-panel-notes.test.js
++++ b/test/looi-panel-notes.test.js
@@@ -36,6 +36,10 @@ function panelPage() {
const batteryNote = el('span');
const batteryFreshness = el('span');
const robotLine = el('p');
++ const headRange = el('span');
++ const onlinePill = el('span');
++ const onlineLabel = el('span');
++ const clock = { t: 0 };
const opsCard = el('section');
opsCard.querySelector = (sel) => (sel === '[data-operators-robot]' ? robotLine : null);
const selectors = {
@@@ -43,10 +47,12 @@ function panelPage() {
'[data-battery-text]': [batteryText],
'[data-battery-note]': [batteryNote],
'[data-battery-freshness]': [batteryFreshness],
++ '[data-head-range]': [headRange],
};
++ const one = { '[data-operators]': opsCard, '[data-online-pill]': onlinePill, '[data-online-label]': onlineLabel };
const document = {
getElementById: (id) => (id === 'panel' ? main : null),
-- querySelector: (sel) => (sel === '[data-operators]' ? opsCard : null),
++ querySelector: (sel) => one[sel] || null,
querySelectorAll: (sel) => selectors[sel] || [],
createElement: (tag) => el(tag),
addEventListener() {}, head: el('head'), hidden: false, activeElement: null, hasFocus: () => true,
@@@ -60,7 +66,7 @@ function panelPage() {
class CustomEvent { constructor(type, init = {}) { this.type = type; this.detail = init.detail; } }
const ctx = {
document, WebSocket, CustomEvent, console, Promise, JSON, Math, Date: FakeDate, URL, Number, String,
-- Object, Array, Set, Map, Error, CSS: { escape: (s) => s }, performance: { now: () => 0 },
++ Object, Array, Set, Map, Error, CSS: { escape: (s) => s }, performance: { now: () => clock.t },
location: { protocol: 'https:', host: 'openvibe.bot' }, navigator: {},
setTimeout: (fn) => { void fn; return 0; }, clearTimeout() {},
setInterval: () => 0, clearInterval() {},
@@@ -74,7 +80,7 @@ function panelPage() {
assert.ok(socket, 'the panel opened /control');
const send = (m) => socket.onmessage({ data: JSON.stringify(m) });
const state = (s) => send({ type: 'robot_state', state: { online: true, ...s } });
-- return { state, send, battery, batteryText, batteryNote, batteryFreshness, robotLine };
++ return { state, send, battery, batteryText, batteryNote, batteryFreshness, robotLine, headRange, onlinePill, onlineLabel, clock };
}
(async () => {
@@@ -100,6 +106,55 @@ function panelPage() {
assert.strictEqual(page.batteryFreshness.hidden, true, 'a missing reading has no freshness note');
});
++ await check('a polled reading says provisional, updated Ns ago (source poll, Maticus 11-12)', () => {
++ const page = panelPage();
++ page.state({ battery: 0.6, telemetry: { sensors: { p7_battery: { pct: 60, usb: false, raw: 153, unverified: true, source: 'poll', at: iso(NOW - 7400) } } } });
++ assert.strictEqual(page.batteryFreshness.textContent, 'provisional, updated 7s ago');
++ page.state({ battery: 0.6, telemetry: { sensors: { p7_battery: { pct: 60, unverified: true, source: 'poll', at: NOW - 180000 } } } });
++ assert.strictEqual(page.batteryFreshness.textContent, 'provisional, updated 3m ago');
++ page.state({ battery: 0.6, telemetry: { sensors: { p7_battery: { pct: 60, unverified: true, at: iso(NOW - 500) } } } });
++ assert.strictEqual(page.batteryFreshness.textContent, 'read at connect', 'a read at connect still says so');
++ });
++
++ await check('the head slider names its mounting: Portrait and its range, or Landscape; nothing without p7_head', () => {
++ const page = panelPage();
++ page.state({ telemetry: { sensors: { p7_head: { orientation: 'portrait', min: 0, max: 200, center: 90 } } } });
++ assert.strictEqual(page.headRange.textContent, 'Portrait · 0–200, centre 90');
++ assert.strictEqual(page.headRange.hidden, false);
++ assert.strictEqual(page.headRange.dataset.orientation, 'portrait');
++ page.state({ telemetry: { sensors: { p7_head: { orientation: 'landscape', min: 0, max: 255, center: 90 } } } });
++ assert.strictEqual(page.headRange.textContent, 'Landscape · 0–255, centre 90');
++ page.state({ telemetry: { sensors: {} } });
++ assert.strictEqual(page.headRange.hidden, true);
++ page.state({ telemetry: { sensors: { p7_head: { orientation: 'sideways', min: 0, max: 9 } } } });
++ assert.strictEqual(page.headRange.hidden, true, 'an orientation it does not know is not shown');
++ });
++
++ await check('a Bot restart reads Reconnecting… for a moment, not Device offline (Maticus 11-12: close 1012 every deploy)', () => {
++ const page = panelPage();
++ page.clock.t = 1000;
++ page.state({ online: true });
++ assert.strictEqual(page.onlineLabel.textContent, 'Online');
++ page.clock.t = 3000;
++ page.state({ online: false });
++ assert.deepStrictEqual([page.onlineLabel.textContent, page.onlinePill.dataset.state], ['Reconnecting…', 'disconnected'], 'two seconds after it was online');
++ page.clock.t = 3800;
++ page.state({ online: true });
++ assert.strictEqual(page.onlineLabel.textContent, 'Online', 'and back');
++ page.clock.t = 19000;
++ page.state({ online: true });
++ page.clock.t = 20000;
++ page.state({ online: false });
++ assert.strictEqual(page.onlineLabel.textContent, 'Reconnecting…');
++ page.clock.t = 29000;
++ page.state({ online: false });
++ assert.deepStrictEqual([page.onlineLabel.textContent, page.onlinePill.dataset.state], ['Device offline', 'offline'], 'gone longer than the grace: offline');
++ const fresh = panelPage();
++ fresh.clock.t = 500;
++ fresh.state({ online: false });
++ assert.strictEqual(fresh.onlineLabel.textContent, 'Device offline', 'a robot never seen online is plainly offline');
++ });
++
await check('the Operators card says The robot follows: name (channel) only while the robot view is fresh', () => {
const page = panelPage();
const view = (at, source = 'robot', channel = 'head') => ({