openvibers/openvibe.bot Commits
Robot commander view and battery freshness (#420)
Changes
@@@ -83,7 +83,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.|---|---|---|| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` || `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |--| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |++| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame. `sensors.p7_battery` may carry `{ pct, at, unverified }`: `at` is an ISO time or epoch ms and `unverified: true` means the reading was taken at connect. `sensors.p7_commander` is the tray's own view `{ subject, role, channel, at }`: `role` is owner, mod or operator, `channel` is drive, head or headlight, and `at` is an ISO time or epoch ms no more than 5 s ahead of Bot's clock; anything else is ignored || `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below. `estop_latched` is a legacy field and is not read || `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing || `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` |@@@ -435,7 +435,7 @@ pass). Each authority change is in `robot_operator_audit` with the actor and sub|---|---|---|| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card || `robot_state` | `state{robot_id, online, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |--| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |++| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at,source},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz. An admitted command is `source: "bot"`; a valid `sensors.p7_commander` from a roster member is recorded as that subject's activity with `source: "robot"` from its `at`, and the newer `at` wins on one channel || `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame; a refusal is never a silent drop || `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` || `error` | `code, detail` | a frame or join the server refused |@@@ -204,7 +204,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }.battery-head { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }.battery-note { color: var(--muted); font-size: 0.85rem; }--.battery-provisional { color: var(--warn); font-size: 0.78rem; }++.battery-freshness { color: var(--muted); font-size: 0.78rem; }/* The LOOI status row: Battery | Latency in one compact row (Maticus's Operators spec). */.status-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; order: -2; }#panel[data-profile-id="looi"] .status-row { order: -2; }@@@ -252,6 +252,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }++.ops-robot { margin: 0.55rem 0 0; color: var(--muted); font-size: 0.8rem; }.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }@media (max-width: 30rem) {.ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }@@@ -1242,6 +1242,23 @@roleBtn.textContent = m.role === 'operator' ? 'Make Mod' : 'Make Operator';}}++ // The robot's own view, while it is fresh: one quiet line under the roster. Text only, never markup.++ const robotLine = $('[data-operators-robot]', opsCard);++ if (robotLine) {++ const nowMs = Date.parse(ops.now) || Date.now();++ let view = null;++ for (const m of ops.members) {++ const rec = ops.activity[m.subject];++ const last = rec && rec.last;++ if (!last || last.source !== 'robot') continue;++ const at = Date.parse(last.at);++ if (!Number.isFinite(at) || nowMs - at >= 5000) continue;++ if (!view || at > view.at) view = { at, last, name: opsName(m.subject) };++ }++ const channel = view && ['drive', 'head', 'lights'].includes(view.last.channel) ? view.last.channel : null;++ robotLine.textContent = view && channel ? `The robot follows: ${view.name} (${channel})` : '';++ robotLine.hidden = !robotLine.textContent;++ }}if (opsCard) {// Age "1s ago" and the Live badge even when no new command arrives (both are read from Bot's stamp).@@@ -1407,18 +1424,33 @@pill.dataset.state = dataState;}// Battery | Latency. A missing reading is a dash, an empty bar and the word unknown, never 0 %; a voltage is-- // never invented. The tray's unverified FED8 read is a quiet provisional note, not a second number.++ // never invented. The tray's reading says how fresh it is: "read at connect" for an unverified FED8 read, or++ // "updated 12s ago" from its `at`. No `at` and not unverified: no note.++ function batteryAge(at) {++ const then = typeof at === 'number' ? at : Date.parse(at);++ if (!Number.isFinite(then)) return '';++ const ms = Math.max(0, Date.now() - then);++ return ms < 60000 ? `${Math.floor(ms / 1000)}s` : `${Math.floor(ms / 60000)}m`;++ }function paintBattery(state) {const fraction = state.battery;const sensors = state.telemetry && state.telemetry.sensors;-- const provisional = !!(sensors && sensors.p7_battery && sensors.p7_battery.unverified === true);++ 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';++ else if (p7.at != null) {++ const age = batteryAge(p7.at);++ if (age) freshness = `updated ${age} ago`;++ }++ }for (const el of $$('[data-battery]')) {if (fraction == null) { el.removeAttribute('value'); el.dataset.state = 'unknown'; }else { el.value = String(Math.max(0, Math.min(1, fraction))); el.dataset.state = 'known'; }}for (const el of $$('[data-battery-text]')) el.textContent = fraction != null ? `${Math.round(fraction * 100)} %` : '—';for (const el of $$('[data-battery-note]')) { el.textContent = fraction != null ? '' : 'unknown'; el.hidden = fraction != null; }-- for (const el of $$('[data-battery-provisional]')) el.hidden = !provisional;++ for (const el of $$('[data-battery-freshness]')) { el.textContent = freshness; el.hidden = !freshness; }}function paint(state) {if (!state) return;@@@ -699,6 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat// break a robot's telemetry. The domain ignores an event that is not a known step.domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));}++ // The robot's own commander view: only a roster member the card knows, and only a strictly valid frame.++ const commander = robotCommander(msg.sensors);++ if (commander) {++ for (const robotId of deviceRobotIds(conn.device)) {++ const members = operatorMembers.get(robotId);++ if (!members || !members.has(commander.subject)) continue;++ stampOperatorActivity(robotId, commander.subject, commander.channel, commander.atMs, 'robot', commander.kind);++ queueOperatorsState(robotId);++ }++ }for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);}@@@ -1224,7 +1234,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }// A device may report battery the legacy way (a 0..1 fraction) or the Node's top-level percent (0..100), or as-- // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort, marked provisional.++ // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort; its `at` and++ // `unverified` fields tell the panel how fresh that reading is ("read at connect" when unverified).// A missing reading is null, never 0: the panel shows a dash and "unknown" rather than a made-up empty battery.const clampPct = (v) => Math.min(100, Math.max(0, Number(v)));function batteryFraction(telemetry) {@@@ -1237,27 +1248,49 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}// ── The Operators card (Maticus's 2026-11-12 LOOI spec) ─────────────────────────────────────────-- // Bot stamps each admitted command itself, so the card's activity is Bot's own truth even before the tray-- // publishes sensors.p7_commander. In memory: a restart loses "1s ago", never an authority or an audit row.++ // Bot stamps each admitted command itself, so the card's activity is Bot's own truth. The tray's++ // sensors.p7_commander is the robot's own view of the same person: it is recorded with source "robot",++ // and when both exist for one channel the newer `at` wins. In memory: a restart loses "1s ago", never an++ // authority or an audit row.const operatorActivity = new Map(); // robot_id → Map(subject → { last, drive_at, head_at, lights_at })++ const operatorMembers = new Map(); // robot_id → Set(subject) the last roster the card knewconst operatorsSentAt = new Map(); // robot_id → when the last operators_state went out (ms)const operatorsTimer = new Map(); // robot_id → a pending throttled broadcastconst channelOf = (kind) => (kind === 'drive' ? 'drive' : kind === 'actuator' ? 'head' : kind === 'headlight' ? 'lights': kind === 'button' ? 'button' : kind === 'say' ? 'say' : kind === 'display' ? 'display' : kind === 'ptz' ? 'ptz' : 'other');-- function noteOperatorActivity(robotId, subject, kind) {-- if (!subject) return;++ const ROBOT_CHANNELS = { drive: 'drive', head: 'head', headlight: 'lights' };++ const ROBOT_ROLES = new Set(['owner', 'mod', 'operator']);++ const CHANNEL_FIELD = { drive: 'drive_at', head: 'head_at', lights: 'lights_at' };++ /** Record one activity stamp; an older `at` for the same channel never replaces a newer one. */++ function stampOperatorActivity(robotId, subject, channel, atMs, source, kind) {let bySubject = operatorActivity.get(robotId);if (!bySubject) { bySubject = new Map(); operatorActivity.set(robotId, bySubject); }-- const at = now();-- const channel = channelOf(kind);const rec = bySubject.get(subject) || { last: null, drive_at: null, head_at: null, lights_at: null };-- rec.last = { kind, channel, at: iso(at) };-- if (channel === 'drive') rec.drive_at = iso(at);-- if (channel === 'head') rec.head_at = iso(at);-- if (channel === 'lights') rec.lights_at = iso(at);++ const at = iso(atMs);++ const field = CHANNEL_FIELD[channel];++ const channelAt = field ? Date.parse(rec[field]) : NaN;++ if (field && (!Number.isFinite(channelAt) || atMs > channelAt)) rec[field] = at;++ const lastAt = rec.last ? Date.parse(rec.last.at) : NaN;++ if (!Number.isFinite(lastAt) || atMs > lastAt) rec.last = { kind, channel, at, source };bySubject.set(subject, rec);if (bySubject.size > 256) bySubject.delete(bySubject.keys().next().value);}++ function noteOperatorActivity(robotId, subject, kind) {++ if (!subject) return;++ stampOperatorActivity(robotId, subject, channelOf(kind), now(), 'bot', kind);++ }++ /** A tray's p7_commander, strictly checked; anything invalid is ignored and never an error. */++ function robotCommander(sensors) {++ const c = sensors && sensors.p7_commander;++ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;++ if (typeof c.subject !== 'string' || !c.subject || c.subject.length > 200) return null;++ if (!ROBOT_ROLES.has(c.role)) return null;++ const channel = ROBOT_CHANNELS[c.channel];++ if (!channel) return null;++ const atMs = typeof c.at === 'number' ? c.at : typeof c.at === 'string' ? Date.parse(c.at) : NaN;++ if (!Number.isFinite(atMs) || atMs > now() + 5000) return null;++ return { subject: c.subject, channel, atMs, kind: c.channel };++ }function operatorActivityOf(robotId) {const out = {};for (const [subject, rec] of operatorActivity.get(robotId) || []) out[subject] = rec;@@@ -1265,6 +1298,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}async function operatorsState(robotId) {const [members, policy] = await Promise.all([domain.operators.list(robotId), domain.operators.policy(robotId)]);++ operatorMembers.set(robotId, new Set(members.map((m) => m.subject)));return {sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,members: members.map((m) => ({ subject: m.subject, role: m.role, enabled: m.enabled !== false })),@@@ -1342,8 +1376,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};const picked = {};for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];-- // The tray's provisional battery reading rides along so the panel can say "provisional, at connect" to a-- // watcher too. It is a reading, never a command.++ // The tray's battery reading (and its `at`/`unverified` freshness) rides along so the panel can say++ // "read at connect" or "updated 12s ago" to a watcher too. It is a reading, never a command.if (sensors.p7_battery !== undefined) picked.p7_battery = sensors.p7_battery;// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;@@@ -321,11 +321,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =break;case 'battery':// The LOOI status row: a big percentage, a fill bar, a dash and the word "unknown" when there is no-- // reading (never 0 %), and a quiet provisional note when the tray sent an unverified FED8 read.++ // reading (never 0 %), and a quiet freshness note ("read at connect" or "updated 12s ago").inner = '<div class="battery">'+ '<div class="battery-head"><output class="readout" data-battery-text>—</output><span class="battery-note" data-battery-note>unknown</span></div>'+ '<meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter>'-- + '<span class="battery-provisional" data-battery-provisional hidden>provisional, at connect</span>'++ + '<span class="battery-freshness" data-battery-freshness hidden></span>'+ '</div>';break;case 'latency':@@@ -642,6 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${${rows}${viewerRow}</ul>++<p class="ops-robot" data-operators-robot hidden></p><p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The tray's own Stop stays local. The owner always overrides.</p></section>`;}@@@ -54,7 +54,7 @@ require('../server/realtime');}const looi = loadProfiles().get('looi');-- await check('the battery reads the Node top-level percent (0..100), a dash/unknown when missing, and p7 provisional', async () => {++ await check('the battery prefers the Node top-level percent (0..100), falls back to p7, and is null when missing', async () => {const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });const dev = await deviceFor(robot.id, pairing.code);const a = await op(alex);@@@ -65,12 +65,18 @@ require('../server/realtime');const html = await (await get(`/panel/${robot.id}`, alex)).text();assert.ok(html.includes('data-status-row'), 'Battery | Latency is one compact status row');assert.ok(html.includes('data-battery-note>unknown'), 'the initial meter says unknown, never 0 %');-- assert.ok(html.includes('data-battery-provisional'), 'the provisional note is in the markup');++ assert.ok(html.includes('data-battery-freshness'), 'the freshness note is in the markup');// A full 100 is 100 %, not 1 %: a number above 1 is a percent, not the legacy fraction.dev.send({ type: 'telemetry', battery: 100, events: [{ kind: 'battery_full' }], sensors: { p7_battery: { pct: 100, usb: true, raw: '6401', unverified: true } } });const full = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 100);assert.strictEqual(full.state.battery, 1);-- assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the raw provisional reading reaches the panel state');++ assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the unverified reading reaches the panel state');++ // With no top-level battery, the tray's pct is the reading (and its at rides along for the freshness note).++ const at = new Date(Date.now() - 12000).toISOString();++ dev.send({ type: 'telemetry', events: [{ kind: 'battery_poll' }], sensors: { p7_battery: { pct: 37, at } } });++ const p7 = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.p7_battery && m.state.telemetry.sensors.p7_battery.pct === 37);++ assert.strictEqual(p7.state.battery, 0.37, 'p7 pct is a 0..1 fraction');++ assert.strictEqual(p7.state.telemetry.sensors.p7_battery.at, at, 'the poll time reaches the panel');// Missing is null (a dash and an empty bar), never a made-up 0.dev.send({ type: 'telemetry', events: [{ kind: 'battery_clear' }], sensors: { cleared: true } });const none = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.cleared === true);@@@ -78,6 +84,52 @@ require('../server/realtime');a.close(); dev.close();});++ await check('the robot’s own commander view is source robot; invalid views change nothing and Bot wins when newer', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ const dev = await deviceFor(robot.id, pairing.code);++ const a = await op(alex); const b = await op(bob);++ await join(a, robot.id); await join(b, robot.id);++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const headAt = new Date(Date.now() - 2000).toISOString();++ dev.send({ type: 'telemetry', events: [{ kind: 'commander' }],++ sensors: { p7_commander: { subject: bob.subject, role: 'operator', channel: 'head', at: headAt } } });++ const robotView = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject]++ && m.operators.activity[bob.subject].last && m.operators.activity[bob.subject].last.source === 'robot');++ const rec = robotView.operators.activity[bob.subject];++ assert.deepStrictEqual([rec.last.channel, rec.last.kind, rec.last.at, rec.head_at], ['head', 'head', headAt, headAt]);++ // An unknown channel, a channel report an hour ahead, an invalid role and a stranger change nothing.++ const seen = a.messages.length;++ for (const p7 of [++ { subject: bob.subject, role: 'operator', channel: 'wheels', at: new Date().toISOString() },++ { subject: bob.subject, role: 'operator', channel: 'drive', at: new Date(Date.now() + 3600000).toISOString() },++ { subject: bob.subject, role: 'viewer', channel: 'drive', at: new Date().toISOString() },++ { subject: 'stranger', role: 'operator', channel: 'drive', at: new Date().toISOString() },++ { subject: 42, role: 'operator', channel: 'drive', at: new Date().toISOString() },++ ]) dev.send({ type: 'telemetry', events: [{ kind: 'commander_check' }], sensors: { p7_commander: p7 } });++ await wait(700);++ assert.strictEqual(a.messages.slice(seen).filter((m) => m.type === 'operators_state').length, 0, 'invalid views never broadcast');++ // Bot's own head stamp is newer than the robot's view: Bot's at and source stay.++ cmd(b, { id: 'commander-bot', kind: 'actuator', value: { name: 'head', value: 0.4 } });++ const head = await dev.waitFor((m) => m.type === 'command' && m.ref === 'commander-bot');++ dev.send({ type: 'ack', id: head.id });++ await result(b, 'commander-bot');++ const botView = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject]++ && m.operators.activity[bob.subject].last && m.operators.activity[bob.subject].last.source === 'bot');++ const botHeadAt = botView.operators.activity[bob.subject].head_at;++ assert.ok(Date.parse(botHeadAt) > Date.parse(headAt), 'the newer Bot stamp is the head time');++ // The robot reporting its older head view again does not take the newer stamp back.++ const beforeOld = a.messages.length;++ dev.send({ type: 'telemetry', events: [{ kind: 'commander_old' }],++ sensors: { p7_commander: { subject: bob.subject, role: 'operator', channel: 'head', at: headAt } } });++ await wait(700);++ const states = a.messages.slice(beforeOld).filter((m) => m.type === 'operators_state');++ const latest = states[states.length - 1] || botView;++ assert.strictEqual(latest.operators.activity[bob.subject].last.source, 'bot', 'an older robot view never wins');++ assert.strictEqual(latest.operators.activity[bob.subject].head_at, botHeadAt);++ a.close(); b.close(); dev.close();++ });++await check('sole control admits only that subject; others are refused with a visible message; the owner overrides', async () => {const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });await invite(robot.id, bob.subject, 'operator');nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++// public/panel.js on a LOOI panel: the battery row's freshness note and the Operators card's one line about the++// robot's own view. The client is run in a small DOM with the same data-* hooks the server draws; no browser.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++const { check, done } = require('./helpers/app');++++const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');++const NOW = Date.UTC(2026, 10, 12, 12, 0, 0);++const iso = (ms) => new Date(ms).toISOString();++++/** A Date whose "now" is fixed, so "updated Ns ago" is exact. */++class FakeDate extends Date {++ constructor(...args) { if (args.length) super(...args); else super(NOW); }++ static now() { return NOW; }++}++++function el(tag, attrs = {}) {++ return {++ tagName: tag.toUpperCase(), dataset: { ...attrs }, hidden: false, textContent: '', value: '',++ style: { setProperty() {} },++ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },++ addEventListener() {}, removeEventListener() {}, setAttribute() {}, removeAttribute() {},++ querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },++ contains() { return false; }, focus() {}, blur() {}, appendChild() {}, remove() {},++ getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; },++ };++}++++function panelPage() {++ const main = el('main', { robotId: 'rob_looi', role: 'owner', allowed: '[]', holdMs: '150' });++ const battery = el('meter');++ const batteryText = el('output');++ const batteryNote = el('span');++ const batteryFreshness = el('span');++ const robotLine = el('p');++ const opsCard = el('section');++ opsCard.querySelector = (sel) => (sel === '[data-operators-robot]' ? robotLine : null);++ const selectors = {++ '[data-battery]': [battery],++ '[data-battery-text]': [batteryText],++ '[data-battery-note]': [batteryNote],++ '[data-battery-freshness]': [batteryFreshness],++ };++ const document = {++ getElementById: (id) => (id === 'panel' ? main : null),++ querySelector: (sel) => (sel === '[data-operators]' ? opsCard : null),++ querySelectorAll: (sel) => selectors[sel] || [],++ createElement: (tag) => el(tag),++ addEventListener() {}, head: el('head'), hidden: false, activeElement: null, hasFocus: () => true,++ };++ const sockets = [];++ class WebSocket {++ constructor(url) { this.url = url; this.readyState = 0; sockets.push(this); }++ send() {} close() {} addEventListener() {} removeEventListener() {}++ }++ WebSocket.OPEN = 1;++ 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 },++ location: { protocol: 'https:', host: 'openvibe.bot' }, navigator: {},++ setTimeout: (fn) => { void fn; return 0; }, clearTimeout() {},++ setInterval: () => 0, clearInterval() {},++ requestAnimationFrame() {}, fetch: async () => ({ ok: false, json: async () => ({}) }),++ addEventListener() {}, dispatchEvent() {}, MediaStream: class {},++ };++ ctx.window = ctx;++ vm.createContext(ctx);++ vm.runInContext(SOURCE, ctx, { filename: 'panel.js' });++ const socket = sockets[0];++ 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 };++}++++(async () => {++ await check('the battery row says read at connect, updated Ns/min ago, or nothing; a missing reading is a dash and unknown', () => {++ const page = panelPage();++ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, unverified: true, at: iso(NOW - 500) } } } });++ assert.strictEqual(page.batteryText.textContent, '42 %');++ assert.strictEqual(page.batteryNote.hidden, true);++ assert.strictEqual(page.batteryFreshness.textContent, 'read at connect');++ assert.strictEqual(page.batteryFreshness.hidden, false);++ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, at: iso(NOW - 12300) } } } });++ assert.strictEqual(page.batteryFreshness.textContent, 'updated 12s ago');++ assert.strictEqual(page.batteryFreshness.hidden, false);++ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, at: NOW - 125000 } } } });++ assert.strictEqual(page.batteryFreshness.textContent, 'updated 2m ago', 'the same note in minutes');++ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42 } } } });++ assert.strictEqual(page.batteryFreshness.textContent, '');++ assert.strictEqual(page.batteryFreshness.hidden, true, 'no at and not unverified: no note');++ page.state({ battery: null, telemetry: { sensors: { p7_battery: { unverified: true, at: iso(NOW - 1000) } } } });++ assert.strictEqual(page.batteryText.textContent, '—');++ assert.strictEqual(page.batteryNote.textContent, 'unknown');++ assert.strictEqual(page.batteryNote.hidden, false);++ assert.strictEqual(page.batteryFreshness.hidden, true, 'a missing reading has no freshness note');++ });++++ 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') => ({++ type: 'operators_state',++ operators: {++ sole: null, viewers: 0, now: iso(NOW),++ members: [{ subject: 'bob', role: 'operator', enabled: true }],++ activity: { bob: { last: { kind: channel, channel, at: iso(at), source }, [`${channel}_at`]: iso(at) } },++ },++ });++ page.send(view(NOW - 1000));++ assert.strictEqual(page.robotLine.textContent, 'The robot follows: bob (head)');++ assert.strictEqual(page.robotLine.hidden, false);++ page.send(view(NOW - 1000, 'robot', 'lights'));++ assert.strictEqual(page.robotLine.textContent, 'The robot follows: bob (lights)');++ page.send(view(NOW - 6000));++ assert.strictEqual(page.robotLine.textContent, '');++ assert.strictEqual(page.robotLine.hidden, true, 'an older robot view is not shown');++ page.send(view(NOW - 1000, 'bot'));++ assert.strictEqual(page.robotLine.textContent, '');++ assert.strictEqual(page.robotLine.hidden, true, 'Bot\'s own stamp is not the robot\'s view');++ });++++ done();++})();
@@ -83,7 +83,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -83,7 +83,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
|---|---|---|
|---|---|---|
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
-| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
+| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame. `sensors.p7_battery` may carry `{ pct, at, unverified }`: `at` is an ISO time or epoch ms and `unverified: true` means the reading was taken at connect. `sensors.p7_commander` is the tray's own view `{ subject, role, channel, at }`: `role` is owner, mod or operator, `channel` is drive, head or headlight, and `at` is an ISO time or epoch ms no more than 5 s ahead of Bot's clock; anything else is ignored |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below. `estop_latched` is a legacy field and is not read |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below. `estop_latched` is a legacy field and is not read |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` |
| `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` |
@@ -435,7 +435,7 @@ pass). Each authority change is in `robot_operator_audit` with the actor and sub
@@ -435,7 +435,7 @@ pass). Each authority change is in `robot_operator_audit` with the actor and sub
|---|---|---|
|---|---|---|
| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |
| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |
| `robot_state` | `state{robot_id, online, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |
| `robot_state` | `state{robot_id, online, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |
-| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |
+| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at,source},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz. An admitted command is `source: "bot"`; a valid `sensors.p7_commander` from a roster member is recorded as that subject's activity with `source: "robot"` from its `at`, and the newer `at` wins on one channel |
| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame; a refusal is never a silent drop |
| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame; a refusal is never a silent drop |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `error` | `code, detail` | a frame or join the server refused |
| `error` | `code, detail` | a frame or join the server refused |
@@ -204,7 +204,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -204,7 +204,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }
.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }
.battery-head { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }
.battery-head { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }
.battery-note { color: var(--muted); font-size: 0.85rem; }
.battery-note { color: var(--muted); font-size: 0.85rem; }
-.battery-provisional { color: var(--warn); font-size: 0.78rem; }
+.battery-freshness { color: var(--muted); font-size: 0.78rem; }
/* The LOOI status row: Battery | Latency in one compact row (Maticus's Operators spec). */
/* The LOOI status row: Battery | Latency in one compact row (Maticus's Operators spec). */
.status-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; order: -2; }
.status-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; order: -2; }
#panel[data-profile-id="looi"] .status-row { order: -2; }
#panel[data-profile-id="looi"] .status-row { order: -2; }
@@ -252,6 +252,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -252,6 +252,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }
.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }
.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }
.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }
+.ops-robot { margin: 0.55rem 0 0; color: var(--muted); font-size: 0.8rem; }
.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }
.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }
@media (max-width: 30rem) {
@media (max-width: 30rem) {
.ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }
.ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }
@@ -1242,6 +1242,23 @@
@@ -1242,6 +1242,23 @@
roleBtn.textContent = m.role === 'operator' ? 'Make Mod' : 'Make Operator';
roleBtn.textContent = m.role === 'operator' ? 'Make Mod' : 'Make Operator';
}
}
}
}
+ // The robot's own view, while it is fresh: one quiet line under the roster. Text only, never markup.
+ const robotLine = $('[data-operators-robot]', opsCard);
+ if (robotLine) {
+ const nowMs = Date.parse(ops.now) || Date.now();
+ let view = null;
+ for (const m of ops.members) {
+ const rec = ops.activity[m.subject];
+ const last = rec && rec.last;
+ if (!last || last.source !== 'robot') continue;
+ const at = Date.parse(last.at);
+ if (!Number.isFinite(at) || nowMs - at >= 5000) continue;
+ if (!view || at > view.at) view = { at, last, name: opsName(m.subject) };
+ }
+ const channel = view && ['drive', 'head', 'lights'].includes(view.last.channel) ? view.last.channel : null;
+ robotLine.textContent = view && channel ? `The robot follows: ${view.name} (${channel})` : '';
+ robotLine.hidden = !robotLine.textContent;
+ }
}
}
if (opsCard) {
if (opsCard) {
// Age "1s ago" and the Live badge even when no new command arrives (both are read from Bot's stamp).
// Age "1s ago" and the Live badge even when no new command arrives (both are read from Bot's stamp).
@@ -1407,18 +1424,33 @@
@@ -1407,18 +1424,33 @@
pill.dataset.state = dataState;
pill.dataset.state = dataState;
}
}
// Battery | Latency. A missing reading is a dash, an empty bar and the word unknown, never 0 %; a voltage is
// Battery | Latency. A missing reading is a dash, an empty bar and the word unknown, never 0 %; a voltage is
- // never invented. The tray's unverified FED8 read is a quiet provisional note, not a second number.
+ // never invented. The tray's reading says how fresh it is: "read at connect" for an unverified FED8 read, or
+ // "updated 12s ago" from its `at`. No `at` and not unverified: no note.
+ function batteryAge(at) {
+ const then = typeof at === 'number' ? at : Date.parse(at);
+ if (!Number.isFinite(then)) return '';
+ const ms = Math.max(0, Date.now() - then);
+ return ms < 60000 ? `${Math.floor(ms / 1000)}s` : `${Math.floor(ms / 60000)}m`;
+ }
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 provisional = !!(sensors && sensors.p7_battery && sensors.p7_battery.unverified === true);
+ 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';
+ else if (p7.at != null) {
+ const age = batteryAge(p7.at);
+ if (age) freshness = `updated ${age} ago`;
+ }
+ }
for (const el of $$('[data-battery]')) {
for (const el of $$('[data-battery]')) {
if (fraction == null) { el.removeAttribute('value'); el.dataset.state = 'unknown'; }
if (fraction == null) { el.removeAttribute('value'); el.dataset.state = 'unknown'; }
else { el.value = String(Math.max(0, Math.min(1, fraction))); el.dataset.state = 'known'; }
else { el.value = String(Math.max(0, Math.min(1, fraction))); el.dataset.state = 'known'; }
}
}
for (const el of $$('[data-battery-text]')) el.textContent = fraction != null ? `${Math.round(fraction * 100)} %` : '—';
for (const el of $$('[data-battery-text]')) el.textContent = fraction != null ? `${Math.round(fraction * 100)} %` : '—';
for (const el of $$('[data-battery-note]')) { el.textContent = fraction != null ? '' : 'unknown'; el.hidden = fraction != null; }
for (const el of $$('[data-battery-note]')) { el.textContent = fraction != null ? '' : 'unknown'; el.hidden = fraction != null; }
- for (const el of $$('[data-battery-provisional]')) el.hidden = !provisional;
+ for (const el of $$('[data-battery-freshness]')) { el.textContent = freshness; el.hidden = !freshness; }
}
}
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
@@ -699,6 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -699,6 +699,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// break a robot's telemetry. The domain ignores an event that is not a known step.
// break a robot's telemetry. The domain ignores an event that is not a known step.
domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));
domain.devices.recordFirstRun(conn.device.id, msg.events).catch((e) => log.warn(`[Bot] first-run steps: ${e.message}`));
}
}
+ // The robot's own commander view: only a roster member the card knows, and only a strictly valid frame.
+ const commander = robotCommander(msg.sensors);
+ if (commander) {
+ for (const robotId of deviceRobotIds(conn.device)) {
+ const members = operatorMembers.get(robotId);
+ if (!members || !members.has(commander.subject)) continue;
+ stampOperatorActivity(robotId, commander.subject, commander.channel, commander.atMs, 'robot', commander.kind);
+ queueOperatorsState(robotId);
+ }
+ }
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
}
}
@@ -1224,7 +1234,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1224,7 +1234,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
// A device may report battery the legacy way (a 0..1 fraction) or the Node's top-level percent (0..100), or as
// A device may report battery the legacy way (a 0..1 fraction) or the Node's top-level percent (0..100), or as
- // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort, marked provisional.
+ // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort; its `at` and
+ // `unverified` fields tell the panel how fresh that reading is ("read at connect" when unverified).
// A missing reading is null, never 0: the panel shows a dash and "unknown" rather than a made-up empty battery.
// A missing reading is null, never 0: the panel shows a dash and "unknown" rather than a made-up empty battery.
const clampPct = (v) => Math.min(100, Math.max(0, Number(v)));
const clampPct = (v) => Math.min(100, Math.max(0, Number(v)));
function batteryFraction(telemetry) {
function batteryFraction(telemetry) {
@@ -1237,27 +1248,49 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1237,27 +1248,49 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
// ── The Operators card (Maticus's 2026-11-12 LOOI spec) ─────────────────────────────────────────
// ── The Operators card (Maticus's 2026-11-12 LOOI spec) ─────────────────────────────────────────
- // Bot stamps each admitted command itself, so the card's activity is Bot's own truth even before the tray
- // publishes sensors.p7_commander. In memory: a restart loses "1s ago", never an authority or an audit row.
+ // Bot stamps each admitted command itself, so the card's activity is Bot's own truth. The tray's
+ // sensors.p7_commander is the robot's own view of the same person: it is recorded with source "robot",
+ // and when both exist for one channel the newer `at` wins. In memory: a restart loses "1s ago", never an
+ // authority or an audit row.
const operatorActivity = new Map(); // robot_id → Map(subject → { last, drive_at, head_at, lights_at })
const operatorActivity = new Map(); // robot_id → Map(subject → { last, drive_at, head_at, lights_at })
+ const operatorMembers = new Map(); // robot_id → Set(subject) the last roster the card knew
const operatorsSentAt = new Map(); // robot_id → when the last operators_state went out (ms)
const operatorsSentAt = new Map(); // robot_id → when the last operators_state went out (ms)
const operatorsTimer = new Map(); // robot_id → a pending throttled broadcast
const operatorsTimer = new Map(); // robot_id → a pending throttled broadcast
const channelOf = (kind) => (kind === 'drive' ? 'drive' : kind === 'actuator' ? 'head' : kind === 'headlight' ? 'lights'
const channelOf = (kind) => (kind === 'drive' ? 'drive' : kind === 'actuator' ? 'head' : kind === 'headlight' ? 'lights'
: kind === 'button' ? 'button' : kind === 'say' ? 'say' : kind === 'display' ? 'display' : kind === 'ptz' ? 'ptz' : 'other');
: kind === 'button' ? 'button' : kind === 'say' ? 'say' : kind === 'display' ? 'display' : kind === 'ptz' ? 'ptz' : 'other');
- function noteOperatorActivity(robotId, subject, kind) {
- if (!subject) return;
+ const ROBOT_CHANNELS = { drive: 'drive', head: 'head', headlight: 'lights' };
+ const ROBOT_ROLES = new Set(['owner', 'mod', 'operator']);
+ const CHANNEL_FIELD = { drive: 'drive_at', head: 'head_at', lights: 'lights_at' };
+ /** Record one activity stamp; an older `at` for the same channel never replaces a newer one. */
+ function stampOperatorActivity(robotId, subject, channel, atMs, source, kind) {
let bySubject = operatorActivity.get(robotId);
let bySubject = operatorActivity.get(robotId);
if (!bySubject) { bySubject = new Map(); operatorActivity.set(robotId, bySubject); }
if (!bySubject) { bySubject = new Map(); operatorActivity.set(robotId, bySubject); }
- const at = now();
- const channel = channelOf(kind);
const rec = bySubject.get(subject) || { last: null, drive_at: null, head_at: null, lights_at: null };
const rec = bySubject.get(subject) || { last: null, drive_at: null, head_at: null, lights_at: null };
- rec.last = { kind, channel, at: iso(at) };
- if (channel === 'drive') rec.drive_at = iso(at);
- if (channel === 'head') rec.head_at = iso(at);
- if (channel === 'lights') rec.lights_at = iso(at);
+ const at = iso(atMs);
+ const field = CHANNEL_FIELD[channel];
+ const channelAt = field ? Date.parse(rec[field]) : NaN;
+ if (field && (!Number.isFinite(channelAt) || atMs > channelAt)) rec[field] = at;
+ const lastAt = rec.last ? Date.parse(rec.last.at) : NaN;
+ if (!Number.isFinite(lastAt) || atMs > lastAt) rec.last = { kind, channel, at, source };
bySubject.set(subject, rec);
bySubject.set(subject, rec);
if (bySubject.size > 256) bySubject.delete(bySubject.keys().next().value);
if (bySubject.size > 256) bySubject.delete(bySubject.keys().next().value);
}
}
+ function noteOperatorActivity(robotId, subject, kind) {
+ if (!subject) return;
+ stampOperatorActivity(robotId, subject, channelOf(kind), now(), 'bot', kind);
+ }
+ /** A tray's p7_commander, strictly checked; anything invalid is ignored and never an error. */
+ function robotCommander(sensors) {
+ const c = sensors && sensors.p7_commander;
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
+ if (typeof c.subject !== 'string' || !c.subject || c.subject.length > 200) return null;
+ if (!ROBOT_ROLES.has(c.role)) return null;
+ const channel = ROBOT_CHANNELS[c.channel];
+ if (!channel) return null;
+ const atMs = typeof c.at === 'number' ? c.at : typeof c.at === 'string' ? Date.parse(c.at) : NaN;
+ if (!Number.isFinite(atMs) || atMs > now() + 5000) return null;
+ return { subject: c.subject, channel, atMs, kind: c.channel };
+ }
function operatorActivityOf(robotId) {
function operatorActivityOf(robotId) {
const out = {};
const out = {};
for (const [subject, rec] of operatorActivity.get(robotId) || []) out[subject] = rec;
for (const [subject, rec] of operatorActivity.get(robotId) || []) out[subject] = rec;
@@ -1265,6 +1298,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1265,6 +1298,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
async function operatorsState(robotId) {
async function operatorsState(robotId) {
const [members, policy] = await Promise.all([domain.operators.list(robotId), domain.operators.policy(robotId)]);
const [members, policy] = await Promise.all([domain.operators.list(robotId), domain.operators.policy(robotId)]);
+ operatorMembers.set(robotId, new Set(members.map((m) => m.subject)));
return {
return {
sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,
sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,
members: members.map((m) => ({ subject: m.subject, role: m.role, enabled: m.enabled !== false })),
members: members.map((m) => ({ subject: m.subject, role: m.role, enabled: m.enabled !== false })),
@@ -1342,8 +1376,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1342,8 +1376,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const picked = {};
const picked = {};
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
- // The tray's provisional battery reading rides along so the panel can say "provisional, at connect" to a
- // watcher too. It is a reading, never a command.
+ // The tray's battery reading (and its `at`/`unverified` freshness) rides along so the panel can say
+ // "read at connect" or "updated 12s ago" to a watcher too. It is a reading, never a command.
if (sensors.p7_battery !== undefined) picked.p7_battery = sensors.p7_battery;
if (sensors.p7_battery !== undefined) picked.p7_battery = sensors.p7_battery;
// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.
// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.
const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;
const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;
@@ -321,11 +321,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -321,11 +321,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
break;
break;
case 'battery':
case 'battery':
// The LOOI status row: a big percentage, a fill bar, a dash and the word "unknown" when there is no
// The LOOI status row: a big percentage, a fill bar, a dash and the word "unknown" when there is no
- // reading (never 0 %), and a quiet provisional note when the tray sent an unverified FED8 read.
+ // reading (never 0 %), and a quiet freshness note ("read at connect" or "updated 12s ago").
inner = '<div class="battery">'
inner = '<div class="battery">'
+ '<div class="battery-head"><output class="readout" data-battery-text>—</output><span class="battery-note" data-battery-note>unknown</span></div>'
+ '<div class="battery-head"><output class="readout" data-battery-text>—</output><span class="battery-note" data-battery-note>unknown</span></div>'
+ '<meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter>'
+ '<meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter>'
- + '<span class="battery-provisional" data-battery-provisional hidden>provisional, at connect</span>'
+ + '<span class="battery-freshness" data-battery-freshness hidden></span>'
+ '</div>';
+ '</div>';
break;
break;
case 'latency':
case 'latency':
@@ -642,6 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${
@@ -642,6 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${
${rows}
${rows}
${viewerRow}
${viewerRow}
</ul>
</ul>
+<p class="ops-robot" data-operators-robot hidden></p>
<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The tray's own Stop stays local. The owner always overrides.</p>
<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The tray's own Stop stays local. The owner always overrides.</p>
</section>`;
</section>`;
}
}
@@ -54,7 +54,7 @@ require('../server/realtime');
@@ -54,7 +54,7 @@ require('../server/realtime');
}
}
const looi = loadProfiles().get('looi');
const looi = loadProfiles().get('looi');
- await check('the battery reads the Node top-level percent (0..100), a dash/unknown when missing, and p7 provisional', async () => {
+ await check('the battery prefers the Node top-level percent (0..100), falls back to p7, and is null when missing', async () => {
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
const dev = await deviceFor(robot.id, pairing.code);
const dev = await deviceFor(robot.id, pairing.code);
const a = await op(alex);
const a = await op(alex);
@@ -65,12 +65,18 @@ require('../server/realtime');
@@ -65,12 +65,18 @@ require('../server/realtime');
const html = await (await get(`/panel/${robot.id}`, alex)).text();
const html = await (await get(`/panel/${robot.id}`, alex)).text();
assert.ok(html.includes('data-status-row'), 'Battery | Latency is one compact status row');
assert.ok(html.includes('data-status-row'), 'Battery | Latency is one compact status row');
assert.ok(html.includes('data-battery-note>unknown'), 'the initial meter says unknown, never 0 %');
assert.ok(html.includes('data-battery-note>unknown'), 'the initial meter says unknown, never 0 %');
- assert.ok(html.includes('data-battery-provisional'), 'the provisional note is in the markup');
+ assert.ok(html.includes('data-battery-freshness'), 'the freshness note is in the markup');
// A full 100 is 100 %, not 1 %: a number above 1 is a percent, not the legacy fraction.
// A full 100 is 100 %, not 1 %: a number above 1 is a percent, not the legacy fraction.
dev.send({ type: 'telemetry', battery: 100, events: [{ kind: 'battery_full' }], sensors: { p7_battery: { pct: 100, usb: true, raw: '6401', unverified: true } } });
dev.send({ type: 'telemetry', battery: 100, events: [{ kind: 'battery_full' }], sensors: { p7_battery: { pct: 100, usb: true, raw: '6401', unverified: true } } });
const full = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 100);
const full = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 100);
assert.strictEqual(full.state.battery, 1);
assert.strictEqual(full.state.battery, 1);
- assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the raw provisional reading reaches the panel state');
+ assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the unverified reading reaches the panel state');
+ // With no top-level battery, the tray's pct is the reading (and its at rides along for the freshness note).
+ const at = new Date(Date.now() - 12000).toISOString();
+ dev.send({ type: 'telemetry', events: [{ kind: 'battery_poll' }], sensors: { p7_battery: { pct: 37, at } } });
+ const p7 = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.p7_battery && m.state.telemetry.sensors.p7_battery.pct === 37);
+ assert.strictEqual(p7.state.battery, 0.37, 'p7 pct is a 0..1 fraction');
+ assert.strictEqual(p7.state.telemetry.sensors.p7_battery.at, at, 'the poll time reaches the panel');
// Missing is null (a dash and an empty bar), never a made-up 0.
// Missing is null (a dash and an empty bar), never a made-up 0.
dev.send({ type: 'telemetry', events: [{ kind: 'battery_clear' }], sensors: { cleared: true } });
dev.send({ type: 'telemetry', events: [{ kind: 'battery_clear' }], sensors: { cleared: true } });
const none = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.cleared === true);
const none = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.cleared === true);
@@ -78,6 +84,52 @@ require('../server/realtime');
@@ -78,6 +84,52 @@ require('../server/realtime');
a.close(); dev.close();
a.close(); dev.close();
});
});
+ await check('the robot’s own commander view is source robot; invalid views change nothing and Bot wins when newer', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ const dev = await deviceFor(robot.id, pairing.code);
+ const a = await op(alex); const b = await op(bob);
+ await join(a, robot.id); await join(b, robot.id);
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const headAt = new Date(Date.now() - 2000).toISOString();
+ dev.send({ type: 'telemetry', events: [{ kind: 'commander' }],
+ sensors: { p7_commander: { subject: bob.subject, role: 'operator', channel: 'head', at: headAt } } });
+ const robotView = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject]
+ && m.operators.activity[bob.subject].last && m.operators.activity[bob.subject].last.source === 'robot');
+ const rec = robotView.operators.activity[bob.subject];
+ assert.deepStrictEqual([rec.last.channel, rec.last.kind, rec.last.at, rec.head_at], ['head', 'head', headAt, headAt]);
+ // An unknown channel, a channel report an hour ahead, an invalid role and a stranger change nothing.
+ const seen = a.messages.length;
+ for (const p7 of [
+ { subject: bob.subject, role: 'operator', channel: 'wheels', at: new Date().toISOString() },
+ { subject: bob.subject, role: 'operator', channel: 'drive', at: new Date(Date.now() + 3600000).toISOString() },
+ { subject: bob.subject, role: 'viewer', channel: 'drive', at: new Date().toISOString() },
+ { subject: 'stranger', role: 'operator', channel: 'drive', at: new Date().toISOString() },
+ { subject: 42, role: 'operator', channel: 'drive', at: new Date().toISOString() },
+ ]) dev.send({ type: 'telemetry', events: [{ kind: 'commander_check' }], sensors: { p7_commander: p7 } });
+ await wait(700);
+ assert.strictEqual(a.messages.slice(seen).filter((m) => m.type === 'operators_state').length, 0, 'invalid views never broadcast');
+ // Bot's own head stamp is newer than the robot's view: Bot's at and source stay.
+ cmd(b, { id: 'commander-bot', kind: 'actuator', value: { name: 'head', value: 0.4 } });
+ const head = await dev.waitFor((m) => m.type === 'command' && m.ref === 'commander-bot');
+ dev.send({ type: 'ack', id: head.id });
+ await result(b, 'commander-bot');
+ const botView = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject]
+ && m.operators.activity[bob.subject].last && m.operators.activity[bob.subject].last.source === 'bot');
+ const botHeadAt = botView.operators.activity[bob.subject].head_at;
+ assert.ok(Date.parse(botHeadAt) > Date.parse(headAt), 'the newer Bot stamp is the head time');
+ // The robot reporting its older head view again does not take the newer stamp back.
+ const beforeOld = a.messages.length;
+ dev.send({ type: 'telemetry', events: [{ kind: 'commander_old' }],
+ sensors: { p7_commander: { subject: bob.subject, role: 'operator', channel: 'head', at: headAt } } });
+ await wait(700);
+ const states = a.messages.slice(beforeOld).filter((m) => m.type === 'operators_state');
+ const latest = states[states.length - 1] || botView;
+ assert.strictEqual(latest.operators.activity[bob.subject].last.source, 'bot', 'an older robot view never wins');
+ assert.strictEqual(latest.operators.activity[bob.subject].head_at, botHeadAt);
+ a.close(); b.close(); dev.close();
+ });
+
await check('sole control admits only that subject; others are refused with a visible message; the owner overrides', async () => {
await check('sole control admits only that subject; others are refused with a visible message; the owner overrides', async () => {
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
await invite(robot.id, bob.subject, 'operator');
await invite(robot.id, bob.subject, 'operator');
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+// public/panel.js on a LOOI panel: the battery row's freshness note and the Operators card's one line about the
+// robot's own view. The client is run in a small DOM with the same data-* hooks the server draws; no browser.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+const { check, done } = require('./helpers/app');
+
+const SOURCE = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
+const NOW = Date.UTC(2026, 10, 12, 12, 0, 0);
+const iso = (ms) => new Date(ms).toISOString();
+
+/** A Date whose "now" is fixed, so "updated Ns ago" is exact. */
+class FakeDate extends Date {
+ constructor(...args) { if (args.length) super(...args); else super(NOW); }
+ static now() { return NOW; }
+}
+
+function el(tag, attrs = {}) {
+ return {
+ tagName: tag.toUpperCase(), dataset: { ...attrs }, hidden: false, textContent: '', value: '',
+ style: { setProperty() {} },
+ classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
+ addEventListener() {}, removeEventListener() {}, setAttribute() {}, removeAttribute() {},
+ querySelector() { return null; }, querySelectorAll() { return []; }, closest() { return null; },
+ contains() { return false; }, focus() {}, blur() {}, appendChild() {}, remove() {},
+ getBoundingClientRect() { return { left: 0, top: 0, width: 0, height: 0 }; },
+ };
+}
+
+function panelPage() {
+ const main = el('main', { robotId: 'rob_looi', role: 'owner', allowed: '[]', holdMs: '150' });
+ const battery = el('meter');
+ const batteryText = el('output');
+ const batteryNote = el('span');
+ const batteryFreshness = el('span');
+ const robotLine = el('p');
+ const opsCard = el('section');
+ opsCard.querySelector = (sel) => (sel === '[data-operators-robot]' ? robotLine : null);
+ const selectors = {
+ '[data-battery]': [battery],
+ '[data-battery-text]': [batteryText],
+ '[data-battery-note]': [batteryNote],
+ '[data-battery-freshness]': [batteryFreshness],
+ };
+ const document = {
+ getElementById: (id) => (id === 'panel' ? main : null),
+ querySelector: (sel) => (sel === '[data-operators]' ? opsCard : null),
+ querySelectorAll: (sel) => selectors[sel] || [],
+ createElement: (tag) => el(tag),
+ addEventListener() {}, head: el('head'), hidden: false, activeElement: null, hasFocus: () => true,
+ };
+ const sockets = [];
+ class WebSocket {
+ constructor(url) { this.url = url; this.readyState = 0; sockets.push(this); }
+ send() {} close() {} addEventListener() {} removeEventListener() {}
+ }
+ WebSocket.OPEN = 1;
+ 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 },
+ location: { protocol: 'https:', host: 'openvibe.bot' }, navigator: {},
+ setTimeout: (fn) => { void fn; return 0; }, clearTimeout() {},
+ setInterval: () => 0, clearInterval() {},
+ requestAnimationFrame() {}, fetch: async () => ({ ok: false, json: async () => ({}) }),
+ addEventListener() {}, dispatchEvent() {}, MediaStream: class {},
+ };
+ ctx.window = ctx;
+ vm.createContext(ctx);
+ vm.runInContext(SOURCE, ctx, { filename: 'panel.js' });
+ const socket = sockets[0];
+ 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 };
+}
+
+(async () => {
+ await check('the battery row says read at connect, updated Ns/min ago, or nothing; a missing reading is a dash and unknown', () => {
+ const page = panelPage();
+ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, unverified: true, at: iso(NOW - 500) } } } });
+ assert.strictEqual(page.batteryText.textContent, '42 %');
+ assert.strictEqual(page.batteryNote.hidden, true);
+ assert.strictEqual(page.batteryFreshness.textContent, 'read at connect');
+ assert.strictEqual(page.batteryFreshness.hidden, false);
+ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, at: iso(NOW - 12300) } } } });
+ assert.strictEqual(page.batteryFreshness.textContent, 'updated 12s ago');
+ assert.strictEqual(page.batteryFreshness.hidden, false);
+ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42, at: NOW - 125000 } } } });
+ assert.strictEqual(page.batteryFreshness.textContent, 'updated 2m ago', 'the same note in minutes');
+ page.state({ battery: 0.42, telemetry: { sensors: { p7_battery: { pct: 42 } } } });
+ assert.strictEqual(page.batteryFreshness.textContent, '');
+ assert.strictEqual(page.batteryFreshness.hidden, true, 'no at and not unverified: no note');
+ page.state({ battery: null, telemetry: { sensors: { p7_battery: { unverified: true, at: iso(NOW - 1000) } } } });
+ assert.strictEqual(page.batteryText.textContent, '—');
+ assert.strictEqual(page.batteryNote.textContent, 'unknown');
+ assert.strictEqual(page.batteryNote.hidden, false);
+ assert.strictEqual(page.batteryFreshness.hidden, true, 'a missing reading has no freshness note');
+ });
+
+ 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') => ({
+ type: 'operators_state',
+ operators: {
+ sole: null, viewers: 0, now: iso(NOW),
+ members: [{ subject: 'bob', role: 'operator', enabled: true }],
+ activity: { bob: { last: { kind: channel, channel, at: iso(at), source }, [`${channel}_at`]: iso(at) } },
+ },
+ });
+ page.send(view(NOW - 1000));
+ assert.strictEqual(page.robotLine.textContent, 'The robot follows: bob (head)');
+ assert.strictEqual(page.robotLine.hidden, false);
+ page.send(view(NOW - 1000, 'robot', 'lights'));
+ assert.strictEqual(page.robotLine.textContent, 'The robot follows: bob (lights)');
+ page.send(view(NOW - 6000));
+ assert.strictEqual(page.robotLine.textContent, '');
+ assert.strictEqual(page.robotLine.hidden, true, 'an older robot view is not shown');
+ page.send(view(NOW - 1000, 'bot'));
+ assert.strictEqual(page.robotLine.textContent, '');
+ assert.strictEqual(page.robotLine.hidden, true, 'Bot\'s own stamp is not the robot\'s view');
+ });
+
+ done();
+})();