@apocaliss92/scrypted-reolink-native 0.5.26 → 0.5.28

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.26",
3
+ "version": "0.5.28",
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",
@@ -44,7 +44,7 @@
44
44
  ]
45
45
  },
46
46
  "dependencies": {
47
- "@apocaliss92/nodelink-js": "^0.4.31",
47
+ "@apocaliss92/nodelink-js": "^0.4.32",
48
48
  "@scrypted/common": "file:../../scrypted/common",
49
49
  "@scrypted/rtsp": "file:../../scrypted/plugins/rtsp",
50
50
  "@scrypted/sdk": "^0.3.118"
@@ -15,6 +15,13 @@ export interface BaichuanConnectionConfig {
15
15
  transport: BaichuanTransport;
16
16
  debugOptions?: any;
17
17
  udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
18
+ /**
19
+ * When set, the api auto-bridges the global email-push bus into
20
+ * its own `simpleEventListeners` so SMTP motion lands on the same
21
+ * `onSimpleEvent` stream as native Baichuan push. Standalone
22
+ * (non-NVR-child, non-multifocal-lens) cameras only.
23
+ */
24
+ emailPushCameraId?: string;
18
25
  }
19
26
 
20
27
  export interface BaichuanConnectionCallbacks {
@@ -349,6 +356,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
349
356
  logger,
350
357
  debugOptions: config.debugOptions,
351
358
  udpDiscoveryMethod: config.udpDiscoveryMethod,
359
+ ...(config.emailPushCameraId
360
+ ? { emailPushCameraId: config.emailPushCameraId }
361
+ : {}),
352
362
  },
353
363
  transport: config.transport,
354
364
  });
package/src/camera.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type {
2
2
  BatteryInfo,
3
3
  DeviceCapabilities,
4
- EmailPushEvent,
5
4
  NativeVideoStreamVariant,
6
5
  PtzCommand,
7
6
  PtzPreset,
@@ -809,9 +808,11 @@ export class ReolinkCamera
809
808
  private periodicStarted = false;
810
809
  private statusPollTimer: NodeJS.Timeout | undefined;
811
810
  // Off-handle for the global email-push bus subscription. Tied to
812
- // the camera lifecycle (init release), NOT to the Baichuan api
813
- // lifetime battery cams drop the api when they sleep, so an
814
- // api-scoped subscription would be dead by the next motion email.
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.
815
816
  private emailPushBusOff?: () => void;
816
817
 
817
818
  // Cooldown timestamps to prevent alignAuxDevicesState from overwriting recently set states
@@ -1563,6 +1564,15 @@ export class ReolinkCamera
1563
1564
  // throw new Error("IP Address is required for TCP devices");
1564
1565
  // }
1565
1566
 
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.
1575
+
1566
1576
  return {
1567
1577
  host: ipAddress,
1568
1578
  username,
@@ -2118,6 +2128,17 @@ export class ReolinkCamera
2118
2128
  this.processEvents({ motion, objects }).catch((e) => {
2119
2129
  logger.warn("Error processing events", e?.message || String(e));
2120
2130
  });
2131
+
2132
+ // Battery cams cache `lastPicture` aggressively because waking
2133
+ // the device for a snapshot is expensive. When motion fires the
2134
+ // cam is, by definition, awake right now — proactively refresh
2135
+ // the cache so the Snapshot mixin → MQTT image entity / HA
2136
+ // discovery serves the trigger frame on the next `takePicture`
2137
+ // call instead of the previous cached one. Wired cams don't
2138
+ // need this (Snapshot mixin always gets fresh data anyway).
2139
+ if (motion && this.isBattery && !this.multiFocalDevice) {
2140
+ void this.refreshSnapshotOnMotion(ev.timestamp ?? Date.now());
2141
+ }
2121
2142
  } catch (e) {
2122
2143
  logger.warn("Error in onSimpleEvent handler", e?.message || String(e));
2123
2144
  }
@@ -2741,13 +2762,17 @@ export class ReolinkCamera
2741
2762
  }
2742
2763
 
2743
2764
  /**
2744
- * Subscribe to the lib's global email-push bus and translate each
2745
- * matching event into a synthetic motion `ReolinkSimpleEvent` fed
2746
- * to this camera's existing `onSimpleEvent` handler so the same
2747
- * code path that flips `motionDetected` for native Baichuan push
2748
- * fires for SMTP-delivered alerts too. Lives on the camera (not on
2749
- * the api) so the subscription survives api closes for battery
2750
- * cams. Idempotent: returns early if already subscribed.
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`.
2751
2776
  */
