@apocaliss92/scrypted-reolink-native 0.5.26 → 0.5.27

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.27",
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,
@@ -808,11 +807,6 @@ export class ReolinkCamera
808
807
  private batteryUpdateTimer: NodeJS.Timeout | undefined;
809
808
  private periodicStarted = false;
810
809
  private statusPollTimer: NodeJS.Timeout | undefined;
811
- // 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.
815
- private emailPushBusOff?: () => void;
816
810
 
817
811
  // Cooldown timestamps to prevent alignAuxDevicesState from overwriting recently set states
818
812
  // Camera can take 10+ seconds to reflect state changes in its API response
@@ -1563,6 +1557,14 @@ export class ReolinkCamera
1563
1557
  // throw new Error("IP Address is required for TCP devices");
1564
1558
  // }
1565
1559
 
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
+
1566
1568
  return {
1567
1569
  host: ipAddress,
1568
1570
  username,
@@ -1573,6 +1575,9 @@ export class ReolinkCamera
1573
1575
  udpDiscoveryMethod: discoveryMethod,
1574
1576
  // NOTE: idleDisconnect is NOT set here - the library handles it internally
1575
1577
  // based on the battery status detected during connection
1578
+ ...(eligibleForEmailPush && this.nativeId
1579
+ ? { emailPushCameraId: this.nativeId }
1580
+ : {}),
1576
1581
  };
1577
1582
  }
1578
1583
 
@@ -2118,6 +2123,17 @@ export class ReolinkCamera
2118
2123
  this.processEvents({ motion, objects }).catch((e) => {
2119
2124
  logger.warn("Error processing events", e?.message || String(e));
2120
2125
  });
2126
+
2127
+ // Battery cams cache `lastPicture` aggressively because waking
2128
+ // the device for a snapshot is expensive. When motion fires the
2129
+ // cam is, by definition, awake right now — proactively refresh
2130
+ // the cache so the Snapshot mixin → MQTT image entity / HA
2131
+ // discovery serves the trigger frame on the next `takePicture`
2132
+ // call instead of the previous cached one. Wired cams don't
2133
+ // need this (Snapshot mixin always gets fresh data anyway).
2134
+ if (motion && this.isBattery && !this.multiFocalDevice) {
2135
+ void this.refreshSnapshotOnMotion(ev.timestamp ?? Date.now());
2136
+ }
2121
2137
  } catch (e) {
2122
2138
  logger.warn("Error in onSimpleEvent handler", e?.message || String(e));
2123
2139
  }
@@ -2741,109 +2757,37 @@ export class ReolinkCamera
2741
2757
  }
2742
2758
 
2743
2759
  /**
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.
2751
- */
2752
- private async subscribeToEmailPushBus(): Promise<void> {
2753
- if (this.emailPushBusOff) return;
2754
- if (this.isOnNvr || this.multiFocalDevice) return;
2755
- const ownNativeId = this.nativeId ?? "";
2756
- if (!ownNativeId) return;
2757
- const logger = this.getBaichuanLogger();
2758
- try {
2759
- const { onEmailPushEvent, mapEmailPushInferredType } = await import(
2760
- "@apocaliss92/nodelink-js"
2761
- );
2762
- this.emailPushBusOff = onEmailPushEvent((event) => {
2763
- if (event.cameraId !== ownNativeId) return;
2764
- logger.log(
2765
- `E-mail Push event received (type=${event.inferredType}) → dispatching motion`,
2766
- );
2767
- try {
2768
- this.onSimpleEvent({
2769
- type: mapEmailPushInferredType(event.inferredType),
2770
- channel: this.storageSettings.values.rtspChannel ?? 0,
2771
- timestamp: event.receivedAtMs,
2772
- });
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
- ) {
2781
- this.onSimpleEvent({
2782
- type: "motion",
2783
- channel: this.storageSettings.values.rtspChannel ?? 0,
2784
- timestamp: event.receivedAtMs,
2785
- });
2786
- }
2787
- } catch (e) {
2788
- logger.warn(
2789
- `E-mail Push: onSimpleEvent dispatch threw: ${e?.message || String(e)}`,
2790
- );
2791
- }
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
- });
2801
- logger.log("E-mail Push: subscribed to global bus");
2802
- } catch (e) {
2803
- logger.warn(
2804
- `E-mail Push: bus subscribe failed: ${e?.message || String(e)}`,
2805
- );
2806
- }
2807
- }
2808
-
2809
- private unsubscribeFromEmailPushBus(): void {
2810
- if (this.emailPushBusOff) {
2811
- try {
2812
- this.emailPushBusOff();
2813
- } catch {}
2814
- this.emailPushBusOff = undefined;
2815
- }
2816
- }
2817
-
2818
- /**
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.
2760
+ * Refresh the `lastPicture` cache by fetching a live snapshot via
2761
+ * the Baichuan API. Used on motion events for battery cameras: the
2762
+ * cam is guaranteed awake at that moment (just sent native push or
2763
+ * SMTP), so the round-trip is cheap, and downstream consumers
2764
+ * (Snapshot mixin MQTT image entity, HA discovery) reading
2765
+ * `takePicture()` on the back of the motion get the trigger frame
2766
+ * instead of the previous cached one. Fire-and-forget; per-camera
2767
+ * debounce so an event burst doesn't trigger overlapping fetches.
2830
2768
  */
