@apocaliss92/nodelink-js 0.4.26 โ†’ 0.4.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/README.md CHANGED
@@ -39,13 +39,15 @@ await api.onSimpleEvent((event) => {
39
39
 
40
40
  ## Email Push for Battery Cameras
41
41
 
42
+ > ๐Ÿงช **Experimental** โ€” the feature is officially enabled but still under active testing. The default flow uses the manager's built-in SMTP server; expect rough edges and please report any issue you hit.
43
+
42
44
  Battery cameras (Argus, Go, โ€ฆ) can't reliably keep a TCP/ONVIF push subscription alive while sleeping. The manager app embeds an SMTP server so the camera can deliver motion alerts via email โ€” the most resilient path for sleep-heavy devices.
43
45
 
44
46
  **Flow**:
45
47
 
46
- 1. Enable the manager's built-in SMTP server (**Settings โ†’ Email Push**, default port `2525`).
48
+ 1. Enable the manager's built-in SMTP server (**Settings โ†’ Email Push**, default port `2525`). All settings (host, port, domain, auth) remain manually editable.
47
49
  2. Each camera gets a unique recipient `cam-<id>@<domain>` (`emailPush.getCameraAddress`).
48
- 3. From the camera's **Email Push** tab in the manager UI, click **Auto-configure** โ€” the manager pushes the right SMTP server, recipients and 24/7 schedule to the camera via Baichuan (`baichuan.setupEmailPushToManager`).
50
+ 3. From the camera's **Email Push** tab in the manager UI (Camera Settings modal), click **Auto-configure** โ€” the manager pushes the right SMTP server, recipients and 24/7 schedule to the camera via Baichuan (`baichuan.setupEmailPushToManager`). You can also fill the fields by hand in the Reolink app: server = your manager `domain`, sender = `authUsername`, password = `authPassword`, port = `2525`, TLS off, receiver = `cam-<id>@<domain>`.
49
51
  4. On motion, the camera sends an email. The manager parses it, classifies the trigger (people/vehicle/motion), saves the snapshot under `${DATA_PATH}/email-push/<cameraId>/`, and emits a synthetic motion event into the same bus used by native Baichuan push โ€” so MQTT, Home Assistant, Frigate, etc. see it transparently.
50
52
 
51
53
  See [documentation/baichuan-api/email.md](./documentation/baichuan-api/email.md) for the full API and [documentation/baichuan-api/time.md](./documentation/baichuan-api/time.md) for the related NTP / DST / system clock setters.
@@ -5317,6 +5317,21 @@ async function* createNativeStream(api, channel, profile, options) {
5317
5317
  let audioSampleRate = null;
5318
5318
  let streamStarted = false;
5319
5319
  let closed = false;
5320
+ const signal = options?.signal;
5321
+ let sleepResolve = null;
5322
+ let sleepTimer = null;
5323
+ const clearSleepTimer = () => {
5324
+ if (sleepTimer) {
5325
+ clearTimeout(sleepTimer);
5326
+ sleepTimer = null;
5327
+ }
5328
+ };
5329
+ const handleAbort = () => {
5330
+ clearSleepTimer();
5331
+ const r = sleepResolve;
5332
+ sleepResolve = null;
5333
+ r?.();
5334
+ };
5320
5335
  const onError = (_error) => {
5321
5336
  closed = true;
5322
5337
  api.logger?.warn?.(
@@ -5414,7 +5429,13 @@ async function* createNativeStream(api, channel, profile, options) {
5414
5429
  }
5415
5430
  });
5416
5431
  streamStarted = true;
5417
- const signal = options?.signal;
5432
+ if (signal) {
5433
+ if (signal.aborted) {
5434
+ closed = true;
5435
+ } else {
5436
+ signal.addEventListener("abort", handleAbort);
5437
+ }
5438
+ }
5418
5439
  while (!closed && !signal?.aborted) {
5419
5440
  if (frameQueue.length > 0) {
5420
5441
  const frame = frameQueue.shift();
@@ -5422,31 +5443,30 @@ async function* createNativeStream(api, channel, profile, options) {
5422
5443
  } else {
5423
5444
  await new Promise((resolve) => {
5424
5445
  frameResolve = resolve;
5425
- const timer = setTimeout(() => {
5446
+ sleepResolve = resolve;
5447
+ sleepTimer = setTimeout(() => {
5448
+ sleepTimer = null;
5449
+ if (sleepResolve === resolve) sleepResolve = null;
5426
5450
  if (frameResolve === resolve) {
5427
5451
  frameResolve = null;
5428
5452
  resolve();
5429
5453
  }
5430
5454
  }, 1e3);
5431
- if (signal) {
5432
- const onAbort = () => {
5433
- clearTimeout(timer);
5434
- if (frameResolve === resolve) frameResolve = null;
5435
- resolve();
5436
- };
5437
- if (signal.aborted) {
5438
- clearTimeout(timer);
5439
- frameResolve = null;
5440
- resolve();
5441
- } else {
5442
- signal.addEventListener("abort", onAbort, { once: true });
5443
- }
5455
+ if (signal?.aborted) {
5456
+ clearSleepTimer();
5457
+ sleepResolve = null;
5458
+ frameResolve = null;
5459
+ resolve();
5444
5460
  }
5445
5461
  });
5462
+ sleepResolve = null;
5463
+ clearSleepTimer();
5446
5464
  }
5447
5465
  }
5448
5466
  } finally {
5449
5467
  closed = true;
5468
+ if (signal) signal.removeEventListener("abort", handleAbort);
5469
+ clearSleepTimer();
5450
5470
  try {
5451
5471
  await videoStream.stop();
5452
5472
  } catch {
@@ -7483,12 +7503,13 @@ var BaichuanRtspServer = class _BaichuanRtspServer extends EventEmitter3 {
7483
7503
  if (frame.videoType === "H264" || frame.videoType === "H265") {
7484
7504
  this.setFlowVideoType(frame.videoType, "native stream");
7485
7505
  }
7486
- this.flow.extractParameterSets(frame.data);
7487
- const { hasParamSets } = this.flow.getFmtp();
7488
- if (hasParamSets) {
7506
+ if (!this.flow.getFmtp().hasParamSets) {
7507
+ this.flow.extractParameterSets(frame.data);
7508
+ }
7509
+ if (this.flow.getFmtp().hasParamSets) {
7489
7510
  this.markFirstFrameReceived();
7490
7511
  }
7491
- const isKeyframe = this.isRawFrameKeyframe(frame);
7512
+ const isKeyframe = typeof frame.isKeyframe === "boolean" ? frame.isKeyframe : this.isRawFrameKeyframe(frame);
7492
7513
  this.prebuffer.push({
7493
7514
  frame: { ...frame, data: Buffer.from(frame.data) },
7494
7515
  time: Date.now(),
@@ -8668,6 +8689,60 @@ function patchMotionSensitivityListXml(currentXml, bands) {
8668
8689
  );
8669
8690
  }
8670
8691
 
8692
+ // src/emailPush/bus.ts
8693
+ import { EventEmitter as EventEmitter4 } from "events";
8694
+ var emitter = new EventEmitter4();
8695
+ var cameraResolver = () => void 0;
8696
+ var lastEventByCamera = /* @__PURE__ */ new Map();
8697
+ var MAX_GLOBAL_EVENTS = 300;
8698
+ var globalRecentEvents = [];
8699
+ function setEmailPushCameraResolver(resolver) {
8700
+ cameraResolver = resolver;
8701
+ }
8702
+ function getEmailPushCameraResolver() {
8703
+ return cameraResolver;
8704
+ }
8705
+ function onEmailPushEvent(handler) {
8706
+ emitter.on("event", handler);
8707
+ return () => emitter.off("event", handler);
8708
+ }
8709
+ function emitEmailPushEvent(event) {
8710
+ lastEventByCamera.set(event.cameraId, event);
8711
+ globalRecentEvents.unshift(event);
8712
+ if (globalRecentEvents.length > MAX_GLOBAL_EVENTS) {
8713
+ globalRecentEvents.length = MAX_GLOBAL_EVENTS;
8714
+ }
8715
+ emitter.emit("event", event);
8716
+ }
8717
+ function getLastEmailPushEvent(cameraId) {
8718
+ return lastEventByCamera.get(cameraId);
8719
+ }
8720
+ function getRecentEmailPushEvents(limit = MAX_GLOBAL_EVENTS) {
8721
+ const clamped = Math.max(0, Math.min(limit, MAX_GLOBAL_EVENTS));
8722
+ return globalRecentEvents.slice(0, clamped);
8723
+ }
8724
+ function mapEmailPushInferredType(inferred) {
8725
+ switch (inferred) {
8726
+ case "motion":
8727
+ case "doorbell":
8728
+ case "people":
8729
+ case "vehicle":
8730
+ case "animal":
8731
+ case "face":
8732
+ case "package":
8733
+ return inferred;
8734
+ case "other":
8735
+ default:
8736
+ return "motion";
8737
+ }
8738
+ }
8739
+ function _resetEmailPushBusForTests() {
8740
+ emitter.removeAllListeners();
8741
+ cameraResolver = () => void 0;
8742
+ lastEventByCamera.clear();
8743
+ globalRecentEvents.length = 0;
8744
+ }
8745
+
8671
8746
  // src/reolink/baichuan/ReolinkBaichuanApi.ts
8672
8747
  import { spawn as spawn2 } from "child_process";
8673
8748
  import { mkdir } from "fs/promises";
@@ -11443,7 +11518,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11443
11518
  * general socket is created, logged in, and all event/push/guard listeners
11444
11519
  * are re-attached automatically.
11445
11520
  *
11446
- * This is a **no-op** when the API is already {@link isReady}.
11521
+ * This is a **no-op** when the API is already ready (see `isReadyState()`).
11447
11522
  *
11448
11523
  * @throws If `close()` was called โ€” the API is permanently closed and a new
11449
11524
  * instance must be created.
@@ -11524,7 +11599,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11524
11599
  /**
11525
11600
  * Attach event, push, channelInfo, and guard listeners to the current
11526
11601
  * "general" client. Called from the constructor and from
11527
- * {@link reconnectGeneralSocket}.
11602
+ * `reconnectGeneralSocket()`.
11528
11603
  */
11529
11604
  setupGeneralClientListeners() {
11530
11605
  const client = this.client;
@@ -13113,6 +13188,40 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13113
13188
  this.econnresetStormRebootInFlight = void 0;
13114
13189
  });
13115
13190
  }
13191
+ /**
13192
+ * Bind this API instance to the global email-push bus so that incoming
13193
+ * SMTP-delivered motion / AI events for the matching camera surface on
13194
+ * this instance's standard `onSimpleEvent` channel. The consumer keeps
13195
+ * a single subscription (`onSimpleEvent`) and gets both the native
13196
+ * Baichuan push and the email-push transport on the same stream.
13197
+ *
13198
+ * - `cameraId` shorthand: match events with `event.cameraId === cameraId`.
13199
+ * - `match`: arbitrary predicate (e.g. when the consumer uses a
13200
+ * nickname-based mapping or wants to handle multiple recipients).
13201
+ *
13202
+ * Returns an `off()` handle. Safe to call repeatedly โ€” each call
13203
+ * registers its own listener.
13204
+ */
13205
+ subscribeEmailPushEvents(params) {
13206
+ const channel = params.channel ?? 0;
13207
+ const matches = "match" in params ? params.match : (event) => event.cameraId === params.cameraId;
13208
+ const off = onEmailPushEvent((event) => {
13209
+ if (!matches(event)) return;
13210
+ this.dispatchSimpleEvent({
13211
+ type: mapEmailPushInferredType(event.inferredType),
13212
+ channel,
13213
+ timestamp: event.receivedAtMs
13214
+ });
13215
+ if (event.inferredType !== "motion" && event.inferredType !== "doorbell" && event.inferredType !== "other") {
13216
+ this.dispatchSimpleEvent({
13217
+ type: "motion",
13218
+ channel,
13219
+ timestamp: event.receivedAtMs
13220
+ });
13221
+ }
13222
+ });
13223
+ return off;
13224
+ }
13116
13225
  /**
13117
13226
  * Subscribe to minimal high-level events.
13118
13227
  * The API manages Baichuan subscribe/unsubscribe automatically.
@@ -13171,7 +13280,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13171
13280
  * Subscribe to per-frame detection events sourced from the BcMedia
13172
13281
  * `additionalHeader` block on active video streams.
13173
13282
  *
13174
- * Mirrors {@link onSimpleEvent} but is fed by the streaming side-channel:
13283
+ * Mirrors `onSimpleEvent()` but is fed by the streaming side-channel:
13175
13284
  * one event fires for every I-frame / P-frame that carries an overlay block.
13176
13285
  * Coordinates are reported in normalized [0, 1] fractions of the source
13177
13286
  * frame, so the same box renders correctly on mainStream, subStream, and
@@ -13198,7 +13307,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13198
13307
  * Subscribe to AI object detections (people / vehicle / animal / face boxes
13199
13308
  * with class label and confidence) without managing a video stream yourself.
13200
13309
  *
13201
- * Mirrors {@link onSimpleEvent} end-to-end: on the first listener for a given
13310
+ * Mirrors `onSimpleEvent()` end-to-end: on the first listener for a given
13202
13311
  * `(channel, profile)` tuple the API ensures the corresponding video stream
13203
13312
  * is running (the pool socket may already be shared with a regular consumer),
13204
13313
  * forwards every box-bearing `additionalHeader` to your callback, and tears
@@ -20042,7 +20151,7 @@ ${xml}`
20042
20151
  * Field meaning per stream:
20043
20152
  * - `audio` โ€” 0/1 toggle
20044
20153
  * - `width`/`height` โ€” resolution in pixels. Must be one of the
20045
- * resolutions returned by {@link getStreamInfoList}.
20154
+ * resolutions returned by `getStreamInfoList()`.
20046
20155
  * - `bitRate` โ€” kbps. Must match the table from `getStreamInfoList`.
20047
20156
  * - `frameRate` โ€” fps. Must match the table from `getStreamInfoList`.
20048
20157
  * - `videoEncType` โ€” `"h264"` or `"h265"`
@@ -21431,10 +21540,12 @@ ${xml}`
21431
21540
  const triggers = params.triggerTypes ?? ["MD", "people", "vehicle"];
21432
21541
  const attachmentType = params.attachmentType ?? "picture";
21433
21542
  const interval = params.interval ?? 30;
21543
+ const rawUser = params.authUsername ?? recipient;
21544
+ const wireUser = rawUser.includes("@") ? rawUser : `${rawUser}@${domain}`;
21434
21545
  const emailPatch = {
21435
21546
  smtpServer: params.managerHost,
21436
21547
  smtpPort: port,
21437
- userName: params.authUsername ?? recipient,
21548
+ userName: wireUser,
21438
21549
  password: params.authPassword ?? "",
21439
21550
  address1: recipient,
21440
21551
  address2: "",
@@ -24583,6 +24694,14 @@ export {
24583
24694
  patchAiDetectCfgXml,
24584
24695
  encodeMotionSensitivityListXml,
24585
24696
  patchMotionSensitivityListXml,
24697
+ setEmailPushCameraResolver,
24698
+ getEmailPushCameraResolver,
24699
+ onEmailPushEvent,
24700
+ emitEmailPushEvent,
24701
+ getLastEmailPushEvent,
24702
+ getRecentEmailPushEvents,
24703
+ mapEmailPushInferredType,
24704
+ _resetEmailPushBusForTests,
24586
24705
  DUAL_LENS_DUAL_MOTION_MODELS,
24587
24706
  DUAL_LENS_SINGLE_MOTION_MODELS,
24588
24707
  DUAL_LENS_MODELS,
@@ -24604,4 +24723,4 @@ export {
24604
24723
  isTcpFailureThatShouldFallbackToUdp,
24605
24724
  autoDetectDeviceType
24606
24725
  };
24607
- //# sourceMappingURL=chunk-F3XCYKYT.js.map
24726
+ //# sourceMappingURL=chunk-NQ7D5TLR.js.map