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