@apocaliss92/nodelink-js 0.4.5 → 0.4.7

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;
@@ -17434,6 +17495,13 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17434
17495
  * - "replay:XXX" - dedicated per replay session
17435
17496
  */
17436
17497
  socketPool = /* @__PURE__ */ new Map();
17498
+ /**
17499
+ * Consecutive stream-start (cmdId=3) timeout counter per socket tag.
17500
+ * When a streaming socket has N consecutive timeouts, the socket is force-closed
17501
+ * so the next attempt creates a fresh connection. Resets on success.
17502
+ */
17503
+ consecutiveStreamTimeouts = /* @__PURE__ */ new Map();
17504
+ static MAX_CONSECUTIVE_STREAM_TIMEOUTS = 3;
17437
17505
  /** BaichuanClientOptions to use when creating new sockets */
17438
17506
  clientOptions;
17439
17507
  /**
@@ -17588,14 +17656,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17588
17656
  if (!xml) return;
17589
17657
  const channel = frame.header.channelId;
17590
17658
  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
- });
17659
+ if (battery.batteryPercent === void 0 && battery.chargeStatus === void 0 && battery.adapterStatus === void 0) {
17660
+ return;
17661
+ }
17662
+ const key = `${battery.batteryPercent ?? ""}|${battery.chargeStatus ?? ""}|${battery.adapterStatus ?? ""}`;
17663
+ if (this.lastBatteryPushKey.get(channel) === key) {
17664
+ return;
17598
17665
  }
17666
+ this.lastBatteryPushKey.set(channel, key);
17667
+ this.dispatchSimpleEvent({
17668
+ type: "battery",
17669
+ channel,
17670
+ timestamp: Date.now(),
17671
+ battery
17672
+ });
17599
17673
  } catch (e) {
17600
17674
  this.logger.debug?.(
17601
17675
  "[ReolinkBaichuanApi] Error parsing battery push",
@@ -17761,6 +17835,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17761
17835
  deviceCapabilitiesCache = /* @__PURE__ */ new Map();
17762
17836
  static CAPABILITIES_CACHE_TTL_MS = 5 * 60 * 1e3;
17763
17837
  // 5 minutes
17838
+ /**
17839
+ * Dedupe key for battery push events (cmd_id 252), per channel.
17840
+ * Cameras emit BatteryInfoList frequently while streaming (every few
17841
+ * seconds). We only forward an event when the meaningful fields change
17842
+ * (percent, chargeStatus, adapterStatus) to avoid flooding SSE/MQTT
17843
+ * consumers and the UI event log.
17844
+ */
17845
+ lastBatteryPushKey = /* @__PURE__ */ new Map();
17764
17846
  // ─────────────────────────────────────────────────────────────────────────────
17765
17847
  // SOCKET POOL CONSTANTS
17766
17848
  // ─────────────────────────────────────────────────────────────────────────────
@@ -18077,6 +18159,20 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18077
18159
  const prefix = basename.substring(0, 10).toUpperCase();
18078
18160
  return prefix.includes("S") ? "subStream" : "mainStream";
18079
18161
  }
