@apocaliss92/nodelink-js 0.4.24 โ†’ 0.4.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/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";
@@ -11265,6 +11340,39 @@ var applyFloodlightSettingsToXml = (xml, settings) => {
11265
11340
  return modifiedXml;
11266
11341
  };
11267
11342
 
11343
+ // src/reolink/baichuan/utils/whiteLedStatusPush.ts
11344
+ var parseFloodlightStatusListPushXml = (xml) => {
11345
+ const out = [];
11346
+ const re = /<channel>\s*(\d+)\s*<\/channel>[\s\S]*?<status>\s*(\d+)\s*<\/status>/gi;
11347
+ let m;
11348
+ while ((m = re.exec(xml)) !== null) {
11349
+ const channel = Number.parseInt(m[1] ?? "", 10);
11350
+ const status = Number.parseInt(m[2] ?? "", 10);
11351
+ if (!Number.isFinite(channel) || !Number.isFinite(status)) continue;
11352
+ out.push({ channel, status });
11353
+ }
11354
+ return out;
11355
+ };
11356
+
11357
+ // src/reolink/baichuan/utils/sirenStatusPush.ts
11358
+ var parseSirenStatusListPushXml = (xml) => {
11359
+ const out = [];
11360
+ const re = /<(?:channelId|channel)>\s*(\d+)\s*<\/(?:channelId|channel)>[\s\S]*?<status>\s*(\d+)\s*<\/status>(?:[\s\S]*?<playing>\s*(\d+)\s*<\/playing>)?/gi;
11361
+ let m;
11362
+ while ((m = re.exec(xml)) !== null) {
11363
+ const channel = Number.parseInt(m[1] ?? "", 10);
11364
+ const status = Number.parseInt(m[2] ?? "", 10);
11365
+ if (!Number.isFinite(channel) || !Number.isFinite(status)) continue;
11366
+ const entry = { channel, status };
11367
+ if (m[3] !== void 0) {
11368
+ const playing = Number.parseInt(m[3], 10);
11369
+ if (Number.isFinite(playing)) entry.playing = playing;
11370
+ }
11371
+ out.push(entry);
11372
+ }
11373
+ return out;
11374
+ };
11375
+
11268
11376
  // src/reolink/baichuan/ReolinkBaichuanApi.ts
