openvibers/openvibe.bot Commits
Bot: public driver and profile docs (/docs, /docs/drivers, /docs/profiles) with a profile validator (plan T15) (#52)
Changes
@@@ -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).@@@ -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) => {@@@ -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 };nnew file mode 100644@@@ -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: "<path>"</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: { <axis>: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { <name>: { type: number|rgb|tone|bool, … } } }</code>), <code>say</code>, <code>display</code>, <code>button</code> and <code>point</code>. Every profile also takes <code>halt</code>. The gate allows only the kinds declared here and builds every value from it.</li>++<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>++<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>++<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="{ "id": "my.robot", ... }" 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 };@@@ -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">',@@@ -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 };@@@ -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, limitry { 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 });nnew file mode 100644@@@ -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(`"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/);++ // 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();++})();
@@ -16,6 +16,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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 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 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 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`).
- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- Operators and viewers from the panel: the owner adds a person to a robot by `@username` (OpenVibe.Network resolves the name to a subject) and removes them again, each listed on the panel's People card. On an open-queue robot a signed-in visitor sees their turn — or their place in line — and can leave the queue, which promotes the next person at once (`POST /robots/:id/operators`, `POST /robots/:id/operators/:subject/remove`, `POST /robots/:id/queue/leave`, all plain forms that work without JavaScript).
- Operators 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).
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* | Method & path | Capability (services) | People |
* | Method & path | Capability (services) | People |
* |------------------------------------------------|------------------------|-------------------------------------|
* |------------------------------------------------|------------------------|-------------------------------------|
* | GET /profiles, /profiles/:id | — (public) | anyone |
* | GET /profiles, /profiles/:id | — (public) | anyone |
+ * | POST /profiles/validate | — (public) | anyone (the profile validator) |
* | GET /kits, /kits/:id | — (public) | anyone |
* | GET /kits, /kits/:id | — (public) | anyone |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | GET /robots | bot.robot.read | own robots (?owner= only themself) |
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
* | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
@@ -35,7 +36,7 @@
@@ -35,7 +36,7 @@
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { fail, userSubject, isRobotId, json } = require('../util');
-const { getProfile, listProfiles } = require('../profiles');
+const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const kits = require('../kits');
const CAP = {
const CAP = {
@@ -132,6 +133,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
@@ -132,6 +133,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
res.json({ profile: p.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) ────────────────────────
// ── Kits (public read; the "Get a robot" catalogue, server/kits/*.json) ────────────────────────
r.get('/kits', wrap(async (req, res) => {
r.get('/kits', wrap(async (req, res) => {
@@ -206,6 +206,28 @@ function validateProfile(p) {
@@ -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). */
/** Every profile in server/profiles/*.json, validated. Throws on the first invalid file (a broken ship). */
function loadProfiles(dir = DIR) {
function loadProfiles(dir = DIR) {
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
@@ -263,4 +285,4 @@ function reservedKeyReason(key, profile) {
@@ -263,4 +285,4 @@ function reservedKeyReason(key, profile) {
return null;
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -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: "<path>"</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: { <axis>: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { <name>: { type: number|rgb|tone|bool, … } } }</code>), <code>say</code>, <code>display</code>, <code>button</code> and <code>point</code>. Every profile also takes <code>halt</code>. The gate allows only the kinds declared here and builds every value from it.</li>
+<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
+<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
+<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="{ "id": "my.robot", ... }" 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 };
@@ -75,14 +75,16 @@ function sections({ signedIn }) {
@@ -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: '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.' },
{ 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 }) {
function renderHome({ config, signedIn = false }) {
const nav = {
const nav = {
service: 'bot',
service: 'bot',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
- links: [{ label: 'Robots', href: '/robots' }],
+ links: [{ label: 'Robots', href: '/robots' }, { label: 'Build', href: '/docs' }],
history: { type: 'page', title: SITE_NAME },
history: { type: 'page', title: SITE_NAME },
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
loginUrl: '/auth/login?next={path}',
loginUrl: '/auth/login?next={path}',
@@ -98,7 +100,7 @@ function renderHome({ config, signedIn = false }) {
@@ -98,7 +100,7 @@ function renderHome({ config, signedIn = false }) {
summary: DESCRIPTION,
summary: DESCRIPTION,
canonical: `${config.baseUrl}/`,
canonical: `${config.baseUrl}/`,
robots: 'index, follow',
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: [
head: [
appIcon.headTags({ site: 'bot' }),
appIcon.headTags({ site: 'bot' }),
'<link rel="manifest" href="/manifest.webmanifest">',
'<link rel="manifest" href="/manifest.webmanifest">',
@@ -441,4 +441,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
@@ -441,4 +441,4 @@ function renderPairingPage({ robot, pairing, profile = null }) {
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
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 };
@@ -37,6 +37,8 @@ const { frameAncestors } = require('../config');
@@ -37,6 +37,8 @@ const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
+const { renderDocsIndex, renderDriversPage, renderProfilesPage } = require('./docs');
+const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const PUBLIC = path.join(__dirname, '..', '..', 'public');
@@ -45,7 +47,7 @@ const HOLD_RESEND_MS = 150;
@@ -45,7 +47,7 @@ const HOLD_RESEND_MS = 150;
const SITE_TAGLINE = 'An open control panel for robots';
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
// 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.
// 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
// 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
// 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
// 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
@@ -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; }
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.
// 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 });
const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public });
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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(`"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/);
+ // 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();
+})();