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