@apocaliss92/nodelink-js 0.4.4 → 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.
@@ -3,7 +3,7 @@ import {
3
3
  BaichuanRtspServer,
4
4
  ReolinkBaichuanApi,
5
5
  autoDetectDeviceType
6
- } from "../chunk-UHFJPQA4.js";
6
+ } from "../chunk-WDFKIHM5.js";
7
7
  import {
8
8
  __require
9
9
  } from "../chunk-DEOMUWBN.js";
package/dist/index.cjs CHANGED
@@ -9682,6 +9682,14 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
9682
9682
  * even if the current client instance is idle/disconnected.
9683
9683
  */
9684
9684
  static streamingRegistry = /* @__PURE__ */ new Map();
9685
+ /**
9686
+ * Per-host D2C_DISC backoff state that persists across client instance recreation.
9687
+ *
9688
+ * Why: when a D2C_DISC kills a client, the socket pool destroys the old instance
9689
+ * and creates a new one. Instance-level backoff variables would reset to zero,
9690
+ * allowing immediate reconnection and perpetuating the storm.
9691
+ */
9692
+ static d2cDiscBackoff = /* @__PURE__ */ new Map();
9685
9693
  /**
9686
9694
  * Global (process-wide) CoverPreview serialization.
9687
9695
  *
@@ -10377,7 +10385,12 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10377
10385
  }
10378
10386
  async waitForUdpReconnectCooldown() {
10379
10387
  const now = Date.now();
10380
- const waitMs = this.udpReconnectCooldownUntilMs - now;
10388
+ const staticEntry = _BaichuanClient.d2cDiscBackoff.get(this.opts.host);
10389
+ const effectiveCooldownUntil = Math.max(
10390
+ this.udpReconnectCooldownUntilMs,
10391
+ staticEntry?.cooldownUntilMs ?? 0
10392
+ );
10393
+ const waitMs = effectiveCooldownUntil - now;
10381
10394
  if (waitMs <= 0) return;
10382
10395
  const sid = this.socketSessionId;
10383
10396
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -10386,7 +10399,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10386
10399
  host: this.opts.host,
10387
10400
  sid,
10388
10401
  uid: shortUid,
10389
- waitMs
10402
+ waitMs,
10403
+ persistent: staticEntry != null
10390
10404
  });
10391
10405
  await new Promise((resolve) => setTimeout(resolve, waitMs));
10392
10406
  }
@@ -10629,21 +10643,30 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10629
10643
  uid: shortUid2,
10630
10644
  message: err.message
10631
10645
  });
10632
- const withinWindow = now - this.udpReconnectLastD2cDiscAtMs < 6e4;
10646
+ const hostKey = this.opts.host;
10647
+ const prev = _BaichuanClient.d2cDiscBackoff.get(hostKey);
10648
+ const withinWindow = prev != null && now - prev.lastAtMs < 6e4;
10633
10649
  const baseMs = 2e3;
10634
10650
  const maxMs = 3e4;
10635
10651
  const nextBackoffMs = withinWindow ? Math.min(
10636
10652
  maxMs,
10637
10653
  Math.max(
10638
10654
  baseMs,
10639
- this.udpReconnectBackoffMs > 0 ? this.udpReconnectBackoffMs * 2 : baseMs
10655
+ prev.backoffMs > 0 ? prev.backoffMs * 2 : baseMs
10640
10656
  )
10641
10657
  ) : baseMs;
10642
- this.udpReconnectLastD2cDiscAtMs = now;
10643
- this.udpReconnectBackoffMs = nextBackoffMs;
10658
+ const cooldownUntilMs = Math.max(
10659
+ prev?.cooldownUntilMs ?? 0,
10660
+ now + nextBackoffMs
10661
+ );
10662
+ _BaichuanClient.d2cDiscBackoff.set(hostKey, {
10663
+ backoffMs: nextBackoffMs,
10664
+ lastAtMs: now,
10665
+ cooldownUntilMs
10666
+ });
10644
10667
  this.udpReconnectCooldownUntilMs = Math.max(
10645
10668
  this.udpReconnectCooldownUntilMs,
10646
- now + nextBackoffMs
10669
+ cooldownUntilMs
10647
10670
  );
10648
10671
  this.logDebug("d2c_disc_backoff", {
10649
10672
  transport: "udp",
@@ -10651,7 +10674,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10651
10674
  sid: sid2,
10652
10675
  uid: shortUid2,
10653
10676
  backoffMs: nextBackoffMs,
10654
- cooldownUntilMs: this.udpReconnectCooldownUntilMs
10677
+ cooldownUntilMs,
10678
+ persistent: true
10655
10679
  });
10656
10680
  this.stopKeepAlive();
10657
10681
  this.loggedIn = false;
@@ -10660,6 +10684,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10660
10684
  this.videoSubscriptions.clear();
10661
10685
  this.recomputeGlobalStreamingContribution();
10662
10686
  }
10687
+ this.emit("d2c_disc", { host: this.opts.host, atMs: now });
10663
10688
  }
10664
10689
  this.emit("error", err);
10665
10690
  });
@@ -10958,6 +10983,13 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10958
10983
  }
10959
10984
  }
10960
10985
  this.emit("push", frame);
10986
+ if (frame.header.cmdId === 252 && frame.body.length > 0) {
10987
+ try {
10988
+ this.emit("batteryPush", frame);
10989
+ } catch (error) {
10990
+ this.logDebug("battery_push_error", error);
10991
+ }
10992
+ }
10961
10993
  if (frame.header.cmdId === 33) {
10962
10994
  try {
10963
10995
  const sid = this.socketSessionId;
@@ -18077,6 +18109,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18077
18109
  }
18078
18110
  }
18079
18111
  const newClient = new BaichuanClient(this.clientOptions);
18112
+ this.attachD2cDiscListener(newClient);
18080
18113
  this.socketPool.set("general", {
18081
18114
  client: newClient,
18082
18115
  refCount: 1,
@@ -18122,6 +18155,31 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18122
18155
  if (!mapped) return;
18123
18156
  this.dispatchSimpleEvent(mapped);
18124
18157
  });
18158
+ client.on("batteryPush", (frame) => {
18159
+ try {
18160
+ const xml = this.client.tryDecryptXml(
18161
+ frame.body,
18162
+ frame.header.channelId,
18163
+ this.client.enc
18164
+ );
18165
+ if (!xml) return;
18166
+ const channel = frame.header.channelId;
18167
+ const battery = this.parseBatteryInfoXml(xml, channel);
18168
+ if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
18169
+ this.dispatchSimpleEvent({
18170
+ type: "battery",
18171
+ channel,
18172
+ timestamp: Date.now(),
18173
+ battery
18174
+ });
18175
+ }
18176
+ } catch (e) {
18177
+ this.logger.debug?.(
18178
+ "[ReolinkBaichuanApi] Error parsing battery push",
18179
+ formatErrorForLog(e)
18180
+ );
18181
+ }
18182
+ });
18125
18183
  client.on("channelInfo", (xml) => {
18126
18184
  try {
18127
18185
  this.parseAndStoreChannelInfo(xml);
@@ -18257,6 +18315,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18257
18315
  rtspServers = /* @__PURE__ */ new Set();
