@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.
@@ -11851,6 +11851,14 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
11851
11851
  * even if the current client instance is idle/disconnected.
11852
11852
  */
11853
11853
  static streamingRegistry = /* @__PURE__ */ new Map();
11854
+ /**
11855
+ * Per-host D2C_DISC backoff state that persists across client instance recreation.
11856
+ *
11857
+ * Why: when a D2C_DISC kills a client, the socket pool destroys the old instance
11858
+ * and creates a new one. Instance-level backoff variables would reset to zero,
11859
+ * allowing immediate reconnection and perpetuating the storm.
11860
+ */
11861
+ static d2cDiscBackoff = /* @__PURE__ */ new Map();
11854
11862
  /**
11855
11863
  * Global (process-wide) CoverPreview serialization.
11856
11864
  *
@@ -12546,7 +12554,12 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12546
12554
  }
12547
12555
  async waitForUdpReconnectCooldown() {
12548
12556
  const now = Date.now();
12549
- const waitMs = this.udpReconnectCooldownUntilMs - now;
12557
+ const staticEntry = _BaichuanClient.d2cDiscBackoff.get(this.opts.host);
12558
+ const effectiveCooldownUntil = Math.max(
12559
+ this.udpReconnectCooldownUntilMs,
12560
+ staticEntry?.cooldownUntilMs ?? 0
12561
+ );
12562
+ const waitMs = effectiveCooldownUntil - now;
12550
12563
  if (waitMs <= 0) return;
12551
12564
  const sid = this.socketSessionId;
12552
12565
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -12555,7 +12568,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12555
12568
  host: this.opts.host,
12556
12569
  sid,
12557
12570
  uid: shortUid,
12558
- waitMs
12571
+ waitMs,
12572
+ persistent: staticEntry != null
12559
12573
  });
12560
12574
  await new Promise((resolve) => setTimeout(resolve, waitMs));
12561
12575
  }
@@ -12798,21 +12812,30 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12798
12812
  uid: shortUid2,
12799
12813
  message: err.message
12800
12814
  });
12801
- const withinWindow = now - this.udpReconnectLastD2cDiscAtMs < 6e4;
12815
+ const hostKey = this.opts.host;
12816
+ const prev = _BaichuanClient.d2cDiscBackoff.get(hostKey);
12817
+ const withinWindow = prev != null && now - prev.lastAtMs < 6e4;
12802
12818
  const baseMs = 2e3;
12803
12819
  const maxMs = 3e4;
12804
12820
  const nextBackoffMs = withinWindow ? Math.min(
12805
12821
  maxMs,
12806
12822
  Math.max(
12807
12823
  baseMs,
12808
- this.udpReconnectBackoffMs > 0 ? this.udpReconnectBackoffMs * 2 : baseMs
12824
+ prev.backoffMs > 0 ? prev.backoffMs * 2 : baseMs
12809
12825
  )
12810
12826
  ) : baseMs;
12811
- this.udpReconnectLastD2cDiscAtMs = now;
12812
- this.udpReconnectBackoffMs = nextBackoffMs;
12827
+ const cooldownUntilMs = Math.max(
12828
+ prev?.cooldownUntilMs ?? 0,
12829
+ now + nextBackoffMs
12830
+ );
12831
+ _BaichuanClient.d2cDiscBackoff.set(hostKey, {
12832
+ backoffMs: nextBackoffMs,
12833
+ lastAtMs: now,
12834
+ cooldownUntilMs
12835
+ });
12813
12836
  this.udpReconnectCooldownUntilMs = Math.max(
12814
12837
  this.udpReconnectCooldownUntilMs,
12815
- now + nextBackoffMs
12838
+ cooldownUntilMs
12816
12839
  );
12817
12840
  this.logDebug("d2c_disc_backoff", {
12818
12841
  transport: "udp",
@@ -12820,7 +12843,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12820
12843
  sid: sid2,
12821
12844
  uid: shortUid2,
12822
12845
  backoffMs: nextBackoffMs,
12823
- cooldownUntilMs: this.udpReconnectCooldownUntilMs
12846
+ cooldownUntilMs,
12847
+ persistent: true
12824
12848
  });
12825
12849
  this.stopKeepAlive();
12826
12850
  this.loggedIn = false;
@@ -12829,6 +12853,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12829
12853
  this.videoSubscriptions.clear();
12830
12854
  this.recomputeGlobalStreamingContribution();
12831
12855
  }
12856
+ this.emit("d2c_disc", { host: this.opts.host, atMs: now });
12832
12857
  }
12833
12858
  this.emit("error", err);
12834
12859
  });
@@ -13127,6 +13152,13 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
13127
13152
  }
13128
13153
  }
13129
13154
  this.emit("push", frame);
13155
+ if (frame.header.cmdId === 252 && frame.body.length > 0) {
13156
+ try {
13157
+ this.emit("batteryPush", frame);
13158
+ } catch (error) {
13159
+ this.logDebug("battery_push_error", error);
13160
+ }
13161
+ }
13130
13162
  if (frame.header.cmdId === 33) {
13131
13163
  try {
13132
13164
  const sid = this.socketSessionId;
@@ -17500,6 +17532,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17500
17532
  }
17501
17533
  }
17502
17534
  const newClient = new BaichuanClient(this.clientOptions);
17535
+ this.attachD2cDiscListener(newClient);
17503
17536
  this.socketPool.set("general", {
17504
17537
  client: newClient,
17505
17538
  refCount: 1,
@@ -17545,6 +17578,31 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17545
17578
  if (!mapped) return;
17546
17579
  this.dispatchSimpleEvent(mapped);
17547
17580
  });
17581
+ client.on("batteryPush", (frame) => {
17582
+ try {
17583
+ const xml = this.client.tryDecryptXml(
17584
+ frame.body,
17585
+ frame.header.channelId,
17586
+ this.client.enc
17587
+ );
17588
+ if (!xml) return;
17589
+ const channel = frame.header.channelId;
17590
+ const battery = this.parseBatteryInfoXml(xml, channel);
17591
+ if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
17592
+ this.dispatchSimpleEvent({
17593
+ type: "battery",
17594
+ channel,
17595
+ timestamp: Date.now(),
17596
+ battery
17597
+ });
17598
+ }
17599
+ } catch (e) {
17600
+ this.logger.debug?.(
17601
+ "[ReolinkBaichuanApi] Error parsing battery push",
17602
+ formatErrorForLog(e)
17603
+ );
17604
+ }
17605
+ });
17548
17606
  client.on("channelInfo", (xml) => {
17549
17607
  try {
17550
17608
  this.parseAndStoreChannelInfo(xml);
@@ -17680,6 +17738,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17680
17738
  rtspServers = /* @__PURE__ */ new Set();
