openvibers/openvibe.bot Commits

Connect server-side ONVIF cameras as devices (#31)

OP OpenVibers committed 5 Oct 2026 · ddbf0b8d6a · 11 files, +770 −11

* Connect server-side ONVIF cameras as devices

All steps are done. Summary:

**Files changed**
- `server/onvif/index.js` (new): the server-side ONVIF connector — an in-process device attached with `hub.attachSim` beside the simulator; writes a real `kind: 'server'` device row via `domain.devices.ensureServerDevice`, sends hello/heartbeat/telemetry, translates `ptz` → ONVIF `ContinuousMove` (`Stop` for zeros/`halt`/e-stop, plus a deadline deadman), parses `GetStatus` into the telemetry readout (`camera: { transport, resolution, reachable, ptz }`), and resolves credentials only from secret references (`username_ref`/`password_ref`), sent as a WS-Security digest. Every request is pinned to the one configured URL with `redirect: 'error'` and a timeout.
- `server/config.js`: `parseOnvifCameras` + `config.onvif` — parses/validates `BOT_ONVIF_CAMERAS` (http/https only, one host, no embedded credentials, both-or-neither secret refs) and `BOT_ONVIF_TIMEOUT_MS`; a bad value refuses to boot.
- `server/domain/index.js`: `devices.ensureServerDevice` — find-or-insert a `kind: 'server'` row with a deterministic id and a discarded random credential hash; exported.
- `server/app.js`: constructs `createOnvif` beside the simulator, passes it to the web routes, exposes it on `app.locals`.
- `server/index.js`: `onvif.startAll()` at boot and `onvif.stopAll()` in the graceful shutdown.
- `server/web/routes.js`: `attachInProcess` starts sim-or-configured-ONVIF, so a configured camera robot goes straight to its panel like a sim robot.
- `test/onvif.test.js` (new): boots against a stub ONVIF HTTP endpoint and checks the config refusals, the `kind: 'server'` row, GetStatus readout in telemetry, `ptz` → `ContinuousMove` to the configured host with a WS-Security digest and no clear/ logged secret, `halt` → `Stop`, a nack when the camera is down, and `startAll`/no-attach-for-non-onvif.
- `.env.example`, `README.md`, `STATUS.json`, `docs/protocol.md`: document the connector, credentials-by-reference, host-pinning/timeout, and the new test.

**Tests run**
- `ov test onvif` → 1 passed.
- `ov test device panels profiles api install docs control streaming pairing` (batch; includes `device.test.js`) → 12 passed.
- `ov test onvif docs profiles api` → `docs.test.js` passed, the unchanged three skipped as green.

**Could not do / notes**
- Bot has no outbound-request inventory or SSRF test to classify the new request in — the closest is the installer-host allow-list. I classified it instead through `BOT_ONVIF_CAMERAS` boot validation (scheme/host/no-embedded-credentials), the host-pinning + no-redirect + timeout in the connector, a README Security bullet, and test assertions that no request leaves the configured host.
- No `CHANGELOG` exists in this repo, so nothing to keep in step there.
- Did not commit, push, deploy or install packages, per instructions.

* ONVIF: robot-id keys only, 64 KB replies, a stop after every move, retried stops, camera clock offset (review)

All five fixes are in place, tested, and nothing unrelated changed.

**Changes (file:line)**
1. **Wildcard camera grab** — `server/config.js:78`: `parseOnvifCameras` now rejects any key that isn't a robot id (`isRobotId`), so `"*"` and arbitrary keys refuse to boot; `server/onvif/index.js:91`: `cameraFor` no longer falls back to `opts.cameras['*']` (each camera names exactly one robot). README wildcard text updated in `README.md:85`.
2. **Unbounded camera response** — `server/onvif/index.js:41,50`: new `readCapped` streams the body, cancels/aborts past 64 KB, and turns an oversized/endless answer into an error; `server/onvif/index.js:223` uses it in `request` (marks unreachable on failure); `server/onvif/index.js:233`: `refreshStatus` only parses replies that are actually a `GetStatusResponse`.
3. **Move with no stop** — `server/onvif/index.js:196`: `scheduleStop` falls back to `now() + domain.control.effectiveLimits(robot, profile).maxCommandMs` when `deadline_ms` isn't finite, so every continuous move is followed by a Stop (`camMaxCommandMs` helper just below).
4. **Silent stop failures** — `server/onvif/index.js:183`: `stopWithRetry` retries a failed Stop once, then `log.warn` names only the robot id (never URL/credentials); used by both `halt` and the `scheduleStop` timer.
5. **Clock skew** — `server/onvif/index.js:248`: on attach, `syncClock` calls unauthenticated `GetSystemDateAndTime` once and stores the offset; `server/onvif/index.js:274`: the WS-Security `wsu:Created` applies it. A failed/unreadable call leaves local time.

Tests added in `test/onvif.test.js` cover: non-robot keys (`"*"`, `rob_x`, empty) refused at boot; a per-robot camera never attaches to another robot; oversized/never-ending body nacks in bounded time; a deadline-less move still gets a Stop; a first Stop failure is retried and a double failure logs only the robot id; and a stub camera 5 minutes ahead gets a `Created` tracking its clock (with the clock call unauthenticated).

**Test results**
- `ov test test/onvif.test.js test/device.test.js` → `test/onvif.test.js` ✓ (all 11 checks pass on Node 22.23.3, 7.7 s); `test/device.test.js` green (skipped as unchanged since the earlier run in this session).
- No `test/config*.test.js` exists; config parsing is covered inside `test/onvif.test.js`.
- Nothing committed or pushed.

Changes

ddiff --git a/.env.example b/.env.example
iindex 9bddf73..c44697c 100644
---- a/.env.example
++++ b/.env.example
@@@ -78,6 +78,15 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_OPENRE_TOKEN=
#BOT_OPENRE_TIMEOUT_MS=8000
++# ── Server-side ONVIF cameras (camera.onvif) ────────────────
++# A JSON object keyed by robot id (or "*" for any ONVIF-profile robot) → its camera's ONVIF PTZ service.
++# Bot connects server-side, writes a kind server device row and translates ptz commands to ONVIF; the profile
++# carries no host or credential. username_ref/password_ref name the environment variables (or secret-store
++# keys) holding the ONVIF user and password; the values are read per request and never stored or logged. The
++# URL must be http(s), on one host, with no embedded credentials, or Bot refuses to boot.
++#BOT_ONVIF_CAMERAS={"rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X":{"url":"http://192.168.1.50/onvif/ptz_service","username_ref":"CAM_USER","password_ref":"CAM_PASSWORD"}}
++#BOT_ONVIF_TIMEOUT_MS=5000
++
# ── OpenVibe.Billing: job usage (plan T14 L1) ───────────────
# Where the per-second readings of jobs run on paired Nodes go: billing.usage.record, POST <url>/api/v1/usage.
# The token is a Network service token for audience openvibe.billing holding billing.usage.record. Either
ddiff --git a/README.md b/README.md
iindex c576c04..b646cda 100644
---- a/README.md
++++ b/README.md
@@@ -14,6 +14,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; the camera stays a placeholder until OpenRe.Stream can play a WHIP-published stream back to a browser.
++- Server-side ONVIF cameras (`camera.onvif`, `mapping.driver: onvif`): an in-process connector beside the simulator (`server/onvif`) attaches a camera configured with `BOT_ONVIF_CAMERAS`, writes a real `kind: server` device row, translates a `ptz` command into ONVIF `ContinuousMove`/`Stop` (with the profile's deadline as a deadman) and carries the camera's `GetStatus` readout in telemetry. Its credentials are secret references only (`username_ref`/`password_ref`).
- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
- The robot's streaming and recording toggles, off by default: `media` records live sessions to OpenVibe.Media and `live` shows them on the owner's OpenVibe.Live channel. They live only on the robot's OpenRe stream (`recording_mode`, `mirror_to_live`); Bot stores no copy and the owner, not an operator, flips them (`GET`/`POST /api/v1/robots/:id/streaming`).
- Dispatching `platform.job@1` jobs to a paired Node over `/device` (`server/jobs/index.js` over `server/jobs/dispatch.js`; the internal `bot.job.dispatch` API is `POST /api/v1/jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`) and metering them per wall-clock second (`run_jobs`, `run_usage_outbox`; docs/protocol.md §1.2).
@@@ -28,6 +29,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
## Devices
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`; any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
++- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
## Depends on
@@@ -53,7 +55,7 @@ A person acts on their own robots with a Network user token; a service acts with
## Tests
--`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop and the outbox.
++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the profiles and the kit catalogue, the owner fence, the e-stop, the outbox and the server-side ONVIF camera (against a stub endpoint: `ptz` → `ContinuousMove`, telemetry → the `GetStatus` readout).
## Security
@@@ -61,6 +63,7 @@ A person acts on their own robots with a Network user token; a service acts with
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
++- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.
## Deploy
@@@ -79,6 +82,8 @@ Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each devi
The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). The same token covers the streaming toggles: `GET /api/v1/robots/:id/streaming` reads the stream with `openre.stream.read`, `POST` PATCHes `recording_mode`/`mirror_to_live` with `openre.stream.write`. Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
++Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
++
`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe.Node's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with Node's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
## Deploy files
ddiff --git a/STATUS.json b/STATUS.json
iindex 8c200cb..4d6529d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -43,7 +43,8 @@
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
-- "streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRe stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once"
++ "streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRe stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
++ "server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
],
"updated": "2026-10-04"
}
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 373f092..acd4c80 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -156,6 +156,12 @@ against these):
| `camera.onvif` | server driver `onvif` | `ptz {pan, tilt, zoom}` −1..1 |
| `sim.rover` | server driver `sim` | `drive {throttle, steer}` |
++The two server drivers are not machines that connect: Bot attaches them in-process (`server/sim`,
++`server/onvif`). A `sim` device needs nothing; an `onvif` camera is configured with `BOT_ONVIF_CAMERAS`
++(its URL plus `username_ref`/`password_ref` secret references — never the profile) and Bot translates each
++`ptz` command into an ONVIF `ContinuousMove` (`Stop` at the deadline, on `halt` or on the e-stop), carrying
++the camera's `GetStatus` position in telemetry under `camera`.
++
**A device's `estop_state` is a report, never a clear.** `latched: true` latches each robot the device is
attached to (if it is not already latched). `latched: false` is recorded as the device's reported state
only (`robot_state.state.device_estop`); it never clears Bot's latch — clearing is the owner's alone
ddiff --git a/server/app.js b/server/app.js
iindex 3ada7f3..a3806f1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -29,6 +29,7 @@ const { createUsageRelay } = require('./jobs/metering');
const { createDomain } = require('./domain');
const { createRealtime } = require('./realtime');
const { createSimulator } = require('./sim');
++const { createOnvif } = require('./onvif');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { createJobs } = require('./jobs');
@@@ -61,6 +62,9 @@ function createApp(opts = {}) {
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
hub.bindJobs(jobs);
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
++ // Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
++ // kind 'server' device row and an outbound ONVIF request per ptz command.
++ const onvif = opts.onvif || createOnvif({ config, domain, hub, now, log, fetchImpl, env: opts.env });
const apiAuth = createApiAuth({ config, keys, userAuth });
const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
@@@ -93,7 +97,7 @@ function createApp(opts = {}) {
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
-- app.use(createWebRoutes(config, { domain, sim, limits, log }));
++ app.use(createWebRoutes(config, { domain, sim, onvif, limits, log }));
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@@ -112,7 +116,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
});
-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, userAuth, metrics });
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics });
return app;
}
ddiff --git a/server/config.js b/server/config.js
iindex 8dfa682..9b21af1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -9,6 +9,7 @@
* per-device publish key over WHIP).
*/
require('dotenv').config();
++const { isRobotId } = require('./util');
const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
@@@ -57,6 +58,45 @@ function frameAncestors(config) {
return ["'self'", ...config.embed.origins].join(' ');
}
++/**
++ * BOT_ONVIF_CAMERAS: the server-side cameras the `onvif` driver connects to (server/onvif), a JSON object
++ * keyed by robot id — each key names exactly one robot, so no camera is ever shared between robots. Each
++ * entry is the camera's ONVIF PTZ service URL plus the *names* of the secrets holding its user and password
++ * (username_ref/password_ref, an env var name or a secret store key); the values are read at request time
++ * and never live in the config or the profile. The URL must be http(s), on one host, and carry no embedded
++ * credentials. Anything else — including a key that is not a robot id ("*" included) — refuses to boot, so
++ * the connector can only ever POST to a camera someone configured deliberately for one robot.
++ */
++function parseOnvifCameras(value) {
++ const raw = String(value || '').trim();
++ if (!raw) return {};
++ let parsed;
++ try { parsed = JSON.parse(raw); } catch { throw new Error(`BOT_ONVIF_CAMERAS must be JSON: ${JSON.stringify(raw.slice(0, 80))}`); }
++ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('BOT_ONVIF_CAMERAS must be a JSON object keyed by robot id');
++ const out = {};
++ for (const [key, entry] of Object.entries(parsed)) {
++ if (!isRobotId(key)) throw new Error(`BOT_ONVIF_CAMERAS: ${JSON.stringify(key)} is not a robot id (rob_…): each entry names one robot, and no key is a wildcard`);
++ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`BOT_ONVIF_CAMERAS[${key}] must be an object`);
++ let u = null;
++ try { u = new URL(String(entry.url || '')); } catch { /* reported below */ }
++ if (!u || (u.protocol !== 'http:' && u.protocol !== 'https:')) throw new Error(`BOT_ONVIF_CAMERAS[${key}].url must be an http(s) ONVIF service URL`);
++ if (u.username || u.password) throw new Error(`BOT_ONVIF_CAMERAS[${key}].url must carry no credentials: name them with username_ref/password_ref`);
++ const ref = (name) => {
++ const v = entry[name];
++ if (v == null || v === '') return null;
++ if (typeof v !== 'string' || /\s/.test(v)) throw new Error(`BOT_ONVIF_CAMERAS[${key}].${name} must be a secret reference (an env var name or a secret store key)`);
++ return v;
++ };
++ const usernameRef = ref('username_ref');
++ const passwordRef = ref('password_ref');
++ if (!!usernameRef !== !!passwordRef) throw new Error(`BOT_ONVIF_CAMERAS[${key}] needs both username_ref and password_ref, or neither`);
++ const profileToken = entry.profile_token == null ? 'profile_1' : String(entry.profile_token);
++ if (!profileToken.trim()) throw new Error(`BOT_ONVIF_CAMERAS[${key}].profile_token must be a non-empty string`);
++ out[key] = { url: u.toString(), usernameRef, passwordRef, profileToken };
++ }
++ return out;
++}
++
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
@@@ -164,6 +204,14 @@ function loadConfig(env = process.env) {
media: {
whipBase: trim(env.BOT_WHIP_BASE || ''),
},
++ // Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by
++ // robot id (one camera per robot), plus the timeout each request gets. Credentials are secret
++ // references only (username_ref/password_ref), resolved when the request is built; the config never
++ // holds a value.
++ onvif: {
++ cameras: parseOnvifCameras(env.BOT_ONVIF_CAMERAS),
++ timeoutMs: Math.max(100, int(env.BOT_ONVIF_TIMEOUT_MS, 5000)),
++ },
// OpenRe.Stream (T15 R5): the publish key is the ingest key of the robot's OpenRe stream, the only kind
// OpenRe's WHIP worker admits. token is a Network service token holding openre.stream.read (the lookup
// by external ref), openre.stream.write and openre.key.rotate; it is never logged or returned. Either unset: devices pair without video (no
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 0153b36..6a8b0b1 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -399,6 +399,22 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
[hash, iso(now())]);
}
async function getDevice(id) { return db.maybe('SELECT * FROM devices WHERE id = $1', [id]); }
++ /**
++ * The device row for a server-side connector (server/onvif): a real kind 'server' row, so the owner sees
++ * the camera as a device of the robot, keyed by a deterministic id so a restart reuses it instead of
++ * adding one per boot. It holds no credential: the connector attaches in-process, never over /device, so
++ * the stored hash is of a random token nothing is ever given (and a revoked row is left alone).
++ */
++ async function ensureServerDevice({ id, robotId, name, drivers = [], capabilities = {} }) {
++ const inserted = await db.maybe(
++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, created_at, updated_at)
++ VALUES ($1, $2::jsonb, $3, 'server', NULL, $4::jsonb, $5::jsonb, $6, $7, $7)
++ ON CONFLICT (id) DO UPDATE SET robot_ids = EXCLUDED.robot_ids, name = EXCLUDED.name,
++ drivers = EXCLUDED.drivers, capabilities = EXCLUDED.capabilities, updated_at = EXCLUDED.updated_at
++ WHERE devices.revoked_at IS NULL RETURNING *`,
++ [id, JSON.stringify([robotId]), name, JSON.stringify(drivers), JSON.stringify(capabilities), hashSecret(token(32)), iso(now())]);
++ return inserted || db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
++ }
async function listDevicesForRobot(robotId) {
return db.many('SELECT * FROM devices WHERE robot_ids @> $1::jsonb ORDER BY created_at DESC', [JSON.stringify([robotId])]);
}
@@@ -846,7 +862,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
-- devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
++ devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
streaming: { get: streaming, set: setStreaming },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
ddiff --git a/server/index.js b/server/index.js
iindex 9fac354..221993d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -32,7 +32,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
-- const { domain, keys, outbox, usage, hub, sim } = app.locals;
++ const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;
keys.start();
const timers = [];
@@@ -51,8 +51,10 @@ async function main() {
});
server.keepAliveTimeout = 65_000;
server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));
-- // The robots whose profile's driver is `sim` get their in-process device back (server/sim).
++ // The robots whose profile's driver is `sim` get their in-process device back (server/sim); the ones whose
++ // driver is `onvif` get their configured camera attached server-side (server/onvif).
sim.startAll().then((n) => n && console.log(`[Bot] simulator: ${n} sim robots`), (e) => console.warn(`[Bot] simulator: ${e.message}`));
++ onvif.startAll().then((n) => n && console.log(`[Bot] onvif: ${n} cameras`), (e) => console.warn(`[Bot] onvif: ${e.message}`));
// systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs the stop
// steps in order (nothing new starts), drains the HTTP server, runs the close steps, then exits. The stop steps
@@@ -70,6 +72,7 @@ async function main() {
() => timers.forEach(clearInterval),
() => keys.stop(),
() => sim.stopAll(),
++ () => onvif.stopAll(),
() => hub.close().catch(() => {}),
() => outbox.stop(),
() => usage.stop(),
ddiff --git a/server/onvif/index.js b/server/onvif/index.js
nnew file mode 100644
iindex 0000000..6851b4f
---- /dev/null
++++ b/server/onvif/index.js
@@@ -0,0 +1,306 @@
++'use strict';
++
++/**
++ * The server-side ONVIF camera (camera.onvif, `mapping.driver` `onvif`): an in-process device attached to the
++ * hub with hub.attachSim, beside the simulator (server/sim), for a camera Bot talks to itself rather than a
++ * machine that opens a socket. Unlike the simulator it owns a real `kind: 'server'` device row
++ * (domain.devices.ensureServerDevice), so the owner sees the camera as a device of the robot.
++ *
++ * It passes the same gate: it answers hello/config, acks commands, mirrors the e-stop and stops at a motion
++ * command's deadline. A `ptz` command is translated to ONVIF ContinuousMove (Stop for zeros/halt), and a
++ * GetStatus after a move is parsed into the camera readout carried by telemetry:
++ *
++ * telemetry = { battery: null, sensors: {}, rtt_ms, camera: { transport: 'onvif', resolution, reachable,
++ * ptz: { pan, tilt, zoom } | null } }
++ *
++ * attach(robotId, profile) start the camera if its profile is an `onvif` one AND BOT_ONVIF_CAMERAS names
++ * that robot's id (no wildcard); idempotent → true/false
++ * startAll() every existing `onvif` robot at boot
++ * stop(robotId), stopAll(), running(robotId)
++ *
++ * Credentials are secret references only (username_ref/password_ref, an env var name or a secret store key,
++ * `resolveSecret` may be replaced): resolved when a request is built, sent as a WS-Security digest (the
++ * password never travels in clear), never stored and never logged. Every request goes to the one configured
++ * URL, on that one host, with a timeout and `redirect: 'error'` — a camera cannot steer Bot elsewhere.
++ */
++const crypto = require('crypto');
++const { getProfile, listProfiles } = require('../profiles');
++
++const isOnvifProfile = (profile) => !!(profile && profile.mapping && profile.mapping.driver === 'onvif');
++
++const SOAP_NS = 'http://www.w3.org/2003/05/soap-envelope';
++const PTZ_NS = 'http://www.onvif.org/ver20/ptz/wsdl';
++const TDS_NS = 'http://www.onvif.org/ver10/device/wsdl';
++const SCHEMA_NS = 'http://www.onvif.org/ver10/schema';
++const WSSE_NS = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd';
++const WSU_NS = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd';
++const PASSWORD_DIGEST = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest';
++const NONCE_ENCODING = 'http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary';
++// A camera answer is read at most this far; a GetStatus reply is a few KB, so this only ever cuts an
++// endless or bogus body, which becomes an error instead of a request that never finishes.
++const MAX_RESPONSE_BYTES = 64 * 1024;
++
++const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
++const esc = (s) => String(s).replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' }[c]));
++
++/**
++ * Read a fetch Response body up to `max` bytes, aborting the stream as soon as it grows past that. A camera
++ * that streams an endless or oversized answer becomes an error in bounded time, never a request that hangs.
++ */
++async function readCapped(res, max) {
++ if (!res.body || typeof res.body.getReader !== 'function') {
++ const text = await res.text().catch(() => '');
++ if (Buffer.byteLength(text, 'utf8') > max) throw new Error(`the camera answer exceeded ${max} bytes`);
++ return text;
++ }
++ const reader = res.body.getReader();
++ const chunks = [];
++ let total = 0;
++ for (;;) {
++ const { done, value } = await reader.read();
++ if (done) break;
++ total += value.byteLength;
++ if (total > max) {
++ reader.cancel().catch(() => {}); // aborts the rest of the body; do not wait for it
++ throw new Error(`the camera answer exceeded ${max} bytes`);
++ }
++ chunks.push(Buffer.from(value));
++ }
++ return Buffer.concat(chunks).toString('utf8');
++}
++
++/** The camera's UTC time from a GetSystemDateAndTime answer, in epoch ms, or null when none can be read. */
++function parseCameraTime(xml) {
++ const utc = /<[^>]*UTCDateTime[^>]*>([\s\S]*?)<\/[^>]*UTCDateTime>/.exec(xml);
++ const src = utc ? utc[1] : xml;
++ const part = (tag) => { const m = new RegExp(`<[^>]*${tag}[^>]*>\\s*(\\d+)\\s*<`).exec(src); return m ? Number(m[1]) : null; };
++ const [year, month, day, hour, minute, second] = ['Year', 'Month', 'Day', 'Hour', 'Minute', 'Second'].map(part);
++ if ([year, month, day, hour, minute, second].some((v) => v == null)) return null;
++ const at = Date.UTC(year, month - 1, day, hour, minute, second);
++ return Number.isFinite(at) ? at : null;
++}
++
++function createOnvif({ config, domain, hub, now = () => Date.now(), log = console, fetchImpl = globalThis.fetch, env = process.env, resolveSecret = null } = {}) {
++ const cameras = new Map(); // robot_id → connector state
++ const opts = config.onvif || {};
++ const timeoutMs = opts.timeoutMs || 5000;
++ const telemetryMs = Math.ceil(1000 / Math.max(1, config.control.telemetryHz)) + 20; // just above the hub's sample window
++ const heartbeatMs = Math.max(50, Math.floor(config.device.heartbeatMs / 2));
++ const every = (ms, fn) => { const t = setInterval(() => { try { fn(); } catch (e) { log.warn(`[Bot] onvif: ${e.message}`); } }, ms); if (t.unref) t.unref(); return t; };
++
++ const cameraFor = (robotId) => opts.cameras[robotId] || null;
++ const secret = resolveSecret || ((ref) => (ref ? env[ref] : null));
++
++ // ── Attach and lifecycle ──────────────────────────────────────────────────────────────────────
++ function start(robot, profile, camera, device) {
++ const cam = { robotId: robot.id, robot, device, camera, profile, link: null, timers: [], stopTimer: null, estop: !!robot.estop_latched, readout: null, reachable: false, rttMs: null, clockOffsetMs: 0, at: now() };
++ cameras.set(robot.id, cam);
++ connect(cam);
++ cam.timers.push(every(heartbeatMs, () => { if (cam.link) cam.link.deliver({ type: 'heartbeat', t: now(), rtt_ms: cam.rttMs }); }));
++ cam.timers.push(every(telemetryMs, () => telemetry(cam)));
++ return cam;
++ }
++ function connect(cam) {
++ cam.link = hub.attachSim(cam.device, { onFrame: (f) => onFrame(cam, f), onClose: () => halt(cam) });
++ // Sync the camera's clock (unauthenticated, as ONVIF allows) before the first authenticated request,
++ // so a camera whose clock is a few seconds off still accepts the WS-Security digest.
++ if (cam.link) cam.link.ready.then(() => syncClock(cam), () => {}).then(() => { refreshStatus(cam); telemetry(cam); });
++ }
++
++ /** Start the camera for a robot whose profile's driver is `onvif` and that BOT_ONVIF_CAMERAS names (idempotent). */
++ async function attach(robotOrId, profile) {
++ const robot = typeof robotOrId === 'string' ? await domain.robots.get(robotOrId) : robotOrId;
++ if (!robot) return false;
++ if (cameras.has(robot.id)) return true;
++ if (profile === undefined) {
++ const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
++ profile = row && row.profile;
++ }
++ if (!isOnvifProfile(profile)) return false;
++ const camera = cameraFor(robot.id);
++ if (!camera) return false;
++ const resolution = (profile.camera && profile.camera.resolution) || null;
++ const device = await domain.devices.ensureServerDevice({
++ id: `dev_onvif_${robot.id}`, robotId: robot.id, name: 'onvif camera',
++ drivers: ['onvif'], capabilities: { camera: { transport: 'onvif', resolution } },
++ });
++ if (!device) { log.warn(`[Bot] onvif: no device row for ${robot.id}`); return false; }
++ start(robot, profile, camera, device);
++ return true;
++ }
++
++ async function startAll() {
++ const ids = (await listProfiles(domain.db)).filter((r) => isOnvifProfile(r.profile)).map((r) => r.id);
++ if (!ids.length) return 0;
++ const robots = await domain.db.many('SELECT * FROM robots WHERE profile_id = ANY($1)', [ids]);
++ let n = 0;
++ for (const robot of robots) if (await attach(robot)) n++;
++ return n;
++ }
++
++ function stop(robotId) {
++ const cam = cameras.get(robotId);
++ if (!cam) return;
++ cameras.delete(robotId);
++ for (const t of cam.timers) clearInterval(t);
++ stopTimerClear(cam);
++ cam.link = null;
++ hub.detachSim(cam.device.id);
++ }
++ function stopAll() { for (const id of [...cameras.keys()]) stop(id); }
++
++ // ── Frames from the hub ───────────────────────────────────────────────────────────────────────
++ function onFrame(cam, f) {
++ if (f.type === 'config') { cam.estop = !!f.estop_latched; if (cam.estop) halt(cam); return; }
++ if (f.type === 'estop') { cam.estop = !!f.latched; if (cam.estop) halt(cam); return; }
++ if (f.type !== 'command') return;
++ const reply = (fields) => cam.link && cam.link.deliver({ id: f.id, ...fields });
++ if (f.kind === 'halt') { halt(cam); return reply({ type: 'ack' }); }
++ if (cam.estop) return reply({ type: 'nack', fault_code: 'estop_latched' });
++ if (f.kind !== 'ptz') return reply({ type: 'nack', fault_code: 'bot.unknown_command' });
++ return ptz(cam, f, reply);
++ }
++
++ async function ptz(cam, f, reply) {
++ const v = f.value || {};
++ const axes = { pan: num(v.pan), tilt: num(v.tilt), zoom: num(v.zoom) };
++ try {
++ if (!axes.pan && !axes.tilt && !axes.zoom) await request(cam, stopBody(cam));
++ else { await request(cam, moveBody(cam, axes)); scheduleStop(cam, f.deadline_ms); }
++ if (axes.pan || axes.tilt || axes.zoom) refreshStatus(cam); // the readout follows the move
++ return reply({ type: 'ack' });
++ } catch (e) {
++ return reply({ type: 'nack', fault_code: 'bot.onvif_unreachable' });
++ }
++ }
++
++ function halt(cam) {
++ stopTimerClear(cam);
++ stopWithRetry(cam);
++ }
++ function stopTimerClear(cam) { if (cam.stopTimer) clearTimeout(cam.stopTimer); cam.stopTimer = null; }
++ /** A Stop is safety-critical: try once, retry a failure once, then log the robot id (never the URL or secrets). */
++ async function stopWithRetry(cam) {
++ try { await request(cam, stopBody(cam)); return; }
++ catch { /* one retry below */ }
++ try { await request(cam, stopBody(cam)); }
++ catch { log.warn(`[Bot] onvif: stop failed for robot ${cam.robotId}`); }
++ }
++ /**
++ * The deadman: a ContinuousMove stops by itself at the command's deadline unless a newer one arrived. A
++ * frame without a finite deadline (an older or direct caller) still gets one — the robot's own
++ * maxCommandMs — so a continuous move is never left running.
++ */
++ function scheduleStop(cam, deadline) {
++ stopTimerClear(cam);
++ const at = Number.isFinite(deadline) ? deadline : now() + camMaxCommandMs(cam);
++ cam.stopTimer = setTimeout(() => { cam.stopTimer = null; stopWithRetry(cam); }, Math.max(0, at - now()));
++ if (cam.stopTimer.unref) cam.stopTimer.unref();
++ }
++ function camMaxCommandMs(cam) {
++ try { return domain.control.effectiveLimits(cam.robot, cam.profile).maxCommandMs; }
++ catch { return config.control.maxCommandMs; }
++ }
++
++ // ── ONVIF over HTTP (the one configured host, with a timeout) ─────────────────────────────────
++ async function request(cam, envelope) {
++ const started = now();
++ let res;
++ try {
++ res = await fetchImpl(cam.camera.url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/soap+xml; charset=utf-8', Accept: 'application/soap+xml' },
++ body: envelope,
++ redirect: 'error', // never follow a camera to another host
++ signal: AbortSignal.timeout(timeoutMs),
++ });
++ } catch (e) {
++ cam.reachable = false;
++ throw new Error(`the camera did not answer within ${timeoutMs} ms`);
++ }
++ cam.rttMs = Math.max(0, Math.round(now() - started));
++ if (!res.ok) { cam.reachable = false; throw new Error(`the camera answered ${res.status}`); }
++ let text;
++ try { text = await readCapped(res, MAX_RESPONSE_BYTES); }
++ catch (e) { cam.reachable = false; throw e; }
++ cam.reachable = true;
++ return text;
++ }
++
++ async function refreshStatus(cam) {
++ try {
++ const xml = await request(cam, statusBody(cam));
++ if (!/<[^>]*GetStatusResponse[\s>]/.test(xml)) return; // never read a position out of any other reply
++ const read = parseStatus(xml);
++ if (read) cam.readout = read;
++ } catch { /* telemetry carries reachable: false until the camera answers again */ }
++ }
++
++ /**
++ * Ask the camera for its time once (GetSystemDateAndTime is unauthenticated, as ONVIF allows) and remember
++ * the offset, so the WS-Security Created matches the camera's clock and a camera a few seconds off still
++ * accepts the digest. A camera without the call, or an answer we cannot read, leaves local time in place.
++ */
++ async function syncClock(cam) {
++ try {
++ const xml = await request(cam, envelope('', `<tds:GetSystemDateAndTime xmlns:tds="${TDS_NS}"/>`));
++ const at = parseCameraTime(xml);
++ if (at != null) cam.clockOffsetMs = at - now();
++ } catch { /* keep local time */ }
++ }
++
++ function telemetry(cam) {
++ if (!cam.link) return;
++ cam.link.deliver({
++ type: 'telemetry',
++ battery: null,
++ sensors: {},
++ camera: {
++ transport: 'onvif',
++ resolution: (cam.profile.camera && cam.profile.camera.resolution) || null,
++ reachable: cam.reachable,
++ ptz: cam.readout,
++ },
++ rtt_ms: cam.rttMs,
++ });
++ }
++
++ // ── SOAP, with the credentials resolved only here and sent as a WS-Security digest ─────────────
++ function securityHeader(cam) {
++ const username = secret(cam.camera.usernameRef);
++ const password = secret(cam.camera.passwordRef);
++ if (!username || !password) return '';
++ const nonce = crypto.randomBytes(16);
++ const created = new Date(now() + (cam.clockOffsetMs || 0)).toISOString();
++ const digest = crypto.createHash('sha1').update(Buffer.concat([nonce, Buffer.from(created), Buffer.from(String(password))])).digest('base64');
++ return `<s:Header><wsse:Security xmlns:wsse="${WSSE_NS}" xmlns:wsu="${WSU_NS}" s:mustUnderstand="1">`
++ + `<wsse:UsernameToken><wsse:Username>${esc(username)}</wsse:Username>`
++ + `<wsse:Password Type="${PASSWORD_DIGEST}">${digest}</wsse:Password>`
++ + `<wsse:Nonce EncodingType="${NONCE_ENCODING}">${nonce.toString('base64')}</wsse:Nonce>`
++ + `<wsu:Created>${created}</wsu:Created></wsse:UsernameToken></wsse:Security></s:Header>`;
++ }
++ const envelope = (header, body) => `<?xml version="1.0" encoding="UTF-8"?>`
++ + `<s:Envelope xmlns:s="${SOAP_NS}" xmlns:tptz="${PTZ_NS}" xmlns:tt="${SCHEMA_NS}">${header}<s:Body>${body}</s:Body></s:Envelope>`;
++ const token = (cam) => `<tptz:ProfileToken>${esc(cam.camera.profileToken)}</tptz:ProfileToken>`;
++
++ function moveBody(cam, a) {
++ const velocity = [];
++ if (a.pan || a.tilt) velocity.push(`<tt:PanTilt x="${a.pan}" y="${a.tilt}"/>`);
++ if (a.zoom) velocity.push(`<tt:Zoom x="${a.zoom}"/>`);
++ return envelope(securityHeader(cam), `<tptz:ContinuousMove>${token(cam)}<tptz:Velocity>${velocity.join('')}</tptz:Velocity></tptz:ContinuousMove>`);
++ }
++ const stopBody = (cam) => envelope(securityHeader(cam), `<tptz:Stop>${token(cam)}<tptz:PanTilt>true</tptz:PanTilt><tptz:Zoom>true</tptz:Zoom></tptz:Stop>`);
++ const statusBody = (cam) => envelope(securityHeader(cam), `<tptz:GetStatus>${token(cam)}</tptz:GetStatus>`);
++
++ /** The PTZ position from a GetStatus answer: { pan, tilt, zoom } when one is present, else null. */
++ function parseStatus(xml) {
++ const pt = /<[^>]*PanTilt[^>]*\bx="([^"]+)"[^>]*\by="([^"]+)"/.exec(xml);
++ const z = /<[^>]*Zoom[^>]*\bx="([^"]+)"/.exec(xml);
++ if (!pt && !z) return null;
++ return { pan: pt ? num(pt[1]) : 0, tilt: pt ? num(pt[2]) : 0, zoom: z ? num(z[1]) : 0 };
++ }
++
++ return { attach, startAll, stop, stopAll, running: (robotId) => cameras.has(robotId), isOnvifProfile, cameraFor };
++}
++
++module.exports = { createOnvif, isOnvifProfile };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 1da60b1..6f556bb 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -33,7 +33,7 @@ const VERSION = require('../../package.json').version;
const PUBLIC = path.join(__dirname, '..', '..', 'public');
const HOLD_RESEND_MS = 150;
--function createWebRoutes(config, { domain = null, sim = null, limits = null, log = console } = {}) {
++function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, log = console } = {}) {
const r = express.Router();
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
@@@ -53,7 +53,13 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
const row = await getProfile(domain.db, robot.profile_id, robot.profile_version);
return row ? row.profile : null;
};
-- const startSim = (robot, profile) => (sim ? sim.attach(robot, profile).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; }) : Promise.resolve(false));
++ // A sim or configured-ONVIF robot needs no machine to install: attach its in-process device if the profile
++ // is one (and, for ONVIF, BOT_ONVIF_CAMERAS names it). Anything else falls through to the pairing page.
++ const attachInProcess = async (robot, profile) => {
++ if (sim && await sim.attach(robot, profile).catch((e) => { log.warn(`[Bot] simulator for ${robot.id}: ${e.message}`); return false; })) return true;
++ if (onvif && await onvif.attach(robot, profile).catch((e) => { log.warn(`[Bot] onvif for ${robot.id}: ${e.message}`); return false; })) return true;
++ return false;
++ };
// Counted by who is signed in (req.principal, as /api/v1 counts), or by address before the sign-in redirect.
const manage = limits
? [(req, res, next) => { if (req.viewer && !req.principal) req.principal = req.viewer; next(); }, limits('bot.robot.manage', { minute: 30, hour: 300 })]
@@@ -105,7 +111,7 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
}
// A simulated robot needs no machine: straight to its panel. Otherwise the code create minted is shown here.
const profile = await profileOf(robot);
-- if (await startSim(robot, profile)) return res.redirect(303, `/panel/${robot.id}`);
++ if (await attachInProcess(robot, profile)) return res.redirect(303, `/panel/${robot.id}`);
return res.status(201).type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile }));
}));
@@@ -175,7 +181,7 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
return role || (robot.access_policy === 'queue' ? 'queue' : null);
}
async function sendPanel(res, robot, profile, role, mode = 'page', signedIn = true) {
-- await startSim(robot, profile);
++ await attachInProcess(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
// Only the owner's own page shows the streaming switches; an OpenRe that does not answer leaves them
// greyed out with a reason instead of failing the panel.
ddiff --git a/test/onvif.test.js b/test/onvif.test.js
nnew file mode 100644
iindex 0000000..dccb817
---- /dev/null
++++ b/test/onvif.test.js
@@@ -0,0 +1,355 @@
++'use strict';
++// The server-side ONVIF camera (camera.onvif, server/onvif): an in-process device attached beside the
++// simulator, but with a real `kind: 'server'` device row and one outbound ONVIF request per ptz command.
++// Against a stub ONVIF endpoint this checks a ptz command becomes a ContinuousMove (with the credentials as a
++// WS-Security digest, never in clear) and that telemetry carries the camera's GetStatus readout. The request
++// only ever goes to the configured host, with a timeout, and BOT_ONVIF_CAMERAS refuses a URL that would
++// carry credentials, a non-http(s) scheme, or a key that is not a robot id (no wildcard) at boot.
++//
++// Also, away from the stub endpoint: an oversized answer is an error rather than a hang; a move with no
++// deadline still gets a Stop at the robot's maxCommandMs; a failed Stop is retried once and then logged with
++// the robot id alone; and a camera whose clock is minutes ahead gets a WS-Security Created matching its clock.
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/app');
++const { loadConfig } = require('../server/config');
++const { createOnvif } = require('../server/onvif');
++
++const CAM_USER = 'onvif-operator';
++const CAM_PASS = 'sup3r-secret-pass';
++const ROB = 'rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X'; // an id a robot could have; "*" and other keys are refused
++const TT = 'http://www.onvif.org/ver10/schema';
++const TDS = 'http://www.onvif.org/ver10/device/wsdl';
++
++const statusXml = '<?xml version="1.0" encoding="UTF-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope">'
++ + '<s:Body><tptz:GetStatusResponse xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl"><tptz:PTZStatus>'
++ + '<tt:Position xmlns:tt="' + TT + '"><tt:PanTilt x="0.25" y="-0.5"/><tt:Zoom x="0.1"/></tt:Position>'
++ + '<tt:MoveStatus><tt:PanTilt>IDLE</tt:PanTilt><tt:Zoom>IDLE</tt:Zoom></tt:MoveStatus>'
++ + '</tptz:PTZStatus></tptz:GetStatusResponse></s:Body></s:Envelope>';
++const okXml = '<?xml version="1.0" encoding="UTF-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body/></s:Envelope>';
++
++/** A GetSystemDateAndTime answer whose UTC clock is `skewMs` from the local one. */
++function dateTimeXml(skewMs) {
++ const d = new Date(Date.now() + skewMs);
++ return '<?xml version="1.0" encoding="UTF-8"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body>'
++ + '<tds:GetSystemDateAndTimeResponse xmlns:tds="' + TDS + '"><tds:SystemDateAndTime>'
++ + '<tt:UTCDateTime xmlns:tt="' + TT + '"><tt:Date>'
++ + `<tt:Year>${d.getUTCFullYear()}</tt:Year><tt:Month>${d.getUTCMonth() + 1}</tt:Month><tt:Day>${d.getUTCDate()}</tt:Day></tt:Date>`
++ + `<tt:Time><tt:Hour>${d.getUTCHours()}</tt:Hour><tt:Minute>${d.getUTCMinutes()}</tt:Minute><tt:Second>${d.getUTCSeconds()}</tt:Second></tt:Time>`
++ + '</tt:UTCDateTime></tds:SystemDateAndTime></tds:GetSystemDateAndTimeResponse></s:Body></s:Envelope>';
++}
++
++/** A stub ONVIF PTZ service: records every request and answers GetStatus, GetSystemDateAndTime and moves. */
++function startCamera({ skewMs = 0 } = {}) {
++ return new Promise((resolve) => {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (d) => chunks.push(d));
++ req.on('end', () => {
++ const body = Buffer.concat(chunks).toString('utf8');
++ calls.push({ method: req.method, path: req.url, host: req.headers.host, contentType: req.headers['content-type'] || '', body });
++ res.writeHead(200, { 'Content-Type': 'application/soap+xml' });
++ if (body.includes('GetSystemDateAndTime')) return res.end(dateTimeXml(skewMs));
++ if (body.includes('GetStatus')) return res.end(statusXml);
++ res.end(okXml);
++ });
++ });
++ server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}/onvif/ptz_service`, calls, close: () => new Promise((r) => server.close(r)) }));
++ });
++}
++
++/**
++ * A stub camera that streams an oversized body (256 KB) and then leaves the connection open. Reading it must
++ * cut off at the cap in bounded time; without the cap, res.text() would wait for an end that never comes.
++ */
++function startHugeCamera() {
++ return new Promise((resolve) => {
++ const server = http.createServer((req, res) => {
++ req.on('data', () => {});
++ req.on('end', () => {
++ res.writeHead(200, { 'Content-Type': 'application/soap+xml' });
++ res.on('error', () => {});
++ res.write(Buffer.alloc(256 * 1024, 0x61)); // 256 KB of 'a', then no end()
++ });
++ });
++ server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}/onvif/ptz_service`,
++ close: () => new Promise((r) => { if (server.closeAllConnections) server.closeAllConnections(); server.close(r); }),
++ }));
++ });
++}
++
++async function poll(fn, ms) {
++ const end = Date.now() + ms;
++ for (;;) {
++ const v = await fn();
++ if (v) return v;
++ if (Date.now() > end) return null;
++ await new Promise((r) => setTimeout(r, 25));
++ }
++}
++
++const cameraEntry = (url) => ({ url, usernameRef: 'CAM_USER', passwordRef: 'CAM_PASS', profileToken: 'profile_1' });
++
++/**
++ * A connector wired to fakes, so a raw command frame (with no deadline_ms, as only an older or direct caller
++ * would send) can be pushed through the same onFrame a hub uses.
++ */
++function unitOnvif({ fetchImpl, log = { warn() {} } } = {}) {
++ const robot = { id: ROB, limits: {} };
++ const profile = { mapping: { driver: 'onvif' }, camera: { resolution: null }, limits: {} };
++ const camera = { url: 'http://cam.test/onvif/ptz_service', usernameRef: null, passwordRef: null, profileToken: 'profile_1' };
++ const links = new Map();
++ const hub = {
++ attachSim(device, { onFrame }) { const link = { onFrame, ready: Promise.resolve(), deliver() {} }; links.set(device.id, link); return link; },
++ detachSim(id) { links.delete(id); },
++ };
++ const domain = {
++ robots: { get: async (id) => (id === robot.id ? robot : null) },
++ devices: { ensureServerDevice: async () => ({ id: `dev_onvif_${robot.id}`, robot_ids: [robot.id], kind: 'server' }) },
++ control: { effectiveLimits: () => ({ maxCommandMs: 80, maxSpeed: 1, maxTurn: 1, cooldownMs: 0 }) },
++ db: {},
++ };
++ const config = { onvif: { cameras: { [ROB]: camera }, timeoutMs: 2000 }, control: { telemetryHz: 2, maxCommandMs: 80 }, device: { heartbeatMs: 1000 } };
++ const onvif = createOnvif({ config, domain, hub, log, fetchImpl: fetchImpl || (async () => new Response(okXml, { status: 200 })) });
++ return { onvif, robot, profile, camera, links };
++}
++
++(async () => {
++ await check('BOT_ONVIF_CAMERAS is validated at boot: a non-robot key (no wildcard), a bad URL, scheme or credential refuses', () => {
++ const parse = (v) => { try { loadConfig({ BOT_ONVIF_CAMERAS: v }); return null; } catch (e) { return e.message; } };
++ assert.match(parse('not json'), /BOT_ONVIF_CAMERAS must be JSON/);
++ assert.match(parse('[]'), /must be a JSON object/);
++ // A camera names one robot: "*" and any other non-robot key refuse, so no camera is ever shared.
++ assert.match(parse(JSON.stringify({ '*': { url: 'http://cam/x' } })), /not a robot id/);
++ assert.match(parse(JSON.stringify({ rob_x: { url: 'http://cam/x' } })), /not a robot id/);
++ assert.match(parse(JSON.stringify({ '': { url: 'http://cam/x' } })), /not a robot id/);
++ assert.match(parse(JSON.stringify({ [ROB]: { url: 'ftp://cam/x' } })), /http\(s\)/);
++ assert.match(parse(JSON.stringify({ [ROB]: { url: 'http://user:pw@cam/x' } })), /must carry no credentials/);
++ assert.match(parse(JSON.stringify({ [ROB]: { url: 'http://cam/x', username_ref: 'U' } })), /both username_ref and password_ref/);
++ assert.match(parse(JSON.stringify({ [ROB]: { url: 'http://cam/x', password_ref: 'P' } })), /both username_ref and password_ref/);
++ // The ref, not the value, is what config keeps.
++ const c = loadConfig({ BOT_ONVIF_CAMERAS: JSON.stringify({ [ROB]: { url: 'http://cam/x', username_ref: 'CAM_USER', password_ref: 'CAM_PASS' } }) });
++ assert.deepStrictEqual(c.onvif.cameras[ROB], { url: 'http://cam/x', usernameRef: 'CAM_USER', passwordRef: 'CAM_PASS', profileToken: 'profile_1' });
++ assert.strictEqual(c.onvif.cameras['*'], undefined, 'no wildcard entry survives parsing');
++ assert.strictEqual(JSON.stringify(c).includes(CAM_PASS), false, 'no secret value appears in the config');
++ });
++
++ const cam = await startCamera();
++ const skewed = await startCamera({ skewMs: 5 * 60 * 1000 }); // the camera's clock is 5 minutes ahead
++ const huge = await startHugeCamera();
++ process.env.CAM_USER = CAM_USER;
++ process.env.CAM_PASS = CAM_PASS;
++ const t = await boot({
++ env: {
++ BOT_ONVIF_CAMERAS: JSON.stringify({ [ROB]: cameraEntry(cam.url) }),
++ BOT_ONVIF_TIMEOUT_MS: '8000',
++ },
++ });
++ const alex = t.network.newUser('alex');
++ // The boot camera names one placeholder robot; the real robot ids are only known after the app boots, so
++ // the tests point the connector at a camera per robot the way an operator's config would.
++ const plot = (robotId, url = cam.url) => { t.config.onvif.cameras[robotId] = cameraEntry(url); return robotId; };
++
++ await check('an onvif robot attaches in-process: a kind:server device row, no sim count, GetStatus readout', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ assert.strictEqual(await t.app.locals.onvif.attach(plot(robot.id)), true);
++ assert.strictEqual(t.app.locals.onvif.running(robot.id), true);
++ // The device row: a server device of this robot, drivers ['onvif'], and no credential handed out.
++ const row = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [`dev_onvif_${robot.id}`]);
++ assert.ok(row, 'a devices row was written');
++ assert.strictEqual(row.kind, 'server');
++ assert.deepStrictEqual(row.robot_ids, [robot.id]);
++ assert.deepStrictEqual(row.drivers, ['onvif']);
++ assert.strictEqual(row.capabilities.camera.transport, 'onvif');
++ assert.strictEqual(row.capabilities.camera.resolution, '1920x1080');
++ assert.strictEqual(row.last_seen, null, 'a server camera is not reported as a device seen over /device');
++ assert.ok(!JSON.stringify(row).includes(CAM_PASS), 'the device row holds no camera secret');
++ // It passes the hub like a device, but is not counted among machines (like the simulator).
++ assert.strictEqual(t.hub.isOnline(`dev_onvif_${robot.id}`), true);
++ assert.strictEqual(t.hub.onlineCount(), 0, 'a server camera is not a machine anyone runs');
++ // The clock sync is asked once, unauthenticated (the camera answers; no token on that request).
++ assert.ok(await poll(() => cam.calls.some((c) => c.body.includes('GetSystemDateAndTime')), 2000), 'GetSystemDateAndTime was called');
++ const sync = cam.calls.find((c) => c.body.includes('GetSystemDateAndTime'));
++ assert.ok(!sync.body.includes('UsernameToken') && !sync.body.includes('#PasswordDigest'), 'the clock sync carries no credentials');
++ // Telemetry carries the camera readout parsed from GetStatus.
++ const state = await poll(() => {
++ const s = t.hub.deviceState(`dev_onvif_${robot.id}`);
++ return s.telemetry && s.telemetry.camera && s.telemetry.camera.ptz ? s : null;
++ }, 2000);
++ assert.ok(state, 'telemetry with a readout within 2 s');
++ assert.deepStrictEqual(state.telemetry.camera.ptz, { pan: 0.25, tilt: -0.5, zoom: 0.1 });
++ assert.strictEqual(state.telemetry.camera.reachable, true);
++ assert.ok(Number.isFinite(state.telemetry.rtt_ms), 'the readout carries the round trip');
++ assert.ok(cam.calls.some((c) => c.body.includes('GetStatus')), 'the readout came from a GetStatus request');
++ });
++
++ await check('a camera configured for one robot is never attached to another (no wildcard fallback)', async () => {
++ const a = (await t.robot(alex, { profile_id: 'camera.onvif' })).robot;
++ const b = (await t.robot(alex, { profile_id: 'camera.onvif' })).robot;
++ plot(a.id);
++ assert.strictEqual(await t.app.locals.onvif.attach(a.id), true, 'the named robot attaches');
++ assert.strictEqual(await t.app.locals.onvif.attach(b.id), false, 'a robot with no camera entry is left alone');
++ assert.strictEqual(t.app.locals.onvif.running(b.id), false);
++ });
++
++ await check('a ptz command becomes an ONVIF ContinuousMove to the configured host, and is acked', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ assert.strictEqual(await t.app.locals.onvif.attach(plot(robot.id)), true);
++ const before = cam.calls.length;
++ const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ op.send({ type: 'join', robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'joined');
++ op.send({ type: 'command', id: 'p1', kind: 'ptz', value: { pan: 0.5, tilt: -0.25, zoom: 0 } });
++ const res = await op.waitFor((m) => m.type === 'command_result' && m.id === 'p1');
++ assert.strictEqual(res.result, 'ack', res.reason || JSON.stringify(res));
++
++ const calls = cam.calls.slice(before);
++ const move = calls.find((c) => c.body.includes('ContinuousMove'));
++ assert.ok(move, 'the command became a ContinuousMove');
++ assert.strictEqual(move.method, 'POST');
++ assert.strictEqual(move.path, '/onvif/ptz_service');
++ assert.match(move.contentType, /application\/soap\+xml/);
++ assert.ok(move.body.includes('<tt:PanTilt x="0.5" y="-0.25"/>'), 'the pan/tilt velocity is translated');
++ // Every request went to the one configured host, soap only.
++ for (const c of cam.calls) assert.strictEqual(c.host, new URL(cam.url).host, 'no request left the configured host');
++ // Credentials ride as a WS-Security digest, never the clear password, and are never logged.
++ assert.ok(move.body.includes('<wsse:UsernameToken>') && move.body.includes(`<wsse:Username>${CAM_USER}</wsse:Username>`), 'the username is in the token');
++ assert.ok(move.body.includes('#PasswordDigest'), 'the password is digested');
++ assert.ok(!move.body.includes(CAM_PASS), 'the password is never sent in clear');
++ assert.ok(!t.logs.join('\n').includes(CAM_PASS), 'the password is never logged');
++ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.camera);
++ assert.ok(state && state.state.telemetry.camera.ptz, 'the operator sees the camera readout');
++ op.close();
++ });
++
++ await check('halt sends an ONVIF Stop; a ptz command nacks when the camera is unreachable', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ await t.app.locals.onvif.attach(plot(robot.id));
++ const before = cam.calls.length;
++ const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ op.send({ type: 'join', robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'joined');
++ op.send({ type: 'command', id: 'h1', kind: 'halt', value: {} });
++ const res = await op.waitFor((m) => m.type === 'command_result' && m.id === 'h1');
++ assert.strictEqual(res.result, 'ack');
++ assert.ok(await poll(() => cam.calls.slice(before).some((c) => c.body.includes('<tptz:Stop>')), 1000), 'a Stop reached the camera');
++
++ // The camera goes away: a ptz command is nacked, never left pending.
++ await cam.close();
++ op.send({ type: 'command', id: 'p2', kind: 'ptz', value: { zoom: 0.5 } });
++ const down = await op.waitFor((m) => m.type === 'command_result' && m.id === 'p2');
++ assert.strictEqual(down.result, 'nack');
++ assert.strictEqual(down.reason, 'bot.onvif_unreachable');
++ op.close();
++ });
++
++ await check('a profile that is not the onvif driver is never attached by the connector', async () => {
++ const { robot } = await t.robot(alex, { name: 'Rover' }); // sim.rover
++ assert.strictEqual(await t.app.locals.onvif.attach(robot.id), false, 'a sim robot is left to the simulator');
++ assert.strictEqual(t.app.locals.onvif.running(robot.id), false);
++ });
++
++ await check('startAll attaches every onvif robot that has a configured camera', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ plot(robot.id);
++ t.app.locals.onvif.stopAll();
++ const n = await t.app.locals.onvif.startAll();
++ assert.ok(n >= 1, `startAll attached the onvif robots (${n})`);
++ assert.strictEqual(t.app.locals.onvif.running(robot.id), true);
++ t.app.locals.onvif.stopAll();
++ assert.strictEqual(t.app.locals.onvif.running(robot.id), false);
++ assert.strictEqual(t.hub.isOnline(`dev_onvif_${robot.id}`), false);
++ });
++
++ await check('an oversized camera answer is an error in bounded time, not a hang', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ assert.strictEqual(await t.app.locals.onvif.attach(plot(robot.id, huge.url)), true);
++ const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ op.send({ type: 'join', robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'joined');
++ const started = Date.now();
++ op.send({ type: 'command', id: 'big', kind: 'ptz', value: { pan: 0.5 } });
++ const res = await op.waitFor((m) => m.type === 'command_result' && m.id === 'big', 5000);
++ assert.ok(res, 'the command was answered, not left pending');
++ assert.strictEqual(res.result, 'nack');
++ assert.ok(Date.now() - started < 4000, 'the oversized reply was cut off well before the request timeout');
++ op.close();
++ });
++
++ await check('a move with no finite deadline_ms is still followed by a Stop at the robot maxCommandMs', async () => {
++ const sent = [];
++ const u = unitOnvif({ fetchImpl: async (url, opts) => { sent.push(opts.body); return new Response(okXml, { status: 200 }); } });
++ await u.onvif.attach(u.robot, u.profile);
++ const link = u.links.get(`dev_onvif_${ROB}`);
++ link.onFrame({ type: 'command', id: 'm1', kind: 'ptz', value: { pan: 0.5 } }); // no deadline_ms
++ assert.ok(await poll(() => sent.some((b) => b.includes('<tptz:ContinuousMove>')), 1000), 'the move was sent');
++ assert.ok(await poll(() => sent.some((b) => b.includes('<tptz:Stop>')), 1500), 'a continuous move is never left running');
++ u.onvif.stopAll();
++ });
++
++ await check('a failed Stop is retried once, and a double failure logs the robot id alone', async () => {
++ // First Stop fails, the retry succeeds: two attempts, nothing logged.
++ const warns = [];
++ let stops = 0;
++ const retried = unitOnvif({
++ log: { warn: (...a) => warns.push(a.join(' ')) },
++ fetchImpl: async (url, opts) => {
++ if (opts.body.includes('<tptz:Stop>') && ++stops === 1) throw new Error('network down');
++ return new Response(okXml, { status: 200 });
++ },
++ });
++ await retried.onvif.attach(retried.robot, retried.profile);
++ retried.links.get(`dev_onvif_${ROB}`).onFrame({ type: 'command', id: 'h1', kind: 'halt', value: {} });
++ assert.ok(await poll(() => stops >= 2, 1500), `the failed Stop was retried (${stops})`);
++ assert.ok(!warns.some((w) => w.includes('stop failed')), 'a Stop that succeeds on retry is not logged');
++ retried.onvif.stopAll();
++
++ // Both attempts fail: warn once, with the robot id and nothing else.
++ const failedWarns = [];
++ const failed = unitOnvif({
++ log: { warn: (...a) => failedWarns.push(a.join(' ')) },
++ fetchImpl: async (url, opts) => {
++ if (opts.body.includes('<tptz:Stop>')) throw new Error(`cannot reach ${url}`);
++ return new Response(okXml, { status: 200 });
++ },
++ });
++ await failed.onvif.attach(failed.robot, failed.profile);
++ failed.links.get(`dev_onvif_${ROB}`).onFrame({ type: 'command', id: 'h2', kind: 'halt', value: {} });
++ assert.ok(await poll(() => failedWarns.some((w) => w.includes('stop failed')), 1500), 'the double failure was logged');
++ const line = failedWarns.find((w) => w.includes('stop failed'));
++ assert.ok(line.includes(ROB), 'the log names the robot');
++ assert.ok(!line.includes(failed.camera.url) && !line.includes('#PasswordDigest'), 'the log carries no URL or credentials');
++ failed.onvif.stopAll();
++ });
++
++ await check('a camera whose clock is 5 minutes ahead gets a Created matched to its clock', async () => {
++ const { robot } = await t.robot(alex, { profile_id: 'camera.onvif' });
++ assert.strictEqual(await t.app.locals.onvif.attach(plot(robot.id, skewed.url)), true);
++ assert.ok(await poll(() => skewed.calls.some((c) => c.body.includes('GetSystemDateAndTime')), 2000), 'the clock was read');
++ const other = skewed.calls.find((c) => c.body.includes('GetStatus'));
++ assert.ok(other, 'a GetStatus followed the clock sync');
++ const before = skewed.calls.length;
++ const op = await t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ op.send({ type: 'join', robot_id: robot.id });
++ await op.waitFor((m) => m.type === 'joined');
++ op.send({ type: 'command', id: 'skew1', kind: 'ptz', value: { pan: 0.5 } });
++ const res = await op.waitFor((m) => m.type === 'command_result' && m.id === 'skew1');
++ assert.strictEqual(res.result, 'ack');
++ const move = skewed.calls.slice(before).find((c) => c.body.includes('ContinuousMove'));
++ assert.ok(move, 'the move reached the skewed camera');
++ const created = /<wsu:Created>([^<]+)<\/wsu:Created>/.exec(move.body);
++ assert.ok(created, 'the move carries a WS-Security Created');
++ const delta = new Date(created[1]).getTime() - Date.now();
++ assert.ok(Math.abs(delta - 5 * 60 * 1000) < 5000, `Created tracks the camera clock, not the local one (delta ${delta} ms)`);
++ op.close();
++ });
++
++ await t.close();
++ await skewed.close();
++ await huge.close();
++ delete process.env.CAM_USER;
++ delete process.env.CAM_PASS;
++ done();
++})();