openvibers/openvibe.bot Commits

Bot: the LOOI desktop robot profile (looi); the panel shows device faults and refusals (#78)

OP OpenVibers committed 11 Oct 2026 · 91cc7c929b · 16 files, +127 −19

* Bot: the LOOI desktop robot profile (looi), its installer driver and the drivers page

* Bot: the LOOI desktop robot profile (looi); the panel shows device faults and refusals

Support for the LOOI desktop robot, whose owners are writing the Generic LOOI driver (a Bluetooth bridge plugin for the OpenVibe Node). Also, the panel stops hiding why a robot does not move.

**The `looi` profile** (`server/profiles/looi.json`):
- **Shape:** a bridge with `drive.differential` (throttle/steer -1..1, Cozmo's exact shape), `head` (one actuator, -1..1, Cozmo's range) and `battery`.
- **No camera:** the robot's phone streams on its own, for example from its browser to the owner's Live channel, where the panel shows under the stream.
- **Limits:** start conservative: speed 0.5, turn 0.6, 300 ms per command, 1 s heartbeat.
- **Mapping:** `mapping.driver: "looi"`, now in Bot's driver registry and in `bot.robot-profile@1` since Contracts 0.137.0.
- **Installer:** the pairing command gives `--driver looi` (OpenVibe.Node v0.2.0's installer takes it).
- **Contract:** the driver implements OpenVibe.Node `docs/hardware-looi.md`. `test/helpers/plugins.js` transcribes it, so every panel control is checked against what the driver accepts.

**The panel shows faults and refusals.**
- **Faults:** a device's faults (`status.faults` from the Node: code, driver, message) appear as "Device fault: …" while it is online. Before, nothing showed them, so a robot whose driver had lost its link looked silently broken.
- **Refusals:** a robot's refusal (a command's `nack`) is shown in words: stopped on the robot, the e-stop latched, not connected, the device's link lost, still starting, driver not running, or the code. A deadline that passed in transit stays quiet, and the next accepted command clears the message.

**Docs:**
- The drivers page names LOOI's driver and its contract, and how a driver raises the local stop (`local_stop`, OpenVibe.Node v0.2.0).
- The profile reference lists `looi`.
- README: the installer's driver mapping.

**Pins:** `openvibe-contracts` v0.129.0 → v0.137.0 (no breaking changes in between, per Contracts' compat check). STATUS.json follows.

**Tests:**
- `profiles`: seven shipped profiles, and the `looi` shape.
- `install`: `driverForProfile('looi')`.
- `api` and `local-controls`: the list.
- `device-safety`: faults reach the state, and the panel reads them and the refusal reasons.
- `ov test`: 31 of 31 pass.

Changes

ddiff --git a/README.md b/README.md
iindex a01b64d..24052ba 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex f5f6a7f..867c46d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/package-lock.json b/package-lock.json
iindex 118e1d3..11ccd26 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 300b394..af2b029 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/panel.js b/public/panel.js
iindex 8683ae0..02d8870 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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);
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 16637e7..931eeec 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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; };
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 87cdda0..2852e7c 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -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']);
ddiff --git a/server/profiles/looi.json b/server/profiles/looi.json
nnew file mode 100644
iindex 0000000..ad89878
---- /dev/null
++++ b/server/profiles/looi.json
@@@ -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 }
++}
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 942da09..074036e 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -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: { &lt;axis&gt;: [min, max] } }</code>), <code>actuator</code> (<code>{ names: { &lt;name&gt;: { type: number|rgb|tone|bool, … } } }</code>), <code>say</code>, <code>display</code>, <code>button</code> and <code>point</code>. Every profile also takes <code>halt</code>. The gate allows only the kinds declared here and builds every value from it.</li>
<li><code>widgets</code> — the panel, in order. Each is <code>{ type, capability?, label?, command? }</code>; types are <code>drive</code>, <code>pan-tilt</code>, <code>servo</code>, <code>lights</code>, <code>horn</code>, <code>speaker</code>, <code>display</code>, <code>telemetry</code>, <code>battery</code>, <code>latency</code>, <code>ptz</code>, <code>camera</code>, <code>head</code>, <code>lift</code>, <code>buttons</code>, <code>video_click</code>. A widget that sends commands names them in <code>command</code>, checked against <code>commands</code> at load.</li>
<li><code>camera</code> — optional: <code>{ transport: "whip"|"onvif"|"rtsp", resolution }</code>.</li>
ddiff --git a/server/web/render.js b/server/web/render.js
iindex b6962dd..eca8edb 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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')}
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 094a9ac..ff7b486 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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 });
ddiff --git a/test/device-safety.test.js b/test/device-safety.test.js
iindex 28fce45..c3cdf8b 100644
---- a/test/device-safety.test.js
++++ b/test/device-safety.test.js
@@@ -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);
ddiff --git a/test/helpers/plugins.js b/test/helpers/plugins.js
iindex 54644e9..93d29de 100644
---- a/test/helpers/plugins.js
++++ b/test/helpers/plugins.js
@@@ -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 };
ddiff --git a/test/install.test.js b/test/install.test.js
iindex b637732..73c8180 100644
---- a/test/install.test.js
++++ b/test/install.test.js
@@@ -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));
});
ddiff --git a/test/local-controls.test.js b/test/local-controls.test.js
iindex 1fde701..552d2b0 100644
---- a/test/local-controls.test.js
++++ b/test/local-controls.test.js
@@@ -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 () => {
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 533d09b..6081b6a 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -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']);