@apocaliss92/scrypted-reolink-native 0.1.30 → 0.1.32

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/main.ts CHANGED
@@ -57,7 +57,10 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
57
57
  },
58
58
  );
59
59
 
60
- this.console.log(`[AutoDetect] Detected device type ${detection.type} on transport ${detection.transport}: ${JSON.stringify(detection.deviceInfo)}`);
60
+ this.console.log(`[AutoDetect] Detected device type: ${detection.type} (transport: ${detection.transport})`);
61
+
62
+ // Use the API that was successfully used for detection
63
+ const detectedApi = detection.api;
61
64
 
62
65
  // Handle multi-focal device case
63
66
  if (detection.type === 'multifocal') {
@@ -69,18 +72,12 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
69
72
 
70
73
  settings.newCamera ||= name;
71
74
 
72
- const interfaces = [
73
- ScryptedInterface.Settings,
74
- ScryptedInterface.DeviceProvider,
75
- ScryptedInterface.Reboot,
76
- ];
77
-
78
- if (isBattery) {
79
- interfaces.push(
80
- ScryptedInterface.Battery,
81
- ScryptedInterface.Sleep
82
- );
83
- }
75
+ const { capabilities, objects, presets } = await detectedApi.getDeviceCapabilities();
76
+
77
+ const { interfaces } = getDeviceInterfaces({
78
+ capabilities,
79
+ logger: this.console,
80
+ });
84
81
 
85
82
  await sdk.deviceManager.onDeviceDiscovered({
86
83
  nativeId,
@@ -94,10 +91,13 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
94
91
  if (!(device instanceof ReolinkNativeMultiFocalDevice)) {
95
92
  throw new Error('Expected multi-focal device but got different type');
96
93
  }
94
+ device.classes = objects;
95
+ device.presets = presets;
97
96
  device.storageSettings.values.ipAddress = ipAddress;
98
97
  device.storageSettings.values.username = username;
99
98
  device.storageSettings.values.password = password;
100
- device.storageSettings.values.uid = detection.uid || '';
99
+ device.storageSettings.values.uid = uid;
100
+ device.storageSettings.values.capabilities = capabilities;
101
101
 
102
102
  return nativeId;
103
103
  }
@@ -149,22 +149,10 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
149
149
 
150
150
  settings.newCamera ||= name;
151
151
 
152
- // Create API connection to get capabilities
153
- const api = await createBaichuanApi({
154
- inputs: {
155
- host: ipAddress,
156
- username,
157
- password,
158
- uid: detection.uid,
159
- logger: this.console,
160
- },
161
- transport: detection.transport,
162
- });
163
-
152
+ // Use the API that was successfully used for detection
164
153
  try {
165
- await api.login();
166
154
  const rtspChannel = 0;
167
- const { capabilities, objects, presets } = await api.getDeviceCapabilities(rtspChannel);
155
+ const { capabilities, objects, presets } = await detectedApi.getDeviceCapabilities(rtspChannel);
168
156
 
169
157
  const { interfaces, type } = getDeviceInterfaces({
170
158
  capabilities,
@@ -189,7 +177,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
189
177
  device.storageSettings.values.rtspChannel = rtspChannel;
190
178
  device.storageSettings.values.ipAddress = ipAddress;
191
179
  device.storageSettings.values.capabilities = capabilities;
192
- device.storageSettings.values.uid = detection.uid;
180
+ device.storageSettings.values.uid = uid;
193
181
 
194
182
  return nativeId;
195
183
  }
@@ -197,9 +185,6 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
197
185
  this.console.error('Error adding Reolink device', e);
198
186
  throw e;
199
187
  }
200
- finally {
201
- await api.close();
202
- }
203
188
  }
204
189
 
205
190
  async releaseDevice(id: string, nativeId: ScryptedNativeId): Promise<void> {
package/src/multiFocal.ts CHANGED
@@ -1,6 +1,5 @@
1
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";
3
- import { type BaichuanConnectionCallbacks } from "./baichuan-base";
2
+ import sdk, { Device, DeviceProvider, Reboot, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
4
3
  import { ReolinkNativeCamera } from "./camera";
5
4
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
6
5
  import { CameraType, CommonCameraMixin } from "./common";
@@ -34,49 +33,10 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
34
33
  }
35
34
  }
36
35
 
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
- protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
51
- return {
52
- onError: undefined, // Use default error handling
53
- onClose: async () => {
54
- // Reinit after cleanup
55
- await this.reinit();
56
- if (!this.isBattery) {
57
- setTimeout(async () => {
58
- try {
59
- await this.subscribeToEvents();
60
- } catch (e) {
61
- const logger = this.getBaichuanLogger();
62
- logger.warn('Failed to resubscribe to events after reconnection', e);
63
- }
64
- }, 1000);
65
- }
66
- },
67
- onSimpleEvent: (ev) => this.forwardNativeEvent(ev),
68
- getEventSubscriptionEnabled: () => true,
69
- };
70
- }
71
-
72
36
  protected async onBeforeCleanup(): Promise<void> {
73
37
  await this.unsubscribeFromAllEvents();
74
38
  }
75
39
 
76
- protected isDebugEnabled(): boolean {
77
- return this.storageSettings.values.debugEvents || false;
78
- }
79
-
80
40
  protected getDeviceName(): string {
81
41
  return this.name || 'Multi-Focal Device';
82
42
  }
@@ -148,7 +108,8 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
148
108
 
149
109
  getInterfaces(channel: number) {
150
110
  const logger = this.getBaichuanLogger();
151
- const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
111
+ const values = this.storageSettings.values as any;
112
+ const { capabilities: caps, multifocalInfo } = values;
152
113
  const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
153
114
 
154
115
  const capabilities: DeviceCapabilities = {
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 {
@@ -111,6 +114,60 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
111
114
  mso.audio.channels = audio.channels;
112
115
  }
113
116
 
117
+ const url = new URL(`tcp://${host}`);
118
+ url.port = port.toString();
119
+ if (username) {
120
+ url.username = username;
121
+ }
122
+ if (password) {
123
+ url.password = password;
124
+ }
125
+
126
+ const rfc = {
127
+ url,
128
+ sdp,
129
+ mediaStreamOptions: mso as ResponseMediaStreamOptions,
130
+ };
131
+
132
+ return await sdk.mediaManager.createMediaObject(Buffer.from(JSON.stringify(rfc)), 'x-scrypted/x-rfc4571', {
133
+ sourceId,
134
+ });
135
+ }
136
+
137
+ export async function createRfc4571CompositeMediaObjectFromStreamManager(params: {
138
+ streamManager: StreamManager;
139
+ profile: StreamProfile;
140
+ streamKey: string;
141
+ expectedVideoType?: 'H264' | 'H265';
142
+ selected: UrlMediaStreamOptions;
143
+ sourceId: string;
144
+ onDetectedCodec?: (detectedCodec: 'h264' | 'h265') => void;
145
+ }): Promise<MediaObject> {
146
+ const { streamManager, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
147
+
148
+ const { host, port, sdp, audio, username, password } = await streamManager.getRfcCompositeStream(profile, streamKey, expectedVideoType);
149
+
150
+ // Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
151
+ try {
152
+ const detected = /a=rtpmap:\d+\s+(H26[45])\//.exec(sdp)?.[1];
153
+ if (detected) {
154
+ const dc = detected === 'H265' ? 'h265' : 'h264';
155
+ onDetectedCodec?.(dc);
156
+ }
157
+ }
158
+ catch {
159
+ // ignore
160
+ }
161
+
162
+ const { url: _ignoredUrl, ...mso }: any = selected;
163
+ mso.container = 'rtp';
164
+ if (audio) {
165
+ mso.audio ||= {};
166
+ mso.audio.codec = audio.codec;
167
+ mso.audio.sampleRate = audio.sampleRate;
168
+ mso.audio.channels = audio.channels;
169
+ }
170
+
114
171
  // Build URL with credentials: tcp://username:password@host:port
115
172
  const encodedUsername = encodeURIComponent(username || '');
116
173
  const encodedPassword = encodeURIComponent(password || '');
@@ -137,21 +194,24 @@ type RfcServerInfo = {
137
194
  };
138
195
 
139
196
  export class StreamManager {
140
- private nativeRfcServers = new Map<string, ScryptedRfc4571TcpServer>();
197
+ private nativeRfcServers = new Map<string, Rfc4571TcpServer>();
141
198
  private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
142
199
 
143
200
  constructor(private opts: StreamManagerOptions) {
144
201
  }
145
202
 
146
203
  private getLogger() {
147
- return this.opts.getLogger() as Console;
204
+ return this.opts.getLogger() ;
148
205
  }
149
206
 
150
- private async ensureNativeRfcServer(
207
+ private async ensureRfcServer(
151
208
  streamKey: string,
152
- channel: number,
153
209
  profile: StreamProfile,
154
- expectedVideoType?: 'H264' | 'H265',
210
+ expectedVideoType: 'H264' | 'H265' | undefined,
211
+ options: {
212
+ channel?: number;
213
+ compositeOptions?: CompositeStreamPipOptions;
214
+ },
155
215
  ): Promise<RfcServerInfo> {
156
216
  const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
157
217
  if (existingCreate) {
@@ -162,8 +222,9 @@ export class StreamManager {
162
222
  const cached = this.nativeRfcServers.get(streamKey);
163
223
  if (cached?.server?.listening) {
164
224
  if (expectedVideoType && cached.videoType !== expectedVideoType) {
225
+ const kind = options.channel === undefined ? 'composite' : 'native';
165
226
  this.getLogger().warn(
166
- `Native RFC cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
227
+ `Native RFC ${kind} cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
167
228
  );
168
229
  }
169
230
  else {
@@ -189,7 +250,7 @@ export class StreamManager {
189
250
  }
190
251
 
191
252
  const api = await this.opts.createStreamClient();
192
- const { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
253
+ const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
193
254
 
194
255
  // Use the same credentials as the main connection
195
256
  const { username, password } = this.opts.credentials;
@@ -197,15 +258,16 @@ export class StreamManager {
197
258
  // If connection is shared, don't close it when stream teardown happens
198
259
  const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
199
260
 
200
- const created = await createScryptedRfc4571TcpServer({
261
+ const created = await createRfc4571TcpServer({
201
262
  api,
202
- channel,
263
+ channel: options.channel,
203
264
  profile,
204
265
  logger: this.getLogger(),
205
266
  expectedVideoType: expectedVideoType as VideoType | undefined,
206
267
  closeApiOnTeardown,
207
268
  username,
208
269
  password,
270
+ ...(options.compositeOptions ? { compositeOptions: options.compositeOptions } : {}),
209
271
  });
210
272
 
211
273
  this.nativeRfcServers.set(streamKey, created);
@@ -239,7 +301,20 @@ export class StreamManager {
239
301
  streamKey: string,
240
302
  expectedVideoType?: 'H264' | 'H265',
241
303
  ): Promise<RfcServerInfo> {
242
- return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
304
+ return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
305
+ channel,
306
+ });
307
+ }
308
+
309
+ async getRfcCompositeStream(
310
+ profile: StreamProfile,
311
+ streamKey: string,
312
+ expectedVideoType?: 'H264' | 'H265',
313
+ ): Promise<RfcServerInfo> {
314
+ return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
315
+ channel: undefined, // Undefined channel indicates composite stream
316
+ compositeOptions: this.opts.compositeOptions,
317
+ });
243
318
  }
244
319
 
245
320
  /**
package/src/utils.ts CHANGED
@@ -44,6 +44,7 @@ export const getDeviceInterfaces = (props: {
44
44
  ScryptedInterface.AudioSensor,
45
45
  ScryptedInterface.MotionSensor,
46
46
  ScryptedInterface.VideoTextOverlays,
47
+ ScryptedInterface.VideoClips,
47
48
  ];
48
49
 
49
50
  try {