17681
17739
  // Track all RTSP servers for cleanup
17682
17740
  activeVideoMsgNums = /* @__PURE__ */ new Map();
17741
+ // ─── D2C_DISC cooldown & storm detection ────────────────────────────────────
17742
+ // Tracked on the API instance (survives BaichuanClient recreation).
17743
+ /** Timestamp of the most recent D2C_DISC from any client for this device. */
17744
+ lastD2cDiscAtMs = 0;
17745
+ /** Sliding window of recent D2C_DISC timestamps for storm detection. */
17746
+ d2cDiscTimestamps = [];
17747
+ /** Immediate cooldown (ms) applied to socket pool on every D2C_DISC.
17748
+ * Prevents reconnect attempts while the camera is transitioning to sleep. */
17749
+ static D2C_DISC_IMMEDIATE_COOLDOWN_MS = 1e4;
17750
+ /** Number of D2C_DISCs within the storm window to trigger extended cooldown. */
17751
+ static D2C_DISC_STORM_THRESHOLD = 3;
17752
+ /** Sliding window size (ms) for storm detection. */
17753
+ static D2C_DISC_STORM_WINDOW_MS = 6e4;
17754
+ /** Extended cooldown (ms) applied to socket pool when a D2C_DISC storm is detected. */
17755
+ static D2C_DISC_STORM_COOLDOWN_MS = 12e4;
17683
17756
  nvrChannelsSummaryCache = /* @__PURE__ */ new Map();
