@apocaliss92/scrypted-reolink-native 0.5.25 → 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.25",
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"
@@ -1,6 +1,5 @@
1
1
  import type {
2
2
  BaichuanClientOptions,
3
- EmailPushEvent,
4
3
  ReolinkBaichuanApi,
5
4
  ReolinkSimpleEvent,
6
5
  } from "@apocaliss92/nodelink-js" with { "resolution-mode": "import" };
@@ -16,6 +15,13 @@ export interface BaichuanConnectionConfig {
16
15
  transport: BaichuanTransport;
17
16
  debugOptions?: any;
18
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;
19
25
  }
20
26
 
21
27
  export interface BaichuanConnectionCallbacks {
@@ -23,26 +29,6 @@ export interface BaichuanConnectionCallbacks {
23
29
  onClose?: () => void | Promise<void>;
24
30
  onSimpleEvent?: (ev: ReolinkSimpleEvent) => void;
25
31
  getEventSubscriptionEnabled?: () => boolean;
26
- /**
27
- * When provided, the base class binds the camera's api to the global
28
- * email-push bus filtered on `cameraId === emailPushCameraId()`. The
29
- * lib's `subscribeEmailPushEvents` converts each matching event into
30
- * a `ReolinkSimpleEvent` and dispatches it through `onSimpleEvent`,
31
- * so the camera's existing motion / AI handler lights up for SMTP-
32
- * delivered events with no extra wiring. Standalone cameras only —
33
- * leave undefined on NVR children where email-push isn't meaningful.
34
- */
35
- emailPushCameraId?: () => string;
36
- /**
37
- * Optional. When the email-push event carries an image attachment
38
- * (typical for `attachmentType=picture` on motion), the camera
39
- * receives the full event so it can republish the snapshot —
40
- * usually by updating its `lastPicture` cache so subsequent
41
- * `takePicture()` calls return the fresh thumbnail without waking
42
- * the camera. Invoked AFTER the simple-event dispatch so any motion
43
- * listener has already fired.
44
- */
45
- onEmailPushEvent?: (event: EmailPushEvent) => void;
46
32
  }
47
33
 
48
34
  /**
@@ -207,7 +193,6 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
207
193
  private eventSubscriptionActive: boolean = false;
208
194
  private lastEventTime: number = 0;
209
195
  private currentWrappedEventHandler?: (ev: ReolinkSimpleEvent) => void;
210
- private currentEmailPushOff?: () => void;
211
196
  private subscribeToEventsPromise?: Promise<void>;
212
197
  private pingInterval?: NodeJS.Timeout;
213
198
  private autoRenewInterval?: NodeJS.Timeout;
@@ -371,6 +356,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
371
356
  logger,
372
357
  debugOptions: config.debugOptions,
373
358
  udpDiscoveryMethod: config.udpDiscoveryMethod,
359
+ ...(config.emailPushCameraId
360
+ ? { emailPushCameraId: config.emailPushCameraId }
361
+ : {}),
374
362
  },
375
363
  transport: config.transport,
376
364
  });
@@ -904,33 +892,11 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
904
892
  // the library watchdog handles auto-recovery internally.
905
893
  await api.onSimpleEvent(this.currentWrappedEventHandler);
906
894
 
907
- // Bridge the global email-push bus into this api's onSimpleEvent
908
- // stream so the same wrapped handler above (and any other
909
- // listener) sees SMTP-delivered motion exactly like a native push.
910
- // Idempotent: if a previous off-handle is still around, release it.
911
- if (callbacks.emailPushCameraId) {
912
- if (this.currentEmailPushOff) {
913
- try {
914
- this.currentEmailPushOff();
915
- } catch {}
916
- this.currentEmailPushOff = undefined;
917
- }
918
- try {
919
- this.currentEmailPushOff = api.subscribeEmailPushEvents({
920
- cameraId: callbacks.emailPushCameraId(),
921
- channel: 0,
922
- ...(callbacks.onEmailPushEvent
923
- ? { onEvent: callbacks.onEmailPushEvent }
924
- : {}),
925
- });
926
- logger.debug("Bridged email-push bus to onSimpleEvent");
927
- } catch (e) {
928
- logger.warn(
929
- "Failed to bridge email-push events",
930
- e?.message || String(e),
931
- );
932
- }
933
- }
895
+ // NOTE: the email-push bus subscription used to live here, but
896
+ // it's now owned by `ReolinkCamera.subscribeToEmailPushBus()`
897
+ // because the bus is global and must outlive the Baichuan api —
898
+ // battery cams routinely drop the api between motions, which
899
+ // would silently drop SMTP events with an api-scoped bridge.
934
900
 
935
901
  this.eventSubscriptionActive = true;
936
902
  this.lastEventTime = Date.now(); // Initialize on subscription
@@ -961,12 +927,6 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
961
927
  // api.close() destroys the pool before the promise settles.
962
928
  await this.baichuanApi.offSimpleEvent(this.currentWrappedEventHandler);
963
929
  this.currentWrappedEventHandler = undefined;
964
- if (this.currentEmailPushOff) {
965
- try {
966
- this.currentEmailPushOff();
967
- } catch {}
968
- this.currentEmailPushOff = undefined;
969
- }
970
930
  logger.debug("Unsubscribed from Baichuan events");
971
931
  } catch (e) {
972
932
  logger.warn("Error unsubscribing from events", e?.message || String(e));
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,
@@ -1558,6 +1557,14 @@ export class ReolinkCamera
1558
1557
  // throw new Error("IP Address is required for TCP devices");
1559
1558
  // }
1560
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
+
1561
1568
  return {
1562
1569
  host: ipAddress,
1563
1570
  username,
@@ -1568,6 +1575,9 @@ export class ReolinkCamera
1568
1575
  udpDiscoveryMethod: discoveryMethod,
1569
1576
  // NOTE: idleDisconnect is NOT set here - the library handles it internally
1570
1577
  // based on the battery status detected during connection
1578
+ ...(eligibleForEmailPush && this.nativeId
1579
+ ? { emailPushCameraId: this.nativeId }
1580
+ : {}),
1571
1581
  };
1572
1582
  }
1573
1583
 
@@ -1609,20 +1619,12 @@ export class ReolinkCamera
1609
1619
  onSimpleEvent: this.onSimpleEventBound,
1610
1620
  getEventSubscriptionEnabled: () =>
1611
1621
  this.isEventDispatchEnabled?.() ?? false,
1612
- // Bind this api to the global email-push bus so battery cameras
1613
- // delivering motion via SMTP land on the same `onSimpleEvent`
1614
- // stream as the native push. The Email Push Server device
1615
- // (singleton under the provider) maps `cam-<nativeId>@<domain>`
1616
- // RCPT addresses back to this `nativeId`.
1617
- emailPushCameraId: () => this.nativeId ?? "",
1618
- // When the e-mail carries the snapshot Reolink sends with
1619
- // `attachmentType=picture`, hand it to the camera's `lastPicture`
1620
- // cache. Downstream consumers (Snapshot mixin → MQTT image
1621
- // entity, Advanced Notifier, HA discovery) call `takePicture()`
1622
- // on the back of the motion event; for battery cams that returns
1623
- // the cached value, so dropping the fresh snapshot in here makes
1624
- // the trigger frame surface in MQTT/HA without waking the camera.
1625
- onEmailPushEvent: (event) => this.handleEmailPushSnapshot(event),
1622
+ // Email-push events are wired in `init()` directly against the
1623
+ // lib's global bus NOT through the api here, because for
1624
+ // battery cams the Baichuan api lifetime is much shorter than
1625
+ // the camera lifetime (api closes when the cam sleeps), so an
1626
+ // api-level subscription would be dead by the time the next
1627
+ // motion email arrives. See `subscribeToEmailPushBus()`.
1626
1628
  };
1627
1629
  }
1628
1630
 
@@ -2121,6 +2123,17 @@ export class ReolinkCamera
2121
2123
  this.processEvents({ motion, objects }).catch((e) => {
2122
2124
  logger.warn("Error processing events", e?.message || String(e));
2123
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
+ }
2124
2137
  } catch (e) {
2125
2138
  logger.warn("Error in onSimpleEvent handler", e?.message || String(e));
2126
2139
  }
@@ -2744,34 +2757,37 @@ export class ReolinkCamera
2744
2757
  }
2745
2758
 
2746
2759
  /**
2747
- * Called from `baichuan-base.subscribeToEvents` when a per-camera
2748
- * email-push event lands on the bus. We don't use the e-mail's image
2749
- * attachment Reolink firmwares vary on quality/encoding and we'd
2750
- * be reimplementing the snapshot pipeline. Instead we leverage the
2751
- * fact that the camera is *guaranteed awake* right now (it just
2752
- * pushed an e-mail) and call the live Baichuan snapshot API: it
2753
- * returns a fresh frame in milliseconds without forcing an extra
2754
- * wake-up cycle. The result is stashed in `lastPicture` so the
2755
- * Snapshot mixin → MQTT image entity (and any other consumer that
2756
- * calls `takePicture()` on the back of the motion event) serves the
2757
- * trigger frame instead of 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.
2758
2768
  */
2759
- private async handleEmailPushSnapshot(
2760
- event: EmailPushEvent,
2761
- ): 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;
2762
2776
  const logger = this.getBaichuanLogger();
2763
2777
  try {
2764
2778
  const client = await this.ensureClient();
2765
2779
  const mo = await this.takePictureInternal(client);
2766
- this.lastPicture = { mo, atMs: event.receivedAtMs };
2780
+ this.lastPicture = { mo, atMs: timestampMs };
2767
2781
  this.forceNewSnapshot = false;
2768
2782
  logger.log(
2769
- `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`,
2770
2784
  );
2771
2785
  } catch (e) {
2772
2786
  logger.warn(
2773
- `E-mail Push: live snapshot fetch failed: ${e?.message || String(e)}`,
2787
+ `Motion snapshot refresh failed: ${e?.message || String(e)}`,
2774
2788
  );
2789
+ } finally {
2790
+ this.motionSnapshotInFlight = false;
2775
2791
  }
2776
2792
  }
2777
2793
 
@@ -2933,6 +2949,8 @@ export class ReolinkCamera
2933
2949
  this.statusPollTimer && clearInterval(this.statusPollTimer);
2934
2950
  this.sleepCheckTimer && clearInterval(this.sleepCheckTimer);
2935
2951
  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.
2936
2954
  this.resetBaichuanClient();
2937
2955
  this.plugin.camerasMap.delete(this.id);
2938
2956
  }
@@ -3601,6 +3619,13 @@ export class ReolinkCamera
3601
3619
  }
3602
3620
 
3603
3621
  this.startPeriodicTasks();
3622
+
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.
3628
+
3604
3629
  await this.ensureClient();
3605
3630
 
3606
3631
  try {
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