openvibers/openvibe.bot Commits
LOOI: Operators card, Mod role, sole control and the battery binding (#413)
Changes
@@@ -93,7 +93,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag- User tokens (SSO session / `ov_token` cookie) and service tokens are both accepted on `/api/v1`;a **node token is refused** on person and service routes. A person acts only as themself — naminganyone else is refused, never ignored.--- The **owner** can list a robot, add/remove operators, edit limits, clear the latched e-stop, set++- The **owner** can list a robot, add/remove operators, edit limits, clear the latched panel e-stop, setthe agent switch and flip streaming toggles. An operator drives within limits and cooldowns. Anopen-queue robot gives a viewer a turn, and leaving promotes the next person at once.- `svc:actor` may read/drive **only** the owner's robot and only with `robots.agent_control` on;@@@ -101,7 +101,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag- A machine runs only its owner's jobs: the dispatcher compares the job requester with the machineowner and refuses anyone else with `bot.not_owner`, whatever the placer chose.- Every command, allowed or refused, is audited. Pairing codes are one-time; `halt` always passes;-- only the owner clears the e-stop.++ the owner or a Mod clears the panel e-stop, while the tray's STOP latch and ARM stay local.## Frontend loading model@@@ -143,8 +143,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam## Pitfalls--- **The e-stop is latched and only the owner clears it**; `halt` always passes the gate and a layout-- cannot hide Stop or the overlay e-stop.++- **The panel e-stop is latched; the owner or a Mod clears it** (a Mod never clears the tray's local STOP/ARM);++ `halt` always passes the gate and a layout cannot hide Stop or the overlay e-stop.- **Never widen the owner fence.** A person naming another subject, a service, a node token or`svc:actor` without the owner's switch must be refused.- **Device credentials are hashed and read-once.** Do not add a readable copy or a query-string form.@@@ -38,7 +38,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Link — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.--- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).++- Operators, Mods and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. The LOOI panel's Operators card also shows live activity and last command, switches an Operator (or a Mod, by the owner) on or off immediately, grants one person sole control, and lets a Mod trip or clear the panel E-STOP (never the tray's local STOP or ARM). On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).- Dispatching `platform.job@1` jobs to a paired Link over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).@@@ -206,7 +206,7 @@ A person acts on their own robots with a Network user token; a service acts with- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.--- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.++- Pairing codes are one-time and short-lived; the panel e-stop is latched and the owner or a Mod clears it (the tray's own STOP and ARM stay local), while `halt` always passes the gate.- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.@@@ -228,9 +228,10 @@ the camera's `GetStatus` position in telemetry under `camera`.**A device's `estop_state` is a report, never a clear.** `latched: true` latches each robot the device isattached to (if it is not already latched). `latched: false` is recorded as the device's reported state--only (`robot_state.state.device_estop`); it never clears Bot's latch — clearing is the owner's alone--(`estop_clear` on `/control`, `POST /robots/:id/estop/clear`), after which Bot sends `estop` with--`latched: false`. A `robot_id` the device is not attached to is refused with `error` `bot.forbidden`++only (`robot_state.state.device_estop`); it never clears Bot's latch — clearing the panel latch is the++owner's or a Mod's (`estop_clear` on `/control`; `POST /robots/:id/estop/clear` stays owner-only), after++which Bot sends `estop` with `latched: false`. The tray's own STOP latch and ARM are not this latch and++are never cleared or armed remotely. A `robot_id` the device is not attached to is refused with `error` `bot.forbidden`and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine@@@ -424,6 +425,13 @@ forward commands without holding a socket. The HTTP gate is the same gate but nowith no role is not put into a `queue` robot's queue (as a socket `join` would), so over HTTP a chat viewerneeds an explicit owner or operator role — a no-role caller is refused `bot.not_an_operator`.++**Roles and sole control.** The owner, a Mod and an Operator may drive and press the panel e-stop; a Mod++may also switch Operators on or off, grant or clear sole control, and clear the panel latch. Only the owner++promotes an Operator to Mod or demotes a Mod, changes speed/mode/Viewer access, clears the tray's STOP or++ARM, or switches a Mod. A switched-off Operator (or Mod) is refused `bot.operator_off`; under sole control++every other subject's command is refused `bot.sole_control` (the owner and `halt` always pass). Each++authority change is in `robot_operator_audit` with the actor and subject, never a token.++### Client → server| type | fields | notes |@@@ -431,16 +439,19 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no| `join` | `robot_id` | subscribe to a robot; refused with `error` `bot.not_an_operator` when the caller has no access || `leave` | — | unsubscribe || `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |--| `estop` | — | latch the e-stop (owner or operator) |--| `estop_clear` | — | clear the latched e-stop (**owner only**) |--| `give_back` | — | owner or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |++| `estop` | — | latch the panel e-stop (owner, Mod or operator) |++| `estop_clear` | — | clear the latched **panel** e-stop (owner or Mod). It never clears the tray's local STOP or ARM |++| `operators` | `id?, action, subject?, on?, role?` | `toggle` an Operator (owner or Mod) or a Mod (owner), `sole` grant/clear one subject (`subject: null` is Everyone), `role` promote an Operator to Mod or demote a Mod (owner). Every answer is `operators_result` |++| `give_back` | — | owner, Mod or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |### Server → client| type | fields | notes ||---|---|---|--| `joined` | `robot, role, profile, allowed_commands, state` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive |--| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, device_estop{latched,at}, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `estop` is Bot's latch, `device_estop` the device's last report (or `null`) |++| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |++| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, device_estop{latched,at}, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `estop` is Bot's panel latch, `device_estop` the device's last report (or `null`); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |++| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |++| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame or a panel `estop`/`estop_clear`; a refusal is never a silent drop || `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` || `error` | `code, detail` | a frame or join the server refused |@@@ -492,12 +503,13 @@ resumes it at once.```json{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }--{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner clears the e-stop" }++{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner or a Mod clears the panel e-stop" }```Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,--`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,++`bot.read_only`, `bot.operator_off`, `bot.sole_control`, `bot.command_not_allowed` (the role's allowlist),++`bot.device_offline`, `bot.cooldown`,`bot.not_your_turn`, `bot.owner_control` (the owner or an operator has taken the turn over), `bot.turn_budget`,`bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused`nnew file mode 100644@@@ -0,0 +1,34 @@++-- phase: expand++-- LOOI Operators card (Maticus's 2026-11-12 spec): a Mod role between the owner and operators, a per-person++-- on/off switch that the command gate reads before it forwards anything, sole control for one subject, and an++-- audit of who changed authority. Panel E-STOP stays the robots.estop_latched latch; the tray's local STOP and++-- ARM are never touched by this table.++ALTER TABLE robot_operators DROP CONSTRAINT IF EXISTS robot_operators_role_check;++ALTER TABLE robot_operators ADD CONSTRAINT robot_operators_role_check CHECK (role IN ('owner', 'operator', 'viewer', 'mod'));++ALTER TABLE robot_operators ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;++++-- Sole control: one controlling subject at a time, or no row/sole_subject for Everyone. The owner always overrides.++CREATE TABLE IF NOT EXISTS robot_operator_control (++ robot_id text PRIMARY KEY REFERENCES robots(id) ON DELETE CASCADE,++ sole_subject text,++ sole_by text,++ sole_at timestamptz,++ updated_at timestamptz NOT NULL++);++++-- Authority changes: who granted sole control, switched someone on or off, promoted or demoted a Mod, or++-- tripped/cleared the panel E-STOP. Subjects and roles only: no command values, tokens or secrets.++CREATE TABLE IF NOT EXISTS robot_operator_audit (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ robot_id text NOT NULL,++ actor_subject text,++ actor_kind text,++ actor_role text,++ action text NOT NULL,++ subject text,++ role text,++ value jsonb NOT NULL DEFAULT '{}'::jsonb,++ at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS robot_operator_audit_robot ON robot_operator_audit (robot_id, id DESC);++CREATE INDEX IF NOT EXISTS robot_operator_audit_at ON robot_operator_audit (at);@@@ -209,6 +209,69 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf/* Readouts and the latency meter */.readout { font-variant-numeric: tabular-nums; font-size: 1.35rem; font-weight: 600; }.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }++.battery-head { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }++.battery-note { color: var(--muted); font-size: 0.85rem; }++.battery-provisional { color: var(--warn); font-size: 0.78rem; }++/* The LOOI status row: Battery | Latency in one compact row (Maticus's Operators spec). */++.status-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; order: -2; }++#panel[data-profile-id="looi"] .status-row { order: -2; }++.status-row .widget { padding: 0.8rem; }++.status-row .battery { flex-direction: column; align-items: stretch; gap: 0.45rem; }++.status-row .battery .readout { font-size: 1.55rem; }++.status-row .battery-head { justify-content: space-between; }++.status-row meter { width: 100%; height: 0.55rem; }++.status-row .stat { padding: 0; border: 0; background: transparent; }++.status-row .readout { font-size: 1.55rem; }++/* The Operators card (Maticus's 2026-11-12 LOOI spec). Full width under the drive/head controls, same tokens. */++.widget-operators { grid-column: 1 / -1; order: 1; }++#panel[data-profile-id="looi"] .widget-operators { order: 1; }++.ops-head { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem; }++.ops-lede { flex: 1 1 16rem; margin: 0; color: var(--muted); font-size: 0.85rem; }++.ops-mode { align-self: center; }++.ops-mode-btn { min-height: 32px; padding: 0 0.7rem; border-radius: 999px; font-size: 0.8rem; }++.ops-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }++.ops-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "avatar who actions" ". sub sub"; align-items: center; gap: 0.25rem 0.7rem;++ padding: 0.7rem 0.8rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }++.ops-row.ops-owner { background: color-mix(in srgb, var(--accent) 7%, var(--bg)); }++.ops-row.ops-mod { background: color-mix(in srgb, var(--warn) 6%, var(--bg)); }++.ops-row.is-live { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); background: color-mix(in srgb, var(--ok) 9%, var(--bg)); }++.ops-avatar { grid-area: avatar; align-self: start; width: 2.5rem; height: 2.5rem; border-radius: 50%; display: grid; place-items: center; flex: none;++ border: 1px solid var(--line); background: var(--raised); color: var(--text); font-weight: 700; }++.ops-count { color: var(--muted); }++.ops-who { grid-area: who; min-width: 0; }++.ops-name { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem 0.55rem; margin: 0; }++.ops-name b { font-size: 0.98rem; }++.ops-role { font-size: 0.66rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0.05rem 0.4rem; }++.ops-you { color: var(--muted); font-size: 0.8rem; }++.ops-sub { grid-area: sub; margin: 0.1rem 0 0; color: var(--muted); font-size: 0.8rem; }++.ops-last, .ops-head-state { display: inline; margin: 0 0.65rem 0 0; color: var(--muted); font-size: 0.78rem; }++.ops-live { display: inline-flex; align-items: center; gap: 0.3rem; color: var(--ok); font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }++.ops-live .dot { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); }++.ops-actions { grid-area: actions; display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0.4rem; align-self: start; }++.ops-sole, .ops-role-btn { min-height: 34px; padding: 0 0.65rem; font-size: 0.78rem; border-radius: 999px; }++.ops-sole.is-on { border-color: var(--ok); color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); }++.ops-role-btn { color: var(--muted); min-height: 26px; padding: 0 0.45rem; font-size: 0.7rem; margin-left: 0.35rem; vertical-align: 1px; }++.ops-switch { position: relative; display: inline-flex; align-items: center; width: 2.8rem; min-height: 44px; flex: none; cursor: pointer; }++.ops-switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }++.ops-switch-track { position: relative; width: 2.6rem; height: 1.45rem; border-radius: 999px; background: var(--line); transition: background 0.12s; }++.ops-switch-track::after { content: ""; position: absolute; top: 0.18rem; left: 0.18rem; width: 1.09rem; height: 1.09rem; border-radius: 50%; background: #fff; transition: transform 0.12s; }++.ops-switch input:checked + .ops-switch-track { background: var(--ok); }++.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }++.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }++.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }++.ops-estop { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.75rem; padding: 0.7rem 0.8rem; border-radius: 10px;++ border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line)); background: color-mix(in srgb, var(--danger) 8%, var(--bg)); }++.ops-estop p { flex: 1 1 14rem; margin: 0; font-size: 0.8rem; }++.ops-estop[data-latched="true"] { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, var(--bg)); }++.ops-estop-btn { min-height: 40px; padding: 0 0.8rem; border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); color: var(--danger-text, var(--danger)); font-weight: 600; }++.ops-estop[data-latched="true"] .ops-estop-btn { background: var(--fill-danger); border-color: var(--fill-danger); color: #fff; }++.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }++@media (max-width: 30rem) {++ .ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }++ .ops-actions { justify-content: flex-end; }++ .ops-role-btn { margin-left: 0.35rem; }++ .status-row .readout { font-size: 1.35rem; }++}.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }.stat { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.55rem 0.6rem; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); min-width: 0; }.stat .readout { font-size: 1.15rem; }@@@ -853,3 +916,13 @@ html[data-ov-motion="off"] #panel:not(.bot-surface) .headlight[data-state="pendi#panel:not(.bot-surface) .headlight-unconfirmed { font-size: 0.78rem; color: var(--muted); padding-left: 0.2rem; }#panel:not(.bot-surface) .headlight-unconfirmed[hidden] { display: none; }#panel:not(.bot-surface) .headlight-note { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--warn); }++++/* The LOOI status row is one card with two readings: keep its own stack over the single-readout rules above. */++#panel:not(.bot-surface) .status-row .widget-battery { display: block; padding: 0.8rem; }++#panel:not(.bot-surface) .status-row .widget-battery > h2 { margin: 0 0 0.35rem; }++#panel:not(.bot-surface) .status-row .battery { display: flex; flex-direction: column; align-items: stretch; gap: 0.45rem; }++#panel:not(.bot-surface) .status-row .battery .readout { font-size: 1.6rem; text-align: left; }++#panel:not(.bot-surface) .status-row .widget-latency { padding: 0.8rem; }++#panel:not(.bot-surface) .status-row .stat { background: transparent; }++#panel:not(.bot-surface)[data-profile-id="looi"] .widget-drive { order: 0; }++#panel:not(.bot-surface)[data-profile-id="looi"] .widget-head { order: -1; }@@@ -274,7 +274,7 @@let allowed = JSON.parse(main.dataset.allowed || '[]');let ws = null;let joined = false;-- let myRole = null; // the role the joined frame gave this panel (owner, operator, viewer, queue)++ let myRole = main.dataset.role && main.dataset.role !== 'watcher' ? main.dataset.role : null; // the role the joined frame gave this panel (owner, mod, operator, viewer, queue)let retry = 0;let gone = false; // refused for good (no access): no reconnect// The embeddable panel's two compact views (?mode=overlay|compact): only the live controls, over a picture or@@@ -1143,6 +1143,136 @@$('[data-headlight-off]', headlightEl).addEventListener('click', () => sendHeadlight(false));paintHeadlight();}++ // ── Operators (Maticus's 2026-11-12 LOOI spec) ──────────────────────────────────────────────────++ // The server draws the roster. This keeps it current from `operators_state` (Bot's own stamps for activity),++ // sends a toggle / sole / role / panel E-STOP, and shows the refusal in the same notice line as a command's.++ const opsCard = $('[data-operators]');++ const opsNames = new Map();++ if (opsCard) for (const row of $$('[data-operator-subject]', opsCard)) opsNames.set(row.dataset.operatorSubject, row.dataset.operatorName || row.dataset.operatorSubject);++ const ops = { sole: null, members: [], activity: {}, viewers: 0, now: null };++ function opsName(subject) {++ if (!subject) return 'that person';++ return opsNames.get(subject) || String(subject).slice(0, 14);++ }++ const OPS_CHANNEL = { drive: 'Drive', head: 'Head', lights: 'Lights', button: 'Button', say: 'Say', display: 'Display', ptz: 'Camera' };++ const OPS_ROLE = { owner: 'OWNER · YOU', mod: 'MOD', operator: 'OPERATOR', viewer: 'VIEWER' };++ function opsAgo(at) {++ const then = Date.parse(at);++ const now = Date.parse(ops.now) || Date.now();++ if (!Number.isFinite(then)) return 'just now';++ const ms = Math.max(0, now - then);++ if (ms < 1000) return 'just now';++ if (ms < 60000) return `${Math.round(ms / 1000)}s ago`;++ if (ms < 3600000) return `${Math.round(ms / 60000)}m ago`;++ return `${Math.round(ms / 3600000)}h ago`;++ }++ const opsFresh = (rec, channel) => {++ if (!rec || !rec[`${channel}_at`]) return false;++ const then = Date.parse(rec[`${channel}_at`]);++ const now = Date.parse(ops.now) || Date.now();++ return Number.isFinite(then) && now - then < 1500;++ };++ function opsSend(action, fields) {++ if (!joined) { notice('Join the robot first.'); return; }++ raw({ type: 'operators', action, ...fields });++ }++ function paintOperatorsEstop(state) {++ if (!opsCard) return;++ const box = $('[data-operators-estop-box]', opsCard);++ const latched = !!(state && state.estop && state.estop.latched);++ if (box) box.dataset.latched = latched ? 'true' : 'false';++ const note = $('[data-operators-estop-note]', opsCard);++ if (note) note.innerHTML = latched++ ? '<b>Panel E-STOP is tripped.</b> A Mod may clear it. That never clears the tray STOP latch or ARM; the robot cannot move until the owner clears STOP and re-arms on the tray.'++ : '<b>Panel E-STOP is clear.</b> A Mod may trip it, or clear it after a panel stop. That never clears the tray STOP latch or ARM.';++ const btn = $('[data-operators-estop]', opsCard);++ if (btn) {++ btn.textContent = latched ? 'Clear panel E-STOP' : 'Trip panel E-STOP';++ btn.hidden = !(myRole === 'owner' || myRole === 'mod');++ }++ }++ function paintOperators(state, keepNow = false) {++ if (!opsCard || !state) return;++ ops.sole = state.sole || null;++ ops.members = state.members || [];++ ops.activity = state.activity || {};++ ops.viewers = state.viewers || 0;++ if (!keepNow) ops.now = state.now || new Date().toISOString();++ const mode = $('[data-operators-mode]', opsCard);++ if (mode) mode.textContent = ops.sole ? `Sole: ${opsName(ops.sole.subject)}` : 'Everyone';++ const clear = $('[data-operators-clear]', opsCard);++ if (clear) clear.hidden = !ops.sole;++ for (const p of $$('.ops-viewers .pill', opsCard)) if (/watching/.test(p.textContent)) p.textContent = `${ops.viewers} watching`;++ for (const m of ops.members) {++ const row = $(`[data-operator-subject="${CSS.escape(m.subject)}"]`, opsCard);++ if (!row) continue;++ row.dataset.operatorRole = m.role;++ row.dataset.operatorEnabled = String(m.enabled !== false);++ row.dataset.operatorName = opsNames.get(m.subject) || row.dataset.operatorName || m.subject;++ row.classList.remove('ops-owner', 'ops-mod', 'ops-operator', 'ops-viewer');++ row.classList.add(`ops-${m.role}`);++ const tag = $('[data-operator-role-tag]', row);++ if (tag) tag.textContent = m.role === 'owner' ? (row.dataset.operatorYou === 'true' ? 'OWNER · YOU' : 'OWNER') : (OPS_ROLE[m.role] || m.role);++ const sub = $('[data-operator-sub]', row);++ if (sub) sub.textContent = m.role === 'owner'++ ? 'Always has override, local STOP, and ARM'++ : m.role === 'mod'++ ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'++ : 'Can drive and tilt the head while switched on.';++ const rec = ops.activity[m.subject];++ const active = m.enabled !== false && (opsFresh(rec, 'drive') || opsFresh(rec, 'head'));++ row.classList.toggle('is-live', active);++ const live = $('[data-operator-activity]', row);++ if (live) live.hidden = !active;++ const last = $('[data-operator-last]', row);++ if (last) last.textContent = rec && rec.last ? `${OPS_CHANNEL[rec.last.channel] || 'Control'} · ${opsAgo(rec.last.at)}${rec.last.channel === 'lights' ? ' · non-motion' : ''}` : 'No command yet';++ const head = $('[data-operator-head]', row);++ if (head) head.textContent = opsFresh(rec, 'head') ? 'Head active' : 'Head idle';++ const toggle = $('[data-operator-toggle]', row);++ if (toggle) {++ toggle.checked = m.enabled !== false;++ const can = m.role !== 'owner' && (myRole === 'owner' || (myRole === 'mod' && m.role === 'operator'));++ toggle.disabled = !can;++ }++ const soleBtn = $('[data-operator-sole]', row);++ if (soleBtn) {++ const active = !!(ops.sole && ops.sole.subject === m.subject);++ soleBtn.dataset.soleActive = active ? 'true' : 'false';++ soleBtn.textContent = active ? 'Sole · on' : 'Sole control';++ soleBtn.hidden = !(myRole === 'owner' || myRole === 'mod');++ soleBtn.disabled = m.enabled === false;++ }++ const roleBtn = $('[data-operator-role]', row);++ if (roleBtn) {++ roleBtn.hidden = !(myRole === 'owner' && (m.role === 'operator' || m.role === 'mod'));++ roleBtn.dataset.operatorRoleSet = m.role === 'operator' ? 'mod' : 'operator';++ roleBtn.textContent = m.role === 'operator' ? 'Make Mod' : 'Make Operator';++ }++ }++ }++ if (opsCard) {++ // Age "1s ago" and the Live badge even when no new command arrives (both are read from Bot's stamp).++ setInterval(() => { if (joined) { ops.now = new Date().toISOString(); paintOperators(ops, true); } }, 1000);++ opsCard.addEventListener('click', (e) => {++ const btn = e.target.closest && e.target.closest('button');++ if (!btn) return;++ if (btn.matches('[data-operators-clear]')) { opsSend('sole', { subject: null }); return; }++ const soleBtn = btn.closest('[data-operator-sole]');++ if (soleBtn) { opsSend('sole', { subject: soleBtn.dataset.soleActive === 'true' ? null : soleBtn.dataset.operatorSole }); return; }++ const roleBtn = btn.closest('[data-operator-role]');++ if (roleBtn) { opsSend('role', { subject: roleBtn.dataset.subject, role: roleBtn.dataset.operatorRoleSet }); return; }++ if (btn.closest('[data-operators-estop]')) {++ const box = $('[data-operators-estop-box]', opsCard);++ if (box && box.dataset.latched === 'true') raw({ type: 'estop_clear' });++ else estop();++ }++ });++ opsCard.addEventListener('change', (e) => {++ const toggle = e.target.closest && e.target.closest('[data-operator-toggle]');++ if (toggle) opsSend('toggle', { subject: toggle.dataset.subject, on: toggle.checked });++ });++ }++$$('form[data-say]').forEach((f) => f.addEventListener('submit', (e) => {e.preventDefault();const text = f.elements.text.value.trim();@@@ -1294,6 +1424,20 @@if (text) text.textContent = label;pill.dataset.state = dataState;}++ // Battery | Latency. A missing reading is a dash, an empty bar and the word unknown, never 0 %; a voltage is++ // never invented. The tray's unverified FED8 read is a quiet provisional note, not a second number.++ function paintBattery(state) {++ const fraction = state.battery;++ const sensors = state.telemetry && state.telemetry.sensors;++ const provisional = !!(sensors && sensors.p7_battery && sensors.p7_battery.unverified === true);++ for (const el of $$('[data-battery]')) {++ if (fraction == null) { el.removeAttribute('value'); el.dataset.state = 'unknown'; }++ else { el.value = String(Math.max(0, Math.min(1, fraction))); el.dataset.state = 'known'; }++ }++ for (const el of $$('[data-battery-text]')) el.textContent = fraction != null ? `${Math.round(fraction * 100)} %` : '—';++ for (const el of $$('[data-battery-note]')) { el.textContent = fraction != null ? '' : 'unknown'; el.hidden = fraction != null; }++ for (const el of $$('[data-battery-provisional]')) el.hidden = !provisional;++ }function paint(state) {if (!state) return;const latched = !!(state.estop && state.estop.latched);@@@ -1307,8 +1451,8 @@const note = $('.estop-note');if (note) {const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';-- note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner clears it here.`-- : latched ? ' — the robot stays still until the owner clears it.' : '';++ note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner or a Mod clears it here.`++ : latched ? ' — the robot stays still until the owner or a Mod clears it.' : '';}const clear = $('[data-estop-clear]');if (clear) {@@@ -1325,8 +1469,12 @@else if (!state.online) lastTelemetryAt = 0;paintAge();for (const el of $$('[data-link]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';-- 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)} %` : '—';++ if (!samples.length) for (const el of $$('[data-latency]')) {++ el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';++ el.dataset.quality = quality(state.latency_ms, 120, 300);++ }++ paintBattery(state);++ paintOperatorsEstop(state);const sensors = state.telemetry && state.telemetry.sensors;headlightTelemetry(sensors);for (const el of $$('[data-telemetry]')) {@@@ -1392,19 +1540,23 @@joined = true; retry = 0;allowed = m.allowed_commands || [];if (m.ticket) ticket = m.ticket;-- if (driving) {-- const label = $('[data-role-label]');-- myRole = m.role || null;-- if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;-- for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');-- }++ const label = $('[data-role-label]');++ myRole = m.role || myRole;++ if (label && m.role) label.textContent = { owner: 'Owner', mod: 'Mod', operator: 'Operator', viewer: 'Viewer', watcher: 'Watching', queue: 'In the queue' }[m.role] || m.role;++ for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'mod' || m.role === 'operator');notice('');enableControls();paint(m.state);++ if (m.operators) paintOperators(m.operators);paintTurn(m.state && m.state.queue);} else if (m.type === 'robot_state') {paint(m.state);paintTurn(m.state && m.state.queue);++ } else if (m.type === 'operators_state') {++ paintOperators(m.operators);++ } else if (m.type === 'operators_result') {++ if (!m.ok) notice(m.reason || m.code || 'That change was refused.');++ else notice('');} else if (m.type === 'command_result') {roundTrip(m.id, m.result);if (m.id && headlightIds.delete(m.id)) { headlightAck(m); return; }@@@ -204,7 +204,7 @@stopBtn.textContent = localStop ? 'Resume' : 'Stop';stopBtn.classList.toggle('resume', localStop);const note = $('[data-safety]');-- note.textContent = localStop ? 'Stopped on this phone: press Resume, then the owner clears the e-stop.' : remoteLatched ? 'The robot\'s e-stop is latched: the owner clears it on the panel.' : '';++ note.textContent = localStop ? 'Stopped on this phone: press Resume, then the owner or a Mod clears the panel e-stop.' : remoteLatched ? 'The robot\'s e-stop is latched: the owner clears it on the panel.' : '';note.hidden = !note.textContent;}@@@ -28,6 +28,12 @@ const TABLES = [{ table: 'robots', subject: 'estop_by', file: null, erase: anonymize },{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },++ // Sole control names the person it admits and who granted it; the audit names the actor and the subject. Both are++ // authority records, not secrets: on deletion the person's id leaves them, the rest of the record stays.++ { table: 'robot_operator_control', subject: 'sole_subject', file: null, erase: anonymize },++ { table: 'robot_operator_control', subject: 'sole_by', file: null, erase: anonymize },++ { table: 'robot_operator_audit', subject: 'subject', file: null, erase: anonymize },++ { table: 'robot_operator_audit', subject: 'actor_subject', file: null, erase: anonymize },{ table: 'live_conversions', subject: 'owner_subject', file: null },// A device's request to be added (server/claims.js): who approved or denied it. Short-lived; erased with the account.{ table: 'device_claims', subject: 'owner_subject', file: null },@@@ -76,6 +82,7 @@ function create({ domain, log = console } = {}) {await t.exec(`UPDATE devices SET name = NULL WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))AND jsonb_array_length(robot_ids) > 0`, [ids]);counts.add(counts.erased, 'command_audit', await t.exec('DELETE FROM command_audit WHERE robot_id = ANY($1::text[])', [ids]));++ counts.add(counts.erased, 'robot_operator_audit', await t.exec('DELETE FROM robot_operator_audit WHERE robot_id = ANY($1::text[])', [ids]));// Each robot the way its owner would remove it (OpenRestream key revoked first; a refusal there rolls this back and// Events retries).for (const id of ids) await domain.robots.remove(id);@@@ -25,12 +25,14 @@ const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_ALLOW = {owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],++ mod: ['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', 'headlight', 'halt']);const MOTION = new Set(['drive', 'actuator', 'ptz']);--const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);++const CONTROL_ROLES = new Set(['owner', 'mod', 'operator', 'queue']);++const OPERATOR_ROLES = new Set(['owner', 'mod', 'operator', 'viewer']);// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.const TURN_AXES = new Set(['steer', 'rotation']);// The older drive names, still read for the axis they stand for.@@@ -122,7 +124,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =return db.many('SELECT subject, role, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);}async function addOperator(robotId, subject, role, addedBy) {-- if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');++ if (!OPERATOR_ROLES.has(role)) fail(422, 'bot.invalid_role', 'role must be owner, mod, operator or viewer');if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');const before = await roleOf(robotId, subject);await db.query(@@@ -134,7 +136,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function removeOperator(robotId, subject) {const before = await roleOf(robotId, subject);await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);-- if (before && before !== 'owner') membershipChanged(robotId, subject, 'you were removed from this robot');++ if (before && before !== 'owner') {++ const policy = await operatorPolicy(robotId);++ if (policy.sole_subject === subject) await setSole(robotId, null, subject);++ membershipChanged(robotId, subject, 'you were removed from this robot');++ }}/*** A membership change: the person's live /control sockets on the robot are dropped (so their join@@@ -146,6 +152,118 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (link && link.broadcast) link.broadcast(robotId);}++ // ── Operators, Mods and sole control (Maticus's 2026-11-12 LOOI spec) ─────────────────────────++ // The owner always has all of this. A Mod may switch Operators (never Mods or the owner), grant or clear++ // sole control, and trip or clear the panel E-STOP. Only the owner promotes a Mod. The gate reads `enabled`++ // and `sole_subject` fresh before every command, so a change is immediate with no tray restart.++ const GRANT_ROLES = new Set(['owner', 'mod', 'operator']);++++ async function operatorStateOf(robotId, subject, q = db) {++ if (!subject) return null;++ const row = await q.maybe(++ `SELECT m.role, m.enabled, c.sole_subject FROM robot_operators m++ LEFT JOIN robot_operator_control c ON c.robot_id = m.robot_id++ WHERE m.robot_id = $1 AND m.subject = $2`, [robotId, subject]);++ return row ? { role: row.role, enabled: row.enabled !== false, sole: row.sole_subject || null } : null;++ }++ async function operatorPolicy(robotId, q = db) {++ const row = await q.maybe('SELECT sole_subject, sole_by, sole_at FROM robot_operator_control WHERE robot_id = $1', [robotId]);++ return row++ ? { sole_subject: row.sole_subject || null, sole_by: row.sole_by || null, sole_at: row.sole_at ? iso(new Date(row.sole_at).getTime()) : null }++ : { sole_subject: null, sole_by: null, sole_at: null };++ }++ async function listRoster(robotId) {++ const rows = await db.many('SELECT subject, role, enabled, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);++ return rows.map((r) => ({ subject: r.subject, role: r.role, enabled: r.role === 'owner' ? true : r.enabled !== false, added_by: r.added_by || null, created_at: iso(new Date(r.created_at).getTime()) }));++ }++ async function auditOperator(entry) {++ const at = iso(entry.at != null ? entry.at : now());++ return db.maybe(++ `INSERT INTO robot_operator_audit (robot_id, actor_subject, actor_kind, actor_role, action, subject, role, value, at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) RETURNING id, at`,++ [entry.robotId, entry.actorSubject || null, entry.actorKind || null, entry.actorRole || null,++ entry.action, entry.subject || null, entry.role || null, JSON.stringify(entry.value || {}), at]);++ }++ async function listOperatorAudit(robotId, { limit = 50 } = {}) {++ const rows = await db.many('SELECT * FROM robot_operator_audit WHERE robot_id = $1 ORDER BY id DESC LIMIT $2', [robotId, Math.min(200, Math.max(1, limit))]);++ return rows.map((r) => ({++ id: Number(r.id), robot_id: r.robot_id, actor_subject: r.actor_subject, actor_kind: r.actor_kind,++ actor_role: r.actor_role, action: r.action, subject: r.subject, role: r.role,++ value: json(r.value, {}), at: iso(new Date(r.at).getTime()),++ }));++ }++ /** Set or clear the one subject sole control admits; null is Everyone. */++ async function setSole(robotId, subject, actor) {++ const at = iso(now());++ if (subject == null) {++ await db.query('DELETE FROM robot_operator_control WHERE robot_id = $1', [robotId]);++ return;++ }++ await db.query(++ `INSERT INTO robot_operator_control (robot_id, sole_subject, sole_by, sole_at, updated_at)++ VALUES ($1, $2, $3, $4, $4)++ ON CONFLICT (robot_id) DO UPDATE SET sole_subject = EXCLUDED.sole_subject,++ sole_by = EXCLUDED.sole_by, sole_at = EXCLUDED.sole_at, updated_at = EXCLUDED.updated_at`,++ [robotId, subject, actor, at]);++ }++ async function setMemberEnabled(robotId, subject, enabled, actor, actorRole, targetRole) {++ await db.query('UPDATE robot_operators SET enabled = $3 WHERE robot_id = $1 AND subject = $2', [robotId, subject, !!enabled]);++ if (!enabled) {++ const policy = await operatorPolicy(robotId);++ if (policy.sole_subject === subject) await setSole(robotId, null, actor);++ }++ await auditOperator({ robotId, actorSubject: actor, actorKind: 'user', actorRole, action: 'operator.toggle', subject, role: targetRole, value: { enabled: !!enabled } });++ }++ async function setMemberRole(robotId, subject, next, actor, role) {++ await db.query('UPDATE robot_operators SET role = $3 WHERE robot_id = $1 AND subject = $2', [robotId, subject, next]);++ await auditOperator({ robotId, actorSubject: actor, actorKind: 'user', actorRole: role, action: 'operator.role', subject, role: next, value: { role: next } });++ membershipChanged(robotId, subject, `your role on this robot is now ${next}`);++ }++ /**++ * One authority change from a panel (/control `operators`). The actor's role is read fresh: a stale socket++ * cannot act on a role it no longer has. Refusals carry a stable code and a plain reason the panel shows.++ */++ async function actOperator({ robotId, actorSubject, action, subject = null, enabled = null, role = null }) {++ const robot = await getRobot(robotId);++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');++ const actorRole = await roleOf(robotId, actorSubject);++ if (!actorRole) fail(403, 'bot.not_an_operator', 'you have no access to this robot');++ if (!subject && !['sole', 'policy', 'viewer_access'].includes(action)) fail(422, 'bot.invalid_input', 'name the person this change is for');++ const target = subject ? await roleOf(robotId, subject) : null;++ if (subject && !target) fail(404, 'bot.not_an_operator', 'that person is not on this robot');++ if (action === 'toggle') {++ if (actorRole !== 'owner' && actorRole !== 'mod') fail(403, 'bot.mod_limited', 'only the owner or a Mod may switch people on or off');++ if (target === 'owner') fail(403, 'bot.owner_always_on', 'the owner is always on and cannot be switched off');++ if (target !== 'operator' && target !== 'mod') fail(422, 'bot.invalid_input', 'switches are for Operators and Mods');++ if (actorRole === 'mod' && target !== 'operator') fail(403, 'bot.mod_limited', 'Mods switch Operators on or off; only the owner switches Mods');++ if (typeof enabled !== 'boolean') fail(422, 'bot.invalid_input', 'a switch needs on: true or false');++ await setMemberEnabled(robotId, subject, enabled, actorSubject, actorRole, target);++ return { action, subject, enabled, role: target };++ }++ if (action === 'sole') {++ if (actorRole !== 'owner' && actorRole !== 'mod') fail(403, 'bot.mod_limited', 'only the owner or a Mod may change sole control');++ if (subject != null) {++ if (target === 'owner') fail(422, 'bot.invalid_input', 'the owner always overrides; sole control is for a Mod or an Operator');++ if (!GRANT_ROLES.has(target)) fail(422, 'bot.invalid_input', 'sole control is for a Mod or an Operator');++ const state = await operatorStateOf(robotId, subject);++ if (!state || !state.enabled) fail(409, 'bot.operator_off', 'switch that person on before giving them sole control');++ }++ await setSole(robotId, subject == null ? null : subject, actorSubject);++ await auditOperator({ robotId, actorSubject, actorKind: 'user', actorRole, action: 'operator.sole', subject: subject || null, role: target || null, value: subject ? { sole: subject } : { sole: null } });++ return { action, subject: subject || null };++ }++ if (action === 'role') {++ if (actorRole !== 'owner') fail(403, 'bot.owner_only', 'only the owner can promote or demote a Mod');++ if (target === 'operator' && role === 'mod') { await setMemberRole(robotId, subject, 'mod', actorSubject, actorRole); return { action, subject, role: 'mod' }; }++ if (target === 'mod' && role === 'operator') { await setMemberRole(robotId, subject, 'operator', actorSubject, actorRole); return { action, subject, role: 'operator' }; }++ fail(422, 'bot.invalid_input', 'an Operator is promoted to Mod, or a Mod demoted to Operator');++ }++ // The panel accepts no speed, mode or Viewer-access change through this route, for anyone. The owner's own++ // forms remain the only way, and a Mod is told exactly that.++ if (action === 'viewer_access' || action === 'policy') fail(403, 'bot.mod_limited', 'Viewer access is the owner\'s switch');++ fail(422, 'bot.invalid_input', `unknown operators action ${String(action).slice(0, 24)}`);++ }++// ── Robots ────────────────────────────────────────────────────────────────────────────────────async function getRobot(id) {return db.maybe('SELECT * FROM robots WHERE id = $1', [id]);@@@ -182,7 +300,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}if (limits.allow && typeof limits.allow === 'object') {out.allow = {};-- for (const role of ['owner', 'operator', 'queue']) {++ for (const role of ['owner', 'mod', 'operator', 'queue']) {if (Array.isArray(limits.allow[role])) out.allow[role] = limits.allow[role].filter((k) => KINDS.has(k));}}@@@ -1076,10 +1194,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}// ── E-stop (ADR-043 decision 6) ───────────────────────────────────────────────────────────────-- // `estop.set` only latches: a clear goes through clearEstop (owner only), so no device report and no other-- // caller can ever lift an owner's latch.++ // `estop.set` only latches: a clear goes through clearEstop (the owner, or a Mod for the panel latch only), so++ // no device report and no other caller can ever lift an owner's latch. The tray's local STOP and ARM are not++ // this latch and are never touched here.async function setEstop(robotId, { latched, by, principalKind = 'device' }) {-- if (!latched) fail(403, 'bot.forbidden', 'only the owner clears the e-stop');++ if (!latched) fail(403, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');return writeEstop(robotId, { latched: true, by, principalKind });}async function writeEstop(robotId, { latched, by, principalKind }) {@@@ -1095,11 +1214,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =});return updated;}-- async function clearEstop(robotId, owner) {++ async function clearEstop(robotId, actor, role = 'owner') {const robot = await getRobot(robotId);if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');-- if (robot.owner_subject !== owner) fail(403, 'bot.forbidden', 'only the owner clears the e-stop');-- return writeEstop(robotId, { latched: false, by: owner, principalKind: 'user' });++ if (robot.owner_subject !== actor && role !== 'mod') fail(403, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');++ return writeEstop(robotId, { latched: false, by: actor, principalKind: 'user' });}// ── Turn queue (ADR-043 decision 8) ───────────────────────────────────────────────────────────@@@ -1348,7 +1467,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =// 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'));++ && (k !== 'headlight' || role === 'owner' || role === 'mod' || role === 'operator'));return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;};@@@ -1473,10 +1592,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };if (!profileTakes(profile, kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `this robot takes no ${kind} commands`, robot };const halt = kind === 'halt';-- if (robot.estop_latched && !halt) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; only the owner can clear it', robot };++ if (robot.estop_latched && !halt) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; the owner or a Mod clears the panel e-stop', robot };const subject = principal && principal.kind !== 'device' ? principal.subject : null;-- let role = await roleOf(robotId, subject);++ const member = await operatorStateOf(robotId, subject);++ let role = member ? member.role : null;if (!role && robot.access_policy === 'queue' && subject) {const turn = await currentTurn(robotId);if (turn && turn.subject === subject) role = 'queue';@@@ -1488,6 +1608,19 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (robot.access_policy === 'queue') role = 'queue';else return { ok: false, code: 'bot.read_only', reason: 'you are a viewer on this robot', robot, role };}++ // A switched-off Operator or Mod is refused before the command reaches the device, with a message the panel++ // shows; the owner is always on. This is read fresh on every command, so a toggle needs no reconnect.++ if (role !== 'owner' && member && member.enabled === false) {++ return { ok: false, code: 'bot.operator_off', reason: 'You are switched off on this robot. The owner or a Mod can switch you back on.', robot, role };++ }++ // Sole control: one authenticated subject's commands are admitted; the owner always overrides and a safety++ // command (halt) always passes. This is a gate rule, not a motion kind, so it never renews a drive lease.++ if (!halt && role !== 'owner' && subject) {++ const policy = await operatorPolicy(robotId);++ if (policy.sole_subject && policy.sole_subject !== subject) {++ return { ok: false, code: 'bot.sole_control', reason: 'Sole control is on: only the person it was given to can send commands right now. The owner or a Mod can switch back to Everyone.', robot, role };++ }++ }const allow = allowedFor(robot, role, profile);if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };if (!online) return { ok: false, code: 'bot.device_offline', reason: 'The robot is offline. Nothing you press waits for it: it moves only while it is connected.', robot, role };@@@ -1514,13 +1647,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const budget = await consumeTurn(robotId, subject);if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : budget.code === 'bot.owner_control' ? PAUSED : 'it is not your turn', robot, role };}-- } else if ((role === 'owner' || role === 'operator') && robot.access_policy === 'queue' && subject) {++ } else if ((role === 'owner' || role === 'mod' || role === 'operator') && robot.access_policy === 'queue' && subject) {// 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 };++ if (kind === 'headlight' && role !== 'owner' && role !== 'mod' && role !== 'operator') {++ return { ok: false, code: 'bot.command_not_allowed', reason: 'headlights are for the owner, Mods 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 }; }@@@ -1558,6 +1691,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,localProfile, saveLocalProfile, useCatalogueProfile },members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },++ operators: { stateOf: operatorStateOf, policy: operatorPolicy, list: listRoster, act: actOperator,++ audit: auditOperator, auditList: listOperatorAudit, setSole, roles: OPERATOR_ROLES },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,recordFirstRun, firstRunOf, firstRunFunnel },@@@ -732,7 +732,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}// A device reports its own latch. It may latch a robot it is attached to; it never clears one — latched:false-- // is recorded as the device's report only, and the owner's latch stands until the owner clears it.++ // is recorded as the device's report only, and Bot's panel latch stands until the owner or a Mod clears it.async function onEstopState(conn, msg) {const attached = deviceRobotIds(conn.device);const latched = !!msg.latched;@@@ -798,6 +798,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datcase 'command': return onOperatorCommand(conn, msg);case 'estop': return onOperatorEstop(conn, true);case 'estop_clear': return onOperatorEstop(conn, false);++ case 'operators': return onOperatorsAction(conn, msg);case 'give_back': return onGiveBack(conn);default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);}@@@ -825,6 +826,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datrobot: domain.present.robot(robot), role, profile,allowed_commands: domain.control.allowedFor(robot, role, profile),state: await robotState(robotId, conn.subject),++ operators: await operatorsState(robotId),...(next ? { ticket: next.ticket } : {}),});}@@@ -883,6 +885,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.device_offline', reason: 'the device is offline' });}lastRobotCommandAt.set(robotId, now());++ // The Operators card's activity is Bot's own stamp: the subject, the channel and when. A lights command is++ // non-motion and is shown as such. This is display truth; the gate above is what admitted the command.++ noteOperatorActivity(robotId, conn.subject, decision.kind);++ queueOperatorsState(robotId);// 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 };@@@ -1109,11 +1115,34 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat}}-- /** `give_back`: an owner or operator who took over a queue turn hands it back now (it resumes with its time). */++ /**++ * One Operators card action (/control `operators`): toggle an operator, grant/clear sole control, promote or++ * demote a Mod. The domain re-reads the actor's role and enforces the limits; the session is only a transport.++ * Every path answers with an `operators_result` (never a silent drop), and an accepted change is broadcast at once.++ */++ async function onOperatorsAction(conn, msg) {++ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');++ const action = String(msg.action || '');++ const reply = (ok, fields = {}) => sendFrame(conn, 'operators_result', { id: msg.id != null ? String(msg.id) : null, action, ok, ...fields });++ try {++ const result = await domain.operators.act({++ robotId: conn.robotId, actorSubject: conn.subject, action,++ subject: msg.subject != null ? String(msg.subject).slice(0, 64) : null,++ enabled: msg.on === true ? true : msg.on === false ? false : null,++ role: msg.role != null ? String(msg.role) : null,++ });++ await queueOperatorsState(conn.robotId, true);++ return reply(true, result);++ } catch (e) {++ return reply(false, { code: e.code || 'bot.forbidden', reason: e.detail || e.message });++ }++ }++++ /** `give_back`: an owner, Mod or operator who took over a queue turn hands it back now (it resumes with its time). */async function onGiveBack(conn) {if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');const role = await domain.members.roleOf(conn.robotId, conn.subject);-- if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or an operator takes over and gives back');++ if (!['owner', 'mod', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner, a Mod or an operator takes over and gives back');await domain.queue.giveBack(conn.robotId);broadcast(conn.robotId);return undefined;@@@ -1122,17 +1151,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datasync function onOperatorEstop(conn, latched) {if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');const robotId = conn.robotId;++ let role = null;try {++ role = await domain.members.roleOf(robotId, conn.subject);if (latched) {-- const role = await domain.members.roleOf(robotId, conn.subject);-- if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only an operator may set the e-stop');++ if (!['owner', 'mod', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only an operator, a Mod or the owner may set the panel e-stop');await domain.estop.set(robotId, { latched: true, by: conn.subject, principalKind: conn.principal.kind });} else {-- await domain.estop.clear(robotId, conn.subject); // owner only; throws otherwise++ if (!['owner', 'mod'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');++ // This clears the panel latch only. A device's local STOP latch (device_estop.local) and ARM stay on++ // the tray; nothing here sends a STOP clear or an ARM to the machine.++ await domain.estop.clear(robotId, conn.subject, role);}++ await domain.operators.audit({ robotId, actorSubject: conn.subject, actorKind: conn.principal.kind,++ actorRole: role, action: latched ? 'estop.trip' : 'estop.clear', value: { latched: !!latched } });} catch (e) { return sendError(conn, e.code || 'bot.forbidden', e.detail || e.message); }await pushEstop(robotId, latched, conn.subject);broadcast(robotId);++ await queueOperatorsState(robotId, true);++ sendFrame(conn, 'operators_result', { action: latched ? 'estop.trip' : 'estop.clear', ok: true, latched: !!latched });++ return undefined;}// ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────@@@ -1233,14 +1271,82 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }-- // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe Link does ({volts, percent}).++ // A device may report battery the legacy way (a 0..1 fraction) or the Node's top-level percent (0..100), or as++ // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort, marked provisional.++ // A missing reading is null, never 0: the panel shows a dash and "unknown" rather than a made-up empty battery.++ const clampPct = (v) => Math.min(100, Math.max(0, Number(v)));function batteryFraction(telemetry) {const b = telemetry ? telemetry.battery : null;-- if (Number.isFinite(b)) return b;-- if (b && typeof b === 'object' && Number.isFinite(b.percent)) return Math.min(1, Math.max(0, b.percent / 100));++ if (Number.isFinite(b)) return b >= 0 && b <= 1 ? b : clampPct(b) / 100;++ if (b && typeof b === 'object' && Number.isFinite(b.percent)) return clampPct(b.percent) / 100;++ const p7 = telemetry && telemetry.sensors && telemetry.sensors.p7_battery;++ if (p7 && typeof p7 === 'object' && Number.isFinite(p7.pct)) return clampPct(p7.pct) / 100;return null;}++ // ── The Operators card (Maticus's 2026-11-12 LOOI spec) ─────────────────────────────────────────++ // Bot stamps each admitted command itself, so the card's activity is Bot's own truth even before the tray++ // publishes sensors.p7_commander. In memory: a restart loses "1s ago", never an authority or an audit row.++ const operatorActivity = new Map(); // robot_id → Map(subject → { last, drive_at, head_at, lights_at })++ const operatorsSentAt = new Map(); // robot_id → when the last operators_state went out (ms)++ const operatorsTimer = new Map(); // robot_id → a pending throttled broadcast++ const channelOf = (kind) => (kind === 'drive' ? 'drive' : kind === 'actuator' ? 'head' : kind === 'headlight' ? 'lights'++ : kind === 'button' ? 'button' : kind === 'say' ? 'say' : kind === 'display' ? 'display' : kind === 'ptz' ? 'ptz' : 'other');++ function noteOperatorActivity(robotId, subject, kind) {++ if (!subject) return;++ let bySubject = operatorActivity.get(robotId);++ if (!bySubject) { bySubject = new Map(); operatorActivity.set(robotId, bySubject); }++ const at = now();++ const channel = channelOf(kind);++ const rec = bySubject.get(subject) || { last: null, drive_at: null, head_at: null, lights_at: null };++ rec.last = { kind, channel, at: iso(at) };++ if (channel === 'drive') rec.drive_at = iso(at);++ if (channel === 'head') rec.head_at = iso(at);++ if (channel === 'lights') rec.lights_at = iso(at);++ bySubject.set(subject, rec);++ if (bySubject.size > 256) bySubject.delete(bySubject.keys().next().value);++ }++ function operatorActivityOf(robotId) {++ const out = {};++ for (const [subject, rec] of operatorActivity.get(robotId) || []) out[subject] = rec;++ return out;++ }++ async function operatorsState(robotId) {++ const [members, policy] = await Promise.all([domain.operators.list(robotId), domain.operators.policy(robotId)]);++ return {++ sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,++ members: members.map((m) => ({ subject: m.subject, role: m.role, enabled: m.enabled !== false })),++ viewers: members.filter((m) => m.role === 'viewer').length,++ activity: operatorActivityOf(robotId),++ now: iso(now()),++ };++ }++ function sendOperatorsState(robotId) {++ operatorsTimer.delete(robotId);++ operatorsSentAt.set(robotId, now());++ const subs = subsByRobot.get(robotId);++ if (!subs || !subs.size) return;++ operatorsState(robotId).then((state) => {++ for (const sub of [...subs]) if (!sub.watcher) sendFrame(sub, 'operators_state', { operators: state });++ }).catch((e) => log.warn(`[Bot] operators for ${robotId}: ${e.message}`));++ }++ /** Activity is broadcast at most twice a second; a real authority change forces one at once. */++ function queueOperatorsState(robotId, force = false) {++ if (force) {++ const pending = operatorsTimer.get(robotId);++ if (pending) { clearTimeout(pending); operatorsTimer.delete(robotId); }++ return sendOperatorsState(robotId);++ }++ const since = now() - (operatorsSentAt.get(robotId) || 0);++ if (since >= 500) return sendOperatorsState(robotId);++ if (!operatorsTimer.has(robotId)) {++ const t = setTimeout(() => sendOperatorsState(robotId), Math.max(0, 500 - since));++ if (t.unref) t.unref();++ operatorsTimer.set(robotId, t);++ }++ return undefined;++ }++async function robotState(robotId, subjectForQueue = null) {// The device's state is read before the first await, so each broadcast carries the frame that caused it// (two telemetry frames with events back to back are both delivered, not the second one twice).@@@ -1286,6 +1392,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};const picked = {};for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];++ // The tray's provisional battery reading rides along so the panel can say "provisional, at connect" to a++ // watcher too. It is a reading, never a command.++ if (sensors.p7_battery !== undefined) picked.p7_battery = sensors.p7_battery;// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;return {@@@ -1356,6 +1465,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,++ operatorActivity: operatorActivityOf,machineCommand, machineScreen, computerOf, machineChanged,// Network's grant relay and agent tasks (Contracts 0.409.0): close every relay under a revoked grant, and// hand an agent.task to the machine it names.@@@ -221,7 +221,7 @@ ${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li><li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-link profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul><p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}--<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;++<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop the owner or a Mod clears (the tray's own STOP and ARM stay local). The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });}@@@ -162,7 +162,7 @@ function moreSection({ config, kit: k, models }) {const base = config.baseUrl;const panel = rows([['/try', 'Real controls', 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.'],-- ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a latched emergency stop that only the owner clears; a halt always gets through.'],++ ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a latched emergency stop that the owner or a Mod clears (the panel latch only); a halt always gets through.'],['/robots', 'Every command on the record', 'Allowed or refused, each command is audited and kept for 30 days.'],['/robots', 'Share control', 'Add people by @username as operators or viewers, or open a queue where visitors take turns.'],['https://openvibe.live/', 'Show it on your channel', 'Bind the robot to your OpenVibe.Live channel: its panel shows under your stream, and anyone allowed takes the controls there.'],@@@ -225,7 +225,7 @@ function faqItems({ models }) {{ q: 'Do I need to open ports on my router?', a: 'No. The robot\'s computer connects out to OpenVibe, so it works behind an ordinary home or mobile connection. The Adeept kit\'s own control server, with its fixed password, is never run.' },{ q: 'Can I try it without a robot?', a: 'Yes. The simulated rover at openvibe.bot/try runs in your browser with no account and no hardware, through the same panel a paired robot gets.' },list ? { q: 'Which robots work today?', a: `${list}. Each has a profile the panel draws its controls from, and anything a local script can switch works through the relay robot.` } : null,-- { q: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. The emergency stop is latched until the owner clears it, a stop pressed on the robot holds until it is released there, and the device stops its motors by itself when commands stop arriving.' },++ { q: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. The emergency stop is latched until the owner or a Mod clears the panel latch, a stop pressed on the robot holds until it is released there, and the device stops its motors by itself when commands stop arriving.' },{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },@@@ -30,14 +30,14 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])/** A data-* attribute's JSON, escaped for a double-quoted attribute. */const data = (v) => esc(JSON.stringify(v));--/** The e-stop is the owner's and an operator's; only the owner clears it (the gate enforces both). */--const ESTOP_ROLES = new Set(['owner', 'operator']);++/** The panel e-stop is the owner's, a Mod's and an operator's; the owner or a Mod clears it (the gate enforces it). */++const ESTOP_ROLES = new Set(['owner', 'mod', 'operator']);/** The drive joystick's two axes, by preference: horizontal and vertical. The rest stay hold buttons. */const STICK_X = ['steer', 'x', 'rotation'];const STICK_Y = ['throttle', 'y'];const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));--const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };++const ROLE_LABEL = { owner: 'Owner', mod: 'Mod', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────const PATHS = {@@@ -63,6 +63,16 @@ const PATHS = {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"/>',};const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;++// The network's one glyph registry (openvibe-shared/glyphs): a Font Awesome name or a glyph name, drawn in the++// panel's currentColor. The old inline PATHS stay for the controls they already draw.++let glyphs = null;++try { glyphs = require('openvibe-shared/glyphs'); } catch { /* an older shared pin: the inline PATHS only */ }++const glyphIcon = (name, cls = 'icon') => {++ if (!glyphs) return icon(name, cls);++ const found = glyphs.path(name) ? name : glyphs.fromFa(name);++ const d = found ? glyphs.path(found) : '';++ return d ? `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>` : icon(name, cls);++};/** Hold-button icons by axis: [negative, positive]. */const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };@@@ -312,14 +322,22 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;break;case 'battery':-- inner = '<div class="battery"><meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output></div>';++ // The LOOI status row: a big percentage, a fill bar, a dash and the word "unknown" when there is no++ // reading (never 0 %), and a quiet provisional note when the tray sent an unverified FED8 read.++ inner = '<div class="battery">'++ + '<div class="battery-head"><output class="readout" data-battery-text>—</output><span class="battery-note" data-battery-note>unknown</span></div>'++ + '<meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter>'++ + '<span class="battery-provisional" data-battery-provisional hidden>provisional, at connect</span>'++ + '</div>';break;case 'latency':-- inner = '<div class="stats">'-- + '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Round trip, median</span></div>'-- + '<div class="stat"><output class="readout" data-telemetry-age data-quality="none">—</output><span class="stat-label">Telemetry age</span></div>'-- + '<div class="stat"><output class="readout" data-link>—</output><span class="stat-label">Device link</span></div>'-- + '</div>';++ inner = profile.id === 'looi'++ ? '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Latency, round trip</span></div>'++ : '<div class="stats">'++ + '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Round trip, median</span></div>'++ + '<div class="stat"><output class="readout" data-telemetry-age data-quality="none">—</output><span class="stat-label">Telemetry age</span></div>'++ + '<div class="stat"><output class="readout" data-link>—</output><span class="stat-label">Device link</span></div>'++ + '</div>';break;case 'camera':inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url,@@@ -530,7 +548,7 @@ const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(h* Rendered only for the owner's own page.*/function peopleCard(robot, people) {-- const roleName = (r) => (r === 'viewer' ? 'Viewer' : 'Operator');++ const roleName = (r) => ({ viewer: 'Viewer', mod: 'Mod', operator: 'Operator' }[r] || 'Operator');const row = (p) => {const remove = `<form method="post" action="/robots/${esc(robot.id)}/operators/${esc(encodeURIComponent(p.subject))}/remove"><button type="submit" class="quiet">Remove</button></form>`;const who = p.username@@@ -549,6 +567,94 @@ ${(people || []).map(row).join('\n')}</section>`;}++/**++ * The Operators card (Maticus's 2026-11-12 LOOI spec): the owner, every Mod and Operator with a live activity++ * indicator and last command, the viewers' count, sole control, the per-person switches, promotion/demotion and++ * the panel E-STOP. The server renders the whole roster; public/panel.js keeps it current from `operators_state`.++ * Actions are in the panel's /control socket and are checked again in server/domain/index.js, never here.++ */++function operatorsCard(robot, operators, role, viewer = null) {++ if (!operators) return '';++ const canManage = role === 'owner' || role === 'mod';++ const canRole = role === 'owner';++ const memberName = (m) => {++ if (!m) return 'Everyone';++ if (m.role === 'owner' && (!m.username || !m.display_name)) return 'the owner';++ return m.display_name || (m.username ? `@${m.username}` : m.subject);++ };++ const handle = (m) => (m.username ? `@${m.username}` : (m.display_name || String(m.subject).slice(0, 12)));++ const initial = (m) => String(m.display_name || m.username || m.subject || '?').trim().charAt(0).toUpperCase() || '?';++ const roleTag = { owner: 'OWNER', mod: 'MOD', operator: 'OPERATOR', viewer: 'VIEWER' };++ const sole = operators.sole;++ const soleName = sole ? memberName((operators.members || []).find((m) => m.subject === sole.subject)) : null;++ const isMe = (m) => !!(viewer && m.subject === viewer.subject);++ const toggle = (m) => {++ const canToggle = m.role !== 'owner' && ((role === 'owner' && (m.role === 'operator' || m.role === 'mod'))++ || (role === 'mod' && m.role === 'operator'));++ const checked = m.role === 'owner' ? ' checked' : (m.enabled !== false ? ' checked' : '');++ const disabled = canToggle ? '' : ' disabled';++ const title = m.role === 'owner' ? 'The owner is always on' : canToggle ? (m.enabled !== false ? 'Switch this person off' : 'Switch this person on') : 'Only the owner switches Mods; the owner and Mods switch Operators';++ return `<label class="ops-switch" title="${esc(title)}"><input type="checkbox" data-operator-toggle data-subject="${esc(m.subject)}"${checked}${disabled}><span class="ops-switch-track" aria-hidden="true"></span></label>`;++ };++ const soleButton = (m) => {++ if (!canManage || m.role === 'owner') return '';++ const active = !!(sole && sole.subject === m.subject);++ return `<button type="button" class="ops-sole${active ? ' is-on' : ''}" data-operator-sole="${esc(m.subject)}" data-sole-active="${active ? 'true' : 'false'}"${m.enabled === false ? ' disabled' : ''}>${active ? 'Sole · on' : 'Sole control'}</button>`;++ };++ const roleButton = (m) => {++ if (!canRole || (m.role !== 'operator' && m.role !== 'mod')) return '';++ const next = m.role === 'operator' ? 'mod' : 'operator';++ const label = m.role === 'operator' ? 'Make Mod' : 'Make Operator';++ const title = m.role === 'operator' ? 'Promote this Operator to Mod' : 'Demote this Mod to Operator';++ return `<button type="button" class="ops-role-btn" data-operator-role-set="${next}" data-operator-role data-subject="${esc(m.subject)}" title="${esc(title)}">${label}</button>`;++ };++ const activity = (m) => {++ if (m.role === 'viewer' || m.role === 'owner') return '';++ return `<span class="ops-live" data-operator-activity hidden><span class="dot"></span>Live</span>`++ + `<small class="ops-last" data-operator-last>No command yet</small>`++ + `<small class="ops-head-state" data-operator-head>Head idle</small>`;++ };++ const row = (m) => {++ const you = isMe(m) && m.role !== 'owner';++ const tag = m.role === 'owner' ? (isMe(m) ? 'OWNER · YOU' : 'OWNER') : (roleTag[m.role] || m.role);++ const sub = m.role === 'owner'++ ? 'Always has override, local STOP, and ARM'++ : m.role === 'mod'++ ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'++ : 'Can drive and tilt the head while switched on.';++ return `<li class="ops-row ops-${esc(m.role)}" data-operator-subject="${esc(m.subject)}" data-operator-role="${esc(m.role)}" data-operator-enabled="${m.enabled !== false ? 'true' : 'false'}" data-operator-you="${isMe(m) ? 'true' : 'false'}" data-operator-name="${esc(handle(m))}">`++ + `<span class="ops-avatar" aria-hidden="true">${esc(initial(m))}</span>`++ + `<div class="ops-who"><p class="ops-name"><b>${esc(handle(m))}</b>${you ? ' <span class="ops-you">(you)</span>' : ''}`++ + `<span class="ops-role" data-operator-role-tag>${esc(tag)}</span>${activity(m)}</p></div>`++ + `<div class="ops-actions">${soleButton(m)}${toggle(m)}</div>`++ + `<p class="ops-sub"><span data-operator-sub>${esc(sub)}</span>${roleButton(m)}</p></li>`;++ };++ const viewers = (operators.members || []).filter((m) => m.role === 'viewer');++ const viewerRow = `<li class="ops-row ops-viewers"><span class="ops-avatar ops-count" aria-hidden="true">${esc(String(viewers.length))}</span>`++ + `<div class="ops-who"><p class="ops-name"><b>Viewers</b> <span class="pill">${esc(String(viewers.length))} watching</span></p>`++ + '<p class="ops-sub">Can watch only. No drive, head, lights, or speed/mode changes.</p></div>'++ + `<span class="pill">${robot.embed_public ? 'Watching' : 'Hidden'}</span></li>`;++ const latched = !!(robot.estop && robot.estop.latched);++ const estopNote = latched++ ? '<b>Panel E-STOP is tripped.</b> A Mod may clear it. That never clears the tray STOP latch or ARM; the robot cannot move until the owner clears STOP and re-arms on the tray.'++ : '<b>Panel E-STOP is clear.</b> A Mod may trip it, or clear it after a panel stop. That never clears the tray STOP latch or ARM.';++ const estopButton = canManage++ ? `<button type="button" class="ops-estop-btn" data-operators-estop>${glyphIcon('stop')}<span>${latched ? 'Clear panel E-STOP' : 'Trip panel E-STOP'}</span></button>` : '';++ const rows = (operators.members || []).filter((m) => m.role !== 'viewer').map(row).join('\n');++ return `<section class="widget widget-operators" data-widget="operators" data-operators data-can-manage="${canManage ? 'true' : 'false'}" data-can-role="${canRole ? 'true' : 'false'}">++<h2>${glyphIcon('users')} <span>Operators</span></h2>++<div class="ops-head"><p class="ops-lede">People on this robot. Owner and Mods can hand sole control or switch someone off.${canRole ? ' Only you can promote a Mod, or change speed, mode, and Viewer access.' : ''}</p>++<span class="pill ops-mode" data-operators-mode>${sole ? `Sole: ${esc(soleName)}` : 'Everyone'}</span>++${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${sole ? '' : ' hidden'}>Everyone</button>` : ''}</div>++<ul class="ops-list">++${rows}++${viewerRow}++</ul>++<div class="ops-estop" data-operators-estop-box data-latched="${latched ? 'true' : 'false'}"><p data-operators-estop-note>${estopNote}</p>${estopButton}</div>++<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The robot still cannot move until you Clear STOP and ARM on the tray. Owner always overrides.</p>++</section>`;++}++/*** The turn strip on a `queue` robot's panel. A signed-in non-member sees the active turn's countdown or their place in* line, with a plain leave form that works without JavaScript. The owner and operators see it only while their own@@@ -715,7 +821,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResreturn `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}${dataScript}</main>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, operators = null, viewer = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {const embed = mode === 'embed';const botView = embed ? botViewOf(view) : 'panel';if (embed && botView !== 'panel') {@@@ -751,17 +857,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMswidgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${attrsOf(['stop']).stop || ''}>`+ `<h2>Stop</h2><button type="button" class="stop" data-stop>${icon('square')}<span>Stop</span></button></section>`);}++ // Maticus's LOOI status row: Battery | Latency, one compact row (public/panel.css). The server groups the two++ // widgets when the person has not arranged the panel; an arranged layout keeps their separate grid cells.++ if (!arrangeApply && profile.id === 'looi') {++ const batteryAt = widgets.findIndex((html) => html.includes('data-widget="battery"'));++ const latencyAt = widgets.findIndex((html) => html.includes('data-widget="latency"'));++ if (batteryAt >= 0 && latencyAt >= 0) {++ const pair = [widgets[batteryAt], widgets[latencyAt]];++ const rest = widgets.filter((_, i) => i !== batteryAt && i !== latencyAt);++ rest.splice(Math.min(batteryAt, rest.length), 0, `<div class="status-row" data-status-row>${pair.join('')}</div>`);++ widgets.splice(0, widgets.length, ...rest);++ }++ }++ // The Operators card sits under the controls, inside the same panel grid. A watcher has no roster and no card.++ const operatorsHtml = profile.id === 'looi' ? operatorsCard(robot, operators, role, viewer) : '';++ if (operatorsHtml) widgets.push(operatorsHtml);// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');-- const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';++ const clear = (role === 'owner' || role === 'mod') ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';const arrangeButton = arrange && arrange.src && role === 'owner'? `<button type="button" class="arrange-btn" data-arrange data-arrange-src="${esc(arrange.src || '')}" title="Arrange the controls">${icon('sliders')}<span>Arrange</span></button>` : '';const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}--<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>++<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner or a Mod clears it.' : ''}</span></p>${clear}${estop}</div></header>--<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''} data-bot-view="${esc(botView)}"${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>++<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-profile-id="${esc(profile.id || '')}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''} data-bot-view="${esc(botView)}"${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}><div class="panel-head">${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>@@@ -86,7 +86,7 @@ const LLMS_PAGES = [{path: '/', title: 'OpenVibe.Bot — drive your robot from any browser',note: 'What Bot is, and how a robot goes from added to driving.',-- text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that only the owner clears; a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',++ text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that the owner or a Mod clears (the panel latch only; the tray\'s STOP and ARM stay local); a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',},{path: '/try', title: 'Try the panel with a simulated rover',@@@ -936,9 +936,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi? null: domain.video.live(robot.id).catch((e) => { log.warn(`[Bot] live video for ${robot.id}: ${e.message}`); return null; }),]);-- // The owner's People card: every member but the owner, with Network's name for each. A Network that-- // does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.-- const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;++ // The People and Operators cards: every member, with Network's name where it answers. A Network that does++ // not answer leaves the subject id and a note (see renderPanel) instead of failing the panel. Anonymous++ // watchers have no roster.++ let roster = null;++ if (role !== 'watcher') {++ try { roster = await rosterFor(robot); } catch (e) { log.warn(`[Bot] roster for ${robot.id}: ${e.message}`); }++ }++ const people = role === 'owner' && mode === 'page' && roster ? roster.members.filter((m) => m.role !== 'owner') : null;// The owner's routines and webhooks (server/routines.js, server/webhooks.js), with each hook's last delivery.let automation = null;if (role === 'owner' && mode === 'page' && routines && webhooks) {@@@ -997,6 +1002,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi}res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,++ operators: roster, viewer: req.viewer ? { subject: req.viewer.subject, username: req.viewer.username || null, name: req.viewer.name || null } : null,profileForm, automation, limitsInfo,// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put// the panel under their Live stream.@@@ -1067,18 +1073,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limireturn res.redirect(303, `/panel/${robot.id}`);}));-- /** The non-owner members with their Network names, or null names when Network cannot be asked. */-- async function peopleFor(robot) {-- const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);++ /** The Operators card's initial roster: every member, Network's names, the sole-control policy and live activity. */++ async function rosterFor(robot) {++ let members = await domain.members.list(robot.id);++ if (!members.some((m) => m.role === 'owner')) {++ members = [{ subject: robot.owner_subject, role: 'owner', enabled: true, added_by: null, created_at: null }, ...members];++ }let names = new Map();if (members.length && identity) {try { names = await identity.names(members.map((m) => m.subject)); }catch (e) { log.warn(`[Bot] names for ${robot.id}: ${e.message}`); }}-- return members.map((m) => {++ const policy = await domain.operators.policy(robot.id);++ const rows = members.map((m) => {const n = names.get(m.subject);-- return { subject: m.subject, role: m.role, username: n ? n.username : null, display_name: n ? n.display_name : null };++ return {++ subject: m.subject, role: m.role, enabled: m.role === 'owner' ? true : m.enabled !== false,++ username: n ? n.username : null, display_name: n ? n.display_name : null,++ };});++ return {++ sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,++ members: rows, viewers: rows.filter((m) => m.role === 'viewer').length,++ activity: hub && hub.operatorActivity ? hub.operatorActivity(robot.id) : {},++ now: new Date().toISOString(),++ };}r.get('/panel/:id', page(async (req, res) => {nnew file mode 100644@@@ -0,0 +1,267 @@++'use strict';++// The LOOI Operators card and battery (Maticus's 2026-11-12 spec): a Mod role, per-person on/off, sole control,++// the panel E-STOP that never touches the tray's local STOP/ARM, authority audit, and the battery bound to the++// Node's top-level `battery` (0..100). No hardware: the fake device and the real 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({ env: { BOT_HEARTBEAT_MS: '60000', BOT_OFFLINE_MISSES: '100' } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const carol = t.network.newUser('carol');++ const dave = t.network.newUser('dave');++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });++ 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);++ const opResult = (client, id) => client.waitFor((m) => m.type === 'operators_result' && m.id === id);++ let seq = 0;++ const nextId = (p) => `${p}-${++seq}`;++ 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}` } });++ await dev.waitFor((m) => m.type === 'hello');++ await dev.waitFor((m) => m.type === 'config');++ return dev;++ }++ async function act(client, action, fields = {}) {++ const id = nextId(`ops-${action}`);++ client.send({ type: 'operators', id, action, ...fields });++ return opResult(client, id);++ }++ async function promotedMod(client, robotId, subject) {++ const r = await act(client, 'role', { subject, role: 'mod' });++ assert.strictEqual(r.ok, true, r.reason);++ return r;++ }++ /** Send a drive, wait for the device's copy, ack it, and return the operator's result. */++ async function drive(client, dev, id, extra = {}) {++ cmd(client, { id, kind: 'drive', value: { throttle: 0.2, steer: 0 }, ...extra });++ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === id);++ if (!c) throw new Error(`no device command for ${id}`);++ dev.send({ type: 'ack', id: c.id });++ return result(client, id);++ }++ const looi = loadProfiles().get('looi');++++ await check('the battery reads the Node top-level percent (0..100), a dash/unknown when missing, and p7 provisional', async () => {++ 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);++ dev.send({ type: 'telemetry', battery: 42, sensors: { ultrasonic: 120 } });++ const st = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 42);++ assert.strictEqual(st.state.battery, 0.42, '42 % becomes the 0..1 fraction the meter draws');++ const html = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(html.includes('data-status-row'), 'Battery | Latency is one compact status row');++ assert.ok(html.includes('data-battery-note>unknown'), 'the initial meter says unknown, never 0 %');++ assert.ok(html.includes('data-battery-provisional'), 'the provisional note is in the markup');++ // A full 100 is 100 %, not 1 %: a number above 1 is a percent, not the legacy fraction.++ dev.send({ type: 'telemetry', battery: 100, events: [{ kind: 'battery_full' }], sensors: { p7_battery: { pct: 100, usb: true, raw: '6401', unverified: true } } });++ const full = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 100);++ assert.strictEqual(full.state.battery, 1);++ assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the raw provisional reading reaches the panel state');++ // Missing is null (a dash and an empty bar), never a made-up 0.++ dev.send({ type: 'telemetry', events: [{ kind: 'battery_clear' }], sensors: { cleared: true } });++ const none = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.cleared === true);++ assert.strictEqual(none.state.battery, null, 'no battery field is null, not 0');++ a.close(); dev.close();++ });++++ await check('sole control admits only that subject; others are refused with a visible message; the owner overrides', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ await invite(robot.id, carol.subject, 'operator');++ const dev = await deviceFor(robot.id, pairing.code);++ const a = await op(alex); const b = await op(bob); const c = await op(carol);++ await join(a, robot.id); await join(b, robot.id); await join(c, robot.id);++ const granted = await act(a, 'sole', { subject: bob.subject });++ assert.strictEqual(granted.ok, true, granted.reason);++ const bobDrive = await drive(b, dev, 'sole-bob');++ assert.strictEqual(bobDrive.result, 'ack');++ cmd(c, { id: 'sole-carol', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ const refused = await result(c, 'sole-carol');++ assert.strictEqual(refused.result, 'refused');++ assert.strictEqual(refused.code, 'bot.sole_control');++ assert.strictEqual(refused.reason, 'Sole control is on: only the person it was given to can send commands right now. The owner or a Mod can switch back to Everyone.');++ // Owner always overrides, including while someone else has sole control.++ const ownerDrive = await drive(a, dev, 'sole-owner');++ assert.strictEqual(ownerDrive.result, 'ack');++ // halt always passes: safety is not blocked by sole control.++ cmd(c, { id: 'sole-halt', kind: 'halt', value: {} });++ const halt = await dev.waitFor((m) => m.type === 'command' && m.ref === 'sole-halt');++ assert.ok(halt, 'a halt reaches the device even under sole control');++ dev.send({ type: 'ack', id: halt.id });++ // Everyone gives shared control back to all enabled operators.++ const cleared = await act(a, 'sole', { subject: null });++ assert.strictEqual(cleared.ok, true);++ const carolDrive = await drive(c, dev, 'sole-carol-2');++ assert.strictEqual(carolDrive.result, 'ack');++ a.close(); b.close(); c.close(); dev.close();++ });++++ await check('an Operator switched off is refused immediately; Mods switch Operators, the owner switches Mods', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ await invite(robot.id, carol.subject, 'operator');++ const dev = await deviceFor(robot.id, pairing.code);++ const a = await op(alex); const b = await op(bob);++ await join(a, robot.id); await join(b, robot.id);++ assert.strictEqual((await act(a, 'role', { subject: bob.subject, role: 'mod' })).ok, true);++ await b.waitForClose();++ const b2 = await op(bob); await join(b2, robot.id);++ assert.strictEqual((await act(b2, 'toggle', { subject: carol.subject, on: false })).ok, true);++ const c = await op(carol); await join(c, robot.id);++ cmd(c, { id: 'off-1', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ const off = await result(c, 'off-1');++ assert.strictEqual(off.code, 'bot.operator_off');++ assert.strictEqual(off.reason, 'You are switched off on this robot. The owner or a Mod can switch you back on.');++ assert.strictEqual((await act(b2, 'toggle', { subject: carol.subject, on: true })).ok, true);++ const on = await drive(c, dev, 'off-2');++ assert.strictEqual(on.result, 'ack');++ // The owner switches the Mod off; the Mod's gate reads it fresh and refuses.++ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: false })).ok, true);++ cmd(b2, { id: 'mod-off', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ assert.strictEqual((await result(b2, 'mod-off')).code, 'bot.operator_off');++ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: true })).ok, true);++ // A Mod cannot switch a Mod (itself), and nobody switches the owner off.++ const self = await act(b2, 'toggle', { subject: bob.subject, on: false });++ assert.strictEqual(self.ok, false);++ assert.strictEqual(self.code, 'bot.mod_limited');++ const owner = await act(a, 'toggle', { subject: alex.subject, on: false });++ assert.strictEqual(owner.ok, false);++ assert.strictEqual(owner.code, 'bot.owner_always_on');++ a.close(); b2.close(); c.close(); dev.close();++ });++++ await check('Mods may grant/clear sole control and trip/clear the panel E-STOP; they may not promote, change Viewer access, or clear tray STOP/ARM', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ await invite(robot.id, carol.subject, 'operator');++ await invite(robot.id, dave.subject, 'viewer');++ const dev = await deviceFor(robot.id, pairing.code);++ const a = await op(alex); const b = await op(bob);++ await join(a, robot.id); await join(b, robot.id);++ await promotedMod(a, robot.id, bob.subject);++ await b.waitForClose();++ const b2 = await op(bob); await join(b2, robot.id);++ assert.strictEqual((await act(b2, 'sole', { subject: carol.subject })).ok, true);++ cmd(b2, { id: 'mod-sole', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ assert.strictEqual((await result(b2, 'mod-sole')).code, 'bot.sole_control');++ assert.strictEqual((await act(b2, 'sole', { subject: null })).ok, true);++ // Old Mod limits.++ const promote = await act(b2, 'role', { subject: carol.subject, role: 'mod' });++ assert.deepStrictEqual([promote.ok, promote.code], [false, 'bot.owner_only']);++ const policy = await act(b2, 'policy', { policy: 'queue' });++ assert.deepStrictEqual([policy.ok, policy.code], [false, 'bot.mod_limited']);++ const viewerBtn = await act(b2, 'toggle', { subject: dave.subject, on: false });++ assert.strictEqual(viewerBtn.ok, false, 'viewers are not an Operator switch');++ // The Mod cannot change the owner's Viewer access through the owner's form either.++ const form = await fetch(`${t.base}/robots/${robot.id}/access`, { method: 'POST', redirect: 'manual',++ headers: { ...cookie(bob), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'access_policy=queue' });++ assert.strictEqual(form.status, 403);++ const limits = await fetch(`${t.base}/robots/${robot.id}/limits`, { method: 'POST', redirect: 'manual',++ headers: { ...cookie(bob), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'max_speed=0.9&max_turn=0.9' });++ assert.strictEqual(limits.status, 403, 'a Mod cannot change speed or turn');++ // The panel E-STOP is the Mod's: trip and clear. It never touches the tray's local stop.++ b2.send({ type: 'estop' });++ const tripped = await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === true);++ assert.ok(tripped);++ cmd(b2, { id: 'estop-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ assert.strictEqual((await result(b2, 'estop-drive')).code, 'bot.estop_latched');++ b2.send({ type: 'estop_clear' });++ await b2.waitFor((m) => m.type === 'operators_result' && m.action === 'estop.clear' && m.ok === true);++ await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === false);++ // A tray local STOP arrives as device_estop.local; the Mod's clear clears only Bot's panel latch.++ dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'button on the base' });++ const held = await b2.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.local === true);++ assert.strictEqual(held.state.estop.latched, true, 'the local stop also latches Bot');++ b2.send({ type: 'estop_clear' });++ const cleared = await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === false && m.state.device_estop && m.state.device_estop.latched === true);++ assert.ok(cleared, 'the tray STOP latch stays latched after a panel clear');++ assert.strictEqual(cleared.state.device_estop.local, true, 'and stays local, for the tray only');++ const estopAudit = await t.domain.operators.auditList(robot.id, { limit: 50 });++ assert.ok(estopAudit.some((r) => r.action === 'estop.trip' && r.actor_subject === bob.subject), 'the trip is audited');++ assert.ok(estopAudit.some((r) => r.action === 'estop.clear' && r.actor_subject === bob.subject), 'the clear is audited');++ // The viewer never drives.++ const d = await op(dave); await join(d, robot.id);++ cmd(d, { id: 'viewer-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });++ assert.strictEqual((await result(d, 'viewer-drive')).code, 'bot.read_only');++ a.close(); b2.close(); d.close(); dev.close();++ });++++ await check('every authority change is audited with who did it, and the activity is Bot\'s own stamp', async () => {++ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ const dev = await deviceFor(robot.id, pairing.code);++ const a = await op(alex); const b = await op(bob);++ await join(a, robot.id); await join(b, robot.id);++ await act(a, 'sole', { subject: bob.subject });++ const drove = await drive(b, dev, 'act-drive');++ assert.strictEqual(drove.result, 'ack');++ const driveState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last);++ assert.strictEqual(driveState.operators.activity[bob.subject].last.channel, 'drive');++ assert.ok(driveState.operators.activity[bob.subject].last.at, 'the command has a when');++ assert.strictEqual((await act(a, 'sole', { subject: null })).ok, true);++ // Head and lights are their own channels; lights are non-motion.++ cmd(b, { id: 'act-head', kind: 'actuator', value: { name: 'head', value: 0.3 } });++ const head = await dev.waitFor((m) => m.type === 'command' && m.ref === 'act-head');++ dev.send({ type: 'ack', id: head.id });++ await result(b, 'act-head');++ const headState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last.channel === 'head');++ assert.ok(headState);++ cmd(b, { id: 'act-lights', kind: 'headlight', value: { on: true } });++ const lights = await dev.waitFor((m) => m.type === 'command' && m.ref === 'act-lights');++ dev.send({ type: 'ack', id: lights.id });++ await result(b, 'act-lights');++ const lightState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last.channel === 'lights');++ assert.ok(lightState);++ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: false })).ok, true);++ await act(a, 'toggle', { subject: bob.subject, on: true });++ await promotedMod(a, robot.id, bob.subject);++ await b.waitForClose();++ const b2 = await op(bob); await join(b2, robot.id);++ // The audit names the actors and actions, never a token or a command payload.++ const rows = await t.domain.operators.auditList(robot.id, { limit: 50 });++ const actions = rows.map((r) => r.action);++ for (const a of ['operator.sole', 'operator.toggle', 'operator.role']) assert.ok(actions.includes(a), a);++ assert.ok(rows.every((r) => JSON.stringify(r.value).length < 400 && !/token|bearer|secret/i.test(JSON.stringify(r.value))));++ a.close(); b2.close(); dev.close();++ });++++ await check('the panel draws the card for each role, with the owner-only and Mod-only controls', async () => {++ const { robot } = await t.robot(alex, { profile_id: 'looi' });++ await invite(robot.id, bob.subject, 'operator');++ await invite(robot.id, carol.subject, 'operator');++ await invite(robot.id, dave.subject, 'viewer');++ const ownerHtml = await (await get(`/panel/${robot.id}`, alex)).text();++ assert.ok(ownerHtml.includes('data-operators'), 'the owner sees the card');++ assert.ok(ownerHtml.includes('data-operator-subject='), 'rows carry their subject');++ assert.ok(ownerHtml.includes('Make Mod'), 'the owner can promote an Operator');++ assert.ok(ownerHtml.includes('data-operators-estop>'), 'the panel E-STOP control is there');++ const ownerOp = await op(alex); await join(ownerOp, robot.id);++ await promotedMod(ownerOp, robot.id, bob.subject);++ ownerOp.close();++ const modHtml = await (await get(`/panel/${robot.id}`, bob)).text();++ assert.ok(modHtml.includes('data-operators'), 'a Mod sees the card');++ assert.ok(!modHtml.includes('Make Mod') && !modHtml.includes('Make Operator'), 'a Mod has no owner-only role buttons');++ assert.ok(modHtml.includes('data-operator-sole'), 'a Mod can grant sole control');++ const opHtml = await (await get(`/panel/${robot.id}`, carol)).text();++ assert.ok(opHtml.includes('data-operators') && !opHtml.includes('data-operator-sole'), 'an Operator sees the roster but no controls');++ const viewerHtml = await (await get(`/panel/${robot.id}`, dave)).text();++ assert.ok(viewerHtml.includes('Viewers') && !viewerHtml.includes('data-operator-sole') && !viewerHtml.includes('data-operators-estop>'), 'a viewer watches only');++ });++++ await t.close();++ done();++})();
@@ -93,7 +93,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
@@ -93,7 +93,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
- User tokens (SSO session / `ov_token` cookie) and service tokens are both accepted on `/api/v1`;
- User tokens (SSO session / `ov_token` cookie) and service tokens are both accepted on `/api/v1`;
a **node token is refused** on person and service routes. A person acts only as themself — naming
a **node token is refused** on person and service routes. A person acts only as themself — naming
anyone else is refused, never ignored.
anyone else is refused, never ignored.
-- The **owner** can list a robot, add/remove operators, edit limits, clear the latched e-stop, set
+- The **owner** can list a robot, add/remove operators, edit limits, clear the latched panel e-stop, set
the agent switch and flip streaming toggles. An operator drives within limits and cooldowns. An
the agent switch and flip streaming toggles. An operator drives within limits and cooldowns. An
open-queue robot gives a viewer a turn, and leaving promotes the next person at once.
open-queue robot gives a viewer a turn, and leaving promotes the next person at once.
- `svc:actor` may read/drive **only** the owner's robot and only with `robots.agent_control` on;
- `svc:actor` may read/drive **only** the owner's robot and only with `robots.agent_control` on;
@@ -101,7 +101,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
@@ -101,7 +101,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
- A machine runs only its owner's jobs: the dispatcher compares the job requester with the machine
- A machine runs only its owner's jobs: the dispatcher compares the job requester with the machine
owner and refuses anyone else with `bot.not_owner`, whatever the placer chose.
owner and refuses anyone else with `bot.not_owner`, whatever the placer chose.
- Every command, allowed or refused, is audited. Pairing codes are one-time; `halt` always passes;
- Every command, allowed or refused, is audited. Pairing codes are one-time; `halt` always passes;
- only the owner clears the e-stop.
+ the owner or a Mod clears the panel e-stop, while the tray's STOP latch and ARM stay local.
## Frontend loading model
## Frontend loading model
@@ -143,8 +143,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam
@@ -143,8 +143,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam
## Pitfalls
## Pitfalls
-- **The e-stop is latched and only the owner clears it**; `halt` always passes the gate and a layout
- cannot hide Stop or the overlay e-stop.
+- **The panel e-stop is latched; the owner or a Mod clears it** (a Mod never clears the tray's local STOP/ARM);
+ `halt` always passes the gate and a layout cannot hide Stop or the overlay e-stop.
- **Never widen the owner fence.** A person naming another subject, a service, a node token or
- **Never widen the owner fence.** A person naming another subject, a service, a node token or
`svc:actor` without the owner's switch must be refused.
`svc:actor` without the owner's switch must be refused.
- **Device credentials are hashed and read-once.** Do not add a readable copy or a query-string form.
- **Device credentials are hashed and read-once.** Do not add a readable copy or a query-string form.
@@ -38,7 +38,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -38,7 +38,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Link — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- The public build pages (`server/web/docs.js`): `/docs` (an index), `/docs/drivers` (write a driver for the OpenVibe Link — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
-- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
+- Operators, Mods and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. The LOOI panel's Operators card also shows live activity and last command, switches an Operator (or a Mod, by the owner) on or off immediately, grants one person sole control, and lets a Mod trip or clear the panel E-STOP (never the tray's local STOP or ARM). On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRestream stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- Dispatching `platform.job@1` jobs to a paired Link over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
- Dispatching `platform.job@1` jobs to a paired Link over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
@@ -206,7 +206,7 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -206,7 +206,7 @@ A person acts on their own robots with a Network user token; a service acts with
- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.
- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.
-- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
+- Pairing codes are one-time and short-lived; the panel e-stop is latched and the owner or a Mod clears it (the tray's own STOP and ARM stay local), while `halt` always passes the gate.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.
- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.
@@ -228,9 +228,10 @@ the camera's `GetStatus` position in telemetry under `camera`.
@@ -228,9 +228,10 @@ the camera's `GetStatus` position in telemetry under `camera`.
**A device's `estop_state` is a report, never a clear.** `latched: true` latches each robot the device is
**A device's `estop_state` is a report, never a clear.** `latched: true` latches each robot the device is
attached to (if it is not already latched). `latched: false` is recorded as the device's reported state
attached to (if it is not already latched). `latched: false` is recorded as the device's reported state
-only (`robot_state.state.device_estop`); it never clears Bot's latch — clearing is the owner's alone
-(`estop_clear` on `/control`, `POST /robots/:id/estop/clear`), after which Bot sends `estop` with
-`latched: false`. A `robot_id` the device is not attached to is refused with `error` `bot.forbidden`
+only (`robot_state.state.device_estop`); it never clears Bot's latch — clearing the panel latch is the
+owner's or a Mod's (`estop_clear` on `/control`; `POST /robots/:id/estop/clear` stays owner-only), after
+which Bot sends `estop` with `latched: false`. The tray's own STOP latch and ARM are not this latch and
+are never cleared or armed remotely. A `robot_id` the device is not attached to is refused with `error` `bot.forbidden`
and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.
and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
@@ -424,6 +425,13 @@ forward commands without holding a socket. The HTTP gate is the same gate but no
@@ -424,6 +425,13 @@ forward commands without holding a socket. The HTTP gate is the same gate but no
with no role is not put into a `queue` robot's queue (as a socket `join` would), so over HTTP a chat viewer
with no role is not put into a `queue` robot's queue (as a socket `join` would), so over HTTP a chat viewer
needs an explicit owner or operator role — a no-role caller is refused `bot.not_an_operator`.
needs an explicit owner or operator role — a no-role caller is refused `bot.not_an_operator`.
+**Roles and sole control.** The owner, a Mod and an Operator may drive and press the panel e-stop; a Mod
+may also switch Operators on or off, grant or clear sole control, and clear the panel latch. Only the owner
+promotes an Operator to Mod or demotes a Mod, changes speed/mode/Viewer access, clears the tray's STOP or
+ARM, or switches a Mod. A switched-off Operator (or Mod) is refused `bot.operator_off`; under sole control
+every other subject's command is refused `bot.sole_control` (the owner and `halt` always pass). Each
+authority change is in `robot_operator_audit` with the actor and subject, never a token.
+
### Client → server
### Client → server
| type | fields | notes |
| type | fields | notes |
@@ -431,16 +439,19 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
@@ -431,16 +439,19 @@ needs an explicit owner or operator role — a no-role caller is refused `bot.no
| `join` | `robot_id` | subscribe to a robot; refused with `error` `bot.not_an_operator` when the caller has no access |
| `join` | `robot_id` | subscribe to a robot; refused with `error` `bot.not_an_operator` when the caller has no access |
| `leave` | — | unsubscribe |
| `leave` | — | unsubscribe |
| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |
| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |
-| `estop` | — | latch the e-stop (owner or operator) |
-| `estop_clear` | — | clear the latched e-stop (**owner only**) |
-| `give_back` | — | owner or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |
+| `estop` | — | latch the panel e-stop (owner, Mod or operator) |
+| `estop_clear` | — | clear the latched **panel** e-stop (owner or Mod). It never clears the tray's local STOP or ARM |
+| `operators` | `id?, action, subject?, on?, role?` | `toggle` an Operator (owner or Mod) or a Mod (owner), `sole` grant/clear one subject (`subject: null` is Everyone), `role` promote an Operator to Mod or demote a Mod (owner). Every answer is `operators_result` |
+| `give_back` | — | owner, Mod or operator: hand back a queue turn your driving paused (§1 Owner takeover); it resumes at once with its time kept |
### Server → client
### Server → client
| type | fields | notes |
| type | fields | notes |
|---|---|---|
|---|---|---|
-| `joined` | `robot, role, profile, allowed_commands, state` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive |
-| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, device_estop{latched,at}, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `estop` is Bot's latch, `device_estop` the device's last report (or `null`) |
+| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |
+| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, device_estop{latched,at}, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `estop` is Bot's panel latch, `device_estop` the device's last report (or `null`); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |
+| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |
+| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame or a panel `estop`/`estop_clear`; a refusal is never a silent drop |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `error` | `code, detail` | a frame or join the server refused |
| `error` | `code, detail` | a frame or join the server refused |
@@ -492,12 +503,13 @@ resumes it at once.
@@ -492,12 +503,13 @@ resumes it at once.
```json
```json
{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }
{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }
-{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner clears the e-stop" }
+{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner or a Mod clears the panel e-stop" }
```
```
Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`
Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`
(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,
(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,
-`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,
+`bot.read_only`, `bot.operator_off`, `bot.sole_control`, `bot.command_not_allowed` (the role's allowlist),
+`bot.device_offline`, `bot.cooldown`,
`bot.not_your_turn`, `bot.owner_control` (the owner or an operator has taken the turn over), `bot.turn_budget`,
`bot.not_your_turn`, `bot.owner_control` (the owner or an operator has taken the turn over), `bot.turn_budget`,
`bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.
`bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.
A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused`
A `command` whose `id` is still in flight (the first one has not been acked) is answered `refused`
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+-- phase: expand
+-- LOOI Operators card (Maticus's 2026-11-12 spec): a Mod role between the owner and operators, a per-person
+-- on/off switch that the command gate reads before it forwards anything, sole control for one subject, and an
+-- audit of who changed authority. Panel E-STOP stays the robots.estop_latched latch; the tray's local STOP and
+-- ARM are never touched by this table.
+ALTER TABLE robot_operators DROP CONSTRAINT IF EXISTS robot_operators_role_check;
+ALTER TABLE robot_operators ADD CONSTRAINT robot_operators_role_check CHECK (role IN ('owner', 'operator', 'viewer', 'mod'));
+ALTER TABLE robot_operators ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
+
+-- Sole control: one controlling subject at a time, or no row/sole_subject for Everyone. The owner always overrides.
+CREATE TABLE IF NOT EXISTS robot_operator_control (
+ robot_id text PRIMARY KEY REFERENCES robots(id) ON DELETE CASCADE,
+ sole_subject text,
+ sole_by text,
+ sole_at timestamptz,
+ updated_at timestamptz NOT NULL
+);
+
+-- Authority changes: who granted sole control, switched someone on or off, promoted or demoted a Mod, or
+-- tripped/cleared the panel E-STOP. Subjects and roles only: no command values, tokens or secrets.
+CREATE TABLE IF NOT EXISTS robot_operator_audit (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ robot_id text NOT NULL,
+ actor_subject text,
+ actor_kind text,
+ actor_role text,
+ action text NOT NULL,
+ subject text,
+ role text,
+ value jsonb NOT NULL DEFAULT '{}'::jsonb,
+ at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS robot_operator_audit_robot ON robot_operator_audit (robot_id, id DESC);
+CREATE INDEX IF NOT EXISTS robot_operator_audit_at ON robot_operator_audit (at);
@@ -209,6 +209,69 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -209,6 +209,69 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
/* Readouts and the latency meter */
/* Readouts and the latency meter */
.readout { font-variant-numeric: tabular-nums; font-size: 1.35rem; font-weight: 600; }
.readout { font-variant-numeric: tabular-nums; font-size: 1.35rem; font-weight: 600; }
.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }
.battery { display: flex; align-items: center; gap: 0.75rem; } meter { flex: 1; height: 0.9rem; }
+.battery-head { display: flex; align-items: baseline; gap: 0.45rem; min-width: 0; }
+.battery-note { color: var(--muted); font-size: 0.85rem; }
+.battery-provisional { color: var(--warn); font-size: 0.78rem; }
+/* The LOOI status row: Battery | Latency in one compact row (Maticus's Operators spec). */
+.status-row { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.75rem; order: -2; }
+#panel[data-profile-id="looi"] .status-row { order: -2; }
+.status-row .widget { padding: 0.8rem; }
+.status-row .battery { flex-direction: column; align-items: stretch; gap: 0.45rem; }
+.status-row .battery .readout { font-size: 1.55rem; }
+.status-row .battery-head { justify-content: space-between; }
+.status-row meter { width: 100%; height: 0.55rem; }
+.status-row .stat { padding: 0; border: 0; background: transparent; }
+.status-row .readout { font-size: 1.55rem; }
+/* The Operators card (Maticus's 2026-11-12 LOOI spec). Full width under the drive/head controls, same tokens. */
+.widget-operators { grid-column: 1 / -1; order: 1; }
+#panel[data-profile-id="looi"] .widget-operators { order: 1; }
+.ops-head { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 0.5rem 0.75rem; margin-bottom: 0.75rem; }
+.ops-lede { flex: 1 1 16rem; margin: 0; color: var(--muted); font-size: 0.85rem; }
+.ops-mode { align-self: center; }
+.ops-mode-btn { min-height: 32px; padding: 0 0.7rem; border-radius: 999px; font-size: 0.8rem; }
+.ops-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.5rem; }
+.ops-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; grid-template-areas: "avatar who actions" ". sub sub"; align-items: center; gap: 0.25rem 0.7rem;
+ padding: 0.7rem 0.8rem; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
+.ops-row.ops-owner { background: color-mix(in srgb, var(--accent) 7%, var(--bg)); }
+.ops-row.ops-mod { background: color-mix(in srgb, var(--warn) 6%, var(--bg)); }
+.ops-row.is-live { border-color: color-mix(in srgb, var(--ok) 55%, var(--line)); background: color-mix(in srgb, var(--ok) 9%, var(--bg)); }
+.ops-avatar { grid-area: avatar; align-self: start; width: 2.5rem; height: 2.5rem; border-radius: 50%; display: grid; place-items: center; flex: none;
+ border: 1px solid var(--line); background: var(--raised); color: var(--text); font-weight: 700; }
+.ops-count { color: var(--muted); }
+.ops-who { grid-area: who; min-width: 0; }
+.ops-name { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4rem 0.55rem; margin: 0; }
+.ops-name b { font-size: 0.98rem; }
+.ops-role { font-size: 0.66rem; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0.05rem 0.4rem; }
+.ops-you { color: var(--muted); font-size: 0.8rem; }
+.ops-sub { grid-area: sub; margin: 0.1rem 0 0; color: var(--muted); font-size: 0.8rem; }
+.ops-last, .ops-head-state { display: inline; margin: 0 0.65rem 0 0; color: var(--muted); font-size: 0.78rem; }
+.ops-live { display: inline-flex; align-items: center; gap: 0.3rem; color: var(--ok); font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
+.ops-live .dot { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 20%, transparent); }
+.ops-actions { grid-area: actions; display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 0.4rem; align-self: start; }
+.ops-sole, .ops-role-btn { min-height: 34px; padding: 0 0.65rem; font-size: 0.78rem; border-radius: 999px; }
+.ops-sole.is-on { border-color: var(--ok); color: var(--ok); background: color-mix(in srgb, var(--ok) 12%, transparent); }
+.ops-role-btn { color: var(--muted); min-height: 26px; padding: 0 0.45rem; font-size: 0.7rem; margin-left: 0.35rem; vertical-align: 1px; }
+.ops-switch { position: relative; display: inline-flex; align-items: center; width: 2.8rem; min-height: 44px; flex: none; cursor: pointer; }
+.ops-switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: inherit; }
+.ops-switch-track { position: relative; width: 2.6rem; height: 1.45rem; border-radius: 999px; background: var(--line); transition: background 0.12s; }
+.ops-switch-track::after { content: ""; position: absolute; top: 0.18rem; left: 0.18rem; width: 1.09rem; height: 1.09rem; border-radius: 50%; background: #fff; transition: transform 0.12s; }
+.ops-switch input:checked + .ops-switch-track { background: var(--ok); }
+.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }
+.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
+.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }
+.ops-estop { display: flex; align-items: center; flex-wrap: wrap; gap: 0.6rem; margin-top: 0.75rem; padding: 0.7rem 0.8rem; border-radius: 10px;
+ border: 1px solid color-mix(in srgb, var(--danger) 45%, var(--line)); background: color-mix(in srgb, var(--danger) 8%, var(--bg)); }
+.ops-estop p { flex: 1 1 14rem; margin: 0; font-size: 0.8rem; }
+.ops-estop[data-latched="true"] { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, var(--bg)); }
+.ops-estop-btn { min-height: 40px; padding: 0 0.8rem; border-color: color-mix(in srgb, var(--danger) 60%, var(--line)); color: var(--danger-text, var(--danger)); font-weight: 600; }
+.ops-estop[data-latched="true"] .ops-estop-btn { background: var(--fill-danger); border-color: var(--fill-danger); color: #fff; }
+.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }
+@media (max-width: 30rem) {
+ .ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }
+ .ops-actions { justify-content: flex-end; }
+ .ops-role-btn { margin-left: 0.35rem; }
+ .status-row .readout { font-size: 1.35rem; }
+}
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
.stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
.stat { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.55rem 0.6rem; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); min-width: 0; }
.stat { display: flex; flex-direction: column; gap: 0.15rem; padding: 0.55rem 0.6rem; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); min-width: 0; }
.stat .readout { font-size: 1.15rem; }
.stat .readout { font-size: 1.15rem; }
@@ -853,3 +916,13 @@ html[data-ov-motion="off"] #panel:not(.bot-surface) .headlight[data-state="pendi
@@ -853,3 +916,13 @@ html[data-ov-motion="off"] #panel:not(.bot-surface) .headlight[data-state="pendi
#panel:not(.bot-surface) .headlight-unconfirmed { font-size: 0.78rem; color: var(--muted); padding-left: 0.2rem; }
#panel:not(.bot-surface) .headlight-unconfirmed { font-size: 0.78rem; color: var(--muted); padding-left: 0.2rem; }
#panel:not(.bot-surface) .headlight-unconfirmed[hidden] { display: none; }
#panel:not(.bot-surface) .headlight-unconfirmed[hidden] { display: none; }
#panel:not(.bot-surface) .headlight-note { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--warn); }
#panel:not(.bot-surface) .headlight-note { margin: 0.35rem 0 0; font-size: 0.85rem; color: var(--warn); }
+
+/* The LOOI status row is one card with two readings: keep its own stack over the single-readout rules above. */
+#panel:not(.bot-surface) .status-row .widget-battery { display: block; padding: 0.8rem; }
+#panel:not(.bot-surface) .status-row .widget-battery > h2 { margin: 0 0 0.35rem; }
+#panel:not(.bot-surface) .status-row .battery { display: flex; flex-direction: column; align-items: stretch; gap: 0.45rem; }
+#panel:not(.bot-surface) .status-row .battery .readout { font-size: 1.6rem; text-align: left; }
+#panel:not(.bot-surface) .status-row .widget-latency { padding: 0.8rem; }
+#panel:not(.bot-surface) .status-row .stat { background: transparent; }
+#panel:not(.bot-surface)[data-profile-id="looi"] .widget-drive { order: 0; }
+#panel:not(.bot-surface)[data-profile-id="looi"] .widget-head { order: -1; }
@@ -274,7 +274,7 @@
@@ -274,7 +274,7 @@
let allowed = JSON.parse(main.dataset.allowed || '[]');
let allowed = JSON.parse(main.dataset.allowed || '[]');
let ws = null;
let ws = null;
let joined = false;
let joined = false;
- let myRole = null; // the role the joined frame gave this panel (owner, operator, viewer, queue)
+ let myRole = main.dataset.role && main.dataset.role !== 'watcher' ? main.dataset.role : null; // the role the joined frame gave this panel (owner, mod, operator, viewer, queue)
let retry = 0;
let retry = 0;
let gone = false; // refused for good (no access): no reconnect
let gone = false; // refused for good (no access): no reconnect
// The embeddable panel's two compact views (?mode=overlay|compact): only the live controls, over a picture or
// The embeddable panel's two compact views (?mode=overlay|compact): only the live controls, over a picture or
@@ -1143,6 +1143,136 @@
@@ -1143,6 +1143,136 @@
$('[data-headlight-off]', headlightEl).addEventListener('click', () => sendHeadlight(false));
$('[data-headlight-off]', headlightEl).addEventListener('click', () => sendHeadlight(false));
paintHeadlight();
paintHeadlight();
}
}
+ // ── Operators (Maticus's 2026-11-12 LOOI spec) ──────────────────────────────────────────────────
+ // The server draws the roster. This keeps it current from `operators_state` (Bot's own stamps for activity),
+ // sends a toggle / sole / role / panel E-STOP, and shows the refusal in the same notice line as a command's.
+ const opsCard = $('[data-operators]');
+ const opsNames = new Map();
+ if (opsCard) for (const row of $$('[data-operator-subject]', opsCard)) opsNames.set(row.dataset.operatorSubject, row.dataset.operatorName || row.dataset.operatorSubject);
+ const ops = { sole: null, members: [], activity: {}, viewers: 0, now: null };
+ function opsName(subject) {
+ if (!subject) return 'that person';
+ return opsNames.get(subject) || String(subject).slice(0, 14);
+ }
+ const OPS_CHANNEL = { drive: 'Drive', head: 'Head', lights: 'Lights', button: 'Button', say: 'Say', display: 'Display', ptz: 'Camera' };
+ const OPS_ROLE = { owner: 'OWNER · YOU', mod: 'MOD', operator: 'OPERATOR', viewer: 'VIEWER' };
+ function opsAgo(at) {
+ const then = Date.parse(at);
+ const now = Date.parse(ops.now) || Date.now();
+ if (!Number.isFinite(then)) return 'just now';
+ const ms = Math.max(0, now - then);
+ if (ms < 1000) return 'just now';
+ if (ms < 60000) return `${Math.round(ms / 1000)}s ago`;
+ if (ms < 3600000) return `${Math.round(ms / 60000)}m ago`;
+ return `${Math.round(ms / 3600000)}h ago`;
+ }
+ const opsFresh = (rec, channel) => {
+ if (!rec || !rec[`${channel}_at`]) return false;
+ const then = Date.parse(rec[`${channel}_at`]);
+ const now = Date.parse(ops.now) || Date.now();
+ return Number.isFinite(then) && now - then < 1500;
+ };
+ function opsSend(action, fields) {
+ if (!joined) { notice('Join the robot first.'); return; }
+ raw({ type: 'operators', action, ...fields });
+ }
+ function paintOperatorsEstop(state) {
+ if (!opsCard) return;
+ const box = $('[data-operators-estop-box]', opsCard);
+ const latched = !!(state && state.estop && state.estop.latched);
+ if (box) box.dataset.latched = latched ? 'true' : 'false';
+ const note = $('[data-operators-estop-note]', opsCard);
+ if (note) note.innerHTML = latched
+ ? '<b>Panel E-STOP is tripped.</b> A Mod may clear it. That never clears the tray STOP latch or ARM; the robot cannot move until the owner clears STOP and re-arms on the tray.'
+ : '<b>Panel E-STOP is clear.</b> A Mod may trip it, or clear it after a panel stop. That never clears the tray STOP latch or ARM.';
+ const btn = $('[data-operators-estop]', opsCard);
+ if (btn) {
+ btn.textContent = latched ? 'Clear panel E-STOP' : 'Trip panel E-STOP';
+ btn.hidden = !(myRole === 'owner' || myRole === 'mod');
+ }
+ }
+ function paintOperators(state, keepNow = false) {
+ if (!opsCard || !state) return;
+ ops.sole = state.sole || null;
+ ops.members = state.members || [];
+ ops.activity = state.activity || {};
+ ops.viewers = state.viewers || 0;
+ if (!keepNow) ops.now = state.now || new Date().toISOString();
+ const mode = $('[data-operators-mode]', opsCard);
+ if (mode) mode.textContent = ops.sole ? `Sole: ${opsName(ops.sole.subject)}` : 'Everyone';
+ const clear = $('[data-operators-clear]', opsCard);
+ if (clear) clear.hidden = !ops.sole;
+ for (const p of $$('.ops-viewers .pill', opsCard)) if (/watching/.test(p.textContent)) p.textContent = `${ops.viewers} watching`;
+ for (const m of ops.members) {
+ const row = $(`[data-operator-subject="${CSS.escape(m.subject)}"]`, opsCard);
+ if (!row) continue;
+ row.dataset.operatorRole = m.role;
+ row.dataset.operatorEnabled = String(m.enabled !== false);
+ row.dataset.operatorName = opsNames.get(m.subject) || row.dataset.operatorName || m.subject;
+ row.classList.remove('ops-owner', 'ops-mod', 'ops-operator', 'ops-viewer');
+ row.classList.add(`ops-${m.role}`);
+ const tag = $('[data-operator-role-tag]', row);
+ if (tag) tag.textContent = m.role === 'owner' ? (row.dataset.operatorYou === 'true' ? 'OWNER · YOU' : 'OWNER') : (OPS_ROLE[m.role] || m.role);
+ const sub = $('[data-operator-sub]', row);
+ if (sub) sub.textContent = m.role === 'owner'
+ ? 'Always has override, local STOP, and ARM'
+ : m.role === 'mod'
+ ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'
+ : 'Can drive and tilt the head while switched on.';
+ const rec = ops.activity[m.subject];
+ const active = m.enabled !== false && (opsFresh(rec, 'drive') || opsFresh(rec, 'head'));
+ row.classList.toggle('is-live', active);
+ const live = $('[data-operator-activity]', row);
+ if (live) live.hidden = !active;
+ const last = $('[data-operator-last]', row);
+ if (last) last.textContent = rec && rec.last ? `${OPS_CHANNEL[rec.last.channel] || 'Control'} · ${opsAgo(rec.last.at)}${rec.last.channel === 'lights' ? ' · non-motion' : ''}` : 'No command yet';
+ const head = $('[data-operator-head]', row);
+ if (head) head.textContent = opsFresh(rec, 'head') ? 'Head active' : 'Head idle';
+ const toggle = $('[data-operator-toggle]', row);
+ if (toggle) {
+ toggle.checked = m.enabled !== false;
+ const can = m.role !== 'owner' && (myRole === 'owner' || (myRole === 'mod' && m.role === 'operator'));
+ toggle.disabled = !can;
+ }
+ const soleBtn = $('[data-operator-sole]', row);
+ if (soleBtn) {
+ const active = !!(ops.sole && ops.sole.subject === m.subject);
+ soleBtn.dataset.soleActive = active ? 'true' : 'false';
+ soleBtn.textContent = active ? 'Sole · on' : 'Sole control';
+ soleBtn.hidden = !(myRole === 'owner' || myRole === 'mod');
+ soleBtn.disabled = m.enabled === false;
+ }
+ const roleBtn = $('[data-operator-role]', row);
+ if (roleBtn) {
+ roleBtn.hidden = !(myRole === 'owner' && (m.role === 'operator' || m.role === 'mod'));
+ roleBtn.dataset.operatorRoleSet = m.role === 'operator' ? 'mod' : 'operator';
+ roleBtn.textContent = m.role === 'operator' ? 'Make Mod' : 'Make Operator';
+ }
+ }
+ }
+ if (opsCard) {
+ // Age "1s ago" and the Live badge even when no new command arrives (both are read from Bot's stamp).
+ setInterval(() => { if (joined) { ops.now = new Date().toISOString(); paintOperators(ops, true); } }, 1000);
+ opsCard.addEventListener('click', (e) => {
+ const btn = e.target.closest && e.target.closest('button');
+ if (!btn) return;
+ if (btn.matches('[data-operators-clear]')) { opsSend('sole', { subject: null }); return; }
+ const soleBtn = btn.closest('[data-operator-sole]');
+ if (soleBtn) { opsSend('sole', { subject: soleBtn.dataset.soleActive === 'true' ? null : soleBtn.dataset.operatorSole }); return; }
+ const roleBtn = btn.closest('[data-operator-role]');
+ if (roleBtn) { opsSend('role', { subject: roleBtn.dataset.subject, role: roleBtn.dataset.operatorRoleSet }); return; }
+ if (btn.closest('[data-operators-estop]')) {
+ const box = $('[data-operators-estop-box]', opsCard);
+ if (box && box.dataset.latched === 'true') raw({ type: 'estop_clear' });
+ else estop();
+ }
+ });
+ opsCard.addEventListener('change', (e) => {
+ const toggle = e.target.closest && e.target.closest('[data-operator-toggle]');
+ if (toggle) opsSend('toggle', { subject: toggle.dataset.subject, on: toggle.checked });
+ });
+ }
+
$$('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();
@@ -1294,6 +1424,20 @@
@@ -1294,6 +1424,20 @@
if (text) text.textContent = label;
if (text) text.textContent = label;
pill.dataset.state = dataState;
pill.dataset.state = dataState;
}
}
+ // Battery | Latency. A missing reading is a dash, an empty bar and the word unknown, never 0 %; a voltage is
+ // never invented. The tray's unverified FED8 read is a quiet provisional note, not a second number.
+ function paintBattery(state) {
+ const fraction = state.battery;
+ const sensors = state.telemetry && state.telemetry.sensors;
+ const provisional = !!(sensors && sensors.p7_battery && sensors.p7_battery.unverified === true);
+ for (const el of $$('[data-battery]')) {
+ if (fraction == null) { el.removeAttribute('value'); el.dataset.state = 'unknown'; }
+ else { el.value = String(Math.max(0, Math.min(1, fraction))); el.dataset.state = 'known'; }
+ }
+ for (const el of $$('[data-battery-text]')) el.textContent = fraction != null ? `${Math.round(fraction * 100)} %` : '—';
+ for (const el of $$('[data-battery-note]')) { el.textContent = fraction != null ? '' : 'unknown'; el.hidden = fraction != null; }
+ for (const el of $$('[data-battery-provisional]')) el.hidden = !provisional;
+ }
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
const latched = !!(state.estop && state.estop.latched);
@@ -1307,8 +1451,8 @@
@@ -1307,8 +1451,8 @@
const note = $('.estop-note');
const note = $('.estop-note');
if (note) {
if (note) {
const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';
const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';
- note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner clears it here.`
- : latched ? ' — the robot stays still until the owner clears it.' : '';
+ note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner or a Mod clears it here.`
+ : latched ? ' — the robot stays still until the owner or a Mod clears it.' : '';
}
}
const clear = $('[data-estop-clear]');
const clear = $('[data-estop-clear]');
if (clear) {
if (clear) {
@@ -1325,8 +1469,12 @@
@@ -1325,8 +1469,12 @@
else if (!state.online) lastTelemetryAt = 0;
else if (!state.online) lastTelemetryAt = 0;
paintAge();
paintAge();
for (const el of $$('[data-link]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
for (const el of $$('[data-link]')) el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
- 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)} %` : '—';
+ if (!samples.length) for (const el of $$('[data-latency]')) {
+ el.textContent = state.latency_ms != null ? `${state.latency_ms} ms` : '—';
+ el.dataset.quality = quality(state.latency_ms, 120, 300);
+ }
+ paintBattery(state);
+ paintOperatorsEstop(state);
const sensors = state.telemetry && state.telemetry.sensors;
const sensors = state.telemetry && state.telemetry.sensors;
headlightTelemetry(sensors);
headlightTelemetry(sensors);
for (const el of $$('[data-telemetry]')) {
for (const el of $$('[data-telemetry]')) {
@@ -1392,19 +1540,23 @@
@@ -1392,19 +1540,23 @@
joined = true; retry = 0;
joined = true; retry = 0;
allowed = m.allowed_commands || [];
allowed = m.allowed_commands || [];
if (m.ticket) ticket = m.ticket;
if (m.ticket) ticket = m.ticket;
- if (driving) {
- const label = $('[data-role-label]');
- myRole = m.role || null;
- if (label && m.role) label.textContent = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue' }[m.role] || m.role;
- for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'operator');
- }
+ const label = $('[data-role-label]');
+ myRole = m.role || myRole;
+ if (label && m.role) label.textContent = { owner: 'Owner', mod: 'Mod', operator: 'Operator', viewer: 'Viewer', watcher: 'Watching', queue: 'In the queue' }[m.role] || m.role;
+ for (const el of $$('[data-estop]')) el.hidden = !(m.role === 'owner' || m.role === 'mod' || m.role === 'operator');
notice('');
notice('');
enableControls();
enableControls();
paint(m.state);
paint(m.state);
+ if (m.operators) paintOperators(m.operators);
paintTurn(m.state && m.state.queue);
paintTurn(m.state && m.state.queue);
} else if (m.type === 'robot_state') {
} else if (m.type === 'robot_state') {
paint(m.state);
paint(m.state);
paintTurn(m.state && m.state.queue);
paintTurn(m.state && m.state.queue);
+ } else if (m.type === 'operators_state') {
+ paintOperators(m.operators);
+ } else if (m.type === 'operators_result') {
+ if (!m.ok) notice(m.reason || m.code || 'That change was refused.');
+ else notice('');
} 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.id && headlightIds.delete(m.id)) { headlightAck(m); return; }
@@ -204,7 +204,7 @@
@@ -204,7 +204,7 @@
stopBtn.textContent = localStop ? 'Resume' : 'Stop';
stopBtn.textContent = localStop ? 'Resume' : 'Stop';
stopBtn.classList.toggle('resume', localStop);
stopBtn.classList.toggle('resume', localStop);
const note = $('[data-safety]');
const note = $('[data-safety]');
- note.textContent = localStop ? 'Stopped on this phone: press Resume, then the owner clears the e-stop.' : remoteLatched ? 'The robot\'s e-stop is latched: the owner clears it on the panel.' : '';
+ note.textContent = localStop ? 'Stopped on this phone: press Resume, then the owner or a Mod clears the panel e-stop.' : remoteLatched ? 'The robot\'s e-stop is latched: the owner clears it on the panel.' : '';
note.hidden = !note.textContent;
note.hidden = !note.textContent;
}
}
@@ -28,6 +28,12 @@ const TABLES = [
@@ -28,6 +28,12 @@ const TABLES = [
{ table: 'robots', subject: 'estop_by', file: null, erase: anonymize },
{ table: 'robots', subject: 'estop_by', file: null, erase: anonymize },
{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },
{ table: 'pairing_codes', subject: 'created_by', file: null, erase: anonymize },
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
{ table: 'robot_operators', subject: 'added_by', file: null, erase: anonymize },
+ // Sole control names the person it admits and who granted it; the audit names the actor and the subject. Both are
+ // authority records, not secrets: on deletion the person's id leaves them, the rest of the record stays.
+ { table: 'robot_operator_control', subject: 'sole_subject', file: null, erase: anonymize },
+ { table: 'robot_operator_control', subject: 'sole_by', file: null, erase: anonymize },
+ { table: 'robot_operator_audit', subject: 'subject', file: null, erase: anonymize },
+ { table: 'robot_operator_audit', subject: 'actor_subject', file: null, erase: anonymize },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
{ table: 'live_conversions', subject: 'owner_subject', file: null },
// A device's request to be added (server/claims.js): who approved or denied it. Short-lived; erased with the account.
// A device's request to be added (server/claims.js): who approved or denied it. Short-lived; erased with the account.
{ table: 'device_claims', subject: 'owner_subject', file: null },
{ table: 'device_claims', subject: 'owner_subject', file: null },
@@ -76,6 +82,7 @@ function create({ domain, log = console } = {}) {
@@ -76,6 +82,7 @@ function create({ domain, log = console } = {}) {
await t.exec(`UPDATE devices SET name = NULL WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))
await t.exec(`UPDATE devices SET name = NULL WHERE NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(robot_ids) r WHERE NOT (r = ANY($1::text[])))
AND jsonb_array_length(robot_ids) > 0`, [ids]);
AND jsonb_array_length(robot_ids) > 0`, [ids]);
counts.add(counts.erased, 'command_audit', await t.exec('DELETE FROM command_audit WHERE robot_id = ANY($1::text[])', [ids]));
counts.add(counts.erased, 'command_audit', await t.exec('DELETE FROM command_audit WHERE robot_id = ANY($1::text[])', [ids]));
+ counts.add(counts.erased, 'robot_operator_audit', await t.exec('DELETE FROM robot_operator_audit WHERE robot_id = ANY($1::text[])', [ids]));
// Each robot the way its owner would remove it (OpenRestream key revoked first; a refusal there rolls this back and
// Each robot the way its owner would remove it (OpenRestream key revoked first; a refusal there rolls this back and
// Events retries).
// Events retries).
for (const id of ids) await domain.robots.remove(id);
for (const id of ids) await domain.robots.remove(id);
@@ -25,12 +25,14 @@ const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -25,12 +25,14 @@ const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_ALLOW = {
const DEFAULT_ALLOW = {
owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],
owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],
+ mod: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'headlight', 'halt'],
operator: ['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', 'headlight', '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', 'mod', 'operator', 'queue']);
+const OPERATOR_ROLES = new Set(['owner', 'mod', 'operator', 'viewer']);
// 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.
const TURN_AXES = new Set(['steer', 'rotation']);
const TURN_AXES = new Set(['steer', 'rotation']);
// The older drive names, still read for the axis they stand for.
// The older drive names, still read for the axis they stand for.
@@ -122,7 +124,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -122,7 +124,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return db.many('SELECT subject, role, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);
return db.many('SELECT subject, role, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);
}
}
async function addOperator(robotId, subject, role, addedBy) {
async function addOperator(robotId, subject, role, addedBy) {
- if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');
+ if (!OPERATOR_ROLES.has(role)) fail(422, 'bot.invalid_role', 'role must be owner, mod, operator or viewer');
if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');
if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');
const before = await roleOf(robotId, subject);
const before = await roleOf(robotId, subject);
await db.query(
await db.query(
@@ -134,7 +136,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -134,7 +136,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function removeOperator(robotId, subject) {
async function removeOperator(robotId, subject) {
const before = await roleOf(robotId, subject);
const before = await roleOf(robotId, subject);
await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);
await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);
- if (before && before !== 'owner') membershipChanged(robotId, subject, 'you were removed from this robot');
+ if (before && before !== 'owner') {
+ const policy = await operatorPolicy(robotId);
+ if (policy.sole_subject === subject) await setSole(robotId, null, subject);
+ membershipChanged(robotId, subject, 'you were removed from this robot');
+ }
}
}
/**
/**
* A membership change: the person's live /control sockets on the robot are dropped (so their join
* A membership change: the person's live /control sockets on the robot are dropped (so their join
@@ -146,6 +152,118 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -146,6 +152,118 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (link && link.broadcast) link.broadcast(robotId);
if (link && link.broadcast) link.broadcast(robotId);
}
}
+ // ── Operators, Mods and sole control (Maticus's 2026-11-12 LOOI spec) ─────────────────────────
+ // The owner always has all of this. A Mod may switch Operators (never Mods or the owner), grant or clear
+ // sole control, and trip or clear the panel E-STOP. Only the owner promotes a Mod. The gate reads `enabled`
+ // and `sole_subject` fresh before every command, so a change is immediate with no tray restart.
+ const GRANT_ROLES = new Set(['owner', 'mod', 'operator']);
+
+ async function operatorStateOf(robotId, subject, q = db) {
+ if (!subject) return null;
+ const row = await q.maybe(
+ `SELECT m.role, m.enabled, c.sole_subject FROM robot_operators m
+ LEFT JOIN robot_operator_control c ON c.robot_id = m.robot_id
+ WHERE m.robot_id = $1 AND m.subject = $2`, [robotId, subject]);
+ return row ? { role: row.role, enabled: row.enabled !== false, sole: row.sole_subject || null } : null;
+ }
+ async function operatorPolicy(robotId, q = db) {
+ const row = await q.maybe('SELECT sole_subject, sole_by, sole_at FROM robot_operator_control WHERE robot_id = $1', [robotId]);
+ return row
+ ? { sole_subject: row.sole_subject || null, sole_by: row.sole_by || null, sole_at: row.sole_at ? iso(new Date(row.sole_at).getTime()) : null }
+ : { sole_subject: null, sole_by: null, sole_at: null };
+ }
+ async function listRoster(robotId) {
+ const rows = await db.many('SELECT subject, role, enabled, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);
+ return rows.map((r) => ({ subject: r.subject, role: r.role, enabled: r.role === 'owner' ? true : r.enabled !== false, added_by: r.added_by || null, created_at: iso(new Date(r.created_at).getTime()) }));
+ }
+ async function auditOperator(entry) {
+ const at = iso(entry.at != null ? entry.at : now());
+ return db.maybe(
+ `INSERT INTO robot_operator_audit (robot_id, actor_subject, actor_kind, actor_role, action, subject, role, value, at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9) RETURNING id, at`,
+ [entry.robotId, entry.actorSubject || null, entry.actorKind || null, entry.actorRole || null,
+ entry.action, entry.subject || null, entry.role || null, JSON.stringify(entry.value || {}), at]);
+ }
+ async function listOperatorAudit(robotId, { limit = 50 } = {}) {
+ const rows = await db.many('SELECT * FROM robot_operator_audit WHERE robot_id = $1 ORDER BY id DESC LIMIT $2', [robotId, Math.min(200, Math.max(1, limit))]);
+ return rows.map((r) => ({
+ id: Number(r.id), robot_id: r.robot_id, actor_subject: r.actor_subject, actor_kind: r.actor_kind,
+ actor_role: r.actor_role, action: r.action, subject: r.subject, role: r.role,
+ value: json(r.value, {}), at: iso(new Date(r.at).getTime()),
+ }));
+ }
+ /** Set or clear the one subject sole control admits; null is Everyone. */
+ async function setSole(robotId, subject, actor) {
+ const at = iso(now());
+ if (subject == null) {
+ await db.query('DELETE FROM robot_operator_control WHERE robot_id = $1', [robotId]);
+ return;
+ }
+ await db.query(
+ `INSERT INTO robot_operator_control (robot_id, sole_subject, sole_by, sole_at, updated_at)
+ VALUES ($1, $2, $3, $4, $4)
+ ON CONFLICT (robot_id) DO UPDATE SET sole_subject = EXCLUDED.sole_subject,
+ sole_by = EXCLUDED.sole_by, sole_at = EXCLUDED.sole_at, updated_at = EXCLUDED.updated_at`,
+ [robotId, subject, actor, at]);
+ }
+ async function setMemberEnabled(robotId, subject, enabled, actor, actorRole, targetRole) {
+ await db.query('UPDATE robot_operators SET enabled = $3 WHERE robot_id = $1 AND subject = $2', [robotId, subject, !!enabled]);
+ if (!enabled) {
+ const policy = await operatorPolicy(robotId);
+ if (policy.sole_subject === subject) await setSole(robotId, null, actor);
+ }
+ await auditOperator({ robotId, actorSubject: actor, actorKind: 'user', actorRole, action: 'operator.toggle', subject, role: targetRole, value: { enabled: !!enabled } });
+ }
+ async function setMemberRole(robotId, subject, next, actor, role) {
+ await db.query('UPDATE robot_operators SET role = $3 WHERE robot_id = $1 AND subject = $2', [robotId, subject, next]);
+ await auditOperator({ robotId, actorSubject: actor, actorKind: 'user', actorRole: role, action: 'operator.role', subject, role: next, value: { role: next } });
+ membershipChanged(robotId, subject, `your role on this robot is now ${next}`);
+ }
+ /**
+ * One authority change from a panel (/control `operators`). The actor's role is read fresh: a stale socket
+ * cannot act on a role it no longer has. Refusals carry a stable code and a plain reason the panel shows.
+ */
+ async function actOperator({ robotId, actorSubject, action, subject = null, enabled = null, role = null }) {
+ const robot = await getRobot(robotId);
+ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
+ const actorRole = await roleOf(robotId, actorSubject);
+ if (!actorRole) fail(403, 'bot.not_an_operator', 'you have no access to this robot');
+ if (!subject && !['sole', 'policy', 'viewer_access'].includes(action)) fail(422, 'bot.invalid_input', 'name the person this change is for');
+ const target = subject ? await roleOf(robotId, subject) : null;
+ if (subject && !target) fail(404, 'bot.not_an_operator', 'that person is not on this robot');
+ if (action === 'toggle') {
+ if (actorRole !== 'owner' && actorRole !== 'mod') fail(403, 'bot.mod_limited', 'only the owner or a Mod may switch people on or off');
+ if (target === 'owner') fail(403, 'bot.owner_always_on', 'the owner is always on and cannot be switched off');
+ if (target !== 'operator' && target !== 'mod') fail(422, 'bot.invalid_input', 'switches are for Operators and Mods');
+ if (actorRole === 'mod' && target !== 'operator') fail(403, 'bot.mod_limited', 'Mods switch Operators on or off; only the owner switches Mods');
+ if (typeof enabled !== 'boolean') fail(422, 'bot.invalid_input', 'a switch needs on: true or false');
+ await setMemberEnabled(robotId, subject, enabled, actorSubject, actorRole, target);
+ return { action, subject, enabled, role: target };
+ }
+ if (action === 'sole') {
+ if (actorRole !== 'owner' && actorRole !== 'mod') fail(403, 'bot.mod_limited', 'only the owner or a Mod may change sole control');
+ if (subject != null) {
+ if (target === 'owner') fail(422, 'bot.invalid_input', 'the owner always overrides; sole control is for a Mod or an Operator');
+ if (!GRANT_ROLES.has(target)) fail(422, 'bot.invalid_input', 'sole control is for a Mod or an Operator');
+ const state = await operatorStateOf(robotId, subject);
+ if (!state || !state.enabled) fail(409, 'bot.operator_off', 'switch that person on before giving them sole control');
+ }
+ await setSole(robotId, subject == null ? null : subject, actorSubject);
+ await auditOperator({ robotId, actorSubject, actorKind: 'user', actorRole, action: 'operator.sole', subject: subject || null, role: target || null, value: subject ? { sole: subject } : { sole: null } });
+ return { action, subject: subject || null };
+ }
+ if (action === 'role') {
+ if (actorRole !== 'owner') fail(403, 'bot.owner_only', 'only the owner can promote or demote a Mod');
+ if (target === 'operator' && role === 'mod') { await setMemberRole(robotId, subject, 'mod', actorSubject, actorRole); return { action, subject, role: 'mod' }; }
+ if (target === 'mod' && role === 'operator') { await setMemberRole(robotId, subject, 'operator', actorSubject, actorRole); return { action, subject, role: 'operator' }; }
+ fail(422, 'bot.invalid_input', 'an Operator is promoted to Mod, or a Mod demoted to Operator');
+ }
+ // The panel accepts no speed, mode or Viewer-access change through this route, for anyone. The owner's own
+ // forms remain the only way, and a Mod is told exactly that.
+ if (action === 'viewer_access' || action === 'policy') fail(403, 'bot.mod_limited', 'Viewer access is the owner\'s switch');
+ fail(422, 'bot.invalid_input', `unknown operators action ${String(action).slice(0, 24)}`);
+ }
+
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
async function getRobot(id) {
async function getRobot(id) {
return db.maybe('SELECT * FROM robots WHERE id = $1', [id]);
return db.maybe('SELECT * FROM robots WHERE id = $1', [id]);
@@ -182,7 +300,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -182,7 +300,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
if (limits.allow && typeof limits.allow === 'object') {
if (limits.allow && typeof limits.allow === 'object') {
out.allow = {};
out.allow = {};
- for (const role of ['owner', 'operator', 'queue']) {
+ for (const role of ['owner', 'mod', 'operator', 'queue']) {
if (Array.isArray(limits.allow[role])) out.allow[role] = limits.allow[role].filter((k) => KINDS.has(k));
if (Array.isArray(limits.allow[role])) out.allow[role] = limits.allow[role].filter((k) => KINDS.has(k));
}
}
}
}
@@ -1076,10 +1194,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1076,10 +1194,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
// ── E-stop (ADR-043 decision 6) ───────────────────────────────────────────────────────────────
// ── E-stop (ADR-043 decision 6) ───────────────────────────────────────────────────────────────
- // `estop.set` only latches: a clear goes through clearEstop (owner only), so no device report and no other
- // caller can ever lift an owner's latch.
+ // `estop.set` only latches: a clear goes through clearEstop (the owner, or a Mod for the panel latch only), so
+ // no device report and no other caller can ever lift an owner's latch. The tray's local STOP and ARM are not
+ // this latch and are never touched here.
async function setEstop(robotId, { latched, by, principalKind = 'device' }) {
async function setEstop(robotId, { latched, by, principalKind = 'device' }) {
- if (!latched) fail(403, 'bot.forbidden', 'only the owner clears the e-stop');
+ if (!latched) fail(403, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');
return writeEstop(robotId, { latched: true, by, principalKind });
return writeEstop(robotId, { latched: true, by, principalKind });
}
}
async function writeEstop(robotId, { latched, by, principalKind }) {
async function writeEstop(robotId, { latched, by, principalKind }) {
@@ -1095,11 +1214,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1095,11 +1214,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
});
});
return updated;
return updated;
}
}
- async function clearEstop(robotId, owner) {
+ async function clearEstop(robotId, actor, role = 'owner') {
const robot = await getRobot(robotId);
const robot = await getRobot(robotId);
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
- if (robot.owner_subject !== owner) fail(403, 'bot.forbidden', 'only the owner clears the e-stop');
- return writeEstop(robotId, { latched: false, by: owner, principalKind: 'user' });
+ if (robot.owner_subject !== actor && role !== 'mod') fail(403, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');
+ return writeEstop(robotId, { latched: false, by: actor, principalKind: 'user' });
}
}
// ── Turn queue (ADR-043 decision 8) ───────────────────────────────────────────────────────────
// ── Turn queue (ADR-043 decision 8) ───────────────────────────────────────────────────────────
@@ -1348,7 +1467,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1348,7 +1467,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// Headlights are the owner's and People Operators' only, whatever an allow list says: never the queue,
// 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.
// 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)
const kinds = (fromOwner || DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profileTakes(profile, k)
- && (k !== 'headlight' || role === 'owner' || role === 'operator'));
+ && (k !== 'headlight' || role === 'owner' || role === 'mod' || role === 'operator'));
return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;
return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;
};
};
@@ -1473,10 +1592,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1473,10 +1592,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };
if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };
if (!profileTakes(profile, kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `this robot takes no ${kind} commands`, robot };
if (!profileTakes(profile, kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `this robot takes no ${kind} commands`, robot };
const halt = kind === 'halt';
const halt = kind === 'halt';
- if (robot.estop_latched && !halt) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; only the owner can clear it', robot };
+ if (robot.estop_latched && !halt) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; the owner or a Mod clears the panel e-stop', robot };
const subject = principal && principal.kind !== 'device' ? principal.subject : null;
const subject = principal && principal.kind !== 'device' ? principal.subject : null;
- let role = await roleOf(robotId, subject);
+ const member = await operatorStateOf(robotId, subject);
+ let role = member ? member.role : null;
if (!role && robot.access_policy === 'queue' && subject) {
if (!role && robot.access_policy === 'queue' && subject) {
const turn = await currentTurn(robotId);
const turn = await currentTurn(robotId);
if (turn && turn.subject === subject) role = 'queue';
if (turn && turn.subject === subject) role = 'queue';
@@ -1488,6 +1608,19 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1488,6 +1608,19 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (robot.access_policy === 'queue') role = 'queue';
if (robot.access_policy === 'queue') role = 'queue';
else return { ok: false, code: 'bot.read_only', reason: 'you are a viewer on this robot', robot, role };
else return { ok: false, code: 'bot.read_only', reason: 'you are a viewer on this robot', robot, role };
}
}
+ // A switched-off Operator or Mod is refused before the command reaches the device, with a message the panel
+ // shows; the owner is always on. This is read fresh on every command, so a toggle needs no reconnect.
+ if (role !== 'owner' && member && member.enabled === false) {
+ return { ok: false, code: 'bot.operator_off', reason: 'You are switched off on this robot. The owner or a Mod can switch you back on.', robot, role };
+ }
+ // Sole control: one authenticated subject's commands are admitted; the owner always overrides and a safety
+ // command (halt) always passes. This is a gate rule, not a motion kind, so it never renews a drive lease.
+ if (!halt && role !== 'owner' && subject) {
+ const policy = await operatorPolicy(robotId);
+ if (policy.sole_subject && policy.sole_subject !== subject) {
+ return { ok: false, code: 'bot.sole_control', reason: 'Sole control is on: only the person it was given to can send commands right now. The owner or a Mod can switch back to Everyone.', robot, role };
+ }
+ }
const allow = allowedFor(robot, role, profile);
const allow = allowedFor(robot, role, profile);
if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
if (!online) return { ok: false, code: 'bot.device_offline', reason: 'The robot is offline. Nothing you press waits for it: it moves only while it is connected.', robot, role };
if (!online) return { ok: false, code: 'bot.device_offline', reason: 'The robot is offline. Nothing you press waits for it: it moves only while it is connected.', robot, role };
@@ -1514,13 +1647,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1514,13 +1647,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const budget = await consumeTurn(robotId, subject);
const budget = await consumeTurn(robotId, subject);
if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : budget.code === 'bot.owner_control' ? PAUSED : 'it is not your turn', robot, role };
if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : budget.code === 'bot.owner_control' ? PAUSED : 'it is not your turn', robot, role };
}
}
- } else if ((role === 'owner' || role === 'operator') && robot.access_policy === 'queue' && subject) {
+ } else if ((role === 'owner' || role === 'mod' || role === 'operator') && robot.access_policy === 'queue' && subject) {
// 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.
// 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 };
+ if (kind === 'headlight' && role !== 'owner' && role !== 'mod' && role !== 'operator') {
+ return { ok: false, code: 'bot.command_not_allowed', reason: 'headlights are for the owner, Mods 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 }; }
@@ -1558,6 +1691,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1558,6 +1691,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
localProfile, saveLocalProfile, useCatalogueProfile },
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
+ operators: { stateOf: operatorStateOf, policy: operatorPolicy, list: listRoster, act: actOperator,
+ audit: auditOperator, auditList: listOperatorAudit, setSole, roles: OPERATOR_ROLES },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, setFleet, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange,
recordFirstRun, firstRunOf, firstRunFunnel },
recordFirstRun, firstRunOf, firstRunFunnel },
@@ -732,7 +732,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -732,7 +732,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
// A device reports its own latch. It may latch a robot it is attached to; it never clears one — latched:false
// A device reports its own latch. It may latch a robot it is attached to; it never clears one — latched:false
- // is recorded as the device's report only, and the owner's latch stands until the owner clears it.
+ // is recorded as the device's report only, and Bot's panel latch stands until the owner or a Mod clears it.
async function onEstopState(conn, msg) {
async function onEstopState(conn, msg) {
const attached = deviceRobotIds(conn.device);
const attached = deviceRobotIds(conn.device);
const latched = !!msg.latched;
const latched = !!msg.latched;
@@ -798,6 +798,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -798,6 +798,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'command': return onOperatorCommand(conn, msg);
case 'command': return onOperatorCommand(conn, msg);
case 'estop': return onOperatorEstop(conn, true);
case 'estop': return onOperatorEstop(conn, true);
case 'estop_clear': return onOperatorEstop(conn, false);
case 'estop_clear': return onOperatorEstop(conn, false);
+ case 'operators': return onOperatorsAction(conn, msg);
case 'give_back': return onGiveBack(conn);
case 'give_back': return onGiveBack(conn);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
}
}
@@ -825,6 +826,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -825,6 +826,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
robot: domain.present.robot(robot), role, profile,
robot: domain.present.robot(robot), role, profile,
allowed_commands: domain.control.allowedFor(robot, role, profile),
allowed_commands: domain.control.allowedFor(robot, role, profile),
state: await robotState(robotId, conn.subject),
state: await robotState(robotId, conn.subject),
+ operators: await operatorsState(robotId),
...(next ? { ticket: next.ticket } : {}),
...(next ? { ticket: next.ticket } : {}),
});
});
}
}
@@ -883,6 +885,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -883,6 +885,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
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());
+ // The Operators card's activity is Bot's own stamp: the subject, the channel and when. A lights command is
+ // non-motion and is shown as such. This is display truth; the gate above is what admitted the command.
+ noteOperatorActivity(robotId, conn.subject, decision.kind);
+ queueOperatorsState(robotId);
// A headlight is not motion: its short deadline must not renew a drive/head movement lease.
// 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));
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 };
@@ -1109,11 +1115,34 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1109,11 +1115,34 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
}
}
}
}
- /** `give_back`: an owner or operator who took over a queue turn hands it back now (it resumes with its time). */
+ /**
+ * One Operators card action (/control `operators`): toggle an operator, grant/clear sole control, promote or
+ * demote a Mod. The domain re-reads the actor's role and enforces the limits; the session is only a transport.
+ * Every path answers with an `operators_result` (never a silent drop), and an accepted change is broadcast at once.
+ */
+ async function onOperatorsAction(conn, msg) {
+ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
+ const action = String(msg.action || '');
+ const reply = (ok, fields = {}) => sendFrame(conn, 'operators_result', { id: msg.id != null ? String(msg.id) : null, action, ok, ...fields });
+ try {
+ const result = await domain.operators.act({
+ robotId: conn.robotId, actorSubject: conn.subject, action,
+ subject: msg.subject != null ? String(msg.subject).slice(0, 64) : null,
+ enabled: msg.on === true ? true : msg.on === false ? false : null,
+ role: msg.role != null ? String(msg.role) : null,
+ });
+ await queueOperatorsState(conn.robotId, true);
+ return reply(true, result);
+ } catch (e) {
+ return reply(false, { code: e.code || 'bot.forbidden', reason: e.detail || e.message });
+ }
+ }
+
+ /** `give_back`: an owner, Mod or operator who took over a queue turn hands it back now (it resumes with its time). */
async function onGiveBack(conn) {
async function onGiveBack(conn) {
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
const role = await domain.members.roleOf(conn.robotId, conn.subject);
const role = await domain.members.roleOf(conn.robotId, conn.subject);
- if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or an operator takes over and gives back');
+ if (!['owner', 'mod', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner, a Mod or an operator takes over and gives back');
await domain.queue.giveBack(conn.robotId);
await domain.queue.giveBack(conn.robotId);
broadcast(conn.robotId);
broadcast(conn.robotId);
return undefined;
return undefined;
@@ -1122,17 +1151,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1122,17 +1151,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
async function onOperatorEstop(conn, latched) {
async function onOperatorEstop(conn, latched) {
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
const robotId = conn.robotId;
const robotId = conn.robotId;
+ let role = null;
try {
try {
+ role = await domain.members.roleOf(robotId, conn.subject);
if (latched) {
if (latched) {
- const role = await domain.members.roleOf(robotId, conn.subject);
- if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only an operator may set the e-stop');
+ if (!['owner', 'mod', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only an operator, a Mod or the owner may set the panel e-stop');
await domain.estop.set(robotId, { latched: true, by: conn.subject, principalKind: conn.principal.kind });
await domain.estop.set(robotId, { latched: true, by: conn.subject, principalKind: conn.principal.kind });
} else {
} else {
- await domain.estop.clear(robotId, conn.subject); // owner only; throws otherwise
+ if (!['owner', 'mod'].includes(role)) return sendError(conn, 'bot.forbidden', 'only the owner or a Mod clears the panel e-stop');
+ // This clears the panel latch only. A device's local STOP latch (device_estop.local) and ARM stay on
+ // the tray; nothing here sends a STOP clear or an ARM to the machine.
+ await domain.estop.clear(robotId, conn.subject, role);
}
}
+ await domain.operators.audit({ robotId, actorSubject: conn.subject, actorKind: conn.principal.kind,
+ actorRole: role, action: latched ? 'estop.trip' : 'estop.clear', value: { latched: !!latched } });
} catch (e) { return sendError(conn, e.code || 'bot.forbidden', e.detail || e.message); }
} catch (e) { return sendError(conn, e.code || 'bot.forbidden', e.detail || e.message); }
await pushEstop(robotId, latched, conn.subject);
await pushEstop(robotId, latched, conn.subject);
broadcast(robotId);
broadcast(robotId);
+ await queueOperatorsState(robotId, true);
+ sendFrame(conn, 'operators_result', { action: latched ? 'estop.trip' : 'estop.clear', ok: true, latched: !!latched });
+ return undefined;
}
}
// ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────
// ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────
@@ -1233,14 +1271,82 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1233,14 +1271,82 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
- // A device may report battery the legacy way (a 0..1 fraction) or as OpenVibe Link does ({volts, percent}).
+ // A device may report battery the legacy way (a 0..1 fraction) or the Node's top-level percent (0..100), or as
+ // OpenVibe Link does ({volts, percent}). The LOOI tray's p7_battery.pct is a last resort, marked provisional.
+ // A missing reading is null, never 0: the panel shows a dash and "unknown" rather than a made-up empty battery.
+ const clampPct = (v) => Math.min(100, Math.max(0, Number(v)));
function batteryFraction(telemetry) {
function batteryFraction(telemetry) {
const b = telemetry ? telemetry.battery : null;
const b = telemetry ? telemetry.battery : null;
- if (Number.isFinite(b)) return b;
- if (b && typeof b === 'object' && Number.isFinite(b.percent)) return Math.min(1, Math.max(0, b.percent / 100));
+ if (Number.isFinite(b)) return b >= 0 && b <= 1 ? b : clampPct(b) / 100;
+ if (b && typeof b === 'object' && Number.isFinite(b.percent)) return clampPct(b.percent) / 100;
+ const p7 = telemetry && telemetry.sensors && telemetry.sensors.p7_battery;
+ if (p7 && typeof p7 === 'object' && Number.isFinite(p7.pct)) return clampPct(p7.pct) / 100;
return null;
return null;
}
}
+ // ── The Operators card (Maticus's 2026-11-12 LOOI spec) ─────────────────────────────────────────
+ // Bot stamps each admitted command itself, so the card's activity is Bot's own truth even before the tray
+ // publishes sensors.p7_commander. In memory: a restart loses "1s ago", never an authority or an audit row.
+ const operatorActivity = new Map(); // robot_id → Map(subject → { last, drive_at, head_at, lights_at })
+ const operatorsSentAt = new Map(); // robot_id → when the last operators_state went out (ms)
+ const operatorsTimer = new Map(); // robot_id → a pending throttled broadcast
+ const channelOf = (kind) => (kind === 'drive' ? 'drive' : kind === 'actuator' ? 'head' : kind === 'headlight' ? 'lights'
+ : kind === 'button' ? 'button' : kind === 'say' ? 'say' : kind === 'display' ? 'display' : kind === 'ptz' ? 'ptz' : 'other');
+ function noteOperatorActivity(robotId, subject, kind) {
+ if (!subject) return;
+ let bySubject = operatorActivity.get(robotId);
+ if (!bySubject) { bySubject = new Map(); operatorActivity.set(robotId, bySubject); }
+ const at = now();
+ const channel = channelOf(kind);
+ const rec = bySubject.get(subject) || { last: null, drive_at: null, head_at: null, lights_at: null };
+ rec.last = { kind, channel, at: iso(at) };
+ if (channel === 'drive') rec.drive_at = iso(at);
+ if (channel === 'head') rec.head_at = iso(at);
+ if (channel === 'lights') rec.lights_at = iso(at);
+ bySubject.set(subject, rec);
+ if (bySubject.size > 256) bySubject.delete(bySubject.keys().next().value);
+ }
+ function operatorActivityOf(robotId) {
+ const out = {};
+ for (const [subject, rec] of operatorActivity.get(robotId) || []) out[subject] = rec;
+ return out;
+ }
+ async function operatorsState(robotId) {
+ const [members, policy] = await Promise.all([domain.operators.list(robotId), domain.operators.policy(robotId)]);
+ return {
+ sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,
+ members: members.map((m) => ({ subject: m.subject, role: m.role, enabled: m.enabled !== false })),
+ viewers: members.filter((m) => m.role === 'viewer').length,
+ activity: operatorActivityOf(robotId),
+ now: iso(now()),
+ };
+ }
+ function sendOperatorsState(robotId) {
+ operatorsTimer.delete(robotId);
+ operatorsSentAt.set(robotId, now());
+ const subs = subsByRobot.get(robotId);
+ if (!subs || !subs.size) return;
+ operatorsState(robotId).then((state) => {
+ for (const sub of [...subs]) if (!sub.watcher) sendFrame(sub, 'operators_state', { operators: state });
+ }).catch((e) => log.warn(`[Bot] operators for ${robotId}: ${e.message}`));
+ }
+ /** Activity is broadcast at most twice a second; a real authority change forces one at once. */
+ function queueOperatorsState(robotId, force = false) {
+ if (force) {
+ const pending = operatorsTimer.get(robotId);
+ if (pending) { clearTimeout(pending); operatorsTimer.delete(robotId); }
+ return sendOperatorsState(robotId);
+ }
+ const since = now() - (operatorsSentAt.get(robotId) || 0);
+ if (since >= 500) return sendOperatorsState(robotId);
+ if (!operatorsTimer.has(robotId)) {
+ const t = setTimeout(() => sendOperatorsState(robotId), Math.max(0, 500 - since));
+ if (t.unref) t.unref();
+ operatorsTimer.set(robotId, t);
+ }
+ return undefined;
+ }
+
async function robotState(robotId, subjectForQueue = null) {
async function robotState(robotId, subjectForQueue = null) {
// The device's state is read before the first await, so each broadcast carries the frame that caused it
// The device's state is read before the first await, so each broadcast carries the frame that caused it
// (two telemetry frames with events back to back are both delivered, not the second one twice).
// (two telemetry frames with events back to back are both delivered, not the second one twice).
@@ -1286,6 +1392,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1286,6 +1392,9 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const sensors = t && t.sensors && typeof t.sensors === 'object' ? t.sensors : {};
const picked = {};
const picked = {};
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
for (const k of readouts) if (Object.prototype.hasOwnProperty.call(sensors, k)) picked[k] = sensors[k];
+ // The tray's provisional battery reading rides along so the panel can say "provisional, at connect" to a
+ // watcher too. It is a reading, never a command.
+ if (sensors.p7_battery !== undefined) picked.p7_battery = sensors.p7_battery;
// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.
// A simulated rover's pose and lights draw its camera (public/sim-view.js); a real device has no `sim`.
const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;
const sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;
return {
return {
@@ -1356,6 +1465,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1356,6 +1465,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
+ operatorActivity: operatorActivityOf,
machineCommand, machineScreen, computerOf, machineChanged,
machineCommand, machineScreen, computerOf, machineChanged,
// Network's grant relay and agent tasks (Contracts 0.409.0): close every relay under a revoked grant, and
// Network's grant relay and agent tasks (Contracts 0.409.0): close every relay under a revoked grant, and
// hand an agent.task to the machine it names.
// hand an agent.task to the machine it names.
@@ -221,7 +221,7 @@ ${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p
@@ -221,7 +221,7 @@ ${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-link profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-link profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
-<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
+<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop the owner or a Mod clears (the tray's own STOP and ARM stay local). The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
}
}
@@ -162,7 +162,7 @@ function moreSection({ config, kit: k, models }) {
@@ -162,7 +162,7 @@ function moreSection({ config, kit: k, models }) {
const base = config.baseUrl;
const base = config.baseUrl;
const panel = rows([
const panel = rows([
['/try', 'Real controls', 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.'],
['/try', 'Real controls', 'A touch joystick, the keyboard or a gamepad, plus buttons and sliders, laid out from the robot\'s profile, with a latency meter.'],
- ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a latched emergency stop that only the owner clears; a halt always gets through.'],
+ ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a latched emergency stop that the owner or a Mod clears (the panel latch only); a halt always gets through.'],
['/robots', 'Every command on the record', 'Allowed or refused, each command is audited and kept for 30 days.'],
['/robots', 'Every command on the record', 'Allowed or refused, each command is audited and kept for 30 days.'],
['/robots', 'Share control', 'Add people by @username as operators or viewers, or open a queue where visitors take turns.'],
['/robots', 'Share control', 'Add people by @username as operators or viewers, or open a queue where visitors take turns.'],
['https://openvibe.live/', 'Show it on your channel', 'Bind the robot to your OpenVibe.Live channel: its panel shows under your stream, and anyone allowed takes the controls there.'],
['https://openvibe.live/', 'Show it on your channel', 'Bind the robot to your OpenVibe.Live channel: its panel shows under your stream, and anyone allowed takes the controls there.'],
@@ -225,7 +225,7 @@ function faqItems({ models }) {
@@ -225,7 +225,7 @@ function faqItems({ models }) {
{ q: 'Do I need to open ports on my router?', a: 'No. The robot\'s computer connects out to OpenVibe, so it works behind an ordinary home or mobile connection. The Adeept kit\'s own control server, with its fixed password, is never run.' },
{ q: 'Do I need to open ports on my router?', a: 'No. The robot\'s computer connects out to OpenVibe, so it works behind an ordinary home or mobile connection. The Adeept kit\'s own control server, with its fixed password, is never run.' },
{ q: 'Can I try it without a robot?', a: 'Yes. The simulated rover at openvibe.bot/try runs in your browser with no account and no hardware, through the same panel a paired robot gets.' },
{ q: 'Can I try it without a robot?', a: 'Yes. The simulated rover at openvibe.bot/try runs in your browser with no account and no hardware, through the same panel a paired robot gets.' },
list ? { q: 'Which robots work today?', a: `${list}. Each has a profile the panel draws its controls from, and anything a local script can switch works through the relay robot.` } : null,
list ? { q: 'Which robots work today?', a: `${list}. Each has a profile the panel draws its controls from, and anything a local script can switch works through the relay robot.` } : null,
- { q: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. The emergency stop is latched until the owner clears it, a stop pressed on the robot holds until it is released there, and the device stops its motors by itself when commands stop arriving.' },
+ { q: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. The emergency stop is latched until the owner or a Mod clears the panel latch, a stop pressed on the robot holds until it is released there, and the device stops its motors by itself when commands stop arriving.' },
{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },
{ q: 'Can other people drive my robot?', a: 'Only if you say so. Add people by @username as operators or viewers, or open a queue where signed-in visitors take timed turns. Listing a robot in the directory lets anyone watch it.' },
{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },
{ q: 'Can I show it on my stream?', a: 'Yes. Bind the robot to your OpenVibe.Live channel and its panel shows under your stream, where anyone you allow can take the controls.' },
{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },
{ q: 'Can my AI agent drive it?', a: 'Only when you turn on Your agent for that robot. Then your OpenVibe.Actor agent can read its state and run steps as you, through the same gate, and for nobody else.' },
@@ -30,14 +30,14 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -30,14 +30,14 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
const data = (v) => esc(JSON.stringify(v));
const data = (v) => esc(JSON.stringify(v));
-/** The e-stop is the owner's and an operator's; only the owner clears it (the gate enforces both). */
-const ESTOP_ROLES = new Set(['owner', 'operator']);
+/** The panel e-stop is the owner's, a Mod's and an operator's; the owner or a Mod clears it (the gate enforces it). */
+const ESTOP_ROLES = new Set(['owner', 'mod', 'operator']);
/** The drive joystick's two axes, by preference: horizontal and vertical. The rest stay hold buttons. */
/** The drive joystick's two axes, by preference: horizontal and vertical. The rest stay hold buttons. */
const STICK_X = ['steer', 'x', 'rotation'];
const STICK_X = ['steer', 'x', 'rotation'];
const STICK_Y = ['throttle', 'y'];
const STICK_Y = ['throttle', 'y'];
const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];
const POLICIES = [['private', 'Only me and people I add'], ['invite', 'People I invite'], ['queue', 'Anyone, taking turns']];
const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));
const POLICY_LABEL = Object.fromEntries(POLICIES.map(([v]) => [v, { private: 'Private', invite: 'Invite only', queue: 'Open queue' }[v]]));
-const ROLE_LABEL = { owner: 'Owner', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };
+const ROLE_LABEL = { owner: 'Owner', mod: 'Mod', operator: 'Operator', viewer: 'Viewer', queue: 'In the queue', watcher: 'Watching' };
// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────
// ── Icons: 24×24 strokes in currentColor, decorative (the text beside them is the label) ─────────────────────
const PATHS = {
const PATHS = {
@@ -63,6 +63,16 @@ const PATHS = {
@@ -63,6 +63,16 @@ const PATHS = {
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"/>',
};
};
const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
const icon = (name, cls = 'icon') => `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${PATHS[name] || ''}</svg>`;
+// The network's one glyph registry (openvibe-shared/glyphs): a Font Awesome name or a glyph name, drawn in the
+// panel's currentColor. The old inline PATHS stay for the controls they already draw.
+let glyphs = null;
+try { glyphs = require('openvibe-shared/glyphs'); } catch { /* an older shared pin: the inline PATHS only */ }
+const glyphIcon = (name, cls = 'icon') => {
+ if (!glyphs) return icon(name, cls);
+ const found = glyphs.path(name) ? name : glyphs.fromFa(name);
+ const d = found ? glyphs.path(found) : '';
+ return d ? `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>` : icon(name, cls);
+};
/** Hold-button icons by axis: [negative, positive]. */
/** Hold-button icons by axis: [negative, positive]. */
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_ICONS = { throttle: ['down', 'up'], y: ['down', 'up'], steer: ['left', 'right'], x: ['left', 'right'], rotation: ['ccw', 'cw'], pan: ['left', 'right'], tilt: ['down', 'up'], zoom: ['minus', 'plus'] };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
const AXIS_NAME = { throttle: 'Forward / back', y: 'Forward / back', steer: 'Steer', x: 'Sideways', rotation: 'Turn on the spot', pan: 'Pan', tilt: 'Tilt', zoom: 'Zoom' };
@@ -312,14 +322,22 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -312,14 +322,22 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
inner = `<output class="readout" data-telemetry="${esc(w.capability || '')}">—</output>`;
break;
break;
case 'battery':
case 'battery':
- inner = '<div class="battery"><meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter> <output class="readout" data-battery-text>—</output></div>';
+ // The LOOI status row: a big percentage, a fill bar, a dash and the word "unknown" when there is no
+ // reading (never 0 %), and a quiet provisional note when the tray sent an unverified FED8 read.
+ inner = '<div class="battery">'
+ + '<div class="battery-head"><output class="readout" data-battery-text>—</output><span class="battery-note" data-battery-note>unknown</span></div>'
+ + '<meter min="0" max="1" low="0.25" high="0.6" optimum="1" data-battery></meter>'
+ + '<span class="battery-provisional" data-battery-provisional hidden>provisional, at connect</span>'
+ + '</div>';
break;
break;
case 'latency':
case 'latency':
- inner = '<div class="stats">'
- + '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Round trip, median</span></div>'
- + '<div class="stat"><output class="readout" data-telemetry-age data-quality="none">—</output><span class="stat-label">Telemetry age</span></div>'
- + '<div class="stat"><output class="readout" data-link>—</output><span class="stat-label">Device link</span></div>'
- + '</div>';
+ inner = profile.id === 'looi'
+ ? '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Latency, round trip</span></div>'
+ : '<div class="stats">'
+ + '<div class="stat"><output class="readout" data-latency data-quality="none">—</output><span class="stat-label">Round trip, median</span></div>'
+ + '<div class="stat"><output class="readout" data-telemetry-age data-quality="none">—</output><span class="stat-label">Telemetry age</span></div>'
+ + '<div class="stat"><output class="readout" data-link>—</output><span class="stat-label">Device link</span></div>'
+ + '</div>';
break;
break;
case 'camera':
case 'camera':
inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url,
inner = `<div class="cameras">${cameraTiles(w, profile, signaling_url,
@@ -530,7 +548,7 @@ const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(h
@@ -530,7 +548,7 @@ const outLink = (href, text, cls = 'button') => `<a class="${cls}" href="${esc(h
* Rendered only for the owner's own page.
* Rendered only for the owner's own page.
*/
*/
function peopleCard(robot, people) {
function peopleCard(robot, people) {
- const roleName = (r) => (r === 'viewer' ? 'Viewer' : 'Operator');
+ const roleName = (r) => ({ viewer: 'Viewer', mod: 'Mod', operator: 'Operator' }[r] || 'Operator');
const row = (p) => {
const row = (p) => {
const remove = `<form method="post" action="/robots/${esc(robot.id)}/operators/${esc(encodeURIComponent(p.subject))}/remove"><button type="submit" class="quiet">Remove</button></form>`;
const remove = `<form method="post" action="/robots/${esc(robot.id)}/operators/${esc(encodeURIComponent(p.subject))}/remove"><button type="submit" class="quiet">Remove</button></form>`;
const who = p.username
const who = p.username
@@ -549,6 +567,94 @@ ${(people || []).map(row).join('\n')}
@@ -549,6 +567,94 @@ ${(people || []).map(row).join('\n')}
</section>`;
</section>`;
}
}
+/**
+ * The Operators card (Maticus's 2026-11-12 LOOI spec): the owner, every Mod and Operator with a live activity
+ * indicator and last command, the viewers' count, sole control, the per-person switches, promotion/demotion and
+ * the panel E-STOP. The server renders the whole roster; public/panel.js keeps it current from `operators_state`.
+ * Actions are in the panel's /control socket and are checked again in server/domain/index.js, never here.
+ */
+function operatorsCard(robot, operators, role, viewer = null) {
+ if (!operators) return '';
+ const canManage = role === 'owner' || role === 'mod';
+ const canRole = role === 'owner';
+ const memberName = (m) => {
+ if (!m) return 'Everyone';
+ if (m.role === 'owner' && (!m.username || !m.display_name)) return 'the owner';
+ return m.display_name || (m.username ? `@${m.username}` : m.subject);
+ };
+ const handle = (m) => (m.username ? `@${m.username}` : (m.display_name || String(m.subject).slice(0, 12)));
+ const initial = (m) => String(m.display_name || m.username || m.subject || '?').trim().charAt(0).toUpperCase() || '?';
+ const roleTag = { owner: 'OWNER', mod: 'MOD', operator: 'OPERATOR', viewer: 'VIEWER' };
+ const sole = operators.sole;
+ const soleName = sole ? memberName((operators.members || []).find((m) => m.subject === sole.subject)) : null;
+ const isMe = (m) => !!(viewer && m.subject === viewer.subject);
+ const toggle = (m) => {
+ const canToggle = m.role !== 'owner' && ((role === 'owner' && (m.role === 'operator' || m.role === 'mod'))
+ || (role === 'mod' && m.role === 'operator'));
+ const checked = m.role === 'owner' ? ' checked' : (m.enabled !== false ? ' checked' : '');
+ const disabled = canToggle ? '' : ' disabled';
+ const title = m.role === 'owner' ? 'The owner is always on' : canToggle ? (m.enabled !== false ? 'Switch this person off' : 'Switch this person on') : 'Only the owner switches Mods; the owner and Mods switch Operators';
+ return `<label class="ops-switch" title="${esc(title)}"><input type="checkbox" data-operator-toggle data-subject="${esc(m.subject)}"${checked}${disabled}><span class="ops-switch-track" aria-hidden="true"></span></label>`;
+ };
+ const soleButton = (m) => {
+ if (!canManage || m.role === 'owner') return '';
+ const active = !!(sole && sole.subject === m.subject);
+ return `<button type="button" class="ops-sole${active ? ' is-on' : ''}" data-operator-sole="${esc(m.subject)}" data-sole-active="${active ? 'true' : 'false'}"${m.enabled === false ? ' disabled' : ''}>${active ? 'Sole · on' : 'Sole control'}</button>`;
+ };
+ const roleButton = (m) => {
+ if (!canRole || (m.role !== 'operator' && m.role !== 'mod')) return '';
+ const next = m.role === 'operator' ? 'mod' : 'operator';
+ const label = m.role === 'operator' ? 'Make Mod' : 'Make Operator';
+ const title = m.role === 'operator' ? 'Promote this Operator to Mod' : 'Demote this Mod to Operator';
+ return `<button type="button" class="ops-role-btn" data-operator-role-set="${next}" data-operator-role data-subject="${esc(m.subject)}" title="${esc(title)}">${label}</button>`;
+ };
+ const activity = (m) => {
+ if (m.role === 'viewer' || m.role === 'owner') return '';
+ return `<span class="ops-live" data-operator-activity hidden><span class="dot"></span>Live</span>`
+ + `<small class="ops-last" data-operator-last>No command yet</small>`
+ + `<small class="ops-head-state" data-operator-head>Head idle</small>`;
+ };
+ const row = (m) => {
+ const you = isMe(m) && m.role !== 'owner';
+ const tag = m.role === 'owner' ? (isMe(m) ? 'OWNER · YOU' : 'OWNER') : (roleTag[m.role] || m.role);
+ const sub = m.role === 'owner'
+ ? 'Always has override, local STOP, and ARM'
+ : m.role === 'mod'
+ ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'
+ : 'Can drive and tilt the head while switched on.';
+ return `<li class="ops-row ops-${esc(m.role)}" data-operator-subject="${esc(m.subject)}" data-operator-role="${esc(m.role)}" data-operator-enabled="${m.enabled !== false ? 'true' : 'false'}" data-operator-you="${isMe(m) ? 'true' : 'false'}" data-operator-name="${esc(handle(m))}">`
+ + `<span class="ops-avatar" aria-hidden="true">${esc(initial(m))}</span>`
+ + `<div class="ops-who"><p class="ops-name"><b>${esc(handle(m))}</b>${you ? ' <span class="ops-you">(you)</span>' : ''}`
+ + `<span class="ops-role" data-operator-role-tag>${esc(tag)}</span>${activity(m)}</p></div>`
+ + `<div class="ops-actions">${soleButton(m)}${toggle(m)}</div>`
+ + `<p class="ops-sub"><span data-operator-sub>${esc(sub)}</span>${roleButton(m)}</p></li>`;
+ };
+ const viewers = (operators.members || []).filter((m) => m.role === 'viewer');
+ const viewerRow = `<li class="ops-row ops-viewers"><span class="ops-avatar ops-count" aria-hidden="true">${esc(String(viewers.length))}</span>`
+ + `<div class="ops-who"><p class="ops-name"><b>Viewers</b> <span class="pill">${esc(String(viewers.length))} watching</span></p>`
+ + '<p class="ops-sub">Can watch only. No drive, head, lights, or speed/mode changes.</p></div>'
+ + `<span class="pill">${robot.embed_public ? 'Watching' : 'Hidden'}</span></li>`;
+ const latched = !!(robot.estop && robot.estop.latched);
+ const estopNote = latched
+ ? '<b>Panel E-STOP is tripped.</b> A Mod may clear it. That never clears the tray STOP latch or ARM; the robot cannot move until the owner clears STOP and re-arms on the tray.'
+ : '<b>Panel E-STOP is clear.</b> A Mod may trip it, or clear it after a panel stop. That never clears the tray STOP latch or ARM.';
+ const estopButton = canManage
+ ? `<button type="button" class="ops-estop-btn" data-operators-estop>${glyphIcon('stop')}<span>${latched ? 'Clear panel E-STOP' : 'Trip panel E-STOP'}</span></button>` : '';
+ const rows = (operators.members || []).filter((m) => m.role !== 'viewer').map(row).join('\n');
+ return `<section class="widget widget-operators" data-widget="operators" data-operators data-can-manage="${canManage ? 'true' : 'false'}" data-can-role="${canRole ? 'true' : 'false'}">
+<h2>${glyphIcon('users')} <span>Operators</span></h2>
+<div class="ops-head"><p class="ops-lede">People on this robot. Owner and Mods can hand sole control or switch someone off.${canRole ? ' Only you can promote a Mod, or change speed, mode, and Viewer access.' : ''}</p>
+<span class="pill ops-mode" data-operators-mode>${sole ? `Sole: ${esc(soleName)}` : 'Everyone'}</span>
+${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${sole ? '' : ' hidden'}>Everyone</button>` : ''}</div>
+<ul class="ops-list">
+${rows}
+${viewerRow}
+</ul>
+<div class="ops-estop" data-operators-estop-box data-latched="${latched ? 'true' : 'false'}"><p data-operators-estop-note>${estopNote}</p>${estopButton}</div>
+<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The robot still cannot move until you Clear STOP and ARM on the tray. Owner always overrides.</p>
+</section>`;
+}
+
/**
/**
* The turn strip on a `queue` robot's panel. A signed-in non-member sees the active turn's countdown or their place in
* The turn strip on a `queue` robot's panel. A signed-in non-member sees the active turn's countdown or their place in
* line, with a plain leave form that works without JavaScript. The owner and operators see it only while their own
* line, with a plain leave form that works without JavaScript. The owner and operators see it only while their own
@@ -715,7 +821,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
@@ -715,7 +821,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}${dataScript}</main>`;
return `<main id="panel" class="bot-surface" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}" data-bot-view="${esc(view)}"${parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>${body}${dataScript}</main>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', view = 'panel', signedIn = true, streaming = null, people = null, operators = null, viewer = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null, parentOrigins = [], liveUrl = null, limitsInfo = null, arrange = null }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const botView = embed ? botViewOf(view) : 'panel';
const botView = embed ? botViewOf(view) : 'panel';
if (embed && botView !== 'panel') {
if (embed && botView !== 'panel') {
@@ -751,17 +857,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -751,17 +857,32 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${attrsOf(['stop']).stop || ''}>`
widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${attrsOf(['stop']).stop || ''}>`
+ `<h2>Stop</h2><button type="button" class="stop" data-stop>${icon('square')}<span>Stop</span></button></section>`);
+ `<h2>Stop</h2><button type="button" class="stop" data-stop>${icon('square')}<span>Stop</span></button></section>`);
}
}
+ // Maticus's LOOI status row: Battery | Latency, one compact row (public/panel.css). The server groups the two
+ // widgets when the person has not arranged the panel; an arranged layout keeps their separate grid cells.
+ if (!arrangeApply && profile.id === 'looi') {
+ const batteryAt = widgets.findIndex((html) => html.includes('data-widget="battery"'));
+ const latencyAt = widgets.findIndex((html) => html.includes('data-widget="latency"'));
+ if (batteryAt >= 0 && latencyAt >= 0) {
+ const pair = [widgets[batteryAt], widgets[latencyAt]];
+ const rest = widgets.filter((_, i) => i !== batteryAt && i !== latencyAt);
+ rest.splice(Math.min(batteryAt, rest.length), 0, `<div class="status-row" data-status-row>${pair.join('')}</div>`);
+ widgets.splice(0, widgets.length, ...rest);
+ }
+ }
+ // The Operators card sits under the controls, inside the same panel grid. A watcher has no roster and no card.
+ const operatorsHtml = profile.id === 'looi' ? operatorsCard(robot, operators, role, viewer) : '';
+ if (operatorsHtml) widgets.push(operatorsHtml);
// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.
// A framed watcher may take the controls with Drive; its e-stop waits, hidden, for an owner's or operator's join.
const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`
const estop = ESTOP_ROLES.has(role) ? `<button type="button" class="estop" data-estop>${icon('stop')}<span>E-stop</span></button>`
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');
: (embed && role === 'watcher' ? `<button type="button" class="estop" data-estop hidden>${icon('stop')}<span>E-stop</span></button>` : '');
- const clear = role === 'owner' ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
+ const clear = (role === 'owner' || role === 'mod') ? `<button type="button" class="estop-clear" data-estop-clear${latched ? '' : ' hidden'}>Clear e-stop</button>` : '';
const arrangeButton = arrange && arrange.src && role === 'owner'
const arrangeButton = arrange && arrange.src && role === 'owner'
? `<button type="button" class="arrange-btn" data-arrange data-arrange-src="${esc(arrange.src || '')}" title="Arrange the controls">${icon('sliders')}<span>Arrange</span></button>` : '';
? `<button type="button" class="arrange-btn" data-arrange data-arrange-src="${esc(arrange.src || '')}" title="Arrange the controls">${icon('sliders')}<span>Arrange</span></button>` : '';
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
const body = `<header class="estop-banner" data-estop-banner data-latched="${latched}">
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${embed ? '' : tryIt ? topbar('<span class="crumb">Try the panel</span>', '/') : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
-<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner clears it.' : ''}</span></p>${clear}${estop}</div>
+<div class="estop-bar"><p class="estop-state" role="status" aria-live="assertive"><span class="estop-dot"></span><span data-estop-state>${latched ? 'E-stop latched' : 'E-stop clear'}</span><span class="estop-note">${latched ? ' — the robot stays still until the owner or a Mod clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
</header>
-<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''} data-bot-view="${esc(botView)}"${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
+<main id="panel" tabindex="0" data-robot-id="${esc(robot.id)}" data-profile-id="${esc(profile.id || '')}" data-role="${esc(role)}" data-hold-ms="${esc(holdResendMs)}" data-allowed="${data(allowed_commands)}"${tryIt ? ' data-transport="local"' : ''} data-bot-view="${esc(botView)}"${embed && parentOrigins.length ? ` data-parent-origins="${esc(parentOrigins.join(' '))}"` : ''}>
<div class="panel-head">
<div class="panel-head">
${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
${kit.pageHead({ title: `${robot.name}`, icon: 'gear', back: embed ? null : { href: '/', label: 'Home' } })}
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
<p class="meta"><span class="pill online" data-online-pill data-state="connecting"><span class="dot"></span><span data-online-label>Connecting…</span></span> <span class="pill">${esc(profile.name || robot.profile_id)}</span> <span class="pill role" data-role-label>${esc(ROLE_LABEL[role] || role)}</span></p>
@@ -86,7 +86,7 @@ const LLMS_PAGES = [
@@ -86,7 +86,7 @@ const LLMS_PAGES = [
{
{
path: '/', title: 'OpenVibe.Bot — drive your robot from any browser',
path: '/', title: 'OpenVibe.Bot — drive your robot from any browser',
note: 'What Bot is, and how a robot goes from added to driving.',
note: 'What Bot is, and how a robot goes from added to driving.',
- text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that only the owner clears; a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',
+ text: 'OpenVibe.Bot is the open control panel for robots. Add a robot and pick its profile, pair it by pasting one command on the robot\'s computer, then drive it from any browser. The panel lays its controls out from the robot\'s profile: a touch joystick, the keyboard, a gamepad, buttons and sliders, with a latency meter. Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget, and a latched emergency stop that the owner or a Mod clears (the panel latch only; the tray\'s STOP and ARM stay local); a stop pressed on the robot itself holds until it is released there. An owner can add people by username as operators or viewers, or open a queue where visitors take turns. A simulated rover runs inside Bot, so the whole panel can be tried before any hardware exists.',
},
},
{
{
path: '/try', title: 'Try the panel with a simulated rover',
path: '/try', title: 'Try the panel with a simulated rover',
@@ -936,9 +936,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -936,9 +936,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
? null
? null
: domain.video.live(robot.id).catch((e) => { log.warn(`[Bot] live video for ${robot.id}: ${e.message}`); return null; }),
: domain.video.live(robot.id).catch((e) => { log.warn(`[Bot] live video for ${robot.id}: ${e.message}`); return null; }),
]);
]);
- // The owner's People card: every member but the owner, with Network's name for each. A Network that
- // does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.
- const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
+ // The People and Operators cards: every member, with Network's name where it answers. A Network that does
+ // not answer leaves the subject id and a note (see renderPanel) instead of failing the panel. Anonymous
+ // watchers have no roster.
+ let roster = null;
+ if (role !== 'watcher') {
+ try { roster = await rosterFor(robot); } catch (e) { log.warn(`[Bot] roster for ${robot.id}: ${e.message}`); }
+ }
+ const people = role === 'owner' && mode === 'page' && roster ? roster.members.filter((m) => m.role !== 'owner') : null;
// The owner's routines and webhooks (server/routines.js, server/webhooks.js), with each hook's last delivery.
// The owner's routines and webhooks (server/routines.js, server/webhooks.js), with each hook's last delivery.
let automation = null;
let automation = null;
if (role === 'owner' && mode === 'page' && routines && webhooks) {
if (role === 'owner' && mode === 'page' && routines && webhooks) {
@@ -997,6 +1002,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -997,6 +1002,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}
}
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, view, signedIn, streaming, people, signaling_url,
+ operators: roster, viewer: req.viewer ? { subject: req.viewer.subject, username: req.viewer.username || null, name: req.viewer.name || null } : null,
profileForm, automation, limitsInfo,
profileForm, automation, limitsInfo,
// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
// The page that frames the embed may hand it a Drive ticket (public/panel.js); the owner's page offers to put
// the panel under their Live stream.
// the panel under their Live stream.
@@ -1067,18 +1073,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -1067,18 +1073,31 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, `/panel/${robot.id}`);
return res.redirect(303, `/panel/${robot.id}`);
}));
}));
- /** The non-owner members with their Network names, or null names when Network cannot be asked. */
- async function peopleFor(robot) {
- const members = (await domain.members.list(robot.id)).filter((m) => m.subject !== robot.owner_subject);
+ /** The Operators card's initial roster: every member, Network's names, the sole-control policy and live activity. */
+ async function rosterFor(robot) {
+ let members = await domain.members.list(robot.id);
+ if (!members.some((m) => m.role === 'owner')) {
+ members = [{ subject: robot.owner_subject, role: 'owner', enabled: true, added_by: null, created_at: null }, ...members];
+ }
let names = new Map();
let names = new Map();
if (members.length && identity) {
if (members.length && identity) {
try { names = await identity.names(members.map((m) => m.subject)); }
try { names = await identity.names(members.map((m) => m.subject)); }
catch (e) { log.warn(`[Bot] names for ${robot.id}: ${e.message}`); }
catch (e) { log.warn(`[Bot] names for ${robot.id}: ${e.message}`); }
}
}
- return members.map((m) => {
+ const policy = await domain.operators.policy(robot.id);
+ const rows = members.map((m) => {
const n = names.get(m.subject);
const n = names.get(m.subject);
- return { subject: m.subject, role: m.role, username: n ? n.username : null, display_name: n ? n.display_name : null };
+ return {
+ subject: m.subject, role: m.role, enabled: m.role === 'owner' ? true : m.enabled !== false,
+ username: n ? n.username : null, display_name: n ? n.display_name : null,
+ };
});
});
+ return {
+ sole: policy.sole_subject ? { subject: policy.sole_subject, by: policy.sole_by, at: policy.sole_at } : null,
+ members: rows, viewers: rows.filter((m) => m.role === 'viewer').length,
+ activity: hub && hub.operatorActivity ? hub.operatorActivity(robot.id) : {},
+ now: new Date().toISOString(),
+ };
}
}
r.get('/panel/:id', page(async (req, res) => {
r.get('/panel/:id', page(async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,267 @@
@@ -0,0 +1,267 @@
+'use strict';
+// The LOOI Operators card and battery (Maticus's 2026-11-12 spec): a Mod role, per-person on/off, sole control,
+// the panel E-STOP that never touches the tray's local STOP/ARM, authority audit, and the battery bound to the
+// Node's top-level `battery` (0..100). No hardware: the fake device and the real 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({ env: { BOT_HEARTBEAT_MS: '60000', BOT_OFFLINE_MISSES: '100' } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const carol = t.network.newUser('carol');
+ const dave = t.network.newUser('dave');
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
+ 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);
+ const opResult = (client, id) => client.waitFor((m) => m.type === 'operators_result' && m.id === id);
+ let seq = 0;
+ const nextId = (p) => `${p}-${++seq}`;
+ 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}` } });
+ await dev.waitFor((m) => m.type === 'hello');
+ await dev.waitFor((m) => m.type === 'config');
+ return dev;
+ }
+ async function act(client, action, fields = {}) {
+ const id = nextId(`ops-${action}`);
+ client.send({ type: 'operators', id, action, ...fields });
+ return opResult(client, id);
+ }
+ async function promotedMod(client, robotId, subject) {
+ const r = await act(client, 'role', { subject, role: 'mod' });
+ assert.strictEqual(r.ok, true, r.reason);
+ return r;
+ }
+ /** Send a drive, wait for the device's copy, ack it, and return the operator's result. */
+ async function drive(client, dev, id, extra = {}) {
+ cmd(client, { id, kind: 'drive', value: { throttle: 0.2, steer: 0 }, ...extra });
+ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === id);
+ if (!c) throw new Error(`no device command for ${id}`);
+ dev.send({ type: 'ack', id: c.id });
+ return result(client, id);
+ }
+ const looi = loadProfiles().get('looi');
+
+ await check('the battery reads the Node top-level percent (0..100), a dash/unknown when missing, and p7 provisional', async () => {
+ 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);
+ dev.send({ type: 'telemetry', battery: 42, sensors: { ultrasonic: 120 } });
+ const st = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 42);
+ assert.strictEqual(st.state.battery, 0.42, '42 % becomes the 0..1 fraction the meter draws');
+ const html = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(html.includes('data-status-row'), 'Battery | Latency is one compact status row');
+ assert.ok(html.includes('data-battery-note>unknown'), 'the initial meter says unknown, never 0 %');
+ assert.ok(html.includes('data-battery-provisional'), 'the provisional note is in the markup');
+ // A full 100 is 100 %, not 1 %: a number above 1 is a percent, not the legacy fraction.
+ dev.send({ type: 'telemetry', battery: 100, events: [{ kind: 'battery_full' }], sensors: { p7_battery: { pct: 100, usb: true, raw: '6401', unverified: true } } });
+ const full = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 100);
+ assert.strictEqual(full.state.battery, 1);
+ assert.strictEqual(full.state.telemetry.sensors.p7_battery.unverified, true, 'the raw provisional reading reaches the panel state');
+ // Missing is null (a dash and an empty bar), never a made-up 0.
+ dev.send({ type: 'telemetry', events: [{ kind: 'battery_clear' }], sensors: { cleared: true } });
+ const none = await a.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.sensors && m.state.telemetry.sensors.cleared === true);
+ assert.strictEqual(none.state.battery, null, 'no battery field is null, not 0');
+ a.close(); dev.close();
+ });
+
+ await check('sole control admits only that subject; others are refused with a visible message; the owner overrides', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ await invite(robot.id, carol.subject, 'operator');
+ const dev = await deviceFor(robot.id, pairing.code);
+ const a = await op(alex); const b = await op(bob); const c = await op(carol);
+ await join(a, robot.id); await join(b, robot.id); await join(c, robot.id);
+ const granted = await act(a, 'sole', { subject: bob.subject });
+ assert.strictEqual(granted.ok, true, granted.reason);
+ const bobDrive = await drive(b, dev, 'sole-bob');
+ assert.strictEqual(bobDrive.result, 'ack');
+ cmd(c, { id: 'sole-carol', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ const refused = await result(c, 'sole-carol');
+ assert.strictEqual(refused.result, 'refused');
+ assert.strictEqual(refused.code, 'bot.sole_control');
+ assert.strictEqual(refused.reason, 'Sole control is on: only the person it was given to can send commands right now. The owner or a Mod can switch back to Everyone.');
+ // Owner always overrides, including while someone else has sole control.
+ const ownerDrive = await drive(a, dev, 'sole-owner');
+ assert.strictEqual(ownerDrive.result, 'ack');
+ // halt always passes: safety is not blocked by sole control.
+ cmd(c, { id: 'sole-halt', kind: 'halt', value: {} });
+ const halt = await dev.waitFor((m) => m.type === 'command' && m.ref === 'sole-halt');
+ assert.ok(halt, 'a halt reaches the device even under sole control');
+ dev.send({ type: 'ack', id: halt.id });
+ // Everyone gives shared control back to all enabled operators.
+ const cleared = await act(a, 'sole', { subject: null });
+ assert.strictEqual(cleared.ok, true);
+ const carolDrive = await drive(c, dev, 'sole-carol-2');
+ assert.strictEqual(carolDrive.result, 'ack');
+ a.close(); b.close(); c.close(); dev.close();
+ });
+
+ await check('an Operator switched off is refused immediately; Mods switch Operators, the owner switches Mods', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ await invite(robot.id, carol.subject, 'operator');
+ const dev = await deviceFor(robot.id, pairing.code);
+ const a = await op(alex); const b = await op(bob);
+ await join(a, robot.id); await join(b, robot.id);
+ assert.strictEqual((await act(a, 'role', { subject: bob.subject, role: 'mod' })).ok, true);
+ await b.waitForClose();
+ const b2 = await op(bob); await join(b2, robot.id);
+ assert.strictEqual((await act(b2, 'toggle', { subject: carol.subject, on: false })).ok, true);
+ const c = await op(carol); await join(c, robot.id);
+ cmd(c, { id: 'off-1', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ const off = await result(c, 'off-1');
+ assert.strictEqual(off.code, 'bot.operator_off');
+ assert.strictEqual(off.reason, 'You are switched off on this robot. The owner or a Mod can switch you back on.');
+ assert.strictEqual((await act(b2, 'toggle', { subject: carol.subject, on: true })).ok, true);
+ const on = await drive(c, dev, 'off-2');
+ assert.strictEqual(on.result, 'ack');
+ // The owner switches the Mod off; the Mod's gate reads it fresh and refuses.
+ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: false })).ok, true);
+ cmd(b2, { id: 'mod-off', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ assert.strictEqual((await result(b2, 'mod-off')).code, 'bot.operator_off');
+ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: true })).ok, true);
+ // A Mod cannot switch a Mod (itself), and nobody switches the owner off.
+ const self = await act(b2, 'toggle', { subject: bob.subject, on: false });
+ assert.strictEqual(self.ok, false);
+ assert.strictEqual(self.code, 'bot.mod_limited');
+ const owner = await act(a, 'toggle', { subject: alex.subject, on: false });
+ assert.strictEqual(owner.ok, false);
+ assert.strictEqual(owner.code, 'bot.owner_always_on');
+ a.close(); b2.close(); c.close(); dev.close();
+ });
+
+ await check('Mods may grant/clear sole control and trip/clear the panel E-STOP; they may not promote, change Viewer access, or clear tray STOP/ARM', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ await invite(robot.id, carol.subject, 'operator');
+ await invite(robot.id, dave.subject, 'viewer');
+ const dev = await deviceFor(robot.id, pairing.code);
+ const a = await op(alex); const b = await op(bob);
+ await join(a, robot.id); await join(b, robot.id);
+ await promotedMod(a, robot.id, bob.subject);
+ await b.waitForClose();
+ const b2 = await op(bob); await join(b2, robot.id);
+ assert.strictEqual((await act(b2, 'sole', { subject: carol.subject })).ok, true);
+ cmd(b2, { id: 'mod-sole', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ assert.strictEqual((await result(b2, 'mod-sole')).code, 'bot.sole_control');
+ assert.strictEqual((await act(b2, 'sole', { subject: null })).ok, true);
+ // Old Mod limits.
+ const promote = await act(b2, 'role', { subject: carol.subject, role: 'mod' });
+ assert.deepStrictEqual([promote.ok, promote.code], [false, 'bot.owner_only']);
+ const policy = await act(b2, 'policy', { policy: 'queue' });
+ assert.deepStrictEqual([policy.ok, policy.code], [false, 'bot.mod_limited']);
+ const viewerBtn = await act(b2, 'toggle', { subject: dave.subject, on: false });
+ assert.strictEqual(viewerBtn.ok, false, 'viewers are not an Operator switch');
+ // The Mod cannot change the owner's Viewer access through the owner's form either.
+ const form = await fetch(`${t.base}/robots/${robot.id}/access`, { method: 'POST', redirect: 'manual',
+ headers: { ...cookie(bob), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'access_policy=queue' });
+ assert.strictEqual(form.status, 403);
+ const limits = await fetch(`${t.base}/robots/${robot.id}/limits`, { method: 'POST', redirect: 'manual',
+ headers: { ...cookie(bob), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'max_speed=0.9&max_turn=0.9' });
+ assert.strictEqual(limits.status, 403, 'a Mod cannot change speed or turn');
+ // The panel E-STOP is the Mod's: trip and clear. It never touches the tray's local stop.
+ b2.send({ type: 'estop' });
+ const tripped = await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === true);
+ assert.ok(tripped);
+ cmd(b2, { id: 'estop-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ assert.strictEqual((await result(b2, 'estop-drive')).code, 'bot.estop_latched');
+ b2.send({ type: 'estop_clear' });
+ await b2.waitFor((m) => m.type === 'operators_result' && m.action === 'estop.clear' && m.ok === true);
+ await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === false);
+ // A tray local STOP arrives as device_estop.local; the Mod's clear clears only Bot's panel latch.
+ dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'button on the base' });
+ const held = await b2.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.local === true);
+ assert.strictEqual(held.state.estop.latched, true, 'the local stop also latches Bot');
+ b2.send({ type: 'estop_clear' });
+ const cleared = await b2.waitFor((m) => m.type === 'robot_state' && m.state.estop && m.state.estop.latched === false && m.state.device_estop && m.state.device_estop.latched === true);
+ assert.ok(cleared, 'the tray STOP latch stays latched after a panel clear');
+ assert.strictEqual(cleared.state.device_estop.local, true, 'and stays local, for the tray only');
+ const estopAudit = await t.domain.operators.auditList(robot.id, { limit: 50 });
+ assert.ok(estopAudit.some((r) => r.action === 'estop.trip' && r.actor_subject === bob.subject), 'the trip is audited');
+ assert.ok(estopAudit.some((r) => r.action === 'estop.clear' && r.actor_subject === bob.subject), 'the clear is audited');
+ // The viewer never drives.
+ const d = await op(dave); await join(d, robot.id);
+ cmd(d, { id: 'viewer-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
+ assert.strictEqual((await result(d, 'viewer-drive')).code, 'bot.read_only');
+ a.close(); b2.close(); d.close(); dev.close();
+ });
+
+ await check('every authority change is audited with who did it, and the activity is Bot\'s own stamp', async () => {
+ const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ const dev = await deviceFor(robot.id, pairing.code);
+ const a = await op(alex); const b = await op(bob);
+ await join(a, robot.id); await join(b, robot.id);
+ await act(a, 'sole', { subject: bob.subject });
+ const drove = await drive(b, dev, 'act-drive');
+ assert.strictEqual(drove.result, 'ack');
+ const driveState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last);
+ assert.strictEqual(driveState.operators.activity[bob.subject].last.channel, 'drive');
+ assert.ok(driveState.operators.activity[bob.subject].last.at, 'the command has a when');
+ assert.strictEqual((await act(a, 'sole', { subject: null })).ok, true);
+ // Head and lights are their own channels; lights are non-motion.
+ cmd(b, { id: 'act-head', kind: 'actuator', value: { name: 'head', value: 0.3 } });
+ const head = await dev.waitFor((m) => m.type === 'command' && m.ref === 'act-head');
+ dev.send({ type: 'ack', id: head.id });
+ await result(b, 'act-head');
+ const headState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last.channel === 'head');
+ assert.ok(headState);
+ cmd(b, { id: 'act-lights', kind: 'headlight', value: { on: true } });
+ const lights = await dev.waitFor((m) => m.type === 'command' && m.ref === 'act-lights');
+ dev.send({ type: 'ack', id: lights.id });
+ await result(b, 'act-lights');
+ const lightState = await a.waitFor((m) => m.type === 'operators_state' && m.operators.activity[bob.subject] && m.operators.activity[bob.subject].last.channel === 'lights');
+ assert.ok(lightState);
+ assert.strictEqual((await act(a, 'toggle', { subject: bob.subject, on: false })).ok, true);
+ await act(a, 'toggle', { subject: bob.subject, on: true });
+ await promotedMod(a, robot.id, bob.subject);
+ await b.waitForClose();
+ const b2 = await op(bob); await join(b2, robot.id);
+ // The audit names the actors and actions, never a token or a command payload.
+ const rows = await t.domain.operators.auditList(robot.id, { limit: 50 });
+ const actions = rows.map((r) => r.action);
+ for (const a of ['operator.sole', 'operator.toggle', 'operator.role']) assert.ok(actions.includes(a), a);
+ assert.ok(rows.every((r) => JSON.stringify(r.value).length < 400 && !/token|bearer|secret/i.test(JSON.stringify(r.value))));
+ a.close(); b2.close(); dev.close();
+ });
+
+ await check('the panel draws the card for each role, with the owner-only and Mod-only controls', async () => {
+ const { robot } = await t.robot(alex, { profile_id: 'looi' });
+ await invite(robot.id, bob.subject, 'operator');
+ await invite(robot.id, carol.subject, 'operator');
+ await invite(robot.id, dave.subject, 'viewer');
+ const ownerHtml = await (await get(`/panel/${robot.id}`, alex)).text();
+ assert.ok(ownerHtml.includes('data-operators'), 'the owner sees the card');
+ assert.ok(ownerHtml.includes('data-operator-subject='), 'rows carry their subject');
+ assert.ok(ownerHtml.includes('Make Mod'), 'the owner can promote an Operator');
+ assert.ok(ownerHtml.includes('data-operators-estop>'), 'the panel E-STOP control is there');
+ const ownerOp = await op(alex); await join(ownerOp, robot.id);
+ await promotedMod(ownerOp, robot.id, bob.subject);
+ ownerOp.close();
+ const modHtml = await (await get(`/panel/${robot.id}`, bob)).text();
+ assert.ok(modHtml.includes('data-operators'), 'a Mod sees the card');
+ assert.ok(!modHtml.includes('Make Mod') && !modHtml.includes('Make Operator'), 'a Mod has no owner-only role buttons');
+ assert.ok(modHtml.includes('data-operator-sole'), 'a Mod can grant sole control');
+ const opHtml = await (await get(`/panel/${robot.id}`, carol)).text();
+ assert.ok(opHtml.includes('data-operators') && !opHtml.includes('data-operator-sole'), 'an Operator sees the roster but no controls');
+ const viewerHtml = await (await get(`/panel/${robot.id}`, dave)).text();
+ assert.ok(viewerHtml.includes('Viewers') && !viewerHtml.includes('data-operator-sole') && !viewerHtml.includes('data-operators-estop>'), 'a viewer watches only');
+ });
+
+ await t.close();
+ done();
+})();