openvibers/openvibe.bot Commits

LOOI headlights: an ON/OFF action for the owner and People Operators (#402)

OP OpenVibers committed 1 days ago · 2b49b404da · 10 files, +472 −31

* Bot: LOOI headlights for the owner and People Operators

The LOOI panel gets one labelled Headlights button beside the head slider,
in the full panel and the channel's Robot tab only: On / Off / Unknown
(commanded null) / Turning on… / Turning off…, the two explicit choices
when Unknown, a Busy line on a refusal and The robot refused: <reason> on
a nack, with Not confirmed on the robot while physical_state_verified is
false. It is hidden entirely from anonymous viewers, plain watchers, the
queue and operators the owner's allow list does not give it.

The action is {"kind":"headlight","value":{"on":true|false}},
never a toggle. Bot's gate decides the owner/operator role, validates the
value, gives it a 300 ms original lifetime and a 250 ms deadline, stamps
operator, turn_epoch and provenance (session, seq, issued_at_ms,
expires_at_ms, clock_uncertainty_ms) from Bot's own frame, and keeps a
2 requests/second budget. It goes to Link as kind headlight (not
actuator, which would renew the motion deadline). Link's ack data
stage=headlight_pending/headlight_refused and nack message reach the
panel; sensors.p7_headlight drives the displayed commanded state.

* Bot docs: name the headlight custom action in mapping.actions

* Bot panel: keep the headlight independent of the head actuator's gating

The headlight is a custom kind in the head card, not an actuator control:
enableControls must not disable it from the actuator's allowed state, and
it is enabled only when the joined panel may send headlights.

* Bot docs: show the headlight frame and its ack data

Changes

ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 93fad58..c80265c 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -72,7 +72,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once**; without OpenRestream configured `video: "not_configured"` instead of `publish_key`/`whip_url` |
| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
--| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
++| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id, provenance?` | an operator's command passed the gate; `provenance` is present on `headlight` |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
@@@ -87,16 +87,31 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `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 |
| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
| `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` | a command ran (or, keyed by a job id, a job was accepted: §1.2) |
++| `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}` |
| `nack` | `id, fault_code` | a command (or a job, §1.2) was refused on the device |
| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |
--`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `halt`, and a device only ever gets the
--kinds its robot's profile declares (§1.1), with values in the shapes and ranges declared there.
--`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds only (`drive`,
--`actuator`, `ptz`): the device **must** stop the motors at that instant if no newer command arrived, and
--on every disconnect or crash path, without asking the network. `halt` (value `{}`, no deadline) stops
--everything at once and passes the gate even while the e-stop is latched.
++`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `button`, `point`, `headlight` and `halt`, and a
++device only ever gets the kinds its robot's profile declares (§1.1) or a custom action it declares in
++`mapping.actions`, with values in the shapes and ranges declared there.
++`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds (`drive`, `actuator`, `ptz`), on a
++held `button`, and on `headlight` (which is not motion and never renews a motion deadline): the device **must** stop
++the motors at that instant if no newer command arrived, and on every disconnect or crash path, without asking the
++network. `halt` (value `{}`, no deadline) stops everything at once and passes the gate even while the e-stop is
++latched.
++
++**Headlights** (`headlight`, LOOI; `{ "on": true|false }`, explicit and never a toggle) are the owner's and People
++Operators' only, never an anonymous viewer, a plain watcher or the queue. Bot mints the trusted fields itself, never
++from the browser: `operator {subject, role}`, `turn_epoch`, an original lifetime of 300 ms, a `deadline_ms` of at most
++250 ms, and `provenance { session, seq, issued_at_ms, expires_at_ms, clock_uncertainty_ms }` (the hello's session id,
++this frame's seq and ts, the 300 ms expiry, and Bot's last measured device round trip). OpenVibe Link checks them
++against the frame, keeps at least 200 ms of the original lifetime on receipt, and gives the driver the note's
++envelope with `expires_at_mono_ns` on its shared clock. An `ack` with `data {stage:"headlight_pending", admitted:true}`
++means accepted, **not confirmed**; `{stage:"headlight_refused", admitted:false}` means busy or rate-limited and the
++panel keeps its displayed state; a `nack` is a rejection. `sensors.p7_headlight` telemetry carries
++`{ commanded: true|false|null, pending, physical_state_verified: false }`: the panel's On/Off/Unknown is commanded
++state only. At most two are accepted per rolling second, shared with the owner's local controls; nothing retries
++automatically.
Bot sends a `halt` of its own when a `queue` robot's turn ends — the turn ran out, or its holder left —
before the next person's first command can pass the gate, so a held button, a drive still inside its
@@@ -191,6 +206,7 @@ refused here (`bot.invalid_input`, `bot.unknown_actuator`) and never sent.
| `say` | `max_chars` | `{ "text": "…" }` |
| `display` | `modes` of `text`, `face`, `image_png_b64`; `faces`; `max_chars` | exactly one of `{ "text" }`, `{ "face": "happy" }`, `{ "image_png_b64": "…" }` (base64, ≤ 48 KiB) |
| `halt` | — (every profile) | `{}` |
++| `headlight` | `mapping.actions: ["headlight"]` (a custom action; `commands` stays closed) | `{ "on": true }` or `{ "on": false }`, exactly; no toggle, no other key |
The shipped profiles and the plugin each one matches (`test/profiles.test.js` checks every panel control
against these):
@@@ -201,6 +217,7 @@ against these):
| `adeept.adr036.mecanum` | Link plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |
| `cozmo` | Link plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image |
| `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 |
++| `looi` | Link plugin `looi` | `drive {throttle, steer}`; `actuator head` −1..1; custom action `headlight {on}` |
| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,
@@@ -270,6 +287,20 @@ A drive command (a held control re-sends every 150 ms with a fresh `id`):
"operator": { "subject": "usr_01J8…", "role": "operator" }, "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X" }
```
++A headlight (Bot stamps the actor, the epoch and the provenance; the browser sends only `kind` and `value`):
++
++```json
++{ "v": 1, "seq": 10, "ts": 1738065600000, "type": "command", "id": "cmd_01J8Z4H…", "ref": "op_hl…",
++ "kind": "headlight", "value": { "on": true }, "deadline_ms": 1738065600250,
++ "operator": { "subject": "usr_01J8…", "role": "owner" }, "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "provenance": { "session": "sess_01J8Z4A…", "seq": 10, "issued_at_ms": 1738065600000,
++ "expires_at_ms": 1738065600300, "clock_uncertainty_ms": 0 } }
++```
++
++Its link-level answer arrives in the ack's `data`: `{ "stage": "headlight_pending", "admitted": true }` (accepted,
++not confirmed) or `{ "stage": "headlight_refused", "admitted": false }` (busy/rate; the panel keeps its state). A
++`nack` is a rejection, with the driver's `message` as the reason the panel shows.
++
```json
{ "v": 1, "seq": 10, "ts": 1738065600005, "type": "ack", "id": "cmd_01J8Z4F…" }
{ "v": 1, "seq": 11, "ts": 1738065600010, "type": "nack", "id": "cmd_01J8Z4F…", "fault_code": "obstacle" }
ddiff --git a/public/panel.js b/public/panel.js
iindex 04f4530..adf5153 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -323,7 +323,7 @@
window.dispatchEvent(new CustomEvent('ov-bot-command', { detail: { kind, value } }));
sentAt.set(id, performance.now());
if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);
-- return true;
++ return id;
}
const quality = (ms, ok, poor) => (ms == null ? 'none' : ms <= ok ? 'good' : ms <= poor ? 'fair' : 'poor');
function roundTrip(id, result) {
@@@ -934,6 +934,123 @@
});
$$('[data-rgb-off]').forEach((el) => el.addEventListener('click', () => send('actuator', { name: el.dataset.rgbOff, value: null })));
$$('[data-bool]').forEach((el) => el.addEventListener('change', () => send('actuator', { name: el.dataset.bool, value: el.checked })));
++
++ // ── LOOI headlights ───────────────────────────────────────────────────────────────────────────────
++ // The driver's telemetry is the truth: sensors.p7_headlight {commanded, pending, physical_state_verified}.
++ // A press sends the opposite explicit state; Unknown offers Turn on and Turn off. The pending state is shown
++ // until telemetry says it settled, and a refusal never changes the displayed state.
++ const headlightEl = $('[data-headlight]');
++ const headlightIds = new Set();
++ const hl = { commanded: null, pending: false, verified: false, target: null, choices: false, timer: null };
++ const hlStateEl = headlightEl && $('[data-headlight-state]', headlightEl);
++ const hlToggle = headlightEl && $('[data-headlight-toggle]', headlightEl);
++ const hlChoices = headlightEl && $('[data-headlight-choices]', headlightEl);
++ const hlUnconfirmed = headlightEl && $('[data-headlight-unconfirmed]', headlightEl);
++ const hlBadge = $('[data-headlight-note]');
++ function hlNotice(text) {
++ if (!hlBadge) return;
++ hlBadge.textContent = text || '';
++ hlBadge.hidden = !text;
++ }
++ function paintHeadlight() {
++ if (!headlightEl || !hlStateEl) return;
++ const on = hl.target != null ? hl.target : hl.commanded;
++ const state = hl.pending
++ ? (on === true ? 'Turning on…' : on === false ? 'Turning off…' : 'Unknown')
++ : (hl.commanded === true ? 'On' : hl.commanded === false ? 'Off' : 'Unknown');
++ hlStateEl.textContent = state;
++ if (hlUnconfirmed) hlUnconfirmed.hidden = hl.verified;
++ const unknown = !hl.pending && hl.commanded === null;
++ if (hlChoices) hlChoices.hidden = !(unknown && hl.choices);
++ hlToggle.setAttribute('aria-expanded', String(!!(unknown && hl.choices)));
++ hlToggle.setAttribute('aria-label', `Headlights: ${state}. ${hl.pending ? 'Waiting for the robot.' : unknown ? 'Choose Turn on or Turn off.' : `Press to turn ${hl.commanded ? 'off' : 'on'}.`}`);
++ hlToggle.disabled = hl.pending;
++ }
++ function sendHeadlight(on) {
++ if (hl.pending || !joined) return;
++ hlNotice('');
++ hl.pending = true;
++ hl.target = on;
++ hl.choices = false;
++ paintHeadlight();
++ const id = send('headlight', { on });
++ if (!id) { hl.pending = false; hl.target = null; paintHeadlight(); return; }
++ headlightIds.add(id);
++ if (headlightIds.size > 100) headlightIds.delete(headlightIds.values().next().value);
++ clearTimeout(hl.timer);
++ // No automatic retry: if no telemetry arrives, stop claiming it is turning, and keep the state that was shown.
++ hl.timer = setTimeout(() => { if (hl.pending) { hl.pending = false; hl.target = null; paintHeadlight(); } }, 2000);
++ }
++ function headlightAck(m) {
++ clearTimeout(hl.timer);
++ hl.timer = null;
++ if (m.result === 'ack' && m.data && m.data.stage === 'headlight_pending' && m.data.admitted === true) {
++ hlNotice('');
++ if (!hl.pending) { hl.pending = true; paintHeadlight(); }
++ return;
++ }
++ if (m.result === 'ack' && !m.data) {
++ // An older/plain ack means accepted; keep the pending state only while telemetry may still arrive.
++ hl.timer = setTimeout(() => { if (hl.pending) { hl.pending = false; hl.target = null; paintHeadlight(); } }, 2000);
++ return;
++ }
++ if (m.result === 'ack' && m.data && m.data.stage === 'headlight_refused' && m.data.admitted === false) {
++ hl.pending = false; hl.target = null;
++ hlNotice('Busy, try again in a moment.');
++ paintHeadlight();
++ return;
++ }
++ if (m.result === 'nack') {
++ hl.pending = false; hl.target = null;
++ hlNotice(`The robot refused: ${m.reason || 'no reason given'}`);
++ paintHeadlight();
++ return;
++ }
++ if (m.result === 'refused') {
++ hl.pending = false; hl.target = null;
++ const busy = ['bot.headlight_busy', 'bot.cooldown', 'bot.command_pending'].includes(m.code);
++ hlNotice(busy ? 'Busy, try again in a moment.' : (m.reason || 'It was refused.'));
++ paintHeadlight();
++ return;
++ }
++ if (m.result === 'expired') {
++ hl.pending = false; hl.target = null;
++ paintHeadlight();
++ }
++ }
++ /** Paint the commanded/pending state from the driver's telemetry (the only state the robot actually reports). */
++ function headlightTelemetry(sensors) {
++ if (!headlightEl) return;
++ const h = sensors && sensors.p7_headlight;
++ if (!h || typeof h !== 'object') return;
++ if (h.commanded === true || h.commanded === false || h.commanded === null) hl.commanded = h.commanded;
++ hl.pending = h.pending === true;
++ hl.verified = h.physical_state_verified === true;
++ if (!hl.pending) {
++ hl.target = null;
++ clearTimeout(hl.timer);
++ hl.timer = null;
++ }
++ paintHeadlight();
++ }
++ if (headlightEl) {
++ hlToggle.addEventListener('click', () => {
++ if (hl.pending) return;
++ if (hl.commanded === null) {
++ hl.choices = !hl.choices;
++ paintHeadlight();
++ if (hl.choices) {
++ const first = $('[data-headlight-on]', headlightEl);
++ if (first) first.focus();
++ }
++ return;
++ }
++ sendHeadlight(!hl.commanded);
++ });
++ $('[data-headlight-on]', headlightEl).addEventListener('click', () => sendHeadlight(true));
++ $('[data-headlight-off]', headlightEl).addEventListener('click', () => sendHeadlight(false));
++ paintHeadlight();
++ }
$$('form[data-say]').forEach((f) => f.addEventListener('submit', (e) => {
e.preventDefault();
const text = f.elements.text.value.trim();
@@@ -984,10 +1101,13 @@
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
++ for (const el of $$('[data-headlight] button')) el.disabled = !(joined && allowed.includes('headlight'));
for (const section of $$('[data-command]')) {
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
// The pad's arrangement is this person's own and works offline: it is never a control of the robot.
-- for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]')) el.disabled = !ok;
++ // A custom action in the card (LOOI's headlights) has its own kind: never disable it from the actuator's
++ // state. The join below enables it only when the panel may send headlights.
++ for (const el of $$('button, input, select', section)) if (!el.closest('[data-pad-prefs-menu]') && !el.matches('[data-pad-prefs]') && !el.closest('[data-headlight]')) el.disabled = !ok;
for (const el of $$('[data-joystick], [data-button-stick], [data-ptz-stick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
}
announceControls();
@@@ -1116,6 +1236,7 @@
for (const el of $$('[data-battery]')) el.value = state.battery != null ? state.battery : 0;
for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';
const sensors = state.telemetry && state.telemetry.sensors;
++ headlightTelemetry(sensors);
for (const el of $$('[data-telemetry]')) {
const [key, value] = sensors ? sensorValue(sensors, el.dataset.telemetry) : [null, null];
el.textContent = readout(key != null ? value : null, key);
@@@ -1194,6 +1315,7 @@
paintTurn(m.state && m.state.queue);
} else if (m.type === 'command_result') {
roundTrip(m.id, m.result);
++ if (m.id && headlightIds.delete(m.id)) { headlightAck(m); return; }
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
else if (m.result === 'nack') { const t = nackText(m.reason); if (t) notice(t); }
else if (m.result === 'ack') notice('');
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 75c8da2..3084cae 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -24,11 +24,11 @@ const { ENVELOPE } = require('../events/outbox');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_ALLOW = {
-- owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
-- operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
++ owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],
++ operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],
queue: ['drive', 'button', 'point', 'halt'],
};
--const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt']);
++const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt']);
const MOTION = new Set(['drive', 'actuator', 'ptz']);
const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);
// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.
@@@ -1317,9 +1317,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
};
++ // Headlights: at most two accepted in any rolling second, one budget per robot. Link keeps the same budget
++ // with the owner's local controls; this one stops a browser double-click before it reaches the Node.
++ const headlightAt = new Map();
++ const spendHeadlight = (robotId, at) => {
++ const seen = headlightAt.get(robotId) || [];
++ if (seen.length >= 2 && at - seen[0] < 1000) return false;
++ seen.push(at);
++ if (seen.length > 2) seen.shift();
++ headlightAt.set(robotId, seen);
++ if (headlightAt.size > 4096) headlightAt.delete(headlightAt.keys().next().value);
++ return true;
++ };
-- /** The command kinds the robot's profile takes (`halt` always). */
-- const profileTakes = (profile, kind) => kind === 'halt' || !!(profile && profile.commands && profile.commands[kind]);
++ /**
++ * The command kinds the robot's profile takes (`halt` always). A profile may also declare a custom action in
++ * its open `mapping.actions` (LOOI's `headlight`): the panel action is gated like a command, but the robot-profile
++ * contract keeps `commands` closed, so the mapping is where a driver's side action is declared.
++ */
++ const profileTakes = (profile, kind) => kind === 'halt' || !!(profile && (
++ (profile.commands && profile.commands[kind])
++ || (profile.mapping && Array.isArray(profile.mapping.actions) && profile.mapping.actions.includes(kind))));
/**
* What `role` may send to this robot: the owner's allowlist for the role (or the default), cut to the
* kinds the profile takes. Every role that may drive also gets `halt`, which no allowlist removes.
@@@ -1327,7 +1345,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const allowedFor = (robot, role, profile = null) => {
const rl = json(robot.limits, {});
const fromOwner = rl.allow && Array.isArray(rl.allow[role]) ? rl.allow[role] : null;
-- const kinds = (fromOwner || DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profileTakes(profile, k));
++ // Headlights are the owner's and People Operators' only, whatever an allow list says: never the queue,
++ // never a viewer. Every role that may drive also gets halt, which no allow list removes.
++ const kinds = (fromOwner || DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profileTakes(profile, k)
++ && (k !== 'headlight' || role === 'owner' || role === 'operator'));
return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;
};
@@@ -1384,6 +1405,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!hold && Object.hasOwn(v, 'state')) fail(422, 'bot.invalid_input', 'a plain button takes no state');
return hold ? { name, state: v.state } : { name };
}
++ if (kind === 'headlight') {
++ // Explicit, never a toggle: exactly {"on": true|false}.
++ if (Object.keys(v).length !== 1 || Object.keys(v)[0] !== 'on' || typeof v.on !== 'boolean') {
++ fail(422, 'bot.invalid_input', 'headlight needs exactly on: true or false');
++ }
++ return { on: v.on };
++ }
if (kind === 'point') {
if (Object.keys(v).some((k) => k !== 'x' && k !== 'y') ||
typeof v.x !== 'number' || !Number.isFinite(v.x) || v.x < 0 || v.x > 1 ||
@@@ -1490,6 +1518,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// Owner takeover: the holder's turn pauses and the robot halts before this command goes (takeOver).
await takeOver(robotId, subject, role);
}
++ // The panel action must never become a queue or viewer path, even if an old allow list named it.
++ if (kind === 'headlight' && role !== 'owner' && role !== 'operator') {
++ return { ok: false, code: 'bot.command_not_allowed', reason: 'headlights are for the owner and operators', robot, role };
++ }
let built;
try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
const specificMs = kind === 'button' ? profile.commands.button.names[built.name].cooldown_ms || 0
@@@ -1501,13 +1533,22 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
if (cooled) markCooldown(robotId, subject, kind);
if (specificMs > 0 && !release && !repeatedHold) markCooldown(robotId, subject, specificKey);
-- const deadlineMs = (MOTION.has(kind) || (kind === 'button' && built.state === 'down'))
-- ? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
-- : null;
++ const at = now();
++ if (kind === 'headlight' && !spendHeadlight(robotId, at)) {
++ return { ok: false, code: 'bot.headlight_busy', reason: 'busy', robot, role };
++ }
++ // A headlight is minted with its own numbers, not the profile's motion cap: a 300 ms original lifetime and a
++ // 250 ms deadline. The Node maps the original expiry onto its shared clock and keeps 200 ms of it on receipt.
++ const deadlineMs = kind === 'headlight' ? at + 250
++ : (MOTION.has(kind) || (kind === 'button' && built.state === 'down'))
++ ? at + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
++ : null;
++ const expiresAtMs = kind === 'headlight' ? at + 300 : null;
++ const issuedAtMs = kind === 'headlight' ? at : null;
if (activeKey && built.state === 'down') activeButtons.set(activeKey, deadlineMs);
if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);
-- return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff, epoch };
++ return { ok: true, robot, profile, role, kind, value: built, deadlineMs, expiresAtMs, issuedAtMs, limits: eff, epoch };
}
return {
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 0fbdad4..c83dad9 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -74,6 +74,8 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
* say { max_chars }
* display { modes: [text|face|image_png_b64], faces: [...] (with face), max_chars }
* halt {} every profile takes it; the gate never refuses it for the e-stop
++ * A driver's side action that the closed `commands` schema cannot name (LOOI's headlight) is declared in the open
++ * `mapping.actions`; the gate still decides the role, allow list, e-stop and value.
*/
const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
ddiff --git a/server/profiles/looi.json b/server/profiles/looi.json
iindex 85df2e2..53c4714 100644
---- a/server/profiles/looi.json
++++ b/server/profiles/looi.json
@@@ -6,7 +6,7 @@
"kind": "bridge",
"description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Link with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",
"capabilities": ["drive.differential", "head", "battery"],
-- "mapping": { "driver": "looi", "plugin": "looi" },
++ "mapping": { "driver": "looi", "plugin": "looi", "actions": ["headlight"] },
"commands": {
"drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
"actuator": { "names": { "head": { "type": "number", "range": [-1, 1] } } }
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 53f0ab0..ada0a34 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -516,13 +516,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (p.timer) clearTimeout(p.timer);
const result = msg.type === 'ack' ? 'ack' : 'nack';
const latency = now() - p.at;
++ // A headlight's ack data is the driver's stage/admitted answer (accepted is not confirmed); only those two
++ // fields are passed on, so a driver can never put arbitrary data on a panel.
++ const rawData = result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) && (p.computer || p.kind === 'headlight') ? msg.data : null;
++ const data = p.kind === 'headlight' ? headlightData(rawData) : rawData && p.kind === 'browser' ? boundPage(rawData) : rawData;
// A computer command's answer goes to its caller once; the cache keeps the result only (a repeated id gets
-- // no data back), so 4096 cached results never hold 4096 screens.
-- cacheResult(p.key, { result, reason: msg.fault_code || null });
++ // no data back), so 4096 cached results never hold 4096 screens. A headlight's tiny answer is kept so a
++ // repeated id still says accepted/refused.
++ cacheResult(p.key, { result, reason: msg.fault_code || null, ...(p.kind === 'headlight' && data ? { data } : {}) });
await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
-- const rawData = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
-- const data = rawData && p.kind === 'browser' ? boundPage(rawData) : rawData;
-- const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
++ const message = (p.computer || p.kind === 'headlight') && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
}
@@@ -664,13 +667,22 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// turn_epoch (contracts 0.213.0): the robot's epoch now, which a queue command's check above proved is the one it
// was decided under; an owner's command that just took a turn over carries the epoch its own halt moved to.
const epoch = domain.queue.epoch(robotId);
-- const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId, ...(epoch ? { turn_epoch: epoch } : {}) };
++ // A headlight carries Bot's own provenance: the hello session, this frame's seq and ts, and Bot's original
++ // expiry 300 ms out (the gate's 250 ms deadline is the frame's deadline_ms). Nothing here comes from a browser.
++ const issuedAt = decision.issuedAtMs != null ? decision.issuedAtMs : now();
++ const seq = nextSeq(device);
++ const provenance = decision.expiresAtMs != null ? {
++ session: device.sessionId, seq, issued_at_ms: issuedAt, expires_at_ms: decision.expiresAtMs,
++ clock_uncertainty_ms: Number.isFinite(device.rttMs) ? Math.max(0, Math.ceil(device.rttMs)) : 0,
++ } : null;
++ const frame = { v: 1, type: 'command', seq, ts: issuedAt, id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId, ...(provenance ? { provenance } : {}), ...(epoch ? { turn_epoch: epoch } : {}) };
if (!send(device.ws, frame)) {
await domain.audit.record({ robotId, deviceId: device.device.id, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, result: 'refused', reason: 'bot.device_offline' });
return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.device_offline', reason: 'the device is offline' });
}
lastRobotCommandAt.set(robotId, now());
-- if (decision.deadlineMs != null) movementLeaseUntil.set(robotId, Math.max(movementLeaseUntil.get(robotId) || 0, decision.deadlineMs));
++ // A headlight is not motion: its short deadline must not renew a drive/head movement lease.
++ if (decision.deadlineMs != null && decision.kind !== 'headlight') movementLeaseUntil.set(robotId, Math.max(movementLeaseUntil.get(robotId) || 0, decision.deadlineMs));
const p = { id: deviceCmdId, opId: id, key, deviceId: device.device.id, conn, robotId, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, at: now(), timer: null };
pendingCmds.set(deviceCmdId, p);
inflight.set(key, deviceCmdId);
@@@ -707,6 +719,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (p.timer.unref) p.timer.unref();
}
++ /** The only fields of a driver's headlight ack data that reach a panel: stage and admitted. */
++ function headlightData(raw) {
++ if (!raw) return null;
++ const out = {};
++ if (raw.stage === 'headlight_pending' || raw.stage === 'headlight_refused') out.stage = raw.stage;
++ if (typeof raw.admitted === 'boolean') out.admitted = raw.admitted;
++ return Object.keys(out).length ? out : null;
++ }
function cacheResult(key, r) { results.set(key, { ...r, at: now() }); if (results.size > 4096) results.delete(results.keys().next().value); }
/**
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 0c51a66..97a6614 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -260,7 +260,7 @@ ${kit.pageHead({ title: `Robot profiles`, icon: 'code', back: { href: '/', label
<li><code>kind</code> — <code>onboard</code>, <code>bridge</code> or <code>server</code> (see ${inLink('/docs/drivers', 'Write a driver')}).</li>
<li><code>capabilities</code> — one or more of the registry: <code>drive.differential</code>, <code>drive.mecanum</code>, <code>servo.pan_tilt</code>, <code>head</code>, <code>lift</code>, <code>lights.rgb</code>, <code>lights.backpack</code>, <code>lights.cube</code>, <code>speaker.horn</code>, <code>speaker.say</code>, <code>display.text</code>, <code>sensor.ultrasonic</code>, <code>sensor.line</code>, <code>sensor.cliff</code>, <code>sensor.pickup</code>, <code>battery</code>, <code>ptz</code>, <code>camera</code>, <code>relay</code>. A typo is refused at load.</li>
<li><code>variants</code> — optional: one profile, several wheel layouts.</li>
--<li><code>mapping</code> — <code>{ "driver": … }</code>, one of <code>pca9685</code>, <code>ads7830</code>, <code>cozmo</code>, <code>looi</code>, <code>onvif</code>, <code>sim</code>, <code>relay</code>, plus the driver's own hardware map (addresses, channels).</li>
++<li><code>mapping</code> — <code>{ "driver": … }</code>, one of <code>pca9685</code>, <code>ads7830</code>, <code>cozmo</code>, <code>looi</code>, <code>onvif</code>, <code>sim</code>, <code>relay</code>, plus the driver's own hardware map (addresses, channels). A driver's side action the closed <code>commands</code> schema cannot name goes in the open <code>mapping.actions</code>; LOOI declares <code>["headlight"]</code>, which the gate allows to the owner and People Operators and renders with the head controls.</li>
<li><code>commands</code> — what the driver accepts, keyed by kind: <code>drive</code> and <code>ptz</code> (<code>{ axes: { &lt;axis&gt;: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { &lt;name&gt;: { type: number|rgb|tone|bool, … } } }</code>), <code>say</code>, <code>display</code>, <code>button</code> and <code>point</code>. Every profile also takes <code>halt</code>. The gate allows only the kinds declared here and builds every value from it.</li>
<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
ddiff --git a/server/web/render.js b/server/web/render.js
iindex e475756..9af309f 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -57,6 +57,7 @@ const PATHS = {
robot: '<rect x="4" y="8" width="16" height="11" rx="2.5"/><path d="M12 4v4M9 13h.01M15 13h.01M2 13v2M22 13v2"/>',
plug: '<path d="M9 2v5M15 2v5M6 7h12v4a6 6 0 0 1-12 0zM12 17v5"/>',
gamepad: '<path d="M7 7h10a4 4 0 0 1 4 4l.8 5a2.6 2.6 0 0 1-4.6 2L15 15H9l-2.2 3a2.6 2.6 0 0 1-4.6-2L3 11a4 4 0 0 1 4-4z"/><path d="M7.5 10v3M6 11.5h3M16 11h.01M18 13h.01"/>',
++ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
external: '<path d="M14 4h6v6M20 4l-9 9"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>',
};
@@@ -173,6 +174,23 @@ function padPrefs() {
/** An actuator's name as a label: backpack_lights → Backpack lights. */
const humanName = (name) => { const s = String(name).replace(/_/g, ' '); return s.charAt(0).toUpperCase() + s.slice(1); };
++/** A profile's custom side actions, from the open `mapping.actions` (the closed `commands` contract cannot name them). */
++const profileActions = (profile) => (profile && profile.mapping && Array.isArray(profile.mapping.actions) ? profile.mapping.actions : []);
++const headlightAction = (profile) => profileActions(profile).includes('headlight');
++
++/**
++ * The LOOI headlights control: one labelled button, its state (On / Off / Unknown / Turning on… / Turning off…), the
++ * unconfirmed note, and the two explicit choices when the commanded state is unknown. public/panel.js sends
++ * {"kind":"headlight","value":{"on":true|false}}; this markup carries no state from the browser.
++ */
++function headlightControl() {
++ return `<div class="control headlight" data-headlight>`
++ + `<button type="button" class="headlight-toggle" data-headlight-toggle aria-expanded="false">${icon('sun')}<span class="control-name">Headlights</span> <span class="headlight-state" data-headlight-state>Unknown</span></button>`
++ + `<span class="headlight-unconfirmed" data-headlight-unconfirmed>Not confirmed on the robot</span>`
++ + `<span class="headlight-choices" data-headlight-choices hidden><button type="button" data-headlight-on>Turn on</button> <button type="button" data-headlight-off>Turn off</button></span>`
++ + `</div><p class="headlight-note" data-headlight-note role="alert" hidden></p>`;
++}
++
/** One control per actuator the widget drives, by the actuator's declared type. */
/**
* Sliders that repeat while held (2026-11-09, the LOOI bridge's short head-target leases): by profile and actuator, the
@@@ -280,7 +298,11 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
}
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
-- inner = `<div class="controls">${(w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled, profile.id)).join('')}</div>`;
++ const parts = (w.command && w.command.names ? w.command.names : []).filter((n) => names[n]).map((n) => actuatorControl(n, names[n], enabled, profile.id));
++ // A profile may declare a custom side action in its open mapping (LOOI's headlight). It sits beside the
++ // head slider, only for someone the gate allows; with no permission it is not drawn at all, not disabled.
++ if (w.type === 'head' && headlightAction(profile) && allowed_commands.includes('headlight')) parts.push(headlightControl());
++ inner = `<div class="controls">${parts.join('')}</div>`;
break;
}
case 'speaker': {
ddiff --git a/test/headlight.test.js b/test/headlight.test.js
nnew file mode 100644
iindex 0000000..f4243c8
---- /dev/null
++++ b/test/headlight.test.js
@@@ -0,0 +1,200 @@
++'use strict';
++// LOOI headlights (Maticus's 2026-11-11 handoff): the owner's and People Operators' explicit ON/OFF action, gated
++// by the same command gate as drive/actuator, stamped with Bot's provenance and a 300 ms lifetime/250 ms deadline,
++// relayed as its own non-motion kind, with the driver's stage=headlight_pending/headlight_refused ack data and
++// sensors.p7_headlight telemetry. No hardware: the fake device and HTTP panel only.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { loadProfiles } = require('../server/profiles');
++// The /control and /device frames under test are server/realtime.js's; require it so the runner maps this test to it.
++require('../server/realtime');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const carol = t.network.newUser('carol');
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const get = (p, user, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers: { ...(user ? cookie(user) : {}), ...headers } });
++ const op = (user) => t.ws('/control', { headers: cookie(user) });
++ const invite = (robotId, subject, role) => t.call('POST', `/api/v1/robots/${robotId}/operators`, { user: alex, body: { subject, role } });
++ const cmd = (client, o) => client.send({ type: 'command', ...o });
++ const result = (client, id) => client.waitFor((m) => m.type === 'command_result' && m.id === id);
++ async function join(client, robotId) {
++ client.send({ type: 'join', robot_id: robotId });
++ return client.waitFor((m) => m.type === 'joined' || m.type === 'error');
++ }
++ async function deviceFor(robotId, code) {
++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robotId, code } });
++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });
++ const hello = await dev.waitFor((m) => m.type === 'hello');
++ await dev.waitFor((m) => m.type === 'config');
++ return { dev, hello };
++ }
++ const looi = loadProfiles().get('looi');
++
++ await check('the LOOI profile declares the custom headlight action; owner and operator are allowed, queue is not', () => {
++ assert.deepStrictEqual(looi.mapping.actions, ['headlight']);
++ const owner = t.domain.control.allowedFor({ limits: {} }, 'owner', looi);
++ const operator = t.domain.control.allowedFor({ limits: {} }, 'operator', looi);
++ const queue = t.domain.control.allowedFor({ limits: {} }, 'queue', looi);
++ assert.ok(owner.includes('headlight') && operator.includes('headlight'));
++ assert.ok(!queue.includes('headlight'));
++ // An owner who removes it from the operator allow list takes it away from People Operators.
++ const restricted = t.domain.control.allowedFor({ limits: { allow: { operator: ['drive'] } } }, 'operator', looi);
++ assert.deepStrictEqual(restricted, ['drive', 'halt']);
++ });
++
++ await check('the owner\'s action is stamped by Bot: operator, turn epoch, provenance (300 ms original / 250 ms deadline)', async () => {
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
++ const { dev, hello } = await deviceFor(robot.id, pairing.code);
++ const a = await op(alex);
++ const j = await join(a, robot.id);
++ assert.strictEqual(j.role, 'owner');
++ assert.ok(j.allowed_commands.includes('headlight'));
++ const before = Date.now() + t.clock.offset;
++ cmd(a, { id: 'hl-1', kind: 'headlight', value: { on: true } });
++ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === 'hl-1');
++ assert.strictEqual(c.kind, 'headlight', 'actuator would renew the motion deadline');
++ assert.deepStrictEqual(c.value, { on: true });
++ assert.strictEqual(c.operator.role, 'owner');
++ assert.strictEqual(c.operator.subject, alex.subject);
++ assert.ok(c.deadline_ms > before && c.deadline_ms <= before + 250 + 20, `deadline ${c.deadline_ms - before} ms`);
++ assert.strictEqual(c.provenance.expires_at_ms - c.provenance.issued_at_ms, 300);
++ assert.strictEqual(c.provenance.issued_at_ms, c.ts, 'issued_at_ms is the frame ts');
++ assert.strictEqual(c.provenance.seq, c.seq, 'provenance seq is the envelope seq');
++ assert.strictEqual(c.provenance.session, hello.session_id);
++ assert.strictEqual(typeof c.provenance.clock_uncertainty_ms, 'number');
++ // The accepted-not-confirmed answer rides back in the ack's data.
++ dev.send({ type: 'ack', id: c.id, data: { stage: 'headlight_pending', admitted: true } });
++ const r = await result(a, 'hl-1');
++ assert.strictEqual(r.result, 'ack');
++ assert.deepStrictEqual(r.data, { stage: 'headlight_pending', admitted: true });
++ // The driver's busy answer is an ack too, admitted:false; the panel keeps the state it shows.
++ t.clock.offset += 1000;
++ cmd(a, { id: 'hl-2', kind: 'headlight', value: { on: false } });
++ const c2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'hl-2');
++ dev.send({ type: 'ack', id: c2.id, data: { stage: 'headlight_refused', admitted: false } });
++ const r2 = await result(a, 'hl-2');
++ assert.strictEqual(r2.result, 'ack');
++ assert.deepStrictEqual(r2.data, { stage: 'headlight_refused', admitted: false });
++ // A nack is a rejection, with the driver's words.
++ t.clock.offset += 1000;
++ cmd(a, { id: 'hl-3', kind: 'headlight', value: { on: true } });
++ const c3 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'hl-3');
++ dev.send({ type: 'nack', id: c3.id, fault_code: 'not_connected', message: 'LOOI is not connected over Bluetooth' });
++ const r3 = await result(a, 'hl-3');
++ assert.strictEqual(r3.result, 'nack');
++ assert.strictEqual(r3.reason, 'LOOI is not connected over Bluetooth');
++ // Telemetry is the only state the robot reports.
++ dev.send({ type: 'telemetry', sensors: { p7_headlight: { commanded: true, pending: false, physical_state_verified: false } } });
++ const st = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.p7_headlight);
++ assert.deepStrictEqual(st.state.telemetry.sensors.p7_headlight, { commanded: true, pending: false, physical_state_verified: false });
++ a.close(); dev.close();
++ });
++
++ await check('a People Operator is allowed and stamped as operator; removing headlight from the owner\'s allow list refuses and hides it', 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 b = await op(bob);
++ const j = await join(b, robot.id);
++ assert.strictEqual(j.role, 'operator');
++ assert.ok(j.allowed_commands.includes('headlight'));
++ cmd(b, { id: 'op-hl', kind: 'headlight', value: { on: true } });
++ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === 'op-hl');
++ assert.deepStrictEqual(c.operator, { subject: bob.subject, role: 'operator' });
++ dev.send({ type: 'ack', id: c.id });
++ await result(b, 'op-hl');
++ assert.ok((await (await get(`/panel/${robot.id}`, bob)).text()).includes('data-headlight'));
++ t.clock.offset += 1000;
++ const patch = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: alex, body: { limits: { allow: { operator: ['drive'] } } } });
++ assert.strictEqual(patch.status, 200, patch.text);
++ cmd(b, { id: 'op-hl-2', kind: 'headlight', value: { on: false } });
++ const r = await result(b, 'op-hl-2');
++ assert.strictEqual(r.result, 'refused');
++ assert.strictEqual(r.code, 'bot.command_not_allowed');
++ assert.ok(!(await (await get(`/panel/${robot.id}`, bob)).text()).includes('data-headlight'), 'hidden, not disabled');
++ b.close(); dev.close();
++ });
++
++ await check('viewers, watchers and the queue never get it: each command is refused, each panel has no control', async () => {
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
++ await invite(robot.id, carol.subject, 'viewer');
++ await t.db.query('UPDATE robots SET embed_public = true WHERE id = $1', [robot.id]);
++ const { dev } = await deviceFor(robot.id, pairing.code);
++ const v = await op(carol);
++ const vj = await join(v, robot.id);
++ assert.strictEqual(vj.role, 'viewer');
++ assert.ok(!vj.allowed_commands.includes('headlight'));
++ cmd(v, { id: 'v-hl', kind: 'headlight', value: { on: true } });
++ const vr = await result(v, 'v-hl');
++ assert.strictEqual(vr.result, 'refused');
++ assert.strictEqual(vr.code, 'bot.read_only');
++ const viewerHtml = await (await get(`/panel/${robot.id}`, carol)).text();
++ assert.ok(!viewerHtml.includes('data-headlight'));
++ const anon = await (await get(`/panel/${robot.id}/embed`)).text();
++ assert.ok(anon.includes('data-role="watcher"') && !anon.includes('data-headlight'));
++ // An anonymous caller has no subject: the command route refuses before any command reaches the gate.
++ const anonCmd = await t.call('POST', `/api/v1/robots/${robot.id}/commands`, { token: null, body: { id: 'a-hl', kind: 'headlight', value: { on: true } } });
++ assert.strictEqual(anonCmd.status, 401);
++ assert.strictEqual(anonCmd.json.code, 'bot.sign_in');
++ // The queue role is not a headlight path, even with a turn.
++ const q = await t.robot(alex, { profile_id: 'looi', access_policy: 'queue' });
++ const qc = await op(carol);
++ const qj = await join(qc, q.robot.id);
++ assert.strictEqual(qj.role, 'queue');
++ assert.ok(!qj.allowed_commands.includes('headlight'));
++ t.clock.offset += 1000;
++ cmd(qc, { id: 'q-hl', kind: 'headlight', value: { on: true } });
++ const qr = await result(qc, 'q-hl');
++ assert.strictEqual(qr.result, 'refused');
++ assert.strictEqual(qr.code, 'bot.command_not_allowed');
++ v.close(); qc.close(); dev.close();
++ });
++
++ await check('the value is explicit on/off only, and three in a second are rate-limited', async () => {
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
++ const { dev } = await deviceFor(robot.id, pairing.code);
++ const a = await op(alex);
++ await join(a, robot.id);
++ for (const [id, value] of [['bad-1', {}], ['bad-2', { on: 'yes' }], ['bad-3', { on: 1 }], ['bad-4', { on: true, x: 1 }]]) {
++ cmd(a, { id, kind: 'headlight', value });
++ const r = await result(a, id);
++ assert.strictEqual(r.result, 'refused', id);
++ assert.strictEqual(r.code, 'bot.invalid_input', id);
++ }
++ for (let i = 0; i < 2; i++) {
++ const id = `rate-${i}`;
++ cmd(a, { id, kind: 'headlight', value: { on: i === 0 } });
++ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === id);
++ dev.send({ type: 'ack', id: c.id });
++ assert.strictEqual((await result(a, id)).result, 'ack');
++ }
++ cmd(a, { id: 'rate-2', kind: 'headlight', value: { on: true } });
++ const r = await result(a, 'rate-2');
++ assert.strictEqual(r.result, 'refused');
++ assert.strictEqual(r.code, 'bot.headlight_busy');
++ t.clock.offset += 1000;
++ cmd(a, { id: 'rate-3', kind: 'headlight', value: { on: false } });
++ assert.ok(await dev.waitFor((m) => m.type === 'command' && m.ref === 'rate-3'), 'the next second is allowed');
++ a.close(); dev.close();
++ });
++
++ await check('the full panel and the embed panel carry the control and its exact words; overlay and compact do not', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'looi' });
++ const page = await (await get(`/panel/${robot.id}`, alex)).text();
++ for (const s of ['data-headlight', 'Headlights', 'Unknown', 'Not confirmed on the robot', 'Turn on', 'Turn off']) assert.ok(page.includes(s), s);
++ // The control sits in the head controls card beside the slider, not as a new widget.
++ assert.ok(/data-widget="head"[\s\S]*data-headlight/.test(page));
++ for (const mode of ['overlay', 'compact']) {
++ const html = await (await get(`/panel/${robot.id}/embed?mode=${mode}`)).text();
++ assert.ok(!html.includes('data-headlight'), `${mode} has the full panel's control`);
++ }
++ assert.ok((await (await get(`/panel/${robot.id}/embed`, alex)).text()).includes('data-headlight'), 'the channel Robot tab uses the full panel');
++ });
++
++ t.app.locals.sim.stopAll();
++ await t.close();
++ done();
++})();
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 7afe34d..6a238e8 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -99,7 +99,7 @@ function samples(profile, kind, names) {
const p = profiles.get('looi');
assert.strictEqual(p.kind, 'bridge');
assert.deepStrictEqual(p.capabilities.slice().sort(), ['battery', 'drive.differential', 'head']);
-- assert.deepStrictEqual(p.mapping, { driver: 'looi', plugin: 'looi' });
++ assert.deepStrictEqual(p.mapping, { driver: 'looi', plugin: 'looi', actions: ['headlight'] });
assert.deepStrictEqual(Object.keys(p.commands).sort(), ['actuator', 'drive', 'halt'], 'halt is in every profile');
assert.deepStrictEqual(p.commands.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });
assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });
@@@ -107,6 +107,9 @@ function samples(profile, kind, names) {
const { renderWidget } = require('../server/web/render');
const head = renderWidget(p.widgets.find((w) => w.type === 'head'), { profile: p, allowed_commands: ['actuator'] });
assert.match(head, /data-actuator="head" data-hold-repeat="90"/, 'the slider carries its repeat');
++ assert.ok(!head.includes('data-headlight'), 'no headlight without the action allow');
++ const withHeadlight = renderWidget(p.widgets.find((w) => w.type === 'head'), { profile: p, allowed_commands: ['actuator', 'headlight'] });
++ assert.match(withHeadlight, /data-headlight/, 'the headlight sits in the head card beside the slider');
assert.ok(!p.camera, 'no camera of its own');
// 500 ms since 2026-11-11 (Maticus: held drive expired on ordinary Wi-Fi jitter at 300); still short.
assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 500, 'a desk robot starts slow, with short commands');