@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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera.ts +3002 -90
- package/src/intercom.ts +2 -4
- package/src/main.ts +12 -38
- package/src/multiFocal.ts +70 -142
- package/src/nvr.ts +17 -193
- package/src/presets.ts +2 -2
- package/src/utils.ts +3 -4
- package/src/camera-battery.ts +0 -283
- package/src/common.ts +0 -2782
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 {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
36
|
-
multiFocalDevice?: ReolinkNativeMultiFocalDevice
|
|
676
|
+
public options: ReolinkCameraOptions
|
|
37
677
|
) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
707
|
+
async getVideoClips(options?: VideoClipOptions): Promise<VideoClip[]> {
|
|
708
|
+
// Check if videoclips are enabled
|
|
709
|
+
if (!this.storageSettings.values.enableVideoclips) {
|
|
710
|
+
return [];
|
|
53
711
|
}
|
|
54
|
-
|
|
55
|
-
|
|
712
|
+
|
|
713
|
+
if (this.multiFocalDevice) {
|
|
714
|
+
return this.multiFocalDevice.getVideoClips(options);
|
|
56
715
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
await this.alignAuxDevicesState();
|
|
1168
|
+
removeVideoClips(...videoClipIds: string[]): Promise<void> {
|
|
1169
|
+
throw new Error("removeVideoClips is not implemented yet.");
|
|
107
1170
|
}
|
|
108
1171
|
|
|
109
|
-
|
|
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
|
-
|
|
1196
|
+
logger.log(`Starting video clips auto-load: checking every ${videoclipsRegularChecks} minutes`);
|
|
113
1197
|
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|