18162
+ /**
18163
+ * Stream profiles that the device explicitly rejected (response_code 400).
18164
+ * Keyed by `"ch:profile"` (e.g. `"0:ext"`). Once a profile is in this set
18165
+ * it is excluded from `buildVideoStreamOptions()` results and no further
18166
+ * start attempts are made until the API instance is recreated.
18167
+ */
18168
+ _rejectedStreamProfiles = /* @__PURE__ */ new Set();
18169
+ /**
18170
+ * Check whether a stream profile was rejected by the device at runtime
18171
+ * (e.g. ext returned response_code 400).
18172
+ */
18173
+ isStreamProfileRejected(channel, profile) {
18174
+ return this._rejectedStreamProfiles.has(`${channel}:${profile}`);
18175
+ }
18080
18176
  /**
18081
18177
  * Cache for buildVideoStreamOptions.
18082
18178
  *
@@ -23201,6 +23297,16 @@ ${stderr}`)
23201
23297
  }
23202
23298
  if (!frame) frame = await targetClient.sendFrame(baseParams);
23203
23299
  if (frame.header.responseCode !== 200) {
23300
+ if (frame.header.responseCode === 400) {
23301
+ const rejKey = `${ch}:${profile}`;
23302
+ if (!this._rejectedStreamProfiles.has(rejKey)) {
23303
+ this._rejectedStreamProfiles.add(rejKey);
23304
+ this.videoStreamOptionsCache.clear();
23305
+ this.logger?.warn?.(
23306
+ `[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.`
23307
+ );
23308
+ }
23309
+ }
23204
23310
  throw new Error(
23205
23311
  `Video stream request rejected (response_code ${frame.header.responseCode}). Expected response_code 200, camera returned ${frame.header.responseCode}`
23206
23312
  );
@@ -23209,6 +23315,7 @@ ${stderr}`)
23209
23315
  `${ch}:${profile}:${variant}`,
23210
23316
  frame.header.msgNum
23211
23317
  );
23318
+ this.resetStreamTimeoutCounter(targetClient);
23212
23319
  return;
23213
23320
  } catch (error) {
23214
23321
  lastError = error;
@@ -23223,6 +23330,10 @@ ${stderr}`)
23223
23330
  }
23224
23331
  }
23225
23332
  }
23333
+ const isTimeout = lastError instanceof Error && lastError.message?.includes("timeout");
23334
+ if (isTimeout) {
23335
+ this.trackStreamTimeout(targetClient);
23336
+ }
23226
23337
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
23227
23338
  }
23228
23339
  /**
@@ -23682,6 +23793,18 @@ ${stderr}`)
23682
23793
  notifyD2cDisc() {
23683
23794
  const now = Date.now();
23684
23795
  this.lastD2cDiscAtMs = now;
23796
+ const streamingTags = Array.from(this.socketPool.keys()).filter(
23797
+ (tag) => tag.startsWith("streaming:")
23798
+ );
23799
+ if (streamingTags.length > 0) {
23800
+ this.logger?.log?.(
23801
+ `[D2C_DISC] Force-closing ${streamingTags.length} streaming socket(s): ${streamingTags.join(", ")}`
23802
+ );
23803
+ for (const tag of streamingTags) {
23804
+ this.forceClosePooledSocket(tag, this.logger).catch(() => {
23805
+ });
23806
+ }
23807
+ }
23685
23808
  const immediateCooldownUntil = now + _ReolinkBaichuanApi.D2C_DISC_IMMEDIATE_COOLDOWN_MS;
23686
23809
  const existing = this.socketPoolCooldowns.get(this.host);
23687
23810
  if (!existing || existing.cooldownUntil < immediateCooldownUntil) {
@@ -23714,6 +23837,43 @@ ${stderr}`)
23714
23837
  }
23715
23838
  }
23716
23839
  }
23840
+ /**
23841
+ * Find the socket pool tag for a given BaichuanClient instance.
23842
+ * Returns undefined if the client is not in the pool (e.g. it's the general socket used directly).
23843
+ */
23844
+ findSocketTagForClient(client) {
23845
+ for (const [tag, entry] of this.socketPool) {
23846
+ if (entry.client === client) return tag;
23847
+ }
23848
+ return void 0;
23849
+ }
23850
+ /**
23851
+ * Reset the consecutive stream-start timeout counter for a streaming socket.
23852
+ * Called on successful stream start.
23853
+ */
23854
+ resetStreamTimeoutCounter(client) {
23855
+ const tag = this.findSocketTagForClient(client);
23856
+ if (tag) this.consecutiveStreamTimeouts.delete(tag);
23857
+ }
23858
+ /**
23859
+ * Track a stream-start timeout on a streaming socket.
23860
+ * After MAX_CONSECUTIVE_STREAM_TIMEOUTS consecutive timeouts, force-close the
23861
+ * socket so the next attempt creates a fresh connection.
23862
+ */
23863
+ trackStreamTimeout(client) {
23864
+ const tag = this.findSocketTagForClient(client);
23865
+ if (!tag || !tag.startsWith("streaming:")) return;
23866
+ const count = (this.consecutiveStreamTimeouts.get(tag) ?? 0) + 1;
23867
+ this.consecutiveStreamTimeouts.set(tag, count);
23868
+ if (count >= _ReolinkBaichuanApi.MAX_CONSECUTIVE_STREAM_TIMEOUTS) {
23869
+ this.logger?.warn?.(
23870
+ `[SocketPool] ${count} consecutive stream timeouts on tag=${tag}, force-closing socket`
23871
+ );
23872
+ this.consecutiveStreamTimeouts.delete(tag);
23873
+ this.forceClosePooledSocket(tag, this.logger).catch(() => {
23874
+ });
23875
+ }
23876
+ }
23717
23877
  /**
23718
23878
  * Best-effort sleeping inference for battery/BCUDP cameras.
23719
23879
  *
@@ -25222,6 +25382,8 @@ ${xml}`
25222
25382
  for (const metadata of params.metadatas) {
25223
25383
  const profile = metadata.profile;
25224
25384
  if (isMultiFocal && profile === "ext") continue;
25385
+ if (this._rejectedStreamProfiles.has(`${params.channel}:${profile}`))
25386
+ continue;
25225
25387
  if (params.includeRtsp && profile !== "ext") {
25226
25388
  const streamName = profile === "main" ? "main" : "sub";
25227
25389
  pushRtsp({
@@ -27949,16 +28111,16 @@ function isTcpFailureThatShouldFallbackToUdp(e) {
27949
28111
  return message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT") || message.includes("EHOSTUNREACH") || message.includes("ENETUNREACH") || message.includes("socket hang up") || message.includes("TCP connection timeout") || message.includes("Baichuan socket closed") || message.includes("timeout waiting for nonce") || message.includes("expected encryption info") || message.includes("ECONNRESET") || message.includes("EPIPE");
27950
28112
  }
27951
28113
  async function pingHost(host, timeoutMs = 3e3) {
28114
+ const { exec } = await import("child_process");
28115
+ const platform2 = process.platform;
28116
+ const pingCmd = platform2 === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform2 === "darwin" ? (
28117
+ // macOS: -W is in milliseconds (Linux: seconds)
28118
+ `ping -c 1 -W ${timeoutMs} ${host}`
28119
+ ) : (
28120
+ // Linux/BSD-ish: -W is in seconds on most distros
28121
+ `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`
28122
+ );
27952
28123
  return new Promise((resolve) => {
27953
- const { exec } = require("child_process");
27954
- const platform2 = process.platform;
27955
- const pingCmd = platform2 === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform2 === "darwin" ? (
27956
- // macOS: -W is in milliseconds (Linux: seconds)
27957
- `ping -c 1 -W ${timeoutMs} ${host}`
27958
- ) : (
27959
- // Linux/BSD-ish: -W is in seconds on most distros
27960
- `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`
27961
- );
27962
28124
  exec(pingCmd, (error) => {
27963
28125
  resolve(!error);
27964
28126
  });