openvibers/openvibe.bot Commits

Bot: public driver and profile docs (/docs, /docs/drivers, /docs/profiles) with a profile validator (plan T15) (#52)

OP OpenVibers committed 10 Oct 2026 · 33420e31a1 · 8 files, +503 −7

Plan T15 asks for a public driver and profile spec early, so contributors build drivers against a spec instead of reverse-engineering Bot. This publishes it on openvibe.bot.

- **`/docs`:** "Build for OpenVibe.Bot", linking the two guides and the robots page.
- **`/docs/drivers`, "Write a driver":**
  - what a driver is (a separate program the OpenVibe Node runs, any language, JSON lines over stdio);
  - the three connection kinds, the lifecycle, and every message type with an example, taken from OpenVibe.Node's `docs/protocol.md` and `docs/plugins.md`;
  - the safety rules as requirements;
  - a minimal Python driver using `openvibe_plugin` and the same in plain stdio;
  - testing with `dryrun` and the simulated rover, and how to contribute.
- **`/docs/profiles`, "Robot profiles":** the `bot.robot-profile@1` fields, the full `sim.rover` example, and a validator.
  - `POST /docs/profiles/validate` (form, same-origin) and `POST /api/v1/profiles/validate` (JSON) answer `{ valid, problems[] }`. They run the same checks as shipped profiles: the Contracts schema plus the loader's rules.
  - Both are rate-limited per address (30 a minute).
  - `server/profiles` gains `checkProfile`; shipped profiles load unchanged.
- **Linking:** the home page links `/docs`, and the sitemap lists the three pages.

Tests: the new `test/docs-pages.test.js`. Suite: 29/29 files pass.

Changes

ddiff --git a/README.md b/README.md
iindex d62d0ba..b723910 100644
---- a/README.md
++++ b/README.md
@@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The front page, `GET /` (`server/web/home.js`): 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 and the kits with drivers), with 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 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 Node — the plugin protocol, every message, the safety rules and how to test), and `/docs/profiles` (the `bot.robot-profile@1` fields, a full example and a validator). The validator answers `POST /docs/profiles/validate` (a form) and `POST /api/v1/profiles/validate` (JSON in, `{ valid, problems[] }` out) by running the same checks a shipped profile gets (`server/profiles` `checkProfile`); the pages carry no script and run under the site-wide CSP.
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 98ddf08..1639a15 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -9,6 +9,7 @@
* | Method & path | Capability (services) | People |
* |------------------------------------------------|------------------------|-------------------------------------|
* | GET /profiles, /profiles/:id | — (public) | anyone |
++ * | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | GET /kits, /kits/:id | — (public) | anyone |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
@@@ -35,7 +36,7 @@
const express = require('express');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
--const { getProfile, listProfiles } = require('../profiles');
++const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const CAP = {
@@@ -132,6 +133,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
res.json({ profile: p.profile });
}));
++ // The same validator the /docs/profiles form runs (server/profiles checkProfile): a profile in,
++ // { valid, problems[] } out. Public, and rate-limited per address exactly as the form is.
++ r.post('/profiles/validate', limits('bot.docs.validate', { minute: 30, hour: 300 }), wrap((req, res) => {
++ const body = req.body;
++ const value = body && typeof body.profile === 'object' && body.profile !== null ? body.profile : body;
++ const result = checkProfile(value);
++ res.json({ valid: result.valid, problems: result.problems, ...(result.profile ? { profile: result.profile } : {}) });
++ }));
// ── Kits (public read; the "Get a robot" catalogue, server/kits/*.json) ────────────────────────
r.get('/kits', wrap(async (req, res) => {
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 3e60834..87cdda0 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -206,6 +206,28 @@ function validateProfile(p) {
};
}
++/**
++ * Validate a value exactly as a shipped profile is validated, for the public validator (GET /docs/profiles,
++ * POST /docs/profiles/validate, POST /api/v1/profiles/validate): the contracts schema plus the loader's own
++ * rules, reported as a list of readable problems instead of throwing on the first. A shipped profile still
++ * loads through loadProfiles/validateProfile, unchanged. Returns { valid, problems[, profile] }.
++ */
++function checkProfile(value) {
++ if (!isObject(value)) return { valid: false, problems: ['a profile must be a JSON object'] };
++ const contract = validate('bot.robot-profile@1', value);
++ if (!contract.valid) {
++ return { valid: false, problems: (contract.errors || []).map((e) => {
++ const at = String(e.path || '').replace(/^\//, '').replace(/\//g, '.');
++ return at ? `${at}: ${e.message}` : String(e.message);
++ }) };
++ }
++ try {
++ return { valid: true, problems: [], profile: validateProfile(value) };
++ } catch (e) {
++ return { valid: false, problems: [e && e.detail ? e.detail : String((e && e.message) || e)] };
++ }
++}
++
/** Every profile in server/profiles/*.json, validated. Throws on the first invalid file (a broken ship). */
function loadProfiles(dir = DIR) {
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
@@@ -263,4 +285,4 @@ function reservedKeyReason(key, profile) {
return null;
}
--module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, reservedKeyReason, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
++module.exports = { validateProfile, checkProfile, loadProfiles, seedProfiles, getProfile, listProfiles, reservedKeyReason, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
ddiff --git a/server/web/docs.js b/server/web/docs.js
nnew file mode 100644
iindex 0000000..1c667a3
---- /dev/null
++++ b/server/web/docs.js
@@@ -0,0 +1,287 @@
++'use strict';
++
++/**
++ * The public "Build for OpenVibe.Bot" pages (plan T15): a guide to writing a driver for the OpenVibe Node, the
++ * robot-profile format, and a validator for a pasted profile.
++ *
++ * They are rendered with the app's own page helper (server/web/render.js, the one the robots page uses), which
++ * emits the site stylesheet and no script at all, so these pages run under the site-wide `default-src 'self'`
++ * CSP. Every value is escaped; the code samples are `<pre class="installer"><code>…</code></pre>`, escaped.
++ *
++ * renderDocsIndex({ config }) GET /docs
++ * renderDriversPage({ config }) GET /docs/drivers
++ * renderProfilesPage({ config, source, result }) GET /docs/profiles, POST /docs/profiles/validate
++ * formatProblems(problems) / validationResult(result)
++ */
++const { page, esc } = require('./render');
++const simRover = require('../profiles/sim.rover.json');
++
++// The driver protocol and the bundled drivers live in OpenVibe.Node (the Node core and its plugins/ tree).
++const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';
++const TREE = `${NODE}/tree/main`;
++const BLOB = `${NODE}/blob/main`;
++
++/** A code sample, in a `<pre><code>` block, escaped. */
++const code = (text) => `<pre class="installer"><code>${esc(text)}</code></pre>`;
++/** An external link (openvibe.bot's CSP allows none of these origins in a frame, so it is a plain link). */
++const out = (href, text) => `<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`;
++/** A link on this site. */
++const inLink = (href, text) => `<a href="${esc(href)}">${esc(text)}</a>`;
++
++/** The shared header of the three pages, marking the current one. Plain markup: no script anywhere. */
++function docsNav(current) {
++ const items = [['/docs', 'Build'], ['/docs/drivers', 'Write a driver'], ['/docs/profiles', 'Robot profiles'], ['/robots', 'Robots']];
++ const links = items.map(([href, label]) => (href === current
++ ? `<span class="pill">${esc(label)}</span>`
++ : `<a href="${esc(href)}">${esc(label)}</a>`)).join(' ');
++ return `<nav class="topbar"><a class="brand" href="/"><span>OpenVibe<b>.Bot</b></span></a><span class="crumbs">${links}</span></nav>`;
++}
++
++/** The shared footer of the three pages. */
++const docsFoot = () => `<footer class="page"><p class="muted">The Node and its bundled drivers are in `
++ + `${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is `
++ + `${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></footer>`;
++
++/** A message type: its name, a sentence, and a short JSON example. */
++const message = (name, text, sample) => `<div class="card"><p><code>${esc(name)}</code> — ${esc(text)}</p>${code(sample)}</div>`;
++
++// ── GET /docs ─────────────────────────────────────────────────────────────────────────────────────
++
++function renderDocsIndex({ config }) {
++ const body = `${docsNav('/docs')}
++<main class="page">
++<h1>Build for OpenVibe.Bot</h1>
++<p class="muted">OpenVibe.Bot drives a robot from its profile. Two things are open to build against: the <b>driver</b> — a separate program the OpenVibe Node runs — and the <b>profile</b>, the JSON that turns a driver's capabilities into a panel.</p>
++<section class="card"><h2>Write a driver</h2>
++<p>A driver is a separate program the OpenVibe Node starts and talks to in JSON lines over stdin/stdout. It can be written in any language. It may run on the robot (on-board), on a computer beside it (a bridge), or be attached by Bot itself on the server — the same protocol either way.</p>
++<p>${inLink('/docs/drivers', 'Write a driver')} covers the lifecycle, every message, the safety rules, a minimal Python driver, and how to test and contribute it.</p></section>
++<section class="card"><h2>Robot profiles</h2>
++<p>A profile (<code>bot.robot-profile@1</code>) says what the robot can do, which commands its driver accepts, and which widgets the panel shows. The panel is a function of the profile: a new robot needs a profile, not code.</p>
++<p>${inLink('/docs/profiles', 'Robot profiles')} lists the fields, shows a full example, and has a validator for a profile you paste.</p></section>
++<section class="card"><h2>Robots</h2>
++<p>Add a robot and drive it from any browser. The <b>Simulated rover</b> runs inside Bot with no hardware, so you can see your profile's panel before anything is wired up.</p>
++<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
++<h2>Where things live</h2>
++<ul>
++<li>The <b>OpenVibe Node</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>
++<li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>
++<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')} and ${out(`${TREE}/plugins/relay`, 'relay')}.</li>
++<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
++</ul>
++</main>
++${docsFoot()}`;
++ return page('Build for OpenVibe.Bot', body);
++}
++
++// ── GET /docs/drivers ──────────────────────────────────────────────────────────────────────────────
++
++function renderDriversPage({ config }) {
++ const body = `${docsNav('/docs/drivers')}
++<main class="page">
++<h1>Write a driver</h1>
++<p class="muted">A driver is how OpenVibe.Bot moves a specific piece of hardware. This page is the spec: build against it instead of reverse-engineering Bot. The full contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')} in OpenVibe.Node.</p>
++
++<section class="card"><h2>What a driver is</h2>
++<p>A driver is a <b>separate executable</b> the OpenVibe Node 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 Node 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>
++
++<section class="card"><h2>The three connection kinds</h2>
++<p>The robot's profile carries a <code>kind</code>, which says where the Node runs relative to the robot:</p>
++<ul>
++<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Node runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>
++<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Node runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>
++<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>
++</ul>
++<p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>
++
++<section class="card"><h2>Lifecycle</h2>
++<ol>
++<li>The core starts the process and sends <code>hello</code>.</li>
++<li>The driver answers <code>describe</code> <b>before touching any hardware</b>.</li>
++<li>Unless <code>hello.probe</code> is true, the driver opens its hardware with every actuator stopped and sends <code>ready</code> (or <code>fault</code> if it cannot). Probe mode is used by <code>openvibe-node pair</code> and <code>openvibe-node plugins</code>: describe, then wait for EOF without opening anything.</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>
++</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>
++
++<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>
++<h3>Core → driver</h3>
++${message('hello', 'start-up: the driver answers describe; then sets up unless probe', '{ "op": "hello", "config": { "backend": "real" }, "probe": false }')}
++${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" }')}
++${message('fault', 'the driver runs but cannot drive; commands get this code as nack', '{ "op": "fault", "fault_code": "hardware", "message": "gripper not responding" }')}
++${message('ack', 'the command was carried out', '{ "op": "ack", "id": "cmd_01J8Z4M2Q0R7T9YV3K6N8P1W2X" }')}
++${message('nack', 'the command was refused', '{ "op": "nack", "id": "cmd_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "fault_code": "unsupported", "message": "only actuators" }')}
++${message('telemetry', 'battery, signal and sensor readouts', '{ "op": "telemetry", "battery": { "volts": 7.4, "percent": 82 }, "rssi": -54, "sensors": { "distance_cm": 34 } }')}
++${message('event', 'a discrete thing that happened', '{ "op": "event", "name": "cliff", "fields": { "side": "left" } }')}
++${message('video', 'a frame written to a file; the core reads and encodes it', '{ "op": "video", "format": "jpeg", "path": "/run/frame-1.jpg", "width": 640, "height": 360, "seq": 1 }')}
++<p class="muted"><code>video</code> writes each frame to a temporary name and renames it, so a slow reader never sees a partial file. Plugin events (<code>cliff</code>, <code>picked_up</code>, <code>low_battery</code>, …) travel as <code>event</code>. There is no separate event message type.</p></section>
++
++<section class="card"><h2>Safety rules (requirements, not options)</h2>
++<p>A driver <b>must stop every actuator itself</b>, because the core dying must never leave motors running:</p>
++<ul>
++<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>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. 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>
++${code(`from openvibe_plugin import Fault, Plugin, clamp, run
++
++class Arm(Plugin):
++ driver, version = "my_arm", "0.1.0"
++ motion_kinds = frozenset({"drive", "actuator"}) # stop these at their deadline
++
++ def describe(self, config):
++ return {"actuator": {"names": ["shoulder", "gripper"]}}
++
++ def setup(self, config):
++ self.bus = open_serial(config["port"]) # every actuator stopped when this returns
++
++ def handle(self, cmd):
++ if cmd.kind != "actuator":
++ raise Fault("unsupported")
++ self.bus.speed(cmd.value["name"], clamp(cmd.value.get("value"), -1, 1))
++
++ def stop(self):
++ if getattr(self, "bus", None):
++ self.bus.stop_all()
++
++if __name__ == "__main__":
++ raise SystemExit(run(Arm()))`)}
++<p class="muted">Test it with <code>openvibe_plugin.Runtime(plugin, infile, outfile, clock)</code> and a fake clock, as the bundled drivers do.</p></section>
++
++<section class="card"><h2>The same driver in plain stdin/stdout</h2>
++<p>Any language: read one JSON object per line, write one per line, and keep your own stop timers. This is the same arm, without the SDK.</p>
++${code(`#!/usr/bin/env python3
++import json, sys, threading, time
++
++last = time.monotonic() # when the last heartbeat (or fresh motion command) arrived
++latched = False
++
++def out(message):
++ sys.stdout.write(json.dumps(message) + "\\n")
++ sys.stdout.flush()
++
++def stop():
++ pass # stop every actuator here: idempotent, safe even before setup
++
++def watchdog():
++ while True:
++ time.sleep(0.1)
++ if time.monotonic() - last > 1.0: # the deadman: no heartbeat for 1 s → stop
++ stop()
++
++threading.Thread(target=watchdog, daemon=True).start()
++
++for line in sys.stdin: # EOF means the core is gone: stop and exit
++ m = json.loads(line)
++ if m["op"] == "hello":
++ out({"op": "describe", "driver": "my_arm", "version": "0.1.0", "protocol": 1,
++ "capabilities": {"actuator": {"names": ["shoulder", "gripper"]}},
++ "motion_kinds": ["drive", "actuator"]})
++ if not m.get("probe"):
++ out({"op": "ready"}) # open the bus with every actuator stopped, then say ready
++ elif m["op"] == "command":
++ if latched or m["kind"] != "actuator":
++ out({"op": "nack", "id": m["id"], "fault_code": "unsupported", "message": "only actuators"})
++ continue
++ move(m["value"]["name"], m["value"].get("value")) # clamp and set your own deadline_ms timer
++ last = time.monotonic()
++ out({"op": "ack", "id": m["id"]})
++ elif m["op"] == "heartbeat":
++ last = time.monotonic()
++ elif m["op"] == "stop":
++ stop()
++ elif m["op"] == "estop":
++ latched = True
++ stop()
++ elif m["op"] == "resume":
++ latched = False
++stop()`)}</section>
++
++<section class="card"><h2>Test it</h2>
++<p><b>With the <code>dryrun</code> driver</b> (${out(`${TREE}/plugins/dryrun`, 'plugins/dryrun')}): it accepts every kind, logs it, reports simulated battery and distance, and asks the core for the test-pattern camera. Add it to your <code>config.json</code> plugins and set <code>config.record: "&lt;path&gt;"</code> to append one JSON line per <code>setup</code>, <code>command</code> and <code>stop</code> — end-to-end tests read that file to check what reached the "robot".</p>
++${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.jsonl" } } ] }')}
++<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section>
++
++<section class="card"><h2>Contribute it</h2>
++<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Node. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
++</main>
++${docsFoot()}`;
++ return page('Write a driver', body);
++}
++
++// ── GET /docs/profiles · POST /docs/profiles/validate ─────────────────────────────────────────────
++
++/** The validator's answer block: "valid", or the readable list of problems. */
++function validationResult(result) {
++ if (!result) return '';
++ if (result.valid) {
++ const p = result.profile || {};
++ const widgets = Array.isArray(p.widgets) ? p.widgets.length : 0;
++ return `<section class="card" aria-labelledby="result-h"><h2 id="result-h">Result</h2>
++<p role="status"><strong>This profile is valid.</strong></p>
++<p class="muted">id <code>${esc(p.id)}</code>, version <code>${esc(p.version)}</code>, ${widgets} widget${widgets === 1 ? '' : 's'}. It passes the same checks a shipped profile does: the <code>bot.robot-profile@1</code> contract and the loader's capability, driver, widget and command registries.</p></section>`;
++ }
++ const n = result.problems.length;
++ return `<section class="card" aria-labelledby="result-h"><h2 id="result-h">Result</h2>
++<div class="error" role="alert"><p><strong>This profile is not valid (${n} problem${n === 1 ? '' : 's'}):</strong></p>
++<ul>${result.problems.map((x) => `<li>${esc(x)}</li>`).join('')}</ul></div></section>`;
++}
++
++function renderProfilesPage({ config, source = '', result = null } = {}) {
++ const example = JSON.stringify(simRover, null, 2);
++ const body = `${docsNav('/docs/profiles')}
++<main class="page">
++<h1>Robot profiles</h1>
++<p class="muted">A robot profile (<code>bot.robot-profile@1</code>) is the contract between a robot and its panel. The panel is a function of the profile: a new robot needs a profile, not code. Profiles ship as <code>server/profiles/*.json</code>, are validated by the loader, and are served at ${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}.</p>
++
++<section class="card"><h2>Fields</h2>
++<ul>
++<li><code>id</code> — the profile's name, <code>^[a-z][a-z0-9._-]{1,63}$</code> (for example <code>sim.rover</code>).</li>
++<li><code>version</code> — a positive integer; a new profile is a new version.</li>
++<li><code>name</code> — what the owner sees. <code>vendor</code>, <code>description</code> — optional.</li>
++<li><code>kind</code> — <code>onboard</code>, <code>bridge</code> or <code>server</code> (see ${inLink('/docs/drivers', 'Write a driver')}).</li>
++<li><code>capabilities</code> — one or more of the registry: <code>drive.differential</code>, <code>drive.mecanum</code>, <code>servo.pan_tilt</code>, <code>head</code>, <code>lift</code>, <code>lights.rgb</code>, <code>lights.backpack</code>, <code>lights.cube</code>, <code>speaker.horn</code>, <code>speaker.say</code>, <code>display.text</code>, <code>sensor.ultrasonic</code>, <code>sensor.line</code>, <code>sensor.cliff</code>, <code>sensor.pickup</code>, <code>battery</code>, <code>ptz</code>, <code>camera</code>, <code>relay</code>. A typo is refused at load.</li>
++<li><code>variants</code> — optional: one profile, several wheel layouts.</li>
++<li><code>mapping</code> — <code>{ "driver": … }</code>, one of <code>pca9685</code>, <code>ads7830</code>, <code>cozmo</code>, <code>onvif</code>, <code>sim</code>, <code>relay</code>, plus the driver's own hardware map (addresses, channels).</li>
++<li><code>commands</code> — what the driver accepts, keyed by kind: <code>drive</code> and <code>ptz</code> (<code>{ axes: { &lt;axis&gt;: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { &lt;name&gt;: { type: number|rgb|tone|bool, … } } }</code>), <code>say</code>, <code>display</code>, <code>button</code> and <code>point</code>. Every profile also takes <code>halt</code>. The gate allows only the kinds declared here and builds every value from it.</li>
++<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
++<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
++<li><code>limits</code> — optional, with defaults: <code>max_speed</code> and <code>max_turn</code> (1), <code>max_command_ms</code> (300), <code>heartbeat_ms</code> (1000). Positive; the stricter of these and the server's own limits applies.</li>
++</ul>
++<p class="muted">The schema is <code>bot.robot-profile@1</code>; capability and widget names are open strings checked against the registries in <code>server/profiles/index.js</code>, so a new one is one line there, never a schema change.</p></section>
++
++<section class="card"><h2>A full example</h2>
++<p>The simulated rover (<code>sim.rover</code>), the profile the in-process simulator on this site runs:</p>
++${code(example)}</section>
++
++<section class="card"><h2>Validate a profile</h2>
++<p>Paste a profile JSON. It is run through exactly the checks a shipped profile gets.</p>
++<form method="post" action="/docs/profiles/validate">
++<label class="field"><span class="field-label">Profile JSON</span>
++<textarea name="profile" rows="14" spellcheck="false" autocomplete="off" placeholder="{ &quot;id&quot;: &quot;my.robot&quot;, ... }" required>${esc(source)}</textarea></label>
++<button type="submit" class="primary">Validate</button>
++</form>
++<p class="muted">Tools can POST the profile as JSON to <code>/api/v1/profiles/validate</code> and read <code>{ valid, problems }</code> back.</p></section>
++
++${validationResult(result)}
++</main>
++${docsFoot()}`;
++ return page('Robot profiles', body);
++}
++
++module.exports = { renderDocsIndex, renderDriversPage, renderProfilesPage, validationResult };
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 30994fc..6a50e1c 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -75,14 +75,16 @@ function sections({ signedIn }) {
{ icon: 'ov:check', title: 'Cozmo', text: 'Through the device agent on a computer next to it.' },
{ icon: 'ov:check', title: 'A simulated rover', text: 'Runs inside Bot, so you can try the whole panel with no hardware.' },
],
-- });
++ }) + `<section class="sc-sec" id="build"><h2>Build for OpenVibe.Bot</h2>
++<p class="sc-lede">Write a driver for a robot, or describe one with a profile and let the panel render it. The driver and profile spec, with a validator and the simulated rover, lives on the build pages.</p>
++<p class="sc-actions"><a class="sc-btn sc-primary" href="/docs">Build for OpenVibe.Bot</a> <a class="sc-btn" href="/docs/drivers">Write a driver</a> <a class="sc-btn" href="/docs/profiles">Robot profiles</a></p></section>`;
}
function renderHome({ config, signedIn = false }) {
const nav = {
service: 'bot',
apiBase: NETWORK_URL,
-- links: [{ label: 'Robots', href: '/robots' }],
++ links: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
history: { type: 'page', title: SITE_NAME },
sessionUrl: '/auth/me',
loginUrl: '/auth/login?next={path}',
@@@ -98,7 +100,7 @@ function renderHome({ config, signedIn = false }) {
summary: DESCRIPTION,
canonical: `${config.baseUrl}/`,
robots: 'index, follow',
-- navbar: nav, footer, home: '/', navLinks: [{ label: 'Robots', href: '/robots' }],
++ navbar: nav, footer, home: '/', navLinks: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
head: [
appIcon.headTags({ site: 'bot' }),
'<link rel="manifest" href="/manifest.webmanifest">',
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 6e54bf1..b0c5a21 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -441,4 +441,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
--module.exports = { renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
++module.exports = { page, renderPanel, renderEmbedRefused, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex f69c30b..3746d4c 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -37,6 +37,8 @@ const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
++const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
++const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
@@@ -45,7 +47,7 @@ const HOLD_RESEND_MS = 150;
const SITE_TAGLINE = 'An open control panel for robots';
// Every public GET page, for the sitemap. The interactive pages (/robots, /pair/, /panel/) need a session and
// are Disallowed in robots.txt; the API and sign-in are not pages.
--const PUBLIC_PAGES = ['/', '/install', '/terms', '/privacy', '/dmca'];
++const PUBLIC_PAGES = ['/', '/install', '/docs', '/docs/drivers', '/docs/profiles', '/terms', '/privacy', '/dmca'];
// The panel plays the robot's live video with mediasoup-client, whose npm package ships no browser
// bundle: the page imports the pinned ESM build OpenVibe.Live uses from esm.sh, and its viewer
// signaling endpoint is OpenRestream's wss host (OpenRestream README "playback descriptor"). Exactly these two
@@@ -119,6 +121,29 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
try { return new URL(origin).origin === new URL(config.baseUrl).origin || new URL(origin).host === req.headers.host; } catch { return false; }
};
++ // ── The public build pages (plan T15): /docs, /docs/drivers and /docs/profiles. ──
++ // They are rendered with the app's page helper, carry no script, and run under the site-wide CSP. The
++ // profile validator is anonymous, so it is rate-limited per address the way the app's other public limits
++ // are, and a cross-site form post is refused as every other form post refuses one.
++ const validateLimit = limits ? [limits('bot.docs.validate', { minute: 30, hour: 300 })] : [];
++ r.get('/docs', (req, res) => res.type('html').send(renderDocsIndex({ config })));
++ r.get('/docs/drivers', (req, res) => res.type('html').send(renderDriversPage({ config })));
++ r.get('/docs/profiles', (req, res) => res.type('html').send(renderProfilesPage({ config })));
++ r.post('/docs/profiles/validate', ...validateLimit, express.urlencoded({ extended: false, limit: '32kb' }), (req, res, next) => {
++ try {
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('cross-site form posts are refused\n');
++ res.setHeader('Cache-Control', 'no-store');
++ const source = typeof (req.body && req.body.profile) === 'string' ? req.body.profile : '';
++ let result;
++ if (!source.trim()) result = { valid: false, problems: ['Paste a profile JSON to validate.'] };
++ else {
++ try { result = checkProfile(JSON.parse(source)); }
++ catch (e) { result = { valid: false, problems: [`not valid JSON: ${e.message}`] }; }
++ }
++ return res.type('html').send(renderProfilesPage({ config, source, result }));
++ } catch (e) { return next(e); }
++ });
++
// The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.
const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public });
ddiff --git a/test/docs-pages.test.js b/test/docs-pages.test.js
nnew file mode 100644
iindex 0000000..9e0198e
---- /dev/null
++++ b/test/docs-pages.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++// The public build pages (plan T15): GET /docs, /docs/drivers and /docs/profiles, the profile validator they
++// carry (POST /docs/profiles/validate, POST /api/v1/profiles/validate), and the sitemap that lists them. The
++// pages carry no script, so the site-wide CSP stands; the validator runs the same checks a shipped profile does.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++
++const ROOT = path.join(__dirname, '..');
++const sim = JSON.parse(fs.readFileSync(path.join(ROOT, 'server', 'profiles', 'sim.rover.json'), 'utf8'));
++// The app's own CSP (server/app.js), which the docs pages do not loosen.
++const SITE_CSP = "default-src 'self'; frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
++
++(async () => {
++ const t = await boot({ openre: false });
++ const get = (p, opts) => fetch(t.base + p, { redirect: 'manual', ...opts });
++ const form = (profile) => fetch(t.base + '/docs/profiles/validate', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
++ body: new URLSearchParams({ profile }).toString(),
++ });
++
++ await check('GET /docs: the build index, linking both guides and the robots page, under the site CSP', async () => {
++ const r = await get('/docs');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
++ const html = await r.text();
++ assert.match(html, /Build for OpenVibe\.Bot/);
++ assert.match(html, /href="\/docs\/drivers"/);
++ assert.match(html, /href="\/docs\/profiles"/);
++ assert.match(html, /href="\/robots"/);
++ assert.ok(!/<script\b/i.test(html), 'no script on a docs page');
++ });
++
++ await check('GET /docs/drivers: the guide, connection kinds, every message, safety rules and testing', async () => {
++ const r = await get('/docs/drivers');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
++ const html = await r.text();
++ assert.match(html, /Write a driver/);
++ assert.match(html, /stdin\/stdout/);
++ assert.match(html, /openvibe_plugin/);
++ assert.match(html, /dryrun/);
++ // The three connection kinds.
++ for (const kind of ['onboard', 'bridge', 'server']) assert.ok(html.includes(kind), `no ${kind} connection kind`);
++ // 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']) {
++ assert.ok(html.includes(`&quot;op&quot;: &quot;${op}&quot;`), `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/);
++ // Links out to the full protocol and the contributor path.
++ assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/plugins\.md/);
++ assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/protocol\.md/);
++ assert.match(html, /pull request/);
++ });
++
++ await check('GET /docs/profiles: the fields, the sim.rover example and the validator form', async () => {
++ const r = await get('/docs/profiles');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('content-security-policy'), SITE_CSP);
++ const html = await r.text();
++ assert.match(html, /Robot profiles/);
++ assert.match(html, /bot\.robot-profile@1/);
++ assert.match(html, /sim\.rover/);
++ assert.match(html, /action="\/docs\/profiles\/validate"/);
++ assert.match(html, /<textarea name="profile"/);
++ assert.ok(!/<script\b/i.test(html), 'no script on the profiles page');
++ });
++
++ await check('POST /docs/profiles/validate: the shipped sim.rover profile is valid', async () => {
++ const r = await form(JSON.stringify(sim));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
++ const html = await r.text();
++ assert.match(html, /This profile is valid\./);
++ assert.ok(!/not valid/.test(html), 'no problem is reported');
++ });
++
++ await check('POST /docs/profiles/validate: a missing required field is refused with a readable problem', async () => {
++ const broken = JSON.parse(JSON.stringify(sim));
++ delete broken.name;
++ const html = await (await form(JSON.stringify(broken))).text();
++ assert.match(html, /This profile is not valid/);
++ assert.match(html, /must have required property/);
++ assert.match(html, /name/);
++ });
++
++ await check('POST /docs/profiles/validate: an unknown driver is refused with a readable problem', async () => {
++ const broken = JSON.parse(JSON.stringify(sim));
++ broken.mapping.driver = 'bogus';
++ const html = await (await form(JSON.stringify(broken))).text();
++ assert.match(html, /This profile is not valid/);
++ assert.match(html, /mapping\.driver/);
++ });
++
++ await check('POST /docs/profiles/validate: malformed JSON and a cross-site post are refused', async () => {
++ const html = await (await form('{ not json')).text();
++ assert.match(html, /not valid JSON/);
++ const r = await fetch(t.base + '/docs/profiles/validate', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://evil.example' },
++ body: new URLSearchParams({ profile: JSON.stringify(sim) }).toString(),
++ });
++ assert.strictEqual(r.status, 403);
++ });
++
++ await check('POST /api/v1/profiles/validate: valid and broken profiles, as { valid, problems }', async () => {
++ const post = (value) => fetch(t.base + '/api/v1/profiles/validate', {
++ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value),
++ });
++ const ok = await (await post(sim)).json();
++ assert.strictEqual(ok.valid, true);
++ assert.deepStrictEqual(ok.problems, []);
++ assert.strictEqual(ok.profile.id, 'sim.rover');
++
++ const missing = JSON.parse(JSON.stringify(sim));
++ delete missing.capabilities;
++ const a = await (await post(missing)).json();
++ assert.strictEqual(a.valid, false);
++ assert.ok(a.problems.length > 0 && a.problems.every((p) => typeof p === 'string' && p.length), 'readable problems');
++ assert.ok(a.problems.some((p) => /capabilities/.test(p)), 'names the missing field');
++
++ const badDriver = JSON.parse(JSON.stringify(sim));
++ badDriver.mapping.driver = 'bogus';
++ const b = await (await post(badDriver)).json();
++ assert.strictEqual(b.valid, false);
++ assert.ok(b.problems.some((p) => /mapping\.driver/.test(p)), 'names the unknown driver');
++ });
++
++ await check('GET /sitemap.xml lists the three docs pages', async () => {
++ const xml = await (await get('/sitemap.xml')).text();
++ for (const p of ['/docs', '/docs/drivers', '/docs/profiles']) assert.ok(xml.includes(`http://bot.test${p}<`), `sitemap has no ${p}`);
++ });
++
++ await check('GET / links the build pages', async () => {
++ const html = await (await get('/')).text();
++ assert.match(html, /href="\/docs"/);
++ });
++
++ await t.close();
++ done();
++})();