openvibers/openvibe.bot Commits

Bot profiles emit exactly what the Node plugins accept; effective limits reach the device; pairing bootstraps video (plan T15 follow-up C)

OP OpenVibers committed 1 Oct 2026 · ca8963094c · 17 files, +815 −115

Profiles declare `commands` per kind (drive/ptz axes and ranges, actuator names with number/rgb/tone/bool
values, say, display modes and faces; halt always), validated against a per-kind schema, and each control
widget names the command it sends. The gate allows only the profile's kinds and builds every value from
the declaration, clamped by the owner's limits; a value the plugin would refuse is refused before it is sent.

- Adeept: actuator {name, value} for pan, tilt (-1..1), buzzer ({hz} 220-880, notes converted) and lights
  ({r,g,b} 0-255, index < 8); the mecanum chassis is its own profile, adeept.adr036.mecanum, whose drive
  passes x/y/rotation through.
- Cozmo: head (-1..1), lift (0..1), backpack/cube lights; display {text|face|image_png_b64}; the stock
  animations the plugin does not implement are gone.
- halt passes the e-stop, the allowlist, cooldowns and the turn budget (it still needs a role that drives).
- config carries the effective limits (owner clamped by profile) and is re-sent when the owner changes them.
- POST /pair, the WS paired frame and rotation return whip_url = <BOT_WHIP_BASE>/<publish key>
  (OpenRe WHIP ingest by default), documented in docs/protocol.md.

The new test/helpers/plugins.js transcribes the Adeept and Cozmo plugin contracts (OpenVibe.Node 2479ef1,
file:line) and the server drivers'; every panel control of all five profiles is checked against them.

Changes

