openvibers/openvibe.bot Commits
Bot: the LOOI desktop robot profile (looi); the panel shows device faults and refusals (#78)
Changes
@@@ -31,7 +31,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato## Devices--- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.++- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.129.0",++ "contracts": "openvibe-contracts v0.137.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v3.0.1","packages": [-- "openvibe-contracts v0.129.0",++ "openvibe-contracts v0.137.0","openvibe-sdk v0.44.0","openvibe-shared v3.0.1"],@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1","pg": "^8.23.0",@@@ -844,9 +844,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.129.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",-- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",++ "version": "0.137.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0",++ "integrity": "sha512-laBgfprFqS4S/ZoiiRDjaZz2P0wOnYYBZu1mQgThQ01XY3NAQaLFqtUJ2UZ45M9HHVKCuQaldYa7H3YC/vqnAA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1","pg": "^8.23.0",@@@ -642,6 +642,33 @@for (const el of $$('[data-joystick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }}}++ // What a device's refusal means, in words (a nack's fault code). A deadline that passed in transit is not news.++ const NACK_TEXT = {++ local_stop: 'The robot is stopped on the robot itself; release it there first.',++ estopped: 'The e-stop is latched.',++ not_connected: 'The robot is not connected to its device.',++ no_heartbeat: 'The device lost its link to the robot.',++ not_ready: 'The robot is still starting.',++ plugin_down: 'The robot\'s driver is not running.',++ };++ const QUIET_NACKS = new Set(['expired']);++ function nackText(code) {++ if (!code || QUIET_NACKS.has(code)) return '';++ return NACK_TEXT[code] || `The robot refused that (${code}).`;++ }++ /** The device's own faults (status.faults from the Node: { code, driver, message }), while it is online. */++ function paintFaults(state) {++ const el = $('[data-device-fault]');++ if (!el) return;++ const faults = state && state.online && state.status && Array.isArray(state.status.faults) ? state.status.faults : [];++ const text = faults.slice(0, 3).map((f) => {++ const code = String((f && f.code) || 'fault');++ const msg = f && f.message ? String(f.message).slice(0, 160) : '';++ return msg ? `${code}: ${msg}` : code;++ }).join(' · ');++ el.textContent = text ? `Device fault: ${text}` : '';++ el.hidden = !text;++ }function readout(v) {if (v == null) return '—';if (typeof v !== 'object') return String(v);@@@ -676,6 +703,7 @@clear.title = held ? 'Release the stop on the robot first' : '';}if (latched || held) releaseAll();++ paintFaults(state);setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');main.dataset.deviceOnline = String(!!state.online);if (state.telemetry) lastTelemetryAt = performance.now();@@@ -738,6 +766,8 @@} else if (m.type === 'command_result') {roundTrip(m.id, m.result);if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);++ else if (m.result === 'nack') { const t = nackText(m.reason); if (t) notice(t); }++ else if (m.result === 'ack') notice('');} else if (m.type === 'error') {if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }notice(m.detail || m.code);@@@ -50,7 +50,7 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.--const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo' });++const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi' });const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };@@@ -48,7 +48,8 @@ const CAPABILITIES = new Set([const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */--const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim', 'relay']);++// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).++const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay']);const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;const KINDS = new Set(['onboard', 'bridge', 'server']);nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "id": "looi",++ "version": 1,++ "name": "LOOI desktop robot",++ "vendor": "LOOI",++ "kind": "bridge",++ "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Node with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",++ "capabilities": ["drive.differential", "head", "battery"],++ "mapping": { "driver": "looi", "plugin": "looi" },++ "commands": {++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },++ "actuator": { "names": { "head": { "type": "number", "range": [-1, 1] } } }++ },++ "widgets": [++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },++ { "type": "head", "capability": "head", "label": "Head tilt", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },++ { "type": "latency", "label": "Latency", "order": 4 }++ ],++ "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 }++}@@@ -66,7 +66,7 @@ function renderDocsIndex({ config }) {<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>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')}. LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</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>`;@@@ -133,7 +133,8 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '<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>++<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>++<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Node's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Node is not answering and survives restarts. 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>@@@ -254,7 +255,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {<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>mapping</code> — <code>{ "driver": … }</code>, one of <code>pca9685</code>, <code>ads7830</code>, <code>cozmo</code>, <code>looi</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>@@@ -366,6 +366,7 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}${turnStrip(robot, role)}</div>${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}++<p class="notice" data-device-fault role="status" hidden></p><p class="notice" data-notice role="alert" hidden></p><div class="widgets">${widgets.join('\n')}@@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/app');await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {const list = await t.call('GET', '/api/v1/profiles', { token: null });assert.strictEqual(list.status, 200);-- assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);++ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });assert.strictEqual(one.json.profile.limits.max_command_ms, 300);const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });@@@ -78,6 +78,24 @@ const { boot, check, done } = require('./helpers/app');op.close(); dev.close();});++ await check('a device\'s faults reach the panel\'s state, and the panel shows them and the robot\'s refusals in words', async () => {++ const { robot, dev } = await connected();++ const op = await ownerJoined(robot.id);++ op.messages.length = 0;++ dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }], estop_latched: false });++ const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.status && m.state.status.faults && m.state.status.faults.length === 1);++ assert.deepStrictEqual(st.state.status.faults[0], { code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' });++ const fs = require('fs');++ const path = require('path');++ const panel = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');++ assert.match(panel, /state\.status && Array\.isArray\(state\.status\.faults\)/, 'the panel reads the device\'s faults');++ assert.match(panel, /else if \(m\.result === 'nack'\) \{ const t = nackText\(m\.reason\); if \(t\) notice\(t\); \}/, 'and says why the robot refused');++ assert.match(panel, /const QUIET_NACKS = new Set\(\['expired'\]\);/, 'a deadline that passed in transit is not news');++ const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover', estop: { latched: false } }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });++ assert.ok(html.includes('data-device-fault'), 'the panel has a place for them');++ op.close(); dev.close();++ });++await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {const { dev, deviceId } = await connected();const { robot: other } = await t.robot(alex);@@@ -8,6 +8,7 @@* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py* SDK = plugins/sdk/openvibe_plugin/__init__.py*++ * LOOI = OpenVibe.Node docs/hardware-looi.md (a community driver; the contract it implements).* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.** camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the@@@ -170,13 +171,35 @@ const server = {},};++/**++ * LOOI = OpenVibe.Node docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is++ * throttle/steer -1..1, the one actuator is head -1..1, and halt.++ */++const looi = {++ name: 'looi',++ accepts(kind, v) {++ if (kind === 'halt') return halt(v);++ if (!isObj(v)) return `${kind} value must be an object`;++ if (kind === 'drive') {++ if (!('throttle' in v) && !('steer' in v)) return 'drive carries throttle/steer';++ return only(v, ['throttle', 'steer']) && ['throttle', 'steer'].every((a) => !(a in v) || num(v[a], -1, 1)) ? null : 'drive is throttle/steer -1..1';++ }++ if (kind === 'actuator') {++ if (!only(v, ['name', 'value'])) return 'an actuator is {name, value}';++ return v.name === 'head' && num(v.value, -1, 1) ? null : 'the one actuator is head -1..1';++ }++ return `LOOI takes no ${kind}`;++ },++};++/** The contract a profile's commands must meet: its Node plugin, or the server driver. */function contractFor(profile) {const m = profile.mapping || {};if (m.plugin === 'adeept_adr036') return adeept(m.wheels);if (m.plugin === 'cozmo') return cozmo();if (m.plugin === 'relay') return relay;++ if (m.plugin === 'looi') return looi;return server[profile.id] || null;}--module.exports = { adeept, cozmo, relay, server, contractFor, COZMO_FACES };++module.exports = { adeept, cozmo, relay, looi, server, contractFor, COZMO_FACES };@@@ -20,10 +20,11 @@ const { driverForProfile } = require('../server/domain');}});-- await check('driverForProfile: adeept, adeept-mecanum, cozmo, else none', async () => {++ await check('driverForProfile: adeept, adeept-mecanum, cozmo, looi, else none', async () => {assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');assert.strictEqual(driverForProfile('cozmo'), 'cozmo');++ assert.strictEqual(driverForProfile('looi'), 'looi');for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));});@@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');try {await check('every existing catalogue profile still loads under the contract', () => {-- assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);++ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);});await check('an operator cannot edit or switch the robot-owned profile', async () => {@@@ -58,8 +58,8 @@ function samples(profile, kind, names) {(async () => {const profiles = loadProfiles();-- await check('the six shipped profiles load and validate', () => {-- assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);++ await check('the seven shipped profiles load and validate', () => {++ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);for (const p of profiles.values()) {assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);assert.ok(p.widgets.length > 0, `${p.id} widgets`);@@@ -95,6 +95,18 @@ function samples(profile, kind, names) {assert.strictEqual(p.camera.resolution, '320x240');});++ await check('looi is a Bluetooth bridge: tracks and head tilt, battery, no camera of its own (the phone streams)', () => {++ const p = profiles.get('looi');++ assert.strictEqual(p.kind, 'bridge');++ assert.deepStrictEqual(p.capabilities.slice().sort(), ['battery', 'drive.differential', 'head']);++ assert.deepStrictEqual(p.mapping, { driver: 'looi', plugin: 'looi' });++ assert.deepStrictEqual(Object.keys(p.commands).sort(), ['actuator', 'drive', 'halt'], 'halt is in every profile');++ assert.deepStrictEqual(p.commands.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });++ assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });++ assert.ok(!p.camera, 'no camera of its own');++ assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');++ });++await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
@@ -31,7 +31,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -31,7 +31,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
## Devices
## Devices
-- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
+- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.129.0",
+ "contracts": "openvibe-contracts v0.137.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v3.0.1",
"shared": "openvibe-shared v3.0.1",
"packages": [
"packages": [
- "openvibe-contracts v0.129.0",
+ "openvibe-contracts v0.137.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v3.0.1"
"openvibe-shared v3.0.1"
],
],
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -844,9 +844,9 @@
@@ -844,9 +844,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.129.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
- "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",
+ "version": "0.137.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0",
+ "integrity": "sha512-laBgfprFqS4S/ZoiiRDjaZz2P0wOnYYBZu1mQgThQ01XY3NAQaLFqtUJ2UZ45M9HHVKCuQaldYa7H3YC/vqnAA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.137.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -642,6 +642,33 @@
@@ -642,6 +642,33 @@
for (const el of $$('[data-joystick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
for (const el of $$('[data-joystick]', section)) { if (ok) el.removeAttribute('aria-disabled'); else el.setAttribute('aria-disabled', 'true'); }
}
}
}
}
+ // What a device's refusal means, in words (a nack's fault code). A deadline that passed in transit is not news.
+ const NACK_TEXT = {
+ local_stop: 'The robot is stopped on the robot itself; release it there first.',
+ estopped: 'The e-stop is latched.',
+ not_connected: 'The robot is not connected to its device.',
+ no_heartbeat: 'The device lost its link to the robot.',
+ not_ready: 'The robot is still starting.',
+ plugin_down: 'The robot\'s driver is not running.',
+ };
+ const QUIET_NACKS = new Set(['expired']);
+ function nackText(code) {
+ if (!code || QUIET_NACKS.has(code)) return '';
+ return NACK_TEXT[code] || `The robot refused that (${code}).`;
+ }
+ /** The device's own faults (status.faults from the Node: { code, driver, message }), while it is online. */
+ function paintFaults(state) {
+ const el = $('[data-device-fault]');
+ if (!el) return;
+ const faults = state && state.online && state.status && Array.isArray(state.status.faults) ? state.status.faults : [];
+ const text = faults.slice(0, 3).map((f) => {
+ const code = String((f && f.code) || 'fault');
+ const msg = f && f.message ? String(f.message).slice(0, 160) : '';
+ return msg ? `${code}: ${msg}` : code;
+ }).join(' · ');
+ el.textContent = text ? `Device fault: ${text}` : '';
+ el.hidden = !text;
+ }
function readout(v) {
function readout(v) {
if (v == null) return '—';
if (v == null) return '—';
if (typeof v !== 'object') return String(v);
if (typeof v !== 'object') return String(v);
@@ -676,6 +703,7 @@
@@ -676,6 +703,7 @@
clear.title = held ? 'Release the stop on the robot first' : '';
clear.title = held ? 'Release the stop on the robot first' : '';
}
}
if (latched || held) releaseAll();
if (latched || held) releaseAll();
+ paintFaults(state);
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
setOnline(state.online ? 'online' : 'offline', state.online ? 'Online' : 'Device offline');
main.dataset.deviceOnline = String(!!state.online);
main.dataset.deviceOnline = String(!!state.online);
if (state.telemetry) lastTelemetryAt = performance.now();
if (state.telemetry) lastTelemetryAt = performance.now();
@@ -738,6 +766,8 @@
@@ -738,6 +766,8 @@
} else if (m.type === 'command_result') {
} else if (m.type === 'command_result') {
roundTrip(m.id, m.result);
roundTrip(m.id, m.result);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
if (m.result === 'refused' && m.code !== 'bot.cooldown') notice(m.reason || m.code);
+ else if (m.result === 'nack') { const t = nackText(m.reason); if (t) notice(t); }
+ else if (m.result === 'ack') notice('');
} else if (m.type === 'error') {
} else if (m.type === 'error') {
if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
if (m.code === 'bot.not_an_operator' || m.code === 'bot.robot_not_found') { gone = true; joined = false; enableControls(); }
notice(m.detail || m.code);
notice(m.detail || m.code);
@@ -50,7 +50,7 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
@@ -50,7 +50,7 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,
// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,
// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
-const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo' });
+const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi' });
const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');
const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
@@ -48,7 +48,8 @@ const CAPABILITIES = new Set([
@@ -48,7 +48,8 @@ const CAPABILITIES = new Set([
const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);
const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift', 'buttons', 'video_click']);
/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
/** Device drivers a mapping may name. `relay` is a Node plugin with no hardware map of its own. */
-const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim', 'relay']);
+// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe.Node docs/hardware-looi.md).
+const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay']);
const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const KINDS = new Set(['onboard', 'bridge', 'server']);
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "id": "looi",
+ "version": 1,
+ "name": "LOOI desktop robot",
+ "vendor": "LOOI",
+ "kind": "bridge",
+ "description": "LOOI, the tracked desk robot that uses a phone as its face: differential drive and head tilt over Bluetooth from a bridge computer (Windows, macOS or Linux) running the OpenVibe Node with the Generic LOOI driver. The phone streams its own video, for example from its browser to the owner's OpenVibe.Live channel, where the robot's panel shows under the stream.",
+ "capabilities": ["drive.differential", "head", "battery"],
+ "mapping": { "driver": "looi", "plugin": "looi" },
+ "commands": {
+ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
+ "actuator": { "names": { "head": { "type": "number", "range": [-1, 1] } } }
+ },
+ "widgets": [
+ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
+ { "type": "head", "capability": "head", "label": "Head tilt", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },
+ { "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
+ { "type": "latency", "label": "Latency", "order": 4 }
+ ],
+ "limits": { "max_speed": 0.5, "max_turn": 0.6, "max_command_ms": 300, "heartbeat_ms": 1000 }
+}
@@ -66,7 +66,7 @@ function renderDocsIndex({ config }) {
@@ -66,7 +66,7 @@ function renderDocsIndex({ config }) {
<ul>
<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>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>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>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')}. LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</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>
<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>
</ul>
`;
`;
@@ -133,7 +133,8 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
@@ -133,7 +133,8 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
<li>on <code>stop</code>, <code>estop</code> and a <code>halt</code> command;</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>
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
</ul>
</ul>
-<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
+<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
+<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Node's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Node is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
<section class="card"><h2>A minimal driver in Python</h2>
<section class="card"><h2>A minimal driver in Python</h2>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
@@ -254,7 +255,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {
@@ -254,7 +255,7 @@ function renderProfilesPage({ config, source = '', result = null } = {}) {
<li><code>kind</code> — <code>onboard</code>, <code>bridge</code> or <code>server</code> (see ${inLink('/docs/drivers', 'Write a driver')}).</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>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>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>mapping</code> — <code>{ "driver": … }</code>, one of <code>pca9685</code>, <code>ads7830</code>, <code>cozmo</code>, <code>looi</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>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>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>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
@@ -366,6 +366,7 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
@@ -366,6 +366,7 @@ ${embed ? '' : topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
${turnStrip(robot, role)}
${turnStrip(robot, role)}
</div>
</div>
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-drive-bar><button type="button" class="button primary" data-drive>${icon('gamepad')}<span>Drive</span></button> ${outLink(`/panel/${robot.id}`, 'Open on openvibe.bot')}</p>` : ''}
+<p class="notice" data-device-fault role="status" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<p class="notice" data-notice role="alert" hidden></p>
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -27,7 +27,7 @@ const { boot, check, done } = require('./helpers/app');
await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
const list = await t.call('GET', '/api/v1/profiles', { token: null });
const list = await t.call('GET', '/api/v1/profiles', { token: null });
assert.strictEqual(list.status, 200);
assert.strictEqual(list.status, 200);
- assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
+ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);
const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });
const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });
assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
@@ -78,6 +78,24 @@ const { boot, check, done } = require('./helpers/app');
@@ -78,6 +78,24 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
op.close(); dev.close();
});
});
+ await check('a device\'s faults reach the panel\'s state, and the panel shows them and the robot\'s refusals in words', async () => {
+ const { robot, dev } = await connected();
+ const op = await ownerJoined(robot.id);
+ op.messages.length = 0;
+ dev.send({ type: 'status', firmware: 'test', capabilities: {}, faults: [{ code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' }], estop_latched: false });
+ const st = await op.waitFor((m) => m.type === 'robot_state' && m.state.status && m.state.status.faults && m.state.status.faults.length === 1);
+ assert.deepStrictEqual(st.state.status.faults[0], { code: 'not_connected', driver: 'looi', message: 'LOOI is not answering over Bluetooth' });
+ const fs = require('fs');
+ const path = require('path');
+ const panel = fs.readFileSync(path.join(__dirname, '..', 'public', 'panel.js'), 'utf8');
+ assert.match(panel, /state\.status && Array\.isArray\(state\.status\.faults\)/, 'the panel reads the device\'s faults');
+ assert.match(panel, /else if \(m\.result === 'nack'\) \{ const t = nackText\(m\.reason\); if \(t\) notice\(t\); \}/, 'and says why the robot refused');
+ assert.match(panel, /const QUIET_NACKS = new Set\(\['expired'\]\);/, 'a deadline that passed in transit is not news');
+ const html = require('../server/web/render').renderPanel({ robot: { id: robot.id, name: 'R', profile_id: 'sim.rover', estop: { latched: false } }, profile: { id: 'sim.rover', name: 'Rover', commands: {}, widgets: [] }, role: 'operator' });
+ assert.ok(html.includes('data-device-fault'), 'the panel has a place for them');
+ op.close(); dev.close();
+ });
+
await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {
await check('a device cannot latch or report for a robot it is not attached to (bot.forbidden, audited)', async () => {
const { dev, deviceId } = await connected();
const { dev, deviceId } = await connected();
const { robot: other } = await t.robot(alex);
const { robot: other } = await t.robot(alex);
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__init__.py
* SDK = plugins/sdk/openvibe_plugin/__init__.py
*
*
+ * LOOI = OpenVibe.Node docs/hardware-looi.md (a community driver; the contract it implements).
* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.
* RELAY = the relay plugin (plan T15 R9 step 4), a bridge with no hardware of its own.
*
*
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
@@ -170,13 +171,35 @@ const server = {
@@ -170,13 +171,35 @@ const server = {
},
},
};
};
+/**
+ * LOOI = OpenVibe.Node docs/hardware-looi.md, the contract the community's Generic LOOI driver implements: drive is
+ * throttle/steer -1..1, the one actuator is head -1..1, and halt.
+ */
+const looi = {
+ name: 'looi',
+ accepts(kind, v) {
+ if (kind === 'halt') return halt(v);
+ if (!isObj(v)) return `${kind} value must be an object`;
+ if (kind === 'drive') {
+ if (!('throttle' in v) && !('steer' in v)) return 'drive carries throttle/steer';
+ return only(v, ['throttle', 'steer']) && ['throttle', 'steer'].every((a) => !(a in v) || num(v[a], -1, 1)) ? null : 'drive is throttle/steer -1..1';
+ }
+ if (kind === 'actuator') {
+ if (!only(v, ['name', 'value'])) return 'an actuator is {name, value}';
+ return v.name === 'head' && num(v.value, -1, 1) ? null : 'the one actuator is head -1..1';
+ }
+ return `LOOI takes no ${kind}`;
+ },
+};
+
/** The contract a profile's commands must meet: its Node plugin, or the server driver. */
/** The contract a profile's commands must meet: its Node plugin, or the server driver. */
function contractFor(profile) {
function contractFor(profile) {
const m = profile.mapping || {};
const m = profile.mapping || {};
if (m.plugin === 'adeept_adr036') return adeept(m.wheels);
if (m.plugin === 'adeept_adr036') return adeept(m.wheels);
if (m.plugin === 'cozmo') return cozmo();
if (m.plugin === 'cozmo') return cozmo();
if (m.plugin === 'relay') return relay;
if (m.plugin === 'relay') return relay;
+ if (m.plugin === 'looi') return looi;
return server[profile.id] || null;
return server[profile.id] || null;
}
}
-module.exports = { adeept, cozmo, relay, server, contractFor, COZMO_FACES };
+module.exports = { adeept, cozmo, relay, looi, server, contractFor, COZMO_FACES };
@@ -20,10 +20,11 @@ const { driverForProfile } = require('../server/domain');
@@ -20,10 +20,11 @@ const { driverForProfile } = require('../server/domain');
}
}
});
});
- await check('driverForProfile: adeept, adeept-mecanum, cozmo, else none', async () => {
+ await check('driverForProfile: adeept, adeept-mecanum, cozmo, looi, else none', async () => {
assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');
assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');
assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');
assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');
assert.strictEqual(driverForProfile('cozmo'), 'cozmo');
assert.strictEqual(driverForProfile('cozmo'), 'cozmo');
+ assert.strictEqual(driverForProfile('looi'), 'looi');
for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
});
});
@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');
@@ -26,7 +26,7 @@ const { renderPanel } = require('../server/web/render');
try {
try {
await check('every existing catalogue profile still loads under the contract', () => {
await check('every existing catalogue profile still loads under the contract', () => {
- assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
+ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);
});
});
await check('an operator cannot edit or switch the robot-owned profile', async () => {
await check('an operator cannot edit or switch the robot-owned profile', async () => {
@@ -58,8 +58,8 @@ function samples(profile, kind, names) {
@@ -58,8 +58,8 @@ function samples(profile, kind, names) {
(async () => {
(async () => {
const profiles = loadProfiles();
const profiles = loadProfiles();
- await check('the six shipped profiles load and validate', () => {
- assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'relay.generic', 'sim.rover']);
+ await check('the seven shipped profiles load and validate', () => {
+ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'relay.generic', 'sim.rover']);
for (const p of profiles.values()) {
for (const p of profiles.values()) {
assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
assert.ok(p.widgets.length > 0, `${p.id} widgets`);
assert.ok(p.widgets.length > 0, `${p.id} widgets`);
@@ -95,6 +95,18 @@ function samples(profile, kind, names) {
@@ -95,6 +95,18 @@ function samples(profile, kind, names) {
assert.strictEqual(p.camera.resolution, '320x240');
assert.strictEqual(p.camera.resolution, '320x240');
});
});
+ await check('looi is a Bluetooth bridge: tracks and head tilt, battery, no camera of its own (the phone streams)', () => {
+ const p = profiles.get('looi');
+ assert.strictEqual(p.kind, 'bridge');
+ assert.deepStrictEqual(p.capabilities.slice().sort(), ['battery', 'drive.differential', 'head']);
+ assert.deepStrictEqual(p.mapping, { driver: 'looi', plugin: 'looi' });
+ assert.deepStrictEqual(Object.keys(p.commands).sort(), ['actuator', 'drive', 'halt'], 'halt is in every profile');
+ assert.deepStrictEqual(p.commands.drive.axes, { throttle: [-1, 1], steer: [-1, 1] });
+ assert.deepStrictEqual(p.commands.actuator.names, { head: { type: 'number', range: [-1, 1] } });
+ assert.ok(!p.camera, 'no camera of its own');
+ assert.ok(p.limits.max_speed <= 0.5 && p.limits.max_command_ms <= 300, 'a desk robot starts slow, with short commands');
+ });
+
await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {
await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {
assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);
assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);
assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);