@camstack/addon-provider-onvif 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,624 @@
1
+ // src/onvif-client.ts
2
+ import { Cam } from "onvif";
3
+ var OnvifClient = class {
4
+ constructor(host, port, username, password, logger) {
5
+ this.host = host;
6
+ this.port = port;
7
+ this.username = username;
8
+ this.password = password;
9
+ this.logger = logger;
10
+ }
11
+ cam = null;
12
+ /** Connect to the camera */
13
+ async connect() {
14
+ return new Promise((resolve, reject) => {
15
+ this.cam = new Cam(
16
+ {
17
+ hostname: this.host,
18
+ port: this.port,
19
+ username: this.username,
20
+ password: this.password
21
+ },
22
+ (err) => {
23
+ if (err) {
24
+ reject(err);
25
+ } else {
26
+ this.logger.info(`Connected to ONVIF camera at ${this.host}:${this.port}`);
27
+ resolve();
28
+ }
29
+ }
30
+ );
31
+ });
32
+ }
33
+ /** Get device info */
34
+ async getDeviceInfo() {
35
+ return new Promise((resolve, reject) => {
36
+ if (!this.cam) return reject(new Error("Not connected"));
37
+ this.cam.getDeviceInformation((err, info) => {
38
+ if (err) reject(err);
39
+ else resolve(info);
40
+ });
41
+ });
42
+ }
43
+ /** Get RTSP stream URI */
44
+ async getStreamUri(profileToken) {
45
+ return new Promise((resolve, reject) => {
46
+ if (!this.cam) return reject(new Error("Not connected"));
47
+ const options = { protocol: "RTSP" };
48
+ if (profileToken) options.profileToken = profileToken;
49
+ this.cam.getStreamUri(options, (err, stream) => {
50
+ if (err) reject(err);
51
+ else resolve(stream.uri);
52
+ });
53
+ });
54
+ }
55
+ /** Get available media profiles */
56
+ async getProfiles() {
57
+ return new Promise((resolve, reject) => {
58
+ if (!this.cam) return reject(new Error("Not connected"));
59
+ this.cam.getProfiles((err, profiles) => {
60
+ if (err) reject(err);
61
+ else
62
+ resolve(
63
+ profiles.map((p) => ({
64
+ token: p.$.token ?? p.token,
65
+ name: p.name,
66
+ videoWidth: p.videoEncoderConfiguration?.resolution?.width,
67
+ videoHeight: p.videoEncoderConfiguration?.resolution?.height
68
+ }))
69
+ );
70
+ });
71
+ });
72
+ }
73
+ /** Get snapshot URI */
74
+ async getSnapshotUri(profileToken) {
75
+ return new Promise((resolve, reject) => {
76
+ if (!this.cam) return reject(new Error("Not connected"));
77
+ const options = {};
78
+ if (profileToken) options.profileToken = profileToken;
79
+ this.cam.getSnapshotUri(options, (err, res) => {
80
+ if (err) reject(err);
81
+ else resolve(res.uri);
82
+ });
83
+ });
84
+ }
85
+ /** Check if PTZ is supported */
86
+ hasPtz() {
87
+ return this.cam?.ptzUri != null;
88
+ }
89
+ /** PTZ move (continuous) */
90
+ async ptzMove(options) {
91
+ return new Promise((resolve, reject) => {
92
+ if (!this.cam) return reject(new Error("Not connected"));
93
+ this.cam.continuousMove(
94
+ {
95
+ x: options.x ?? 0,
96
+ y: options.y ?? 0,
97
+ zoom: options.zoom ?? 0,
98
+ timeout: 1e3
99
+ },
100
+ (err) => {
101
+ if (err) reject(err);
102
+ else resolve();
103
+ }
104
+ );
105
+ });
106
+ }
107
+ /** PTZ stop */
108
+ async ptzStop() {
109
+ return new Promise((resolve, reject) => {
110
+ if (!this.cam) return reject(new Error("Not connected"));
111
+ this.cam.stop({}, (err) => {
112
+ if (err) reject(err);
113
+ else resolve();
114
+ });
115
+ });
116
+ }
117
+ /** PTZ absolute move */
118
+ async ptzAbsoluteMove(options) {
119
+ return new Promise((resolve, reject) => {
120
+ if (!this.cam) return reject(new Error("Not connected"));
121
+ this.cam.absoluteMove(
122
+ {
123
+ x: options.x,
124
+ y: options.y,
125
+ zoom: options.zoom
126
+ },
127
+ (err) => {
128
+ if (err) reject(err);
129
+ else resolve();
130
+ }
131
+ );
132
+ });
133
+ }
134
+ /** Get PTZ presets */
135
+ async getPtzPresets() {
136
+ return new Promise((resolve, reject) => {
137
+ if (!this.cam) return reject(new Error("Not connected"));
138
+ this.cam.getPresets({}, (err, presets) => {
139
+ if (err) reject(err);
140
+ else {
141
+ const list = Array.isArray(presets) ? presets : [presets];
142
+ resolve(
143
+ list.filter(Boolean).map((p) => ({
144
+ token: p.$.token ?? String(p.token),
145
+ name: p.name ?? p.$.token ?? "Preset"
146
+ }))
147
+ );
148
+ }
149
+ });
150
+ });
151
+ }
152
+ /** Go to PTZ preset */
153
+ async gotoPreset(presetToken) {
154
+ return new Promise((resolve, reject) => {
155
+ if (!this.cam) return reject(new Error("Not connected"));
156
+ this.cam.gotoPreset({ preset: presetToken }, (err) => {
157
+ if (err) reject(err);
158
+ else resolve();
159
+ });
160
+ });
161
+ }
162
+ disconnect() {
163
+ this.cam = null;
164
+ }
165
+ };
166
+
167
+ // src/onvif-device.ts
168
+ var OnvifDevice = class {
169
+ constructor(client, config, ctx) {
170
+ this.client = client;
171
+ this.config = config;
172
+ this.id = `${config.providerId}/${config.cameraId}`;
173
+ this.name = config.cameraName;
174
+ this.providerId = config.providerId;
175
+ this.ctx = ctx;
176
+ const caps = ["camera"];
177
+ if (config.hasPtz) caps.push("panTiltZoom");
178
+ this.capabilities = caps;
179
+ this.capabilityMap.set("camera", this.createCamera());
180
+ if (config.hasPtz) this.capabilityMap.set("panTiltZoom", this.createPtz());
181
+ }
182
+ id;
183
+ name;
184
+ providerId;
185
+ type = "camera";
186
+ capabilities;
187
+ ctx;
188
+ capabilityMap = /* @__PURE__ */ new Map();
189
+ getCapability(cap) {
190
+ return this.capabilityMap.get(cap) ?? null;
191
+ }
192
+ hasCapability(cap) {
193
+ return this.capabilityMap.has(cap);
194
+ }
195
+ getState() {
196
+ return { online: true };
197
+ }
198
+ getMetadata() {
199
+ return {
200
+ manufacturer: this.config.manufacturer ?? "ONVIF",
201
+ model: this.config.model,
202
+ firmware: this.config.firmware
203
+ };
204
+ }
205
+ createCamera() {
206
+ const { config } = this;
207
+ return {
208
+ kind: "camera",
209
+ async getSnapshot() {
210
+ if (config.snapshotUrl) {
211
+ const res = await fetch(config.snapshotUrl);
212
+ return Buffer.from(await res.arrayBuffer());
213
+ }
214
+ return Buffer.alloc(0);
215
+ },
216
+ async getStreamOptions() {
217
+ const options = [
218
+ {
219
+ id: `${config.cameraId}_main`,
220
+ label: "Main",
221
+ protocol: "rtsp",
222
+ quality: "main",
223
+ url: config.rtspUrl
224
+ }
225
+ ];
226
+ if (config.subStreamUrl) {
227
+ options.push({
228
+ id: `${config.cameraId}_sub`,
229
+ label: "Sub",
230
+ protocol: "rtsp",
231
+ quality: "sub",
232
+ url: config.subStreamUrl
233
+ });
234
+ }
235
+ return options;
236
+ },
237
+ async getStreamUrl(option) {
238
+ return option.url ?? config.rtspUrl;
239
+ },
240
+ getConnectionMode() {
241
+ return "on-demand";
242
+ },
243
+ async setConnectionMode() {
244
+ }
245
+ };
246
+ }
247
+ createPtz() {
248
+ const { client } = this;
249
+ return {
250
+ kind: "panTiltZoom",
251
+ async move(cmd) {
252
+ await client.ptzMove({ x: cmd.pan, y: cmd.tilt, zoom: cmd.zoom, speed: cmd.speed });
253
+ },
254
+ async continuousMove(cmd) {
255
+ await client.ptzMove({ x: cmd.pan, y: cmd.tilt, zoom: cmd.zoom });
256
+ },
257
+ async stop() {
258
+ await client.ptzStop();
259
+ },
260
+ async getPresets() {
261
+ const presets = await client.getPtzPresets();
262
+ return presets.map((p) => ({ id: p.token, name: p.name }));
263
+ },
264
+ async goToPreset(presetId) {
265
+ await client.gotoPreset(presetId);
266
+ },
267
+ async goHome() {
268
+ await client.ptzAbsoluteMove({ x: 0, y: 0, zoom: 0 });
269
+ },
270
+ async getPosition() {
271
+ return { pan: 0, tilt: 0, zoom: 0 };
272
+ }
273
+ };
274
+ }
275
+ };
276
+
277
+ // src/onvif-discovery.ts
278
+ import { Discovery } from "onvif";
279
+ async function discoverOnvifCameras(timeout = 5e3) {
280
+ return new Promise((resolve) => {
281
+ const cameras = [];
282
+ Discovery.on("device", (cam) => {
283
+ cameras.push({
284
+ host: cam.hostname,
285
+ port: cam.port ?? 80,
286
+ name: cam.name,
287
+ manufacturer: cam.manufacturer,
288
+ model: cam.model,
289
+ scopes: cam.scopes
290
+ });
291
+ });
292
+ Discovery.probe({ timeout });
293
+ setTimeout(() => {
294
+ Discovery.removeAllListeners("device");
295
+ resolve(cameras);
296
+ }, timeout + 500);
297
+ });
298
+ }
299
+
300
+ // src/element-config-store.ts
301
+ var ElementConfigStore = class {
302
+ constructor(elementId, storage) {
303
+ this.elementId = elementId;
304
+ this.storage = storage;
305
+ }
306
+ cache = {};
307
+ listeners = /* @__PURE__ */ new Set();
308
+ loaded = false;
309
+ /** Load config from storage into cache. Called once on first access. */
310
+ async ensureLoaded() {
311
+ if (this.loaded) return;
312
+ if (!this.storage.structured) {
313
+ this.loaded = true;
314
+ return;
315
+ }
316
+ try {
317
+ const records = await this.storage.structured.query("config", {
318
+ where: { id: this.elementId },
319
+ limit: 1
320
+ });
321
+ if (records.length > 0) {
322
+ this.cache = records[0].data ?? {};
323
+ }
324
+ } catch {
325
+ }
326
+ this.loaded = true;
327
+ }
328
+ getAll() {
329
+ return { ...this.cache };
330
+ }
331
+ get(key) {
332
+ const parts = key.split(".");
333
+ let current = this.cache;
334
+ for (const part of parts) {
335
+ if (current == null || typeof current !== "object") return void 0;
336
+ current = current[part];
337
+ }
338
+ return current;
339
+ }
340
+ async set(key, value) {
341
+ await this.ensureLoaded();
342
+ setNestedValue(this.cache, key, value);
343
+ await this.persist();
344
+ this.notifyListeners();
345
+ }
346
+ async setAll(config) {
347
+ await this.ensureLoaded();
348
+ this.cache = { ...config };
349
+ await this.persist();
350
+ this.notifyListeners();
351
+ }
352
+ onChange(callback) {
353
+ this.listeners.add(callback);
354
+ return () => {
355
+ this.listeners.delete(callback);
356
+ };
357
+ }
358
+ /** Initialize from storage — called by ContextFactory after creation */
359
+ async load() {
360
+ await this.ensureLoaded();
361
+ }
362
+ /** Initialize with default values (doesn't overwrite existing) */
363
+ async loadDefaults(defaults) {
364
+ await this.ensureLoaded();
365
+ if (Object.keys(this.cache).length === 0) {
366
+ this.cache = { ...defaults };
367
+ await this.persist();
368
+ }
369
+ }
370
+ async persist() {
371
+ if (!this.storage.structured) return;
372
+ try {
373
+ const existing = await this.storage.structured.query("config", {
374
+ where: { id: this.elementId },
375
+ limit: 1
376
+ });
377
+ if (existing.length > 0) {
378
+ await this.storage.structured.update("config", this.elementId, this.cache);
379
+ } else {
380
+ await this.storage.structured.insert({
381
+ collection: "config",
382
+ id: this.elementId,
383
+ data: this.cache
384
+ });
385
+ }
386
+ } catch {
387
+ }
388
+ }
389
+ notifyListeners() {
390
+ const snapshot = this.getAll();
391
+ for (const listener of this.listeners) {
392
+ try {
393
+ listener(snapshot);
394
+ } catch {
395
+ }
396
+ }
397
+ }
398
+ };
399
+ function setNestedValue(obj, path, value) {
400
+ const parts = path.split(".");
401
+ let current = obj;
402
+ for (let i = 0; i < parts.length - 1; i++) {
403
+ const part = parts[i];
404
+ if (!(part in current) || typeof current[part] !== "object" || current[part] === null) {
405
+ current[part] = {};
406
+ }
407
+ current = current[part];
408
+ }
409
+ current[parts[parts.length - 1]] = value;
410
+ }
411
+
412
+ // src/onvif-provider.ts
413
+ var OnvifProvider = class {
414
+ constructor(config, ctx) {
415
+ this.config = config;
416
+ this.id = config.id;
417
+ this.name = config.name;
418
+ this.ctx = ctx;
419
+ }
420
+ id;
421
+ type = "onvif";
422
+ name;
423
+ discoveryMode = "both";
424
+ ctx;
425
+ devices = [];
426
+ clients = /* @__PURE__ */ new Map();
427
+ async start() {
428
+ if (this.config.discovery?.enabled !== false) {
429
+ const timeout = this.config.discovery?.timeout ?? 5e3;
430
+ this.ctx.logger.info(`Starting ONVIF discovery (timeout: ${timeout}ms)`);
431
+ const discovered = await discoverOnvifCameras(timeout);
432
+ this.ctx.logger.info(`Discovered ${discovered.length} ONVIF camera(s)`);
433
+ for (const cam of discovered) {
434
+ await this.addCamera({
435
+ id: cam.host.replace(/\./g, "-"),
436
+ name: cam.name ?? cam.host,
437
+ host: cam.host,
438
+ port: cam.port,
439
+ username: this.config.defaultUsername,
440
+ password: this.config.defaultPassword
441
+ });
442
+ }
443
+ }
444
+ for (const cam of this.config.cameras ?? []) {
445
+ await this.addCamera(cam);
446
+ }
447
+ this.ctx.logger.info(
448
+ `ONVIF provider started with ${this.devices.length} camera(s)`
449
+ );
450
+ }
451
+ async addCamera(cam) {
452
+ try {
453
+ const client = new OnvifClient(
454
+ cam.host,
455
+ cam.port ?? 80,
456
+ cam.username ?? "",
457
+ cam.password ?? "",
458
+ this.ctx.logger.child(cam.name)
459
+ );
460
+ await client.connect();
461
+ const info = await client.getDeviceInfo();
462
+ const profiles = await client.getProfiles();
463
+ const mainProfile = profiles[0];
464
+ const subProfile = profiles.length > 1 ? profiles[1] : void 0;
465
+ const rtspUrl = cam.rtspUrl ?? await client.getStreamUri(mainProfile?.token);
466
+ const subStreamUrl = subProfile ? await client.getStreamUri(subProfile.token) : void 0;
467
+ let snapshotUrl;
468
+ try {
469
+ snapshotUrl = await client.getSnapshotUri(mainProfile?.token);
470
+ } catch {
471
+ }
472
+ const deviceCtx = {
473
+ id: `device:${this.id}/${cam.id}`,
474
+ logger: this.ctx.logger.child(cam.name),
475
+ eventBus: this.ctx.eventBus,
476
+ storage: this.ctx.storage,
477
+ config: new ElementConfigStore(`device:${this.id}/${cam.id}`, this.ctx.storage)
478
+ };
479
+ const device = new OnvifDevice(
480
+ client,
481
+ {
482
+ cameraId: cam.id,
483
+ cameraName: cam.name,
484
+ providerId: this.id,
485
+ rtspUrl,
486
+ subStreamUrl,
487
+ snapshotUrl,
488
+ hasPtz: client.hasPtz(),
489
+ profiles,
490
+ manufacturer: info.manufacturer,
491
+ model: info.model,
492
+ firmware: info.firmwareVersion
493
+ },
494
+ deviceCtx
495
+ );
496
+ this.devices = [...this.devices, device];
497
+ this.clients.set(cam.id, client);
498
+ this.ctx.logger.info(
499
+ `Camera ${cam.name} (${cam.host}) connected \u2014 PTZ: ${client.hasPtz()}, profiles: ${profiles.length}`
500
+ );
501
+ } catch (err) {
502
+ const message = err instanceof Error ? err.message : String(err);
503
+ this.ctx.logger.warn(`Failed to connect to ${cam.host}: ${message}`);
504
+ }
505
+ }
506
+ async stop() {
507
+ for (const client of this.clients.values()) {
508
+ client.disconnect();
509
+ }
510
+ this.devices = [];
511
+ this.clients.clear();
512
+ this.ctx.logger.info("ONVIF provider stopped");
513
+ }
514
+ getStatus() {
515
+ return { connected: true, deviceCount: this.devices.length };
516
+ }
517
+ async discoverDevices() {
518
+ const cameras = await discoverOnvifCameras();
519
+ return cameras.map((c) => ({
520
+ externalId: c.host,
521
+ name: c.name ?? c.host,
522
+ type: "camera",
523
+ capabilities: ["camera"],
524
+ metadata: { manufacturer: c.manufacturer, model: c.model }
525
+ }));
526
+ }
527
+ getDevices() {
528
+ return [...this.devices];
529
+ }
530
+ getDeviceConfigSchema() {
531
+ return {
532
+ id: "string",
533
+ name: "string",
534
+ host: "string (IP or hostname)",
535
+ port: "number (default: 80)",
536
+ username: "string (optional)",
537
+ password: "string (optional)",
538
+ rtspUrl: "string (optional, override ONVIF stream URI)"
539
+ };
540
+ }
541
+ subscribeLiveEvents(_callback) {
542
+ return () => {
543
+ };
544
+ }
545
+ };
546
+
547
+ // src/addon.ts
548
+ var OnvifProviderAddon = class {
549
+ manifest = {
550
+ id: "provider-onvif",
551
+ name: "ONVIF Camera Provider",
552
+ version: "0.1.0",
553
+ capabilities: ["device-provider"]
554
+ };
555
+ provider = null;
556
+ async initialize(context) {
557
+ const config = context.addonConfig;
558
+ const providerConfig = {
559
+ id: config.id ?? "onvif-default",
560
+ name: config.name ?? "ONVIF Cameras",
561
+ discovery: config.discovery,
562
+ cameras: config.cameras,
563
+ defaultUsername: config.defaultUsername,
564
+ defaultPassword: config.defaultPassword
565
+ };
566
+ this.provider = new OnvifProvider(providerConfig, {
567
+ id: context.id,
568
+ logger: context.logger,
569
+ eventBus: context.eventBus,
570
+ storage: context.storage,
571
+ config: context.config
572
+ });
573
+ context.logger.info("ONVIF provider addon initialized");
574
+ }
575
+ async shutdown() {
576
+ await this.provider?.stop();
577
+ this.provider = null;
578
+ }
579
+ getCapabilityProvider(name) {
580
+ if (name === "device-provider" && this.provider) {
581
+ return this.provider;
582
+ }
583
+ return null;
584
+ }
585
+ getConfigSchema() {
586
+ return {
587
+ sections: [
588
+ {
589
+ id: "discovery",
590
+ title: "ONVIF Discovery",
591
+ description: "Auto-discover ONVIF cameras on the local network",
592
+ columns: 2,
593
+ fields: [
594
+ { type: "boolean", key: "discovery.enabled", label: "Enable Auto-Discovery" },
595
+ { type: "number", key: "discovery.timeout", label: "Discovery Timeout", unit: "ms", min: 1e3, max: 3e4, step: 1e3 }
596
+ ]
597
+ },
598
+ {
599
+ id: "credentials",
600
+ title: "Default Credentials",
601
+ description: "Default credentials for discovered cameras",
602
+ columns: 2,
603
+ fields: [
604
+ { type: "text", key: "defaultUsername", label: "Default Username" },
605
+ { type: "password", key: "defaultPassword", label: "Default Password", showToggle: true }
606
+ ]
607
+ }
608
+ ]
609
+ };
610
+ }
611
+ getConfig() {
612
+ return {};
613
+ }
614
+ async onConfigChange(_config) {
615
+ }
616
+ };
617
+ export {
618
+ OnvifClient,
619
+ OnvifDevice,
620
+ OnvifProvider,
621
+ OnvifProviderAddon,
622
+ discoverOnvifCameras
623
+ };
624
+ //# sourceMappingURL=index.mjs.map