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)
Changes
@@@ -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@@@ -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, dprincipal 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.@@@ -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 onceres.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;@@@ -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'),@@@ -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 = $6WHERE 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 };@@@ -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 }nnew file mode 100644@@@ -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 }++}@@@ -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 }],@@@ -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 }@@@ -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 };@@@ -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 }],@@@ -101,17 +101,25 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datserver_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 = () => Datconn.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 = () => Datreturn {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() {@@@ -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 });@@@ -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);nnew file mode 100644@@@ -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 };@@@ -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');@@@ -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();})();
@@ -59,3 +59,5 @@ OV_OAUTH_CLIENT_SECRET=
@@ -59,3 +59,5 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
#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
@@ -33,9 +33,9 @@ so a reconnecting agent cannot be shadowed by a stale socket.
@@ -33,9 +33,9 @@ so a reconnecting agent cannot be shadowed by a stale socket.
| type | fields (besides v, seq, ts) | when |
| 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 |
| `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 |
| `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 |
| `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 |
| `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.
@@ -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 |
| `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 |
| `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
### Examples
@@ -72,7 +113,8 @@ The answer (the only time the credential appears over the wire):
@@ -72,7 +113,8 @@ The answer (the only time the credential appears over the wire):
```json
```json
{ "v": 1, "seq": 1, "ts": 1738065600000, "type": "paired", "device_id": "dev_01J8Z4…",
{ "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 } } }
"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):
@@ -84,7 +126,15 @@ The answer (the only time the credential appears over the wire):
```json
```json
{ "v": 1, "seq": 3, "ts": 1738065600000, "type": "config",
{ "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 },
"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`):
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
@@ -150,7 +200,7 @@ The gate then applies the **acted-for subject's** role — a service acting for
| type | fields | notes |
| 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 |
| `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` |
| `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 |
| `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).
@@ -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",
"robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private",
"estop": { "latched": false, "by": null, "at": null } },
"estop": { "latched": false, "by": null, "at": null } },
"role": "operator", "profile": { "id": "adeept.adr036" },
"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 } }
"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).
@@ -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" }
{ "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`.
@@ -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[] }` |
| `GET /robots` | user, or `bot.robot.read` + `?owner=` | `{ robots[] }` |
| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }` |
| `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 }` |
| `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` |
| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }` |
| `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[] }` |
| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ 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`) |
| `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 /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` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `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) |
| `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
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).
`/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
@@ -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`,
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`,
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
`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.
@@ -107,6 +107,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -107,6 +107,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
r.patch('/robots/:id', manage, wrap(async (req, res) => {
r.patch('/robots/:id', manage, wrap(async (req, res) => {
await owner(req, req.params.id);
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 });
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) });
res.json({ robot: domain.present.robot(updated) });
}));
}));
r.delete('/robots/:id', manage, wrap(async (req, res) => {
r.delete('/robots/:id', manage, wrap(async (req, res) => {
@@ -148,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -148,7 +149,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
if (!d) fail(404, 'bot.device_not_found', 'no such device');
if (!d) fail(404, 'bot.device_not_found', 'no such device');
await owner(req, json(d.robot_ids, [])[0]);
await owner(req, json(d.robot_ids, [])[0]);
const rotated = await domain.devices.rotate(req.params.id);
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) => {
r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
const d = await domain.devices.get(req.params.id);
const d = await domain.devices.get(req.params.id);
@@ -195,7 +196,7 @@ function v1Router({ domain, apiAuth, limits, hub }) {
@@ -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 : [],
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,
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;
return r;
@@ -98,6 +98,11 @@ function loadConfig(env = process.env) {
@@ -98,6 +98,11 @@ function loadConfig(env = process.env) {
enabled: env.BOT_JOBS !== 'off',
enabled: env.BOT_JOBS !== 'off',
intervalMs: int(env.BOT_JOBS_INTERVAL_MS, 5000),
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).
// The one-line installer the owner copies next to the pairing code (the agent job builds it).
installer: {
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
@@ -7,10 +7,12 @@
@@ -7,10 +7,12 @@
*
*
* A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more
* 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
* 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 {
const {
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,
BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,
@@ -25,6 +27,14 @@ const DEFAULT_ALLOW = {
@@ -25,6 +27,14 @@ const DEFAULT_ALLOW = {
};
};
const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
const MOTION = new Set(['drive', 'actuator', 'ptz']);
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.
// Crockford base32: no I, L, O or U, so a code read aloud cannot be mistyped into another.
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
@@ -40,6 +50,15 @@ const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
@@ -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; };
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 }) {
function createDomain({ db, config, outbox, link = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
@@ -195,7 +214,15 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -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]);
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);
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() {
async function prunePairingCodes() {
await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
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(),
@@ -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
`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 *`,
WHERE id = $1 RETURNING *`,
[id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
[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) {
async function revokeDevice(id) {
const d = await getDevice(id);
const d = await getDevice(id);
@@ -365,15 +392,25 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -365,15 +392,25 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
}
}
// ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────
// ── 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 effectiveLimits = (robot, profile) => {
const rl = json(robot.limits, {});
const rl = json(robot.limits, {});
const pl = (profile && profile.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;
const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;
return { maxSpeed, maxTurn, maxCommandMs, 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
// 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.
// kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.
const lastCommandAt = new Map();
const lastCommandAt = new Map();
@@ -382,42 +419,101 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -382,42 +419,101 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
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 rl = json(robot.limits, {});
const fromOwner = rl.allow && Array.isArray(rl.allow[role]) ? rl.allow[role] : null;
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') {
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;
return out;
}
}
if (kind === 'actuator') {
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') {
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');
if (!t) fail(422, 'bot.invalid_input', 'say needs text');
return { text: t };
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(),
@@ -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 profileRow = await getProfile(db, robot.profile_id, robot.profile_version);
const profile = profileRow ? profileRow.profile : null;
const profile = profileRow ? profileRow.profile : null;
if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };
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;
const subject = principal && principal.kind !== 'device' ? principal.subject : null;
let role = await roleOf(robotId, subject);
let role = await roleOf(robotId, subject);
@@ -445,28 +543,35 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -445,28 +543,35 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
if (robot.access_policy === 'queue') role = 'queue';
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 };
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 (!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 };
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);
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}`);
const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);
if (last && now() - last < eff.cooldownMs) {
if (last && now() - last < eff.cooldownMs) {
return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
}
}
}
}
if (role === 'queue') {
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)
const deadlineMs = MOTION.has(kind)
? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
: null;
: 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 {
return {
@@ -474,13 +579,13 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
@@ -474,13 +579,13 @@ function createDomain({ db, config, outbox, link = null, now = () => Date.now(),
present: { robot: presentRobot, device: presentDevice },
present: { robot: presentRobot, device: presentDevice },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
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 },
devices: { byCredential, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
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 };
@@ -4,10 +4,9 @@
@@ -4,10 +4,9 @@
"name": "Adeept ADR036 4WD Smart Car",
"name": "Adeept ADR036 4WD Smart Car",
"vendor": "Adeept",
"vendor": "Adeept",
"kind": "onboard",
"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": [
"capabilities": [
"drive.differential",
"drive.differential",
- "drive.mecanum",
"servo.pan_tilt",
"servo.pan_tilt",
"lights.rgb",
"lights.rgb",
"speaker.horn",
"speaker.horn",
@@ -15,12 +14,10 @@
@@ -15,12 +14,10 @@
"sensor.line",
"sensor.line",
"battery"
"battery"
],
],
- "variants": {
- "differential": { "drive": "differential" },
- "mecanum": { "drive": "mecanum" }
- },
"mapping": {
"mapping": {
"driver": "pca9685",
"driver": "pca9685",
+ "plugin": "adeept_adr036",
+ "wheels": "ordinary",
"i2c_bus": 1,
"i2c_bus": 1,
"address": "0x5f",
"address": "0x5f",
"motor_channels": [8, 9, 10, 11, 12, 13, 14, 15],
"motor_channels": [8, 9, 10, 11, 12, 13, 14, 15],
@@ -29,11 +26,22 @@
@@ -29,11 +26,22 @@
"lights": "ws2812",
"lights": "ws2812",
"horn": "buzzer"
"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": [
"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": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
{ "type": "latency", "label": "Latency", "order": 7 }
{ "type": "latency", "label": "Latency", "order": 7 }
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -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 }
+}
@@ -10,8 +10,11 @@
@@ -10,8 +10,11 @@
"driver": "onvif",
"driver": "onvif",
"credentials": "secret-ref"
"credentials": "secret-ref"
},
},
+ "commands": {
+ "ptz": { "axes": { "pan": [-1, 1], "tilt": [-1, 1], "zoom": [-1, 1] } }
+ },
"widgets": [
"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": "camera", "capability": "camera", "label": "Camera", "order": 2 },
{ "type": "latency", "label": "Latency", "order": 3 }
{ "type": "latency", "label": "Latency", "order": 3 }
],
],
@@ -4,14 +4,13 @@
@@ -4,14 +4,13 @@
"name": "Cozmo",
"name": "Cozmo",
"vendor": "Anki",
"vendor": "Anki",
"kind": "bridge",
"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": [
"capabilities": [
"drive.differential",
"drive.differential",
"head",
"head",
"lift",
"lift",
"speaker.say",
"speaker.say",
"display.text",
"display.text",
- "display.animation",
"lights.backpack",
"lights.backpack",
"lights.cube",
"lights.cube",
"battery",
"battery",
@@ -21,25 +20,28 @@
@@ -21,25 +20,28 @@
],
],
"mapping": {
"mapping": {
"driver": "cozmo",
"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": [
"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": "telemetry", "capability": "sensor.cliff", "label": "Cliff / pick-up", "order": 7 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 8 },
{ "type": "battery", "capability": "battery", "label": "Battery", "order": 8 },
{ "type": "latency", "label": "Latency", "order": 9 }
{ "type": "latency", "label": "Latency", "order": 9 }
@@ -13,9 +13,15 @@
@@ -13,9 +13,15 @@
* capabilities: ["drive.differential", …], // in CAPABILITIES
* capabilities: ["drive.differential", …], // in CAPABILITIES
* variants: { differential: { drive: … }, … }, // optional: one profile, several wheel layouts
* variants: { differential: { drive: … }, … }, // optional: one profile, several wheel layouts
* mapping: { driver: "pca9685", … }, // driver + the hardware mapping (addresses, channels)
* 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 },
* camera: { transport: "whip|onvif|rtsp", resolution },
* limits: { max_speed, max_turn, max_command_ms, heartbeat_ms } }
* 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 fs = require('fs');
const path = require('path');
const path = require('path');
@@ -29,7 +35,7 @@ const CAPABILITIES = new Set([
@@ -29,7 +35,7 @@ const CAPABILITIES = new Set([
'servo.pan_tilt', 'head', 'lift',
'servo.pan_tilt', 'head', 'lift',
'lights.rgb', 'lights.backpack', 'lights.cube',
'lights.rgb', 'lights.backpack', 'lights.cube',
'speaker.horn', 'speaker.say',
'speaker.horn', 'speaker.say',
- 'display.text', 'display.animation',
+ 'display.text',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
'battery', 'ptz', 'camera',
'battery', 'ptz', 'camera',
]);
]);
@@ -44,9 +50,105 @@ const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
@@ -44,9 +50,105 @@ const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const KINDS = new Set(['onboard', 'bridge', 'server']);
const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const int = (v) => (Number.isInteger(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); }
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). */
/** Validate one profile, filling the limit defaults (max_command_ms 300, heartbeat_ms 1000). */
function validateProfile(p) {
function validateProfile(p) {
if (!p || typeof p !== 'object') bad('a profile must be an object');
if (!p || typeof p !== 'object') bad('a profile must be an object');
@@ -56,12 +158,14 @@ function validateProfile(p) {
@@ -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 (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`);
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}`);
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`);
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 || 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.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`);
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(', ')}`);
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 l = p.limits && typeof p.limits === 'object' ? p.limits : {};
const limits = {
const limits = {
@@ -75,7 +179,7 @@ function validateProfile(p) {
@@ -75,7 +179,7 @@ function validateProfile(p) {
return {
return {
id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
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,
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) {
@@ -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');
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 };
@@ -7,8 +7,11 @@
@@ -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.",
"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"],
"capabilities": ["drive.differential", "camera"],
"mapping": { "driver": "sim" },
"mapping": { "driver": "sim" },
+ "commands": {
+ "drive": { "axes": { "throttle": [-1, 1], "steer": [-1, 1] } }
+ },
"widgets": [
"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": "camera", "capability": "camera", "label": "Camera", "order": 2 },
{ "type": "latency", "label": "Latency", "order": 3 }
{ "type": "latency", "label": "Latency", "order": 3 }
],
],
@@ -101,17 +101,25 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -101,17 +101,25 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
server_time: iso(now()),
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) {
async function sendConfig(conn) {
const robotId = deviceRobotIds(conn.device)[0];
const robotId = deviceRobotIds(conn.device)[0];
const robot = robotId ? await domain.robots.get(robotId) : null;
const robot = robotId ? await domain.robots.get(robotId) : null;
const profileRow = robot ? await getProfile(domain.db, robot.profile_id, robot.profile_version) : 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', {
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
estop_latched: robot ? !!robot.estop_latched : false,
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) {
async function bringOnline(conn) {
await domain.devices.setOnline(conn.device, true).catch((e) => log.warn(`[Bot] online event: ${e.message}`));
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);
for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
@@ -156,7 +164,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -156,7 +164,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
});
attachDevice(conn, r.device);
attachDevice(conn, r.device);
sendFrame(conn, 'paired', {
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,
robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
});
});
sendHello(conn);
sendHello(conn);
@@ -250,10 +258,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -250,10 +258,10 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
conn.robotId = robotId; conn.role = role;
conn.robotId = robotId; conn.role = role;
subscribe(conn);
subscribe(conn);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ const profile = profileRow ? profileRow.profile : null;
sendFrame(conn, 'joined', {
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),
state: await robotState(robotId, conn.subject),
});
});
}
}
@@ -382,7 +390,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -382,7 +390,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return {
return {
bindDomain(d) { domain = d; },
bindDomain(d) { domain = d; },
handleUpgrade,
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; },
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 })); },
devices() { return [...deviceConns.values()].map((c) => ({ device_id: c.device.id, online: c.online })); },
async close() {
async close() {
@@ -28,7 +28,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 () => {
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', '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 });
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 });
@@ -91,6 +91,69 @@ const { boot, check, done } = require('./helpers/app');
@@ -91,6 +91,69 @@ const { boot, check, done } = require('./helpers/app');
a.close(); b.close();
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 () => {
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 { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 300, turn_budget: 5 } });
const dev = await deviceFor(robot.id, pairing.code);
const dev = await deviceFor(robot.id, pairing.code);
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -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 };
@@ -18,6 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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.credential && first.json.credential.length >= 40);
assert.ok(first.json.publish_key && first.json.publish_key.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.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 });
const again = await redeem({ robot: robot.id, code: pairing.code });
assert.strictEqual(again.status, 403);
assert.strictEqual(again.status, 403);
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
assert.strictEqual(again.json.code, 'bot.pairing_code_used');
@@ -71,6 +72,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -71,6 +72,7 @@ const { boot, check, done } = require('./helpers/app');
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
assert.strictEqual(rot.status, 200, rot.text);
assert.strictEqual(rot.status, 200, rot.text);
assert.ok(await t.domain.devices.byCredential(rot.json.credential), 'the new credential authenticates');
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');
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;
t.clock.offset += 61 * 1000;
assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
@@ -1,14 +1,56 @@
@@ -1,14 +1,56 @@
'use strict';
'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 assert = require('assert');
const { check, done } = require('./helpers/app');
const { check, done } = require('./helpers/app');
const { loadProfiles, validateProfile } = require('../server/profiles');
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 () => {
(async () => {
const profiles = loadProfiles();
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()) {
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`);
@@ -24,17 +66,23 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
@@ -24,17 +66,23 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
assert.deepStrictEqual(p.mapping.servo_channels, { pan: 0, tilt: 1 });
assert.deepStrictEqual(p.mapping.servo_channels, { pan: 0, tilt: 1 });
assert.strictEqual(p.mapping.battery.driver, 'ads7830');
assert.strictEqual(p.mapping.battery.driver, 'ads7830');
assert.strictEqual(p.mapping.battery.address, '0x48');
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');
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.ok(p.capabilities.includes(c), `cozmo has ${c}`);
}
}
assert.strictEqual(p.mapping.driver, 'cozmo');
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');
assert.strictEqual(p.camera.resolution, '320x240');
});
});
@@ -56,5 +104,88 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
@@ -56,5 +104,88 @@ const { loadProfiles, validateProfile } = require('../server/profiles');
assert.throws(() => validateProfile({ ...base, mapping: { driver: 'does-not-exist' } }), /mapping.driver/);
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();
done();
})();
})();