@apocaliss92/scrypted-reolink-native 0.1.42 → 0.2.0

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/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.1.42",
3
+ "version": "0.2.0",
4
4
  "description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
7
7
  "scripts": {
8
+ "build:lib": "./build-lib.sh",
8
9
  "scrypted-setup-project": "scrypted-setup-project",
9
10
  "prescrypted-setup-project": "scrypted-package-json",
10
11
  "build": "scrypted-webpack",
@@ -162,7 +162,15 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
162
162
  protected baichuanApi: ReolinkBaichuanApi | undefined;
163
163
  protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
164
164
  protected connectionTime: number | undefined;
165
-
165
+ transport: BaichuanTransport;
166
+ // Map of stream clients keyed by streamKey (profile + variantType)
167
+ private streamClients = new Map<string, ReolinkBaichuanApi>();
168
+
169
+ constructor(nativeId: string, transport: BaichuanTransport) {
170
+ super(nativeId);
171
+ this.transport = transport
172
+ }
173
+
166
174
  private errorListener?: (err: unknown) => void;
167
175
  private closeListener?: () => void;
168
176
  private lastDisconnectTime: number = 0;
@@ -189,6 +197,13 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
189
197
  */
190
198
  protected abstract getDeviceName(): string;
191
199
 
200
+ /**
201
+ * Get connection inputs for creating a stream client for a specific streamKey
202
+ * This method is called by createStreamClient to get the inputs needed to create a new client
203
+ * @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
204
+ */
205
+ protected abstract getStreamClientInputs(): BaichuanConnectionConfig;
206
+
192
207
  /**
193
208
  * Get a Baichuan logger instance with formatting and debug control
194
209
  * This logger implements Console interface and can be used everywhere
@@ -216,11 +231,11 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
216
231
  if (this.baichuanApi) {
217
232
  const isConnected = this.baichuanApi.client.isSocketConnected();
218
233
  const isLoggedIn = this.baichuanApi.client.loggedIn;
219
-
234
+
220
235
  // Only reuse if both conditions are true
221
236
  if (isConnected && isLoggedIn) {
222
- return this.baichuanApi;
223
- }
237
+ return this.baichuanApi;
238
+ }
224
239
 
225
240
  // If socket is not connected or not logged in, cleanup the stale client
226
241
  // This prevents leaking connections when the socket appears connected but isn't
@@ -251,33 +266,42 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
251
266
  // Create new Baichuan client
252
267
  // BaichuanLogger implements Console, so it can be used directly
253
268
  const logger = this.getBaichuanLogger();
254
- const api = await createBaichuanApi({
255
- inputs: {
256
- host: config.host,
257
- username: config.username,
258
- password: config.password,
259
- uid: config.uid,
260
- logger,
261
- debugOptions: config.debugOptions,
262
- udpDiscoveryMethod: config.udpDiscoveryMethod,
263
- },
264
- transport: config.transport,
265
- });
266
-
267
- await api.login();
268
-
269
- // Verify socket is connected before returning
270
- if (!api.client.isSocketConnected()) {
271
- throw new Error('Socket not connected after login');
272
- }
269
+ try {
270
+ const api = await createBaichuanApi({
271
+ inputs: {
272
+ host: config.host,
273
+ username: config.username,
274
+ password: config.password,
275
+ uid: config.uid,
276
+ logger,
277
+ debugOptions: config.debugOptions,
278
+ udpDiscoveryMethod: config.udpDiscoveryMethod,
279
+ },
280
+ transport: config.transport,
281
+ });
282
+
283
+ await api.login();
284
+
285
+ // Verify socket is connected before returning
286
+ if (!api.client.isSocketConnected()) {
287
+ throw new Error('Socket not connected after login');
288
+ }
273
289
 
274
- // Attach listeners
275
- this.attachBaichuanListeners(api);
290
+ // Attach listeners
291
+ this.attachBaichuanListeners(api);
276
292
 
277
- this.baichuanApi = api;
278
- this.connectionTime = Date.now();
293
+ this.baichuanApi = api;
294
+ this.connectionTime = Date.now();
279
295
 
280
- return api;
296
+ return api;
297
+ }
298
+ catch (e) {
299
+ // Apply backoff for connection failures too, otherwise multiple callers can hammer connect().
300
+ this.lastDisconnectTime = Date.now();
301
+ // Ensure state is reset so next attempt is clean.
302
+ await this.cleanupBaichuanApi();
303
+ throw e;
304
+ }
281
305
  })();
282
306
 
283
307
  try {
@@ -310,7 +334,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
310
334
  return;
311
335
  }
312
336
  logger.error(`error: ${msg}`);
313
-
337
+
314
338
  // Call custom error handler if provided
315
339
  if (callbacks.onError) {
316
340
  try {
@@ -359,7 +383,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
359
383
  // Only cleanup if this is still the current API instance
360
384
  // This prevents cleanup of a new connection that was created
361
385
  // while the old one was closing
362
- await this.cleanupBaichuanApi();
386
+ await this.cleanupBaichuanApi();
363
387
  }
364
388
 
365
389
  // Call custom close handler if provided
@@ -394,6 +418,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
394
418
  // Call before cleanup hook
395
419
  await this.onBeforeCleanup();
396
420
 
421
+ // Cleanup all stream clients
422
+ await this.cleanupStreamClients();
423
+
397
424
  // Remove all listeners
398
425
  if (this.closeListener) {
399
426
  try {
@@ -498,5 +525,97 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
498
525
 
499
526
  this.eventSubscriptionActive = false;
500
527
  }
528
+
529
+ private async createNewClient(): Promise<ReolinkBaichuanApi> {
530
+ const config = this.getStreamClientInputs();
531
+ const logger = this.getBaichuanLogger();
532
+
533
+ const api = await createBaichuanApi({
534
+ inputs: {
535
+ host: config.host,
536
+ username: config.username,
537
+ password: config.password,
538
+ uid: config.uid,
539
+ logger,
540
+ debugOptions: config.debugOptions,
541
+ udpDiscoveryMethod: config.udpDiscoveryMethod,
542
+ },
543
+ transport: config.transport,
544
+ });
545
+
546
+ await api.login();
547
+
548
+ return api;
549
+ }
550
+
551
+ /**
552
+ * Create or get a dedicated Baichuan API session for streaming (used by StreamManager).
553
+ * Returns an existing client if one exists for the same streamKey, otherwise creates a new one.
554
+ * @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
555
+ */
556
+ async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
557
+ const logger = this.getBaichuanLogger();
558
+
559
+ // Check if a client already exists for this streamKey
560
+ const existingClient = this.streamClients.get(streamKey);
561
+ if (existingClient) {
562
+ // Verify the client is still valid (connected and logged in)
563
+ const isConnected = existingClient.client.isSocketConnected();
564
+ const isLoggedIn = existingClient.client.loggedIn;
565
+
566
+ if (isConnected && isLoggedIn) {
567
+ // Return existing valid client
568
+ logger.log(`Reusing existing stream client for streamKey=${streamKey}`);
569
+ return existingClient;
570
+ }
571
+
572
+ logger.log(`Stale stream client detected for streamKey=${streamKey}, recreating`);
573
+ try {
574
+ if (existingClient.client.isSocketConnected()) {
575
+ await existingClient.close();
576
+ }
577
+ } catch {
578
+ // ignore cleanup errors
579
+ }
580
+ this.streamClients.delete(streamKey);
581
+ }
582
+
583
+ // Create new client for this streamKey
584
+ const api = await this.createNewClient();
585
+
586
+ // Store in map for future reuse
587
+ this.streamClients.set(streamKey, api);
588
+ logger.log(`Created new stream client for streamKey=${streamKey}`);
589
+
590
+ // Clean up when client closes
591
+ api.client.once('close', () => {
592
+ const currentClient = this.streamClients.get(streamKey);
593
+ if (currentClient === api) {
594
+ this.streamClients.delete(streamKey);
595
+ }
596
+ });
597
+
598
+ return api;
599
+ }
600
+
601
+ /**
602
+ * Cleanup all stream clients (called during device cleanup)
603
+ */
604
+ async cleanupStreamClients(): Promise<void> {
605
+ const clients = Array.from(this.streamClients.values());
606
+ this.streamClients.clear();
607
+
608
+ await Promise.allSettled(
609
+ clients.map(async (api) => {
610
+ try {
611
+ if (api.client.isSocketConnected()) {
612
+ await api.close();
613
+ }
614
+ } catch {
615
+ // ignore cleanup errors
616
+ }
617
+ })
618
+ );
619
+ }
501
620
  }
