@apocaliss92/scrypted-reolink-native 0.0.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/.vscode/launch.json +23 -0
- package/.vscode/settings.json +4 -0
- package/.vscode/tasks.json +20 -0
- package/README.md +1 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +3 -0
- package/dist/plugin.zip +0 -0
- package/package.json +47 -0
- package/src/camera.ts +1576 -0
- package/src/intercom.ts +417 -0
- package/src/main.ts +126 -0
- package/src/presets.ts +209 -0
- package/src/stream-utils.ts +112 -0
- package/src/utils.ts +60 -0
- package/tsconfig.json +13 -0
package/src/camera.ts
ADDED
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
import type { BatteryInfo, DeviceCapabilities, PtzCommand, ReolinkBaichuanApi, ReolinkSimpleEvent, StreamProfile } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
import sdk, { Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, Sleep, VideoCamera, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
|
|
3
|
+
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
4
|
+
import { RtspClient } from "../../scrypted/common/src/rtsp-server";
|
|
5
|
+
import { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
6
|
+
import { ReolinkBaichuanIntercom } from "./intercom";
|
|
7
|
+
import ReolinkNativePlugin from "./main";
|
|
8
|
+
import { ReolinkPtzPresets } from "./presets";
|
|
9
|
+
import { parseStreamProfileFromId, StreamManager } from './stream-utils';
|
|
10
|
+
|
|
11
|
+
export const moToB64 = async (mo: MediaObject) => {
|
|
12
|
+
const bufferImage = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
13
|
+
return bufferImage?.toString('base64');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const b64ToMo = async (b64: string) => {
|
|
17
|
+
const buffer = Buffer.from(b64, 'base64');
|
|
18
|
+
return await sdk.mediaManager.createMediaObject(buffer, 'image/jpeg');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff {
|
|
22
|
+
sirenTimeout: NodeJS.Timeout;
|
|
23
|
+
|
|
24
|
+
constructor(public camera: ReolinkNativeCamera, nativeId: string) {
|
|
25
|
+
super(nativeId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async turnOff() {
|
|
29
|
+
this.camera.getLogger().log(`Siren toggle: turnOff (device=${this.nativeId})`);
|
|
30
|
+
this.on = false;
|
|
31
|
+
try {
|
|
32
|
+
await this.setSiren(false);
|
|
33
|
+
this.camera.getLogger().log(`Siren toggle: turnOff ok (device=${this.nativeId})`);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
this.camera.getLogger().warn(`Siren toggle: turnOff failed (device=${this.nativeId})`, e);
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async turnOn() {
|
|
42
|
+
this.camera.getLogger().log(`Siren toggle: turnOn (device=${this.nativeId})`);
|
|
43
|
+
this.on = true;
|
|
44
|
+
try {
|
|
45
|
+
await this.setSiren(true);
|
|
46
|
+
this.camera.getLogger().log(`Siren toggle: turnOn ok (device=${this.nativeId})`);
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
this.camera.getLogger().warn(`Siren toggle: turnOn failed (device=${this.nativeId})`, e);
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private async setSiren(on: boolean) {
|
|
55
|
+
this.camera.markActivity();
|
|
56
|
+
|
|
57
|
+
const api = await this.camera.ensureClient();
|
|
58
|
+
const channel = this.camera.getRtspChannel();
|
|
59
|
+
|
|
60
|
+
await this.camera.withBaichuanRetry(async () => await api.setSiren(channel, on));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brightness {
|
|
65
|
+
constructor(public camera: ReolinkNativeCamera, nativeId: string) {
|
|
66
|
+
super(nativeId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async setBrightness(brightness: number): Promise<void> {
|
|
70
|
+
this.camera.getLogger().log(`Floodlight toggle: setBrightness (device=${this.nativeId} brightness=${brightness})`);
|
|
71
|
+
this.brightness = brightness;
|
|
72
|
+
try {
|
|
73
|
+
await this.setFloodlight(undefined, brightness);
|
|
74
|
+
this.camera.getLogger().log(`Floodlight toggle: setBrightness ok (device=${this.nativeId} brightness=${brightness})`);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
this.camera.getLogger().warn(`Floodlight toggle: setBrightness failed (device=${this.nativeId} brightness=${brightness})`, e);
|
|
78
|
+
throw e;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async turnOff() {
|
|
83
|
+
this.camera.getLogger().log(`Floodlight toggle: turnOff (device=${this.nativeId})`);
|
|
84
|
+
this.on = false;
|
|
85
|
+
try {
|
|
86
|
+
await this.setFloodlight(false);
|
|
87
|
+
this.camera.getLogger().log(`Floodlight toggle: turnOff ok (device=${this.nativeId})`);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
this.camera.getLogger().warn(`Floodlight toggle: turnOff failed (device=${this.nativeId})`, e);
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async turnOn() {
|
|
96
|
+
this.camera.getLogger().log(`Floodlight toggle: turnOn (device=${this.nativeId})`);
|
|
97
|
+
this.on = true;
|
|
98
|
+
try {
|
|
99
|
+
await this.setFloodlight(true);
|
|
100
|
+
this.camera.getLogger().log(`Floodlight toggle: turnOn ok (device=${this.nativeId})`);
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
this.camera.getLogger().warn(`Floodlight toggle: turnOn failed (device=${this.nativeId})`, e);
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async setFloodlight(on?: boolean, brightness?: number) {
|
|
109
|
+
this.camera.markActivity();
|
|
110
|
+
|
|
111
|
+
const api = await this.camera.ensureClient();
|
|
112
|
+
const channel = this.camera.getRtspChannel();
|
|
113
|
+
|
|
114
|
+
await this.camera.withBaichuanRetry(async () => await api.setWhiteLedState(channel, on, brightness));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
class ReolinkCameraPirSensor extends ScryptedDeviceBase implements OnOff {
|
|
119
|
+
constructor(public camera: ReolinkNativeCamera, nativeId: string) {
|
|
120
|
+
super(nativeId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async turnOff() {
|
|
124
|
+
this.camera.getLogger().log(`PIR toggle: turnOff (device=${this.nativeId})`);
|
|
125
|
+
this.on = false;
|
|
126
|
+
try {
|
|
127
|
+
await this.setPir(false);
|
|
128
|
+
this.camera.getLogger().log(`PIR toggle: turnOff ok (device=${this.nativeId})`);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
this.camera.getLogger().warn(`PIR toggle: turnOff failed (device=${this.nativeId})`, e);
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async turnOn() {
|
|
137
|
+
this.camera.getLogger().log(`PIR toggle: turnOn (device=${this.nativeId})`);
|
|
138
|
+
this.on = true;
|
|
139
|
+
try {
|
|
140
|
+
await this.setPir(true);
|
|
141
|
+
this.camera.getLogger().log(`PIR toggle: turnOn ok (device=${this.nativeId})`);
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
this.camera.getLogger().warn(`PIR toggle: turnOn failed (device=${this.nativeId})`, e);
|
|
145
|
+
throw e;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async setPir(on: boolean) {
|
|
150
|
+
this.camera.markActivity();
|
|
151
|
+
|
|
152
|
+
const api = await this.camera.ensureClient();
|
|
153
|
+
const channel = this.camera.getRtspChannel();
|
|
154
|
+
|
|
155
|
+
await this.camera.withBaichuanRetry(async () => await api.setPirInfo(channel, { enable: on ? 1 : 0 }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// export class ReolinkNativeCamera extends ScryptedDeviceBase implements Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays {
|
|
160
|
+
export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCamera, Settings, Camera, DeviceProvider, Intercom, ObjectDetector, PanTiltZoom, Sleep, VideoTextOverlays {
|
|
161
|
+
videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
|
|
162
|
+
motionTimeout: NodeJS.Timeout;
|
|
163
|
+
siren: ReolinkCameraSiren;
|
|
164
|
+
floodlight: ReolinkCameraFloodlight;
|
|
165
|
+
pirSensor: ReolinkCameraPirSensor;
|
|
166
|
+
private baichuanApi: ReolinkBaichuanApi | undefined;
|
|
167
|
+
private baichuanInitPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
168
|
+
private refreshDeviceStatePromise: Promise<void> | undefined;
|
|
169
|
+
|
|
170
|
+
private subscribedToEvents = false;
|
|
171
|
+
private onSimpleEvent: ((ev: ReolinkSimpleEvent) => void) | undefined;
|
|
172
|
+
private eventsApi: ReolinkBaichuanApi | undefined;
|
|
173
|
+
|
|
174
|
+
private periodicStarted = false;
|
|
175
|
+
private statusPollTimer: NodeJS.Timeout | undefined;
|
|
176
|
+
private eventsRestartTimer: NodeJS.Timeout | undefined;
|
|
177
|
+
private lastActivityMs = Date.now();
|
|
178
|
+
private lastB64Snapshot: string | undefined;
|
|
179
|
+
private lastSnapshotTaken: number | undefined;
|
|
180
|
+
private streamManager: StreamManager;
|
|
181
|
+
|
|
182
|
+
private dispatchEventsApplyTimer: NodeJS.Timeout | undefined;
|
|
183
|
+
private dispatchEventsApplySeq = 0;
|
|
184
|
+
|
|
185
|
+
private lastAppliedDispatchEventsKey: string | undefined;
|
|
186
|
+
|
|
187
|
+
intercomClient: RtspClient;
|
|
188
|
+
|
|
189
|
+
private intercom: ReolinkBaichuanIntercom;
|
|
190
|
+
|
|
191
|
+
private ptzPresets: ReolinkPtzPresets;
|
|
192
|
+
|
|
193
|
+
storageSettings = new StorageSettings(this, {
|
|
194
|
+
ipAddress: {
|
|
195
|
+
title: 'IP Address',
|
|
196
|
+
type: 'string',
|
|
197
|
+
},
|
|
198
|
+
username: {
|
|
199
|
+
type: 'string',
|
|
200
|
+
title: 'Username',
|
|
201
|
+
},
|
|
202
|
+
password: {
|
|
203
|
+
type: 'password',
|
|
204
|
+
title: 'Password',
|
|
205
|
+
},
|
|
206
|
+
rtspChannel: {
|
|
207
|
+
type: 'number',
|
|
208
|
+
hide: true,
|
|
209
|
+
defaultValue: 0
|
|
210
|
+
},
|
|
211
|
+
capabilities: {
|
|
212
|
+
json: true,
|
|
213
|
+
hide: true
|
|
214
|
+
},
|
|
215
|
+
dispatchEvents: {
|
|
216
|
+
subgroup: 'Advanced',
|
|
217
|
+
title: 'Dispatch Events',
|
|
218
|
+
description: 'Select which events to emit. Empty disables event subscription entirely.',
|
|
219
|
+
multiple: true,
|
|
220
|
+
combobox: true,
|
|
221
|
+
immediate: true,
|
|
222
|
+
defaultValue: ['motion', 'objects'],
|
|
223
|
+
choices: ['motion', 'objects'],
|
|
224
|
+
onPut: async () => {
|
|
225
|
+
this.scheduleApplyEventDispatchSettings();
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
debugLogs: {
|
|
229
|
+
subgroup: 'Advanced',
|
|
230
|
+
title: 'Debug Logs',
|
|
231
|
+
description: 'Enable specific debug logs. Baichuan client logs require reconnect; event logs are immediate.',
|
|
232
|
+
multiple: true,
|
|
233
|
+
combobox: true,
|
|
234
|
+
immediate: true,
|
|
235
|
+
defaultValue: [],
|
|
236
|
+
choices: ['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets', 'eventLogs'],
|
|
237
|
+
onPut: async (ov, value) => {
|
|
238
|
+
// Only reconnect if Baichuan-client flags changed; toggling event logs should be immediate.
|
|
239
|
+
const oldSel = new Set(ov);
|
|
240
|
+
const newSel = new Set(value);
|
|
241
|
+
oldSel.delete('eventLogs');
|
|
242
|
+
newSel.delete('eventLogs');
|
|
243
|
+
|
|
244
|
+
const changed = oldSel.size !== newSel.size || Array.from(oldSel).some((k) => !newSel.has(k));
|
|
245
|
+
if (changed) {
|
|
246
|
+
await this.resetBaichuanClient('debugLogs changed');
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
motionTimeout: {
|
|
251
|
+
subgroup: 'Advanced',
|
|
252
|
+
title: 'Motion Timeout',
|
|
253
|
+
defaultValue: 20,
|
|
254
|
+
type: 'number',
|
|
255
|
+
},
|
|
256
|
+
presets: {
|
|
257
|
+
group: 'PTZ',
|
|
258
|
+
title: 'Presets to enable',
|
|
259
|
+
description: 'PTZ Presets in the format "id=name". Where id is the PTZ Preset identifier and name is a friendly name.',
|
|
260
|
+
multiple: true,
|
|
261
|
+
defaultValue: [],
|
|
262
|
+
combobox: true,
|
|
263
|
+
onPut: async (ov, presets: string[]) => {
|
|
264
|
+
const caps = {
|
|
265
|
+
...this.ptzCapabilities,
|
|
266
|
+
presets: {},
|
|
267
|
+
};
|
|
268
|
+
for (const preset of presets) {
|
|
269
|
+
const [key, name] = preset.split('=');
|
|
270
|
+
caps.presets[key] = name;
|
|
271
|
+
}
|
|
272
|
+
this.ptzCapabilities = caps;
|
|
273
|
+
},
|
|
274
|
+
mapGet: () => {
|
|
275
|
+
const presets = this.ptzCapabilities?.presets || {};
|
|
276
|
+
return Object.entries(presets).map(([key, name]) => key + '=' + name);
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
ptzMoveDurationMs: {
|
|
280
|
+
title: 'PTZ Move Duration (ms)',
|
|
281
|
+
description: 'How long a PTZ command moves before sending stop. Higher = more movement per click.',
|
|
282
|
+
type: 'number',
|
|
283
|
+
defaultValue: 300,
|
|
284
|
+
group: 'PTZ',
|
|
285
|
+
},
|
|
286
|
+
ptzZoomStep: {
|
|
287
|
+
group: 'PTZ',
|
|
288
|
+
title: 'PTZ Zoom Step',
|
|
289
|
+
description: 'How much to change zoom per zoom command (in zoom factor units, where 1.0 is normal).',
|
|
290
|
+
type: 'number',
|
|
291
|
+
defaultValue: 0.1,
|
|
292
|
+
},
|
|
293
|
+
ptzCreatePreset: {
|
|
294
|
+
group: 'PTZ',
|
|
295
|
+
title: 'Create Preset',
|
|
296
|
+
description: 'Enter a name and press Save to create a new PTZ preset at the current position.',
|
|
297
|
+
type: 'string',
|
|
298
|
+
placeholder: 'e.g. Door',
|
|
299
|
+
defaultValue: '',
|
|
300
|
+
onPut: async (_ov, value) => {
|
|
301
|
+
const name = String(value ?? '').trim();
|
|
302
|
+
if (!name) {
|
|
303
|
+
// Cleanup if user saved whitespace.
|
|
304
|
+
if (String(value ?? '') !== '') {
|
|
305
|
+
await this.storageSettings.putSetting('ptzCreatePreset', '');
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
this.markActivity();
|
|
311
|
+
const logger = this.getLogger();
|
|
312
|
+
logger.log(`PTZ presets: create preset requested (name=${name})`);
|
|
313
|
+
|
|
314
|
+
const preset = await this.withBaichuanRetry(async () => await this.ptzPresets.createPtzPreset(name));
|
|
315
|
+
const selection = `${preset.id}=${preset.name}`;
|
|
316
|
+
|
|
317
|
+
// Auto-select created preset.
|
|
318
|
+
await this.storageSettings.putSetting('ptzSelectedPreset', selection);
|
|
319
|
+
// Cleanup input field.
|
|
320
|
+
await this.storageSettings.putSetting('ptzCreatePreset', '');
|
|
321
|
+
|
|
322
|
+
logger.log(`PTZ presets: created preset id=${preset.id} name=${preset.name}`);
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
ptzSelectedPreset: {
|
|
326
|
+
group: 'PTZ',
|
|
327
|
+
title: 'Selected Preset',
|
|
328
|
+
description: 'Select the preset to update or delete. Format: "id=name".',
|
|
329
|
+
type: 'string',
|
|
330
|
+
combobox: false,
|
|
331
|
+
immediate: true,
|
|
332
|
+
},
|
|
333
|
+
ptzUpdateSelectedPreset: {
|
|
334
|
+
group: 'PTZ',
|
|
335
|
+
title: 'Update Selected Preset Position',
|
|
336
|
+
description: 'Overwrite the selected preset with the current PTZ position.',
|
|
337
|
+
type: 'button',
|
|
338
|
+
immediate: true,
|
|
339
|
+
onPut: async () => {
|
|
340
|
+
const presetId = this.getSelectedPresetId();
|
|
341
|
+
if (presetId === undefined) {
|
|
342
|
+
throw new Error('No preset selected');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
this.markActivity();
|
|
346
|
+
const logger = this.getLogger();
|
|
347
|
+
logger.log(`PTZ presets: update position requested (presetId=${presetId})`);
|
|
348
|
+
|
|
349
|
+
await this.withBaichuanRetry(async () => await this.ptzPresets.updatePtzPresetToCurrentPosition(presetId));
|
|
350
|
+
logger.log(`PTZ presets: update position ok (presetId=${presetId})`);
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
ptzDeleteSelectedPreset: {
|
|
354
|
+
group: 'PTZ',
|
|
355
|
+
title: 'Delete Selected Preset',
|
|
356
|
+
description: 'Delete the selected preset (firmware dependent).',
|
|
357
|
+
type: 'button',
|
|
358
|
+
immediate: true,
|
|
359
|
+
onPut: async () => {
|
|
360
|
+
const presetId = this.getSelectedPresetId();
|
|
361
|
+
if (presetId === undefined) {
|
|
362
|
+
throw new Error('No preset selected');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
this.markActivity();
|
|
366
|
+
const logger = this.getLogger();
|
|
367
|
+
logger.log(`PTZ presets: delete requested (presetId=${presetId})`);
|
|
368
|
+
|
|
369
|
+
await this.withBaichuanRetry(async () => await this.ptzPresets.deletePtzPreset(presetId));
|
|
370
|
+
|
|
371
|
+
// If we deleted the selected preset, clear selection.
|
|
372
|
+
await this.storageSettings.putSetting('ptzSelectedPreset', '');
|
|
373
|
+
logger.log(`PTZ presets: delete ok (presetId=${presetId})`);
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
cachedPresets: {
|
|
377
|
+
multiple: true,
|
|
378
|
+
hide: true,
|
|
379
|
+
json: true,
|
|
380
|
+
defaultValue: [],
|
|
381
|
+
},
|
|
382
|
+
// cachedOsd: {
|
|
383
|
+
// multiple: true,
|
|
384
|
+
// hide: true,
|
|
385
|
+
// json: true,
|
|
386
|
+
// defaultValue: [],
|
|
387
|
+
// },
|
|
388
|
+
prebufferSet: {
|
|
389
|
+
type: 'boolean',
|
|
390
|
+
hide: true
|
|
391
|
+
},
|
|
392
|
+
intercomBlocksPerPayload: {
|
|
393
|
+
subgroup: 'Advanced',
|
|
394
|
+
title: 'Intercom Blocks Per Payload',
|
|
395
|
+
description: 'Lower reduces latency (more packets). Typical: 1-4. Requires restarting talk session to take effect.',
|
|
396
|
+
type: 'number',
|
|
397
|
+
defaultValue: 1,
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
constructor(nativeId: string, public plugin: ReolinkNativePlugin) {
|
|
402
|
+
super(nativeId);
|
|
403
|
+
|
|
404
|
+
this.streamManager = new StreamManager({
|
|
405
|
+
createStreamClient: () => this.createStreamClient(),
|
|
406
|
+
getLogger: () => this.getLogger(),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
this.intercom = new ReolinkBaichuanIntercom(this);
|
|
410
|
+
this.ptzPresets = new ReolinkPtzPresets(this);
|
|
411
|
+
|
|
412
|
+
this.storageSettings.settings.presets.onGet = async () => {
|
|
413
|
+
const choices = this.storageSettings.values.cachedPresets.map((preset) => preset.id + '=' + preset.name);
|
|
414
|
+
return {
|
|
415
|
+
choices,
|
|
416
|
+
};
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
this.storageSettings.settings.ptzSelectedPreset.onGet = async () => {
|
|
420
|
+
const choices = (this.storageSettings.values.cachedPresets || []).map((preset) => preset.id + '=' + preset.name);
|
|
421
|
+
return { choices };
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
setTimeout(async () => {
|
|
425
|
+
await this.init();
|
|
426
|
+
}, 2000);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private getSelectedPresetId(): number | undefined {
|
|
430
|
+
const s = this.storageSettings.values.ptzSelectedPreset;
|
|
431
|
+
if (!s) return undefined;
|
|
432
|
+
|
|
433
|
+
const idPart = s.includes('=') ? s.split('=')[0] : s;
|
|
434
|
+
const id = Number(idPart);
|
|
435
|
+
return Number.isFinite(id) ? id : undefined;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
private isRecoverableBaichuanError(e: any): boolean {
|
|
439
|
+
const message = e?.message || e?.toString?.() || '';
|
|
440
|
+
return typeof message === 'string' && (
|
|
441
|
+
message.includes('Baichuan socket closed') ||
|
|
442
|
+
message.includes('socket hang up') ||
|
|
443
|
+
message.includes('ECONNRESET') ||
|
|
444
|
+
message.includes('EPIPE')
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
private async resetBaichuanClient(reason?: any): Promise<void> {
|
|
449
|
+
try {
|
|
450
|
+
await this.baichuanApi?.close();
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
this.getLogger().warn('Error closing Baichuan client during reset', e);
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
if (this.eventsApi && this.onSimpleEvent) {
|
|
457
|
+
try {
|
|
458
|
+
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// ignore
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
this.baichuanApi = undefined;
|
|
465
|
+
this.baichuanInitPromise = undefined;
|
|
466
|
+
this.subscribedToEvents = false;
|
|
467
|
+
this.eventsApi = undefined;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (reason) {
|
|
471
|
+
const message = reason?.message || reason?.toString?.() || reason;
|
|
472
|
+
this.getLogger().warn(`Baichuan client reset requested: ${message}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
|
|
477
|
+
try {
|
|
478
|
+
return await fn();
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
if (!this.isRecoverableBaichuanError(e)) {
|
|
482
|
+
throw e;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await this.resetBaichuanClient(e);
|
|
486
|
+
return await fn();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
public getLogger() {
|
|
491
|
+
return this.console;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async init() {
|
|
495
|
+
const logger = this.getLogger();
|
|
496
|
+
|
|
497
|
+
// Migrate older boolean value to the new multi-select format.
|
|
498
|
+
this.migrateDispatchEventsSetting();
|
|
499
|
+
|
|
500
|
+
// Initialize Baichuan API
|
|
501
|
+
await this.ensureClient();
|
|
502
|
+
|
|
503
|
+
// Refresh cached device metadata/abilities as early as possible, since we use them for interface gating.
|
|
504
|
+
await this.refreshDeviceState();
|
|
505
|
+
|
|
506
|
+
await this.reportDevices();
|
|
507
|
+
this.updateDeviceInfo();
|
|
508
|
+
this.updatePtzCaps();
|
|
509
|
+
|
|
510
|
+
const interfaces = await this.getDeviceInterfaces();
|
|
511
|
+
|
|
512
|
+
const device: Device = {
|
|
513
|
+
nativeId: this.nativeId,
|
|
514
|
+
providerNativeId: this.plugin.nativeId,
|
|
515
|
+
name: this.name,
|
|
516
|
+
interfaces,
|
|
517
|
+
type: this.type as ScryptedDeviceType,
|
|
518
|
+
info: this.info,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
|
|
522
|
+
|
|
523
|
+
await sdk.deviceManager.onDeviceDiscovered(device);
|
|
524
|
+
|
|
525
|
+
// Start event subscription after discovery.
|
|
526
|
+
try {
|
|
527
|
+
if (this.isEventDispatchEnabled()) {
|
|
528
|
+
await this.ensureBaichuanEventSubscription();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
catch (e) {
|
|
532
|
+
logger.warn('Failed to subscribe to Baichuan events', e);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Periodic status refresh + event resubscribe.
|
|
536
|
+
this.startPeriodicTasks();
|
|
537
|
+
|
|
538
|
+
if (this.hasBattery() && !this.storageSettings.getItem('prebufferSet')) {
|
|
539
|
+
const device = sdk.systemManager.getDeviceById<Settings>(this.id);
|
|
540
|
+
logger.log('Disabling prebbufer for battery cam');
|
|
541
|
+
await device.putSetting('prebuffer:enabledStreams', '[]');
|
|
542
|
+
this.storageSettings.values.prebufferSet = true;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async ensureClient(): Promise<ReolinkBaichuanApi> {
|
|
547
|
+
if (this.baichuanInitPromise) {
|
|
548
|
+
return this.baichuanInitPromise;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (this.baichuanApi && this.baichuanApi.client.loggedIn) {
|
|
552
|
+
return this.baichuanApi;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const { ipAddress, username, password } = this.storageSettings.values;
|
|
556
|
+
|
|
557
|
+
if (!ipAddress || !username || !password) {
|
|
558
|
+
throw new Error('Missing camera credentials');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
this.baichuanInitPromise = (async () => {
|
|
562
|
+
if (this.baichuanApi) {
|
|
563
|
+
await this.baichuanApi.close();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
|
|
567
|
+
const debugOptions = this.getBaichuanDebugOptions();
|
|
568
|
+
this.baichuanApi = new ReolinkBaichuanApi({
|
|
569
|
+
host: ipAddress,
|
|
570
|
+
username,
|
|
571
|
+
password,
|
|
572
|
+
logger: this.console,
|
|
573
|
+
...(debugOptions ? { debugOptions } : {}),
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
await this.baichuanApi.login();
|
|
577
|
+
return this.baichuanApi;
|
|
578
|
+
})();
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
return await this.baichuanInitPromise;
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
// If login failed, allow future retries.
|
|
585
|
+
if (!this.baichuanApi?.client?.loggedIn) {
|
|
586
|
+
this.baichuanInitPromise = undefined;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private async createStreamClient(): Promise<ReolinkBaichuanApi> {
|
|
592
|
+
const { ipAddress, username, password } = this.storageSettings.values;
|
|
593
|
+
if (!ipAddress || !username || !password) {
|
|
594
|
+
throw new Error('Missing camera credentials');
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const { ReolinkBaichuanApi } = await import('@apocaliss92/reolink-baichuan-js');
|
|
598
|
+
const debugOptions = this.getBaichuanDebugOptions();
|
|
599
|
+
const api = new ReolinkBaichuanApi({
|
|
600
|
+
host: ipAddress,
|
|
601
|
+
username,
|
|
602
|
+
password,
|
|
603
|
+
logger: this.console,
|
|
604
|
+
...(debugOptions ? { debugOptions } : {}),
|
|
605
|
+
});
|
|
606
|
+
await api.login();
|
|
607
|
+
return api;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
getClient(): ReolinkBaichuanApi | undefined {
|
|
611
|
+
return this.baichuanApi;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async refreshDeviceState(): Promise<void> {
|
|
615
|
+
if (this.refreshDeviceStatePromise) return this.refreshDeviceStatePromise;
|
|
616
|
+
|
|
617
|
+
this.refreshDeviceStatePromise = (async () => {
|
|
618
|
+
const logger = this.getLogger();
|
|
619
|
+
const api = await this.ensureClient();
|
|
620
|
+
const channel = this.getRtspChannel();
|
|
621
|
+
|
|
622
|
+
try {
|
|
623
|
+
const { capabilities, abilities, support, presets } = await api.getDeviceCapabilities(channel);
|
|
624
|
+
this.storageSettings.values.capabilities = capabilities;
|
|
625
|
+
this.ptzPresets.setCachedPtzPresets(presets);
|
|
626
|
+
this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
logger.warn('Failed to refresh abilities', e);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Best-effort status refreshes.
|
|
633
|
+
await this.refreshAuxDevicesStatus().catch(() => { });
|
|
634
|
+
})().finally(() => {
|
|
635
|
+
this.refreshDeviceStatePromise = undefined;
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
return this.refreshDeviceStatePromise;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private async ensureBaichuanEventSubscription(): Promise<void> {
|
|
642
|
+
if (!this.isEventDispatchEnabled()) {
|
|
643
|
+
await this.disableBaichuanEventSubscription();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
if (this.subscribedToEvents) return;
|
|
647
|
+
const api = await this.ensureClient();
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
await api.subscribeEvents();
|
|
651
|
+
}
|
|
652
|
+
catch {
|
|
653
|
+
// Some firmwares don't require explicit subscribe or may reject it.
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
this.onSimpleEvent ||= (ev: any) => {
|
|
657
|
+
try {
|
|
658
|
+
if (!this.isEventDispatchEnabled()) return;
|
|
659
|
+
if (this.isEventLogsEnabled()) {
|
|
660
|
+
this.getLogger().debug(`Baichuan event: ${JSON.stringify(ev)}`);
|
|
661
|
+
}
|
|
662
|
+
const channel = this.getRtspChannel();
|
|
663
|
+
if (ev?.channel !== undefined && ev.channel !== channel) return;
|
|
664
|
+
|
|
665
|
+
const objects: string[] = [];
|
|
666
|
+
let motion = false;
|
|
667
|
+
|
|
668
|
+
switch (ev?.type) {
|
|
669
|
+
case 'motion':
|
|
670
|
+
motion = this.shouldDispatchMotion();
|
|
671
|
+
break;
|
|
672
|
+
case 'doorbell':
|
|
673
|
+
// Placeholder: treat doorbell as motion.
|
|
674
|
+
motion = this.shouldDispatchMotion();
|
|
675
|
+
break;
|
|
676
|
+
case 'people':
|
|
677
|
+
case 'vehicle':
|
|
678
|
+
case 'animal':
|
|
679
|
+
case 'face':
|
|
680
|
+
case 'package':
|
|
681
|
+
case 'other':
|
|
682
|
+
if (this.shouldDispatchObjects()) objects.push(ev.type);
|
|
683
|
+
break;
|
|
684
|
+
default:
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
this.processEvents({ motion, objects }).catch(() => { });
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
// ignore
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// Attach the handler to the current API instance, and detach from any previous instance.
|
|
696
|
+
if (this.eventsApi && this.eventsApi !== api && this.onSimpleEvent) {
|
|
697
|
+
try {
|
|
698
|
+
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// ignore
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (this.eventsApi !== api && this.onSimpleEvent) {
|
|
705
|
+
api.simpleEvents.on('event', this.onSimpleEvent);
|
|
706
|
+
this.eventsApi = api;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this.subscribedToEvents = true;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async disableBaichuanEventSubscription(): Promise<void> {
|
|
713
|
+
// Do not wake up battery cameras / do not force login: best-effort cleanup only.
|
|
714
|
+
const api = this.getClient();
|
|
715
|
+
if (api?.client?.loggedIn) {
|
|
716
|
+
try {
|
|
717
|
+
await api.unsubscribeEvents();
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
// ignore
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (this.eventsApi && this.onSimpleEvent) {
|
|
725
|
+
try {
|
|
726
|
+
this.eventsApi.simpleEvents.off('event', this.onSimpleEvent);
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
// ignore
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
this.subscribedToEvents = false;
|
|
734
|
+
this.eventsApi = undefined;
|
|
735
|
+
|
|
736
|
+
if (this.motionTimeout) {
|
|
737
|
+
clearTimeout(this.motionTimeout);
|
|
738
|
+
}
|
|
739
|
+
this.motionDetected = false;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async applyEventDispatchSettings(): Promise<void> {
|
|
743
|
+
const logger = this.getLogger();
|
|
744
|
+
const selection = Array.from(this.getDispatchEventsSelection()).sort();
|
|
745
|
+
const key = selection.join(',');
|
|
746
|
+
const prevKey = this.lastAppliedDispatchEventsKey;
|
|
747
|
+
|
|
748
|
+
if (prevKey !== undefined && prevKey !== key) {
|
|
749
|
+
logger.log(`Dispatch Events changed: ${selection.length ? selection.join(', ') : '(disabled)'}`);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// User-initiated settings change counts as activity.
|
|
753
|
+
this.markActivity();
|
|
754
|
+
|
|
755
|
+
// Empty selection disables everything.
|
|
756
|
+
if (!this.isEventDispatchEnabled()) {
|
|
757
|
+
if (this.subscribedToEvents) {
|
|
758
|
+
logger.log('Event listener stopped (Dispatch Events disabled)');
|
|
759
|
+
}
|
|
760
|
+
await this.disableBaichuanEventSubscription();
|
|
761
|
+
this.lastAppliedDispatchEventsKey = key;
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// If motion is not selected, ensure state is cleared.
|
|
766
|
+
if (!this.shouldDispatchMotion()) {
|
|
767
|
+
if (this.motionTimeout) clearTimeout(this.motionTimeout);
|
|
768
|
+
this.motionDetected = false;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Apply immediately even if we were already subscribed.
|
|
772
|
+
// If nothing actually changed and we're already subscribed, avoid a noisy resubscribe.
|
|
773
|
+
if (prevKey === key && this.subscribedToEvents) {
|
|
774
|
+
// Track baseline so later changes are logged.
|
|
775
|
+
this.lastAppliedDispatchEventsKey = key;
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!this.subscribedToEvents) {
|
|
780
|
+
logger.log(`Event listener started (${selection.join(', ')})`);
|
|
781
|
+
await this.ensureBaichuanEventSubscription();
|
|
782
|
+
this.lastAppliedDispatchEventsKey = key;
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
logger.log(`Event listener restarting (${selection.join(', ')})`);
|
|
787
|
+
await this.disableBaichuanEventSubscription();
|
|
788
|
+
await this.ensureBaichuanEventSubscription();
|
|
789
|
+
this.lastAppliedDispatchEventsKey = key;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
markActivity(): void {
|
|
793
|
+
this.lastActivityMs = Date.now();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
private shouldAvoidWakingBatteryCamera(): boolean {
|
|
797
|
+
if (!this.hasBattery()) return false;
|
|
798
|
+
if (this.sleeping) return true;
|
|
799
|
+
|
|
800
|
+
// If we don't already have an active logged-in client, don't try to connect/login.
|
|
801
|
+
const api = this.getClient();
|
|
802
|
+
if (!api?.client?.loggedIn) return true;
|
|
803
|
+
|
|
804
|
+
// If there's no recent activity, avoid periodic polling/resubscribe.
|
|
805
|
+
const ageMs = Date.now() - this.lastActivityMs;
|
|
806
|
+
return ageMs > 30_000;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async release() {
|
|
810
|
+
this.statusPollTimer && clearInterval(this.statusPollTimer);
|
|
811
|
+
this.eventsRestartTimer && clearInterval(this.eventsRestartTimer);
|
|
812
|
+
return this.resetBaichuanClient();
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
private startPeriodicTasks(): void {
|
|
816
|
+
if (this.periodicStarted) return;
|
|
817
|
+
this.periodicStarted = true;
|
|
818
|
+
|
|
819
|
+
this.statusPollTimer = setInterval(() => {
|
|
820
|
+
this.periodic10sTick().catch(() => { });
|
|
821
|
+
}, 10_000);
|
|
822
|
+
|
|
823
|
+
this.eventsRestartTimer = setInterval(() => {
|
|
824
|
+
this.periodic60sRestartEvents().catch(() => { });
|
|
825
|
+
}, 60_000);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private async periodic10sTick(): Promise<void> {
|
|
829
|
+
if (this.shouldAvoidWakingBatteryCamera()) return;
|
|
830
|
+
|
|
831
|
+
// For wired cameras, reconnecting is fine.
|
|
832
|
+
if (!this.hasBattery()) {
|
|
833
|
+
await this.ensureClient();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
await this.refreshAuxDevicesStatus();
|
|
837
|
+
|
|
838
|
+
// Best-effort: ensure we're subscribed.
|
|
839
|
+
if (this.isEventDispatchEnabled() && !this.subscribedToEvents) {
|
|
840
|
+
if (this.hasBattery()) {
|
|
841
|
+
const api = this.getClient();
|
|
842
|
+
if (!api?.client?.loggedIn) return;
|
|
843
|
+
}
|
|
844
|
+
await this.ensureBaichuanEventSubscription();
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private async periodic60sRestartEvents(): Promise<void> {
|
|
849
|
+
if (this.shouldAvoidWakingBatteryCamera()) return;
|
|
850
|
+
|
|
851
|
+
if (!this.isEventDispatchEnabled()) {
|
|
852
|
+
await this.disableBaichuanEventSubscription();
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Wired cameras can reconnect; battery cameras only operate on an existing active client.
|
|
857
|
+
if (!this.hasBattery()) {
|
|
858
|
+
await this.ensureClient();
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
const api = this.getClient();
|
|
862
|
+
if (!api?.client?.loggedIn) return;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const api = this.getClient();
|
|
866
|
+
if (!api) return;
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
await api.unsubscribeEvents();
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
// ignore
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
this.subscribedToEvents = false;
|
|
876
|
+
await this.ensureBaichuanEventSubscription();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private async refreshAuxDevicesStatus(): Promise<void> {
|
|
880
|
+
const api = this.getClient();
|
|
881
|
+
if (!api) return;
|
|
882
|
+
|
|
883
|
+
const channel = this.getRtspChannel();
|
|
884
|
+
|
|
885
|
+
try {
|
|
886
|
+
if (this.hasFloodlight()) {
|
|
887
|
+
const wl = await api.getWhiteLedState(channel);
|
|
888
|
+
if (this.floodlight) {
|
|
889
|
+
this.floodlight.on = !!wl.enabled;
|
|
890
|
+
if (wl.brightness !== undefined) this.floodlight.brightness = wl.brightness;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
// ignore
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
try {
|
|
899
|
+
if (this.hasPirEvents()) {
|
|
900
|
+
const pir = await api.getPirInfo(channel);
|
|
901
|
+
if (this.pirSensor) this.pirSensor.on = !!pir.enabled;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// ignore
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
async getVideoTextOverlays(): Promise<Record<string, VideoTextOverlay>> {
|
|
910
|
+
const client = this.getClient();
|
|
911
|
+
if (!client) {
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// TODO: restore
|
|
915
|
+
// const { cachedOsd } = this.storageSettings.values;
|
|
916
|
+
|
|
917
|
+
// return {
|
|
918
|
+
// osdChannel: {
|
|
919
|
+
// text: cachedOsd.value.Osd.osdChannel.enable ? cachedOsd.value.Osd.osdChannel.name : undefined,
|
|
920
|
+
// },
|
|
921
|
+
// osdTime: {
|
|
922
|
+
// text: !!cachedOsd.value.Osd.osdTime.enable,
|
|
923
|
+
// readonly: true,
|
|
924
|
+
// }
|
|
925
|
+
// }
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
async setVideoTextOverlay(id: 'osdChannel' | 'osdTime', value: VideoTextOverlay): Promise<void> {
|
|
929
|
+
const client = await this.ensureClient();
|
|
930
|
+
if (!client) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
// TODO: restore
|
|
934
|
+
|
|
935
|
+
// const osd = await client.getOsd();
|
|
936
|
+
|
|
937
|
+
// if (id === 'osdChannel') {
|
|
938
|
+
// osd.osdChannel.enable = value.text ? 1 : 0;
|
|
939
|
+
// // name must always be valid.
|
|
940
|
+
// osd.osdChannel.name = typeof value.text === 'string' && value.text
|
|
941
|
+
// ? value.text
|
|
942
|
+
// : osd.osdChannel.name || 'Camera';
|
|
943
|
+
// }
|
|
944
|
+
// else if (id === 'osdTime') {
|
|
945
|
+
// osd.osdTime.enable = value.text ? 1 : 0;
|
|
946
|
+
// }
|
|
947
|
+
// else {
|
|
948
|
+
// throw new Error('unknown overlay: ' + id);
|
|
949
|
+
// }
|
|
950
|
+
|
|
951
|
+
// await client.setOsd(channel, osd);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
updatePtzCaps() {
|
|
955
|
+
const { hasPanTilt, hasZoom } = this.getPtzCapabilities();
|
|
956
|
+
this.ptzCapabilities = {
|
|
957
|
+
...this.ptzCapabilities,
|
|
958
|
+
pan: hasPanTilt,
|
|
959
|
+
tilt: hasPanTilt,
|
|
960
|
+
zoom: hasZoom,
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
getAbilities(): DeviceCapabilities {
|
|
965
|
+
return this.storageSettings.values.capabilities;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async getDetectionInput(detectionId: string, eventId?: any): Promise<MediaObject> {
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async ptzCommand(command: PanTiltZoomCommand): Promise<void> {
|
|
973
|
+
this.markActivity();
|
|
974
|
+
const client = await this.ensureClient();
|
|
975
|
+
if (!client) {
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const channel = this.getRtspChannel();
|
|
980
|
+
|
|
981
|
+
// Preset navigation.
|
|
982
|
+
const preset = (command as any).preset;
|
|
983
|
+
if (preset !== undefined && preset !== null) {
|
|
984
|
+
const presetId = Number(preset);
|
|
985
|
+
if (!Number.isFinite(presetId)) {
|
|
986
|
+
this.getLogger().warn(`Invalid PTZ preset id: ${preset}`);
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
await this.ptzPresets.moveToPreset(presetId);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// Map PanTiltZoomCommand to PtzCommand
|
|
994
|
+
let ptzAction: 'start' | 'stop' = 'start';
|
|
995
|
+
let ptzCommand: 'Left' | 'Right' | 'Up' | 'Down' | 'ZoomIn' | 'ZoomOut' | 'FocusNear' | 'FocusFar' = 'Left';
|
|
996
|
+
|
|
997
|
+
if (command.pan !== undefined) {
|
|
998
|
+
if (command.pan === 0) {
|
|
999
|
+
// Stop pan movement - send stop with last direction
|
|
1000
|
+
ptzAction = 'stop';
|
|
1001
|
+
ptzCommand = 'Left'; // Use any direction for stop
|
|
1002
|
+
} else {
|
|
1003
|
+
ptzCommand = command.pan > 0 ? 'Right' : 'Left';
|
|
1004
|
+
ptzAction = 'start';
|
|
1005
|
+
}
|
|
1006
|
+
} else if (command.tilt !== undefined) {
|
|
1007
|
+
if (command.tilt === 0) {
|
|
1008
|
+
// Stop tilt movement
|
|
1009
|
+
ptzAction = 'stop';
|
|
1010
|
+
ptzCommand = 'Up'; // Use any direction for stop
|
|
1011
|
+
} else {
|
|
1012
|
+
ptzCommand = command.tilt > 0 ? 'Up' : 'Down';
|
|
1013
|
+
ptzAction = 'start';
|
|
1014
|
+
}
|
|
1015
|
+
} else if (command.zoom !== undefined) {
|
|
1016
|
+
// Zoom is handled separately.
|
|
1017
|
+
// Scrypted typically provides a normalized zoom value; treat it as direction and apply a step.
|
|
1018
|
+
const z = Number(command.zoom);
|
|
1019
|
+
if (!Number.isFinite(z) || z === 0) return;
|
|
1020
|
+
|
|
1021
|
+
const step = Number(this.storageSettings.values.ptzZoomStep);
|
|
1022
|
+
const stepFactor = Number.isFinite(step) && step > 0 ? step : 0.2;
|
|
1023
|
+
|
|
1024
|
+
const info = await client.getZoomFocus(channel);
|
|
1025
|
+
if (!info?.zoom) {
|
|
1026
|
+
this.getLogger().warn('Zoom command requested but camera did not report zoom support.');
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// In Baichuan API, 1000 == 1.0x.
|
|
1031
|
+
const curFactor = (info.zoom.curPos ?? 1000) / 1000;
|
|
1032
|
+
const minFactor = (info.zoom.minPos ?? 1000) / 1000;
|
|
1033
|
+
const maxFactor = (info.zoom.maxPos ?? 1000) / 1000;
|
|
1034
|
+
|
|
1035
|
+
const direction = z > 0 ? 1 : -1;
|
|
1036
|
+
const next = Math.min(maxFactor, Math.max(minFactor, curFactor + direction * stepFactor));
|
|
1037
|
+
await client.zoomToFactor(channel, next);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const ptzCmd: PtzCommand = {
|
|
1042
|
+
action: ptzAction,
|
|
1043
|
+
command: ptzCommand,
|
|
1044
|
+
speed: typeof command.speed === 'number' ? command.speed : 32,
|
|
1045
|
+
autoStopMs: Number(this.storageSettings.values.ptzMoveDurationMs) || 500,
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
await client.ptz(channel, ptzCmd);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async getObjectTypes(): Promise<ObjectDetectionTypes> {
|
|
1052
|
+
try {
|
|
1053
|
+
const client = await this.ensureClient();
|
|
1054
|
+
const ai = await client.getAiState();
|
|
1055
|
+
|
|
1056
|
+
const classes: string[] = [];
|
|
1057
|
+
// AI state structure may vary, check if it's an object with support field
|
|
1058
|
+
if (ai && typeof ai === 'object' && 'support' in ai) {
|
|
1059
|
+
if (ai.support) {
|
|
1060
|
+
// Add common AI types if supported
|
|
1061
|
+
classes.push('people', 'vehicle', 'dog_cat', 'face', 'package');
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
classes,
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
catch (e) {
|
|
1070
|
+
return {
|
|
1071
|
+
classes: [],
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
hasSiren() {
|
|
1077
|
+
const capabilities = this.getAbilities();
|
|
1078
|
+
return Boolean(capabilities?.hasSiren);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
hasFloodlight() {
|
|
1082
|
+
const capabilities = this.getAbilities();
|
|
1083
|
+
return Boolean(capabilities?.hasFloodlight);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
hasBattery() {
|
|
1087
|
+
const capabilities = this.getAbilities();
|
|
1088
|
+
return Boolean(capabilities?.hasBattery);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
getPtzCapabilities() {
|
|
1092
|
+
const capabilities = this.getAbilities();
|
|
1093
|
+
const hasZoom = Boolean(capabilities?.hasZoom);
|
|
1094
|
+
const hasPanTilt = Boolean(capabilities?.hasPan && capabilities?.hasTilt);
|
|
1095
|
+
const hasPresets = Boolean(capabilities?.hasPresets);
|
|
1096
|
+
|
|
1097
|
+
return {
|
|
1098
|
+
hasZoom,
|
|
1099
|
+
hasPanTilt,
|
|
1100
|
+
hasPresets,
|
|
1101
|
+
hasPtz: hasZoom || hasPanTilt || hasPresets,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
hasPtzCtrl() {
|
|
1106
|
+
const capabilities = this.getAbilities();
|
|
1107
|
+
return Boolean(capabilities?.hasPtz);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
hasPirEvents() {
|
|
1111
|
+
const capabilities = this.getAbilities();
|
|
1112
|
+
return Boolean(capabilities?.hasPir);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
async getDeviceInterfaces() {
|
|
1116
|
+
const interfaces = [
|
|
1117
|
+
ScryptedInterface.VideoCamera,
|
|
1118
|
+
ScryptedInterface.Settings,
|
|
1119
|
+
...this.plugin.getCameraInterfaces(),
|
|
1120
|
+
];
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
// Expose Intercom if the camera supports Baichuan talkback.
|
|
1124
|
+
try {
|
|
1125
|
+
const api = this.getClient();
|
|
1126
|
+
if (api) {
|
|
1127
|
+
const ability = await api.getTalkAbility(this.getRtspChannel());
|
|
1128
|
+
if (Array.isArray((ability as any)?.audioConfigList) && (ability as any).audioConfigList.length > 0) {
|
|
1129
|
+
interfaces.push(ScryptedInterface.Intercom);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
catch {
|
|
1134
|
+
// ignore: camera likely doesn't support talkback
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const { hasPtz } = this.getPtzCapabilities();
|
|
1138
|
+
|
|
1139
|
+
if (hasPtz) {
|
|
1140
|
+
interfaces.push(ScryptedInterface.PanTiltZoom);
|
|
1141
|
+
}
|
|
1142
|
+
if ((await this.getObjectTypes()).classes.length > 0) {
|
|
1143
|
+
interfaces.push(ScryptedInterface.ObjectDetector);
|
|
1144
|
+
}
|
|
1145
|
+
if (this.hasSiren() || this.hasFloodlight() || this.hasPirEvents())
|
|
1146
|
+
interfaces.push(ScryptedInterface.DeviceProvider);
|
|
1147
|
+
if (this.hasBattery()) {
|
|
1148
|
+
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
|
|
1149
|
+
}
|
|
1150
|
+
} catch (e) {
|
|
1151
|
+
this.getLogger().error('Error getting device interfaces', e);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return interfaces;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async processBatteryData(data: BatteryInfo) {
|
|
1158
|
+
const logger = this.getLogger();
|
|
1159
|
+
const batteryLevel = data.batteryPercent;
|
|
1160
|
+
const sleeping = data.sleeping || false;
|
|
1161
|
+
// const debugEvents = this.storageSettings.values.debugEvents;
|
|
1162
|
+
|
|
1163
|
+
// if (debugEvents) {
|
|
1164
|
+
// logger.debug(`Battery info received: ${JSON.stringify(data)}`);
|
|
1165
|
+
// }
|
|
1166
|
+
|
|
1167
|
+
if (sleeping !== this.sleeping) {
|
|
1168
|
+
this.sleeping = sleeping;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (batteryLevel !== this.batteryLevel) {
|
|
1172
|
+
this.batteryLevel = batteryLevel ?? this.batteryLevel;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async updateDeviceInfo() {
|
|
1177
|
+
const ip = this.storageSettings.values.ipAddress;
|
|
1178
|
+
if (!ip)
|
|
1179
|
+
return;
|
|
1180
|
+
|
|
1181
|
+
const api = await this.ensureClient();
|
|
1182
|
+
const deviceData = await api.getInfo();
|
|
1183
|
+
const info = this.info || {};
|
|
1184
|
+
info.ip = ip;
|
|
1185
|
+
|
|
1186
|
+
info.serialNumber = deviceData?.serialNumber || deviceData?.itemNo;
|
|
1187
|
+
info.firmware = deviceData?.firmwareVersion || deviceData?.firmVer;
|
|
1188
|
+
info.version = deviceData?.hardwareVersion || deviceData?.boardInfo;
|
|
1189
|
+
info.model = deviceData?.type || deviceData?.typeInfo;
|
|
1190
|
+
info.manufacturer = 'Reolink native';
|
|
1191
|
+
info.managementUrl = `http://${ip}`;
|
|
1192
|
+
this.info = info;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
async processEvents(events: { motion?: boolean; objects?: string[] }) {
|
|
1196
|
+
const logger = this.getLogger();
|
|
1197
|
+
|
|
1198
|
+
if (!this.isEventDispatchEnabled()) return;
|
|
1199
|
+
|
|
1200
|
+
if (this.isEventLogsEnabled()) {
|
|
1201
|
+
logger.debug(`Events received: ${JSON.stringify(events)}`);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// const debugEvents = this.storageSettings.values.debugEvents;
|
|
1205
|
+
// if (debugEvents) {
|
|
1206
|
+
// logger.debug(`Events received: ${JSON.stringify(events)}`);
|
|
1207
|
+
// }
|
|
1208
|
+
|
|
1209
|
+
if (this.shouldDispatchMotion() && events.motion !== this.motionDetected) {
|
|
1210
|
+
if (events.motion) {
|
|
1211
|
+
this.motionDetected = true;
|
|
1212
|
+
this.motionTimeout && clearTimeout(this.motionTimeout);
|
|
1213
|
+
this.motionTimeout = setTimeout(() => this.motionDetected = false, this.storageSettings.values.motionTimeout * 1000);
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
this.motionDetected = false;
|
|
1217
|
+
this.motionTimeout && clearTimeout(this.motionTimeout);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
if (this.shouldDispatchObjects() && events.objects?.length) {
|
|
1222
|
+
const od: ObjectsDetected = {
|
|
1223
|
+
timestamp: Date.now(),
|
|
1224
|
+
detections: [],
|
|
1225
|
+
};
|
|
1226
|
+
for (const c of events.objects) {
|
|
1227
|
+
od.detections.push({
|
|
1228
|
+
className: c,
|
|
1229
|
+
score: 1,
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
sdk.deviceManager.onDeviceEvent(this.nativeId, ScryptedInterface.ObjectDetector, od);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
private normalizeDebugLogs(value: unknown): string[] {
|
|
1237
|
+
const allowed = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets', 'eventLogs']);
|
|
1238
|
+
|
|
1239
|
+
const items = Array.isArray(value) ? value : (typeof value === 'string' ? [value] : []);
|
|
1240
|
+
const out: string[] = [];
|
|
1241
|
+
for (const v of items) {
|
|
1242
|
+
if (typeof v !== 'string') continue;
|
|
1243
|
+
const s = v.trim();
|
|
1244
|
+
if (!allowed.has(s)) continue;
|
|
1245
|
+
out.push(s);
|
|
1246
|
+
}
|
|
1247
|
+
return Array.from(new Set(out));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private getBaichuanDebugOptions(): any | undefined {
|
|
1251
|
+
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1252
|
+
if (!sel.size) return undefined;
|
|
1253
|
+
|
|
1254
|
+
// Keep this as `any` so we don't need to import DebugOptions types here.
|
|
1255
|
+
const debugOptions: any = {};
|
|
1256
|
+
// Only pass through Baichuan client debug flags.
|
|
1257
|
+
const clientKeys = new Set(['enabled', 'debugRtsp', 'traceStream', 'traceTalk', 'debugH264', 'debugParamSets']);
|
|
1258
|
+
for (const k of sel) {
|
|
1259
|
+
if (!clientKeys.has(k)) continue;
|
|
1260
|
+
debugOptions[k] = true;
|
|
1261
|
+
}
|
|
1262
|
+
return Object.keys(debugOptions).length ? debugOptions : undefined;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
private isEventLogsEnabled(): boolean {
|
|
1266
|
+
const sel = new Set(this.normalizeDebugLogs((this.storageSettings.values as any).debugLogs));
|
|
1267
|
+
return sel.has('eventLogs');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private getDispatchEventsSelection(): Set<'motion' | 'objects'> {
|
|
1271
|
+
return new Set(this.storageSettings.values.dispatchEvents);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
private isEventDispatchEnabled(): boolean {
|
|
1275
|
+
return this.getDispatchEventsSelection().size > 0;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
private shouldDispatchMotion(): boolean {
|
|
1279
|
+
return this.getDispatchEventsSelection().has('motion');
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private shouldDispatchObjects(): boolean {
|
|
1283
|
+
return this.getDispatchEventsSelection().has('objects');
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
private migrateDispatchEventsSetting(): void {
|
|
1287
|
+
const cur = (this.storageSettings.values as any).dispatchEvents;
|
|
1288
|
+
if (typeof cur === 'boolean') {
|
|
1289
|
+
(this.storageSettings.values as any).dispatchEvents = cur ? ['motion', 'objects'] : [];
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
private scheduleApplyEventDispatchSettings(): void {
|
|
1294
|
+
// Debounce to avoid rapid apply loops while editing multi-select.
|
|
1295
|
+
this.dispatchEventsApplySeq++;
|
|
1296
|
+
const seq = this.dispatchEventsApplySeq;
|
|
1297
|
+
|
|
1298
|
+
if (this.dispatchEventsApplyTimer) {
|
|
1299
|
+
clearTimeout(this.dispatchEventsApplyTimer);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
this.dispatchEventsApplyTimer = setTimeout(() => {
|
|
1303
|
+
// Fire-and-forget; never block settings UI.
|
|
1304
|
+
this.applyEventDispatchSettings().catch((e) => {
|
|
1305
|
+
// Only log once per debounce window.
|
|
1306
|
+
if (seq === this.dispatchEventsApplySeq) {
|
|
1307
|
+
this.getLogger().warn('Failed to apply Dispatch Events setting', e);
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
}, 300);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
async takeSnapshotInternal(timeout?: number) {
|
|
1314
|
+
this.markActivity();
|
|
1315
|
+
return this.withBaichuanRetry(async () => {
|
|
1316
|
+
try {
|
|
1317
|
+
const now = Date.now();
|
|
1318
|
+
const client = await this.ensureClient();
|
|
1319
|
+
const snapshotBuffer = await client.getSnapshot();
|
|
1320
|
+
const mo = await this.createMediaObject(snapshotBuffer, 'image/jpeg');
|
|
1321
|
+
this.lastB64Snapshot = await moToB64(mo);
|
|
1322
|
+
this.lastSnapshotTaken = now;
|
|
1323
|
+
|
|
1324
|
+
return mo;
|
|
1325
|
+
} catch (e) {
|
|
1326
|
+
this.getLogger().error('Error taking snapshot', e);
|
|
1327
|
+
throw e;
|
|
1328
|
+
}
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async getPictureOptions(): Promise<ResponsePictureOptions[]> {
|
|
1333
|
+
return [];
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
getSettings(): Promise<Setting[]> {
|
|
1337
|
+
return this.storageSettings.getSettings();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async takePicture(options?: RequestPictureOptions): Promise<MediaObject> {
|
|
1341
|
+
const isBattery = this.hasBattery();
|
|
1342
|
+
const now = Date.now();
|
|
1343
|
+
const logger = this.getLogger();
|
|
1344
|
+
|
|
1345
|
+
const isMaxTimePassed = !this.lastSnapshotTaken || ((now - this.lastSnapshotTaken) > 1000 * 60 * 60);
|
|
1346
|
+
const isBatteryTimePassed = !this.lastSnapshotTaken || ((now - this.lastSnapshotTaken) > 1000 * 15);
|
|
1347
|
+
let canTake = false;
|
|
1348
|
+
|
|
1349
|
+
if (!this.lastB64Snapshot || !this.lastSnapshotTaken) {
|
|
1350
|
+
logger.log('Allowing new snapshot because not taken yet');
|
|
1351
|
+
canTake = true;
|
|
1352
|
+
} else if (this.sleeping && isMaxTimePassed) {
|
|
1353
|
+
logger.log('Allowing new snapshot while sleeping because older than 1 hour');
|
|
1354
|
+
canTake = true;
|
|
1355
|
+
} else if (!this.sleeping && isBattery && isBatteryTimePassed) {
|
|
1356
|
+
logger.log('Allowing new snapshot because older than 15 seconds');
|
|
1357
|
+
canTake = true;
|
|
1358
|
+
} else {
|
|
1359
|
+
canTake = true;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (canTake) {
|
|
1363
|
+
return this.takeSnapshotInternal(options?.timeout);
|
|
1364
|
+
} else if (this.lastB64Snapshot) {
|
|
1365
|
+
const mo = await b64ToMo(this.lastB64Snapshot);
|
|
1366
|
+
|
|
1367
|
+
return mo;
|
|
1368
|
+
} else {
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
getRtspChannel(): number {
|
|
1374
|
+
const channel = this.storageSettings.values.rtspChannel;
|
|
1375
|
+
return channel !== undefined ? Number(channel) : 0;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
async getVideoStream(vso: RequestMediaStreamOptions): Promise<MediaObject> {
|
|
1380
|
+
this.markActivity();
|
|
1381
|
+
if (!vso)
|
|
1382
|
+
throw new Error('video streams not set up or no longer exists.');
|
|
1383
|
+
|
|
1384
|
+
const vsos = await this.getVideoStreamOptions();
|
|
1385
|
+
const selected = vsos?.find(s => s.id === vso.id) || vsos?.[0];
|
|
1386
|
+
if (!selected)
|
|
1387
|
+
throw new Error('No stream options available');
|
|
1388
|
+
|
|
1389
|
+
const profile = parseStreamProfileFromId(selected.id) || 'main';
|
|
1390
|
+
|
|
1391
|
+
return this.withBaichuanRetry(async () => {
|
|
1392
|
+
const channel = this.getRtspChannel();
|
|
1393
|
+
const streamKey = `${channel}_${profile}`;
|
|
1394
|
+
|
|
1395
|
+
const expectedVideoType = selected?.video?.codec?.includes('265') ? 'H265'
|
|
1396
|
+
: selected?.video?.codec?.includes('264') ? 'H264'
|
|
1397
|
+
: undefined;
|
|
1398
|
+
|
|
1399
|
+
const { host, port, sdp, audio } = await this.streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType as any);
|
|
1400
|
+
|
|
1401
|
+
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
1402
|
+
// This stream is delivered as RFC4571 (RTP over raw TCP), not RTSP.
|
|
1403
|
+
// Mark it accordingly to avoid RTSP-specific handling in downstream plugins.
|
|
1404
|
+
mso.container = 'rtp';
|
|
1405
|
+
if (audio) {
|
|
1406
|
+
mso.audio ||= {};
|
|
1407
|
+
mso.audio.codec = audio.codec;
|
|
1408
|
+
mso.audio.sampleRate = audio.sampleRate;
|
|
1409
|
+
(mso.audio as any).channels = audio.channels;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
const rfc = {
|
|
1413
|
+
url: `tcp://${host}:${port}`,
|
|
1414
|
+
sdp,
|
|
1415
|
+
mediaStreamOptions: mso as ResponseMediaStreamOptions,
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
const jsonString = JSON.stringify(rfc);
|
|
1419
|
+
return await sdk.mediaManager.createMediaObject(
|
|
1420
|
+
Buffer.from(jsonString),
|
|
1421
|
+
'x-scrypted/x-rfc4571',
|
|
1422
|
+
{
|
|
1423
|
+
sourceId: this.id,
|
|
1424
|
+
},
|
|
1425
|
+
);
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
async getVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
|
|
1430
|
+
this.markActivity();
|
|
1431
|
+
return this.withBaichuanRetry(async () => {
|
|
1432
|
+
const client = await this.ensureClient();
|
|
1433
|
+
const channel = this.storageSettings.values.rtspChannel;
|
|
1434
|
+
const streamMetadata = await client.getStreamMetadata(channel);
|
|
1435
|
+
|
|
1436
|
+
const streams: UrlMediaStreamOptions[] = [];
|
|
1437
|
+
|
|
1438
|
+
// Only return stable identifiers + codec info. RTSP server is ensured on-demand in createVideoStream.
|
|
1439
|
+
for (const stream of streamMetadata.streams) {
|
|
1440
|
+
const profile = stream.profile as StreamProfile;
|
|
1441
|
+
const codec = stream.videoEncType.includes('264') ? 'h264' : stream.videoEncType.includes('265') ? 'h265' : stream.videoEncType.toLowerCase();
|
|
1442
|
+
const id = profile === 'main'
|
|
1443
|
+
? 'mainstream'
|
|
1444
|
+
: profile === 'sub'
|
|
1445
|
+
? 'substream'
|
|
1446
|
+
: 'extstream';
|
|
1447
|
+
const name = profile === 'main'
|
|
1448
|
+
? 'Main Stream'
|
|
1449
|
+
: profile === 'sub'
|
|
1450
|
+
? 'Sub Stream'
|
|
1451
|
+
: 'Ext Stream';
|
|
1452
|
+
|
|
1453
|
+
streams.push({
|
|
1454
|
+
name,
|
|
1455
|
+
id,
|
|
1456
|
+
// We return RFC4571 (RTP over TCP). Mark as RTP so other plugins do not attempt RTSP prebuffering.
|
|
1457
|
+
container: 'rtp',
|
|
1458
|
+
video: { codec, width: stream.width, height: stream.height },
|
|
1459
|
+
url: ``,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
return streams;
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async getOtherSettings(): Promise<Setting[]> {
|
|
1468
|
+
return await this.storageSettings.getSettings();
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
async putSetting(key: string, value: string) {
|
|
1472
|
+
await this.storageSettings.putSetting(key, value);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
showRtspUrlOverride() {
|
|
1476
|
+
return false;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
async reportDevices() {
|
|
1480
|
+
const hasSiren = this.hasSiren();
|
|
1481
|
+
const hasFloodlight = this.hasFloodlight();
|
|
1482
|
+
const hasPirEvents = this.hasPirEvents();
|
|
1483
|
+
|
|
1484
|
+
const devices: Device[] = [];
|
|
1485
|
+
|
|
1486
|
+
if (hasSiren) {
|
|
1487
|
+
const sirenNativeId = `${this.nativeId}-siren`;
|
|
1488
|
+
const sirenDevice: Device = {
|
|
1489
|
+
providerNativeId: this.nativeId,
|
|
1490
|
+
name: `${this.name} Siren`,
|
|
1491
|
+
nativeId: sirenNativeId,
|
|
1492
|
+
info: {
|
|
1493
|
+
...this.info,
|
|
1494
|
+
},
|
|
1495
|
+
interfaces: [
|
|
1496
|
+
ScryptedInterface.OnOff
|
|
1497
|
+
],
|
|
1498
|
+
type: ScryptedDeviceType.Siren,
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
devices.push(sirenDevice);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
if (hasFloodlight) {
|
|
1505
|
+
const floodlightNativeId = `${this.nativeId}-floodlight`;
|
|
1506
|
+
const floodlightDevice: Device = {
|
|
1507
|
+
providerNativeId: this.nativeId,
|
|
1508
|
+
name: `${this.name} Floodlight`,
|
|
1509
|
+
nativeId: floodlightNativeId,
|
|
1510
|
+
info: {
|
|
1511
|
+
...this.info,
|
|
1512
|
+
},
|
|
1513
|
+
interfaces: [
|
|
1514
|
+
ScryptedInterface.OnOff
|
|
1515
|
+
],
|
|
1516
|
+
type: ScryptedDeviceType.Light,
|
|
1517
|
+
};
|
|
1518
|
+
|
|
1519
|
+
devices.push(floodlightDevice);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
if (hasPirEvents) {
|
|
1523
|
+
const pirNativeId = `${this.nativeId}-pir`;
|
|
1524
|
+
const pirDevice: Device = {
|
|
1525
|
+
providerNativeId: this.nativeId,
|
|
1526
|
+
name: `${this.name} PIR sensor`,
|
|
1527
|
+
nativeId: pirNativeId,
|
|
1528
|
+
info: {
|
|
1529
|
+
...this.info,
|
|
1530
|
+
},
|
|
1531
|
+
interfaces: [
|
|
1532
|
+
ScryptedInterface.OnOff
|
|
1533
|
+
],
|
|
1534
|
+
type: ScryptedDeviceType.Switch,
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
devices.push(pirDevice);
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
sdk.deviceManager.onDevicesChanged({
|
|
1541
|
+
providerNativeId: this.nativeId,
|
|
1542
|
+
devices
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
async getDevice(nativeId: string): Promise<any> {
|
|
1547
|
+
if (nativeId.endsWith('-siren')) {
|
|
1548
|
+
this.siren ||= new ReolinkCameraSiren(this, nativeId);
|
|
1549
|
+
return this.siren;
|
|
1550
|
+
} else if (nativeId.endsWith('-floodlight')) {
|
|
1551
|
+
this.floodlight ||= new ReolinkCameraFloodlight(this, nativeId);
|
|
1552
|
+
return this.floodlight;
|
|
1553
|
+
} else if (nativeId.endsWith('-pir')) {
|
|
1554
|
+
this.pirSensor ||= new ReolinkCameraPirSensor(this, nativeId);
|
|
1555
|
+
return this.pirSensor;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
async releaseDevice(id: string, nativeId: string) {
|
|
1560
|
+
if (nativeId.endsWith('-siren')) {
|
|
1561
|
+
delete this.siren;
|
|
1562
|
+
} else if (nativeId.endsWith('-floodlight')) {
|
|
1563
|
+
delete this.floodlight;
|
|
1564
|
+
} else if (nativeId.endsWith('-pir')) {
|
|
1565
|
+
delete this.pirSensor;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
async startIntercom(media: MediaObject): Promise<void> {
|
|
1570
|
+
await this.intercom.start(media);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
stopIntercom(): Promise<void> {
|
|
1574
|
+
return this.intercom.stop();
|
|
1575
|
+
}
|
|
1576
|
+
}
|