@apocaliss92/nodelink-js 0.5.1 → 0.5.2

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.
@@ -11292,6 +11292,143 @@ function parseD2cHb(xml) {
11292
11292
  return { cid: Number(cid), did: Number(did) };
11293
11293
  }
11294
11294
 
11295
+ // src/cloud/server-binding.ts
11296
+ var REOLINK_API_V2_BASE = "https://apis.reolink.com/v2";
11297
+ var POSITIVE_TTL_MS = 24 * 60 * 60 * 1e3;
11298
+ var NEGATIVE_TTL_MS = 30 * 1e3;
11299
+ var cache = /* @__PURE__ */ new Map();
11300
+ function readCache(uid, now) {
11301
+ const e = cache.get(uid);
11302
+ if (!e) return void 0;
11303
+ if (now >= e.expires) {
11304
+ cache.delete(uid);
11305
+ return void 0;
11306
+ }
11307
+ return e;
11308
+ }
11309
+ async function getServerBinding(uid, options = {}) {
11310
+ if (!uid || typeof uid !== "string") return void 0;
11311
+ const now = Date.now();
11312
+ const cached = readCache(uid, now);
11313
+ if (cached?.kind === "ok") return cached.response;
11314
+ if (cached?.kind === "err") return void 0;
11315
+ const language = options.language ?? "en";
11316
+ const baseUrl = options.baseUrl ?? REOLINK_API_V2_BASE;
11317
+ const timeoutMs = options.timeoutMs ?? 4e3;
11318
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
11319
+ const logger = options.logger;
11320
+ if (typeof fetchImpl !== "function") {
11321
+ logger?.debug?.(
11322
+ `[server-binding] global fetch unavailable; skipping cloud lookup`
11323
+ );
11324
+ cache.set(uid, { kind: "err", expires: now + NEGATIVE_TTL_MS });
11325
+ return void 0;
11326
+ }
11327
+ const url = `${baseUrl}/devices/${encodeURIComponent(uid)}/server-binding?language=${encodeURIComponent(language)}`;
11328
+ const controller = new AbortController();
11329
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
11330
+ try {
11331
+ const res = await fetchImpl(url, {
11332
+ method: "GET",
11333
+ signal: controller.signal,
11334
+ headers: { Accept: "application/json" }
11335
+ });
11336
+ if (!res.ok) {
11337
+ logger?.debug?.(
11338
+ `[server-binding] ${uid}: HTTP ${res.status} ${res.statusText}`
11339
+ );
11340
+ cache.set(uid, { kind: "err", expires: now + NEGATIVE_TTL_MS });
11341
+ return void 0;
11342
+ }
11343
+ const json = await res.json();
11344
+ const parsed = parseServerBindingResponse(json);
11345
+ if (!parsed) {
11346
+ logger?.debug?.(
11347
+ `[server-binding] ${uid}: response shape did not match expectations`
11348
+ );
11349
+ cache.set(uid, { kind: "err", expires: now + NEGATIVE_TTL_MS });
11350
+ return void 0;
11351
+ }
11352
+ cache.set(uid, {
11353
+ kind: "ok",
11354
+ response: parsed,
11355
+ expires: now + POSITIVE_TTL_MS
11356
+ });
11357
+ const pick = parsed.availableZones.find(
11358
+ (z) => z.status === "active" && z.services.p2p?.server
11359
+ );
11360
+ const hint = pick?.services.p2p?.server ?? parsed.availableZones[0]?.services.p2p?.server;
11361
+ logger?.log?.(
11362
+ `[server-binding] ${uid}: ${parsed.availableZones.length} zone(s)${hint ? `, p2p hint=${hint}` : ""}`
11363
+ );
11364
+ return parsed;
11365
+ } catch (e) {
11366
+ logger?.debug?.(
11367
+ `[server-binding] ${uid}: ${e?.message ?? String(e)}`
11368
+ );
11369
+ cache.set(uid, { kind: "err", expires: now + NEGATIVE_TTL_MS });
11370
+ return void 0;
11371
+ } finally {
11372
+ clearTimeout(timer);
11373
+ }
11374
+ }
11375
+ function pickP2pHostFromBinding(response) {
11376
+ if (!response) return void 0;
11377
+ const zones = response.availableZones;
11378
+ if (!zones || zones.length === 0) return void 0;
11379
+ const active = zones.find(
11380
+ (z) => z.status === "active" && z.services.p2p?.server
11381
+ );
11382
+ if (active?.services.p2p?.server) return active.services.p2p.server;
11383
+ const def = zones.find(
11384
+ (z) => z.status === "default" && z.services.p2p?.server
11385
+ );
11386
+ if (def?.services.p2p?.server) return def.services.p2p.server;
11387
+ const any = zones.find((z) => z.services.p2p?.server);
11388
+ return any?.services.p2p?.server;
11389
+ }
11390
+ function isString(v) {
11391
+ return typeof v === "string";
11392
+ }
11393
+ function parseServerBindingResponse(raw) {
11394
+ if (!raw || typeof raw !== "object") return void 0;
11395
+ const rawZones = raw.availableZones;
11396
+ if (!Array.isArray(rawZones)) return void 0;
11397
+ const zones = [];
11398
+ for (const r of rawZones) {
11399
+ if (!r || typeof r !== "object") continue;
11400
+ const rec = r;
11401
+ const id = rec.id;
11402
+ const name = rec.name;
11403
+ const status = rec.status;
11404
+ if (!isString(id) || !isString(name) || !isString(status)) continue;
11405
+ const servicesRaw = rec.services;
11406
+ const services = {};
11407
+ if (servicesRaw && typeof servicesRaw === "object") {
11408
+ const s = servicesRaw;
11409
+ for (const key of ["p2p", "cloud", "roms_ota", "alarm_push"]) {
11410
+ const v = s[key];
11411
+ if (v && typeof v === "object") {
11412
+ const server = v.server;
11413
+ if (isString(server) && server.length > 0) {
11414
+ services[key] = { server };
11415
+ }
11416
+ }
11417
+ }
11418
+ }
11419
+ const locationsRaw = rec.locations;
11420
+ const locations = Array.isArray(locationsRaw) && locationsRaw.every(isString) ? locationsRaw : void 0;
11421
+ zones.push({
11422
+ id,
11423
+ name,
11424
+ status,
11425
+ services,
11426
+ ...locations ? { locations } : {}
11427
+ });
11428
+ }
11429
+ return { availableZones: zones };
11430
+ }
11431
+
11295
11432
  // src/bcudp/BcUdpStream.ts
