openvibers/openvibe.bot Commits

Bot: ROS 2 robots are models (ros2.diffdrive, ros2.holonomic) whose pairing installs the Node's ros2 driver (#95)

OP OpenVibers committed 14 Oct 2026 · 9ecf19c500 · 12 files, +93 −12

Plan T15 B10, the Bot side. A ROS 2 robot is now a model you pick on openvibe.bot. Its pairing command installs the OpenVibe Node with the new `ros2` driver (OpenVibe.Node#37, released as Node v0.3.0, whose installer Bot serves from the latest release).

**Models**
- `ros2.diffdrive` "ROS 2 robot": `drive.differential` (throttle/steer), camera, distance ahead (`sensor.ultrasonic`, which the panel reads from `distance_cm`, fed by a Range or the front of a LaserScan), battery.
- `ros2.holonomic` "ROS 2 robot (mecanum or omni)": the same with `drive.mecanum` (x/y/rotation).
- Both map to `"driver": "plugin", "plugin": "ros2"` (Contracts 0.160.0), kind `onboard`.

**Pairing**
- `driverForProfile`: `ros2.diffdrive` → `--driver ros2`, `ros2.holonomic` → `--driver ros2-holonomic`. The installer writes the rosbridge config, with `holonomic` set.

**Pages**
- `/models` and the add-robot form list them as on-board, with setup notes (run rosbridge on the robot, paste the command, set topics and safe speeds) and a link to the driver guide (OpenVibe.Node `plugins/ros2/README.md`).
- The "Something else?" copy no longer sends ROS 2 robots to the relay robot.
- `/docs` names `ros2` among the bundled drivers.

**Docs**
- The README's driver mapping names both.
- STATUS lists every shipped profile (it still named five of ten and the Live conversion) and gains the ROS 2 feature line.

**Tests**
- `profiles`: ten shipped profiles. The test helpers gain the `ros2` plugin's contract (transcribed from `Ros2.handle`: drive only, throttle/steer or x/y/rotation in −1..1), so every panel control of both models is checked against what the plugin accepts.
- `install`: the two new driver flags.
- The full suite passes: 38/38.

Changes

ddiff --git a/README.md b/README.md
iindex 761af3d..7f2b4e8 100644
---- a/README.md
++++ b/README.md
@@@ -40,7 +40,7 @@ Sitemap entries use the release commit time for these code-backed pages.
## 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`, `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 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`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); 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 e472971..a3b342c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -35,7 +35,8 @@
"operator WebSocket /control and the REST API (/api/v1, including POST /robots/:id/commands so a bound channel's chat can forward a command over HTTP) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",
"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",
-- "catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover and relay.generic (the bare relay robot OpenVibe.Live's controls convert to) seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
++ "catalogue profiles adeept.adr036 (+ mecanum variant), cozmo, looi, phone.camera, camera.onvif, sim.rover, relay.generic (the bare relay robot; OpenVibe.Live's old controls were converted to it and deleted from Live) and ros2.diffdrive / ros2.holonomic, seeded from server/profiles/*.json; owner-edited robot-local button and video-click profiles stored with each robot",
++ "ROS 2 robots: profiles ros2.diffdrive and ros2.holonomic (mapping driver plugin, plugin ros2; drive, camera, distance ahead, battery), installer --driver ros2 / ros2-holonomic; the device side is OpenVibe.Node's bundled ros2 plugin (cmd_vel Twist, BatteryState, Range/LaserScan, Odometry, CompressedImage; rclpy or rosbridge)",
"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",
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex d8eca19..c0a706e 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -50,7 +50,10 @@ 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', looi: 'looi' });
++const DRIVER_BY_PROFILE = Object.freeze({
++ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo', looi: 'looi',
++ 'ros2.diffdrive': 'ros2', 'ros2.holonomic': 'ros2-holonomic',
++});
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/ros2.diffdrive.json b/server/profiles/ros2.diffdrive.json
nnew file mode 100644
iindex 0000000..0dfe86e
---- /dev/null
++++ b/server/profiles/ros2.diffdrive.json
@@@ -0,0 +1,22 @@
++{
++ "id": "ros2.diffdrive",
++ "version": 1,
++ "name": "ROS 2 robot",
++ "vendor": "ROS 2",
++ "kind": "onboard",
++ "description": "Any ROS 2 robot that drives on cmd_vel (geometry_msgs/Twist): TurtleBot 3 or 4, a Jackal, a home-built base. The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery. Mecanum and omni bases are ros2.holonomic.",
++ "capabilities": ["drive.differential", "camera", "sensor.ultrasonic", "battery"],
++ "mapping": { "driver": "plugin", "plugin": "ros2" },
++ "commands": {
++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } }
++ },
++ "widgets": [
++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
++ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance ahead", "order": 2 },
++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
++ { "type": "latency", "label": "Latency", "order": 4 }
++ ],
++ "camera": { "transport": "whip", "resolution": "640x480" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/profiles/ros2.holonomic.json b/server/profiles/ros2.holonomic.json
nnew file mode 100644
iindex 0000000..307c636
---- /dev/null
++++ b/server/profiles/ros2.holonomic.json
@@@ -0,0 +1,22 @@
++{
++ "id": "ros2.holonomic",
++ "version": 1,
++ "name": "ROS 2 robot (mecanum or omni)",
++ "vendor": "ROS 2",
++ "kind": "onboard",
++ "description": "A ROS 2 robot with mecanum or omni wheels that moves in any direction on cmd_vel (linear x and y, angular z). The OpenVibe Node's ros2 driver reaches it through rosbridge (or as a ROS 2 node with rclpy): drive, a camera from a JPEG CompressedImage topic, the distance ahead from a laser scan or range sensor, and the battery.",
++ "capabilities": ["drive.mecanum", "camera", "sensor.ultrasonic", "battery"],
++ "mapping": { "driver": "plugin", "plugin": "ros2" },
++ "commands": {
++ "drive": { "axes": { "x": [-1, 1], "y": [-1, 1], "rotation": [-1, 1] } }
++ },
++ "widgets": [
++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 0 },
++ { "type": "drive", "capability": "drive.mecanum", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
++ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance ahead", "order": 2 },
++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 3 },
++ { "type": "latency", "label": "Latency", "order": 4 }
++ ],
++ "camera": { "transport": "whip", "resolution": "640x480" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 436dfc4..bed88ef 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -66,6 +66,17 @@ const MODELS = {
setup: ['Any ONVIF pan-tilt-zoom camera: Bot moves it, with the same gate and limits as a robot.', 'Today an operator names the camera in Bot\'s configuration (its address and the names of its secrets).'],
guide: { href: 'https://github.com/OpenVibers/OpenVibe.Bot#readme', label: 'ONVIF notes' },
},
++ 'ros2.diffdrive': {
++ connect: 'onboard', order: 5,
++ setup: ['Any ROS 2 robot that drives on cmd_vel: TurtleBot 3 or 4, a Jackal, your own base.', 'On the robot run a rosbridge server, then paste the pairing command on the robot or a computer beside it; the installer sets up the ros2 driver.',
++ 'Set your topics and safe speeds (cmd_vel, the camera\'s CompressedImage, max_linear) in the Node\'s config.json.'],
++ guide: { href: `${NODE_BLOB}/plugins/ros2/README.md`, label: 'ROS 2 driver guide' },
++ },
++ 'ros2.holonomic': {
++ connect: 'onboard', order: 5,
++ setup: ['A ROS 2 base with mecanum or omni wheels: it moves sideways too (linear x and y on cmd_vel).', 'Pair it with the same one command; the installer picks the holonomic setting.'],
++ guide: { href: `${NODE_BLOB}/plugins/ros2/README.md`, label: 'ROS 2 driver guide' },
++ },
'relay.generic': {
connect: 'onboard', order: 6,
setup: ['Any Raspberry Pi or Linux machine running the OpenVibe Node.', 'Each button on the panel is passed to a script of yours by the relay driver: lights, a door, a motor board.'],
@@@ -146,7 +157,7 @@ ${listed.length ? `<ul class="dir-grid">${listed.map(robotCard).join('')}</ul>`
<section class="dir-sec" aria-labelledby="models-h"><div class="dir-sec-head"><h2 id="models-h">Models it drives</h2>${link('/models', 'All models')}</div>
<ul class="dir-grid">${sortModels(models).map(modelCard).join('')}</ul></section>
<section class="dir-sec" aria-labelledby="byo-h"><h2 id="byo-h">Something else?</h2>
--<p class="muted">Any robot with a computer on it or next to it can join: a Raspberry Pi or other Linux board, an ESP32, a ROS 2 robot, an ONVIF camera. Describe it with a profile and the panel draws itself; write a driver in any language for the hardware, then submit it so anyone can add one.</p>
++<p class="muted">Any robot with a computer on it or next to it can join: a Raspberry Pi or other Linux board, an ESP32, a ROS 2 robot (it has its own models above), an ONVIF camera. Describe it with a profile and the panel draws itself; write a driver in any language for the hardware, then submit it so anyone can add one.</p>
<p class="dir-actions">${link('/robots/submit', 'Submit a robot', 'dir-btn dir-primary')} ${link('/docs/profiles', 'Robot profiles', 'dir-btn')} ${link('/docs/drivers', 'Write a driver', 'dir-btn')}</p></section>`;
return shell({ config, path: '/robots', title: 'Robots', description: 'Robots on OpenVibe.Bot: watch the ones people list live, see every model it drives, and add your own.', body });
}
@@@ -222,7 +233,7 @@ ${error ? `<p class="dir-error" role="alert">${esc(error)}</p>` : ''}
<label class="dir-field"><span class="dir-field-label">Name</span><input type="text" name="name" maxlength="80" required autocomplete="off" placeholder="e.g. Kitchen rover" value="${esc(values.name || '')}"></label>
<fieldset class="dir-fieldset"><legend class="dir-field-label">What are you connecting?</legend>
${groups.map(([title, ms]) => `<p class="dir-group">${esc(title)}</p><div class="dir-choices">${ms.map(option).join('')}</div>`).join('\n')}
--<p class="muted dir-note">Something else (an ESP32, a ROS 2 robot, your own board)? Choose the relay robot and drive it with your own script, or ${link('/docs/profiles', 'write a profile')} for it.</p>
++<p class="muted dir-note">Something else (an ESP32, your own board)? Choose the relay robot and drive it with your own script, or ${link('/docs/profiles', 'write a profile')} for it.</p>
</fieldset>
<label class="dir-field"><span class="dir-field-label">Who may drive</span><select name="access_policy">${POLICIES.map(([v, l]) => `<option value="${esc(v)}"${v === (values.access_policy || 'private') ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
<p><button type="submit" class="dir-btn dir-primary">Add robot</button></p>
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex 074036e..5cb9f46 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')}. 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>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). 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>
`;
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 535e34c..3f135ad 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', 'looi', 'phone.camera', 'relay.generic', 'sim.rover']);
++ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'phone.camera', 'relay.generic', 'ros2.diffdrive', 'ros2.holonomic', '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/helpers/plugins.js b/test/helpers/plugins.js
iindex 047f62e..eabcb78 100644
---- a/test/helpers/plugins.js
++++ b/test/helpers/plugins.js
@@@ -10,6 +10,8 @@
*
* 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.
++ * ROS2 = plugins/ros2/openvibe_ros2/__init__.py (Ros2.handle): drive only (say needs say_topic, which no shipped
++ * profile assumes); throttle/steer, or x/y/rotation when the profile's base is holonomic.
*
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
* value table in docs/protocol.md.
@@@ -150,6 +152,23 @@ const relay = {
},
};
++// ROS2 Ros2.handle: drive → a Twist from throttle/steer (differential) or x/y/rotation (holonomic: true), each -1..1.
++function ros2(holonomic) {
++ const axes = holonomic ? ['x', 'y', 'rotation'] : ['throttle', 'steer'];
++ return {
++ name: `ros2 (${holonomic ? 'holonomic' : 'differential'})`,
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (!isObj(v)) return `${kind} value must be an object`;
++ if (kind === 'drive') {
++ if (!axes.some((a) => a in v)) return `drive carries ${axes.join('/')}`;
++ return only(v, axes) && axes.every((a) => !(a in v) || num(v[a], -1, 1)) ? null : `drive is ${axes.join('/')} -1..1`;
++ }
++ return `kind ${kind} not supported`;
++ },
++ };
++}
++
/** The server-side drivers (docs/protocol.md, command values). */
const server = {
'sim.rover': {
@@@ -231,7 +250,8 @@ function contractFor(profile) {
if (m.plugin === 'cozmo') return cozmo();
if (m.plugin === 'relay') return relay;
if (m.plugin === 'looi') return looi;
++ if (m.plugin === 'ros2') return ros2(!!(profile.commands && profile.commands.drive && 'x' in profile.commands.drive.axes));
return server[profile.id] || null;
}
--module.exports = { adeept, cozmo, relay, looi, phone, server, contractFor, COZMO_FACES, PHONE_FACES };
++module.exports = { adeept, cozmo, relay, looi, ros2, phone, server, contractFor, COZMO_FACES, PHONE_FACES };
ddiff --git a/test/install.test.js b/test/install.test.js
iindex 73c8180..b87550f 100644
---- a/test/install.test.js
++++ b/test/install.test.js
@@@ -20,11 +20,13 @@ const { driverForProfile } = require('../server/domain');
}
});
-- await check('driverForProfile: adeept, adeept-mecanum, cozmo, looi, else none', async () => {
++ await check('driverForProfile: adeept, adeept-mecanum, cozmo, looi, ros2, ros2-holonomic, 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');
++ assert.strictEqual(driverForProfile('ros2.diffdrive'), 'ros2');
++ assert.strictEqual(driverForProfile('ros2.holonomic'), 'ros2-holonomic');
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 5c55cf0..e0b87c5 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', 'looi', 'phone.camera', 'relay.generic', 'sim.rover']);
++ assert.deepStrictEqual([...loadProfiles().keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'phone.camera', 'relay.generic', 'ros2.diffdrive', 'ros2.holonomic', '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 15db674..78a0bfc 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 eight shipped profiles load and validate', () => {
-- assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'phone.camera', 'relay.generic', 'sim.rover']);
++ await check('the ten shipped profiles load and validate', () => {
++ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', 'looi', 'phone.camera', 'relay.generic', 'ros2.diffdrive', 'ros2.holonomic', '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`);