@apocaliss92/nodelink-js 0.4.4 → 0.4.6

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.
@@ -1567,6 +1567,19 @@ var init_BaichuanVideoStream = __esm({
1567
1567
  // Stateful AES decryptor for fragmented BcMedia packets (full_aes mode)
1568
1568
  // In CFB mode, continuation frames must use the cipher state from previous frames.
1569
1569
  aesStreamDecryptor = null;
1570
+ /**
1571
+ * Pending startup error stashed when emitSafeError is called before any
1572
+ * "error" listener is registered (e.g. camera returns 400 during start()).
1573
+ * The rfc4571-server's waitForKeyframe can consume this immediately instead
1574
+ * of waiting for the full keyframe timeout.
1575
+ */
1576
+ _pendingStartupError;
1577
+ /** Consume and clear any pending startup error. */
1578
+ consumePendingStartupError() {
1579
+ const err = this._pendingStartupError;
1580
+ this._pendingStartupError = void 0;
1581
+ return err;
1582
+ }
1570
1583
  emitSafeError(err) {
1571
1584
  if (!this.active) {
1572
1585
  this.logger?.warn?.(
@@ -1578,6 +1591,7 @@ var init_BaichuanVideoStream = __esm({
1578
1591
  this.logger?.warn?.(
1579
1592
  `[BaichuanVideoStream] Unhandled stream error: ${err.message}`
1580
1593
  );
1594
+ this._pendingStartupError = err;
1581
1595
  return;
1582
1596
  }
1583
1597
  this.emit("error", err);
@@ -10657,6 +10671,9 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
10657
10671
  resendTimer;
10658
10672
  hbTimer;
10659
10673
  discoveryTid;
10674
+ // Track discovery-phase timers so close() can cancel them even if
10675
+ // discovery is still in progress (prevents ERR_SOCKET_DGRAM_NOT_RUNNING).
10676
+ discoveryTimers = [];
10660
10677
  acceptSent = false;
10661
10678
  lastAcceptAtMs;
10662
10679
  ackScheduled = false;
@@ -10705,9 +10722,31 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
10705
10722
  });
10706
10723
  sock.on("error", (e) => this.emit("error", e));
10707
10724
  sock.on("close", () => this.emit("close"));
10708
- await new Promise(
10709
- (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
10710
- );
10725
+ const portRange = Array.from({ length: 500 }, (_, i) => 53500 + i);
10726
+ for (let i = portRange.length - 1; i > 0; i--) {
10727
+ const j = Math.floor(Math.random() * (i + 1));
10728
+ [portRange[i], portRange[j]] = [portRange[j], portRange[i]];
10729
+ }
10730
+ let bound = false;
10731
+ for (const port of portRange) {
10732
+ try {
10733
+ await new Promise((resolve, reject) => {
10734
+ sock.once("error", reject);
10735
+ sock.bind(port, "0.0.0.0", () => {
10736
+ sock.removeListener("error", reject);
10737
+ resolve();
10738
+ });
10739
+ });
10740
+ bound = true;
10741
+ break;
10742
+ } catch {
10743
+ }
10744
+ }
10745
+ if (!bound) {
10746
+ await new Promise(
10747
+ (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
10748
+ );
10749
+ }
10711
10750
  if (this.opts.mode === "direct") {
10712
10751
  this.remote = { host: this.opts.host, port: this.opts.port };
10713
10752
  this.clientId = this.opts.clientId;
@@ -11127,7 +11166,24 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11127
11166
  BCUDP_DISCOVERY_PORT_LOCAL_ANY,
11128
11167
  BCUDP_DISCOVERY_PORT_LOCAL_UID
11129
11168
  ];
11130
- const broadcastHost = "255.255.255.255";
11169
+ const broadcastHosts = ["255.255.255.255"];
11170
+ const ifaces = (0, import_node_os.networkInterfaces)();
11171
+ for (const name of Object.keys(ifaces)) {
11172
+ const entries = ifaces[name];
11173
+ if (!entries) continue;
11174
+ for (const addr2 of entries) {
11175
+ if (addr2.family === "IPv4" && !addr2.internal && addr2.cidr) {
11176
+ const ipParts = addr2.address.split(".").map(Number);
11177
+ const maskParts = addr2.netmask.split(".").map(Number);
11178
+ if (ipParts.length === 4 && maskParts.length === 4) {
11179
+ const bcast = ipParts.map((octet, i) => octet | ~maskParts[i] & 255).join(".");
11180
+ if (!broadcastHosts.includes(bcast)) {
11181
+ broadcastHosts.push(bcast);
11182
+ }
11183
+ }
11184
+ }
11185
+ }
11186
+ }
11131
11187
  const directHost = (this.opts.directHost ?? "").trim();
11132
11188
  const localMode = opts?.localMode ?? "local-broadcast";
11133
11189
  const directFirstWindowMs = localMode === "local-direct" && directHost ? 3e3 : 0;
@@ -11154,6 +11210,7 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11154
11210
  )
11155
11211
  );
11156
11212
  }, discoveryTimeout);
11213
+ this.discoveryTimers.push(timeout);
11157
11214
  let retryTimer;
11158
11215
  let retryCount = 0;
11159
11216
  let discoveredSid;
@@ -11330,11 +11387,11 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11330
11387
  if (directHost) {
11331
11388
  if (directFirstWindowMs > 0 && elapsedMs < directFirstWindowMs)
11332
11389
  return [directHost];
11333
- return [directHost, broadcastHost];
11390
+ return [directHost, ...broadcastHosts];
11334
11391
  }
11335
- return [broadcastHost];
11392
+ return broadcastHosts;
11336
11393
  }
11337
- return [broadcastHost];
11394
+ return broadcastHosts;
11338
11395
  })();
