@apocaliss92/scrypted-reolink-native 0.5.27 → 0.5.29

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.5.27",
3
+ "version": "0.5.29",
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",
@@ -486,19 +486,30 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
486
486
  this.lastDisconnectTime = now;
487
487
 
488
488
  logger.log(
489
- `Socket closed, resetting client state for reconnection${timeSinceLastDisconnect != null ? ` (last disconnect ${timeSinceLastDisconnect}ms ago)` : " (first disconnect)"}`,
489
+ `Socket closed${timeSinceLastDisconnect != null ? ` (last disconnect ${timeSinceLastDisconnect}ms ago)` : " (first disconnect)"} — api object kept alive, will reconnect on next use`,
490
490
  );
491
491
 
492
- // Mark as disconnected immediately to prevent reuse
493
- // This prevents race conditions where ensureBaichuanClient might check
494
- // isSocketConnected() before cleanup completes
495
- const currentApi = this.baichuanApi;
496
- if (currentApi === api) {
497
- // Only cleanup if this is still the current API instance
498
- // This prevents cleanup of a new connection that was created
499
- // while the old one was closing
500
- await this.cleanupBaichuanApi();
501
- }
492
+ // The api object itself stays alive across socket disconnects:
493
+ // - It's NOT explicitly closed (no `_closed = true`), so simple
494
+ // event listeners, the email-push auto-bridge (when used),
495
+ // recordings cache, sleep inference state, etc. are preserved
496
+ // for the next reconnect cycle.
497
+ // - The next `ensureBaichuanClient` call sees `api.isReady=false`
498
+ // AND `api.isClosed=false` and routes through
499
+ // `api.ensureConnected()`, which reuses the same api with a
500
+ // freshly reconnected socket pool — see the
501
+ // `socket disconnected but API still valid` branch above.
502
+ // - If the lib reconnect fails repeatedly, the same caller
503
+ // falls back to `cleanupBaichuanApi()` + full recreate (the
504
+ // catch around `ensureConnected()` in ensureBaichuanClient).
505
+ //
506
+ // Calling `cleanupBaichuanApi()` here would defeat all of that:
507
+ // it sets `_closed=true` (next ensureBaichuanClient destroys +
508
+ // recreates from scratch), and worse it tears down any consumer
509
+ // registered against `api.simpleEventListeners` — like the lib
510
+ // email-push auto-bridge — for the entire gap until the next
511
+ // `ensureBaichuanClient` call (which on battery cams can be
512
+ // minutes). We deliberately don't.
502
513
 
503
514
  // Call custom close handler if provided
504
515
  if (callbacks.onClose) {
package/src/camera.ts CHANGED
@@ -807,6 +807,13 @@ export class ReolinkCamera
807
807
  private batteryUpdateTimer: NodeJS.Timeout | undefined;
808
808
  private periodicStarted = false;
809
809
  private statusPollTimer: NodeJS.Timeout | undefined;
810
+ // Off-handle for the global email-push bus subscription. Tied to
811
+ // the camera (Scrypted device) lifecycle, NOT to the Baichuan api
812
+ // — the plugin destroys the api on every idle_disconnect (battery
813
+ // cams every ~30s), and an api-scoped bridge would be dead in
814
+ // that window. The camera-scoped subscription stays alive across
815
+ // every reconnect cycle so SMTP motion always finds a listener.
816
+ private emailPushBusOff?: () => void;
810
817
 
811
818
  // Cooldown timestamps to prevent alignAuxDevicesState from overwriting recently set states
812
819
  // Camera can take 10+ seconds to reflect state changes in its API response
@@ -1557,13 +1564,14 @@ export class ReolinkCamera
1557
1564
  // throw new Error("IP Address is required for TCP devices");
1558
1565
  // }
1559
1566
 
1560
- // Enable the lib-level email-push auto-bridge on the api so SMTP
1561
- // motion lands on `api.onSimpleEvent` together with native push.
1562
- // Skipped for NVR children / multifocal lens children they
1563
- // share the parent's connection and the parent (camera/NVR) owns
1564
- // the mail path; running the bridge twice would dispatch the
1565
- // event to both ends and risk double-counting.
1566
- const eligibleForEmailPush = !this.isOnNvr && !this.multiFocalDevice;
1567
+ // The lib's `emailPushCameraId` auto-bridge would die every time
1568
+ // the plugin calls `cleanupBaichuanApi` (every idle_disconnect on
1569
+ // battery cams every ~30s of idle). In that window SMTP motion
1570
+ // events emitted on the global bus have no listener and are lost.
1571
+ // Instead, the camera subscribes to the bus directly in `init()`
1572
+ // via `subscribeToEmailPushBus()` that subscription is tied to
1573
+ // the camera (Scrypted device) lifecycle, not to the api object,
1574
+ // so it survives every api recreate / idle disconnect / wake.
1567
1575
 
1568
1576
  return {
1569
1577
  host: ipAddress,
@@ -1575,9 +1583,6 @@ export class ReolinkCamera
1575
1583
  udpDiscoveryMethod: discoveryMethod,
1576
1584
  // NOTE: idleDisconnect is NOT set here - the library handles it internally
1577
1585
  // based on the battery status detected during connection
1578
- ...(eligibleForEmailPush && this.nativeId
1579
- ? { emailPushCameraId: this.nativeId }
1580
- : {}),
1581
1586
  };
1582
1587
  }