11296
11433
  var AckLatency = class {
11297
11434
  currentValues = [];
@@ -11373,6 +11510,16 @@ var P2P_MAX_WAIT_MS = 15e3;
11373
11510
  var P2P_RESEND_WAIT_MS = 500;
11374
11511
  var BcUdpStream = class extends import_node_events3.EventEmitter {
11375
11512
  opts;
11513
+ /**
11514
+ * Optional info-level logger for diagnostic milestones — set via
11515
+ * {@link BcUdpStream.setLogger} by `BaichuanClient` so the lib's
11516
+ * standard logger sink sees BCUDP / P2P progress (DNS resolutions,
11517
+ * outgoing UDP probes, timeouts with elapsed times) without the user
11518
+ * having to opt into the per-packet `debug` event firehose. Kept
11519
+ * separate from `emit('debug', ...)` because that channel is intended
11520
+ * for the per-packet debug trace and is gated by debugOptions.
11521
+ */
11522
+ discoveryLogger;
11376
11523
  sock;
11377
11524
  remote;
11378
11525
  mtu;
@@ -11416,6 +11563,17 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11416
11563
  this.mtu = BCUDP_DEFAULT_MTU;
11417
11564
  }
11418
11565
  /** True if the underlying UDP socket is open and the remote peer is known. */
11566
+ /**
11567
+ * Attach an info-level logger for high-signal diagnostic milestones
11568
+ * (DNS resolution, outgoing UDP probe sends, P2P UID lookup wins/losses,
11569
+ * BCUDP local discovery timeouts). The lib's `BaichuanClient` calls
11570
+ * this immediately after constructing the stream so consumers get
11571
+ * actionable progress logs without enabling the per-packet debug trace.
11572
+ * Safe to call repeatedly; only the most recent logger is used.
11573
+ */
11574
+ setLogger(logger) {
11575
+ this.discoveryLogger = logger;
11576
+ }
11419
11577
  isConnected() {
11420
11578
  return !!this.sock && !!this.remote && this.cameraId != null;
11421
11579
  }
@@ -11533,20 +11691,60 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11533
11691
  this.remote = { host: connected.rhost, port: connected.rport };
11534
11692
  }
