openvibers/openvibe.bot Commits
Bot: ROS 2 robots are models (ros2.diffdrive, ros2.holonomic) whose pairing installs the Node's ros2 driver (#95)
Changes
@@@ -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.@@@ -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",@@@ -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; };nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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 }++}@@@ -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>@@@ -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>`;@@@ -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 });@@@ -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 };@@@ -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));});@@@ -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 () => {@@@ -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`);
@@ -40,7 +40,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -40,7 +40,7 @@ Sitemap entries use the release commit time for these code-backed pages.
## 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`, `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.
- 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.
@@ -35,7 +35,8 @@
@@ -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",
"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",
"pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
"device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
- "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)",
"curated kit catalogue (server/kits/*.json; GET /api/v1/kits, /kits/:id): each kit's parts list and build guide, validated and bound to a shipped profile — the Adeept ADR036 first; metadata only (pricing, shipping and returns are O29)",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
"command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
@@ -50,7 +50,10 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
@@ -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,
// 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', 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 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; };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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 }
+}
@@ -66,6 +66,17 @@ const MODELS = {
@@ -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).'],
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' },
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': {
'relay.generic': {
connect: 'onboard', order: 6,
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.'],
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>`
@@ -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>
<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>
<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>
<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>`;
<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 });
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>` : ''}
@@ -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>
<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>
<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')}
${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>
</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>
<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>
<p><button type="submit" class="dir-btn dir-primary">Add robot</button></p>
@@ -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')}. 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>
<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>
`;
`;
@@ -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', '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 });
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 });
@@ -10,6 +10,8 @@
@@ -10,6 +10,8 @@
*
*
* LOOI = OpenVibe.Node docs/hardware-looi.md (a community driver; the contract it implements).
* 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.
+ * 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
* camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
* value table in docs/protocol.md.
* value table in docs/protocol.md.
@@ -150,6 +152,23 @@ const relay = {
@@ -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). */
/** The server-side drivers (docs/protocol.md, command values). */
const server = {
const server = {
'sim.rover': {
'sim.rover': {
@@ -231,7 +250,8 @@ function contractFor(profile) {
@@ -231,7 +250,8 @@ function contractFor(profile) {
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;
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;
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 };
@@ -20,11 +20,13 @@ const { driverForProfile } = require('../server/domain');
@@ -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'), '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');
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));
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', '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 () => {
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 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()) {
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`);