@apocaliss92/scrypted-reolink-native 0.4.28 → 0.4.30

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.4.28",
3
+ "version": "0.4.30",
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",
@@ -277,26 +277,39 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
277
277
  return await this.ensureClientPromise;
278
278
  }
279
279
 
280
- // Reuse existing client if socket is still connected and logged in
281
- // Check this AFTER checking the promise to avoid race conditions
280
+ // Reuse existing API if possible
282
281
  if (this.baichuanApi) {
283
- const isConnected = this.baichuanApi.client.isSocketConnected();
284
- const isLoggedIn = this.baichuanApi.client.loggedIn;
285
-
286
- // Only reuse if both conditions are true
287
- if (isConnected && isLoggedIn) {
282
+ // Already connected and ready → reuse immediately
283
+ if (this.baichuanApi.isReady) {
288
284
  logger.debug(
289
285
  `ensureBaichuanClient: reusing existing client (caller: ${caller})`,
290
286
  );
291
287
  return this.baichuanApi;
292
288
  }
293
289
 
294
- // If socket is not connected or not logged in, cleanup the stale client
295
- // This prevents leaking connections when the socket appears connected but isn't
296
- logger.log(
297
- `Stale client detected: connected=${isConnected}, loggedIn=${isLoggedIn}, cleaning up (caller: ${caller})`,
298
- );
299
- await this.cleanupBaichuanApi();
290
+ // API was explicitly closed destroy and recreate from scratch
291
+ if (this.baichuanApi.isClosed) {
292
+ logger.log(
293
+ `API is closed, creating new instance (caller: ${caller})`,
294
+ );
295
+ await this.cleanupBaichuanApi();
296
+ } else {
297
+ // Socket disconnected but API still valid → let the library reconnect
298
+ // the general socket internally (preserves NVR/multifocal flags,
299
+ // streaming sockets, and all library-side state)
300
+ try {
301
+ logger.log(
302
+ `General socket lost, reconnecting via ensureConnected (caller: ${caller})`,
303
+ );
304
+ await this.baichuanApi.ensureConnected();
305
+ return this.baichuanApi;
306
+ } catch (e) {
307
+ logger.log(
308
+ `ensureConnected failed: ${e instanceof Error ? e.message : String(e)}, creating new instance`,
309
+ );
310
+ await this.cleanupBaichuanApi();
311
+ }
312
+ }
300
313
  }
301
314
 
302
315
  logger.log(`ensureBaichuanClient: creating NEW client (caller: ${caller})`);
@@ -543,11 +556,14 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
543
556
 
544
557
  // Stop event check interval
545
558
  this.stopEventCheck();
546
-
547
- // Reset state
559
+ } finally {
560
+ // Reset state ALWAYS — even if an earlier step threw.
561
+ // This prevents the client from being permanently "stuck":
562
+ // if baichuanApi remains set with a destroyed socket pool,
563
+ // ensureBaichuanClient() will repeatedly crash on the `client` getter
564
+ // and never recover.
548
565
  this.baichuanApi = undefined;
549
566
  this.ensureClientPromise = undefined;
550
- } finally {
551
567
  this.cleanupInProgress = false;
552
568
  }
553
569
  }