11339
11396
  for (const host of Array.from(new Set(hosts))) {
11340
11397
  for (const port of ports) {
@@ -11342,8 +11399,7 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11342
11399
  sock.send(packet, port, host);
11343
11400
  retryCount++;
11344
11401
  this.emit("debug", "discovery_send", { retryCount, host, port });
11345
- } catch (e) {
11346
- this.emit("error", e instanceof Error ? e : new Error(String(e)));
11402
+ } catch {
11347
11403
  }
11348
11404
  }
11349
11405
  }
@@ -11352,6 +11408,7 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11352
11408
  retryTimer = (0, import_node_timers.setInterval)(() => {
11353
11409
  sendDiscovery();
11354
11410
  }, retryInterval);
11411
+ this.discoveryTimers.push(retryTimer);
11355
11412
  });
11356
11413
  this.clientId = reply.cid;
11357
11414
  this.cameraId = reply.did;
@@ -11668,6 +11725,10 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11668
11725
  this.ackTimer = void 0;
11669
11726
  this.resendTimer = void 0;
11670
11727
  this.hbTimer = void 0;
11728
+ for (const t of this.discoveryTimers) {
11729
+ clearInterval(t);
11730
+ }
11731
+ this.discoveryTimers = [];
11671
11732
  const s = this.sock;
11672
11733
  this.sock = void 0;
11673
11734
  if (!s) return;
@@ -11851,6 +11912,14 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
11851
11912
  * even if the current client instance is idle/disconnected.
11852
11913
  */
11853
11914
  static streamingRegistry = /* @__PURE__ */ new Map();
