@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.
@@ -3,10 +3,10 @@ import {
3
3
  BaichuanRtspServer,
4
4
  ReolinkBaichuanApi,
5
5
  autoDetectDeviceType
6
- } from "../chunk-UHFJPQA4.js";
6
+ } from "../chunk-F2Y5U3YP.js";
7
7
  import {
8
8
  __require
9
- } from "../chunk-DEOMUWBN.js";
9
+ } from "../chunk-TR3V5FTO.js";
10
10
 
11
11
  // src/cli/rtsp-server.ts
12
12
  function parseArgs() {
package/dist/index.cjs CHANGED
@@ -2178,6 +2178,19 @@ var init_BaichuanVideoStream = __esm({
2178
2178
  // Stateful AES decryptor for fragmented BcMedia packets (full_aes mode)
2179
2179
  // In CFB mode, continuation frames must use the cipher state from previous frames.
2180
2180
  aesStreamDecryptor = null;
2181
+ /**
2182
+ * Pending startup error stashed when emitSafeError is called before any
2183
+ * "error" listener is registered (e.g. camera returns 400 during start()).
2184
+ * The rfc4571-server's waitForKeyframe can consume this immediately instead
2185
+ * of waiting for the full keyframe timeout.
2186
+ */
2187
+ _pendingStartupError;
2188
+ /** Consume and clear any pending startup error. */
2189
+ consumePendingStartupError() {
2190
+ const err = this._pendingStartupError;
2191
+ this._pendingStartupError = void 0;
2192
+ return err;
2193
+ }
2181
2194
  emitSafeError(err) {
2182
2195
  if (!this.active) {
2183
2196
  this.logger?.warn?.(
@@ -2189,6 +2202,7 @@ var init_BaichuanVideoStream = __esm({
2189
2202
  this.logger?.warn?.(
2190
2203
  `[BaichuanVideoStream] Unhandled stream error: ${err.message}`
2191
2204
  );
2205
+ this._pendingStartupError = err;
2192
2206
  return;
2193
2207
  }
2194
2208
  this.emit("error", err);
@@ -8527,6 +8541,9 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8527
8541
  resendTimer;
8528
8542
  hbTimer;
8529
8543
  discoveryTid;
8544
+ // Track discovery-phase timers so close() can cancel them even if
8545
+ // discovery is still in progress (prevents ERR_SOCKET_DGRAM_NOT_RUNNING).
8546
+ discoveryTimers = [];
8530
8547
  acceptSent = false;
8531
8548
  lastAcceptAtMs;
8532
8549
  ackScheduled = false;
@@ -8575,9 +8592,31 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8575
8592
  });
8576
8593
  sock.on("error", (e) => this.emit("error", e));
8577
8594
  sock.on("close", () => this.emit("close"));
8578
- await new Promise(
8579
- (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
8580
- );
8595
+ const portRange = Array.from({ length: 500 }, (_, i) => 53500 + i);
8596
+ for (let i = portRange.length - 1; i > 0; i--) {
8597
+ const j = Math.floor(Math.random() * (i + 1));
8598
+ [portRange[i], portRange[j]] = [portRange[j], portRange[i]];
8599
+ }
8600
+ let bound = false;
8601
+ for (const port of portRange) {
8602
+ try {
8603
+ await new Promise((resolve, reject) => {
8604
+ sock.once("error", reject);
8605
+ sock.bind(port, "0.0.0.0", () => {
8606
+ sock.removeListener("error", reject);
8607
+ resolve();
8608
+ });
8609
+ });
8610
+ bound = true;
8611
+ break;
8612
+ } catch {
8613
+ }
8614
+ }
8615
+ if (!bound) {
8616
+ await new Promise(
8617
+ (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
8618
+ );
8619
+ }
8581
8620
  if (this.opts.mode === "direct") {
8582
8621
  this.remote = { host: this.opts.host, port: this.opts.port };
8583
8622
  this.clientId = this.opts.clientId;
@@ -8997,7 +9036,24 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
8997
9036
  BCUDP_DISCOVERY_PORT_LOCAL_ANY,
8998
9037
  BCUDP_DISCOVERY_PORT_LOCAL_UID
8999
9038
  ];
9000
- const broadcastHost = "255.255.255.255";
9039
+ const broadcastHosts = ["255.255.255.255"];
9040
+ const ifaces = (0, import_node_os.networkInterfaces)();
9041
+ for (const name of Object.keys(ifaces)) {
9042
+ const entries = ifaces[name];
9043
+ if (!entries) continue;
9044
+ for (const addr2 of entries) {
9045
+ if (addr2.family === "IPv4" && !addr2.internal && addr2.cidr) {
9046
+ const ipParts = addr2.address.split(".").map(Number);
9047
+ const maskParts = addr2.netmask.split(".").map(Number);
9048
+ if (ipParts.length === 4 && maskParts.length === 4) {
9049
+ const bcast = ipParts.map((octet, i) => octet | ~maskParts[i] & 255).join(".");
9050
+ if (!broadcastHosts.includes(bcast)) {
9051
+ broadcastHosts.push(bcast);
9052
+ }
9053
+ }
9054
+ }
9055
+ }
9056
+ }
9001
9057
  const directHost = (this.opts.directHost ?? "").trim();
9002
9058
  const localMode = opts?.localMode ?? "local-broadcast";
9003
9059
  const directFirstWindowMs = localMode === "local-direct" && directHost ? 3e3 : 0;
@@ -9024,6 +9080,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9024
9080
  )
9025
9081
  );
9026
9082
  }, discoveryTimeout);
9083
+ this.discoveryTimers.push(timeout);
9027
9084
  let retryTimer;
9028
9085
  let retryCount = 0;
9029
9086
  let discoveredSid;
@@ -9200,11 +9257,11 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9200
9257
  if (directHost) {
9201
9258
  if (directFirstWindowMs > 0 && elapsedMs < directFirstWindowMs)
9202
9259
  return [directHost];
9203
- return [directHost, broadcastHost];
9260
+ return [directHost, ...broadcastHosts];
9204
9261
  }
9205
- return [broadcastHost];
9262
+ return broadcastHosts;
9206
9263
  }
9207
- return [broadcastHost];
9264
+ return broadcastHosts;
9208
9265
  })();