11269
11377
  var DUAL_LENS_DUAL_MOTION_MODELS = /* @__PURE__ */ new Set([
11270
11378
  "Reolink Duo PoE",
@@ -11410,7 +11518,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11410
11518
  * general socket is created, logged in, and all event/push/guard listeners
11411
11519
  * are re-attached automatically.
11412
11520
  *
11413
- * 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()`).
11414
11522
  *
11415
11523
  * @throws If `close()` was called โ€” the API is permanently closed and a new
11416
11524
  * instance must be created.
@@ -11491,7 +11599,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11491
11599
  /**
11492
11600
  * Attach event, push, channelInfo, and guard listeners to the current
11493
11601
  * "general" client. Called from the constructor and from
11494
- * {@link reconnectGeneralSocket}.
11602
+ * `reconnectGeneralSocket()`.
11495
11603
  */
11496
11604
  setupGeneralClientListeners() {
11497
11605
  const client = this.client;
@@ -11543,7 +11651,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11543
11651
  });
11544
11652
  client.on("push", (frame) => {
11545
11653
  const cmdId = frame.header.cmdId;
11546
- if (cmdId !== BC_CMD_ID_PUSH_VIDEO_INPUT && cmdId !== BC_CMD_ID_PUSH_SERIAL && cmdId !== BC_CMD_ID_PUSH_NET_INFO && cmdId !== BC_CMD_ID_PUSH_DINGDONG_LIST && cmdId !== BC_CMD_ID_PUSH_SLEEP_STATUS && cmdId !== BC_CMD_ID_PUSH_COORDINATE_POINT_LIST) {
11654
+ if (cmdId !== BC_CMD_ID_PUSH_VIDEO_INPUT && cmdId !== BC_CMD_ID_PUSH_SERIAL && cmdId !== BC_CMD_ID_PUSH_NET_INFO && cmdId !== BC_CMD_ID_PUSH_DINGDONG_LIST && cmdId !== BC_CMD_ID_PUSH_SLEEP_STATUS && cmdId !== BC_CMD_ID_PUSH_COORDINATE_POINT_LIST && cmdId !== BC_CMD_ID_FLOODLIGHT_STATUS_LIST && cmdId !== BC_CMD_ID_GET_AUDIO_ALARM) {
11547
11655
  return;
11548
11656
  }
11549
11657
  try {
@@ -13080,6 +13188,40 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13080
13188
  this.econnresetStormRebootInFlight = void 0;
13081
13189
  });
13082
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
+ }
13083
13225
  /**
13084
13226
  * Subscribe to minimal high-level events.
13085
13227
  * The API manages Baichuan subscribe/unsubscribe automatically.
@@ -13138,7 +13280,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13138
13280
  * Subscribe to per-frame detection events sourced from the BcMedia
13139
13281
  * `additionalHeader` block on active video streams.
13140
13282
  *
13141
- * Mirrors {@link onSimpleEvent} but is fed by the streaming side-channel:
13283
+ * Mirrors `onSimpleEvent()` but is fed by the streaming side-channel:
13142
13284
  * one event fires for every I-frame / P-frame that carries an overlay block.
13143
13285
  * Coordinates are reported in normalized [0, 1] fractions of the source
13144
13286
  * frame, so the same box renders correctly on mainStream, subStream, and
@@ -13165,7 +13307,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
13165
13307
  * Subscribe to AI object detections (people / vehicle / animal / face boxes
13166
13308
  * with class label and confidence) without managing a video stream yourself.
13167
13309
  *
13168
- * 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
13169
13311
  * `(channel, profile)` tuple the API ensures the corresponding video stream
13170
13312
  * is running (the pool socket may already be shared with a regular consumer),
13171
13313
  * forwards every box-bearing `additionalHeader` to your callback, and tears
@@ -20009,7 +20151,7 @@ ${xml}`
20009
20151
  * Field meaning per stream:
20010
20152
  * - `audio` โ€” 0/1 toggle
20011
20153
  * - `width`/`height` โ€” resolution in pixels. Must be one of the
20012
- * resolutions returned by {@link getStreamInfoList}.
20154
+ * resolutions returned by `getStreamInfoList()`.
20013
20155
  * - `bitRate` โ€” kbps. Must match the table from `getStreamInfoList`.
20014
20156
  * - `frameRate` โ€” fps. Must match the table from `getStreamInfoList`.
20015
20157
  * - `videoEncType` โ€” `"h264"` or `"h265"`
@@ -20584,6 +20726,33 @@ ${xml}`
20584
20726
  };
20585
20727
  return;
20586
20728
  }
20729
+ if (cmdId === BC_CMD_ID_FLOODLIGHT_STATUS_LIST) {
20730
+ const entries = parseFloodlightStatusListPushXml(xml);
20731
+ if (entries.length === 0) return;
20732
+ for (const entry of entries) {
20733
+ const channel = normalizePushChannel(entry.channel) ?? channelFromHeader;
20734
+ getEntry(channel).floodlightStatus = {
20735
+ updatedAtMs: now,
20736
+ value: { status: entry.status === 1 }
20737
+ };
20738
+ }
20739
+ return;
20740
+ }
20741
+ if (cmdId === BC_CMD_ID_GET_AUDIO_ALARM) {
20742
+ const entries = parseSirenStatusListPushXml(xml);
20743
+ if (entries.length === 0) return;
20744
+ for (const entry of entries) {
20745
+ const channel = normalizePushChannel(entry.channel) ?? channelFromHeader;
20746
+ getEntry(channel).sirenStatus = {
20747
+ updatedAtMs: now,
20748
+ value: {
20749
+ status: entry.status === 1,
20750
+ ...entry.playing !== void 0 ? { playing: entry.playing === 1 } : {}
20751
+ }
20752
+ };
20753
+ }
20754
+ return;
20755
+ }
20587
20756
  }
20588
20757
  /** Read-only snapshot of cached settings pushes (cmd_id 78/79/464/484/623/723). */
20589
20758
  getSettingsPushCacheSnapshot() {
@@ -20616,6 +20785,18 @@ ${xml}`
20616
20785
  ...entry.coordinatePointList,
20617
20786
  value: { ...entry.coordinatePointList.value }
20618
20787
  }
20788
+ } : {},
20789
+ ...entry.floodlightStatus ? {
20790
+ floodlightStatus: {
20791
+ ...entry.floodlightStatus,
20792
+ value: { ...entry.floodlightStatus.value }
20793
+ }
20794
+ } : {},
20795
+ ...entry.sirenStatus ? {
20796
+ sirenStatus: {
20797
+ ...entry.sirenStatus,
20798
+ value: { ...entry.sirenStatus.value }
20799
+ }
20619
20800
  } : {}