2752
2777
  private async subscribeToEmailPushBus(): Promise<void> {
2753
2778
  if (this.emailPushBusOff) return;
@@ -2761,26 +2786,23 @@ export class ReolinkCamera
2761
2786
  );
2762
2787
  this.emailPushBusOff = onEmailPushEvent((event) => {
2763
2788
  if (event.cameraId !== ownNativeId) return;
2789
+ const channel = this.storageSettings.values.rtspChannel ?? 0;
2790
+ const mapped = mapEmailPushInferredType(event.inferredType);
2764
2791
  logger.log(
2765
- `E-mail Push event received (type=${event.inferredType}) → dispatching motion`,
2792
+ `E-mail Push event received (type=${event.inferredType}) → dispatching ${mapped}`,
2766
2793
  );
2767
2794
  try {
2768
2795
  this.onSimpleEvent({
2769
- type: mapEmailPushInferredType(event.inferredType),
2770
- channel: this.storageSettings.values.rtspChannel ?? 0,
2796
+ type: mapped,
2797
+ channel,
2771
2798
  timestamp: event.receivedAtMs,
2772
2799
  });
2773
- // Mirror the api-side fan-out: when the camera signalled an
2774
- // AI sub-type (people / vehicle / …), also raise a generic
2775
- // motion event so motion-only consumers light up.
2776
- if (
2777
- event.inferredType !== "motion" &&
2778
- event.inferredType !== "doorbell" &&
2779
- event.inferredType !== "other"
2780
- ) {
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") {
2781
2803
  this.onSimpleEvent({
2782
2804
  type: "motion",
2783
- channel: this.storageSettings.values.rtspChannel ?? 0,
2805
+ channel,
2784
2806
  timestamp: event.receivedAtMs,
2785
2807
  });
2786
2808
  }
@@ -2789,14 +2811,6 @@ export class ReolinkCamera
2789
2811
  `E-mail Push: onSimpleEvent dispatch threw: ${e?.message || String(e)}`,
2790
2812
  );
2791
2813
  }
2792
- // Fire-and-forget snapshot fetch; runs in parallel with the
2793
- // motion dispatch so consumers reading `takePicture` right
2794
- // after motion get the fresh frame.
2795
- void this.handleEmailPushSnapshot(event).catch((e) => {
2796
- logger.warn(
2797
- `E-mail Push: snapshot fetch threw: ${e?.message || String(e)}`,
2798
- );
2799
- });
2800
2814
  });
2801
2815
  logger.log("E-mail Push: subscribed to global bus");
2802
2816
  } catch (e) {
@@ -2816,34 +2830,37 @@ export class ReolinkCamera
2816
2830
  }
2817
2831
 
2818
2832
  /**
2819
- * Called from `subscribeToEmailPushBus` for every matching event.
2820
- * We don't use the e-mail's image attachment Reolink firmwares
2821
- * vary on quality/encoding and we'd be reimplementing the snapshot
2822
- * pipeline. Instead we leverage the fact that the camera is
2823
- * *guaranteed awake* right now (it just pushed an e-mail) and call
2824
- * the live Baichuan snapshot API: it returns a fresh frame in
2825
- * milliseconds without forcing an extra wake-up cycle. The result
2826
- * is stashed in `lastPicture` so the Snapshot mixin → MQTT image
2827
- * entity (and any other consumer that calls `takePicture()` on the
2828
- * back of the motion event) serves the trigger frame instead of
2829
- * the previous cached one.
2833
+ * Refresh the `lastPicture` cache by fetching a live snapshot via
2834
+ * the Baichuan API. Used on motion events for battery cameras: the
2835
+ * cam is guaranteed awake at that moment (just sent native push or
2836
+ * SMTP), so the round-trip is cheap, and downstream consumers
2837
+ * (Snapshot mixin MQTT image entity, HA discovery) reading
2838
+ * `takePicture()` on the back of the motion get the trigger frame
2839
+ * instead of the previous cached one. Fire-and-forget; per-camera
2840
+ * debounce so an event burst doesn't trigger overlapping fetches.
2830
2841
  */