502
621
 
@@ -1,16 +1,9 @@
1
- import type { ReolinkBaichuanApi, SleepStatus } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, {
3
- type MediaObject,
4
- RequestPictureOptions,
5
- ResponsePictureOptions
6
- } from "@scrypted/sdk";
7
1
  import {
8
2
  CommonCameraMixin,
9
3
  } from "./common";
10
- import { DebugLogOption } from "./debug-options";
11
4
  import type ReolinkNativePlugin from "./main";
12
- import { ReolinkNativeNvrDevice } from "./nvr";
13
5
  import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
6
+ import { ReolinkNativeNvrDevice } from "./nvr";
14
7
 
15
8
  export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
16
9
  doorbellBinaryTimeout?: NodeJS.Timeout;
@@ -46,6 +39,10 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
46
39
  return this.resetBaichuanClient();
47
40
  }
48
41
 
42
+ async reportDevices(): Promise<void> {
43
+ // Do nothing
44
+ }
45
+
49
46
  private stopPeriodicTasks(): void {
50
47
  if (this.sleepCheckTimer) {
51
48
  clearInterval(this.sleepCheckTimer);
@@ -97,56 +94,6 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
97
94
  logger.log(`Periodic tasks started: sleep check every 5s, battery update every ${batteryUpdateIntervalMinutes} minutes`);
98
95
  }
99
96
 
100
- async updateSleepingState(sleepStatus: SleepStatus): Promise<void> {
101
- try {
102
- if (this.isDebugEnabled()) {
103
- this.getBaichuanLogger().debug('getSleepStatus result:', JSON.stringify(sleepStatus));
104
- }
105
-
106
- if (sleepStatus.state === 'sleeping') {
107
- if (!this.sleeping) {
108
- this.getBaichuanLogger().log(`Camera is sleeping: ${sleepStatus.reason}`);
109
- this.sleeping = true;
110
- }
111
- } else if (sleepStatus.state === 'awake') {
112
- // Camera is awake
113
- const wasSleeping = this.sleeping;
114
- if (wasSleeping) {
115
- this.getBaichuanLogger().log(`Camera woke up: ${sleepStatus.reason}`);
116
- this.sleeping = false;
117
- }
118
-
119
- if (wasSleeping) {
120
- this.alignAuxDevicesState().catch(() => { });
121
- if (this.forceNewSnapshot) {
122
- this.takePicture().catch(() => { });
123
- }
124
- }
125
- } else {
126
- // Unknown state
127
- this.getBaichuanLogger().debug(`Sleep status unknown: ${sleepStatus.reason}`);
128
- }
129
- } catch (e) {
130
- // Silently ignore errors in sleep check to avoid spam
131
- this.getBaichuanLogger().debug('Error in updateSleepingState:', e);
132
- }
133
- }
134
-
135
- async updateOnlineState(isOnline: boolean): Promise<void> {
136
- try {
137
- if (this.isDebugEnabled()) {
138
- this.getBaichuanLogger().debug('updateOnlineState result:', isOnline);
139
- }
140
-
141
- if (isOnline !== this.online) {
142
- this.online = isOnline;
143
- }
144
- } catch (e) {
145
- // Silently ignore errors in sleep check to avoid spam
146
- this.getBaichuanLogger().debug('Error in updateOnlineState:', e);
147
- }
148
- }
149
-
150
97
  async checkRecordingAction(newBatteryLevel: number) {
151
98
  const nvrDeviceId = this.plugin.nvrDeviceId;
152
99
  if (nvrDeviceId && this.mixins.includes(nvrDeviceId)) {
package/src/camera.ts CHANGED
@@ -22,7 +22,6 @@ export const b64ToMo = async (b64: string) => {
22
22
  export class ReolinkNativeCamera extends CommonCameraMixin {
23
23
  videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
24
24
  motionTimeout?: NodeJS.Timeout;
25
- initComplete: boolean = false;
26
25
  doorbellBinaryTimeout?: NodeJS.Timeout;
27
26
  ptzCapabilities?: any;
28
27
 
@@ -43,13 +42,17 @@ export class ReolinkNativeCamera extends CommonCameraMixin {
43
42
  });
44
43
  }
45
44
 
45
+ async reportDevices(): Promise<void> {
46
+ // Do nothing
47
+ }
48
+
46
49
  async resetBaichuanClient(reason?: any): Promise<void> {
47
50
  try {
48
51
  this.unsubscribedToEvents?.();
49
52
  await this.baichuanApi?.close();
50
53
  }
51
54
  catch (e) {
52
- this.getBaichuanLogger().warn('Error closing Baichuan client during reset', e);
55
+ this.getBaichuanLogger().warn('Error closing Baichuan client during reset', e?.message || String(e));
53
56
  }
54
57
  finally {
55
58
  this.baichuanApi = undefined;