11535
11693
  async p2pUidLookup(sock, uid) {
11694
+ const log = (msg) => this.discoveryLogger?.log?.(`[P2P] ${msg}`);
11695
+ const shortUid = uid.length > 7 ? `${uid.slice(0, 5)}\u2026${uid.slice(-2)}` : uid;
11696
+ const t0 = Date.now();
11697
+ const hostnamesToTry = [];
11698
+ const binding = await getServerBinding(uid, {
11699
+ ...this.discoveryLogger ? { logger: this.discoveryLogger } : {}
11700
+ }).catch(() => void 0);
11701
+ const hintedHost = pickP2pHostFromBinding(binding);
11702
+ if (hintedHost) {
11703
+ hostnamesToTry.push(hintedHost);
11704
+ log(
11705
+ `UID=${shortUid} cloud server-binding \u2192 hint=${hintedHost} (will try first)`
11706
+ );
11707
+ } else {
11708
+ log(
11709
+ `UID=${shortUid} cloud server-binding \u2192 no hint (apis.reolink.com unreachable / no zone match) \u2192 sweeping ${P2P_RELAY_HOSTNAMES.length} fallback hostnames`
11710
+ );
11711
+ }
11712
+ for (const host of P2P_RELAY_HOSTNAMES) {
11713
+ if (!hostnamesToTry.includes(host)) hostnamesToTry.push(host);
11714
+ }
11536
11715
  const resolved = [];
11537
11716
  const sinkholed = [];
11538
- for (const host of P2P_RELAY_HOSTNAMES) {
11717
+ for (const host of hostnamesToTry) {
11539
11718
  try {
11540
11719
  const answers = await import_promises.default.lookup(host, { family: 4, all: true });
11720
+ let publicCount = 0;
11721
+ let sinkCount = 0;
11541
11722
  for (const a of answers) {
11542
11723
  if (!a.address) continue;
11543
11724
  if (isUnroutableForP2P(a.address)) {
11544
11725
  sinkholed.push({ host, ip: a.address });
11726
+ sinkCount++;
11545
11727
  continue;
11546
11728
  }
11547
- if (!resolved.includes(a.address)) resolved.push(a.address);
11729
+ if (!resolved.includes(a.address)) {
11730
+ resolved.push(a.address);
11731
+ publicCount++;
11732
+ }
11548
11733
  }
11549
- } catch {
11734
+ if (sinkCount > 0 && publicCount === 0) {
11735
+ log(
11736
+ `DNS ${host} \u2192 sinkhole (${sinkholed[sinkholed.length - 1]?.ip}) \u2014 DNS filter / /etc/hosts override`
11737
+ );
11738
+ } else if (publicCount > 0) {
11739
+ if (host === hintedHost) {
11740
+ log(`DNS ${host} \u2192 ${answers.find((a) => !isUnroutableForP2P(a.address))?.address} \u2713`);
11741
+ }
11742
+ }
11743
+ } catch (e) {
11744
+ log(`DNS ${host} \u2192 ENOTFOUND/timeout (${e?.code ?? "?"})`);
11745
+ }
11746
+ if (hintedHost && host === hintedHost && resolved.length > 0 && sinkholed.length === 0) {
11747
+ break;
11550
11748
  }
11551
11749
  }
11552
11750
  if (resolved.length === 0) {
@@ -11560,11 +11758,22 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11560
11758
  "P2P UID lookup failed: no p2p.reolink.com addresses resolved (DNS failure)"
11561
11759
  );
11562
11760
  }
11761
+ log(
11762
+ `Resolved ${resolved.length} P2P relay IP(s) (${resolved.slice(0, 3).join(", ")}${resolved.length > 3 ? "\u2026" : ""}). Sending C2M_Q probes (3s budget each, ${P2P_MAX_WAIT_MS}ms total)`
11763
+ );
11563
11764
  const start = Date.now();
11564
11765
  let lastErr;
11766
+ let attemptsMade = 0;
11565
11767
  for (const ip of resolved) {
11566
11768
  const remaining = P2P_MAX_WAIT_MS - (Date.now() - start);
11567
- if (remaining <= 0) break;
11769
+ if (remaining <= 0) {
11770
+ log(
11771
+ `Aborting after ${attemptsMade} attempt(s) \u2014 total budget ${P2P_MAX_WAIT_MS}ms exhausted`
11772
+ );
11773
+ break;
11774
+ }
11775
+ attemptsMade++;
11776
+ const probeStart = Date.now();
11568
11777
  try {
11569
11778
  const res = await this.p2pUidLookupOne(
11570
11779
  sock,
@@ -11572,11 +11781,20 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11572
11781
  { host: ip, port: P2P_LOOKUP_PORT },
11573
11782
  Math.min(remaining, 3e3)
11574
11783
  );
11784
+ log(
11785
+ `${ip}:${P2P_LOOKUP_PORT} replied in ${Date.now() - probeStart}ms \u2713 (total ${Date.now() - t0}ms)`
11786
+ );
11575
11787
  return res;
11576
11788
  } catch (e) {
11789
+ const ms = Date.now() - probeStart;
11790
+ const msg = e?.message ?? String(e);
11791
+ log(`${ip}:${P2P_LOOKUP_PORT} no reply after ${ms}ms (${msg})`);
11577
11792
  lastErr = e instanceof Error ? e : new Error(String(e));
11578
11793
  }
11579
11794
  }
11795
+ log(
11796
+ `Exhausted all ${attemptsMade} relay candidate(s) after ${Date.now() - t0}ms \u2014 UID lookup failed`
11797
+ );
11580
11798
  throw lastErr ?? new Error("P2P UID lookup failed");
11581
11799
  }
