openvibers/openvibe.bot Commits
LOOI panel: polled battery, the head's mounting, no offline blink on a Bot restart (#424)
Changes
@@@ -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; }@@@ -315,6 +315,23 @@const sentAt = new Map(); // id → performance.now() at sendconst samples = []; // the last round trips, mslet 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]')) {@@@ -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>`;@@@ -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') => ({
@@ -789,6 +789,9 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -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) .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 { 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; }
#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) .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 { 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; }
#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; }
@@ -315,6 +315,23 @@
@@ -315,6 +315,23 @@
const sentAt = new Map(); // id → performance.now() at send
const sentAt = new Map(); // id → performance.now() at send
const samples = []; // the last round trips, ms
const samples = []; // the last round trips, ms
let lastTelemetryAt = 0;
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) {
function send(kind, value) {
if (!joined || !allowed.includes(kind)) return false;
if (!joined || !allowed.includes(kind)) return false;
const id = newId();
const id = newId();
@@ -1416,7 +1433,9 @@
@@ -1416,7 +1433,9 @@
if (!pill) return;
if (!pill) return;
const queue = state.queue;
const queue = state.queue;
let label = 'Online'; let dataState = 'online';
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.active) { label = 'Your turn'; dataState = 'turn'; }
else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
const text = $('[data-bot-state]');
const text = $('[data-bot-state]');
@@ -1432,13 +1451,32 @@
@@ -1432,13 +1451,32 @@
const ms = Math.max(0, Date.now() - then);
const ms = Math.max(0, Date.now() - then);
return ms < 60000 ? `${Math.floor(ms / 1000)}s` : `${Math.floor(ms / 60000)}m`;
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) {
function paintBattery(state) {
const fraction = state.battery;
const fraction = state.battery;
const sensors = state.telemetry && state.telemetry.sensors;
const sensors = state.telemetry && state.telemetry.sensors;
const p7 = sensors && sensors.p7_battery && typeof sensors.p7_battery === 'object' ? sensors.p7_battery : null;
const p7 = sensors && sensors.p7_battery && typeof sensors.p7_battery === 'object' ? sensors.p7_battery : null;
let freshness = '';
let freshness = '';
if (fraction != null && p7) {
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) {
else if (p7.at != null) {
const age = batteryAge(p7.at);
const age = batteryAge(p7.at);
if (age) freshness = `updated ${age} ago`;
if (age) freshness = `updated ${age} ago`;
@@ -1454,8 +1492,10 @@
@@ -1454,8 +1492,10 @@
}
}
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
+ lastPainted = state;
paintFaults(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);
paintBotPill(state);
main.dataset.deviceOnline = String(!!state.online);
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
if (state.telemetry) lastTelemetryAt = performance.now();
@@ -1467,6 +1507,7 @@
@@ -1467,6 +1507,7 @@
el.dataset.quality = quality(state.latency_ms, 120, 300);
el.dataset.quality = quality(state.latency_ms, 120, 300);
}
}
paintBattery(state);
paintBattery(state);
+ paintHeadRange(state);
const sensors = state.telemetry && state.telemetry.sensors;
const sensors = state.telemetry && state.telemetry.sensors;
headlightTelemetry(sensors);
headlightTelemetry(sensors);
for (const el of $$('[data-telemetry]')) {
for (const el of $$('[data-telemetry]')) {
@@ -208,7 +208,9 @@ function actuatorControl(name, a, enabled, profileId = '') {
@@ -208,7 +208,9 @@ function actuatorControl(name, a, enabled, profileId = '') {
const off = disabledUnless(enabled);
const off = disabledUnless(enabled);
const label = humanName(name);
const label = humanName(name);
if (a.type === 'number') {
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>`
+ `<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)}`
+ `${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>`;
+ `<button type="button" class="step" data-step-for="${esc(name)}" data-step="1" aria-label="${esc(`${label} up`)}"${off}>${icon('plus')}</button></div></div>`;
@@ -36,6 +36,10 @@ function panelPage() {
@@ -36,6 +36,10 @@ function panelPage() {
const batteryNote = el('span');
const batteryNote = el('span');
const batteryFreshness = el('span');
const batteryFreshness = el('span');
const robotLine = el('p');
const robotLine = el('p');
+ const headRange = el('span');
+ const onlinePill = el('span');
+ const onlineLabel = el('span');
+ const clock = { t: 0 };
const opsCard = el('section');
const opsCard = el('section');
opsCard.querySelector = (sel) => (sel === '[data-operators-robot]' ? robotLine : null);
opsCard.querySelector = (sel) => (sel === '[data-operators-robot]' ? robotLine : null);
const selectors = {
const selectors = {
@@ -43,10 +47,12 @@ function panelPage() {
@@ -43,10 +47,12 @@ function panelPage() {
'[data-battery-text]': [batteryText],
'[data-battery-text]': [batteryText],
'[data-battery-note]': [batteryNote],
'[data-battery-note]': [batteryNote],
'[data-battery-freshness]': [batteryFreshness],
'[data-battery-freshness]': [batteryFreshness],
+ '[data-head-range]': [headRange],
};
};
+ const one = { '[data-operators]': opsCard, '[data-online-pill]': onlinePill, '[data-online-label]': onlineLabel };
const document = {
const document = {
getElementById: (id) => (id === 'panel' ? main : null),
getElementById: (id) => (id === 'panel' ? main : null),
- querySelector: (sel) => (sel === '[data-operators]' ? opsCard : null),
+ querySelector: (sel) => one[sel] || null,
querySelectorAll: (sel) => selectors[sel] || [],
querySelectorAll: (sel) => selectors[sel] || [],
createElement: (tag) => el(tag),
createElement: (tag) => el(tag),
addEventListener() {}, head: el('head'), hidden: false, activeElement: null, hasFocus: () => true,
addEventListener() {}, head: el('head'), hidden: false, activeElement: null, hasFocus: () => true,
@@ -60,7 +66,7 @@ function panelPage() {
@@ -60,7 +66,7 @@ function panelPage() {
class CustomEvent { constructor(type, init = {}) { this.type = type; this.detail = init.detail; } }
class CustomEvent { constructor(type, init = {}) { this.type = type; this.detail = init.detail; } }
const ctx = {
const ctx = {
document, WebSocket, CustomEvent, console, Promise, JSON, Math, Date: FakeDate, URL, Number, String,
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: {},
location: { protocol: 'https:', host: 'openvibe.bot' }, navigator: {},
setTimeout: (fn) => { void fn; return 0; }, clearTimeout() {},
setTimeout: (fn) => { void fn; return 0; }, clearTimeout() {},
setInterval: () => 0, clearInterval() {},
setInterval: () => 0, clearInterval() {},
@@ -74,7 +80,7 @@ function panelPage() {
@@ -74,7 +80,7 @@ function panelPage() {
assert.ok(socket, 'the panel opened /control');
assert.ok(socket, 'the panel opened /control');
const send = (m) => socket.onmessage({ data: JSON.stringify(m) });
const send = (m) => socket.onmessage({ data: JSON.stringify(m) });
const state = (s) => send({ type: 'robot_state', state: { online: true, ...s } });
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 () => {
(async () => {
@@ -100,6 +106,55 @@ function panelPage() {
@@ -100,6 +106,55 @@ function panelPage() {
assert.strictEqual(page.batteryFreshness.hidden, true, 'a missing reading has no freshness note');
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', () => {
await check('the Operators card says The robot follows: name (channel) only while the robot view is fresh', () => {
const page = panelPage();
const page = panelPage();
const view = (at, source = 'robot', channel = 'head') => ({
const view = (at, source = 'robot', channel = 'head') => ({