@apocaliss92/nodelink-js 0.4.3 → 0.4.5

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
@@ -93,6 +93,8 @@ Devices with captured fixtures (verified API compatibility):
93
93
  | --- | --- | --- |
94
94
  | E1 Outdoor PoE | Wired camera | v3.1.0.5223 |
95
95
  | E1 Zoom | Wired camera (H.265, PTZ) | v3.2.0.4741 |
96
+ | RLC-810A | Wired camera (8MP) | v3.1.0.1162 |
97
+ | B400 | Wired camera (4MP) | v3.0.0.183 |
96
98
  | Argus 3E | Battery camera (via Home Hub) | v3.0.0.3623 |
97
99
  | Argus PT Ultra | Battery camera with PTZ (via Home Hub) | v3.0.0.3911 |
98
100
  | Reolink Home Hub | NVR / Hub | v3.3.0.456 |
@@ -1806,6 +1806,14 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1806
1806
  * even if the current client instance is idle/disconnected.
1807
1807
  */
1808
1808
  static streamingRegistry = /* @__PURE__ */ new Map();
1809
+ /**
1810
+ * Per-host D2C_DISC backoff state that persists across client instance recreation.
1811
+ *
1812
+ * Why: when a D2C_DISC kills a client, the socket pool destroys the old instance
1813
+ * and creates a new one. Instance-level backoff variables would reset to zero,
1814
+ * allowing immediate reconnection and perpetuating the storm.
1815
+ */
1816
+ static d2cDiscBackoff = /* @__PURE__ */ new Map();
1809
1817
  /**
1810
1818
  * Global (process-wide) CoverPreview serialization.
1811
1819
  *
@@ -2501,7 +2509,12 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2501
2509
  }
2502
2510
  async waitForUdpReconnectCooldown() {
2503
2511
  const now = Date.now();
2504
- const waitMs = this.udpReconnectCooldownUntilMs - now;
2512
+ const staticEntry = _BaichuanClient.d2cDiscBackoff.get(this.opts.host);
2513
+ const effectiveCooldownUntil = Math.max(
2514
+ this.udpReconnectCooldownUntilMs,
2515
+ staticEntry?.cooldownUntilMs ?? 0
2516
+ );
2517
+ const waitMs = effectiveCooldownUntil - now;
2505
2518
  if (waitMs <= 0) return;
2506
2519
  const sid = this.socketSessionId;
2507
2520
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -2510,7 +2523,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2510
2523
  host: this.opts.host,
2511
2524
  sid,
2512
2525
  uid: shortUid,
2513
- waitMs
2526
+ waitMs,
2527
+ persistent: staticEntry != null
2514
2528
  });
2515
2529
  await new Promise((resolve) => setTimeout(resolve, waitMs));
2516
2530
  }
@@ -2753,21 +2767,30 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2753
2767
  uid: shortUid2,
2754
2768
  message: err.message
2755
2769
  });
2756
- const withinWindow = now - this.udpReconnectLastD2cDiscAtMs < 6e4;
2770
+ const hostKey = this.opts.host;
2771
+ const prev = _BaichuanClient.d2cDiscBackoff.get(hostKey);
2772
+ const withinWindow = prev != null && now - prev.lastAtMs < 6e4;
2757
2773
  const baseMs = 2e3;
2758
2774
  const maxMs = 3e4;
2759
2775
  const nextBackoffMs = withinWindow ? Math.min(
2760
2776
  maxMs,
2761
2777
  Math.max(
2762
2778
  baseMs,
2763
- this.udpReconnectBackoffMs > 0 ? this.udpReconnectBackoffMs * 2 : baseMs
2779
+ prev.backoffMs > 0 ? prev.backoffMs * 2 : baseMs
2764
2780
  )
2765
2781
  ) : baseMs;
2766
- this.udpReconnectLastD2cDiscAtMs = now;
2767
- this.udpReconnectBackoffMs = nextBackoffMs;
2782
+ const cooldownUntilMs = Math.max(
2783
+ prev?.cooldownUntilMs ?? 0,
2784
+ now + nextBackoffMs
2785
+ );
2786
+ _BaichuanClient.d2cDiscBackoff.set(hostKey, {
2787
+ backoffMs: nextBackoffMs,
2788
+ lastAtMs: now,
2789
+ cooldownUntilMs
2790
+ });
2768
2791
  this.udpReconnectCooldownUntilMs = Math.max(
2769
2792
  this.udpReconnectCooldownUntilMs,
2770
- now + nextBackoffMs
2793
+ cooldownUntilMs
2771
2794
  );
2772
2795
  this.logDebug("d2c_disc_backoff", {
2773
2796
  transport: "udp",
@@ -2775,7 +2798,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2775
2798
  sid: sid2,
2776
2799
  uid: shortUid2,
2777
2800
  backoffMs: nextBackoffMs,
2778
- cooldownUntilMs: this.udpReconnectCooldownUntilMs
2801
+ cooldownUntilMs,
2802
+ persistent: true
2779
2803
  });
2780
2804
  this.stopKeepAlive();
2781
2805
  this.loggedIn = false;
@@ -2784,6 +2808,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2784
2808
  this.videoSubscriptions.clear();
2785
2809
  this.recomputeGlobalStreamingContribution();
2786
2810
  }
2811
+ this.emit("d2c_disc", { host: this.opts.host, atMs: now });
2787
2812
  }
2788
2813
  this.emit("error", err);
2789
2814
  });
@@ -3082,6 +3107,13 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
3082
3107
  }
3083
3108
  }
3084
3109
  this.emit("push", frame);
3110
+ if (frame.header.cmdId === 252 && frame.body.length > 0) {
3111
+ try {
3112
+ this.emit("batteryPush", frame);
3113
+ } catch (error) {
3114
+ this.logDebug("battery_push_error", error);
3115
+ }
3116
+ }
3085
3117
  if (frame.header.cmdId === 33) {
3086
3118
  try {
3087
3119
  const sid = this.socketSessionId;
@@ -10149,6 +10181,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10149
10181
  }
10150
10182
  }
10151
10183
  const newClient = new BaichuanClient(this.clientOptions);
10184
+ this.attachD2cDiscListener(newClient);
10152
10185
  this.socketPool.set("general", {
10153
10186
  client: newClient,
10154
10187
  refCount: 1,
@@ -10194,6 +10227,31 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10194
10227
  if (!mapped) return;
10195
10228
  this.dispatchSimpleEvent(mapped);
10196
10229
  });
10230
+ client.on("batteryPush", (frame) => {
10231
+ try {
10232
+ const xml = this.client.tryDecryptXml(
10233
+ frame.body,
10234
+ frame.header.channelId,
10235
+ this.client.enc
10236
+ );
10237
+ if (!xml) return;
10238
+ const channel = frame.header.channelId;
10239
+ const battery = this.parseBatteryInfoXml(xml, channel);
10240
+ if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
10241
+ this.dispatchSimpleEvent({
10242
+ type: "battery",
10243
+ channel,
10244
+ timestamp: Date.now(),
10245
+ battery
10246
+ });
10247
+ }
10248
+ } catch (e) {
10249
+ this.logger.debug?.(
10250
+ "[ReolinkBaichuanApi] Error parsing battery push",
10251
+ formatErrorForLog(e)
10252
+ );
10253
+ }
10254
+ });
10197
10255
  client.on("channelInfo", (xml) => {
10198
10256
  try {
10199
10257
  this.parseAndStoreChannelInfo(xml);
@@ -10329,6 +10387,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10329
10387
  rtspServers = /* @__PURE__ */ new Set();