2831
- private async handleEmailPushSnapshot(
2832
- event: EmailPushEvent,
2833
- ): Promise<void> {
2769
+ private lastMotionSnapshotAtMs = 0;
2770
+ private motionSnapshotInFlight = false;
2771
+ private async refreshSnapshotOnMotion(timestampMs: number): Promise<void> {
2772
+ if (this.motionSnapshotInFlight) return;
2773
+ if (timestampMs - this.lastMotionSnapshotAtMs < 5_000) return;
2774
+ this.motionSnapshotInFlight = true;
2775
+ this.lastMotionSnapshotAtMs = timestampMs;
2834
2776
  const logger = this.getBaichuanLogger();
2835
2777
  try {
2836
2778
  const client = await this.ensureClient();
2837
2779
  const mo = await this.takePictureInternal(client);
2838
- this.lastPicture = { mo, atMs: event.receivedAtMs };
2780
+ this.lastPicture = { mo, atMs: timestampMs };
2839
2781
  this.forceNewSnapshot = false;
2840
2782
  logger.log(
2841
- `E-mail Push snapshot fetched live (type=${event.inferredType}) — next takePicture will serve it`,
2783
+ `Motion snapshot refreshed — next takePicture will serve the trigger frame`,
2842
2784
  );
2843
2785
  } catch (e) {
2844
2786
  logger.warn(
2845
- `E-mail Push: live snapshot fetch failed: ${e?.message || String(e)}`,
2787
+ `Motion snapshot refresh failed: ${e?.message || String(e)}`,
2846
2788
  );
2789
+ } finally {
2790
+ this.motionSnapshotInFlight = false;
2847
2791
  }
2848
2792
  }
2849
2793
 
@@ -3005,7 +2949,8 @@ export class ReolinkCamera
3005
2949
  this.statusPollTimer && clearInterval(this.statusPollTimer);
3006
2950
  this.sleepCheckTimer && clearInterval(this.sleepCheckTimer);
3007
2951
  this.batteryUpdateTimer && clearInterval(this.batteryUpdateTimer);
3008
- this.unsubscribeFromEmailPushBus();
2952
+ // The lib's email-push auto-bridge is released by `api.close()`
2953
+ // inside `resetBaichuanClient` — no separate teardown needed.
3009
2954
  this.resetBaichuanClient();
3010
2955
  this.plugin.camerasMap.delete(this.id);
3011
2956
  }
@@ -3675,10 +3620,11 @@ export class ReolinkCamera
3675
3620
 
3676
3621
  this.startPeriodicTasks();
3677
3622
 
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.
3681
- await this.subscribeToEmailPushBus();
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.
3682
3628
 
3683
3629
  await this.ensureClient();
3684
3630
 
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