@apocaliss92/scrypted-reolink-native 0.1.30 → 0.1.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +0 -4
- package/src/camera-battery.ts +4 -59
- package/src/camera.ts +0 -15
- package/src/common.ts +187 -57
- package/src/debug-options.ts +0 -8
- package/src/main.ts +15 -30
- package/src/multiFocal.ts +53 -20
- package/src/nvr.ts +26 -8
- package/src/stream-utils.ts +144 -5
package/src/multiFocal.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
-
import sdk, { Device, DeviceProvider, MediaObject, Reboot, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
|
1
|
+
import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent, ReolinkSupportedStream } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
import sdk, { Device, DeviceProvider, MediaObject, Reboot, RequestMediaStreamOptions, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
|
3
3
|
import { type BaichuanConnectionCallbacks } from "./baichuan-base";
|
|
4
4
|
import { ReolinkNativeCamera } from "./camera";
|
|
5
5
|
import { ReolinkNativeBatteryCamera } from "./camera-battery";
|
|
6
6
|
import { CameraType, CommonCameraMixin } from "./common";
|
|
7
7
|
import ReolinkNativePlugin from "./main";
|
|
8
|
+
import { StreamManager } from "./stream-utils";
|
|
9
|
+
import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
8
10
|
import { batteryCameraSuffix, cameraSuffix, getDeviceInterfaces, updateDeviceInfo } from "./utils";
|
|
9
11
|
|
|
10
12
|
export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements Settings, DeviceProvider, Reboot {
|
|
@@ -34,19 +36,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
34
36
|
}
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
async reboot(): Promise<void> {
|
|
38
|
-
const api = await this.ensureBaichuanClient();
|
|
39
|
-
await api.reboot();
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
takePicture(options?: any): Promise<MediaObject> {
|
|
43
|
-
throw new Error("Method not implemented.");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
getPictureOptions(): Promise<any[]> {
|
|
47
|
-
throw new Error("Method not implemented.");
|
|
48
|
-
}
|
|
49
|
-
|
|
50
39
|
protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
|
|
51
40
|
return {
|
|
52
41
|
onError: undefined, // Use default error handling
|
|
@@ -73,10 +62,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
73
62
|
await this.unsubscribeFromAllEvents();
|
|
74
63
|
}
|
|
75
64
|
|
|
76
|
-
protected isDebugEnabled(): boolean {
|
|
77
|
-
return this.storageSettings.values.debugEvents || false;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
65
|
protected getDeviceName(): string {
|
|
81
66
|
return this.name || 'Multi-Focal Device';
|
|
82
67
|
}
|
|
@@ -148,7 +133,8 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
148
133
|
|
|
149
134
|
getInterfaces(channel: number) {
|
|
150
135
|
const logger = this.getBaichuanLogger();
|
|
151
|
-
const
|
|
136
|
+
const values = this.storageSettings.values as any;
|
|
137
|
+
const { capabilities: caps, multifocalInfo } = values;
|
|
152
138
|
const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
|
|
153
139
|
|
|
154
140
|
const capabilities: DeviceCapabilities = {
|
|
@@ -234,6 +220,53 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
234
220
|
}
|
|
235
221
|
|
|
236
222
|
await super.reportDevices();
|
|
223
|
+
|
|
224
|
+
// Initialize StreamManager with composite options for multifocal device
|
|
225
|
+
// Use saved settings or defaults
|
|
226
|
+
const values = this.storageSettings.values as any;
|
|
227
|
+
const pipPosition = (values.pipPosition || 'bottom-right') as any;
|
|
228
|
+
const pipSize = values.pipSize ?? 0.25;
|
|
229
|
+
const pipMargin = values.pipMargin ?? 10;
|
|
230
|
+
const widerChannel = values.widerChannel ?? 0;
|
|
231
|
+
const teleChannel = values.teleChannel ?? 1;
|
|
232
|
+
|
|
233
|
+
if (!this.streamManager) {
|
|
234
|
+
this.streamManager = new StreamManager({
|
|
235
|
+
createStreamClient: () => this.createStreamClient(),
|
|
236
|
+
getLogger: () => logger,
|
|
237
|
+
credentials: {
|
|
238
|
+
username,
|
|
239
|
+
password
|
|
240
|
+
},
|
|
241
|
+
sharedConnection: this.isBattery,
|
|
242
|
+
compositeOptions: {
|
|
243
|
+
widerChannel,
|
|
244
|
+
teleChannel,
|
|
245
|
+
pipPosition: pipPosition as any,
|
|
246
|
+
pipSize,
|
|
247
|
+
pipMargin,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
// Recreate StreamManager with new settings if they changed
|
|
252
|
+
// StreamManager doesn't expose opts, so we need to recreate it
|
|
253
|
+
this.streamManager = new StreamManager({
|
|
254
|
+
createStreamClient: () => this.createStreamClient(),
|
|
255
|
+
getLogger: () => logger,
|
|
256
|
+
credentials: {
|
|
257
|
+
username,
|
|
258
|
+
password
|
|
259
|
+
},
|
|
260
|
+
sharedConnection: this.isBattery,
|
|
261
|
+
compositeOptions: {
|
|
262
|
+
widerChannel,
|
|
263
|
+
teleChannel,
|
|
264
|
+
pipPosition: pipPosition as any,
|
|
265
|
+
pipSize,
|
|
266
|
+
pipMargin,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
237
270
|
}
|
|
238
271
|
|
|
239
272
|
async getDevice(nativeId: string) {
|
package/src/nvr.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { getDeviceInterfaces, updateDeviceInfo } from "./utils";
|
|
|
10
10
|
|
|
11
11
|
export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Settings, DeviceDiscovery, DeviceProvider, Reboot {
|
|
12
12
|
storageSettings = new StorageSettings(this, {
|
|
13
|
-
|
|
13
|
+
debugLogs: {
|
|
14
14
|
title: 'Debug Events',
|
|
15
15
|
type: 'boolean',
|
|
16
16
|
immediate: true,
|
|
@@ -68,6 +68,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
68
68
|
lastDevicesStatusCheck: number | undefined;
|
|
69
69
|
cameraNativeMap = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
|
|
70
70
|
private channelToNativeIdMap = new Map<number, string>();
|
|
71
|
+
private discoverDevicesPromise: Promise<DiscoveredDevice[]> | undefined;
|
|
71
72
|
processing = false;
|
|
72
73
|
private initReinitTimeout: NodeJS.Timeout | undefined;
|
|
73
74
|
|
|
@@ -116,7 +117,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
116
117
|
|
|
117
118
|
|
|
118
119
|
protected isDebugEnabled(): boolean {
|
|
119
|
-
return this.storageSettings.values.
|
|
120
|
+
return this.storageSettings.values.debugLogs || false;
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
protected getDeviceName(): string {
|
|
@@ -333,11 +334,11 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
333
334
|
|
|
334
335
|
async init() {
|
|
335
336
|
const logger = this.getBaichuanLogger();
|
|
336
|
-
|
|
337
|
+
|
|
337
338
|
// Ensure both APIs are ready before proceeding
|
|
338
339
|
const api = await this.ensureClient();
|
|
339
340
|
await this.ensureBaichuanClient();
|
|
340
|
-
|
|
341
|
+
|
|
341
342
|
await this.updateDeviceInfo();
|
|
342
343
|
|
|
343
344
|
await this.reinitEventSubscriptions();
|
|
@@ -356,7 +357,6 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
356
357
|
}
|
|
357
358
|
|
|
358
359
|
if (!this.lastNvrInfoCheck || now - this.lastNvrInfoCheck > 1000 * 60 * 5) {
|
|
359
|
-
logger.log('Starting NVR info data fetch');
|
|
360
360
|
this.lastNvrInfoCheck = now;
|
|
361
361
|
const { nvrData } = await api.getNvrInfo();
|
|
362
362
|
const { devicesData, channelsResponse, response } = await api.getDevicesInfo();
|
|
@@ -468,11 +468,11 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
468
468
|
|
|
469
469
|
async syncEntitiesFromRemote() {
|
|
470
470
|
const logger = this.getBaichuanLogger();
|
|
471
|
-
|
|
471
|
+
|
|
472
472
|
// Ensure both APIs are ready before syncing
|
|
473
473
|
const api = await this.ensureClient();
|
|
474
474
|
const baichuanApi = await this.ensureBaichuanClient();
|
|
475
|
-
|
|
475
|
+
|
|
476
476
|
// Wait for Baichuan connection to be fully established
|
|
477
477
|
if (baichuanApi?.client) {
|
|
478
478
|
// Check if already connected
|
|
@@ -555,10 +555,28 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
555
555
|
}
|
|
556
556
|
|
|
557
557
|
async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
|
|
558
|
+
// If a discovery is already in progress, return that promise
|
|
559
|
+
if (this.discoverDevicesPromise) {
|
|
560
|
+
return await this.discoverDevicesPromise;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// If scan is requested, start a new discovery
|
|
558
564
|
if (scan) {
|
|
559
|
-
|
|
565
|
+
this.discoverDevicesPromise = (async () => {
|
|
566
|
+
try {
|
|
567
|
+
await this.syncEntitiesFromRemote();
|
|
568
|
+
return [...this.discoveredDevices.values()].map(d => ({
|
|
569
|
+
...d.device,
|
|
570
|
+
description: d.description,
|
|
571
|
+
}));
|
|
572
|
+
} finally {
|
|
573
|
+
this.discoverDevicesPromise = undefined;
|
|
574
|
+
}
|
|
575
|
+
})();
|
|
576
|
+
return await this.discoverDevicesPromise;
|
|
560
577
|
}
|
|
561
578
|
|
|
579
|
+
// If no scan requested, return cached devices immediately
|
|
562
580
|
return [...this.discoveredDevices.values()].map(d => ({
|
|
563
581
|
...d.device,
|
|
564
582
|
description: d.description,
|
package/src/stream-utils.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
CompositeStreamPipOptions,
|
|
2
3
|
ReolinkBaichuanApi,
|
|
3
|
-
|
|
4
|
+
Rfc4571TcpServer,
|
|
4
5
|
StreamProfile,
|
|
5
6
|
VideoType,
|
|
6
7
|
} from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
@@ -29,6 +30,8 @@ export interface StreamManagerOptions {
|
|
|
29
30
|
};
|
|
30
31
|
/** If true, the stream client is shared with the main connection. Default: false. */
|
|
31
32
|
sharedConnection?: boolean;
|
|
33
|
+
/** Composite stream options for multifocal cameras */
|
|
34
|
+
compositeOptions?: CompositeStreamPipOptions;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
|
|
@@ -102,6 +105,56 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
102
105
|
// ignore
|
|
103
106
|
}
|
|
104
107
|
|
|
108
|
+
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
109
|
+
mso.container = 'rtp';
|
|
110
|
+
if (audio) {
|
|
111
|
+
mso.audio ||= {};
|
|
112
|
+
mso.audio.codec = audio.codec;
|
|
113
|
+
mso.audio.sampleRate = audio.sampleRate;
|
|
114
|
+
mso.audio.channels = audio.channels;
|
|
115
|
+
}
|
|
116
|
+
const url = new URL(host);
|
|
117
|
+
url.port = port.toString();
|
|
118
|
+
url.protocol = 'tcp';
|
|
119
|
+
url.username = username;
|
|
120
|
+
url.password = password;
|
|
121
|
+
|
|
122
|
+
const rfc = {
|
|
123
|
+
url,
|
|
124
|
+
sdp,
|
|
125
|
+
mediaStreamOptions: mso as ResponseMediaStreamOptions,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return await sdk.mediaManager.createMediaObject(Buffer.from(JSON.stringify(rfc)), 'x-scrypted/x-rfc4571', {
|
|
129
|
+
sourceId,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function createRfc4571CompositeMediaObjectFromStreamManager(params: {
|
|
134
|
+
streamManager: StreamManager;
|
|
135
|
+
profile: StreamProfile;
|
|
136
|
+
streamKey: string;
|
|
137
|
+
expectedVideoType?: 'H264' | 'H265';
|
|
138
|
+
selected: UrlMediaStreamOptions;
|
|
139
|
+
sourceId: string;
|
|
140
|
+
onDetectedCodec?: (detectedCodec: 'h264' | 'h265') => void;
|
|
141
|
+
}): Promise<MediaObject> {
|
|
142
|
+
const { streamManager, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
|
|
143
|
+
|
|
144
|
+
const { host, port, sdp, audio, username, password } = await streamManager.getRfcCompositeStream(profile, streamKey, expectedVideoType);
|
|
145
|
+
|
|
146
|
+
// Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
|
|
147
|
+
try {
|
|
148
|
+
const detected = /a=rtpmap:\d+\s+(H26[45])\//.exec(sdp)?.[1];
|
|
149
|
+
if (detected) {
|
|
150
|
+
const dc = detected === 'H265' ? 'h265' : 'h264';
|
|
151
|
+
onDetectedCodec?.(dc);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// ignore
|
|
156
|
+
}
|
|
157
|
+
|
|
105
158
|
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
106
159
|
mso.container = 'rtp';
|
|
107
160
|
if (audio) {
|
|
@@ -137,14 +190,14 @@ type RfcServerInfo = {
|
|
|
137
190
|
};
|
|
138
191
|
|
|
139
192
|
export class StreamManager {
|
|
140
|
-
private nativeRfcServers = new Map<string,
|
|
193
|
+
private nativeRfcServers = new Map<string, Rfc4571TcpServer>();
|
|
141
194
|
private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
|
|
142
195
|
|
|
143
196
|
constructor(private opts: StreamManagerOptions) {
|
|
144
197
|
}
|
|
145
198
|
|
|
146
199
|
private getLogger() {
|
|
147
|
-
return this.opts.getLogger()
|
|
200
|
+
return this.opts.getLogger() ;
|
|
148
201
|
}
|
|
149
202
|
|
|
150
203
|
private async ensureNativeRfcServer(
|
|
@@ -189,7 +242,7 @@ export class StreamManager {
|
|
|
189
242
|
}
|
|
190
243
|
|
|
191
244
|
const api = await this.opts.createStreamClient();
|
|
192
|
-
const {
|
|
245
|
+
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
193
246
|
|
|
194
247
|
// Use the same credentials as the main connection
|
|
195
248
|
const { username, password } = this.opts.credentials;
|
|
@@ -197,7 +250,7 @@ export class StreamManager {
|
|
|
197
250
|
// If connection is shared, don't close it when stream teardown happens
|
|
198
251
|
const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
|
|
199
252
|
|
|
200
|
-
const created = await
|
|
253
|
+
const created = await createRfc4571TcpServer({
|
|
201
254
|
api,
|
|
202
255
|
channel,
|
|
203
256
|
profile,
|
|
@@ -242,6 +295,92 @@ export class StreamManager {
|
|
|
242
295
|
return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
|
|
243
296
|
}
|
|
244
297
|
|
|
298
|
+
async getRfcCompositeStream(
|
|
299
|
+
profile: StreamProfile,
|
|
300
|
+
streamKey: string,
|
|
301
|
+
expectedVideoType?: 'H264' | 'H265',
|
|
302
|
+
): Promise<RfcServerInfo> {
|
|
303
|
+
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
304
|
+
if (existingCreate) {
|
|
305
|
+
return await existingCreate;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const createPromise = (async () => {
|
|
309
|
+
const cached = this.nativeRfcServers.get(streamKey);
|
|
310
|
+
if (cached?.server?.listening) {
|
|
311
|
+
if (expectedVideoType && cached.videoType !== expectedVideoType) {
|
|
312
|
+
this.getLogger().warn(
|
|
313
|
+
`Native RFC composite cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
return {
|
|
318
|
+
host: cached.host,
|
|
319
|
+
port: cached.port,
|
|
320
|
+
sdp: cached.sdp,
|
|
321
|
+
audio: cached.audio,
|
|
322
|
+
username: (cached as any).username || this.opts.credentials.username,
|
|
323
|
+
password: (cached as any).password || this.opts.credentials.password,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (cached) {
|
|
329
|
+
try {
|
|
330
|
+
await cached.close('recreate');
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// ignore
|
|
334
|
+
}
|
|
335
|
+
this.nativeRfcServers.delete(streamKey);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const api = await this.opts.createStreamClient();
|
|
339
|
+
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
340
|
+
|
|
341
|
+
// Use the same credentials as the main connection
|
|
342
|
+
const { username, password } = this.opts.credentials;
|
|
343
|
+
|
|
344
|
+
// If connection is shared, don't close it when stream teardown happens
|
|
345
|
+
const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
|
|
346
|
+
|
|
347
|
+
const created = await createRfc4571TcpServer({
|
|
348
|
+
api,
|
|
349
|
+
channel: undefined, // Undefined channel indicates composite stream
|
|
350
|
+
profile,
|
|
351
|
+
logger: this.getLogger(),
|
|
352
|
+
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
353
|
+
closeApiOnTeardown,
|
|
354
|
+
username,
|
|
355
|
+
password,
|
|
356
|
+
compositeOptions: this.opts.compositeOptions,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
this.nativeRfcServers.set(streamKey, created);
|
|
360
|
+
created.server.once('close', () => {
|
|
361
|
+
const current = this.nativeRfcServers.get(streamKey);
|
|
362
|
+
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
host: created.host,
|
|
367
|
+
port: created.port,
|
|
368
|
+
sdp: created.sdp,
|
|
369
|
+
audio: created.audio,
|
|
370
|
+
username: (created as any).username || this.opts.credentials.username,
|
|
371
|
+
password: (created as any).password || this.opts.credentials.password,
|
|
372
|
+
};
|
|
373
|
+
})();
|
|
374
|
+
|
|
375
|
+
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
376
|
+
try {
|
|
377
|
+
return await createPromise;
|
|
378
|
+
}
|
|
379
|
+
finally {
|
|
380
|
+
this.nativeRfcServerCreatePromises.delete(streamKey);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
245
384
|
/**
|
|
246
385
|
* Close all active stream servers.
|
|
247
386
|
* Useful when the main connection is reset and streams need to be recreated.
|