18258
18316
  // Track all RTSP servers for cleanup
18259
18317
  activeVideoMsgNums = /* @__PURE__ */ new Map();
18318
+ // ─── D2C_DISC cooldown & storm detection ────────────────────────────────────
18319
+ // Tracked on the API instance (survives BaichuanClient recreation).
18320
+ /** Timestamp of the most recent D2C_DISC from any client for this device. */
18321
+ lastD2cDiscAtMs = 0;
18322
+ /** Sliding window of recent D2C_DISC timestamps for storm detection. */
18323
+ d2cDiscTimestamps = [];
18324
+ /** Immediate cooldown (ms) applied to socket pool on every D2C_DISC.
18325
+ * Prevents reconnect attempts while the camera is transitioning to sleep. */
18326
+ static D2C_DISC_IMMEDIATE_COOLDOWN_MS = 1e4;
18327
+ /** Number of D2C_DISCs within the storm window to trigger extended cooldown. */
18328
+ static D2C_DISC_STORM_THRESHOLD = 3;
18329
+ /** Sliding window size (ms) for storm detection. */
18330
+ static D2C_DISC_STORM_WINDOW_MS = 6e4;
18331
+ /** Extended cooldown (ms) applied to socket pool when a D2C_DISC storm is detected. */
18332
+ static D2C_DISC_STORM_COOLDOWN_MS = 12e4;
18260
18333
  nvrChannelsSummaryCache = /* @__PURE__ */ new Map();