11915
+ /**
11916
+ * Per-host D2C_DISC backoff state that persists across client instance recreation.
11917
+ *
11918
+ * Why: when a D2C_DISC kills a client, the socket pool destroys the old instance
11919
+ * and creates a new one. Instance-level backoff variables would reset to zero,
11920
+ * allowing immediate reconnection and perpetuating the storm.
11921
+ */
11922
+ static d2cDiscBackoff = /* @__PURE__ */ new Map();
11854
11923
  /**
11855
11924
  * Global (process-wide) CoverPreview serialization.
11856
11925
  *
@@ -12546,7 +12615,12 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12546
12615
  }
12547
12616
  async waitForUdpReconnectCooldown() {
12548
12617
  const now = Date.now();
12549
- const waitMs = this.udpReconnectCooldownUntilMs - now;
12618
+ const staticEntry = _BaichuanClient.d2cDiscBackoff.get(this.opts.host);
12619
+ const effectiveCooldownUntil = Math.max(
12620
+ this.udpReconnectCooldownUntilMs,
12621
+ staticEntry?.cooldownUntilMs ?? 0
12622
+ );
12623
+ const waitMs = effectiveCooldownUntil - now;
12550
12624
  if (waitMs <= 0) return;
12551
12625
  const sid = this.socketSessionId;
12552
12626
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -12555,7 +12629,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12555
12629
  host: this.opts.host,
12556
12630
  sid,
12557
12631
  uid: shortUid,
12558
- waitMs
12632
+ waitMs,
12633
+ persistent: staticEntry != null
12559
12634
  });
12560
12635
  await new Promise((resolve) => setTimeout(resolve, waitMs));
12561
12636
  }
@@ -12798,21 +12873,30 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12798
12873
  uid: shortUid2,
12799
12874
  message: err.message
12800
12875
  });
12801
- const withinWindow = now - this.udpReconnectLastD2cDiscAtMs < 6e4;
12876
+ const hostKey = this.opts.host;
12877
+ const prev = _BaichuanClient.d2cDiscBackoff.get(hostKey);
12878
+ const withinWindow = prev != null && now - prev.lastAtMs < 6e4;
12802
12879
  const baseMs = 2e3;
12803
12880
  const maxMs = 3e4;
12804
12881
  const nextBackoffMs = withinWindow ? Math.min(
12805
12882
  maxMs,
12806
12883
  Math.max(
12807
12884
  baseMs,
12808
- this.udpReconnectBackoffMs > 0 ? this.udpReconnectBackoffMs * 2 : baseMs
12885
+ prev.backoffMs > 0 ? prev.backoffMs * 2 : baseMs
12809
12886
  )
12810
12887
  ) : baseMs;
12811
- this.udpReconnectLastD2cDiscAtMs = now;
12812
- this.udpReconnectBackoffMs = nextBackoffMs;
12888
+ const cooldownUntilMs = Math.max(
12889
+ prev?.cooldownUntilMs ?? 0,
12890
+ now + nextBackoffMs
12891
+ );
12892
+ _BaichuanClient.d2cDiscBackoff.set(hostKey, {
12893
+ backoffMs: nextBackoffMs,
12894
+ lastAtMs: now,
12895
+ cooldownUntilMs
12896
+ });
12813
12897
  this.udpReconnectCooldownUntilMs = Math.max(
12814
12898
  this.udpReconnectCooldownUntilMs,
12815
- now + nextBackoffMs
12899
+ cooldownUntilMs
12816
12900
  );
12817
12901
  this.logDebug("d2c_disc_backoff", {
12818
12902
  transport: "udp",
@@ -12820,7 +12904,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12820
12904
  sid: sid2,
12821
12905
  uid: shortUid2,
12822
12906
  backoffMs: nextBackoffMs,
12823
- cooldownUntilMs: this.udpReconnectCooldownUntilMs
12907
+ cooldownUntilMs,
12908
+ persistent: true
12824
12909
  });
12825
12910
  this.stopKeepAlive();
12826
12911
  this.loggedIn = false;
@@ -12829,6 +12914,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
12829
12914
  this.videoSubscriptions.clear();
12830
12915
  this.recomputeGlobalStreamingContribution();
12831
12916
  }
12917
+ this.emit("d2c_disc", { host: this.opts.host, atMs: now });
12832
12918
  }
12833
12919
  this.emit("error", err);
12834
12920
  });
@@ -13127,6 +13213,13 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
13127
13213
  }
13128
13214
  }
13129
13215
  this.emit("push", frame);
13216
+ if (frame.header.cmdId === 252 && frame.body.length > 0) {
13217
+ try {
13218
+ this.emit("batteryPush", frame);
13219
+ } catch (error) {
13220
+ this.logDebug("battery_push_error", error);
13221
+ }
13222
+ }
13130
13223
  if (frame.header.cmdId === 33) {
13131
13224
  try {
13132
13225
  const sid = this.socketSessionId;
@@ -17500,6 +17593,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17500
17593
  }
17501
17594
  }
17502
17595
  const newClient = new BaichuanClient(this.clientOptions);
17596
+ this.attachD2cDiscListener(newClient);
17503
17597
  this.socketPool.set("general", {
17504
17598
  client: newClient,
17505
17599
  refCount: 1,
@@ -17545,6 +17639,31 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17545
17639
  if (!mapped) return;
17546
17640
  this.dispatchSimpleEvent(mapped);
17547
17641
  });
17642
+ client.on("batteryPush", (frame) => {
17643
+ try {
17644
+ const xml = this.client.tryDecryptXml(
17645
+ frame.body,
17646
+ frame.header.channelId,
17647
+ this.client.enc
17648
+ );
17649
+ if (!xml) return;
17650
+ const channel = frame.header.channelId;
17651
+ const battery = this.parseBatteryInfoXml(xml, channel);
17652
+ if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
17653
+ this.dispatchSimpleEvent({
17654
+ type: "battery",
17655
+ channel,
17656
+ timestamp: Date.now(),
17657
+ battery
17658
+ });
17659
+ }
17660
+ } catch (e) {
17661
+ this.logger.debug?.(
17662
+ "[ReolinkBaichuanApi] Error parsing battery push",
17663
+ formatErrorForLog(e)
17664
+ );
17665
+ }
17666
+ });
17548
17667
  client.on("channelInfo", (xml) => {
17549
17668
  try {
17550
17669
  this.parseAndStoreChannelInfo(xml);
@@ -17680,6 +17799,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17680
17799
  rtspServers = /* @__PURE__ */ new Set();