10330
10388
  // Track all RTSP servers for cleanup
10331
10389
  activeVideoMsgNums = /* @__PURE__ */ new Map();
10390
+ // ─── D2C_DISC cooldown & storm detection ────────────────────────────────────
10391
+ // Tracked on the API instance (survives BaichuanClient recreation).
10392
+ /** Timestamp of the most recent D2C_DISC from any client for this device. */
10393
+ lastD2cDiscAtMs = 0;
10394
+ /** Sliding window of recent D2C_DISC timestamps for storm detection. */
10395
+ d2cDiscTimestamps = [];
10396
+ /** Immediate cooldown (ms) applied to socket pool on every D2C_DISC.
10397
+ * Prevents reconnect attempts while the camera is transitioning to sleep. */
10398
+ static D2C_DISC_IMMEDIATE_COOLDOWN_MS = 1e4;
10399
+ /** Number of D2C_DISCs within the storm window to trigger extended cooldown. */
10400
+ static D2C_DISC_STORM_THRESHOLD = 3;
10401
+ /** Sliding window size (ms) for storm detection. */
10402
+ static D2C_DISC_STORM_WINDOW_MS = 6e4;
10403
+ /** Extended cooldown (ms) applied to socket pool when a D2C_DISC storm is detected. */
10404
+ static D2C_DISC_STORM_COOLDOWN_MS = 12e4;
10332
10405
  nvrChannelsSummaryCache = /* @__PURE__ */ new Map();
