openvibers/openvibe.bot Commits
LOOI headlights: an ON/OFF action for the owner and People Operators (#402)
Changes
@@@ -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 controlagainst 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" }@@@ -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('');@@@ -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 {@@@ -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']);@@@ -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] } } }@@@ -516,13 +516,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (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 = () => Datif (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); }/**@@@ -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: { <axis>: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { <name>: { 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>@@@ -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': {nnew file mode 100644@@@ -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();++})();@@@ -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');
@@ -72,7 +72,7 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -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` |
| `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 |
| `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) |
| `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 |
| `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 |
| `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 |
| `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`.
@@ -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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 —
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
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.
@@ -191,6 +206,7 @@ refused here (`bot.invalid_input`, `bot.unknown_actuator`) and never sent.
| `say` | `max_chars` | `{ "text": "…" }` |
| `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) |
| `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) | `{}` |
| `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
The shipped profiles and the plugin each one matches (`test/profiles.test.js` checks every panel control
against these):
against these):
@@ -201,6 +217,7 @@ against these):
@@ -201,6 +217,7 @@ against these):
| `adeept.adr036.mecanum` | Link plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |
| `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 |
| `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 |
| `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}` |
| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,
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`):
@@ -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" }
"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
```json
{ "v": 1, "seq": 10, "ts": 1738065600005, "type": "ack", "id": "cmd_01J8Z4F…" }
{ "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" }
{ "v": 1, "seq": 11, "ts": 1738065600010, "type": "nack", "id": "cmd_01J8Z4F…", "fault_code": "obstacle" }
@@ -323,7 +323,7 @@
@@ -323,7 +323,7 @@
window.dispatchEvent(new CustomEvent('ov-bot-command', { detail: { kind, value } }));
window.dispatchEvent(new CustomEvent('ov-bot-command', { detail: { kind, value } }));
sentAt.set(id, performance.now());
sentAt.set(id, performance.now());
if (sentAt.size > 200) sentAt.delete(sentAt.keys().next().value);
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');
const quality = (ms, ok, poor) => (ms == null ? 'none' : ms <= ok ? 'good' : ms <= poor ? 'fair' : 'poor');
function roundTrip(id, result) {
function roundTrip(id, result) {
@@ -934,6 +934,123 @@
@@ -934,6 +934,123 @@
});
});
$$('[data-rgb-off]').forEach((el) => el.addEventListener('click', () => send('actuator', { name: el.dataset.rgbOff, value: null })));
$$('[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 })));
$$('[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) => {
$$('form[data-say]').forEach((f) => f.addEventListener('submit', (e) => {
e.preventDefault();
e.preventDefault();
const text = f.elements.text.value.trim();
const text = f.elements.text.value.trim();
@@ -984,10 +1101,13 @@
@@ -984,10 +1101,13 @@
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
function enableControls() {
+ for (const el of $$('[data-headlight] button')) el.disabled = !(joined && allowed.includes('headlight'));
for (const section of $$('[data-command]')) {
for (const section of $$('[data-command]')) {
const ok = joined && allowed.includes(JSON.parse(section.dataset.command).kind);
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.
// 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'); }
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();
announceControls();
@@ -1116,6 +1236,7 @@
@@ -1116,6 +1236,7 @@
for (const el of $$('[data-battery]')) el.value = state.battery != null ? state.battery : 0;
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)} %` : '—';
for (const el of $$('[data-battery-text]')) el.textContent = state.battery != null ? `${Math.round(state.battery * 100)} %` : '—';
const sensors = state.telemetry && state.telemetry.sensors;
const sensors = state.telemetry && state.telemetry.sensors;
+ headlightTelemetry(sensors);
for (const el of $$('[data-telemetry]')) {
for (const el of $$('[data-telemetry]')) {
const [key, value] = sensors ? sensorValue(sensors, el.dataset.telemetry) : [null, null];
const [key, value] = sensors ? sensorValue(sensors, el.dataset.telemetry) : [null, null];
el.textContent = readout(key != null ? value : null, key);
el.textContent = readout(key != null ? value : null, key);
@@ -1194,6 +1315,7 @@
@@ -1194,6 +1315,7 @@
paintTurn(m.state && m.state.queue);
paintTurn(m.state && m.state.queue);
} else if (m.type === 'command_result') {
} else if (m.type === 'command_result') {
roundTrip(m.id, m.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);
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 === 'nack') { const t = nackText(m.reason); if (t) notice(t); }
else if (m.result === 'ack') notice('');
else if (m.result === 'ack') notice('');
@@ -24,11 +24,11 @@ const { ENVELOPE } = require('../events/outbox');
@@ -24,11 +24,11 @@ const { ENVELOPE } = require('../events/outbox');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_ALLOW = {
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'],
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 MOTION = new Set(['drive', 'actuator', 'ptz']);
const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);
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.
// 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 =
@@ -1317,9 +1317,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
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
* 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.
* 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 =
@@ -1327,7 +1345,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const allowedFor = (robot, role, profile = null) => {
const allowedFor = (robot, role, profile = null) => {
const rl = json(robot.limits, {});
const rl = json(robot.limits, {});
const fromOwner = rl.allow && Array.isArray(rl.allow[role]) ? rl.allow[role] : null;
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;
return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;
};
};
@@ -1384,6 +1405,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -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');
if (!hold && Object.hasOwn(v, 'state')) fail(422, 'bot.invalid_input', 'a plain button takes no state');
return hold ? { name, state: v.state } : { name };
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 (kind === 'point') {
if (Object.keys(v).some((k) => k !== 'x' && k !== 'y') ||
if (Object.keys(v).some((k) => k !== 'x' && k !== 'y') ||
typeof v.x !== 'number' || !Number.isFinite(v.x) || v.x < 0 || v.x > 1 ||
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 =
@@ -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).
// Owner takeover: the holder's turn pauses and the robot halts before this command goes (takeOver).
await takeOver(robotId, subject, role);
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;
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 }; }
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
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 =
@@ -1501,13 +1533,22 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
if (cooled) markCooldown(robotId, subject, kind);
if (cooled) markCooldown(robotId, subject, kind);
if (specificMs > 0 && !release && !repeatedHold) markCooldown(robotId, subject, specificKey);
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 === 'down') activeButtons.set(activeKey, deadlineMs);
if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
if (activeKey && built.state === 'up') activeButtons.delete(activeKey);
if (activeButtons.size > 4096) activeButtons.delete(activeButtons.keys().next().value);
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 {
return {
@@ -74,6 +74,8 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
@@ -74,6 +74,8 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
* say { max_chars }
* say { max_chars }
* display { modes: [text|face|image_png_b64], faces: [...] (with face), 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
* 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 AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"kind": "bridge",
"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.",
"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"],
"capabilities": ["drive.differential", "head", "battery"],
- "mapping": { "driver": "looi", "plugin": "looi" },
+ "mapping": { "driver": "looi", "plugin": "looi", "actions": ["headlight"] },
"commands": {
"commands": {
"drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
"drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
"actuator": { "names": { "head": { "type": "number", "range": [-1, 1] } } }
"actuator": { "names": { "head": { "type": "number", "range": [-1, 1] } } }
@@ -516,13 +516,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -516,13 +516,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (p.timer) clearTimeout(p.timer);
if (p.timer) clearTimeout(p.timer);
const result = msg.type === 'ack' ? 'ack' : 'nack';
const result = msg.type === 'ack' ? 'ack' : 'nack';
const latency = now() - p.at;
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
// 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 });
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 } : {}) });
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
@@ -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
// 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.
// 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 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)) {
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' });
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' });
return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.device_offline', reason: 'the device is offline' });
}
}
lastRobotCommandAt.set(robotId, now());
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 };
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);
pendingCmds.set(deviceCmdId, p);
inflight.set(key, deviceCmdId);
inflight.set(key, deviceCmdId);
@@ -707,6 +719,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -707,6 +719,14 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (p.timer.unref) p.timer.unref();
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); }
function cacheResult(key, r) { results.set(key, { ...r, at: now() }); if (results.size > 4096) results.delete(results.keys().next().value); }
/**
/**
@@ -260,7 +260,7 @@ ${kit.pageHead({ title: `Robot profiles`, icon: 'code', back: { href: '/', label
@@ -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>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>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>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: { <axis>: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { <name>: { 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>commands</code> — what the driver accepts, keyed by kind: <code>drive</code> and <code>ptz</code> (<code>{ axes: { <axis>: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { <name>: { 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>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>
<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
@@ -57,6 +57,7 @@ const PATHS = {
@@ -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"/>',
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"/>',
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"/>',
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"/>',
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"/>',
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() {
@@ -173,6 +174,23 @@ function padPrefs() {
/** An actuator's name as a label: backpack_lights → Backpack lights. */
/** 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); };
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. */
/** 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
* 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 =
@@ -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': {
case 'pan-tilt': case 'servo': case 'head': case 'lift': case 'lights': case 'horn': {
const names = (commands.actuator && commands.actuator.names) || {};
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;
break;
}
}
case 'speaker': {
case 'speaker': {
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -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();
+})();
@@ -99,7 +99,7 @@ function samples(profile, kind, names) {
@@ -99,7 +99,7 @@ function samples(profile, kind, names) {
const p = profiles.get('looi');
const p = profiles.get('looi');
assert.strictEqual(p.kind, 'bridge');
assert.strictEqual(p.kind, 'bridge');
assert.deepStrictEqual(p.capabilities.slice().sort(), ['battery', 'drive.differential', 'head']);
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(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.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });
assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });
assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });
@@ -107,6 +107,9 @@ function samples(profile, kind, names) {
@@ -107,6 +107,9 @@ function samples(profile, kind, names) {
const { renderWidget } = require('../server/web/render');
const { renderWidget } = require('../server/web/render');
const head = renderWidget(p.widgets.find((w) => w.type === 'head'), { profile: p, allowed_commands: ['actuator'] });
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.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');
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.
// 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');
assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 500, 'a desk robot starts slow, with short commands');