17681
17800
  // Track all RTSP servers for cleanup
17682
17801
  activeVideoMsgNums = /* @__PURE__ */ new Map();
17802
+ // ─── D2C_DISC cooldown & storm detection ────────────────────────────────────
17803
+ // Tracked on the API instance (survives BaichuanClient recreation).
17804
+ /** Timestamp of the most recent D2C_DISC from any client for this device. */
17805
+ lastD2cDiscAtMs = 0;
17806
+ /** Sliding window of recent D2C_DISC timestamps for storm detection. */
17807
+ d2cDiscTimestamps = [];
17808
+ /** Immediate cooldown (ms) applied to socket pool on every D2C_DISC.
17809
+ * Prevents reconnect attempts while the camera is transitioning to sleep. */
17810
+ static D2C_DISC_IMMEDIATE_COOLDOWN_MS = 1e4;
17811
+ /** Number of D2C_DISCs within the storm window to trigger extended cooldown. */
17812
+ static D2C_DISC_STORM_THRESHOLD = 3;
17813
+ /** Sliding window size (ms) for storm detection. */
17814
+ static D2C_DISC_STORM_WINDOW_MS = 6e4;
17815
+ /** Extended cooldown (ms) applied to socket pool when a D2C_DISC storm is detected. */
17816
+ static D2C_DISC_STORM_COOLDOWN_MS = 12e4;
17683
17817
  nvrChannelsSummaryCache = /* @__PURE__ */ new Map();