9209
9266
  for (const host of Array.from(new Set(hosts))) {
9210
9267
  for (const port of ports) {
@@ -9212,8 +9269,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9212
9269
  sock.send(packet, port, host);
9213
9270
  retryCount++;
9214
9271
  this.emit("debug", "discovery_send", { retryCount, host, port });
9215
- } catch (e) {
9216
- this.emit("error", e instanceof Error ? e : new Error(String(e)));
9272
+ } catch {
9217
9273
  }
9218
9274
  }
9219
9275
  }
@@ -9222,6 +9278,7 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9222
9278
  retryTimer = (0, import_node_timers.setInterval)(() => {
9223
9279
  sendDiscovery();
9224
9280
  }, retryInterval);
9281
+ this.discoveryTimers.push(retryTimer);
9225
9282
  });
9226
9283
  this.clientId = reply.cid;
9227
9284
  this.cameraId = reply.did;
@@ -9538,6 +9595,10 @@ var BcUdpStream = class extends import_node_events.EventEmitter {
9538
9595
  this.ackTimer = void 0;
9539
9596
  this.resendTimer = void 0;
9540
9597
  this.hbTimer = void 0;
9598
+ for (const t of this.discoveryTimers) {
9599
+ clearInterval(t);
9600
+ }
9601
+ this.discoveryTimers = [];
9541
9602
  const s = this.sock;
9542
9603
  this.sock = void 0;
9543
9604
  if (!s) return;
@@ -9682,6 +9743,14 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
9682
9743
  * even if the current client instance is idle/disconnected.
9683
9744
  */
9684
9745
  static streamingRegistry = /* @__PURE__ */ new Map();
9746
+ /**
9747
+ * Per-host D2C_DISC backoff state that persists across client instance recreation.
9748
+ *
9749
+ * Why: when a D2C_DISC kills a client, the socket pool destroys the old instance
9750
+ * and creates a new one. Instance-level backoff variables would reset to zero,
9751
+ * allowing immediate reconnection and perpetuating the storm.
9752
+ */
9753
+ static d2cDiscBackoff = /* @__PURE__ */ new Map();
9685
9754
  /**
9686
9755
  * Global (process-wide) CoverPreview serialization.
9687
9756
  *
@@ -10377,7 +10446,12 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10377
10446
  }
10378
10447
  async waitForUdpReconnectCooldown() {
10379
10448
  const now = Date.now();
10380
- const waitMs = this.udpReconnectCooldownUntilMs - now;
10449
+ const staticEntry = _BaichuanClient.d2cDiscBackoff.get(this.opts.host);
10450
+ const effectiveCooldownUntil = Math.max(
10451
+ this.udpReconnectCooldownUntilMs,
10452
+ staticEntry?.cooldownUntilMs ?? 0
10453
+ );
10454
+ const waitMs = effectiveCooldownUntil - now;
10381
10455
  if (waitMs <= 0) return;
10382
10456
  const sid = this.socketSessionId;
10383
10457
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -10386,7 +10460,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10386
10460
  host: this.opts.host,
10387
10461
  sid,
10388
10462
  uid: shortUid,
10389
- waitMs
10463
+ waitMs,
10464
+ persistent: staticEntry != null
10390
10465
  });
10391
10466
  await new Promise((resolve) => setTimeout(resolve, waitMs));
10392
10467
  }
@@ -10629,21 +10704,30 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10629
10704
  uid: shortUid2,
10630
10705
  message: err.message
10631
10706
  });
10632
- const withinWindow = now - this.udpReconnectLastD2cDiscAtMs < 6e4;
10707
+ const hostKey = this.opts.host;
10708
+ const prev = _BaichuanClient.d2cDiscBackoff.get(hostKey);
10709
+ const withinWindow = prev != null && now - prev.lastAtMs < 6e4;
10633
10710
  const baseMs = 2e3;
10634
10711
  const maxMs = 3e4;
10635
10712
  const nextBackoffMs = withinWindow ? Math.min(
10636
10713
  maxMs,
10637
10714
  Math.max(
10638
10715
  baseMs,
10639
- this.udpReconnectBackoffMs > 0 ? this.udpReconnectBackoffMs * 2 : baseMs
10716
+ prev.backoffMs > 0 ? prev.backoffMs * 2 : baseMs
10640
10717
  )
10641
10718
  ) : baseMs;
10642
- this.udpReconnectLastD2cDiscAtMs = now;
10643
- this.udpReconnectBackoffMs = nextBackoffMs;
10719
+ const cooldownUntilMs = Math.max(
10720
+ prev?.cooldownUntilMs ?? 0,
10721
+ now + nextBackoffMs
10722
+ );
10723
+ _BaichuanClient.d2cDiscBackoff.set(hostKey, {
10724
+ backoffMs: nextBackoffMs,
10725
+ lastAtMs: now,
10726
+ cooldownUntilMs
10727
+ });
10644
10728
  this.udpReconnectCooldownUntilMs = Math.max(
10645
10729
  this.udpReconnectCooldownUntilMs,
10646
- now + nextBackoffMs
10730
+ cooldownUntilMs
10647
10731
  );
10648
10732
  this.logDebug("d2c_disc_backoff", {
10649
10733
  transport: "udp",
@@ -10651,7 +10735,8 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10651
10735
  sid: sid2,
10652
10736
  uid: shortUid2,
10653
10737
  backoffMs: nextBackoffMs,
10654
- cooldownUntilMs: this.udpReconnectCooldownUntilMs
10738
+ cooldownUntilMs,
10739
+ persistent: true
10655
10740
  });
10656
10741
  this.stopKeepAlive();
10657
10742
  this.loggedIn = false;
@@ -10660,6 +10745,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10660
10745
  this.videoSubscriptions.clear();
10661
10746
  this.recomputeGlobalStreamingContribution();
10662
10747
  }
10748
+ this.emit("d2c_disc", { host: this.opts.host, atMs: now });
10663
10749
  }
10664
10750
  this.emit("error", err);
10665
10751
  });
@@ -10958,6 +11044,13 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10958
11044
  }
10959
11045
  }
10960
11046
  this.emit("push", frame);
11047
+ if (frame.header.cmdId === 252 && frame.body.length > 0) {
11048
+ try {
11049
+ this.emit("batteryPush", frame);
11050
+ } catch (error) {
11051
+ this.logDebug("battery_push_error", error);
11052
+ }
11053
+ }
10961
11054
  if (frame.header.cmdId === 33) {
10962
11055
  try {
10963
11056
  const sid = this.socketSessionId;
@@ -18077,6 +18170,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18077
18170
  }
18078
18171
  }
18079
18172
  const newClient = new BaichuanClient(this.clientOptions);
18173
+ this.attachD2cDiscListener(newClient);
18080
18174
  this.socketPool.set("general", {
18081
18175
  client: newClient,
18082
18176
  refCount: 1,
@@ -18122,6 +18216,31 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18122
18216
  if (!mapped) return;
18123
18217
  this.dispatchSimpleEvent(mapped);
18124
18218
  });
18219
+ client.on("batteryPush", (frame) => {
18220
+ try {
18221
+ const xml = this.client.tryDecryptXml(
18222
+ frame.body,
18223
+ frame.header.channelId,
18224
+ this.client.enc
18225
+ );
18226
+ if (!xml) return;
18227
+ const channel = frame.header.channelId;
18228
+ const battery = this.parseBatteryInfoXml(xml, channel);
18229
+ if (battery.batteryPercent !== void 0 || battery.chargeStatus !== void 0 || battery.adapterStatus !== void 0) {
18230
+ this.dispatchSimpleEvent({
18231
+ type: "battery",
18232
+ channel,
18233
+ timestamp: Date.now(),
18234
+ battery
18235
+ });
18236
+ }
18237
+ } catch (e) {
18238
+ this.logger.debug?.(
18239
+ "[ReolinkBaichuanApi] Error parsing battery push",
18240
+ formatErrorForLog(e)
18241
+ );
18242
+ }
18243
+ });
18125
18244
  client.on("channelInfo", (xml) => {
18126
18245
  try {
18127
18246
  this.parseAndStoreChannelInfo(xml);
@@ -18257,6 +18376,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18257
18376
  rtspServers = /* @__PURE__ */ new Set();
18258
18377
  // Track all RTSP servers for cleanup
18259
18378
  activeVideoMsgNums = /* @__PURE__ */ new Map();
18379
+ // ─── D2C_DISC cooldown & storm detection ────────────────────────────────────
18380
+ // Tracked on the API instance (survives BaichuanClient recreation).
18381
+ /** Timestamp of the most recent D2C_DISC from any client for this device. */
18382
+ lastD2cDiscAtMs = 0;
18383
+ /** Sliding window of recent D2C_DISC timestamps for storm detection. */
18384
+ d2cDiscTimestamps = [];
18385
+ /** Immediate cooldown (ms) applied to socket pool on every D2C_DISC.
18386
+ * Prevents reconnect attempts while the camera is transitioning to sleep. */
18387
+ static D2C_DISC_IMMEDIATE_COOLDOWN_MS = 1e4;
18388
+ /** Number of D2C_DISCs within the storm window to trigger extended cooldown. */
18389
+ static D2C_DISC_STORM_THRESHOLD = 3;
18390
+ /** Sliding window size (ms) for storm detection. */
18391
+ static D2C_DISC_STORM_WINDOW_MS = 6e4;
18392
+ /** Extended cooldown (ms) applied to socket pool when a D2C_DISC storm is detected. */
18393
+ static D2C_DISC_STORM_COOLDOWN_MS = 12e4;
18260
18394
  nvrChannelsSummaryCache = /* @__PURE__ */ new Map();
18261
18395
  /**
18262
18396
  * Cached device capabilities per channel.
@@ -18581,6 +18715,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18581
18715
  const prefix = basename.substring(0, 10).toUpperCase();
18582
18716
  return prefix.includes("S") ? "subStream" : "mainStream";
18583
18717
  }
18718
+ /**
18719
+ * Stream profiles that the device explicitly rejected (response_code 400).
18720
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
18721
+ * it is excluded from `buildVideoStreamOptions()` results and no further
18722
+ * start attempts are made until the API instance is recreated.
18723
+ */
18724
+ _rejectedStreamProfiles = /* @__PURE__ */ new Set();
18725
+ /**
18726
+ * Check whether a stream profile was rejected by the device at runtime
18727
+ * (e.g. ext returned response_code 400).
18728
+ */
18729
+ isStreamProfileRejected(channel, profile) {
18730
+ return this._rejectedStreamProfiles.has(`${channel}:${profile}`);
18731
+ }
18584
18732
  /**
18585
18733
  * Cache for buildVideoStreamOptions.
18586
18734
  *
@@ -18670,6 +18818,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18670
18818
  }
18671
18819
  return "general";
18672
18820
  }
18821
+ /**
18822
+ * Attach a D2C_DISC listener to a BaichuanClient so that the API-level
18823
+ * grace period and storm detection are updated regardless of which
18824
+ * pool socket receives the disconnect.
18825
+ */
18826
+ attachD2cDiscListener(client) {
18827
+ client.on("d2c_disc", () => this.notifyD2cDisc());
18828
+ }
18673
18829
  /**
18674
18830
  * Acquire a socket from the pool by tag.
18675
18831
  * Creates a new socket if needed, or reuses an existing one.
@@ -18690,10 +18846,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18690
18846
  );
18691
18847
  } else if (now < cooldownEntry.cooldownUntil) {
18692
18848
  const remainingMs = cooldownEntry.cooldownUntil - now;
18849
+ const isD2cDisc = this.lastD2cDiscAtMs > 0 && now - this.lastD2cDiscAtMs < 12e4;
18850
+ const reason = isD2cDisc ? "D2C_DISC (camera sleeping)" : "repeated login failures";
18693
18851
  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}`
18852
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to ${reason}. tag=${tag}`
18695
18853
  );
18696
- log?.warn?.(error.message);
18854
+ log?.debug?.(error.message);
18697
18855
  throw error;
18698
18856
  }
18699
18857
  }
@@ -18784,12 +18942,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18784
18942
  try {
18785
18943
  const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
18786
18944
  const newClient = new BaichuanClient(clientOpts);
18945
+ this.attachD2cDiscListener(newClient);
18787
18946
  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);
18947
+ const existingCooldown = this.socketPoolCooldowns.get(this.host);
18948
+ if (existingCooldown) {
18949
+ const isStormCooldown = existingCooldown.failureCount >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD;
18950
+ if (!isStormCooldown) {
18951
+ log?.debug?.(
18952
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
18953
+ );
18954
+ this.socketPoolCooldowns.delete(this.host);
18955
+ } else {
18956
+ log?.debug?.(
18957
+ `[SocketPool] Preserving D2C_DISC storm cooldown for host=${this.host} (expires in ${Math.ceil((existingCooldown.cooldownUntil - Date.now()) / 1e3)}s)`
18958
+ );
18959
+ }
18793
18960
  }
18794
18961
  entry.client = newClient;
18795
18962
  entry.refCount = 1;
@@ -19091,6 +19258,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
19091
19258
  ...opts.channel !== void 0 ? { channel: opts.channel } : {}
19092
19259
  };
19093
19260
  const generalClient = new BaichuanClient(opts);
19261
+ this.attachD2cDiscListener(generalClient);
19094
19262
  this.socketPool.set("general", {
19095
19263
  client: generalClient,
19096
19264
  refCount: 1,
@@ -23685,6 +23853,16 @@ ${stderr}`)
23685
23853
  }
23686
23854
  if (!frame) frame = await targetClient.sendFrame(baseParams);
23687
23855
  if (frame.header.responseCode !== 200) {
23856
+ if (frame.header.responseCode === 400) {
23857
+ const rejKey = `${ch}:${profile}`;
23858
+ if (!this._rejectedStreamProfiles.has(rejKey)) {
23859
+ this._rejectedStreamProfiles.add(rejKey);
23860
+ this.videoStreamOptionsCache.clear();
23861
+ this.logger?.warn?.(
23862
+ `[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.`
23863
+ );
23864
+ }
23865
+ }
23688
23866
  throw new Error(
23689
23867
  `Video stream request rejected (response_code ${frame.header.responseCode}). Expected response_code 200, camera returned ${frame.header.responseCode}`
23690
23868
  );
@@ -24155,6 +24333,49 @@ ${stderr}`)
24155
24333
  if (batteryVersion !== void 0) out.batteryVersion = batteryVersion;
24156
24334
  return out;
24157
24335
  }
24336
+ /**
24337
+ * Called when any BaichuanClient for this device receives a D2C_DISC.
24338
+ *
24339
+ * Two-tier response:
24340
+ * 1. **Immediate**: every D2C_DISC applies a short socket pool cooldown
24341
+ * (10 s) to prevent reconnect attempts while the camera transitions to sleep.
24342
+ * 2. **Storm**: ≥3 D2C_DISCs within 60 s triggers extended cooldown (120 s).
24343
+ */
24344
+ notifyD2cDisc() {
24345
+ const now = Date.now();
24346
+ this.lastD2cDiscAtMs = now;
24347
+ const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
24348
+ const existing = this.socketPoolCooldowns.get(this.host);
24349
+ if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
24350
+ this.socketPoolCooldowns.set(this.host, {
24351
+ failureCount: existing?.failureCount ?? 1,
24352
+ lastFailureAt: now,
24353
+ cooldownUntil: immediateCooldownUntil
24354
+ });
24355
+ this.logger?.log?.(
24356
+ `[D2C_DISC] Immediate cooldown: socket pool blocked for ${_ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS / 1e3}s`
24357
+ );
24358
+ }
24359
+ this.d2cDiscTimestamps.push(now);
24360
+ const cutoff = now - _ReolinkBaichuanApi.D2C_DISC_STORM_WINDOW_MS;
24361
+ while (this.d2cDiscTimestamps.length > 0 && this.d2cDiscTimestamps[0] < cutoff) {
24362
+ this.d2cDiscTimestamps.shift();
24363
+ }
24364
+ if (this.d2cDiscTimestamps.length >= _ReolinkBaichuanApi.D2C_DISC_STORM_THRESHOLD) {
24365
+ const stormCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_STORM_COOLDOWN_MS;
24366
+ const currentEntry = this.socketPoolCooldowns.get(this.host);
24367
+ if (!currentEntry || currentEntry.cooldownUntil < stormCooldownUntil) {
24368
+ this.socketPoolCooldowns.set(this.host, {
24369
+ failureCount: this.d2cDiscTimestamps.length,
24370
+ lastFailureAt: now,
24371
+ cooldownUntil: stormCooldownUntil
24372
+ });
24373
+ this.logger?.warn?.(
24374
+ `[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`
24375
+ );
24376
+ }
24377
+ }
24378
+ }
24158
24379
  /**
24159
24380
  * Best-effort sleeping inference for battery/BCUDP cameras.
24160
24381
  *
@@ -24185,6 +24406,8 @@ ${stderr}`)
24185
24406
  const socketConnected = this.client.isSocketConnected?.() ?? false;
24186
24407
  const now = Date.now();
24187
24408
  const cutoff = now - windowMs;
24409
+ const msSinceD2cDisc = now - this.lastD2cDiscAtMs;
24410
+ const recentD2cDisc = this.lastD2cDiscAtMs > 0 && msSinceD2cDisc < 3e4;
24188
24411
  const rx = (this.client.getRxHistory?.() ?? []).filter(
24189
24412
  (h) => h.atMs >= cutoff
24190
24413
  );
@@ -24192,6 +24415,12 @@ ${stderr}`)
24192
24415
  (h) => h.atMs >= cutoff
24193
24416
  );
24194
24417
  if (rx.length === 0 && tx.length === 0) {
24418
+ if (recentD2cDisc) {
24419
+ return {
24420
+ state: "sleeping",
24421
+ reason: `D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
24422
+ };
24423
+ }
24195
24424
  return {
24196
24425
  state: "sleeping",
24197
24426
  reason: `no rx/tx activity in last ${windowMs}ms${socketConnected ? "" : " (socket disconnected)"}`,
@@ -24215,6 +24444,12 @@ ${stderr}`)
24215
24444
  idleMs: now - firstWakingTx.atMs
24216
24445
  };
24217
24446
  }
24447
+ if (recentD2cDisc) {
24448
+ return {
24449
+ state: "sleeping",
24450
+ reason: `only non-waking cmdIds + D2C_DISC ${Math.round(msSinceD2cDisc / 1e3)}s ago, camera terminated session`
24451
+ };
24452
+ }
24218
24453
  return {
24219
24454
  state: "sleeping",
24220
24455
  reason: `only non-waking cmdIds observed in last ${windowMs}ms (non-waking: ${Array.from(nonWakingCmdIds).join(",")})`,
@@ -25649,6 +25884,8 @@ ${xml}`
25649
25884
  for (const metadata of params.metadatas) {
25650
25885
  const profile = metadata.profile;
25651
25886
  if (isMultiFocal && profile === "ext") continue;
25887
+ if (this._rejectedStreamProfiles.has(`${params.channel}:${profile}`))
25888
+ continue;
25652
25889
  if (params.includeRtsp && profile !== "ext") {
25653
25890
  const streamName = profile === "main" ? "main" : "sub";
25654
25891
  pushRtsp({
@@ -32139,6 +32376,11 @@ async function createRfc4571TcpServerInternal(options) {
32139
32376
  "videoAccessUnit",
32140
32377
  onAu
32141
32378
  );
32379
+ const pendingErr = videoStream.consumePendingStartupError?.();
32380
+ if (pendingErr) {
32381
+ cleanup();
32382
+ reject(pendingErr);
32383
+ }
32142
32384
  });
32143
32385
  }
32144
32386
  };
@@ -32150,24 +32392,32 @@ async function createRfc4571TcpServerInternal(options) {
32150
32392
  await videoStream.stop();
32151
32393
  } catch {
32152
32394
  }
32153
- if (closeApiOnTeardown) {
32154
- await Promise.allSettled(
32155
- Array.from(apisToClose).map(async (a) => {
32395
+ if (dedicatedSession) {
32396
+ try {
32397
+ await dedicatedSession.release();
32398
+ } catch {
32399
+ }
32400
+ }
32401
+ if (!dedicatedSession) {
32402
+ if (closeApiOnTeardown) {
32403
+ await Promise.allSettled(
32404
+ Array.from(apisToClose).map(async (a) => {
32405
+ try {
32406
+ await a.close();
32407
+ } catch {
32408
+ }
32409
+ })
32410
+ );
32411
+ } else {
32412
+ const graceMs = isComposite ? 5e3 : 0;
32413
+ for (const a of Array.from(apisToClose)) {
32156
32414
  try {
32157
- await a.close();
32415
+ a?.client?.requestIdleDisconnectSoon?.(
32416
+ "rfc4571_teardown",
32417
+ graceMs
32418
+ );
32158
32419
  } catch {
32159
32420
  }
32160
- })
32161
- );
32162
- } else {
32163
- const graceMs = isComposite ? 5e3 : 0;
32164
- for (const a of Array.from(apisToClose)) {
32165
- try {
32166
- a?.client?.requestIdleDisconnectSoon?.(
32167
- "rfc4571_teardown",
32168
- graceMs
32169
- );
32170
- } catch {
32171
32421
  }
32172
32422
  }
32173
32423
  }
@@ -32423,7 +32673,7 @@ async function createRfc4571TcpServerInternal(options) {
32423
32673
  } catch {
32424
32674
  }
32425
32675
  }
32426
- if (closeApiOnTeardown) {
32676
+ if (closeApiOnTeardown && !dedicatedSession) {
32427
32677
  await Promise.allSettled(
32428
32678
  Array.from(apisToClose).map(async (a) => {
32429
32679
  try {