openvibers/openvibe.bot Commits
A plain optional Stop (#416)
Changes
@@@ -21,8 +21,7 @@ budget — over a panel built from the robot's profile rather than per-robot cod(`server/kits/*.json`, metadata only).- Devices and their credentials (hashed at rest, rotated, revoked, never readable back) and pairingcodes (one-time, short-lived; `BOT_PAIRING_AUTHORITY=network` lets Network mint them).--- The `/device`, `/control` and `/watch` WebSockets, the command gate, the operator queue and the-- **latched e-stop**.++- The `/device`, `/control` and `/watch` WebSockets, the command gate and the operator queue.- Routines (`server/routines.js`), owner webhooks (`server/webhooks.js`, signed, retried), communityrobot profiles (`server/submissions.js`), the phone-as-robot device, the in-process simulator, theserver-side ONVIF camera connector, and the job dispatcher for paired Link machines@@@ -93,7 +92,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 panel e-stop, set++- The **owner** can list a robot, add/remove operators, edit limits, 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 +100,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;-- the owner or a Mod clears the panel e-stop, while the tray's STOP latch and ARM stay local.++ Stop is one optional part of a layout, and the tray's own stop stays local.## Frontend loading model@@@ -126,7 +125,7 @@ Unit [deploy/systemd/openvibe-bot.service](deploy/systemd/openvibe-bot.service)`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`, withPGlite and in-process Network/Events stubs. It covers the device and operator WebSocket gates, the--REST routes, pairing paths, profiles and kits, the owner fence, the e-stop, the outbox, routines,++REST routes, pairing paths, profiles and kits, the owner fence, the optional Stop, the outbox, routines,webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF camera against a stub.`npm run test:pg` runs the same on PostgreSQL. `ov test` is the harness wrapper.@@@ -143,8 +142,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam## Pitfalls--- **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.++- **Stop is one optional part, off by default.** It sends the robot's own `halt`; nothing latches and nothing++ clears. The tray's own Stop stays local, and `halt` always passes the gate.- **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.@@@ -15,8 +15,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.--- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.--- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.++- The `/device` and `/control` WebSockets, the command gate and the optional Stop.++- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and Stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.- **Make your own controls.** The panel and the controls over a stream are arrangeable surfaces: Bot publishes`bot.controls` and `bot.overlay` as `ui.surface@1` catalogs built from the robot's own profile, and each person's@@@ -24,21 +24,21 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operatoreal parts before first paint. **Arrange** loads `openvibe-shared/ov-arrange.js` only then: drag to move, the cornerto resize, arrows and Shift with the arrows, a long press for a part's visible/size/colour/shape/opacity/label andbindings, live validation through `openvibe-sdk/ui-layout` with the reason in plain words, Undo, Reset to default,-- Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop and the overlay's e-stop are++ Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop is one optional part,required; a layout only arranges, and every command still passes the same gate and the robot's limits. The oldbrowser store (`ov-bot-pad-prefs`: Stop's hand, pad size, readouts) is migrated into the person's first layout once.- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.--- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.--- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.++- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.++- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Link plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.--- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.++- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping and deadman), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.- 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, 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).++- 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 shows the audit of those changes. 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).@@@ -174,7 +174,7 @@ per export and deletion in `account_data_events` (migration 0008).with it, and so does its command history. A device that served only their robots is revoked and loses its name;the row stays because run jobs reference it.- **Elsewhere:** their operator role and queue place on other robots go, and their id leaves other robots' command-- history, e-stop marks, pairing codes and invitations.++ history, pairing codes and invitations.- **Kept:** run jobs they started, whose usage seconds were reported to Billing.## Capabilities@@@ -185,7 +185,7 @@ per export and deletion in `account_data_events` (migration 0008).| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) || `bot.desk.session` | OpenVibe.Run's sessions (contracts 0.223.0): `GET /desk-hosts`, then lease, use and end one browser desk per session (`/machines/:id/desk-leases…`) on the person's own machine or a sandboxed desk host shared with other people || `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |--| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |++| `bot.robot.control` | the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) || `bot.device.connect` | rotate and revoke a device credential || `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" || `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |@@@ -199,14 +199,14 @@ A person acts on their own robots with a Network user token; a service acts with## Tests--`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop, the outbox and the server-side ONVIF camera (against a stub endpoint: `ptz` → `ContinuousMove`, telemetry → the `GetStatus` readout).++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the optional Stop, the outbox and the server-side ONVIF camera (against a stub endpoint: `ptz` → `ContinuousMove`, telemetry → the `GetStatus` readout).## Security- 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 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.++- Pairing codes are one-time and short-lived; Stop is one optional part of a layout, `halt` always passes the gate, and the tray's own stop stays local.- 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`.@@@ -30,8 +30,6 @@"events": ["bot.robot.online","bot.robot.offline",-- "bot.estop.set",-- "bot.estop.cleared","bot.command.refused"],"deployed": true,@@@ -44,29 +42,29 @@"catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot","ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe Link's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)","curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",-- "a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",-- "command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",++ "a Stop that sends the machine's own halt, always allowed, and a device-side deadman deadline on the motion kinds",++ "command audit kept 30 days, and the bot.robot.online/offline and bot.command.refused outbox events","public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue","per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)","signed-in pages: /robots/new (add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",-- "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",++ "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping and the deadman); listed in the sitemap and llms.txt","the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",-- "a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",++ "a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop; /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)","community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless","routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",-- "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",++ "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline and bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks","the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed","the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)","panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Link's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Link over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record","embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",-- "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",++ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, latency, battery, the profile's readouts), capped per address and per robot","streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once","server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects","a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)","machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change","watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for",-- "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop and the e-stop are required and robot commands are untouched"++ "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop is one optional part (off by default) and robot commands are untouched"],"updated": "2026-11-12"}@@@ -5,14 +5,14 @@ robot's site allows (`BOT_EMBED_ORIGINS`; the robot's `data-parent-origins` list| view | query | what it draws ||---|---|---|--| `panel` | `/embed` or `?mode=panel` | the whole panel: header, camera, every widget, the e-stop and the readouts. This is the default and what existing pages get. |--| `overlay` | `?mode=overlay` | a transparent page that fills its frame and draws only the live controls: the robot's name and state (online, your turn, someone else driving, e-stop on) as one pill at the top left, the first stick in a 132 px circle at the bottom left, the first slider as a 44 × 132 vertical track at the bottom right, the e-stop as a 44 px round button at the top right, and a “more” button under it. Safe-area insets are applied. |--| `compact` | `?mode=compact` | one row for a narrow card: the same pill, a 96 px stick, the same slider across, the e-stop and “more”. |++| `panel` | `/embed` or `?mode=panel` | the whole panel: header, camera, every widget, the optional Stop and the readouts. This is the default and what existing pages get. |++| `overlay` | `?mode=overlay` | a transparent page that fills its frame and draws only the live controls: the robot's name and state (online, your turn, someone else driving) as one pill at the top left, the first stick in a 132 px circle at the bottom left, the first slider as a 44 × 132 vertical track at the bottom right, and a “more” button at the top right. Safe-area insets are applied. |++| `compact` | `?mode=compact` | one row for a narrow card: the same pill, a 96 px stick, the same slider across and “more”. |Which stick and slider exist comes from the robot's profile (`server/profiles/*.json`): the first `drive`, `ptz` ordirection-button widget with a stick, and the first `number` actuator a widget drives. A robot with neither still gets--the pill, the e-stop (for its owner and operators) and “more”. A viewer sees the pill and “more” until they take the--controls the same way the full panel lets them.++the pill and “more”. A viewer sees the pill and “more” until they take the controls the same way the full panel lets++them. Stop is one optional part of a full panel's layout, off until a person adds it.Typical iframe:@@@ -70,8 +70,7 @@ window.addEventListener('message', (e) => {`clip-path` participates in hit testing: a press outside every rectangle reaches the video (or whatever is behind theframe) and the player's own controls keep working. Put the frame in a layer above the video and **below** the player'scontrols bar, unmute pill and chat button, and leave the bottom bar's height out of the layer, e.g.--`position:absolute; inset:0 0 var(--player-bar-height, 64px) 0; z-index:4`. The overlay's e-stop and pill then sit--above the picture but under those page controls. In full screen, keep the frame inside the element that goes full++`position:absolute; inset:0 0 var(--player-bar-height, 64px) 0; z-index:4`. The overlay's pill and more button then sit above the picture but under those page controls. In full screen, keep the frame inside the element that goes fullscreen (`requestFullscreen` on the player box), so the controls stay over the picture.Do not use `pointer-events: none` on the iframe element itself as a click-through trick: it turns the controls off too.@@@ -80,7 +79,7 @@ Do not use `pointer-events: none` on the iframe element itself as a click-throug- The frame is sandboxed exactly as above and accepts parent messages only from `data-parent-origins`.- The page accepts frame messages only from the Bot origin **and** that exact iframe, and only the shapes below.--- A page never sends `estop`, `drive`, `actuator` or any other command to the frame: only OpenVibe.Bot's panel sends++- A page never sends `stop`, `halt`, `drive`, `actuator` or any other command to the frame: only OpenVibe.Bot's panel sendscommands, through Bot's own gate, as the viewer. Chat commands on a bound OpenVibe.Live channel go through thechannel route in Live, never through this protocol.- Treat `hit` as layout data only: clamp it and ignore anything malformed.@@@ -20,7 +20,7 @@ the owner's embed opt-in:when the switch goes off.- `server/realtime.js` — the third WebSocket, `wss://…/watch`: no credential, ever. A `join` on an`embed_public` robot answers `joined { role: "watcher", allowed_commands: [], state }` where `state` is the-- **public state** (online, e-stop latch, latency, battery, and only the sensor keys the profile's `telemetry`++ **public state** (online, latency, battery, and only the sensor keys the profile's `telemetry`widgets read); an unknown or non-public robot answers `bot.not_an_operator` alike. Every other frame answers`bot.read_only` and reaches neither a device nor the audit. Frames over 4 KiB close 1009; over`BOT_WATCH_MAX_PER_IP` open sockets per client address (default 20) or `BOT_WATCH_MAX_PER_ROBOT` watchers on@@@ -97,9 +97,8 @@ adds it from this file at the verified head; it resolves to:'self'`.4. Anonymous `wss://openvibe.bot/watch` (through nginx, HTTP 101) with `{"type":"join","robot_id":"<public id>"}`answers `joined` with `role: "watcher"`, `allowed_commands: []` and the public state only; the same join on-- a **non-public or unknown** robot answers `bot.not_an_operator` and reveals nothing. A `command`/`estop`/-- `estop_clear` frame answers `bot.read_only` and produces no device frame and no audit row. The owner's-- e-stop reaches a joined watcher; the owner turning `embed_public` off closes its watchers with 4003.++ a **non-public or unknown** robot answers `bot.not_an_operator` and reveals nothing. A `command`/`stop` frame answers `bot.read_only` and produces no device frame and no audit row. A state++ change reaches a joined watcher; the owner turning `embed_public` off closes its watchers with 4003.5. With `BOT_WATCH_MAX_PER_IP=2` / `BOT_WATCH_MAX_PER_ROBOT=3` on a scratch start, the 3rd socket from oneaddress and the 4th watcher on one robot close 4003; a frame over 4 KiB closes 1009.6. The row counts from the backup step are unchanged (this PR writes none); `robots.embed_public` is only ever@@@ -146,7 +145,7 @@ node test/panel.test.js`GET /panel/:id/embed` serves a public robot to anyone with its own CSP, `no-store` and no cookie and nevera redirect; a non-public robot is `403` with no data and a member keeps their role; `/watch` answers thepublic state only and refuses a non-public or unknown robot; any other frame is `bot.read_only` and reaches-- neither device nor audit; the owner's e-stop reaches a watcher and readouts carry only the profile's sensor++ neither device nor audit; a state change reaches a watcher and readouts carry only the profile's sensorkeys; turning embedding off closes watchers 4003; the per-address/per-robot caps close 4003) and`node test/panel.test.js` (the signed-in panel page, its controls and the simulated robot still render).3. It confirms **0 pending** migrations afterwards and the fixture row counts unchanged — this PR writes no@@@ -60,7 +60,7 @@ credential, as above.Frames sent while authentication is still in progress — right after an upgrade that carries theheader, or after `pair` until it is answered — are **kept** and handled in arrival order once it--completes (after `hello` and `config`), so a device may send its first `status` and `estop_state`++completes (after `hello` and `config`), so a device may send its first `status` and its Stop reportstraight after the upgrade. They are never answered `bot.not_paired`. If the credential is refusedthey are discarded with the socket; after a refused `pair` they are answered as on an unpaired socket.At most 64 frames wait; any more are answered `error` `bot.not_ready`.@@@ -71,9 +71,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.|---|---|---|| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once**; without OpenRestream configured `video: "not_configured"` instead of `publish_key`/`whip_url` || `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |--| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |++| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists). `estop_latched` is a legacy field: Bot always sends `false` and no longer latches anything || `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id, provenance?` | an operator's command passed the gate; `provenance` is present on `headlight` |--| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it || `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's || `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself || `error` | `code, detail` | a frame the server refused |@@@ -85,11 +84,11 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` || `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency || `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |--| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |++| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below. `estop_latched` is a legacy field and is not read || `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing || `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` || `nack` | `id, fault_code` | a command (or a job, §1.2) was refused on the device |--| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |++| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the old name of a machine's own Stop report; accepted and ignored. `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `button`, `point`, `headlight` and `halt`, and adevice only ever gets the kinds its robot's profile declares (§1.1) or a custom action it declares in@@@ -97,8 +96,7 @@ device only ever gets the kinds its robot's profile declares (§1.1) or a custom`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds (`drive`, `actuator`, `ptz`), on aheld `button`, and on `headlight` (which is not motion and never renews a motion deadline): the device **must** stopthe motors at that instant if no newer command arrived, and on every disconnect or crash path, without asking the--network. `halt` (value `{}`, no deadline) stops everything at once and passes the gate even while the e-stop is--latched.++network. `halt` (value `{}`, no deadline) stops everything at once and always passes the gate.**Headlights** (`headlight`, LOOI; `{ "on": true|false }`, explicit and never a toggle) are the owner's and PeopleOperators' only, never an anonymous viewer, a plain watcher or the queue. Bot mints the trusted fields itself, never@@@ -223,24 +221,18 @@ against these):The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,`server/onvif`). A `sim` device needs nothing; an `onvif` camera is configured with `BOT_ONVIF_CAMERAS`(its URL plus `username_ref`/`password_ref` secret references — never the profile) and Bot translates each--`ptz` command into an ONVIF `ContinuousMove` (`Stop` at the deadline, on `halt` or on the e-stop), carrying++`ptz` command into an ONVIF `ContinuousMove` (`Stop` at the deadline or on `halt`), carryingthe 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--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 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--itself: `openvibe-link stop`, or a driver's own stop button through it. Only a release on that machine--(`openvibe-link resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`--`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps--`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is--stopped on the robot and holds back its Clear button until the device reports the release. After the release--(`latched: false`) Bot's own latch still waits for the owner: two keys, the robot's and the owner's.++**A machine's own Stop report stays local.** The frame's old name is `estop_state`; a device may still send it,++and Bot accepts it without latching, gating, clearing or auditing anything. The tray's own Stop is its author's++program, and only that machine releases it. A `robot_id` the device is not attached to is refused with `error`++`bot.forbidden`; nothing changes on that robot.++++**A local stop** is one held on the machine itself: `openvibe-link stop`, or a driver's own stop button through it.++Only a release on that machine (`openvibe-link resume`) lifts it. The Link keeps refusing motion (`nack` `local_stop`)++and reports the stop again. Bot does not keep that latch and never clears it; a remote stop is the plain `halt`++command, nothing more.### Examples@@@ -321,13 +313,9 @@ not confirmed) or `{ "stage": "headlight_refused", "admitted": false }` (busy/ra```json{ "v": 1, "seq": 14, "ts": 1738065600900, "type": "status", "firmware": "adeept-0.1.0",-- "capabilities": { "drive": { "type": "differential", "axes": ["throttle", "steer"] } }, "faults": [], "estop_latched": false }++ "capabilities": { "drive": { "type": "differential", "axes": ["throttle", "steer"] } }, "faults": [] }```--```json--{ "v": 1, "seq": 15, "ts": 1738065601200, "type": "estop_state", "latched": true, "by": "device", "at": "2026-09-29T19:20:01.200Z" }--{ "v": 1, "seq": 6, "ts": 1738065601201, "type": "estop", "latched": false, "by": "usr_01J8…", "at": "2026-09-29T19:20:01.201Z" }--```Liveness: the server marks a device **offline** when no frame arrives within`heartbeat_ms × 2 + 3000 ms` (2 missed beats plus the grace) and never queues a command for it; a@@@ -425,12 +413,11 @@ 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.++**Roles and sole control.** The owner, a Mod and an Operator may drive; a Mod may also switch Operators on++or off and grant or clear sole control. Only the owner promotes an Operator to Mod or demotes a Mod, changes++speed/mode/Viewer access, 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@@@ -439,8 +426,6 @@ authority change is in `robot_operator_audit` with the actor and subject, never| `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 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 |@@@ -449,9 +434,9 @@ authority change is in `robot_operator_audit` with the actor and subject, never| type | fields | notes ||---|---|---|| `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`) |++| `robot_state` | `state{robot_id, online, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) || `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |--| `operators_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 |++| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame; a refusal is never a silent drop || `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` || `error` | `code, detail` | a frame or join the server refused |@@@ -471,8 +456,7 @@ resumes it at once.```json{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined",-- "robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private",-- "estop": { "latched": false, "by": null, "at": null } },++ "robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private" },"role": "operator", "profile": { "id": "adeept.adr036" },"allowed_commands": ["drive", "actuator", "halt"],"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "latency_ms": 63, "battery": 0.72, "queue": null } }@@@ -496,25 +480,20 @@ resumes it at once.```json{ "v": 1, "seq": 3, "ts": 1738065600800, "type": "robot_state","state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,-- "estop": { "latched": false, "by": null, "at": null }, "latency_ms": 63, "battery": 0.71,-- "telemetry": { "battery": 0.71, "voltage": 7.4 }, "status": null, "device_estop": null,++ "latency_ms": 63, "battery": 0.71,++ "telemetry": { "battery": 0.71, "voltage": 7.4 }, "status": null,"queue": { "active": false, "position": 3, "turn_ends_at": null, "budget": 50, "used": 0 } } }```--```json--{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }--{ "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`,++(the profile takes no such kind), `bot.not_an_operator`, `bot.sign_in`,`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``bot.command_pending` rather than a second device command or a silent wait.--`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a++`halt` is never refused for the allowlist, a cooldown or the turn budget; it still needs arole that may drive (a queue robot's turn holder, not someone waiting) and an online device.### 2.1 `/watch` and the embeddable panel (plan T15 R9)@@@ -531,7 +510,7 @@ owner's panel leads with **On your stream**: one button (`POST /robots/:id/on-stsame-origin) turns `embed_public` on and sends them to their Live channel's bind prompt(`<LIVE_URL>/@<username>?bind_robot=<id>`), and a **Who may drive** select (`POST /robots/:id/access`,`private`/`invite`/`queue`) sits beside it.--An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.++An unknown robot is `404`. The panel shows only the controls and the optional Stop a role may use.Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps`frame-ancestors 'self'`. Framing takes both sides and the two lists are independent: `BOT_EMBED_ORIGINS` lets@@@ -545,12 +524,12 @@ The watcher's panel joins on `wss://openvibe.bot/watch`: no credential, never a|---|---|---|| `join` | `robot_id` | `joined { role: "watcher", profile, allowed_commands: [], state }` for an `embed_public` robot; otherwise (or an unknown robot) `error` `bot.not_an_operator` || `leave` | — | unsubscribe |--| anything else (`command`, `estop`, `estop_clear`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |++| anything else (`command`, `stop`, `halt`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |A joined watcher then gets `robot_state { state }` on every change, as a `/control` subscriber does, but always--the **public state**: `{ robot_id, online, estop{latched}, latency_ms, battery, telemetry }`, where++the **public state**: `{ robot_id, online, latency_ms, battery, telemetry }`, where`telemetry` is `null` or `{ sensors: { … } }` holding only the keys the profile's `telemetry` widgets read--(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, who latched the e-stop, the device's ids,++(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, the device's ids,its `status` or the rest of its telemetry.Frames over 4 KiB close the socket (1009). Over `BOT_WATCH_MAX_PER_IP` open sockets from one client address@@@ -560,7 +539,7 @@ owner turning `embed_public` off closes the robot's watchers with 4003 too.```json{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined", "role": "watcher", "profile": { "id": "adeept.adr036" },"allowed_commands": [],-- "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "estop": { "latched": false },++ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,"latency_ms": 63, "battery": 0.72, "telemetry": { "sensors": { "ultrasonic": 41.5 } } } }```@@@ -576,8 +555,7 @@ never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` othe subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`and `POST /devices/bind` answers `401 bot.sign_in`: `GET /me`, `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,--`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,--`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same++`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the sameroutes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The threejobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or anode token, and a service token is judged on `bot.job.dispatch` alone.@@@ -601,12 +579,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered || `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) || `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.link_token_required`, `403 bot.link_not_bound` |--| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |--| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |--| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |++| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling || `POST /robots/:id/control-ticket` | `bot.robot.control` acting for a person (`X-OV-Subject`) who is a member, or anyone on a `queue` robot | `{ ticket, robot_id, role, expires_in }`, `Cache-Control: no-store`: a Drive ticket for that person (§2), for the page that frames the panel. `403 bot.not_an_operator`; `422` without a subject |--| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |--| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |++| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |++| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner || `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) || `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRestream stream; `available:false` is `not_configured` (OpenRestream unset), `not_paired` (no stream yet) or `stream_missing` (OpenRestream answered 404) || `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` |@@@ -662,7 +638,7 @@ POST /api/v1/robots/rob_…/commissionWith it, `PATCH /robots/:id` may set `max_speed` and `max_turn` above the profile default and up to theceiling; without it they are clamped to the default exactly as before. `max_command_ms` is never raised (itbounds how long a command may run). Uncommissioning returns to the defaults at once and the device is sent--the new config. The e-stop, roles, cooldowns and turn budget are unchanged, and operators and agents can++the new config. Roles, cooldowns and the turn budget are unchanged, and operators and agents cannever commission or raise a limit: the route answers `403 bot.forbidden` for anyone but the owner's ownperson token.@@@ -678,5 +654,5 @@ queue command refused because its turn ended while the gate decided is a `refuse(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); anidempotent repeat writes none. Commissioning writes `robot.commission` and uncommissioning`robot.uncommission`, both as the owner. The outbox publishes `bot.robot.online`, `bot.robot.offline`,--`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and++`bot.command.refused` — small payloads of ids, kinds andresults, never a credential, a pairing code, a publish key or a WHIP URL.@@@ -25,9 +25,9 @@ CREATE TABLE robots (profile_version integer NOT NULL DEFAULT 1,access_policy text NOT NULL DEFAULT 'private' CHECK (access_policy IN ('private', 'invite', 'queue')),limits jsonb NOT NULL DEFAULT '{}', -- owner-set: max_speed, max_turn, max_command_ms, turn_ms, turn_budget, cooldown_ms, allow-- estop_latched boolean NOT NULL DEFAULT false,-- estop_by text, -- the principal that latched it, or 'device'-- estop_at timestamptz,++ estop_latched boolean NOT NULL DEFAULT false, -- legacy stop latch, no longer read or written; contract-migration later++ estop_by text, -- legacy: who latched it; unused++ estop_at timestamptz, -- legacy; unusedcreated_at timestamptz NOT NULL,updated_at timestamptz NOT NULL);@@@ -94,7 +94,7 @@ CREATE TABLE command_audit (operator_subject text, -- the person, or the subject a service acted foroperator_kind text, -- user | servicerole text, -- owner | operator | viewer | null-- kind text, -- drive | actuator | ptz | say | display | halt | estop++ kind text, -- drive | actuator | ptz | say | display | haltvalue jsonb NOT NULL DEFAULT '{}', -- a small summary (clamped axes, servo, text length), never a credentialresult text NOT NULL CHECK (result IN ('ack', 'nack', 'refused', 'expired')),reason text,@@@ -2,7 +2,7 @@---- The owner's agent (plan T15 "an Actor agent can pilot a robot under the owner's grant", always through Bot): with-- agent_control on, the owner's OpenVibe.Actor agent may send this robot commands as the owner (Actor's service token,++-- grant bot.robot.control, X-OV-Subject = the owner). Bot's gate and limits still decide every command; the-- device's deadman still stops it. Off for every robot until its owner turns it on. Additive only.ALTER TABLE robots ADD COLUMN agent_control boolean NOT NULL DEFAULT false;@@@ -1,8 +1,8 @@-- 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. A Stop is now one ordinary control (a halt command), not a latch; the old++-- robots.estop_* columns are left for a later contract migration and are no longer read or written.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;@@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS robot_operator_control ();-- Authority changes: who granted sole control, switched someone on or off, promoted or demoted a Mod, or++-- granted or cleared sole control. 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,@@@ -186,7 +186,7 @@if (doc.body) observer.observe(doc.body);observer.observe(html);}-- // Over a picture the body never changes size, but its controls do: Drive gives way to the e-stop, a line++ // Over a picture the body never changes size, but its controls do: Drive gives way to the panel, a line// appears under them. Their boxes are the page's clip, so a control shown or hidden is sent again.let watcher = null;if (view() === 'overlay' && typeof win.MutationObserver === 'function' && doc.body) {@@@ -1,5 +1,5 @@/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at-- least 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */++ least 44 px, and the panel laid out for a phone held either way. *//* The embeddable panel takes the page's tokens (public/embed-frame.js): --bg-primary, --bg-card, --text-primary,--text-secondary, --accent, --accent-strong, --on-accent-strong, --border and --color-scheme. Without a message itfollows prefers-color-scheme with the network's default pair (Vibe dark, cloud light) below. */@@@ -82,25 +82,8 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width.crumb { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }--/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */--.estop-banner { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }--.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }--.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }--.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }--.estop-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(63, 185, 80, 0.18); flex: none; }--.estop {-- min-height: 56px; min-width: 112px; padding: 0 1.1rem; border: 2px solid #ff8a8d; border-radius: 14px;-- background: var(--fill-danger); color: #fff; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase;-- box-shadow: 0 4px 0 var(--danger-deep), 0 6px 16px rgba(229, 72, 77, 0.35);--}--.estop .icon { width: 1.5em; height: 1.5em; stroke-width: 2.4; }--.estop:active:not(:disabled) { transform: translateY(3px); box-shadow: 0 1px 0 var(--danger-deep); }--.estop-clear { background: #fff; color: #111; border-color: #fff; font-weight: 700; }--.estop-banner[data-latched="true"] { background: var(--danger-deep); border-bottom-color: var(--danger); }--.estop-banner[data-latched="true"] .crumb, .estop-banner[data-latched="true"] .brand b { color: #ffd1d2; }--.estop-banner[data-latched="true"] .estop-dot { background: #fff; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.25); animation: pulse 1.2s ease-in-out infinite; }--.estop-banner[data-latched="true"] .estop { background: #7a1014; border-color: #ff8a8d; box-shadow: none; }--@keyframes pulse { 50% { opacity: 0.35; } }++/* ── The page header stays in view while the controls scroll under it ──────────────────────────────── */++.panel-top { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }/* ── The panel ──────────────────────────────────────────────────────────────────────────────────────── */main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); max-width: 72rem; margin: 0 auto; }@@@ -127,7 +110,18 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.widgets[data-ov-arranged] > [data-ov-part] { overflow: auto; }#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stick { width: 100%; height: 100%; }:is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .drive-pad { display: contents; }--#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: 50%; }++#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: inherit; }++/* Stop is one plain control: a small button with a square glyph, not a red bar. */++.stop { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; min-height: 44px; min-width: 44px;++ padding: 0 0.75rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); color: var(--text);++ font: inherit; font-size: 0.9rem; font-weight: 500; cursor: pointer; }++.stop .icon { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; }++.stop:hover:not(:disabled) { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }++.stop:active:not(:disabled) { transform: translateY(1px); }++.stop:disabled { opacity: 0.5; }++.widget-stop { display: flex; justify-content: flex-start; }++.widget-stop .stop { width: auto; height: auto; }++/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so thepicture and the controls are on screen together; the readouts and the other controls follow. */@@@ -183,7 +177,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.axis .hold .icon { width: 1.6em; height: 1.6em; }.axis-name { color: var(--muted); font-size: 0.8rem; text-align: center; }.hold.held { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }--.drive-pad .stop { width: 100%; max-width: 22rem; min-height: 52px; font-weight: 600; }.hint { margin: 0.9rem 0 0; color: var(--muted); font-size: 0.8rem; text-align: center; }.pad-hint { display: none; }#panel[data-gamepad="true"] .pad-hint { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text); }@@@ -259,12 +252,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }--.ops-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; }@@@ -278,11 +265,11 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.stat-label { color: var(--muted); font-size: 0.72rem; }[data-quality="good"] { color: var(--ok); } [data-quality="fair"] { color: var(--warn); } [data-quality="poor"] { color: var(--danger-text, var(--danger)); }--/* On a phone the picture never scrolls away while driving: the camera stays pinned under the e-stop bar (its height,-- --banner-h, is measured by public/panel.js) and everything else scrolls beneath it. Upright, it takes at most a++/* On a phone the picture never scrolls away while driving: the camera stays pinned under the page header (its height,++ --top-h, is measured by public/panel.js) and everything else scrolls beneath it. Upright, it takes at most athird of the screen, and several cameras sit side by side and swipe instead of stacking. */@media (max-width: 43.99rem) and (orientation: portrait), (orientation: landscape) and (max-height: 32rem) {-- .widget-camera, .widget-video_click { position: sticky; top: calc(var(--banner-h, 6.5rem) + 0.4rem); z-index: 6; align-self: start;++ .widget-camera, .widget-video_click { position: sticky; top: calc(var(--top-h, 4rem) + 0.4rem); z-index: 6; align-self: start;box-shadow: 0 0.75rem 1.5rem -0.75rem rgba(0, 0, 0, 0.85); }}@media (max-width: 43.99rem) and (orientation: portrait) {@@@ -293,8 +280,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf}/* Phone on its side: the camera on the left, the drive pad on the right, both in view without scrolling. */@media (orientation: landscape) and (max-height: 32rem) {-- .estop-bar { padding-top: 0.3rem; padding-bottom: 0.35rem; } .topbar { min-height: 2.5rem; }-- .estop { min-height: 48px; }++ .topbar { min-height: 2.5rem; }.panel-head h1 { font-size: 1.15rem; display: inline; margin-right: 0.5rem; } .meta { display: inline-flex; }.widgets { grid-template-columns: minmax(0, 1fr) minmax(14rem, 38%); align-items: start; }.widget-camera { grid-column: 1; grid-row: span 3; order: 0; }@@@ -550,7 +536,7 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:.bot-pill[data-state="online"] .dot { background: var(--ok); }.bot-pill[data-state="turn"] .dot { background: var(--accent); }.bot-pill[data-state="busy"] .dot { background: var(--warn); }--.bot-pill[data-state="offline"] .dot, .bot-pill[data-state="estop"] .dot { background: var(--danger); }++.bot-pill[data-state="offline"] .dot { background: var(--danger); }/* The first stick, 132 px at the bottom left. The scrim is the circle; the stick itself is transparent. */.bot-stick {@@@ -572,16 +558,14 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:margin: 0; padding: 0; accent-color: var(--accent);}--/* The e-stop, 44 px at the top right, and the way to the full panel under it. */--.bot-estop, .bot-more {-- position: absolute; right: calc(16px + env(safe-area-inset-right)); width: 44px; height: 44px; min-width: 44px; min-height: 44px;-- padding: 0; border-radius: 50%; justify-content: center; font-size: 15px; font-weight: 700; letter-spacing: 0.04em;++/* The way to the full panel, 44 px at the top right. */++.bot-more {++ position: absolute; right: calc(16px + env(safe-area-inset-right)); top: calc(16px + env(safe-area-inset-top));++ width: 44px; height: 44px; min-width: 44px; min-height: 44px; padding: 0; border-radius: 50%;++ justify-content: center; font-size: 16px; font-weight: 700; letter-spacing: 0.04em; line-height: 1;}--.bot-estop { top: calc(16px + env(safe-area-inset-top)); color: var(--danger); }--.bot-estop .icon { width: 22px; height: 22px; stroke-width: 2.4; }--.bot-more { top: calc(68px + env(safe-area-inset-top)); font-size: 16px; line-height: 1; }--/* One row for a narrow card: the pill, a 96 px stick, the slider across, the e-stop and more. */++/* One row for a narrow card: the pill, a 96 px stick, the slider across and more. */.bot-compact {display: flex; align-items: center; gap: 8px; min-height: 64px; padding: 8px 10px; border-radius: 16px;background: rgba(8, 12, 22, 0.42); border: 1px solid rgba(255, 255, 255, 0.14);@@@ -592,12 +576,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde.bot-compact .bot-stick { position: static; flex: none; width: 96px; height: 96px; border-radius: 50%; }.bot-compact .bot-slider { position: static; flex: none; width: 108px; height: 44px; border-radius: 22px; padding: 0 8px; }.bot-compact .bot-slider input[type="range"] { writing-mode: initial; direction: ltr; width: 92px; height: 28px; }--.bot-compact .bot-estop, .bot-compact .bot-more { position: static; flex: none; width: 44px; height: 44px; }++.bot-compact .bot-more { position: static; flex: none; width: 44px; height: 44px; }/* ── The overlay over a short picture, and a stick you can see (Maticus, 2026-11-10: the joystick and head slider overthe stream "would be great for phone"). Live gives the framed page the picture less the player's bar: about 170 pxtall on a phone held upright, 220 px on its side. The controls take their size from the frame's own height, and the-- e-stop and the way to the panel stand beside the slider instead of over it, so nothing overlaps at any size. ───── */++ way to the panel stands beside the slider instead of over it, so nothing overlaps at any size. ───── */.bot-overlay { --bot-sz: 132px; --bot-gap: 16px; }.bot-stick, .bot-slider { width: var(--bot-sz); height: var(--bot-sz); }.bot-slider { width: 44px; }@@@ -607,9 +591,7 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde.bot-pill { top: calc(var(--bot-gap) + env(safe-area-inset-top)); left: calc(12px + env(safe-area-inset-left)); height: 32px; max-width: calc(100% - 132px); }.bot-stick { left: calc(12px + env(safe-area-inset-left)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }.bot-slider { right: calc(12px + env(safe-area-inset-right)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }-- .bot-estop, .bot-more { right: calc(12px + 44px + 8px + env(safe-area-inset-right)); }-- .bot-estop { top: calc(var(--bot-gap) + env(safe-area-inset-top)); }-- .bot-more { top: calc(var(--bot-gap) + 52px + env(safe-area-inset-top)); }++ .bot-more { right: calc(12px + env(safe-area-inset-right)); top: calc(var(--bot-gap) + env(safe-area-inset-top)); }}/* The stick: a frosted base, a light thumb that reads on any picture, four soft direction marks; pressed, the thumbtakes the accent and a halo. */@@@ -624,21 +606,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde.bot-stick .stick.active .stick-knob { background: radial-gradient(circle at 38% 32%, #dce8ff, var(--accent) 72%); border-color: rgba(255, 255, 255, 0.9);box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 28%, transparent), 0 6px 16px rgba(0, 0, 0, 0.5); }.bot-stick:has(.stick.active) { border-color: color-mix(in srgb, var(--accent) 55%, rgba(255, 255, 255, 0.14)); }--/* The e-stop reads as one: a red ring and glyph on the same scrim. */--.bot-estop { border-color: color-mix(in srgb, var(--danger) 70%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }--.bot-pill[data-state="estop"] { border-color: color-mix(in srgb, var(--danger) 55%, rgba(255, 255, 255, 0.14)); }/* ── The panel in a frame, compact at any size (ask 353, 2026-11-11: on a phone the framed panel was 950 px ofbanner, header, pills, a 240 px stick and four cards, and it pushed the stream's chat off the screen) ──────────A frame's height follows its content (embed-frame.js), so nothing here may depend on the frame's height ororientation: those media queries above are for the panel's own page. The frame's width decides the columns. */html.embed-page[data-bot-view="panel"] main#panel:not(.bot-surface) { padding: 0.6rem 0.7rem calc(0.7rem + env(safe-area-inset-bottom)); }--/* The e-stop bar only when it says something (latched) or holds the button (you drive): clear and watching, the-- state is the dot in the head's pill. */--html.embed-page[data-bot-view="panel"] .estop-banner[data-latched="false"]:not(:has(.estop:not([hidden]))) { display: none; }--html.embed-page[data-bot-view="panel"] .estop-bar { padding: 0.35rem 0.7rem; gap: 0.5rem; }--html.embed-page[data-bot-view="panel"] .estop-state { font-size: 0.9rem; }--html.embed-page[data-bot-view="panel"] .estop { min-height: 44px; min-width: 0; padding: 0 0.85rem; border-radius: 12px; font-size: 0.85rem; box-shadow: 0 3px 0 var(--danger-deep); }/* The head: one row with the name, its state, Drive and the way to the robot's own page. */html.embed-page[data-bot-view="panel"] #panel > .panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; margin: 0 0 0.6rem; }html.embed-page[data-bot-view="panel"] .panel-head .ovk-page-head { margin: 0; flex: 0 1 auto; min-width: 0; padding: 0; }@@@ -696,7 +669,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }.bot-stick .stick[aria-disabled="true"] { opacity: 0.55; }.bot-slider input:disabled { opacity: 0.55; }--/* A watcher's Drive: where the e-stop goes once they have the controls; and the page's answer under it. */++/* A watcher's Drive: where it goes once they have the controls; and the page's answer under it. */.bot-drive {position: absolute; top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right));height: 44px; min-height: 44px; padding: 0 14px 0 12px; gap: 6px; border-radius: 22px; font-size: 13px; font-weight: 700;@@@ -713,7 +686,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }.bot-overlay .bot-notice { bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); max-width: calc(100% - 2 * var(--bot-sz) - 24px); }}--/* The one row, as a small remote: the stick at the left, the name over the slider, Drive or the e-stop over "more".++/* The one row, as a small remote: the stick at the left, the name over the slider, Drive over "more".A wide frame puts it all on one line. */body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }.bot-compact {@@@ -726,7 +699,7 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }.bot-compact .bot-slider { grid-area: slider; width: 100%; max-width: 260px; height: 44px; padding: 0 10px; }.bot-compact .bot-slider .bot-widget, .bot-compact .bot-slider label { width: 100%; }.bot-compact .bot-slider input[type="range"] { width: 100%; height: 28px; }--.bot-compact :is(.bot-estop, .bot-drive) { grid-area: act; justify-self: end; }++.bot-compact .bot-drive { grid-area: act; justify-self: end; }.bot-compact .bot-more { grid-area: more; justify-self: end; }.bot-compact .bot-drive { position: static; }.bot-compact .bot-notice { grid-area: note; position: static; transform: none; max-width: none; text-align: left; }@@@ -735,14 +708,13 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }.bot-compact[data-no-slider] { grid-template-areas: "pill stick stick act more" "note note note note note"; }}/* The row sits in the host's own card (Live's robot card): no second box, and the page's colours, so it reads on a-- light card as well as a dark one. Drive keeps its fill; the e-stop its red ring. */++ light card as well as a dark one. Drive keeps its fill. */.bot-compact { background: transparent; border: 0; border-radius: 0; -webkit-backdrop-filter: none; backdrop-filter: none; padding: 6px 8px; }body.bot-mode-compact .bot-compact .bot-control { color: var(--text); }body.bot-mode-compact .bot-compact .bot-pill-state { color: var(--muted); }body.bot-mode-compact .bot-compact .bot-stick { background: color-mix(in srgb, var(--text) 6%, transparent); border-color: var(--line); }body.bot-mode-compact .bot-compact .bot-stick .stick-ring { border-color: var(--line); }--body.bot-mode-compact .bot-compact :is(.bot-more, .bot-estop) { background: color-mix(in srgb, var(--text) 7%, transparent); }--body.bot-mode-compact .bot-compact .bot-estop { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 70%, transparent); }++body.bot-mode-compact .bot-compact .bot-more { background: color-mix(in srgb, var(--text) 7%, transparent); }body.bot-mode-compact .bot-compact .bot-drive { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }@@@ -784,8 +756,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:hand, size and readouts are now the person's ui.layout@1. ─── */#panel:not(.bot-surface) .widget-drive { position: relative; }#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }--#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }--#panel:not(.bot-surface)[data-hand="left"] .drive-pad .stop { order: -1; }++#panel:not(.bot-surface) .drive-pad > :not(.stick) { grid-column: 1 / -1; }#panel:not(.bot-surface) .stick {--stick: clamp(8.5rem, 44vw, 12.5rem);width: var(--stick);@@@ -813,20 +784,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:#panel:not(.bot-surface) .stick.active .stick-knob { background: radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.4), transparent 42%), radial-gradient(circle at 50% 55%, #6ea8ff, var(--accent) 75%); border-color: #9cc2ff; box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent), 0 10px 22px rgba(0, 0, 0, 0.45); }#panel:not(.bot-surface) .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }--#panel:not(.bot-surface) .drive-pad .stop {-- width: calc(var(--stick, 11rem) * 0.46); height: calc(var(--stick, 11rem) * 0.46); min-width: 4.25rem; min-height: 4.25rem; max-width: 6rem; max-height: 6rem;-- padding: 0; border-radius: 50%; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem;-- font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text);-- background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 22%, var(--surface)), color-mix(in srgb, var(--fill-danger) 10%, var(--bg)));-- border: 2px solid color-mix(in srgb, var(--danger) 70%, transparent);-- box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 10%, transparent), 0 6px 14px rgba(0, 0, 0, 0.35);-- transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease;--}--#panel:not(.bot-surface) .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }--#panel:not(.bot-surface) .drive-pad .stop:hover:not(:disabled) { background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 36%, var(--surface)), color-mix(in srgb, var(--fill-danger) 18%, var(--bg))); }--#panel:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }--#panel:not(.bot-surface) .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }--/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }@@@ -869,7 +826,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:#panel:not(.bot-surface) .notice[data-device-fault], #panel:not(.bot-surface) .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }#panel:not(.bot-surface) .notice[hidden] { display: none; }--@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }++@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .stick-dirs { transition: none; } }/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */#panel:not(.bot-surface) .widgets { align-items: start; }@@@ -15,14 +15,14 @@* keyboard arrows/WASD drive, Space stops; Q/E tilt the head where the robot has one (else they rotate)* gamepad polled on requestAnimationFrame only while a pad is connected and the page has focus: left stick* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,-- * B = stop, Start = e-stop; gamepaddisconnected releases it all++ * B = Stop; gamepaddisconnected releases it all* latency round trip from a command's id to its result on /control (rolling median) + telemetry age* camera each tile the server marked with a live OpenRestream session's signaling URL plays it: the* mediasoup-client viewer flow OpenVibe.Live runs, reconnecting with backoff; a tile with no* URL is left as the server drew it** A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints-- * the state it is sent: no command, e-stop or clear frame is ever sent from it. Its Drive button opens++ * the state it is sent: no command or stop frame is ever sent from it. Its Drive button opens* /panel/:id/drive, a first-party popup with the openvibe.bot session the frame cannot see; the popup posts back a* one-time ticket (same origin only), and the panel re-joins on /control offering it as a subprotocol. Each join* made that way carries the next ticket, so a dropped socket reconnects; when none is left it watches again.@@@ -35,13 +35,12 @@const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];const wsUrl = (path = '/control') => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`;-- // The camera pins under the sticky e-stop bar on phones (public/panel.css): the bar's height, kept current as it-- // wraps, rotates or shows the latched note.-- const banner = $('[data-estop-banner]');-- if (banner) {-- const setTop = () => document.documentElement.style.setProperty('--banner-h', `${banner.offsetHeight}px`);++ // The camera pins under the page header on phones (public/panel.css): keep its height in --top-h.++ const pageTop = $('.panel-top');++ if (pageTop) {++ const setTop = () => document.documentElement.style.setProperty('--top-h', `${pageTop.offsetHeight}px`);setTop();-- if ('ResizeObserver' in window) new ResizeObserver(setTop).observe(banner); else window.addEventListener('resize', setTop);++ if ('ResizeObserver' in window) new ResizeObserver(setTop).observe(pageTop); else window.addEventListener('resize', setTop);}for (const b of $$('[data-copy]')) {@@@ -445,7 +444,7 @@el.addEventListener('contextmenu', (e) => e.preventDefault());}function releaseAll() {-- // Held slider repeats stop too (the e-stop, blur, a hidden page): the slider code listens for this.++ // Held slider repeats stop too (Stop, blur, a hidden page): the slider code listens for this.try { window.dispatchEvent(new Event('ov-bot-release-all')); } catch { /* */ }for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);@@@ -641,7 +640,7 @@// each one step on from the slider's own value, clamped to its range, and the slider moves with it, so a drag after the// keys starts where the head is going (the slider sends absolute targets: no jump). Release sends nothing: head 0 is// Center, not stop, and the last command's lease bounds what is left of the move. Q and E together: nothing. Blur, a-- // hidden page, pagehide, STOP, the e-stop and a lost socket stop it (releaseAll and stopDrive).++ // hidden page, pagehide, Stop and a lost socket stop it (releaseAll and stopDrive).const HEAD_KEYS = { KeyQ: 1, KeyE: -1 };const headSlider = () => $('[data-actuator="head"]');const head = { down: new Set(), timer: null };@@@ -736,14 +735,11 @@document.addEventListener('keyup', onKeyUp);function stopDrive() {-- headReleaseAll();-- const h = holds.get(DRIVE);-- if (h) for (const source of [...h.src.keys()]) { if (source instanceof Element) releaseButton(source); else holdRelease(DRIVE, source); }-- for (const s of sticks) s.release();-- send(DRIVE, {}); // every declared axis absent → 0++ // The machine's own Stop (halt): release every local hold, then send the one command that stops it.++ releaseAll();++ send('halt', {});}$$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));-- function estop() { releaseAll(); raw({ type: 'estop' }); }// ── Gamepad ───────────────────────────────────────────────────────────────────────────────────────const PAD_DPAD = { 12: 'ArrowUp', 13: 'ArrowDown', 14: 'ArrowLeft', 15: 'ArrowRight' };@@@ -754,7 +750,7 @@loop: 0,down: new Set(), // buttons held at the last polltone: null, // the tone button A holds-- // After a reset (blur, a hidden page, the e-stop) nothing is pressed again until the pad is let go of++ // After a reset (blur, a hidden page, Stop) nothing is pressed again until the pad is let go of// entirely: a stick still pushed or a button still held does not undo a release.latched: false,dir: null, // the direction button the pad holds (a robot made of buttons)@@@ -787,7 +783,7 @@const now = new Set(p.buttons.map((b, i) => (b && b.pressed ? i : -1)).filter((i) => i >= 0));if (buttonDrive) {// A robot made of buttons: the left stick or the D-pad points at a direction, and its button is pressed.-- // Like the drive: after a reset nothing is pressed again until the pad is let go of; B lets go; Start is the e-stop.++ // Like the drive: after a reset nothing is pressed again until the pad is let go of; B lets go.const ok = g.focused && !document.hidden && joined && allowed.includes('button');let want = stickDir(dead(ax(0), PAD_DEADZONE), -dead(ax(1), PAD_DEADZONE));if (!want) for (const [b, d] of Object.entries({ 12: 'up', 13: 'down', 14: 'left', 15: 'right' })) if (now.has(Number(b))) { want = d; break; }@@@ -795,8 +791,6 @@if (!pad.latched) pad.reset();} else if (pad.latched) {if (!want && !now.size) pad.latched = false;-- } else if (now.has(9) && !pad.down.has(9) && $('[data-estop]:not([hidden])')) {-- estop();} else if (now.has(1) && !pad.down.has(1)) {pad.reset();} else if (want !== pad.dir) {@@@ -822,7 +816,6 @@if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);else if (b === 0) { pad.tone = $$('[data-tone]').find((el) => !el.disabled) || null; if (pad.tone) pressTone(pad.tone); }else if (b === 1) { stopDrive(); pad.reset(); } // a stick still pushed does not drive on-- else if (b === 9 && $('[data-estop]:not([hidden])')) estop();}for (const b of pad.down) if (!now.has(b)) {if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);@@@ -900,7 +893,7 @@// ── Make your own controls: the Arrange editor and the Controls v2 store's one migration ─────────────// The panel is drawn with the person's ui.layout@1 on the server (never a flash of the default). Arrange// loads openvibe-shared/ov-arrange.js only when pressed; the editor works on these real parts. The old-- // browser store (ov-bot-pad-prefs: Stop's hand, pad size, readouts) becomes a layout once, then goes.++ // browser store (ov-bot-pad-prefs: pad size, readouts) becomes a layout once, then goes.const arrangeData = () => {const el = document.getElementById('ov-arrange-data');if (!el) return null;@@@ -916,24 +909,12 @@try { prefs = JSON.parse(raw) || {}; } catch { return; }const { data } = got;const set = (id, fn) => { const e = (data.layout.parts || []).find((p) => p.id === id); if (e) fn(e); };-- if (prefs.hand === 'left') {-- const stick = (data.layout.parts || []).find((p) => p.id === 'stick');-- const stop = (data.layout.parts || []).find((p) => p.id === 'stop');-- if (stick && stop && stick.place && stop.place && stick.place.x != null && stop.place.x != null) {-- const x = stick.place.x; stick.place.x = stop.place.x; stop.place.x = x;-- }-- }if (prefs.size === 'l') {const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;const stick = (data.layout.parts || []).find((p) => p.id === 'stick');-- const stop = (data.layout.parts || []).find((p) => p.id === 'stop');if (stick && stick.place && stick.place.x != null) {stick.place.w = Math.min(3, cols); stick.place.h = 3;stick.place.x = Math.max(0, Math.min(stick.place.x, cols - stick.place.w));-- if (stop && stop.place && stick.place.x < stop.place.x + stop.place.w && stick.place.x + stick.place.w > stop.place.x-- && stick.place.y < stop.place.y + stop.place.h && stick.place.y + stick.place.h > stop.place.y) {-- stick.place.x = 0; stick.place.y = stop.place.y + stop.place.h;-- }}}if (prefs.readouts === 'off') for (const id of ['battery', 'latency', 'telemetry']) set(id, (p) => { p.visible = false; });@@@ -979,6 +960,7 @@if (!surfaceEl) { opening = false; return; }window.ovArrange = window.OVArrange.open({surface: surfaceEl, catalog: data.catalog, layout: data.layout, device: data.device,++ defaults: () => data.defaultLayout && JSON.parse(JSON.stringify(data.defaultLayout)),onSave: async (layout) => {const out = await post(data.save, { surface: data.surface, device: data.device, catalog: data.catalog, layout });data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;@@@ -998,6 +980,32 @@onClose: () => { opening = false; if (saved) setTimeout(() => location.reload(), 60); },confirm: () => true,});++ // The parts palette has one optional part here: Stop. It is off until a person adds it, and it is++ // just a plain button that sends the robot's own Stop (halt). The editor's Save keeps the change.++ const bar = document.querySelector('.ov-arrange-bar .ov-arrange-actions');++ const editorial = window.ovArrange;++ if (bar && editorial && (data.catalog.parts || []).some((p) => p.id === 'stop')) {++ const add = document.createElement('button');++ add.type = 'button'; add.className = 'ovk-btn ovk-btn--quiet'; add.dataset.addStop = 'true';++ const sync = () => {++ const part = (editorial.layout().parts || []).find((p) => p.id === 'stop');++ const on = !!(part && part.visible !== false);++ add.textContent = on ? 'Stop added' : 'Add Stop';++ add.disabled = on;++ };++ add.addEventListener('click', () => {++ const layout = editorial.layout();++ const part = (layout.parts || []).find((p) => p.id === 'stop');++ if (!part) return;++ part.visible = true;++ editorial.apply(layout);++ sync();++ const save = document.querySelector('.ov-arrange-bar [data-save]');++ if (save) { save.disabled = false; save.click(); }++ });++ bar.insertBefore(add, bar.firstChild);++ sync();++ }};btn.addEventListener('click', () => {if (opening) return;@@@ -1145,7 +1153,7 @@}// ── 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.++ // sends a toggle / sole / role, 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);@@@ -1176,21 +1184,6 @@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;@@@ -1215,9 +1208,9 @@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'++ ? 'Always has override.': m.role === 'mod'-- ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'++ ? 'Can switch Operators on or off and give or clear sole control. 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'));@@@ -1261,11 +1254,6 @@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]');@@@ -1285,8 +1273,6 @@if (face) send('display', { face });else if (text) send('display', { text });}));-- $$('[data-estop]').forEach((el) => el.addEventListener('click', estop));-- $$('[data-estop-clear]').forEach((el) => el.addEventListener('click', () => raw({ type: 'estop_clear' })));// Each camera tile takes its camera's shape (CSSOM, so the CSP's no-inline rule holds).for (const cam of $$('[data-resolution]')) {@@@ -1314,7 +1300,7 @@main.addEventListener('pointerdown', (e) => {if (!watcher || driving) return;const t = e.target && e.target.closest ? e.target.closest('[data-command], .bot-stick, .bot-slider') : null;-- if (!t || (e.target.closest && e.target.closest('[data-drive], [data-bot-more], [data-estop]'))) return;++ if (!t || (e.target.closest && e.target.closest('[data-drive], [data-bot-more]'))) return;const drive = $('[data-drive]:not([hidden])');if (!drive) return;e.preventDefault();@@@ -1360,7 +1346,6 @@// What a device's refusal means, in words (a nack's fault code). A deadline that passed in transit is not news.const NACK_TEXT = {local_stop: 'The robot is stopped on the robot itself; release it there first.',-- estopped: 'The e-stop is latched.',not_connected: 'The robot is not connected to its device.',no_heartbeat: 'The device lost its link to the robot.',not_ready: 'The robot is still starting.',@@@ -1408,16 +1393,13 @@const online = $('[data-online-label]');if (online) online.textContent = label;}-- /** The overlay's one-line state: e-stop, offline, your turn, someone else's turn, or just online. */++ /** The overlay's one-line state: offline, your turn, someone else's turn, or just online. */function paintBotPill(state) {const pill = $('[data-bot-pill]');if (!pill) return;-- const latched = !!(state.estop && state.estop.latched);-- const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);const queue = state.queue;let label = 'Online'; let dataState = 'online';-- if (latched || held) { label = 'E-stop on'; dataState = 'estop'; }-- else if (!state.online) { label = 'Offline'; dataState = 'offline'; }++ if (!state.online) { label = 'Offline'; dataState = 'offline'; }else if (queue && queue.active) { label = 'Your turn'; dataState = 'turn'; }else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }const text = $('[data-bot-state]');@@@ -1440,27 +1422,6 @@}function paint(state) {if (!state) return;-- const latched = !!(state.estop && state.estop.latched);-- // The robot's own stop (openvibe-link stop, or its driver's stop button) holds until it is released on the-- // robot: clearing here cannot lift it, so the button waits and the note says where to release it.-- const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);-- const banner = $('[data-estop-banner]');-- if (banner) banner.dataset.latched = String(latched || held);-- const label = $('[data-estop-state]');-- if (label) label.textContent = held ? 'Stopped on the robot' : latched ? 'E-stop latched' : 'E-stop clear';-- 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 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) {-- clear.hidden = !latched;-- clear.disabled = held;-- clear.title = held ? 'Release the stop on the robot first' : '';-- }-- if (latched || held) releaseAll();paintFaults(state);setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');paintBotPill(state);@@@ -1474,7 +1435,6 @@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]')) {@@@ -1543,7 +1503,6 @@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);@@@ -1606,7 +1565,6 @@function stopDriving(text) {driving = false; watcher = true; ticket = null;announceControls();-- for (const el of $$('[data-estop]')) el.hidden = true;const label = $('[data-role-label]');if (label) label.textContent = 'Watching';const bar = $('[data-drive-bar]');@@@ -13,8 +13,8 @@* plays it. Restarted with backoff when it drops* commands say (speechSynthesis), display (a face or text, full screen), actuator torch (the camera's torch) and* chime (a tone while it is held: its deadline ends it, as a motor's does), halt; each acked or nacked-- * safety Bot's e-stop silences everything and refuses all but halt; the phone's own Stop button latches a local-- * stop (estop_state local_stop) that only this phone's Resume releases++ * safety the phone's own Stop button latches a local stop (the old estop_state local_stop report) that only++ * this phone's Resume releases; it stays local** The page needs one tap to start (browsers allow sound, the camera and the wake lock only after a gesture).*/@@@ -95,7 +95,7 @@// ── Running ───────────────────────────────────────────────────────────────────────────────────────────let ws = null; let seq = 0; let retry = 0; let running = false;let heartbeatMs = 1000; let heartbeatTimer = null; let telemetryTimer = null;-- let rtt = null; let remoteLatched = false; let localStop = false;++ let rtt = null; let localStop = false;let front = false;const linkEl = $('[data-link-state]');const videoEl = $('[data-video-state]');@@@ -152,18 +152,16 @@if (m.type === 'hello') { retry = 0; return; }if (m.type === 'config') {heartbeatMs = Math.max(250, Math.min(5000, Number(m.heartbeat_ms) || 1000));-- remoteLatched = !!m.estop_latched;clearInterval(heartbeatTimer); clearInterval(telemetryTimer);heartbeatTimer = setInterval(() => out('heartbeat', { t: Date.now(), rtt_ms: rtt }), heartbeatMs);telemetryTimer = setInterval(sendTelemetry, 1000);-- out('status', { firmware: AGENT, capabilities: { torch: torchSupported(), speech: 'speechSynthesis' in window }, faults: faults(), estop_latched: remoteLatched || localStop });++ out('status', { firmware: AGENT, capabilities: { torch: torchSupported(), speech: 'speechSynthesis' in window }, faults: faults() });if (localStop) out('estop_state', { latched: true, local_stop: true, reason: 'stopped on the phone' });paintSafety();setLink('Connected', 'online');return;}if (m.type === 'heartbeat_ack') { if (Number.isFinite(m.echo)) rtt = Math.max(0, Date.now() - m.echo); return; }-- if (m.type === 'estop') { remoteLatched = !!m.latched; if (remoteLatched) stopEffects(); paintSafety(); return; }if (m.type === 'command') return onCommand(m);if (m.type === 'rotate') return undefined; // a Network-paired machine's frame; a phone holds a Bot credentialreturn undefined;@@@ -175,7 +173,6 @@const v = m.value && typeof m.value === 'object' ? m.value : {};if (m.kind === 'halt') { stopEffects(); return ack(); }if (localStop) return nack('local_stop');-- if (remoteLatched) return nack('estopped');if (m.kind === 'say') return speak(String(v.text || '')) ? ack() : nack('unsupported');if (m.kind === 'display') {if (v.face) showFace(String(v.face)); else if (v.text) showText(String(v.text)); else return nack('invalid');@@@ -200,11 +197,11 @@paintSafety();});function paintSafety() {-- main.dataset.stopped = String(localStop || remoteLatched);++ main.dataset.stopped = String(localStop);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 or a Mod clears the panel 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 when it is safe to move again.' : '';note.hidden = !note.textContent;}@@@ -7,9 +7,9 @@* arena's kinematics (public/sim-world.js) at the page's frame rate.** It keeps the gate's rules that a driver would feel: only the profile's command kinds; drive axes and actuator-- * values shaped to the profile's ranges (as server/domain does); a latched e-stop that refuses everything but halt-- * until it is cleared; and the device's deadman: a motion command lapses max_command_ms after it was sent unless-- * a newer one arrives (the panel re-sends a held control every 150 ms). Bumping into something stops the wheels.++ * values shaped to the profile's ranges (as server/domain does); the device's deadman: a motion command lapses++ * max_command_ms after it was sent unless a newer one arrives (the panel re-sends a held control every 150 ms);++ * and the machine's own Stop (halt). Bumping into something stops the wheels.** window.OVSimLocal exposes the pose, the headlights and the horn to the camera view (public/sim-view.js).*/@@@ -28,7 +28,7 @@const rover = {pose: { ...W.START }, drive: {}, deadline: 0, headlights: null, horn: null, hornUntil: 0,-- battery: 100, estop: false, bumped: false,++ battery: 100, bumped: false,};const clamp = (v, lo, hi) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; };const noteHz = (note) => {@@@ -78,14 +78,13 @@function state() {return {robot_id: main.dataset.robotId, online: true,-- estop: { latched: rover.estop, by: null, at: null },latency_ms: 12, battery: rover.battery / 100,telemetry: {battery: { percent: Math.round(rover.battery) },sensors: { distance_cm: W.distanceCm(rover.pose), line: W.line(rover.pose) },sim: { pose: { ...rover.pose }, drive: { ...rover.drive }, headlights: rover.headlights, horn: rover.horn, bumped: rover.bumped },},-- status: null, device_estop: null, queue: null,++ status: null, queue: null,};}@@@ -98,7 +97,7 @@if (rover.deadline && now > rover.deadline) halt(); // the deadmanif (rover.hornUntil && now > rover.hornUntil) { rover.horn = null; rover.hornUntil = 0; }const moving = Object.values(rover.drive).some((x) => x);-- if (moving && !rover.estop) {++ if (moving) {const r = W.step(rover.pose, rover.drive, dt);rover.pose = r.pose;if (r.bumped && !rover.bumped) halt(); // the bumper stops the wheels@@@ -137,14 +136,11 @@return;}if (!this.joined) return this.deliver({ type: 'error', code: 'bot.not_joined', detail: 'join first' });-- if (m.type === 'estop') { rover.estop = true; halt(); rover.horn = null; return broadcast(); }-- if (m.type === 'estop_clear') { rover.estop = false; return broadcast(); }if (m.type !== 'command') return;const id = String(m.id || '');const kind = String(m.kind || '');const reply = (fields) => this.deliver({ type: 'command_result', id, ...fields }, 10 + Math.round(Math.random() * 14));if (!ALLOWED.includes(kind)) return reply({ result: 'refused', code: 'bot.command_not_allowed', reason: `${kind} is not something this robot does` });-- if (rover.estop && kind !== 'halt') return reply({ result: 'refused', code: 'bot.estopped', reason: 'The e-stop is latched. Clear it to drive again.' });const shaped = shape(kind, m.value);if (shaped.refused) return reply({ result: 'refused', code: 'bot.invalid_input', reason: shaped.refused });apply(kind, shaped.value);@@@ -10,7 +10,7 @@* (domain.devices.revoke): run jobs reference devices and are billing records, so the device* row stays, revoked, without its name.* elsewhere their operator role and queue place on other people's robots go; their id leaves other-- * robots' command history (operator_subject), e-stop marks, pairing codes and invitations.++ * robots' command history (operator_subject), pairing codes and invitations.* their machines the worker machines they added (no robot, plan T14/T16) are exported, then revoked and kept* without a name or an owner (run jobs reference them).* kept run jobs they started: usage seconds reported to Billing, counted as retained.@@@ -25,7 +25,6 @@ const TABLES = [{ table: 'robot_operators', subject: 'subject', file: 'operating.json', columns: ['robot_id', 'role', 'created_at'] },{ table: 'robot_queue', subject: 'subject', file: null },{ table: 'command_audit', subject: 'operator_subject', file: 'commands.json', columns: ['robot_id', 'kind', 'result', 'reason', 'at'], order: 'at', erase: anonymize },-- { 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@@@ -6,10 +6,8 @@* common.resource-control-result@1 (200 for every outcome, without `resource`: robots have no OVRN; a malformed request* is a 400 problem).*-- * stop latch the robot's remote e-stop, as POST /robots/:id/estop: every actuator stops and nothing moves-- * until it is cleared-- * resume clear that e-stop, as POST /robots/:id/estop/clear (a local stop on the robot itself still holds). An-- * agent's resume needs the owner's confirmation, which Services asks for before it sends one.++ * stop send the robot's own Stop command (halt): every actuator stops, and nothing is latched.++ * resume answer that nothing was latched, so there is nothing to clear. A Stop on the machine itself stays local.** Any other action is refused `control.unsupported_action`. Only the owner Bot holds may be named: a robot of another* person is refused as unknown, never confirmed to exist. dry_run decides and explains without applying. The first@@@ -59,24 +57,16 @@ function router({ domain, apiAuth, hub, now = () => Date.now() }) {if (!robot || robot.owner_subject !== request.owner.id) {return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no robot ${request.owned.id} of this person`) });}-- const asker = request.on_behalf_of || { type: 'user', id: request.owner.id };-- if (request.action === 'resume' && asker.type !== 'user' && !request.confirmation_id) {-- return result(request, 'refused', { problem: problem(403, 'control.confirmation_needed', 'an agent resumes a robot only with its owner\'s confirmation') });-- }-- const latched = !!robot.estop_latched;++ const owner = { kind: 'user', subject: robot.owner_subject };if (request.action === 'stop') {-- if (latched) return result(request, 'done', { result: { estop: true, changed: false } });-- if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'latch the robot\'s e-stop' } });-- const by = asker.id;-- await domain.estop.set(robot.id, { latched: true, by, principalKind: asker.type === 'user' ? 'user' : 'agent' });-- if (hub) { await hub.pushEstop(robot.id, true, by); hub.broadcast(robot.id); }-- return result(request, 'done', { result: { estop: true, changed: true } });++ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'send the robot a Stop' } });++ const out = hub++ ? await hub.operatorCommand(robot.id, owner, robot.owner_subject, { id: `rc-${crypto.randomUUID()}`, kind: 'halt', value: {}, ms: 5000 })++ : { result: 'refused', code: 'bot.device_offline' };++ return result(request, 'done', { result: { stop: true, changed: out.result === 'ack', ...(out.result === 'ack' ? {} : { reason: out.reason || out.code || 'the robot is offline' }) } });}-- if (!latched) return result(request, 'done', { result: { estop: false, changed: false } });-- if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'clear the robot\'s e-stop' } });-- await domain.estop.clear(robot.id, robot.owner_subject);-- if (hub) { await hub.pushEstop(robot.id, false, robot.owner_subject); hub.broadcast(robot.id); }-- return result(request, 'done', { result: { estop: false, changed: true } });++ // Nothing is latched any more: a resume has nothing to clear. A Stop held on the machine itself stays local.++ return result(request, 'done', { result: { stop: false, changed: false } });}r.post('/', firstParty, json, wrap(async (req, res) => {@@@ -6,7 +6,7 @@* (openvibe-sdk 0.41 sends it for a person's own resources; an owner is a person, so an agt_ owner matches nothing).* Neither is in a project, so a project filter matches nothing. A malformed filter is a 400.*-- * Kinds: bot.robot (rob_, state ready or estopped), then bot.machine (dev_: a person's own machine, the Desks plan's++ * Kinds: bot.robot (rob_, state ready), then bot.machine (dev_: a person's own machine, the Desks plan's* hosts, state online or offline). A robot's device keeps no owner of its own and is its robot's, not listed here.* The cursor names the kind and id of the last one, so a page that ends among the robots goes on to the machines.*/@@@ -31,7 +31,7 @@ const consoleOf = (baseUrl, path) => (/^https:\/\//.test(String(baseUrl || ''))function robotSummary(row, baseUrl = null) {const summary = {id: row.id, kind: KIND, service: SERVICE,-- name: row.name, state: row.estop_latched ? 'estopped' : 'ready',++ name: row.name, state: 'ready',created_at: new Date(row.created_at).toISOString(),updated_at: new Date(row.updated_at).toISOString(),};@@@ -104,7 +104,7 @@ function router({ db, apiAuth, hub = null, baseUrl = null }) {const cur = filters.cursor;const out = [];if ((!filters.kind || filters.kind === KIND) && (!cur || cur.kind === KIND)) {-- const rows = await db.many(`SELECT id, owner_subject, name, estop_latched, created_at, updated_at++ const rows = await db.many(`SELECT id, owner_subject, name, created_at, updated_atFROM robots WHERE ($1::text IS NULL OR id > $1) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,[cur ? cur.id : null, filters.limit + 1, filters.owner]);out.push(...rows.map((row) => robotSummary(row, baseUrl)));@@@ -27,8 +27,6 @@* | GET /robots/:id/devices | bot.robot.read | a member |* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |* | POST /devices/bind | — (a Link token) | a machine paired for Bot on Network |-- * | POST /robots/:id/estop | bot.robot.control | owner or operator |-- * | POST /robots/:id/estop/clear | bot.robot.control | the owner only |* | POST /robots/:id/commands | bot.robot.control | owner or operator |* | POST /robots/:id/control-ticket | bot.robot.control | a member (or anyone on a queue robot): a Drive ticket |* | GET /robots/:id/audit | bot.robot.read | owner (paged) |@@@ -465,23 +463,6 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json(await domain.streaming.set(req.params.id, { subject, kind: req.principal.kind }, { to: req.body?.to, on: req.body?.on }));}));-- // ── E-stop ────────────────────────────────────────────────────────────────────────────────────-- r.post('/robots/:id/estop', control, wrap(async (req, res) => {-- const { robot } = await member(req, req.params.id, ['owner', 'operator']);-- if (req.principal.kind === 'service') requireControlCap(req);-- if (!robot.estop_latched) await domain.estop.set(req.params.id, { latched: true, by: me(req) || req.principal.sub, principalKind: req.principal.kind });-- await hub.pushEstop(req.params.id, true, me(req) || req.principal.sub);-- hub.broadcast(req.params.id);-- res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });-- }));-- r.post('/robots/:id/estop/clear', control, wrap(async (req, res) => {-- const robot = await owner(req, req.params.id);-- await domain.estop.clear(req.params.id, robot.owner_subject);-- await hub.pushEstop(req.params.id, false, robot.owner_subject);-- hub.broadcast(req.params.id);-- res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });-- }));--// ── A Drive ticket for an embedded panel (server/control-tickets.js) ───────────────────────────// The page that frames a robot's panel (a Live channel bound to it) asks for a person who is signed in there: the// frame cannot see an openvibe.bot session, and the popup Drive took (its own sign-in) was a step too many. The@@@ -949,7 +930,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst st = await hub.robotState(robot.id);const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};res.json({-- robot_id: robot.id, name: robot.name, profile_id: robot.profile_id, online: st.online, estop: st.estop, battery: st.battery, sensors,++ robot_id: robot.id, name: robot.name, profile_id: robot.profile_id, online: st.online, battery: st.battery, sensors,steps: stepHelp(profile), max_steps: AGENT_MAX_STEPS, max_seconds: AGENT_MAX_S,// S7: the commands an agent drove through Bot, as steps ("Drive forward 1 m": passed). A command that// named its stream is filterable with ?agent_stream=ags_…; the list never carries a second way to command.@@@ -1048,7 +1029,7 @@ function commandRefusalStatus(code) {if (code === 'bot.robot_not_found') return 404;if (code === 'bot.not_an_operator' || code === 'bot.read_only' || code === 'bot.forbidden' || code === 'bot.not_your_turn') return 403;if (code === 'bot.cooldown' || code === 'bot.turn_budget') return 429;-- if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;++ if (code === 'bot.device_offline' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;return 422; // unknown_command, command_not_allowed, unknown_actuator, invalid_input, text_too_long}@@@ -178,11 +178,11 @@ function createApp(opts = {}) {};app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, onEvent, log }));-- // ── OpenVibe.Live's kill switch reaches the robot (Agent Streams S7) ──-- // When an agent stream ends, Live e-stops the robot its channel is bound to. The call is loopback-only (nginx-- // answers 404 for /internal/) and carries the deployment's shared secret; it can only latch the e-stop, never-- // clear it. No person token, no chat token, and no route in the public API reaches this.-- app.post('/internal/robots/:id/estop', express.json({ limit: '4kb' }), async (req, res) => {++ // ── OpenVibe.Live's stop reaches the robot (Agent Streams S7) ──++ // When an agent stream ends, Live sends the robot its own Stop (halt). The call is loopback-only (nginx answers++ // 404 for /internal/) and carries the deployment's shared secret. No person token, no chat token, and no route++ // in the public API reaches this.++ app.post('/internal/robots/:id/stop', express.json({ limit: '4kb' }), async (req, res) => {const secret = String(process.env.BOT_KILL_SECRET || '');const given = String(req.get('x-ov-bot-kill') || '');const a = Buffer.from(given);@@@ -195,10 +195,11 @@ function createApp(opts = {}) {try {const robot = await domain.robots.get(id);if (!robot) return res.status(404).json({ error: 'bot.robot_not_found' });-- if (!robot.estop_latched) await domain.estop.set(id, { latched: true, by: 'openvibe.live', principalKind: 'service' });-- return res.json({ ok: true, robot_id: id, estop: { latched: true } });++ await hub.operatorCommand(id, { kind: 'user', subject: robot.owner_subject }, robot.owner_subject,++ { id: `live-stop-${Date.now()}`, kind: 'halt', value: {}, ms: 5000 });++ return res.json({ ok: true, robot_id: id, stop: true });} catch (err) {-- log.error('[Bot] the kill switch failed:', err && err.message);++ log.error('[Bot] the stop switch failed:', err && err.message);return res.status(500).json({ error: 'bot.kill_failed' });}});@@@ -7,12 +7,12 @@** A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more* robots, holding one rotatable, revocable credential (32 random bytes, stored hashed, shown once). The-- * gate applies, in order: the robot exists, its profile takes the command kind, the e-stop is clear, the-- * caller's role, the per-role command allowlist, the access policy (private/invite: owner + operators;-- * queue: the active turn holder too), the device being online (commands are never queued), cooldowns and-- * the per-turn budget, and finally builds the value from the profile's `commands` (names, shapes, ranges,-- * clamped by the owner's limits). `halt` passes the e-stop, the allowlist, cooldowns and the budget: a-- * stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.++ * gate applies, in order: the robot exists, its profile takes the command kind, the caller's role, the++ * per-role command allowlist, the access policy (private/invite: owner + operators; queue: the active++ * turn holder too), the device being online (commands are never queued), cooldowns and the per-turn++ * budget, and finally builds the value from the profile's `commands` (names, shapes, ranges, clamped by++ * the owner's limits). `halt` passes the allowlist, cooldowns and the budget: a Stop is never refused to++ * someone who may drive. Every decision — allowed or refused — is audited.*/const {BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,@@@ -89,7 +89,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,commissioned: r.commissioned_at ? { at: iso(new Date(r.commissioned_at).getTime()), by: r.commissioned_by || null } : null,-- estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),} : null);// A device is presented without any hash: the credential and the publish key are shown once only.@@@ -153,9 +152,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}// ── 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.++ // The owner always has all of this. A Mod may switch Operators (never Mods or the owner) and grant or++ // clear sole control. 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) {@@@ -1193,34 +1192,6 @@ 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 (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 or a Mod clears the panel e-stop');-- return writeEstop(robotId, { latched: true, by, principalKind });-- }-- async function writeEstop(robotId, { latched, by, principalKind }) {-- const robot = await getRobot(robotId);-- if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');-- const at = iso(now());-- const updated = await db.tx(async (t) => {-- const row = await t.maybe('UPDATE robots SET estop_latched = $2, estop_by = $3, estop_at = CASE WHEN $2 THEN $4::timestamptz ELSE NULL END, updated_at = $4 WHERE id = $1 RETURNING *',-- [robotId, latched, latched ? (by || null) : null, at]);-- await emit(t, latched ? 'bot.estop.set' : 'bot.estop.cleared', { type: 'robot', id: robotId },-- { robot_id: robotId, by: latched ? (by || null) : null, principal_kind: principalKind });-- return row;-- });-- return updated;-- }-- 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 !== 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) ───────────────────────────────────────────────────────────const turnMs = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_ms) > 0 ? Number(l.turn_ms) : config.control.queueTurnMs; };const turnBudget = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_budget) > 0 ? Number(l.turn_budget) : config.control.queueTurnBudget; };@@@ -1592,7 +1563,6 @@ 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; the owner or a Mod clears the panel e-stop', robot };const subject = principal && principal.kind !== 'device' ? principal.subject : null;const member = await operatorStateOf(robotId, subject);@@@ -1703,7 +1673,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =deskLeases,streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },-- estop: { set: setEstop, clear: clearEstop },queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff, onResume, epoch: epochOf, giveBack, OWNER_IDLE_MS },audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },@@@ -5,9 +5,7 @@** bot.robot.online a device authenticated and is serving its robot* bot.robot.offline no heartbeat within 2 missed intervals + the grace window-- * bot.estop.set the e-stop latched (by the owner, an operator or the device)-- * bot.estop.cleared the owner cleared the latched e-stop-- * bot.command.refused the gate refused a command (role, allowlist, limits, policy, e-stop, cooldown)++ * bot.command.refused the gate refused a command (role, allowlist, limits, policy, cooldown)** emitIn(t, …) runs inside the transaction that makes the change, so an event exists if and only if its* change committed. The relay publishes with Bot's service token (events.event.publish) only when@@@ -6,7 +6,7 @@* machine that opens a socket. Unlike the simulator it owns a real `kind: 'server'` device row* (domain.devices.ensureServerDevice), so the owner sees the camera as a device of the robot.*-- * It passes the same gate: it answers hello/config, acks commands, mirrors the e-stop and stops at a motion++ * It passes the same gate: it answers hello/config, acks commands and stops at a motion* command's deadline. A `ptz` command is translated to ONVIF ContinuousMove (Stop for zeros/halt), and a* GetStatus after a move is parsed into the camera readout carried by telemetry:*@@@ -93,7 +93,7 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol// ── Attach and lifecycle ──────────────────────────────────────────────────────────────────────function start(robot, profile, camera, device) {-- const cam = { robotId: robot.id, robot, device, camera, profile, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, readout: null, reachable: false, rttMs: null, clockOffsetMs: 0, at: now() };++ const cam = { robotId: robot.id, robot, device, camera, profile, link: null, timers: [], stopTimer: null, readout: null, reachable: false, rttMs: null, clockOffsetMs: 0, at: now() };cameras.set(robot.id, cam);connect(cam);cam.timers.push(every(heartbeatMs, () => { if (cam.link) cam.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: cam.rttMs }); }));@@@ -150,12 +150,10 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol// ── Frames from the hub ───────────────────────────────────────────────────────────────────────function onFrame(cam, f) {-- if (f.type === 'config') { cam.estop = !!f.estop_latched; if (cam.estop) halt(cam); return; }-- if (f.type === 'estop') { cam.estop = !!f.latched; if (cam.estop) halt(cam); return; }++ if (f.type === 'config' || f.type === 'estop') return; // no remote latch: Stop is a commandif (f.type !== 'command') return;const reply = (fields) => cam.link && cam.link.deliver({ id: f.id, ...fields });if (f.kind === 'halt') { halt(cam); return reply({ type: 'ack' }); }-- if (cam.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });if (f.kind !== 'ptz') return reply({ type: 'nack', fault_code: 'bot.unknown_command' });return ptz(cam, f, reply);}@@@ -73,9 +73,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }* `index` < count when given), tone { hz: [min, max] }, bool* say { max_chars }* display { modes: [text|face|image_png_b64], faces: [...] (with face), max_chars }-- * halt {} every profile takes it; the gate never refuses it for the e-stop++ * halt {} every profile takes it: the machine's own Stop* A driver's side action that the closed `commands` schema cannot name (LOOI's headlight) is declared in the open-- * `mapping.actions`; the gate still decides the role, allow list, e-stop and value.++ * `mapping.actions`; the gate still decides the role, allow list and value.*/const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);@@@ -220,7 +220,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datsend(data) { setImmediate(() => { if (ws.readyState === OPEN) onFrame(JSON.parse(data)); }); },close() { if (ws.readyState !== OPEN) return; ws.readyState = 3; onClose(); },};-- const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };++ const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };attachDevice(conn, device);sendHello(conn);const ready = sendConfig(conn).then(() => bringOnline(conn)).catch((e) => log.warn(`[Bot] simulator ${device.id}: ${e.message}`));@@@ -236,9 +236,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat// ── Device side ───────────────────────────────────────────────────────────────────────────────async function onDeviceSocket(ws, req) {// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled-- // in order once it resolves, so a device's first status/estop_state is never lost or answered not_paired.-- // deviceEstop: robot_id → the device's own last estop_state report (a report only, never a clear).-- const conn = { ws, seq: 0, pending: true, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };++ // in order once it resolves, so a device's first status/stop report is never lost or answered not_paired.++ const conn = { ws, seq: 0, pending: true, device: null, online: false, rttMs: null, telemetry: null, status: null, backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };ws.conn = conn;ws.on('message', (raw) => onDeviceFrame(conn, raw));ws.on('close', () => { conn.backlog = null; dropDevice(conn); });@@@ -360,7 +359,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS, SCREEN_KIND])] : base;sendFrame(conn, 'config', {heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,-- estop_latched: robot ? !!robot.estop_latched : false,++ estop_latched: false, // the field stays for older devices; Bot has no remote stop latch// Vibe by voice: the machine listens for its call word only while its owner has voice on....(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),// What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Link@@@ -368,17 +367,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datroles: json(conn.device.roles, []),});}-- /** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist, or the latch moved). */++ /** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist). */async function refreshConfig(robotId) {for (const conn of [...deviceConns.values()]) {if (conn.online && deviceRobotIds(conn.device).includes(robotId)) await sendConfig(conn);}}-- /** Tell the robot's devices the latch moved: the `estop` frame acts at once, then `config` carries the new state. */-- async function pushEstop(robotId, latched, by) {-- sendToRobotDevices(robotId, { type: 'estop', latched, by, at: iso(now()) });-- await refreshConfig(robotId);-- }/** The online/offline event and last_seen are a real machine's: a simulator (no devices row) reports neither. */function reportOnline(conn, online) {if (conn.sim) return Promise.resolve();@@@ -628,7 +622,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn conn.device.node_principal ? persistDeclared(conn, msg) : undefined;case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);case 'ack': case 'nack': return onAck(conn, msg);-- case 'estop_state': return onEstopState(conn, msg);++ case 'estop_state': return onStopReport(conn, msg);case 'voice_ask': return onVoiceAsk(conn, msg);// Machine-to-machine relay (Contracts 0.409.0) and Network's agent tasks to a machine.case 'relay.open': return onRelayOpen(conn, msg);@@@ -731,30 +725,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => DatsendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });}-- // 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 Bot's panel latch stands until the owner or a Mod clears it.-- async function onEstopState(conn, msg) {++ // A device may report a Stop held on the machine itself (the tray's own button or a driver's local stop).++ // That stays local: Bot does not latch it, gate on it, clear it or audit it. The field keeps its old name++ // because devices already send it; the tray itself decides when the robot moves again.++ async function onStopReport(conn, msg) {const attached = deviceRobotIds(conn.device);-- const latched = !!msg.latched;if (msg.robot_id != null && !attached.includes(msg.robot_id)) {-- await domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');}-- // local_stop: the latch is held on the machine itself (openvibe-link stop, a driver's own stop button), which-- // only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.-- const local = latched && msg.local_stop === true;-- const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;-- for (const robotId of msg.robot_id != null ? [msg.robot_id] : attached) {-- conn.deviceEstop.set(robotId, { latched, local, reason, at: iso(now()) });-- if (latched) {-- const robot = await domain.robots.get(robotId);-- if (robot && !robot.estop_latched) {-- await domain.estop.set(robotId, { latched: true, by: conn.device.id, principalKind: 'device' });-- await refreshConfig(robotId);-- }-- }-- broadcast(robotId);-- }++ return undefined;}// ── Operator side ─────────────────────────────────────────────────────────────────────────────@@@ -796,8 +775,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datcase 'join': return onJoin(conn, msg);case 'leave': unsubscribe(conn); conn.robotId = null; return undefined;case '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}`);@@@ -1148,31 +1125,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn undefined;}-- async 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) {-- 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 {-- 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) ───────────────────────────────────────────────────────────/** The caller's address as Express decides it with the same trust proxy hop count (config.trustProxy). */function clientAddress(req) {@@@ -1358,12 +1310,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datbattery: batteryFraction(telemetry),telemetry,status: online && conn.status ? conn.status : null,-- device_estop: online ? (conn.deviceEstop.get(robotId) || null) : null,};const robot = await domain.robots.get(robotId);const state = {robot_id: robotId, online,-- estop: robot ? { latched: !!robot.estop_latched, by: robot.estop_by || null, at: robot.estop_at ? iso(new Date(robot.estop_at).getTime()) : null } : null,...live,queue: null,};@@@ -1384,7 +1334,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn [...keys];}/**-- * What a watcher may see: online, the latch, latency, battery and the readouts' sensor values. Never the++ * What a watcher may see: online, latency, battery and the readouts' sensor values. Never the* queue, a subject, the device's ids, its status or the rest of its telemetry.*/function publicState(state, readouts) {@@@ -1399,7 +1349,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst sim = t && t.sim && typeof t.sim === 'object' && t.sim.pose ? simView(t.sim) : null;return {robot_id: state.robot_id, online: state.online,-- estop: { latched: !!(state.estop && state.estop.latched) },latency_ms: state.latency_ms, battery: state.battery,telemetry: t ? { sensors: picked, ...(sim ? { sim } : {}) } : null,};@@@ -1464,7 +1413,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,++ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, 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@@@ -3,7 +3,7 @@/*** Robot routines (plan T15 "schedules and automations"): an owner writes a robot's steps in plain lines, Bot runs them* on demand, on a schedule, or when the robot comes online. Every step passes the same gate as a person's command, as-- * the robot's owner (hub.operatorCommand): the robot's limits, cooldowns and e-stop apply, and the audit has each one.++ * the robot's owner (hub.operatorCommand): the robot's limits and cooldowns apply, and the audit has each one.** parseSteps(text, profile) → { steps } or throws BotError 422 with the line that is wrong. One step per line:* forward [seconds] [speed] back [s] [speed] left [s] [speed] right [s] [speed] stop@@@ -18,7 +18,7 @@* POST /api/v1/robots/:id/steps); at most AGENT_MAX_STEPS steps and AGENT_MAX_S seconds, nothing stored.** A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero-- * drive ends it. A refused or failed command stops the routine there (an e-stop, the robot going offline, a cooldown++ * drive ends it. A refused or failed command stops the routine there (the robot going offline, a cooldown* the owner set too long); the reason is kept in last_result. One routine runs per robot at a time.*/const { BotError, prefixedId, iso, text } = require('./util');@@@ -4,7 +4,7 @@* The simulated device (ADR-043 decision 3): an in-process stand-in for a robot whose profile's driver is* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers-- * `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself++ * `hello`/`config`, sends heartbeats and telemetry, acks each command and stops by itself* at a motion command's `deadline_ms` unless a newer command arrived first (the Link's deadman).** attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false@@@ -36,7 +36,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = cofunction start(robot) {if (sims.has(robot.id)) return sims.get(robot.id);const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };-- const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };++ const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };sims.set(robot.id, sim);connect(sim);sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));@@@ -55,12 +55,10 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co}function onFrame(sim, f) {-- if (f.type === 'config') { sim.estop = !!f.estop_latched; if (sim.estop) halt(sim); return; }-- if (f.type === 'estop') { sim.estop = !!f.latched; if (sim.estop) halt(sim); return; }++ if (f.type === 'config' || f.type === 'estop') return; // no remote latch: Stop is a commandif (f.type !== 'command') return;const reply = (fields) => sim.link && sim.link.deliver({ id: f.id, ...fields });if (f.kind === 'halt') { halt(sim); return reply({ type: 'ack' }); }-- if (sim.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });if (f.kind === 'drive') {sim.drive = { ...f.value };if (sim.stopTimer) clearTimeout(sim.stopTimer);@@@ -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 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>`;++<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns. A Stop is one optional button that sends the robot's own stop command; the tray's own Stop stays 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 });}@@@ -235,7 +235,7 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rol<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</div><div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>--<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;++<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate as on its own panel.</p>`;return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });}@@@ -89,7 +89,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label<section class="card"><h2>What a driver is</h2><p>A driver is a <b>separate executable</b> the OpenVibe Link core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p><p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>--<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section>++<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the deadman and the local Stop, and forwards already-validated commands to a driver.</p></section><section class="card"><h2>The three connection kinds</h2><p>The robot's profile carries a <code>kind</code>, which says where the Link runs relative to the robot:</p>@@@ -108,7 +108,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label<li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li><li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li></ol>--<p>A driver that exits is restarted with exponential backoff (1 s doubling to 30 s, reset after a minute of uptime). It starts stopped; if the e-stop or the local kill switch is latched, the core sends <code>estop</code> right after <code>hello</code>. A driver that does not describe itself within 15 s, or stops reading stdin, is killed.</p></section>++<p>A driver that exits is restarted with exponential backoff (1 s doubling to 30 s, reset after a minute of uptime). It starts stopped; if a local Stop is latched, the core sends <code>stop</code> right after <code>hello</code>. A driver that does not describe itself within 15 s, or stops reading stdin, is killed.</p></section><section class="card"><h2>Messages</h2><p>Every message is one JSON object on one line with an <code>op</code> field. Command <code>value</code> has already been validated and clamped to the owner's limits by the core, and <code>deadline_ms</code> is relative to receipt and already capped.</p>@@@ -117,8 +117,6 @@ ${message('hello', 'start-up: the driver answers describe; then sets up unless p${message('command', 'act, then answer ack or nack with the same id', '{ "op": "command", "id": "cmd_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "kind": "drive", "value": { "throttle": 0.5, "steer": 0 }, "deadline_ms": 300 }')}${message('heartbeat', 'the core clock; note the time for the deadman', '{ "op": "heartbeat", "t": 1728384000000 }')}${message('stop', 'stop every actuator (idempotent, safe at any time)', '{ "op": "stop" }')}--${message('estop', 'stop every actuator and refuse motion until resume', '{ "op": "estop" }')}--${message('resume', 'accept motion again', '{ "op": "resume" }')}<h3>Driver → core</h3>${message('describe', 'the driver names itself and its capabilities, before hardware', '{ "op": "describe", "driver": "my_arm", "version": "0.1.0", "protocol": 1, "capabilities": { "actuator": { "names": ["shoulder", "gripper"] } }, "motion_kinds": ["drive", "actuator"] }')}${message('ready', 'setup finished, every actuator stopped', '{ "op": "ready" }')}@@@ -136,11 +134,11 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '<li>when its <b>stdin closes</b> (EOF), then exit;</li><li>when a <b>motion command's deadline passes</b> without a newer command (its own timer to <code>deadline_ms</code>);</li><li>when it has <b>not heard a heartbeat for 1 s</b> (and refuse motion with <code>no_heartbeat</code> until they return);</li>--<li>on <code>stop</code>, <code>estop</code> and a <code>halt</code> command;</li>++<li>on <code>stop</code> and a <code>halt</code> command;</li><li>when its own sensors say so (for example a cliff or a pick-up).</li></ul>--<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>--<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-link resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>++<p><code>stop</code> must be idempotent and safe at any time, including before setup. A local Stop that your own hardware holds stays local: keep refusing motion until it is released there.</p>++<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> Stop: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and only <code>openvibe-link resume</code> on that machine releases it. It works even when the Link is not answering and survives restarts. The tray's own Stop stays local. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section><section class="card"><h2>A minimal driver in Python</h2><p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>@@@ -211,11 +209,6 @@ for line in sys.stdin: # EOF means the core is gone: stop andlast = time.monotonic()elif m["op"] == "stop":stop()-- elif m["op"] == "estop":-- latched = True-- stop()-- elif m["op"] == "resume":-- latched = Falsestop()`)}</section><section class="card"><h2>Test it</h2>@@@ -295,7 +288,7 @@ const WEBHOOK_VERIFY = `const { verifyDeliveryV2 } = require('openvibe-sdk/eventapp.post('/robot-events', express.text({ type: 'application/json' }), (req, res) => {if (!verifyDeliveryV2(req.body, req.headers, process.env.ROBOT_WEBHOOK_SECRET)) return res.status(401).end();const event = JSON.parse(req.body); // { id, type, robot_id, occurred_at, data }-- if (event.type === 'bot.estop.set') console.log('robot', event.robot_id, 'stopped');++ if (event.type === 'bot.robot.offline') console.log('robot', event.robot_id, 'is offline');res.status(204).end();});`;@@@ -317,7 +310,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/stepfunction renderAutomationPage({ config }) {const content = `--${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.` })}++${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits and cooldowns apply, and the audit records each command.` })}<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2><p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>@@@ -328,7 +321,7 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:<li><code>headlights #ff8800</code>, <code>horn A4 0.3</code>, <code>lights off</code>, <code>pan 0.5 1</code>: an actuator or a camera axis by its name in the profile, with a value and how long to hold it.</li><li><code>press horn</code>: one of its buttons.</li></ul>--<p>A held step (a motion or a tone) is re-sent inside the device's deadman window and released at its end. A refused step (the e-stop, the robot going offline, a cooldown) stops the rest, with the reason.</p></section>++<p>A held step (a motion or a tone) is re-sent inside the device's deadman window and released at its end. A refused step (the robot going offline, a cooldown) stops the rest, with the reason.</p></section><section class="card" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2><p>On a robot's panel, under <b>Routines</b>, the owner names a routine, writes its steps and picks when it runs:</p>@@@ -341,13 +334,13 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:<p>A routine runs as the owner. One routine runs on a robot at a time; removing a running routine stops it. Limits: 60 steps, 60 seconds a step, 10 minutes in all. The same list is <code>GET|POST /api/v1/robots/:id/routines</code>, <code>POST …/routines/:rid/run</code> and <code>DELETE …/routines/:rid</code>, for the owner or a service with <code>bot.robot.manage</code>.</p></section><section class="card" aria-labelledby="hooks-h"><h2 id="hooks-h">Webhooks</h2>--<p>Under <b>Webhooks</b>, the owner adds up to five https addresses, each for some of the robot's events: <code>bot.robot.online</code>, <code>bot.robot.offline</code>, <code>bot.estop.set</code>, <code>bot.estop.cleared</code>, <code>bot.command.refused</code>. Each hook has its own secret (<code>whsec_…</code>), shown once when it is made.</p>++<p>Under <b>Webhooks</b>, the owner adds up to five https addresses, each for some of the robot's events: <code>bot.robot.online</code>, <code>bot.robot.offline</code>, <code>bot.command.refused</code>. Each hook has its own secret (<code>whsec_…</code>), shown once when it is made.</p><p>Bot posts <code>{ id, type, robot_id, occurred_at, data }</code>, signed the way OpenVibe.Events signs a delivery (<code>X-OpenVibe-Timestamp</code>, <code>X-OpenVibe-Signature-V2</code>), only to public addresses, never following a redirect. Anything but a 2xx is retried after 30 seconds, then 2, 10, 30 and 120 minutes. Check the signature before trusting a delivery:</p>${code(WEBHOOK_VERIFY)}<p class="muted">The API is <code>GET|POST /api/v1/robots/:id/webhooks</code> (the POST answers the secret) and <code>DELETE …/webhooks/:hid</code>.</p></section><section class="card" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>--<p>On the panel, <b>Your agent</b> has one switch: <b>Let my OpenVibe.Actor agent drive this robot</b>. It is off until the owner turns it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "drive my rover forward for a second and turn its lights green", can see your robots (online, e-stop, battery, sensors) and run steps on this one, as you. It never acts for anyone else, and turning the switch off stops it at once.</p>++<p>On the panel, <b>Your agent</b> has one switch: <b>Let my OpenVibe.Actor agent drive this robot</b>. It is off until the owner turns it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "drive my rover forward for a second and turn its lights green", can see your robots (online, battery, sensors) and run steps on this one, as you. It never acts for anyone else, and turning the switch off stops it at once.</p><p>Your own token runs steps directly, and answers when they end:</p>${code(STEPS_CALL)}<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>@@@ -28,7 +28,7 @@ const { pickBlock, foldBlock } = require('./short');const SITE_NAME = 'OpenVibe.Bot';const NETWORK_URL = 'https://openvibe.network';const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '-- + 'with roles, limits and an emergency stop on every command.';++ + 'with roles and limits on every command.';const esc = showcase.esc;/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */@@@ -82,7 +82,7 @@ function hero({ signedIn, config }) {eyebrow: 'OpenVibe.Bot · alpha',title: 'Drive your robot', accent: 'from any browser.',lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '-- + 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',++ + 'the keyboard or a gamepad. Every command passes one gate, with roles and limits that you set.',// Two, so they sit side by side on a phone; how it works is the steps a scroll below.actions: signedIn? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }]@@@ -135,7 +135,7 @@ function connectSection({ models }) {label: g.label,html: kit.list([kit.row({ href: g.ms.length === 1 ? `/models/${g.ms[0].id}` : `/models#k-${g.k}`, title: g.label, sub: g.text, meta: plural(g.ms.length, 'model', 'models') })]),}));-- return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel, the same gate and the same emergency stop.',++ return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel and the same gate.',pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));}@@@ -162,14 +162,14 @@ 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 the owner or a Mod clears (the panel latch only); a halt always gets through.'],++ ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a Stop button that sends the robot its own stop; 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.'],]);const automate = rows([['/docs/automation', 'Routines', 'Plain step lines such as forward 2, wait 1, say Hello, on demand, on a schedule, or each time the robot comes online.'],-- ['/docs/automation', 'Signed webhooks', 'Online, offline, e-stop and refused commands sent to your own https address, signed like OpenVibe.Events deliveries.'],++ ['/docs/automation', 'Signed webhooks', 'Online, offline and refused commands sent to your own https address, signed like OpenVibe.Events deliveries.'],['https://openvibe.actor/', 'Your agent drives', 'Turn on Your agent and your OpenVibe.Actor agent can run steps on that robot, as you and nobody else.'],]);const computers = rows([@@@ -178,7 +178,7 @@ function moreSection({ config, kit: k, models }) {['/docs/computers', 'A desk live on your channel', 'When you choose, an agent\'s desk shows on your OpenVibe Live channel for up to 30 minutes, so people can watch it work.'],]);const api = showcase.code({-- id: 'api', title: '', lede: 'A REST API and the openvibe-sdk client, held to the same roles, limits and emergency stop as the panel. Profiles and kits are public reads.',++ id: 'api', title: '', lede: 'A REST API and the openvibe-sdk client, held to the same roles and limits as the panel. Profiles and kits are public reads.',samples: [{ label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createBotClient } from 'openvibe-sdk/bot';\n\nconst bot = createBotClient(createClient({ credentials: 'include' }));\nconst { robots } = await bot.robots.list();\n\n// Half speed ahead for 300 ms, same gate as the joystick\nawait bot.robots.command(robots[0].id, {\n kind: 'drive', value: { throttle: 0.5, steer: 0 }, ms: 300,\n});" },{ label: 'Public reads (curl)', lang: 'sh', code: `# Every robot model and its controls\ncurl ${base}/api/v1/profiles\n\n# The kits, with their parts lists\ncurl ${base}/api/v1/kits` },@@@ -200,7 +200,7 @@ function moreSection({ config, kit: k, models }) {head: `<h2 id="more-h" class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>`,html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}--${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}++${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same audit.</p>${automate}` })}${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Link', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}@@@ -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 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: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. A Stop is one optional button that sends the robot its own stop; 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,8 +30,6 @@ 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 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'];@@@ -149,8 +147,9 @@ const DIRECTION_BUTTONS = [/*** The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest.-- * Its stick, Stop and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):++ * Its stick and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):* the section around them flattens (`display: contents`) so each is a cell of the panel's grid.++ * Stop is its own optional part, off by default, added from the Arrange palette.*/function drivePad(axes, enabled, attrs = {}, flatten = false) {const x = STICK_X.find((a) => axes[a]);@@@ -166,8 +165,7 @@ function drivePad(axes, enabled, attrs = {}, flatten = false) {+ `<span class="stick-knob" data-knob></span></div>`: '';const pad = Object.keys(rest).length ? `<div class="pad" data-ov-part="pad" ${attrs.pad || ''}>${axisButtons(rest, enabled)}</div>` : '';-- return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}`-- + `<button type="button" class="stop" data-stop data-ov-part="stop" ${attrs.stop || ''}${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;++ return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}</div>`;}/** An actuator's name as a label: backpack_lights → Backpack lights. */@@@ -281,7 +279,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';inner = drivePad(axes, enabled, partAttrs, flatten)+ `<p class="hint" data-ov-decor><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `-- + `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;++ + `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops.</span></p>`;break;}case 'ptz': {@@@ -392,7 +390,7 @@ function routinesCard(robot, profile, a) {};const t = v.trigger || 'manual';return `<section class="setting-card" id="routines" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>--<p class="setting-note">Steps the robot runs by itself, through the same limits, cooldowns and e-stop as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>++<p class="setting-note">Steps the robot runs by itself, through the same limits and cooldowns as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>${a.routineError ? `<p class="error" role="alert">${esc(a.routineError)}</p>` : ''}${a.routines.length ? `<ul class="people-list">${a.routines.map(row).join('\n')}</ul>` : ''}<details${a.routineError ? ' open' : ''}><summary>Add a routine</summary>@@@ -431,7 +429,7 @@ ${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>`function agentForm(robot) {return `<section class="setting-card" id="agent" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2><form class="embed-form" method="post" action="/robots/${esc(robot.id)}/agent">--${toggle('agent_control', robot.agent_control, 'Let my OpenVibe.Actor agent drive this robot', 'Ask your agent on openvibe.actor to move it, speak or show something. It acts as you, through the same limits, cooldowns and e-stop, and every command it sends is on the record.')}++${toggle('agent_control', robot.agent_control, 'Let my OpenVibe.Actor agent drive this robot', 'Ask your agent on openvibe.actor to move it, speak or show something. It acts as you, through the same limits and cooldowns, and every command it sends is on the record.')}<button type="submit">Save</button></form></section>`;}@@@ -456,7 +454,7 @@ function onStreamCard(robot, { liveUrl = null } = {}) {? `<p class="setting-ok" role="status">${icon('check')}<span>Its panel shows wherever it is embedded. Bound to your channel, viewers see it under your stream.</span></p>`: '';return `<section class="setting-card on-stream" id="on-stream" aria-labelledby="on-stream-h"><h2 id="on-stream-h">On your stream</h2>--<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons, the e-stop and the latency.</p>++<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons and the latency.</p>${state}${liveUrl ? `<form method="post" action="/robots/${esc(robot.id)}/on-stream"><button type="submit" class="button primary">${icon('gamepad')}<span>Put the buttons under my OpenVibe.Live stream</span></button></form><p class="setting-note">Opens your channel on OpenVibe.Live, which asks you to confirm. To take it off, choose no robot there, or turn off the panel below.</p>` : ''}<form class="access-form" method="post" action="/robots/${esc(robot.id)}/access">@@@ -558,7 +556,7 @@ function peopleCard(robot, people) {};const queue = robot.access_policy === 'queue' ? ' Anyone signed in can also take a turn: this robot uses the open queue.' : '';return `<section class="setting-card people" aria-labelledby="people-h"><h2 id="people-h">People</h2>--<p class="setting-note">Operators drive and can press the e-stop; viewers watch.${queue}</p>++<p class="setting-note">Operators drive; viewers watch.${queue}</p><ul class="people-list"><li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>${(people || []).map(row).join('\n')}@@@ -569,8 +567,8 @@ ${(people || []).map(row).join('\n')}/*** 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`.++ * indicator and last command, the viewers' count, sole control, the per-person switches and promotion/demotion.++ * 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) {@@@ -618,9 +616,9 @@ function operatorsCard(robot, operators, role, viewer = null) {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'++ ? 'Always has override.': m.role === 'mod'-- ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'++ ? 'Can switch Operators on or off and give or clear sole control. 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>`@@@ -634,12 +632,6 @@ function operatorsCard(robot, operators, role, viewer = null) {+ `<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>@@@ -650,8 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${${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>++<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The tray's own Stop stays local. The owner always overrides.</p></section>`;}@@@ -713,21 +704,21 @@ function commissionForm(robot, profile) {${field('max_speed', 'Top speed', own.max_speed != null ? own.max_speed : limits.max_speed, ceiling.max_speed)}${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max_turn, ceiling.max_turn)}<button type="submit" class="primary">Save the limits</button></form>--<p class="setting-note">The robot gets them at once. The e-stop, cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p>++<p class="setting-note">The robot gets them at once. Cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p><form method="post" action="/robots/${esc(robot.id)}/uncommission"><button type="submit" class="quiet">Return to the profile defaults (${esc(limits.max_speed)} speed, ${esc(limits.max_turn)} turn)</button></form></section>`;}// Not commissioned: one deliberate act, with the owner saying what they checked.return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2><p class="setting-note">The profile's speed (${esc(limits.max_speed)}) and turn (${esc(limits.max_turn)}) limits clamp every command. Commissioning this robot lets you set its own higher values ${allow}. It records that you did it and when, and it is yours alone: operators and your agent can never do it.</p><form method="post" action="/robots/${esc(robot.id)}/commission" class="setting-form">--<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its e-stop.</label>++<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its Stop.</label><button type="submit" class="primary">Commission this robot</button></form></section>`;}// ── The same controls over a picture ({mode=overlay}) or in a narrow card ({mode=compact}) ───────────────// The embeddable panel normally draws the whole page. Over a stream it must draw only the live controls:--// the first stick, the first slider, the e-stop and a way to the full panel. Which ones exist comes from the++// the first stick, the first slider, Drive and a way to the full panel. Which ones exist comes from the// robot's profile (commands + widgets), exactly as the full panel does; nothing here is robot-specific.const BOT_VIEWS = new Set(['panel', 'overlay', 'compact']);const botViewOf = (v) => (BOT_VIEWS.has(v) ? v : 'panel');@@@ -787,13 +778,9 @@ function overlaySlider(profile, allowed) {}const botPill = (robot, attrs = '') => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit data-ov-part="pill" ${attrs}><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;--// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,--// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says--// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over--// the stream there was no way to start driving at all.--const botEstop = (role, canDrive = false, attrs = '') => (ESTOP_ROLES.has(role)-- ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" ${attrs} aria-label="E-stop" title="E-stop">${icon('stop')}</button>`-- : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" data-ov-role-hidden="true" ${attrs} aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');++// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel, and the line++// under the controls says what the page around answered (sign in there, or the owner decides who drives). Ask 353,++// 2026-11-11: on a phone over the stream there was no way to start driving at all.const botDrive = (canDrive, attrs = '') => (canDrive? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit data-ov-part="drive" ${attrs} title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';@@@ -807,7 +794,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResconst stick = overlayStickMarkup(profile, allowed_commands);const slider = overlaySlider(profile, allowed_commands);const canDrive = role === 'watcher' && !signedIn;-- const stop = botEstop(role, canDrive, attrs('estop')) + botDrive(canDrive, attrs('drive'));++ const stop = botDrive(canDrive, attrs('drive'));const sliderLabel = slider ? esc(humanName(slider.name)) : '';const sliderMarkup = slider? `<div class="bot-slider bot-control" data-bot-hit data-ov-part="slider" ${attrs('slider')}><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`@@@ -817,7 +804,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdResconst body = view === 'compact'? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'} ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`: `<div class="bot-overlay" data-bot-overlay ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`;-- const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : '';++ const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null, defaultLayout: arrange.defaultLayout || null }).replace(/</g, '\\u003c')}</script>` : '';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>`;}@@@ -836,10 +823,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs}const minSize = BOT_VIEW_MIN.panel;const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';-- const latched = !!(robot.estop && robot.estop.latched);const widgetOrder = (profile.widgets || []).map((w, i) => ({ w, i })).sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i));-- // The arrangeable part ids each widget draws: the drive is split into its stick, Stop and direction buttons.++ // The arrangeable part ids each widget draws: the drive is split into its stick and direction buttons.const byWidget = new Map();for (const p of (arrange && arrange.catalog ? arrange.catalog.parts || [] : [])) {const source = (arrange.parts || []).find((x) => x.id === p.id);@@@ -851,11 +837,12 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMsconst arrangeApply = !!(arrange && arrange.layoutSource === 'account');const attrsOf = (ids) => Object.fromEntries(ids.map((id) => [id, arrange ? require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '']));const widgets = widgetOrder.map(({ w, i }) => renderWidget(w, { profile, allowed_commands, signaling_url, part: widgetParts(i)[0] || null, partAttrs: attrsOf(widgetParts(i)), flatten: arrangeApply }));-- // A robot with no drive widget still gets its Stop: a required part of bot.controls, drawn on its own.-- const drawn = new Set([...byWidget.values()].flat());-- if (arrange && !drawn.has('stop') && require('./surface').partOf(arrange.catalog, '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>`);++ // Stop is one ordinary part of bot.controls, off by default: it appears only when a layout adds it.++ if (arrange && require('./surface').partOf(arrange.catalog, 'stop')) {++ // The default layout places Stop hidden (off), so apply its attributes even when the page keeps its own look.++ const stopAttrs = require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, 'stop', { apply: true });++ widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${stopAttrs}>`++ + `<button type="button" class="stop plain-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.@@@ -872,15 +859,10 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs// 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' || 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}">++ const body = `<header class="panel-top">${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 or a Mod clears it.' : ''}</span></p>${clear}${estop}</div></header><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">@@@ -896,7 +878,7 @@ ${tryIt ? tryIntro() : ''}<div class="widgets"${arrange ? require('./surface').surfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply) : ''}>${widgets.join('\n')}</div>--${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null, defaultLayout: arrange.defaultLayout || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -905,13 +887,13 @@ ${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringi});}--const TRY_DESCRIPTION = 'Drive a simulated rover from your browser with no account or hardware: the same Bot panel, safety gate and e-stop a real robot gets, with a live camera.';++const TRY_DESCRIPTION = 'Drive a simulated rover from your browser with no account or hardware: the same Bot panel and safety gate a real robot gets, with a live camera.';/** /try's opening lines: what this is, and that nothing here is real hardware. */function tryIntro() {return `<section class="try-intro" aria-labelledby="try-h"><h2 id="try-h">Drive a rover, right here</h2>--<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls, the same limits, and an e-stop that holds until it is cleared.</p>++<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls and the same limits.</p><ul class="try-keys"><li><kbd>W A S D</kbd> or the arrows, or drag the joystick</li><li><kbd>Space</kbd> stops</li><li>Follow the yellow tape, or find the crates</li></ul><p><button type="button" class="button quiet" data-try-reset>Back to the start</button></p></section>`;@@@ -79,19 +79,19 @@ const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/docs', '/docs/drivers// written from what the page and its renderer actually say (README "Owns", web/home.js, web/docs.js, shared/legal).const LLMS_NAME = 'OpenVibe.Bot';const LLMS_SUMMARY = 'An open control panel for robots: pair a robot with one pasted command and drive it from any '-- + 'browser, with roles, limits and a latched emergency stop on every command.';++ + 'browser, with roles and limits on every command.';const LLMS_NO_TWIN = new Set(['/terms', '/privacy', '/dmca']);const LLMS_LEGAL = new Set(['/terms', '/privacy', '/dmca']);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 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.',++ 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. Stop is one optional button that sends the machine\'s own stop; a stop pressed on the robot itself stays local. 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',note: 'Drive a simulated rover in the browser, no account and no hardware.',-- text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',++ text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping and deadman. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',},{path: '/setup/sd-card', title: 'A robot from a blank microSD card',@@@ -116,7 +116,7 @@ const LLMS_PAGES = [{path: '/docs/drivers', title: 'Write a driver',note: 'The driver protocol: lifecycle, every message and the safety rules.',-- text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',++ text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',},{path: '/docs/profiles', title: 'Robot profiles',@@@ -126,7 +126,7 @@ const LLMS_PAGES = [{path: '/docs/automation', title: 'Routines, webhooks and your agent',note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',-- text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',++ text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',},{path: '/docs/computers', title: 'Your computer for your agents',@@@ -266,7 +266,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limir.get('/try', (req, res) => {res.setHeader('Cache-Control', 'public, max-age=300');res.type('html').send(renderPanel({-- robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private', estop: { latched: false } },++ robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private' },profile: TRY_PROFILE, role: 'owner', allowed_commands: tryAllowed, holdResendMs: HOLD_RESEND_MS, tryIt: true,}));});@@@ -982,7 +982,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst stored = usable(catalog, storedRaw, fallbackLayout);arrange = {catalog, layout: stored || fallbackLayout, device,-- signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default',++ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', defaultLayout: fallbackLayout,save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,};@@@ -995,7 +995,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limitry { src = sharedServe.url('ov-arrange.js'); } catch { /* an older openvibe-shared pin: no editor */ }arrange = {catalog, parts: surface.partsOf(profile), layout: stored || fallbackLayout, device,-- signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src,++ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src, defaultLayout: fallbackLayout,save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,};@@@ -6,9 +6,10 @@* that put a person's layout on the parts the pages render.** A catalog is built from the robot's profile: the parts a layout may place are exactly the controls this robot-- * has (the drive stick, Stop, a head slider, battery, latency, the camera, the buttons, ...). Stop and the-- * overlay's e-stop are required and carry their own minimum pixel size. The default layout keeps the panel's-- * familiar order: the camera, then the stick and Stop side by side, then the rest.++ * has (the drive stick, a head slider, battery, latency, the camera, the buttons, ...). Stop is one ordinary part,++ * off by default: a person adds it from the Arrange palette when they want it. Nothing is required, so every part++ * can be hidden or left out. The default layout keeps the panel's familiar order: the camera, then the stick,++ * then the rest.** A layout only arranges: every command still goes through the panel's one gate and the robot's limits.*/@@@ -42,7 +43,7 @@ function basePart(id, name, kind, extra = {}) {};}--/** The parts one widget contributes: the drive is split into its stick, Stop and direction buttons. */++/** The parts one widget contributes: the drive is split into its stick and direction buttons. */function driveParts(profile, widget) {const kind = widget && widget.command ? widget.command.kind : 'drive';const command = (profile.commands || {})[kind];@@@ -56,11 +57,6 @@ function driveParts(profile, widget) {styles: { tints: ['surface', 'accent'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: false },bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },}), { widgetIndex: null }]);-- parts.push([basePart('stop', 'Stop', 'button', {-- required: true, pressed: true, min: { w: 2, h: 2, px: 68 },-- styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },-- bindings: { keys: ['Space'], gamepad: ['south'] },-- }), { widgetIndex: null }]);if (rest.length) parts.push([basePart('pad', 'Direction buttons', 'button', {pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],styles: { tints: ['surface'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: true },@@@ -120,14 +116,12 @@ function partsOf(profile) {}), { widgetIndex: i, widgetType: w.type });}});-- if (!drive) {-- // Every robot with a panel can be stopped, even one made only of a pan-tilt camera or buttons.-- if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {-- required: true, pressed: true, min: { w: 2, h: 2, px: 68 },-- styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },-- bindings: { keys: ['Space'], gamepad: ['south'] },-- }));-- }++ // Stop is a plain, optional control: every robot may have one, and a person adds it when they want it.++ if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {++ pressed: true, min: { w: 1, h: 1, px: 44 },++ styles: { tints: ['surface'], shapes: ['pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] },++ }));return out;}@@@ -135,7 +129,7 @@ function partsOf(profile) {function controlsCatalog(profile) {return {id: 'bot.controls', version: 1, name: 'Robot controls',-- description: 'The full OpenVibe.Bot panel: the drive stick, Stop, the head or camera slider, the buttons, the camera and the readouts.',++ description: 'The full OpenVibe.Bot panel: the drive stick, the head or camera slider, the buttons, the camera and the readouts. Stop is an optional part.',devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS,parts: partsOf(profile).map((p) => {const { widgetIndex, widgetType, ...part } = p; // eslint-disable-line no-unused-vars@@@ -144,31 +138,31 @@ function controlsCatalog(profile) {};}--/** The overlay's catalog: the stick, the slider, the e-stop, more and the pill (plus Drive for a watcher). */++/** The overlay's catalog: the stick, the slider, the more button, the pill and an optional Stop (plus Drive for a watcher). */function overlayCatalog(profile, { slider = null, hasStick = false, canDrive = false } = {}) {const parts = [basePart('pill', 'The robot', 'readout', { pressed: false, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }),basePart('more', 'More', 'button', { pressed: true, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false }, bindings: { keys: [], gamepad: [] } }),-- basePart('estop', 'E-stop', 'button', {-- required: true, pressed: true, min: { w: 2, h: 2, px: 56 }, never_covers: ['more', 'pill'],-- styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },-- bindings: { keys: ['Space'], gamepad: ['start'] },++ basePart('stop', 'Stop', 'button', {++ pressed: true, min: { w: 1, h: 1, px: 44 }, never_covers: ['more', 'pill'],++ styles: { tints: ['surface'], shapes: ['pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },++ bindings: { keys: ['Space'], gamepad: ['south'] },}),];if (hasStick) parts.push(basePart('stick', 'Stick', 'stick', {-- pressed: true, min: { w: 2, h: 2 }, never_covers: ['estop', 'pill'],++ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop', 'pill'],styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },}));if (slider) parts.push(basePart('slider', slider.name || 'Slider', 'slider', {-- pressed: true, min: { w: 2, h: 1 }, never_covers: ['estop'],++ pressed: true, min: { w: 2, h: 1 }, never_covers: ['stop'],styles: { tints: ['surface', 'accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },bindings: { keys: [], gamepad: [] },}));if (canDrive) parts.push(basePart('drive', 'Drive', 'button', { pressed: true, min: { w: 2, h: 1 }, styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }));return {id: 'bot.overlay', version: 1, name: 'Robot over a stream',-- description: 'The controls over a robot’s stream: the stick, the slider, the e-stop, more and the robot’s pill.',++ description: 'The controls over a robot’s stream: the stick, the slider, more, the robot’s pill and an optional Stop.',devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS, parts,};}@@@ -202,24 +196,26 @@ function resolvedEntry(catalog, layout, device, id) {}/**-- * The default layout: the camera, then the stick and Stop, then the rest in the catalog's order. Phone rows are-- * full width; a computer gives the camera eight of its twelve columns and keeps the stick and Stop beside it.++ * The default layout: the camera, then the stick, then the rest in the catalog's order. Stop is placed last and++ * hidden: it is off until a person adds it. Phone rows are full width; a computer gives the camera eight of its++ * twelve columns and keeps the stick beside it.*/function defaultLayout(catalog, device = 'computer') {const d = DEVICES.includes(device) ? device : 'computer';const cols = GRID.columns[d];const order = [...(catalog.parts || [])].sort((a, b) => {-- const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'stop' ? -1 : p.id === 'pad' ? 0 : 1);++ const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'pad' ? 0 : p.id === 'stop' ? 2 : 1);return rank(a) - rank(b);});let x = 0; let y = 0; let rowH = 0;const parts = order.map((p) => {const camera = p.id === 'camera';-- const side = p.id === 'stick' || p.id === 'stop';++ const side = p.id === 'stick';let w = camera ? (d === 'computer' ? 8 : cols) : side ? 2 : Math.min(cols, Math.max(p.min.w, d === 'phone' ? cols : 4));if (p.kind === 'readout' && d !== 'phone') w = Math.min(cols, Math.max(2, p.min.w));++ if (p.id === 'stop') w = 1;w = Math.min(cols, w);-- let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' || p.id === 'stick' ? 2 : Math.max(p.min.h, 2);++ let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' ? 1 : p.id === 'stick' ? 2 : Math.max(p.min.h, 2);if (p.kind === 'slider') h = Math.max(1, p.min.h);if (p.id === 'latency') h = 2;if (x + w > cols) { x = 0; y += rowH; rowH = 0; }@@@ -227,12 +223,12 @@ function defaultLayout(catalog, device = 'computer') {x += w;rowH = Math.max(rowH, h);const st = styleOf(p, null);-- return { id: p.id, visible: true, place, style: st, bindings: { keys: [], gamepad: [] } };++ return { id: p.id, visible: p.id !== 'stop', place, style: st, bindings: { keys: [], gamepad: [] } };});return { surface: catalog.id, surface_version: catalog.version, device: d, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };}--/** The overlay's default layout: the pill and stick left, more and e-stop right, the slider at the bottom. */++/** The overlay's default layout: the pill and stick left, more and Drive right, the slider at the bottom. */function overlayDefaultLayout(catalog, device = 'computer') {const dockedOf = (id, dock, order, w = 1, h = 1) => {const p = partOf(catalog, id);@@@ -244,7 +240,6 @@ function overlayDefaultLayout(catalog, device = 'computer') {dockedOf('stick', 'left', 1, 2, 2),dockedOf('slider', 'bottom', 0, 2, 1),dockedOf('more', 'right', 0, 1, 1),-- dockedOf('estop', 'right', 2, 2, 2),dockedOf('drive', 'right', 0, 2, 1),].filter(Boolean);return { surface: catalog.id, surface_version: catalog.version, device, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };@@@ -254,8 +249,7 @@ const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace/*** The attributes that put one part's entry on the element: its grid area (or dock edge), its style variables-- * and whether this person hid it. `roleHidden` marks a part the page hides for a role reason (the e-stop before-- * a watcher takes the controls): the editor must not unhide it.++ * and whether this person hid it.*/function attrsFor(catalog, layout, device, id, { apply = true } = {}) {if (!apply) return '';@@@ -23,7 +23,7 @@ const { signDeliveryHeaders } = require('openvibe-sdk/events');const { BotError, prefixedId, iso } = require('./util');const { checkWebhookUrl, WebhookRefused } = require('./net/webhook-post');--const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.estop.set', 'bot.estop.cleared', 'bot.command.refused'];++const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.command.refused'];const BACKOFF_S = [30, 120, 600, 1800, 7200];const MAX_HOOKS = 5;const TICK_MS = 5000;@@@ -1,7 +1,7 @@'use strict';// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe Link// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives--// and actuates it over the /control socket, reads battery and latency back, latches and clears the e-stop,++// and actuates it over the /control socket, reads battery and latency back, and stops it with halt,// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.//// The scenario follows the node-plan-t15-t14 audit's last OpenVibe.Bot item: "an end-to-end Adeept dry-run@@@ -46,8 +46,8 @@ const WHIP_BASE = 'https://whip.test/ingest';assert.strictEqual(paired.json.profile.id, 'adeept.adr036');});-- // ── 2. the socket handshake: hello, config, then the device's first status/estop_state ────────────-- await check('step 2: hello (session/robot_ids/ISO time) then config (allowed, latch false) precede status', async () => {++ // ── 2. the socket handshake: hello, config, then the device's first status ──────────────────────────++ await check('step 2: hello (session/robot_ids/ISO time) then config (allowed) precede status', async () => {dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });const hello = await dev.waitFor((m) => m.type === 'hello');assert.ok(hello, 'hello arrived');@@@ -57,15 +57,12 @@ const WHIP_BASE = 'https://whip.test/ingest';assert.ok(!Number.isNaN(Date.parse(hello.server_time)), `an ISO server_time: ${hello.server_time}`);const config = await dev.waitFor((m) => m.type === 'config');assert.ok(config, 'config arrived after hello');-- assert.strictEqual(config.estop_latched, false);assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);// Only now does the Link report: its link holds every frame until hello.dev.send({ type: 'status', firmware: 'openvibe-link-0.1.0', capabilities: {}, agent_version: '0.1.0',-- device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });-- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });++ device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [] });assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');-- assert.strictEqual((await t.domain.robots.get(robot.id)).estop_latched, false, 'a device latch:false does not latch');});// ── 3. drive, pan, tilt, lights and horn reach the device in the Adeept plugin's shapes ──────────@@@ -128,26 +125,6 @@ const WHIP_BASE = 'https://whip.test/ingest';assert.ok(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), `the envelope seq is monotonic: ${seqs.join(',')}`);});-- // ── 6. the owner's e-stop latches, a device report cannot clear it, the owner clears it ──────────-- await check('step 6: owner latch sends estop true and refuses drive; a device false is not a clear', async () => {-- op.send({ type: 'estop' });-- const latched = await dev.waitFor((m) => m.type === 'estop' && m.latched === true);-- assert.ok(latched, 'the device is told to latch');-- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the robot is latched');-- op.send({ type: 'command', id: 'e-stop-drive', kind: 'drive', value: { throttle: 0.2 } });-- const refused = await result('e-stop-drive');-- assert.strictEqual(refused.result, 'refused');-- assert.strictEqual(refused.code, 'bot.estop_latched');-- // The device reporting its own latch:false is recorded, never an owner clear.-- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });-- await t.wait(60);-- assert.strictEqual((await t.domain.robots.get(robot.id)).estop_latched, true, 'the owner latch stands');-- op.send({ type: 'estop_clear' });-- const cleared = await dev.waitFor((m) => m.type === 'estop' && m.latched === false);-- assert.ok(cleared, 'the owner clear reaches the device as estop false');-- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !r.estop_latched)), 'the robot is unlatched');-- });--// ── 7. no owner read route or state frame carries a device secret or a WHIP URL ──────────────────await check('step 7: reads and robot_state never contain whip_url, the publish key or the credential', async () => {const secrets = [paired.json.credential, paired.json.publish_key, paired.json.whip_url];@@@ -45,11 +45,10 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).json.robot.agent_control, true);});-- await check('the agent reads the robot for its owner: online, the e-stop, and the steps this robot takes', async () => {++ await check('the agent reads the robot for its owner: online, and the steps this robot takes', async () => {const r = await asAgent('GET', '/steps', alex);assert.strictEqual(r.status, 200, r.text);assert.strictEqual(r.json.online, true);-- assert.strictEqual(r.json.estop.latched, false);assert.strictEqual(r.json.name, 'Desk rover');assert.ok(r.json.steps.includes('forward 2') && r.json.steps.includes('headlights #ff8800'), r.json.steps.join(', '));assert.deepStrictEqual([r.json.max_steps, r.json.max_seconds], [20, 30]);@@@ -92,12 +91,7 @@ const { boot, check, done } = require('./helpers/app');assert.match(long.json.detail, /30 seconds/);});-- await check('the e-stop stops the agent with its reason; turning the switch off refuses it again', async () => {-- assert.strictEqual((await t.call('POST', `/api/v1/robots/${robot.id}/estop`, { user: alex })).status, 200);-- const r = await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' });-- assert.strictEqual(r.status, 200, r.text);-- assert.match(r.json.result, /^stopped: /);-- assert.strictEqual((await asAgent('GET', '/steps', alex)).json.estop.latched, true);++ await check('turning the agent switch off refuses it again', async () => {assert.strictEqual((await post(`/robots/${robot.id}/agent`, alex, 'agent_control=off')).status, 303);refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');});@@@ -1,6 +1,6 @@'use strict';// Agent Streams S7, Bot's part: an agent's commands are steps on its stream, chat cannot drive through the agent--// stream, and Live's kill switch latches the robot's e-stop through Bot.++// stream, and Live's stop reaches the robot through Bot.const assert = require('assert');const { boot, check, done } = require('./helpers/app');@@@ -42,24 +42,17 @@ const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';assert.strictEqual(r.status, 403, r.text);assert.strictEqual(r.json.code, 'bot.chat_cannot_drive');const state = await asAgent('GET', '/steps');-- assert.strictEqual(state.json.estop.latched, false, 'the refused call did not reach the robot');});-- await check('Live\'s kill switch latches the e-stop through Bot, and the next command is refused', async () => {-- const wrong = await t.call('POST', `/internal/robots/${robot.id}/estop`, { headers: { 'X-OV-Bot-Kill': 'not-the-secret' }, body: {} });++ await check('Live\'s stop reaches the robot through Bot as a halt', async () => {++ const wrong = await t.call('POST', `/internal/robots/${robot.id}/stop`, { headers: { 'X-OV-Bot-Kill': 'not-the-secret' }, body: {} });assert.strictEqual(wrong.status, 401, wrong.text);-- const ok = await t.call('POST', `/internal/robots/${robot.id}/estop`, { headers: { 'X-OV-Bot-Kill': 'test-kill-secret' }, body: { reason: 'agent_stream_ended' } });++ const ok = await t.call('POST', `/internal/robots/${robot.id}/stop`, { headers: { 'X-OV-Bot-Kill': 'test-kill-secret' }, body: { reason: 'agent_stream_ended' } });assert.strictEqual(ok.status, 200, ok.text);-- assert.strictEqual(ok.json.estop.latched, true);-- const state = await asAgent('GET', '/steps');-- assert.strictEqual(state.json.estop.latched, true, 'the robot is stopped');-- const refused = await asAgent('POST', '/commands', { id: 's7-2', kind: 'drive', value: { throttle: 0.5 }, ms: 500, agent_stream_id: AGS });-- assert.strictEqual(refused.status, 409, refused.text);-- assert.strictEqual(refused.json.code, 'bot.estop_latched');-- const steps = await asAgent('GET', `/steps?agent_stream=${AGS}`);-- const last = steps.json.commands[steps.json.commands.length - 1];-- assert.strictEqual(last.status, 'refused', 'the refused command is still a step, with the reason');-- assert.strictEqual(last.reason, 'bot.estop_latched');++ assert.strictEqual(ok.json.stop, true);++ const sent = await asAgent('POST', '/commands', { id: 's7-2', kind: 'drive', value: { throttle: 0.5 }, ms: 500, agent_stream_id: AGS });++ assert.strictEqual(sent.status, 200, sent.text);++ assert.strictEqual(sent.json.result, 'ack', 'a Stop is one command, not a latch');});await check('the step title is the command, never markup or unbounded text', async () => {@@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');await check('an outbox event is written for a refused command (bot.command.refused)', async () => {const { robot } = await t.robot(alex);-- await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'bot.estop_latched' });++ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'bot.command_not_allowed' });const rows = await t.outboxRows('bot.command.refused');assert.ok(rows.length >= 1);assert.strictEqual(rows[rows.length - 1].source, 'bot');@@@ -1,9 +1,9 @@'use strict';// The Arrange editor on the real panel, in headless Chrome (make your own controls, stage 2): the panel is drawn--// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, Stop moves on the grid, Save posts--// the layout to the panel's own route, and Ask OpenVibe previews a suggestion before Keep. No robot and no--// Network: the page is rendered by renderPanel and the two arrange endpoints answer with a stub. Skipped without--// Chrome.++// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, the palette adds the optional Stop,++// Stop moves on the grid, Save posts the layout to the panel's own route, and Ask OpenVibe previews a suggestion++// before Keep. No robot and no Network: the page is rendered by renderPanel and the two arrange endpoints answer++// with a stub. Skipped without Chrome.const assert = require('node:assert/strict');const fs = require('node:fs');const os = require('node:os');@@@ -21,7 +21,7 @@ const sharedRoot = path.dirname(require.resolve('openvibe-shared/package.json'))const catalog = surface.controlsCatalog(profile);const defaults = surface.defaultLayout(catalog, 'phone');const allowed = Object.keys(profile.commands);--const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };++const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };const pageHtml = render.renderPanel({robot, profile, role: 'owner', allowed_commands: allowed,arrange: {@@@ -80,12 +80,18 @@ const server = http.createServer((req, res) => {assert.ok(await until(`!!window.OVArrange && !!window.ovArrange`), 'Arrange loads the editor and opens it');if (!await until(`!!document.querySelector('[data-ov-custom]')`)) console.log('DEBUG', await evalv(`JSON.stringify({custom: !!document.querySelector('[data-ov-custom]'), surfaces: [...document.querySelectorAll('[data-ov-surface]')].map(e=>e.className+':'+e.getAttribute('data-ov-custom')), bar: !!document.querySelector('.ov-arrange-bar'), status: document.querySelector('.ov-arrange-status') && document.querySelector('.ov-arrange-status').textContent, err: window.__err})`));assert.ok(await until(`!!document.querySelector('[data-ov-custom]')`), 'the editor applies the layout to the real surface');++ // Stop is off by default; the Arrange palette adds it as one ordinary part.++ assert.ok(await evalv(`document.querySelector('[data-ov-part="stop"]').hidden`), 'Stop is hidden by default');++ assert.ok(await until(`!!document.querySelector('[data-add-stop]')`), 'the palette offers Stop');++ await evalv(`document.querySelector('[data-add-stop]').click()`);++ assert.ok(await until(`!document.querySelector('[data-ov-part="stop"]').hidden`), 'Add Stop shows it');++ const savedIt = async () => { const end = Date.now() + 8000; while (Date.now() < end) { if (calls.save && calls.save.layout) return true; await wait(50); } return false; };++ assert.ok(await savedIt(), 'adding it saves the layout');// The stick and Stop are in their own cells; the old pad wrapper flattens.assert.ok(await evalv(`getComputedStyle(document.querySelector('.widget-drive')).display === 'contents'`), 'the drive section flattens so its parts are grid cells');-- const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`);++ const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stick').place.y`);for (let i = 0; i < 2; i++) await evalv(`(() => { const el = document.querySelector('[data-ov-part="stick"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); })()`);-- await evalv(`(() => { const el = document.querySelector('[data-ov-part="stop"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); })()`);-- assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), before - 1, 'Stop moved one cell left');++ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stick').place.y`), before + 2, 'the stick moved two cells down');await evalv(`document.querySelector('[data-save]').click()`);assert.ok(calls.save && calls.save.layout, 'Save posted the layout to the panel route');// Ask OpenVibe: previewed, then kept.@@@ -103,8 +109,8 @@ const server = http.createServer((req, res) => {await p2.goto(base + '/panel/r1', { maxMs: 8000 });assert.ok(await p2until(`localStorage.getItem('ov-bot-pad-prefs') === null`), 'the old store is retired after one migration');const migrated = JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`));-- assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stop').place, { x: 0, y: 0, w: 2, h: 2 }, 'Stop takes the left hand');-- assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 2, w: 3, h: 3 }, 'the pad takes its larger size, moved clear of Stop on a phone');++ assert.strictEqual(migrated.layout.parts.find((p) => p.id === 'stop').visible, false, 'Stop stays off in a migrated layout');++ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 0, w: 3, h: 3 }, 'the pad takes its larger size');assert.equal(migrated.layout.parts.find((p) => p.id === 'battery').visible, false, 'the readouts are folded');assert.ok(await p2until(`!!document.querySelector('.widgets[data-ov-arranged]')`), 'the migrated layout is drawn on the parts');assert.equal(JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`)).layoutSource, 'default', 'the page was drawn from the default before the migration');@@@ -8,7 +8,7 @@ const surface = require('../server/web/surface');const profile = require('../server/profiles/looi.json');const { validate } = require('openvibe-sdk/ui-layout');--const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };++const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };const allowed = Object.keys(profile.commands);const catalog = surface.controlsCatalog(profile);const defaults = surface.defaultLayout(catalog, 'phone');@@@ -30,7 +30,8 @@ assert.ok(/data-arrange-src="\/shared\/ov-arrange\.js\?v=abc"/.test(html), 'it nassert.ok(!/<script[^>]+ov-arrange\.js/.test(html), 'the editor is not loaded until Arrange is pressed');assert.ok(html.includes('id="ov-arrange-data"'), 'the catalog and layout reach the client as data');assert.ok(!html.includes('pad-prefs'), 'the old sliders-icon arrangement menu is gone');--assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked');++assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked, Stop hidden by default');++assert.ok(/data-ov-part="stop"[^>]*hidden/.test(html) || /hidden[^>]*data-ov-part="stop"/.test(html), 'Stop is off until a layout adds it');assert.ok(html.includes('data-ov-surface="bot.controls"'), 'the panel names its surface');assert.ok(!html.includes('data-ov-arranged="true"'), 'without a stored layout the page keeps its own look (the editor applies the grid when opened)');@@@ -57,10 +58,10 @@ const overlay = render.renderPanel({signedIn: false, layoutSource: 'default', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',},});--for (const id of ['pill', 'stick', 'slider', 'estop', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);--assert.ok(overlay.includes('data-ov-role-hidden="true"'), 'the watcher’s e-stop stays role-hidden until Drive');++for (const id of ['pill', 'stick', 'slider', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);++assert.ok(!overlay.includes('data-ov-part="stop"'), 'the overlay has no Stop until a layout adds it');assert.ok(overlay.includes('id="ov-arrange-data"'), 'the overlay carries its catalog and layout too');--assert.ok(overlayCatalog.parts.find((p) => p.id === 'estop').required, 'the overlay catalog makes the e-stop required');++assert.strictEqual(overlayCatalog.parts.find((p) => p.id === 'stop').required, false, 'the overlay Stop is optional');// The default layouts are what the SDK would accept, the one check every surface shares.assert.deepEqual(validate(catalog, defaults), { ok: true, reasons: [] });@@@ -65,20 +65,12 @@ const world = require('../public/sim-world');assert.ok(audit.some((a) => a.kind === 'drive' && a.operator_subject === alex.subject && a.result === 'ack'), 'each step is on the record as the owner\'s');});-- await check('one routine at a time; removing a running routine stops it; the e-stop stops one with its reason', async () => {++ await check('one routine at a time; removing a running routine stops it', async () => {const long = (await api('POST', '/routines', alex, { name: 'Long', steps: 'forward 5' })).json.routine;assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 202);assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 409, 'already running');assert.strictEqual((await api('DELETE', `/routines/${long.id}`, alex)).status, 204);assert.ok(await poll(() => !routines.isRunning(robot.id)), 'it stopped');-- const again = (await api('POST', '/routines', alex, { name: 'Again', steps: 'forward 5' })).json.routine;-- await api('POST', `/routines/${again.id}/run`, alex);-- await t.wait(400);-- assert.strictEqual((await api('POST', '/estop', alex)).status, 200);-- const stopped = await poll(async () => { const r = await routineOf(again.id); return r.last_result.startsWith('stopped') ? r : null; });-- assert.ok(stopped, 'the e-stop stopped it');-- assert.match(stopped.last_result, /stopped: .*(e-stop|estop|latched)/i);-- assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);});await check('a schedule runs when due and moves on; an "each time it comes online" routine runs on the robot\'s return', async () => {@@@ -98,7 +90,7 @@ const world = require('../public/sim-world');await check('someone else\'s robot: no routines, no webhooks, read or write', async () => {assert.strictEqual((await api('GET', '/routines', bob)).status, 403);assert.strictEqual((await api('POST', '/routines', bob, { name: 'x', steps: 'forward 1' })).status, 403);-- assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.estop.set'] })).status, 403);++ assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.command.refused'] })).status, 403);const bad = await api('POST', '/routines', alex, { name: 'Bad', steps: 'say hello' });assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_routine']);});@@@ -106,10 +98,10 @@ const world = require('../public/sim-world');let secret; let hookId;await check('webhooks: https and public only, at most five, the secret once', async () => {for (const url of ['http://hooks.example.com/x', 'https://localhost/x', 'https://10.1.2.3/x', 'https://user:[email protected]/x']) {-- assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.estop.set'] })).status, 422, url);++ assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.command.refused'] })).status, 422, url);}assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/x', events: ['bot.robot.exploded'] })).status, 422);-- const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.estop.set', 'bot.estop.cleared'] });++ const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.command.refused', 'bot.robot.online'] });assert.strictEqual(made.status, 201, made.text);secret = made.json.secret;hookId = made.json.webhook.id;@@@ -122,7 +114,7 @@ const world = require('../public/sim-world');await check('an event writes a delivery with it; the sender signs it like Events and retries a failing receiver', async () => {await t.db.query("DELETE FROM robot_webhook_deliveries");-- assert.strictEqual((await api('POST', '/estop', alex)).status, 200);++ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'test' });const rows = await t.db.many("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1", [hookId]);assert.strictEqual(rows.length, 1, 'one delivery, written with the event');const got = [];@@@ -132,10 +124,10 @@ const world = require('../public/sim-world');const first = got[0];assert.strictEqual(first.url, 'https://hooks.example.com/robot');const body = JSON.parse(first.body);-- assert.deepStrictEqual([body.type, body.robot_id], ['bot.estop.set', robot.id]);++ assert.deepStrictEqual([body.type, body.robot_id], ['bot.command.refused', robot.id]);assert.ok(verifyDeliveryV2(first.body, first.headers, secret), 'the signature verifies with the hook\'s secret');assert.ok(!verifyDeliveryV2(first.body, first.headers, 'whsec_wrong'), 'and with nothing else');-- assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.estop.set');++ assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.command.refused');let row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);assert.deepStrictEqual([row.state, row.attempts, row.last_status], ['pending', 1, 500]);assert.ok(Date.parse(row.due_at) > Date.now() + (BACKOFF_S[0] - 2) * 1000, 'retried after 30 s');@@@ -146,9 +138,9 @@ const world = require('../public/sim-world');row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);assert.deepStrictEqual([row.state, row.attempts], ['failed', BACKOFF_S.length + 1], 'it gives up');answer = 204;-- assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);++ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'test again' });await sender.sendDue();-- const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.estop.cleared'", [hookId]);++ const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.command.refused' AND id <> $2", [hookId, rows[0].id]);assert.deepStrictEqual([cleared.state, cleared.last_status], ['delivered', 204]);});@@@ -1,6 +1,6 @@'use strict';// The embeddable panel's three views (?mode=panel|overlay|compact): the full panel as before, a transparent--// overlay that draws only the live controls (the first stick, the first slider, the e-stop and "more"), and one++// overlay that draws only the live controls (the first stick, the first slider, Drive and "more"), and one// row for a narrow card. The controls come from the robot's own profile; a host can change the view by message.const assert = require('assert');const fs = require('fs');@@@ -20,7 +20,7 @@ const DRIVE = {{ type: 'head', label: 'Head', order: 2, command: { kind: 'actuator', names: ['head', 'horn'] } },],};--const ROBOT = { id: 'rob_view1', name: 'LOOI', profile_id: 'looi.demo', estop: { latched: false } };++const ROBOT = { id: 'rob_view1', name: 'LOOI', profile_id: 'looi.demo' };const render = (over = {}) => renderPanel({robot: ROBOT, profile: DRIVE, role: 'owner', allowed_commands: ['drive', 'actuator', 'halt'],mode: 'embed', signedIn: true, parentOrigins: ['https://openvibe.live'], ...over,@@@ -32,16 +32,16 @@ const render = (over = {}) => renderPanel({assert.ok(html.includes('<html lang="en" class="embed-page bot-mode-overlay" data-bot-view="overlay" data-bot-min-width="280" data-bot-min-height="196">'));assert.ok(html.includes('class="embed-page bot-mode-overlay"'));assert.ok(html.includes('data-bot-overlay'));-- assert.ok(!html.includes('class="topbar"') && !html.includes('data-estop-banner') && !html.includes('class="widget-camera"'));++ assert.ok(!html.includes('class="topbar"') && !html.includes('class="panel-top"') && !html.includes('class="widget-camera"'));assert.ok(html.includes('data-bot-pill') && html.includes('data-bot-state') && html.includes('>LOOI<'));assert.ok(html.includes('data-joystick') && html.includes('data-actuator="head"'));-- assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));++ assert.ok(!html.includes('data-estop') && html.includes('data-bot-more'), 'no stop until a layout adds one');const hits = html.match(/data-bot-hit/g) || [];-- assert.ok(hits.length >= 5, `pill, stick, slider, e-stop and more each report a hit box (${hits.length})`);++ assert.ok(hits.length >= 4, `pill, stick, slider and more each report a hit box (${hits.length})`);assert.ok(!html.includes('data-hold=') && !html.includes('data-stop'), 'no pad buttons or stop row in the overlay');});-- await check('overlay: the controls a robot has decide what it draws (first stick, first slider, e-stop by role)', () => {++ await check('overlay: the controls a robot has decide what it draws (first stick, first slider, Drive by role)', () => {const ptz = {id: 'camera.demo', name: 'Camera',commands: { ptz: { axes: { pan: [-1, 1], tilt: [-1, 1], zoom: [-1, 1] } }, actuator: { names: { zoom: { type: 'number', range: [0, 3] } } } },@@@ -52,10 +52,10 @@ const render = (over = {}) => renderPanel({};const cam = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay' });assert.ok(cam.includes('data-ptz-stick') && cam.includes('data-actuator="zoom"'));-- assert.ok(!cam.includes('data-estop'), 'a signed-in watcher has no e-stop and no Drive here');++ assert.ok(!cam.includes('data-estop') && !cam.includes('data-drive'), 'a signed-in watcher has no Stop and no Drive here');const anon = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false });assert.ok(/<button[^>]*class="bot-drive bot-control"[^>]*data-drive data-drive-bar data-bot-hit/.test(anon), 'the anonymous embed takes the controls with Drive (ask 353)');-- assert.ok(/<button[^>]*class="bot-estop bot-control"[^>]*data-estop[^>]*hidden>/.test(anon), 'its e-stop waits, hidden, for the join');++ assert.ok(!anon.includes('data-estop'), 'a watcher has no Stop: the palette adds it to a layout, not to this one');assert.ok(anon.includes('class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden'), 'and the page\'s answer has a line');assert.ok(cam.includes('data-bot-more'), 'but there is always a way to the full panel');const hornOnly = {@@@ -64,21 +64,21 @@ const render = (over = {}) => renderPanel({};const quiet = renderPanel({ robot: ROBOT, profile: hornOnly, role: 'owner', allowed_commands: ['actuator'], mode: 'embed', view: 'overlay' });assert.ok(!quiet.includes('data-actuator') && !quiet.includes('data-joystick'), 'a tone is not a slider, and there is no stick');-- assert.ok(quiet.includes('data-estop') && quiet.includes('data-bot-more'));++ assert.ok(!quiet.includes('data-estop') && quiet.includes('data-bot-more'));});-- await check('compact: one row with the pill, a 96 px stick, the slider across, the e-stop and more', () => {++ await check('compact: one row with the pill, a 96 px stick, the slider across and more', () => {const html = render({ view: 'compact' });assert.ok(html.includes('class="embed-page bot-mode-compact" data-bot-view="compact" data-bot-min-width="320" data-bot-min-height="112"'));assert.ok(html.includes('class="bot-compact"') && !html.includes('data-bot-overlay'));assert.ok(html.includes('data-bot-pill') && html.includes('data-joystick') && html.includes('data-actuator="head"'));-- assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));++ assert.ok(!html.includes('data-estop') && html.includes('data-bot-more'), 'no stop until a layout adds one');});await check('panel is the default view: an unknown mode falls back to the full panel and its old markup', () => {const html = render({});assert.ok(html.includes('data-bot-view="panel"') && html.includes('data-bot-min-width="320"') && html.includes('data-bot-min-height="480"'));-- assert.ok(html.includes('data-estop-banner') && html.includes('class="widgets"'));++ assert.ok(html.includes('class="panel-top"') && html.includes('class="widgets"'));const weird = render({ view: '../../etc/passwd' });assert.ok(weird.includes('data-bot-view="panel"') && weird.includes('class="widgets"'));});@@@ -1,6 +1,6 @@'use strict';// The control gate (ADR-043 decisions 6 and 8): roles, the access policy, owner limits that clamp an--// operator, cooldowns, the latched e-stop (owner-cleared only), the turn queue and a service acting for++// operator, cooldowns, the turn queue and a service acting for// a viewer. Every decision is audited.const assert = require('assert');const { ids } = require('openvibe-contracts');@@@ -73,40 +73,13 @@ const plugins = require('./helpers/plugins');b.close();});-- await check('the e-stop latches, refuses commands, and only the owner clears it', async () => {-- const { robot, pairing } = await t.robot(alex);-- await invite(robot.id, bob.subject, 'operator');-- const dev = await deviceFor(robot.id, pairing.code);-- const b = await op(bob);-- await joinResult(b, robot.id);-- b.send({ type: 'estop' });-- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');-- cmd(b, { id: 'e1', kind: 'drive', value: { throttle: 0.2 } });-- const refused = await result(b, 'e1');-- assert.strictEqual(refused.code, 'bot.estop_latched');-- b.send({ type: 'estop_clear' });-- const denied = await b.waitFor((m) => m.type === 'error' && /forbidden/.test(m.code));-- assert.strictEqual(denied.code, 'bot.forbidden');-- const a = await op(alex);-- await joinResult(a, robot.id);-- a.send({ type: 'estop_clear' });-- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !r.estop_latched)), 'the owner cleared it');-- cmd(b, { id: 'e2', kind: 'drive', value: { throttle: 0.2 } });-- assert.ok(await dev.waitFor((m) => m.type === 'command' && m.ref === 'e2'), 'an operator may drive again');-- a.close(); b.close();-- });---- await check('halt passes the gate while the e-stop is latched; every other kind is refused', async () => {++ await check('halt passes the allowlist, cooldowns and the budget', async () => {const { robot, pairing } = await t.robot(alex, { limits: { cooldown_ms: 60000, allow: { operator: ['drive'] } } });await invite(robot.id, bob.subject, 'operator');const dev = await deviceFor(robot.id, pairing.code);const b = await op(bob);const j = await joinResult(b, robot.id);assert.deepStrictEqual(j.allowed_commands, ['drive', 'halt'], 'no allowlist removes halt');-- b.send({ type: 'estop' });-- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');-- cmd(b, { id: 'h0', kind: 'drive', value: { throttle: 0.2 } });-- assert.strictEqual((await result(b, 'h0')).code, 'bot.estop_latched');cmd(b, { id: 'h1', kind: 'halt' });const h1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'h1');assert.strictEqual(h1.kind, 'halt');@@@ -520,8 +493,7 @@ const plugins = require('./helpers/plugins');assert.strictEqual(anon.json.code, 'bot.sign_in');});-- await check('a latched e-stop and an offline device answer 409 over the HTTP route', async () => {-- // A device-less robot of its own: offline first, then latched (the e-stop is checked before liveness).++ await check('an offline device answers 409 over the HTTP route', async () => {const setup = await t.domain.robots.create({ owner: alex.subject, name: 'No device', profile_id: 'sim.rover' });const robot = setup.robot;await t.domain.members.add(robot.id, bob.subject, 'operator', alex.subject);@@@ -529,10 +501,6 @@ const plugins = require('./helpers/plugins');const offline = await send('off-1');assert.strictEqual(offline.status, 409, offline.text);assert.strictEqual(offline.json.code, 'bot.device_offline');-- await t.domain.estop.set(robot.id, { latched: true, by: alex.subject, principalKind: 'user' });-- const latched = await send('estop-1');-- assert.strictEqual(latched.status, 409, latched.text);-- assert.strictEqual(latched.json.code, 'bot.estop_latched');});await check('a different subject reusing an id is never served another subject\'s cached result', async () => {@@@ -1,14 +1,19 @@'use strict';--// Device-socket safety (plan T15 follow-up A): a device's estop_state is a report that may latch a robot--// it is attached to and never clears the owner's latch; frames sent during authentication are kept and--// handled in order; telemetry carrying events passes the 2-Hz rule; heartbeat_ack correlates through--// `echo` while Bot's envelope seq stays strictly increasing.++// Device-socket safety without a remote latch: a device may report a Stop held on the machine itself, and that++// report stays local. It never latches a robot, never refuses a later command and is never audited as an action;++// a report for a robot the device is not attached to is still refused. Frames sent during authentication are++// kept and handled in order, and a device's faults reach the panel.const assert = require('assert');const { boot, check, done } = require('./helpers/app');(async () => {const t = await boot();const alex = t.network.newUser('alex');++ const poll = async (fn, ms = 2000) => {++ const end = Date.now() + ms;++ while (Date.now() < end) { if (await fn()) return true; await t.wait(25); }++ return false;++ };async function ownerJoined(robotId) {const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });@@@ -16,65 +21,24 @@ const { boot, check, done } = require('./helpers/app');await op.waitFor((m) => m.type === 'joined');return op;}-- /** A credential from the REST pair, so the socket authenticates by header on the upgrade. */-- async function credentialFor() {++ async function connected() {const { robot, pairing } = await t.robot(alex);const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });assert.strictEqual(paired.status, 201, paired.text);-- return { robot, credential: paired.json.credential, deviceId: paired.json.device_id };-- }-- async function connected() {-- const c = await credentialFor();-- const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${c.credential}` } });++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });await dev.waitFor((m) => m.type === 'config');-- return { ...c, dev };++ return { robot, dev, deviceId: paired.json.device_id };}-- await check('a device estop_state latched:false never clears the owner latch (a report only)', async () => {++ await check('a device\'s own Stop report is accepted and stays local: no latch, no refusal, no audit', async () => {const { robot, dev } = await connected();const op = await ownerJoined(robot.id);-- op.send({ type: 'estop' });-- await dev.waitFor((m) => m.type === 'estop' && m.latched === true);-- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id });-- const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.latched === false);-- assert.strictEqual(st.state.estop.latched, true, 'the owner latch stands');-- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, true);-- dev.send({ type: 'estop_state', latched: false }); // robot_id omitted = every attached robot-- await t.wait(100);-- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, true);-- assert.ok(!dev.messages.some((m) => m.type === 'error'), JSON.stringify(dev.messages.filter((m) => m.type === 'error')));-- await assert.rejects(t.domain.estop.set(robot.id, { latched: false, by: 'device', principalKind: 'device' }), (e) => e.code === 'bot.forbidden');-- // the owner clear path still works and tells the device-- op.send({ type: 'estop_clear' });-- await dev.waitFor((m) => m.type === 'estop' && m.latched === false);-- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, false);-- // and a device's latched:true still latches-- dev.send({ type: 'estop_state', latched: true });-- await op.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true);-- op.close(); dev.close();-- });---- await check('a local stop on the device (openvibe-link stop) is shown as one, and the owner clear cannot lift it', async () => {-- const { robot, dev } = await connected();-- const op = await ownerJoined(robot.id);-- dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });-- const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.local === true);-- assert.strictEqual(st.state.estop.latched, true, 'the device latched the robot');-- assert.strictEqual(st.state.device_estop.reason, 'stop button on the robot', 'the reason is shown as plain, short text');-- // The owner clears Bot's latch; the device still holds its own and says so again, which latches the robot again.-- op.send({ type: 'estop_clear' });-- await dev.waitFor((m) => m.type === 'estop' && m.latched === false);-- op.messages.length = 0; // waitFor also searches frames already received: only the ones from here on count-- dev.send({ type: 'estop_state', latched: true, local_stop: true, robot_id: robot.id });-- await op.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true && m.state.device_estop.local === true);-- // Released on the robot: the report is latched:false, never local, and Bot's latch waits for the owner.-- op.messages.length = 0;-- dev.send({ type: 'estop_state', latched: false, local_stop: true, robot_id: robot.id });-- const after = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.latched === false);-- assert.deepStrictEqual([after.state.device_estop.local, after.state.device_estop.reason, after.state.estop.latched], [false, null, true]);-- const panel = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'panel.js'), 'utf8');-- assert.match(panel, /state\.device_estop\.local/, 'the panel reads the local flag');-- assert.match(panel, /clear\.disabled = held;/, 'and waits with the clear button while the robot holds its stop');++ dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'button on the base', robot_id: robot.id });++ assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => r && r.estop_latched === false)), 'the old latch column stays false');++ const cmd = await t.call('POST', `/api/v1/robots/${robot.id}/commands`, { user: alex, body: { id: 'after-stop', kind: 'drive', value: { throttle: 0.2 } } });++ assert.notStrictEqual(cmd.json && cmd.json.code, 'bot.estop_latched', 'a Stop report never refuses a command');++ const audit = await t.domain.audit.list(robot.id, { limit: 10 });++ assert.ok(!audit.some((a) => a.kind === 'estop_state'), 'the report is not an audited action');op.close(); dev.close();});@@@ -82,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');const { robot, dev } = await connected();const op = await ownerJoined(robot.id);op.messages.length = 0;-- dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }], estop_latched: false });++ dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }] });const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.status && m.state.status.faults && m.state.status.faults.length === 1);assert.deepStrictEqual(st.state.status.faults[0], { code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' });const fs = require('fs');@@@ -91,134 +55,43 @@ const { boot, check, done } = require('./helpers/app');assert.match(panel, /state\.status && Array\.isArray\(state\.status\.faults\)/, 'the panel reads the device\'s faults');assert.match(panel, /else if \(m\.result === 'nack'\) \{ const t = nackText\(m\.reason\); if \(t\) notice\(t\); \}/, 'and says why the robot refused');assert.match(panel, /const QUIET_NACKS = new Set\(\['expired'\]\);/, 'a deadline that passed in transit is not news');-- const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover', estop: { latched: false } }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });++ const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover' }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });assert.ok(html.includes('data-device-fault'), 'the panel has a place for them');op.close(); dev.close();});-- await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {++ await check('a device cannot report for a robot it is not attached to (bot.forbidden, no state change)', async () => {const { dev, deviceId } = await connected();const { robot: other } = await t.robot(alex);dev.send({ type: 'estop_state', latched: true, robot_id: other.id });const e1 = await dev.waitFor((m) => m.type === 'error');assert.strictEqual(e1.code, 'bot.forbidden');-- dev.send({ type: 'estop_state', latched: false, robot_id: other.id });-- await dev.waitFor((m) => m.type === 'error' && m !== e1);assert.strictEqual(!!(await t.domain.robots.get(other.id)).estop_latched, false, 'the other robot is untouched');const audit = await t.domain.audit.list(other.id, { limit: 5 });-- const refused = audit.filter((a) => a.kind === 'estop_state' && a.result === 'refused' && a.reason === 'bot.forbidden' && a.device_id === deviceId);-- assert.strictEqual(refused.length, 2, JSON.stringify(audit));++ assert.ok(!audit.some((a) => a.kind === 'estop_state' && a.device_id === deviceId), 'nothing is audited for a report');dev.close();});-- await check('status and estop_state sent right after the upgrade are retained, in order', async () => {-- const { robot, credential, deviceId } = await credentialFor();-- // A slow credential lookup, so the frames below certainly arrive while authentication is in progress.++ await check('status and a Stop report sent right after the upgrade are retained, in order', async () => {++ const { robot, pairing } = await t.robot(alex);++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });const byCredential = t.domain.devices.byCredential;t.domain.devices.byCredential = async (c) => { await t.wait(200); return byCredential(c); };let dev;try {-- dev = await t.ws('/device', { headers: { Authorization: `Bearer ${credential}` } });++ dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });dev.send({ type: 'status', firmware: 'adeept-0.1.0', faults: [] });dev.send({ type: 'estop_state', latched: true });dev.send({ type: 'status', firmware: 'adeept-0.1.1', faults: [] });await dev.waitFor((m) => m.type === 'config');} finally { t.domain.devices.byCredential = byCredential; }-- const latched = await poll(async () => !!(await t.domain.robots.get(robot.id)).estop_latched, 2000);-- assert.ok(latched, 'the startup estop_state latched the robot');assert.ok(!dev.messages.some((m) => m.type === 'error'), JSON.stringify(dev.messages.filter((m) => m.type === 'error')));-- const state = t.hub.deviceState(deviceId);-- assert.strictEqual(state.status && state.status.firmware, 'adeept-0.1.1', 'both status frames handled, in order');++ assert.ok(await poll(() => { const s = t.hub.deviceState(paired.json.device_id); return s && s.status && s.status.firmware === 'adeept-0.1.1'; }), 'both status frames handled, in order');const types = dev.messages.map((m) => m.type);assert.ok(types.indexOf('hello') < types.indexOf('config'), types.join());dev.close();});-- await check('a telemetry frame with events inside the 500-ms window is delivered; plain ones are still throttled', async () => {-- const { robot, dev } = await connected();-- const op = await ownerJoined(robot.id);-- dev.send({ type: 'telemetry', battery: 0.5 });-- dev.send({ type: 'telemetry', battery: 0.4, events: [{ kind: 'bump', at: Date.now() }] });-- dev.send({ type: 'telemetry', battery: 0.39, events: [{ kind: 'low_battery' }] });-- dev.send({ type: 'telemetry', battery: 0.3 });-- await op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.4);-- await op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.39);-- await t.wait(150);-- assert.ok(!op.messages.some((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.3), 'a plain frame inside the window is dropped');-- op.close(); dev.close();-- });---- await check('an event does not use up the sensor window: the next sample is judged against the last sample', async () => {-- const { robot, dev } = await connected();-- const op = await ownerJoined(robot.id);-- const sample = (battery) => op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === battery);-- // The injected clock moves the window; a heartbeat just before each step keeps the device online.-- const beat = async (n) => { dev.send({ type: 'heartbeat', t: n }); await dev.waitFor((m) => m.type === 'heartbeat_ack' && m.echo === n); };-- try {-- dev.send({ type: 'telemetry', battery: 0.9 });-- await sample(0.9);-- await beat(1);-- t.clock.offset += 300;-- dev.send({ type: 'telemetry', battery: 0.8, events: [{ kind: 'bump' }] });-- await sample(0.8);-- dev.send({ type: 'telemetry', battery: 0.7 }); // 300 ms after the last sample: dropped-- await beat(2);-- t.clock.offset += 250;-- dev.send({ type: 'telemetry', battery: 0.6 }); // 550 ms after the last sample, 250 after the event: delivered-- await sample(0.6);-- assert.ok(!op.messages.some((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.7), 'a second sample inside the window is dropped');-- } finally {-- t.clock.offset -= 550;-- }-- op.close(); dev.close();-- });---- await check('setting or clearing the latch re-sends config with estop_latched, from the owner, REST and the device', async () => {-- const { robot, dev } = await connected();-- const first = dev.messages.find((m) => m.type === 'config');-- assert.strictEqual(first.estop_latched, false);-- const op = await ownerJoined(robot.id);-- const nextConfig = (after, latched) => dev.waitFor((m) => m.type === 'config' && m.seq > after && m.estop_latched === latched);-- op.send({ type: 'estop' });-- const estop = await dev.waitFor((m) => m.type === 'estop' && m.latched === true);-- const c1 = await nextConfig(first.seq, true);-- assert.ok(c1.seq > estop.seq, 'the estop frame comes first');-- assert.deepStrictEqual(c1.limits, first.limits);-- assert.deepStrictEqual(c1.allowed_commands, first.allowed_commands);-- op.send({ type: 'estop_clear' });-- const c2 = await nextConfig(c1.seq, false);-- const r = await t.call('POST', `/api/v1/robots/${robot.id}/estop`, { user: alex });-- assert.strictEqual(r.status, 200, r.text);-- const c3 = await nextConfig(c2.seq, true);-- const cleared = await t.call('POST', `/api/v1/robots/${robot.id}/estop/clear`, { user: alex });-- assert.strictEqual(cleared.status, 200, cleared.text);-- const c4 = await nextConfig(c3.seq, false);-- dev.send({ type: 'estop_state', latched: true }); // the device's own latch moves Bot's-- await nextConfig(c4.seq, true);-- op.close(); dev.close();-- });---- await check('heartbeat_ack echoes t; Bot\'s envelope seq stays strictly increasing', async () => {-- const { dev } = await connected();-- for (let i = 1; i <= 4; i++) dev.send({ type: 'heartbeat', seq: 1, t: 1000 + i, rtt_ms: 10 });-- dev.send({ type: 'heartbeat', rtt_ms: 10 });-- await dev.waitFor(() => dev.messages.filter((m) => m.type === 'heartbeat_ack').length === 5);-- const acks = dev.messages.filter((m) => m.type === 'heartbeat_ack');-- assert.deepStrictEqual(acks.map((a) => a.echo), [1001, 1002, 1003, 1004, null]);-- const seqs = dev.messages.map((m) => m.seq);-- for (let i = 1; i < seqs.length; i++) assert.ok(seqs[i] > seqs[i - 1], `seq ${seqs.join(',')}`);-- dev.close();-- });--await t.close();done();--})();----async function poll(fn, ms) {-- const end = Date.now() + ms;-- for (;;) {-- if (await fn()) return true;-- if (Date.now() > end) return false;-- await new Promise((r) => setTimeout(r, 25));-- }--}++})().catch((e) => { console.error(e); process.exit(1); });@@@ -51,14 +51,14 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');// The lifecycle.for (const step of ['hello', 'describe', 'ready', 'heartbeat']) assert.ok(html.includes(step), `no ${step} in the lifecycle`);// Every message type, each with a JSON example.-- for (const op of ['hello', 'command', 'heartbeat', 'stop', 'estop', 'resume', 'describe', 'ready', 'fault', 'ack', 'nack', 'telemetry', 'event', 'video']) {++ for (const op of ['hello', 'command', 'heartbeat', 'stop', 'describe', 'ready', 'fault', 'ack', 'nack', 'telemetry', 'event', 'video']) {assert.ok(html.includes(`"op": "${op}"`), `no ${op} JSON example`);}// The safety rules.assert.match(html, /must stop every actuator/);assert.match(html, /deadline/);assert.match(html, /no_heartbeat/);-- assert.match(html, /e-stop/);++ assert.match(html, /local Stop/);// Links out to the full protocol and the contributor path.assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/plugins\.md/);assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/protocol\.md/);@@@ -84,7 +84,7 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);const html = await r.text();assert.match(html, /<h1>Routines, webhooks and your agent<\/h1>/);-- for (const word of ['forward 2', 'every N minutes', 'bot.estop.set', 'whsec_', 'X-OpenVibe-Signature-V2', 'verifyDeliveryV2', 'Let my OpenVibe.Actor agent drive this robot', '/api/v1/robots/rob_…/steps', 'bot.agent_not_allowed']) {++ for (const word of ['forward 2', 'every N minutes', 'bot.command.refused', 'whsec_', 'X-OpenVibe-Signature-V2', 'verifyDeliveryV2', 'Let my OpenVibe.Actor agent drive this robot', '/api/v1/robots/rob_…/steps', 'bot.agent_not_allowed']) {assert.ok(html.includes(word), word);}assert.ok(html.includes('href="/docs/automation" aria-current="page"'), 'in the build nav');@@@ -32,17 +32,15 @@ const { renderPanel } = require('../server/web/render');assert.strictEqual(ticketFromProtocols(''), null);});-- await check('the framed watcher gets a Drive button and a hidden e-stop; the client takes tickets from its own popup only', () => {++ await check('the framed watcher gets a Drive button; the client takes tickets from its own popup only', () => {const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };-- const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });++ const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });assert.ok(html.includes('data-drive data-drive-bar>'), 'Drive is its own bar: it goes when the controls are taken');-- assert.ok(html.includes('data-estop hidden>'), 'the e-stop waits for an owner or operator join');const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');// Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.assert.match(js, /const fromPopup = e\.origin === location\.origin && popup && e\.source === popup;/);assert.match(js, /if \(!fromPopup && !fromParent\) return;/);assert.match(js, /new WebSocket\(wsUrl\(path\), \['ov\.ticket', `ov\.ticket\.\$\{ticket\}`\]\)/);-- assert.match(js, /\$\('\[data-estop\]:not\(\[hidden\]\)'\)\) estop\(\);/, 'a gamepad never e-stops through a hidden button');const popupJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'drive.js'), 'utf8');assert.match(popupJs, /window\.opener\.postMessage\(\{ type: 'ov-bot-ticket', robot_id: robotId, ticket: body\.ticket \}, location\.origin\)/);});@@@ -77,7 +77,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:await check('embed page: the frame adapter is loaded for the embed only, with the trusted origins on the panel', () => {const profile = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [] };-- const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };++ const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover' };const embed = renderPanel({ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false, parentOrigins: [PARENT, 'https://www.openvibe.live'] });const at = (s) => embed.indexOf(s);assert.ok(embed.includes('<html lang="en" class="embed-page" data-bot-view="panel" data-bot-min-width="320" data-bot-min-height="480">'), 'the embed follows prefers-color-scheme before the first token message, and names its view');@@@ -10,18 +10,17 @@ const { renderPanel } = require('../server/web/render');(async () => {const SIM = { id: 'sim.rover', name: 'Simulated rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }, { type: 'latency', label: 'Latency' }] };-- const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };++ const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover' };await check('BOT_WATCH_MAX_PER_IP and BOT_WATCH_MAX_PER_ROBOT default to 20 and 500', () => {assert.deepStrictEqual(loadConfig({}).watch, { maxPerIp: 20, maxPerRobot: 500 });assert.deepStrictEqual(loadConfig({ BOT_WATCH_MAX_PER_IP: '3', BOT_WATCH_MAX_PER_ROBOT: '0' }).watch, { maxPerIp: 3, maxPerRobot: 1 });});-- await check('embed mode: no topbar, the e-stop state always, controls only for the role, links open a new tab', () => {++ await check('embed mode: no topbar, controls only for the role, links open a new tab', () => {const watcher = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });assert.ok(watcher.includes('class="embed-page"') && !watcher.includes('class="topbar"'));-- assert.ok(watcher.includes('data-estop-banner') && watcher.includes('data-estop-state'));-- assert.ok(!watcher.includes('data-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));++ assert.ok(!watcher.includes('data-estop') && !watcher.includes('data-embed-form'));assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');// Ask 353 (2026-11-11): in the head's row, as an icon with its words for screen readers.@@@ -30,7 +29,7 @@ const { renderPanel } = require('../server/web/render');const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });assert.ok(!signedIn.includes('Open on openvibe.bot'));const owner = renderPanel({ robot: { ...robotRow, embed_public: true }, profile: SIM, role: 'owner', allowed_commands: ['drive', 'halt'], mode: 'embed' });-- assert.ok(owner.includes('data-estop>') && owner.includes('data-estop-clear') && !owner.includes('data-embed-form') && !owner.includes('<a '));++ assert.ok(!owner.includes('data-estop') && !owner.includes('data-embed-form') && !owner.includes('<a '));const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });assert.ok(page.includes('class="panel-page"') && page.includes('class="topbar"') && page.includes('data-embed-form'));});@@@ -71,7 +70,7 @@ const { renderPanel } = require('../server/web/render');assert.strictEqual(r.headers.get('set-cookie'), null);const html = await r.text();assert.ok(html.includes('data-role="watcher"') && html.includes('data-allowed="[]"') && html.includes('class="embed-page"'));-- assert.ok(html.includes('Open on openvibe.bot') && !html.includes('data-estop>'));++ assert.ok(html.includes('Open on openvibe.bot'));assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');// The rest of the site still refuses every frame.assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);@@@ -91,7 +90,7 @@ const { renderPanel } = require('../server/web/render');const owner = await get(`/panel/${closed.id}/embed`, alex);assert.strictEqual(owner.status, 200);const oh = await owner.text();-- assert.ok(oh.includes('data-role="owner"') && oh.includes('data-estop>') && !oh.includes('data-embed-form') && !oh.includes('Open on openvibe.bot'));++ assert.ok(oh.includes('data-role="owner"') && !oh.includes('data-estop') && !oh.includes('data-embed-form') && !oh.includes('Open on openvibe.bot'));const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Open on openvibe.bot'));});@@@ -129,8 +128,7 @@ const { renderPanel } = require('../server/web/render');assert.deepStrictEqual(j.allowed_commands, []);assert.strictEqual(j.profile.id, 'sim.rover');assert.strictEqual(j.robot, undefined);-- assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);-- assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);++ assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'latency_ms', 'online', 'robot_id', 'telemetry']);const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);// The profile's readouts by the driver's keys, and a simulated rover's camera view; never the raw drive or pose.assert.deepStrictEqual(Object.keys(s.state.telemetry).sort(), ['sensors', 'sim'], 'the drive and pose telemetry reached a watcher');@@@ -144,32 +142,19 @@ const { renderPanel } = require('../server/web/render');w.send({ type: 'join', robot_id: open.id });await w.waitFor((m) => m.type === 'joined');const before = await auditCount(open.id);-- for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'estop' }, { type: 'estop_clear' }, { type: 'pair' }]) w.send(f);++ for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'command', id: 'x2', kind: 'halt' }, { type: 'estop' }, { type: 'pair' }]) w.send(f);await w.waitFor(() => w.messages.filter((m) => m.type === 'error').length >= 5);assert.deepStrictEqual(w.messages.filter((m) => m.type === 'error').map((m) => m.code), Array(5).fill('bot.read_only'));assert.ok(!w.messages.some((m) => m.type === 'command_result'));await t.wait(100);assert.strictEqual(await auditCount(open.id), before);-- assert.strictEqual((await t.domain.robots.get(open.id)).estop_latched, false);-- const tele = (m) => m.type === 'robot_state' && m.state.telemetry;++ const tele = (m) => m.type === 'robot_state' && m.state.telemetry;assert.ok(await w.waitFor(tele));assert.strictEqual(t.hub.deviceState(`dev_sim_${open.id}`).telemetry.drive.throttle || 0, 0, 'the robot moved');w.close();});-- await check('the owner\'s e-stop reaches a watcher; readouts carry only the profile\'s sensor keys', async () => {-- const w = await watch();-- w.send({ type: 'join', robot_id: open.id });-- await w.waitFor((m) => m.type === 'joined');-- const c = await t.ws('/control', { headers: cookie(alex) });-- c.send({ type: 'join', robot_id: open.id });-- await c.waitFor((m) => m.type === 'joined');-- c.send({ type: 'estop' });-- assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true));-- c.send({ type: 'estop_clear' });-- assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === false && w.messages.some((x) => x.state && x.state.estop.latched)));-- c.close(); w.close();--++ await check('readouts carry only the profile\'s sensor keys', async () => {const { robot: arm } = await t.robot(alex, { name: 'Arm', profile_id: 'adeept.adr036' });await t.domain.robots.setEmbedPublic(arm.id, true);const dev = t.hub.attachSim({ id: 'dev_fake_arm', robot_ids: JSON.stringify([arm.id]), kind: 'onboard' }, { onFrame() {} });@@@ -1,7 +1,7 @@'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.++// the authority audit, and the battery bound to the Node's top-level `battery` (0..100). A tray's own Stop stays++// local. 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');@@@ -144,7 +144,7 @@ require('../server/realtime');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 () => {++ await check('Mods may grant/clear sole control; they may not promote, change Viewer access, or touch the tray', 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');@@@ -173,26 +173,6 @@ require('../server/realtime');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 } });@@@ -247,8 +227,8 @@ require('../server/realtime');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('data-operators-estop') && !/E-STOP|estop|emergency stop/i.test(ownerHtml.replace(/estop_state|estop_latched/g, '')), 'the card has no E-STOP row, button or note');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();@@@ -259,7 +239,7 @@ require('../server/realtime');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');++ assert.ok(viewerHtml.includes('Viewers') && !viewerHtml.includes('data-operator-sole'), 'a viewer watches only');});await t.close();@@@ -93,7 +93,7 @@ const { boot, check, done } = require('./helpers/app');['GET', `/robots/${id}`], ['PATCH', `/robots/${id}`, { name: 'x' }], ['DELETE', `/robots/${id}`],['POST', `/robots/${id}/pairing-code`], ['GET', `/robots/${id}/operators`], ['POST', `/robots/${id}/operators`, { subject: alex.subject }],['DELETE', `/robots/${id}/operators/${alex.subject}`], ['GET', `/robots/${id}/devices`], ['GET', `/robots/${id}/audit`],-- ['POST', `/robots/${id}/estop`], ['POST', `/robots/${id}/estop/clear`], ['POST', `/devices/${dev}/rotate`], ['POST', `/devices/${dev}/revoke`],++ ['POST', `/devices/${dev}/rotate`], ['POST', `/devices/${dev}/revoke`],];const shape = (r) => ({ status: r.status, code: r.json && r.json.code, title: r.json && r.json.title, detail: r.json && r.json.detail });const real = routes(bobsRobot.id, realDevice);@@@ -58,14 +58,14 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widgetassert.ok(!renderWidget(w, { profile: zoomOnly, allowed_commands: ['ptz'] }).includes('data-ptz-stick'));});-- await check('panel.js: a held slider repeats its value as new commands and stops on release, cancel, blur, a hidden page and the e-stop', () => {++ await check('panel.js: a held slider repeats its value as new commands and stops on release, cancel, blur, a hidden page and Stop', () => {const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');assert.ok(js.includes("const every = Number(el.dataset.holdRepeat) || 0;"), 'only a slider that asks for it');assert.ok(js.includes("if (Date.now() - lastSent >= every - 5) sendNow();"), 'each tick is a new send of the current value, skipped right after a move');assert.ok(js.includes("el.addEventListener(ev, stop);"), 'release, cancel and a lost capture stop it');assert.ok(js.includes("['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop)"), 'all three pointer ends');assert.ok(js.includes("window.addEventListener('blur', stopRepeats);") && js.includes("if (document.hidden) stopRepeats();") && js.includes("window.addEventListener('pagehide', stopRepeats);"), 'blur, a hidden page and pagehide stop it');-- assert.ok(js.includes("window.dispatchEvent(new Event('ov-bot-release-all'))") && js.includes("window.addEventListener('ov-bot-release-all', stopRepeats);"), 'releaseAll (the e-stop) stops it');++ assert.ok(js.includes("window.dispatchEvent(new Event('ov-bot-release-all'))") && js.includes("window.addEventListener('ov-bot-release-all', stopRepeats);"), 'releaseAll (Stop) stops it');});await check('panel.js: keys, stick and gamepad press direction buttons; the PTZ stick holds pan and tilt', () => {@@@ -80,10 +80,10 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widgetassert.ok(js.includes("() => holdRelease(kind, 'ptzstick'));"), 'let go stops the camera');assert.ok(js.includes("for (const k of [...dirRepeats.keys()]) { const i = k.indexOf('|'); dirRelease(k.slice(0, i), k.slice(i + 1)); }"), 'releaseAll lets every repeat go');assert.ok(/\[data-joystick\], \[data-button-stick\], \[data-ptz-stick\]/.test(js), 'the new sticks follow the allowed state');-- // The pad on a button robot keeps the drive's latch, B lets go, Start is the e-stop.++ // The pad on a button robot keeps the drive's latch, B lets go.const branch = js.slice(js.indexOf('if (buttonDrive) {', js.indexOf('function padPoll')), js.indexOf('const v = stickValue(ax(0), -ax(1), PAD_DEADZONE);'));assert.ok(branch.includes('if (!pad.latched) pad.reset();') && branch.includes('if (!want && !now.size) pad.latched = false;'));-- assert.ok(branch.includes('now.has(1) && !pad.down.has(1)') && branch.includes('now.has(9) && !pad.down.has(9)'));++ assert.ok(branch.includes('now.has(1) && !pad.down.has(1)') && !branch.includes('now.has(9)'));});await check('Q and E tilt the head where the robot has one: Q up, E down, through the head command, nothing on release', () => {@@@ -96,7 +96,7 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widgetassert.ok(js.includes("if (up === down) return headStop();") && js.includes("if (up === down) return; // none, or both"), 'both held: nothing');assert.ok(js.includes("head.timer = setInterval(headStep, Number(el && el.dataset.holdRepeat) || HOLD_MS);"), 'the slider\'s hold cadence (LOOI: 90 ms)');assert.ok(js.includes("function headRelease(code) { if (!head.down.delete(code)) return; headRun(); }") && !/headRelease[\s\S]{0,200}send\('actuator'/.test(js), 'release sends nothing: head 0 is Center, not stop');-- assert.ok(js.includes("window.addEventListener('ov-bot-release-all', headReleaseAll);") && js.includes("function stopDrive() {\n headReleaseAll();"), 'blur, a hidden page, a lost socket, the e-stop and STOP stop it');++ assert.ok(js.includes("window.addEventListener('ov-bot-release-all', headReleaseAll);") && js.includes("function stopDrive() {\n // The machine's own Stop (halt)"), 'blur, a hidden page, a lost socket and Stop stop it');assert.ok(js.includes("if (textEntry(e.target)) return;"), 'never while typing');assert.ok(js.includes("if (headSlider() && headEnabled()) { keys.add('KeyQ'); keys.add('KeyE'); keys.add('Space'); }"), 'the page around the frame is offered Q and E, also with no drive');const looi = loadProfiles().get('looi');@@@ -1,6 +1,6 @@'use strict';// The five-minute path and the profile-rendered panel (plan T15 step 3): every profile's widgets render from--// the profile alone, the e-stop is the owner's and an operator's, the signed-in pages answer as the gate does,++// the profile alone, Stop is one optional part, the signed-in pages answer as the gate does,// and a `sim` robot is driven by the in-process simulator (telemetry, acks, the deadman).const assert = require('assert');const { boot, check, done } = require('./helpers/app');@@@ -8,7 +8,7 @@ const { loadProfiles } = require('../server/profiles');const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc } = require('../server/web/render');const { DEFAULT_ALLOW } = require('../server/domain');--const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });++const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, ...extra });const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profile.commands[k]), ...(DEFAULT_ALLOW[role] ? ['halt'] : [])];(async () => {@@@ -21,7 +21,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)for (const w of p.widgets) assert.ok(html.includes(`data-widget="${w.type}"`), `${p.id}: no ${w.type} widget`);for (const w of p.widgets.filter((x) => x.command)) {assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);-- assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);++ assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /( disabled|aria-disabled="true")/, `${p.id}: ${w.type} is enabled with no allowed kind`);}assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');@@@ -29,15 +29,14 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)}});-- await check('the e-stop is shown to the owner and an operator; only the owner gets the clear', () => {++ await check('the panel has no red stop bar; Stop is one optional part, off by default', () => {const p = profiles.find((x) => x.id === 'sim.rover');-- const html = (role) => renderPanel({ robot: robotFor(p, { estop: { latched: true } }), profile: p, role, allowed_commands: allowedFor(p, role) });-- assert.ok(html('owner').includes('data-estop>') && html('owner').includes('data-estop-clear'));-- assert.ok(html('operator').includes('data-estop>') && !html('operator').includes('data-estop-clear'));-- for (const role of ['viewer', 'queue']) {-- assert.ok(!html(role).includes('data-estop>') && !html(role).includes('data-estop-clear'), `${role} sees an e-stop button`);-- assert.ok(html(role).includes('data-estop-banner data-latched="true"'), `${role} does not see the latched banner`);++ const html = (role) => renderPanel({ robot: robotFor(p), profile: p, role, allowed_commands: allowedFor(p, role) });++ for (const role of ['owner', 'operator', 'viewer', 'queue']) {++ assert.ok(!html(role).includes('data-estop'), `${role} sees no stop bar`);++ assert.ok(!/<header class="[^"]*stop/.test(html(role)), `${role} sees no stop header`);}++ assert.ok(!html('owner').includes('data-stop'), 'the default panel has no Stop button');assert.match(renderWidget(p.widgets.find((w) => w.command), { profile: p, allowed_commands: [] }), /disabled/);});@@@ -89,7 +88,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)assert.ok(stick.length >= 1 && stick.every((a) => axes[a]), p.id);assert.deepStrictEqual(attr(html, 'data-axes'), axes, p.id);for (const a of Object.keys(axes)) assert.strictEqual(html.includes(`data-axis="${a}"`), !stick.includes(a), `${p.id}: ${a}`);-- assert.ok(html.includes('data-stop'));++ assert.ok(!html.includes('data-stop'), 'no Stop in the default drive pad');assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /data-joystick="[^"]+" [^>]*aria-disabled="true"/);}}@@@ -185,7 +184,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)assert.strictEqual(page.headers.get('cache-control'), 'no-store');const html = await page.text();for (const w of ['drive', 'camera', 'latency']) assert.ok(html.includes(`data-widget="${w}"`), w);-- assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"') && html.includes('data-estop>'));++ assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"'));const list = await (await get('/robots', alex)).text();assert.ok(list.includes(`/panel/${simId}`));await t.wait(50);@@@ -263,7 +262,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)await check('a viewer member gets the panel with nothing to drive; a stranger on a queue robot gets the queue role', async () => {await t.call('POST', `/api/v1/robots/${simId}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });const html = await (await get(`/panel/${simId}`, carol)).text();-- assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"') && !html.includes('data-estop>'));++ assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"'));const { robot } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });const q = await (await get(`/panel/${robot.id}`, bob)).text();assert.ok(q.includes('data-role="queue"'));@@@ -66,7 +66,7 @@ function identityStub() {await check('the owner sees the People card and adding @bob lists them, and the API agrees', async () => {const html = await (await get(`/panel/${robot.id}`, alex)).text();assert.ok(html.includes('class="setting-card people"') && html.includes('id="people-h"'));-- assert.ok(html.includes('<p class="setting-note">Operators drive and can press the e-stop; viewers watch.</p>'));++ assert.ok(html.includes('<p class="setting-note">Operators drive; viewers watch.</p>'));assert.ok(html.includes('<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>'));assert.ok(html.includes(`<form class="people-add" method="post" action="/robots/${robot.id}/operators">`));assert.ok(html.includes('name="username" placeholder="@username" required maxlength="41"'));@@@ -55,7 +55,7 @@ const { loadProfiles } = require('../server/profiles');assert.ok(await d.waitFor((m) => m.type === 'hello'));const config = await d.waitFor((m) => m.type === 'config');assert.ok(config.allowed_commands.includes('say') && config.allowed_commands.includes('display'));-- d.send({ v: 1, type: 'status', firmware: 'openvibe-phone/1', capabilities: {}, faults: [], estop_latched: false });++ d.send({ v: 1, type: 'status', firmware: 'openvibe-phone/1', capabilities: {}, faults: [] });d.send({ v: 1, type: 'telemetry', battery: { percent: 81 }, sensors: { tilt: { pitch: 4, roll: -2 } } });const c = await t.ws('/control', { headers: cookie(alex) });c.send({ type: 'join', robot_id: robot.id });nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++// The plain Stop: it is one ordinary part of the panel, off until a person adds it, and pressing it sends the++// machine's own Stop (halt) and nothing else. No latch, no clear, no red bar. Also the words: no page, doc or++// AGENTS.md says E-STOP, estop or emergency stop (inline code names a legacy protocol field and is skipped).++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const render = require('../server/web/render');++const surface = require('../server/web/surface');++const profile = require('../server/profiles/looi.json');++++const robot = { id: 'rob_stop1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };++const allowed = Object.keys(profile.commands);++const catalog = surface.controlsCatalog(profile);++const defaults = surface.defaultLayout(catalog, 'phone');++const panel = (layout = defaults, layoutSource = 'default') => render.renderPanel({++ robot, profile, role: 'owner', allowed_commands: allowed,++ arrange: {++ catalog, parts: surface.partsOf(profile), layout, device: 'phone', signedIn: true, layoutSource,++ src: '/shared/ov-arrange.js', defaultLayout: defaults,++ },++});++++const off = panel();++assert.match(off, /data-ov-part="stop"[^>]*hidden|hidden[^>]*data-ov-part="stop"/, 'Stop is in the layout, hidden');++assert.ok(!/class="[^"]*estop/.test(off), 'no stop bar or overlay stop');++++const withStop = JSON.parse(JSON.stringify(defaults));++const entry = withStop.parts.find((p) => p.id === 'stop');++entry.visible = true; entry.place = { x: 0, y: 0, w: 1, h: 1 };++const on = panel(withStop, 'account');++assert.match(on, /<button[^>]*class="stop plain-stop"[^>]*data-stop[^>]*>/, 'the placed Stop is a plain button');++assert.ok(!/data-stop[^>]*class="[^"]*danger/.test(on) && !/danger[^"]*data-stop/.test(on), 'it is not red');++assert.ok(!on.includes('data-estop'), 'no old stop bar');++++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');++assert.ok(js.includes("send('halt', {});"), 'pressing Stop sends the machine its own halt');++assert.ok(!js.includes("raw({ type: 'estop' })"), 'and nothing else');++++// The words: prose only; an inline code name such as `estop_state` is a legacy protocol field.++const files = ['AGENTS.md', 'README.md', 'STATUS.json', ...fs.readdirSync(path.join(__dirname, '..', 'docs')).filter((f) => f.endsWith('.md')).map((f) => `docs/${f}`)];++for (const file of files) {++ let text = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');++ text = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');++ assert.ok(!/(^|[^A-Za-z])e-?stop([^A-Za-z]|$)/i.test(text), `${file} says e-stop`);++ assert.ok(!/emergency\s+stop/i.test(text), `${file} says emergency stop`);++}++console.log('plain-stop: optional Stop sends halt; prose has no E-STOP');@@@ -1,7 +1,7 @@'use strict';--// ADR-048 amendment 2026-10-15: OpenVibe.Services stops and resumes a person's robot through Bot's control API--// (bot.resource.control, common.owned-resource-control-request@1). Only the owner Bot holds may be named; a repeat--// answers the first result; an agent's resume waits for the owner's confirmation; first-party only.++// ADR-048 amendment 2026-10-15: OpenVibe.Services stops a person's robot through Bot's control API++// (bot.resource.control, common.owned-resource-control-request@1). Stop sends the robot's own halt; resume answers++// that nothing was latched. Only the owner Bot holds may be named; a repeat answers the first result; first-party only.const assert = require('assert');const contracts = require('openvibe-contracts');const { boot, check, done } = require('./helpers/app');@@@ -18,45 +18,37 @@ const { boot, check, done } = require('./helpers/app');action, owned: { kind: 'bot.robot', id: robot.id }, owner: { type: 'user', id: alice.subject },on_behalf_of: { type: 'user', id: alice.subject }, idempotency_key: `idem_robot_${++n}_${Date.now()}`, ...over,});-- const latched = async () => (await t.db.query('SELECT estop_latched FROM robots WHERE id = $1', [robot.id])).rows[0].estop_latched;const valid = (body) => assert.ok(contracts.resources.checkControlResult(body).valid, JSON.stringify(contracts.resources.checkControlResult(body).errors));-- await check('stop latches the e-stop; a repeat answers the first result; stop again changes nothing', async () => {++ await check('stop sends a Stop and answers the first result for a repeated key; nothing is latched', async () => {const first = req('stop');let r = await send(first);assert.strictEqual(r.status, 200, r.text);valid(r.json);-- assert.deepStrictEqual([r.json.state, r.json.result, r.json.resource], ['done', { estop: true, changed: true }, undefined]);-- assert.strictEqual(await latched(), true);++ assert.deepStrictEqual([r.json.state, r.json.result.stop, r.json.resource], ['done', true, undefined]);++ assert.strictEqual(r.json.result.changed, false, 'no device is online to take the Stop');const again = await send(first);assert.deepStrictEqual(again.json, r.json, 'the same key answers the first result');r = await send({ ...first, dry_run: true });assert.deepStrictEqual([r.status, r.json.code], [409, 'control.idempotency_conflict'], 'the key reused with another request');-- r = await send(req('stop'));-- assert.deepStrictEqual(r.json.result, { estop: true, changed: false });-- const events = (await t.db.query("SELECT envelope FROM bot_event_outbox WHERE envelope->>'event_type' = 'bot.estop.set' AND envelope->'subject'->>'id' = $1", [robot.id])).rows;-- assert.strictEqual(events.length, 1, 'bot.estop.set is announced once');++ r = await send(req('stop', { dry_run: true }));++ assert.deepStrictEqual([r.json.state, r.json.result.would], ['done', 'send the robot a Stop']);++ const events = (await t.db.query("SELECT envelope FROM bot_event_outbox WHERE envelope->>'event_type' LIKE 'bot.estop.%' AND envelope->'subject'->>'id' = $1", [robot.id])).rows;++ assert.strictEqual(events.length, 0, 'no latch event is announced');});-- await check('resume clears it (dry run first); an agent\'s resume needs the owner\'s confirmation', async () => {++ await check('resume has nothing to clear; an agent may stop too', async () => {let r = await send(req('resume', { dry_run: true }));-- assert.deepStrictEqual([r.json.state, r.json.result.dry_run], ['done', true]);-- assert.strictEqual(await latched(), true, 'a dry run applies nothing');++ assert.deepStrictEqual([r.json.state, r.json.result.stop], ['done', false]);r = await send(req('resume', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));-- assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'control.confirmation_needed']);-- assert.strictEqual(await latched(), true);-- r = await send(req('resume'));-- assert.deepStrictEqual([r.json.state, r.json.result], ['done', { estop: false, changed: true }]);-- assert.strictEqual(await latched(), false);++ assert.deepStrictEqual([r.json.state, r.json.result], ['done', { stop: false, changed: false }], 'a resume is a calm no-op');r = await send(req('stop', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));-- assert.deepStrictEqual(r.json.result, { estop: true, changed: true }, 'an agent may always stop it');-- await send(req('resume'));++ assert.deepStrictEqual(r.json.result.stop, true, 'an agent may always stop it');});await check('only the owner Bot holds; unknown kinds and actions are refused', async () => {let r = await send(req('stop', { owner: { type: 'user', id: bob.subject }, on_behalf_of: { type: 'user', id: bob.subject } }));assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s robot is not confirmed to exist');-- assert.strictEqual(await latched(), false);r = await send(req('stop', { on_behalf_of: { type: 'user', id: bob.subject } }));assert.deepStrictEqual([r.status, r.json.code], [400, 'control.bad_request'], 'a person acts only on what they own');r = await send(req('delete'));@@@ -74,7 +66,6 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(r.status, 403, 'a person\'s own token is not a first-party service');r = await send(req('stop'), { token: null });assert.strictEqual(r.status, 401);-- assert.strictEqual(await latched(), false);});await done(t);@@@ -15,7 +15,6 @@ const { boot, check, done } = require('./helpers/app');const get = (path, options = auth) => t.call('GET', `/api/v1/resources${path}`, options);await check('a page validates and reveals only the allowed summary fields', async () => {-- await t.db.query('UPDATE robots SET estop_latched = true WHERE id = $1', [second.id]);const response = await get('');assert.strictEqual(response.status, 200, response.text);assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', response.json).errors, []);@@@ -30,7 +29,7 @@ const { boot, check, done } = require('./helpers/app');assert.ok(!Number.isNaN(Date.parse(summary.updated_at)));}assert.deepStrictEqual(response.json.resources.find((r) => r.id === second.id).owner, { type: 'user', id: bob.subject });-- assert.strictEqual(response.json.resources.find((r) => r.id === second.id).state, 'estopped');++ assert.strictEqual(response.json.resources.find((r) => r.id === second.id).state, 'ready');assert.strictEqual(response.json.resources.find((r) => r.id === first.id).state, 'ready');});@@@ -121,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');const { machineSummary, robotSummary } = require('../server/api/resource-index');const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.link/machines' });-- assert.deepStrictEqual(robotSummary({ ...row, id: first.id, estop_latched: false }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });++ assert.deepStrictEqual(robotSummary({ ...row, id: first.id }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources.map((r) => r.id), [deviceId]);@@@ -89,9 +89,9 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');await check('the embedded panel asks its allowed page for a ticket; the refusal tells that page', () => {const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };-- const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });++ const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });assert.ok(html.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));-- const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });++ const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });assert.ok(!page.includes('data-parent-origins'), 'only a framed panel');const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');assert.ok(js.includes("window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin)"), 'asks each allowed origin only');@@@ -1,6 +1,6 @@'use strict';// The panel's arrangeable surfaces (make your own controls, stage 2): bot.controls is built from the robot's own--// widgets, Stop and the overlay's e-stop are required, the default layouts pass openvibe-sdk/ui-layout, and the++// widgets, Stop is one optional part, the default layouts pass openvibe-sdk/ui-layout, and the// attributes put a person's layout on the real parts.const assert = require('node:assert/strict');const { validate } = require('openvibe-sdk/ui-layout');@@@ -10,21 +10,22 @@ const rover = require('../server/profiles/sim.rover.json');const ids = (catalog) => catalog.parts.map((p) => p.id);const cat = surface.controlsCatalog(looi);--assert.deepEqual(ids(cat), ['stick', 'stop', 'head', 'battery', 'latency'], 'the LOOI panel: the stick, Stop, the head slider and the readouts');--assert.equal(cat.parts.find((p) => p.id === 'stop').required, true, 'Stop is required');++assert.deepEqual(ids(cat), ['stick', 'head', 'battery', 'latency', 'stop'], 'the LOOI panel: the stick, the head slider, the readouts and the optional Stop');++assert.equal(cat.parts.find((p) => p.id === 'stop').required, false, 'Stop is optional');assert.equal(cat.parts.find((p) => p.id === 'stick').styles.label, false, 'the stick has no label');for (const device of ['phone', 'tablet', 'computer']) {const layout = surface.defaultLayout(cat, device);assert.deepEqual(validate(cat, layout), { ok: true, reasons: [] }, `the ${device} default layout is valid`);++ assert.equal(layout.parts.find((p) => p.id === 'stop').visible, false, `Stop is off in the ${device} default`);}const roverCat = surface.controlsCatalog(rover);--assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights'), 'the rover: the camera, the drive stick, Stop and its controls');++assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights') && ids(roverCat).includes('stop'), 'the rover: the camera, the drive stick, its controls and the optional Stop');assert.ok(ids(roverCat).includes('telemetry.2'), 'a second widget of one type gets its own part');assert.deepEqual(validate(roverCat, surface.defaultLayout(roverCat, 'phone')), { ok: true, reasons: [] });const overlay = surface.overlayCatalog(looi, { slider: { name: 'head' }, hasStick: true, canDrive: true });--assert.deepEqual(ids(overlay), ['pill', 'more', 'estop', 'stick', 'slider', 'drive']);--assert.equal(overlay.parts.find((p) => p.id === 'estop').required, true, 'the overlay e-stop is required');++assert.deepEqual(ids(overlay), ['pill', 'more', 'stop', 'stick', 'slider', 'drive']);++assert.equal(overlay.parts.find((p) => p.id === 'stop').required, false, 'the overlay Stop is optional');assert.deepEqual(validate(overlay, surface.overlayDefaultLayout(overlay, 'phone')), { ok: true, reasons: [] }, 'the overlay default is valid');// A hidden readout and a moved Stop reach the element's attributes; a default (not the person's own) leaves the@@@ -33,10 +34,12 @@ const layout = surface.defaultLayout(cat, 'phone');layout.parts.find((p) => p.id === 'battery').visible = false;assert.match(surface.attrsFor(cat, layout, 'phone', 'battery'), /hidden/, 'a hidden part carries hidden');const stop = surface.attrsFor(cat, layout, 'phone', 'stop');--assert.match(stop, /grid-column:3 \/ span 2/, 'Stop is beside the stick');--assert.equal((stop.match(/style=/g) || []).length, 1, 'one style attribute');--assert.match(stop, /--ov-part-opacity:1/, 'its opacity comes from the layout');--assert.equal(surface.attrsFor(cat, layout, 'phone', 'stop', { apply: false }), '', 'the default is the page, not the layout');++assert.match(stop, /hidden/, 'Stop is hidden until a person adds it');++const head = surface.attrsFor(cat, layout, 'phone', 'head');++assert.match(head, /grid-column:/, 'a visible part carries its grid place');++assert.equal((head.match(/style=/g) || []).length, 1, 'one style attribute');++assert.match(head, /--ov-part-opacity:1/, 'its opacity comes from the layout');++assert.equal(surface.attrsFor(cat, layout, 'phone', 'head', { apply: false }), '', 'the default is the page, not the layout');assert.match(surface.surfaceAttrs(cat, layout, 'phone'), /data-ov-arranged="true"/);assert.match(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-surface="bot.controls"/);assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-arranged/);@@@ -44,4 +47,4 @@ assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' } }), 'phone');assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)' } }), 'tablet');assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' } }), 'computer');--console.log('surface: bot.controls and bot.overlay catalogs, required safety parts, valid defaults and part attributes');++console.log('surface: bot.controls and bot.overlay catalogs, optional Stop, valid defaults and part attributes');
@@ -21,8 +21,7 @@ budget — over a panel built from the robot's profile rather than per-robot cod
@@ -21,8 +21,7 @@ budget — over a panel built from the robot's profile rather than per-robot cod
(`server/kits/*.json`, metadata only).
(`server/kits/*.json`, metadata only).
- Devices and their credentials (hashed at rest, rotated, revoked, never readable back) and pairing
- Devices and their credentials (hashed at rest, rotated, revoked, never readable back) and pairing
codes (one-time, short-lived; `BOT_PAIRING_AUTHORITY=network` lets Network mint them).
codes (one-time, short-lived; `BOT_PAIRING_AUTHORITY=network` lets Network mint them).
-- The `/device`, `/control` and `/watch` WebSockets, the command gate, the operator queue and the
- **latched e-stop**.
+- The `/device`, `/control` and `/watch` WebSockets, the command gate and the operator queue.
- Routines (`server/routines.js`), owner webhooks (`server/webhooks.js`, signed, retried), community
- Routines (`server/routines.js`), owner webhooks (`server/webhooks.js`, signed, retried), community
robot profiles (`server/submissions.js`), the phone-as-robot device, the in-process simulator, the
robot profiles (`server/submissions.js`), the phone-as-robot device, the in-process simulator, the
server-side ONVIF camera connector, and the job dispatcher for paired Link machines
server-side ONVIF camera connector, and the job dispatcher for paired Link machines
@@ -93,7 +92,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
@@ -93,7 +92,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 panel e-stop, set
+- The **owner** can list a robot, add/remove operators, edit limits, 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 +100,7 @@ migration; add a new number. The command audit is pruned after 30 days; job usag
@@ -101,7 +100,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;
- the owner or a Mod clears the panel e-stop, while the tray's STOP latch and ARM stay local.
+ Stop is one optional part of a layout, and the tray's own stop stays local.
## Frontend loading model
## Frontend loading model
@@ -126,7 +125,7 @@ Unit [deploy/systemd/openvibe-bot.service](deploy/systemd/openvibe-bot.service)
@@ -126,7 +125,7 @@ Unit [deploy/systemd/openvibe-bot.service](deploy/systemd/openvibe-bot.service)
`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`, with
`npm test` runs each `test/*.test.js` in its own process through `openvibe-shared/test-runner`, with
PGlite and in-process Network/Events stubs. It covers the device and operator WebSocket gates, the
PGlite and in-process Network/Events stubs. It covers the device and operator WebSocket gates, the
-REST routes, pairing paths, profiles and kits, the owner fence, the e-stop, the outbox, routines,
+REST routes, pairing paths, profiles and kits, the owner fence, the optional Stop, the outbox, routines,
webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF camera against a stub.
webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF camera against a stub.
`npm run test:pg` runs the same on PostgreSQL. `ov test` is the harness wrapper.
`npm run test:pg` runs the same on PostgreSQL. `ov test` is the harness wrapper.
@@ -143,8 +142,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam
@@ -143,8 +142,8 @@ webhooks, jobs/metering, the embed/watch limits, the simulator and the ONVIF cam
## Pitfalls
## Pitfalls
-- **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.
+- **Stop is one optional part, off by default.** It sends the robot's own `halt`; nothing latches and nothing
+ clears. The tray's own Stop stays local, and `halt` always passes the gate.
- **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.
@@ -15,8 +15,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -15,8 +15,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).
- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
-- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
-- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
+- The `/device` and `/control` WebSockets, the command gate and the optional Stop.
+- The front page, `GET /` (`server/web/home.js`, `public/home.css`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and Stop, people and the queue, the read-only embed, the simulator, routines, webhooks and the agent switch), and data sections rendered on the server, each left out when it has nothing to show: numbers from the catalogue, the simulated rover and the listed robots with their online state, every catalogue model with its connection kind and controls, the connection kinds with their models, the first kit's parts list, robotics jobs from OpenVibe.Search (`server/web/network-reads.js`: OpenVibe.Work's documents, read anonymously with `BOT_SEARCH_URL`, cached 15 minutes) and the questions people ask first (JSON-LD WebSite, WebApplication, ItemList and FAQPage). It has its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- **Make your own controls.** The panel and the controls over a stream are arrangeable surfaces: Bot publishes
- **Make your own controls.** The panel and the controls over a stream are arrangeable surfaces: Bot publishes
`bot.controls` and `bot.overlay` as `ui.surface@1` catalogs built from the robot's own profile, and each person's
`bot.controls` and `bot.overlay` as `ui.surface@1` catalogs built from the robot's own profile, and each person's
@@ -24,21 +24,21 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -24,21 +24,21 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
real parts before first paint. **Arrange** loads `openvibe-shared/ov-arrange.js` only then: drag to move, the corner
real parts before first paint. **Arrange** loads `openvibe-shared/ov-arrange.js` only then: drag to move, the corner
to resize, arrows and Shift with the arrows, a long press for a part's visible/size/colour/shape/opacity/label and
to resize, arrows and Shift with the arrows, a long press for a part's visible/size/colour/shape/opacity/label and
bindings, live validation through `openvibe-sdk/ui-layout` with the reason in plain words, Undo, Reset to default,
bindings, live validation through `openvibe-sdk/ui-layout` with the reason in plain words, Undo, Reset to default,
- Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop and the overlay's e-stop are
+ Save and Share, and **Ask OpenVibe** previewed on the parts with Keep and Discard. Stop is one optional part,
required; a layout only arranges, and every command still passes the same gate and the robot's limits. The old
required; a layout only arranges, and every command still passes the same gate and the robot's limits. The old
browser store (`ov-bot-pad-prefs`: Stop's hand, pad size, readouts) is migrated into the person's first layout once.
browser store (`ov-bot-pad-prefs`: Stop's hand, pad size, readouts) is migrated into the person's first layout once.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
-- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
-- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
+- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
+- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Link plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Link plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
-- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
+- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping and deadman), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- The legal pages (`/terms`, `/privacy`, `/dmca`, `openvibe-shared/legal`), `/sitemap.xml`, `/manifest.webmanifest`, `/robots.txt` and the browser-facing 404 (`server/app.js`, `server/web/routes.js`): Bot serves them all itself, and the nginx vhost has no static root.
- 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, 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).
+- 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 shows the audit of those changes. 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).
@@ -174,7 +174,7 @@ per export and deletion in `account_data_events` (migration 0008).
@@ -174,7 +174,7 @@ per export and deletion in `account_data_events` (migration 0008).
with it, and so does its command history. A device that served only their robots is revoked and loses its name;
with it, and so does its command history. A device that served only their robots is revoked and loses its name;
the row stays because run jobs reference it.
the row stays because run jobs reference it.
- **Elsewhere:** their operator role and queue place on other robots go, and their id leaves other robots' command
- **Elsewhere:** their operator role and queue place on other robots go, and their id leaves other robots' command
- history, e-stop marks, pairing codes and invitations.
+ history, pairing codes and invitations.
- **Kept:** run jobs they started, whose usage seconds were reported to Billing.
- **Kept:** run jobs they started, whose usage seconds were reported to Billing.
## Capabilities
## Capabilities
@@ -185,7 +185,7 @@ per export and deletion in `account_data_events` (migration 0008).
@@ -185,7 +185,7 @@ per export and deletion in `account_data_events` (migration 0008).
| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
| `bot.desk.session` | OpenVibe.Run's sessions (contracts 0.223.0): `GET /desk-hosts`, then lease, use and end one browser desk per session (`/machines/:id/desk-leases…`) on the person's own machine or a sandboxed desk host shared with other people |
| `bot.desk.session` | OpenVibe.Run's sessions (contracts 0.223.0): `GET /desk-hosts`, then lease, use and end one browser desk per session (`/machines/:id/desk-leases…`) on the person's own machine or a sandboxed desk host shared with other people |
| `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |
| `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |
-| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
+| `bot.robot.control` | the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" |
| `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" |
| `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |
| `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |
@@ -199,14 +199,14 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -199,14 +199,14 @@ A person acts on their own robots with a Network user token; a service acts with
## Tests
## Tests
-`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop, the outbox and the server-side ONVIF camera (against a stub endpoint: `ptz` → `ContinuousMove`, telemetry → the `GetStatus` readout).
+`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the optional Stop, the outbox and the server-side ONVIF camera (against a stub endpoint: `ptz` → `ContinuousMove`, telemetry → the `GetStatus` readout).
## Security
## Security
- 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 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.
+- Pairing codes are one-time and short-lived; Stop is one optional part of a layout, `halt` always passes the gate, and the tray's own stop stays local.
- 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`.
@@ -30,8 +30,6 @@
@@ -30,8 +30,6 @@
"events": [
"events": [
"bot.robot.online",
"bot.robot.online",
"bot.robot.offline",
"bot.robot.offline",
- "bot.estop.set",
- "bot.estop.cleared",
"bot.command.refused"
"bot.command.refused"
],
],
"deployed": true,
"deployed": true,
@@ -44,29 +42,29 @@
@@ -44,29 +42,29 @@
"catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
"catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
"ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe Link's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",
"ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe Link's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
- "a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
- "command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
+ "a Stop that sends the machine's own halt, always allowed, and a device-side deadman deadline on the motion kinds",
+ "command audit kept 30 days, and the bot.robot.online/offline and bot.command.refused outbox events",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"signed-in pages: /robots/new (add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",
"signed-in pages: /robots/new (add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device in a small arena shared with the browser (public/sim-world.js: walls and crates stop it; telemetry carries distance_cm, line sensors and its pose, from which the panel draws its camera and a map)",
- "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping, the deadman and the latched e-stop); listed in the sitemap and llms.txt",
+ "/try (public, no account): the real panel on a sim.rover that runs in the visitor's browser (public/try-device.js answers /control locally with the gate's value shaping and the deadman); listed in the sitemap and llms.txt",
"the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",
"the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",
- "a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
+ "a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop; /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
"community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
"community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
"routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",
"routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",
- "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
+ "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline and bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
"the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed",
"the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Link's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Link's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Link over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Link over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
- "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
+ "embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, latency, battery, the profile's readouts), capped per address and per robot",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
"machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",
"machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",
"watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for",
"watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for",
- "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop and the e-stop are required and robot commands are untouched"
+ "make your own controls (stage 2): the panel's bot.controls and the overlay's bot.overlay are ui.surface@1 catalogs built from the robot's profile, each person's ui.layout@1 comes from OpenVibe.Network and is drawn server-side before first paint, Arrange (openvibe-shared/ov-arrange.js, loaded only on press) moves, resizes, styles and hides parts with live openvibe-sdk/ui-layout validation, Undo/Reset/Save/Share and an Ask OpenVibe suggestion previewed before it is kept; Stop is one optional part (off by default) and robot commands are untouched"
],
],
"updated": "2026-11-12"
"updated": "2026-11-12"
}
}
@@ -5,14 +5,14 @@ robot's site allows (`BOT_EMBED_ORIGINS`; the robot's `data-parent-origins` list
@@ -5,14 +5,14 @@ robot's site allows (`BOT_EMBED_ORIGINS`; the robot's `data-parent-origins` list
| view | query | what it draws |
| view | query | what it draws |
|---|---|---|
|---|---|---|
-| `panel` | `/embed` or `?mode=panel` | the whole panel: header, camera, every widget, the e-stop and the readouts. This is the default and what existing pages get. |
-| `overlay` | `?mode=overlay` | a transparent page that fills its frame and draws only the live controls: the robot's name and state (online, your turn, someone else driving, e-stop on) as one pill at the top left, the first stick in a 132 px circle at the bottom left, the first slider as a 44 × 132 vertical track at the bottom right, the e-stop as a 44 px round button at the top right, and a “more” button under it. Safe-area insets are applied. |
-| `compact` | `?mode=compact` | one row for a narrow card: the same pill, a 96 px stick, the same slider across, the e-stop and “more”. |
+| `panel` | `/embed` or `?mode=panel` | the whole panel: header, camera, every widget, the optional Stop and the readouts. This is the default and what existing pages get. |
+| `overlay` | `?mode=overlay` | a transparent page that fills its frame and draws only the live controls: the robot's name and state (online, your turn, someone else driving) as one pill at the top left, the first stick in a 132 px circle at the bottom left, the first slider as a 44 × 132 vertical track at the bottom right, and a “more” button at the top right. Safe-area insets are applied. |
+| `compact` | `?mode=compact` | one row for a narrow card: the same pill, a 96 px stick, the same slider across and “more”. |
Which stick and slider exist comes from the robot's profile (`server/profiles/*.json`): the first `drive`, `ptz` or
Which stick and slider exist comes from the robot's profile (`server/profiles/*.json`): the first `drive`, `ptz` or
direction-button widget with a stick, and the first `number` actuator a widget drives. A robot with neither still gets
direction-button widget with a stick, and the first `number` actuator a widget drives. A robot with neither still gets
-the pill, the e-stop (for its owner and operators) and “more”. A viewer sees the pill and “more” until they take the
-controls the same way the full panel lets them.
+the pill and “more”. A viewer sees the pill and “more” until they take the controls the same way the full panel lets
+them. Stop is one optional part of a full panel's layout, off until a person adds it.
Typical iframe:
Typical iframe:
@@ -70,8 +70,7 @@ window.addEventListener('message', (e) => {
@@ -70,8 +70,7 @@ window.addEventListener('message', (e) => {
`clip-path` participates in hit testing: a press outside every rectangle reaches the video (or whatever is behind the
`clip-path` participates in hit testing: a press outside every rectangle reaches the video (or whatever is behind the
frame) and the player's own controls keep working. Put the frame in a layer above the video and **below** the player's
frame) and the player's own controls keep working. Put the frame in a layer above the video and **below** the player's
controls bar, unmute pill and chat button, and leave the bottom bar's height out of the layer, e.g.
controls bar, unmute pill and chat button, and leave the bottom bar's height out of the layer, e.g.
-`position:absolute; inset:0 0 var(--player-bar-height, 64px) 0; z-index:4`. The overlay's e-stop and pill then sit
-above the picture but under those page controls. In full screen, keep the frame inside the element that goes full
+`position:absolute; inset:0 0 var(--player-bar-height, 64px) 0; z-index:4`. The overlay's pill and more button then sit above the picture but under those page controls. In full screen, keep the frame inside the element that goes full
screen (`requestFullscreen` on the player box), so the controls stay over the picture.
screen (`requestFullscreen` on the player box), so the controls stay over the picture.
Do not use `pointer-events: none` on the iframe element itself as a click-through trick: it turns the controls off too.
Do not use `pointer-events: none` on the iframe element itself as a click-through trick: it turns the controls off too.
@@ -80,7 +79,7 @@ Do not use `pointer-events: none` on the iframe element itself as a click-throug
@@ -80,7 +79,7 @@ Do not use `pointer-events: none` on the iframe element itself as a click-throug
- The frame is sandboxed exactly as above and accepts parent messages only from `data-parent-origins`.
- The frame is sandboxed exactly as above and accepts parent messages only from `data-parent-origins`.
- The page accepts frame messages only from the Bot origin **and** that exact iframe, and only the shapes below.
- The page accepts frame messages only from the Bot origin **and** that exact iframe, and only the shapes below.
-- A page never sends `estop`, `drive`, `actuator` or any other command to the frame: only OpenVibe.Bot's panel sends
+- A page never sends `stop`, `halt`, `drive`, `actuator` or any other command to the frame: only OpenVibe.Bot's panel sends
commands, through Bot's own gate, as the viewer. Chat commands on a bound OpenVibe.Live channel go through the
commands, through Bot's own gate, as the viewer. Chat commands on a bound OpenVibe.Live channel go through the
channel route in Live, never through this protocol.
channel route in Live, never through this protocol.
- Treat `hit` as layout data only: clamp it and ignore anything malformed.
- Treat `hit` as layout data only: clamp it and ignore anything malformed.
@@ -20,7 +20,7 @@ the owner's embed opt-in:
@@ -20,7 +20,7 @@ the owner's embed opt-in:
when the switch goes off.
when the switch goes off.
- `server/realtime.js` — the third WebSocket, `wss://…/watch`: no credential, ever. A `join` on an
- `server/realtime.js` — the third WebSocket, `wss://…/watch`: no credential, ever. A `join` on an
`embed_public` robot answers `joined { role: "watcher", allowed_commands: [], state }` where `state` is the
`embed_public` robot answers `joined { role: "watcher", allowed_commands: [], state }` where `state` is the
- **public state** (online, e-stop latch, latency, battery, and only the sensor keys the profile's `telemetry`
+ **public state** (online, latency, battery, and only the sensor keys the profile's `telemetry`
widgets read); an unknown or non-public robot answers `bot.not_an_operator` alike. Every other frame answers
widgets read); an unknown or non-public robot answers `bot.not_an_operator` alike. Every other frame answers
`bot.read_only` and reaches neither a device nor the audit. Frames over 4 KiB close 1009; over
`bot.read_only` and reaches neither a device nor the audit. Frames over 4 KiB close 1009; over
`BOT_WATCH_MAX_PER_IP` open sockets per client address (default 20) or `BOT_WATCH_MAX_PER_ROBOT` watchers on
`BOT_WATCH_MAX_PER_IP` open sockets per client address (default 20) or `BOT_WATCH_MAX_PER_ROBOT` watchers on
@@ -97,9 +97,8 @@ adds it from this file at the verified head; it resolves to:
@@ -97,9 +97,8 @@ adds it from this file at the verified head; it resolves to:
'self'`.
'self'`.
4. Anonymous `wss://openvibe.bot/watch` (through nginx, HTTP 101) with `{"type":"join","robot_id":"<public id>"}`
4. Anonymous `wss://openvibe.bot/watch` (through nginx, HTTP 101) with `{"type":"join","robot_id":"<public id>"}`
answers `joined` with `role: "watcher"`, `allowed_commands: []` and the public state only; the same join on
answers `joined` with `role: "watcher"`, `allowed_commands: []` and the public state only; the same join on
- a **non-public or unknown** robot answers `bot.not_an_operator` and reveals nothing. A `command`/`estop`/
- `estop_clear` frame answers `bot.read_only` and produces no device frame and no audit row. The owner's
- e-stop reaches a joined watcher; the owner turning `embed_public` off closes its watchers with 4003.
+ a **non-public or unknown** robot answers `bot.not_an_operator` and reveals nothing. A `command`/`stop` frame answers `bot.read_only` and produces no device frame and no audit row. A state
+ change reaches a joined watcher; the owner turning `embed_public` off closes its watchers with 4003.
5. With `BOT_WATCH_MAX_PER_IP=2` / `BOT_WATCH_MAX_PER_ROBOT=3` on a scratch start, the 3rd socket from one
5. With `BOT_WATCH_MAX_PER_IP=2` / `BOT_WATCH_MAX_PER_ROBOT=3` on a scratch start, the 3rd socket from one
address and the 4th watcher on one robot close 4003; a frame over 4 KiB closes 1009.
address and the 4th watcher on one robot close 4003; a frame over 4 KiB closes 1009.
6. The row counts from the backup step are unchanged (this PR writes none); `robots.embed_public` is only ever
6. The row counts from the backup step are unchanged (this PR writes none); `robots.embed_public` is only ever
@@ -146,7 +145,7 @@ node test/panel.test.js
@@ -146,7 +145,7 @@ node test/panel.test.js
`GET /panel/:id/embed` serves a public robot to anyone with its own CSP, `no-store` and no cookie and never
`GET /panel/:id/embed` serves a public robot to anyone with its own CSP, `no-store` and no cookie and never
a redirect; a non-public robot is `403` with no data and a member keeps their role; `/watch` answers the
a redirect; a non-public robot is `403` with no data and a member keeps their role; `/watch` answers the
public state only and refuses a non-public or unknown robot; any other frame is `bot.read_only` and reaches
public state only and refuses a non-public or unknown robot; any other frame is `bot.read_only` and reaches
- neither device nor audit; the owner's e-stop reaches a watcher and readouts carry only the profile's sensor
+ neither device nor audit; a state change reaches a watcher and readouts carry only the profile's sensor
keys; turning embedding off closes watchers 4003; the per-address/per-robot caps close 4003) and
keys; turning embedding off closes watchers 4003; the per-address/per-robot caps close 4003) and
`node test/panel.test.js` (the signed-in panel page, its controls and the simulated robot still render).
`node test/panel.test.js` (the signed-in panel page, its controls and the simulated robot still render).
3. It confirms **0 pending** migrations afterwards and the fixture row counts unchanged — this PR writes no
3. It confirms **0 pending** migrations afterwards and the fixture row counts unchanged — this PR writes no
@@ -60,7 +60,7 @@ credential, as above.
@@ -60,7 +60,7 @@ credential, as above.
Frames sent while authentication is still in progress — right after an upgrade that carries the
Frames sent while authentication is still in progress — right after an upgrade that carries the
header, or after `pair` until it is answered — are **kept** and handled in arrival order once it
header, or after `pair` until it is answered — are **kept** and handled in arrival order once it
-completes (after `hello` and `config`), so a device may send its first `status` and `estop_state`
+completes (after `hello` and `config`), so a device may send its first `status` and its Stop report
straight after the upgrade. They are never answered `bot.not_paired`. If the credential is refused
straight after the upgrade. They are never answered `bot.not_paired`. If the credential is refused
they are discarded with the socket; after a refused `pair` they are answered as on an unpaired socket.
they are discarded with the socket; after a refused `pair` they are answered as on an unpaired socket.
At most 64 frames wait; any more are answered `error` `bot.not_ready`.
At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -71,9 +71,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -71,9 +71,8 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
|---|---|---|
|---|---|---|
| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once**; without OpenRestream configured `video: "not_configured"` instead of `publish_key`/`whip_url` |
| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once**; without OpenRestream configured `video: "not_configured"` instead of `publish_key`/`whip_url` |
| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
-| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists) and whenever Bot's latch is set or cleared (after the `estop` frame) |
+| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits (or `allow` lists). `estop_latched` is a legacy field: Bot always sends `false` and no longer latches anything |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id, provenance?` | an operator's command passed the gate; `provenance` is present on `headlight` |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id, provenance?` | an operator's command passed the gate; `provenance` is present on `headlight` |
-| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `heartbeat_ack` | `echo, t, server_time` | the answer to `heartbeat`; `echo` is the heartbeat's `t` (or `null`), so the device computes RTT, and `t` repeats a finite `t` (device ms) unchanged — the field OpenVibe Link reads. The envelope `seq` is Bot's own counter and never echoes the device's |
| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
| `rotate` | — | the owner forced a rotation of a Network-paired machine (`POST /devices/:id/rotate`); the machine rotates its credential with Network itself |
| `error` | `code, detail` | a frame the server refused |
| `error` | `code, detail` | a frame the server refused |
@@ -85,11 +84,11 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
@@ -85,11 +84,11 @@ At most 64 frames wait; any more are answered `error` `bot.not_ready`.
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code. With `BOT_PAIRING_AUTHORITY=network` → `error` `bot.pairing_moved` |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
| `heartbeat` | `t, rtt_ms` | every `heartbeat_ms` (1 s); `t` is the device's send time in unix ms (a finite number), returned as both `heartbeat_ack.echo` and `heartbeat_ack.t`; `rtt_ms` is the device's own measured latency |
| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
| `telemetry` | `battery, voltage, sensors{}, events[]?, …` | at most 2 Hz; extra frames are dropped — except a frame with a non-empty `events` array (a fault, a bump, low battery), which is always delivered and does not count against the samples' window. `battery` is either a 0..1 fraction or an object `{volts, percent}` with `percent` 0..100 (as OpenVibe Link sends it); `robot_state.battery` is always the fraction 0..1 (or `null`), while `robot_state.telemetry` keeps the raw frame |
-| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below |
+| `status` | `firmware, capabilities, faults[], estop_latched, device_kind?, drivers[]?, agent_version?` | on connect and on change. See below. `estop_latched` is a legacy field and is not read |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `reauth` | `token` | a Network-paired machine only: a fresh node token for the same principal, before the last one expires (300 s); a refused one is answered `error` `bot.reauth_refused` and moves nothing |
| `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` |
| `ack` | `id, data?` | a command ran (or, keyed by a job id, a job was accepted: §1.2); `data` is a computer command's answer or a headlight's `{stage, admitted}` |
| `nack` | `id, fault_code` | a command (or a job, §1.2) was refused on the device |
| `nack` | `id, fault_code` | a command (or a job, §1.2) was refused on the device |
-| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the device's own latch, a **report**; `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |
+| `estop_state` | `latched, by, at, robot_id?, local_stop?, reason?` | the old name of a machine's own Stop report; accepted and ignored. `robot_id` omitted = every robot the device serves; `local_stop: true` = held on the machine itself. See below |
`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `button`, `point`, `headlight` and `halt`, and a
`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `button`, `point`, `headlight` and `halt`, and a
device only ever gets the kinds its robot's profile declares (§1.1) or a custom action it declares in
device only ever gets the kinds its robot's profile declares (§1.1) or a custom action it declares in
@@ -97,8 +96,7 @@ device only ever gets the kinds its robot's profile declares (§1.1) or a custom
@@ -97,8 +96,7 @@ device only ever gets the kinds its robot's profile declares (§1.1) or a custom
`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds (`drive`, `actuator`, `ptz`), on a
`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds (`drive`, `actuator`, `ptz`), on a
held `button`, and on `headlight` (which is not motion and never renews a motion deadline): the device **must** stop
held `button`, and on `headlight` (which is not motion and never renews a motion deadline): the device **must** stop
the motors at that instant if no newer command arrived, and on every disconnect or crash path, without asking the
the motors at that instant if no newer command arrived, and on every disconnect or crash path, without asking the
-network. `halt` (value `{}`, no deadline) stops everything at once and passes the gate even while the e-stop is
-latched.
+network. `halt` (value `{}`, no deadline) stops everything at once and always passes the gate.
**Headlights** (`headlight`, LOOI; `{ "on": true|false }`, explicit and never a toggle) are the owner's and People
**Headlights** (`headlight`, LOOI; `{ "on": true|false }`, explicit and never a toggle) are the owner's and People
Operators' only, never an anonymous viewer, a plain watcher or the queue. Bot mints the trusted fields itself, never
Operators' only, never an anonymous viewer, a plain watcher or the queue. Bot mints the trusted fields itself, never
@@ -223,24 +221,18 @@ against these):
@@ -223,24 +221,18 @@ against these):
The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,
The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,
`server/onvif`). A `sim` device needs nothing; an `onvif` camera is configured with `BOT_ONVIF_CAMERAS`
`server/onvif`). A `sim` device needs nothing; an `onvif` camera is configured with `BOT_ONVIF_CAMERAS`
(its URL plus `username_ref`/`password_ref` secret references — never the profile) and Bot translates each
(its URL plus `username_ref`/`password_ref` secret references — never the profile) and Bot translates each
-`ptz` command into an ONVIF `ContinuousMove` (`Stop` at the deadline, on `halt` or on the e-stop), carrying
+`ptz` command into an ONVIF `ContinuousMove` (`Stop` at the deadline or on `halt`), carrying
the camera's `GetStatus` position in telemetry under `camera`.
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
-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 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
-itself: `openvibe-link stop`, or a driver's own stop button through it. Only a release on that machine
-(`openvibe-link resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`
-`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
-`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
-stopped on the robot and holds back its Clear button until the device reports the release. After the release
-(`latched: false`) Bot's own latch still waits for the owner: two keys, the robot's and the owner's.
+**A machine's own Stop report stays local.** The frame's old name is `estop_state`; a device may still send it,
+and Bot accepts it without latching, gating, clearing or auditing anything. The tray's own Stop is its author's
+program, and only that machine releases it. A `robot_id` the device is not attached to is refused with `error`
+`bot.forbidden`; nothing changes on that robot.
+
+**A local stop** is one held on the machine itself: `openvibe-link stop`, or a driver's own stop button through it.
+Only a release on that machine (`openvibe-link resume`) lifts it. The Link keeps refusing motion (`nack` `local_stop`)
+and reports the stop again. Bot does not keep that latch and never clears it; a remote stop is the plain `halt`
+command, nothing more.
### Examples
### Examples
@@ -321,13 +313,9 @@ not confirmed) or `{ "stage": "headlight_refused", "admitted": false }` (busy/ra
@@ -321,13 +313,9 @@ not confirmed) or `{ "stage": "headlight_refused", "admitted": false }` (busy/ra
```json
```json
{ "v": 1, "seq": 14, "ts": 1738065600900, "type": "status", "firmware": "adeept-0.1.0",
{ "v": 1, "seq": 14, "ts": 1738065600900, "type": "status", "firmware": "adeept-0.1.0",
- "capabilities": { "drive": { "type": "differential", "axes": ["throttle", "steer"] } }, "faults": [], "estop_latched": false }
+ "capabilities": { "drive": { "type": "differential", "axes": ["throttle", "steer"] } }, "faults": [] }
```
```
-```json
-{ "v": 1, "seq": 15, "ts": 1738065601200, "type": "estop_state", "latched": true, "by": "device", "at": "2026-09-29T19:20:01.200Z" }
-{ "v": 1, "seq": 6, "ts": 1738065601201, "type": "estop", "latched": false, "by": "usr_01J8…", "at": "2026-09-29T19:20:01.201Z" }
-```
Liveness: the server marks a device **offline** when no frame arrives within
Liveness: the server marks a device **offline** when no frame arrives within
`heartbeat_ms × 2 + 3000 ms` (2 missed beats plus the grace) and never queues a command for it; a
`heartbeat_ms × 2 + 3000 ms` (2 missed beats plus the grace) and never queues a command for it; a
@@ -425,12 +413,11 @@ forward commands without holding a socket. The HTTP gate is the same gate but no
@@ -425,12 +413,11 @@ 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.
+**Roles and sole control.** The owner, a Mod and an Operator may drive; a Mod may also switch Operators on
+or off and grant or clear sole control. Only the owner promotes an Operator to Mod or demotes a Mod, changes
+speed/mode/Viewer access, 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
@@ -439,8 +426,6 @@ authority change is in `robot_operator_audit` with the actor and subject, never
@@ -439,8 +426,6 @@ authority change is in `robot_operator_audit` with the actor and subject, never
| `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 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` |
| `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 |
| `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 |
@@ -449,9 +434,9 @@ authority change is in `robot_operator_audit` with the actor and subject, never
@@ -449,9 +434,9 @@ authority change is in `robot_operator_audit` with the actor and subject, never
| type | fields | notes |
| type | fields | notes |
|---|---|---|
|---|---|---|
| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |
| `joined` | `robot, role, profile, allowed_commands, state, operators` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive; `operators` is the roster, sole-control policy and Bot's command activity for the Operators card |
-| `robot_state` | `state{robot_id, online, 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`) |
+| `robot_state` | `state{robot_id, online, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz (a telemetry frame with `events` always); `battery` is 0..1 (a Node 0..100 reading is divided by 100, and a missing reading is `null`) |
| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},drive_at,head_at,lights_at}}, now}` | the Operators card's live view: an accepted authority change at once, and activity at most twice a second. `channel` is drive, head, lights (non-motion), button, say, display or ptz |
| `operators_state` | `operators{sole, members[{subject, role, enabled}], viewers, activity{subject{last{kind,channel,at},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 |
+| `operators_result` | `id?, action, ok, code?, reason?` | the answer to an `operators` frame; a refusal is never a silent drop |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `error` | `code, detail` | a frame or join the server refused |
| `error` | `code, detail` | a frame or join the server refused |
@@ -471,8 +456,7 @@ resumes it at once.
@@ -471,8 +456,7 @@ resumes it at once.
```json
```json
{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined",
{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined",
- "robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private",
- "estop": { "latched": false, "by": null, "at": null } },
+ "robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private" },
"role": "operator", "profile": { "id": "adeept.adr036" },
"role": "operator", "profile": { "id": "adeept.adr036" },
"allowed_commands": ["drive", "actuator", "halt"],
"allowed_commands": ["drive", "actuator", "halt"],
"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "latency_ms": 63, "battery": 0.72, "queue": null } }
"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "latency_ms": 63, "battery": 0.72, "queue": null } }
@@ -496,25 +480,20 @@ resumes it at once.
@@ -496,25 +480,20 @@ resumes it at once.
```json
```json
{ "v": 1, "seq": 3, "ts": 1738065600800, "type": "robot_state",
{ "v": 1, "seq": 3, "ts": 1738065600800, "type": "robot_state",
"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,
"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,
- "estop": { "latched": false, "by": null, "at": null }, "latency_ms": 63, "battery": 0.71,
- "telemetry": { "battery": 0.71, "voltage": 7.4 }, "status": null, "device_estop": null,
+ "latency_ms": 63, "battery": 0.71,
+ "telemetry": { "battery": 0.71, "voltage": 7.4 }, "status": null,
"queue": { "active": false, "position": 3, "turn_ends_at": null, "budget": 50, "used": 0 } } }
"queue": { "active": false, "position": 3, "turn_ends_at": null, "budget": 50, "used": 0 } } }
```
```
-```json
-{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }
-{ "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.not_an_operator`, `bot.sign_in`,
`bot.read_only`, `bot.operator_off`, `bot.sole_control`, `bot.command_not_allowed` (the role's allowlist),
`bot.read_only`, `bot.operator_off`, `bot.sole_control`, `bot.command_not_allowed` (the role's allowlist),
`bot.device_offline`, `bot.cooldown`,
`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`
`bot.command_pending` rather than a second device command or a silent wait.
`bot.command_pending` rather than a second device command or a silent wait.
-`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
+`halt` is never refused for the allowlist, a cooldown or the turn budget; it still needs a
role that may drive (a queue robot's turn holder, not someone waiting) and an online device.
role that may drive (a queue robot's turn holder, not someone waiting) and an online device.
### 2.1 `/watch` and the embeddable panel (plan T15 R9)
### 2.1 `/watch` and the embeddable panel (plan T15 R9)
@@ -531,7 +510,7 @@ owner's panel leads with **On your stream**: one button (`POST /robots/:id/on-st
@@ -531,7 +510,7 @@ owner's panel leads with **On your stream**: one button (`POST /robots/:id/on-st
same-origin) turns `embed_public` on and sends them to their Live channel's bind prompt
same-origin) turns `embed_public` on and sends them to their Live channel's bind prompt
(`<LIVE_URL>/@<username>?bind_robot=<id>`), and a **Who may drive** select (`POST /robots/:id/access`,
(`<LIVE_URL>/@<username>?bind_robot=<id>`), and a **Who may drive** select (`POST /robots/:id/access`,
`private`/`invite`/`queue`) sits beside it.
`private`/`invite`/`queue`) sits beside it.
-An unknown robot is `404`. The e-stop state is always shown; the E-stop button only to an owner or operator.
+An unknown robot is `404`. The panel shows only the controls and the optional Stop a role may use.
Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;
Its response carries `Content-Security-Policy: default-src 'self'; frame-ancestors 'self' <BOT_EMBED_ORIGINS>;
object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps
object-src 'none'; base-uri 'self'` and `Cache-Control: no-store`; every other page keeps
`frame-ancestors 'self'`. Framing takes both sides and the two lists are independent: `BOT_EMBED_ORIGINS` lets
`frame-ancestors 'self'`. Framing takes both sides and the two lists are independent: `BOT_EMBED_ORIGINS` lets
@@ -545,12 +524,12 @@ The watcher's panel joins on `wss://openvibe.bot/watch`: no credential, never a
@@ -545,12 +524,12 @@ The watcher's panel joins on `wss://openvibe.bot/watch`: no credential, never a
|---|---|---|
|---|---|---|
| `join` | `robot_id` | `joined { role: "watcher", profile, allowed_commands: [], state }` for an `embed_public` robot; otherwise (or an unknown robot) `error` `bot.not_an_operator` |
| `join` | `robot_id` | `joined { role: "watcher", profile, allowed_commands: [], state }` for an `embed_public` robot; otherwise (or an unknown robot) `error` `bot.not_an_operator` |
| `leave` | — | unsubscribe |
| `leave` | — | unsubscribe |
-| anything else (`command`, `estop`, `estop_clear`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |
+| anything else (`command`, `stop`, `halt`, …) | — | `error` `bot.read_only`; never reaches a device or the audit |
A joined watcher then gets `robot_state { state }` on every change, as a `/control` subscriber does, but always
A joined watcher then gets `robot_state { state }` on every change, as a `/control` subscriber does, but always
-the **public state**: `{ robot_id, online, estop{latched}, latency_ms, battery, telemetry }`, where
+the **public state**: `{ robot_id, online, latency_ms, battery, telemetry }`, where
`telemetry` is `null` or `{ sensors: { … } }` holding only the keys the profile's `telemetry` widgets read
`telemetry` is `null` or `{ sensors: { … } }` holding only the keys the profile's `telemetry` widgets read
-(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, who latched the e-stop, the device's ids,
+(`sensor.ultrasonic` → `ultrasonic`). Never the queue, a subject, the device's ids,
its `status` or the rest of its telemetry.
its `status` or the rest of its telemetry.
Frames over 4 KiB close the socket (1009). Over `BOT_WATCH_MAX_PER_IP` open sockets from one client address
Frames over 4 KiB close the socket (1009). Over `BOT_WATCH_MAX_PER_IP` open sockets from one client address
@@ -560,7 +539,7 @@ owner turning `embed_public` off closes the robot's watchers with 4003 too.
@@ -560,7 +539,7 @@ owner turning `embed_public` off closes the robot's watchers with 4003 too.
```json
```json
{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined", "role": "watcher", "profile": { "id": "adeept.adr036" },
{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined", "role": "watcher", "profile": { "id": "adeept.adr036" },
"allowed_commands": [],
"allowed_commands": [],
- "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "estop": { "latched": false },
+ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,
"latency_ms": 63, "battery": 0.72, "telemetry": { "sensors": { "ultrasonic": 41.5 } } } }
"latency_ms": 63, "battery": 0.72, "telemetry": { "sensors": { "ultrasonic": 41.5 } } } }
```
```
@@ -576,8 +555,7 @@ never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` o
@@ -576,8 +555,7 @@ never ignored, on both `GET /robots` and `POST /robots` (a service's `?owner=` o
the subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`
the subject it acts for). Without a token, every route but `GET /profiles`, `GET /profiles/:id`, `POST /pair`
and `POST /devices/bind` answers `401 bot.sign_in`: `GET /me`, `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
and `POST /devices/bind` answers `401 bot.sign_in`: `GET /me`, `GET|POST /robots`, `GET|PATCH|DELETE /robots/:id`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
`POST /robots/:id/pairing-code`, `GET|POST /robots/:id/operators`, `DELETE /robots/:id/operators/:subject`,
-`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/estop`,
-`POST /robots/:id/estop/clear`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same
+`GET /robots/:id/devices`, `POST /devices/:id/rotate|revoke`, `POST /robots/:id/commands`, `GET /robots/:id/audit` and `GET|POST /robots/:id/streaming`. A node token gets `403 bot.forbidden` on the same
routes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The three
routes. Both come before any lookup, so a real robot or device id answers exactly as a made-up one. The three
jobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or a
jobs routes are service-only: the same `401 bot.sign_in` without a token, `403 bot.forbidden` for a person or a
node token, and a service token is judged on `bot.job.dispatch` alone.
node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -601,12 +579,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -601,12 +579,10 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) |
| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) |
| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.link_token_required`, `403 bot.link_not_bound` |
| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.link_token_required`, `403 bot.link_not_bound` |
-| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
-| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
-| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
+| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
| `POST /robots/:id/control-ticket` | `bot.robot.control` acting for a person (`X-OV-Subject`) who is a member, or anyone on a `queue` robot | `{ ticket, robot_id, role, expires_in }`, `Cache-Control: no-store`: a Drive ticket for that person (§2), for the page that frames the panel. `403 bot.not_an_operator`; `422` without a subject |
| `POST /robots/:id/control-ticket` | `bot.robot.control` acting for a person (`X-OV-Subject`) who is a member, or anyone on a `queue` robot | `{ ticket, robot_id, role, expires_in }`, `Cache-Control: no-store`: a Drive ticket for that person (§2), for the page that frames the panel. `403 bot.not_an_operator`; `422` without a subject |
-| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |
-| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |
+| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |
+| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
| `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRestream stream; `available:false` is `not_configured` (OpenRestream unset), `not_paired` (no stream yet) or `stream_missing` (OpenRestream answered 404) |
| `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRestream stream; `available:false` is `not_configured` (OpenRestream unset), `not_paired` (no stream yet) or `stream_missing` (OpenRestream answered 404) |
| `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` |
| `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` |
@@ -662,7 +638,7 @@ POST /api/v1/robots/rob_…/commission
@@ -662,7 +638,7 @@ POST /api/v1/robots/rob_…/commission
With it, `PATCH /robots/:id` may set `max_speed` and `max_turn` above the profile default and up to the
With it, `PATCH /robots/:id` may set `max_speed` and `max_turn` above the profile default and up to the
ceiling; without it they are clamped to the default exactly as before. `max_command_ms` is never raised (it
ceiling; without it they are clamped to the default exactly as before. `max_command_ms` is never raised (it
bounds how long a command may run). Uncommissioning returns to the defaults at once and the device is sent
bounds how long a command may run). Uncommissioning returns to the defaults at once and the device is sent
-the new config. The e-stop, roles, cooldowns and turn budget are unchanged, and operators and agents can
+the new config. Roles, cooldowns and the turn budget are unchanged, and operators and agents can
never commission or raise a limit: the route answers `403 bot.forbidden` for anyone but the owner's own
never commission or raise a limit: the route answers `403 bot.forbidden` for anyone but the owner's own
person token.
person token.
@@ -678,5 +654,5 @@ queue command refused because its turn ended while the gate decided is a `refuse
@@ -678,5 +654,5 @@ queue command refused because its turn ended while the gate decided is a `refuse
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
(`kind` `streaming.media` or `streaming.live`, `value` `{ on }`, role `owner`, result `ack`); an
idempotent repeat writes none. Commissioning writes `robot.commission` and uncommissioning
idempotent repeat writes none. Commissioning writes `robot.commission` and uncommissioning
`robot.uncommission`, both as the owner. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
`robot.uncommission`, both as the owner. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
-`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
+`bot.command.refused` — small payloads of ids, kinds and
results, never a credential, a pairing code, a publish key or a WHIP URL.
results, never a credential, a pairing code, a publish key or a WHIP URL.
@@ -25,9 +25,9 @@ CREATE TABLE robots (
@@ -25,9 +25,9 @@ CREATE TABLE robots (
profile_version integer NOT NULL DEFAULT 1,
profile_version integer NOT NULL DEFAULT 1,
access_policy text NOT NULL DEFAULT 'private' CHECK (access_policy IN ('private', 'invite', 'queue')),
access_policy text NOT NULL DEFAULT 'private' CHECK (access_policy IN ('private', 'invite', 'queue')),
limits jsonb NOT NULL DEFAULT '{}', -- owner-set: max_speed, max_turn, max_command_ms, turn_ms, turn_budget, cooldown_ms, allow
limits jsonb NOT NULL DEFAULT '{}', -- owner-set: max_speed, max_turn, max_command_ms, turn_ms, turn_budget, cooldown_ms, allow
- estop_latched boolean NOT NULL DEFAULT false,
- estop_by text, -- the principal that latched it, or 'device'
- estop_at timestamptz,
+ estop_latched boolean NOT NULL DEFAULT false, -- legacy stop latch, no longer read or written; contract-migration later
+ estop_by text, -- legacy: who latched it; unused
+ estop_at timestamptz, -- legacy; unused
created_at timestamptz NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL
updated_at timestamptz NOT NULL
);
);
@@ -94,7 +94,7 @@ CREATE TABLE command_audit (
@@ -94,7 +94,7 @@ CREATE TABLE command_audit (
operator_subject text, -- the person, or the subject a service acted for
operator_subject text, -- the person, or the subject a service acted for
operator_kind text, -- user | service
operator_kind text, -- user | service
role text, -- owner | operator | viewer | null
role text, -- owner | operator | viewer | null
- kind text, -- drive | actuator | ptz | say | display | halt | estop
+ kind text, -- drive | actuator | ptz | say | display | halt
value jsonb NOT NULL DEFAULT '{}', -- a small summary (clamped axes, servo, text length), never a credential
value jsonb NOT NULL DEFAULT '{}', -- a small summary (clamped axes, servo, text length), never a credential
result text NOT NULL CHECK (result IN ('ack', 'nack', 'refused', 'expired')),
result text NOT NULL CHECK (result IN ('ack', 'nack', 'refused', 'expired')),
reason text,
reason text,
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
--
--
-- The owner's agent (plan T15 "an Actor agent can pilot a robot under the owner's grant", always through Bot): with
-- The owner's agent (plan T15 "an Actor agent can pilot a robot under the owner's grant", always through Bot): with
-- agent_control on, the owner's OpenVibe.Actor agent may send this robot commands as the owner (Actor's service token,
-- agent_control on, the owner's OpenVibe.Actor agent may send this robot commands as the owner (Actor's service token,
+-- grant bot.robot.control, X-OV-Subject = the owner). Bot's gate and limits still decide every command; the
-- device's deadman still stops it. Off for every robot until its owner turns it on. Additive only.
-- device's deadman still stops it. Off for every robot until its owner turns it on. Additive only.
ALTER TABLE robots ADD COLUMN agent_control boolean NOT NULL DEFAULT false;
ALTER TABLE robots ADD COLUMN agent_control boolean NOT NULL DEFAULT false;
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
-- phase: expand
-- phase: expand
-- LOOI Operators card (Maticus's 2026-11-12 spec): a Mod role between the owner and operators, a per-person
-- 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
-- on/off switch that the command gate reads before it forwards anything, sole control for one subject, and an
+-- audit of who changed authority. A Stop is now one ordinary control (a halt command), not a latch; the old
+-- robots.estop_* columns are left for a later contract migration and are no longer read or written.
ALTER TABLE robot_operators DROP CONSTRAINT IF EXISTS robot_operators_role_check;
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 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;
ALTER TABLE robot_operators ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true;
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS robot_operator_control (
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS robot_operator_control (
);
);
-- Authority changes: who granted sole control, switched someone on or off, promoted or demoted a Mod, or
-- Authority changes: who granted sole control, switched someone on or off, promoted or demoted a Mod, or
+-- granted or cleared sole control. Subjects and roles only: no command values, tokens or secrets.
CREATE TABLE IF NOT EXISTS robot_operator_audit (
CREATE TABLE IF NOT EXISTS robot_operator_audit (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
robot_id text NOT NULL,
robot_id text NOT NULL,
@@ -186,7 +186,7 @@
@@ -186,7 +186,7 @@
if (doc.body) observer.observe(doc.body);
if (doc.body) observer.observe(doc.body);
observer.observe(html);
observer.observe(html);
}
}
- // Over a picture the body never changes size, but its controls do: Drive gives way to the e-stop, a line
+ // Over a picture the body never changes size, but its controls do: Drive gives way to the panel, a line
// appears under them. Their boxes are the page's clip, so a control shown or hidden is sent again.
// appears under them. Their boxes are the page's clip, so a control shown or hidden is sent again.
let watcher = null;
let watcher = null;
if (view() === 'overlay' && typeof win.MutationObserver === 'function' && doc.body) {
if (view() === 'overlay' && typeof win.MutationObserver === 'function' && doc.body) {
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at
/* The signed-in pages' one sheet (no build step). A dark control-room look, mobile-first: every control at
- least 44 px, the e-stop always in view, and the panel laid out for a phone held either way. */
+ least 44 px, and the panel laid out for a phone held either way. */
/* The embeddable panel takes the page's tokens (public/embed-frame.js): --bg-primary, --bg-card, --text-primary,
/* The embeddable panel takes the page's tokens (public/embed-frame.js): --bg-primary, --bg-card, --text-primary,
--text-secondary, --accent, --accent-strong, --on-accent-strong, --border and --color-scheme. Without a message it
--text-secondary, --accent, --accent-strong, --on-accent-strong, --border and --color-scheme. Without a message it
follows prefers-color-scheme with the network's default pair (Vibe dark, cloud light) below. */
follows prefers-color-scheme with the network's default pair (Vibe dark, cloud light) below. */
@@ -82,25 +82,8 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
@@ -82,25 +82,8 @@ input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width
.crumb { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crumb { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }
.crumb::before { content: "/"; margin-right: 0.75rem; opacity: 0.5; }
-/* ── The e-stop: always in view; clear is calm, latched is unmistakable ──────────────────────────────── */
-.estop-banner { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
-.estop-bar { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem max(1rem, env(safe-area-inset-right)) 0.6rem max(1rem, env(safe-area-inset-left)); }
-.estop-state { flex: 1; display: flex; align-items: center; gap: 0.5rem; margin: 0; font-weight: 600; min-width: 0; }
-.estop-note { font-weight: 400; color: inherit; opacity: 0.85; }
-.estop-dot { width: 0.7rem; height: 0.7rem; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 4px rgba(63, 185, 80, 0.18); flex: none; }
-.estop {
- min-height: 56px; min-width: 112px; padding: 0 1.1rem; border: 2px solid #ff8a8d; border-radius: 14px;
- background: var(--fill-danger); color: #fff; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase;
- box-shadow: 0 4px 0 var(--danger-deep), 0 6px 16px rgba(229, 72, 77, 0.35);
-}
-.estop .icon { width: 1.5em; height: 1.5em; stroke-width: 2.4; }
-.estop:active:not(:disabled) { transform: translateY(3px); box-shadow: 0 1px 0 var(--danger-deep); }
-.estop-clear { background: #fff; color: #111; border-color: #fff; font-weight: 700; }
-.estop-banner[data-latched="true"] { background: var(--danger-deep); border-bottom-color: var(--danger); }
-.estop-banner[data-latched="true"] .crumb, .estop-banner[data-latched="true"] .brand b { color: #ffd1d2; }
-.estop-banner[data-latched="true"] .estop-dot { background: #fff; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.25); animation: pulse 1.2s ease-in-out infinite; }
-.estop-banner[data-latched="true"] .estop { background: #7a1014; border-color: #ff8a8d; box-shadow: none; }
-@keyframes pulse { 50% { opacity: 0.35; } }
+/* ── The page header stays in view while the controls scroll under it ──────────────────────────────── */
+.panel-top { position: sticky; top: 0; z-index: 10; background: color-mix(in srgb, var(--bg) 94%, transparent); border-bottom: 1px solid var(--line); backdrop-filter: blur(8px); }
/* ── The panel ──────────────────────────────────────────────────────────────────────────────────────── */
/* ── The panel ──────────────────────────────────────────────────────────────────────────────────────── */
main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); max-width: 72rem; margin: 0 auto; }
main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left)); max-width: 72rem; margin: 0 auto; }
@@ -127,7 +110,18 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -127,7 +110,18 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.widgets[data-ov-arranged] > [data-ov-part] { overflow: auto; }
.widgets[data-ov-arranged] > [data-ov-part] { overflow: auto; }
#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stick { width: 100%; height: 100%; }
#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stick { width: 100%; height: 100%; }
:is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .drive-pad { display: contents; }
:is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .drive-pad { display: contents; }
-#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: 50%; }
+#panel :is(.widgets[data-ov-arranged], .widgets.ov-arrange-surface) .stop { width: 100%; height: 100%; border-radius: inherit; }
+/* Stop is one plain control: a small button with a square glyph, not a red bar. */
+.stop { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; min-height: 44px; min-width: 44px;
+ padding: 0 0.75rem; border: 1px solid var(--line); border-radius: 10px; background: var(--raised); color: var(--text);
+ font: inherit; font-size: 0.9rem; font-weight: 500; cursor: pointer; }
+.stop .icon { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; }
+.stop:hover:not(:disabled) { border-color: color-mix(in srgb, var(--line) 65%, var(--text) 35%); }
+.stop:active:not(:disabled) { transform: translateY(1px); }
+.stop:disabled { opacity: 0.5; }
+.widget-stop { display: flex; justify-content: flex-start; }
+.widget-stop .stop { width: auto; height: auto; }
+
/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so the
/* Widgets: one column on a phone held upright. The camera comes first and the drive pad right under it, so the
picture and the controls are on screen together; the readouts and the other controls follow. */
picture and the controls are on screen together; the readouts and the other controls follow. */
@@ -183,7 +177,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -183,7 +177,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.axis .hold .icon { width: 1.6em; height: 1.6em; }
.axis .hold .icon { width: 1.6em; height: 1.6em; }
.axis-name { color: var(--muted); font-size: 0.8rem; text-align: center; }
.axis-name { color: var(--muted); font-size: 0.8rem; text-align: center; }
.hold.held { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
.hold.held { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
-.drive-pad .stop { width: 100%; max-width: 22rem; min-height: 52px; font-weight: 600; }
.hint { margin: 0.9rem 0 0; color: var(--muted); font-size: 0.8rem; text-align: center; }
.hint { margin: 0.9rem 0 0; color: var(--muted); font-size: 0.8rem; text-align: center; }
.pad-hint { display: none; }
.pad-hint { display: none; }
#panel[data-gamepad="true"] .pad-hint { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text); }
#panel[data-gamepad="true"] .pad-hint { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--text); }
@@ -259,12 +252,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -259,12 +252,6 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }
.ops-switch input:checked + .ops-switch-track::after { transform: translateX(1.15rem); }
.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.ops-switch input:focus-visible + .ops-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }
.ops-switch input:disabled + .ops-switch-track { opacity: 0.55; }
-.ops-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; }
.ops-footer { margin: 0.75rem 0 0; color: var(--muted); font-size: 0.78rem; }
@media (max-width: 30rem) {
@media (max-width: 30rem) {
.ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }
.ops-row { grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.25rem 0.55rem; }
@@ -278,11 +265,11 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -278,11 +265,11 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.stat-label { color: var(--muted); font-size: 0.72rem; }
.stat-label { color: var(--muted); font-size: 0.72rem; }
[data-quality="good"] { color: var(--ok); } [data-quality="fair"] { color: var(--warn); } [data-quality="poor"] { color: var(--danger-text, var(--danger)); }
[data-quality="good"] { color: var(--ok); } [data-quality="fair"] { color: var(--warn); } [data-quality="poor"] { color: var(--danger-text, var(--danger)); }
-/* On a phone the picture never scrolls away while driving: the camera stays pinned under the e-stop bar (its height,
- --banner-h, is measured by public/panel.js) and everything else scrolls beneath it. Upright, it takes at most a
+/* On a phone the picture never scrolls away while driving: the camera stays pinned under the page header (its height,
+ --top-h, is measured by public/panel.js) and everything else scrolls beneath it. Upright, it takes at most a
third of the screen, and several cameras sit side by side and swipe instead of stacking. */
third of the screen, and several cameras sit side by side and swipe instead of stacking. */
@media (max-width: 43.99rem) and (orientation: portrait), (orientation: landscape) and (max-height: 32rem) {
@media (max-width: 43.99rem) and (orientation: portrait), (orientation: landscape) and (max-height: 32rem) {
- .widget-camera, .widget-video_click { position: sticky; top: calc(var(--banner-h, 6.5rem) + 0.4rem); z-index: 6; align-self: start;
+ .widget-camera, .widget-video_click { position: sticky; top: calc(var(--top-h, 4rem) + 0.4rem); z-index: 6; align-self: start;
box-shadow: 0 0.75rem 1.5rem -0.75rem rgba(0, 0, 0, 0.85); }
box-shadow: 0 0.75rem 1.5rem -0.75rem rgba(0, 0, 0, 0.85); }
}
}
@media (max-width: 43.99rem) and (orientation: portrait) {
@media (max-width: 43.99rem) and (orientation: portrait) {
@@ -293,8 +280,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -293,8 +280,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
}
}
/* Phone on its side: the camera on the left, the drive pad on the right, both in view without scrolling. */
/* Phone on its side: the camera on the left, the drive pad on the right, both in view without scrolling. */
@media (orientation: landscape) and (max-height: 32rem) {
@media (orientation: landscape) and (max-height: 32rem) {
- .estop-bar { padding-top: 0.3rem; padding-bottom: 0.35rem; } .topbar { min-height: 2.5rem; }
- .estop { min-height: 48px; }
+ .topbar { min-height: 2.5rem; }
.panel-head h1 { font-size: 1.15rem; display: inline; margin-right: 0.5rem; } .meta { display: inline-flex; }
.panel-head h1 { font-size: 1.15rem; display: inline; margin-right: 0.5rem; } .meta { display: inline-flex; }
.widgets { grid-template-columns: minmax(0, 1fr) minmax(14rem, 38%); align-items: start; }
.widgets { grid-template-columns: minmax(0, 1fr) minmax(14rem, 38%); align-items: start; }
.widget-camera { grid-column: 1; grid-row: span 3; order: 0; }
.widget-camera { grid-column: 1; grid-row: span 3; order: 0; }
@@ -550,7 +536,7 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:
@@ -550,7 +536,7 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:
.bot-pill[data-state="online"] .dot { background: var(--ok); }
.bot-pill[data-state="online"] .dot { background: var(--ok); }
.bot-pill[data-state="turn"] .dot { background: var(--accent); }
.bot-pill[data-state="turn"] .dot { background: var(--accent); }
.bot-pill[data-state="busy"] .dot { background: var(--warn); }
.bot-pill[data-state="busy"] .dot { background: var(--warn); }
-.bot-pill[data-state="offline"] .dot, .bot-pill[data-state="estop"] .dot { background: var(--danger); }
+.bot-pill[data-state="offline"] .dot { background: var(--danger); }
/* The first stick, 132 px at the bottom left. The scrim is the circle; the stick itself is transparent. */
/* The first stick, 132 px at the bottom left. The scrim is the circle; the stick itself is transparent. */
.bot-stick {
.bot-stick {
@@ -572,16 +558,14 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:
@@ -572,16 +558,14 @@ body.bot-mode-compact main#panel.bot-surface { margin: 0; padding: 0; max-width:
margin: 0; padding: 0; accent-color: var(--accent);
margin: 0; padding: 0; accent-color: var(--accent);
}
}
-/* The e-stop, 44 px at the top right, and the way to the full panel under it. */
-.bot-estop, .bot-more {
- position: absolute; right: calc(16px + env(safe-area-inset-right)); width: 44px; height: 44px; min-width: 44px; min-height: 44px;
- padding: 0; border-radius: 50%; justify-content: center; font-size: 15px; font-weight: 700; letter-spacing: 0.04em;
+/* The way to the full panel, 44 px at the top right. */
+.bot-more {
+ position: absolute; right: calc(16px + env(safe-area-inset-right)); top: calc(16px + env(safe-area-inset-top));
+ width: 44px; height: 44px; min-width: 44px; min-height: 44px; padding: 0; border-radius: 50%;
+ justify-content: center; font-size: 16px; font-weight: 700; letter-spacing: 0.04em; line-height: 1;
}
}
-.bot-estop { top: calc(16px + env(safe-area-inset-top)); color: var(--danger); }
-.bot-estop .icon { width: 22px; height: 22px; stroke-width: 2.4; }
-.bot-more { top: calc(68px + env(safe-area-inset-top)); font-size: 16px; line-height: 1; }
-/* One row for a narrow card: the pill, a 96 px stick, the slider across, the e-stop and more. */
+/* One row for a narrow card: the pill, a 96 px stick, the slider across and more. */
.bot-compact {
.bot-compact {
display: flex; align-items: center; gap: 8px; min-height: 64px; padding: 8px 10px; border-radius: 16px;
display: flex; align-items: center; gap: 8px; min-height: 64px; padding: 8px 10px; border-radius: 16px;
background: rgba(8, 12, 22, 0.42); border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(8, 12, 22, 0.42); border: 1px solid rgba(255, 255, 255, 0.14);
@@ -592,12 +576,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
@@ -592,12 +576,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
.bot-compact .bot-stick { position: static; flex: none; width: 96px; height: 96px; border-radius: 50%; }
.bot-compact .bot-stick { position: static; flex: none; width: 96px; height: 96px; border-radius: 50%; }
.bot-compact .bot-slider { position: static; flex: none; width: 108px; height: 44px; border-radius: 22px; padding: 0 8px; }
.bot-compact .bot-slider { position: static; flex: none; width: 108px; height: 44px; border-radius: 22px; padding: 0 8px; }
.bot-compact .bot-slider input[type="range"] { writing-mode: initial; direction: ltr; width: 92px; height: 28px; }
.bot-compact .bot-slider input[type="range"] { writing-mode: initial; direction: ltr; width: 92px; height: 28px; }
-.bot-compact .bot-estop, .bot-compact .bot-more { position: static; flex: none; width: 44px; height: 44px; }
+.bot-compact .bot-more { position: static; flex: none; width: 44px; height: 44px; }
/* ── The overlay over a short picture, and a stick you can see (Maticus, 2026-11-10: the joystick and head slider over
/* ── The overlay over a short picture, and a stick you can see (Maticus, 2026-11-10: the joystick and head slider over
the stream "would be great for phone"). Live gives the framed page the picture less the player's bar: about 170 px
the stream "would be great for phone"). Live gives the framed page the picture less the player's bar: about 170 px
tall on a phone held upright, 220 px on its side. The controls take their size from the frame's own height, and the
tall on a phone held upright, 220 px on its side. The controls take their size from the frame's own height, and the
- e-stop and the way to the panel stand beside the slider instead of over it, so nothing overlaps at any size. ───── */
+ way to the panel stands beside the slider instead of over it, so nothing overlaps at any size. ───── */
.bot-overlay { --bot-sz: 132px; --bot-gap: 16px; }
.bot-overlay { --bot-sz: 132px; --bot-gap: 16px; }
.bot-stick, .bot-slider { width: var(--bot-sz); height: var(--bot-sz); }
.bot-stick, .bot-slider { width: var(--bot-sz); height: var(--bot-sz); }
.bot-slider { width: 44px; }
.bot-slider { width: 44px; }
@@ -607,9 +591,7 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
@@ -607,9 +591,7 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
.bot-pill { top: calc(var(--bot-gap) + env(safe-area-inset-top)); left: calc(12px + env(safe-area-inset-left)); height: 32px; max-width: calc(100% - 132px); }
.bot-pill { top: calc(var(--bot-gap) + env(safe-area-inset-top)); left: calc(12px + env(safe-area-inset-left)); height: 32px; max-width: calc(100% - 132px); }
.bot-stick { left: calc(12px + env(safe-area-inset-left)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }
.bot-stick { left: calc(12px + env(safe-area-inset-left)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }
.bot-slider { right: calc(12px + env(safe-area-inset-right)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }
.bot-slider { right: calc(12px + env(safe-area-inset-right)); bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); }
- .bot-estop, .bot-more { right: calc(12px + 44px + 8px + env(safe-area-inset-right)); }
- .bot-estop { top: calc(var(--bot-gap) + env(safe-area-inset-top)); }
- .bot-more { top: calc(var(--bot-gap) + 52px + env(safe-area-inset-top)); }
+ .bot-more { right: calc(12px + env(safe-area-inset-right)); top: calc(var(--bot-gap) + env(safe-area-inset-top)); }
}
}
/* The stick: a frosted base, a light thumb that reads on any picture, four soft direction marks; pressed, the thumb
/* The stick: a frosted base, a light thumb that reads on any picture, four soft direction marks; pressed, the thumb
takes the accent and a halo. */
takes the accent and a halo. */
@@ -624,21 +606,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
@@ -624,21 +606,12 @@ body.bot-mode-compact .bot-compact .bot-control { background: transparent; borde
.bot-stick .stick.active .stick-knob { background: radial-gradient(circle at 38% 32%, #dce8ff, var(--accent) 72%); border-color: rgba(255, 255, 255, 0.9);
.bot-stick .stick.active .stick-knob { background: radial-gradient(circle at 38% 32%, #dce8ff, var(--accent) 72%); border-color: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 28%, transparent), 0 6px 16px rgba(0, 0, 0, 0.5); }
box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 28%, transparent), 0 6px 16px rgba(0, 0, 0, 0.5); }
.bot-stick:has(.stick.active) { border-color: color-mix(in srgb, var(--accent) 55%, rgba(255, 255, 255, 0.14)); }
.bot-stick:has(.stick.active) { border-color: color-mix(in srgb, var(--accent) 55%, rgba(255, 255, 255, 0.14)); }
-/* The e-stop reads as one: a red ring and glyph on the same scrim. */
-.bot-estop { border-color: color-mix(in srgb, var(--danger) 70%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 18%, transparent); }
-.bot-pill[data-state="estop"] { border-color: color-mix(in srgb, var(--danger) 55%, rgba(255, 255, 255, 0.14)); }
/* ── The panel in a frame, compact at any size (ask 353, 2026-11-11: on a phone the framed panel was 950 px of
/* ── The panel in a frame, compact at any size (ask 353, 2026-11-11: on a phone the framed panel was 950 px of
banner, header, pills, a 240 px stick and four cards, and it pushed the stream's chat off the screen) ──────────
banner, header, pills, a 240 px stick and four cards, and it pushed the stream's chat off the screen) ──────────
A frame's height follows its content (embed-frame.js), so nothing here may depend on the frame's height or
A frame's height follows its content (embed-frame.js), so nothing here may depend on the frame's height or
orientation: those media queries above are for the panel's own page. The frame's width decides the columns. */
orientation: those media queries above are for the panel's own page. The frame's width decides the columns. */
html.embed-page[data-bot-view="panel"] main#panel:not(.bot-surface) { padding: 0.6rem 0.7rem calc(0.7rem + env(safe-area-inset-bottom)); }
html.embed-page[data-bot-view="panel"] main#panel:not(.bot-surface) { padding: 0.6rem 0.7rem calc(0.7rem + env(safe-area-inset-bottom)); }
-/* The e-stop bar only when it says something (latched) or holds the button (you drive): clear and watching, the
- state is the dot in the head's pill. */
-html.embed-page[data-bot-view="panel"] .estop-banner[data-latched="false"]:not(:has(.estop:not([hidden]))) { display: none; }
-html.embed-page[data-bot-view="panel"] .estop-bar { padding: 0.35rem 0.7rem; gap: 0.5rem; }
-html.embed-page[data-bot-view="panel"] .estop-state { font-size: 0.9rem; }
-html.embed-page[data-bot-view="panel"] .estop { min-height: 44px; min-width: 0; padding: 0 0.85rem; border-radius: 12px; font-size: 0.85rem; box-shadow: 0 3px 0 var(--danger-deep); }
/* The head: one row with the name, its state, Drive and the way to the robot's own page. */
/* The head: one row with the name, its state, Drive and the way to the robot's own page. */
html.embed-page[data-bot-view="panel"] #panel > .panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; margin: 0 0 0.6rem; }
html.embed-page[data-bot-view="panel"] #panel > .panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.35rem 0.5rem; margin: 0 0 0.6rem; }
html.embed-page[data-bot-view="panel"] .panel-head .ovk-page-head { margin: 0; flex: 0 1 auto; min-width: 0; padding: 0; }
html.embed-page[data-bot-view="panel"] .panel-head .ovk-page-head { margin: 0; flex: 0 1 auto; min-width: 0; padding: 0; }
@@ -696,7 +669,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }
@@ -696,7 +669,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }
.bot-stick .stick[aria-disabled="true"] { opacity: 0.55; }
.bot-stick .stick[aria-disabled="true"] { opacity: 0.55; }
.bot-slider input:disabled { opacity: 0.55; }
.bot-slider input:disabled { opacity: 0.55; }
-/* A watcher's Drive: where the e-stop goes once they have the controls; and the page's answer under it. */
+/* A watcher's Drive: where it goes once they have the controls; and the page's answer under it. */
.bot-drive {
.bot-drive {
position: absolute; top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right));
position: absolute; top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right));
height: 44px; min-height: 44px; padding: 0 14px 0 12px; gap: 6px; border-radius: 22px; font-size: 13px; font-weight: 700;
height: 44px; min-height: 44px; padding: 0 14px 0 12px; gap: 6px; border-radius: 22px; font-size: 13px; font-weight: 700;
@@ -713,7 +686,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }
@@ -713,7 +686,7 @@ html.embed-page[data-bot-view="panel"] .hint { display: block; }
.bot-overlay .bot-notice { bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); max-width: calc(100% - 2 * var(--bot-sz) - 24px); }
.bot-overlay .bot-notice { bottom: calc(var(--bot-gap) + env(safe-area-inset-bottom)); max-width: calc(100% - 2 * var(--bot-sz) - 24px); }
}
}
-/* The one row, as a small remote: the stick at the left, the name over the slider, Drive or the e-stop over "more".
+/* The one row, as a small remote: the stick at the left, the name over the slider, Drive over "more".
A wide frame puts it all on one line. */
A wide frame puts it all on one line. */
body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
.bot-compact {
.bot-compact {
@@ -726,7 +699,7 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
@@ -726,7 +699,7 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
.bot-compact .bot-slider { grid-area: slider; width: 100%; max-width: 260px; height: 44px; padding: 0 10px; }
.bot-compact .bot-slider { grid-area: slider; width: 100%; max-width: 260px; height: 44px; padding: 0 10px; }
.bot-compact .bot-slider .bot-widget, .bot-compact .bot-slider label { width: 100%; }
.bot-compact .bot-slider .bot-widget, .bot-compact .bot-slider label { width: 100%; }
.bot-compact .bot-slider input[type="range"] { width: 100%; height: 28px; }
.bot-compact .bot-slider input[type="range"] { width: 100%; height: 28px; }
-.bot-compact :is(.bot-estop, .bot-drive) { grid-area: act; justify-self: end; }
+.bot-compact .bot-drive { grid-area: act; justify-self: end; }
.bot-compact .bot-more { grid-area: more; justify-self: end; }
.bot-compact .bot-more { grid-area: more; justify-self: end; }
.bot-compact .bot-drive { position: static; }
.bot-compact .bot-drive { position: static; }
.bot-compact .bot-notice { grid-area: note; position: static; transform: none; max-width: none; text-align: left; }
.bot-compact .bot-notice { grid-area: note; position: static; transform: none; max-width: none; text-align: left; }
@@ -735,14 +708,13 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
@@ -735,14 +708,13 @@ body.bot-mode-compact main#panel.bot-surface { display: block; padding: 0; }
.bot-compact[data-no-slider] { grid-template-areas: "pill stick stick act more" "note note note note note"; }
.bot-compact[data-no-slider] { grid-template-areas: "pill stick stick act more" "note note note note note"; }
}
}
/* The row sits in the host's own card (Live's robot card): no second box, and the page's colours, so it reads on a
/* The row sits in the host's own card (Live's robot card): no second box, and the page's colours, so it reads on a
- light card as well as a dark one. Drive keeps its fill; the e-stop its red ring. */
+ light card as well as a dark one. Drive keeps its fill. */
.bot-compact { background: transparent; border: 0; border-radius: 0; -webkit-backdrop-filter: none; backdrop-filter: none; padding: 6px 8px; }
.bot-compact { background: transparent; border: 0; border-radius: 0; -webkit-backdrop-filter: none; backdrop-filter: none; padding: 6px 8px; }
body.bot-mode-compact .bot-compact .bot-control { color: var(--text); }
body.bot-mode-compact .bot-compact .bot-control { color: var(--text); }
body.bot-mode-compact .bot-compact .bot-pill-state { color: var(--muted); }
body.bot-mode-compact .bot-compact .bot-pill-state { color: var(--muted); }
body.bot-mode-compact .bot-compact .bot-stick { background: color-mix(in srgb, var(--text) 6%, transparent); border-color: var(--line); }
body.bot-mode-compact .bot-compact .bot-stick { background: color-mix(in srgb, var(--text) 6%, transparent); border-color: var(--line); }
body.bot-mode-compact .bot-compact .bot-stick .stick-ring { border-color: var(--line); }
body.bot-mode-compact .bot-compact .bot-stick .stick-ring { border-color: var(--line); }
-body.bot-mode-compact .bot-compact :is(.bot-more, .bot-estop) { background: color-mix(in srgb, var(--text) 7%, transparent); }
-body.bot-mode-compact .bot-compact .bot-estop { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 70%, transparent); }
+body.bot-mode-compact .bot-compact .bot-more { background: color-mix(in srgb, var(--text) 7%, transparent); }
body.bot-mode-compact .bot-compact .bot-drive { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
body.bot-mode-compact .bot-compact .bot-drive { background: var(--fill-accent); border-color: var(--fill-accent); color: var(--on-fill-accent); }
body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }
body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background: color-mix(in srgb, var(--warn) 10%, transparent); }
@@ -784,8 +756,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -784,8 +756,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
hand, size and readouts are now the person's ui.layout@1. ─── */
hand, size and readouts are now the person's ui.layout@1. ─── */
#panel:not(.bot-surface) .widget-drive { position: relative; }
#panel:not(.bot-surface) .widget-drive { position: relative; }
#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
#panel:not(.bot-surface) .drive-pad { grid-template-columns: auto auto; justify-content: center; align-items: center; gap: 0.75rem 1.4rem; }
-#panel:not(.bot-surface) .drive-pad > :not(.stick):not(.stop) { grid-column: 1 / -1; }
-#panel:not(.bot-surface)[data-hand="left"] .drive-pad .stop { order: -1; }
+#panel:not(.bot-surface) .drive-pad > :not(.stick) { grid-column: 1 / -1; }
#panel:not(.bot-surface) .stick {
#panel:not(.bot-surface) .stick {
--stick: clamp(8.5rem, 44vw, 12.5rem);
--stick: clamp(8.5rem, 44vw, 12.5rem);
width: var(--stick);
width: var(--stick);
@@ -813,20 +784,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -813,20 +784,6 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
#panel:not(.bot-surface) .stick.active .stick-knob { background: radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.4), transparent 42%), radial-gradient(circle at 50% 55%, #6ea8ff, var(--accent) 75%); border-color: #9cc2ff; box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent), 0 10px 22px rgba(0, 0, 0, 0.45); }
#panel:not(.bot-surface) .stick.active .stick-knob { background: radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.4), transparent 42%), radial-gradient(circle at 50% 55%, #6ea8ff, var(--accent) 75%); border-color: #9cc2ff; box-shadow: 0 0 0 6px color-mix(in srgb, var(--accent) 22%, transparent), 0 10px 22px rgba(0, 0, 0, 0.45); }
#panel:not(.bot-surface) .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }
#panel:not(.bot-surface) .stick[aria-disabled="true"] { opacity: 0.5; filter: saturate(0.4); }
-#panel:not(.bot-surface) .drive-pad .stop {
- width: calc(var(--stick, 11rem) * 0.46); height: calc(var(--stick, 11rem) * 0.46); min-width: 4.25rem; min-height: 4.25rem; max-width: 6rem; max-height: 6rem;
- padding: 0; border-radius: 50%; display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.2rem;
- font-size: 0.78rem; font-weight: 700; letter-spacing: 0.02em; color: var(--text);
- background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 22%, var(--surface)), color-mix(in srgb, var(--fill-danger) 10%, var(--bg)));
- border: 2px solid color-mix(in srgb, var(--danger) 70%, transparent);
- box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 10%, transparent), 0 6px 14px rgba(0, 0, 0, 0.35);
- transition: transform 0.12s ease, background 0.15s ease, box-shadow 0.15s ease;
-}
-#panel:not(.bot-surface) .drive-pad .stop .icon { width: 1.35em; height: 1.35em; fill: var(--danger); stroke: none; }
-#panel:not(.bot-surface) .drive-pad .stop:hover:not(:disabled) { background: radial-gradient(circle at 50% 35%, color-mix(in srgb, var(--fill-danger) 36%, var(--surface)), color-mix(in srgb, var(--fill-danger) 18%, var(--bg))); }
-#panel:not(.bot-surface) .drive-pad .stop:active:not(:disabled) { transform: scale(0.94); }
-#panel:not(.bot-surface) .drive-pad .stop:disabled { opacity: 0.5; filter: saturate(0.5); }
-
/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */
/* A slider: its name and value on one line, − the track + on the next; the track fills to the value. */
#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
#panel:not(.bot-surface) .control.slider { display: grid; gap: 0.4rem; align-items: stretch; }
#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
#panel:not(.bot-surface) .slider-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; margin: 0; }
@@ -869,7 +826,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
@@ -869,7 +826,7 @@ body.bot-mode-compact .bot-compact .bot-notice { color: var(--warn); background:
#panel:not(.bot-surface) .notice[data-device-fault], #panel:not(.bot-surface) .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }
#panel:not(.bot-surface) .notice[data-device-fault], #panel:not(.bot-surface) .notice[data-notice] { display: flex; align-items: center; gap: 0.5rem; font-size: 0.88rem; line-height: 1.35; }
#panel:not(.bot-surface) .notice[hidden] { display: none; }
#panel:not(.bot-surface) .notice[hidden] { display: none; }
-@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .drive-pad .stop, #panel:not(.bot-surface) .stick-dirs { transition: none; } }
+@media (prefers-reduced-motion: reduce) { #panel:not(.bot-surface) .stick, #panel:not(.bot-surface) .stick-dirs { transition: none; } }
/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */
/* A wide panel lays the cards in columns: each card is as tall as what it holds (the battery was a tall empty box). */
#panel:not(.bot-surface) .widgets { align-items: start; }
#panel:not(.bot-surface) .widgets { align-items: start; }
@@ -15,14 +15,14 @@
@@ -15,14 +15,14 @@
* keyboard arrows/WASD drive, Space stops; Q/E tilt the head where the robot has one (else they rotate)
* keyboard arrows/WASD drive, Space stops; Q/E tilt the head where the robot has one (else they rotate)
* gamepad polled on requestAnimationFrame only while a pad is connected and the page has focus: left stick
* gamepad polled on requestAnimationFrame only while a pad is connected and the page has focus: left stick
* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
* (and d-pad) drive, right stick rotates when the drive has a rotation axis, A = the first tone,
- * B = stop, Start = e-stop; gamepaddisconnected releases it all
+ * B = Stop; gamepaddisconnected releases it all
* latency round trip from a command's id to its result on /control (rolling median) + telemetry age
* latency round trip from a command's id to its result on /control (rolling median) + telemetry age
* camera each tile the server marked with a live OpenRestream session's signaling URL plays it: the
* camera each tile the server marked with a live OpenRestream session's signaling URL plays it: the
* mediasoup-client viewer flow OpenVibe.Live runs, reconnecting with backoff; a tile with no
* mediasoup-client viewer flow OpenVibe.Live runs, reconnecting with backoff; a tile with no
* URL is left as the server drew it
* URL is left as the server drew it
*
*
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
* A watcher (data-role="watcher": the embed's anonymous, read-only view) joins on /watch instead and only paints
- * the state it is sent: no command, e-stop or clear frame is ever sent from it. Its Drive button opens
+ * the state it is sent: no command or stop frame is ever sent from it. Its Drive button opens
* /panel/:id/drive, a first-party popup with the openvibe.bot session the frame cannot see; the popup posts back a
* /panel/:id/drive, a first-party popup with the openvibe.bot session the frame cannot see; the popup posts back a
* one-time ticket (same origin only), and the panel re-joins on /control offering it as a subprotocol. Each join
* one-time ticket (same origin only), and the panel re-joins on /control offering it as a subprotocol. Each join
* made that way carries the next ticket, so a dropped socket reconnects; when none is left it watches again.
* made that way carries the next ticket, so a dropped socket reconnects; when none is left it watches again.
@@ -35,13 +35,12 @@
@@ -35,13 +35,12 @@
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const wsUrl = (path = '/control') => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`;
const wsUrl = (path = '/control') => `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${path}`;
- // The camera pins under the sticky e-stop bar on phones (public/panel.css): the bar's height, kept current as it
- // wraps, rotates or shows the latched note.
- const banner = $('[data-estop-banner]');
- if (banner) {
- const setTop = () => document.documentElement.style.setProperty('--banner-h', `${banner.offsetHeight}px`);
+ // The camera pins under the page header on phones (public/panel.css): keep its height in --top-h.
+ const pageTop = $('.panel-top');
+ if (pageTop) {
+ const setTop = () => document.documentElement.style.setProperty('--top-h', `${pageTop.offsetHeight}px`);
setTop();
setTop();
- if ('ResizeObserver' in window) new ResizeObserver(setTop).observe(banner); else window.addEventListener('resize', setTop);
+ if ('ResizeObserver' in window) new ResizeObserver(setTop).observe(pageTop); else window.addEventListener('resize', setTop);
}
}
for (const b of $$('[data-copy]')) {
for (const b of $$('[data-copy]')) {
@@ -445,7 +444,7 @@
@@ -445,7 +444,7 @@
el.addEventListener('contextmenu', (e) => e.preventDefault());
el.addEventListener('contextmenu', (e) => e.preventDefault());
}
}
function releaseAll() {
function releaseAll() {
- // Held slider repeats stop too (the e-stop, blur, a hidden page): the slider code listens for this.
+ // Held slider repeats stop too (Stop, blur, a hidden page): the slider code listens for this.
try { window.dispatchEvent(new Event('ov-bot-release-all')); } catch { /* */ }
try { window.dispatchEvent(new Event('ov-bot-release-all')); } catch { /* */ }
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
for (const [kind, h] of [...holds]) for (const source of [...h.src.keys()]) {
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
if (source instanceof Element) releaseButton(source); else holdRelease(kind, source);
@@ -641,7 +640,7 @@
@@ -641,7 +640,7 @@
// each one step on from the slider's own value, clamped to its range, and the slider moves with it, so a drag after the
// each one step on from the slider's own value, clamped to its range, and the slider moves with it, so a drag after the
// keys starts where the head is going (the slider sends absolute targets: no jump). Release sends nothing: head 0 is
// keys starts where the head is going (the slider sends absolute targets: no jump). Release sends nothing: head 0 is
// Center, not stop, and the last command's lease bounds what is left of the move. Q and E together: nothing. Blur, a
// Center, not stop, and the last command's lease bounds what is left of the move. Q and E together: nothing. Blur, a
- // hidden page, pagehide, STOP, the e-stop and a lost socket stop it (releaseAll and stopDrive).
+ // hidden page, pagehide, Stop and a lost socket stop it (releaseAll and stopDrive).
const HEAD_KEYS = { KeyQ: 1, KeyE: -1 };
const HEAD_KEYS = { KeyQ: 1, KeyE: -1 };
const headSlider = () => $('[data-actuator="head"]');
const headSlider = () => $('[data-actuator="head"]');
const head = { down: new Set(), timer: null };
const head = { down: new Set(), timer: null };
@@ -736,14 +735,11 @@
@@ -736,14 +735,11 @@
document.addEventListener('keyup', onKeyUp);
document.addEventListener('keyup', onKeyUp);
function stopDrive() {
function stopDrive() {
- headReleaseAll();
- const h = holds.get(DRIVE);
- if (h) for (const source of [...h.src.keys()]) { if (source instanceof Element) releaseButton(source); else holdRelease(DRIVE, source); }
- for (const s of sticks) s.release();
- send(DRIVE, {}); // every declared axis absent → 0
+ // The machine's own Stop (halt): release every local hold, then send the one command that stops it.
+ releaseAll();
+ send('halt', {});
}
}
$$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));
$$('[data-stop]').forEach((el) => el.addEventListener('click', stopDrive));
- function estop() { releaseAll(); raw({ type: 'estop' }); }
// ── Gamepad ───────────────────────────────────────────────────────────────────────────────────────
// ── Gamepad ───────────────────────────────────────────────────────────────────────────────────────
const PAD_DPAD = { 12: 'ArrowUp', 13: 'ArrowDown', 14: 'ArrowLeft', 15: 'ArrowRight' };
const PAD_DPAD = { 12: 'ArrowUp', 13: 'ArrowDown', 14: 'ArrowLeft', 15: 'ArrowRight' };
@@ -754,7 +750,7 @@
@@ -754,7 +750,7 @@
loop: 0,
loop: 0,
down: new Set(), // buttons held at the last poll
down: new Set(), // buttons held at the last poll
tone: null, // the tone button A holds
tone: null, // the tone button A holds
- // After a reset (blur, a hidden page, the e-stop) nothing is pressed again until the pad is let go of
+ // After a reset (blur, a hidden page, Stop) nothing is pressed again until the pad is let go of
// entirely: a stick still pushed or a button still held does not undo a release.
// entirely: a stick still pushed or a button still held does not undo a release.
latched: false,
latched: false,
dir: null, // the direction button the pad holds (a robot made of buttons)
dir: null, // the direction button the pad holds (a robot made of buttons)
@@ -787,7 +783,7 @@
@@ -787,7 +783,7 @@
const now = new Set(p.buttons.map((b, i) => (b && b.pressed ? i : -1)).filter((i) => i >= 0));
const now = new Set(p.buttons.map((b, i) => (b && b.pressed ? i : -1)).filter((i) => i >= 0));
if (buttonDrive) {
if (buttonDrive) {
// A robot made of buttons: the left stick or the D-pad points at a direction, and its button is pressed.
// A robot made of buttons: the left stick or the D-pad points at a direction, and its button is pressed.
- // Like the drive: after a reset nothing is pressed again until the pad is let go of; B lets go; Start is the e-stop.
+ // Like the drive: after a reset nothing is pressed again until the pad is let go of; B lets go.
const ok = g.focused && !document.hidden && joined && allowed.includes('button');
const ok = g.focused && !document.hidden && joined && allowed.includes('button');
let want = stickDir(dead(ax(0), PAD_DEADZONE), -dead(ax(1), PAD_DEADZONE));
let want = stickDir(dead(ax(0), PAD_DEADZONE), -dead(ax(1), PAD_DEADZONE));
if (!want) for (const [b, d] of Object.entries({ 12: 'up', 13: 'down', 14: 'left', 15: 'right' })) if (now.has(Number(b))) { want = d; break; }
if (!want) for (const [b, d] of Object.entries({ 12: 'up', 13: 'down', 14: 'left', 15: 'right' })) if (now.has(Number(b))) { want = d; break; }
@@ -795,8 +791,6 @@
@@ -795,8 +791,6 @@
if (!pad.latched) pad.reset();
if (!pad.latched) pad.reset();
} else if (pad.latched) {
} else if (pad.latched) {
if (!want && !now.size) pad.latched = false;
if (!want && !now.size) pad.latched = false;
- } else if (now.has(9) && !pad.down.has(9) && $('[data-estop]:not([hidden])')) {
- estop();
} else if (now.has(1) && !pad.down.has(1)) {
} else if (now.has(1) && !pad.down.has(1)) {
pad.reset();
pad.reset();
} else if (want !== pad.dir) {
} else if (want !== pad.dir) {
@@ -822,7 +816,6 @@
@@ -822,7 +816,6 @@
if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);
if (PAD_DPAD[b]) keyPress(PAD_DPAD[b], `pad:${b}`);
else if (b === 0) { pad.tone = $$('[data-tone]').find((el) => !el.disabled) || null; if (pad.tone) pressTone(pad.tone); }
else if (b === 0) { pad.tone = $$('[data-tone]').find((el) => !el.disabled) || null; if (pad.tone) pressTone(pad.tone); }
else if (b === 1) { stopDrive(); pad.reset(); } // a stick still pushed does not drive on
else if (b === 1) { stopDrive(); pad.reset(); } // a stick still pushed does not drive on
- else if (b === 9 && $('[data-estop]:not([hidden])')) estop();
}
}
for (const b of pad.down) if (!now.has(b)) {
for (const b of pad.down) if (!now.has(b)) {
if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
if (PAD_DPAD[b]) keyRelease(PAD_DPAD[b], `pad:${b}`);
@@ -900,7 +893,7 @@
@@ -900,7 +893,7 @@
// ── Make your own controls: the Arrange editor and the Controls v2 store's one migration ─────────────
// ── Make your own controls: the Arrange editor and the Controls v2 store's one migration ─────────────
// The panel is drawn with the person's ui.layout@1 on the server (never a flash of the default). Arrange
// The panel is drawn with the person's ui.layout@1 on the server (never a flash of the default). Arrange
// loads openvibe-shared/ov-arrange.js only when pressed; the editor works on these real parts. The old
// loads openvibe-shared/ov-arrange.js only when pressed; the editor works on these real parts. The old
- // browser store (ov-bot-pad-prefs: Stop's hand, pad size, readouts) becomes a layout once, then goes.
+ // browser store (ov-bot-pad-prefs: pad size, readouts) becomes a layout once, then goes.
const arrangeData = () => {
const arrangeData = () => {
const el = document.getElementById('ov-arrange-data');
const el = document.getElementById('ov-arrange-data');
if (!el) return null;
if (!el) return null;
@@ -916,24 +909,12 @@
@@ -916,24 +909,12 @@
try { prefs = JSON.parse(raw) || {}; } catch { return; }
try { prefs = JSON.parse(raw) || {}; } catch { return; }
const { data } = got;
const { data } = got;
const set = (id, fn) => { const e = (data.layout.parts || []).find((p) => p.id === id); if (e) fn(e); };
const set = (id, fn) => { const e = (data.layout.parts || []).find((p) => p.id === id); if (e) fn(e); };
- if (prefs.hand === 'left') {
- const stick = (data.layout.parts || []).find((p) => p.id === 'stick');
- const stop = (data.layout.parts || []).find((p) => p.id === 'stop');
- if (stick && stop && stick.place && stop.place && stick.place.x != null && stop.place.x != null) {
- const x = stick.place.x; stick.place.x = stop.place.x; stop.place.x = x;
- }
- }
if (prefs.size === 'l') {
if (prefs.size === 'l') {
const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;
const cols = data.catalog && data.catalog.grid ? data.catalog.grid.columns[data.device] : 4;
const stick = (data.layout.parts || []).find((p) => p.id === 'stick');
const stick = (data.layout.parts || []).find((p) => p.id === 'stick');
- const stop = (data.layout.parts || []).find((p) => p.id === 'stop');
if (stick && stick.place && stick.place.x != null) {
if (stick && stick.place && stick.place.x != null) {
stick.place.w = Math.min(3, cols); stick.place.h = 3;
stick.place.w = Math.min(3, cols); stick.place.h = 3;
stick.place.x = Math.max(0, Math.min(stick.place.x, cols - stick.place.w));
stick.place.x = Math.max(0, Math.min(stick.place.x, cols - stick.place.w));
- if (stop && stop.place && stick.place.x < stop.place.x + stop.place.w && stick.place.x + stick.place.w > stop.place.x
- && stick.place.y < stop.place.y + stop.place.h && stick.place.y + stick.place.h > stop.place.y) {
- stick.place.x = 0; stick.place.y = stop.place.y + stop.place.h;
- }
}
}
}
}
if (prefs.readouts === 'off') for (const id of ['battery', 'latency', 'telemetry']) set(id, (p) => { p.visible = false; });
if (prefs.readouts === 'off') for (const id of ['battery', 'latency', 'telemetry']) set(id, (p) => { p.visible = false; });
@@ -979,6 +960,7 @@
@@ -979,6 +960,7 @@
if (!surfaceEl) { opening = false; return; }
if (!surfaceEl) { opening = false; return; }
window.ovArrange = window.OVArrange.open({
window.ovArrange = window.OVArrange.open({
surface: surfaceEl, catalog: data.catalog, layout: data.layout, device: data.device,
surface: surfaceEl, catalog: data.catalog, layout: data.layout, device: data.device,
+ defaults: () => data.defaultLayout && JSON.parse(JSON.stringify(data.defaultLayout)),
onSave: async (layout) => {
onSave: async (layout) => {
const out = await post(data.save, { surface: data.surface, device: data.device, catalog: data.catalog, layout });
const out = await post(data.save, { surface: data.surface, device: data.device, catalog: data.catalog, layout });
data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;
data.layout = layout; got.el.textContent = JSON.stringify(data); saved = true;
@@ -998,6 +980,32 @@
@@ -998,6 +980,32 @@
onClose: () => { opening = false; if (saved) setTimeout(() => location.reload(), 60); },
onClose: () => { opening = false; if (saved) setTimeout(() => location.reload(), 60); },
confirm: () => true,
confirm: () => true,
});
});
+ // The parts palette has one optional part here: Stop. It is off until a person adds it, and it is
+ // just a plain button that sends the robot's own Stop (halt). The editor's Save keeps the change.
+ const bar = document.querySelector('.ov-arrange-bar .ov-arrange-actions');
+ const editorial = window.ovArrange;
+ if (bar && editorial && (data.catalog.parts || []).some((p) => p.id === 'stop')) {
+ const add = document.createElement('button');
+ add.type = 'button'; add.className = 'ovk-btn ovk-btn--quiet'; add.dataset.addStop = 'true';
+ const sync = () => {
+ const part = (editorial.layout().parts || []).find((p) => p.id === 'stop');
+ const on = !!(part && part.visible !== false);
+ add.textContent = on ? 'Stop added' : 'Add Stop';
+ add.disabled = on;
+ };
+ add.addEventListener('click', () => {
+ const layout = editorial.layout();
+ const part = (layout.parts || []).find((p) => p.id === 'stop');
+ if (!part) return;
+ part.visible = true;
+ editorial.apply(layout);
+ sync();
+ const save = document.querySelector('.ov-arrange-bar [data-save]');
+ if (save) { save.disabled = false; save.click(); }
+ });
+ bar.insertBefore(add, bar.firstChild);
+ sync();
+ }
};
};
btn.addEventListener('click', () => {
btn.addEventListener('click', () => {
if (opening) return;
if (opening) return;
@@ -1145,7 +1153,7 @@
@@ -1145,7 +1153,7 @@
}
}
// ── Operators (Maticus's 2026-11-12 LOOI spec) ──────────────────────────────────────────────────
// ── 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),
// 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.
+ // sends a toggle / sole / role, and shows the refusal in the same notice line as a command's.
const opsCard = $('[data-operators]');
const opsCard = $('[data-operators]');
const opsNames = new Map();
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);
if (opsCard) for (const row of $$('[data-operator-subject]', opsCard)) opsNames.set(row.dataset.operatorSubject, row.dataset.operatorName || row.dataset.operatorSubject);
@@ -1176,21 +1184,6 @@
@@ -1176,21 +1184,6 @@
if (!joined) { notice('Join the robot first.'); return; }
if (!joined) { notice('Join the robot first.'); return; }
raw({ type: 'operators', action, ...fields });
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) {
function paintOperators(state, keepNow = false) {
if (!opsCard || !state) return;
if (!opsCard || !state) return;
ops.sole = state.sole || null;
ops.sole = state.sole || null;
@@ -1215,9 +1208,9 @@
@@ -1215,9 +1208,9 @@
if (tag) tag.textContent = m.role === 'owner' ? (row.dataset.operatorYou === 'true' ? 'OWNER · YOU' : 'OWNER') : (OPS_ROLE[m.role] || m.role);
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);
const sub = $('[data-operator-sub]', row);
if (sub) sub.textContent = m.role === 'owner'
if (sub) sub.textContent = m.role === 'owner'
- ? 'Always has override, local STOP, and ARM'
+ ? 'Always has override.'
: m.role === 'mod'
: m.role === 'mod'
- ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'
+ ? 'Can switch Operators on or off and give or clear sole control. Cannot change speed, mode, or Viewer access, or promote Mods.'
: 'Can drive and tilt the head while switched on.';
: 'Can drive and tilt the head while switched on.';
const rec = ops.activity[m.subject];
const rec = ops.activity[m.subject];
const active = m.enabled !== false && (opsFresh(rec, 'drive') || opsFresh(rec, 'head'));
const active = m.enabled !== false && (opsFresh(rec, 'drive') || opsFresh(rec, 'head'));
@@ -1261,11 +1254,6 @@
@@ -1261,11 +1254,6 @@
if (soleBtn) { opsSend('sole', { subject: soleBtn.dataset.soleActive === 'true' ? null : soleBtn.dataset.operatorSole }); return; }
if (soleBtn) { opsSend('sole', { subject: soleBtn.dataset.soleActive === 'true' ? null : soleBtn.dataset.operatorSole }); return; }
const roleBtn = btn.closest('[data-operator-role]');
const roleBtn = btn.closest('[data-operator-role]');
if (roleBtn) { opsSend('role', { subject: roleBtn.dataset.subject, role: roleBtn.dataset.operatorRoleSet }); return; }
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) => {
opsCard.addEventListener('change', (e) => {
const toggle = e.target.closest && e.target.closest('[data-operator-toggle]');
const toggle = e.target.closest && e.target.closest('[data-operator-toggle]');
@@ -1285,8 +1273,6 @@
@@ -1285,8 +1273,6 @@
if (face) send('display', { face });
if (face) send('display', { face });
else if (text) send('display', { text });
else if (text) send('display', { text });
}));
}));
- $$('[data-estop]').forEach((el) => el.addEventListener('click', estop));
- $$('[data-estop-clear]').forEach((el) => el.addEventListener('click', () => raw({ type: 'estop_clear' })));
// Each camera tile takes its camera's shape (CSSOM, so the CSP's no-inline rule holds).
// Each camera tile takes its camera's shape (CSSOM, so the CSP's no-inline rule holds).
for (const cam of $$('[data-resolution]')) {
for (const cam of $$('[data-resolution]')) {
@@ -1314,7 +1300,7 @@
@@ -1314,7 +1300,7 @@
main.addEventListener('pointerdown', (e) => {
main.addEventListener('pointerdown', (e) => {
if (!watcher || driving) return;
if (!watcher || driving) return;
const t = e.target && e.target.closest ? e.target.closest('[data-command], .bot-stick, .bot-slider') : null;
const t = e.target && e.target.closest ? e.target.closest('[data-command], .bot-stick, .bot-slider') : null;
- if (!t || (e.target.closest && e.target.closest('[data-drive], [data-bot-more], [data-estop]'))) return;
+ if (!t || (e.target.closest && e.target.closest('[data-drive], [data-bot-more]'))) return;
const drive = $('[data-drive]:not([hidden])');
const drive = $('[data-drive]:not([hidden])');
if (!drive) return;
if (!drive) return;
e.preventDefault();
e.preventDefault();
@@ -1360,7 +1346,6 @@
@@ -1360,7 +1346,6 @@
// What a device's refusal means, in words (a nack's fault code). A deadline that passed in transit is not news.
// What a device's refusal means, in words (a nack's fault code). A deadline that passed in transit is not news.
const NACK_TEXT = {
const NACK_TEXT = {
local_stop: 'The robot is stopped on the robot itself; release it there first.',
local_stop: 'The robot is stopped on the robot itself; release it there first.',
- estopped: 'The e-stop is latched.',
not_connected: 'The robot is not connected to its device.',
not_connected: 'The robot is not connected to its device.',
no_heartbeat: 'The device lost its link to the robot.',
no_heartbeat: 'The device lost its link to the robot.',
not_ready: 'The robot is still starting.',
not_ready: 'The robot is still starting.',
@@ -1408,16 +1393,13 @@
@@ -1408,16 +1393,13 @@
const online = $('[data-online-label]');
const online = $('[data-online-label]');
if (online) online.textContent = label;
if (online) online.textContent = label;
}
}
- /** The overlay's one-line state: e-stop, offline, your turn, someone else's turn, or just online. */
+ /** The overlay's one-line state: offline, your turn, someone else's turn, or just online. */
function paintBotPill(state) {
function paintBotPill(state) {
const pill = $('[data-bot-pill]');
const pill = $('[data-bot-pill]');
if (!pill) return;
if (!pill) return;
- const latched = !!(state.estop && state.estop.latched);
- const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
const queue = state.queue;
const queue = state.queue;
let label = 'Online'; let dataState = 'online';
let label = 'Online'; let dataState = 'online';
- if (latched || held) { label = 'E-stop on'; dataState = 'estop'; }
- else if (!state.online) { label = 'Offline'; dataState = 'offline'; }
+ if (!state.online) { label = 'Offline'; dataState = 'offline'; }
else if (queue && queue.active) { label = 'Your turn'; dataState = 'turn'; }
else if (queue && queue.active) { label = 'Your turn'; dataState = 'turn'; }
else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
else if (queue && queue.turn_ends_at) { label = 'Someone else driving'; dataState = 'busy'; }
const text = $('[data-bot-state]');
const text = $('[data-bot-state]');
@@ -1440,27 +1422,6 @@
@@ -1440,27 +1422,6 @@
}
}
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
- const latched = !!(state.estop && state.estop.latched);
- // The robot's own stop (openvibe-link stop, or its driver's stop button) holds until it is released on the
- // robot: clearing here cannot lift it, so the button waits and the note says where to release it.
- const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
- const banner = $('[data-estop-banner]');
- if (banner) banner.dataset.latched = String(latched || held);
- const label = $('[data-estop-state]');
- if (label) label.textContent = held ? 'Stopped on the robot' : latched ? 'E-stop latched' : 'E-stop clear';
- 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 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) {
- clear.hidden = !latched;
- clear.disabled = held;
- clear.title = held ? 'Release the stop on the robot first' : '';
- }
- if (latched || held) releaseAll();
paintFaults(state);
paintFaults(state);
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
paintBotPill(state);
paintBotPill(state);
@@ -1474,7 +1435,6 @@
@@ -1474,7 +1435,6 @@
el.dataset.quality = quality(state.latency_ms, 120, 300);
el.dataset.quality = quality(state.latency_ms, 120, 300);
}
}
paintBattery(state);
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]')) {
@@ -1543,7 +1503,6 @@
@@ -1543,7 +1503,6 @@
const label = $('[data-role-label]');
const label = $('[data-role-label]');
myRole = m.role || myRole;
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;
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);
@@ -1606,7 +1565,6 @@
@@ -1606,7 +1565,6 @@
function stopDriving(text) {
function stopDriving(text) {
driving = false; watcher = true; ticket = null;
driving = false; watcher = true; ticket = null;
announceControls();
announceControls();
- for (const el of $$('[data-estop]')) el.hidden = true;
const label = $('[data-role-label]');
const label = $('[data-role-label]');
if (label) label.textContent = 'Watching';
if (label) label.textContent = 'Watching';
const bar = $('[data-drive-bar]');
const bar = $('[data-drive-bar]');
@@ -13,8 +13,8 @@
@@ -13,8 +13,8 @@
* plays it. Restarted with backoff when it drops
* plays it. Restarted with backoff when it drops
* commands say (speechSynthesis), display (a face or text, full screen), actuator torch (the camera's torch) and
* commands say (speechSynthesis), display (a face or text, full screen), actuator torch (the camera's torch) and
* chime (a tone while it is held: its deadline ends it, as a motor's does), halt; each acked or nacked
* chime (a tone while it is held: its deadline ends it, as a motor's does), halt; each acked or nacked
- * safety Bot's e-stop silences everything and refuses all but halt; the phone's own Stop button latches a local
- * stop (estop_state local_stop) that only this phone's Resume releases
+ * safety the phone's own Stop button latches a local stop (the old estop_state local_stop report) that only
+ * this phone's Resume releases; it stays local
*
*
* The page needs one tap to start (browsers allow sound, the camera and the wake lock only after a gesture).
* The page needs one tap to start (browsers allow sound, the camera and the wake lock only after a gesture).
*/
*/
@@ -95,7 +95,7 @@
@@ -95,7 +95,7 @@
// ── Running ───────────────────────────────────────────────────────────────────────────────────────────
// ── Running ───────────────────────────────────────────────────────────────────────────────────────────
let ws = null; let seq = 0; let retry = 0; let running = false;
let ws = null; let seq = 0; let retry = 0; let running = false;
let heartbeatMs = 1000; let heartbeatTimer = null; let telemetryTimer = null;
let heartbeatMs = 1000; let heartbeatTimer = null; let telemetryTimer = null;
- let rtt = null; let remoteLatched = false; let localStop = false;
+ let rtt = null; let localStop = false;
let front = false;
let front = false;
const linkEl = $('[data-link-state]');
const linkEl = $('[data-link-state]');
const videoEl = $('[data-video-state]');
const videoEl = $('[data-video-state]');
@@ -152,18 +152,16 @@
@@ -152,18 +152,16 @@
if (m.type === 'hello') { retry = 0; return; }
if (m.type === 'hello') { retry = 0; return; }
if (m.type === 'config') {
if (m.type === 'config') {
heartbeatMs = Math.max(250, Math.min(5000, Number(m.heartbeat_ms) || 1000));
heartbeatMs = Math.max(250, Math.min(5000, Number(m.heartbeat_ms) || 1000));
- remoteLatched = !!m.estop_latched;
clearInterval(heartbeatTimer); clearInterval(telemetryTimer);
clearInterval(heartbeatTimer); clearInterval(telemetryTimer);
heartbeatTimer = setInterval(() => out('heartbeat', { t: Date.now(), rtt_ms: rtt }), heartbeatMs);
heartbeatTimer = setInterval(() => out('heartbeat', { t: Date.now(), rtt_ms: rtt }), heartbeatMs);
telemetryTimer = setInterval(sendTelemetry, 1000);
telemetryTimer = setInterval(sendTelemetry, 1000);
- out('status', { firmware: AGENT, capabilities: { torch: torchSupported(), speech: 'speechSynthesis' in window }, faults: faults(), estop_latched: remoteLatched || localStop });
+ out('status', { firmware: AGENT, capabilities: { torch: torchSupported(), speech: 'speechSynthesis' in window }, faults: faults() });
if (localStop) out('estop_state', { latched: true, local_stop: true, reason: 'stopped on the phone' });
if (localStop) out('estop_state', { latched: true, local_stop: true, reason: 'stopped on the phone' });
paintSafety();
paintSafety();
setLink('Connected', 'online');
setLink('Connected', 'online');
return;
return;
}
}
if (m.type === 'heartbeat_ack') { if (Number.isFinite(m.echo)) rtt = Math.max(0, Date.now() - m.echo); return; }
if (m.type === 'heartbeat_ack') { if (Number.isFinite(m.echo)) rtt = Math.max(0, Date.now() - m.echo); return; }
- if (m.type === 'estop') { remoteLatched = !!m.latched; if (remoteLatched) stopEffects(); paintSafety(); return; }
if (m.type === 'command') return onCommand(m);
if (m.type === 'command') return onCommand(m);
if (m.type === 'rotate') return undefined; // a Network-paired machine's frame; a phone holds a Bot credential
if (m.type === 'rotate') return undefined; // a Network-paired machine's frame; a phone holds a Bot credential
return undefined;
return undefined;
@@ -175,7 +173,6 @@
@@ -175,7 +173,6 @@
const v = m.value && typeof m.value === 'object' ? m.value : {};
const v = m.value && typeof m.value === 'object' ? m.value : {};
if (m.kind === 'halt') { stopEffects(); return ack(); }
if (m.kind === 'halt') { stopEffects(); return ack(); }
if (localStop) return nack('local_stop');
if (localStop) return nack('local_stop');
- if (remoteLatched) return nack('estopped');
if (m.kind === 'say') return speak(String(v.text || '')) ? ack() : nack('unsupported');
if (m.kind === 'say') return speak(String(v.text || '')) ? ack() : nack('unsupported');
if (m.kind === 'display') {
if (m.kind === 'display') {
if (v.face) showFace(String(v.face)); else if (v.text) showText(String(v.text)); else return nack('invalid');
if (v.face) showFace(String(v.face)); else if (v.text) showText(String(v.text)); else return nack('invalid');
@@ -200,11 +197,11 @@
@@ -200,11 +197,11 @@
paintSafety();
paintSafety();
});
});
function paintSafety() {
function paintSafety() {
- main.dataset.stopped = String(localStop || remoteLatched);
+ main.dataset.stopped = String(localStop);
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 or a Mod clears the panel 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 when it is safe to move again.' : '';
note.hidden = !note.textContent;
note.hidden = !note.textContent;
}
}
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
* arena's kinematics (public/sim-world.js) at the page's frame rate.
* arena's kinematics (public/sim-world.js) at the page's frame rate.
*
*
* It keeps the gate's rules that a driver would feel: only the profile's command kinds; drive axes and actuator
* It keeps the gate's rules that a driver would feel: only the profile's command kinds; drive axes and actuator
- * values shaped to the profile's ranges (as server/domain does); a latched e-stop that refuses everything but halt
- * until it is cleared; and the device's deadman: a motion command lapses max_command_ms after it was sent unless
- * a newer one arrives (the panel re-sends a held control every 150 ms). Bumping into something stops the wheels.
+ * values shaped to the profile's ranges (as server/domain does); the device's deadman: a motion command lapses
+ * max_command_ms after it was sent unless a newer one arrives (the panel re-sends a held control every 150 ms);
+ * and the machine's own Stop (halt). Bumping into something stops the wheels.
*
*
* window.OVSimLocal exposes the pose, the headlights and the horn to the camera view (public/sim-view.js).
* window.OVSimLocal exposes the pose, the headlights and the horn to the camera view (public/sim-view.js).
*/
*/
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
const rover = {
const rover = {
pose: { ...W.START }, drive: {}, deadline: 0, headlights: null, horn: null, hornUntil: 0,
pose: { ...W.START }, drive: {}, deadline: 0, headlights: null, horn: null, hornUntil: 0,
- battery: 100, estop: false, bumped: false,
+ battery: 100, bumped: false,
};
};
const clamp = (v, lo, hi) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; };
const clamp = (v, lo, hi) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : 0; };
const noteHz = (note) => {
const noteHz = (note) => {
@@ -78,14 +78,13 @@
@@ -78,14 +78,13 @@
function state() {
function state() {
return {
return {
robot_id: main.dataset.robotId, online: true,
robot_id: main.dataset.robotId, online: true,
- estop: { latched: rover.estop, by: null, at: null },
latency_ms: 12, battery: rover.battery / 100,
latency_ms: 12, battery: rover.battery / 100,
telemetry: {
telemetry: {
battery: { percent: Math.round(rover.battery) },
battery: { percent: Math.round(rover.battery) },
sensors: { distance_cm: W.distanceCm(rover.pose), line: W.line(rover.pose) },
sensors: { distance_cm: W.distanceCm(rover.pose), line: W.line(rover.pose) },
sim: { pose: { ...rover.pose }, drive: { ...rover.drive }, headlights: rover.headlights, horn: rover.horn, bumped: rover.bumped },
sim: { pose: { ...rover.pose }, drive: { ...rover.drive }, headlights: rover.headlights, horn: rover.horn, bumped: rover.bumped },
},
},
- status: null, device_estop: null, queue: null,
+ status: null, queue: null,
};
};
}
}
@@ -98,7 +97,7 @@
@@ -98,7 +97,7 @@
if (rover.deadline && now > rover.deadline) halt(); // the deadman
if (rover.deadline && now > rover.deadline) halt(); // the deadman
if (rover.hornUntil && now > rover.hornUntil) { rover.horn = null; rover.hornUntil = 0; }
if (rover.hornUntil && now > rover.hornUntil) { rover.horn = null; rover.hornUntil = 0; }
const moving = Object.values(rover.drive).some((x) => x);
const moving = Object.values(rover.drive).some((x) => x);
- if (moving && !rover.estop) {
+ if (moving) {
const r = W.step(rover.pose, rover.drive, dt);
const r = W.step(rover.pose, rover.drive, dt);
rover.pose = r.pose;
rover.pose = r.pose;
if (r.bumped && !rover.bumped) halt(); // the bumper stops the wheels
if (r.bumped && !rover.bumped) halt(); // the bumper stops the wheels
@@ -137,14 +136,11 @@
@@ -137,14 +136,11 @@
return;
return;
}
}
if (!this.joined) return this.deliver({ type: 'error', code: 'bot.not_joined', detail: 'join first' });
if (!this.joined) return this.deliver({ type: 'error', code: 'bot.not_joined', detail: 'join first' });
- if (m.type === 'estop') { rover.estop = true; halt(); rover.horn = null; return broadcast(); }
- if (m.type === 'estop_clear') { rover.estop = false; return broadcast(); }
if (m.type !== 'command') return;
if (m.type !== 'command') return;
const id = String(m.id || '');
const id = String(m.id || '');
const kind = String(m.kind || '');
const kind = String(m.kind || '');
const reply = (fields) => this.deliver({ type: 'command_result', id, ...fields }, 10 + Math.round(Math.random() * 14));
const reply = (fields) => this.deliver({ type: 'command_result', id, ...fields }, 10 + Math.round(Math.random() * 14));
if (!ALLOWED.includes(kind)) return reply({ result: 'refused', code: 'bot.command_not_allowed', reason: `${kind} is not something this robot does` });
if (!ALLOWED.includes(kind)) return reply({ result: 'refused', code: 'bot.command_not_allowed', reason: `${kind} is not something this robot does` });
- if (rover.estop && kind !== 'halt') return reply({ result: 'refused', code: 'bot.estopped', reason: 'The e-stop is latched. Clear it to drive again.' });
const shaped = shape(kind, m.value);
const shaped = shape(kind, m.value);
if (shaped.refused) return reply({ result: 'refused', code: 'bot.invalid_input', reason: shaped.refused });
if (shaped.refused) return reply({ result: 'refused', code: 'bot.invalid_input', reason: shaped.refused });
apply(kind, shaped.value);
apply(kind, shaped.value);
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
* (domain.devices.revoke): run jobs reference devices and are billing records, so the device
* (domain.devices.revoke): run jobs reference devices and are billing records, so the device
* row stays, revoked, without its name.
* row stays, revoked, without its name.
* elsewhere their operator role and queue place on other people's robots go; their id leaves other
* elsewhere their operator role and queue place on other people's robots go; their id leaves other
- * robots' command history (operator_subject), e-stop marks, pairing codes and invitations.
+ * robots' command history (operator_subject), pairing codes and invitations.
* their machines the worker machines they added (no robot, plan T14/T16) are exported, then revoked and kept
* their machines the worker machines they added (no robot, plan T14/T16) are exported, then revoked and kept
* without a name or an owner (run jobs reference them).
* without a name or an owner (run jobs reference them).
* kept run jobs they started: usage seconds reported to Billing, counted as retained.
* kept run jobs they started: usage seconds reported to Billing, counted as retained.
@@ -25,7 +25,6 @@ const TABLES = [
@@ -25,7 +25,6 @@ const TABLES = [
{ table: 'robot_operators', subject: 'subject', file: 'operating.json', columns: ['robot_id', 'role', 'created_at'] },
{ table: 'robot_operators', subject: 'subject', file: 'operating.json', columns: ['robot_id', 'role', 'created_at'] },
{ table: 'robot_queue', subject: 'subject', file: null },
{ table: 'robot_queue', subject: 'subject', file: null },
{ table: 'command_audit', subject: 'operator_subject', file: 'commands.json', columns: ['robot_id', 'kind', 'result', 'reason', 'at'], order: 'at', erase: anonymize },
{ table: 'command_audit', subject: 'operator_subject', file: 'commands.json', columns: ['robot_id', 'kind', 'result', 'reason', 'at'], order: 'at', 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
// Sole control names the person it admits and who granted it; the audit names the actor and the subject. Both are
@@ -6,10 +6,8 @@
@@ -6,10 +6,8 @@
* common.resource-control-result@1 (200 for every outcome, without `resource`: robots have no OVRN; a malformed request
* common.resource-control-result@1 (200 for every outcome, without `resource`: robots have no OVRN; a malformed request
* is a 400 problem).
* is a 400 problem).
*
*
- * stop latch the robot's remote e-stop, as POST /robots/:id/estop: every actuator stops and nothing moves
- * until it is cleared
- * resume clear that e-stop, as POST /robots/:id/estop/clear (a local stop on the robot itself still holds). An
- * agent's resume needs the owner's confirmation, which Services asks for before it sends one.
+ * stop send the robot's own Stop command (halt): every actuator stops, and nothing is latched.
+ * resume answer that nothing was latched, so there is nothing to clear. A Stop on the machine itself stays local.
*
*
* Any other action is refused `control.unsupported_action`. Only the owner Bot holds may be named: a robot of another
* Any other action is refused `control.unsupported_action`. Only the owner Bot holds may be named: a robot of another
* person is refused as unknown, never confirmed to exist. dry_run decides and explains without applying. The first
* person is refused as unknown, never confirmed to exist. dry_run decides and explains without applying. The first
@@ -59,24 +57,16 @@ function router({ domain, apiAuth, hub, now = () => Date.now() }) {
@@ -59,24 +57,16 @@ function router({ domain, apiAuth, hub, now = () => Date.now() }) {
if (!robot || robot.owner_subject !== request.owner.id) {
if (!robot || robot.owner_subject !== request.owner.id) {
return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no robot ${request.owned.id} of this person`) });
return result(request, 'refused', { problem: problem(404, 'resources.unknown_resource', `no robot ${request.owned.id} of this person`) });
}
}
- const asker = request.on_behalf_of || { type: 'user', id: request.owner.id };
- if (request.action === 'resume' && asker.type !== 'user' && !request.confirmation_id) {
- return result(request, 'refused', { problem: problem(403, 'control.confirmation_needed', 'an agent resumes a robot only with its owner\'s confirmation') });
- }
- const latched = !!robot.estop_latched;
+ const owner = { kind: 'user', subject: robot.owner_subject };
if (request.action === 'stop') {
if (request.action === 'stop') {
- if (latched) return result(request, 'done', { result: { estop: true, changed: false } });
- if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'latch the robot\'s e-stop' } });
- const by = asker.id;
- await domain.estop.set(robot.id, { latched: true, by, principalKind: asker.type === 'user' ? 'user' : 'agent' });
- if (hub) { await hub.pushEstop(robot.id, true, by); hub.broadcast(robot.id); }
- return result(request, 'done', { result: { estop: true, changed: true } });
+ if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'send the robot a Stop' } });
+ const out = hub
+ ? await hub.operatorCommand(robot.id, owner, robot.owner_subject, { id: `rc-${crypto.randomUUID()}`, kind: 'halt', value: {}, ms: 5000 })
+ : { result: 'refused', code: 'bot.device_offline' };
+ return result(request, 'done', { result: { stop: true, changed: out.result === 'ack', ...(out.result === 'ack' ? {} : { reason: out.reason || out.code || 'the robot is offline' }) } });
}
}
- if (!latched) return result(request, 'done', { result: { estop: false, changed: false } });
- if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'clear the robot\'s e-stop' } });
- await domain.estop.clear(robot.id, robot.owner_subject);
- if (hub) { await hub.pushEstop(robot.id, false, robot.owner_subject); hub.broadcast(robot.id); }
- return result(request, 'done', { result: { estop: false, changed: true } });
+ // Nothing is latched any more: a resume has nothing to clear. A Stop held on the machine itself stays local.
+ return result(request, 'done', { result: { stop: false, changed: false } });
}
}
r.post('/', firstParty, json, wrap(async (req, res) => {
r.post('/', firstParty, json, wrap(async (req, res) => {
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* (openvibe-sdk 0.41 sends it for a person's own resources; an owner is a person, so an agt_ owner matches nothing).
* (openvibe-sdk 0.41 sends it for a person's own resources; an owner is a person, so an agt_ owner matches nothing).
* Neither is in a project, so a project filter matches nothing. A malformed filter is a 400.
* Neither is in a project, so a project filter matches nothing. A malformed filter is a 400.
*
*
- * Kinds: bot.robot (rob_, state ready or estopped), then bot.machine (dev_: a person's own machine, the Desks plan's
+ * Kinds: bot.robot (rob_, state ready), then bot.machine (dev_: a person's own machine, the Desks plan's
* hosts, state online or offline). A robot's device keeps no owner of its own and is its robot's, not listed here.
* hosts, state online or offline). A robot's device keeps no owner of its own and is its robot's, not listed here.
* The cursor names the kind and id of the last one, so a page that ends among the robots goes on to the machines.
* The cursor names the kind and id of the last one, so a page that ends among the robots goes on to the machines.
*/
*/
@@ -31,7 +31,7 @@ const consoleOf = (baseUrl, path) => (/^https:\/\//.test(String(baseUrl || ''))
@@ -31,7 +31,7 @@ const consoleOf = (baseUrl, path) => (/^https:\/\//.test(String(baseUrl || ''))
function robotSummary(row, baseUrl = null) {
function robotSummary(row, baseUrl = null) {
const summary = {
const summary = {
id: row.id, kind: KIND, service: SERVICE,
id: row.id, kind: KIND, service: SERVICE,
- name: row.name, state: row.estop_latched ? 'estopped' : 'ready',
+ name: row.name, state: 'ready',
created_at: new Date(row.created_at).toISOString(),
created_at: new Date(row.created_at).toISOString(),
updated_at: new Date(row.updated_at).toISOString(),
updated_at: new Date(row.updated_at).toISOString(),
};
};
@@ -104,7 +104,7 @@ function router({ db, apiAuth, hub = null, baseUrl = null }) {
@@ -104,7 +104,7 @@ function router({ db, apiAuth, hub = null, baseUrl = null }) {
const cur = filters.cursor;
const cur = filters.cursor;
const out = [];
const out = [];
if ((!filters.kind || filters.kind === KIND) && (!cur || cur.kind === KIND)) {
if ((!filters.kind || filters.kind === KIND) && (!cur || cur.kind === KIND)) {
- const rows = await db.many(`SELECT id, owner_subject, name, estop_latched, created_at, updated_at
+ const rows = await db.many(`SELECT id, owner_subject, name, created_at, updated_at
FROM robots WHERE ($1::text IS NULL OR id > $1) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
FROM robots WHERE ($1::text IS NULL OR id > $1) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
[cur ? cur.id : null, filters.limit + 1, filters.owner]);
[cur ? cur.id : null, filters.limit + 1, filters.owner]);
out.push(...rows.map((row) => robotSummary(row, baseUrl)));
out.push(...rows.map((row) => robotSummary(row, baseUrl)));
@@ -27,8 +27,6 @@
@@ -27,8 +27,6 @@
* | GET /robots/:id/devices | bot.robot.read | a member |
* | GET /robots/:id/devices | bot.robot.read | a member |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
* | POST /devices/bind | — (a Link token) | a machine paired for Bot on Network |
* | POST /devices/bind | — (a Link token) | a machine paired for Bot on Network |
- * | POST /robots/:id/estop | bot.robot.control | owner or operator |
- * | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
* | POST /robots/:id/control-ticket | bot.robot.control | a member (or anyone on a queue robot): a Drive ticket |
* | POST /robots/:id/control-ticket | bot.robot.control | a member (or anyone on a queue robot): a Drive ticket |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
* | GET /robots/:id/audit | bot.robot.read | owner (paged) |
@@ -465,23 +463,6 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -465,23 +463,6 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json(await domain.streaming.set(req.params.id, { subject, kind: req.principal.kind }, { to: req.body?.to, on: req.body?.on }));
res.json(await domain.streaming.set(req.params.id, { subject, kind: req.principal.kind }, { to: req.body?.to, on: req.body?.on }));
}));
}));
- // ── E-stop ────────────────────────────────────────────────────────────────────────────────────
- r.post('/robots/:id/estop', control, wrap(async (req, res) => {
- const { robot } = await member(req, req.params.id, ['owner', 'operator']);
- if (req.principal.kind === 'service') requireControlCap(req);
- if (!robot.estop_latched) await domain.estop.set(req.params.id, { latched: true, by: me(req) || req.principal.sub, principalKind: req.principal.kind });
- await hub.pushEstop(req.params.id, true, me(req) || req.principal.sub);
- hub.broadcast(req.params.id);
- res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
- }));
- r.post('/robots/:id/estop/clear', control, wrap(async (req, res) => {
- const robot = await owner(req, req.params.id);
- await domain.estop.clear(req.params.id, robot.owner_subject);
- await hub.pushEstop(req.params.id, false, robot.owner_subject);
- hub.broadcast(req.params.id);
- res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
- }));
-
// ── A Drive ticket for an embedded panel (server/control-tickets.js) ───────────────────────────
// ── A Drive ticket for an embedded panel (server/control-tickets.js) ───────────────────────────
// The page that frames a robot's panel (a Live channel bound to it) asks for a person who is signed in there: the
// The page that frames a robot's panel (a Live channel bound to it) asks for a person who is signed in there: the
// frame cannot see an openvibe.bot session, and the popup Drive took (its own sign-in) was a step too many. The
// frame cannot see an openvibe.bot session, and the popup Drive took (its own sign-in) was a step too many. The
@@ -949,7 +930,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -949,7 +930,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const st = await hub.robotState(robot.id);
const st = await hub.robotState(robot.id);
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
res.json({
res.json({
- robot_id: robot.id, name: robot.name, profile_id: robot.profile_id, online: st.online, estop: st.estop, battery: st.battery, sensors,
+ robot_id: robot.id, name: robot.name, profile_id: robot.profile_id, online: st.online, battery: st.battery, sensors,
steps: stepHelp(profile), max_steps: AGENT_MAX_STEPS, max_seconds: AGENT_MAX_S,
steps: stepHelp(profile), max_steps: AGENT_MAX_STEPS, max_seconds: AGENT_MAX_S,
// S7: the commands an agent drove through Bot, as steps ("Drive forward 1 m": passed). A command that
// S7: the commands an agent drove through Bot, as steps ("Drive forward 1 m": passed). A command that
// named its stream is filterable with ?agent_stream=ags_…; the list never carries a second way to command.
// named its stream is filterable with ?agent_stream=ags_…; the list never carries a second way to command.
@@ -1048,7 +1029,7 @@ function commandRefusalStatus(code) {
@@ -1048,7 +1029,7 @@ function commandRefusalStatus(code) {
if (code === 'bot.robot_not_found') return 404;
if (code === 'bot.robot_not_found') return 404;
if (code === 'bot.not_an_operator' || code === 'bot.read_only' || code === 'bot.forbidden' || code === 'bot.not_your_turn') return 403;
if (code === 'bot.not_an_operator' || code === 'bot.read_only' || code === 'bot.forbidden' || code === 'bot.not_your_turn') return 403;
if (code === 'bot.cooldown' || code === 'bot.turn_budget') return 429;
if (code === 'bot.cooldown' || code === 'bot.turn_budget') return 429;
- if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;
+ if (code === 'bot.device_offline' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;
return 422; // unknown_command, command_not_allowed, unknown_actuator, invalid_input, text_too_long
return 422; // unknown_command, command_not_allowed, unknown_actuator, invalid_input, text_too_long
}
}
@@ -178,11 +178,11 @@ function createApp(opts = {}) {
@@ -178,11 +178,11 @@ function createApp(opts = {}) {
};
};
app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, onEvent, log }));
app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, onEvent, log }));
- // ── OpenVibe.Live's kill switch reaches the robot (Agent Streams S7) ──
- // When an agent stream ends, Live e-stops the robot its channel is bound to. The call is loopback-only (nginx
- // answers 404 for /internal/) and carries the deployment's shared secret; it can only latch the e-stop, never
- // clear it. No person token, no chat token, and no route in the public API reaches this.
- app.post('/internal/robots/:id/estop', express.json({ limit: '4kb' }), async (req, res) => {
+ // ── OpenVibe.Live's stop reaches the robot (Agent Streams S7) ──
+ // When an agent stream ends, Live sends the robot its own Stop (halt). The call is loopback-only (nginx answers
+ // 404 for /internal/) and carries the deployment's shared secret. No person token, no chat token, and no route
+ // in the public API reaches this.
+ app.post('/internal/robots/:id/stop', express.json({ limit: '4kb' }), async (req, res) => {
const secret = String(process.env.BOT_KILL_SECRET || '');
const secret = String(process.env.BOT_KILL_SECRET || '');
const given = String(req.get('x-ov-bot-kill') || '');
const given = String(req.get('x-ov-bot-kill') || '');
const a = Buffer.from(given);
const a = Buffer.from(given);
@@ -195,10 +195,11 @@ function createApp(opts = {}) {
@@ -195,10 +195,11 @@ function createApp(opts = {}) {
try {
try {
const robot = await domain.robots.get(id);
const robot = await domain.robots.get(id);
if (!robot) return res.status(404).json({ error: 'bot.robot_not_found' });
if (!robot) return res.status(404).json({ error: 'bot.robot_not_found' });
- if (!robot.estop_latched) await domain.estop.set(id, { latched: true, by: 'openvibe.live', principalKind: 'service' });
- return res.json({ ok: true, robot_id: id, estop: { latched: true } });
+ await hub.operatorCommand(id, { kind: 'user', subject: robot.owner_subject }, robot.owner_subject,
+ { id: `live-stop-${Date.now()}`, kind: 'halt', value: {}, ms: 5000 });
+ return res.json({ ok: true, robot_id: id, stop: true });
} catch (err) {
} catch (err) {
- log.error('[Bot] the kill switch failed:', err && err.message);
+ log.error('[Bot] the stop switch failed:', err && err.message);
return res.status(500).json({ error: 'bot.kill_failed' });
return res.status(500).json({ error: 'bot.kill_failed' });
}
}
});
});
@@ -7,12 +7,12 @@
@@ -7,12 +7,12 @@
*
*
* A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more
* A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more
* robots, holding one rotatable, revocable credential (32 random bytes, stored hashed, shown once). The
* robots, holding one rotatable, revocable credential (32 random bytes, stored hashed, shown once). The
- * gate applies, in order: the robot exists, its profile takes the command kind, the e-stop is clear, the
- * caller's role, the per-role command allowlist, the access policy (private/invite: owner + operators;
- * queue: the active turn holder too), the device being online (commands are never queued), cooldowns and
- * the per-turn budget, and finally builds the value from the profile's `commands` (names, shapes, ranges,
- * clamped by the owner's limits). `halt` passes the e-stop, the allowlist, cooldowns and the budget: a
- * stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.
+ * gate applies, in order: the robot exists, its profile takes the command kind, the caller's role, the
+ * per-role command allowlist, the access policy (private/invite: owner + operators; queue: the active
+ * turn holder too), the device being online (commands are never queued), cooldowns and the per-turn
+ * budget, and finally builds the value from the profile's `commands` (names, shapes, ranges, clamped by
+ * the owner's limits). `halt` passes the allowlist, cooldowns and the budget: a Stop is never refused to
+ * someone who may drive. Every decision — allowed or refused — is audited.
*/
*/
const {
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable, isRobotId,
@@ -89,7 +89,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -89,7 +89,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,
access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,
commissioned: r.commissioned_at ? { at: iso(new Date(r.commissioned_at).getTime()), by: r.commissioned_by || null } : null,
commissioned: r.commissioned_at ? { at: iso(new Date(r.commissioned_at).getTime()), by: r.commissioned_by || null } : null,
- estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
} : null);
} : null);
// A device is presented without any hash: the credential and the publish key are shown once only.
// A device is presented without any hash: the credential and the publish key are shown once only.
@@ -153,9 +152,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -153,9 +152,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
// ── Operators, Mods and sole control (Maticus's 2026-11-12 LOOI spec) ─────────────────────────
// ── 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.
+ // The owner always has all of this. A Mod may switch Operators (never Mods or the owner) and grant or
+ // clear sole control. 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']);
const GRANT_ROLES = new Set(['owner', 'mod', 'operator']);
async function operatorStateOf(robotId, subject, q = db) {
async function operatorStateOf(robotId, subject, q = db) {
@@ -1193,34 +1192,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1193,34 +1192,6 @@ 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 (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 or a Mod clears the panel e-stop');
- return writeEstop(robotId, { latched: true, by, principalKind });
- }
- async function writeEstop(robotId, { latched, by, principalKind }) {
- const robot = await getRobot(robotId);
- if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
- const at = iso(now());
- const updated = await db.tx(async (t) => {
- const row = await t.maybe('UPDATE robots SET estop_latched = $2, estop_by = $3, estop_at = CASE WHEN $2 THEN $4::timestamptz ELSE NULL END, updated_at = $4 WHERE id = $1 RETURNING *',
- [robotId, latched, latched ? (by || null) : null, at]);
- await emit(t, latched ? 'bot.estop.set' : 'bot.estop.cleared', { type: 'robot', id: robotId },
- { robot_id: robotId, by: latched ? (by || null) : null, principal_kind: principalKind });
- return row;
- });
- return updated;
- }
- 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 !== 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) ───────────────────────────────────────────────────────────
const turnMs = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_ms) > 0 ? Number(l.turn_ms) : config.control.queueTurnMs; };
const turnMs = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_ms) > 0 ? Number(l.turn_ms) : config.control.queueTurnMs; };
const turnBudget = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_budget) > 0 ? Number(l.turn_budget) : config.control.queueTurnBudget; };
const turnBudget = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_budget) > 0 ? Number(l.turn_budget) : config.control.queueTurnBudget; };
@@ -1592,7 +1563,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1592,7 +1563,6 @@ 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; 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;
const member = await operatorStateOf(robotId, subject);
const member = await operatorStateOf(robotId, subject);
@@ -1703,7 +1673,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1703,7 +1673,6 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
deskLeases,
deskLeases,
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
- estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff, onResume, epoch: epochOf, giveBack, OWNER_IDLE_MS },
queue: { join: joinQueue, leave: leaveQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues, onHandoff, onResume, epoch: epochOf, giveBack, OWNER_IDLE_MS },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
@@ -5,9 +5,7 @@
@@ -5,9 +5,7 @@
*
*
* bot.robot.online a device authenticated and is serving its robot
* bot.robot.online a device authenticated and is serving its robot
* bot.robot.offline no heartbeat within 2 missed intervals + the grace window
* bot.robot.offline no heartbeat within 2 missed intervals + the grace window
- * bot.estop.set the e-stop latched (by the owner, an operator or the device)
- * bot.estop.cleared the owner cleared the latched e-stop
- * bot.command.refused the gate refused a command (role, allowlist, limits, policy, e-stop, cooldown)
+ * bot.command.refused the gate refused a command (role, allowlist, limits, policy, cooldown)
*
*
* emitIn(t, …) runs inside the transaction that makes the change, so an event exists if and only if its
* emitIn(t, …) runs inside the transaction that makes the change, so an event exists if and only if its
* change committed. The relay publishes with Bot's service token (events.event.publish) only when
* change committed. The relay publishes with Bot's service token (events.event.publish) only when
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* machine that opens a socket. Unlike the simulator it owns a real `kind: 'server'` device row
* machine that opens a socket. Unlike the simulator it owns a real `kind: 'server'` device row
* (domain.devices.ensureServerDevice), so the owner sees the camera as a device of the robot.
* (domain.devices.ensureServerDevice), so the owner sees the camera as a device of the robot.
*
*
- * It passes the same gate: it answers hello/config, acks commands, mirrors the e-stop and stops at a motion
+ * It passes the same gate: it answers hello/config, acks commands and stops at a motion
* command's deadline. A `ptz` command is translated to ONVIF ContinuousMove (Stop for zeros/halt), and a
* command's deadline. A `ptz` command is translated to ONVIF ContinuousMove (Stop for zeros/halt), and a
* GetStatus after a move is parsed into the camera readout carried by telemetry:
* GetStatus after a move is parsed into the camera readout carried by telemetry:
*
*
@@ -93,7 +93,7 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
@@ -93,7 +93,7 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
// ── Attach and lifecycle ──────────────────────────────────────────────────────────────────────
// ── Attach and lifecycle ──────────────────────────────────────────────────────────────────────
function start(robot, profile, camera, device) {
function start(robot, profile, camera, device) {
- const cam = { robotId: robot.id, robot, device, camera, profile, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, readout: null, reachable: false, rttMs: null, clockOffsetMs: 0, at: now() };
+ const cam = { robotId: robot.id, robot, device, camera, profile, link: null, timers: [], stopTimer: null, readout: null, reachable: false, rttMs: null, clockOffsetMs: 0, at: now() };
cameras.set(robot.id, cam);
cameras.set(robot.id, cam);
connect(cam);
connect(cam);
cam.timers.push(every(heartbeatMs, () => { if (cam.link) cam.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: cam.rttMs }); }));
cam.timers.push(every(heartbeatMs, () => { if (cam.link) cam.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: cam.rttMs }); }));
@@ -150,12 +150,10 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
@@ -150,12 +150,10 @@ function createOnvif({ config, domain, hub, now = () => Date.now(), log = consol
// ── Frames from the hub ───────────────────────────────────────────────────────────────────────
// ── Frames from the hub ───────────────────────────────────────────────────────────────────────
function onFrame(cam, f) {
function onFrame(cam, f) {
- if (f.type === 'config') { cam.estop = !!f.estop_latched; if (cam.estop) halt(cam); return; }
- if (f.type === 'estop') { cam.estop = !!f.latched; if (cam.estop) halt(cam); return; }
+ if (f.type === 'config' || f.type === 'estop') return; // no remote latch: Stop is a command
if (f.type !== 'command') return;
if (f.type !== 'command') return;
const reply = (fields) => cam.link && cam.link.deliver({ id: f.id, ...fields });
const reply = (fields) => cam.link && cam.link.deliver({ id: f.id, ...fields });
if (f.kind === 'halt') { halt(cam); return reply({ type: 'ack' }); }
if (f.kind === 'halt') { halt(cam); return reply({ type: 'ack' }); }
- if (cam.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });
if (f.kind !== 'ptz') return reply({ type: 'nack', fault_code: 'bot.unknown_command' });
if (f.kind !== 'ptz') return reply({ type: 'nack', fault_code: 'bot.unknown_command' });
return ptz(cam, f, reply);
return ptz(cam, f, reply);
}
}
@@ -73,9 +73,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
@@ -73,9 +73,9 @@ function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
* `index` < count when given), tone { hz: [min, max] }, bool
* `index` < count when given), tone { hz: [min, max] }, bool
* say { max_chars }
* say { max_chars }
* display { modes: [text|face|image_png_b64], faces: [...] (with face), max_chars }
* display { modes: [text|face|image_png_b64], faces: [...] (with face), max_chars }
- * halt {} every profile takes it; the gate never refuses it for the e-stop
+ * halt {} every profile takes it: the machine's own Stop
* A driver's side action that the closed `commands` schema cannot name (LOOI's headlight) is declared in the open
* A driver's side action that the closed `commands` schema cannot name (LOOI's headlight) is declared in the open
- * `mapping.actions`; the gate still decides the role, allow list, e-stop and value.
+ * `mapping.actions`; the gate still decides the role, allow list and value.
*/
*/
const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
@@ -220,7 +220,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -220,7 +220,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
send(data) { setImmediate(() => { if (ws.readyState === OPEN) onFrame(JSON.parse(data)); }); },
send(data) { setImmediate(() => { if (ws.readyState === OPEN) onFrame(JSON.parse(data)); }); },
close() { if (ws.readyState !== OPEN) return; ws.readyState = 3; onClose(); },
close() { if (ws.readyState !== OPEN) return; ws.readyState = 3; onClose(); },
};
};
- const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
+ const conn = { ws, sim: true, seq: 0, pending: false, device: null, online: false, rttMs: null, telemetry: null, status: null, backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
attachDevice(conn, device);
attachDevice(conn, device);
sendHello(conn);
sendHello(conn);
const ready = sendConfig(conn).then(() => bringOnline(conn)).catch((e) => log.warn(`[Bot] simulator ${device.id}: ${e.message}`));
const ready = sendConfig(conn).then(() => bringOnline(conn)).catch((e) => log.warn(`[Bot] simulator ${device.id}: ${e.message}`));
@@ -236,9 +236,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -236,9 +236,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// ── Device side ───────────────────────────────────────────────────────────────────────────────
// ── Device side ───────────────────────────────────────────────────────────────────────────────
async function onDeviceSocket(ws, req) {
async function onDeviceSocket(ws, req) {
// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled
// backlog: while authentication (credential or pair) is in progress, frames wait here and are handled
- // in order once it resolves, so a device's first status/estop_state is never lost or answered not_paired.
- // deviceEstop: robot_id → the device's own last estop_state report (a report only, never a clear).
- const conn = { ws, seq: 0, pending: true, device: null, online: false, rttMs: null, telemetry: null, status: null, deviceEstop: new Map(), backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
+ // in order once it resolves, so a device's first status/stop report is never lost or answered not_paired.
+ const conn = { ws, seq: 0, pending: true, device: null, online: false, rttMs: null, telemetry: null, status: null, backlog: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
ws.conn = conn;
ws.conn = conn;
ws.on('message', (raw) => onDeviceFrame(conn, raw));
ws.on('message', (raw) => onDeviceFrame(conn, raw));
ws.on('close', () => { conn.backlog = null; dropDevice(conn); });
ws.on('close', () => { conn.backlog = null; dropDevice(conn); });
@@ -360,7 +359,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -360,7 +359,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS, SCREEN_KIND])] : base;
const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS, SCREEN_KIND])] : base;
sendFrame(conn, 'config', {
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
- estop_latched: robot ? !!robot.estop_latched : false,
+ estop_latched: false, // the field stays for older devices; Bot has no remote stop latch
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
// What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Link
// What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Link
@@ -368,17 +367,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -368,17 +367,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
roles: json(conn.device.roles, []),
roles: json(conn.device.roles, []),
});
});
}
}
- /** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist, or the latch moved). */
+ /** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist). */
async function refreshConfig(robotId) {
async function refreshConfig(robotId) {
for (const conn of [...deviceConns.values()]) {
for (const conn of [...deviceConns.values()]) {
if (conn.online && deviceRobotIds(conn.device).includes(robotId)) await sendConfig(conn);
if (conn.online && deviceRobotIds(conn.device).includes(robotId)) await sendConfig(conn);
}
}
}
}
- /** Tell the robot's devices the latch moved: the `estop` frame acts at once, then `config` carries the new state. */
- async function pushEstop(robotId, latched, by) {
- sendToRobotDevices(robotId, { type: 'estop', latched, by, at: iso(now()) });
- await refreshConfig(robotId);
- }
/** The online/offline event and last_seen are a real machine's: a simulator (no devices row) reports neither. */
/** The online/offline event and last_seen are a real machine's: a simulator (no devices row) reports neither. */
function reportOnline(conn, online) {
function reportOnline(conn, online) {
if (conn.sim) return Promise.resolve();
if (conn.sim) return Promise.resolve();
@@ -628,7 +622,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -628,7 +622,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return conn.device.node_principal ? persistDeclared(conn, msg) : undefined;
return conn.device.node_principal ? persistDeclared(conn, msg) : undefined;
case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
case 'ack': case 'nack': return onAck(conn, msg);
case 'ack': case 'nack': return onAck(conn, msg);
- case 'estop_state': return onEstopState(conn, msg);
+ case 'estop_state': return onStopReport(conn, msg);
case 'voice_ask': return onVoiceAsk(conn, msg);
case 'voice_ask': return onVoiceAsk(conn, msg);
// Machine-to-machine relay (Contracts 0.409.0) and Network's agent tasks to a machine.
// Machine-to-machine relay (Contracts 0.409.0) and Network's agent tasks to a machine.
case 'relay.open': return onRelayOpen(conn, msg);
case 'relay.open': return onRelayOpen(conn, msg);
@@ -731,30 +725,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -731,30 +725,15 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
}
}
- // 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 Bot's panel latch stands until the owner or a Mod clears it.
- async function onEstopState(conn, msg) {
+ // A device may report a Stop held on the machine itself (the tray's own button or a driver's local stop).
+ // That stays local: Bot does not latch it, gate on it, clear it or audit it. The field keeps its old name
+ // because devices already send it; the tray itself decides when the robot moves again.
+ async function onStopReport(conn, msg) {
const attached = deviceRobotIds(conn.device);
const attached = deviceRobotIds(conn.device);
- const latched = !!msg.latched;
if (msg.robot_id != null && !attached.includes(msg.robot_id)) {
if (msg.robot_id != null && !attached.includes(msg.robot_id)) {
- await domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });
return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');
return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');
}
}
- // local_stop: the latch is held on the machine itself (openvibe-link stop, a driver's own stop button), which
- // only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.
- const local = latched && msg.local_stop === true;
- const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;
- for (const robotId of msg.robot_id != null ? [msg.robot_id] : attached) {
- conn.deviceEstop.set(robotId, { latched, local, reason, at: iso(now()) });
- if (latched) {
- const robot = await domain.robots.get(robotId);
- if (robot && !robot.estop_latched) {
- await domain.estop.set(robotId, { latched: true, by: conn.device.id, principalKind: 'device' });
- await refreshConfig(robotId);
- }
- }
- broadcast(robotId);
- }
+ return undefined;
}
}
// ── Operator side ─────────────────────────────────────────────────────────────────────────────
// ── Operator side ─────────────────────────────────────────────────────────────────────────────
@@ -796,8 +775,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -796,8 +775,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'join': return onJoin(conn, msg);
case 'join': return onJoin(conn, msg);
case 'leave': unsubscribe(conn); conn.robotId = null; return undefined;
case 'leave': unsubscribe(conn); conn.robotId = null; return undefined;
case 'command': return onOperatorCommand(conn, msg);
case '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 '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}`);
@@ -1148,31 +1125,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1148,31 +1125,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return undefined;
return undefined;
}
}
- async 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) {
- 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 {
- 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) ───────────────────────────────────────────────────────────
// ── Watcher side (read-only, anonymous) ───────────────────────────────────────────────────────────
/** The caller's address as Express decides it with the same trust proxy hop count (config.trustProxy). */
/** The caller's address as Express decides it with the same trust proxy hop count (config.trustProxy). */
function clientAddress(req) {
function clientAddress(req) {
@@ -1358,12 +1310,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1358,12 +1310,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
battery: batteryFraction(telemetry),
battery: batteryFraction(telemetry),
telemetry,
telemetry,
status: online && conn.status ? conn.status : null,
status: online && conn.status ? conn.status : null,
- device_estop: online ? (conn.deviceEstop.get(robotId) || null) : null,
};
};
const robot = await domain.robots.get(robotId);
const robot = await domain.robots.get(robotId);
const state = {
const state = {
robot_id: robotId, online,
robot_id: robotId, online,
- estop: robot ? { latched: !!robot.estop_latched, by: robot.estop_by || null, at: robot.estop_at ? iso(new Date(robot.estop_at).getTime()) : null } : null,
...live,
...live,
queue: null,
queue: null,
};
};
@@ -1384,7 +1334,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1384,7 +1334,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return [...keys];
return [...keys];
}
}
/**
/**
- * What a watcher may see: online, the latch, latency, battery and the readouts' sensor values. Never the
+ * What a watcher may see: online, latency, battery and the readouts' sensor values. Never the
* queue, a subject, the device's ids, its status or the rest of its telemetry.
* queue, a subject, the device's ids, its status or the rest of its telemetry.
*/
*/
function publicState(state, readouts) {
function publicState(state, readouts) {
@@ -1399,7 +1349,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1399,7 +1349,6 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
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 {
robot_id: state.robot_id, online: state.online,
robot_id: state.robot_id, online: state.online,
- estop: { latched: !!(state.estop && state.estop.latched) },
latency_ms: state.latency_ms, battery: state.battery,
latency_ms: state.latency_ms, battery: state.battery,
telemetry: t ? { sensors: picked, ...(sim ? { sim } : {}) } : null,
telemetry: t ? { sensors: picked, ...(sim ? { sim } : {}) } : null,
};
};
@@ -1464,7 +1413,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1464,7 +1413,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, closeWatchers, dropSubject, operatorCommand,
operatorActivity: operatorActivityOf,
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
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* Robot routines (plan T15 "schedules and automations"): an owner writes a robot's steps in plain lines, Bot runs them
* Robot routines (plan T15 "schedules and automations"): an owner writes a robot's steps in plain lines, Bot runs them
* on demand, on a schedule, or when the robot comes online. Every step passes the same gate as a person's command, as
* on demand, on a schedule, or when the robot comes online. Every step passes the same gate as a person's command, as
- * the robot's owner (hub.operatorCommand): the robot's limits, cooldowns and e-stop apply, and the audit has each one.
+ * the robot's owner (hub.operatorCommand): the robot's limits and cooldowns apply, and the audit has each one.
*
*
* parseSteps(text, profile) → { steps } or throws BotError 422 with the line that is wrong. One step per line:
* parseSteps(text, profile) → { steps } or throws BotError 422 with the line that is wrong. One step per line:
* forward [seconds] [speed] back [s] [speed] left [s] [speed] right [s] [speed] stop
* forward [seconds] [speed] back [s] [speed] left [s] [speed] right [s] [speed] stop
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
* POST /api/v1/robots/:id/steps); at most AGENT_MAX_STEPS steps and AGENT_MAX_S seconds, nothing stored.
* POST /api/v1/robots/:id/steps); at most AGENT_MAX_STEPS steps and AGENT_MAX_S seconds, nothing stored.
*
*
* A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero
* A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero
- * drive ends it. A refused or failed command stops the routine there (an e-stop, the robot going offline, a cooldown
+ * drive ends it. A refused or failed command stops the routine there (the robot going offline, a cooldown
* the owner set too long); the reason is kept in last_result. One routine runs per robot at a time.
* the owner set too long); the reason is kept in last_result. One routine runs per robot at a time.
*/
*/
const { BotError, prefixedId, iso, text } = require('./util');
const { BotError, prefixedId, iso, text } = require('./util');
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* The simulated device (ADR-043 decision 3): an in-process stand-in for a robot whose profile's driver is
* The simulated device (ADR-043 decision 3): an in-process stand-in for a robot whose profile's driver is
* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the
* `sim` (sim.rover), so the panel has something to drive before any hardware exists. It is attached to the
* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers
* hub without a socket or a credential (hub.attachSim) and passes the same gate as real hardware: it answers
- * `hello`/`config`, sends heartbeats and telemetry, acks each command, mirrors the e-stop and stops by itself
+ * `hello`/`config`, sends heartbeats and telemetry, acks each command and stops by itself
* at a motion command's `deadline_ms` unless a newer command arrived first (the Link's deadman).
* at a motion command's `deadline_ms` unless a newer command arrived first (the Link's deadman).
*
*
* attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false
* attach(robotId) start the robot's simulator if its profile is a `sim` one (idempotent; pass the profile if loaded) → true/false
@@ -36,7 +36,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -36,7 +36,7 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
function start(robot) {
function start(robot) {
if (sims.has(robot.id)) return sims.get(robot.id);
if (sims.has(robot.id)) return sims.get(robot.id);
const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };
const device = { id: `dev_sim_${robot.id}`, robot_ids: [robot.id], kind: 'server', name: 'simulator' };
- const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };
+ const sim = { robotId: robot.id, device, link: null, timers: [], stopTimer: null, drive: {}, other: {}, battery: 100, pose: { ...world.START }, bumped: false, headlights: null, horn: null, hornUntil: 0, at: now(), sentAt: 0, ticks: 0 };
sims.set(robot.id, sim);
sims.set(robot.id, sim);
connect(sim);
connect(sim);
sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));
sim.timers.push(every(heartbeatMs, () => sim.link && sim.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: 1 })));
@@ -55,12 +55,10 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
@@ -55,12 +55,10 @@ function createSimulator({ config, domain, hub, now = () => Date.now(), log = co
}
}
function onFrame(sim, f) {
function onFrame(sim, f) {
- if (f.type === 'config') { sim.estop = !!f.estop_latched; if (sim.estop) halt(sim); return; }
- if (f.type === 'estop') { sim.estop = !!f.latched; if (sim.estop) halt(sim); return; }
+ if (f.type === 'config' || f.type === 'estop') return; // no remote latch: Stop is a command
if (f.type !== 'command') return;
if (f.type !== 'command') return;
const reply = (fields) => sim.link && sim.link.deliver({ id: f.id, ...fields });
const reply = (fields) => sim.link && sim.link.deliver({ id: f.id, ...fields });
if (f.kind === 'halt') { halt(sim); return reply({ type: 'ack' }); }
if (f.kind === 'halt') { halt(sim); return reply({ type: 'ack' }); }
- if (sim.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });
if (f.kind === 'drive') {
if (f.kind === 'drive') {
sim.drive = { ...f.value };
sim.drive = { ...f.value };
if (sim.stopTimer) clearTimeout(sim.stopTimer);
if (sim.stopTimer) clearTimeout(sim.stopTimer);
@@ -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 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>`;
+<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns. A Stop is one optional button that sends the robot's own stop command; the tray's own Stop stays 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 });
}
}
@@ -235,7 +235,7 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rol
@@ -235,7 +235,7 @@ function renderRobotPage({ config, robot, model, owner = null, state = null, rol
<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>
<p class="muted">${esc(POLICY_WORDS[robot.access_policy] || '')}</p>
${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</div>
${member || turn ? `<p class="dir-actions">${member || turn}</p>` : ''}</div>
<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>
<div class="dir-live"><iframe src="/panel/${esc(robot.id)}/embed" title="${esc(robot.name)}: live panel" loading="lazy" allow="autoplay; gamepad"></iframe></div>
-<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate and e-stop as on its own panel.</p>`;
+<p class="muted dir-note">Watching is open to everyone; driving follows the owner's rules and passes the same gate as on its own panel.</p>`;
return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });
return shell({ config, path: `/r/${robot.id}`, title: robot.name, description: `${robot.name}, a ${model.name} on OpenVibe.Bot. ${POLICY_WORDS[robot.access_policy] || ''}`.trim(), body });
}
}
@@ -89,7 +89,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
@@ -89,7 +89,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
<section class="card"><h2>What a driver is</h2>
<section class="card"><h2>What a driver is</h2>
<p>A driver is a <b>separate executable</b> the OpenVibe Link core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p>
<p>A driver is a <b>separate executable</b> the OpenVibe Link core starts. It can be written in any language. The two speak <b>JSON lines over stdin/stdout</b>: one JSON object per line, UTF-8, in each direction. The core never links driver code; the robot drivers are Python 3 because their hardware libraries are.</p>
<p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>
<p>Nothing but protocol lines may go to stdout. Send diagnostics to <b>stderr</b> (the Python runtime points file descriptor 1 at stderr and keeps a private copy for the protocol, because hardware libraries print).</p>
-<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the e-stop and the deadman, and forwards already-validated commands to a driver.</p></section>
+<p>The Link core itself speaks a different protocol to openvibe.bot (<code>bot.device-message@1</code>, ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')}); a driver never speaks it. The core is the one that pairs, holds the control link, applies the deadman and the local Stop, and forwards already-validated commands to a driver.</p></section>
<section class="card"><h2>The three connection kinds</h2>
<section class="card"><h2>The three connection kinds</h2>
<p>The robot's profile carries a <code>kind</code>, which says where the Link runs relative to the robot:</p>
<p>The robot's profile carries a <code>kind</code>, which says where the Link runs relative to the robot:</p>
@@ -108,7 +108,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
@@ -108,7 +108,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
<li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li>
<li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li>
<li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li>
<li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li>
</ol>
</ol>
-<p>A driver that exits is restarted with exponential backoff (1 s doubling to 30 s, reset after a minute of uptime). It starts stopped; if the e-stop or the local kill switch is latched, the core sends <code>estop</code> right after <code>hello</code>. A driver that does not describe itself within 15 s, or stops reading stdin, is killed.</p></section>
+<p>A driver that exits is restarted with exponential backoff (1 s doubling to 30 s, reset after a minute of uptime). It starts stopped; if a local Stop is latched, the core sends <code>stop</code> right after <code>hello</code>. A driver that does not describe itself within 15 s, or stops reading stdin, is killed.</p></section>
<section class="card"><h2>Messages</h2>
<section class="card"><h2>Messages</h2>
<p>Every message is one JSON object on one line with an <code>op</code> field. Command <code>value</code> has already been validated and clamped to the owner's limits by the core, and <code>deadline_ms</code> is relative to receipt and already capped.</p>
<p>Every message is one JSON object on one line with an <code>op</code> field. Command <code>value</code> has already been validated and clamped to the owner's limits by the core, and <code>deadline_ms</code> is relative to receipt and already capped.</p>
@@ -117,8 +117,6 @@ ${message('hello', 'start-up: the driver answers describe; then sets up unless p
@@ -117,8 +117,6 @@ ${message('hello', 'start-up: the driver answers describe; then sets up unless p
${message('command', 'act, then answer ack or nack with the same id', '{ "op": "command", "id": "cmd_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "kind": "drive", "value": { "throttle": 0.5, "steer": 0 }, "deadline_ms": 300 }')}
${message('command', 'act, then answer ack or nack with the same id', '{ "op": "command", "id": "cmd_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "kind": "drive", "value": { "throttle": 0.5, "steer": 0 }, "deadline_ms": 300 }')}
${message('heartbeat', 'the core clock; note the time for the deadman', '{ "op": "heartbeat", "t": 1728384000000 }')}
${message('heartbeat', 'the core clock; note the time for the deadman', '{ "op": "heartbeat", "t": 1728384000000 }')}
${message('stop', 'stop every actuator (idempotent, safe at any time)', '{ "op": "stop" }')}
${message('stop', 'stop every actuator (idempotent, safe at any time)', '{ "op": "stop" }')}
-${message('estop', 'stop every actuator and refuse motion until resume', '{ "op": "estop" }')}
-${message('resume', 'accept motion again', '{ "op": "resume" }')}
<h3>Driver → core</h3>
<h3>Driver → core</h3>
${message('describe', 'the driver names itself and its capabilities, before hardware', '{ "op": "describe", "driver": "my_arm", "version": "0.1.0", "protocol": 1, "capabilities": { "actuator": { "names": ["shoulder", "gripper"] } }, "motion_kinds": ["drive", "actuator"] }')}
${message('describe', 'the driver names itself and its capabilities, before hardware', '{ "op": "describe", "driver": "my_arm", "version": "0.1.0", "protocol": 1, "capabilities": { "actuator": { "names": ["shoulder", "gripper"] } }, "motion_kinds": ["drive", "actuator"] }')}
${message('ready', 'setup finished, every actuator stopped', '{ "op": "ready" }')}
${message('ready', 'setup finished, every actuator stopped', '{ "op": "ready" }')}
@@ -136,11 +134,11 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
@@ -136,11 +134,11 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
<li>when its <b>stdin closes</b> (EOF), then exit;</li>
<li>when its <b>stdin closes</b> (EOF), then exit;</li>
<li>when a <b>motion command's deadline passes</b> without a newer command (its own timer to <code>deadline_ms</code>);</li>
<li>when a <b>motion command's deadline passes</b> without a newer command (its own timer to <code>deadline_ms</code>);</li>
<li>when it has <b>not heard a heartbeat for 1 s</b> (and refuse motion with <code>no_heartbeat</code> until they return);</li>
<li>when it has <b>not heard a heartbeat for 1 s</b> (and refuse motion with <code>no_heartbeat</code> until they return);</li>
-<li>on <code>stop</code>, <code>estop</code> and a <code>halt</code> command;</li>
+<li>on <code>stop</code> and a <code>halt</code> command;</li>
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
</ul>
</ul>
-<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
-<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-link resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
+<p><code>stop</code> must be idempotent and safe at any time, including before setup. A local Stop that your own hardware holds stays local: keep refusing motion until it is released there.</p>
+<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> Stop: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and only <code>openvibe-link resume</code> on that machine releases it. It works even when the Link is not answering and survives restarts. The tray's own Stop stays local. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
<section class="card"><h2>A minimal driver in Python</h2>
<section class="card"><h2>A minimal driver in Python</h2>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
@@ -211,11 +209,6 @@ for line in sys.stdin: # EOF means the core is gone: stop and
@@ -211,11 +209,6 @@ for line in sys.stdin: # EOF means the core is gone: stop and
last = time.monotonic()
last = time.monotonic()
elif m["op"] == "stop":
elif m["op"] == "stop":
stop()
stop()
- elif m["op"] == "estop":
- latched = True
- stop()
- elif m["op"] == "resume":
- latched = False
stop()`)}</section>
stop()`)}</section>
<section class="card"><h2>Test it</h2>
<section class="card"><h2>Test it</h2>
@@ -295,7 +288,7 @@ const WEBHOOK_VERIFY = `const { verifyDeliveryV2 } = require('openvibe-sdk/event
@@ -295,7 +288,7 @@ const WEBHOOK_VERIFY = `const { verifyDeliveryV2 } = require('openvibe-sdk/event
app.post('/robot-events', express.text({ type: 'application/json' }), (req, res) => {
app.post('/robot-events', express.text({ type: 'application/json' }), (req, res) => {
if (!verifyDeliveryV2(req.body, req.headers, process.env.ROBOT_WEBHOOK_SECRET)) return res.status(401).end();
if (!verifyDeliveryV2(req.body, req.headers, process.env.ROBOT_WEBHOOK_SECRET)) return res.status(401).end();
const event = JSON.parse(req.body); // { id, type, robot_id, occurred_at, data }
const event = JSON.parse(req.body); // { id, type, robot_id, occurred_at, data }
- if (event.type === 'bot.estop.set') console.log('robot', event.robot_id, 'stopped');
+ if (event.type === 'bot.robot.offline') console.log('robot', event.robot_id, 'is offline');
res.status(204).end();
res.status(204).end();
});`;
});`;
@@ -317,7 +310,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/step
@@ -317,7 +310,7 @@ const STEPS_CALL = `curl -X POST https://openvibe.bot/api/v1/robots/rob_…/step
function renderAutomationPage({ config }) {
function renderAutomationPage({ config }) {
const content = `
const content = `
-${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits, cooldowns and e-stop apply, and the audit records each command.` })}
+${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `A robot can move without anyone holding the controls, tell your own server what happened to it, and take orders from your OpenVibe.Actor agent. Every one of these goes through the same gate as a person at the panel: the robot's limits and cooldowns apply, and the audit records each command.` })}
<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2>
<section class="card" aria-labelledby="steps-h"><h2 id="steps-h">Steps</h2>
<p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>
<p>Routines and the agent both speak in steps, one per line, checked against the robot's own profile. A robot takes only what it has:</p>
@@ -328,7 +321,7 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:
@@ -328,7 +321,7 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:
<li><code>headlights #ff8800</code>, <code>horn A4 0.3</code>, <code>lights off</code>, <code>pan 0.5 1</code>: an actuator or a camera axis by its name in the profile, with a value and how long to hold it.</li>
<li><code>headlights #ff8800</code>, <code>horn A4 0.3</code>, <code>lights off</code>, <code>pan 0.5 1</code>: an actuator or a camera axis by its name in the profile, with a value and how long to hold it.</li>
<li><code>press horn</code>: one of its buttons.</li>
<li><code>press horn</code>: one of its buttons.</li>
</ul>
</ul>
-<p>A held step (a motion or a tone) is re-sent inside the device's deadman window and released at its end. A refused step (the e-stop, the robot going offline, a cooldown) stops the rest, with the reason.</p></section>
+<p>A held step (a motion or a tone) is re-sent inside the device's deadman window and released at its end. A refused step (the robot going offline, a cooldown) stops the rest, with the reason.</p></section>
<section class="card" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
<section class="card" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
<p>On a robot's panel, under <b>Routines</b>, the owner names a routine, writes its steps and picks when it runs:</p>
<p>On a robot's panel, under <b>Routines</b>, the owner names a routine, writes its steps and picks when it runs:</p>
@@ -341,13 +334,13 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:
@@ -341,13 +334,13 @@ ${kit.pageHead({ title: `Routines, webhooks and your agent`, icon: 'code', back:
<p>A routine runs as the owner. One routine runs on a robot at a time; removing a running routine stops it. Limits: 60 steps, 60 seconds a step, 10 minutes in all. The same list is <code>GET|POST /api/v1/robots/:id/routines</code>, <code>POST …/routines/:rid/run</code> and <code>DELETE …/routines/:rid</code>, for the owner or a service with <code>bot.robot.manage</code>.</p></section>
<p>A routine runs as the owner. One routine runs on a robot at a time; removing a running routine stops it. Limits: 60 steps, 60 seconds a step, 10 minutes in all. The same list is <code>GET|POST /api/v1/robots/:id/routines</code>, <code>POST …/routines/:rid/run</code> and <code>DELETE …/routines/:rid</code>, for the owner or a service with <code>bot.robot.manage</code>.</p></section>
<section class="card" aria-labelledby="hooks-h"><h2 id="hooks-h">Webhooks</h2>
<section class="card" aria-labelledby="hooks-h"><h2 id="hooks-h">Webhooks</h2>
-<p>Under <b>Webhooks</b>, the owner adds up to five https addresses, each for some of the robot's events: <code>bot.robot.online</code>, <code>bot.robot.offline</code>, <code>bot.estop.set</code>, <code>bot.estop.cleared</code>, <code>bot.command.refused</code>. Each hook has its own secret (<code>whsec_…</code>), shown once when it is made.</p>
+<p>Under <b>Webhooks</b>, the owner adds up to five https addresses, each for some of the robot's events: <code>bot.robot.online</code>, <code>bot.robot.offline</code>, <code>bot.command.refused</code>. Each hook has its own secret (<code>whsec_…</code>), shown once when it is made.</p>
<p>Bot posts <code>{ id, type, robot_id, occurred_at, data }</code>, signed the way OpenVibe.Events signs a delivery (<code>X-OpenVibe-Timestamp</code>, <code>X-OpenVibe-Signature-V2</code>), only to public addresses, never following a redirect. Anything but a 2xx is retried after 30 seconds, then 2, 10, 30 and 120 minutes. Check the signature before trusting a delivery:</p>
<p>Bot posts <code>{ id, type, robot_id, occurred_at, data }</code>, signed the way OpenVibe.Events signs a delivery (<code>X-OpenVibe-Timestamp</code>, <code>X-OpenVibe-Signature-V2</code>), only to public addresses, never following a redirect. Anything but a 2xx is retried after 30 seconds, then 2, 10, 30 and 120 minutes. Check the signature before trusting a delivery:</p>
${code(WEBHOOK_VERIFY)}
${code(WEBHOOK_VERIFY)}
<p class="muted">The API is <code>GET|POST /api/v1/robots/:id/webhooks</code> (the POST answers the secret) and <code>DELETE …/webhooks/:hid</code>.</p></section>
<p class="muted">The API is <code>GET|POST /api/v1/robots/:id/webhooks</code> (the POST answers the secret) and <code>DELETE …/webhooks/:hid</code>.</p></section>
<section class="card" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
<section class="card" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
-<p>On the panel, <b>Your agent</b> has one switch: <b>Let my OpenVibe.Actor agent drive this robot</b>. It is off until the owner turns it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "drive my rover forward for a second and turn its lights green", can see your robots (online, e-stop, battery, sensors) and run steps on this one, as you. It never acts for anyone else, and turning the switch off stops it at once.</p>
+<p>On the panel, <b>Your agent</b> has one switch: <b>Let my OpenVibe.Actor agent drive this robot</b>. It is off until the owner turns it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "drive my rover forward for a second and turn its lights green", can see your robots (online, battery, sensors) and run steps on this one, as you. It never acts for anyone else, and turning the switch off stops it at once.</p>
<p>Your own token runs steps directly, and answers when they end:</p>
<p>Your own token runs steps directly, and answers when they end:</p>
${code(STEPS_CALL)}
${code(STEPS_CALL)}
<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>
<p class="muted"><code>GET /api/v1/robots/:id/steps</code> answers the robot's state and the step words it takes. A call takes at most 20 steps and 30 seconds; the agent's calls are refused with <code>403 bot.agent_not_allowed</code> while the switch is off.</p></section>
@@ -28,7 +28,7 @@ const { pickBlock, foldBlock } = require('./short');
@@ -28,7 +28,7 @@ const { pickBlock, foldBlock } = require('./short');
const SITE_NAME = 'OpenVibe.Bot';
const SITE_NAME = 'OpenVibe.Bot';
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '
const DESCRIPTION = 'An open control panel for robots: pair a robot with one pasted command and drive it from any browser, '
- + 'with roles, limits and an emergency stop on every command.';
+ + 'with roles and limits on every command.';
const esc = showcase.esc;
const esc = showcase.esc;
/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */
/** The panel's own pages keep `default-src 'self'`; the front page also runs the OpenVibe Frame. */
@@ -82,7 +82,7 @@ function hero({ signedIn, config }) {
@@ -82,7 +82,7 @@ function hero({ signedIn, config }) {
eyebrow: 'OpenVibe.Bot · alpha',
eyebrow: 'OpenVibe.Bot · alpha',
title: 'Drive your robot', accent: 'from any browser.',
title: 'Drive your robot', accent: 'from any browser.',
lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '
lede: 'An open control panel for robots. Pair a robot with one pasted command, then drive it with a touch joystick, '
- + 'the keyboard or a gamepad. Every command passes one gate, with roles, limits and an emergency stop that only you clear.',
+ + 'the keyboard or a gamepad. Every command passes one gate, with roles and limits that you set.',
// Two, so they sit side by side on a phone; how it works is the steps a scroll below.
// Two, so they sit side by side on a phone; how it works is the steps a scroll below.
actions: signedIn
actions: signedIn
? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }]
? [{ label: 'Your robots', href: '/robots', primary: true }, { label: 'Try the panel', href: '/try' }]
@@ -135,7 +135,7 @@ function connectSection({ models }) {
@@ -135,7 +135,7 @@ function connectSection({ models }) {
label: g.label,
label: g.label,
html: kit.list([kit.row({ href: g.ms.length === 1 ? `/models/${g.ms[0].id}` : `/models#k-${g.k}`, title: g.label, sub: g.text, meta: plural(g.ms.length, 'model', 'models') })]),
html: kit.list([kit.row({ href: g.ms.length === 1 ? `/models/${g.ms[0].id}` : `/models#k-${g.k}`, title: g.label, sub: g.text, meta: plural(g.ms.length, 'model', 'models') })]),
}));
}));
- return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel, the same gate and the same emergency stop.',
+ return sec('connect', `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, 'However a robot reaches OpenVibe, it gets the same panel and the same gate.',
pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));
pickBlock({ key: 'connect', label: 'The ways to connect', head: `${n} ${groups.length === 1 ? 'way' : 'ways'} to connect, one panel`, panes }));
}
}
@@ -162,14 +162,14 @@ function moreSection({ config, kit: k, models }) {
@@ -162,14 +162,14 @@ 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 the owner or a Mod clears (the panel latch only); a halt always gets through.'],
+ ['/docs/profiles', 'Safe by default', 'Per-robot limits, cooldowns and a turn budget on every command; a Stop button that sends the robot its own stop; 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.'],
]);
]);
const automate = rows([
const automate = rows([
['/docs/automation', 'Routines', 'Plain step lines such as forward 2, wait 1, say Hello, on demand, on a schedule, or each time the robot comes online.'],
['/docs/automation', 'Routines', 'Plain step lines such as forward 2, wait 1, say Hello, on demand, on a schedule, or each time the robot comes online.'],
- ['/docs/automation', 'Signed webhooks', 'Online, offline, e-stop and refused commands sent to your own https address, signed like OpenVibe.Events deliveries.'],
+ ['/docs/automation', 'Signed webhooks', 'Online, offline and refused commands sent to your own https address, signed like OpenVibe.Events deliveries.'],
['https://openvibe.actor/', 'Your agent drives', 'Turn on Your agent and your OpenVibe.Actor agent can run steps on that robot, as you and nobody else.'],
['https://openvibe.actor/', 'Your agent drives', 'Turn on Your agent and your OpenVibe.Actor agent can run steps on that robot, as you and nobody else.'],
]);
]);
const computers = rows([
const computers = rows([
@@ -178,7 +178,7 @@ function moreSection({ config, kit: k, models }) {
@@ -178,7 +178,7 @@ function moreSection({ config, kit: k, models }) {
['/docs/computers', 'A desk live on your channel', 'When you choose, an agent\'s desk shows on your OpenVibe Live channel for up to 30 minutes, so people can watch it work.'],
['/docs/computers', 'A desk live on your channel', 'When you choose, an agent\'s desk shows on your OpenVibe Live channel for up to 30 minutes, so people can watch it work.'],
]);
]);
const api = showcase.code({
const api = showcase.code({
- id: 'api', title: '', lede: 'A REST API and the openvibe-sdk client, held to the same roles, limits and emergency stop as the panel. Profiles and kits are public reads.',
+ id: 'api', title: '', lede: 'A REST API and the openvibe-sdk client, held to the same roles and limits as the panel. Profiles and kits are public reads.',
samples: [
samples: [
{ label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createBotClient } from 'openvibe-sdk/bot';\n\nconst bot = createBotClient(createClient({ credentials: 'include' }));\nconst { robots } = await bot.robots.list();\n\n// Half speed ahead for 300 ms, same gate as the joystick\nawait bot.robots.command(robots[0].id, {\n kind: 'drive', value: { throttle: 0.5, steer: 0 }, ms: 300,\n});" },
{ label: 'JavaScript (openvibe-sdk)', lang: 'js', code: "import { createClient } from 'openvibe-sdk/core';\nimport { createBotClient } from 'openvibe-sdk/bot';\n\nconst bot = createBotClient(createClient({ credentials: 'include' }));\nconst { robots } = await bot.robots.list();\n\n// Half speed ahead for 300 ms, same gate as the joystick\nawait bot.robots.command(robots[0].id, {\n kind: 'drive', value: { throttle: 0.5, steer: 0 }, ms: 300,\n});" },
{ label: 'Public reads (curl)', lang: 'sh', code: `# Every robot model and its controls\ncurl ${base}/api/v1/profiles\n\n# The kits, with their parts lists\ncurl ${base}/api/v1/kits` },
{ label: 'Public reads (curl)', lang: 'sh', code: `# Every robot model and its controls\ncurl ${base}/api/v1/profiles\n\n# The kits, with their parts lists\ncurl ${base}/api/v1/kits` },
@@ -200,7 +200,7 @@ function moreSection({ config, kit: k, models }) {
@@ -200,7 +200,7 @@ function moreSection({ config, kit: k, models }) {
head: `<h2 id="more-h" class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>`,
head: `<h2 id="more-h" class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>`,
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
-${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
+${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same audit.</p>${automate}` })}
${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Link', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Link', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
@@ -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 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: 'How does it keep the robot safe?', a: 'Every command passes one gate with roles, per-robot limits, cooldowns and a turn budget. A Stop is one optional button that sends the robot its own stop; 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,8 +30,6 @@ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
@@ -30,8 +30,6 @@ 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 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'];
@@ -149,8 +147,9 @@ const DIRECTION_BUTTONS = [
@@ -149,8 +147,9 @@ const DIRECTION_BUTTONS = [
/**
/**
* The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest.
* The drive pad: a touch joystick for two axes (pointer events, see panel.js), hold buttons for the rest.
- * Its stick, Stop and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):
+ * Its stick and direction buttons are the panel's arrangeable parts (make your own controls, stage 2):
* the section around them flattens (`display: contents`) so each is a cell of the panel's grid.
* the section around them flattens (`display: contents`) so each is a cell of the panel's grid.
+ * Stop is its own optional part, off by default, added from the Arrange palette.
*/
*/
function drivePad(axes, enabled, attrs = {}, flatten = false) {
function drivePad(axes, enabled, attrs = {}, flatten = false) {
const x = STICK_X.find((a) => axes[a]);
const x = STICK_X.find((a) => axes[a]);
@@ -166,8 +165,7 @@ function drivePad(axes, enabled, attrs = {}, flatten = false) {
@@ -166,8 +165,7 @@ function drivePad(axes, enabled, attrs = {}, flatten = false) {
+ `<span class="stick-knob" data-knob></span></div>`
+ `<span class="stick-knob" data-knob></span></div>`
: '';
: '';
const pad = Object.keys(rest).length ? `<div class="pad" data-ov-part="pad" ${attrs.pad || ''}>${axisButtons(rest, enabled)}</div>` : '';
const pad = Object.keys(rest).length ? `<div class="pad" data-ov-part="pad" ${attrs.pad || ''}>${axisButtons(rest, enabled)}</div>` : '';
- return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}`
- + `<button type="button" class="stop" data-stop data-ov-part="stop" ${attrs.stop || ''}${disabledUnless(enabled)}>${icon('square')}<span>Stop</span></button></div>`;
+ return `<div class="drive-pad" data-axes="${data(axes)}"${flatten ? ' data-ov-flatten="true"' : ''}>${joystick}${pad}</div>`;
}
}
/** An actuator's name as a label: backpack_lights → Backpack lights. */
/** An actuator's name as a label: backpack_lights → Backpack lights. */
@@ -281,7 +279,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -281,7 +279,7 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';
const qe = headName ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to tilt' : axes.rotation ? ' · <kbd>Q</kbd> / <kbd>E</kbd> to turn' : '';
inner = drivePad(axes, enabled, partAttrs, flatten)
inner = drivePad(axes, enabled, partAttrs, flatten)
+ `<p class="hint" data-ov-decor><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `
+ `<p class="hint" data-ov-decor><kbd>W A S D</kbd> or arrows to drive${qe} · <kbd>Space</kbd> to stop. `
- + `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops, Start is the e-stop.</span></p>`;
+ + `<span class="pad-hint" data-pad-hint>${icon('gamepad')} A gamepad works too: left stick drives, A sounds, B stops.</span></p>`;
break;
break;
}
}
case 'ptz': {
case 'ptz': {
@@ -392,7 +390,7 @@ function routinesCard(robot, profile, a) {
@@ -392,7 +390,7 @@ function routinesCard(robot, profile, a) {
};
};
const t = v.trigger || 'manual';
const t = v.trigger || 'manual';
return `<section class="setting-card" id="routines" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
return `<section class="setting-card" id="routines" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
-<p class="setting-note">Steps the robot runs by itself, through the same limits, cooldowns and e-stop as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>
+<p class="setting-note">Steps the robot runs by itself, through the same limits and cooldowns as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>
${a.routineError ? `<p class="error" role="alert">${esc(a.routineError)}</p>` : ''}
${a.routineError ? `<p class="error" role="alert">${esc(a.routineError)}</p>` : ''}
${a.routines.length ? `<ul class="people-list">${a.routines.map(row).join('\n')}</ul>` : ''}
${a.routines.length ? `<ul class="people-list">${a.routines.map(row).join('\n')}</ul>` : ''}
<details${a.routineError ? ' open' : ''}><summary>Add a routine</summary>
<details${a.routineError ? ' open' : ''}><summary>Add a routine</summary>
@@ -431,7 +429,7 @@ ${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>`
@@ -431,7 +429,7 @@ ${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>`
function agentForm(robot) {
function agentForm(robot) {
return `<section class="setting-card" id="agent" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
return `<section class="setting-card" id="agent" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/agent">
<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/agent">
-${toggle('agent_control', robot.agent_control, 'Let my OpenVibe.Actor agent drive this robot', 'Ask your agent on openvibe.actor to move it, speak or show something. It acts as you, through the same limits, cooldowns and e-stop, and every command it sends is on the record.')}
+${toggle('agent_control', robot.agent_control, 'Let my OpenVibe.Actor agent drive this robot', 'Ask your agent on openvibe.actor to move it, speak or show something. It acts as you, through the same limits and cooldowns, and every command it sends is on the record.')}
<button type="submit">Save</button>
<button type="submit">Save</button>
</form></section>`;
</form></section>`;
}
}
@@ -456,7 +454,7 @@ function onStreamCard(robot, { liveUrl = null } = {}) {
@@ -456,7 +454,7 @@ function onStreamCard(robot, { liveUrl = null } = {}) {
? `<p class="setting-ok" role="status">${icon('check')}<span>Its panel shows wherever it is embedded. Bound to your channel, viewers see it under your stream.</span></p>`
? `<p class="setting-ok" role="status">${icon('check')}<span>Its panel shows wherever it is embedded. Bound to your channel, viewers see it under your stream.</span></p>`
: '';
: '';
return `<section class="setting-card on-stream" id="on-stream" aria-labelledby="on-stream-h"><h2 id="on-stream-h">On your stream</h2>
return `<section class="setting-card on-stream" id="on-stream" aria-labelledby="on-stream-h"><h2 id="on-stream-h">On your stream</h2>
-<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons, the e-stop and the latency.</p>
+<p class="setting-note">Put this robot's buttons under your OpenVibe.Live stream so your viewers can drive it there. Your stream stays the video; the panel adds the buttons and the latency.</p>
${state}${liveUrl ? `<form method="post" action="/robots/${esc(robot.id)}/on-stream"><button type="submit" class="button primary">${icon('gamepad')}<span>Put the buttons under my OpenVibe.Live stream</span></button></form>
${state}${liveUrl ? `<form method="post" action="/robots/${esc(robot.id)}/on-stream"><button type="submit" class="button primary">${icon('gamepad')}<span>Put the buttons under my OpenVibe.Live stream</span></button></form>
<p class="setting-note">Opens your channel on OpenVibe.Live, which asks you to confirm. To take it off, choose no robot there, or turn off the panel below.</p>` : ''}
<p class="setting-note">Opens your channel on OpenVibe.Live, which asks you to confirm. To take it off, choose no robot there, or turn off the panel below.</p>` : ''}
<form class="access-form" method="post" action="/robots/${esc(robot.id)}/access">
<form class="access-form" method="post" action="/robots/${esc(robot.id)}/access">
@@ -558,7 +556,7 @@ function peopleCard(robot, people) {
@@ -558,7 +556,7 @@ function peopleCard(robot, people) {
};
};
const queue = robot.access_policy === 'queue' ? ' Anyone signed in can also take a turn: this robot uses the open queue.' : '';
const queue = robot.access_policy === 'queue' ? ' Anyone signed in can also take a turn: this robot uses the open queue.' : '';
return `<section class="setting-card people" aria-labelledby="people-h"><h2 id="people-h">People</h2>
return `<section class="setting-card people" aria-labelledby="people-h"><h2 id="people-h">People</h2>
-<p class="setting-note">Operators drive and can press the e-stop; viewers watch.${queue}</p>
+<p class="setting-note">Operators drive; viewers watch.${queue}</p>
<ul class="people-list">
<ul class="people-list">
<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>
<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>
${(people || []).map(row).join('\n')}
${(people || []).map(row).join('\n')}
@@ -569,8 +567,8 @@ ${(people || []).map(row).join('\n')}
@@ -569,8 +567,8 @@ ${(people || []).map(row).join('\n')}
/**
/**
* The Operators card (Maticus's 2026-11-12 LOOI spec): the owner, every Mod and Operator with a live activity
* 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`.
+ * indicator and last command, the viewers' count, sole control, the per-person switches and promotion/demotion.
+ * 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.
* 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) {
function operatorsCard(robot, operators, role, viewer = null) {
@@ -618,9 +616,9 @@ function operatorsCard(robot, operators, role, viewer = null) {
@@ -618,9 +616,9 @@ function operatorsCard(robot, operators, role, viewer = null) {
const you = isMe(m) && m.role !== 'owner';
const you = isMe(m) && m.role !== 'owner';
const tag = m.role === 'owner' ? (isMe(m) ? 'OWNER · YOU' : 'OWNER') : (roleTag[m.role] || m.role);
const tag = m.role === 'owner' ? (isMe(m) ? 'OWNER · YOU' : 'OWNER') : (roleTag[m.role] || m.role);
const sub = m.role === 'owner'
const sub = m.role === 'owner'
- ? 'Always has override, local STOP, and ARM'
+ ? 'Always has override.'
: m.role === 'mod'
: m.role === 'mod'
- ? 'Can manage operators and clear the panel E-STOP. Cannot change speed, mode, or Viewer access, or promote Mods.'
+ ? 'Can switch Operators on or off and give or clear sole control. Cannot change speed, mode, or Viewer access, or promote Mods.'
: 'Can drive and tilt the head while switched on.';
: '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))}">`
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>`
+ `<span class="ops-avatar" aria-hidden="true">${esc(initial(m))}</span>`
@@ -634,12 +632,6 @@ function operatorsCard(robot, operators, role, viewer = null) {
@@ -634,12 +632,6 @@ function operatorsCard(robot, operators, role, viewer = null) {
+ `<div class="ops-who"><p class="ops-name"><b>Viewers</b> <span class="pill">${esc(String(viewers.length))} watching</span></p>`
+ `<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>'
+ '<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>`;
+ `<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');
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'}">
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>
<h2>${glyphIcon('users')} <span>Operators</span></h2>
@@ -650,8 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${
@@ -650,8 +642,7 @@ ${canManage ? `<button type="button" class="ops-mode-btn" data-operators-clear${
${rows}
${rows}
${viewerRow}
${viewerRow}
</ul>
</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>
+<p class="ops-footer">Everyone returns shared control to all enabled operators. Sole control admits only that person's commands; others get a clear refusal. Toggles apply immediately, with no tray restart. The tray's own Stop stays local. The owner always overrides.</p>
</section>`;
</section>`;
}
}
@@ -713,21 +704,21 @@ function commissionForm(robot, profile) {
@@ -713,21 +704,21 @@ function commissionForm(robot, profile) {
${field('max_speed', 'Top speed', own.max_speed != null ? own.max_speed : limits.max_speed, ceiling.max_speed)}
${field('max_speed', 'Top speed', own.max_speed != null ? own.max_speed : limits.max_speed, ceiling.max_speed)}
${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max_turn, ceiling.max_turn)}
${field('max_turn', 'Top turn', own.max_turn != null ? own.max_turn : limits.max_turn, ceiling.max_turn)}
<button type="submit" class="primary">Save the limits</button></form>
<button type="submit" class="primary">Save the limits</button></form>
-<p class="setting-note">The robot gets them at once. The e-stop, cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p>
+<p class="setting-note">The robot gets them at once. Cooldowns and command length (${esc(limits.max_command_ms)} ms) stay as they are.</p>
<form method="post" action="/robots/${esc(robot.id)}/uncommission"><button type="submit" class="quiet">Return to the profile defaults (${esc(limits.max_speed)} speed, ${esc(limits.max_turn)} turn)</button></form></section>`;
<form method="post" action="/robots/${esc(robot.id)}/uncommission"><button type="submit" class="quiet">Return to the profile defaults (${esc(limits.max_speed)} speed, ${esc(limits.max_turn)} turn)</button></form></section>`;
}
}
// Not commissioned: one deliberate act, with the owner saying what they checked.
// Not commissioned: one deliberate act, with the owner saying what they checked.
return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>
return `<section class="setting-card" id="commission" aria-labelledby="commission-h"><h2 id="commission-h">Commissioned limits</h2>
<p class="setting-note">The profile's speed (${esc(limits.max_speed)}) and turn (${esc(limits.max_turn)}) limits clamp every command. Commissioning this robot lets you set its own higher values ${allow}. It records that you did it and when, and it is yours alone: operators and your agent can never do it.</p>
<p class="setting-note">The profile's speed (${esc(limits.max_speed)}) and turn (${esc(limits.max_turn)}) limits clamp every command. Commissioning this robot lets you set its own higher values ${allow}. It records that you did it and when, and it is yours alone: operators and your agent can never do it.</p>
<form method="post" action="/robots/${esc(robot.id)}/commission" class="setting-form">
<form method="post" action="/robots/${esc(robot.id)}/commission" class="setting-form">
-<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its e-stop.</label>
+<label class="setting-check"><input type="checkbox" name="confirm" value="on" required> I have checked this robot can move faster safely: there is clear space around it, and I can reach its Stop.</label>
<button type="submit" class="primary">Commission this robot</button></form></section>`;
<button type="submit" class="primary">Commission this robot</button></form></section>`;
}
}
// ── The same controls over a picture ({mode=overlay}) or in a narrow card ({mode=compact}) ───────────────
// ── The same controls over a picture ({mode=overlay}) or in a narrow card ({mode=compact}) ───────────────
// The embeddable panel normally draws the whole page. Over a stream it must draw only the live controls:
// The embeddable panel normally draws the whole page. Over a stream it must draw only the live controls:
-// the first stick, the first slider, the e-stop and a way to the full panel. Which ones exist comes from the
+// the first stick, the first slider, Drive and a way to the full panel. Which ones exist comes from the
// robot's profile (commands + widgets), exactly as the full panel does; nothing here is robot-specific.
// robot's profile (commands + widgets), exactly as the full panel does; nothing here is robot-specific.
const BOT_VIEWS = new Set(['panel', 'overlay', 'compact']);
const BOT_VIEWS = new Set(['panel', 'overlay', 'compact']);
const botViewOf = (v) => (BOT_VIEWS.has(v) ? v : 'panel');
const botViewOf = (v) => (BOT_VIEWS.has(v) ? v : 'panel');
@@ -787,13 +778,9 @@ function overlaySlider(profile, allowed) {
@@ -787,13 +778,9 @@ function overlaySlider(profile, allowed) {
}
}
const botPill = (robot, attrs = '') => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit data-ov-part="pill" ${attrs}><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;
const botPill = (robot, attrs = '') => `<span class="bot-pill bot-control" data-bot-pill data-online-pill data-state="connecting" data-bot-hit data-ov-part="pill" ${attrs}><span class="dot"></span><b class="bot-pill-name">${esc(robot.name)}</b><span class="bot-pill-state" data-bot-state>Connecting…</span></span>`;
-// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel; the e-stop waits,
-// hidden, for the join that makes them an operator (public/panel.js onFrame), and the line under the controls says
-// what the page around answered (sign in there, or the owner decides who drives). Ask 353, 2026-11-11: on a phone over
-// the stream there was no way to start driving at all.
-const botEstop = (role, canDrive = false, attrs = '') => (ESTOP_ROLES.has(role)
- ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" ${attrs} aria-label="E-stop" title="E-stop">${icon('stop')}</button>`
- : canDrive ? `<button type="button" class="bot-estop bot-control" data-estop data-bot-hit data-ov-part="estop" data-ov-role-hidden="true" ${attrs} aria-label="E-stop" title="E-stop" hidden>${icon('stop')}</button>` : '');
+// A watcher (the anonymous embed on a stream) takes the controls with Drive, as on the full panel, and the line
+// under the controls says what the page around answered (sign in there, or the owner decides who drives). Ask 353,
+// 2026-11-11: on a phone over the stream there was no way to start driving at all.
const botDrive = (canDrive, attrs = '') => (canDrive
const botDrive = (canDrive, attrs = '') => (canDrive
? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit data-ov-part="drive" ${attrs} title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');
? `<button type="button" class="bot-drive bot-control" data-drive data-drive-bar data-bot-hit data-ov-part="drive" ${attrs} title="Take the controls">${icon('gamepad')}<span>Drive</span></button>` : '');
const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';
const botNotice = '<p class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden></p>';
@@ -807,7 +794,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
@@ -807,7 +794,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
const stick = overlayStickMarkup(profile, allowed_commands);
const stick = overlayStickMarkup(profile, allowed_commands);
const slider = overlaySlider(profile, allowed_commands);
const slider = overlaySlider(profile, allowed_commands);
const canDrive = role === 'watcher' && !signedIn;
const canDrive = role === 'watcher' && !signedIn;
- const stop = botEstop(role, canDrive, attrs('estop')) + botDrive(canDrive, attrs('drive'));
+ const stop = botDrive(canDrive, attrs('drive'));
const sliderLabel = slider ? esc(humanName(slider.name)) : '';
const sliderLabel = slider ? esc(humanName(slider.name)) : '';
const sliderMarkup = slider
const sliderMarkup = slider
? `<div class="bot-slider bot-control" data-bot-hit data-ov-part="slider" ${attrs('slider')}><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`
? `<div class="bot-slider bot-control" data-bot-hit data-ov-part="slider" ${attrs('slider')}><section class="widget bot-widget" data-command="${data(slider.widget.command)}"><label class="control bot-range-label"><span class="sr-only">${sliderLabel}</span>${numberRange(slider.name, slider.actuator, slider.enabled, profile.id)}</label></section></div>`
@@ -817,7 +804,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
@@ -817,7 +804,7 @@ function renderBotSurface({ robot, profile, role, allowed_commands = [], holdRes
const body = view === 'compact'
const body = view === 'compact'
? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'} ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`
? `<div class="bot-compact" data-bot-compact${slider ? '' : ' data-no-slider'} ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`
: `<div class="bot-overlay" data-bot-overlay ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`;
: `<div class="bot-overlay" data-bot-overlay ${surfaceAttrs}>${botPill(robot, attrs('pill'))}${stickMarkup}${sliderMarkup}${stop}${botMore(attrs('more'))}${botNotice}</div>`;
- const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : '';
+ const dataScript = arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null, defaultLayout: arrange.defaultLayout || null }).replace(/</g, '\\u003c')}</script>` : '';
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>`;
}
}
@@ -836,10 +823,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -836,10 +823,9 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
}
}
const minSize = BOT_VIEW_MIN.panel;
const minSize = BOT_VIEW_MIN.panel;
const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';
const htmlAttrs = embed ? ` data-bot-view="panel" data-bot-min-width="${minSize[0]}" data-bot-min-height="${minSize[1]}"` : '';
- const latched = !!(robot.estop && robot.estop.latched);
const widgetOrder = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgetOrder = (profile.widgets || []).map((w, i) => ({ w, i }))
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i));
.sort((a, b) => (a.w.order != null ? a.w.order : 1e9 + a.i) - (b.w.order != null ? b.w.order : 1e9 + b.i));
- // The arrangeable part ids each widget draws: the drive is split into its stick, Stop and direction buttons.
+ // The arrangeable part ids each widget draws: the drive is split into its stick and direction buttons.
const byWidget = new Map();
const byWidget = new Map();
for (const p of (arrange && arrange.catalog ? arrange.catalog.parts || [] : [])) {
for (const p of (arrange && arrange.catalog ? arrange.catalog.parts || [] : [])) {
const source = (arrange.parts || []).find((x) => x.id === p.id);
const source = (arrange.parts || []).find((x) => x.id === p.id);
@@ -851,11 +837,12 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -851,11 +837,12 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
const arrangeApply = !!(arrange && arrange.layoutSource === 'account');
const arrangeApply = !!(arrange && arrange.layoutSource === 'account');
const attrsOf = (ids) => Object.fromEntries(ids.map((id) => [id, arrange ? require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '']));
const attrsOf = (ids) => Object.fromEntries(ids.map((id) => [id, arrange ? require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, id, { apply: arrangeApply }) : '']));
const widgets = widgetOrder.map(({ w, i }) => renderWidget(w, { profile, allowed_commands, signaling_url, part: widgetParts(i)[0] || null, partAttrs: attrsOf(widgetParts(i)), flatten: arrangeApply }));
const widgets = widgetOrder.map(({ w, i }) => renderWidget(w, { profile, allowed_commands, signaling_url, part: widgetParts(i)[0] || null, partAttrs: attrsOf(widgetParts(i)), flatten: arrangeApply }));
- // A robot with no drive widget still gets its Stop: a required part of bot.controls, drawn on its own.
- const drawn = new Set([...byWidget.values()].flat());
- if (arrange && !drawn.has('stop') && require('./surface').partOf(arrange.catalog, '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>`);
+ // Stop is one ordinary part of bot.controls, off by default: it appears only when a layout adds it.
+ if (arrange && require('./surface').partOf(arrange.catalog, 'stop')) {
+ // The default layout places Stop hidden (off), so apply its attributes even when the page keeps its own look.
+ const stopAttrs = require('./surface').attrsFor(arrange.catalog, arrange.layout, arrange.device, 'stop', { apply: true });
+ widgets.push(`<section class="widget widget-stop" data-widget="stop" data-ov-part="stop" ${stopAttrs}>`
+ + `<button type="button" class="stop plain-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
// 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.
// widgets when the person has not arranged the panel; an arranged layout keeps their separate grid cells.
@@ -872,15 +859,10 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
@@ -872,15 +859,10 @@ function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs
// The Operators card sits under the controls, inside the same panel grid. A watcher has no roster and no card.
// 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) : '';
const operatorsHtml = profile.id === 'looi' ? operatorsCard(robot, operators, role, viewer) : '';
if (operatorsHtml) widgets.push(operatorsHtml);
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' || 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="panel-top">
${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 or a Mod clears it.' : ''}</span></p>${clear}${estop}</div>
</header>
</header>
<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(' '))}"` : ''}>
<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">
@@ -896,7 +878,7 @@ ${tryIt ? tryIntro() : ''}
@@ -896,7 +878,7 @@ ${tryIt ? tryIntro() : ''}
<div class="widgets"${arrange ? require('./surface').surfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply) : ''}>
<div class="widgets"${arrange ? require('./surface').surfaceAttrs(arrange.catalog, arrange.layout, arrange.device, arrangeApply) : ''}>
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringify({ catalog: arrange.catalog, layout: arrange.layout, device: arrange.device, surface: arrange.catalog.id, signedIn: arrange.signedIn, layoutSource: arrange.layoutSource || 'default', save: arrange.save || null, suggest: arrange.suggest || null, share: arrange.share || null, reset: arrange.reset || null, author: arrange.author || null, defaultLayout: arrange.defaultLayout || null }).replace(/</g, '\\u003c')}</script>` : ''}${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${limitsCard(robot, limitsInfo, profile)}${onStreamCard(robot, { liveUrl })}${localProfileForm(robot, profile, profileForm)}${commissionForm(robot, profile)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt, embed), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -905,13 +887,13 @@ ${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringi
@@ -905,13 +887,13 @@ ${arrange ? `<script type="application/json" id="ov-arrange-data">${JSON.stringi
});
});
}
}
-const TRY_DESCRIPTION = 'Drive a simulated rover from your browser with no account or hardware: the same Bot panel, safety gate and e-stop a real robot gets, with a live camera.';
+const TRY_DESCRIPTION = 'Drive a simulated rover from your browser with no account or hardware: the same Bot panel and safety gate a real robot gets, with a live camera.';
/** /try's opening lines: what this is, and that nothing here is real hardware. */
/** /try's opening lines: what this is, and that nothing here is real hardware. */
function tryIntro() {
function tryIntro() {
return `<section class="try-intro" aria-labelledby="try-h">
return `<section class="try-intro" aria-labelledby="try-h">
<h2 id="try-h">Drive a rover, right here</h2>
<h2 id="try-h">Drive a rover, right here</h2>
-<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls, the same limits, and an e-stop that holds until it is cleared.</p>
+<p>This rover is simulated in your browser, so nothing you press moves real hardware. It goes through the same panel a paired robot gets: the same controls and the same limits.</p>
<ul class="try-keys"><li><kbd>W A S D</kbd> or the arrows, or drag the joystick</li><li><kbd>Space</kbd> stops</li><li>Follow the yellow tape, or find the crates</li></ul>
<ul class="try-keys"><li><kbd>W A S D</kbd> or the arrows, or drag the joystick</li><li><kbd>Space</kbd> stops</li><li>Follow the yellow tape, or find the crates</li></ul>
<p><button type="button" class="button quiet" data-try-reset>Back to the start</button></p>
<p><button type="button" class="button quiet" data-try-reset>Back to the start</button></p>
</section>`;
</section>`;
@@ -79,19 +79,19 @@ const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/docs', '/docs/drivers
@@ -79,19 +79,19 @@ const PUBLIC_PAGES = ['/', '/try', '/robots', '/models', '/docs', '/docs/drivers
// written from what the page and its renderer actually say (README "Owns", web/home.js, web/docs.js, shared/legal).
// written from what the page and its renderer actually say (README "Owns", web/home.js, web/docs.js, shared/legal).
const LLMS_NAME = 'OpenVibe.Bot';
const LLMS_NAME = 'OpenVibe.Bot';
const LLMS_SUMMARY = 'An open control panel for robots: pair a robot with one pasted command and drive it from any '
const LLMS_SUMMARY = 'An open control panel for robots: pair a robot with one pasted command and drive it from any '
- + 'browser, with roles, limits and a latched emergency stop on every command.';
+ + 'browser, with roles and limits on every command.';
const LLMS_NO_TWIN = new Set(['/terms', '/privacy', '/dmca']);
const LLMS_NO_TWIN = new Set(['/terms', '/privacy', '/dmca']);
const LLMS_LEGAL = new Set(['/terms', '/privacy', '/dmca']);
const LLMS_LEGAL = new Set(['/terms', '/privacy', '/dmca']);
const LLMS_PAGES = [
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 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.',
+ 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. Stop is one optional button that sends the machine\'s own stop; a stop pressed on the robot itself stays local. 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',
note: 'Drive a simulated rover in the browser, no account and no hardware.',
note: 'Drive a simulated rover in the browser, no account and no hardware.',
- text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping, deadman and latched e-stop. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
+ text: 'A rover simulated in the visitor\'s own browser, driven through the same OpenVibe.Bot panel a paired robot gets: a touch joystick, the keyboard (W A S D or the arrows, Space to stop) or a gamepad, with the same command shaping and deadman. It has a front camera drawn from its position in a small arena, an ultrasonic distance sensor, three line sensors over a strip of yellow tape, a battery, RGB headlights and a horn. Nothing pressed there moves real hardware, and nothing needs an account. The page ends with the way to a real robot: add one, pair its computer with one pasted command, and the same panel drives it.',
},
},
{
{
path: '/setup/sd-card', title: 'A robot from a blank microSD card',
path: '/setup/sd-card', title: 'A robot from a blank microSD card',
@@ -116,7 +116,7 @@ const LLMS_PAGES = [
@@ -116,7 +116,7 @@ const LLMS_PAGES = [
{
{
path: '/docs/drivers', title: 'Write a driver',
path: '/docs/drivers', title: 'Write a driver',
note: 'The driver protocol: lifecycle, every message and the safety rules.',
note: 'The driver protocol: lifecycle, every message and the safety rules.',
- text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop, estop, resume; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',
+ text: 'The driver protocol spec for OpenVibe.Bot. A driver is a separate executable the OpenVibe Link core starts, in any language, and the two speak JSON lines over stdin/stdout. The page covers what a driver is, the three connection kinds (on-board, bridge and server-side), the start-up lifecycle and restart rules, every message in both directions (hello, command, heartbeat, stop; describe, ready, fault, ack, nack, telemetry, event and video), the safety rules, a minimal Python driver, and how to test and contribute one.',
},
},
{
{
path: '/docs/profiles', title: 'Robot profiles',
path: '/docs/profiles', title: 'Robot profiles',
@@ -126,7 +126,7 @@ const LLMS_PAGES = [
@@ -126,7 +126,7 @@ const LLMS_PAGES = [
{
{
path: '/docs/automation', title: 'Routines, webhooks and your agent',
path: '/docs/automation', title: 'Routines, webhooks and your agent',
note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',
note: 'Steps on a schedule, signed webhooks for a robot\'s events, and the switch that lets your OpenVibe.Actor agent drive.',
- text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, e-stop, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline, bot.estop.set, bot.estop.cleared and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',
+ text: 'How a robot on OpenVibe.Bot moves without anyone at the controls, through the same gate as a person (limits, cooldowns, audit). GET /api/v1/me answers a person\'s account ID (subject, username, display_name); a robot\'s answer carries owner_subject, effective_limits (max_speed, max_turn, max_command_ms, heartbeat_ms) and limits_source. POST /api/v1/robots/:id/commission lets its owner raise max_speed and max_turn above the profile default, up to the profile ceiling, and POST /robots/:id/uncommission returns to the defaults; only the owner as a person may do either. Steps are plain lines checked against the robot\'s profile: forward, back, left and right for seconds at a speed, stop, wait, say, face, show, an actuator or camera axis with a value and a hold time, and press for a button; held steps are re-sent inside the deadman window and a refused step stops the rest with its reason. Routines run steps as the owner on demand, every N minutes (at least 5), daily at a UTC time on chosen weekdays, or each time the robot comes online; one runs at a time; 60 steps, 60 seconds a step, 10 minutes in all. Webhooks send a robot\'s bot.robot.online, bot.robot.offline and bot.command.refused events to up to five https addresses, each with its own whsec_ secret shown once, signed like OpenVibe.Events deliveries (X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2, checked with verifyDeliveryV2 from openvibe-sdk/events), to public addresses only, retried after 30 seconds, 2, 10, 30 and 120 minutes. The Your agent switch, off until the owner turns it on, lets the owner\'s OpenVibe.Actor agent see their robots and run steps on that one as the owner, never for anyone else; a steps call on Bot\'s API runs up to 20 steps and 30 seconds and answers done, or stopped with the reason.',
},
},
{
{
path: '/docs/computers', title: 'Your computer for your agents',
path: '/docs/computers', title: 'Your computer for your agents',
@@ -266,7 +266,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -266,7 +266,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/try', (req, res) => {
r.get('/try', (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('Cache-Control', 'public, max-age=300');
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
- robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private', estop: { latched: false } },
+ robot: { id: 'try', name: 'Practice rover', profile_id: TRY_PROFILE.id, access_policy: 'private' },
profile: TRY_PROFILE, role: 'owner', allowed_commands: tryAllowed, holdResendMs: HOLD_RESEND_MS, tryIt: true,
profile: TRY_PROFILE, role: 'owner', allowed_commands: tryAllowed, holdResendMs: HOLD_RESEND_MS, tryIt: true,
}));
}));
});
});
@@ -982,7 +982,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -982,7 +982,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const stored = usable(catalog, storedRaw, fallbackLayout);
const stored = usable(catalog, storedRaw, fallbackLayout);
arrange = {
arrange = {
catalog, layout: stored || fallbackLayout, device,
catalog, layout: stored || fallbackLayout, device,
- signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default',
+ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', defaultLayout: fallbackLayout,
save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
};
};
@@ -995,7 +995,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -995,7 +995,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
try { src = sharedServe.url('ov-arrange.js'); } catch { /* an older openvibe-shared pin: no editor */ }
try { src = sharedServe.url('ov-arrange.js'); } catch { /* an older openvibe-shared pin: no editor */ }
arrange = {
arrange = {
catalog, parts: surface.partsOf(profile), layout: stored || fallbackLayout, device,
catalog, parts: surface.partsOf(profile), layout: stored || fallbackLayout, device,
- signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src,
+ signedIn: !!signedIn, layoutSource: stored ? 'account' : 'default', src, defaultLayout: fallbackLayout,
save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
save: `/panel/${robot.id}/arrange`, suggest: `/panel/${robot.id}/arrange/suggest`, share: `/panel/${robot.id}/arrange/share`, reset: `/panel/${robot.id}/arrange/reset`,
author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
author: req.viewer ? { name: req.viewer.username || req.viewer.name || 'Me' } : null,
};
};
@@ -6,9 +6,10 @@
@@ -6,9 +6,10 @@
* that put a person's layout on the parts the pages render.
* that put a person's layout on the parts the pages render.
*
*
* A catalog is built from the robot's profile: the parts a layout may place are exactly the controls this robot
* A catalog is built from the robot's profile: the parts a layout may place are exactly the controls this robot
- * has (the drive stick, Stop, a head slider, battery, latency, the camera, the buttons, ...). Stop and the
- * overlay's e-stop are required and carry their own minimum pixel size. The default layout keeps the panel's
- * familiar order: the camera, then the stick and Stop side by side, then the rest.
+ * has (the drive stick, a head slider, battery, latency, the camera, the buttons, ...). Stop is one ordinary part,
+ * off by default: a person adds it from the Arrange palette when they want it. Nothing is required, so every part
+ * can be hidden or left out. The default layout keeps the panel's familiar order: the camera, then the stick,
+ * then the rest.
*
*
* A layout only arranges: every command still goes through the panel's one gate and the robot's limits.
* A layout only arranges: every command still goes through the panel's one gate and the robot's limits.
*/
*/
@@ -42,7 +43,7 @@ function basePart(id, name, kind, extra = {}) {
@@ -42,7 +43,7 @@ function basePart(id, name, kind, extra = {}) {
};
};
}
}
-/** The parts one widget contributes: the drive is split into its stick, Stop and direction buttons. */
+/** The parts one widget contributes: the drive is split into its stick and direction buttons. */
function driveParts(profile, widget) {
function driveParts(profile, widget) {
const kind = widget && widget.command ? widget.command.kind : 'drive';
const kind = widget && widget.command ? widget.command.kind : 'drive';
const command = (profile.commands || {})[kind];
const command = (profile.commands || {})[kind];
@@ -56,11 +57,6 @@ function driveParts(profile, widget) {
@@ -56,11 +57,6 @@ function driveParts(profile, widget) {
styles: { tints: ['surface', 'accent'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: false },
styles: { tints: ['surface', 'accent'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: false },
bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
}), { widgetIndex: null }]);
}), { widgetIndex: null }]);
- parts.push([basePart('stop', 'Stop', 'button', {
- required: true, pressed: true, min: { w: 2, h: 2, px: 68 },
- styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },
- bindings: { keys: ['Space'], gamepad: ['south'] },
- }), { widgetIndex: null }]);
if (rest.length) parts.push([basePart('pad', 'Direction buttons', 'button', {
if (rest.length) parts.push([basePart('pad', 'Direction buttons', 'button', {
pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],
pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop'],
styles: { tints: ['surface'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
styles: { tints: ['surface'], shapes: ['round', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
@@ -120,14 +116,12 @@ function partsOf(profile) {
@@ -120,14 +116,12 @@ function partsOf(profile) {
}), { widgetIndex: i, widgetType: w.type });
}), { widgetIndex: i, widgetType: w.type });
}
}
});
});
- if (!drive) {
- // Every robot with a panel can be stopped, even one made only of a pan-tilt camera or buttons.
- if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {
- required: true, pressed: true, min: { w: 2, h: 2, px: 68 },
- styles: { tints: ['danger', 'surface'], shapes: ['round', 'pill'], opacity: { min: 0.7, max: 1 }, label: true },
- bindings: { keys: ['Space'], gamepad: ['south'] },
- }));
- }
+ // Stop is a plain, optional control: every robot may have one, and a person adds it when they want it.
+ if (!seen.has('stop')) add(basePart('stop', 'Stop', 'button', {
+ pressed: true, min: { w: 1, h: 1, px: 44 },
+ styles: { tints: ['surface'], shapes: ['pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] },
+ }));
return out;
return out;
}
}
@@ -135,7 +129,7 @@ function partsOf(profile) {
@@ -135,7 +129,7 @@ function partsOf(profile) {
function controlsCatalog(profile) {
function controlsCatalog(profile) {
return {
return {
id: 'bot.controls', version: 1, name: 'Robot controls',
id: 'bot.controls', version: 1, name: 'Robot controls',
- description: 'The full OpenVibe.Bot panel: the drive stick, Stop, the head or camera slider, the buttons, the camera and the readouts.',
+ description: 'The full OpenVibe.Bot panel: the drive stick, the head or camera slider, the buttons, the camera and the readouts. Stop is an optional part.',
devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS,
devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS,
parts: partsOf(profile).map((p) => {
parts: partsOf(profile).map((p) => {
const { widgetIndex, widgetType, ...part } = p; // eslint-disable-line no-unused-vars
const { widgetIndex, widgetType, ...part } = p; // eslint-disable-line no-unused-vars
@@ -144,31 +138,31 @@ function controlsCatalog(profile) {
@@ -144,31 +138,31 @@ function controlsCatalog(profile) {
};
};
}
}
-/** The overlay's catalog: the stick, the slider, the e-stop, more and the pill (plus Drive for a watcher). */
+/** The overlay's catalog: the stick, the slider, the more button, the pill and an optional Stop (plus Drive for a watcher). */
function overlayCatalog(profile, { slider = null, hasStick = false, canDrive = false } = {}) {
function overlayCatalog(profile, { slider = null, hasStick = false, canDrive = false } = {}) {
const parts = [
const parts = [
basePart('pill', 'The robot', 'readout', { pressed: false, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }),
basePart('pill', 'The robot', 'readout', { pressed: false, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }),
basePart('more', 'More', 'button', { pressed: true, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false }, bindings: { keys: [], gamepad: [] } }),
basePart('more', 'More', 'button', { pressed: true, min: { w: 1, h: 1 }, styles: { tints: ['surface'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false }, bindings: { keys: [], gamepad: [] } }),
- basePart('estop', 'E-stop', 'button', {
- required: true, pressed: true, min: { w: 2, h: 2, px: 56 }, never_covers: ['more', 'pill'],
- styles: { tints: ['danger'], shapes: ['round'], opacity: { min: 0.7, max: 1 }, label: true },
- bindings: { keys: ['Space'], gamepad: ['start'] },
+ basePart('stop', 'Stop', 'button', {
+ pressed: true, min: { w: 1, h: 1, px: 44 }, never_covers: ['more', 'pill'],
+ styles: { tints: ['surface'], shapes: ['pill', 'square'], opacity: { min: 0.4, max: 1 }, label: true },
+ bindings: { keys: ['Space'], gamepad: ['south'] },
}),
}),
];
];
if (hasStick) parts.push(basePart('stick', 'Stick', 'stick', {
if (hasStick) parts.push(basePart('stick', 'Stick', 'stick', {
- pressed: true, min: { w: 2, h: 2 }, never_covers: ['estop', 'pill'],
+ pressed: true, min: { w: 2, h: 2 }, never_covers: ['stop', 'pill'],
styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
styles: { tints: ['surface', 'accent'], shapes: ['round'], opacity: { min: 0.4, max: 1 }, label: false },
bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
bindings: { keys: STICK_KEYS, gamepad: ['left-stick', 'dpad'] },
}));
}));
if (slider) parts.push(basePart('slider', slider.name || 'Slider', 'slider', {
if (slider) parts.push(basePart('slider', slider.name || 'Slider', 'slider', {
- pressed: true, min: { w: 2, h: 1 }, never_covers: ['estop'],
+ pressed: true, min: { w: 2, h: 1 }, never_covers: ['stop'],
styles: { tints: ['surface', 'accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },
styles: { tints: ['surface', 'accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true },
bindings: { keys: [], gamepad: [] },
bindings: { keys: [], gamepad: [] },
}));
}));
if (canDrive) parts.push(basePart('drive', 'Drive', 'button', { pressed: true, min: { w: 2, h: 1 }, styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }));
if (canDrive) parts.push(basePart('drive', 'Drive', 'button', { pressed: true, min: { w: 2, h: 1 }, styles: { tints: ['accent'], shapes: ['pill'], opacity: { min: 0.4, max: 1 }, label: true }, bindings: { keys: [], gamepad: [] } }));
return {
return {
id: 'bot.overlay', version: 1, name: 'Robot over a stream',
id: 'bot.overlay', version: 1, name: 'Robot over a stream',
- description: 'The controls over a robot’s stream: the stick, the slider, the e-stop, more and the robot’s pill.',
+ description: 'The controls over a robot’s stream: the stick, the slider, more, the robot’s pill and an optional Stop.',
devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS, parts,
devices: DEVICES, grid: GRID, theme_tokens: THEME_TOKENS, parts,
};
};
}
}
@@ -202,24 +196,26 @@ function resolvedEntry(catalog, layout, device, id) {
@@ -202,24 +196,26 @@ function resolvedEntry(catalog, layout, device, id) {
}
}
/**
/**
- * The default layout: the camera, then the stick and Stop, then the rest in the catalog's order. Phone rows are
- * full width; a computer gives the camera eight of its twelve columns and keeps the stick and Stop beside it.
+ * The default layout: the camera, then the stick, then the rest in the catalog's order. Stop is placed last and
+ * hidden: it is off until a person adds it. Phone rows are full width; a computer gives the camera eight of its
+ * twelve columns and keeps the stick beside it.
*/
*/
function defaultLayout(catalog, device = 'computer') {
function defaultLayout(catalog, device = 'computer') {
const d = DEVICES.includes(device) ? device : 'computer';
const d = DEVICES.includes(device) ? device : 'computer';
const cols = GRID.columns[d];
const cols = GRID.columns[d];
const order = [...(catalog.parts || [])].sort((a, b) => {
const order = [...(catalog.parts || [])].sort((a, b) => {
- const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'stop' ? -1 : p.id === 'pad' ? 0 : 1);
+ const rank = (p) => (p.id === 'camera' ? -3 : p.id === 'stick' ? -2 : p.id === 'pad' ? 0 : p.id === 'stop' ? 2 : 1);
return rank(a) - rank(b);
return rank(a) - rank(b);
});
});
let x = 0; let y = 0; let rowH = 0;
let x = 0; let y = 0; let rowH = 0;
const parts = order.map((p) => {
const parts = order.map((p) => {
const camera = p.id === 'camera';
const camera = p.id === 'camera';
- const side = p.id === 'stick' || p.id === 'stop';
+ const side = p.id === 'stick';
let w = camera ? (d === 'computer' ? 8 : cols) : side ? 2 : Math.min(cols, Math.max(p.min.w, d === 'phone' ? cols : 4));
let w = camera ? (d === 'computer' ? 8 : cols) : side ? 2 : Math.min(cols, Math.max(p.min.w, d === 'phone' ? cols : 4));
if (p.kind === 'readout' && d !== 'phone') w = Math.min(cols, Math.max(2, p.min.w));
if (p.kind === 'readout' && d !== 'phone') w = Math.min(cols, Math.max(2, p.min.w));
+ if (p.id === 'stop') w = 1;
w = Math.min(cols, w);
w = Math.min(cols, w);
- let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' || p.id === 'stick' ? 2 : Math.max(p.min.h, 2);
+ let h = camera ? 3 : p.kind === 'readout' ? 1 : p.id === 'stop' ? 1 : p.id === 'stick' ? 2 : Math.max(p.min.h, 2);
if (p.kind === 'slider') h = Math.max(1, p.min.h);
if (p.kind === 'slider') h = Math.max(1, p.min.h);
if (p.id === 'latency') h = 2;
if (p.id === 'latency') h = 2;
if (x + w > cols) { x = 0; y += rowH; rowH = 0; }
if (x + w > cols) { x = 0; y += rowH; rowH = 0; }
@@ -227,12 +223,12 @@ function defaultLayout(catalog, device = 'computer') {
@@ -227,12 +223,12 @@ function defaultLayout(catalog, device = 'computer') {
x += w;
x += w;
rowH = Math.max(rowH, h);
rowH = Math.max(rowH, h);
const st = styleOf(p, null);
const st = styleOf(p, null);
- return { id: p.id, visible: true, place, style: st, bindings: { keys: [], gamepad: [] } };
+ return { id: p.id, visible: p.id !== 'stop', place, style: st, bindings: { keys: [], gamepad: [] } };
});
});
return { surface: catalog.id, surface_version: catalog.version, device: d, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
return { surface: catalog.id, surface_version: catalog.version, device: d, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
}
}
-/** The overlay's default layout: the pill and stick left, more and e-stop right, the slider at the bottom. */
+/** The overlay's default layout: the pill and stick left, more and Drive right, the slider at the bottom. */
function overlayDefaultLayout(catalog, device = 'computer') {
function overlayDefaultLayout(catalog, device = 'computer') {
const dockedOf = (id, dock, order, w = 1, h = 1) => {
const dockedOf = (id, dock, order, w = 1, h = 1) => {
const p = partOf(catalog, id);
const p = partOf(catalog, id);
@@ -244,7 +240,6 @@ function overlayDefaultLayout(catalog, device = 'computer') {
@@ -244,7 +240,6 @@ function overlayDefaultLayout(catalog, device = 'computer') {
dockedOf('stick', 'left', 1, 2, 2),
dockedOf('stick', 'left', 1, 2, 2),
dockedOf('slider', 'bottom', 0, 2, 1),
dockedOf('slider', 'bottom', 0, 2, 1),
dockedOf('more', 'right', 0, 1, 1),
dockedOf('more', 'right', 0, 1, 1),
- dockedOf('estop', 'right', 2, 2, 2),
dockedOf('drive', 'right', 0, 2, 1),
dockedOf('drive', 'right', 0, 2, 1),
].filter(Boolean);
].filter(Boolean);
return { surface: catalog.id, surface_version: catalog.version, device, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
return { surface: catalog.id, surface_version: catalog.version, device, name: 'Default', author: { name: 'OpenVibe' }, sharing: 'private', parts };
@@ -254,8 +249,7 @@ const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace
@@ -254,8 +249,7 @@ const escAttr = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace
/**
/**
* The attributes that put one part's entry on the element: its grid area (or dock edge), its style variables
* The attributes that put one part's entry on the element: its grid area (or dock edge), its style variables
- * and whether this person hid it. `roleHidden` marks a part the page hides for a role reason (the e-stop before
- * a watcher takes the controls): the editor must not unhide it.
+ * and whether this person hid it.
*/
*/
function attrsFor(catalog, layout, device, id, { apply = true } = {}) {
function attrsFor(catalog, layout, device, id, { apply = true } = {}) {
if (!apply) return '';
if (!apply) return '';
@@ -23,7 +23,7 @@ const { signDeliveryHeaders } = require('openvibe-sdk/events');
@@ -23,7 +23,7 @@ const { signDeliveryHeaders } = require('openvibe-sdk/events');
const { BotError, prefixedId, iso } = require('./util');
const { BotError, prefixedId, iso } = require('./util');
const { checkWebhookUrl, WebhookRefused } = require('./net/webhook-post');
const { checkWebhookUrl, WebhookRefused } = require('./net/webhook-post');
-const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.estop.set', 'bot.estop.cleared', 'bot.command.refused'];
+const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.command.refused'];
const BACKOFF_S = [30, 120, 600, 1800, 7200];
const BACKOFF_S = [30, 120, 600, 1800, 7200];
const MAX_HOOKS = 5;
const MAX_HOOKS = 5;
const TICK_MS = 5000;
const TICK_MS = 5000;
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe Link
// The T15 lane H proving test: one end-to-end Adeept dry-run through Bot, using the frames OpenVibe Link
// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives
// actually sends (internal/protocol, internal/link, testdata/bot). An owner pairs a fake ADR036, drives
-// and actuates it over the /control socket, reads battery and latency back, latches and clears the e-stop,
+// and actuates it over the /control socket, reads battery and latency back, and stops it with halt,
// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.
// and never sees the device's secrets in a read. BOT_WHIP_BASE is set, so pairing must answer a whip_url.
//
//
// The scenario follows the node-plan-t15-t14 audit's last OpenVibe.Bot item: "an end-to-end Adeept dry-run
// The scenario follows the node-plan-t15-t14 audit's last OpenVibe.Bot item: "an end-to-end Adeept dry-run
@@ -46,8 +46,8 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -46,8 +46,8 @@ const WHIP_BASE = 'https://whip.test/ingest';
assert.strictEqual(paired.json.profile.id, 'adeept.adr036');
assert.strictEqual(paired.json.profile.id, 'adeept.adr036');
});
});
- // ── 2. the socket handshake: hello, config, then the device's first status/estop_state ────────────
- await check('step 2: hello (session/robot_ids/ISO time) then config (allowed, latch false) precede status', async () => {
+ // ── 2. the socket handshake: hello, config, then the device's first status ──────────────────────────
+ await check('step 2: hello (session/robot_ids/ISO time) then config (allowed) precede status', async () => {
dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });
dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });
const hello = await dev.waitFor((m) => m.type === 'hello');
const hello = await dev.waitFor((m) => m.type === 'hello');
assert.ok(hello, 'hello arrived');
assert.ok(hello, 'hello arrived');
@@ -57,15 +57,12 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -57,15 +57,12 @@ const WHIP_BASE = 'https://whip.test/ingest';
assert.ok(!Number.isNaN(Date.parse(hello.server_time)), `an ISO server_time: ${hello.server_time}`);
assert.ok(!Number.isNaN(Date.parse(hello.server_time)), `an ISO server_time: ${hello.server_time}`);
const config = await dev.waitFor((m) => m.type === 'config');
const config = await dev.waitFor((m) => m.type === 'config');
assert.ok(config, 'config arrived after hello');
assert.ok(config, 'config arrived after hello');
- assert.strictEqual(config.estop_latched, false);
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
// Only now does the Link report: its link holds every frame until hello.
// Only now does the Link report: its link holds every frame until hello.
dev.send({ type: 'status', firmware: 'openvibe-link-0.1.0', capabilities: {}, agent_version: '0.1.0',
dev.send({ type: 'status', firmware: 'openvibe-link-0.1.0', capabilities: {}, agent_version: '0.1.0',
- device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });
- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
+ device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [] });
assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');
assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');
- assert.strictEqual((await t.domain.robots.get(robot.id)).estop_latched, false, 'a device latch:false does not latch');
});
});
// ── 3. drive, pan, tilt, lights and horn reach the device in the Adeept plugin's shapes ──────────
// ── 3. drive, pan, tilt, lights and horn reach the device in the Adeept plugin's shapes ──────────
@@ -128,26 +125,6 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -128,26 +125,6 @@ const WHIP_BASE = 'https://whip.test/ingest';
assert.ok(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), `the envelope seq is monotonic: ${seqs.join(',')}`);
assert.ok(seqs.every((s, i) => i === 0 || s > seqs[i - 1]), `the envelope seq is monotonic: ${seqs.join(',')}`);
});
});
- // ── 6. the owner's e-stop latches, a device report cannot clear it, the owner clears it ──────────
- await check('step 6: owner latch sends estop true and refuses drive; a device false is not a clear', async () => {
- op.send({ type: 'estop' });
- const latched = await dev.waitFor((m) => m.type === 'estop' && m.latched === true);
- assert.ok(latched, 'the device is told to latch');
- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the robot is latched');
- op.send({ type: 'command', id: 'e-stop-drive', kind: 'drive', value: { throttle: 0.2 } });
- const refused = await result('e-stop-drive');
- assert.strictEqual(refused.result, 'refused');
- assert.strictEqual(refused.code, 'bot.estop_latched');
- // The device reporting its own latch:false is recorded, never an owner clear.
- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
- await t.wait(60);
- assert.strictEqual((await t.domain.robots.get(robot.id)).estop_latched, true, 'the owner latch stands');
- op.send({ type: 'estop_clear' });
- const cleared = await dev.waitFor((m) => m.type === 'estop' && m.latched === false);
- assert.ok(cleared, 'the owner clear reaches the device as estop false');
- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !r.estop_latched)), 'the robot is unlatched');
- });
-
// ── 7. no owner read route or state frame carries a device secret or a WHIP URL ──────────────────
// ── 7. no owner read route or state frame carries a device secret or a WHIP URL ──────────────────
await check('step 7: reads and robot_state never contain whip_url, the publish key or the credential', async () => {
await check('step 7: reads and robot_state never contain whip_url, the publish key or the credential', async () => {
const secrets = [paired.json.credential, paired.json.publish_key, paired.json.whip_url];
const secrets = [paired.json.credential, paired.json.publish_key, paired.json.whip_url];
@@ -45,11 +45,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -45,11 +45,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).json.robot.agent_control, true);
assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).json.robot.agent_control, true);
});
});
- await check('the agent reads the robot for its owner: online, the e-stop, and the steps this robot takes', async () => {
+ await check('the agent reads the robot for its owner: online, and the steps this robot takes', async () => {
const r = await asAgent('GET', '/steps', alex);
const r = await asAgent('GET', '/steps', alex);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.json.online, true);
assert.strictEqual(r.json.online, true);
- assert.strictEqual(r.json.estop.latched, false);
assert.strictEqual(r.json.name, 'Desk rover');
assert.strictEqual(r.json.name, 'Desk rover');
assert.ok(r.json.steps.includes('forward 2') && r.json.steps.includes('headlights #ff8800'), r.json.steps.join(', '));
assert.ok(r.json.steps.includes('forward 2') && r.json.steps.includes('headlights #ff8800'), r.json.steps.join(', '));
assert.deepStrictEqual([r.json.max_steps, r.json.max_seconds], [20, 30]);
assert.deepStrictEqual([r.json.max_steps, r.json.max_seconds], [20, 30]);
@@ -92,12 +91,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -92,12 +91,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(long.json.detail, /30 seconds/);
assert.match(long.json.detail, /30 seconds/);
});
});
- await check('the e-stop stops the agent with its reason; turning the switch off refuses it again', async () => {
- assert.strictEqual((await t.call('POST', `/api/v1/robots/${robot.id}/estop`, { user: alex })).status, 200);
- const r = await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' });
- assert.strictEqual(r.status, 200, r.text);
- assert.match(r.json.result, /^stopped: /);
- assert.strictEqual((await asAgent('GET', '/steps', alex)).json.estop.latched, true);
+ await check('turning the agent switch off refuses it again', async () => {
assert.strictEqual((await post(`/robots/${robot.id}/agent`, alex, 'agent_control=off')).status, 303);
assert.strictEqual((await post(`/robots/${robot.id}/agent`, alex, 'agent_control=off')).status, 303);
refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');
refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// Agent Streams S7, Bot's part: an agent's commands are steps on its stream, chat cannot drive through the agent
// Agent Streams S7, Bot's part: an agent's commands are steps on its stream, chat cannot drive through the agent
-// stream, and Live's kill switch latches the robot's e-stop through Bot.
+// stream, and Live's stop reaches the robot through Bot.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -42,24 +42,17 @@ const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
@@ -42,24 +42,17 @@ const AGS = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPS';
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.json.code, 'bot.chat_cannot_drive');
assert.strictEqual(r.json.code, 'bot.chat_cannot_drive');
const state = await asAgent('GET', '/steps');
const state = await asAgent('GET', '/steps');
- assert.strictEqual(state.json.estop.latched, false, 'the refused call did not reach the robot');
});
});
- await check('Live\'s kill switch latches the e-stop through Bot, and the next command is refused', async () => {
- const wrong = await t.call('POST', `/internal/robots/${robot.id}/estop`, { headers: { 'X-OV-Bot-Kill': 'not-the-secret' }, body: {} });
+ await check('Live\'s stop reaches the robot through Bot as a halt', async () => {
+ const wrong = await t.call('POST', `/internal/robots/${robot.id}/stop`, { headers: { 'X-OV-Bot-Kill': 'not-the-secret' }, body: {} });
assert.strictEqual(wrong.status, 401, wrong.text);
assert.strictEqual(wrong.status, 401, wrong.text);
- const ok = await t.call('POST', `/internal/robots/${robot.id}/estop`, { headers: { 'X-OV-Bot-Kill': 'test-kill-secret' }, body: { reason: 'agent_stream_ended' } });
+ const ok = await t.call('POST', `/internal/robots/${robot.id}/stop`, { headers: { 'X-OV-Bot-Kill': 'test-kill-secret' }, body: { reason: 'agent_stream_ended' } });
assert.strictEqual(ok.status, 200, ok.text);
assert.strictEqual(ok.status, 200, ok.text);
- assert.strictEqual(ok.json.estop.latched, true);
- const state = await asAgent('GET', '/steps');
- assert.strictEqual(state.json.estop.latched, true, 'the robot is stopped');
- const refused = await asAgent('POST', '/commands', { id: 's7-2', kind: 'drive', value: { throttle: 0.5 }, ms: 500, agent_stream_id: AGS });
- assert.strictEqual(refused.status, 409, refused.text);
- assert.strictEqual(refused.json.code, 'bot.estop_latched');
- const steps = await asAgent('GET', `/steps?agent_stream=${AGS}`);
- const last = steps.json.commands[steps.json.commands.length - 1];
- assert.strictEqual(last.status, 'refused', 'the refused command is still a step, with the reason');
- assert.strictEqual(last.reason, 'bot.estop_latched');
+ assert.strictEqual(ok.json.stop, true);
+ const sent = await asAgent('POST', '/commands', { id: 's7-2', kind: 'drive', value: { throttle: 0.5 }, ms: 500, agent_stream_id: AGS });
+ assert.strictEqual(sent.status, 200, sent.text);
+ assert.strictEqual(sent.json.result, 'ack', 'a Stop is one command, not a latch');
});
});
await check('the step title is the command, never markup or unbounded text', async () => {
await check('the step title is the command, never markup or unbounded text', async () => {
@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -109,7 +109,7 @@ const { boot, check, done } = require('./helpers/app');
await check('an outbox event is written for a refused command (bot.command.refused)', async () => {
await check('an outbox event is written for a refused command (bot.command.refused)', async () => {
const { robot } = await t.robot(alex);
const { robot } = await t.robot(alex);
- await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'bot.estop_latched' });
+ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'bot.command_not_allowed' });
const rows = await t.outboxRows('bot.command.refused');
const rows = await t.outboxRows('bot.command.refused');
assert.ok(rows.length >= 1);
assert.ok(rows.length >= 1);
assert.strictEqual(rows[rows.length - 1].source, 'bot');
assert.strictEqual(rows[rows.length - 1].source, 'bot');
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'use strict';
// The Arrange editor on the real panel, in headless Chrome (make your own controls, stage 2): the panel is drawn
// The Arrange editor on the real panel, in headless Chrome (make your own controls, stage 2): the panel is drawn
-// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, Stop moves on the grid, Save posts
-// the layout to the panel's own route, and Ask OpenVibe previews a suggestion before Keep. No robot and no
-// Network: the page is rendered by renderPanel and the two arrange endpoints answer with a stub. Skipped without
-// Chrome.
+// with its parts, Arrange loads openvibe-shared/ov-arrange.js only when pressed, the palette adds the optional Stop,
+// Stop moves on the grid, Save posts the layout to the panel's own route, and Ask OpenVibe previews a suggestion
+// before Keep. No robot and no Network: the page is rendered by renderPanel and the two arrange endpoints answer
+// with a stub. Skipped without Chrome.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
@@ -21,7 +21,7 @@ const sharedRoot = path.dirname(require.resolve('openvibe-shared/package.json'))
@@ -21,7 +21,7 @@ const sharedRoot = path.dirname(require.resolve('openvibe-shared/package.json'))
const catalog = surface.controlsCatalog(profile);
const catalog = surface.controlsCatalog(profile);
const defaults = surface.defaultLayout(catalog, 'phone');
const defaults = surface.defaultLayout(catalog, 'phone');
const allowed = Object.keys(profile.commands);
const allowed = Object.keys(profile.commands);
-const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };
+const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };
const pageHtml = render.renderPanel({
const pageHtml = render.renderPanel({
robot, profile, role: 'owner', allowed_commands: allowed,
robot, profile, role: 'owner', allowed_commands: allowed,
arrange: {
arrange: {
@@ -80,12 +80,18 @@ const server = http.createServer((req, res) => {
@@ -80,12 +80,18 @@ const server = http.createServer((req, res) => {
assert.ok(await until(`!!window.OVArrange && !!window.ovArrange`), 'Arrange loads the editor and opens it');
assert.ok(await until(`!!window.OVArrange && !!window.ovArrange`), 'Arrange loads the editor and opens it');
if (!await until(`!!document.querySelector('[data-ov-custom]')`)) console.log('DEBUG', await evalv(`JSON.stringify({custom: !!document.querySelector('[data-ov-custom]'), surfaces: [...document.querySelectorAll('[data-ov-surface]')].map(e=>e.className+':'+e.getAttribute('data-ov-custom')), bar: !!document.querySelector('.ov-arrange-bar'), status: document.querySelector('.ov-arrange-status') && document.querySelector('.ov-arrange-status').textContent, err: window.__err})`));
if (!await until(`!!document.querySelector('[data-ov-custom]')`)) console.log('DEBUG', await evalv(`JSON.stringify({custom: !!document.querySelector('[data-ov-custom]'), surfaces: [...document.querySelectorAll('[data-ov-surface]')].map(e=>e.className+':'+e.getAttribute('data-ov-custom')), bar: !!document.querySelector('.ov-arrange-bar'), status: document.querySelector('.ov-arrange-status') && document.querySelector('.ov-arrange-status').textContent, err: window.__err})`));
assert.ok(await until(`!!document.querySelector('[data-ov-custom]')`), 'the editor applies the layout to the real surface');
assert.ok(await until(`!!document.querySelector('[data-ov-custom]')`), 'the editor applies the layout to the real surface');
+ // Stop is off by default; the Arrange palette adds it as one ordinary part.
+ assert.ok(await evalv(`document.querySelector('[data-ov-part="stop"]').hidden`), 'Stop is hidden by default');
+ assert.ok(await until(`!!document.querySelector('[data-add-stop]')`), 'the palette offers Stop');
+ await evalv(`document.querySelector('[data-add-stop]').click()`);
+ assert.ok(await until(`!document.querySelector('[data-ov-part="stop"]').hidden`), 'Add Stop shows it');
+ const savedIt = async () => { const end = Date.now() + 8000; while (Date.now() < end) { if (calls.save && calls.save.layout) return true; await wait(50); } return false; };
+ assert.ok(await savedIt(), 'adding it saves the layout');
// The stick and Stop are in their own cells; the old pad wrapper flattens.
// The stick and Stop are in their own cells; the old pad wrapper flattens.
assert.ok(await evalv(`getComputedStyle(document.querySelector('.widget-drive')).display === 'contents'`), 'the drive section flattens so its parts are grid cells');
assert.ok(await evalv(`getComputedStyle(document.querySelector('.widget-drive')).display === 'contents'`), 'the drive section flattens so its parts are grid cells');
- const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`);
+ const before = await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stick').place.y`);
for (let i = 0; i < 2; i++) await evalv(`(() => { const el = document.querySelector('[data-ov-part="stick"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); })()`);
for (let i = 0; i < 2; i++) await evalv(`(() => { const el = document.querySelector('[data-ov-part="stick"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); })()`);
- await evalv(`(() => { const el = document.querySelector('[data-ov-part="stop"]'); el.focus(); el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); })()`);
- assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stop').place.x`), before - 1, 'Stop moved one cell left');
+ assert.equal(await evalv(`window.ovArrange.layout().parts.find((p) => p.id === 'stick').place.y`), before + 2, 'the stick moved two cells down');
await evalv(`document.querySelector('[data-save]').click()`);
await evalv(`document.querySelector('[data-save]').click()`);
assert.ok(calls.save && calls.save.layout, 'Save posted the layout to the panel route');
assert.ok(calls.save && calls.save.layout, 'Save posted the layout to the panel route');
// Ask OpenVibe: previewed, then kept.
// Ask OpenVibe: previewed, then kept.
@@ -103,8 +109,8 @@ const server = http.createServer((req, res) => {
@@ -103,8 +109,8 @@ const server = http.createServer((req, res) => {
await p2.goto(base + '/panel/r1', { maxMs: 8000 });
await p2.goto(base + '/panel/r1', { maxMs: 8000 });
assert.ok(await p2until(`localStorage.getItem('ov-bot-pad-prefs') === null`), 'the old store is retired after one migration');
assert.ok(await p2until(`localStorage.getItem('ov-bot-pad-prefs') === null`), 'the old store is retired after one migration');
const migrated = JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`));
const migrated = JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`));
- assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stop').place, { x: 0, y: 0, w: 2, h: 2 }, 'Stop takes the left hand');
- assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 2, w: 3, h: 3 }, 'the pad takes its larger size, moved clear of Stop on a phone');
+ assert.strictEqual(migrated.layout.parts.find((p) => p.id === 'stop').visible, false, 'Stop stays off in a migrated layout');
+ assert.deepEqual(migrated.layout.parts.find((p) => p.id === 'stick').place, { x: 0, y: 0, w: 3, h: 3 }, 'the pad takes its larger size');
assert.equal(migrated.layout.parts.find((p) => p.id === 'battery').visible, false, 'the readouts are folded');
assert.equal(migrated.layout.parts.find((p) => p.id === 'battery').visible, false, 'the readouts are folded');
assert.ok(await p2until(`!!document.querySelector('.widgets[data-ov-arranged]')`), 'the migrated layout is drawn on the parts');
assert.ok(await p2until(`!!document.querySelector('.widgets[data-ov-arranged]')`), 'the migrated layout is drawn on the parts');
assert.equal(JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`)).layoutSource, 'default', 'the page was drawn from the default before the migration');
assert.equal(JSON.parse(await p2.evaluate(`document.getElementById('ov-arrange-data').textContent`)).layoutSource, 'default', 'the page was drawn from the default before the migration');
@@ -8,7 +8,7 @@ const surface = require('../server/web/surface');
@@ -8,7 +8,7 @@ const surface = require('../server/web/surface');
const profile = require('../server/profiles/looi.json');
const profile = require('../server/profiles/looi.json');
const { validate } = require('openvibe-sdk/ui-layout');
const { validate } = require('openvibe-sdk/ui-layout');
-const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private', estop: { latched: false } };
+const robot = { id: 'r1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };
const allowed = Object.keys(profile.commands);
const allowed = Object.keys(profile.commands);
const catalog = surface.controlsCatalog(profile);
const catalog = surface.controlsCatalog(profile);
const defaults = surface.defaultLayout(catalog, 'phone');
const defaults = surface.defaultLayout(catalog, 'phone');
@@ -30,7 +30,8 @@ assert.ok(/data-arrange-src="\/shared\/ov-arrange\.js\?v=abc"/.test(html), 'it n
@@ -30,7 +30,8 @@ assert.ok(/data-arrange-src="\/shared\/ov-arrange\.js\?v=abc"/.test(html), 'it n
assert.ok(!/<script[^>]+ov-arrange\.js/.test(html), 'the editor is not loaded until Arrange is pressed');
assert.ok(!/<script[^>]+ov-arrange\.js/.test(html), 'the editor is not loaded until Arrange is pressed');
assert.ok(html.includes('id="ov-arrange-data"'), 'the catalog and layout reach the client as data');
assert.ok(html.includes('id="ov-arrange-data"'), 'the catalog and layout reach the client as data');
assert.ok(!html.includes('pad-prefs'), 'the old sliders-icon arrangement menu is gone');
assert.ok(!html.includes('pad-prefs'), 'the old sliders-icon arrangement menu is gone');
-assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked');
+assert.ok(html.includes('data-ov-part="stop"') && html.includes('data-ov-part="stick"') && html.includes('data-ov-part="head"'), 'the real parts are marked, Stop hidden by default');
+assert.ok(/data-ov-part="stop"[^>]*hidden/.test(html) || /hidden[^>]*data-ov-part="stop"/.test(html), 'Stop is off until a layout adds it');
assert.ok(html.includes('data-ov-surface="bot.controls"'), 'the panel names its surface');
assert.ok(html.includes('data-ov-surface="bot.controls"'), 'the panel names its surface');
assert.ok(!html.includes('data-ov-arranged="true"'), 'without a stored layout the page keeps its own look (the editor applies the grid when opened)');
assert.ok(!html.includes('data-ov-arranged="true"'), 'without a stored layout the page keeps its own look (the editor applies the grid when opened)');
@@ -57,10 +58,10 @@ const overlay = render.renderPanel({
@@ -57,10 +58,10 @@ const overlay = render.renderPanel({
signedIn: false, layoutSource: 'default', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',
signedIn: false, layoutSource: 'default', save: '/panel/r1/arrange', suggest: '/panel/r1/arrange/suggest', share: '/panel/r1/arrange/share',
},
},
});
});
-for (const id of ['pill', 'stick', 'slider', 'estop', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);
-assert.ok(overlay.includes('data-ov-role-hidden="true"'), 'the watcher’s e-stop stays role-hidden until Drive');
+for (const id of ['pill', 'stick', 'slider', 'more']) assert.ok(overlay.includes(`data-ov-part="${id}"`), `the overlay marks ${id}`);
+assert.ok(!overlay.includes('data-ov-part="stop"'), 'the overlay has no Stop until a layout adds it');
assert.ok(overlay.includes('id="ov-arrange-data"'), 'the overlay carries its catalog and layout too');
assert.ok(overlay.includes('id="ov-arrange-data"'), 'the overlay carries its catalog and layout too');
-assert.ok(overlayCatalog.parts.find((p) => p.id === 'estop').required, 'the overlay catalog makes the e-stop required');
+assert.strictEqual(overlayCatalog.parts.find((p) => p.id === 'stop').required, false, 'the overlay Stop is optional');
// The default layouts are what the SDK would accept, the one check every surface shares.
// The default layouts are what the SDK would accept, the one check every surface shares.
assert.deepEqual(validate(catalog, defaults), { ok: true, reasons: [] });
assert.deepEqual(validate(catalog, defaults), { ok: true, reasons: [] });
@@ -65,20 +65,12 @@ const world = require('../public/sim-world');
@@ -65,20 +65,12 @@ const world = require('../public/sim-world');
assert.ok(audit.some((a) => a.kind === 'drive' && a.operator_subject === alex.subject && a.result === 'ack'), 'each step is on the record as the owner\'s');
assert.ok(audit.some((a) => a.kind === 'drive' && a.operator_subject === alex.subject && a.result === 'ack'), 'each step is on the record as the owner\'s');
});
});
- await check('one routine at a time; removing a running routine stops it; the e-stop stops one with its reason', async () => {
+ await check('one routine at a time; removing a running routine stops it', async () => {
const long = (await api('POST', '/routines', alex, { name: 'Long', steps: 'forward 5' })).json.routine;
const long = (await api('POST', '/routines', alex, { name: 'Long', steps: 'forward 5' })).json.routine;
assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 202);
assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 202);
assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 409, 'already running');
assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 409, 'already running');
assert.strictEqual((await api('DELETE', `/routines/${long.id}`, alex)).status, 204);
assert.strictEqual((await api('DELETE', `/routines/${long.id}`, alex)).status, 204);
assert.ok(await poll(() => !routines.isRunning(robot.id)), 'it stopped');
assert.ok(await poll(() => !routines.isRunning(robot.id)), 'it stopped');
- const again = (await api('POST', '/routines', alex, { name: 'Again', steps: 'forward 5' })).json.routine;
- await api('POST', `/routines/${again.id}/run`, alex);
- await t.wait(400);
- assert.strictEqual((await api('POST', '/estop', alex)).status, 200);
- const stopped = await poll(async () => { const r = await routineOf(again.id); return r.last_result.startsWith('stopped') ? r : null; });
- assert.ok(stopped, 'the e-stop stopped it');
- assert.match(stopped.last_result, /stopped: .*(e-stop|estop|latched)/i);
- assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);
});
});
await check('a schedule runs when due and moves on; an "each time it comes online" routine runs on the robot\'s return', async () => {
await check('a schedule runs when due and moves on; an "each time it comes online" routine runs on the robot\'s return', async () => {
@@ -98,7 +90,7 @@ const world = require('../public/sim-world');
@@ -98,7 +90,7 @@ const world = require('../public/sim-world');
await check('someone else\'s robot: no routines, no webhooks, read or write', async () => {
await check('someone else\'s robot: no routines, no webhooks, read or write', async () => {
assert.strictEqual((await api('GET', '/routines', bob)).status, 403);
assert.strictEqual((await api('GET', '/routines', bob)).status, 403);
assert.strictEqual((await api('POST', '/routines', bob, { name: 'x', steps: 'forward 1' })).status, 403);
assert.strictEqual((await api('POST', '/routines', bob, { name: 'x', steps: 'forward 1' })).status, 403);
- assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.estop.set'] })).status, 403);
+ assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.command.refused'] })).status, 403);
const bad = await api('POST', '/routines', alex, { name: 'Bad', steps: 'say hello' });
const bad = await api('POST', '/routines', alex, { name: 'Bad', steps: 'say hello' });
assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_routine']);
assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_routine']);
});
});
@@ -106,10 +98,10 @@ const world = require('../public/sim-world');
@@ -106,10 +98,10 @@ const world = require('../public/sim-world');
let secret; let hookId;
let secret; let hookId;
await check('webhooks: https and public only, at most five, the secret once', async () => {
await check('webhooks: https and public only, at most five, the secret once', async () => {
for (const url of ['http://hooks.example.com/x', 'https://localhost/x', 'https://10.1.2.3/x', 'https://user:[email protected]/x']) {
for (const url of ['http://hooks.example.com/x', 'https://localhost/x', 'https://10.1.2.3/x', 'https://user:[email protected]/x']) {
- assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.estop.set'] })).status, 422, url);
+ assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.command.refused'] })).status, 422, url);
}
}
assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/x', events: ['bot.robot.exploded'] })).status, 422);
assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/x', events: ['bot.robot.exploded'] })).status, 422);
- const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.estop.set', 'bot.estop.cleared'] });
+ const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.command.refused', 'bot.robot.online'] });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
secret = made.json.secret;
secret = made.json.secret;
hookId = made.json.webhook.id;
hookId = made.json.webhook.id;
@@ -122,7 +114,7 @@ const world = require('../public/sim-world');
@@ -122,7 +114,7 @@ const world = require('../public/sim-world');
await check('an event writes a delivery with it; the sender signs it like Events and retries a failing receiver', async () => {
await check('an event writes a delivery with it; the sender signs it like Events and retries a failing receiver', async () => {
await t.db.query("DELETE FROM robot_webhook_deliveries");
await t.db.query("DELETE FROM robot_webhook_deliveries");
- assert.strictEqual((await api('POST', '/estop', alex)).status, 200);
+ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'test' });
const rows = await t.db.many("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1", [hookId]);
const rows = await t.db.many("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1", [hookId]);
assert.strictEqual(rows.length, 1, 'one delivery, written with the event');
assert.strictEqual(rows.length, 1, 'one delivery, written with the event');
const got = [];
const got = [];
@@ -132,10 +124,10 @@ const world = require('../public/sim-world');
@@ -132,10 +124,10 @@ const world = require('../public/sim-world');
const first = got[0];
const first = got[0];
assert.strictEqual(first.url, 'https://hooks.example.com/robot');
assert.strictEqual(first.url, 'https://hooks.example.com/robot');
const body = JSON.parse(first.body);
const body = JSON.parse(first.body);
- assert.deepStrictEqual([body.type, body.robot_id], ['bot.estop.set', robot.id]);
+ assert.deepStrictEqual([body.type, body.robot_id], ['bot.command.refused', robot.id]);
assert.ok(verifyDeliveryV2(first.body, first.headers, secret), 'the signature verifies with the hook\'s secret');
assert.ok(verifyDeliveryV2(first.body, first.headers, secret), 'the signature verifies with the hook\'s secret');
assert.ok(!verifyDeliveryV2(first.body, first.headers, 'whsec_wrong'), 'and with nothing else');
assert.ok(!verifyDeliveryV2(first.body, first.headers, 'whsec_wrong'), 'and with nothing else');
- assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.estop.set');
+ assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.command.refused');
let row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
let row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
assert.deepStrictEqual([row.state, row.attempts, row.last_status], ['pending', 1, 500]);
assert.deepStrictEqual([row.state, row.attempts, row.last_status], ['pending', 1, 500]);
assert.ok(Date.parse(row.due_at) > Date.now() + (BACKOFF_S[0] - 2) * 1000, 'retried after 30 s');
assert.ok(Date.parse(row.due_at) > Date.now() + (BACKOFF_S[0] - 2) * 1000, 'retried after 30 s');
@@ -146,9 +138,9 @@ const world = require('../public/sim-world');
@@ -146,9 +138,9 @@ const world = require('../public/sim-world');
row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
assert.deepStrictEqual([row.state, row.attempts], ['failed', BACKOFF_S.length + 1], 'it gives up');
assert.deepStrictEqual([row.state, row.attempts], ['failed', BACKOFF_S.length + 1], 'it gives up');
answer = 204;
answer = 204;
- assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);
+ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'test again' });
await sender.sendDue();
await sender.sendDue();
- const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.estop.cleared'", [hookId]);
+ const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.command.refused' AND id <> $2", [hookId, rows[0].id]);
assert.deepStrictEqual([cleared.state, cleared.last_status], ['delivered', 204]);
assert.deepStrictEqual([cleared.state, cleared.last_status], ['delivered', 204]);
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The embeddable panel's three views (?mode=panel|overlay|compact): the full panel as before, a transparent
// The embeddable panel's three views (?mode=panel|overlay|compact): the full panel as before, a transparent
-// overlay that draws only the live controls (the first stick, the first slider, the e-stop and "more"), and one
+// overlay that draws only the live controls (the first stick, the first slider, Drive and "more"), and one
// row for a narrow card. The controls come from the robot's own profile; a host can change the view by message.
// row for a narrow card. The controls come from the robot's own profile; a host can change the view by message.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -20,7 +20,7 @@ const DRIVE = {
@@ -20,7 +20,7 @@ const DRIVE = {
{ type: 'head', label: 'Head', order: 2, command: { kind: 'actuator', names: ['head', 'horn'] } },
{ type: 'head', label: 'Head', order: 2, command: { kind: 'actuator', names: ['head', 'horn'] } },
],
],
};
};
-const ROBOT = { id: 'rob_view1', name: 'LOOI', profile_id: 'looi.demo', estop: { latched: false } };
+const ROBOT = { id: 'rob_view1', name: 'LOOI', profile_id: 'looi.demo' };
const render = (over = {}) => renderPanel({
const render = (over = {}) => renderPanel({
robot: ROBOT, profile: DRIVE, role: 'owner', allowed_commands: ['drive', 'actuator', 'halt'],
robot: ROBOT, profile: DRIVE, role: 'owner', allowed_commands: ['drive', 'actuator', 'halt'],
mode: 'embed', signedIn: true, parentOrigins: ['https://openvibe.live'], ...over,
mode: 'embed', signedIn: true, parentOrigins: ['https://openvibe.live'], ...over,
@@ -32,16 +32,16 @@ const render = (over = {}) => renderPanel({
@@ -32,16 +32,16 @@ const render = (over = {}) => renderPanel({
assert.ok(html.includes('<html lang="en" class="embed-page bot-mode-overlay" data-bot-view="overlay" data-bot-min-width="280" data-bot-min-height="196">'));
assert.ok(html.includes('<html lang="en" class="embed-page bot-mode-overlay" data-bot-view="overlay" data-bot-min-width="280" data-bot-min-height="196">'));
assert.ok(html.includes('class="embed-page bot-mode-overlay"'));
assert.ok(html.includes('class="embed-page bot-mode-overlay"'));
assert.ok(html.includes('data-bot-overlay'));
assert.ok(html.includes('data-bot-overlay'));
- assert.ok(!html.includes('class="topbar"') && !html.includes('data-estop-banner') && !html.includes('class="widget-camera"'));
+ assert.ok(!html.includes('class="topbar"') && !html.includes('class="panel-top"') && !html.includes('class="widget-camera"'));
assert.ok(html.includes('data-bot-pill') && html.includes('data-bot-state') && html.includes('>LOOI<'));
assert.ok(html.includes('data-bot-pill') && html.includes('data-bot-state') && html.includes('>LOOI<'));
assert.ok(html.includes('data-joystick') && html.includes('data-actuator="head"'));
assert.ok(html.includes('data-joystick') && html.includes('data-actuator="head"'));
- assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));
+ assert.ok(!html.includes('data-estop') && html.includes('data-bot-more'), 'no stop until a layout adds one');
const hits = html.match(/data-bot-hit/g) || [];
const hits = html.match(/data-bot-hit/g) || [];
- assert.ok(hits.length >= 5, `pill, stick, slider, e-stop and more each report a hit box (${hits.length})`);
+ assert.ok(hits.length >= 4, `pill, stick, slider and more each report a hit box (${hits.length})`);
assert.ok(!html.includes('data-hold=') && !html.includes('data-stop'), 'no pad buttons or stop row in the overlay');
assert.ok(!html.includes('data-hold=') && !html.includes('data-stop'), 'no pad buttons or stop row in the overlay');
});
});
- await check('overlay: the controls a robot has decide what it draws (first stick, first slider, e-stop by role)', () => {
+ await check('overlay: the controls a robot has decide what it draws (first stick, first slider, Drive by role)', () => {
const ptz = {
const ptz = {
id: 'camera.demo', name: 'Camera',
id: 'camera.demo', name: 'Camera',
commands: { ptz: { axes: { pan: [-1, 1], tilt: [-1, 1], zoom: [-1, 1] } }, actuator: { names: { zoom: { type: 'number', range: [0, 3] } } } },
commands: { ptz: { axes: { pan: [-1, 1], tilt: [-1, 1], zoom: [-1, 1] } }, actuator: { names: { zoom: { type: 'number', range: [0, 3] } } } },
@@ -52,10 +52,10 @@ const render = (over = {}) => renderPanel({
@@ -52,10 +52,10 @@ const render = (over = {}) => renderPanel({
};
};
const cam = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay' });
const cam = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay' });
assert.ok(cam.includes('data-ptz-stick') && cam.includes('data-actuator="zoom"'));
assert.ok(cam.includes('data-ptz-stick') && cam.includes('data-actuator="zoom"'));
- assert.ok(!cam.includes('data-estop'), 'a signed-in watcher has no e-stop and no Drive here');
+ assert.ok(!cam.includes('data-estop') && !cam.includes('data-drive'), 'a signed-in watcher has no Stop and no Drive here');
const anon = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false });
const anon = renderPanel({ robot: { ...ROBOT, id: 'rob_cam1' }, profile: ptz, role: 'watcher', allowed_commands: [], mode: 'embed', view: 'overlay', signedIn: false });
assert.ok(/<button[^>]*class="bot-drive bot-control"[^>]*data-drive data-drive-bar data-bot-hit/.test(anon), 'the anonymous embed takes the controls with Drive (ask 353)');
assert.ok(/<button[^>]*class="bot-drive bot-control"[^>]*data-drive data-drive-bar data-bot-hit/.test(anon), 'the anonymous embed takes the controls with Drive (ask 353)');
- assert.ok(/<button[^>]*class="bot-estop bot-control"[^>]*data-estop[^>]*hidden>/.test(anon), 'its e-stop waits, hidden, for the join');
+ assert.ok(!anon.includes('data-estop'), 'a watcher has no Stop: the palette adds it to a layout, not to this one');
assert.ok(anon.includes('class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden'), 'and the page\'s answer has a line');
assert.ok(anon.includes('class="bot-notice bot-control" data-notice data-bot-hit role="alert" hidden'), 'and the page\'s answer has a line');
assert.ok(cam.includes('data-bot-more'), 'but there is always a way to the full panel');
assert.ok(cam.includes('data-bot-more'), 'but there is always a way to the full panel');
const hornOnly = {
const hornOnly = {
@@ -64,21 +64,21 @@ const render = (over = {}) => renderPanel({
@@ -64,21 +64,21 @@ const render = (over = {}) => renderPanel({
};
};
const quiet = renderPanel({ robot: ROBOT, profile: hornOnly, role: 'owner', allowed_commands: ['actuator'], mode: 'embed', view: 'overlay' });
const quiet = renderPanel({ robot: ROBOT, profile: hornOnly, role: 'owner', allowed_commands: ['actuator'], mode: 'embed', view: 'overlay' });
assert.ok(!quiet.includes('data-actuator') && !quiet.includes('data-joystick'), 'a tone is not a slider, and there is no stick');
assert.ok(!quiet.includes('data-actuator') && !quiet.includes('data-joystick'), 'a tone is not a slider, and there is no stick');
- assert.ok(quiet.includes('data-estop') && quiet.includes('data-bot-more'));
+ assert.ok(!quiet.includes('data-estop') && quiet.includes('data-bot-more'));
});
});
- await check('compact: one row with the pill, a 96 px stick, the slider across, the e-stop and more', () => {
+ await check('compact: one row with the pill, a 96 px stick, the slider across and more', () => {
const html = render({ view: 'compact' });
const html = render({ view: 'compact' });
assert.ok(html.includes('class="embed-page bot-mode-compact" data-bot-view="compact" data-bot-min-width="320" data-bot-min-height="112"'));
assert.ok(html.includes('class="embed-page bot-mode-compact" data-bot-view="compact" data-bot-min-width="320" data-bot-min-height="112"'));
assert.ok(html.includes('class="bot-compact"') && !html.includes('data-bot-overlay'));
assert.ok(html.includes('class="bot-compact"') && !html.includes('data-bot-overlay'));
assert.ok(html.includes('data-bot-pill') && html.includes('data-joystick') && html.includes('data-actuator="head"'));
assert.ok(html.includes('data-bot-pill') && html.includes('data-joystick') && html.includes('data-actuator="head"'));
- assert.ok(html.includes('data-estop') && html.includes('data-bot-more'));
+ assert.ok(!html.includes('data-estop') && html.includes('data-bot-more'), 'no stop until a layout adds one');
});
});
await check('panel is the default view: an unknown mode falls back to the full panel and its old markup', () => {
await check('panel is the default view: an unknown mode falls back to the full panel and its old markup', () => {
const html = render({});
const html = render({});
assert.ok(html.includes('data-bot-view="panel"') && html.includes('data-bot-min-width="320"') && html.includes('data-bot-min-height="480"'));
assert.ok(html.includes('data-bot-view="panel"') && html.includes('data-bot-min-width="320"') && html.includes('data-bot-min-height="480"'));
- assert.ok(html.includes('data-estop-banner') && html.includes('class="widgets"'));
+ assert.ok(html.includes('class="panel-top"') && html.includes('class="widgets"'));
const weird = render({ view: '../../etc/passwd' });
const weird = render({ view: '../../etc/passwd' });
assert.ok(weird.includes('data-bot-view="panel"') && weird.includes('class="widgets"'));
assert.ok(weird.includes('data-bot-view="panel"') && weird.includes('class="widgets"'));
});
});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The control gate (ADR-043 decisions 6 and 8): roles, the access policy, owner limits that clamp an
// The control gate (ADR-043 decisions 6 and 8): roles, the access policy, owner limits that clamp an
-// operator, cooldowns, the latched e-stop (owner-cleared only), the turn queue and a service acting for
+// operator, cooldowns, the turn queue and a service acting for
// a viewer. Every decision is audited.
// a viewer. Every decision is audited.
const assert = require('assert');
const assert = require('assert');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
@@ -73,40 +73,13 @@ const plugins = require('./helpers/plugins');
@@ -73,40 +73,13 @@ const plugins = require('./helpers/plugins');
b.close();
b.close();
});
});
- await check('the e-stop latches, refuses commands, and only the owner clears it', async () => {
- const { robot, pairing } = await t.robot(alex);
- await invite(robot.id, bob.subject, 'operator');
- const dev = await deviceFor(robot.id, pairing.code);
- const b = await op(bob);
- await joinResult(b, robot.id);
- b.send({ type: 'estop' });
- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');
- cmd(b, { id: 'e1', kind: 'drive', value: { throttle: 0.2 } });
- const refused = await result(b, 'e1');
- assert.strictEqual(refused.code, 'bot.estop_latched');
- b.send({ type: 'estop_clear' });
- const denied = await b.waitFor((m) => m.type === 'error' && /forbidden/.test(m.code));
- assert.strictEqual(denied.code, 'bot.forbidden');
- const a = await op(alex);
- await joinResult(a, robot.id);
- a.send({ type: 'estop_clear' });
- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !r.estop_latched)), 'the owner cleared it');
- cmd(b, { id: 'e2', kind: 'drive', value: { throttle: 0.2 } });
- assert.ok(await dev.waitFor((m) => m.type === 'command' && m.ref === 'e2'), 'an operator may drive again');
- a.close(); b.close();
- });
-
- await check('halt passes the gate while the e-stop is latched; every other kind is refused', async () => {
+ await check('halt passes the allowlist, cooldowns and the budget', async () => {
const { robot, pairing } = await t.robot(alex, { limits: { cooldown_ms: 60000, allow: { operator: ['drive'] } } });
const { robot, pairing } = await t.robot(alex, { limits: { cooldown_ms: 60000, allow: { operator: ['drive'] } } });
await invite(robot.id, bob.subject, 'operator');
await invite(robot.id, bob.subject, 'operator');
const dev = await deviceFor(robot.id, pairing.code);
const dev = await deviceFor(robot.id, pairing.code);
const b = await op(bob);
const b = await op(bob);
const j = await joinResult(b, robot.id);
const j = await joinResult(b, robot.id);
assert.deepStrictEqual(j.allowed_commands, ['drive', 'halt'], 'no allowlist removes halt');
assert.deepStrictEqual(j.allowed_commands, ['drive', 'halt'], 'no allowlist removes halt');
- b.send({ type: 'estop' });
- assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');
- cmd(b, { id: 'h0', kind: 'drive', value: { throttle: 0.2 } });
- assert.strictEqual((await result(b, 'h0')).code, 'bot.estop_latched');
cmd(b, { id: 'h1', kind: 'halt' });
cmd(b, { id: 'h1', kind: 'halt' });
const h1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'h1');
const h1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'h1');
assert.strictEqual(h1.kind, 'halt');
assert.strictEqual(h1.kind, 'halt');
@@ -520,8 +493,7 @@ const plugins = require('./helpers/plugins');
@@ -520,8 +493,7 @@ const plugins = require('./helpers/plugins');
assert.strictEqual(anon.json.code, 'bot.sign_in');
assert.strictEqual(anon.json.code, 'bot.sign_in');
});
});
- await check('a latched e-stop and an offline device answer 409 over the HTTP route', async () => {
- // A device-less robot of its own: offline first, then latched (the e-stop is checked before liveness).
+ await check('an offline device answers 409 over the HTTP route', async () => {
const setup = await t.domain.robots.create({ owner: alex.subject, name: 'No device', profile_id: 'sim.rover' });
const setup = await t.domain.robots.create({ owner: alex.subject, name: 'No device', profile_id: 'sim.rover' });
const robot = setup.robot;
const robot = setup.robot;
await t.domain.members.add(robot.id, bob.subject, 'operator', alex.subject);
await t.domain.members.add(robot.id, bob.subject, 'operator', alex.subject);
@@ -529,10 +501,6 @@ const plugins = require('./helpers/plugins');
@@ -529,10 +501,6 @@ const plugins = require('./helpers/plugins');
const offline = await send('off-1');
const offline = await send('off-1');
assert.strictEqual(offline.status, 409, offline.text);
assert.strictEqual(offline.status, 409, offline.text);
assert.strictEqual(offline.json.code, 'bot.device_offline');
assert.strictEqual(offline.json.code, 'bot.device_offline');
- await t.domain.estop.set(robot.id, { latched: true, by: alex.subject, principalKind: 'user' });
- const latched = await send('estop-1');
- assert.strictEqual(latched.status, 409, latched.text);
- assert.strictEqual(latched.json.code, 'bot.estop_latched');
});
});
await check('a different subject reusing an id is never served another subject\'s cached result', async () => {
await check('a different subject reusing an id is never served another subject\'s cached result', async () => {
@@ -1,14 +1,19 @@
@@ -1,14 +1,19 @@
'use strict';
'use strict';
-// Device-socket safety (plan T15 follow-up A): a device's estop_state is a report that may latch a robot
-// it is attached to and never clears the owner's latch; frames sent during authentication are kept and
-// handled in order; telemetry carrying events passes the 2-Hz rule; heartbeat_ack correlates through
-// `echo` while Bot's envelope seq stays strictly increasing.
+// Device-socket safety without a remote latch: a device may report a Stop held on the machine itself, and that
+// report stays local. It never latches a robot, never refuses a later command and is never audited as an action;
+// a report for a robot the device is not attached to is still refused. Frames sent during authentication are
+// kept and handled in order, and a device's faults reach the panel.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
(async () => {
(async () => {
const t = await boot();
const t = await boot();
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
+ const poll = async (fn, ms = 2000) => {
+ const end = Date.now() + ms;
+ while (Date.now() < end) { if (await fn()) return true; await t.wait(25); }
+ return false;
+ };
async function ownerJoined(robotId) {
async function ownerJoined(robotId) {
const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
@@ -16,65 +21,24 @@ const { boot, check, done } = require('./helpers/app');
@@ -16,65 +21,24 @@ const { boot, check, done } = require('./helpers/app');
await op.waitFor((m) => m.type === 'joined');
await op.waitFor((m) => m.type === 'joined');
return op;
return op;
}
}
- /** A credential from the REST pair, so the socket authenticates by header on the upgrade. */
- async function credentialFor() {
+ async function connected() {
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
assert.strictEqual(paired.status, 201, paired.text);
assert.strictEqual(paired.status, 201, paired.text);
- return { robot, credential: paired.json.credential, deviceId: paired.json.device_id };
- }
- async function connected() {
- const c = await credentialFor();
- const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${c.credential}` } });
+ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });
await dev.waitFor((m) => m.type === 'config');
await dev.waitFor((m) => m.type === 'config');
- return { ...c, dev };
+ return { robot, dev, deviceId: paired.json.device_id };
}
}
- await check('a device estop_state latched:false never clears the owner latch (a report only)', async () => {
+ await check('a device\'s own Stop report is accepted and stays local: no latch, no refusal, no audit', async () => {
const { robot, dev } = await connected();
const { robot, dev } = await connected();
const op = await ownerJoined(robot.id);
const op = await ownerJoined(robot.id);
- op.send({ type: 'estop' });
- await dev.waitFor((m) => m.type === 'estop' && m.latched === true);
- dev.send({ type: 'estop_state', latched: false, robot_id: robot.id });
- const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.latched === false);
- assert.strictEqual(st.state.estop.latched, true, 'the owner latch stands');
- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, true);
- dev.send({ type: 'estop_state', latched: false }); // robot_id omitted = every attached robot
- await t.wait(100);
- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, true);
- assert.ok(!dev.messages.some((m) => m.type === 'error'), JSON.stringify(dev.messages.filter((m) => m.type === 'error')));
- await assert.rejects(t.domain.estop.set(robot.id, { latched: false, by: 'device', principalKind: 'device' }), (e) => e.code === 'bot.forbidden');
- // the owner clear path still works and tells the device
- op.send({ type: 'estop_clear' });
- await dev.waitFor((m) => m.type === 'estop' && m.latched === false);
- assert.strictEqual(!!(await t.domain.robots.get(robot.id)).estop_latched, false);
- // and a device's latched:true still latches
- dev.send({ type: 'estop_state', latched: true });
- await op.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true);
- op.close(); dev.close();
- });
-
- await check('a local stop on the device (openvibe-link stop) is shown as one, and the owner clear cannot lift it', async () => {
- const { robot, dev } = await connected();
- const op = await ownerJoined(robot.id);
- dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });
- const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.local === true);
- assert.strictEqual(st.state.estop.latched, true, 'the device latched the robot');
- assert.strictEqual(st.state.device_estop.reason, 'stop button on the robot', 'the reason is shown as plain, short text');
- // The owner clears Bot's latch; the device still holds its own and says so again, which latches the robot again.
- op.send({ type: 'estop_clear' });
- await dev.waitFor((m) => m.type === 'estop' && m.latched === false);
- op.messages.length = 0; // waitFor also searches frames already received: only the ones from here on count
- dev.send({ type: 'estop_state', latched: true, local_stop: true, robot_id: robot.id });
- await op.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true && m.state.device_estop.local === true);
- // Released on the robot: the report is latched:false, never local, and Bot's latch waits for the owner.
- op.messages.length = 0;
- dev.send({ type: 'estop_state', latched: false, local_stop: true, robot_id: robot.id });
- const after = await op.waitFor((m) => m.type === 'robot_state' && m.state.device_estop && m.state.device_estop.latched === false);
- assert.deepStrictEqual([after.state.device_estop.local, after.state.device_estop.reason, after.state.estop.latched], [false, null, true]);
- const panel = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'panel.js'), 'utf8');
- assert.match(panel, /state\.device_estop\.local/, 'the panel reads the local flag');
- assert.match(panel, /clear\.disabled = held;/, 'and waits with the clear button while the robot holds its stop');
+ dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'button on the base', robot_id: robot.id });
+ assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => r && r.estop_latched === false)), 'the old latch column stays false');
+ const cmd = await t.call('POST', `/api/v1/robots/${robot.id}/commands`, { user: alex, body: { id: 'after-stop', kind: 'drive', value: { throttle: 0.2 } } });
+ assert.notStrictEqual(cmd.json && cmd.json.code, 'bot.estop_latched', 'a Stop report never refuses a command');
+ const audit = await t.domain.audit.list(robot.id, { limit: 10 });
+ assert.ok(!audit.some((a) => a.kind === 'estop_state'), 'the report is not an audited action');
op.close(); dev.close();
op.close(); dev.close();
});
});
@@ -82,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -82,7 +46,7 @@ const { boot, check, done } = require('./helpers/app');
const { robot, dev } = await connected();
const { robot, dev } = await connected();
const op = await ownerJoined(robot.id);
const op = await ownerJoined(robot.id);
op.messages.length = 0;
op.messages.length = 0;
- dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }], estop_latched: false });
+ dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }] });
const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.status && m.state.status.faults && m.state.status.faults.length === 1);
const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.status && m.state.status.faults && m.state.status.faults.length === 1);
assert.deepStrictEqual(st.state.status.faults[0], { code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' });
assert.deepStrictEqual(st.state.status.faults[0], { code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' });
const fs = require('fs');
const fs = require('fs');
@@ -91,134 +55,43 @@ const { boot, check, done } = require('./helpers/app');
@@ -91,134 +55,43 @@ const { boot, check, done } = require('./helpers/app');
assert.match(panel, /state\.status && Array\.isArray\(state\.status\.faults\)/, 'the panel reads the device\'s faults');
assert.match(panel, /state\.status && Array\.isArray\(state\.status\.faults\)/, 'the panel reads the device\'s faults');
assert.match(panel, /else if \(m\.result === 'nack'\) \{ const t = nackText\(m\.reason\); if \(t\) notice\(t\); \}/, 'and says why the robot refused');
assert.match(panel, /else if \(m\.result === 'nack'\) \{ const t = nackText\(m\.reason\); if \(t\) notice\(t\); \}/, 'and says why the robot refused');
assert.match(panel, /const QUIET_NACKS = new Set\(\['expired'\]\);/, 'a deadline that passed in transit is not news');
assert.match(panel, /const QUIET_NACKS = new Set\(\['expired'\]\);/, 'a deadline that passed in transit is not news');
- const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover', estop: { latched: false } }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });
+ const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover' }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });
assert.ok(html.includes('data-device-fault'), 'the panel has a place for them');
assert.ok(html.includes('data-device-fault'), 'the panel has a place for them');
op.close(); dev.close();
op.close(); dev.close();
});
});
- await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {
+ await check('a device cannot report for a robot it is not attached to (bot.forbidden, no state change)', async () => {
const { dev, deviceId } = await connected();
const { dev, deviceId } = await connected();
const { robot: other } = await t.robot(alex);
const { robot: other } = await t.robot(alex);
dev.send({ type: 'estop_state', latched: true, robot_id: other.id });
dev.send({ type: 'estop_state', latched: true, robot_id: other.id });
const e1 = await dev.waitFor((m) => m.type === 'error');
const e1 = await dev.waitFor((m) => m.type === 'error');
assert.strictEqual(e1.code, 'bot.forbidden');
assert.strictEqual(e1.code, 'bot.forbidden');
- dev.send({ type: 'estop_state', latched: false, robot_id: other.id });
- await dev.waitFor((m) => m.type === 'error' && m !== e1);
assert.strictEqual(!!(await t.domain.robots.get(other.id)).estop_latched, false, 'the other robot is untouched');
assert.strictEqual(!!(await t.domain.robots.get(other.id)).estop_latched, false, 'the other robot is untouched');
const audit = await t.domain.audit.list(other.id, { limit: 5 });
const audit = await t.domain.audit.list(other.id, { limit: 5 });
- const refused = audit.filter((a) => a.kind === 'estop_state' && a.result === 'refused' && a.reason === 'bot.forbidden' && a.device_id === deviceId);
- assert.strictEqual(refused.length, 2, JSON.stringify(audit));
+ assert.ok(!audit.some((a) => a.kind === 'estop_state' && a.device_id === deviceId), 'nothing is audited for a report');
dev.close();
dev.close();
});
});
- await check('status and estop_state sent right after the upgrade are retained, in order', async () => {
- const { robot, credential, deviceId } = await credentialFor();
- // A slow credential lookup, so the frames below certainly arrive while authentication is in progress.
+ await check('status and a Stop report sent right after the upgrade are retained, in order', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
const byCredential = t.domain.devices.byCredential;
const byCredential = t.domain.devices.byCredential;
t.domain.devices.byCredential = async (c) => { await t.wait(200); return byCredential(c); };
t.domain.devices.byCredential = async (c) => { await t.wait(200); return byCredential(c); };
let dev;
let dev;
try {
try {
- dev = await t.ws('/device', { headers: { Authorization: `Bearer ${credential}` } });
+ dev = await t.ws('/device', { headers: { Authorization: `Bearer ${paired.json.credential}` } });
dev.send({ type: 'status', firmware: 'adeept-0.1.0', faults: [] });
dev.send({ type: 'status', firmware: 'adeept-0.1.0', faults: [] });
dev.send({ type: 'estop_state', latched: true });
dev.send({ type: 'estop_state', latched: true });
dev.send({ type: 'status', firmware: 'adeept-0.1.1', faults: [] });
dev.send({ type: 'status', firmware: 'adeept-0.1.1', faults: [] });
await dev.waitFor((m) => m.type === 'config');
await dev.waitFor((m) => m.type === 'config');
} finally { t.domain.devices.byCredential = byCredential; }
} finally { t.domain.devices.byCredential = byCredential; }
- const latched = await poll(async () => !!(await t.domain.robots.get(robot.id)).estop_latched, 2000);
- assert.ok(latched, 'the startup estop_state latched the robot');
assert.ok(!dev.messages.some((m) => m.type === 'error'), JSON.stringify(dev.messages.filter((m) => m.type === 'error')));
assert.ok(!dev.messages.some((m) => m.type === 'error'), JSON.stringify(dev.messages.filter((m) => m.type === 'error')));
- const state = t.hub.deviceState(deviceId);
- assert.strictEqual(state.status && state.status.firmware, 'adeept-0.1.1', 'both status frames handled, in order');
+ assert.ok(await poll(() => { const s = t.hub.deviceState(paired.json.device_id); return s && s.status && s.status.firmware === 'adeept-0.1.1'; }), 'both status frames handled, in order');
const types = dev.messages.map((m) => m.type);
const types = dev.messages.map((m) => m.type);
assert.ok(types.indexOf('hello') < types.indexOf('config'), types.join());
assert.ok(types.indexOf('hello') < types.indexOf('config'), types.join());
dev.close();
dev.close();
});
});
- await check('a telemetry frame with events inside the 500-ms window is delivered; plain ones are still throttled', async () => {
- const { robot, dev } = await connected();
- const op = await ownerJoined(robot.id);
- dev.send({ type: 'telemetry', battery: 0.5 });
- dev.send({ type: 'telemetry', battery: 0.4, events: [{ kind: 'bump', at: Date.now() }] });
- dev.send({ type: 'telemetry', battery: 0.39, events: [{ kind: 'low_battery' }] });
- dev.send({ type: 'telemetry', battery: 0.3 });
- await op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.4);
- await op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.39);
- await t.wait(150);
- assert.ok(!op.messages.some((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.3), 'a plain frame inside the window is dropped');
- op.close(); dev.close();
- });
-
- await check('an event does not use up the sensor window: the next sample is judged against the last sample', async () => {
- const { robot, dev } = await connected();
- const op = await ownerJoined(robot.id);
- const sample = (battery) => op.waitFor((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === battery);
- // The injected clock moves the window; a heartbeat just before each step keeps the device online.
- const beat = async (n) => { dev.send({ type: 'heartbeat', t: n }); await dev.waitFor((m) => m.type === 'heartbeat_ack' && m.echo === n); };
- try {
- dev.send({ type: 'telemetry', battery: 0.9 });
- await sample(0.9);
- await beat(1);
- t.clock.offset += 300;
- dev.send({ type: 'telemetry', battery: 0.8, events: [{ kind: 'bump' }] });
- await sample(0.8);
- dev.send({ type: 'telemetry', battery: 0.7 }); // 300 ms after the last sample: dropped
- await beat(2);
- t.clock.offset += 250;
- dev.send({ type: 'telemetry', battery: 0.6 }); // 550 ms after the last sample, 250 after the event: delivered
- await sample(0.6);
- assert.ok(!op.messages.some((m) => m.type === 'robot_state' && m.state.telemetry && m.state.telemetry.battery === 0.7), 'a second sample inside the window is dropped');
- } finally {
- t.clock.offset -= 550;
- }
- op.close(); dev.close();
- });
-
- await check('setting or clearing the latch re-sends config with estop_latched, from the owner, REST and the device', async () => {
- const { robot, dev } = await connected();
- const first = dev.messages.find((m) => m.type === 'config');
- assert.strictEqual(first.estop_latched, false);
- const op = await ownerJoined(robot.id);
- const nextConfig = (after, latched) => dev.waitFor((m) => m.type === 'config' && m.seq > after && m.estop_latched === latched);
- op.send({ type: 'estop' });
- const estop = await dev.waitFor((m) => m.type === 'estop' && m.latched === true);
- const c1 = await nextConfig(first.seq, true);
- assert.ok(c1.seq > estop.seq, 'the estop frame comes first');
- assert.deepStrictEqual(c1.limits, first.limits);
- assert.deepStrictEqual(c1.allowed_commands, first.allowed_commands);
- op.send({ type: 'estop_clear' });
- const c2 = await nextConfig(c1.seq, false);
- const r = await t.call('POST', `/api/v1/robots/${robot.id}/estop`, { user: alex });
- assert.strictEqual(r.status, 200, r.text);
- const c3 = await nextConfig(c2.seq, true);
- const cleared = await t.call('POST', `/api/v1/robots/${robot.id}/estop/clear`, { user: alex });
- assert.strictEqual(cleared.status, 200, cleared.text);
- const c4 = await nextConfig(c3.seq, false);
- dev.send({ type: 'estop_state', latched: true }); // the device's own latch moves Bot's
- await nextConfig(c4.seq, true);
- op.close(); dev.close();
- });
-
- await check('heartbeat_ack echoes t; Bot\'s envelope seq stays strictly increasing', async () => {
- const { dev } = await connected();
- for (let i = 1; i <= 4; i++) dev.send({ type: 'heartbeat', seq: 1, t: 1000 + i, rtt_ms: 10 });
- dev.send({ type: 'heartbeat', rtt_ms: 10 });
- await dev.waitFor(() => dev.messages.filter((m) => m.type === 'heartbeat_ack').length === 5);
- const acks = dev.messages.filter((m) => m.type === 'heartbeat_ack');
- assert.deepStrictEqual(acks.map((a) => a.echo), [1001, 1002, 1003, 1004, null]);
- const seqs = dev.messages.map((m) => m.seq);
- for (let i = 1; i < seqs.length; i++) assert.ok(seqs[i] > seqs[i - 1], `seq ${seqs.join(',')}`);
- dev.close();
- });
-
await t.close();
await t.close();
done();
done();
-})();
-
-async function poll(fn, ms) {
- const end = Date.now() + ms;
- for (;;) {
- if (await fn()) return true;
- if (Date.now() > end) return false;
- await new Promise((r) => setTimeout(r, 25));
- }
-}
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -51,14 +51,14 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
@@ -51,14 +51,14 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
// The lifecycle.
// The lifecycle.
for (const step of ['hello', 'describe', 'ready', 'heartbeat']) assert.ok(html.includes(step), `no ${step} in the lifecycle`);
for (const step of ['hello', 'describe', 'ready', 'heartbeat']) assert.ok(html.includes(step), `no ${step} in the lifecycle`);
// Every message type, each with a JSON example.
// Every message type, each with a JSON example.
- for (const op of ['hello', 'command', 'heartbeat', 'stop', 'estop', 'resume', 'describe', 'ready', 'fault', 'ack', 'nack', 'telemetry', 'event', 'video']) {
+ for (const op of ['hello', 'command', 'heartbeat', 'stop', 'describe', 'ready', 'fault', 'ack', 'nack', 'telemetry', 'event', 'video']) {
assert.ok(html.includes(`"op": "${op}"`), `no ${op} JSON example`);
assert.ok(html.includes(`"op": "${op}"`), `no ${op} JSON example`);
}
}
// The safety rules.
// The safety rules.
assert.match(html, /must stop every actuator/);
assert.match(html, /must stop every actuator/);
assert.match(html, /deadline/);
assert.match(html, /deadline/);
assert.match(html, /no_heartbeat/);
assert.match(html, /no_heartbeat/);
- assert.match(html, /e-stop/);
+ assert.match(html, /local Stop/);
// Links out to the full protocol and the contributor path.
// Links out to the full protocol and the contributor path.
assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/plugins\.md/);
assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/plugins\.md/);
assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/protocol\.md/);
assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/protocol\.md/);
@@ -84,7 +84,7 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
@@ -84,7 +84,7 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
const html = await r.text();
const html = await r.text();
assert.match(html, /<h1>Routines, webhooks and your agent<\/h1>/);
assert.match(html, /<h1>Routines, webhooks and your agent<\/h1>/);
- for (const word of ['forward 2', 'every N minutes', 'bot.estop.set', 'whsec_', 'X-OpenVibe-Signature-V2', 'verifyDeliveryV2', 'Let my OpenVibe.Actor agent drive this robot', '/api/v1/robots/rob_…/steps', 'bot.agent_not_allowed']) {
+ for (const word of ['forward 2', 'every N minutes', 'bot.command.refused', 'whsec_', 'X-OpenVibe-Signature-V2', 'verifyDeliveryV2', 'Let my OpenVibe.Actor agent drive this robot', '/api/v1/robots/rob_…/steps', 'bot.agent_not_allowed']) {
assert.ok(html.includes(word), word);
assert.ok(html.includes(word), word);
}
}
assert.ok(html.includes('href="/docs/automation" aria-current="page"'), 'in the build nav');
assert.ok(html.includes('href="/docs/automation" aria-current="page"'), 'in the build nav');
@@ -32,17 +32,15 @@ const { renderPanel } = require('../server/web/render');
@@ -32,17 +32,15 @@ const { renderPanel } = require('../server/web/render');
assert.strictEqual(ticketFromProtocols(''), null);
assert.strictEqual(ticketFromProtocols(''), null);
});
});
- await check('the framed watcher gets a Drive button and a hidden e-stop; the client takes tickets from its own popup only', () => {
+ await check('the framed watcher gets a Drive button; the client takes tickets from its own popup only', () => {
const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
- const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
+ const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
assert.ok(html.includes('data-drive data-drive-bar>'), 'Drive is its own bar: it goes when the controls are taken');
assert.ok(html.includes('data-drive data-drive-bar>'), 'Drive is its own bar: it goes when the controls are taken');
- assert.ok(html.includes('data-estop hidden>'), 'the e-stop waits for an owner or operator join');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
// Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.
// Only its own popup, or the allowed page around the frame (stream-buttons.test.js), hands it a ticket.
assert.match(js, /const fromPopup = e\.origin === location\.origin && popup && e\.source === popup;/);
assert.match(js, /const fromPopup = e\.origin === location\.origin && popup && e\.source === popup;/);
assert.match(js, /if \(!fromPopup && !fromParent\) return;/);
assert.match(js, /if \(!fromPopup && !fromParent\) return;/);
assert.match(js, /new WebSocket\(wsUrl\(path\), \['ov\.ticket', `ov\.ticket\.\$\{ticket\}`\]\)/);
assert.match(js, /new WebSocket\(wsUrl\(path\), \['ov\.ticket', `ov\.ticket\.\$\{ticket\}`\]\)/);
- assert.match(js, /\$\('\[data-estop\]:not\(\[hidden\]\)'\)\) estop\(\);/, 'a gamepad never e-stops through a hidden button');
const popupJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'drive.js'), 'utf8');
const popupJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'drive.js'), 'utf8');
assert.match(popupJs, /window\.opener\.postMessage\(\{ type: 'ov-bot-ticket', robot_id: robotId, ticket: body\.ticket \}, location\.origin\)/);
assert.match(popupJs, /window\.opener\.postMessage\(\{ type: 'ov-bot-ticket', robot_id: robotId, ticket: body\.ticket \}, location\.origin\)/);
});
});
@@ -77,7 +77,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
@@ -77,7 +77,7 @@ const themeMessage = (tokens, extra = {}) => ({ type: 'ov-bot-theme', robot_id:
await check('embed page: the frame adapter is loaded for the embed only, with the trusted origins on the panel', () => {
await check('embed page: the frame adapter is loaded for the embed only, with the trusted origins on the panel', () => {
const profile = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [] };
const profile = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [] };
- const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };
+ const robot = { id: 'rob_x1', name: 'Rover', profile_id: 'sim.rover' };
const embed = renderPanel({ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false, parentOrigins: [PARENT, 'https://www.openvibe.live'] });
const embed = renderPanel({ robot, profile, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false, parentOrigins: [PARENT, 'https://www.openvibe.live'] });
const at = (s) => embed.indexOf(s);
const at = (s) => embed.indexOf(s);
assert.ok(embed.includes('<html lang="en" class="embed-page" data-bot-view="panel" data-bot-min-width="320" data-bot-min-height="480">'), 'the embed follows prefers-color-scheme before the first token message, and names its view');
assert.ok(embed.includes('<html lang="en" class="embed-page" data-bot-view="panel" data-bot-min-width="320" data-bot-min-height="480">'), 'the embed follows prefers-color-scheme before the first token message, and names its view');
@@ -10,18 +10,17 @@ const { renderPanel } = require('../server/web/render');
@@ -10,18 +10,17 @@ const { renderPanel } = require('../server/web/render');
(async () => {
(async () => {
const SIM = { id: 'sim.rover', name: 'Simulated rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }, { type: 'latency', label: 'Latency' }] };
const SIM = { id: 'sim.rover', name: 'Simulated rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }, { type: 'latency', label: 'Latency' }] };
- const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } };
+ const robotRow = { id: 'rob_e1', name: 'Rover', profile_id: 'sim.rover' };
await check('BOT_WATCH_MAX_PER_IP and BOT_WATCH_MAX_PER_ROBOT default to 20 and 500', () => {
await check('BOT_WATCH_MAX_PER_IP and BOT_WATCH_MAX_PER_ROBOT default to 20 and 500', () => {
assert.deepStrictEqual(loadConfig({}).watch, { maxPerIp: 20, maxPerRobot: 500 });
assert.deepStrictEqual(loadConfig({}).watch, { maxPerIp: 20, maxPerRobot: 500 });
assert.deepStrictEqual(loadConfig({ BOT_WATCH_MAX_PER_IP: '3', BOT_WATCH_MAX_PER_ROBOT: '0' }).watch, { maxPerIp: 3, maxPerRobot: 1 });
assert.deepStrictEqual(loadConfig({ BOT_WATCH_MAX_PER_IP: '3', BOT_WATCH_MAX_PER_ROBOT: '0' }).watch, { maxPerIp: 3, maxPerRobot: 1 });
});
});
- await check('embed mode: no topbar, the e-stop state always, controls only for the role, links open a new tab', () => {
+ await check('embed mode: no topbar, controls only for the role, links open a new tab', () => {
const watcher = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
const watcher = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: false });
assert.ok(watcher.includes('class="embed-page"') && !watcher.includes('class="topbar"'));
assert.ok(watcher.includes('class="embed-page"') && !watcher.includes('class="topbar"'));
- assert.ok(watcher.includes('data-estop-banner') && watcher.includes('data-estop-state'));
- assert.ok(!watcher.includes('data-estop>') && !watcher.includes('data-estop-clear') && !watcher.includes('data-embed-form'));
+ assert.ok(!watcher.includes('data-estop') && !watcher.includes('data-embed-form'));
assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
assert.ok(watcher.includes('data-role="watcher"') && watcher.includes('data-allowed="[]"'));
assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');
assert.ok(!/<button(?![^>]*disabled)[^>]*data-(hold|stop)/.test(watcher), 'a control is enabled for a watcher');
// Ask 353 (2026-11-11): in the head's row, as an icon with its words for screen readers.
// Ask 353 (2026-11-11): in the head's row, as an icon with its words for screen readers.
@@ -30,7 +29,7 @@ const { renderPanel } = require('../server/web/render');
@@ -30,7 +29,7 @@ const { renderPanel } = require('../server/web/render');
const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });
const signedIn = renderPanel({ robot: robotRow, profile: SIM, role: 'watcher', allowed_commands: [], mode: 'embed', signedIn: true });
assert.ok(!signedIn.includes('Open on openvibe.bot'));
assert.ok(!signedIn.includes('Open on openvibe.bot'));
const owner = renderPanel({ robot: { ...robotRow, embed_public: true }, profile: SIM, role: 'owner', allowed_commands: ['drive', 'halt'], mode: 'embed' });
const owner = renderPanel({ robot: { ...robotRow, embed_public: true }, profile: SIM, role: 'owner', allowed_commands: ['drive', 'halt'], mode: 'embed' });
- assert.ok(owner.includes('data-estop>') && owner.includes('data-estop-clear') && !owner.includes('data-embed-form') && !owner.includes('<a '));
+ assert.ok(!owner.includes('data-estop') && !owner.includes('data-embed-form') && !owner.includes('<a '));
const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });
const page = renderPanel({ robot: robotRow, profile: SIM, role: 'owner', allowed_commands: ['drive'] });
assert.ok(page.includes('class="panel-page"') && page.includes('class="topbar"') && page.includes('data-embed-form'));
assert.ok(page.includes('class="panel-page"') && page.includes('class="topbar"') && page.includes('data-embed-form'));
});
});
@@ -71,7 +70,7 @@ const { renderPanel } = require('../server/web/render');
@@ -71,7 +70,7 @@ const { renderPanel } = require('../server/web/render');
assert.strictEqual(r.headers.get('set-cookie'), null);
assert.strictEqual(r.headers.get('set-cookie'), null);
const html = await r.text();
const html = await r.text();
assert.ok(html.includes('data-role="watcher"') && html.includes('data-allowed="[]"') && html.includes('class="embed-page"'));
assert.ok(html.includes('data-role="watcher"') && html.includes('data-allowed="[]"') && html.includes('class="embed-page"'));
- assert.ok(html.includes('Open on openvibe.bot') && !html.includes('data-estop>'));
+ assert.ok(html.includes('Open on openvibe.bot'));
assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');
assert.ok(t.hub.isOnline(`dev_sim_${open.id}`), 'the embed starts the simulator as the panel does');
// The rest of the site still refuses every frame.
// The rest of the site still refuses every frame.
assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
assert.match((await get(`/panel/${open.id}`, alex)).headers.get('content-security-policy'), /frame-ancestors 'self';/);
@@ -91,7 +90,7 @@ const { renderPanel } = require('../server/web/render');
@@ -91,7 +90,7 @@ const { renderPanel } = require('../server/web/render');
const owner = await get(`/panel/${closed.id}/embed`, alex);
const owner = await get(`/panel/${closed.id}/embed`, alex);
assert.strictEqual(owner.status, 200);
assert.strictEqual(owner.status, 200);
const oh = await owner.text();
const oh = await owner.text();
- assert.ok(oh.includes('data-role="owner"') && oh.includes('data-estop>') && !oh.includes('data-embed-form') && !oh.includes('Open on openvibe.bot'));
+ assert.ok(oh.includes('data-role="owner"') && !oh.includes('data-estop') && !oh.includes('data-embed-form') && !oh.includes('Open on openvibe.bot'));
const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();
const stranger = await (await get(`/panel/${open.id}/embed`, bob)).text();
assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Open on openvibe.bot'));
assert.ok(stranger.includes('data-role="watcher"') && !stranger.includes('Open on openvibe.bot'));
});
});
@@ -129,8 +128,7 @@ const { renderPanel } = require('../server/web/render');
@@ -129,8 +128,7 @@ const { renderPanel } = require('../server/web/render');
assert.deepStrictEqual(j.allowed_commands, []);
assert.deepStrictEqual(j.allowed_commands, []);
assert.strictEqual(j.profile.id, 'sim.rover');
assert.strictEqual(j.profile.id, 'sim.rover');
assert.strictEqual(j.robot, undefined);
assert.strictEqual(j.robot, undefined);
- assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'estop', 'latency_ms', 'online', 'robot_id', 'telemetry']);
- assert.deepStrictEqual(Object.keys(j.state.estop), ['latched']);
+ assert.deepStrictEqual(Object.keys(j.state).sort(), ['battery', 'latency_ms', 'online', 'robot_id', 'telemetry']);
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
const s = await w.waitFor((m) => m.type === 'robot_state' && m.state.telemetry);
// The profile's readouts by the driver's keys, and a simulated rover's camera view; never the raw drive or pose.
// The profile's readouts by the driver's keys, and a simulated rover's camera view; never the raw drive or pose.
assert.deepStrictEqual(Object.keys(s.state.telemetry).sort(), ['sensors', 'sim'], 'the drive and pose telemetry reached a watcher');
assert.deepStrictEqual(Object.keys(s.state.telemetry).sort(), ['sensors', 'sim'], 'the drive and pose telemetry reached a watcher');
@@ -144,32 +142,19 @@ const { renderPanel } = require('../server/web/render');
@@ -144,32 +142,19 @@ const { renderPanel } = require('../server/web/render');
w.send({ type: 'join', robot_id: open.id });
w.send({ type: 'join', robot_id: open.id });
await w.waitFor((m) => m.type === 'joined');
await w.waitFor((m) => m.type === 'joined');
const before = await auditCount(open.id);
const before = await auditCount(open.id);
- for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'estop' }, { type: 'estop_clear' }, { type: 'pair' }]) w.send(f);
+ for (const f of [{ type: 'command', id: 'x1', kind: 'drive', value: { throttle: 1 } }, { type: 'command', kind: 'halt' }, { type: 'command', id: 'x2', kind: 'halt' }, { type: 'estop' }, { type: 'pair' }]) w.send(f);
await w.waitFor(() => w.messages.filter((m) => m.type === 'error').length >= 5);
await w.waitFor(() => w.messages.filter((m) => m.type === 'error').length >= 5);
assert.deepStrictEqual(w.messages.filter((m) => m.type === 'error').map((m) => m.code), Array(5).fill('bot.read_only'));
assert.deepStrictEqual(w.messages.filter((m) => m.type === 'error').map((m) => m.code), Array(5).fill('bot.read_only'));
assert.ok(!w.messages.some((m) => m.type === 'command_result'));
assert.ok(!w.messages.some((m) => m.type === 'command_result'));
await t.wait(100);
await t.wait(100);
assert.strictEqual(await auditCount(open.id), before);
assert.strictEqual(await auditCount(open.id), before);
- assert.strictEqual((await t.domain.robots.get(open.id)).estop_latched, false);
- const tele = (m) => m.type === 'robot_state' && m.state.telemetry;
+ const tele = (m) => m.type === 'robot_state' && m.state.telemetry;
assert.ok(await w.waitFor(tele));
assert.ok(await w.waitFor(tele));
assert.strictEqual(t.hub.deviceState(`dev_sim_${open.id}`).telemetry.drive.throttle || 0, 0, 'the robot moved');
assert.strictEqual(t.hub.deviceState(`dev_sim_${open.id}`).telemetry.drive.throttle || 0, 0, 'the robot moved');
w.close();
w.close();
});
});
- await check('the owner\'s e-stop reaches a watcher; readouts carry only the profile\'s sensor keys', async () => {
- const w = await watch();
- w.send({ type: 'join', robot_id: open.id });
- await w.waitFor((m) => m.type === 'joined');
- const c = await t.ws('/control', { headers: cookie(alex) });
- c.send({ type: 'join', robot_id: open.id });
- await c.waitFor((m) => m.type === 'joined');
- c.send({ type: 'estop' });
- assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === true));
- c.send({ type: 'estop_clear' });
- assert.ok(await w.waitFor((m) => m.type === 'robot_state' && m.state.estop.latched === false && w.messages.some((x) => x.state && x.state.estop.latched)));
- c.close(); w.close();
-
+ await check('readouts carry only the profile\'s sensor keys', async () => {
const { robot: arm } = await t.robot(alex, { name: 'Arm', profile_id: 'adeept.adr036' });
const { robot: arm } = await t.robot(alex, { name: 'Arm', profile_id: 'adeept.adr036' });
await t.domain.robots.setEmbedPublic(arm.id, true);
await t.domain.robots.setEmbedPublic(arm.id, true);
const dev = t.hub.attachSim({ id: 'dev_fake_arm', robot_ids: JSON.stringify([arm.id]), kind: 'onboard' }, { onFrame() {} });
const dev = t.hub.attachSim({ id: 'dev_fake_arm', robot_ids: JSON.stringify([arm.id]), kind: 'onboard' }, { onFrame() {} });
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The LOOI Operators card and battery (Maticus's 2026-11-12 spec): a Mod role, per-person on/off, sole control,
// 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.
+// the authority audit, and the battery bound to the Node's top-level `battery` (0..100). A tray's own Stop stays
+// local. No hardware: the fake device and the real HTTP panel only.
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const { loadProfiles } = require('../server/profiles');
const { loadProfiles } = require('../server/profiles');
@@ -144,7 +144,7 @@ require('../server/realtime');
@@ -144,7 +144,7 @@ require('../server/realtime');
a.close(); b2.close(); c.close(); dev.close();
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 () => {
+ await check('Mods may grant/clear sole control; they may not promote, change Viewer access, or touch the tray', async () => {
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
const { robot, pairing } = await t.robot(alex, { profile_id: 'looi' });
await invite(robot.id, bob.subject, 'operator');
await invite(robot.id, bob.subject, 'operator');
await invite(robot.id, carol.subject, 'operator');
await invite(robot.id, carol.subject, 'operator');
@@ -173,26 +173,6 @@ require('../server/realtime');
@@ -173,26 +173,6 @@ require('../server/realtime');
const limits = await fetch(`${t.base}/robots/${robot.id}/limits`, { method: 'POST', redirect: 'manual',
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' });
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');
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.
// The viewer never drives.
const d = await op(dave); await join(d, robot.id);
const d = await op(dave); await join(d, robot.id);
cmd(d, { id: 'viewer-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
cmd(d, { id: 'viewer-drive', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
@@ -247,8 +227,8 @@ require('../server/realtime');
@@ -247,8 +227,8 @@ require('../server/realtime');
const ownerHtml = await (await get(`/panel/${robot.id}`, alex)).text();
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-operators'), 'the owner sees the card');
assert.ok(ownerHtml.includes('data-operator-subject='), 'rows carry their subject');
assert.ok(ownerHtml.includes('data-operator-subject='), 'rows carry their subject');
+ assert.ok(!ownerHtml.includes('data-operators-estop') && !/E-STOP|estop|emergency stop/i.test(ownerHtml.replace(/estop_state|estop_latched/g, '')), 'the card has no E-STOP row, button or note');
assert.ok(ownerHtml.includes('Make Mod'), 'the owner can promote an Operator');
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);
const ownerOp = await op(alex); await join(ownerOp, robot.id);
await promotedMod(ownerOp, robot.id, bob.subject);
await promotedMod(ownerOp, robot.id, bob.subject);
ownerOp.close();
ownerOp.close();
@@ -259,7 +239,7 @@ require('../server/realtime');
@@ -259,7 +239,7 @@ require('../server/realtime');
const opHtml = await (await get(`/panel/${robot.id}`, carol)).text();
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');
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();
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');
+ assert.ok(viewerHtml.includes('Viewers') && !viewerHtml.includes('data-operator-sole'), 'a viewer watches only');
});
});
await t.close();
await t.close();
@@ -93,7 +93,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -93,7 +93,7 @@ const { boot, check, done } = require('./helpers/app');
['GET', `/robots/${id}`], ['PATCH', `/robots/${id}`, { name: 'x' }], ['DELETE', `/robots/${id}`],
['GET', `/robots/${id}`], ['PATCH', `/robots/${id}`, { name: 'x' }], ['DELETE', `/robots/${id}`],
['POST', `/robots/${id}/pairing-code`], ['GET', `/robots/${id}/operators`], ['POST', `/robots/${id}/operators`, { subject: alex.subject }],
['POST', `/robots/${id}/pairing-code`], ['GET', `/robots/${id}/operators`], ['POST', `/robots/${id}/operators`, { subject: alex.subject }],
['DELETE', `/robots/${id}/operators/${alex.subject}`], ['GET', `/robots/${id}/devices`], ['GET', `/robots/${id}/audit`],
['DELETE', `/robots/${id}/operators/${alex.subject}`], ['GET', `/robots/${id}/devices`], ['GET', `/robots/${id}/audit`],
- ['POST', `/robots/${id}/estop`], ['POST', `/robots/${id}/estop/clear`], ['POST', `/devices/${dev}/rotate`], ['POST', `/devices/${dev}/revoke`],
+ ['POST', `/devices/${dev}/rotate`], ['POST', `/devices/${dev}/revoke`],
];
];
const shape = (r) => ({ status: r.status, code: r.json && r.json.code, title: r.json && r.json.title, detail: r.json && r.json.detail });
const shape = (r) => ({ status: r.status, code: r.json && r.json.code, title: r.json && r.json.title, detail: r.json && r.json.detail });
const real = routes(bobsRobot.id, realDevice);
const real = routes(bobsRobot.id, realDevice);
@@ -58,14 +58,14 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
@@ -58,14 +58,14 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
assert.ok(!renderWidget(w, { profile: zoomOnly, allowed_commands: ['ptz'] }).includes('data-ptz-stick'));
assert.ok(!renderWidget(w, { profile: zoomOnly, allowed_commands: ['ptz'] }).includes('data-ptz-stick'));
});
});
- await check('panel.js: a held slider repeats its value as new commands and stops on release, cancel, blur, a hidden page and the e-stop', () => {
+ await check('panel.js: a held slider repeats its value as new commands and stops on release, cancel, blur, a hidden page and Stop', () => {
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.ok(js.includes("const every = Number(el.dataset.holdRepeat) || 0;"), 'only a slider that asks for it');
assert.ok(js.includes("const every = Number(el.dataset.holdRepeat) || 0;"), 'only a slider that asks for it');
assert.ok(js.includes("if (Date.now() - lastSent >= every - 5) sendNow();"), 'each tick is a new send of the current value, skipped right after a move');
assert.ok(js.includes("if (Date.now() - lastSent >= every - 5) sendNow();"), 'each tick is a new send of the current value, skipped right after a move');
assert.ok(js.includes("el.addEventListener(ev, stop);"), 'release, cancel and a lost capture stop it');
assert.ok(js.includes("el.addEventListener(ev, stop);"), 'release, cancel and a lost capture stop it');
assert.ok(js.includes("['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop)"), 'all three pointer ends');
assert.ok(js.includes("['pointerup', 'pointercancel', 'lostpointercapture']) el.addEventListener(ev, stop)"), 'all three pointer ends');
assert.ok(js.includes("window.addEventListener('blur', stopRepeats);") && js.includes("if (document.hidden) stopRepeats();") && js.includes("window.addEventListener('pagehide', stopRepeats);"), 'blur, a hidden page and pagehide stop it');
assert.ok(js.includes("window.addEventListener('blur', stopRepeats);") && js.includes("if (document.hidden) stopRepeats();") && js.includes("window.addEventListener('pagehide', stopRepeats);"), 'blur, a hidden page and pagehide stop it');
- assert.ok(js.includes("window.dispatchEvent(new Event('ov-bot-release-all'))") && js.includes("window.addEventListener('ov-bot-release-all', stopRepeats);"), 'releaseAll (the e-stop) stops it');
+ assert.ok(js.includes("window.dispatchEvent(new Event('ov-bot-release-all'))") && js.includes("window.addEventListener('ov-bot-release-all', stopRepeats);"), 'releaseAll (Stop) stops it');
});
});
await check('panel.js: keys, stick and gamepad press direction buttons; the PTZ stick holds pan and tilt', () => {
await check('panel.js: keys, stick and gamepad press direction buttons; the PTZ stick holds pan and tilt', () => {
@@ -80,10 +80,10 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
@@ -80,10 +80,10 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
assert.ok(js.includes("() => holdRelease(kind, 'ptzstick'));"), 'let go stops the camera');
assert.ok(js.includes("() => holdRelease(kind, 'ptzstick'));"), 'let go stops the camera');
assert.ok(js.includes("for (const k of [...dirRepeats.keys()]) { const i = k.indexOf('|'); dirRelease(k.slice(0, i), k.slice(i + 1)); }"), 'releaseAll lets every repeat go');
assert.ok(js.includes("for (const k of [...dirRepeats.keys()]) { const i = k.indexOf('|'); dirRelease(k.slice(0, i), k.slice(i + 1)); }"), 'releaseAll lets every repeat go');
assert.ok(/\[data-joystick\], \[data-button-stick\], \[data-ptz-stick\]/.test(js), 'the new sticks follow the allowed state');
assert.ok(/\[data-joystick\], \[data-button-stick\], \[data-ptz-stick\]/.test(js), 'the new sticks follow the allowed state');
- // The pad on a button robot keeps the drive's latch, B lets go, Start is the e-stop.
+ // The pad on a button robot keeps the drive's latch, B lets go.
const branch = js.slice(js.indexOf('if (buttonDrive) {', js.indexOf('function padPoll')), js.indexOf('const v = stickValue(ax(0), -ax(1), PAD_DEADZONE);'));
const branch = js.slice(js.indexOf('if (buttonDrive) {', js.indexOf('function padPoll')), js.indexOf('const v = stickValue(ax(0), -ax(1), PAD_DEADZONE);'));
assert.ok(branch.includes('if (!pad.latched) pad.reset();') && branch.includes('if (!want && !now.size) pad.latched = false;'));
assert.ok(branch.includes('if (!pad.latched) pad.reset();') && branch.includes('if (!want && !now.size) pad.latched = false;'));
- assert.ok(branch.includes('now.has(1) && !pad.down.has(1)') && branch.includes('now.has(9) && !pad.down.has(9)'));
+ assert.ok(branch.includes('now.has(1) && !pad.down.has(1)') && !branch.includes('now.has(9)'));
});
});
await check('Q and E tilt the head where the robot has one: Q up, E down, through the head command, nothing on release', () => {
await check('Q and E tilt the head where the robot has one: Q up, E down, through the head command, nothing on release', () => {
@@ -96,7 +96,7 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
@@ -96,7 +96,7 @@ const buttonsOf = (profile, allowed = ['button']) => renderWidget(profile.widget
assert.ok(js.includes("if (up === down) return headStop();") && js.includes("if (up === down) return; // none, or both"), 'both held: nothing');
assert.ok(js.includes("if (up === down) return headStop();") && js.includes("if (up === down) return; // none, or both"), 'both held: nothing');
assert.ok(js.includes("head.timer = setInterval(headStep, Number(el && el.dataset.holdRepeat) || HOLD_MS);"), 'the slider\'s hold cadence (LOOI: 90 ms)');
assert.ok(js.includes("head.timer = setInterval(headStep, Number(el && el.dataset.holdRepeat) || HOLD_MS);"), 'the slider\'s hold cadence (LOOI: 90 ms)');
assert.ok(js.includes("function headRelease(code) { if (!head.down.delete(code)) return; headRun(); }") && !/headRelease[\s\S]{0,200}send\('actuator'/.test(js), 'release sends nothing: head 0 is Center, not stop');
assert.ok(js.includes("function headRelease(code) { if (!head.down.delete(code)) return; headRun(); }") && !/headRelease[\s\S]{0,200}send\('actuator'/.test(js), 'release sends nothing: head 0 is Center, not stop');
- assert.ok(js.includes("window.addEventListener('ov-bot-release-all', headReleaseAll);") && js.includes("function stopDrive() {\n headReleaseAll();"), 'blur, a hidden page, a lost socket, the e-stop and STOP stop it');
+ assert.ok(js.includes("window.addEventListener('ov-bot-release-all', headReleaseAll);") && js.includes("function stopDrive() {\n // The machine's own Stop (halt)"), 'blur, a hidden page, a lost socket and Stop stop it');
assert.ok(js.includes("if (textEntry(e.target)) return;"), 'never while typing');
assert.ok(js.includes("if (textEntry(e.target)) return;"), 'never while typing');
assert.ok(js.includes("if (headSlider() && headEnabled()) { keys.add('KeyQ'); keys.add('KeyE'); keys.add('Space'); }"), 'the page around the frame is offered Q and E, also with no drive');
assert.ok(js.includes("if (headSlider() && headEnabled()) { keys.add('KeyQ'); keys.add('KeyE'); keys.add('Space'); }"), 'the page around the frame is offered Q and E, also with no drive');
const looi = loadProfiles().get('looi');
const looi = loadProfiles().get('looi');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The five-minute path and the profile-rendered panel (plan T15 step 3): every profile's widgets render from
// The five-minute path and the profile-rendered panel (plan T15 step 3): every profile's widgets render from
-// the profile alone, the e-stop is the owner's and an operator's, the signed-in pages answer as the gate does,
+// the profile alone, Stop is one optional part, the signed-in pages answer as the gate does,
// and a `sim` robot is driven by the in-process simulator (telemetry, acks, the deadman).
// and a `sim` robot is driven by the in-process simulator (telemetry, acks, the deadman).
const assert = require('assert');
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -8,7 +8,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -8,7 +8,7 @@ const { loadProfiles } = require('../server/profiles');
const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc } = require('../server/web/render');
const { renderPanel, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc } = require('../server/web/render');
const { DEFAULT_ALLOW } = require('../server/domain');
const { DEFAULT_ALLOW } = require('../server/domain');
-const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, estop: { latched: false }, ...extra });
+const robotFor = (profile, extra = {}) => ({ id: 'rob_1', name: 'Rover', profile_id: profile.id, ...extra });
const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profile.commands[k]), ...(DEFAULT_ALLOW[role] ? ['halt'] : [])];
const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profile.commands[k]), ...(DEFAULT_ALLOW[role] ? ['halt'] : [])];
(async () => {
(async () => {
@@ -21,7 +21,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -21,7 +21,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
for (const w of p.widgets) assert.ok(html.includes(`data-widget="${w.type}"`), `${p.id}: no ${w.type} widget`);
for (const w of p.widgets) assert.ok(html.includes(`data-widget="${w.type}"`), `${p.id}: no ${w.type} widget`);
for (const w of p.widgets.filter((x) => x.command)) {
for (const w of p.widgets.filter((x) => x.command)) {
assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
assert.doesNotMatch(renderWidget(w, { profile: p, allowed_commands: allowedFor(p, 'owner') }), / disabled/, `${p.id}: ${w.type} is disabled for the owner`);
- assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), / disabled/, `${p.id}: ${w.type} is enabled with no allowed kind`);
+ assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /( disabled|aria-disabled="true")/, `${p.id}: ${w.type} is enabled with no allowed kind`);
}
}
assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');
assert.match(html, /<script src="\/panel\/panel\.js\?v=[0-9a-f]{10}" defer><\/script>/, 'the client by its content hash');
assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');
assert.match(html, /<link rel="stylesheet" href="\/panel\/panel\.css\?v=[0-9a-f]{10}">/, 'the stylesheet by its content hash');
@@ -29,15 +29,14 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -29,15 +29,14 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
}
}
});
});
- await check('the e-stop is shown to the owner and an operator; only the owner gets the clear', () => {
+ await check('the panel has no red stop bar; Stop is one optional part, off by default', () => {
const p = profiles.find((x) => x.id === 'sim.rover');
const p = profiles.find((x) => x.id === 'sim.rover');
- const html = (role) => renderPanel({ robot: robotFor(p, { estop: { latched: true } }), profile: p, role, allowed_commands: allowedFor(p, role) });
- assert.ok(html('owner').includes('data-estop>') && html('owner').includes('data-estop-clear'));
- assert.ok(html('operator').includes('data-estop>') && !html('operator').includes('data-estop-clear'));
- for (const role of ['viewer', 'queue']) {
- assert.ok(!html(role).includes('data-estop>') && !html(role).includes('data-estop-clear'), `${role} sees an e-stop button`);
- assert.ok(html(role).includes('data-estop-banner data-latched="true"'), `${role} does not see the latched banner`);
+ const html = (role) => renderPanel({ robot: robotFor(p), profile: p, role, allowed_commands: allowedFor(p, role) });
+ for (const role of ['owner', 'operator', 'viewer', 'queue']) {
+ assert.ok(!html(role).includes('data-estop'), `${role} sees no stop bar`);
+ assert.ok(!/<header class="[^"]*stop/.test(html(role)), `${role} sees no stop header`);
}
}
+ assert.ok(!html('owner').includes('data-stop'), 'the default panel has no Stop button');
assert.match(renderWidget(p.widgets.find((w) => w.command), { profile: p, allowed_commands: [] }), /disabled/);
assert.match(renderWidget(p.widgets.find((w) => w.command), { profile: p, allowed_commands: [] }), /disabled/);
});
});
@@ -89,7 +88,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -89,7 +88,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.ok(stick.length >= 1 && stick.every((a) => axes[a]), p.id);
assert.ok(stick.length >= 1 && stick.every((a) => axes[a]), p.id);
assert.deepStrictEqual(attr(html, 'data-axes'), axes, p.id);
assert.deepStrictEqual(attr(html, 'data-axes'), axes, p.id);
for (const a of Object.keys(axes)) assert.strictEqual(html.includes(`data-axis="${a}"`), !stick.includes(a), `${p.id}: ${a}`);
for (const a of Object.keys(axes)) assert.strictEqual(html.includes(`data-axis="${a}"`), !stick.includes(a), `${p.id}: ${a}`);
- assert.ok(html.includes('data-stop'));
+ assert.ok(!html.includes('data-stop'), 'no Stop in the default drive pad');
assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /data-joystick="[^"]+" [^>]*aria-disabled="true"/);
assert.match(renderWidget(w, { profile: p, allowed_commands: [] }), /data-joystick="[^"]+" [^>]*aria-disabled="true"/);
}
}
}
}
@@ -185,7 +184,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -185,7 +184,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
assert.strictEqual(page.headers.get('cache-control'), 'no-store');
assert.strictEqual(page.headers.get('cache-control'), 'no-store');
const html = await page.text();
const html = await page.text();
for (const w of ['drive', 'camera', 'latency']) assert.ok(html.includes(`data-widget="${w}"`), w);
for (const w of ['drive', 'camera', 'latency']) assert.ok(html.includes(`data-widget="${w}"`), w);
- assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"') && html.includes('data-estop>'));
+ assert.ok(html.includes(`data-robot-id="${simId}"`) && html.includes('data-role="owner"'));
const list = await (await get('/robots', alex)).text();
const list = await (await get('/robots', alex)).text();
assert.ok(list.includes(`/panel/${simId}`));
assert.ok(list.includes(`/panel/${simId}`));
await t.wait(50);
await t.wait(50);
@@ -263,7 +262,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
@@ -263,7 +262,7 @@ const allowedFor = (profile, role) => [...(DEFAULT_ALLOW[role] || []).filter((k)
await check('a viewer member gets the panel with nothing to drive; a stranger on a queue robot gets the queue role', async () => {
await check('a viewer member gets the panel with nothing to drive; a stranger on a queue robot gets the queue role', async () => {
await t.call('POST', `/api/v1/robots/${simId}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });
await t.call('POST', `/api/v1/robots/${simId}/operators`, { user: alex, body: { subject: carol.subject, role: 'viewer' } });
const html = await (await get(`/panel/${simId}`, carol)).text();
const html = await (await get(`/panel/${simId}`, carol)).text();
- assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"') && !html.includes('data-estop>'));
+ assert.ok(html.includes('data-role="viewer"') && html.includes('data-allowed="[]"'));
const { robot } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
const { robot } = await t.robot(alex, { name: 'Queue', access_policy: 'queue' });
const q = await (await get(`/panel/${robot.id}`, bob)).text();
const q = await (await get(`/panel/${robot.id}`, bob)).text();
assert.ok(q.includes('data-role="queue"'));
assert.ok(q.includes('data-role="queue"'));
@@ -66,7 +66,7 @@ function identityStub() {
@@ -66,7 +66,7 @@ function identityStub() {
await check('the owner sees the People card and adding @bob lists them, and the API agrees', async () => {
await check('the owner sees the People card and adding @bob lists them, and the API agrees', async () => {
const html = await (await get(`/panel/${robot.id}`, alex)).text();
const html = await (await get(`/panel/${robot.id}`, alex)).text();
assert.ok(html.includes('class="setting-card people"') && html.includes('id="people-h"'));
assert.ok(html.includes('class="setting-card people"') && html.includes('id="people-h"'));
- assert.ok(html.includes('<p class="setting-note">Operators drive and can press the e-stop; viewers watch.</p>'));
+ assert.ok(html.includes('<p class="setting-note">Operators drive; viewers watch.</p>'));
assert.ok(html.includes('<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>'));
assert.ok(html.includes('<li class="person"><span class="who"><b>You</b><small>Owner</small></span></li>'));
assert.ok(html.includes(`<form class="people-add" method="post" action="/robots/${robot.id}/operators">`));
assert.ok(html.includes(`<form class="people-add" method="post" action="/robots/${robot.id}/operators">`));
assert.ok(html.includes('name="username" placeholder="@username" required maxlength="41"'));
assert.ok(html.includes('name="username" placeholder="@username" required maxlength="41"'));
@@ -55,7 +55,7 @@ const { loadProfiles } = require('../server/profiles');
@@ -55,7 +55,7 @@ const { loadProfiles } = require('../server/profiles');
assert.ok(await d.waitFor((m) => m.type === 'hello'));
assert.ok(await d.waitFor((m) => m.type === 'hello'));
const config = await d.waitFor((m) => m.type === 'config');
const config = await d.waitFor((m) => m.type === 'config');
assert.ok(config.allowed_commands.includes('say') && config.allowed_commands.includes('display'));
assert.ok(config.allowed_commands.includes('say') && config.allowed_commands.includes('display'));
- d.send({ v: 1, type: 'status', firmware: 'openvibe-phone/1', capabilities: {}, faults: [], estop_latched: false });
+ d.send({ v: 1, type: 'status', firmware: 'openvibe-phone/1', capabilities: {}, faults: [] });
d.send({ v: 1, type: 'telemetry', battery: { percent: 81 }, sensors: { tilt: { pitch: 4, roll: -2 } } });
d.send({ v: 1, type: 'telemetry', battery: { percent: 81 }, sensors: { tilt: { pitch: 4, roll: -2 } } });
const c = await t.ws('/control', { headers: cookie(alex) });
const c = await t.ws('/control', { headers: cookie(alex) });
c.send({ type: 'join', robot_id: robot.id });
c.send({ type: 'join', robot_id: robot.id });
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+// The plain Stop: it is one ordinary part of the panel, off until a person adds it, and pressing it sends the
+// machine's own Stop (halt) and nothing else. No latch, no clear, no red bar. Also the words: no page, doc or
+// AGENTS.md says E-STOP, estop or emergency stop (inline code names a legacy protocol field and is skipped).
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const render = require('../server/web/render');
+const surface = require('../server/web/surface');
+const profile = require('../server/profiles/looi.json');
+
+const robot = { id: 'rob_stop1', name: 'Looi', profile_id: profile.id, access_policy: 'private' };
+const allowed = Object.keys(profile.commands);
+const catalog = surface.controlsCatalog(profile);
+const defaults = surface.defaultLayout(catalog, 'phone');
+const panel = (layout = defaults, layoutSource = 'default') => render.renderPanel({
+ robot, profile, role: 'owner', allowed_commands: allowed,
+ arrange: {
+ catalog, parts: surface.partsOf(profile), layout, device: 'phone', signedIn: true, layoutSource,
+ src: '/shared/ov-arrange.js', defaultLayout: defaults,
+ },
+});
+
+const off = panel();
+assert.match(off, /data-ov-part="stop"[^>]*hidden|hidden[^>]*data-ov-part="stop"/, 'Stop is in the layout, hidden');
+assert.ok(!/class="[^"]*estop/.test(off), 'no stop bar or overlay stop');
+
+const withStop = JSON.parse(JSON.stringify(defaults));
+const entry = withStop.parts.find((p) => p.id === 'stop');
+entry.visible = true; entry.place = { x: 0, y: 0, w: 1, h: 1 };
+const on = panel(withStop, 'account');
+assert.match(on, /<button[^>]*class="stop plain-stop"[^>]*data-stop[^>]*>/, 'the placed Stop is a plain button');
+assert.ok(!/data-stop[^>]*class="[^"]*danger/.test(on) && !/danger[^"]*data-stop/.test(on), 'it is not red');
+assert.ok(!on.includes('data-estop'), 'no old stop bar');
+
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
+assert.ok(js.includes("send('halt', {});"), 'pressing Stop sends the machine its own halt');
+assert.ok(!js.includes("raw({ type: 'estop' })"), 'and nothing else');
+
+// The words: prose only; an inline code name such as `estop_state` is a legacy protocol field.
+const files = ['AGENTS.md', 'README.md', 'STATUS.json', ...fs.readdirSync(path.join(__dirname, '..', 'docs')).filter((f) => f.endsWith('.md')).map((f) => `docs/${f}`)];
+for (const file of files) {
+ let text = fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
+ text = text.replace(/```[\s\S]*?```/g, '').replace(/`[^`]*`/g, '');
+ assert.ok(!/(^|[^A-Za-z])e-?stop([^A-Za-z]|$)/i.test(text), `${file} says e-stop`);
+ assert.ok(!/emergency\s+stop/i.test(text), `${file} says emergency stop`);
+}
+console.log('plain-stop: optional Stop sends halt; prose has no E-STOP');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
-// ADR-048 amendment 2026-10-15: OpenVibe.Services stops and resumes a person's robot through Bot's control API
-// (bot.resource.control, common.owned-resource-control-request@1). Only the owner Bot holds may be named; a repeat
-// answers the first result; an agent's resume waits for the owner's confirmation; first-party only.
+// ADR-048 amendment 2026-10-15: OpenVibe.Services stops a person's robot through Bot's control API
+// (bot.resource.control, common.owned-resource-control-request@1). Stop sends the robot's own halt; resume answers
+// that nothing was latched. Only the owner Bot holds may be named; a repeat answers the first result; first-party only.
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
@@ -18,45 +18,37 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,45 +18,37 @@ const { boot, check, done } = require('./helpers/app');
action, owned: { kind: 'bot.robot', id: robot.id }, owner: { type: 'user', id: alice.subject },
action, owned: { kind: 'bot.robot', id: robot.id }, owner: { type: 'user', id: alice.subject },
on_behalf_of: { type: 'user', id: alice.subject }, idempotency_key: `idem_robot_${++n}_${Date.now()}`, ...over,
on_behalf_of: { type: 'user', id: alice.subject }, idempotency_key: `idem_robot_${++n}_${Date.now()}`, ...over,
});
});
- const latched = async () => (await t.db.query('SELECT estop_latched FROM robots WHERE id = $1', [robot.id])).rows[0].estop_latched;
const valid = (body) => assert.ok(contracts.resources.checkControlResult(body).valid, JSON.stringify(contracts.resources.checkControlResult(body).errors));
const valid = (body) => assert.ok(contracts.resources.checkControlResult(body).valid, JSON.stringify(contracts.resources.checkControlResult(body).errors));
- await check('stop latches the e-stop; a repeat answers the first result; stop again changes nothing', async () => {
+ await check('stop sends a Stop and answers the first result for a repeated key; nothing is latched', async () => {
const first = req('stop');
const first = req('stop');
let r = await send(first);
let r = await send(first);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
valid(r.json);
valid(r.json);
- assert.deepStrictEqual([r.json.state, r.json.result, r.json.resource], ['done', { estop: true, changed: true }, undefined]);
- assert.strictEqual(await latched(), true);
+ assert.deepStrictEqual([r.json.state, r.json.result.stop, r.json.resource], ['done', true, undefined]);
+ assert.strictEqual(r.json.result.changed, false, 'no device is online to take the Stop');
const again = await send(first);
const again = await send(first);
assert.deepStrictEqual(again.json, r.json, 'the same key answers the first result');
assert.deepStrictEqual(again.json, r.json, 'the same key answers the first result');
r = await send({ ...first, dry_run: true });
r = await send({ ...first, dry_run: true });
assert.deepStrictEqual([r.status, r.json.code], [409, 'control.idempotency_conflict'], 'the key reused with another request');
assert.deepStrictEqual([r.status, r.json.code], [409, 'control.idempotency_conflict'], 'the key reused with another request');
- r = await send(req('stop'));
- assert.deepStrictEqual(r.json.result, { estop: true, changed: false });
- const events = (await t.db.query("SELECT envelope FROM bot_event_outbox WHERE envelope->>'event_type' = 'bot.estop.set' AND envelope->'subject'->>'id' = $1", [robot.id])).rows;
- assert.strictEqual(events.length, 1, 'bot.estop.set is announced once');
+ r = await send(req('stop', { dry_run: true }));
+ assert.deepStrictEqual([r.json.state, r.json.result.would], ['done', 'send the robot a Stop']);
+ const events = (await t.db.query("SELECT envelope FROM bot_event_outbox WHERE envelope->>'event_type' LIKE 'bot.estop.%' AND envelope->'subject'->>'id' = $1", [robot.id])).rows;
+ assert.strictEqual(events.length, 0, 'no latch event is announced');
});
});
- await check('resume clears it (dry run first); an agent\'s resume needs the owner\'s confirmation', async () => {
+ await check('resume has nothing to clear; an agent may stop too', async () => {
let r = await send(req('resume', { dry_run: true }));
let r = await send(req('resume', { dry_run: true }));
- assert.deepStrictEqual([r.json.state, r.json.result.dry_run], ['done', true]);
- assert.strictEqual(await latched(), true, 'a dry run applies nothing');
+ assert.deepStrictEqual([r.json.state, r.json.result.stop], ['done', false]);
r = await send(req('resume', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
r = await send(req('resume', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
- assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'control.confirmation_needed']);
- assert.strictEqual(await latched(), true);
- r = await send(req('resume'));
- assert.deepStrictEqual([r.json.state, r.json.result], ['done', { estop: false, changed: true }]);
- assert.strictEqual(await latched(), false);
+ assert.deepStrictEqual([r.json.state, r.json.result], ['done', { stop: false, changed: false }], 'a resume is a calm no-op');
r = await send(req('stop', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
r = await send(req('stop', { on_behalf_of: { type: 'agent', id: 'agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C' } }));
- assert.deepStrictEqual(r.json.result, { estop: true, changed: true }, 'an agent may always stop it');
- await send(req('resume'));
+ assert.deepStrictEqual(r.json.result.stop, true, 'an agent may always stop it');
});
});
await check('only the owner Bot holds; unknown kinds and actions are refused', async () => {
await check('only the owner Bot holds; unknown kinds and actions are refused', async () => {
let r = await send(req('stop', { owner: { type: 'user', id: bob.subject }, on_behalf_of: { type: 'user', id: bob.subject } }));
let r = await send(req('stop', { owner: { type: 'user', id: bob.subject }, on_behalf_of: { type: 'user', id: bob.subject } }));
assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s robot is not confirmed to exist');
assert.deepStrictEqual([r.json.state, r.json.problem.code], ['refused', 'resources.unknown_resource'], 'another person\'s robot is not confirmed to exist');
- assert.strictEqual(await latched(), false);
r = await send(req('stop', { on_behalf_of: { type: 'user', id: bob.subject } }));
r = await send(req('stop', { on_behalf_of: { type: 'user', id: bob.subject } }));
assert.deepStrictEqual([r.status, r.json.code], [400, 'control.bad_request'], 'a person acts only on what they own');
assert.deepStrictEqual([r.status, r.json.code], [400, 'control.bad_request'], 'a person acts only on what they own');
r = await send(req('delete'));
r = await send(req('delete'));
@@ -74,7 +66,6 @@ const { boot, check, done } = require('./helpers/app');
@@ -74,7 +66,6 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(r.status, 403, 'a person\'s own token is not a first-party service');
assert.strictEqual(r.status, 403, 'a person\'s own token is not a first-party service');
r = await send(req('stop'), { token: null });
r = await send(req('stop'), { token: null });
assert.strictEqual(r.status, 401);
assert.strictEqual(r.status, 401);
- assert.strictEqual(await latched(), false);
});
});
await done(t);
await done(t);
@@ -15,7 +15,6 @@ const { boot, check, done } = require('./helpers/app');
@@ -15,7 +15,6 @@ const { boot, check, done } = require('./helpers/app');
const get = (path, options = auth) => t.call('GET', `/api/v1/resources${path}`, options);
const get = (path, options = auth) => t.call('GET', `/api/v1/resources${path}`, options);
await check('a page validates and reveals only the allowed summary fields', async () => {
await check('a page validates and reveals only the allowed summary fields', async () => {
- await t.db.query('UPDATE robots SET estop_latched = true WHERE id = $1', [second.id]);
const response = await get('');
const response = await get('');
assert.strictEqual(response.status, 200, response.text);
assert.strictEqual(response.status, 200, response.text);
assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', response.json).errors, []);
assert.deepStrictEqual(contracts.validate('common.resource-list-result@1', response.json).errors, []);
@@ -30,7 +29,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -30,7 +29,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!Number.isNaN(Date.parse(summary.updated_at)));
assert.ok(!Number.isNaN(Date.parse(summary.updated_at)));
}
}
assert.deepStrictEqual(response.json.resources.find((r) => r.id === second.id).owner, { type: 'user', id: bob.subject });
assert.deepStrictEqual(response.json.resources.find((r) => r.id === second.id).owner, { type: 'user', id: bob.subject });
- assert.strictEqual(response.json.resources.find((r) => r.id === second.id).state, 'estopped');
+ assert.strictEqual(response.json.resources.find((r) => r.id === second.id).state, 'ready');
assert.strictEqual(response.json.resources.find((r) => r.id === first.id).state, 'ready');
assert.strictEqual(response.json.resources.find((r) => r.id === first.id).state, 'ready');
});
});
@@ -121,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -121,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
const { machineSummary, robotSummary } = require('../server/api/resource-index');
const { machineSummary, robotSummary } = require('../server/api/resource-index');
const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };
const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };
assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.link/machines' });
assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.link/machines' });
- assert.deepStrictEqual(robotSummary({ ...row, id: first.id, estop_latched: false }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });
+ assert.deepStrictEqual(robotSummary({ ...row, id: first.id }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });
assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');
assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');
assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);
assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);
assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources.map((r) => r.id), [deviceId]);
assert.deepStrictEqual((await get('?kind=bot.machine')).json.resources.map((r) => r.id), [deviceId]);
@@ -89,9 +89,9 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');
@@ -89,9 +89,9 @@ const { renderPanel, renderEmbedRefused } = require('../server/web/render');
await check('the embedded panel asks its allowed page for a ticket; the refusal tells that page', () => {
await check('the embedded panel asks its allowed page for a ticket; the refusal tells that page', () => {
const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
const SIM = { id: 'sim.rover', name: 'Rover', commands: { drive: { axes: { throttle: [-1, 1], steer: [-1, 1] } } }, widgets: [{ type: 'drive', label: 'Drive', command: { kind: 'drive' } }] };
- const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });
+ const html = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', mode: 'embed', signedIn: false, parentOrigins: ['https://openvibe.live', 'https://www.openvibe.live'] });
assert.ok(html.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));
assert.ok(html.includes('data-parent-origins="https://openvibe.live https://www.openvibe.live"'));
- const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover', estop: { latched: false } }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });
+ const page = renderPanel({ robot: { id: 'rob_d1', name: 'Rover', profile_id: 'sim.rover' }, profile: SIM, role: 'watcher', mode: 'page', parentOrigins: ['https://openvibe.live'] });
assert.ok(!page.includes('data-parent-origins'), 'only a framed panel');
assert.ok(!page.includes('data-parent-origins'), 'only a framed panel');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
assert.ok(js.includes("window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin)"), 'asks each allowed origin only');
assert.ok(js.includes("window.parent.postMessage({ type: 'ov-bot-ticket-request', robot_id: ROBOT_ID }, origin)"), 'asks each allowed origin only');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// The panel's arrangeable surfaces (make your own controls, stage 2): bot.controls is built from the robot's own
// The panel's arrangeable surfaces (make your own controls, stage 2): bot.controls is built from the robot's own
-// widgets, Stop and the overlay's e-stop are required, the default layouts pass openvibe-sdk/ui-layout, and the
+// widgets, Stop is one optional part, the default layouts pass openvibe-sdk/ui-layout, and the
// attributes put a person's layout on the real parts.
// attributes put a person's layout on the real parts.
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { validate } = require('openvibe-sdk/ui-layout');
const { validate } = require('openvibe-sdk/ui-layout');
@@ -10,21 +10,22 @@ const rover = require('../server/profiles/sim.rover.json');
@@ -10,21 +10,22 @@ const rover = require('../server/profiles/sim.rover.json');
const ids = (catalog) => catalog.parts.map((p) => p.id);
const ids = (catalog) => catalog.parts.map((p) => p.id);
const cat = surface.controlsCatalog(looi);
const cat = surface.controlsCatalog(looi);
-assert.deepEqual(ids(cat), ['stick', 'stop', 'head', 'battery', 'latency'], 'the LOOI panel: the stick, Stop, the head slider and the readouts');
-assert.equal(cat.parts.find((p) => p.id === 'stop').required, true, 'Stop is required');
+assert.deepEqual(ids(cat), ['stick', 'head', 'battery', 'latency', 'stop'], 'the LOOI panel: the stick, the head slider, the readouts and the optional Stop');
+assert.equal(cat.parts.find((p) => p.id === 'stop').required, false, 'Stop is optional');
assert.equal(cat.parts.find((p) => p.id === 'stick').styles.label, false, 'the stick has no label');
assert.equal(cat.parts.find((p) => p.id === 'stick').styles.label, false, 'the stick has no label');
for (const device of ['phone', 'tablet', 'computer']) {
for (const device of ['phone', 'tablet', 'computer']) {
const layout = surface.defaultLayout(cat, device);
const layout = surface.defaultLayout(cat, device);
assert.deepEqual(validate(cat, layout), { ok: true, reasons: [] }, `the ${device} default layout is valid`);
assert.deepEqual(validate(cat, layout), { ok: true, reasons: [] }, `the ${device} default layout is valid`);
+ assert.equal(layout.parts.find((p) => p.id === 'stop').visible, false, `Stop is off in the ${device} default`);
}
}
const roverCat = surface.controlsCatalog(rover);
const roverCat = surface.controlsCatalog(rover);
-assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights'), 'the rover: the camera, the drive stick, Stop and its controls');
+assert.ok(ids(roverCat).includes('camera') && ids(roverCat).includes('stick') && ids(roverCat).includes('lights') && ids(roverCat).includes('stop'), 'the rover: the camera, the drive stick, its controls and the optional Stop');
assert.ok(ids(roverCat).includes('telemetry.2'), 'a second widget of one type gets its own part');
assert.ok(ids(roverCat).includes('telemetry.2'), 'a second widget of one type gets its own part');
assert.deepEqual(validate(roverCat, surface.defaultLayout(roverCat, 'phone')), { ok: true, reasons: [] });
assert.deepEqual(validate(roverCat, surface.defaultLayout(roverCat, 'phone')), { ok: true, reasons: [] });
const overlay = surface.overlayCatalog(looi, { slider: { name: 'head' }, hasStick: true, canDrive: true });
const overlay = surface.overlayCatalog(looi, { slider: { name: 'head' }, hasStick: true, canDrive: true });
-assert.deepEqual(ids(overlay), ['pill', 'more', 'estop', 'stick', 'slider', 'drive']);
-assert.equal(overlay.parts.find((p) => p.id === 'estop').required, true, 'the overlay e-stop is required');
+assert.deepEqual(ids(overlay), ['pill', 'more', 'stop', 'stick', 'slider', 'drive']);
+assert.equal(overlay.parts.find((p) => p.id === 'stop').required, false, 'the overlay Stop is optional');
assert.deepEqual(validate(overlay, surface.overlayDefaultLayout(overlay, 'phone')), { ok: true, reasons: [] }, 'the overlay default is valid');
assert.deepEqual(validate(overlay, surface.overlayDefaultLayout(overlay, 'phone')), { ok: true, reasons: [] }, 'the overlay default is valid');
// A hidden readout and a moved Stop reach the element's attributes; a default (not the person's own) leaves the
// A hidden readout and a moved Stop reach the element's attributes; a default (not the person's own) leaves the
@@ -33,10 +34,12 @@ const layout = surface.defaultLayout(cat, 'phone');
@@ -33,10 +34,12 @@ const layout = surface.defaultLayout(cat, 'phone');
layout.parts.find((p) => p.id === 'battery').visible = false;
layout.parts.find((p) => p.id === 'battery').visible = false;
assert.match(surface.attrsFor(cat, layout, 'phone', 'battery'), /hidden/, 'a hidden part carries hidden');
assert.match(surface.attrsFor(cat, layout, 'phone', 'battery'), /hidden/, 'a hidden part carries hidden');
const stop = surface.attrsFor(cat, layout, 'phone', 'stop');
const stop = surface.attrsFor(cat, layout, 'phone', 'stop');
-assert.match(stop, /grid-column:3 \/ span 2/, 'Stop is beside the stick');
-assert.equal((stop.match(/style=/g) || []).length, 1, 'one style attribute');
-assert.match(stop, /--ov-part-opacity:1/, 'its opacity comes from the layout');
-assert.equal(surface.attrsFor(cat, layout, 'phone', 'stop', { apply: false }), '', 'the default is the page, not the layout');
+assert.match(stop, /hidden/, 'Stop is hidden until a person adds it');
+const head = surface.attrsFor(cat, layout, 'phone', 'head');
+assert.match(head, /grid-column:/, 'a visible part carries its grid place');
+assert.equal((head.match(/style=/g) || []).length, 1, 'one style attribute');
+assert.match(head, /--ov-part-opacity:1/, 'its opacity comes from the layout');
+assert.equal(surface.attrsFor(cat, layout, 'phone', 'head', { apply: false }), '', 'the default is the page, not the layout');
assert.match(surface.surfaceAttrs(cat, layout, 'phone'), /data-ov-arranged="true"/);
assert.match(surface.surfaceAttrs(cat, layout, 'phone'), /data-ov-arranged="true"/);
assert.match(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-surface="bot.controls"/);
assert.match(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-surface="bot.controls"/);
assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-arranged/);
assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-arranged/);
@@ -44,4 +47,4 @@ assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-
@@ -44,4 +47,4 @@ assert.doesNotMatch(surface.surfaceAttrs(cat, layout, 'phone', false), /data-ov-
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' } }), 'phone');
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)' } }), 'phone');
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)' } }), 'tablet');
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)' } }), 'tablet');
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' } }), 'computer');
assert.equal(surface.deviceClass({ headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)' } }), 'computer');
-console.log('surface: bot.controls and bot.overlay catalogs, required safety parts, valid defaults and part attributes');
+console.log('surface: bot.controls and bot.overlay catalogs, optional Stop, valid defaults and part attributes');