18261
18334
  /**
18262
18335
  * Cached device capabilities per channel.
@@ -18670,6 +18743,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18670
18743
  }
18671
18744
  return "general";
18672
18745
  }
18746
+ /**
18747
+ * Attach a D2C_DISC listener to a BaichuanClient so that the API-level
18748
+ * grace period and storm detection are updated regardless of which
18749
+ * pool socket receives the disconnect.
18750
+ */
18751
+ attachD2cDiscListener(client) {
18752
+ client.on("d2c_disc", () => this.notifyD2cDisc());
18753
+ }
18673
18754
  /**
18674
18755
  * Acquire a socket from the pool by tag.
18675
18756
  * Creates a new socket if needed, or reuses an existing one.
@@ -18690,10 +18771,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18690
18771
  );
18691
18772
  } else if (now < cooldownEntry.cooldownUntil) {
18692
18773
  const remainingMs = cooldownEntry.cooldownUntil - now;
18774
+ const isD2cDisc = this.lastD2cDiscAtMs > 0 && now - this.lastD2cDiscAtMs < 12e4;
18775
+ const reason = isD2cDisc ? "D2C_DISC (camera sleeping)" : "repeated login failures";
18693
18776
  const error = new Error(
18694
- `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to repeated login failures. tag=${tag}`
18777
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to ${reason}. tag=${tag}`
18695
18778
  );
18696
- log?.warn?.(error.message);
18779
+ log?.debug?.(error.message);
18697
18780
  throw error;
18698
18781
  }
18699
18782
  }
@@ -18784,12 +18867,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18784
18867
  try {
18785
18868
  const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
18786
18869
  const newClient = new BaichuanClient(clientOpts);
18870
+ this.attachD2cDiscListener(newClient);
18787
18871
  await newClient.login();
18788
- if (this.socketPoolCooldowns.has(this.host)) {
18789
- log?.debug?.(
18790
- `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18791
- );
18792
- this.socketPoolCooldowns.delete(this.host);
18872
+ const existingCooldown = this.socketPoolCooldowns.get(this.host);
18873
+ if (existingCooldown) {
18874
+ const isStormCooldown = existingCooldown.failureCount >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD;
18875
+ if (!isStormCooldown) {
18876
+ log?.debug?.(
18877
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18878
+ );
18879
+ this.socketPoolCooldowns.delete(this.host);
18880
+ } else {
18881
+ log?.debug?.(
18882
+ `[SocketPool] Preserving D2C_DISC storm cooldown for host=${this.host} (expires in ${Math.ceil((existingCooldown.cooldownUntil - Date.now()) / 1e3)}s)`
18883
+ );
18884
+ }
18793
18885
  }
18794
18886
  entry.client = newClient;
18795
18887
  entry.refCount = 1;
@@ -19091,6 +19183,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
19091
19183
  ...opts.channel !== void 0 ? { channel: opts.channel } : {}
19092
19184
  };
19093
19185
  const generalClient = new BaichuanClient(opts);
19186
+ this.attachD2cDiscListener(generalClient);
19094
19187
  this.socketPool.set("general", {
19095
19188
  client: generalClient,
19096
19189
  refCount: 1,
@@ -24155,6 +24248,49 @@ ${stderr}`)
24155
24248
  if (batteryVersion !== void 0) out.batteryVersion = batteryVersion;
24156
24249
  return out;
24157
24250
  }
24251
+ /**
24252
+ * Called when any BaichuanClient for this device receives a D2C_DISC.
24253
+ *
24254
+ * Two-tier response:
24255
+ * 1. **Immediate**: every D2C_DISC applies a short socket pool cooldown
24256
+ * (10 s) to prevent reconnect attempts while the camera transitions to sleep.
24257
+ * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
24258
+ */
24259
+ notifyD2cDisc() {
24260
+ const now = Date.now();
24261
+ this.lastD2cDiscAtMs = now;
24262
+ const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
24263
+ const existing = this.socketPoolCooldowns.get(this.host);
24264
+ if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
24265
+ this.socketPoolCooldowns.set(this.host, {
24266
+ failureCount: existing?.failureCount ?? 1,
24267
+ lastFailureAt: now,
24268
+ cooldownUntil: immediateCooldownUntil
24269
+ });
24270
+ this.logger?.log?.(
24271
+ `[D2C_DISC] Immediate cooldown: socket pool blocked for ${_ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS / 1e3}s`
24272
+ );
24273
+ }
24274
+ this.d2cDiscTimestamps.push(now);
24275
+ const cutoff = now - _ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS;
24276
+ while (this.d2cDiscTimestamps.length > 0 && this.d2cDiscTimestamps[0] < cutoff) {
24277
+ this.d2cDiscTimestamps.shift();
24278
+ }
24279
+ if (this.d2cDiscTimestamps.length >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD) {
24280
+ const stormCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS;
24281
+ const currentEntry = this.socketPoolCooldowns.get(this.host);
24282
+ if (!currentEntry || currentEntry.cooldownUntil < stormCooldownUntil) {
24283
+ this.socketPoolCooldowns.set(this.host, {
24284
+ failureCount: this.d2cDiscTimestamps.length,
24285
+ lastFailureAt: now,
24286
+ cooldownUntil: stormCooldownUntil
24287
+ });
24288
+ this.logger?.warn?.(
24289
+ `[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`
24290
+ );
24291
+ }
24292
+ }
24293
+ }
24158
24294
  /**
24159
24295
  * Best-effort sleeping inference for battery/BCUDP cameras.
24160
24296
  *
@@ -24185,6 +24321,8 @@ ${stderr}`)
24185
24321
  const socketConnected = this.client.isSocketConnected?.() ?? false;
24186
24322
  const now = Date.now();
24187
24323
  const cutoff = now - windowMs;
24324
+ const msSinceD2cDisc = now - this.lastD2cDiscAtMs;
24325
+ const recentD2cDisc = this.lastD2cDiscAtMs > 0 && msSinceD2cDisc < 3e4;
24188
24326
  const rx = (this.client.getRxHistory?.() ?? []).filter(
24189
24327
  (h) => h.atMs >= cutoff
24190
24328
  );
@@ -24192,6 +24330,12 @@ ${stderr}`)
24192
24330
  (h) => h.atMs >= cutoff
24193
24331
  );
24194
24332
  if (rx.length === 0 && tx.length === 0) {
24333
+ if (recentD2cDisc) {
24334
+ return {
24335
+ state: "sleeping",
24336
+ reason: `D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
24337
+ };
24338
+ }
24195
24339
  return {
24196
24340
  state: "sleeping",
24197
24341
  reason: `no rx/tx activity in last ${windowMs}ms${socketConnected ? "" : " (socket disconnected)"}`,
@@ -24215,6 +24359,12 @@ ${stderr}`)
24215
24359
  idleMs: now - firstWakingTx.atMs
24216
24360
  };
24217
24361
  }
24362
+ if (recentD2cDisc) {
24363
+ return {
24364
+ state: "sleeping",
24365
+ reason: `only non-waking cmdIds + D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
24366
+ };
24367
+ }
24218
24368
  return {
24219
24369
  state: "sleeping",
24220
24370
  reason: `only non-waking cmdIds observed in last ${windowMs}ms (non-waking: ${Array.from(nonWakingCmdIds).join(",")})`,