20620
20801
  });
20621
20802
  }
@@ -20639,6 +20820,29 @@ ${xml}`
20639
20820
  getCoordinatePointListFromPushCache(channel = 0) {
20640
20821
  return this.settingsPushCache.get(channel)?.coordinatePointList;
20641
20822
  }
20823
+ /**
20824
+ * Last cmd_id 291 (FloodlightStatusList) push observed for the channel.
20825
+ * The camera emits this whenever the floodlight transitions on/off,
20826
+ * including the auto-off after the FloodlightManual duration. This is
20827
+ * the only reliable source for the current manual state because cmd 289
20828
+ * only returns the FloodlightTask config.
20829
+ *
20830
+ * Returns undefined when no push has been received yet.
20831
+ */
20832
+ getCachedFloodlightStatus(channel = 0) {
20833
+ return this.settingsPushCache.get(channel)?.floodlightStatus;
20834
+ }
20835
+ /**
20836
+ * Last cmd_id 547 (SirenStatusList) push observed for the channel.
20837
+ * Captures the actual on/off transitions including the firmware's
20838
+ * built-in auto-off after the siren playback duration expires โ€”
20839
+ * polling cmd 547 alone can race that auto-off.
20840
+ *
20841
+ * Returns undefined when no push has been received yet.
20842
+ */
20843
+ getCachedSirenStatus(channel = 0) {
20844
+ return this.settingsPushCache.get(channel)?.sirenStatus;
20845
+ }
20642
20846
  // --------------------
20643
20847
  // PCAP-derived settings getters (typed wrappers)
20644
20848
  // --------------------
@@ -21336,10 +21540,12 @@ ${xml}`
21336
21540
  const triggers = params.triggerTypes ?? ["MD", "people", "vehicle"];
21337
21541
  const attachmentType = params.attachmentType ?? "picture";
21338
21542
  const interval = params.interval ?? 30;
21543
+ const rawUser = params.authUsername ?? recipient;
21544
+ const wireUser = rawUser.includes("@") ? rawUser : `${rawUser}@${domain}`;
21339
21545
  const emailPatch = {
21340
21546
  smtpServer: params.managerHost,
21341
21547
  smtpPort: port,
21342
- userName: params.authUsername ?? recipient,
21548
+ userName: wireUser,
21343
21549
  password: params.authPassword ?? "",
21344
21550
  address1: recipient,
21345
21551
  address2: "",
@@ -24488,6 +24694,14 @@ export {
24488
24694
  patchAiDetectCfgXml,
24489
24695
  encodeMotionSensitivityListXml,
24490
24696
  patchMotionSensitivityListXml,
24697
+ setEmailPushCameraResolver,
24698
+ getEmailPushCameraResolver,
24699
+ onEmailPushEvent,
24700
+ emitEmailPushEvent,
24701
+ getLastEmailPushEvent,
24702
+ getRecentEmailPushEvents,
24703
+ mapEmailPushInferredType,
24704
+ _resetEmailPushBusForTests,
24491
24705
  DUAL_LENS_DUAL_MOTION_MODELS,
24492
24706
  DUAL_LENS_SINGLE_MOTION_MODELS,
24493
24707
  DUAL_LENS_MODELS,
@@ -24509,4 +24723,4 @@ export {
24509
24723
  isTcpFailureThatShouldFallbackToUdp,
24510
24724
  autoDetectDeviceType
24511
24725
  };
24512
- //# sourceMappingURL=chunk-JYHK2ZSH.js.map
24726
+ //# sourceMappingURL=chunk-NQ7D5TLR.js.map