17684
17818
  /**
17685
17819
  * Cached device capabilities per channel.
@@ -18004,6 +18138,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18004
18138
  const prefix = basename.substring(0, 10).toUpperCase();
18005
18139
  return prefix.includes("S") ? "subStream" : "mainStream";
18006
18140
  }
18141
+ /**
18142
+ * Stream profiles that the device explicitly rejected (response_code 400).
18143
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
18144
+ * it is excluded from `buildVideoStreamOptions()` results and no further
18145
+ * start attempts are made until the API instance is recreated.
18146
+ */
18147
+ _rejectedStreamProfiles = /* @__PURE__ */ new Set();
18148
+ /**
18149
+ * Check whether a stream profile was rejected by the device at runtime
18150
+ * (e.g. ext returned response_code 400).
18151
+ */
18152
+ isStreamProfileRejected(channel, profile) {
18153
+ return this._rejectedStreamProfiles.has(`${channel}:${profile}`);
18154
+ }
18007
18155
  /**
18008
18156
  * Cache for buildVideoStreamOptions.
18009
18157
  *
@@ -18093,6 +18241,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18093
18241
  }
18094
18242
  return "general";
18095
18243
  }
18244
+ /**
18245
+ * Attach a D2C_DISC listener to a BaichuanClient so that the API-level
18246
+ * grace period and storm detection are updated regardless of which
18247
+ * pool socket receives the disconnect.
18248
+ */
18249
+ attachD2cDiscListener(client) {
18250
+ client.on("d2c_disc", () => this.notifyD2cDisc());
18251
+ }
18096
18252
  /**
18097
18253
  * Acquire a socket from the pool by tag.
18098
18254
  * Creates a new socket if needed, or reuses an existing one.
@@ -18113,10 +18269,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18113
18269
  );
18114
18270
  } else if (now < cooldownEntry.cooldownUntil) {
18115
18271
  const remainingMs = cooldownEntry.cooldownUntil - now;
18272
+ const isD2cDisc = this.lastD2cDiscAtMs > 0 && now - this.lastD2cDiscAtMs < 12e4;
18273
+ const reason = isD2cDisc ? "D2C_DISC (camera sleeping)" : "repeated login failures";
18116
18274
  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}`
18275
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to ${reason}. tag=${tag}`
18118
18276
  );
18119
- log?.warn?.(error.message);
18277
+ log?.debug?.(error.message);
18120
18278
  throw error;
18121
18279
  }
18122
18280
  }
@@ -18207,12 +18365,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18207
18365
  try {
18208
18366
  const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
18209
18367
  const newClient = new BaichuanClient(clientOpts);
18368
+ this.attachD2cDiscListener(newClient);
18210
18369
  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);
18370
+ const existingCooldown = this.socketPoolCooldowns.get(this.host);
18371
+ if (existingCooldown) {
18372
+ const isStormCooldown = existingCooldown.failureCount >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD;
18373
+ if (!isStormCooldown) {
18374
+ log?.debug?.(
18375
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18376
+ );
18377
+ this.socketPoolCooldowns.delete(this.host);
18378
+ } else {
18379
+ log?.debug?.(
18380
+ `[SocketPool] Preserving D2C_DISC storm cooldown for host=${this.host} (expires in ${Math.ceil((existingCooldown.cooldownUntil - Date.now()) / 1e3)}s)`
18381
+ );
18382
+ }
18216
18383
  }
18217
18384
  entry.client = newClient;
18218
18385
  entry.refCount = 1;
@@ -18514,6 +18681,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18514
18681
  ...opts.channel !== void 0 ? { channel: opts.channel } : {}
18515
18682
  };
18516
18683
  const generalClient = new BaichuanClient(opts);
18684
+ this.attachD2cDiscListener(generalClient);
18517
18685
  this.socketPool.set("general", {
18518
18686
  client: generalClient,
18519
18687
  refCount: 1,
@@ -23108,6 +23276,16 @@ ${stderr}`)
23108
23276
  }
23109
23277
  if (!frame) frame = await targetClient.sendFrame(baseParams);
23110
23278
  if (frame.header.responseCode !== 200) {
23279
+ if (frame.header.responseCode === 400) {
23280
+ const rejKey = `${ch}:${profile}`;
23281
+ if (!this._rejectedStreamProfiles.has(rejKey)) {
23282
+ this._rejectedStreamProfiles.add(rejKey);
23283
+ this.videoStreamOptionsCache.clear();
23284
+ this.logger?.warn?.(
23285
+ `[ReolinkBaichuanApi] Stream profile rejected by device: channel=${ch} profile=${profile} (response_code 400). This profile will be excluded from available streams. The camera may not support this stream profile with the current firmware.`
23286
+ );
23287
+ }
23288
+ }
23111
23289
  throw new Error(
23112
23290
  `Video stream request rejected (response_code ${frame.header.responseCode}). Expected response_code 200, camera returned ${frame.header.responseCode}`
23113
23291
  );
@@ -23578,6 +23756,49 @@ ${stderr}`)
23578
23756
  if (batteryVersion !== void 0) out.batteryVersion = batteryVersion;
23579
23757
  return out;
23580
23758
  }
23759
+ /**
23760
+ * Called when any BaichuanClient for this device receives a D2C_DISC.
23761
+ *
23762
+ * Two-tier response:
23763
+ * 1. **Immediate**: every D2C_DISC applies a short socket pool cooldown
23764
+ * (10 s) to prevent reconnect attempts while the camera transitions to sleep.
23765
+ * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
23766
+ */
23767
+ notifyD2cDisc() {
23768
+ const now = Date.now();
23769
+ this.lastD2cDiscAtMs = now;
23770
+ const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
23771
+ const existing = this.socketPoolCooldowns.get(this.host);
23772
+ if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
23773
+ this.socketPoolCooldowns.set(this.host, {
23774
+ failureCount: existing?.failureCount ?? 1,
23775
+ lastFailureAt: now,
23776
+ cooldownUntil: immediateCooldownUntil
23777
+ });
23778
+ this.logger?.log?.(
23779
+ `[D2C_DISC] Immediate cooldown: socket pool blocked for ${_ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS / 1e3}s`
23780
+ );
23781
+ }
23782
+ this.d2cDiscTimestamps.push(now);
23783
+ const cutoff = now - _ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS;
23784
+ while (this.d2cDiscTimestamps.length > 0 && this.d2cDiscTimestamps[0] < cutoff) {
23785
+ this.d2cDiscTimestamps.shift();
23786
+ }
23787
+ if (this.d2cDiscTimestamps.length >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD) {
23788
+ const stormCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS;
23789
+ const currentEntry = this.socketPoolCooldowns.get(this.host);
23790
+ if (!currentEntry || currentEntry.cooldownUntil < stormCooldownUntil) {
23791
+ this.socketPoolCooldowns.set(this.host, {
23792
+ failureCount: this.d2cDiscTimestamps.length,
23793
+ lastFailureAt: now,
23794
+ cooldownUntil: stormCooldownUntil
23795
+ });
23796
+ this.logger?.warn?.(
23797
+ `[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`
23798
+ );
23799
+ }
23800
+ }
23801
+ }
23581
23802
  /**
23582
23803
  * Best-effort sleeping inference for battery/BCUDP cameras.
23583
23804
  *
@@ -23608,6 +23829,8 @@ ${stderr}`)
23608
23829
  const socketConnected = this.client.isSocketConnected?.() ?? false;
23609
23830
  const now = Date.now();
23610
23831
  const cutoff = now - windowMs;
23832
+ const msSinceD2cDisc = now - this.lastD2cDiscAtMs;
23833
+ const recentD2cDisc = this.lastD2cDiscAtMs > 0 && msSinceD2cDisc < 3e4;
23611
23834
  const rx = (this.client.getRxHistory?.() ?? []).filter(
23612
23835
  (h) => h.atMs >= cutoff
23613
23836
  );
@@ -23615,6 +23838,12 @@ ${stderr}`)
23615
23838
  (h) => h.atMs >= cutoff
23616
23839
  );
23617
23840
  if (rx.length === 0 && tx.length === 0) {
23841
+ if (recentD2cDisc) {
23842
+ return {
23843
+ state: "sleeping",
23844
+ reason: `D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
23845
+ };
23846
+ }
23618
23847
  return {
23619
23848
  state: "sleeping",
23620
23849
  reason: `no rx/tx activity in last ${windowMs}ms${socketConnected ? "" : " (socket disconnected)"}`,
@@ -23638,6 +23867,12 @@ ${stderr}`)
23638
23867
  idleMs: now - firstWakingTx.atMs
23639
23868
  };
23640
23869
  }
23870
+ if (recentD2cDisc) {
23871
+ return {
23872
+ state: "sleeping",
23873
+ reason: `only non-waking cmdIds + D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
23874
+ };
23875
+ }
23641
23876
  return {
23642
23877
  state: "sleeping",
23643
23878
  reason: `only non-waking cmdIds observed in last ${windowMs}ms (non-waking: ${Array.from(nonWakingCmdIds).join(",")})`,
@@ -25072,6 +25307,8 @@ ${xml}`
25072
25307
  for (const metadata of params.metadatas) {
25073
25308
  const profile = metadata.profile;
25074
25309
  if (isMultiFocal && profile === "ext") continue;
25310
+ if (this._rejectedStreamProfiles.has(`${params.channel}:${profile}`))
25311
+ continue;
25075
25312
  if (params.includeRtsp && profile !== "ext") {
25076
25313
  const streamName = profile === "main" ? "main" : "sub";
25077
25314
  pushRtsp({