@apocaliss92/scrypted-reolink-native 0.1.29 → 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/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 { capabilities: caps, multifocalInfo } = this.storageSettings.values;
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
- debugEvents: {
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.debugEvents || false;
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
- await this.syncEntitiesFromRemote();
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,
@@ -1,6 +1,7 @@
1
1
  import type {
2
+ CompositeStreamPipOptions,
2
3
  ReolinkBaichuanApi,
3
- ScryptedRfc4571TcpServer,
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, ScryptedRfc4571TcpServer>();
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() as Console;
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 { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
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 createScryptedRfc4571TcpServer({
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.