openvibers/openvibe.bot Commits

Robot commander view and battery freshness (#420)

OP OpenVibers committed 16 h ago · a0aa5fd91f · 7 files, +271 −24

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 01c4ea8..f5ac6db 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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 |
ddiff --git a/public/panel.css b/public/panel.css
iindex e74c80f..878e757 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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; }
ddiff --git a/public/panel.js b/public/panel.js
iindex 0a6d305..99997ad 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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;
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 11ed4a0..3d1d6ba 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 = () => 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 }; }
// 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 knew
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 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 = () => Dat
const 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;
ddiff --git a/server/web/render.js b/server/web/render.js
iindex a986fdf..98e810c 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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>`;
}
ddiff --git a/test/looi-operators.test.js b/test/looi-operators.test.js
iindex 0d9eeae..3eb012f 100644
---- a/test/looi-operators.test.js
++++ b/test/looi-operators.test.js
@@@ -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');
ddiff --git a/test/looi-panel-notes.test.js b/test/looi-panel-notes.test.js
nnew file mode 100644
iindex 0000000..9585834
---- /dev/null
++++ b/test/looi-panel-notes.test.js
@@@ -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();
++})();