11582
11800
  async p2pUidLookupOne(sock, uid, dest, timeoutMs) {
@@ -11915,13 +12133,23 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11915
12133
  const directHost = (this.opts.directHost ?? "").trim();
11916
12134
  const localMode = opts?.localMode ?? "local-broadcast";
11917
12135
  const directFirstWindowMs = localMode === "local-direct" && directHost ? 3e3 : 0;
11918
- const discoveryTimeout = 3e4;
12136
+ const discoveryTimeout = typeof this.opts.localDiscoveryTimeoutMs === "number" && this.opts.localDiscoveryTimeoutMs > 0 ? this.opts.localDiscoveryTimeoutMs : 15e3;
11919
12137
  const retryInterval = 500;
11920
12138
  const startMs = Date.now();
11921
12139
  sock.setBroadcast(true);
11922
12140
  const addr = sock.address();
11923
12141
  const localPort = typeof addr === "string" ? 0 : addr.port;
11924
12142
  const cid = Math.floor(Math.random() * 2147483647) | 0 || 82e3;
12143
+ const log = (msg) => this.discoveryLogger?.log?.(`[BCUDP] ${msg}`);
12144
+ const shortUid = this.opts.uid.length > 7 ? `${this.opts.uid.slice(0, 5)}\u2026${this.opts.uid.slice(-2)}` : this.opts.uid;
12145
+ log(
12146
+ `local discovery: mode=${localMode} uid=${shortUid} ports=[${ports.join(", ")}] broadcasts=[${broadcastHosts.join(", ")}]${directHost ? ` direct=${directHost}` : ""} localBindPort=${localPort} timeout=${discoveryTimeout}ms`
12147
+ );
12148
+ let bytesSent = 0;
12149
+ let pktsRecv = 0;
12150
+ sock.on("message", () => {
12151
+ pktsRecv++;
12152
+ });
11925
12153
  const xml = buildC2dC({
11926
12154
  uid: this.opts.uid,
11927
12155
  clientPort: localPort,
@@ -11932,6 +12160,9 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
11932
12160
  const timeout = setTimeout(() => {
11933
12161
  if (retryTimer) clearInterval(retryTimer);
11934
12162
  sock.off("message", onMsg);
12163
+ log(
12164
+ `local discovery timeout after ${discoveryTimeout}ms \u2014 sent=${bytesSent}B replies=${pktsRecv} (camera likely sleeping / off-LAN / firewall dropping replies)`
12165
+ );
11935
12166
  reject(
11936
12167
  new Error(
11937
12168
  `BCUDP discovery timeout after ${discoveryTimeout}ms (camera may be sleeping or unreachable)`
@@ -12125,6 +12356,7 @@ var BcUdpStream = class extends import_node_events3.EventEmitter {
12125
12356
  for (const port of ports) {
12126
12357
  try {
12127
12358
  sock.send(packet, port, host);
12359
+ bytesSent += packet.length;
12128
12360
  retryCount++;
12129
12361
  this.emit("debug", "discovery_send", { retryCount, host, port });
12130
12362
  } catch {
@@ -13703,6 +13935,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events4.EventEmit
13703
13935
  sock.on("debug", (event, data) => {
13704
13936
  this.logDebug(`udp_${event}`, data);
13705
13937
  });
13938
+ sock.setLogger(this.logger);
13706
13939
  await sock.connect();
13707
13940
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : "";
13708
13941
  const udpDiscoveryMethod = this.opts.udpDiscoveryMethod ?? "local-direct";
@@ -31501,7 +31734,11 @@ async function discoverUidForHost(host, logger) {
31501
31734
  function isTcpFailureThatShouldFallbackToUdp(e) {
31502
31735
  const message = e?.message || e?.toString?.() || "";
31503
31736
  if (typeof message !== "string") return false;
31504
- 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");
31737
+ return message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT") || message.includes("EHOSTDOWN") || message.includes("EHOSTUNREACH") || message.includes("ENETUNREACH") || message.includes("ENETDOWN") || message.includes("socket hang up") || message.includes("TCP connection timeout") || // Autodetect's own hard deadline on the TCP login attempt — see
31738
+ // `withTcpDeadline` in `autoDetectDeviceType`. Without this entry the
31739
+ // catch block would rethrow the deadline error instead of awaiting
31740
+ // the speculative UDP race.
31741
+ message.includes("TCP login deadline exceeded") || message.includes("Baichuan socket closed") || message.includes("timeout waiting for nonce") || message.includes("expected encryption info") || message.includes("ECONNRESET") || message.includes("EPIPE");
31505
31742
  }
31506
31743
  async function pingHost(host, timeoutMs = 3e3) {
31507
31744
  if (!host || typeof host !== "string") return false;
@@ -31649,6 +31886,7 @@ function attachErrorHandler(api, transport, inputs) {
31649
31886
  }
31650
31887
  async function autoDetectDeviceType(inputs) {
31651
31888
  const { host, uid, logger } = inputs;
31889
+ const autodetectStartedAt = Date.now();
31652
31890
  const mode = inputs.mode ?? "auto";
31653
31891
  const maxRetriesRaw = inputs.maxRetries;
31654
31892
  const maxRetries = Math.max(
@@ -31665,9 +31903,31 @@ async function autoDetectDeviceType(inputs) {
31665
31903
  const sleepMs3 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31666
31904
  const shouldRetryTcp = (e) => {
31667
31905
  const msg = fmtErr(e);
31668
- if (msg.includes("ECONNREFUSED")) return false;
31906
+ if (msg.includes("ECONNREFUSED") || msg.includes("EHOSTDOWN") || msg.includes("EHOSTUNREACH") || msg.includes("ENETUNREACH") || msg.includes("ENETDOWN")) {
31907
+ return false;
31908
+ }
31669
31909
  return isTcpFailureThatShouldFallbackToUdp(e) || msg.includes("timeout waiting for nonce") || msg.includes("expected encryption info") || msg.includes("Baichuan socket closed") || msg.includes("ECONNRESET") || msg.includes("EPIPE");
31670
31910
  };
31911
+ const tcpDeadlineMs = typeof inputs.tcpConnectTimeoutMs === "number" && Number.isFinite(inputs.tcpConnectTimeoutMs) && inputs.tcpConnectTimeoutMs > 0 ? inputs.tcpConnectTimeoutMs : 8e3;
31912
+ const withTcpDeadline = async (op) => {
31913
+ let timer;
31914
+ const deadline = new Promise((_, reject) => {
31915
+ timer = setTimeout(
31916
+ () => reject(
31917
+ new Error(
31918
+ `TCP login deadline exceeded (${tcpDeadlineMs}ms) \u2014 host unreachable`
31919
+ )
31920
+ ),
31921
+ tcpDeadlineMs
31922
+ );
31923
+ timer.unref?.();
31924
+ });
31925
+ try {
31926
+ return await Promise.race([op, deadline]);
31927
+ } finally {
31928
+ if (timer) clearTimeout(timer);
31929
+ }
31930
+ };
31671
31931
  const shouldRetryUdp = (e) => {
31672
31932
  const msg = fmtErr(e);
31673
31933
  return msg.includes("Not running") || msg.includes("Baichuan UDP stream closed") || msg.includes("Baichuan socket closed") || msg.includes("ETIMEDOUT") || msg.toLowerCase().includes("timeout");
@@ -31820,6 +32080,127 @@ async function autoDetectDeviceType(inputs) {
31820
32080
  "Forced UDP autodetect failed for all methods."
31821
32081
  );
31822
32082
  }
32083
+ const detectOverUdpApi = async (udpApi, udpDiscoveryMethod, resolvedUid) => {
32084
+ const [deviceInfo, capabilities, hostNetworkInfo] = await Promise.all([
32085
+ udpApi.getInfo(),
32086
+ udpApi.getDeviceCapabilities(),
32087
+ udpApi.getNetworkInfo(void 0, { timeoutMs: 1200 }).catch(() => void 0)
32088
+ ]);
32089
+ const channelNum = capabilities?.support?.channelNum ?? 1;
32090
+ const model = deviceInfo.type?.trim();
32091
+ const normalizedModel = model ? model.trim() : void 0;
32092
+ const isMultifocalByModel = normalizedModel ? isDualLenseModel(normalizedModel) : false;
32093
+ const channelNumValue = typeof channelNum === "string" ? Number.parseInt(channelNum, 10) : channelNum;
32094
+ const hasDualLensChannelCount = (channelNumValue === 2 || channelNumValue === 3) && Number.isFinite(channelNumValue);
32095
+ const isMultifocal = isMultifocalByModel || hasDualLensChannelCount;
32096
+ const hasBattery = capabilities?.capabilities?.hasBattery === true;
32097
+ udpApi.setIdleDisconnect(hasBattery);
32098
+ if (isMultifocal) {
32099
+ const detectionMethod = isMultifocalByModel ? "model match" : "channelNum fallback";
32100
+ logger?.log?.(
32101
+ `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}, hasBattery=${hasBattery}).`
32102
+ );
32103
+ return {
32104
+ type: "multifocal",
32105
+ transport: "udp",
32106
+ uid: resolvedUid,
32107
+ udpDiscoveryMethod,
32108
+ deviceInfo,
32109
+ ...hostNetworkInfo ? { hostNetworkInfo } : {},
32110
+ channelNum,
32111
+ hasBattery,
32112
+ api: udpApi
32113
+ };
32114
+ }
32115
+ const deviceType = hasBattery ? "battery-cam" : "udp-camera";
32116
+ logger?.log?.(
32117
+ `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected ${deviceType} (hasBattery=${hasBattery}, model=${normalizedModel ?? "unknown"}).`
32118
+ );
32119
+ return {
32120
+ type: deviceType,
32121
+ transport: "udp",
32122
+ uid: resolvedUid,
32123
+ udpDiscoveryMethod,
32124
+ deviceInfo,
32125
+ ...hostNetworkInfo ? { hostNetworkInfo } : {},
32126
+ channelNum: 1,
32127
+ hasBattery,
32128
+ api: udpApi
32129
+ };
32130
+ };
32131
+ const udpRaceAbort = new AbortController();
32132
+ const speculativeUdpRace = mode === "auto" ? (async () => {
32133
+ const resolvedUid = await speculativeUidPromise;
32134
+ const viableMethods = selectViableUdpMethods(Boolean(resolvedUid));
32135
+ return await runUdpMethodsParallel(
32136
+ viableMethods,
32137
+ async (m, isInnerAborted) => {
32138
+ const isAborted = () => udpRaceAbort.signal.aborted || isInnerAborted();
32139
+ if (isAborted()) {
32140
+ throw new Error(
32141
+ `UDP(${m}) speculative race aborted before start`
32142
+ );
32143
+ }
32144
+ logger?.log?.(
32145
+ `[AutoDetect] (race) Trying UDP discovery method: ${m}...`
32146
+ );
32147
+ const udpApi = await withRetries(
32148
+ `UDP(${m})`,
32149
+ maxRetries,
32150
+ async (attempt) => {
32151
+ const apiInputs = {
32152
+ ...inputs,
32153
+ udpDiscoveryMethod: m
32154
+ };
32155
+ if (resolvedUid) apiInputs.uid = resolvedUid;
32156
+ const api = createBaichuanApi(apiInputs, "udp");
32157
+ try {
32158
+ await api.login();
32159
+ return api;
32160
+ } catch (e) {
32161
+ try {
32162
+ await api.close({
32163
+ reason: `autodetect:udp_failed:${m}:attempt_${attempt}`
32164
+ });
32165
+ } catch {
32166
+ }
32167
+ throw e;
32168
+ }
32169
+ },
32170
+ shouldRetryUdp,
32171
+ isAborted
32172
+ );
32173
+ if (isAborted()) {
32174
+ try {
32175
+ await udpApi.close({
32176
+ reason: "autodetect:udp_aborted_after_tcp_won"
32177
+ });
32178
+ } catch {
32179
+ }
32180
+ throw new Error(
32181
+ `UDP(${m}) speculative race aborted after login`
32182
+ );
32183
+ }
32184
+ return detectOverUdpApi(udpApi, m, resolvedUid ?? "");
32185
+ },
32186
+ "Speculative UDP race failed for all methods."
32187
+ );
32188
+ })() : void 0;
32189
+ speculativeUdpRace?.then(
32190
+ (udpResult) => {
32191
+ if (udpRaceAbort.signal.aborted && udpResult?.api) {
32192
+ udpResult.api.close({ reason: "autodetect:tcp_won_race" }).catch(() => void 0);
32193
+ }
32194
+ },
32195
+ () => void 0
32196
+ );
32197
+ const _tcpWin = (result) => {
32198
+ udpRaceAbort.abort();
32199
+ logger?.log?.(
32200
+ `[AutoDetect] DONE in ${Date.now() - autodetectStartedAt}ms via TCP \u2014 type=${result.type} model=${result.deviceInfo?.type ?? "?"} channels=${result.channelNum}`
32201
+ );
32202
+ return result;
32203
+ };
31823
32204
  let tcpApi;
31824
32205
  try {
31825
32206
  logger?.log?.(`[AutoDetect] Trying TCP connection to ${host}...`);
@@ -31829,7 +32210,7 @@ async function autoDetectDeviceType(inputs) {
31829
32210
  async (attempt) => {
31830
32211
  const api2 = createBaichuanApi(inputs, "tcp");
31831
32212
  try {
31832
- await api2.login();
32213
+ await withTcpDeadline(api2.login());
31833
32214
  return api2;
31834
32215
  } catch (e) {
31835
32216
  try {
@@ -31941,7 +32322,7 @@ async function autoDetectDeviceType(inputs) {
31941
32322
  logger?.log?.(
31942
32323
  `[AutoDetect] Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum})`
31943
32324
  );
31944
- return {
32325
+ return _tcpWin({
31945
32326
  type: "multifocal",
31946
32327
  transport: "tcp",
31947
32328
  uid: effectiveUid || uid || "",
@@ -31949,13 +32330,13 @@ async function autoDetectDeviceType(inputs) {
31949
32330
  // ...(hostNetworkInfo ? { hostNetworkInfo } : {}),
31950
32331
  channelNum: effectiveChannelNum,
31951
32332
  api
31952
- };
32333
+ });
31953
32334
  }
31954
32335
  if (effectiveChannelNum > 1) {
31955
32336
  logger?.log?.(
31956
32337
  `[AutoDetect] Detected NVR (${effectiveChannelNum} channels)`
31957
32338
  );
31958
- return {
32339
+ return _tcpWin({
31959
32340
  type: "nvr",
31960
32341
  transport: "tcp",
31961
32342
  uid: effectiveUid || uid || "",
@@ -31963,10 +32344,10 @@ async function autoDetectDeviceType(inputs) {
31963
32344
  // ...(hostNetworkInfo ? { hostNetworkInfo } : {}),
31964
32345
  channelNum: effectiveChannelNum,
31965
32346
  api
31966
- };
32347
+ });
31967
32348
  }
31968
32349
  logger?.log?.(`[AutoDetect] Detected regular camera (single channel)`);
31969
- return {
32350
+ return _tcpWin({
31970
32351
  type: "camera",
31971
32352
  transport: "tcp",
31972
32353
  uid: effectiveUid || uid || "",
@@ -31974,7 +32355,7 @@ async function autoDetectDeviceType(inputs) {
31974
32355
  // ...(hostNetworkInfo ? { hostNetworkInfo } : {}),
31975
32356
  channelNum: 1,
31976
32357
  api
31977
- };
32358
+ });
31978
32359
  } catch (tcpError) {
31979
32360
  if (mode === "tcp") {
31980
32361
  throw tcpError;
@@ -31989,100 +32370,20 @@ async function autoDetectDeviceType(inputs) {
31989
32370
  throw tcpError;
31990
32371
  }
31991
32372
  logger?.log?.(`[AutoDetect] TCP failed, trying UDP...`);
31992
- let normalizedUid = await speculativeUidPromise;
31993
- if (!normalizedUid) {
31994
- logger?.log?.(
31995
- `[AutoDetect] UID discovery failed; only local-direct can run without a UID. If the camera is sleeping or on a different subnet, supply its UID to enable BCUDP P2P fallback (remote/relay/map) which can wake it via Reolink's servers.`
31996
- );
31997
- } else if (effectiveUid === void 0) {
31998
- logger?.log?.(
31999
- `[AutoDetect] UID resolved via concurrent broadcast discovery: ${normalizedUid}`
32373
+ if (!speculativeUdpRace) {
32374
+ throw new Error(
32375
+ `AutoDetect internal: speculative UDP race missing in mode=${mode}`
32000
32376
  );
32001
32377
  }
32002
32378
  try {
32003
- const detectOverUdpApi = async (udpApi, udpDiscoveryMethod) => {
32004
- const [deviceInfo, capabilities, hostNetworkInfo] = await Promise.all([
32005
- udpApi.getInfo(),
32006
- udpApi.getDeviceCapabilities(),
32007
- udpApi.getNetworkInfo(void 0, { timeoutMs: 1200 }).catch(() => void 0)
32008
- ]);
32009
- const channelNum = capabilities?.support?.channelNum ?? 1;
32010
- const model = deviceInfo.type?.trim();
32011
- const normalizedModel = model ? model.trim() : void 0;
32012
- const isMultifocalByModel = normalizedModel ? isDualLenseModel(normalizedModel) : false;
32013
- const channelNumValue = typeof channelNum === "string" ? Number.parseInt(channelNum, 10) : channelNum;
32014
- const hasDualLensChannelCount = (channelNumValue === 2 || channelNumValue === 3) && Number.isFinite(channelNumValue);
32015
- const isMultifocal = isMultifocalByModel || hasDualLensChannelCount;
32016
- const hasBattery = capabilities?.capabilities?.hasBattery === true;
32017
- udpApi.setIdleDisconnect(hasBattery);
32018
- if (isMultifocal) {
32019
- const detectionMethod = isMultifocalByModel ? "model match" : "channelNum fallback";
32020
- logger?.log?.(
32021
- `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}, hasBattery=${hasBattery}).`
32022
- );
32023
- return {
32024
- type: "multifocal",
32025
- transport: "udp",
32026
- uid: normalizedUid ?? "",
32027
- udpDiscoveryMethod,
32028
- deviceInfo,
32029
- ...hostNetworkInfo ? { hostNetworkInfo } : {},
32030
- channelNum,
32031
- hasBattery,
32032
- api: udpApi
32033
- };
32034
- }
32035
- const deviceType = hasBattery ? "battery-cam" : "udp-camera";
32036
- logger?.log?.(
32037
- `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected ${deviceType} (hasBattery=${hasBattery}, model=${normalizedModel ?? "unknown"}).`
32038
- );
32039
- return {
32040
- type: deviceType,
32041
- transport: "udp",
32042
- uid: normalizedUid ?? "",
32043
- udpDiscoveryMethod,
32044
- deviceInfo,
32045
- ...hostNetworkInfo ? { hostNetworkInfo } : {},
32046
- channelNum: 1,
32047
- hasBattery,
32048
- api: udpApi
32049
- };
32050
- };
32051
- const viableMethods = selectViableUdpMethods(Boolean(normalizedUid));
32052
- return await runUdpMethodsParallel(
32053
- viableMethods,
32054
- async (m, isAborted) => {
32055
- logger?.log?.(`[AutoDetect] Trying UDP discovery method: ${m}...`);
32056
- const udpApi = await withRetries(
32057
- `UDP(${m})`,
32058
- maxRetries,
32059
- async (attempt) => {
32060
- const apiInputs = { ...inputs, udpDiscoveryMethod: m };
32061
- if (normalizedUid) apiInputs.uid = normalizedUid;
32062
- const api = createBaichuanApi(apiInputs, "udp");
32063
- try {
32064
- await api.login();
32065
- return api;
32066
- } catch (e) {
32067
- try {
32068
- await api.close({
32069
- reason: `autodetect:udp_failed:${m}:attempt_${attempt}`
32070
- });
32071
- } catch {
32072
- }
32073
- throw e;
32074
- }
32075
- },
32076
- shouldRetryUdp,
32077
- isAborted
32078
- );
32079
- return detectOverUdpApi(udpApi, m);
32080
- },
32081
- "UDP discovery failed for all methods."
32379
+ const udpResult = await speculativeUdpRace;
32380
+ logger?.log?.(
32381
+ `[AutoDetect] DONE in ${Date.now() - autodetectStartedAt}ms via UDP \u2014 type=${udpResult.type} method=${udpResult.udpDiscoveryMethod ?? "n/a"} model=${udpResult.deviceInfo?.type ?? "?"} channels=${udpResult.channelNum}`
32082
32382
  );
32383
+ return udpResult;
32083
32384
  } catch (udpError) {
32084
32385
  logger?.log?.(
32085
- `[AutoDetect] Both TCP and UDP failed. TCP error: ${tcpError}, UDP error: ${udpError}`
32386
+ `[AutoDetect] FAILED after ${Date.now() - autodetectStartedAt}ms \u2014 neither TCP nor UDP could reach the camera. TCP: ${tcpError?.message ?? tcpError}. UDP: ${udpError?.message ?? udpError}`
32086
32387
  );
32087
32388
  throw new Error(
32088
32389
  `Failed to connect via both TCP and UDP. TCP: ${tcpError?.message || tcpError}, UDP: ${udpError?.message || udpError}`