@apocaliss92/scrypted-reolink-native 0.2.0 → 0.2.1

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/src/common.ts DELETED
@@ -1,2782 +0,0 @@
1
- import type { BaichuanClientOptions, DeviceCapabilities, NativeVideoStreamVariant, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, SleepStatus, StreamProfile, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, MediaStreamUrl, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips, VideoClipThumbnailOptions, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
3
- import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
- import path from 'path';
5
- import fs from 'fs';
6
- import crypto from 'crypto';
7
- import { spawn } from 'node:child_process';
8
- import http from 'http';
9
- import https from 'https';
10
- import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
11
- import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
12
- import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
13
- import { convertDebugLogsToApiOptions, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
14
- import { ReolinkBaichuanIntercom } from "./intercom";
15
- import ReolinkNativePlugin from "./main";
16
- import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
17
- import { ReolinkNativeNvrDevice } from "./nvr";
18
- import { ReolinkPtzPresets } from "./presets";
19
- import {
20
- createRfc4571CompositeMediaObjectFromStreamManager,
21
- createRfc4571MediaObjectFromStreamManager,
22
- extractVariantFromStreamId,
23
- parseStreamProfileFromId,
24
- selectStreamOption,
25
- StreamManager,
26
- StreamManagerOptions
27
- } from "./stream-utils";
28
- import { floodlightSuffix, getDeviceInterfaces, pirSuffix, recordingsToVideoClips, sanitizeFfmpegOutput, sirenSuffix, updateDeviceInfo } from "./utils";
29
-
30
- export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
31
-
32
- export interface CommonCameraMixinOptions {
33
- type: CameraType;
34
- nvrDevice?: ReolinkNativeNvrDevice; // Optional reference to NVR device
35
- multiFocalDevice?: ReolinkNativeMultiFocalDevice; // Optional reference to multi-focal device
36
- }
37
-
38
- class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff {
39
- constructor(public camera: CommonCameraMixin, nativeId: string) {
40
- super(nativeId);
41
- }
42
-
43
- async turnOff(): Promise<void> {
44
- this.camera.getBaichuanLogger().log(`Siren toggle: turnOff (device=${this.nativeId})`);
45
- this.on = false;
46
- try {
47
- await this.camera.setSirenEnabled(false);
48
- this.camera.getBaichuanLogger().log(`Siren toggle: turnOff ok (device=${this.nativeId})`);
49
- }
50
- catch (e) {
51
- this.camera.getBaichuanLogger().warn(`Siren toggle: turnOff failed (device=${this.nativeId})`, e?.message || String(e));
52
- throw e;
53
- }
54
- }
55
-
56
- async turnOn(): Promise<void> {
57
- this.camera.getBaichuanLogger().log(`Siren toggle: turnOn (device=${this.nativeId})`);
58
- this.on = true;
59
- try {
60
- await this.camera.setSirenEnabled(true);
61
- this.camera.getBaichuanLogger().log(`Siren toggle: turnOn ok (device=${this.nativeId})`);
62
- }
63
- catch (e) {
64
- this.camera.getBaichuanLogger().warn(`Siren toggle: turnOn failed (device=${this.nativeId})`, e?.message || String(e));
65
- throw e;
66
- }
67
- }
68
- }
69
-
70
- class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brightness {
71
- constructor(public camera: CommonCameraMixin, nativeId: string) {
72
- super(nativeId);
73
- }
74
-
75
- async setBrightness(brightness: number): Promise<void> {
76
- this.camera.getBaichuanLogger().log(`Floodlight toggle: setBrightness (device=${this.nativeId} brightness=${brightness})`);
77
- this.brightness = brightness;
78
- try {
79
- await this.camera.setFloodlightState(undefined, brightness);
80
- this.camera.getBaichuanLogger().log(`Floodlight toggle: setBrightness ok (device=${this.nativeId} brightness=${brightness})`);
81
- }
82
- catch (e) {
83
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: setBrightness failed (device=${this.nativeId} brightness=${brightness})`, e?.message || String(e));
84
- throw e;
85
- }
86
- }
87
-
88
- async turnOff(): Promise<void> {
89
- this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOff (device=${this.nativeId})`);
90
- this.on = false;
91
- try {
92
- await this.camera.setFloodlightState(false);
93
- this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOff ok (device=${this.nativeId})`);
94
- }
95
- catch (e) {
96
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOff failed (device=${this.nativeId})`, e?.message || String(e));
97
- throw e;
98
- }
99
- }
100
-
101
- async turnOn(): Promise<void> {
102
- this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOn (device=${this.nativeId})`);
103
- this.on = true;
104
- try {
105
- await this.camera.setFloodlightState(true);
106
- this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOn ok (device=${this.nativeId})`);
107
- }
108
- catch (e) {
109
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOn failed (device=${this.nativeId})`, e?.message || String(e));
110
- throw e;
111
- }
112
- }
113
- }
114
-
115
- class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff, Settings {
116
- storageSettings = new StorageSettings(this, {
117
- sensitive: {
118
- title: 'PIR Sensitivity',
119
- description: 'Detection sensitivity/threshold (higher = more sensitive)',
120
- type: 'number',
121
- defaultValue: 50,
122
- range: [0, 100],
123
- },
124
- reduceAlarm: {
125
- title: 'Reduce False Alarms',
126
- description: 'Enable reduction of false alarm rate',
127
- type: 'boolean',
128
- defaultValue: false,
129
- },
130
- interval: {
131
- title: 'PIR Detection Interval',
132
- description: 'Detection interval in seconds',
133
- type: 'number',
134
- defaultValue: 5,
135
- range: [1, 60],
136
- },
137
- });
138
-
139
- constructor(public camera: CommonCameraMixin, nativeId: string) {
140
- super(nativeId);
141
- }
142
-
143
- async getSettings(): Promise<Setting[]> {
144
- const settings = await this.storageSettings.getSettings();
145
- return settings;
146
- }
147
-
148
- async putSetting(key: string, value: SettingValue): Promise<void> {
149
- await this.storageSettings.putSetting(key, value);
150
-
151
- // Apply the new settings to the camera
152
- const channel = this.camera.storageSettings.values.rtspChannel;
153
- const enabled = this.on ? 1 : 0;
154
- const sensitive = this.storageSettings.values.sensitive;
155
- const reduceAlarm = this.storageSettings.values.reduceAlarm ? 1 : 0;
156
- const interval = this.storageSettings.values.interval;
157
-
158
- await this.camera.withBaichuanRetry(async () => {
159
- const api = await this.camera.ensureClient();
160
- await api.setPirInfo(channel, {
161
- enable: enabled,
162
- sensitive: sensitive,
163
- reduceAlarm: reduceAlarm,
164
- interval: interval,
165
- });
166
- });
167
- }
168
-
169
- async turnOff(): Promise<void> {
170
- this.on = false;
171
- await this.updatePirSettings();
172
- }
173
-
174
- async turnOn(): Promise<void> {
175
- this.on = true;
176
- await this.updatePirSettings();
177
- }
178
-
179
- private async updatePirSettings(): Promise<void> {
180
- const channel = this.camera.storageSettings.values.rtspChannel;
181
- const enabled = this.on ? 1 : 0;
182
- const sensitive = this.storageSettings.values.sensitive;
183
- const reduceAlarm = this.storageSettings.values.reduceAlarm ? 1 : 0;
184
- const interval = this.storageSettings.values.interval;
185
-
186
- await this.camera.withBaichuanRetry(async () => {
187
- const api = await this.camera.ensureClient();
188
- await api.setPirInfo(channel, {
189
- enable: enabled,
190
- sensitive: sensitive,
191
- reduceAlarm: reduceAlarm,
192
- interval: interval,
193
- });
194
- });
195
- }
196
- }
197
-
198
- export abstract class CommonCameraMixin extends BaseBaichuanClass implements VideoCamera, Camera, Settings, DeviceProvider, ObjectDetector, PanTiltZoom, VideoTextOverlays, BinarySensor, Intercom, Reboot, VideoClips {
199
- storageSettings = new StorageSettings(this, {
200
- // Basic connection settings
201
- ipAddress: {
202
- title: 'IP Address',
203
- type: 'string',
204
- onPut: async () => {
205
- await this.credentialsChanged();
206
- }
207
- },
208
- username: {
209
- type: 'string',
210
- title: 'Username',
211
- onPut: async () => {
212
- await this.credentialsChanged();
213
- }
214
- },
215
- password: {
216
- type: 'password',
217
- title: 'Password',
218
- onPut: async () => {
219
- await this.credentialsChanged();
220
- }
221
- },
222
- rtspChannel: {
223
- type: 'number',
224
- hide: true,
225
- defaultValue: 0,
226
- },
227
- variantType: {
228
- type: 'string',
229
- hide: true,
230
- defaultValue: 'default',
231
- choices: ['default', 'autotrack', 'telephoto'] as NativeVideoStreamVariant[],
232
- },
233
- capabilities: {
234
- json: true,
235
- hide: true,
236
- },
237
- multifocalInfo: {
238
- json: true,
239
- hide: true,
240
- },
241
- // Battery camera specific
242
- uid: {
243
- title: 'UID',
244
- description: 'Reolink UID (required for battery cameras / BCUDP).',
245
- type: 'string',
246
- hide: true,
247
- onPut: async () => {
248
- await this.credentialsChanged();
249
- }
250
- },
251
- discoveryMethod: {
252
- title: 'Discovery Method',
253
- description: 'UDP discovery method for battery cameras (BCUDP).',
254
- type: 'string',
255
- choices: ['local-direct', 'local-broadcast', 'remote', 'map', 'relay'],
256
- defaultValue: 'local-direct',
257
- hide: true,
258
- subgroup: 'Advanced',
259
- onPut: async () => {
260
- await this.credentialsChanged();
261
- }
262
- },
263
- debugLogs: {
264
- title: 'Debug logs',
265
- type: 'boolean',
266
- immediate: true,
267
- },
268
- mixinsSetup: {
269
- type: 'boolean',
270
- hide: true,
271
- },
272
- // Regular camera specific
273
- dispatchEvents: {
274
- subgroup: 'Advanced',
275
- title: 'Dispatch Events',
276
- description: 'Select which events to emit. Empty disables event subscription entirely.',
277
- multiple: true,
278
- combobox: true,
279
- immediate: true,
280
- defaultValue: ['motion', 'objects'],
281
- choices: ['motion', 'objects'],
282
- onPut: async () => {
283
- await this.subscribeToEvents();
284
- },
285
- },
286
- socketApiDebugLogs: {
287
- subgroup: 'Advanced',
288
- title: 'Socket API Debug Logs',
289
- description: 'Enable specific debug logs.',
290
- multiple: true,
291
- combobox: true,
292
- immediate: true,
293
- defaultValue: [],
294
- choices: getDebugLogChoices(),
295
- onPut: async (ov, value) => {
296
- const logger = this.getBaichuanLogger();
297
- const oldApiOptions = getApiRelevantDebugLogs(ov || []);
298
- const newApiOptions = getApiRelevantDebugLogs(value || []);
299
-
300
- const oldSel = new Set(oldApiOptions);
301
- const newSel = new Set(newApiOptions);
302
-
303
- const changed = oldSel.size !== newSel.size || Array.from(oldSel).some((k) => !newSel.has(k));
304
- if (changed && this.resetBaichuanClient) {
305
- // Clear any existing timeout
306
- if (this.debugLogsResetTimeout) {
307
- clearTimeout(this.debugLogsResetTimeout);
308
- this.debugLogsResetTimeout = undefined;
309
- }
310
-
311
- // Defer reset by 2 seconds to allow settings to settle
312
- this.debugLogsResetTimeout = setTimeout(async () => {
313
- this.debugLogsResetTimeout = undefined;
314
- try {
315
- await this.resetBaichuanClient('debugLogs changed');
316
- // Force reconnection with new debug options
317
- this.baichuanApi = undefined;
318
- this.ensureClientPromise = undefined;
319
- // Trigger reconnection
320
- await this.ensureClient();
321
- } catch (e) {
322
- logger.warn('Failed to reset client after debug logs change', e?.message || String(e));
323
- }
324
- }, 2000);
325
- }
326
- },
327
- },
328
- motionTimeout: {
329
- subgroup: 'Advanced',
330
- title: 'Motion Timeout',
331
- defaultValue: 20,
332
- type: 'number',
333
- },
334
- cachedOsd: {
335
- multiple: true,
336
- hide: true,
337
- json: true,
338
- defaultValue: [],
339
- },
340
- intercomBlocksPerPayload: {
341
- subgroup: 'Advanced',
342
- title: 'Intercom Blocks Per Payload',
343
- description: 'Lower reduces latency (more packets). Typical: 1-4. Requires restarting talk session to take effect.',
344
- type: 'number',
345
- defaultValue: 1,
346
- },
347
- // PTZ Presets
348
- presets: {
349
- subgroup: 'PTZ',
350
- title: 'Presets to enable',
351
- description: 'PTZ Presets in the format "id=name". Where id is the PTZ Preset identifier and name is a friendly name.',
352
- multiple: true,
353
- defaultValue: [],
354
- combobox: true,
355
- hide: true, // Will be shown if PTZ is supported
356
- onPut: async (ov, presets: string[]) => {
357
- const caps = {
358
- ...(this.ptzCapabilities || {}),
359
- presets: {},
360
- };
361
- for (const preset of presets) {
362
- const [key, name] = preset.split('=');
363
- caps.presets![key] = name;
364
- }
365
- this.ptzCapabilities = caps;
366
- },
367
- mapGet: () => {
368
- const presets = this.ptzCapabilities?.presets || {};
369
- return Object.entries(presets).map(([key, name]) => key + '=' + name);
370
- },
371
- },
372
- ptzMoveDurationMs: {
373
- title: 'PTZ Move Duration (ms)',
374
- description: 'How long a PTZ command moves before sending stop. Higher = more movement per click.',
375
- type: 'number',
376
- defaultValue: 300,
377
- subgroup: 'PTZ',
378
- hide: true,
379
- },
380
- ptzZoomStep: {
381
- subgroup: 'PTZ',
382
- title: 'PTZ Zoom Step',
383
- description: 'How much to change zoom per zoom command (in zoom factor units, where 1.0 is normal).',
384
- type: 'number',
385
- defaultValue: 0.1,
386
- hide: true,
387
- },
388
- ptzCreatePreset: {
389
- subgroup: 'PTZ',
390
- title: 'Create Preset',
391
- description: 'Enter a name and press Save to create a new PTZ preset at the current position.',
392
- type: 'string',
393
- placeholder: 'e.g. Door',
394
- defaultValue: '',
395
- hide: true,
396
- onPut: async (_ov, value) => {
397
- const name = String(value ?? '').trim();
398
- if (!name) {
399
- // Cleanup if user saved whitespace.
400
- if (String(value ?? '') !== '') {
401
- this.storageSettings.values.ptzCreatePreset = '';
402
- }
403
- return;
404
- }
405
-
406
- const logger = this.getBaichuanLogger();
407
- logger.log(`PTZ presets: create preset requested (name=${name})`);
408
-
409
- const preset = await this.withBaichuanRetry(async () => {
410
- await this.ensureClient();
411
- if (!this.ptzPresets) {
412
- throw new Error('PTZ presets not available');
413
- }
414
- return await this.ptzPresets.createPtzPreset(name);
415
- });
416
- const selection = `${preset.id}=${preset.name}`;
417
-
418
- // Auto-select created preset.
419
- this.storageSettings.values.ptzSelectedPreset = selection;
420
- this.storageSettings.values.ptzCreatePreset = '';
421
-
422
- logger.log(`PTZ presets: created preset id=${preset.id} name=${preset.name}`);
423
- },
424
- },
425
- ptzSelectedPreset: {
426
- subgroup: 'PTZ',
427
- title: 'Selected Preset',
428
- description: 'Select the preset to update or delete. Format: "id=name".',
429
- type: 'string',
430
- combobox: false,
431
- immediate: true,
432
- hide: true,
433
- },
434
- ptzUpdateSelectedPreset: {
435
- subgroup: 'PTZ',
436
- title: 'Update Selected Preset Position',
437
- description: 'Overwrite the selected preset with the current PTZ position.',
438
- type: 'button',
439
- immediate: true,
440
- hide: true,
441
- onPut: async () => {
442
- const presetId = this.getSelectedPresetId();
443
- if (presetId === undefined) {
444
- throw new Error('No preset selected');
445
- }
446
-
447
- const logger = this.getBaichuanLogger();
448
- logger.log(`PTZ presets: update position requested (presetId=${presetId})`);
449
-
450
- await this.withBaichuanRetry(async () => {
451
- await this.ensureClient();
452
- return await (this.ptzPresets).updatePtzPresetToCurrentPosition(presetId);
453
- });
454
- logger.log(`PTZ presets: update position ok (presetId=${presetId})`);
455
- },
456
- },
457
- ptzDeleteSelectedPreset: {
458
- subgroup: 'PTZ',
459
- title: 'Delete Selected Preset',
460
- description: 'Delete the selected preset (firmware dependent).',
461
- type: 'button',
462
- immediate: true,
463
- hide: true,
464
- onPut: async () => {
465
- const presetId = this.getSelectedPresetId();
466
- if (presetId === undefined) {
467
- throw new Error('No preset selected');
468
- }
469
-
470
- const logger = this.getBaichuanLogger();
471
- logger.log(`PTZ presets: delete requested (presetId=${presetId})`);
472
-
473
- await this.withBaichuanRetry(async () => {
474
- await this.ensureClient();
475
- return await (this.ptzPresets).deletePtzPreset(presetId);
476
- });
477
-
478
- this.storageSettings.values.ptzSelectedPreset = '';
479
- logger.log(`PTZ presets: delete ok (presetId=${presetId})`);
480
- },
481
- },
482
- batteryUpdateIntervalMinutes: {
483
- title: "Battery Update Interval (minutes)",
484
- subgroup: 'Advanced',
485
- description: "How often to wake up the camera and update battery status and snapshot (default: 60 minutes).",
486
- type: "number",
487
- defaultValue: 60,
488
- hide: true,
489
- },
490
- lowThresholdBatteryRecording: {
491
- title: "Low Threshold Battery Recording (%)",
492
- subgroup: 'Recording',
493
- description: "Battery level threshold below which recording is disabled (default: 15%).",
494
- type: "number",
495
- defaultValue: 15,
496
- hide: true,
497
- },
498
- highThresholdBatteryRecording: {
499
- title: "High Threshold Battery Recording (%)",
500
- subgroup: 'Recording',
501
- description: "Battery level threshold above which recording is enabled (default: 35%).",
502
- type: "number",
503
- defaultValue: 35,
504
- hide: true,
505
- },
506
- diagnosticsOutputPath: {
507
- title: "Diagnostics Output Path",
508
- subgroup: 'Diagnostics',
509
- description: "Directory where diagnostics files will be saved (default: plugin volume).",
510
- type: "string",
511
- defaultValue: path.join(process.env.SCRYPTED_PLUGIN_VOLUME, 'diagnostics', this.name),
512
- },
513
- enableVideoclips: {
514
- title: "Enable Video Clips",
515
- description: "Enable video clips functionality. If disabled, getVideoClips will return empty and all other videoclip settings are ignored.",
516
- type: "boolean",
517
- defaultValue: false,
518
- immediate: true,
519
- onPut: async () => {
520
- this.updateVideoClipsAutoLoad();
521
- },
522
- },
523
- clipsSource: {
524
- title: "Clips Source",
525
- subgroup: 'Videoclips',
526
- description: "Source for fetching video clips: NVR (fetch from NVR device) or Device (fetch directly from camera).",
527
- type: "string",
528
- choices: ["NVR", "Device"],
529
- immediate: true,
530
- },
531
- loadVideoclips: {
532
- title: "Auto-load Video Clips",
533
- subgroup: 'Videoclips',
534
- description: "Automatically fetch today's video clips and download missing thumbnails at regular intervals.",
535
- type: "boolean",
536
- defaultValue: false,
537
- immediate: true,
538
- onPut: async () => {
539
- this.updateVideoClipsAutoLoad();
540
- },
541
- },
542
- videoclipsRegularChecks: {
543
- title: "Video Clips Check Interval (minutes)",
544
- subgroup: 'Videoclips',
545
- description: "How often to check for new video clips and download thumbnails (default: 30 minutes).",
546
- type: "number",
547
- defaultValue: 30,
548
- onPut: async () => {
549
- this.updateVideoClipsAutoLoad();
550
- },
551
- },
552
- downloadVideoclipsLocally: {
553
- title: "Download Video Clips Locally",
554
- subgroup: 'Videoclips',
555
- description: "Automatically download and cache video clips to local filesystem during auto-load.",
556
- type: "boolean",
557
- defaultValue: false,
558
- immediate: true,
559
- onPut: async () => {
560
- this.updateVideoClipsAutoLoad();
561
- },
562
- },
563
- videoclipsDaysToPreload: {
564
- title: "Days to Preload",
565
- subgroup: 'Videoclips',
566
- description: "Number of days to preload video clips and thumbnails (default: 1, only today).",
567
- type: "number",
568
- defaultValue: 3,
569
- onPut: async () => {
570
- this.updateVideoClipsAutoLoad();
571
- },
572
- },
573
- diagnosticsRun: {
574
- subgroup: 'Diagnostics',
575
- title: 'Run Diagnostics',
576
- description: 'Run all diagnostics and save results to the output path.',
577
- type: 'button',
578
- immediate: true,
579
- onPut: async () => {
580
- await this.runDiagnostics();
581
- },
582
- },
583
- // Multifocal composite stream PIP settings
584
- pipPosition: {
585
- title: 'PIP Position',
586
- description: 'Position of the tele lens overlay on the wider lens view',
587
- type: 'string',
588
- defaultValue: 'bottom-right',
589
- group: 'Composite stream',
590
- choices: [
591
- 'top-left',
592
- 'top-right',
593
- 'bottom-left',
594
- 'bottom-right',
595
- 'center',
596
- 'top-center',
597
- 'bottom-center',
598
- 'left-center',
599
- 'right-center',
600
- ],
601
- hide: true, // Only show for multifocal devices via getAdditionalSettings
602
- },
603
- pipSize: {
604
- title: 'PIP Size',
605
- description: 'Relative size of the PIP overlay (0.1 = 10%, 0.3 = 30%, etc.)',
606
- type: 'number',
607
- defaultValue: 0.25,
608
- group: 'Composite stream',
609
- hide: true,
610
- onPut: async () => {
611
- this.scheduleStreamManagerRestart('pipSize changed');
612
- },
613
- },
614
- pipMargin: {
615
- title: 'PIP Margin',
616
- description: 'Margin from edge in pixels',
617
- type: 'number',
618
- defaultValue: 10,
619
- group: 'Composite stream',
620
- hide: true,
621
- onPut: async () => {
622
- this.scheduleStreamManagerRestart('pipMargin changed');
623
- },
624
- },
625
- });
626
-
627
- ptzPresets = new ReolinkPtzPresets(this);
628
- refreshingState = false;
629
- classes: string[] = [];
630
- presets: PtzPreset[] = [];
631
- streamManager?: StreamManager;
632
- intercom?: ReolinkBaichuanIntercom;
633
-
634
- siren?: ReolinkCameraSiren;
635
- floodlight?: ReolinkCameraFloodlight;
636
- pirSensor?: ReolinkCameraPirSensor;
637
-
638
-
639
- private lastPicture: { mo: MediaObject; atMs: number } | undefined;
640
- private takePictureInFlight: Promise<MediaObject> | undefined;
641
- forceNewSnapshot: boolean = false;
642
-
643
- // Video stream properties
644
- protected cachedVideoStreamOptions?: UrlMediaStreamOptions[];
645
- protected fetchingStreamsPromise: Promise<UrlMediaStreamOptions[]> | undefined;
646
- protected lastNetPortCacheAttempt: number = 0;
647
- protected netPortCacheBackoffMs: number = 5000; // 5 seconds backoff on failure
648
-
649
- // Client management (inherited from BaseBaichuanClass)
650
- private debugLogsResetTimeout: NodeJS.Timeout | undefined;
651
-
652
- abstract init(): Promise<void>;
653
-
654
- abstract reportDevices(): Promise<void>;
655
-
656
- motionTimeout?: NodeJS.Timeout;
657
- doorbellBinaryTimeout?: NodeJS.Timeout;
658
- resetBaichuanClient?(reason?: any): Promise<void>;
659
-
660
- protected nvrDevice?: ReolinkNativeNvrDevice;
661
- protected multiFocalDevice?: ReolinkNativeMultiFocalDevice;
662
- thisDevice: Settings;
663
- isBattery: boolean;
664
- isMultiFocal: boolean;
665
- isOnNvr: boolean;
666
- protocol: BaichuanTransport;
667
- private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
668
- private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
669
- private videoClipsAutoLoadInProgress: boolean = false;
670
- private videoClipsAutoLoadMode: boolean = false;
671
-
672
- constructor(
673
- nativeId: string,
674
- public plugin: ReolinkNativePlugin,
675
- public options: CommonCameraMixinOptions
676
- ) {
677
- const isBattery = options.type === 'battery' || options.type === 'multi-focal-battery';
678
- const transport = isBattery || !!options.nvrDevice ? 'udp' : 'tcp';
679
- super(nativeId, transport);
680
- this.plugin.mixinsMap.set(this.id, this);
681
-
682
- // Store NVR device reference if provided
683
- this.nvrDevice = options.nvrDevice;
684
- this.multiFocalDevice = options.multiFocalDevice;
685
- this.thisDevice = sdk.systemManager.getDeviceById<Settings>(this.id);
686
-
687
- this.isBattery = isBattery;
688
- this.isMultiFocal = options.type === 'multi-focal' || options.type === 'multi-focal-battery';
689
- this.isOnNvr = !!this.nvrDevice || !!this.multiFocalDevice?.nvrDevice;
690
- this.protocol = transport;
691
-
692
- setTimeout(async () => {
693
- await this.parentInit();
694
- }, 2000);
695
- }
696
-
697
- protected async withBaichuanClient<T>(fn: (api: ReolinkBaichuanApi) => Promise<T>): Promise<T> {
698
- const client = await this.ensureClient();
699
- return fn(client);
700
- }
701
-
702
- async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
703
- // Check if videoclips are enabled
704
- if (!this.storageSettings.values.enableVideoclips) {
705
- return [];
706
- }
707
-
708
- // Skip sleeping check during auto-load to allow auto-load to start for battery cameras
709
- if (!this.videoClipsAutoLoadMode && this.isBattery && this.sleeping) {
710
- const logger = this.getBaichuanLogger();
711
- logger.debug('getVideoClips: disabled for battery devices');
712
- return [];
713
- }
714
-
715
- const logger = this.getBaichuanLogger();
716
-
717
- // Determine time window
718
- const nowMs = Date.now();
719
- const defaultWindowMs = 60 * 60 * 1000; // last 60 minutes
720
-
721
- const startMs = options?.startTime ?? (nowMs - defaultWindowMs);
722
- let endMs = options?.endTime ?? nowMs;
723
- const count = options?.count;
724
-
725
- if (endMs > nowMs) {
726
- endMs = nowMs;
727
- }
728
-
729
- if (endMs <= startMs) {
730
- logger.warn('getVideoClips: invalid time window, endTime <= startTime', {
731
- startTime: startMs,
732
- endTime: endMs,
733
- });
734
- return [];
735
- }
736
-
737
- const start = new Date(startMs);
738
- const end = new Date(endMs);
739
- start.setHours(0, 0, 0, 0);
740
-
741
- try {
742
- const { clipsSource } = this.storageSettings.values;
743
- const useNvr = clipsSource === "NVR" && this.nvrDevice;
744
-
745
- const api = await this.ensureClient();
746
-
747
- if (useNvr) {
748
- // Fetch from NVR using listEnrichedVodFiles (library handles parsing correctly)
749
- const channel = this.storageSettings.values.rtspChannel ?? 0;
750
-
751
- // Use listEnrichedVodFiles which properly parses filenames and extracts detection info
752
- logger.debug(`[NVR VOD] Searching for video clips: channel=${channel}, start=${start.toISOString()}, end=${end.toISOString()}`);
753
- // Filter to only include recordings within the requested time window
754
- const enrichedRecordings = await api.listNvrRecordings({
755
- channel,
756
- start,
757
- end,
758
- streamType: "main",
759
- source: "baichuan"
760
- });
761
-
762
- logger.debug(`[NVR VOD] Found ${enrichedRecordings.length} enriched recordings from NVR`);
763
-
764
- // Convert enriched recordings to VideoClip array using the shared parser
765
- const clips = await recordingsToVideoClips(enrichedRecordings, {
766
- fallbackStart: start,
767
- logger,
768
- plugin: this,
769
- deviceId: this.id,
770
- useWebhook: true,
771
- count,
772
- });
773
-
774
- logger.debug(`[NVR VOD] Converted ${clips.length} video clips (limit: ${count || 'none'})`);
775
-
776
- return clips;
777
- } else {
778
- const recordings = await api.listDeviceRecordings({
779
- start,
780
- end,
781
- count,
782
- channel: this.storageSettings.values.rtspChannel,
783
- streamType: 'mainStream',
784
- httpFallback: false,
785
- fetchRtmpUrls: false
786
- });
787
-
788
- // Convert recordings to VideoClip array using the shared parser
789
- const clips = await recordingsToVideoClips(recordings, {
790
- fallbackStart: start,
791
- api,
792
- logger,
793
- plugin: this,
794
- deviceId: this.id,
795
- useWebhook: true,
796
- count,
797
- });
798
-
799
- logger.debug(`Videoclips found: ${clips.length}`);
800
-
801
- return clips;
802
- }
803
- } catch (e: any) {
804
- const message = e instanceof Error ? e.message : String(e);
805
-
806
- if (message?.includes('UID is required to access recordings')) {
807
- logger.log('getVideoClips: recordings not available or UID not resolvable for this device', {
808
- error: message,
809
- });
810
- } else {
811
- logger.warn('getVideoClips: failed to list recordings', {
812
- error: message,
813
- });
814
- }
815
- return [];
816
- }
817
- }
818
-
819
- /**
820
- * Get the cache directory for video clips and thumbnails
821
- */
822
- private getVideoClipCacheDir(): string {
823
- const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
824
- const cameraId = this.id;
825
- return path.join(pluginVolume, 'videoclips', cameraId);
826
- }
827
-
828
- /**
829
- * Get cache file path for a video clip
830
- */
831
- getVideoClipCachePath(videoId: string): string {
832
- // Create a safe filename from videoId using hash
833
- const hash = crypto.createHash('md5').update(videoId).digest('hex');
834
- // Keep original extension if present, otherwise use .mp4
835
- const ext = videoId.includes('.') ? path.extname(videoId) : '.mp4';
836
- const cacheDir = this.getVideoClipCacheDir();
837
- return path.join(cacheDir, `${hash}${ext}`);
838
- }
839
-
840
- async getVideoClip(videoId: string): Promise<MediaObject> {
841
- const logger = this.getBaichuanLogger();
842
- try {
843
- const cacheEnabled = this.storageSettings.values.downloadVideoclipsLocally;
844
- const MIN_VIDEO_CACHE_BYTES = 16 * 1024;
845
-
846
- // Always check cache first, even if caching is disabled (in case user enabled it before)
847
- const cachePath = this.getVideoClipCachePath(videoId);
848
- const cacheDir = this.getVideoClipCacheDir();
849
-
850
- // Check if cached file exists
851
- try {
852
- await fs.promises.access(cachePath, fs.constants.F_OK);
853
- const stats = await fs.promises.stat(cachePath);
854
- if (stats.size < MIN_VIDEO_CACHE_BYTES) {
855
- logger.warn(`[VideoClip] Cached file too small, deleting and re-downloading: fileId=${videoId}, size=${stats.size} bytes`);
856
- try {
857
- await fs.promises.unlink(cachePath);
858
- } catch (unlinkErr) {
859
- logger.warn(`[VideoClip] Failed to delete small cached file: fileId=${videoId}`, unlinkErr);
860
- }
861
- } else {
862
- logger.debug(`[VideoClip] Using cached file: fileId=${videoId}, size=${stats.size} bytes`);
863
- // Return cached file as MediaObject
864
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
865
- return mo;
866
- }
867
- } catch (e) {
868
- // File doesn't exist or error accessing it
869
- logger.debug(`[VideoClip] Cache miss: fileId=${videoId}, error=${e instanceof Error ? e.message : String(e)}`);
870
- if (cacheEnabled) {
871
- logger.debug(`[VideoClip] Will download and cache: fileId=${videoId}`);
872
- }
873
- }
874
-
875
- // If caching is enabled, ensure cache directory exists
876
- if (cacheEnabled) {
877
- await fs.promises.mkdir(cacheDir, { recursive: true });
878
- }
879
-
880
- const { clipsSource } = this.storageSettings.values;
881
- const useNvr = clipsSource === "NVR" && this.nvrDevice && videoId.includes('/');
882
-
883
- // NVR/HUB case: prefer Download endpoint (HTTP) instead of RTMP
884
- if (useNvr && this.nvrDevice) {
885
- // Reuse centralized logic for NVR VOD URL (Download)
886
- const downloadUrl = await this.getVideoClipRtmpUrl(videoId);
887
-
888
- // If caching is enabled, download via HTTP and cache as file
889
- if (cacheEnabled) {
890
- const cachePath = this.getVideoClipCachePath(videoId);
891
- logger.log(`Downloading video clip from NVR to cache: fileId=${videoId}, path=${cachePath}`);
892
-
893
- await new Promise<void>((resolve, reject) => {
894
- const urlObj = new URL(downloadUrl);
895
- const httpModule = urlObj.protocol === 'https:' ? https : http;
896
-
897
- const fileStream = fs.createWriteStream(cachePath);
898
-
899
- const req = httpModule.get(downloadUrl, (res) => {
900
- if (!res.statusCode || res.statusCode >= 400) {
901
- reject(new Error(`NVR download failed: ${res.statusCode} ${res.statusMessage}`));
902
- return;
903
- }
904
-
905
- res.pipe(fileStream);
906
-
907
- res.on('error', (err) => {
908
- reject(err);
909
- });
910
-
911
- fileStream.on('finish', () => {
912
- resolve();
913
- });
914
-
915
- fileStream.on('error', (err) => {
916
- reject(err);
917
- });
918
- });
919
-
920
- req.on('error', (err) => {
921
- reject(err);
922
- });
923
- });
924
-
925
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
926
- return mo;
927
- } else {
928
- // Caching disabled: return HTTP Download URL directly
929
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(downloadUrl);
930
- return mo;
931
- }
932
- }
933
-
934
- // Standalone camera (or fallback): reuse getVideoClipRtmpUrl (Baichuan RTMP)
935
- const playbackUrl = await this.getVideoClipRtmpUrl(videoId);
936
-
937
- // If caching is enabled, download and cache the video via ffmpeg
938
- if (cacheEnabled) {
939
- const cachePath = this.getVideoClipCachePath(videoId);
940
-
941
- // Download and convert RTMP to MP4 using ffmpeg
942
- const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
943
- const ffmpegArgs = [
944
- '-i', playbackUrl,
945
- '-c', 'copy', // Copy codecs without re-encoding
946
- '-f', 'mp4',
947
- '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
948
- cachePath,
949
- ];
950
-
951
- logger.log(`Downloading video clip to cache: ${cachePath}`);
952
-
953
- await new Promise<void>((resolve, reject) => {
954
- const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
955
- stdio: ['ignore', 'pipe', 'pipe'],
956
- });
957
-
958
- let errorOutput = '';
959
-
960
- ffmpeg.stderr.on('data', (chunk: Buffer) => {
961
- errorOutput += chunk.toString();
962
- });
963
-
964
- ffmpeg.on('close', (code) => {
965
- if (code !== 0) {
966
- const sanitized = sanitizeFfmpegOutput(errorOutput);
967
- logger.error(`ffmpeg failed to download video clip: ${sanitized}`);
968
- reject(new Error(`ffmpeg failed with code ${code}: ${sanitized}`));
969
- return;
970
- }
971
-
972
- logger.log(`Video clip cached successfully: ${cachePath}`);
973
- resolve();
974
- });
975
-
976
- ffmpeg.on('error', (error) => {
977
- logger.error(`ffmpeg spawn error for video clip ${videoId}`, error);
978
- reject(error);
979
- });
980
-
981
- // Timeout after 5 minutes
982
- const timeout = setTimeout(() => {
983
- ffmpeg.kill('SIGKILL');
984
- reject(new Error('Video clip download timeout'));
985
- }, 5 * 60 * 1000);
986
-
987
- ffmpeg.on('close', () => {
988
- clearTimeout(timeout);
989
- });
990
- });
991
-
992
- // Return cached file as MediaObject
993
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
994
- return mo;
995
- } else {
996
- // Caching disabled, return playback URL directly (RTMP for standalone camera)
997
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(playbackUrl);
998
- return mo;
999
- }
1000
- } catch (e) {
1001
- logger.error(`getVideoClip: failed to get video clip ${videoId}`, e?.message || String(e));
1002
- throw e;
1003
- }
1004
- }
1005
-
1006
- /**
1007
- * Get the cache directory for thumbnails (same as video clips)
1008
- */
1009
- private getThumbnailCacheDir(): string {
1010
- // Use the same directory as video clips
1011
- return this.getVideoClipCacheDir();
1012
- }
1013
-
1014
- /**
1015
- * Get cache file path for a thumbnail
1016
- */
1017
- private getThumbnailCachePath(fileId: string): string {
1018
- // Use the same hash and base name as video clips, but with .jpg extension
1019
- const hash = crypto.createHash('md5').update(fileId).digest('hex');
1020
- const cacheDir = this.getThumbnailCacheDir();
1021
- return path.join(cacheDir, `${hash}.jpg`);
1022
- }
1023
-
1024
- async getVideoClipThumbnail(thumbnailId: string, options?: VideoClipThumbnailOptions): Promise<MediaObject> {
1025
- const logger = this.getBaichuanLogger();
1026
-
1027
- try {
1028
- // Check cache first
1029
- const cachePath = this.getThumbnailCachePath(thumbnailId);
1030
- const cacheDir = this.getThumbnailCacheDir();
1031
- const MIN_THUMB_CACHE_BYTES = 512; // 0.5KB, evita file vuoti o quasi
1032
-
1033
- try {
1034
- await fs.promises.access(cachePath, fs.constants.F_OK);
1035
- const stats = await fs.promises.stat(cachePath);
1036
- if (stats.size < MIN_THUMB_CACHE_BYTES) {
1037
- logger.warn(`[Thumbnail] Cached thumbnail too small, deleting and regenerating: fileId=${thumbnailId}, size=${stats.size} bytes`);
1038
- try {
1039
- await fs.promises.unlink(cachePath);
1040
- } catch (unlinkErr) {
1041
- logger.warn(`[Thumbnail] Failed to delete small cached thumbnail: fileId=${thumbnailId}`, unlinkErr);
1042
- }
1043
- } else {
1044
- logger.debug(`[Thumbnail] Using cached: fileId=${thumbnailId}, size=${stats.size} bytes`);
1045
- // Return cached thumbnail as MediaObject
1046
- const mo = await sdk.mediaManager.createMediaObjectFromUrl(`file://${cachePath}`);
1047
- return mo;
1048
- }
1049
- } catch {
1050
- // File doesn't exist, need to generate it
1051
- logger.debug(`[Thumbnail] Cache miss: fileId=${thumbnailId}`);
1052
- }
1053
-
1054
- // Ensure cache directory exists
1055
- await fs.promises.mkdir(cacheDir, { recursive: true });
1056
-
1057
- // Check if video clip is already cached locally - use it instead of calling camera
1058
- const videoCachePath = this.getVideoClipCachePath(thumbnailId);
1059
- let useLocalVideo = false;
1060
- try {
1061
- await fs.promises.access(videoCachePath, fs.constants.F_OK);
1062
- useLocalVideo = true;
1063
- logger.debug(`[Thumbnail] Using local video file for thumbnail extraction: fileId=${thumbnailId}`);
1064
- } catch {
1065
- // Video not cached locally, will use RTMP URL
1066
- }
1067
-
1068
- let thumbnail: MediaObject;
1069
-
1070
- if (useLocalVideo) {
1071
- // Extract thumbnail from local video file
1072
- thumbnail = await this.plugin.generateThumbnail({
1073
- deviceId: this.id,
1074
- fileId: thumbnailId,
1075
- filePath: videoCachePath,
1076
- device: this,
1077
- });
1078
- } else {
1079
- // Get RTMP URL using the appropriate API (NVR or Baichuan)
1080
- // Use forThumbnail=true to prefer Download over Playback (better for ffmpeg)
1081
- const rtmpVodUrl = await this.getVideoClipRtmpUrl(thumbnailId, true);
1082
-
1083
- // Use the plugin's thumbnail generation queue with RTMP URL
1084
- thumbnail = await this.plugin.generateThumbnail({
1085
- deviceId: this.id,
1086
- fileId: thumbnailId,
1087
- rtmpUrl: rtmpVodUrl,
1088
- device: this,
1089
- });
1090
- }
1091
-
1092
- // Cache the thumbnail
1093
- try {
1094
- const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(thumbnail, 'image/jpeg');
1095
- await fs.promises.writeFile(cachePath, buffer);
1096
- logger.debug(`[Thumbnail] Cached: fileId=${thumbnailId}, size=${buffer.length} bytes`);
1097
- } catch (e) {
1098
- logger.warn(`[Thumbnail] Failed to cache: fileId=${thumbnailId}`, e?.message || String(e));
1099
- // Continue even if caching fails
1100
- }
1101
-
1102
- return thumbnail;
1103
- } catch (e) {
1104
- logger.error(`[Thumbnail] Error: fileId=${thumbnailId}`, e?.message || String(e));
1105
- throw e;
1106
- }
1107
- }
1108
-
1109
- /**
1110
- * Get RTMP URL for a video clip file
1111
- * Handles both NVR source (full path) and Device source (filename only)
1112
- * @param fileId - The file ID or full path
1113
- * @param forThumbnail - If true, prefer Download over Playback (better for ffmpeg thumbnail extraction)
1114
- */
1115
- async getVideoClipRtmpUrl(fileId: string, forThumbnail: boolean = false): Promise<string> {
1116
- const logger = this.getBaichuanLogger();
1117
- const { clipsSource } = this.storageSettings.values;
1118
- const useNvr = clipsSource === "NVR" && this.nvrDevice && fileId.includes('/');
1119
-
1120
- if (useNvr) {
1121
- logger.debug(`[getVideoClipRtmpUrl] Using NVR API for fileId="${fileId}", forThumbnail=${forThumbnail}`);
1122
- const api = await this.ensureClient();
1123
- const channel = this.storageSettings.values.rtspChannel ?? 0;
1124
-
1125
- try {
1126
- logger.debug(`[getVideoClipRtmpUrl] Trying getVodUrl with Download requestType...`);
1127
- const url = await api.getVodUrl(fileId, channel, {
1128
- requestType: "Download",
1129
- streamType: "main",
1130
- });
1131
- logger.debug(`[getVideoClipRtmpUrl] NVR getVodUrl Download URL received: url="${url || 'none'}"`);
1132
- if (url) return url;
1133
- } catch (e: any) {
1134
- logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e?.message || String(e)}`);
1135
- }
1136
-
1137
- throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
1138
- } else {
1139
- // Camera standalone: DEVE usare RTMP da Baichuan API
1140
- logger.debug(`[getVideoClipRtmpUrl] Getting RTMP URL from Baichuan API for fileId="${fileId}" (camera standalone)`);
1141
- const api = await this.ensureClient();
1142
- const result = await api.getRecordingPlaybackUrls({
1143
- fileName: fileId,
1144
- });
1145
- logger.debug(`[getVideoClipRtmpUrl] Baichuan RTMP URL received: rtmpVodUrl="${result.rtmpVodUrl || 'none'}"`);
1146
- if (!result.rtmpVodUrl) {
1147
- throw new Error(`No RTMP URL found from Baichuan API for file ${fileId}`);
1148
- }
1149
- return result.rtmpVodUrl;
1150
- }
1151
- }
1152
-
1153
- removeVideoClips(...videoClipIds: string[]): Promise<void> {
1154
- throw new Error("removeVideoClips is not implemented yet.");
1155
- }
1156
-
1157
- /**
1158
- * Update video clips auto-load timer based on settings
1159
- */
1160
- private updateVideoClipsAutoLoad(): void {
1161
- // Clear existing interval if any
1162
- if (this.videoClipsAutoLoadInterval) {
1163
- clearInterval(this.videoClipsAutoLoadInterval);
1164
- this.videoClipsAutoLoadInterval = undefined;
1165
- }
1166
-
1167
- // Check if videoclips are enabled at all
1168
- const { enableVideoclips, loadVideoclips, videoclipsRegularChecks } = this.storageSettings.values;
1169
- if (!enableVideoclips) {
1170
- return;
1171
- }
1172
-
1173
-
1174
- if (!loadVideoclips) {
1175
- return;
1176
- }
1177
-
1178
- const logger = this.getBaichuanLogger();
1179
- const intervalMs = videoclipsRegularChecks * 60 * 1000;
1180
-
1181
- logger.log(`Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`);
1182
-
1183
- // Run immediately on start
1184
- this.loadTodayVideoClipsAndThumbnails();
1185
-
1186
- // Then run at regular intervals
1187
- this.videoClipsAutoLoadInterval = setInterval(() => {
1188
- this.loadTodayVideoClipsAndThumbnails();
1189
- }, intervalMs);
1190
- }
1191
-
1192
- /**
1193
- * Load today's video clips and download missing thumbnails
1194
- */
1195
- private async loadTodayVideoClipsAndThumbnails(): Promise<void> {
1196
- // Prevent concurrent executions
1197
- if (this.videoClipsAutoLoadInProgress) {
1198
- const logger = this.getBaichuanLogger();
1199
- logger.debug('Video clips auto-load already in progress, skipping...');
1200
- return;
1201
- }
1202
-
1203
- const logger = this.getBaichuanLogger();
1204
-
1205
- this.videoClipsAutoLoadInProgress = true;
1206
- this.videoClipsAutoLoadMode = true;
1207
-
1208
- try {
1209
- const daysToPreload = this.storageSettings.values.videoclipsDaysToPreload ?? 1;
1210
- logger.log(`Auto-loading video clips and thumbnails for the last ${daysToPreload} day(s)...`);
1211
-
1212
- // Get date range (start of N days ago to now)
1213
- const now = new Date();
1214
- const startDate = new Date(now);
1215
- startDate.setUTCDate(startDate.getUTCDate() - (daysToPreload - 1));
1216
- startDate.setUTCHours(0, 0, 0, 0);
1217
- startDate.setUTCMinutes(0, 0, 0);
1218
-
1219
- // Fetch video clips for the specified number of days
1220
- const clips = await this.getVideoClips({
1221
- startTime: startDate.getTime(),
1222
- endTime: now.getTime(),
1223
- });
1224
-
1225
- logger.log(`Found ${clips.length} video clips for the last ${daysToPreload} day(s)`);
1226
-
1227
- const downloadVideoclipsLocally = this.storageSettings.values.downloadVideoclipsLocally ?? false;
1228
-
1229
- // Track processed clips to avoid duplicate calls to the camera
1230
- const processedClips = new Set<string>();
1231
-
1232
- // Download videos first (if enabled), then thumbnails for each clip
1233
- for (const clip of clips) {
1234
- // Skip if already processed (avoid duplicate calls)
1235
- if (processedClips.has(clip.id)) {
1236
- logger.debug(`Skipping already processed clip: ${clip.id}`);
1237
- continue;
1238
- }
1239
- processedClips.add(clip.id);
1240
-
1241
- try {
1242
- // If downloadVideoclipsLocally is enabled, download the video clip first
1243
- // This allows the thumbnail to use the local file instead of calling the camera
1244
- if (downloadVideoclipsLocally) {
1245
- try {
1246
- // Call getVideoClip to trigger download and caching
1247
- await this.getVideoClip(clip.id);
1248
- logger.debug(`Downloaded video clip: ${clip.id}`);
1249
- } catch (e) {
1250
- logger.warn(`Failed to download video clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1251
- }
1252
- }
1253
-
1254
- // Then get the thumbnail - this will use the local video file if available
1255
- // or call the camera if the video wasn't downloaded
1256
- try {
1257
- await this.getVideoClipThumbnail(clip.id);
1258
- logger.debug(`Downloaded thumbnail for clip: ${clip.id}`);
1259
- } catch (e) {
1260
- logger.warn(`Failed to load thumbnail for clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1261
- }
1262
- } catch (e) {
1263
- logger.warn(`Error processing clip ${clip.id}:`, e instanceof Error ? e.message : String(e));
1264
- }
1265
- }
1266
-
1267
- logger.log(`Completed auto-loading video clips and thumbnails`);
1268
- } catch (e) {
1269
- logger.error('Error during auto-loading video clips:', e?.message || String(e));
1270
- } finally {
1271
- this.videoClipsAutoLoadInProgress = false;
1272
- this.videoClipsAutoLoadMode = false;
1273
- }
1274
- }
1275
-
1276
- async reboot(): Promise<void> {
1277
- const api = await this.ensureClient();
1278
- await api.reboot();
1279
- }
1280
-
1281
- // BaseBaichuanClass abstract methods implementation
1282
- protected getConnectionConfig(): BaichuanConnectionConfig {
1283
- const { ipAddress, username, password, uid, discoveryMethod } = this.storageSettings.values;
1284
- const debugOptions = this.getBaichuanDebugOptions();
1285
- const normalizedUid = this.isBattery ? normalizeUid(uid) : undefined;
1286
-
1287
- if (this.isBattery && !normalizedUid) {
1288
- throw new Error('UID is required for battery cameras (BCUDP)');
1289
- }
1290
-
1291
- // Prevent accidental connections to localhost (Node will default host=127.0.0.1 when host is undefined).
1292
- // This shows up as connect ECONNREFUSED 127.0.0.1:9000 and will never recover with socket resets.
1293
- if (!this.isBattery && !ipAddress) {
1294
- throw new Error('IP Address is required for TCP devices');
1295
- }
1296
-
1297
- return {
1298
- host: ipAddress,
1299
- username,
1300
- password,
1301
- uid: normalizedUid,
1302
- transport: this.protocol,
1303
- debugOptions,
1304
- udpDiscoveryMethod: discoveryMethod as BaichuanClientOptions["udpDiscoveryMethod"],
1305
- };
1306
- }
1307
-
1308
- protected getStreamClientInputs(): BaichuanConnectionConfig {
1309
- const { ipAddress, username, password } = this.storageSettings.values;
1310
- const debugOptions = this.getBaichuanDebugOptions();
1311
-
1312
- return {
1313
- host: ipAddress,
1314
- username,
1315
- password,
1316
- transport: this.transport,
1317
- debugOptions,
1318
- };
1319
- }
1320
-
1321
- protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
1322
- return {
1323
- onClose: async () => {
1324
- // Reset client state on close
1325
- // The base class already handles cleanup
1326
- // For battery cameras, don't auto-resubscribe after idle disconnects
1327
- // (idle disconnects are normal for battery cameras to save power)
1328
- if (!this.isBattery) {
1329
- setTimeout(async () => {
1330
- try {
1331
- await this.subscribeToEvents();
1332
- } catch (e) {
1333
- const logger = this.getBaichuanLogger();
1334
- logger.warn('Failed to resubscribe to events after reconnection', e?.message || String(e));
1335
- }
1336
- }, 1000);
1337
- }
1338
- },
1339
- onSimpleEvent: this.onSimpleEvent,
1340
- getEventSubscriptionEnabled: () => this.isEventDispatchEnabled?.() ?? false,
1341
- };
1342
- }
1343
-
1344
- protected isDebugEnabled(): boolean {
1345
- return this.storageSettings.values.debugLogs;
1346
- }
1347
-
1348
- protected getDeviceName(): string {
1349
- return this.name || 'Camera';
1350
- }
1351
-
1352
- async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
1353
- if (this.isBattery) {
1354
- return await fn();
1355
- } else {
1356
- try {
1357
- return await fn();
1358
- } catch (e) {
1359
- if (!this.isRecoverableBaichuanError(e)) {
1360
- throw e;
1361
- }
1362
-
1363
- // Reset client and clear cache on recoverable error
1364
- await this.resetBaichuanClient(e);
1365
-
1366
- // Important: callers must re-acquire the client inside fn.
1367
- try {
1368
- return await fn();
1369
- } catch (retryError) {
1370
- throw retryError;
1371
- }
1372
- }
1373
- }
1374
- }
1375
-
1376
- async runDiagnostics(): Promise<void> {
1377
- const logger = this.getBaichuanLogger();
1378
- const outputPath = this.storageSettings.values.diagnosticsOutputPath || process.env.SCRYPTED_PLUGIN_VOLUME || "";
1379
-
1380
- if (!outputPath) {
1381
- throw new Error('Diagnostics output path is required');
1382
- }
1383
-
1384
- const channel = this.storageSettings.values.rtspChannel || 0;
1385
- const durationSeconds = 15;
1386
- const selection: StreamSamplingSelection = {
1387
- kinds: ['native'],
1388
- profiles: ['main', 'ext', 'sub'],
1389
- };
1390
-
1391
- logger.log(`Starting diagnostics with parameters: outDir=${outputPath}, channel=${channel}, durationSeconds=${durationSeconds}, selection=${JSON.stringify(selection)}`);
1392
-
1393
- try {
1394
- const api = await this.ensureClient();
1395
- const { ipAddress, username, password } = this.storageSettings.values;
1396
-
1397
- const result = await api.runAllDiagnosticsConsecutively({
1398
- host: ipAddress,
1399
- username,
1400
- password,
1401
- outDir: outputPath,
1402
- channel,
1403
- durationSeconds,
1404
- selection,
1405
- api,
1406
- });
1407
-
1408
- logger.log(`Diagnostics completed successfully. Output directory: ${result.runDir}`);
1409
- logger.log(`Diagnostics file: ${result.diagnosticsPath}`);
1410
- logger.log(`Streams directory: ${result.streamsDir}`);
1411
- } catch (e) {
1412
- logger.error('Failed to run diagnostics', e?.message || String(e));
1413
- throw e;
1414
- }
1415
- }
1416
-
1417
- protected async onBeforeCleanup(): Promise<void> {
1418
- // Unsubscribe from events if needed
1419
- if (this.onSimpleEvent && this.baichuanApi) {
1420
- try {
1421
- this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
1422
- }
1423
- catch {
1424
- // ignore
1425
- }
1426
- }
1427
- }
1428
-
1429
- /**
1430
- * Create a dedicated Baichuan API session for streaming (used by StreamManager).
1431
- *
1432
- * - For TCP devices (regular + multifocal), this creates a new TCP session with its own client.
1433
- * - For UDP/battery devices, this reuses the existing client via ensureClient().
1434
- */
1435
- async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
1436
- // Determine who should create the socket based on device hierarchy:
1437
- // 1. Camera of multifocal with nvrDevice -> nvrDevice creates the socket
1438
- // 2. Camera of multifocal (without nvrDevice) -> multiFocalDevice creates the socket
1439
- // 3. Camera of nvr -> nvrDevice creates the socket
1440
- // 4. Standalone camera -> camera creates its own socket (via base class)
1441
-
1442
- // Case 1: Camera of multifocal with nvrDevice -> delegate to nvrDevice
1443
- if (this.multiFocalDevice?.nvrDevice) {
1444
- return await this.multiFocalDevice.nvrDevice.createStreamClient(streamKey);
1445
- }
1446
-
1447
- // Case 2: Camera of multifocal (without nvrDevice) -> delegate to multiFocalDevice
1448
- if (this.multiFocalDevice) {
1449
- return await this.multiFocalDevice.createStreamClient(streamKey);
1450
- }
1451
-
1452
- // Case 3: Camera of nvr -> delegate to nvrDevice
1453
- if (this.nvrDevice) {
1454
- return await this.nvrDevice.createStreamClient(streamKey);
1455
- }
1456
-
1457
- // Case 4: Standalone camera -> create its own socket using base class method
1458
- // For battery cameras, reuse the main client
1459
- // if (this.isBattery) {
1460
- // return await this.ensureClient();
1461
- // }
1462
-
1463
- // For TCP standalone cameras, use base class createStreamClient which manages stream clients per streamKey
1464
- return await super.createStreamClient(streamKey);
1465
- }
1466
-
1467
- public getAbilities(): DeviceCapabilities {
1468
- if (this.multiFocalDevice) {
1469
- const variantType = this.storageSettings.values.variantType;
1470
- const ifaces = this.multiFocalDevice.getInterfaces(variantType);
1471
- if (ifaces?.capabilities) return ifaces.capabilities;
1472
- } else {
1473
- const caps = this.storageSettings.values.capabilities;
1474
- if (caps) return caps;
1475
- }
1476
-
1477
- // Safe fallback to avoid crashes during init when connection hasn't succeeded yet.
1478
- return {
1479
- channel: this.storageSettings.values.rtspChannel ?? 0,
1480
- ptzMode: 'none',
1481
- hasPan: false,
1482
- hasTilt: false,
1483
- hasZoom: false,
1484
- hasPresets: false,
1485
- hasPtz: false,
1486
- hasBattery: !!this.isBattery,
1487
- hasIntercom: false,
1488
- hasSiren: false,
1489
- hasFloodlight: false,
1490
- hasPir: false,
1491
- isDoorbell: false,
1492
- };
1493
- }
1494
-
1495
- getBaichuanDebugOptions(): any | undefined {
1496
- const socketDebugLogs = this.storageSettings.values.socketApiDebugLogs || [];
1497
- return convertDebugLogsToApiOptions(socketDebugLogs);
1498
- }
1499
-
1500
- /**
1501
- * Initialize or recreate the StreamManager, taking into account multifocal composite options.
1502
- */
1503
- protected initStreamManager(logger?: Console, forceRecreate: boolean = false): void {
1504
- const { username, password } = this.storageSettings.values;
1505
- // Ensure logger is always valid - use provided logger or get from device, fallback to console
1506
- const validLogger = logger || this.getBaichuanLogger() || console;
1507
-
1508
- const baseOptions: StreamManagerOptions = {
1509
- createStreamClient: this.createStreamClient.bind(this),
1510
- logger: validLogger,
1511
- credentials: {
1512
- username,
1513
- password,
1514
- },
1515
- sharedConnection: this.isBattery || !!this.nvrDevice,
1516
- };
1517
-
1518
- if (this.isMultiFocal) {
1519
- const { pipPosition, pipSize, pipMargin, rtspChannel } = this.storageSettings.values;
1520
-
1521
- // On NVR/Hub, TrackMix lenses are selected via stream variant, not via a separate channel.
1522
- // Use rtspChannel for BOTH wide and tele so the library can request tele via streamType/variant.
1523
- const wider = this.isOnNvr ? rtspChannel : undefined;
1524
- const tele = this.isOnNvr ? rtspChannel : undefined;
1525
-
1526
- baseOptions.compositeOptions = {
1527
- widerChannel: wider,
1528
- teleChannel: tele,
1529
- pipPosition,
1530
- pipSize,
1531
- pipMargin,
1532
- onNvr: this.isOnNvr,
1533
- };
1534
- }
1535
-
1536
- if (!this.streamManager || forceRecreate) {
1537
- this.streamManager = new StreamManager(baseOptions);
1538
- }
1539
- }
1540
-
1541
- /**
1542
- * Debounced restart of StreamManager when PIP/composite settings change.
1543
- * Also notifies listeners so that active streams (prebuffer, etc.) restart cleanly.
1544
- */
1545
- protected scheduleStreamManagerRestart(reason: string): void {
1546
- const logger = this.getBaichuanLogger();
1547
- logger.log(`Scheduling StreamManager restart (${reason})`);
1548
-
1549
- if (this.streamManagerRestartTimeout) {
1550
- clearTimeout(this.streamManagerRestartTimeout);
1551
- this.streamManagerRestartTimeout = undefined;
1552
- }
1553
-
1554
- this.streamManagerRestartTimeout = setTimeout(async () => {
1555
- this.streamManagerRestartTimeout = undefined;
1556
- const logger = this.getBaichuanLogger();
1557
- try {
1558
- logger.log('Restarting StreamManager due to PIP/composite settings change');
1559
- this.initStreamManager(logger, true);
1560
-
1561
- // Invalidate snapshot cache for battery/multifocal-battery so that
1562
- // the next snapshot reflects the new PIP/composite configuration.
1563
- if (this.isBattery) {
1564
- this.forceNewSnapshot = true;
1565
- this.lastPicture = undefined;
1566
- }
1567
-
1568
- // Notify consumers (e.g. prebuffer) that stream configuration changed.
1569
- try {
1570
- this.onDeviceEvent(ScryptedInterface.VideoCamera, undefined);
1571
- } catch {
1572
- // best-effort
1573
- }
1574
- } catch (e) {
1575
- logger.warn('Failed to restart StreamManager after settings change', e);
1576
- }
1577
- }, 500);
1578
- }
1579
-
1580
- isRecoverableBaichuanError(e: any): boolean {
1581
- const message = e?.message || e?.toString?.() || '';
1582
- return typeof message === 'string' && (
1583
- message.includes('Baichuan socket closed') ||
1584
- message.includes('Baichuan UDP stream closed') ||
1585
- message.includes('Baichuan TCP socket is not connected') ||
1586
- message.includes('socket hang up') ||
1587
- message.includes('ECONNRESET') ||
1588
- message.includes('EPIPE')
1589
- );
1590
- }
1591
-
1592
- updatePtzCaps() {
1593
- const { hasPan, hasTilt, hasZoom } = this.getAbilities();
1594
- this.ptzCapabilities = {
1595
- ...this.ptzCapabilities,
1596
- pan: hasPan,
1597
- tilt: hasTilt,
1598
- zoom: hasZoom,
1599
- }
1600
- }
1601
-
1602
- // Event subscription methods
1603
- unsubscribedToEvents(): void {
1604
- this.unsubscribeFromEvents().catch(() => {
1605
- });
1606
-
1607
- if (this.motionDetected) {
1608
- this.motionDetected = false;
1609
- }
1610
- }
1611
-
1612
- onSimpleEvent = (ev: ReolinkSimpleEvent) => {
1613
- const logger = this.getBaichuanLogger();
1614
-
1615
- try {
1616
- const logger = this.getBaichuanLogger();
1617
-
1618
- logger.debug(`Baichuan event: ${JSON.stringify(ev)}`);
1619
-
1620
- if (!this.isEventDispatchEnabled()) {
1621
- logger.debug('Event dispatch is disabled, ignoring event');
1622
- return;
1623
- }
1624
-
1625
- const channel = this.storageSettings.values.rtspChannel;
1626
- if (ev?.channel !== undefined && ev.channel !== channel) {
1627
- logger.error(`Event channel ${ev.channel} does not match camera channel ${channel}, ignoring`);
1628
- return;
1629
- }
1630
-
1631
- // Handle battery/online/sleeping events separately from motion events
1632
- switch (ev?.type) {
1633
- case 'awake':
1634
- case 'sleeping':
1635
- // Update sleeping state for battery cameras or devices that support it
1636
- this.updateSleepingState({
1637
- reason: ev?.type === 'sleeping' ? 'sleeping' : 'awake',
1638
- state: ev.type === 'sleeping' ? 'sleeping' : 'awake',
1639
- }).catch((e) => {
1640
- logger.warn('Error updating sleeping state', e);
1641
- });
1642
- return; // These events are handled, no need to process as motion events
1643
-
1644
- case 'offline':
1645
- case 'online':
1646
- // Update online state for battery cameras or devices that support it
1647
- this.updateOnlineState(ev.type === 'online').catch((e) => {
1648
- logger.warn('Error updating online state', e);
1649
- });
1650
- return; // These events are handled, no need to process as motion events
1651
- }
1652
-
1653
- // Handle motion and object detection events
1654
- const objects: string[] = [];
1655
- let motion = false;
1656
-
1657
- switch (ev?.type) {
1658
- case 'motion':
1659
- motion = true;
1660
- break;
1661
- case 'doorbell':
1662
- this.handleDoorbellEvent();
1663
- motion = true;
1664
- break;
1665
- case 'people':
1666
- case 'vehicle':
1667
- case 'animal':
1668
- case 'face':
1669
- case 'package':
1670
- case 'other':
1671
- if (this.shouldDispatchObjects()) objects.push(ev.type);
1672
- motion = true;
1673
- break;
1674
- default:
1675
- logger.error(`Unknown event type: ${ev?.type}`);
1676
- return;
1677
- }
1678
-
1679
- this.processEvents({ motion, objects }).catch((e) => {
1680
- logger.warn('Error processing events', e);
1681
- });
1682
- }
1683
- catch (e) {
1684
- logger.warn('Error in onSimpleEvent handler', e);
1685
- }
1686
- }
1687
-
1688
- /**
1689
- * Subscribe to Baichuan events only if this is a standalone device (not a child of NVR or MultiFocal).
1690
- * If this device has a parent (nvrDevice or multiFocalDevice), events will be forwarded from the parent.
1691
- * This ensures that only the root device in the hierarchy subscribes to events, avoiding duplicate subscriptions.
1692
- */
1693
- async subscribeToEvents(): Promise<void> {
1694
- // If this device has a parent (NVR or MultiFocal), don't subscribe - events will be forwarded from parent
1695
- if (this.nvrDevice || this.multiFocalDevice) {
1696
- const logger = this.getBaichuanLogger();
1697
- logger.debug(`Device has parent (nvrDevice=${!!this.nvrDevice}, multiFocalDevice=${!!this.multiFocalDevice}), skipping event subscription (events will be forwarded from parent)`);
1698
- return;
1699
- }
1700
-
1701
- const api = await this.ensureClient();
1702
-
1703
- const logger = this.getBaichuanLogger();
1704
- const selection = Array.from(this.getDispatchEventsSelection?.() ?? new Set()).sort();
1705
- const enabled = selection.length > 0;
1706
-
1707
- logger.debug(`subscribeToEvents called: enabled=${enabled}, selection=[${selection.join(', ')}], protocol=${this.protocol}`);
1708
-
1709
- this.unsubscribedToEvents();
1710
-
1711
- const shouldDispatchMotion = selection.includes('motion');
1712
- if (!shouldDispatchMotion) {
1713
- if (this.motionTimeout) clearTimeout(this.motionTimeout);
1714
- this.motionDetected = false;
1715
- }
1716
-
1717
- if (!enabled) {
1718
- logger.log('Event subscription disabled, unsubscribing');
1719
- if (this.doorbellBinaryTimeout) {
1720
- clearTimeout(this.doorbellBinaryTimeout);
1721
- this.doorbellBinaryTimeout = undefined;
1722
- }
1723
- this.binaryState = false;
1724
- return;
1725
- }
1726
-
1727
- try {
1728
- await api.onSimpleEvent(this.onSimpleEvent);
1729
- logger.log(`Subscribed to events (${selection.join(', ')}) on ${this.protocol} connection`);
1730
- }
1731
- catch (e) {
1732
- logger.warn('Failed to attach Baichuan event handler', e);
1733
- return;
1734
- }
1735
- }
1736
-
1737
- // VideoTextOverlays interface implementation
1738
- async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> {
1739
- const client = await this.ensureClient();
1740
- const channel = this.storageSettings.values.rtspChannel;
1741
-
1742
- let osd = this.storageSettings.values.cachedOsd;
1743
-
1744
- if (!osd?.length) {
1745
- osd = await client.getOsd(channel);
1746
- this.storageSettings.values.cachedOsd = osd;
1747
- }
1748
-
1749
- return {
1750
- osdChannel: {
1751
- text: osd?.osdChannel?.enable ? osd.osdChannel.name : undefined,
1752
- },
1753
- osdTime: {
1754
- text: !!osd?.osdTime?.enable,
1755
- readonly: true,
1756
- },
1757
- };
1758
- }
1759
-
1760
- async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise<void> {
1761
- const client = await this.ensureClient();
1762
- const channel = this.storageSettings.values.rtspChannel;
1763
-
1764
- const osd = await client.getOsd(channel);
1765
-
1766
- if (id === 'osdChannel') {
1767
- const nextName = typeof value?.text === 'string' ? value.text.trim() : '';
1768
- const enable = !!nextName || value?.text === true;
1769
- osd.osdChannel.enable = enable ? 1 : 0;
1770
- // Name must always be valid when enabled.
1771
- if (enable) {
1772
- osd.osdChannel.name = nextName || osd.osdChannel.name || this.name || 'Camera';
1773
- }
1774
- }
1775
- else if (id === 'osdTime') {
1776
- osd.osdTime.enable = value?.text ? 1 : 0;
1777
- }
1778
- else {
1779
- throw new Error('unknown overlay: ' + id);
1780
- }
1781
-
1782
- await client.setOsd(channel, osd);
1783
- }
1784
-
1785
- // PanTiltZoom interface implementation
1786
- async ptzCommand(command: PanTiltZoomCommand): Promise<void> {
1787
- const logger = this.getBaichuanLogger();
1788
-
1789
- const client = await this.ensureClient();
1790
- if (!client) {
1791
- return;
1792
- }
1793
-
1794
- const channel = this.storageSettings.values.rtspChannel;
1795
-
1796
- // Preset navigation.
1797
- const preset = command.preset;
1798
- if (preset !== undefined && preset !== null) {
1799
- const presetId = Number(preset);
1800
- if (!Number.isFinite(presetId)) {
1801
- logger.warn(`Invalid PTZ preset id: ${preset}`);
1802
- return;
1803
- }
1804
- if (this.ptzPresets) {
1805
- await this.ptzPresets.moveToPreset(presetId);
1806
- } else {
1807
- logger.warn('PTZ presets not available');
1808
- }
1809
- return;
1810
- }
1811
-
1812
- // Map PanTiltZoomCommand to PtzCommand
1813
- let ptzAction: 'start' | 'stop' = 'start';
1814
- let ptzCommand: 'Left' | 'Right' | 'Up' | 'Down' | 'ZoomIn' | 'ZoomOut' | 'FocusNear' | 'FocusFar' = 'Left';
1815
-
1816
- if (command.pan !== undefined) {
1817
- if (command.pan === 0) {
1818
- // Stop pan movement - send stop with last direction
1819
- ptzAction = 'stop';
1820
- ptzCommand = 'Left'; // Use any direction for stop
1821
- } else {
1822
- ptzCommand = command.pan > 0 ? 'Right' : 'Left';
1823
- ptzAction = 'start';
1824
- }
1825
- } else if (command.tilt !== undefined) {
1826
- if (command.tilt === 0) {
1827
- // Stop tilt movement
1828
- ptzAction = 'stop';
1829
- ptzCommand = 'Up'; // Use any direction for stop
1830
- } else {
1831
- ptzCommand = command.tilt > 0 ? 'Up' : 'Down';
1832
- ptzAction = 'start';
1833
- }
1834
- } else if (command.zoom !== undefined) {
1835
- // Zoom is handled separately.
1836
- // Scrypted typically provides a normalized zoom value; treat it as direction and apply a step.
1837
- const z = Number(command.zoom);
1838
- if (!Number.isFinite(z) || z === 0) return;
1839
-
1840
- const step = Number(this.storageSettings.values.ptzZoomStep);
1841
- if (!Number.isFinite(step) || step <= 0) {
1842
- logger.warn('Invalid PTZ zoom step, using default 0.1');
1843
- return;
1844
- }
1845
-
1846
- // Get current zoom factor and apply step
1847
- const info = await client.getZoomFocus(channel);
1848
- if (!info?.zoom) {
1849
- logger.warn('Zoom command requested but camera did not report zoom support.');
1850
- return;
1851
- }
1852
-
1853
- // In Baichuan API, 1000 == 1.0x.
1854
- const curFactor = (info.zoom.curPos ?? 1000) / 1000;
1855
- const minFactor = (info.zoom.minPos ?? 1000) / 1000;
1856
- const maxFactor = (info.zoom.maxPos ?? 1000) / 1000;
1857
- const stepFactor = step;
1858
-
1859
- const direction = z > 0 ? 1 : -1;
1860
- const next = Math.min(maxFactor, Math.max(minFactor, curFactor + direction * stepFactor));
1861
- await client.zoomToFactor(channel, next);
1862
- return;
1863
- }
1864
-
1865
- const ptzCmd: PtzCommand = {
1866
- action: ptzAction,
1867
- command: ptzCommand,
1868
- speed: typeof command.speed === 'number' ? command.speed : 32,
1869
- autoStopMs: Number(this.storageSettings.values.ptzMoveDurationMs) || 500,
1870
- };
1871
-
1872
- await client.ptz(channel, ptzCmd);
1873
- }
1874
-
1875
- // ObjectDetector interface implementation
1876
- async getObjectTypes(): Promise<ObjectDetectionTypes> {
1877
- return {
1878
- classes: this.classes,
1879
- };
1880
- }
1881
-
1882
- async getDetectionInput(detectionId: string, eventId?: any): Promise<MediaObject> {
1883
- return null;
1884
- }
1885
-
1886
- getDispatchEventsSelection(): Set<'motion' | 'objects'> {
1887
- return new Set(this.storageSettings.values.dispatchEvents);
1888
- }
1889
-
1890
- isEventDispatchEnabled(): boolean {
1891
- return this.getDispatchEventsSelection().size > 0;
1892
- }
1893
-
1894
- shouldDispatchMotion(): boolean {
1895
- return this.getDispatchEventsSelection().has('motion');
1896
- }
1897
-
1898
- shouldDispatchObjects(): boolean {
1899
- return this.getDispatchEventsSelection().has('objects');
1900
- }
1901
-
1902
- async processEvents(events: { motion?: boolean; objects?: string[] }): Promise<void> {
1903
- const isEventDispatchEnabled = this.isEventDispatchEnabled?.() ?? true;
1904
- if (!isEventDispatchEnabled) return;
1905
-
1906
- const dispatchEvents = this.getDispatchEventsSelection?.() ?? new Set(['motion', 'objects']);
1907
- const shouldDispatchMotion = dispatchEvents.has('motion');
1908
- const shouldDispatchObjects = dispatchEvents.has('objects');
1909
-
1910
- if (shouldDispatchMotion && events.motion !== undefined) {
1911
- const motionDetected = events.motion;
1912
- if (motionDetected !== this.motionDetected) {
1913
- this.motionDetected = motionDetected;
1914
- if (motionDetected) {
1915
- if (this.motionTimeout) clearTimeout(this.motionTimeout);
1916
- const timeout = (this.storageSettings.values.motionTimeout || 30) * 1000;
1917
- this.motionTimeout = setTimeout(() => {
1918
- this.motionDetected = false;
1919
- }, timeout);
1920
- } else {
1921
- if (this.motionTimeout) clearTimeout(this.motionTimeout);
1922
- }
1923
- }
1924
- }
1925
-
1926
- if (shouldDispatchObjects && events.objects?.length) {
1927
- const od: ObjectsDetected = {
1928
- timestamp: Date.now(),
1929
- detections: [],
1930
- };
1931
- for (const c of events.objects) {
1932
- od.detections.push({
1933
- className: c,
1934
- score: 1,
1935
- });
1936
- }
1937
- if (this.nativeId) {
1938
- sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od);
1939
- }
1940
- }
1941
- }
1942
-
1943
- // BinarySensor interface implementation (for doorbell)
1944
- handleDoorbellEvent(): void {
1945
- if (!this.doorbellBinaryTimeout) {
1946
- this.binaryState = true;
1947
- this.doorbellBinaryTimeout = setTimeout(() => {
1948
- this.binaryState = false;
1949
- this.doorbellBinaryTimeout = undefined;
1950
- }, 5000);
1951
- }
1952
- }
1953
-
1954
- clearDoorbellBinary(): void {
1955
- if (this.doorbellBinaryTimeout) {
1956
- clearTimeout(this.doorbellBinaryTimeout);
1957
- this.doorbellBinaryTimeout = undefined;
1958
- }
1959
- this.binaryState = false;
1960
- }
1961
-
1962
- async reportDevicesParent(): Promise<void> {
1963
- const abilities = this.getAbilities();
1964
-
1965
- const { hasSiren, hasFloodlight, hasPir } = abilities;
1966
-
1967
- if (hasSiren) {
1968
- const sirenNativeId = `${this.nativeId}${sirenSuffix}`;
1969
- const device: Device = {
1970
- providerNativeId: this.nativeId,
1971
- name: `${this.name} Siren`,
1972
- nativeId: sirenNativeId,
1973
- info: {
1974
- ...(this.info || {}),
1975
- },
1976
- interfaces: [ScryptedInterface.OnOff, ScryptedInterface.Settings],
1977
- type: ScryptedDeviceType.Siren,
1978
- };
1979
- sdk.deviceManager.onDeviceDiscovered(device);
1980
- }
1981
-
1982
- if (hasFloodlight) {
1983
- const floodlightNativeId = `${this.nativeId}${floodlightSuffix}`;
1984
- const device: Device = {
1985
- providerNativeId: this.nativeId,
1986
- name: `${this.name} Floodlight`,
1987
- nativeId: floodlightNativeId,
1988
- info: {
1989
- ...(this.info || {}),
1990
- },
1991
- interfaces: [ScryptedInterface.OnOff, ScryptedInterface.Settings],
1992
- type: ScryptedDeviceType.Light,
1993
- };
1994
- sdk.deviceManager.onDeviceDiscovered(device);
1995
- }
1996
-
1997
- if (hasPir) {
1998
- const pirNativeId = `${this.nativeId}${pirSuffix}`;
1999
- const device: Device = {
2000
- providerNativeId: this.nativeId,
2001
- name: `${this.name} PIR`,
2002
- nativeId: pirNativeId,
2003
- info: {
2004
- ...(this.info || {}),
2005
- },
2006
- interfaces: [ScryptedInterface.OnOff, ScryptedInterface.Settings],
2007
- type: ScryptedDeviceType.Switch,
2008
- };
2009
- sdk.deviceManager.onDeviceDiscovered(device);
2010
-
2011
- this.reportDevices && await this.reportDevices();
2012
- }
2013
- }
2014
-
2015
- async getSettings(): Promise<Setting[]> {
2016
- const settings = await this.storageSettings.getSettings();
2017
-
2018
- return settings;
2019
- }
2020
-
2021
- async putSetting(key: string, value: string): Promise<void> {
2022
- await this.storageSettings.putSetting(key, value);
2023
- }
2024
-
2025
- async takePictureInternal(client: ReolinkBaichuanApi) {
2026
- const { rtspChannel, variantType } = this.storageSettings.values;
2027
- const logger = this.getBaichuanLogger();
2028
- logger.log(`Taking new snapshot from camera: forceNewSnapshot=${this.forceNewSnapshot} channel=${rtspChannel} variant=${variantType}`);
2029
-
2030
- const compositeOptions = this.isMultiFocal ? {
2031
- widerChannel: this.isOnNvr ? rtspChannel : undefined,
2032
- teleChannel: this.isOnNvr ? rtspChannel : undefined,
2033
- pipPosition: this.storageSettings.values.pipPosition || 'bottom-right',
2034
- pipSize: this.storageSettings.values.pipSize ?? 0.25,
2035
- pipMargin: this.storageSettings.values.pipMargin ?? 10,
2036
- onNvr: this.isOnNvr,
2037
- } : undefined;
2038
-
2039
- // For multifocal devices, request a composite snapshot by passing channel=undefined.
2040
- const channelArg = this.isMultiFocal ? undefined : rtspChannel;
2041
-
2042
- const snapshotBuffer = await client.getSnapshot(
2043
- channelArg,
2044
- {
2045
- onNvr: this.isOnNvr,
2046
- variant: variantType,
2047
- ...(compositeOptions ? { compositeOptions } : {}),
2048
- }
2049
- );
2050
- const mo = await this.createMediaObject(snapshotBuffer, 'image/jpeg');
2051
-
2052
- return mo;
2053
- }
2054
-
2055
- async takePicture(options?: RequestPictureOptions) {
2056
- if (!this.isBattery) {
2057
- try {
2058
- return this.withBaichuanRetry(async () => {
2059
- const client = await this.ensureClient();
2060
- return await this.takePictureInternal(client);
2061
- });
2062
- } catch (e) {
2063
- this.getBaichuanLogger().error('Error taking snapshot', e);
2064
- throw e;
2065
- }
2066
- } else {
2067
- const logger = this.getBaichuanLogger();
2068
- const shouldTakeNewSnapshot = this.forceNewSnapshot;
2069
-
2070
- if (!shouldTakeNewSnapshot && this.lastPicture) {
2071
- logger.log(`Returning cached snapshot, taken at ${new Date(this.lastPicture.atMs).toLocaleString()}`);
2072
- return this.lastPicture.mo;
2073
- }
2074
-
2075
- if (this.takePictureInFlight) {
2076
- return await this.takePictureInFlight;
2077
- }
2078
- this.forceNewSnapshot = false;
2079
-
2080
- this.takePictureInFlight = (async () => {
2081
- const client = await this.ensureClient();
2082
- const mo = await this.takePictureInternal(client);
2083
- this.lastPicture = { mo, atMs: Date.now() };
2084
- logger.log(`Snapshot taken at ${new Date(this.lastPicture.atMs).toLocaleString()}`);
2085
- return mo;
2086
- })();
2087
-
2088
- try {
2089
- return await this.takePictureInFlight;
2090
- }
2091
- finally {
2092
- this.takePictureInFlight = undefined;
2093
- }
2094
- }
2095
- }
2096
-
2097
- async getPictureOptions(): Promise<ResponsePictureOptions[]> {
2098
- return [];
2099
- }
2100
-
2101
- // Intercom interface methods
2102
- async startIntercom(media: MediaObject): Promise<void> {
2103
- if (this.intercom) {
2104
- await this.intercom.start(media);
2105
- } else {
2106
- throw new Error('Intercom not initialized');
2107
- }
2108
- }
2109
-
2110
- async stopIntercom(): Promise<void> {
2111
- if (this.intercom) {
2112
- return await this.intercom.stop();
2113
- } else {
2114
- throw new Error('Intercom not initialized');
2115
- }
2116
- }
2117
-
2118
- async updateDeviceInfo(): Promise<void> {
2119
- const logger = this.getBaichuanLogger();
2120
-
2121
- if (this.multiFocalDevice) {
2122
- this.info = this.multiFocalDevice.info;
2123
- return;
2124
- }
2125
-
2126
- const { ipAddress, rtspChannel } = this.storageSettings.values;
2127
- try {
2128
- const api = await this.ensureClient();
2129
- const deviceData = await api.getInfo((this.nvrDevice || this.multiFocalDevice) ? rtspChannel : undefined);
2130
-
2131
- await updateDeviceInfo({
2132
- device: this,
2133
- ipAddress,
2134
- deviceData,
2135
- logger,
2136
- });
2137
-
2138
- } catch (e) {
2139
- logger.warn('Failed to fetch device info', e);
2140
- }
2141
- }
2142
-
2143
- // Device provider methods
2144
- async getDevice(nativeId: string): Promise<any> {
2145
- if (nativeId.endsWith(sirenSuffix)) {
2146
- this.siren ||= new ReolinkCameraSiren(this, nativeId);
2147
- return this.siren;
2148
- } else if (nativeId.endsWith(floodlightSuffix)) {
2149
- this.floodlight ||= new ReolinkCameraFloodlight(this, nativeId);
2150
- return this.floodlight;
2151
- } else if (nativeId.endsWith(pirSuffix)) {
2152
- this.pirSensor ||= new ReolinkCameraPirSensor(this, nativeId);
2153
- return this.pirSensor;
2154
- }
2155
- }
2156
-
2157
- async release() {
2158
- this.plugin.mixinsMap.delete(this.id);
2159
- }
2160
-
2161
- async releaseDevice(id: string, nativeId: string): Promise<void> {
2162
- if (nativeId.endsWith(sirenSuffix)) {
2163
- this.siren = undefined;
2164
- } else if (nativeId.endsWith(floodlightSuffix)) {
2165
- this.floodlight = undefined;
2166
- } else if (nativeId.endsWith(pirSuffix)) {
2167
- this.pirSensor = undefined;
2168
- }
2169
- }
2170
-
2171
- async setSirenEnabled(enabled: boolean): Promise<void> {
2172
- const channel = this.storageSettings.values.rtspChannel;
2173
-
2174
- await this.withBaichuanRetry(async () => {
2175
- const api = await this.ensureClient();
2176
- return await api.setSiren(channel, enabled);
2177
- });
2178
- }
2179
-
2180
- async setFloodlightState(on?: boolean, brightness?: number): Promise<void> {
2181
- const channel = this.storageSettings.values.rtspChannel;
2182
-
2183
- await this.withBaichuanRetry(async () => {
2184
- const api = await this.ensureClient();
2185
- return await api.setWhiteLedState(channel, on, brightness);
2186
- });
2187
- }
2188
-
2189
- async setPirEnabled(enabled: boolean): Promise<void> {
2190
- const channel = this.storageSettings.values.rtspChannel;
2191
-
2192
- // Get current PIR settings from the sensor if available
2193
- let sensitive: number | undefined;
2194
- let reduceAlarm: number | undefined;
2195
- let interval: number | undefined;
2196
-
2197
- if (this.pirSensor) {
2198
- sensitive = this.pirSensor.storageSettings.values.sensitive;
2199
- reduceAlarm = this.pirSensor.storageSettings.values.reduceAlarm ? 1 : 0;
2200
- interval = this.pirSensor.storageSettings.values.interval;
2201
- }
2202
-
2203
- await this.withBaichuanRetry(async () => {
2204
- const api = await this.ensureClient();
2205
- return await api.setPirInfo(channel, {
2206
- enable: enabled ? 1 : 0,
2207
- ...(sensitive !== undefined ? { sensitive } : {}),
2208
- ...(reduceAlarm !== undefined ? { reduceAlarm } : {}),
2209
- ...(interval !== undefined ? { interval } : {}),
2210
- });
2211
- });
2212
- }
2213
-
2214
- /**
2215
- * Aligns auxiliary device states (siren, floodlight, PIR) with current API state.
2216
- * This should be called periodically for regular cameras and once when battery cameras wake up.
2217
- */
2218
- async alignAuxDevicesState(): Promise<void> {
2219
- const logger = this.getBaichuanLogger();
2220
-
2221
- const api = this.baichuanApi;
2222
- if (!api) return;
2223
-
2224
- const channel = this.storageSettings.values.rtspChannel;
2225
- const { hasSiren, hasFloodlight, hasPir } = this.getAbilities();
2226
-
2227
- try {
2228
- // Align siren state
2229
- if (hasSiren && this.siren) {
2230
- try {
2231
- const sirenState = await api.getSiren(channel);
2232
- this.siren.on = sirenState.enabled;
2233
- } catch (e) {
2234
- logger.debug('Failed to align siren state', e);
2235
- }
2236
- }
2237
-
2238
- // Align floodlight state
2239
- if (hasFloodlight && this.floodlight) {
2240
- try {
2241
- const wl = await api.getWhiteLedState(channel);
2242
- this.floodlight.on = !!wl.enabled;
2243
- if (wl.brightness !== undefined) {
2244
- this.floodlight.brightness = wl.brightness;
2245
- }
2246
- } catch (e) {
2247
- logger.debug('Failed to align floodlight state', e);
2248
- }
2249
- }
2250
-
2251
- // Align PIR state
2252
- if (hasPir && this.pirSensor) {
2253
- try {
2254
- const pirState = await api.getPirInfo(channel);
2255
- this.pirSensor.on = pirState.enabled;
2256
-
2257
- // Update storage settings with current values from API
2258
- if (pirState.state) {
2259
- if (pirState.state.sensitive !== undefined) {
2260
- this.pirSensor.storageSettings.values.sensitive = pirState.state.sensitive;
2261
- }
2262
- if (pirState.state.reduceAlarm !== undefined) {
2263
- // Convert number (0/1) to boolean
2264
- this.pirSensor.storageSettings.values.reduceAlarm = !!pirState.state.reduceAlarm;
2265
- }
2266
- if (pirState.state.interval !== undefined) {
2267
- this.pirSensor.storageSettings.values.interval = pirState.state.interval;
2268
- }
2269
- }
2270
- } catch (e) {
2271
- logger.debug('Failed to align PIR state', e);
2272
- }
2273
- }
2274
- } catch (e) {
2275
- logger.debug('Failed to align auxiliary devices state', e);
2276
- }
2277
- }
2278
-
2279
- // Video stream helper methods
2280
- protected addRtspCredentials(rtspUrl: string): string {
2281
- const logger = this.getBaichuanLogger();
2282
-
2283
- const { username, password } = this.storageSettings.values;
2284
- if (!username) {
2285
- return rtspUrl;
2286
- }
2287
-
2288
- try {
2289
- const url = new URL(rtspUrl);
2290
-
2291
- // For RTMP, add credentials as query parameters (matching reolink plugin behavior)
2292
- // The reolink plugin uses query parameters from client.parameters (token or user/password)
2293
- // Since we use Baichuan and don't have client.parameters, we use user/password
2294
- if (url.protocol === 'rtmp:') {
2295
- const params = url.searchParams;
2296
- params.set('user', username);
2297
- params.set('password', password || '');
2298
- } else {
2299
- // For RTSP, add credentials in URL auth
2300
- url.username = username;
2301
- url.password = password || '';
2302
- }
2303
-
2304
- return url.toString();
2305
- } catch (e) {
2306
- // If URL parsing fails, return original URL
2307
- logger.warn('Failed to parse URL for credentials', e);
2308
- return rtspUrl;
2309
- }
2310
- }
2311
-
2312
- async getVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
2313
- const logger = this.getBaichuanLogger();
2314
-
2315
- if (this.cachedVideoStreamOptions?.length) {
2316
- return this.cachedVideoStreamOptions;
2317
- }
2318
-
2319
- // If there's already a fetch in progress, return the existing promise
2320
- if (this.fetchingStreamsPromise) {
2321
- return this.fetchingStreamsPromise;
2322
- }
2323
-
2324
- // Create and save the promise
2325
- this.fetchingStreamsPromise = (async (): Promise<UrlMediaStreamOptions[]> => {
2326
- try {
2327
- let streams: UrlMediaStreamOptions[] = [];
2328
-
2329
- const client = await this.ensureClient();
2330
-
2331
- const { rtspChannel, variantType } = this.storageSettings.values;
2332
-
2333
- try {
2334
- // Lens-scoped behavior: request streams only for the current lens/variant.
2335
- // This keeps a single native_main and native_sub for the device.
2336
- const lensParam: NativeVideoStreamVariant | undefined = variantType as any;
2337
-
2338
- const { nativeStreams, rtmpStreams, rtspStreams } = await client.buildVideoStreamOptions({
2339
- onNvr: this.isOnNvr,
2340
- channel: rtspChannel,
2341
- compositeOnly: this.isMultiFocal,
2342
- ...(lensParam !== undefined ? { lens: lensParam } : {})
2343
- });
2344
-
2345
- // const urls = client.getRtspUrl(rtspChannel);
2346
-
2347
- // let supportedStreams: ReolinkSupportedStream[] = [];
2348
- const supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2349
- // logger.log({ supportedStreams, variantType, lensParam, rtspChannel, onNvr: this.isOnNvr, nativeStreams: nativeStreams.map(s => ({ id: s.id, nativeVariant: s.nativeVariant, lens: s.lens })), rtspStreams: rtspStreams.map(s => ({ id: s.id, lens: s.lens })), rtmpStreams: rtmpStreams.map(s => ({ id: s.id, lens: s.lens })) });
2350
-
2351
- for (const supportedStream of supportedStreams) {
2352
- const { id, metadata, url, name, container, nativeVariant, lens } = supportedStream;
2353
-
2354
- // Composite streams are re-encoded to H.264 by the library (ffmpeg/libx264).
2355
- // Do not infer codec from underlying camera metadata.
2356
- const isComposite = id.startsWith('composite_') || lens === 'composite';
2357
- const codec = isComposite
2358
- ? 'h264'
2359
- : String(metadata.videoEncType || "").includes("264")
2360
- ? "h264"
2361
- : String(metadata.videoEncType || "").includes("265")
2362
- ? "h265"
2363
- : String(metadata.videoEncType || "").toLowerCase();
2364
-
2365
- // Preserve variant information for native RTP streams by ensuring the URL contains it.
2366
- let finalUrl = url;
2367
- const variantFromIdOrUrl = extractVariantFromStreamId(id, url);
2368
- const variantToInject = (nativeVariant && nativeVariant !== 'default')
2369
- ? nativeVariant
2370
- : variantFromIdOrUrl;
2371
-
2372
- if (variantToInject && container === 'rtp') {
2373
- try {
2374
- const urlObj = new URL(url);
2375
- if (!urlObj.searchParams.has('variant')) {
2376
- urlObj.searchParams.set('variant', variantToInject);
2377
- finalUrl = urlObj.toString();
2378
- }
2379
- } catch {
2380
- // Invalid URL, use original
2381
- }
2382
- }
2383
-
2384
- streams.push({
2385
- id,
2386
- name,
2387
- url: finalUrl,
2388
- container,
2389
- video: { codec, width: metadata.width, height: metadata.height },
2390
- // audio: { codec: metadata.audioCodec }
2391
- })
2392
- }
2393
- } catch (e) {
2394
- if (!this.isRecoverableBaichuanError?.(e)) {
2395
- logger.warn('Failed to build RTSP/RTMP stream options, falling back to Native', e?.message || String(e));
2396
- }
2397
- }
2398
-
2399
- if (streams.length) {
2400
- logger.log('Fetched video stream options', streams.map((s) => s.name).join(', '));
2401
- logger.debug(JSON.stringify(streams));
2402
- this.cachedVideoStreamOptions = streams;
2403
- return streams;
2404
- }
2405
-
2406
- return [];
2407
- } finally {
2408
- // Always clear the promise when done (success or failure)
2409
- this.fetchingStreamsPromise = undefined;
2410
- }
2411
- })();
2412
-
2413
- return this.fetchingStreamsPromise;
2414
- }
2415
-
2416
- async getVideoStream(vso: RequestMediaStreamOptions): Promise<MediaObject> {
2417
- if (!vso) throw new Error("video streams not set up or no longer exists.");
2418
-
2419
- const vsos = await this.getVideoStreamOptions();
2420
- const logger = this.getBaichuanLogger();
2421
-
2422
- logger.debug(`Available streams: ${vsos?.map(s => s.id).join(', ') || 'none'}`);
2423
- logger.debug(`Requested stream ID: '${vso?.id}'`);
2424
-
2425
- const selected = selectStreamOption(vsos, vso);
2426
-
2427
- // If the request explicitly asks for a variant (e.g. native_telephoto_main),
2428
- // never override it with the device's variantType preference.
2429
- // const requestedVariant = vso?.id ? extractVariantFromStreamId(vso.id, undefined) : undefined;
2430
-
2431
- // If we have variantType set and the selected stream doesn't have the variant,
2432
- // try to find a stream with the correct variant that matches the profile
2433
- // const variantType = this.storageSettings.values.variantType;
2434
- // if (!requestedVariant && variantType && variantType !== 'default') {
2435
- // const profile = parseStreamProfileFromId(selected.id) || 'main';
2436
-
2437
- // // On NVR, firmwares vary: some expose the tele lens as 'autotrack', others as 'telephoto'.
2438
- // // When variantType is set, prefer that variant but fall back to the other tele variant if present.
2439
- // const preferred = variantType as 'autotrack' | 'telephoto';
2440
- // const fallbacks: Array<'autotrack' | 'telephoto'> = this.isOnNvr && preferred === 'telephoto'
2441
- // ? ['telephoto', 'autotrack']
2442
- // : this.isOnNvr && preferred === 'autotrack'
2443
- // ? ['autotrack', 'telephoto']
2444
- // : [preferred];
2445
-
2446
- // const extractedVariant = extractVariantFromStreamId(selected.id, selected.url);
2447
- // for (const v of fallbacks) {
2448
- // const variantId = `native_${v}_${profile}`;
2449
- // const variantStream = vsos?.find(s => s.id === variantId);
2450
- // if (!variantStream) {
2451
- // logger.debug(`Variant stream '${variantId}' not found in available streams`);
2452
- // continue;
2453
- // }
2454
- // // Only use variant stream if the selected one doesn't already have a variant,
2455
- // // or if the selected one has a different variant than what we want.
2456
- // if (!extractedVariant || extractedVariant !== v) {
2457
- // logger.log(`Preferring variant stream: '${variantId}' over '${selected.id}' (variantType='${variantType}')`);
2458
- // selected = variantStream;
2459
- // }
2460
- // break;
2461
- // }
2462
- // }
2463
-
2464
- logger.log(`Selected stream: id='${selected.id}', url='${selected.url}'`);
2465
-
2466
- if (selected.url && (selected.container === 'rtsp' || selected.container === 'rtmp')) {
2467
- const urlWithCredentials = this.addRtspCredentials(selected.url);
2468
- const ret: MediaStreamUrl = {
2469
- container: selected.container,
2470
- url: urlWithCredentials,
2471
- mediaStreamOptions: selected,
2472
- };
2473
- return await this.createMediaObject(ret, ScryptedMimeTypes.MediaStreamUrl);
2474
- }
2475
-
2476
- if (!this.streamManager) {
2477
- throw new Error('StreamManager not initialized');
2478
- }
2479
-
2480
- // Check if this is a composite stream request (for multifocal devices)
2481
- // const isComposite = selected.id?.startsWith('composite_');
2482
- // if (isComposite && this.options && (this.options.type === 'multi-focal' || this.options.type === 'multi-focal-battery')) {
2483
- if (selected.id?.startsWith('composite_')) {
2484
- const profile = parseStreamProfileFromId(selected.id.replace('composite_', '')) || 'main';
2485
- // Include variantType in streamKey to ensure each variantType has its own unique socket
2486
- // This is important for multifocal devices where different variantTypes may request composite streams
2487
- const variantType = this.storageSettings.values.variantType || 'default';
2488
- const streamKey = `composite_${variantType}_${profile}`;
2489
-
2490
- logger.log(`Creating composite stream: profile=${profile}, variantType=${variantType}, streamKey=${streamKey}`);
2491
-
2492
- const createStreamFn = async () => {
2493
- return await createRfc4571CompositeMediaObjectFromStreamManager({
2494
- streamManager: this.streamManager,
2495
- profile,
2496
- streamKey,
2497
- selected,
2498
- sourceId: this.id,
2499
- variantType,
2500
- });
2501
- };
2502
-
2503
- return await this.withBaichuanRetry(createStreamFn);
2504
- }
2505
-
2506
- // Regular stream for single channel
2507
- const profile = parseStreamProfileFromId(selected.id) || 'main';
2508
- const channel = this.storageSettings.values.rtspChannel;
2509
- // Extract variant from stream ID or URL if present (e.g., "autotrack" from "native_autotrack_main" or "?variant=autotrack")
2510
- let variant = extractVariantFromStreamId(selected.id, selected.url);
2511
-
2512
- // Fallback: if no variant found in stream ID/URL, use variantType from device settings
2513
- // This is important for multi-focal devices where the device has a variantType setting
2514
- if (!variant && this.storageSettings.values.variantType && this.storageSettings.values.variantType !== 'default') {
2515
- variant = this.storageSettings.values.variantType as 'autotrack' | 'telephoto';
2516
- logger.log(`Using variant from device settings: '${variant}' (not found in stream ID/URL)`);
2517
- }
2518
-
2519
- logger.log(`Stream selection: id='${selected.id}', profile='${profile}', channel=${channel}, variant='${variant || 'default'}'`);
2520
-
2521
- // Include variant in streamKey to distinguish streams with different variants
2522
- const streamKey = variant ? `${channel}_${variant}_${profile}` : `${channel}_${profile}`;
2523
-
2524
- const createStreamFn = async () => {
2525
- // Honor the requested variant. Some NVR firmwares label the tele lens as either
2526
- // 'autotrack' or 'telephoto', and the library exposes both when available.
2527
- logger.log(`Creating RFC4571 stream: channel=${channel}, profile=${profile}, variant=${variant || 'default'}, streamKey=${streamKey}`);
2528
-
2529
- return await createRfc4571MediaObjectFromStreamManager({
2530
- streamManager: this.streamManager!,
2531
- channel,
2532
- profile,
2533
- streamKey,
2534
- variant,
2535
- selected,
2536
- sourceId: this.id,
2537
- });
2538
- };
2539
-
2540
- return await this.withBaichuanRetry(createStreamFn);
2541
- }
2542
-
2543
- async ensureClient(): Promise<ReolinkBaichuanApi> {
2544
- if (this.nvrDevice) {
2545
- return await this.nvrDevice.ensureClient();
2546
- }
2547
- if (this.multiFocalDevice) {
2548
- return await this.multiFocalDevice.ensureClient();
2549
- }
2550
-
2551
- // Use base class implementation
2552
- return await this.ensureBaichuanClient();
2553
- }
2554
-
2555
- async credentialsChanged(): Promise<void> {
2556
- this.cachedVideoStreamOptions = undefined;
2557
- }
2558
-
2559
- // PTZ Presets methods
2560
- getSelectedPresetId(): number | undefined {
2561
- const s = this.storageSettings.values.ptzSelectedPreset;
2562
- if (!s) return undefined;
2563
-
2564
- const idPart = s.includes('=') ? s.split('=')[0] : s;
2565
- const id = Number(idPart);
2566
- return Number.isFinite(id) ? id : undefined;
2567
- }
2568
-
2569
- async refreshDeviceState(): Promise<void> {
2570
- if (this.refreshingState) {
2571
- return;
2572
- }
2573
- this.refreshingState = true;
2574
-
2575
- const logger = this.getBaichuanLogger();
2576
- const channel = this.storageSettings.values.rtspChannel;
2577
-
2578
- try {
2579
- const { capabilities, abilities, support, presets, objects } = await this.withBaichuanRetry(async () => {
2580
- const api = await this.ensureClient();
2581
- return await api.getDeviceCapabilities(channel);
2582
- });
2583
- this.classes = objects;
2584
- this.presets = presets;
2585
- this.ptzPresets.setCachedPtzPresets(presets);
2586
-
2587
- try {
2588
- const { interfaces, type } = getDeviceInterfaces({
2589
- capabilities,
2590
- logger: this.console,
2591
- });
2592
-
2593
- const device: Device = {
2594
- nativeId: this.nativeId,
2595
- providerNativeId: this.nvrDevice?.nativeId ??
2596
- this.multiFocalDevice?.nativeId ??
2597
- this.plugin?.nativeId,
2598
- name: this.name,
2599
- interfaces,
2600
- type,
2601
- info: this.info,
2602
- };
2603
-
2604
- await sdk.deviceManager.onDeviceDiscovered(device);
2605
-
2606
- logger.log(`Device interfaces updated`);
2607
- logger.debug(JSON.stringify({ hasNvr: !!this.nvrDevice, hasMultiFocal: !!this.multiFocalDevice, hasPlugin: !!this.plugin }));
2608
- logger.debug(`${JSON.stringify(device)}`);
2609
- } catch (e) {
2610
- logger.error('Failed to update device interfaces', e?.message || String(e));
2611
- }
2612
-
2613
- logger.log(`Refreshed device capabilities`);
2614
- logger.debug(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets, objects })}`);
2615
- }
2616
- catch (e) {
2617
- logger.error('Failed to refresh abilities', e?.message || String(e));
2618
- }
2619
-
2620
- this.refreshingState = false;
2621
- }
2622
-
2623
- async parentInit(): Promise<void> {
2624
- const logger = this.getBaichuanLogger();
2625
-
2626
- this.init && await this.init();
2627
-
2628
- try {
2629
- await this.ensureClient();
2630
- await this.updateDeviceInfo();
2631
- }
2632
- catch (e) {
2633
- logger.warn('Failed to update device info during init', e?.message || String(e));
2634
- }
2635
-
2636
- await this.refreshDeviceState();
2637
-
2638
- if (!this.multiFocalDevice) {
2639
- try {
2640
- await this.reportDevices();
2641
- }
2642
- catch (e) {
2643
- logger.warn('Failed to connect/refresh during init', e?.message || String(e));
2644
- }
2645
- }
2646
-
2647
- this.storageSettings.settings.socketApiDebugLogs.hide = !!this.nvrDevice;
2648
- this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
2649
- this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
2650
-
2651
- this.storageSettings.settings.videoclipsRegularChecks.defaultValue = this.isBattery ? 120 : 30;
2652
-
2653
- this.storageSettings.settings.batteryUpdateIntervalMinutes.hide = !this.isBattery;
2654
- this.storageSettings.settings.lowThresholdBatteryRecording.hide = !this.isBattery;
2655
- this.storageSettings.settings.highThresholdBatteryRecording.hide = !this.isBattery;
2656
-
2657
- // Show PIP settings only for multifocal devices
2658
- this.storageSettings.settings.pipPosition.hide = !this.isMultiFocal;
2659
- this.storageSettings.settings.pipSize.hide = !this.isMultiFocal;
2660
- this.storageSettings.settings.pipMargin.hide = !this.isMultiFocal;
2661
-
2662
- this.storageSettings.settings.uid.hide = !this.isBattery
2663
- this.storageSettings.settings.discoveryMethod.hide = !this.isBattery && !this.nvrDevice;
2664
-
2665
- if (this.isBattery && !this.storageSettings.values.mixinsSetup) {
2666
- try {
2667
- const device = sdk.systemManager.getDeviceById<Settings>(this.id);
2668
- if (device) {
2669
- logger.log('Disabling prebuffer and snapshots from prebuffer');
2670
- await device.putSetting('prebuffer:enabledStreams', '[]');
2671
- await device.putSetting('snapshot:snapshotsFromPrebuffer', 'Disabled');
2672
- this.storageSettings.values.mixinsSetup = true;
2673
- }
2674
- }
2675
- catch (e) {
2676
- logger.warn('Failed to setup mixins during init', e?.message || String(e));
2677
- }
2678
- }
2679
-
2680
- try {
2681
- await this.subscribeToEvents();
2682
- }
2683
- catch (e) {
2684
- logger.warn('Failed to subscribe to Baichuan events', e);
2685
- }
2686
-
2687
- try {
2688
- this.initStreamManager();
2689
- }
2690
- catch (e) {
2691
- logger.warn('Failed to initialize StreamManager', e);
2692
- }
2693
-
2694
- const { hasIntercom, hasPtz } = this.getAbilities();
2695
-
2696
- if (hasIntercom) {
2697
- this.intercom = new ReolinkBaichuanIntercom(this);
2698
- }
2699
-
2700
- if (hasPtz) {
2701
- const choices = (this.presets || []).map((preset: any) => preset.id + '=' + preset.name);
2702
-
2703
- this.storageSettings.settings.presets.choices = choices;
2704
- this.storageSettings.settings.ptzSelectedPreset.choices = choices;
2705
-
2706
- this.storageSettings.settings.presets.hide = false;
2707
- this.storageSettings.settings.ptzMoveDurationMs.hide = false;
2708
- this.storageSettings.settings.ptzZoomStep.hide = false;
2709
- this.storageSettings.settings.ptzCreatePreset.hide = false;
2710
- this.storageSettings.settings.ptzSelectedPreset.hide = false;
2711
- this.storageSettings.settings.ptzUpdateSelectedPreset.hide = false;
2712
- this.storageSettings.settings.ptzDeleteSelectedPreset.hide = false;
2713
-
2714
- this.updatePtzCaps();
2715
- }
2716
-
2717
- const parentDevice = this.nvrDevice || this.multiFocalDevice;
2718
- if (parentDevice) {
2719
- this.storageSettings.settings.username.hide = true;
2720
- this.storageSettings.settings.password.hide = true;
2721
- this.storageSettings.settings.ipAddress.hide = true;
2722
-
2723
- this.storageSettings.settings.username.defaultValue = parentDevice.storageSettings.values.username;
2724
- this.storageSettings.settings.password.defaultValue = parentDevice.storageSettings.values.password;
2725
- this.storageSettings.settings.ipAddress.defaultValue = parentDevice.storageSettings.values.ipAddress;
2726
- }
2727
-
2728
- this.updateVideoClipsAutoLoad();
2729
-
2730
- this.onDeviceEvent(ScryptedInterface.Settings, '');
2731
- }
2732
-
2733
- async updateSleepingState(sleepStatus: SleepStatus): Promise<void> {
2734
- try {
2735
- if (this.isDebugEnabled()) {
2736
- this.getBaichuanLogger().debug('getSleepStatus result:', JSON.stringify(sleepStatus));
2737
- }
2738
-
2739
- if (sleepStatus.state === 'sleeping') {
2740
- if (!this.sleeping) {
2741
- this.getBaichuanLogger().log(`Camera is sleeping: ${sleepStatus.reason}`);
2742
- this.sleeping = true;
2743
- }
2744
- } else if (sleepStatus.state === 'awake') {
2745
- // Camera is awake
2746
- const wasSleeping = this.sleeping;
2747
- if (wasSleeping) {
2748
- this.getBaichuanLogger().log(`Camera woke up: ${sleepStatus.reason}`);
2749
- this.sleeping = false;
2750
- }
2751
-
2752
- if (wasSleeping) {
2753
- this.alignAuxDevicesState().catch(() => { });
2754
- if (this.forceNewSnapshot) {
2755
- this.takePicture().catch(() => { });
2756
- }
2757
- }
2758
- } else {
2759
- // Unknown state
2760
- this.getBaichuanLogger().debug(`Sleep status unknown: ${sleepStatus.reason}`);
2761
- }
2762
- } catch (e) {
2763
- // Silently ignore errors in sleep check to avoid spam
2764
- this.getBaichuanLogger().debug('Error in updateSleepingState:', e);
2765
- }
2766
- }
2767
-
2768
- async updateOnlineState(isOnline: boolean): Promise<void> {
2769
- try {
2770
- if (this.isDebugEnabled()) {
2771
- this.getBaichuanLogger().debug('updateOnlineState result:', isOnline);
2772
- }
2773
-
2774
- if (isOnline !== this.online) {
2775
- this.online = isOnline;
2776
- }
2777
- } catch (e) {
2778
- // Silently ignore errors in sleep check to avoid spam
2779
- this.getBaichuanLogger().debug('Error in updateOnlineState:', e);
2780
- }
2781
- }
2782
- }