ddiff --git a/.env.example b/.env.example
iindex 969ecf4..045285e 100644
---- a/.env.example
++++ b/.env.example
@@@ -59,3 +59,5 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
++# Video: devices publish to OpenRe WHIP at <base>/<publish key>; empty = pair without video
++#BOT_WHIP_BASE=https://ingest.openre.stream/whip
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex f53fffd..19d10de 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -33,9 +33,9 @@ so a reconnecting agent cannot be shadowed by a stale socket.
| type | fields (besides v, seq, ts) | when |
|---|---|---|
--| `paired` | `device_id, credential, publish_key, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key` are shown **once** |
++| `paired` | `device_id, credential, publish_key, whip_url, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key`/`whip_url` are shown **once** |
| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
--| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello` |
++| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello`, and again whenever the owner changes the robot's limits |
| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
| `heartbeat_ack` | `seq, server_time` | the answer to `heartbeat`; the device computes RTT |
@@@ -53,10 +53,51 @@ so a reconnecting agent cannot be shadowed by a stale socket.
| `nack` | `id, fault_code` | a command was refused on the device |
| `estop_state` | `latched, by, at, robot_id?` | a hardware latch; `robot_id` omitted = every robot the device serves |
--`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `halt`. `deadline_ms` is an absolute
--epoch-ms instant and is present on the motion kinds only (`drive`, `actuator`, `ptz`): the device
--**must** stop the motors at that instant if no newer command arrived, and on every disconnect or crash
--path, without asking the network.
++`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `halt`, and a device only ever gets the
++kinds its robot's profile declares (§1.1), with values in the shapes and ranges declared there.
++`deadline_ms` is an absolute epoch-ms instant and is present on the motion kinds only (`drive`,
++`actuator`, `ptz`): the device **must** stop the motors at that instant if no newer command arrived, and
++on every disconnect or crash path, without asking the network. `halt` (value `{}`, no deadline) stops
++everything at once and passes the gate even while the e-stop is latched.
++
++`config.limits` are the **effective** limits — the owner's (`PATCH /robots/:id` `limits`) clamped by the
++profile's — as `{ max_speed, max_turn, max_command_ms, heartbeat_ms }`, the same numbers the gate clamps
++with; `allowed_commands` is what the owner may send: the profile's kinds, cut by the owner's allowlist,
++always with `halt`. Both are re-sent to the connected device the moment the owner changes the limits.
++
++`whip_url` is where the device publishes its camera: OpenRe's WHIP ingest (`POST`, RFC 9725, body
++`application/sdp`) at `<BOT_WHIP_BASE>/<publish_key>`, the base defaulting to
++`https://ingest.openre.stream/whip`. It is built from the device's own publish key (never the owner's
++stream key), so it is a secret like the key: returned only by `pair` / `POST /pair` and by a rotation
++(which issues a new key, so a new URL), never by a read. With `BOT_WHIP_BASE` empty it is `null` and the
++device runs without video.
++
++### 1.1 Command values (the profile's `commands`)
++
++Each profile (`server/profiles/*.json`) declares, per kind, what its device accepts; the gate allows only
++those kinds (plus `halt`) and builds every value from the declaration — only the declared axes and
++names, clamped into the declared ranges and the owner's limits; a value the device would refuse is
++refused here (`bot.invalid_input`, `bot.unknown_actuator`) and never sent.
++
++| kind | profile declares | the device gets |
++|---|---|---|
++| `drive` | `axes: { throttle, steer }` (differential) or `{ x, y, rotation }` (mecanum) → `[min, max]` | every declared axis (an absent one is 0): `{ "throttle": 0.6, "steer": -0.25 }` or `{ "x": 0.5, "y": -0.4, "rotation": 0 }`; `max_speed` caps throttle/x/y, `max_turn` steer/rotation |
++| `ptz` | `axes: { pan, tilt, zoom }` → `[min, max]` | only the axes sent: `{ "pan": 0.2 }` |
++| `actuator` | `names: { <name>: { type, … } }` | `{ "name": "<name>", "value": … }` — `number` in its `range`; `rgb` `{r,g,b}` integers 0–255 (plus `index` < `count` when declared) or `null` (off); `tone` `{ "hz": 440 }` inside its `hz` range (an operator may send `{ "note": "A4" }`) or `null` (off); `bool` `true`/`false` |
++| `say` | `max_chars` | `{ "text": "…" }` |
++| `display` | `modes` of `text`, `face`, `image_png_b64`; `faces`; `max_chars` | exactly one of `{ "text" }`, `{ "face": "happy" }`, `{ "image_png_b64": "…" }` (base64, ≤ 48 KiB) |
++| `halt` | — (every profile) | `{}` |
++
++The shipped profiles and the plugin each one matches (`test/profiles.test.js` checks every panel control
++against these):
++
++| profile | device | commands |
++|---|---|---|
++| `adeept.adr036` | Node plugin `adeept_adr036`, ordinary wheels | `drive {throttle, steer}`; `actuator` `pan`/`tilt` −1..1, `buzzer` tone 220–880 Hz, `lights` rgb (8 LEDs) |
++| `adeept.adr036.mecanum` | Node plugin `adeept_adr036`, mecanum wheels | `drive {x, y, rotation}`; the same actuators |
++| `cozmo` | Node plugin `cozmo` | `drive {throttle, steer}`; `actuator` `head` −1..1, `lift` 0..1, `backpack_lights`/`cube_lights` rgb; `say`; `display` text, face (`neutral happy sad surprised sleepy angry`) or image |
++| `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 |
++| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
### Examples
@@@ -72,7 +113,8 @@ The answer (the only time the credential appears over the wire):
```json
{ "v": 1, "seq": 1, "ts": 1738065600000, "type": "paired", "device_id": "dev_01J8Z4…",
-- "credential": "Xb3…", "publish_key": "Vt9…", "robot_ids": ["rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"],
++ "credential": "Xb3…", "publish_key": "Vt9…", "whip_url": "https://ingest.openre.stream/whip/Vt9…",
++ "robot_ids": ["rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"],
"profile_id": "adeept.adr036", "profile": { "id": "adeept.adr036", "limits": { "max_command_ms": 300, "heartbeat_ms": 1000 } } }
```
@@@ -84,7 +126,15 @@ The answer (the only time the credential appears over the wire):
```json
{ "v": 1, "seq": 3, "ts": 1738065600000, "type": "config",
"heartbeat_ms": 1000, "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 },
-- "allowed_commands": ["drive", "actuator", "ptz", "say", "display", "halt"], "estop_latched": false }
++ "allowed_commands": ["drive", "actuator", "halt"], "estop_latched": false }
++```
++
++An actuator command on the Adeept (the pan servo, −1..1):
++
++```json
++{ "v": 1, "seq": 8, "ts": 1738065600000, "type": "command", "id": "cmd_01J8Z4E…", "kind": "actuator",
++ "value": { "name": "pan", "value": -0.4 }, "deadline_ms": 1738065600300,
++ "operator": { "subject": "usr_01J8…", "role": "operator" }, "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X" }
```
A drive command (a held control re-sends every 150 ms with a fresh `id`):
@@@ -150,7 +200,7 @@ The gate then applies the **acted-for subject's** role — a service acting for
| type | fields | notes |
|---|---|---|
--| `joined` | `robot, role, profile, allowed_commands, state` | the answer to `join` |
++| `joined` | `robot, role, profile, allowed_commands, state` | the answer to `join`; `allowed_commands` is the role's allowlist cut to the profile's kinds, with `halt` for every role that may drive |
| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz |
| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
| `error` | `code, detail` | a frame or join the server refused |
@@@ -170,7 +220,7 @@ person's `position` is 1-based (0 while they hold the turn).
"robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private",
"estop": { "latched": false, "by": null, "at": null } },
"role": "operator", "profile": { "id": "adeept.adr036" },
-- "allowed_commands": ["drive", "actuator", "ptz", "say", "display", "halt"],
++ "allowed_commands": ["drive", "actuator", "halt"],
"state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "latency_ms": 63, "battery": 0.72, "queue": null } }
```
@@@ -202,9 +252,12 @@ person's `position` is 1-based (0 while they hold the turn).
{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner clears the e-stop" }
```
--Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.estop_latched`,
--`bot.not_an_operator`, `bot.sign_in`, `bot.read_only`, `bot.command_not_allowed`, `bot.device_offline`,
--`bot.cooldown`, `bot.not_your_turn`, `bot.turn_budget`, `bot.unknown_animation`, `bot.invalid_input`.
++Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.command_not_allowed`
++(the profile takes no such kind), `bot.estop_latched`, `bot.not_an_operator`, `bot.sign_in`,
++`bot.read_only`, `bot.command_not_allowed` (the role's allowlist), `bot.device_offline`, `bot.cooldown`,
++`bot.not_your_turn`, `bot.turn_budget`, `bot.unknown_actuator`, `bot.invalid_input`, `bot.text_too_long`.
++`halt` is never refused for the e-stop, the allowlist, a cooldown or the turn budget; it still needs a
++role that may drive (a queue robot's turn holder, not someone waiting) and an online device.
---
@@@ -220,19 +273,19 @@ shown; a service acts for `X-OV-Subject` / the body's `owner`.
| `GET /robots` | user, or `bot.robot.read` + `?owner=` | `{ robots[] }` |
| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }` |
| `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }` |
--| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }` |
++| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }`; new `limits` re-send `config` to the connected device |
| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
--| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key }` (once) |
++| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once) |
| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
--| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, robot_id, profile }` |
++| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, whip_url, robot_id, profile }` |
Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
`/auth/*` SSO routes, and `GET /` (a one-line text health placeholder).
@@@ -245,4 +298,4 @@ Every command — allowed or refused — is written to `command_audit` (robot, d
principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
reason, latency, time), kept 30 days. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
--results, never a credential, a pairing code or a publish key.
++results, never a credential, a pairing code, a publish key or a WHIP URL.
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 76c67b5..d0b3113 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -107,6 +107,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
r.patch('/robots/:id', manage, wrap(async (req, res) => {
await owner(req, req.params.id);
const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits });
++ if (req.body?.limits !== undefined) await hub.refreshConfig(req.params.id); // the device enforces the new limits at once
res.json({ robot: domain.present.robot(updated) });
}));
r.delete('/robots/:id', manage, wrap(async (req, res) => {
@@@ -148,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
const rotated = await domain.devices.rotate(req.params.id);
-- res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key });
++ res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key, whip_url: rotated.whip_url });
}));
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
const d = await domain.devices.get(req.params.id);
@@@ -195,7 +196,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
device_kind: req.body?.device_kind || 'onboard', drivers: Array.isArray(req.body?.drivers) ? req.body.drivers : [],
capabilities: req.body?.capabilities && typeof req.body.capabilities === 'object' ? req.body.capabilities : {}, name: req.body?.name || null,
});
-- res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
++ res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, whip_url: result.whip_url, robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
}));
return r;
ddiff --git a/server/config.js b/server/config.js
iindex 4bc2f49..37cf299 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -98,6 +98,11 @@ function loadConfig(env = process.env) {
enabled: env.BOT_JOBS !== 'off',
intervalMs: int(env.BOT_JOBS_INTERVAL_MS, 5000),
},
++ // Video: a device publishes its camera to OpenRe's WHIP ingest at <whipBase>/<its publish key>
++ // (`whip_url` in the pairing and rotation answers). Set it empty to pair devices without video.
++ media: {
++ whipBase: trim(env.BOT_WHIP_BASE != null ? env.BOT_WHIP_BASE : 'https://ingest.openre.stream/whip'),
++ },
// The one-line installer the owner copies next to the pairing code (the agent job builds it).
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 15e764e..2fc19d5 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -7,10 +7,12 @@
*
* A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more
* robots, holding one rotatable, revocable credential (32 random bytes, stored hashed, shown once). The
-- * gate applies, in order: the robot exists, the e-stop is clear, the caller's role, the per-role command
-- * allowlist, the access policy (private/invite: owner + operators; queue: the active turn holder too),
-- * the owner's limits (clamped), the per-turn budget and cooldowns, and the device being online (commands
-- * are never queued). Every decision — allowed or refused — is audited.
++ * gate applies, in order: the robot exists, its profile takes the command kind, the e-stop is clear, the
++ * caller's role, the per-role command allowlist, the access policy (private/invite: owner + operators;
++ * queue: the active turn holder too), the device being online (commands are never queued), cooldowns and
++ * the per-turn budget, and finally builds the value from the profile's `commands` (names, shapes, ranges,
++ * clamped by the owner's limits). `halt` passes the e-stop, the allowlist, cooldowns and the budget: a
++ * stop is never refused to someone who may drive. Every decision — allowed or refused — is audited.
*/
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,
@@@ -25,6 +27,14 @@ const DEFAULT_ALLOW = {
};
const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
const MOTION = new Set(['drive', 'actuator', 'ptz']);
++const CONTROL_ROLES = new Set(['owner', 'operator', 'queue']);
++// Drive axes the owner's max_turn caps; every other drive axis is capped by max_speed.
++const TURN_AXES = new Set(['steer', 'rotation']);
++// The older drive names, still read for the axis they stand for.
++const AXIS_ALIASES = { throttle: 'speed', steer: 'turn' };
++// A display image rides a 64 KB control frame, so its base64 stays well under that.
++const MAX_IMAGE_B64 = 48 * 1024;
++const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
// Crockford base32: no I, L, O or U, so a code read aloud cannot be mistyped into another.
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
@@@ -40,6 +50,15 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
++// Scientific pitch: 'A4' is 440 Hz; a sharp (#) or flat (b) after the letter.
++const NOTE_SEMITONES = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
++function noteToHz(note) {
++ const m = /^([A-Ga-g])([#b]?)(-?\d)$/.exec(String(note));
++ if (!m) return null;
++ const semis = NOTE_SEMITONES[m[1].toUpperCase()] + (m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0);
++ return 440 * 2 ** ((12 * (Number(m[3]) + 1) + semis - 69) / 12);
++}
++
function createDomain({ db, config, outbox, link = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
@@@ -195,7 +214,15 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
});
if (result.error) fail(result.error[0], result.error[1], result.error[2]);
const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
-- return { device: result.device, credential: result.credential, publish_key: result.publishKey, profile: profile ? profile.profile : null };
++ return { device: result.device, credential: result.credential, publish_key: result.publishKey, whip_url: whipUrl(result.publishKey), profile: profile ? profile.profile : null };
++ }
++ /**
++ * Where the device publishes its camera: OpenRe's WHIP ingest (`POST <base>/<key>`, RFC 9725) with
++ * this device's publish key as the stream key. It carries the key, so it is shown once, with the key.
++ */
++ function whipUrl(publishKey) {
++ const base = config.media && config.media.whipBase;
++ return base && publishKey ? `${base}/${encodeURIComponent(publishKey)}` : null;
}
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
@@@ -223,7 +250,7 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
`UPDATE devices SET credential_hash = $2, credential_prev_hash = $3, prev_valid_until = $4, publish_key_hash = $5, updated_at = $6
WHERE id = $1 RETURNING *`,
[id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
-- return { device: updated, credential, publish_key: publishKey };
++ return { device: updated, credential, publish_key: publishKey, whip_url: whipUrl(publishKey) };
}
async function revokeDevice(id) {
const d = await getDevice(id);
@@@ -365,15 +392,25 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
}
// ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────
++ /** The owner's limits clamped by the profile's (a profile missing one falls back to Bot's defaults). */
const effectiveLimits = (robot, profile) => {
const rl = json(robot.limits, {});
const pl = (profile && profile.limits) || {};
-- const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, pl.max_speed) : pl.max_speed;
-- const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, pl.max_turn) : pl.max_turn;
-- const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pl.max_command_ms) : pl.max_command_ms;
++ const pSpeed = pl.max_speed != null ? pl.max_speed : 1;
++ const pTurn = pl.max_turn != null ? pl.max_turn : 1;
++ const pMs = pl.max_command_ms != null ? pl.max_command_ms : config.control.maxCommandMs;
++ const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, pSpeed) : pSpeed;
++ const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, pTurn) : pTurn;
++ const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pMs) : pMs;
const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;
return { maxSpeed, maxTurn, maxCommandMs, cooldownMs };
};
++ /** The limits a device enforces itself (the `config` frame): the effective ones, never the profile's alone. */
++ const deviceLimits = (robot, profile) => {
++ const eff = effectiveLimits(robot, profile);
++ const hb = profile && profile.limits && profile.limits.heartbeat_ms != null ? profile.limits.heartbeat_ms : config.device.heartbeatMs;
++ return { max_speed: eff.maxSpeed, max_turn: eff.maxTurn, max_command_ms: eff.maxCommandMs, heartbeat_ms: hb };
++ };
// Cooldown is measured from the last command sent (not from an acknowledgement), per operator and
// kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.
const lastCommandAt = new Map();
@@@ -382,42 +419,101 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
};
-- const allowedFor = (robot, role) => {
++ /** The command kinds the robot's profile takes (`halt` always). */
++ const profileTakes = (profile, kind) => kind === 'halt' || !!(profile && profile.commands && profile.commands[kind]);
++ /**
++ * What `role` may send to this robot: the owner's allowlist for the role (or the default), cut to the
++ * kinds the profile takes. Every role that may drive also gets `halt`, which no allowlist removes.
++ */
++ const allowedFor = (robot, role, profile = null) => {
const rl = json(robot.limits, {});
const fromOwner = rl.allow && Array.isArray(rl.allow[role]) ? rl.allow[role] : null;
-- return fromOwner || DEFAULT_ALLOW[role] || [];
++ const kinds = (fromOwner || DEFAULT_ALLOW[role] || []).filter((k) => k !== 'halt' && profileTakes(profile, k));
++ return CONTROL_ROLES.has(role) ? [...kinds, 'halt'] : kinds;
};
-- /** Clamp a command value to the profile/owner limits; returns the value to send and its audit summary. */
-- function clampValue(kind, value, eff, profile) {
-- const v = value && typeof value === 'object' ? value : {};
++ /** An axis value: the schema's range, cut for drive by the owner's max_speed (or max_turn for a turn axis). */
++ function axisValue(kind, axis, range, raw, eff) {
++ const cap = kind !== 'drive' ? Infinity : TURN_AXES.has(axis) ? eff.maxTurn : eff.maxSpeed;
++ const lo = Math.max(range[0], -cap);
++ const hi = Math.min(range[1], cap);
++ return clampNum(raw, lo, hi, clampNum(0, lo, hi));
++ }
++ function actuatorValue(name, a, x) {
++ if (a.type === 'number') {
++ if (typeof x !== 'number' || !Number.isFinite(x)) fail(422, 'bot.invalid_input', `${name} takes a number`);
++ return clampNum(x, a.range[0], a.range[1]);
++ }
++ if (a.type === 'bool') {
++ if (typeof x !== 'boolean') fail(422, 'bot.invalid_input', `${name} takes true or false`);
++ return x;
++ }
++ if (a.type === 'rgb') {
++ if (x == null || x === false) return null; // off
++ if (!x || typeof x !== 'object' || Array.isArray(x)) fail(422, 'bot.invalid_input', `${name} takes {r,g,b} (0–255) or null`);
++ const out = { r: Math.round(clampNum(x.r, 0, 255)), g: Math.round(clampNum(x.g, 0, 255)), b: Math.round(clampNum(x.b, 0, 255)) };
++ if (x.index != null) {
++ if (!a.count || !Number.isInteger(x.index) || x.index < 0 || x.index >= a.count) fail(422, 'bot.invalid_input', `${name} index must be 0..${(a.count || 1) - 1}`);
++ out.index = x.index;
++ }
++ return out;
++ }
++ // tone: {hz} or {note}, sent as {hz} inside the range; null, 0 or {hz:0} is off.
++ if (!x) return null;
++ if (typeof x !== 'object' || Array.isArray(x)) fail(422, 'bot.invalid_input', `${name} takes {note}, {hz} or null`);
++ const hz = x.note != null ? noteToHz(x.note) : typeof x.hz === 'number' && Number.isFinite(x.hz) ? x.hz : null;
++ if (hz == null) fail(422, 'bot.invalid_input', `${name} takes {note} (like "A4"), {hz} or null`);
++ if (hz === 0) return null;
++ return { hz: Math.round(clampNum(hz, a.hz[0], a.hz[1]) * 100) / 100 };
++ }
++
++ /**
++ * Build the value the device gets from the operator's, by the profile's schema for the kind: only the
++ * declared axes and actuator names, in the declared shapes, clamped to the declared ranges and the
++ * owner's limits. Throws a 422 BotError for a value the device would refuse.
++ */
++ function buildValue(kind, value, eff, profile) {
++ if (kind === 'halt') return {};
++ const spec = profile.commands[kind];
++ const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
if (kind === 'drive') {
-- const out = { throttle: clampNum(v.throttle != null ? v.throttle : v.speed, -eff.maxSpeed, eff.maxSpeed, 0), steer: clampNum(v.steer != null ? v.steer : v.turn, -eff.maxTurn, eff.maxTurn, 0) };
-- if (v.variant) out.variant = String(v.variant).slice(0, 16);
++ // Every declared axis is sent; an absent one is 0, so a drive frame always says the whole motion.
++ const out = {};
++ for (const [axis, range] of Object.entries(spec.axes)) out[axis] = axisValue(kind, axis, range, v[axis] != null ? v[axis] : v[AXIS_ALIASES[axis]], eff);
++ return out;
++ }
++ if (kind === 'ptz') {
++ // Only the axes given move; the others stay where they are.
++ const out = {};
++ for (const [axis, range] of Object.entries(spec.axes)) if (v[axis] != null) out[axis] = axisValue(kind, axis, range, v[axis], eff);
++ if (!Object.keys(out).length) fail(422, 'bot.invalid_input', `ptz needs one of ${Object.keys(spec.axes).join(', ')}`);
return out;
}
if (kind === 'actuator') {
-- const servo = String(v.servo || '').slice(0, 24);
-- return { servo, angle: clampNum(v.angle, -1, 1, 0) };
++ const name = typeof v.name === 'string' ? v.name : '';
++ if (!Object.prototype.hasOwnProperty.call(spec.names, name)) fail(422, 'bot.unknown_actuator', `this robot has no actuator ${name.slice(0, 24) || '(unnamed)'}`);
++ return { name, value: actuatorValue(name, spec.names[name], v.value) };
}
-- if (kind === 'ptz') return { pan: clampNum(v.pan, -1, 1, 0), tilt: clampNum(v.tilt, -1, 1, 0), zoom: clampNum(v.zoom, -1, 1, 0) };
if (kind === 'say') {
-- const t = text(v.text, 'text', 200);
++ const t = text(v.text, 'text', spec.max_chars);
if (!t) fail(422, 'bot.invalid_input', 'say needs text');
return { text: t };
}
-- if (kind === 'display') {
-- if (v.animation != null) {
-- const anims = profile && profile.mapping && Array.isArray(profile.mapping.animations) ? profile.mapping.animations : null;
-- const anim = storable(v.animation).slice(0, 64);
-- if (anims && !anims.includes(anim)) fail(422, 'bot.unknown_animation', 'that animation is not in the profile');
-- return { animation: anim };
-- }
-- const t = text(v.text, 'text', 80);
-- if (!t) fail(422, 'bot.invalid_input', 'display needs text or animation');
-- return { text: t };
++ // display: one of the declared modes, in the plugin's order of preference.
++ const modes = new Set(spec.modes);
++ if (modes.has('image_png_b64') && v.image_png_b64 != null) {
++ const img = String(v.image_png_b64);
++ if (!img || img.length > MAX_IMAGE_B64 || !BASE64_RE.test(img)) fail(422, 'bot.invalid_input', `image_png_b64 must be base64, at most ${MAX_IMAGE_B64} characters`);
++ return { image_png_b64: img };
}
-- return {};
++ if (modes.has('face') && v.face != null) {
++ const face = String(v.face).toLowerCase();
++ if (!spec.faces.includes(face)) fail(422, 'bot.invalid_input', `face must be one of ${spec.faces.join(', ')}`);
++ return { face };
++ }
++ const t = modes.has('text') ? text(v.text, 'text', spec.max_chars) : null;
++ if (!t) fail(422, 'bot.invalid_input', `display needs ${spec.modes.join(' or ')}`);
++ return { text: t };
}
/**
@@@ -430,7 +526,9 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
const profileRow = await getProfile(db, robot.profile_id, robot.profile_version);
const profile = profileRow ? profileRow.profile : null;
if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };
-- if (robot.estop_latched) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; only the owner can clear it', robot };
++ if (!profileTakes(profile, kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `this robot takes no ${kind} commands`, robot };
++ const halt = kind === 'halt';
++ if (robot.estop_latched && !halt) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; only the owner can clear it', robot };
const subject = principal && principal.kind !== 'device' ? principal.subject : null;
let role = await roleOf(robotId, subject);
@@@ -445,28 +543,35 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
if (robot.access_policy === 'queue') role = 'queue';
else return { ok: false, code: 'bot.read_only', reason: 'you are a viewer on this robot', robot, role };
}
-- const allow = allowedFor(robot, role);
++ const allow = allowedFor(robot, role, profile);
if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };
const eff = effectiveLimits(robot, profile);
-- if (eff.cooldownMs > 0 && role !== 'owner') {
++ const cooled = eff.cooldownMs > 0 && role !== 'owner' && !halt;
++ if (cooled) {
const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);
if (last && now() - last < eff.cooldownMs) {
return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
}
}
if (role === 'queue') {
-- const budget = await consumeTurn(robotId, subject);
-- if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : 'it is not your turn', robot, role };
++ // A halt needs the turn (a waiting person may not stop the driver) but spends none of its budget.
++ if (halt) {
++ const turn = await currentTurn(robotId);
++ if (!turn || turn.subject !== subject) return { ok: false, code: 'bot.not_your_turn', reason: 'it is not your turn', robot, role };
++ } else {
++ const budget = await consumeTurn(robotId, subject);
++ if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : 'it is not your turn', robot, role };
++ }
}
-- let clamped;
-- try { clamped = clampValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
-- if (eff.cooldownMs > 0 && role !== 'owner') markCooldown(robotId, subject, kind);
++ let built;
++ try { built = buildValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
++ if (cooled) markCooldown(robotId, subject, kind);
const deadlineMs = MOTION.has(kind)
? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
: null;
-- return { ok: true, robot, profile, role, kind, value: clamped, deadlineMs, limits: eff };
++ return { ok: true, robot, profile, role, kind, value: built, deadlineMs, limits: eff };
}
return {
@@@ -474,13 +579,13 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
present: { robot: presentRobot, device: presentDevice },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
-- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand },
++ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },
devices: { byCredential, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
-- control: { prepare, allowedFor, effectiveLimits, DEFAULT_ALLOW, KINDS },
++ control: { prepare, allowedFor, effectiveLimits, deviceLimits, buildValue, DEFAULT_ALLOW, KINDS },
};
}
--module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape };
++module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };
ddiff --git a/server/profiles/adeept.adr036.json b/server/profiles/adeept.adr036.json
iindex d75b71c..0088dcb 100644
---- a/server/profiles/adeept.adr036.json
++++ b/server/profiles/adeept.adr036.json
@@@ -4,10 +4,9 @@
"name": "Adeept ADR036 4WD Smart Car",
"vendor": "Adeept",
"kind": "onboard",
-- "description": "Raspberry Pi rover with the Adeept Robot HAT: four DC motors and the pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. Ships as both a differential and a mecanum chassis.",
++ "description": "Raspberry Pi rover with the Adeept Robot HAT: four DC motors (ordinary wheels) and the pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. The mecanum chassis is adeept.adr036.mecanum.",
"capabilities": [
"drive.differential",
-- "drive.mecanum",
"servo.pan_tilt",
"lights.rgb",
"speaker.horn",
@@@ -15,12 +14,10 @@
"sensor.line",
"battery"
],
-- "variants": {
-- "differential": { "drive": "differential" },
-- "mecanum": { "drive": "mecanum" }
-- },
"mapping": {
"driver": "pca9685",
++ "plugin": "adeept_adr036",
++ "wheels": "ordinary",
"i2c_bus": 1,
"address": "0x5f",
"motor_channels": [8, 9, 10, 11, 12, 13, 14, 15],
@@@ -29,11 +26,22 @@
"lights": "ws2812",
"horn": "buzzer"
},
++ "commands": {
++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
++ "actuator": {
++ "names": {
++ "pan": { "type": "number", "range": [-1, 1] },
++ "tilt": { "type": "number", "range": [-1, 1] },
++ "buzzer": { "type": "tone", "hz": [220, 880] },
++ "lights": { "type": "rgb", "count": 8 }
++ }
++ }
++ },
"widgets": [
-- { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
-- { "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2 },
-- { "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3 },
-- { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4 },
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
++ { "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
++ { "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
++ { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
{ "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "latency", "label": "Latency", "order": 7 }
ddiff --git a/server/profiles/adeept.adr036.mecanum.json b/server/profiles/adeept.adr036.mecanum.json
nnew file mode 100644
iindex 0000000..7351bb2
---- /dev/null
++++ b/server/profiles/adeept.adr036.mecanum.json
@@@ -0,0 +1,51 @@
++{
++ "id": "adeept.adr036.mecanum",
++ "version": 1,
++ "name": "Adeept ADR036 4WD Smart Car (mecanum)",
++ "vendor": "Adeept",
++ "kind": "onboard",
++ "description": "The Adeept ADR036 on mecanum wheels: it drives forward/back (x), strafes left/right (y) and rotates in place, plus the same pan/tilt servos, WS2812 lights, buzzer, ultrasound, line tracking and ADS7830 battery readout as adeept.adr036.",
++ "capabilities": [
++ "drive.mecanum",
++ "servo.pan_tilt",
++ "lights.rgb",
++ "speaker.horn",
++ "sensor.ultrasonic",
++ "sensor.line",
++ "battery"
++ ],
++ "mapping": {
++ "driver": "pca9685",
++ "plugin": "adeept_adr036",
++ "wheels": "mecanum",
++ "i2c_bus": 1,
++ "address": "0x5f",
++ "motor_channels": [8, 9, 10, 11, 12, 13, 14, 15],
++ "servo_channels": { "pan": 0, "tilt": 1 },
++ "battery": { "driver": "ads7830", "address": "0x48" },
++ "lights": "ws2812",
++ "horn": "buzzer"
++ },
++ "commands": {
++ "drive": { "axes": { "x": [-1, 1], "y": [-1, 1], "rotation": [-1, 1] } },
++ "actuator": {
++ "names": {
++ "pan": { "type": "number", "range": [-1, 1] },
++ "tilt": { "type": "number", "range": [-1, 1] },
++ "buzzer": { "type": "tone", "hz": [220, 880] },
++ "lights": { "type": "rgb", "count": 8 }
++ }
++ }
++ },
++ "widgets": [
++ { "type": "drive", "capability": "drive.mecanum", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
++ { "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2, "command": { "kind": "actuator", "names": ["pan", "tilt"] } },
++ { "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3, "command": { "kind": "actuator", "names": ["lights"] } },
++ { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4, "command": { "kind": "actuator", "names": ["buzzer"] } },
++ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
++ { "type": "latency", "label": "Latency", "order": 7 }
++ ],
++ "camera": { "transport": "whip", "resolution": "640x480" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/profiles/camera.onvif.json b/server/profiles/camera.onvif.json
iindex b39a7da..111ec1c 100644
---- a/server/profiles/camera.onvif.json
++++ b/server/profiles/camera.onvif.json
@@@ -10,8 +10,11 @@
"driver": "onvif",
"credentials": "secret-ref"
},
++ "commands": {
++ "ptz": { "axes": { "pan": [-1, 1], "tilt": [-1, 1], "zoom": [-1, 1] } }
++ },
"widgets": [
-- { "type": "ptz", "capability": "ptz", "label": "Pan / tilt / zoom", "order": 1 },
++ { "type": "ptz", "capability": "ptz", "label": "Pan / tilt / zoom", "order": 1, "command": { "kind": "ptz" } },
{ "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },
{ "type": "latency", "label": "Latency", "order": 3 }
],
ddiff --git a/server/profiles/cozmo.json b/server/profiles/cozmo.json
iindex 1702bdf..b4f062b 100644
---- a/server/profiles/cozmo.json
++++ b/server/profiles/cozmo.json
@@@ -4,14 +4,13 @@
"name": "Cozmo",
"vendor": "Anki",
"kind": "bridge",
-- "description": "Anki Cozmo over PyCozmo: differential drive, head and lift, speech and face display, backpack and cube lights, cliff and pick-up sensors, a 320x240 camera and the stock animation set. A bridge computer joins the robot's Wi-Fi on one interface and OpenVibe on another.",
++ "description": "Anki Cozmo over PyCozmo: differential drive, head and lift, speech, a face display (text, eyes or a picture), backpack and cube lights, cliff and pick-up sensors and a 320x240 camera. A bridge computer joins the robot's Wi-Fi on one interface and OpenVibe on another.",
"capabilities": [
"drive.differential",
"head",
"lift",
"speaker.say",
"display.text",
-- "display.animation",
"lights.backpack",
"lights.cube",
"battery",
@@@ -21,25 +20,28 @@
],
"mapping": {
"driver": "cozmo",
-- "animations": [
-- "anim_freeplay_reacttoface_happy_01",
-- "anim_freeplay_reacttoface_sad_01",
-- "anim_eyepose_happy",
-- "anim_eyepose_sad",
-- "anim_eyepose_angry",
-- "anim_eyepose_excited",
-- "anim_blackjack_win",
-- "anim_bored_01",
-- "anim_fistbump_success_01"
-- ]
++ "plugin": "cozmo"
++ },
++ "commands": {
++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } },
++ "actuator": {
++ "names": {
++ "head": { "type": "number", "range": [-1, 1] },
++ "lift": { "type": "number", "range": [0, 1] },
++ "backpack_lights": { "type": "rgb" },
++ "cube_lights": { "type": "rgb" }
++ }
++ },
++ "say": { "max_chars": 200 },
++ "display": { "modes": ["text", "face", "image_png_b64"], "faces": ["neutral", "happy", "sad", "surprised", "sleepy", "angry"], "max_chars": 200 }
},
"widgets": [
-- { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
-- { "type": "head", "capability": "head", "label": "Head", "order": 2 },
-- { "type": "lift", "capability": "lift", "label": "Lift", "order": 3 },
-- { "type": "speaker", "capability": "speaker.say", "label": "Say", "order": 4 },
-- { "type": "display", "capability": "display.text", "label": "Face", "order": 5 },
-- { "type": "lights", "capability": "lights.backpack", "label": "Lights", "order": 6 },
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
++ { "type": "head", "capability": "head", "label": "Head", "order": 2, "command": { "kind": "actuator", "names": ["head"] } },
++ { "type": "lift", "capability": "lift", "label": "Lift", "order": 3, "command": { "kind": "actuator", "names": ["lift"] } },
++ { "type": "speaker", "capability": "speaker.say", "label": "Say", "order": 4, "command": { "kind": "say" } },
++ { "type": "display", "capability": "display.text", "label": "Face", "order": 5, "command": { "kind": "display" } },
++ { "type": "lights", "capability": "lights.backpack", "label": "Lights", "order": 6, "command": { "kind": "actuator", "names": ["backpack_lights", "cube_lights"] } },
{ "type": "telemetry", "capability": "sensor.cliff", "label": "Cliff / pick-up", "order": 7 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 8 },
{ "type": "latency", "label": "Latency", "order": 9 }
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 87e210d..99b7f6e 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -13,9 +13,15 @@
* capabilities: ["drive.differential", …], // in CAPABILITIES
* variants: { differential: { drive: … }, … }, // optional: one profile, several wheel layouts
* mapping: { driver: "pca9685", … }, // driver + the hardware mapping (addresses, channels)
-- * widgets: [{ type: "drive", capability: …, label: … }], // types in WIDGETS
++ * commands: { drive: { axes: { throttle: [-1, 1], … } }, … }, // what the device accepts (COMMAND_SCHEMAS)
++ * widgets: [{ type: "drive", capability: …, label: …, command: { kind: "drive" } }], // types in WIDGETS
* camera: { transport: "whip|onvif|rtsp", resolution },
* limits: { max_speed, max_turn, max_command_ms, heartbeat_ms } }
++ *
++ * `commands` is the contract with the device's plugin: the gate allows only the kinds declared there
++ * (plus `halt`, which every profile takes) and builds every value from it, so a panel control can only
++ * send a name, shape and range the plugin accepts. A widget that sends commands names them in `command`
++ * ({ kind } or, for actuators, { kind: "actuator", names: [...] }), checked against `commands` at load.
*/
const fs = require('fs');
const path = require('path');
@@@ -29,7 +35,7 @@ const CAPABILITIES = new Set([
'servo.pan_tilt', 'head', 'lift',
'lights.rgb', 'lights.backpack', 'lights.cube',
'speaker.horn', 'speaker.say',
-- 'display.text', 'display.animation',
++ 'display.text',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'battery', 'ptz', 'camera',
]);
@@@ -44,9 +50,105 @@ const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const int = (v) => (Number.isInteger(v) ? v : null);
++const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++const isRange = (r) => Array.isArray(r) && r.length === 2 && number(r[0]) != null && number(r[1]) != null && r[0] < r[1];
function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
++/**
++ * The per-kind schema of `commands` (what each kind's value may be):
++ * drive { axes: { <axis>: [min, max] } } axes: throttle, steer (differential) or x, y, rotation (holonomic)
++ * ptz { axes: { <axis>: [min, max] } } axes: pan, tilt, zoom
++ * actuator { names: { <name>: { type, … } } } number { range: [min, max] }, rgb { count? } ({r,g,b} 0–255,
++ * `index` < count when given), tone { hz: [min, max] }, bool
++ * say { max_chars }
++ * display { modes: [text|face|image_png_b64], faces: [...] (with face), max_chars }
++ * halt {} every profile takes it; the gate never refuses it for the e-stop
++ */
++const AXES = { drive: new Set(['throttle', 'steer', 'x', 'y', 'rotation']), ptz: new Set(['pan', 'tilt', 'zoom']) };
++const ACTUATOR_TYPES = new Set(['number', 'rgb', 'tone', 'bool']);
++const DISPLAY_MODES = new Set(['text', 'face', 'image_png_b64']);
++const COMMAND_KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
++const NAME_RE = /^[a-z][a-z0-9_]{0,23}$/;
++
++function validateAxes(where, axes, allowed) {
++ if (!isObject(axes) || !Object.keys(axes).length) bad(`${where}.axes must name at least one axis`);
++ const out = {};
++ for (const [axis, range] of Object.entries(axes)) {
++ if (!allowed.has(axis)) bad(`${where}: unknown axis ${axis} (one of ${[...allowed].join(', ')})`);
++ if (!isRange(range)) bad(`${where}.axes.${axis} must be [min, max]`);
++ out[axis] = [range[0], range[1]];
++ }
++ return { axes: out };
++}
++
++function validateActuator(where, a) {
++ if (!isObject(a) || !ACTUATOR_TYPES.has(a.type)) bad(`${where}.type must be one of ${[...ACTUATOR_TYPES].join(', ')}`);
++ if (a.type === 'number') {
++ if (!isRange(a.range)) bad(`${where}.range must be [min, max]`);
++ return { type: 'number', range: [a.range[0], a.range[1]] };
++ }
++ if (a.type === 'rgb') {
++ if (a.count != null && !(int(a.count) > 0)) bad(`${where}.count must be a positive integer`);
++ return a.count != null ? { type: 'rgb', count: a.count } : { type: 'rgb' };
++ }
++ if (a.type === 'tone') {
++ if (!isRange(a.hz) || !(a.hz[0] > 0)) bad(`${where}.hz must be [min, max] above 0`);
++ return { type: 'tone', hz: [a.hz[0], a.hz[1]] };
++ }
++ return { type: 'bool' };
++}
++
++/** `commands`, checked kind by kind against the schema above; `halt` is always added. */
++function validateCommands(id, commands) {
++ if (commands != null && !isObject(commands)) bad(`${id}: commands must be an object keyed by command kind`);
++ const out = {};
++ for (const [kind, spec] of Object.entries(commands || {})) {
++ const where = `${id}: commands.${kind}`;
++ if (!COMMAND_KINDS.has(kind)) bad(`${id}: unknown command kind ${kind}`);
++ if (!isObject(spec)) bad(`${where} must be an object`);
++ if (kind === 'drive' || kind === 'ptz') out[kind] = validateAxes(where, spec.axes, AXES[kind]);
++ else if (kind === 'actuator') {
++ if (!isObject(spec.names) || !Object.keys(spec.names).length) bad(`${where}.names must name at least one actuator`);
++ const names = {};
++ for (const [name, a] of Object.entries(spec.names)) {
++ if (!NAME_RE.test(name)) bad(`${where}: actuator name ${name} is not valid`);
++ names[name] = validateActuator(`${where}.names.${name}`, a);
++ }
++ out.actuator = { names };
++ } else if (kind === 'say') {
++ if (spec.max_chars != null && !(int(spec.max_chars) > 0)) bad(`${where}.max_chars must be a positive integer`);
++ out.say = { max_chars: spec.max_chars || 200 };
++ } else if (kind === 'display') {
++ if (!Array.isArray(spec.modes) || !spec.modes.length) bad(`${where}.modes must be a non-empty array`);
++ for (const m of spec.modes) if (!DISPLAY_MODES.has(m)) bad(`${where}: unknown display mode ${m} (one of ${[...DISPLAY_MODES].join(', ')})`);
++ if (spec.max_chars != null && !(int(spec.max_chars) > 0)) bad(`${where}.max_chars must be a positive integer`);
++ const d = { modes: [...new Set(spec.modes)], max_chars: spec.max_chars || 200 };
++ if (d.modes.includes('face')) {
++ if (!Array.isArray(spec.faces) || !spec.faces.length || !spec.faces.every((f) => typeof f === 'string' && NAME_RE.test(f))) bad(`${where}.faces must list the face names`);
++ d.faces = spec.faces.slice();
++ }
++ out.display = d;
++ } else if (Object.keys(spec).length) bad(`${where} takes no options`);
++ }
++ out.halt = {};
++ return out;
++}
++
++/** A widget's `command`: a declared kind, and for an actuator the declared names it drives. */
++function validateWidgetCommand(id, w, commands) {
++ const c = w.command;
++ const where = `${id}: widget ${w.type}`;
++ if (!isObject(c) || !commands[c.kind]) bad(`${where} sends ${c && c.kind}, which commands does not declare`);
++ if (c.kind !== 'actuator') {
++ if (c.names != null) bad(`${where}: only an actuator command names actuators`);
++ return { kind: c.kind };
++ }
++ if (!Array.isArray(c.names) || !c.names.length) bad(`${where}: an actuator command lists its names`);
++ for (const n of c.names) if (!Object.prototype.hasOwnProperty.call(commands.actuator.names, n)) bad(`${where} drives actuator ${n}, which commands does not declare`);
++ return { kind: 'actuator', names: c.names.slice() };
++}
++
/** Validate one profile, filling the limit defaults (max_command_ms 300, heartbeat_ms 1000). */
function validateProfile(p) {
if (!p || typeof p !== 'object') bad('a profile must be an object');
@@@ -56,12 +158,14 @@ function validateProfile(p) {
if (p.kind != null && !KINDS.has(p.kind)) bad(`${p.id}: kind must be one of onboard, bridge, server`);
if (!Array.isArray(p.capabilities) || !p.capabilities.length) bad(`${p.id}: capabilities must be a non-empty array`);
for (const c of p.capabilities) if (!CAPABILITIES.has(c)) bad(`${p.id}: unknown capability ${c}`);
++ const commands = validateCommands(p.id, p.commands);
if (!Array.isArray(p.widgets) || !p.widgets.length) bad(`${p.id}: widgets must be a non-empty array`);
-- for (const w of p.widgets) {
++ const widgets = p.widgets.map((w) => {
if (!w || typeof w.type !== 'string' || !WIDGETS.has(w.type)) bad(`${p.id}: unknown widget type ${w && w.type}`);
if (w.capability != null && !CAPABILITIES.has(w.capability)) bad(`${p.id}: widget ${w.type} binds unknown capability ${w.capability}`);
if (w.label != null && (typeof w.label !== 'string' || w.label.length > 60)) bad(`${p.id}: widget ${w.type} label is invalid`);
-- }
++ return w.command != null ? { ...w, command: validateWidgetCommand(p.id, w, commands) } : { ...w };
++ });
if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const limits = {
@@@ -75,7 +179,7 @@ function validateProfile(p) {
return {
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
-- mapping: p.mapping, widgets: p.widgets.slice(), camera: p.camera || null, limits,
++ mapping: p.mapping, commands, widgets, camera: p.camera || null, limits,
};
}
@@@ -119,4 +223,4 @@ async function listProfiles(db) {
return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles ORDER BY id, version DESC');
}
--module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS };
++module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS, COMMAND_KINDS };
ddiff --git a/server/profiles/sim.rover.json b/server/profiles/sim.rover.json
iindex 36e7e1b..0897bd4 100644
---- a/server/profiles/sim.rover.json
++++ b/server/profiles/sim.rover.json
@@@ -7,8 +7,11 @@
"description": "The browser simulator on the openvibe.bot home: a differential-drive rover with a camera, driven by the same gate and profile as real hardware.",
"capabilities": ["drive.differential", "camera"],
"mapping": { "driver": "sim" },
++ "commands": {
++ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } }
++ },
"widgets": [
-- { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1, "command": { "kind": "drive" } },
{ "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },
{ "type": "latency", "label": "Latency", "order": 3 }
],
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 4e862ea..bc20b0b 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -101,17 +101,25 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
server_time: iso(now()),
});
}
++ /** `config`: the effective limits (the owner's clamped by the profile's) and the kinds the profile takes. */
async function sendConfig(conn) {
const robotId = deviceRobotIds(conn.device)[0];
const robot = robotId ? await domain.robots.get(robotId) : null;
const profileRow = robot ? await getProfile(domain.db, robot.profile_id, robot.profile_version) : null;
-- const limits = profileRow ? profileRow.profile.limits : { max_command_ms: config.control.maxCommandMs, heartbeat_ms: config.device.heartbeatMs };
-- const allowed = robot ? domain.control.allowedFor(robot, 'owner') : ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'];
++ const profile = profileRow ? profileRow.profile : null;
++ const limits = robot ? domain.control.deviceLimits(robot, profile) : { max_command_ms: config.control.maxCommandMs, heartbeat_ms: config.device.heartbeatMs };
++ const allowed = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
estop_latched: robot ? !!robot.estop_latched : false,
});
}
++ /** Re-send `config` to the robot's connected devices (the owner changed its limits). */
++ async function refreshConfig(robotId) {
++ for (const conn of [...deviceConns.values()]) {
++ if (conn.online && deviceRobotIds(conn.device).includes(robotId)) await sendConfig(conn);
++ }
++ }
async function bringOnline(conn) {
await domain.devices.setOnline(conn.device, true).catch((e) => log.warn(`[Bot] online event: ${e.message}`));
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
@@@ -156,7 +164,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
attachDevice(conn, r.device);
sendFrame(conn, 'paired', {
-- device_id: r.device.id, credential: r.credential, publish_key: r.publish_key,
++ device_id: r.device.id, credential: r.credential, publish_key: r.publish_key, whip_url: r.whip_url,
robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
});
sendHello(conn);
@@@ -250,10 +258,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
conn.robotId = robotId; conn.role = role;
subscribe(conn);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
++ const profile = profileRow ? profileRow.profile : null;
sendFrame(conn, 'joined', {
-- robot: domain.present.robot(robot), role,
-- profile: profileRow ? profileRow.profile : null,
-- allowed_commands: domain.control.allowedFor(robot, role === 'viewer' ? 'viewer' : role),
++ robot: domain.present.robot(robot), role, profile,
++ allowed_commands: domain.control.allowedFor(robot, role, profile),
state: await robotState(robotId, conn.subject),
});
}
@@@ -382,7 +390,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return {
bindDomain(d) { domain = d; },
handleUpgrade,
-- isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState,
++ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig,
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online) n++; return n; },
devices() { return [...deviceConns.values()].map((c) => ({ device_id: c.device.id, online: c.online })); },
async close() {
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 0180829..59b94d9 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -28,7 +28,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', 'camera.onvif', 'cozmo', 'sim.rover']);
++ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', '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/control.test.js b/test/control.test.js
iindex a596377..0593333 100644
---- a/test/control.test.js
++++ b/test/control.test.js
@@@ -91,6 +91,69 @@ const { boot, check, done } = require('./helpers/app');
a.close(); b.close();
});
++ await check('halt passes the gate while the e-stop is latched; every other kind is refused', async () => {
++ const { robot, pairing } = await t.robot(alex, { limits: { cooldown_ms: 60000, allow: { operator: ['drive'] } } });
++ await invite(robot.id, bob.subject, 'operator');
++ const dev = await deviceFor(robot.id, pairing.code);
++ const b = await op(bob);
++ const j = await joinResult(b, robot.id);
++ assert.deepStrictEqual(j.allowed_commands, ['drive', 'halt'], 'no allowlist removes halt');
++ b.send({ type: 'estop' });
++ assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');
++ cmd(b, { id: 'h0', kind: 'drive', value: { throttle: 0.2 } });
++ assert.strictEqual((await result(b, 'h0')).code, 'bot.estop_latched');
++ cmd(b, { id: 'h1', kind: 'halt' });
++ const h1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'h1');
++ assert.strictEqual(h1.kind, 'halt');
++ assert.deepStrictEqual(h1.value, {});
++ assert.strictEqual(h1.deadline_ms, null, 'halt is not a motion with a deadline');
++ dev.send({ type: 'ack', id: h1.id });
++ assert.strictEqual((await result(b, 'h1')).result, 'ack');
++ cmd(b, { id: 'h2', kind: 'halt' });
++ const h2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'h2');
++ assert.ok(h2, 'halt is never cooled down');
++ const audit = await t.domain.audit.list(robot.id, { limit: 5 });
++ assert.ok(audit.some((x) => x.kind === 'halt' && x.result === 'ack'), 'halt is audited like any command');
++ b.close();
++ });
++
++ await check('the profile decides the kinds and shapes: a mecanum drive keeps x/y/rotation, an actuator is {name, value}', async () => {
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'adeept.adr036.mecanum', limits: { max_turn: 0.5 } });
++ const dev = await deviceFor(robot.id, pairing.code);
++ const a = await op(alex);
++ const j = await joinResult(a, robot.id);
++ assert.deepStrictEqual(j.allowed_commands, ['drive', 'actuator', 'halt']);
++ cmd(a, { id: 'm1', kind: 'drive', value: { x: 0.5, y: -0.4, rotation: 2 } });
++ const m1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'm1');
++ assert.deepStrictEqual(m1.value, { x: 0.5, y: -0.4, rotation: 0.5 });
++ cmd(a, { id: 'm2', kind: 'actuator', value: { name: 'lights', value: { r: 300, g: 0, b: 7 } } });
++ const m2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'm2');
++ assert.deepStrictEqual(m2.value, { name: 'lights', value: { r: 255, g: 0, b: 7 } });
++ cmd(a, { id: 'm3', kind: 'actuator', value: { name: 'horn' } });
++ assert.strictEqual((await result(a, 'm3')).code, 'bot.unknown_actuator');
++ cmd(a, { id: 'm4', kind: 'say', value: { text: 'hi' } });
++ const m4 = await result(a, 'm4');
++ assert.strictEqual(m4.code, 'bot.command_not_allowed', 'the adeept plugin has no speech');
++ a.close();
++ });
++
++ await check('config carries the effective limits and is re-sent to the device when the owner changes them', async () => {
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'cozmo', limits: { max_speed: 0.9, max_turn: 0.5 } });
++ const dev = await deviceFor(robot.id, pairing.code);
++ const first = await dev.waitFor((m) => m.type === 'config');
++ assert.deepStrictEqual(first.limits, { max_speed: 0.6, max_turn: 0.5, max_command_ms: 300, heartbeat_ms: 1000 }, 'the owner\'s clamped by cozmo\'s 0.6');
++ assert.deepStrictEqual(first.allowed_commands, ['drive', 'actuator', 'say', 'display', 'halt'], 'cozmo takes no ptz');
++ const r = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: alex, body: { limits: { max_speed: 0.3, max_turn: 0.2, max_command_ms: 200, allow: { owner: ['drive', 'say'] } } } });
++ assert.strictEqual(r.status, 200, r.text);
++ const second = await dev.waitFor((m) => m.type === 'config' && m.seq > first.seq);
++ assert.deepStrictEqual(second.limits, { max_speed: 0.3, max_turn: 0.2, max_command_ms: 200, heartbeat_ms: 1000 });
++ assert.deepStrictEqual(second.allowed_commands, ['drive', 'say', 'halt']);
++ const renamed = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: alex, body: { name: 'Cozmo II' } });
++ assert.strictEqual(renamed.status, 200);
++ await t.wait(100);
++ assert.strictEqual(dev.messages.filter((m) => m.type === 'config').length, 2, 'a rename does not re-send config');
++ });
++
await check('a queue robot gives a turn that expires and passes to the next waiting person', async () => {
const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 300, turn_budget: 5 } });
const dev = await deviceFor(robot.id, pairing.code);
ddiff --git a/test/helpers/plugins.js b/test/helpers/plugins.js
nnew file mode 100644
iindex 0000000..0315665
---- /dev/null
++++ b/test/helpers/plugins.js
@@@ -0,0 +1,159 @@
++'use strict';
++/**
++ * What each device plugin accepts, transcribed from OpenVibe.Node (2479ef1) so Bot's profiles are tested
++ * against the device side. A value passes only when the plugin takes it as sent and inside its ranges —
++ * never by leaning on the plugin's own clamp. accepts(kind, value) returns null, or why it would be refused.
++ *
++ * ADEEPT = plugins/adeept_adr036/openvibe_adeept_adr036/__init__.py
++ * COZMO = plugins/cozmo/openvibe_cozmo/__init__.py
++ * SDK = plugins/sdk/openvibe_plugin/__init__.py
++ *
++ * camera.onvif and sim.rover are `kind: "server"` profiles with no Node plugin; their contract is the
++ * value table in docs/protocol.md.
++ */
++const num = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
++const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
++const only = (v, keys) => Object.keys(v).every((k) => keys.includes(k));
++const byte = (v) => Number.isInteger(v) && v >= 0 && v <= 255;
++
++// SDK:256 — the runtime answers halt itself (safe_stop) before any plugin sees it.
++const halt = (v) => (v == null || (isObj(v) && !Object.keys(v).length) ? null : 'halt carries no value');
++
++// ADEEPT:132 note_to_hz.
++const SEMI = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
++function noteHz(note) {
++ const m = /^([A-G])([#b]?)(-?\d+)$/.exec(String(note));
++ if (!m) return null;
++ return 440 * 2 ** ((12 * (Number(m[3]) + 1) + SEMI[m[1]] + (m[2] === '#' ? 1 : m[2] === 'b' ? -1 : 0) - 69) / 12);
++}
++
++/** ADEEPT:150 ADR036 with `wheels` ordinary|mecanum and the default config (buzzer octaves 1, 8 LEDs: ADEEPT:53–54). */
++function adeept(wheels, { octaves = 1, leds = 8 } = {}) {
++ const axes = wheels === 'mecanum' ? ['x', 'y', 'rotation'] : ['throttle', 'steer'];
++ return {
++ name: `adeept_adr036 (${wheels})`,
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (!isObj(v)) return `${kind} value must be an object`;
++ if (kind === 'drive') {
++ // ADEEPT:294 — mecanum reads x/y/rotation when one is present (else converts throttle/steer and
++ // loses strafe); ordinary reads throttle/steer (else x/rotation and ignores y).
++ if (!axes.some((a) => a in v)) return `a ${wheels} drive carries ${axes.join('/')}`;
++ if (!only(v, axes)) return `the ${wheels} drive ignores ${Object.keys(v).filter((k) => !axes.includes(k))}`;
++ return axes.every((a) => !(a in v) || num(v[a], -1, 1)) ? null : 'drive axes are -1..1';
++ }
++ if (kind === 'ptz') {
++ // ADEEPT:274
++ if (!('pan' in v) && !('tilt' in v)) return 'ptz needs pan and/or tilt';
++ return only(v, ['pan', 'tilt']) && ['pan', 'tilt'].every((a) => !(a in v) || num(v[a], -1, 1)) ? null : 'ptz is pan/tilt -1..1';
++ }
++ if (kind !== 'actuator') return `kind ${kind} not supported`; // ADEEPT:291
++ if (!only(v, ['name', 'value'])) return 'an actuator is {name, value}';
++ const { name, value } = v;
++ if (name === 'pan' || name === 'tilt') return num(value, -1, 1) ? null : `${name} is -1..1`; // ADEEPT:90, 313
++ if (name === 'buzzer') {
++ // ADEEPT:317 — falsy is off; {note} or {hz} inside A4 ± octaves (ADEEPT:336).
++ if (!value) return null;
++ if (!isObj(value)) return 'buzzer value is {note}, {hz} or null';
++ const hz = 'note' in value ? noteHz(value.note) : 'hz' in value ? value.hz : null;
++ if (hz == null) return 'buzzer value is {note}, {hz} or null';
++ if (hz === 0) return null;
++ const lo = 440 / 2 ** octaves; const hi = 440 * 2 ** octaves;
++ return num(hz, lo - 0.01, hi + 0.01) ? null : `tone ${hz} Hz outside ${lo}..${hi}`;
++ }
++ if (name === 'lights') {
++ // ADEEPT:347 — falsy is off; {r,g,b} 0..255 and an optional LED index.
++ if (!value) return null;
++ if (!isObj(value) || !only(value, ['r', 'g', 'b', 'index'])) return 'lights value is {r,g,b[,index]} or null';
++ if (!['r', 'g', 'b'].every((c) => byte(value[c]))) return 'r, g, b are integers 0..255';
++ if (value.index != null && !(Number.isInteger(value.index) && value.index >= 0 && value.index < leds)) return `index must be 0..${leds - 1}`;
++ return null;
++ }
++ return `no actuator ${name}`; // ADEEPT:290
++ },
++ };
++}
++
++const COZMO_FACES = ['neutral', 'happy', 'sad', 'surprised', 'sleepy', 'angry']; // render.py:16
++
++/** COZMO plugin with the default config (max_say_chars 200). */
++function cozmo({ maxSay = 200 } = {}) {
++ // COZMO:314 _light_state — null/false is off, {r,g,b} 0..255.
++ const light = (value) => {
++ if (value == null || value === false) return null;
++ if (!isObj(value) || !only(value, ['r', 'g', 'b'])) return 'light value is {r,g,b} or null';
++ return ['r', 'g', 'b'].every((c) => byte(value[c])) ? null : 'r, g, b are integers 0..255';
++ };
++ return {
++ name: 'cozmo',
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (!isObj(v)) return `${kind} value must be an object`;
++ if (kind === 'drive') {
++ // COZMO:290 — throttle/steer (x/rotation is the dry-run's form; y is ignored).
++ 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 === 'ptz') {
++ // COZMO:265 — tilt moves the head; there is no pan axis.
++ if ('pan' in v && v.pan !== 0 && v.pan != null) return 'Cozmo has no pan axis';
++ return 'tilt' in v && num(v.tilt, -1, 1) ? null : 'ptz is tilt -1..1';
++ }
++ if (kind === 'actuator') {
++ // COZMO:327
++ const { name, value } = v;
++ if (!only(v, name === 'cube_lights' ? ['name', 'value', 'cube'] : ['name', 'value'])) return 'an actuator is {name, value}';
++ if (name === 'head') return num(value, -1, 1) ? null : 'head is -1..1'; // COZMO:303
++ if (name === 'lift') return num(value, 0, 1) ? null : 'lift is 0..1'; // COZMO:307
++ if (name === 'backpack_lights' || name === 'cube_lights') return light(value);
++ if (name === 'head_light') return typeof value === 'boolean' ? null : 'head_light is true or false';
++ return `unknown actuator ${name}`;
++ }
++ if (kind === 'say') {
++ // COZMO:371 — non-empty text; longer than max_say_chars would be cut.
++ return typeof v.text === 'string' && v.text.trim() && v.text.length <= maxSay && only(v, ['text']) ? null : `say is {text} of 1..${maxSay} characters`;
++ }
++ if (kind === 'display') {
++ // COZMO:407 — exactly one of image_png_b64, face, text (render.py: faces, 200 characters, 256 KB).
++ const keys = Object.keys(v);
++ if (keys.length !== 1) return 'display carries exactly one of image_png_b64, face, text';
++ if ('image_png_b64' in v) return typeof v.image_png_b64 === 'string' && v.image_png_b64 && v.image_png_b64.length <= 256 * 1024 ? null : 'image_png_b64 is a base64 string';
++ if ('face' in v) return COZMO_FACES.includes(v.face) ? null : `face is one of ${COZMO_FACES.join(', ')}`;
++ if ('text' in v) return typeof v.text === 'string' && v.text.length <= 200 ? null : 'text is at most 200 characters';
++ return 'display needs text, face or image_png_b64';
++ }
++ return `kind ${kind}`; // COZMO:277
++ },
++ };
++}
++
++/** The server-side drivers (docs/protocol.md, command values). */
++const server = {
++ 'sim.rover': {
++ name: 'sim (server)',
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (kind !== 'drive') return `kind ${kind} not supported`;
++ return isObj(v) && only(v, ['throttle', 'steer']) && num(v.throttle, -1, 1) && num(v.steer, -1, 1) ? null : 'drive is {throttle, steer} -1..1';
++ },
++ },
++ 'camera.onvif': {
++ name: 'onvif (server)',
++ accepts(kind, v) {
++ if (kind === 'halt') return halt(v);
++ if (kind !== 'ptz') return `kind ${kind} not supported`;
++ const axes = isObj(v) ? Object.keys(v) : [];
++ return axes.length && only(v, ['pan', 'tilt', 'zoom']) && axes.every((a) => num(v[a], -1, 1)) ? null : 'ptz is pan/tilt/zoom -1..1';
++ },
++ },
++};
++
++/** 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();
++ return server[profile.id] || null;
++}
++
++module.exports = { adeept, cozmo, server, contractFor, COZMO_FACES };
ddiff --git a/test/pairing.test.js b/test/pairing.test.js
iindex 0e3f35f..d7c63fc 100644
---- a/test/pairing.test.js
++++ b/test/pairing.test.js
@@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(first.json.credential && first.json.credential.length >= 40);
assert.ok(first.json.publish_key && first.json.publish_key.length >= 40);
assert.strictEqual(first.json.profile.id, 'sim.rover');
++ assert.strictEqual(first.json.whip_url, `https://ingest.openre.stream/whip/${first.json.publish_key}`, 'video bootstraps from the publish key');
const again = await redeem({ robot: robot.id, code: pairing.code });
assert.strictEqual(again.status, 403);
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
@@@ -71,6 +72,7 @@ const { boot, check, done } = require('./helpers/app');
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
assert.strictEqual(rot.status, 200, rot.text);
assert.ok(await t.domain.devices.byCredential(rot.json.credential), 'the new credential authenticates');
++ assert.strictEqual(rot.json.whip_url, `https://ingest.openre.stream/whip/${rot.json.publish_key}`, 'the new publish key brings its WHIP URL');
assert.ok(await t.domain.devices.byCredential(paired.json.credential), 'the old credential still works inside the 60 s grace');
t.clock.offset += 61 * 1000;
assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
iindex 65c40e2..ff745c4 100644
---- a/test/profiles.test.js
++++ b/test/profiles.test.js
@@@ -1,14 +1,56 @@
'use strict';
--// Robot profiles (ADR-043 decision 7): every shipped profile validates, the registries refuse typos.
++// Robot profiles (ADR-043 decision 7): every shipped profile validates, the registries refuse typos, and
++// every panel control produces only commands the matching device plugin accepts (names, shapes, ranges).
const assert = require('assert');
const { check, done } = require('./helpers/app');
const { loadProfiles, validateProfile } = require('../server/profiles');
++const { createDomain } = require('../server/domain');
++const plugins = require('./helpers/plugins');
++
++// The gate's value builder needs no database: only the limits and the profile.
++const domain = createDomain({ db: null, outbox: null, config: { control: { maxCommandMs: 300, cooldownMs: 0 }, device: { heartbeatMs: 1000 }, media: {} } });
++const build = (profile, kind, value, limits = {}) => domain.control.buildValue(kind, value, domain.control.effectiveLimits({ limits }, profile), profile);
++// The panel widgets that send commands; each must say which.
++const CONTROL_WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'ptz', 'head', 'lift']);
++// A 1x1 PNG.
++const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
++
++/** What an operator might send through a control: in range, at the edges, beyond them, and odd shapes. */
++function samples(profile, kind, names) {
++ const spec = profile.commands[kind];
++ if (kind === 'halt') return [{}, null];
++ if (kind === 'drive' || kind === 'ptz') {
++ const axes = Object.keys(spec.axes);
++ const out = [Object.fromEntries(axes.map((a) => [a, 0.5]))];
++ for (const a of axes) out.push({ [a]: 7 }, { [a]: -7 }, { [a]: spec.axes[a][1] });
++ if (kind === 'drive') out.push({}, { speed: 0.4, turn: -0.2 }, { throttle: 0.3, x: 0.3, rotation: -9, y: 2 });
++ return out;
++ }
++ if (kind === 'actuator') {
++ const out = [];
++ for (const name of names) {
++ const a = spec.names[name];
++ const values = a.type === 'number' ? [a.range[0] - 1, a.range[0], (a.range[0] + a.range[1]) / 2, a.range[1], a.range[1] + 5]
++ : a.type === 'rgb' ? [{ r: 300, g: -4, b: 12.6 }, { r: 0, g: 128, b: 255 }, null, false, ...(a.count ? [{ r: 1, g: 2, b: 3, index: a.count - 1 }] : [])]
++ : a.type === 'tone' ? [{ note: 'A4' }, { note: 'C5' }, { note: 'C9' }, { hz: 100000 }, { hz: 1 }, { hz: 0 }, null, 0]
++ : [true, false];
++ for (const value of values) out.push({ name, value });
++ }
++ return out;
++ }
++ if (kind === 'say') return [{ text: 'hello there' }, { text: ' padded ' }];
++ const out = []; // display
++ if (spec.modes.includes('text')) out.push({ text: 'hi' });
++ if (spec.modes.includes('face')) for (const face of spec.faces) out.push({ face }, { face: face.toUpperCase() });
++ if (spec.modes.includes('image_png_b64')) out.push({ image_png_b64: PNG }, { image_png_b64: PNG, text: 'both' });
++ return out;
++}
(async () => {
const profiles = loadProfiles();
-- await check('the four shipped profiles load and validate', () => {
-- assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'camera.onvif', 'cozmo', 'sim.rover']);
++ await check('the five shipped profiles load and validate', () => {
++ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'adeept.adr036.mecanum', 'camera.onvif', 'cozmo', '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`);
@@@ -24,17 +66,23 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
assert.deepStrictEqual(p.mapping.servo_channels, { pan: 0, tilt: 1 });
assert.strictEqual(p.mapping.battery.driver, 'ads7830');
assert.strictEqual(p.mapping.battery.address, '0x48');
-- assert.ok(p.capabilities.includes('drive.differential') && p.capabilities.includes('drive.mecanum'), 'both wheel variants');
-- assert.deepStrictEqual(Object.keys(p.variants).sort(), ['differential', 'mecanum']);
++ assert.strictEqual(p.mapping.wheels, 'ordinary');
++ assert.deepStrictEqual(Object.keys(p.commands.drive.axes), ['throttle', 'steer']);
++ const m = profiles.get('adeept.adr036.mecanum');
++ assert.strictEqual(m.mapping.wheels, 'mecanum');
++ assert.deepStrictEqual(m.mapping.motor_channels, p.mapping.motor_channels);
++ assert.deepStrictEqual(Object.keys(m.commands.drive.axes), ['x', 'y', 'rotation'], 'the mecanum chassis strafes');
});
-- await check('cozmo carries drive, head, lift, say, display/animation, lights, cliff/pick-up and a 320x240 camera', () => {
++ await check('cozmo carries drive, head, lift, say, display, lights, cliff/pick-up and a 320x240 camera, and no animations', () => {
const p = profiles.get('cozmo');
-- for (const c of ['drive.differential', 'head', 'lift', 'speaker.say', 'display.text', 'display.animation', 'lights.backpack', 'lights.cube', 'battery', 'sensor.cliff', 'sensor.pickup', 'camera']) {
++ for (const c of ['drive.differential', 'head', 'lift', 'speaker.say', 'display.text', 'lights.backpack', 'lights.cube', 'battery', 'sensor.cliff', 'sensor.pickup', 'camera']) {
assert.ok(p.capabilities.includes(c), `cozmo has ${c}`);
}
assert.strictEqual(p.mapping.driver, 'cozmo');
-- assert.ok(Array.isArray(p.mapping.animations) && p.mapping.animations.length > 0);
++ assert.ok(!p.capabilities.includes('display.animation') && !p.mapping.animations, 'the plugin implements no animations');
++ assert.deepStrictEqual(p.commands.display.modes, ['text', 'face', 'image_png_b64']);
++ assert.deepStrictEqual(p.commands.actuator.names.lift.range, [0, 1]);
assert.strictEqual(p.camera.resolution, '320x240');
});
@@@ -56,5 +104,88 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
assert.throws(() => validateProfile({ ...base, mapping: { driver: 'does-not-exist' } }), /mapping.driver/);
});
++ await check('commands are checked against the per-kind schema, and a widget only sends what commands declares', () => {
++ const base = { id: 'x.y', version: 1, name: 'X', capabilities: ['drive.differential'], mapping: { driver: 'sim' }, widgets: [{ type: 'latency' }] };
++ const ok = validateProfile({ ...base, commands: { drive: { axes: { throttle: [-1, 1] } } }, widgets: [{ type: 'drive', command: { kind: 'drive' } }] });
++ assert.deepStrictEqual(Object.keys(ok.commands).sort(), ['drive', 'halt'], 'halt is always taken');
++ assert.deepStrictEqual(validateProfile(base).commands, { halt: {} }, 'no commands: halt only');
++ const refused = [
++ [{ teleport: {} }, /unknown command kind teleport/],
++ [{ drive: { axes: { warp: [-1, 1] } } }, /unknown axis warp/],
++ [{ drive: { axes: { throttle: [1, -1] } } }, /throttle must be \[min, max\]/],
++ [{ drive: {} }, /axes must name/],
++ [{ ptz: { axes: { throttle: [-1, 1] } } }, /unknown axis throttle/],
++ [{ actuator: { names: { pan: { type: 'number' } } } }, /range must be/],
++ [{ actuator: { names: { pan: { type: 'servo' } } } }, /type must be one of/],
++ [{ actuator: { names: { horn: { type: 'tone', hz: [0, 880] } } } }, /hz must be/],
++ [{ actuator: { names: { 'Bad Name': { type: 'bool' } } } }, /not valid/],
++ [{ display: { modes: ['animation'] } }, /unknown display mode animation/],
++ [{ display: { modes: ['face'] } }, /faces must list/],
++ [{ say: { max_chars: -1 } }, /max_chars/],
++ [{ halt: { hard: true } }, /takes no options/],
++ ];
++ for (const [commands, re] of refused) assert.throws(() => validateProfile({ ...base, commands }), re, JSON.stringify(commands));
++ const actuators = { actuator: { names: { pan: { type: 'number', range: [-1, 1] } } } };
++ assert.throws(() => validateProfile({ ...base, commands: actuators, widgets: [{ type: 'drive', command: { kind: 'drive' } }] }), /sends drive, which commands does not declare/);
++ assert.throws(() => validateProfile({ ...base, commands: actuators, widgets: [{ type: 'pan-tilt', command: { kind: 'actuator', names: ['pan', 'tilt'] } }] }), /drives actuator tilt/);
++ assert.throws(() => validateProfile({ ...base, commands: actuators, widgets: [{ type: 'pan-tilt', command: { kind: 'actuator' } }] }), /lists its names/);
++ });
++
++ for (const p of profiles.values()) {
++ await check(`${p.id}: every panel control produces a command ${plugins.contractFor(p).name} accepts`, () => {
++ const plugin = plugins.contractFor(p);
++ let tried = 0;
++ for (const w of p.widgets) {
++ if (CONTROL_WIDGETS.has(w.type)) assert.ok(w.command, `${p.id}: the ${w.type} widget names the command it sends`);
++ if (!w.command) continue;
++ for (const value of samples(p, w.command.kind, w.command.names || [])) {
++ const sent = build(p, w.command.kind, value);
++ const why = plugin.accepts(w.command.kind, sent);
++ assert.strictEqual(why, null, `${p.id} ${w.type}: ${JSON.stringify(value)} → ${JSON.stringify(sent)}: ${why}`);
++ tried++;
++ }
++ }
++ const why = plugin.accepts('halt', build(p, 'halt', {}));
++ assert.strictEqual(why, null, `${p.id}: halt: ${why}`);
++ assert.ok(tried > 0, `${p.id} has controls`);
++ });
++ }
++
++ await check('the owner\'s limits cut the drive axes the device gets (max_speed: throttle, x, y; max_turn: steer, rotation)', () => {
++ const limits = { max_speed: 0.4, max_turn: 0.25 };
++ assert.deepStrictEqual(build(profiles.get('adeept.adr036'), 'drive', { throttle: 1, steer: -1 }, limits), { throttle: 0.4, steer: -0.25 });
++ assert.deepStrictEqual(build(profiles.get('adeept.adr036.mecanum'), 'drive', { x: 1, y: -1, rotation: 1 }, limits), { x: 0.4, y: -0.4, rotation: 0.25 });
++ assert.deepStrictEqual(build(profiles.get('cozmo'), 'drive', { throttle: 1, steer: 1 }), { throttle: 0.6, steer: 0.8 }, 'the profile\'s own limits too');
++ });
++
++ await check('values the plugins would refuse are refused by Bot before they reach a device', () => {
++ const adeept = profiles.get('adeept.adr036');
++ const cozmo = profiles.get('cozmo');
++ const refuses = (p, kind, value, code) => assert.throws(() => build(p, kind, value), (e) => e.code === code, `${p.id} ${kind} ${JSON.stringify(value)}`);
++ refuses(adeept, 'actuator', { name: 'horn', value: { note: 'A4' } }, 'bot.unknown_actuator');
++ refuses(adeept, 'actuator', { name: 'servo', value: 0.5 }, 'bot.unknown_actuator');
++ refuses(adeept, 'actuator', { name: 'pan', value: 'left' }, 'bot.invalid_input');
++ refuses(adeept, 'actuator', { name: 'buzzer', value: { note: 'H2' } }, 'bot.invalid_input');
++ refuses(adeept, 'actuator', { name: 'lights', value: { r: 1, g: 2, b: 3, index: 8 } }, 'bot.invalid_input');
++ refuses(adeept, 'actuator', { name: 'lights', value: [255, 0, 0] }, 'bot.invalid_input');
++ refuses(cozmo, 'display', { animation: 'anim_bored_01' }, 'bot.invalid_input');
++ refuses(cozmo, 'display', { face: 'wink' }, 'bot.invalid_input');
++ refuses(cozmo, 'display', { image_png_b64: 'not base64!' }, 'bot.invalid_input');
++ refuses(cozmo, 'say', { text: ' ' }, 'bot.invalid_input');
++ refuses(cozmo, 'actuator', { name: 'lights', value: { r: 1, g: 1, b: 1 } }, 'bot.unknown_actuator');
++ refuses(profiles.get('camera.onvif'), 'ptz', {}, 'bot.invalid_input');
++ assert.ok(!adeept.commands.say && !adeept.commands.display && !cozmo.commands.ptz, 'kinds a plugin does not take are not declared');
++ });
++
++ await check('a buzzer note is sent as the tone in Hz the plugin plays; a lights colour as integers 0..255', () => {
++ const adeept = profiles.get('adeept.adr036');
++ assert.deepStrictEqual(build(adeept, 'actuator', { name: 'buzzer', value: { note: 'A4' } }), { name: 'buzzer', value: { hz: 440 } });
++ assert.deepStrictEqual(build(adeept, 'actuator', { name: 'buzzer', value: { note: 'C5' } }), { name: 'buzzer', value: { hz: 523.25 } });
++ assert.deepStrictEqual(build(adeept, 'actuator', { name: 'buzzer', value: { hz: 5000 } }), { name: 'buzzer', value: { hz: 880 } });
++ assert.deepStrictEqual(build(adeept, 'actuator', { name: 'buzzer', value: null }), { name: 'buzzer', value: null });
++ assert.deepStrictEqual(build(adeept, 'actuator', { name: 'lights', value: { r: 300, g: -1, b: 12.6 } }), { name: 'lights', value: { r: 255, g: 0, b: 13 } });
++ assert.deepStrictEqual(build(profiles.get('cozmo'), 'actuator', { name: 'lift', value: -0.5 }), { name: 'lift', value: 0 });
++ });
++
done();
})();