17684
17757
  /**
17685
17758
  * Cached device capabilities per channel.
@@ -18093,6 +18166,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18093
18166
  }
18094
18167
  return "general";
18095
18168
  }
18169
+ /**
18170
+ * Attach a D2C_DISC listener to a BaichuanClient so that the API-level
18171
+ * grace period and storm detection are updated regardless of which
18172
+ * pool socket receives the disconnect.
18173
+ */
18174
+ attachD2cDiscListener(client) {
18175
+ client.on("d2c_disc", () => this.notifyD2cDisc());
18176
+ }
18096
18177
  /**
18097
18178
  * Acquire a socket from the pool by tag.
18098
18179
  * Creates a new socket if needed, or reuses an existing one.
@@ -18113,10 +18194,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18113
18194
  );
18114
18195
  } else if (now < cooldownEntry.cooldownUntil) {
18115
18196
  const remainingMs = cooldownEntry.cooldownUntil - now;
18197
+ const isD2cDisc = this.lastD2cDiscAtMs > 0 && now - this.lastD2cDiscAtMs < 12e4;
18198
+ const reason = isD2cDisc ? "D2C_DISC (camera sleeping)" : "repeated login failures";
18116
18199
  const error = new Error(
18117
- `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to repeated login failures. tag=${tag}`
18200
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to ${reason}. tag=${tag}`
18118
18201
  );
18119
- log?.warn?.(error.message);
18202
+ log?.debug?.(error.message);
18120
18203
  throw error;
18121
18204
  }
18122
18205
  }
@@ -18207,12 +18290,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18207
18290
  try {
18208
18291
  const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
18209
18292
  const newClient = new BaichuanClient(clientOpts);
18293
+ this.attachD2cDiscListener(newClient);
18210
18294
  await newClient.login();
18211
- if (this.socketPoolCooldowns.has(this.host)) {
18212
- log?.debug?.(
18213
- `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18214
- );
18215
- this.socketPoolCooldowns.delete(this.host);
18295
+ const existingCooldown = this.socketPoolCooldowns.get(this.host);
18296
+ if (existingCooldown) {
18297
+ const isStormCooldown = existingCooldown.failureCount >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD;
18298
+ if (!isStormCooldown) {
18299
+ log?.debug?.(
18300
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18301
+ );
18302
+ this.socketPoolCooldowns.delete(this.host);
18303
+ } else {
18304
+ log?.debug?.(
18305
+ `[SocketPool] Preserving D2C_DISC storm cooldown for host=${this.host} (expires in ${Math.ceil((existingCooldown.cooldownUntil - Date.now()) / 1e3)}s)`
18306
+ );
18307
+ }
18216
18308
  }
18217
18309
  entry.client = newClient;
18218
18310
  entry.refCount = 1;
@@ -18514,6 +18606,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18514
18606
  ...opts.channel !== void 0 ? { channel: opts.channel } : {}
18515
18607
  };
18516
18608
  const generalClient = new BaichuanClient(opts);
18609
+ this.attachD2cDiscListener(generalClient);
18517
18610
  this.socketPool.set("general", {
18518
18611
  client: generalClient,
18519
18612
  refCount: 1,
@@ -23578,6 +23671,49 @@ ${stderr}`)
23578
23671
  if (batteryVersion !== void 0) out.batteryVersion = batteryVersion;
23579
23672
  return out;
23580
23673
  }
23674
+ /**
23675
+ * Called when any BaichuanClient for this device receives a D2C_DISC.
23676
+ *
23677
+ * Two-tier response:
23678
+ * 1. **Immediate**: every D2C_DISC applies a short socket pool cooldown
23679
+ * (10 s) to prevent reconnect attempts while the camera transitions to sleep.
23680
+ * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
23681
+ */
23682
+ notifyD2cDisc() {
23683
+ const now = Date.now();
23684
+ this.lastD2cDiscAtMs = now;
23685
+ const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
23686
+ const existing = this.socketPoolCooldowns.get(this.host);
23687
+ if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
23688
+ this.socketPoolCooldowns.set(this.host, {
23689
+ failureCount: existing?.failureCount ?? 1,
23690
+ lastFailureAt: now,
23691
+ cooldownUntil: immediateCooldownUntil
23692
+ });
23693
+ this.logger?.log?.(
23694
+ `[D2C_DISC] Immediate cooldown: socket pool blocked for ${_ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS / 1e3}s`
23695
+ );
23696
+ }
23697
+ this.d2cDiscTimestamps.push(now);
23698
+ const cutoff = now - _ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS;
23699
+ while (this.d2cDiscTimestamps.length > 0 && this.d2cDiscTimestamps[0] < cutoff) {
23700
+ this.d2cDiscTimestamps.shift();
23701
+ }
23702
+ if (this.d2cDiscTimestamps.length >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD) {
23703
+ const stormCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS;
23704
+ const currentEntry = this.socketPoolCooldowns.get(this.host);
23705
+ if (!currentEntry || currentEntry.cooldownUntil < stormCooldownUntil) {
23706
+ this.socketPoolCooldowns.set(this.host, {
23707
+ failureCount: this.d2cDiscTimestamps.length,
23708
+ lastFailureAt: now,
23709
+ cooldownUntil: stormCooldownUntil
23710
+ });
23711
+ this.logger?.warn?.(
23712
+ `[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`
23713
+ );
23714
+ }
23715
+ }
23716
+ }
23581
23717
  /**
23582
23718
  * Best-effort sleeping inference for battery/BCUDP cameras.
23583
23719
  *
@@ -23608,6 +23744,8 @@ ${stderr}`)
23608
23744
  const socketConnected = this.client.isSocketConnected?.() ?? false;
23609
23745
  const now = Date.now();
23610
23746
  const cutoff = now - windowMs;
23747
+ const msSinceD2cDisc = now - this.lastD2cDiscAtMs;
23748
+ const recentD2cDisc = this.lastD2cDiscAtMs > 0 && msSinceD2cDisc < 3e4;
23611
23749
  const rx = (this.client.getRxHistory?.() ?? []).filter(
23612
23750
  (h) => h.atMs >= cutoff
23613
23751
  );
@@ -23615,6 +23753,12 @@ ${stderr}`)
23615
23753
  (h) => h.atMs >= cutoff
23616
23754
  );
23617
23755
  if (rx.length === 0 && tx.length === 0) {
23756
+ if (recentD2cDisc) {
23757
+ return {
23758
+ state: "sleeping",
23759
+ reason: `D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
23760
+ };
23761
+ }
23618
23762
  return {
23619
23763
  state: "sleeping",
23620
23764
  reason: `no rx/tx activity in last ${windowMs}ms${socketConnected ? "" : " (socket disconnected)"}`,
@@ -23638,6 +23782,12 @@ ${stderr}`)
23638
23782
  idleMs: now - firstWakingTx.atMs
23639
23783
  };
23640
23784
  }
23785
+ if (recentD2cDisc) {
23786
+ return {
23787
+ state: "sleeping",
23788
+ reason: `only non-waking cmdIds + D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
23789
+ };
23790
+ }
23641
23791
  return {
23642
23792
  state: "sleeping",
23643
23793
  reason: `only non-waking cmdIds observed in last ${windowMs}ms (non-waking: ${Array.from(nonWakingCmdIds).join(",")})`,