openvibers/openvibe.bot Commits
Add a kit catalogue and the Adeept kit entry, with a Get a robot section (#30)
Changes
@@@ -9,6 +9,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato## Owns- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.++- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.@@@ -52,7 +53,7 @@ A person acts on their own robots with a Network user token; a service acts with## Tests--`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop and the outbox.## Security@@@ -33,6 +33,7 @@"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s","device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine","profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",++ "curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)","a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds","command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events","public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",@@@ -220,3 +220,28 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.toggle b { display: block; font-weight: 600; }.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }++++/* "Get a robot": the curated kits under the add form. */++.kits { margin-top: 1.75rem; }++.kits > h2 { margin-bottom: 0.25rem; }++.kits > .muted { margin: 0 0 0.85rem; }++.kit-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; }++.kit { display: grid; gap: 0.6rem; }++.kit-head { display: flex; gap: 0.75rem; align-items: flex-start; }++.kit-icon { width: 2.5rem; height: 2.5rem; flex: none; padding: 0.45rem; border-radius: 10px; color: var(--accent); background: var(--raised); }++.kit h3 { margin: 0 0 0.3rem; font-size: 1.05rem; line-height: 1.3; }++.kit-desc { margin: 0; color: var(--muted); font-size: 0.92rem; }++.kit-parts summary { cursor: pointer; font-weight: 600; min-height: var(--tap); display: flex; align-items: center; gap: 0.35rem; }++.kit-parts ul { list-style: none; margin: 0.25rem 0 0; padding: 0; display: grid; gap: 0.35rem; }++.kit-parts li { display: grid; grid-template-columns: 2.25rem minmax(0, 1fr); gap: 0.4rem; font-size: 0.92rem; }++.kit-parts small { display: block; color: var(--muted); font-size: 0.82rem; }++.part-qty { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }++.kit-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }++.embed-inline { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }++.embed-inline .toggle { flex: 1 1 18rem; padding: 0.45rem 0.65rem; background: transparent; }++.embed-inline .toggle small { font-size: 0.8rem; }++.kit-parts summary { list-style: none; }++.kit-parts summary::-webkit-details-marker { display: none; }++.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }++.kit-parts[open] summary::before { transform: rotate(45deg); }++@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }@@@ -9,6 +9,7 @@* | Method & path | Capability (services) | People |* |------------------------------------------------|------------------------|-------------------------------------|* | GET /profiles, /profiles/:id | — (public) | anyone |++ * | 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)|* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |@@@ -35,6 +36,7 @@ const express = require('express');const { http } = require('openvibe-contracts');const { fail, userSubject, isRobotId, json } = require('../util');const { getProfile, listProfiles } = require('../profiles');++const kits = require('../kits');const CAP = {read: 'bot.robot.read',@@@ -131,6 +133,16 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {res.json({ profile: p.profile });}));++ // ── Kits (public read; the "Get a robot" catalogue, server/kits/*.json) ────────────────────────++ r.get('/kits', wrap(async (req, res) => {++ res.json({ kits: kits.list() });++ }));++ r.get('/kits/:id', wrap(async (req, res) => {++ const kit = kits.get(req.params.id);++ if (!kit) fail(404, 'bot.kit_not_found', 'no such kit');++ res.json({ kit });++ }));++// ── Robots ────────────────────────────────────────────────────────────────────────────────────r.get('/robots', wrap(async (req, res) => {requireRead(req);@@@ -28,6 +28,7 @@ async function main() {const m = await migrate(config, { serving: db });if (m.held.length) console.warn(`[Bot] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);await seedProfiles(db, { log: console });++ require('./kits').catalogue(); // the kit catalogue is validated at boot: a bad server/kits/*.json stops it hereconst valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });nnew file mode 100644@@@ -0,0 +1,68 @@++{++ "id": "adeept.adr036",++ "name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi",++ "vendor": "Adeept",++ "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Node agent drives the HAT directly, so the kit's stock control server is never exposed.",++ "profile_id": "adeept.adr036",++ "build_guide_url": "https://www.adeept.com/learn/detail-97.html",++ "parts": [++ {++ "name": "Adeept ADR036 4WD Smart Car Kit (acrylic chassis and hardware)",++ "qty": 1,++ "note": "Adeept's tutorial download page is the build guide"++ },++ {++ "name": "Adeept Robot HAT (PCA9685 motor/servo driver, ADS7830 battery ADC)",++ "qty": 1,++ "note": "Comes with the kit; also charges the 18650 pack over USB-C"++ },++ {++ "name": "TT DC motors",++ "qty": 4++ },++ {++ "name": "Wheels",++ "qty": 4++ },++ {++ "name": "AD002 pan/tilt servos",++ "qty": 2++ },++ {++ "name": "Raspberry Pi camera module",++ "qty": 1++ },++ {++ "name": "Ultrasonic range sensor",++ "qty": 1++ },++ {++ "name": "Line-tracking module",++ "qty": 1++ },++ {++ "name": "WS2812 RGB LED module",++ "qty": 1++ },++ {++ "name": "Buzzer",++ "qty": 1,++ "note": "On the Robot HAT"++ },++ {++ "name": "Raspberry Pi 4B or 5 (2 GB or more)",++ "qty": 1,++ "note": "Not included in the Adeept kit"++ },++ {++ "name": "microSD card, 16 GB or more",++ "qty": 1,++ "note": "Raspberry Pi OS Bookworm, 64-bit"++ },++ {++ "name": "18650 Li-ion cells",++ "qty": 2,++ "note": "The Robot HAT charges the pack over USB-C"++ }++ ]++}nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++++/**++ * Curated kit catalogue (plan T15 "Get a robot", the O29 metadata half): the kits a person can buy to get a++ * robot online, each bound to a shipped profile and carrying its parts list and build guide. Kits live as++ * server/kits/*.json and are validated here in the style of server/profiles/index.js — a typo or a kit bound++ * to a profile that does not ship refuses at boot (server/index.js loads the catalogue), never at a store page.++ *++ * Metadata only: this is what to buy and how to build it. Pricing, shipping, returns and fulfilment are the++ * owner's (O29), not modelled here, so nothing in this catalogue carries a price.++ *++ * Kit shape (bot.kit@1):++ * { id, name, vendor?, description?,++ * profile_id, // must name a shipped profile (loadKits checks it)++ * build_guide_url, // https++ * parts: [{ name, qty?, required?, note? }] } // qty defaults to 1, required defaults to true++ *++ * GET /api/v1/kits serves list()/get(); the robots page ("Get a robot") reads the same helper.++ */++const fs = require('fs');++const path = require('path');++const { BotError } = require('../util');++const { loadProfiles } = require('../profiles');++++const DIR = path.join(__dirname);++const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;++const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);++++function bad(why) { throw new BotError(500, 'bot.kit_invalid', why); }++++/** https only, so a catalogue entry can never smuggle a `javascript:` or plain-http link into a page. */++function isGuideUrl(v) {++ if (typeof v !== 'string' || !v.trim() || v.length > 300) return false;++ try { const u = new URL(v); return u.protocol === 'https:' && !!u.hostname; } catch { return false; }++}++++/** One line of a kit's parts list: what it is, how many, and whether the build needs it. */++function validatePart(where, p) {++ if (!isObject(p)) bad(`${where} must be an object`);++ if (typeof p.name !== 'string' || !p.name.trim()) bad(`${where}.name is required`);++ if (p.name.trim().length > 120) bad(`${where}.name must be at most 120 characters`);++ const qty = p.qty == null ? 1 : p.qty;++ if (!(Number.isInteger(qty) && qty > 0)) bad(`${where}.qty must be a positive integer`);++ if (p.required != null && typeof p.required !== 'boolean') bad(`${where}.required must be true or false`);++ if (p.note != null && (typeof p.note !== 'string' || p.note.trim().length > 200)) bad(`${where}.note must be at most 200 characters`);++ return { name: p.name.trim(), qty, required: p.required !== false, ...(p.note && p.note.trim() ? { note: p.note.trim() } : {}) };++}++++/**++ * Validate one kit. `profiles` (a Map of id → profile) is optional: loadKits passes the shipped set so a kit++ * bound to a profile that does not ship is refused; validateKit alone checks shape only.++ */++function validateKit(k, { profiles = null } = {}) {++ if (!isObject(k)) bad('a kit must be an object');++ if (typeof k.id !== 'string' || !ID_RE.test(k.id)) bad(`kit id is not valid: ${k.id}`);++ if (typeof k.name !== 'string' || !k.name.trim()) bad(`${k.id}: name is required`);++ if (k.name.trim().length > 120) bad(`${k.id}: name must be at most 120 characters`);++ if (k.vendor != null && (typeof k.vendor !== 'string' || k.vendor.trim().length > 60)) bad(`${k.id}: vendor must be at most 60 characters`);++ if (k.description != null && (typeof k.description !== 'string' || k.description.trim().length > 500)) bad(`${k.id}: description must be at most 500 characters`);++ if (typeof k.profile_id !== 'string' || !ID_RE.test(k.profile_id)) bad(`${k.id}: profile_id is required`);++ if (profiles && !profiles.has(k.profile_id)) bad(`${k.id}: profile_id ${k.profile_id} is not a shipped profile`);++ if (!isGuideUrl(k.build_guide_url)) bad(`${k.id}: build_guide_url must be an https URL`);++ if (!Array.isArray(k.parts) || !k.parts.length) bad(`${k.id}: parts must be a non-empty array`);++ const parts = k.parts.map((p, i) => validatePart(`${k.id}: parts[${i}]`, p));++ return {++ id: k.id, name: k.name.trim(), vendor: k.vendor ? k.vendor.trim() : null,++ description: k.description ? k.description.trim() : null,++ profile_id: k.profile_id, build_guide_url: k.build_guide_url, parts,++ };++}++++/** Every kit in server/kits/*.json, validated and bound to a shipped profile. Throws on the first invalid file. */++function loadKits(dir = DIR) {++ const profiles = loadProfiles();++ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();++ const out = new Map();++ for (const f of files) {++ let raw;++ try { raw = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (e) { bad(`${f}: ${e.message}`); }++ const kit = validateKit(raw, { profiles });++ out.set(kit.id, kit);++ }++ if (!out.size) bad('no kits found');++ return out;++}++++// The shipped catalogue is read once: server/index.js loads it at boot (a bad file stops the boot there), and++// every list()/get() after that is a lookup, not a directory read.++let shipped = null;++const catalogue = () => (shipped || (shipped = loadKits()));++/** The catalogue as the API and the robots page present it. */++function list() { return [...catalogue().values()]; }++function get(id) { return catalogue().get(id) || null; }++++module.exports = { validateKit, validatePart, loadKits, catalogue, list, get, DIR };@@@ -215,13 +215,13 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off"><label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;--/** The owner's embed switch: plain markup, works without JavaScript. */--function embedForm(robot) {-- return `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>--<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>++/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */++function embedForm(robot, { card = true } = {}) {++ const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}<button type="submit">Save</button>--</form></section>`;++</form>`;++ return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;}// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus@@@ -290,15 +290,37 @@ function renderEmbedRefused({ robotId }) {return page('Not shared', body, { bodyClass: 'embed-page' });}--function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {++/**++ * "Get a robot": the curated kits (server/kits), each with what it is, the parts to buy, its build guide and a++ * one-click start that opens the add form with the kit's tested profile chosen. Metadata only: no prices or++ * shop links until the owner settles supply (plan O29).++ */++function kitsSection(kits = [], profiles = []) {++ const shipped = new Set(profiles.map((p) => p.id));++ const usable = kits.filter((k) => shipped.has(k.profile_id));++ if (!usable.length) return '';++ const part = (p) => `<li><span class="part-qty">${esc(p.qty)}×</span><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`;++ const card = (k) => {++ const need = k.parts.filter((p) => p.required !== false).length;++ return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div>++<p class="kit-desc">${esc(k.description || '')}</p>++<details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>++<p class="kit-actions"><a class="button primary" href="/robots?profile=${encodeURIComponent(k.profile_id)}#add-robot">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;++ };++ return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2>++<p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>++<ul class="kit-list">${usable.map(card).join('')}</ul></section>`;++}++++function renderRobotsPage({ robots = [], profiles = [], kits = [], error = null, values = {} }) {const names = new Map(profiles.map((p) => [p.id, p.name]));const list = robots.length? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`+ `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`-- + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r)}</li>`).join('')}</ul>`++ + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r, { card: false })}</li>`).join('')}</ul>`: `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;-- const form = `<form class="add-robot card" method="post" action="/robots">++ const form = `<form class="add-robot card" id="add-robot" method="post" action="/robots"><h2>Add a robot</h2>${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span class="field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>@@@ -306,7 +328,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label><button type="submit" class="primary">Add robot</button></form>`;-- return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);++ return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);}function renderPairingPage({ robot, pairing, profile = null }) {@@@ -26,6 +26,7 @@ const express = require('express');const { getProfile, listProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');++const kits = require('../kits');const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');const VERSION = require('../../package.json').version;@@@ -82,7 +83,10 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, logasync function robotsPage(req, res, me, extra = {}) {const robots = (await domain.robots.list(me.subject)).map(presentForPage);const profiles = (await listProfiles(domain.db)).map((p) => p.profile);-- res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));++ // A kit's "Add this robot" arrives as ?profile=<id>: the add form opens with that model chosen.++ const asked = typeof req.query.profile === 'string' && profiles.some((p) => p.id === req.query.profile) ? req.query.profile : null;++ const values = extra.values || (asked ? { profile_id: asked } : {});++ res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));}r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++// The kit catalogue (plan T15 "Get a robot", O29 metadata half): the shipped kits validate, every kit binds++// to a profile that ships, an invalid catalogue refuses at load, and GET /kits is a public read.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++const { loadKits, validateKit, list, get } = require('../server/kits');++const { loadProfiles } = require('../server/profiles');++++// A minimal valid kit, cloned into the failure cases below.++const MINIMAL = {++ id: 'x.y', name: 'X', profile_id: 'sim.rover', build_guide_url: 'https://example.test/guide',++ parts: [{ name: 'A board' }],++};++++(async () => {++ await check('the shipped kit catalogue loads, validates and binds every kit to a shipped profile', () => {++ const kits = loadKits();++ assert.deepStrictEqual([...kits.keys()], ['adeept.adr036']);++ const profiles = loadProfiles();++ for (const kit of kits.values()) {++ assert.ok(profiles.has(kit.profile_id), `${kit.id} binds a shipped profile`);++ assert.ok(kit.parts.length > 0, `${kit.id} has parts`);++ assert.match(kit.build_guide_url, /^https:\/\//);++ }++ });++++ await check('the Adeept kit is the ADR036 car on adeept.adr036, with the parts you need and its build guide', () => {++ const kit = get('adeept.adr036');++ assert.strictEqual(kit.name, 'Adeept ADR036 4WD Smart Car Kit for Raspberry Pi');++ assert.strictEqual(kit.profile_id, 'adeept.adr036');++ assert.strictEqual(kit.vendor, 'Adeept');++ assert.match(kit.build_guide_url, /^https:\/\//);++ const names = kit.parts.map((p) => p.name).join('\n');++ assert.match(names, /Robot HAT/);++ assert.match(names, /Raspberry Pi/);++ assert.match(names, /microSD/);++ assert.match(names, /18650/);++ for (const p of kit.parts) {++ assert.ok(Number.isInteger(p.qty) && p.qty > 0, `${p.name} qty`);++ assert.strictEqual(typeof p.required, 'boolean', `${p.name} required`);++ }++ const pi = kit.parts.find((p) => /Raspberry Pi 4B or 5/.test(p.name));++ assert.match(pi.note, /not included/i, 'the kit does not include the Pi board');++ assert.strictEqual(list().length, 1, 'list() is the same catalogue the API serves');++ assert.strictEqual(get('nope'), null);++ });++++ await check('qty defaults to 1 and required to true; a note is optional', () => {++ const kit = validateKit({ ...MINIMAL });++ assert.deepStrictEqual(kit.parts, [{ name: 'A board', qty: 1, required: true }]);++ const withNote = validateKit({ ...MINIMAL, parts: [{ name: 'A board', qty: 2, required: false, note: 'spare' }] });++ assert.deepStrictEqual(withNote.parts, [{ name: 'A board', qty: 2, required: false, note: 'spare' }]);++ assert.strictEqual(withNote.vendor, null);++ assert.strictEqual(withNote.description, null);++ });++++ await check('a kit with a bad id, name, URL, parts or part is refused', () => {++ const refused = [++ [{ ...MINIMAL, id: 'Bad Id' }, /kit id is not valid/],++ [{ ...MINIMAL, id: undefined }, /kit id is not valid/],++ [{ ...MINIMAL, name: ' ' }, /name is required/],++ [{ ...MINIMAL, profile_id: undefined }, /profile_id is required/],++ [{ ...MINIMAL, build_guide_url: 'http://example.test/guide' }, /build_guide_url must be an https URL/],++ [{ ...MINIMAL, build_guide_url: 'javascript:alert(1)' }, /build_guide_url must be an https URL/],++ [{ ...MINIMAL, parts: [] }, /parts must be a non-empty array/],++ [{ ...MINIMAL, parts: [{ qty: 1 }] }, /\.name is required/],++ [{ ...MINIMAL, parts: [{ name: 'A board', qty: 0 }] }, /qty must be a positive integer/],++ [{ ...MINIMAL, parts: [{ name: 'A board', note: 'x'.repeat(201) }] }, /note must be at most/],++ ];++ for (const [kit, re] of refused) assert.throws(() => validateKit(kit), re, JSON.stringify(kit));++ });++++ await check('a kit bound to a profile that does not ship is refused at load', () => {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-kits-'));++ try {++ fs.writeFileSync(path.join(dir, 'orphan.json'), JSON.stringify({ ...MINIMAL, profile_id: 'does.not.exist' }));++ assert.throws(() => loadKits(dir), /is not a shipped profile/);++ // The shipped catalogue itself always passes the same check.++ assert.doesNotThrow(() => loadKits());++ } finally {++ fs.rmSync(dir, { recursive: true, force: true });++ }++ });++++ const t = await boot();++ await check('GET /api/v1/kits is a public read; an unknown kit is a problem+json 404', async () => {++ const all = await t.call('GET', '/api/v1/kits', { token: null });++ assert.strictEqual(all.status, 200);++ assert.deepStrictEqual(all.json.kits.map((k) => k.id), ['adeept.adr036']);++ const one = await t.call('GET', '/api/v1/kits/adeept.adr036', { token: null });++ assert.strictEqual(one.status, 200);++ assert.strictEqual(one.json.kit.profile_id, 'adeept.adr036');++ assert.ok(one.json.kit.parts.length > 0);++ const missing = await t.call('GET', '/api/v1/kits/nope', { token: null });++ assert.strictEqual(missing.status, 404);++ assert.strictEqual(missing.headers.get('content-type'), 'application/problem+json');++ assert.strictEqual(missing.json.code, 'bot.kit_not_found');++ });++++ await check('the robots page offers "Get a robot": the kit, its parts, its guide and a start with its profile chosen', async () => {++ const alex = t.network.newUser('alex');++ const page = (q = '') => fetch(`${t.base}/robots${q}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());++ const html = await page();++ assert.match(html, /<h2 id="kits-h">Get a robot<\/h2>/);++ assert.match(html, /Adeept ADR036 4WD Smart Car Kit for Raspberry Pi/);++ assert.match(html, /What you need/);++ assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" target="_blank" rel="noopener">Build guide/);++ assert.match(html, /href="\/robots\?profile=adeept\.adr036#add-robot">Add this robot/);++ assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');++ assert.match(await page('?profile=adeept.adr036'), /<option value="adeept\.adr036" selected>/, 'the kit start chooses its profile');++ assert.doesNotMatch(await page('?profile=nope'), /<option value="nope"/);++ });++ await t.close();++++ done();++})();
@@ -9,6 +9,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -9,6 +9,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
## Owns
## Owns
- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.
- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.
+- The curated kit catalogue (`server/kits/*.json`, `GET /api/v1/kits`, `/kits/:id`): each kit's parts list and build guide, bound to a shipped profile (the Adeept ADR036 first). Metadata only — pricing, shipping and returns are the owner's (O29).
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
@@ -52,7 +53,7 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -52,7 +53,7 @@ A person acts on their own robots with a Network user token; a service acts with
## Tests
## Tests
-`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.
+`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop and the outbox.
## Security
## Security
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
"profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
+ "curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
"public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
@@ -220,3 +220,28 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -220,3 +220,28 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.toggle b { display: block; font-weight: 600; }
.toggle b { display: block; font-weight: 600; }
.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }
.toggle small { display: block; margin-top: 0.15rem; color: var(--muted); font-size: 0.85rem; line-height: 1.4; }
.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }
.toggle:has(input:disabled) b, .toggle:has(input:disabled) small { opacity: 0.55; }
+
+/* "Get a robot": the curated kits under the add form. */
+.kits { margin-top: 1.75rem; }
+.kits > h2 { margin-bottom: 0.25rem; }
+.kits > .muted { margin: 0 0 0.85rem; }
+.kit-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 0.75rem; }
+.kit { display: grid; gap: 0.6rem; }
+.kit-head { display: flex; gap: 0.75rem; align-items: flex-start; }
+.kit-icon { width: 2.5rem; height: 2.5rem; flex: none; padding: 0.45rem; border-radius: 10px; color: var(--accent); background: var(--raised); }
+.kit h3 { margin: 0 0 0.3rem; font-size: 1.05rem; line-height: 1.3; }
+.kit-desc { margin: 0; color: var(--muted); font-size: 0.92rem; }
+.kit-parts summary { cursor: pointer; font-weight: 600; min-height: var(--tap); display: flex; align-items: center; gap: 0.35rem; }
+.kit-parts ul { list-style: none; margin: 0.25rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
+.kit-parts li { display: grid; grid-template-columns: 2.25rem minmax(0, 1fr); gap: 0.4rem; font-size: 0.92rem; }
+.kit-parts small { display: block; color: var(--muted); font-size: 0.82rem; }
+.part-qty { color: var(--muted); font-variant-numeric: tabular-nums; text-align: right; }
+.kit-actions { margin: 0; display: flex; flex-wrap: wrap; gap: 0.5rem; }
+.embed-inline { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
+.embed-inline .toggle { flex: 1 1 18rem; padding: 0.45rem 0.65rem; background: transparent; }
+.embed-inline .toggle small { font-size: 0.8rem; }
+.kit-parts summary { list-style: none; }
+.kit-parts summary::-webkit-details-marker { display: none; }
+.kit-parts summary::before { content: ''; width: 0.45rem; height: 0.45rem; margin: 0 0.4rem 0 0.15rem; border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted); transform: rotate(-45deg); transition: transform 0.12s; }
+.kit-parts[open] summary::before { transform: rotate(45deg); }
+@media (prefers-reduced-motion: reduce) { .kit-parts summary::before { transition: none; } }
@@ -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 |
+ * | 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)|
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
* | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
@@ -35,6 +36,7 @@ const express = require('express');
@@ -35,6 +36,7 @@ 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 } = require('../profiles');
+const kits = require('../kits');
const CAP = {
const CAP = {
read: 'bot.robot.read',
read: 'bot.robot.read',
@@ -131,6 +133,16 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
@@ -131,6 +133,16 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config }) {
res.json({ profile: p.profile });
res.json({ profile: p.profile });
}));
}));
+ // ── Kits (public read; the "Get a robot" catalogue, server/kits/*.json) ────────────────────────
+ r.get('/kits', wrap(async (req, res) => {
+ res.json({ kits: kits.list() });
+ }));
+ r.get('/kits/:id', wrap(async (req, res) => {
+ const kit = kits.get(req.params.id);
+ if (!kit) fail(404, 'bot.kit_not_found', 'no such kit');
+ res.json({ kit });
+ }));
+
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
// ── Robots ────────────────────────────────────────────────────────────────────────────────────
r.get('/robots', wrap(async (req, res) => {
r.get('/robots', wrap(async (req, res) => {
requireRead(req);
requireRead(req);
@@ -28,6 +28,7 @@ async function main() {
@@ -28,6 +28,7 @@ async function main() {
const m = await migrate(config, { serving: db });
const m = await migrate(config, { serving: db });
if (m.held.length) console.warn(`[Bot] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
if (m.held.length) console.warn(`[Bot] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
await seedProfiles(db, { log: console });
await seedProfiles(db, { log: console });
+ require('./kits').catalogue(); // the kit catalogue is validated at boot: a bad server/kits/*.json stops it here
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+{
+ "id": "adeept.adr036",
+ "name": "Adeept ADR036 4WD Smart Car Kit for Raspberry Pi",
+ "vendor": "Adeept",
+ "description": "The first curated OpenVibe robot: the Adeept 4WD Smart Car with the Robot HAT — four DC motors and pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The OpenVibe Node agent drives the HAT directly, so the kit's stock control server is never exposed.",
+ "profile_id": "adeept.adr036",
+ "build_guide_url": "https://www.adeept.com/learn/detail-97.html",
+ "parts": [
+ {
+ "name": "Adeept ADR036 4WD Smart Car Kit (acrylic chassis and hardware)",
+ "qty": 1,
+ "note": "Adeept's tutorial download page is the build guide"
+ },
+ {
+ "name": "Adeept Robot HAT (PCA9685 motor/servo driver, ADS7830 battery ADC)",
+ "qty": 1,
+ "note": "Comes with the kit; also charges the 18650 pack over USB-C"
+ },
+ {
+ "name": "TT DC motors",
+ "qty": 4
+ },
+ {
+ "name": "Wheels",
+ "qty": 4
+ },
+ {
+ "name": "AD002 pan/tilt servos",
+ "qty": 2
+ },
+ {
+ "name": "Raspberry Pi camera module",
+ "qty": 1
+ },
+ {
+ "name": "Ultrasonic range sensor",
+ "qty": 1
+ },
+ {
+ "name": "Line-tracking module",
+ "qty": 1
+ },
+ {
+ "name": "WS2812 RGB LED module",
+ "qty": 1
+ },
+ {
+ "name": "Buzzer",
+ "qty": 1,
+ "note": "On the Robot HAT"
+ },
+ {
+ "name": "Raspberry Pi 4B or 5 (2 GB or more)",
+ "qty": 1,
+ "note": "Not included in the Adeept kit"
+ },
+ {
+ "name": "microSD card, 16 GB or more",
+ "qty": 1,
+ "note": "Raspberry Pi OS Bookworm, 64-bit"
+ },
+ {
+ "name": "18650 Li-ion cells",
+ "qty": 2,
+ "note": "The Robot HAT charges the pack over USB-C"
+ }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+
+/**
+ * Curated kit catalogue (plan T15 "Get a robot", the O29 metadata half): the kits a person can buy to get a
+ * robot online, each bound to a shipped profile and carrying its parts list and build guide. Kits live as
+ * server/kits/*.json and are validated here in the style of server/profiles/index.js — a typo or a kit bound
+ * to a profile that does not ship refuses at boot (server/index.js loads the catalogue), never at a store page.
+ *
+ * Metadata only: this is what to buy and how to build it. Pricing, shipping, returns and fulfilment are the
+ * owner's (O29), not modelled here, so nothing in this catalogue carries a price.
+ *
+ * Kit shape (bot.kit@1):
+ * { id, name, vendor?, description?,
+ * profile_id, // must name a shipped profile (loadKits checks it)
+ * build_guide_url, // https
+ * parts: [{ name, qty?, required?, note? }] } // qty defaults to 1, required defaults to true
+ *
+ * GET /api/v1/kits serves list()/get(); the robots page ("Get a robot") reads the same helper.
+ */
+const fs = require('fs');
+const path = require('path');
+const { BotError } = require('../util');
+const { loadProfiles } = require('../profiles');
+
+const DIR = path.join(__dirname);
+const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
+const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
+
+function bad(why) { throw new BotError(500, 'bot.kit_invalid', why); }
+
+/** https only, so a catalogue entry can never smuggle a `javascript:` or plain-http link into a page. */
+function isGuideUrl(v) {
+ if (typeof v !== 'string' || !v.trim() || v.length > 300) return false;
+ try { const u = new URL(v); return u.protocol === 'https:' && !!u.hostname; } catch { return false; }
+}
+
+/** One line of a kit's parts list: what it is, how many, and whether the build needs it. */
+function validatePart(where, p) {
+ if (!isObject(p)) bad(`${where} must be an object`);
+ if (typeof p.name !== 'string' || !p.name.trim()) bad(`${where}.name is required`);
+ if (p.name.trim().length > 120) bad(`${where}.name must be at most 120 characters`);
+ const qty = p.qty == null ? 1 : p.qty;
+ if (!(Number.isInteger(qty) && qty > 0)) bad(`${where}.qty must be a positive integer`);
+ if (p.required != null && typeof p.required !== 'boolean') bad(`${where}.required must be true or false`);
+ if (p.note != null && (typeof p.note !== 'string' || p.note.trim().length > 200)) bad(`${where}.note must be at most 200 characters`);
+ return { name: p.name.trim(), qty, required: p.required !== false, ...(p.note && p.note.trim() ? { note: p.note.trim() } : {}) };
+}
+
+/**
+ * Validate one kit. `profiles` (a Map of id → profile) is optional: loadKits passes the shipped set so a kit
+ * bound to a profile that does not ship is refused; validateKit alone checks shape only.
+ */
+function validateKit(k, { profiles = null } = {}) {
+ if (!isObject(k)) bad('a kit must be an object');
+ if (typeof k.id !== 'string' || !ID_RE.test(k.id)) bad(`kit id is not valid: ${k.id}`);
+ if (typeof k.name !== 'string' || !k.name.trim()) bad(`${k.id}: name is required`);
+ if (k.name.trim().length > 120) bad(`${k.id}: name must be at most 120 characters`);
+ if (k.vendor != null && (typeof k.vendor !== 'string' || k.vendor.trim().length > 60)) bad(`${k.id}: vendor must be at most 60 characters`);
+ if (k.description != null && (typeof k.description !== 'string' || k.description.trim().length > 500)) bad(`${k.id}: description must be at most 500 characters`);
+ if (typeof k.profile_id !== 'string' || !ID_RE.test(k.profile_id)) bad(`${k.id}: profile_id is required`);
+ if (profiles && !profiles.has(k.profile_id)) bad(`${k.id}: profile_id ${k.profile_id} is not a shipped profile`);
+ if (!isGuideUrl(k.build_guide_url)) bad(`${k.id}: build_guide_url must be an https URL`);
+ if (!Array.isArray(k.parts) || !k.parts.length) bad(`${k.id}: parts must be a non-empty array`);
+ const parts = k.parts.map((p, i) => validatePart(`${k.id}: parts[${i}]`, p));
+ return {
+ id: k.id, name: k.name.trim(), vendor: k.vendor ? k.vendor.trim() : null,
+ description: k.description ? k.description.trim() : null,
+ profile_id: k.profile_id, build_guide_url: k.build_guide_url, parts,
+ };
+}
+
+/** Every kit in server/kits/*.json, validated and bound to a shipped profile. Throws on the first invalid file. */
+function loadKits(dir = DIR) {
+ const profiles = loadProfiles();
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
+ const out = new Map();
+ for (const f of files) {
+ let raw;
+ try { raw = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (e) { bad(`${f}: ${e.message}`); }
+ const kit = validateKit(raw, { profiles });
+ out.set(kit.id, kit);
+ }
+ if (!out.size) bad('no kits found');
+ return out;
+}
+
+// The shipped catalogue is read once: server/index.js loads it at boot (a bad file stops the boot there), and
+// every list()/get() after that is a lookup, not a directory read.
+let shipped = null;
+const catalogue = () => (shipped || (shipped = loadKits()));
+/** The catalogue as the API and the robots page present it. */
+function list() { return [...catalogue().values()]; }
+function get(id) { return catalogue().get(id) || null; }
+
+module.exports = { validateKit, validatePart, loadKits, catalogue, list, get, DIR };
@@ -215,13 +215,13 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
@@ -215,13 +215,13 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
-/** The owner's embed switch: plain markup, works without JavaScript. */
-function embedForm(robot) {
- return `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>
-<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
+/** The owner's embed switch: plain markup, works without JavaScript. On the panel it is a card; in the robots list, a compact row. */
+function embedForm(robot, { card = true } = {}) {
+ const form = `<form class="embed-form${card ? '' : ' embed-inline'}" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}
${toggle('embed_public', robot.embed_public, 'Let anyone watch where it is embedded', "A page that embeds this robot shows its video and readouts to everyone; never the controls.")}
<button type="submit">Save</button>
<button type="submit">Save</button>
-</form></section>`;
+</form>`;
+ return card ? `<section class="setting-card" aria-labelledby="embed-h"><h2 id="embed-h">Embedding</h2>\n${form}</section>` : form;
}
}
// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus
// Why the streaming switches cannot be used right now, in the owner's words (domain.streaming reasons, plus
@@ -290,15 +290,37 @@ function renderEmbedRefused({ robotId }) {
@@ -290,15 +290,37 @@ function renderEmbedRefused({ robotId }) {
return page('Not shared', body, { bodyClass: 'embed-page' });
return page('Not shared', body, { bodyClass: 'embed-page' });
}
}
-function renderRobotsPage({ robots = [], profiles = [], error = null, values = {} }) {
+/**
+ * "Get a robot": the curated kits (server/kits), each with what it is, the parts to buy, its build guide and a
+ * one-click start that opens the add form with the kit's tested profile chosen. Metadata only: no prices or
+ * shop links until the owner settles supply (plan O29).
+ */
+function kitsSection(kits = [], profiles = []) {
+ const shipped = new Set(profiles.map((p) => p.id));
+ const usable = kits.filter((k) => shipped.has(k.profile_id));
+ if (!usable.length) return '';
+ const part = (p) => `<li><span class="part-qty">${esc(p.qty)}×</span><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`;
+ const card = (k) => {
+ const need = k.parts.filter((p) => p.required !== false).length;
+ return `<li class="kit card"><div class="kit-head">${icon('robot', 'icon kit-icon')}<div><h3>${esc(k.name)}</h3>${k.vendor ? `<span class="pill">${esc(k.vendor)}</span>` : ''}</div></div>
+<p class="kit-desc">${esc(k.description || '')}</p>
+<details class="kit-parts"><summary>What you need <span class="muted">(${esc(need)} ${need === 1 ? 'part' : 'parts'})</span></summary><ul>${k.parts.map(part).join('')}</ul></details>
+<p class="kit-actions"><a class="button primary" href="/robots?profile=${encodeURIComponent(k.profile_id)}#add-robot">Add this robot</a> ${outLink(k.build_guide_url, 'Build guide', 'button quiet')}</p></li>`;
+ };
+ return `<section class="kits" aria-labelledby="kits-h"><h2 id="kits-h">Get a robot</h2>
+<p class="muted">Curated kits with a ready-made profile: build one, pair it, and its panel already knows every motor and sensor.</p>
+<ul class="kit-list">${usable.map(card).join('')}</ul></section>`;
+}
+
+function renderRobotsPage({ robots = [], profiles = [], kits = [], error = null, values = {} }) {
const names = new Map(profiles.map((p) => [p.id, p.name]));
const names = new Map(profiles.map((p) => [p.id, p.name]));
const list = robots.length
const list = robots.length
? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`
? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`
+ `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`
- + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r)}</li>`).join('')}</ul>`
+ + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r, { card: false })}</li>`).join('')}</ul>`
: `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;
: `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;
const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
- const form = `<form class="add-robot card" method="post" action="/robots">
+ const form = `<form class="add-robot card" id="add-robot" method="post" action="/robots">
<h2>Add a robot</h2>
<h2>Add a robot</h2>
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
<label class="field"><span class="field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
@@ -306,7 +328,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
@@ -306,7 +328,7 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
<label class="field"><span class="field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, label]) => option(v, label, v === (values.access_policy || 'private'))).join('')}</select></label>
<button type="submit" class="primary">Add robot</button>
<button type="submit" class="primary">Add robot</button>
</form>`;
</form>`;
- return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n</main>`);
+ return page('Your robots', `${topbar()}\n<main class="page">\n<h1>Your robots</h1>\n${list}\n${form}\n${kitsSection(kits, profiles)}\n</main>`);
}
}
function renderPairingPage({ robot, pairing, profile = null }) {
function renderPairingPage({ robot, pairing, profile = null }) {
@@ -26,6 +26,7 @@ const express = require('express');
@@ -26,6 +26,7 @@ const express = require('express');
const { getProfile, listProfiles } = require('../profiles');
const { getProfile, listProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
+const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderRobotsPage, renderPairingPage } = require('./render');
const VERSION = require('../../package.json').version;
const VERSION = require('../../package.json').version;
@@ -82,7 +83,10 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
@@ -82,7 +83,10 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
async function robotsPage(req, res, me, extra = {}) {
async function robotsPage(req, res, me, extra = {}) {
const robots = (await domain.robots.list(me.subject)).map(presentForPage);
const robots = (await domain.robots.list(me.subject)).map(presentForPage);
const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
- res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));
+ // A kit's "Add this robot" arrives as ?profile=<id>: the add form opens with that model chosen.
+ const asked = typeof req.query.profile === 'string' && profiles.some((p) => p.id === req.query.profile) ? req.query.profile : null;
+ const values = extra.values || (asked ? { profile_id: asked } : {});
+ res.type('html').send(renderRobotsPage({ robots, profiles, kits: kits.list(), ...extra, values }));
}
}
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
r.get('/robots', page(async (req, res) => robotsPage(req, res, requireUser(req))));
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+// The kit catalogue (plan T15 "Get a robot", O29 metadata half): the shipped kits validate, every kit binds
+// to a profile that ships, an invalid catalogue refuses at load, and GET /kits is a public read.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+const { loadKits, validateKit, list, get } = require('../server/kits');
+const { loadProfiles } = require('../server/profiles');
+
+// A minimal valid kit, cloned into the failure cases below.
+const MINIMAL = {
+ id: 'x.y', name: 'X', profile_id: 'sim.rover', build_guide_url: 'https://example.test/guide',
+ parts: [{ name: 'A board' }],
+};
+
+(async () => {
+ await check('the shipped kit catalogue loads, validates and binds every kit to a shipped profile', () => {
+ const kits = loadKits();
+ assert.deepStrictEqual([...kits.keys()], ['adeept.adr036']);
+ const profiles = loadProfiles();
+ for (const kit of kits.values()) {
+ assert.ok(profiles.has(kit.profile_id), `${kit.id} binds a shipped profile`);
+ assert.ok(kit.parts.length > 0, `${kit.id} has parts`);
+ assert.match(kit.build_guide_url, /^https:\/\//);
+ }
+ });
+
+ await check('the Adeept kit is the ADR036 car on adeept.adr036, with the parts you need and its build guide', () => {
+ const kit = get('adeept.adr036');
+ assert.strictEqual(kit.name, 'Adeept ADR036 4WD Smart Car Kit for Raspberry Pi');
+ assert.strictEqual(kit.profile_id, 'adeept.adr036');
+ assert.strictEqual(kit.vendor, 'Adeept');
+ assert.match(kit.build_guide_url, /^https:\/\//);
+ const names = kit.parts.map((p) => p.name).join('\n');
+ assert.match(names, /Robot HAT/);
+ assert.match(names, /Raspberry Pi/);
+ assert.match(names, /microSD/);
+ assert.match(names, /18650/);
+ for (const p of kit.parts) {
+ assert.ok(Number.isInteger(p.qty) && p.qty > 0, `${p.name} qty`);
+ assert.strictEqual(typeof p.required, 'boolean', `${p.name} required`);
+ }
+ const pi = kit.parts.find((p) => /Raspberry Pi 4B or 5/.test(p.name));
+ assert.match(pi.note, /not included/i, 'the kit does not include the Pi board');
+ assert.strictEqual(list().length, 1, 'list() is the same catalogue the API serves');
+ assert.strictEqual(get('nope'), null);
+ });
+
+ await check('qty defaults to 1 and required to true; a note is optional', () => {
+ const kit = validateKit({ ...MINIMAL });
+ assert.deepStrictEqual(kit.parts, [{ name: 'A board', qty: 1, required: true }]);
+ const withNote = validateKit({ ...MINIMAL, parts: [{ name: 'A board', qty: 2, required: false, note: 'spare' }] });
+ assert.deepStrictEqual(withNote.parts, [{ name: 'A board', qty: 2, required: false, note: 'spare' }]);
+ assert.strictEqual(withNote.vendor, null);
+ assert.strictEqual(withNote.description, null);
+ });
+
+ await check('a kit with a bad id, name, URL, parts or part is refused', () => {
+ const refused = [
+ [{ ...MINIMAL, id: 'Bad Id' }, /kit id is not valid/],
+ [{ ...MINIMAL, id: undefined }, /kit id is not valid/],
+ [{ ...MINIMAL, name: ' ' }, /name is required/],
+ [{ ...MINIMAL, profile_id: undefined }, /profile_id is required/],
+ [{ ...MINIMAL, build_guide_url: 'http://example.test/guide' }, /build_guide_url must be an https URL/],
+ [{ ...MINIMAL, build_guide_url: 'javascript:alert(1)' }, /build_guide_url must be an https URL/],
+ [{ ...MINIMAL, parts: [] }, /parts must be a non-empty array/],
+ [{ ...MINIMAL, parts: [{ qty: 1 }] }, /\.name is required/],
+ [{ ...MINIMAL, parts: [{ name: 'A board', qty: 0 }] }, /qty must be a positive integer/],
+ [{ ...MINIMAL, parts: [{ name: 'A board', note: 'x'.repeat(201) }] }, /note must be at most/],
+ ];
+ for (const [kit, re] of refused) assert.throws(() => validateKit(kit), re, JSON.stringify(kit));
+ });
+
+ await check('a kit bound to a profile that does not ship is refused at load', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-kits-'));
+ try {
+ fs.writeFileSync(path.join(dir, 'orphan.json'), JSON.stringify({ ...MINIMAL, profile_id: 'does.not.exist' }));
+ assert.throws(() => loadKits(dir), /is not a shipped profile/);
+ // The shipped catalogue itself always passes the same check.
+ assert.doesNotThrow(() => loadKits());
+ } finally {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ const t = await boot();
+ await check('GET /api/v1/kits is a public read; an unknown kit is a problem+json 404', async () => {
+ const all = await t.call('GET', '/api/v1/kits', { token: null });
+ assert.strictEqual(all.status, 200);
+ assert.deepStrictEqual(all.json.kits.map((k) => k.id), ['adeept.adr036']);
+ const one = await t.call('GET', '/api/v1/kits/adeept.adr036', { token: null });
+ assert.strictEqual(one.status, 200);
+ assert.strictEqual(one.json.kit.profile_id, 'adeept.adr036');
+ assert.ok(one.json.kit.parts.length > 0);
+ const missing = await t.call('GET', '/api/v1/kits/nope', { token: null });
+ assert.strictEqual(missing.status, 404);
+ assert.strictEqual(missing.headers.get('content-type'), 'application/problem+json');
+ assert.strictEqual(missing.json.code, 'bot.kit_not_found');
+ });
+
+ await check('the robots page offers "Get a robot": the kit, its parts, its guide and a start with its profile chosen', async () => {
+ const alex = t.network.newUser('alex');
+ const page = (q = '') => fetch(`${t.base}/robots${q}`, { redirect: 'manual', headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((r) => r.text());
+ const html = await page();
+ assert.match(html, /<h2 id="kits-h">Get a robot<\/h2>/);
+ assert.match(html, /Adeept ADR036 4WD Smart Car Kit for Raspberry Pi/);
+ assert.match(html, /What you need/);
+ assert.match(html, /href="https:\/\/www\.adeept\.com\/learn\/detail-97\.html" target="_blank" rel="noopener">Build guide/);
+ assert.match(html, /href="\/robots\?profile=adeept\.adr036#add-robot">Add this robot/);
+ assert.doesNotMatch(html, /\(O30\)/, 'no internal plan codes in public copy');
+ assert.match(await page('?profile=adeept.adr036'), /<option value="adeept\.adr036" selected>/, 'the kit start chooses its profile');
+ assert.doesNotMatch(await page('?profile=nope'), /<option value="nope"/);
+ });
+ await t.close();
+
+ done();
+})();