2831
- private async handleEmailPushSnapshot(
2832
- event: EmailPushEvent,
2833
- ): Promise<void> {
2842
+ private lastMotionSnapshotAtMs = 0;
2843
+ private motionSnapshotInFlight = false;
2844
+ private async refreshSnapshotOnMotion(timestampMs: number): Promise<void> {
2845
+ if (this.motionSnapshotInFlight) return;
2846
+ if (timestampMs - this.lastMotionSnapshotAtMs < 5_000) return;
2847
+ this.motionSnapshotInFlight = true;
2848
+ this.lastMotionSnapshotAtMs = timestampMs;
2834
2849
  const logger = this.getBaichuanLogger();
2835
2850
  try {
2836
2851
  const client = await this.ensureClient();
2837
2852
  const mo = await this.takePictureInternal(client);
2838
- this.lastPicture = { mo, atMs: event.receivedAtMs };
2853
+ this.lastPicture = { mo, atMs: timestampMs };
2839
2854
  this.forceNewSnapshot = false;
2840
2855
  logger.log(
2841
- `E-mail Push snapshot fetched live (type=${event.inferredType}) — next takePicture will serve it`,
2856
+ `Motion snapshot refreshed — next takePicture will serve the trigger frame`,
2842
2857
  );
2843
2858
  } catch (e) {
2844
2859
  logger.warn(
2845
- `E-mail Push: live snapshot fetch failed: ${e?.message || String(e)}`,
2860
+ `Motion snapshot refresh failed: ${e?.message || String(e)}`,
2846
2861
  );
2862
+ } finally {
2863
+ this.motionSnapshotInFlight = false;
2847
2864
  }
2848
2865
  }
2849
2866
 
@@ -3675,9 +3692,10 @@ export class ReolinkCamera
3675
3692
 
3676
3693
  this.startPeriodicTasks();
3677
3694
 
3678
- // Subscribe BEFORE ensureClient so battery cams whose api fails to
3679
- // connect on first try still pick up incoming SMTP motion events.
3680
- // The subscription is idempotent safe across re-inits.
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.
3681
3699
  await this.subscribeToEmailPushBus();
3682
3700
 
3683
3701
  await this.ensureClient();
package/src/connect.ts CHANGED
@@ -14,6 +14,18 @@ export type BaichuanConnectInputs = {
14
14
  logger?: Console;
15
15
  debugOptions?: BaichuanClientOptions["debugOptions"];
16
16
  udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
17
+ /**
18
+ * When set, the lib api auto-bridges the global email-push bus into
19
+ * its own `simpleEventListeners`, so SMTP motion lands on the same
20
+ * `onSimpleEvent` stream as native Baichuan push. Bridge survives
21
+ * TCP transient disconnects (it's a pure JS fan-out). For Reolink
22
+ * cameras this should be the camera's plugin-side nativeId — the
23
+ * `EmailPushServerDevice` resolves `cam-<nativeId>@<domain>` against
24
+ * the same string, so the round-trip is symmetric.
25
+ */
26
+ emailPushCameraId?: string;
27
+ /** Channel reported on the synthesised event. Default 0. */
28
+ emailPushChannel?: number;
17
29
  };
18
30
 
19
31
  export function normalizeUid(uid?: string): string | undefined {
@@ -38,6 +50,21 @@ export async function createBaichuanApi(props: {
38
50
  debugOptions: inputs.debugOptions ?? {},
39
51
  };
40
52
 
53
+ // The lib's auto-bridge fields belong on the second positional arg
54
+ // of `new ReolinkBaichuanApi(opts)` alongside `nativeOnly` etc.; we
55
+ // build a small `extras` bag and spread it at construction time
56
+ // below for both tcp + udp paths.
57
+ const extras: {
58
+ emailPushCameraId?: string;
59
+ emailPushChannel?: number;
60
+ } = {};
61
+ if (inputs.emailPushCameraId) {
62
+ extras.emailPushCameraId = inputs.emailPushCameraId;
63
+ if (inputs.emailPushChannel !== undefined) {
64
+ extras.emailPushChannel = inputs.emailPushChannel;
65
+ }
66
+ }
67
+
41
68
  const attachErrorHandler = (api: ReolinkBaichuanApi) => {
42
69
  // Critical: BaichuanClient emits 'error'. If nobody listens, Node treats it as an
43
70
  // uncaught exception. Ensure we always have a listener.
@@ -75,6 +102,7 @@ export async function createBaichuanApi(props: {
75
102
  if (transport === "tcp") {
76
103
  const api = new ReolinkBaichuanApi({
77
104
  ...base,
105
+ ...extras,
78
106
  transport: "tcp",
79
107
  });
80
108
  attachErrorHandler(api);
@@ -88,6 +116,7 @@ export async function createBaichuanApi(props: {
88
116
 
89
117
  const api = new ReolinkBaichuanApi({
90
118
  ...base,
119
+ ...extras,
91
120
  transport: "udp",
92
121
  uid,
93
122
  // NOTE: idleDisconnect is NOT set here - the library handles it internally