@@ -558,13 +574,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
558
574
  private getAllActiveConnections(): ReolinkBaichuanApi[] {
559
575
  const connections: ReolinkBaichuanApi[] = [];
560
576
 
561
- // Add main connection if exists and is valid
562
- if (this.baichuanApi) {
563
- const isConnected = this.baichuanApi.client.isSocketConnected();
564
- const isLoggedIn = this.baichuanApi.client.loggedIn;
565
- if (isConnected && isLoggedIn) {
566
- connections.push(this.baichuanApi);
567
- }
577
+ // Add main connection if it exists and is ready (safe, never throws)
578
+ if (this.baichuanApi?.isReady) {
579
+ connections.push(this.baichuanApi);
568
580
  }
569
581
 
570
582
  return connections;
@@ -715,14 +727,10 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
715
727
  return; // Connection changed, stop this interval
716
728
  }
717
729
 
718
- // If event subscription is not active but the connection is alive and
719
- // event subscription is desired, attempt to re-subscribe.
720
- // This handles the case where subscribeToEvents() failed during
721
- // reconnection (e.g. ECONNREFUSED) but the connection was later
722
- // re-established by the streaming infrastructure.
730
+ // If event subscription is not active, skip the library's
731
+ // built-in event watchdog handles auto-recovery with exponential
732
+ // backoff (including after reconnection / camera reboot).
723
733
  if (!this.eventSubscriptionActive) {
724
- // The library's built-in event watchdog handles auto-recovery
725
- // with exponential backoff. No need to retry here.
726
734
  return;
727
735
  }
728
736
 
@@ -849,7 +857,6 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
849
857
  */
850
858
  async unsubscribeFromEvents(silent: boolean = false): Promise<void> {
851
859
  const logger = this.getBaichuanLogger();
852
- const callbacks = this.getConnectionCallbacks();
853
860
 
854
861
  // Only unsubscribe if we have an active subscription
855
862
  if (
package/src/camera.ts CHANGED
@@ -3507,38 +3507,22 @@ export class ReolinkCamera
3507
3507
  }
3508
3508
 
3509
3509
  async resetBaichuanClient(reason?: any): Promise<void> {
3510
- try {
3511
- // Close all active streams before resetting the client
3512
- // This ensures no streams remain active when the client is reset
3513
- if (this.streamManager) {
3514
- try {
3515
- const hasActiveStreams = this.streamManager.hasActiveStreams();
3516
- if (hasActiveStreams) {
3517
- const logger = this.getBaichuanLogger();
3518
- logger.log("Closing all active streams due to client reset");
3519
- await this.streamManager.closeAllStreams("client reset");
3520
- }
3521
- } catch (e) {
3522
- const logger = this.getBaichuanLogger();
3523
- logger.error(
3524
- "Error closing streams during client reset:",
3525
- e?.message || String(e),
3526
- );
3527
- }
3528
- }
3529
-
3530
- this.unsubscribedToEvents?.();
3531
- await this.baichuanApi?.close();
3532
- } catch (e) {
3533
- this.getBaichuanLogger().error(
3534
- "Error closing Baichuan client during reset",
3535
- e?.message || String(e),
3536
- );
3537
- } finally {
3538
- this.baichuanApi = undefined;
3539
- this.connectionTime = undefined;
3540
- this.ensureClientPromise = undefined;
3541
- }
3510
+ // Delegate to cleanupBaichuanApi() which properly:
3511
+ // - unsubscribes from events (offSimpleEvent)
3512
+ // - calls onBeforeCleanup (closes streams, etc.)
3513
+ // - removes close/error listeners
3514
+ // - stops connection maintenance timers (ping, auto-renewal)
3515
+ // - stops event check interval
3516
+ // - uses cleanupInProgress guard to prevent races
3517
+ // Previously this method called api.close() directly, which left
3518
+ // listeners attached, timers running, and could race with the
3519
+ // close handler's own cleanupBaichuanApi() call.
3520
+ await this.cleanupBaichuanApi();
3521
+
3522
+ // Ensure state is fully reset even if cleanup was a no-op
3523
+ // (e.g. baichuanApi was already undefined)
3524
+ this.connectionTime = undefined;
3525
+ this.ensureClientPromise = undefined;
3542
3526
 
3543
3527
  if (reason) {
3544
3528
  const message = reason?.message || reason?.toString?.() || reason;
package/src/intercom.ts CHANGED
@@ -205,9 +205,32 @@ export class ReolinkBaichuanIntercom {
205
205
 
206
206
  this.ffmpeg = ffmpeg;
207
207
 
208
+ // Startup timeout: if ffmpeg has not produced any PCM output within
209
+ // this window it is likely stuck on RTSP connection negotiation.
210
+ // Kill it early with a clear error rather than hanging silently.
211
+ const STARTUP_TIMEOUT_MS = 10_000;
212
+ let receivedFirstPcm = false;
213
+ const startupTimer = setTimeout(() => {
214
+ if (!receivedFirstPcm && this.ffmpeg === ffmpeg) {
215
+ logger.warn(
216
+ `Intercom ffmpeg startup timeout (${STARTUP_TIMEOUT_MS}ms): no PCM data received, killing process`,
217
+ );
218
+ try {
219
+ ffmpeg.kill("SIGKILL");
220
+ } catch {
221
+ // ignore
222
+ }
223
+ }
224
+ }, STARTUP_TIMEOUT_MS);
225
+
208
226
  ffmpeg.stdout.on("data", (chunk: Buffer) => {
209
227
  if (this.session !== session) return;
210
228
  if (!chunk?.length) return;
229
+ if (!receivedFirstPcm) {
230
+ receivedFirstPcm = true;
231
+ clearTimeout(startupTimer);
232
+ logger.log("Intercom ffmpeg: first PCM data received");
233
+ }
211
234
  this.enqueuePcm(session, chunk, bytesNeeded, blockSize);
212
235
  });
213
236
 
@@ -220,6 +243,7 @@ export class ReolinkBaichuanIntercom {
220
243
  });
221
244
 
222
245
  ffmpeg.on("exit", (code, signal) => {
246
+ clearTimeout(startupTimer);
223
247
  logger.warn(`Intercom ffmpeg exited code=${code} signal=${signal}`);
224
248
  this.stop().catch(() => {});
225
249
  });
@@ -380,9 +404,20 @@ export class ReolinkBaichuanIntercom {
380
404
 
381
405
  // FFmpegInput may already contain one or more "-i" entries.
382
406
  // For intercom decode, we only need a single input and only the first audio stream.
407
+ //
408
+ // IMPORTANT: We must also strip `-rtsp_transport tcp` (and similar transport
409
+ // overrides) inherited from the upstream FFmpegInput. The RTSP URL points at a
410
+ // local relay server (e.g. from the WebRTC plugin) that typically only supports
411
+ // RTP/UDP. Forcing TCP causes ffmpeg to hang on SETUP negotiation (the relay
412
+ // replies 461 Unsupported Transport) and eventually get SIGKILLed with no audio
413
+ // ever reaching the intercom pipeline.
383
414
  const sanitizedArgs: string[] = [];
384
415
  let chosenInput: string | undefined;
385
416
 
417
+ // Set of pre-input flags that take a value argument and should be stripped
418
+ // because they conflict with the local RTSP relay's transport capabilities.
419
+ const stripWithValue = new Set(["-rtsp_transport"]);
420
+
386
421
  for (let i = 0; i < inputArgs.length; i++) {
387
422
  const arg = inputArgs[i];
388
423
  if (arg === "-i") {
@@ -397,6 +432,12 @@ export class ReolinkBaichuanIntercom {
397
432
  }
398
433
  }
399
434
 
435
+ // Strip transport-related flags that break the local RTSP relay.
436
+ if (stripWithValue.has(arg)) {
437
+ i++; // skip the value argument as well
438
+ continue;
439
+ }
440
+
400
441
  sanitizedArgs.push(arg);
401
442
  }
402
443