1583
1588
 
@@ -2756,6 +2761,74 @@ export class ReolinkCamera
2756
2761
  }
2757
2762
  }
2758
2763
 
2764
+ /**
2765
+ * Subscribe to the lib's global email-push bus and translate every
2766
+ * matching SMTP delivery into a synthetic `ReolinkSimpleEvent` fed
2767
+ * to this camera's `onSimpleEvent` — same code path used by native
2768
+ * Baichuan push so `motionDetected` flips uniformly.
2769
+ *
2770
+ * Subscription lifetime is tied to the camera (init → release), NOT
2771
+ * to the Baichuan api: the plugin destroys the api on every
2772
+ * idle_disconnect (battery cams sleep every ~30s of idle), and a
2773
+ * lib-level api auto-bridge would be dead in that window. The
2774
+ * camera-level subscription stays alive across every reconnect so
2775
+ * SMTP motion always finds a listener and lands on `onSimpleEvent`.
2776
+ */
2777
+ private async subscribeToEmailPushBus(): Promise<void> {
2778
+ if (this.emailPushBusOff) return;
2779
+ if (this.isOnNvr || this.multiFocalDevice) return;
2780
+ const ownNativeId = this.nativeId ?? "";
2781
+ if (!ownNativeId) return;
2782
+ const logger = this.getBaichuanLogger();
2783
+ try {
2784
+ const { onEmailPushEvent, mapEmailPushInferredType } = await import(
2785
+ "@apocaliss92/nodelink-js"
2786
+ );
2787
+ this.emailPushBusOff = onEmailPushEvent((event) => {
2788
+ if (event.cameraId !== ownNativeId) return;
2789
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
2790
+ const mapped = mapEmailPushInferredType(event.inferredType);
2791
+ logger.log(
2792
+ `E-mail Push event received (type=${event.inferredType}) → dispatching ${mapped}`,
2793
+ );
2794
+ try {
2795
+ this.onSimpleEvent({
2796
+ type: mapped,
2797
+ channel,
2798
+ timestamp: event.receivedAtMs,
2799
+ });
2800
+ // Fan out a generic motion for AI sub-types so motion-only
2801
+ // consumers still flip — mirrors lib's per-api behaviour.
2802
+ if (mapped !== "motion" && mapped !== "doorbell") {
2803
+ this.onSimpleEvent({
2804
+ type: "motion",
2805
+ channel,
2806
+ timestamp: event.receivedAtMs,
2807
+ });
2808
+ }
2809
+ } catch (e) {
2810
+ logger.warn(
2811
+ `E-mail Push: onSimpleEvent dispatch threw: ${e?.message || String(e)}`,
2812
+ );
2813
+ }
2814
+ });
2815
+ logger.log("E-mail Push: subscribed to global bus");
2816
+ } catch (e) {
2817
+ logger.warn(
2818
+ `E-mail Push: bus subscribe failed: ${e?.message || String(e)}`,
2819
+ );
2820
+ }
2821
+ }
2822
+
2823
+ private unsubscribeFromEmailPushBus(): void {
2824
+ if (this.emailPushBusOff) {
2825
+ try {
2826
+ this.emailPushBusOff();
2827
+ } catch {}
2828
+ this.emailPushBusOff = undefined;
2829
+ }
2830
+ }
2831
+
2759
2832
  /**
2760
2833
  * Refresh the `lastPicture` cache by fetching a live snapshot via
2761
2834
  * the Baichuan API. Used on motion events for battery cameras: the
@@ -2949,8 +3022,7 @@ export class ReolinkCamera
2949
3022
  this.statusPollTimer && clearInterval(this.statusPollTimer);
2950
3023
  this.sleepCheckTimer && clearInterval(this.sleepCheckTimer);
2951
3024
  this.batteryUpdateTimer && clearInterval(this.batteryUpdateTimer);
2952
- // The lib's email-push auto-bridge is released by `api.close()`
2953
- // inside `resetBaichuanClient` — no separate teardown needed.
3025
+ this.unsubscribeFromEmailPushBus();
2954
3026
  this.resetBaichuanClient();
2955
3027
  this.plugin.camerasMap.delete(this.id);
2956
3028
  }
@@ -3620,11 +3692,11 @@ export class ReolinkCamera
3620
3692
 
3621
3693
  this.startPeriodicTasks();
3622
3694
 
3623
- // NOTE: the email-push bus subscription used to live here, but
3624
- // the lib's api factory now auto-bridges SMTP motion into
3625
- // `api.onSimpleEvent` when `emailPushCameraId` is passed (see
3626
- // `getConnectionConfig()`), so there's nothing to wire up at the
3627
- // camera level — the next `ensureClient()` activates the bridge.
3695
+ // Subscribe to the lib's email-push bus BEFORE ensureClient so
3696
+ // SMTP motion is never lost during the api's connection window.
3697
+ // Lifetime is tied to the camera (released in `release()`) so it
3698
+ // survives every `cleanupBaichuanApi` cycle on battery cams.
3699
+ await this.subscribeToEmailPushBus();
3628
3700
 
3629
3701
  await this.ensureClient();
3630
3702