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