10333
10406
  /**
10334
10407
  * Cached device capabilities per channel.
@@ -10742,6 +10815,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10742
10815
  }
10743
10816
  return "general";
10744
10817
  }
10818
+ /**
10819
+ * Attach a D2C_DISC listener to a BaichuanClient so that the API-level
10820
+ * grace period and storm detection are updated regardless of which
10821
+ * pool socket receives the disconnect.
10822
+ */
10823
+ attachD2cDiscListener(client) {
10824
+ client.on("d2c_disc", () => this.notifyD2cDisc());
10825
+ }
10745
10826
  /**
10746
10827
  * Acquire a socket from the pool by tag.
10747
10828
  * Creates a new socket if needed, or reuses an existing one.
@@ -10762,10 +10843,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10762
10843
  );
10763
10844
  } else if (now < cooldownEntry.cooldownUntil) {
10764
10845
  const remainingMs = cooldownEntry.cooldownUntil - now;
10846
+ const isD2cDisc = this.lastD2cDiscAtMs > 0 && now - this.lastD2cDiscAtMs < 12e4;
10847
+ const reason = isD2cDisc ? "D2C_DISC (camera sleeping)" : "repeated login failures";
10765
10848
  const error = new Error(
10766
- `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to repeated login failures. tag=${tag}`
10849
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to ${reason}. tag=${tag}`
10767
10850
  );
10768
- log?.warn?.(error.message);
10851
+ log?.debug?.(error.message);
10769
10852
  throw error;
10770
10853
  }
10771
10854
  }
@@ -10856,12 +10939,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10856
10939
  try {
10857
10940
  const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
10858
10941
  const newClient = new BaichuanClient(clientOpts);
10942
+ this.attachD2cDiscListener(newClient);
10859
10943
  await newClient.login();
10860
- if (this.socketPoolCooldowns.has(this.host)) {
10861
- log?.debug?.(
10862
- `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
10863
- );
10864
- this.socketPoolCooldowns.delete(this.host);
10944
+ const existingCooldown = this.socketPoolCooldowns.get(this.host);
10945
+ if (existingCooldown) {
10946
+ const isStormCooldown = existingCooldown.failureCount >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD;
10947
+ if (!isStormCooldown) {
10948
+ log?.debug?.(
10949
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
10950
+ );
10951
+ this.socketPoolCooldowns.delete(this.host);
10952
+ } else {
10953
+ log?.debug?.(
10954
+ `[SocketPool] Preserving D2C_DISC storm cooldown for host=${this.host} (expires in ${Math.ceil((existingCooldown.cooldownUntil - Date.now()) / 1e3)}s)`
10955
+ );
10956
+ }
10865
10957
  }
10866
10958
  entry.client = newClient;
10867
10959
  entry.refCount = 1;
@@ -11163,6 +11255,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11163
11255
  ...opts.channel !== void 0 ? { channel: opts.channel } : {}
11164
11256
  };
11165
11257
  const generalClient = new BaichuanClient(opts);
11258
+ this.attachD2cDiscListener(generalClient);
11166
11259
  this.socketPool.set("general", {
11167
11260
  client: generalClient,
11168
11261
  refCount: 1,
@@ -16227,6 +16320,49 @@ ${stderr}`)
16227
16320
  if (batteryVersion !== void 0) out.batteryVersion = batteryVersion;
16228
16321
  return out;
16229
16322
  }
16323
+ /**
16324
+ * Called when any BaichuanClient for this device receives a D2C_DISC.
16325
+ *
16326
+ * Two-tier response:
16327
+ * 1. **Immediate**: every D2C_DISC applies a short socket pool cooldown
16328
+ * (10 s) to prevent reconnect attempts while the camera transitions to sleep.
16329
+ * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
16330
+ */
16331
+ notifyD2cDisc() {
16332
+ const now = Date.now();
16333
+ this.lastD2cDiscAtMs = now;
16334
+ const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
16335
+ const existing = this.socketPoolCooldowns.get(this.host);
16336
+ if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
16337
+ this.socketPoolCooldowns.set(this.host, {
16338
+ failureCount: existing?.failureCount ?? 1,
16339
+ lastFailureAt: now,
16340
+ cooldownUntil: immediateCooldownUntil
16341
+ });
16342
+ this.logger?.log?.(
16343
+ `[D2C_DISC] Immediate cooldown: socket pool blocked for ${_ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS / 1e3}s`
16344
+ );
16345
+ }
16346
+ this.d2cDiscTimestamps.push(now);
16347
+ const cutoff = now - _ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS;
16348
+ while (this.d2cDiscTimestamps.length > 0 && this.d2cDiscTimestamps[0] < cutoff) {
16349
+ this.d2cDiscTimestamps.shift();
16350
+ }
16351
+ if (this.d2cDiscTimestamps.length >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD) {
16352
+ const stormCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS;
16353
+ const currentEntry = this.socketPoolCooldowns.get(this.host);
16354
+ if (!currentEntry || currentEntry.cooldownUntil < stormCooldownUntil) {
16355
+ this.socketPoolCooldowns.set(this.host, {
16356
+ failureCount: this.d2cDiscTimestamps.length,
16357
+ lastFailureAt: now,
16358
+ cooldownUntil: stormCooldownUntil
16359
+ });
16360
+ this.logger?.warn?.(
16361
+ `[D2C_DISC] Storm detected: ${this.d2cDiscTimestamps.length} disconnects in ${_ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS / 1e3}s \u2192 socket pool cooldown ${_ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS / 1e3}s`
16362
+ );
16363
+ }
16364
+ }
16365
+ }
16230
16366
  /**
16231
16367
  * Best-effort sleeping inference for battery/BCUDP cameras.
16232
16368
  *
@@ -16257,6 +16393,8 @@ ${stderr}`)
16257
16393
  const socketConnected = this.client.isSocketConnected?.() ?? false;
16258
16394
  const now = Date.now();
16259
16395
  const cutoff = now - windowMs;
16396
+ const msSinceD2cDisc = now - this.lastD2cDiscAtMs;
16397
+ const recentD2cDisc = this.lastD2cDiscAtMs > 0 && msSinceD2cDisc < 3e4;
16260
16398
  const rx = (this.client.getRxHistory?.() ?? []).filter(
16261
16399
  (h) => h.atMs >= cutoff
16262
16400
  );
@@ -16264,6 +16402,12 @@ ${stderr}`)
16264
16402
  (h) => h.atMs >= cutoff
16265
16403
  );
16266
16404
  if (rx.length === 0 && tx.length === 0) {
16405
+ if (recentD2cDisc) {
16406
+ return {
16407
+ state: "sleeping",
16408
+ reason: `D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
16409
+ };
16410
+ }
16267
16411
  return {
16268
16412
  state: "sleeping",
16269
16413
  reason: `no rx/tx activity in last ${windowMs}ms${socketConnected ? "" : " (socket disconnected)"}`,
@@ -16287,6 +16431,12 @@ ${stderr}`)
16287
16431
  idleMs: now - firstWakingTx.atMs
16288
16432
  };
16289
16433
  }
16434
+ if (recentD2cDisc) {
16435
+ return {
16436
+ state: "sleeping",
16437
+ reason: `only non-waking cmdIds + D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
16438
+ };
16439
+ }
16290
16440
  return {
16291
16441
  state: "sleeping",
16292
16442
  reason: `only non-waking cmdIds observed in last ${windowMs}ms (non-waking: ${Array.from(nonWakingCmdIds).join(",")})`,
@@ -21580,4 +21730,4 @@ export {
21580
21730
  isTcpFailureThatShouldFallbackToUdp,
21581
21731
  autoDetectDeviceType
21582
21732
  };
21583
- //# sourceMappingURL=chunk-UHFJPQA4.js.map
21733
+ //# sourceMappingURL=chunk-WDFKIHM5.js.map