@apocaliss92/nodelink-js 0.1.9 → 0.1.17

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.
@@ -58,6 +58,7 @@ import {
58
58
  BC_CMD_ID_GET_WIFI,
59
59
  BC_CMD_ID_GET_WIFI_SIGNAL,
60
60
  BC_CMD_ID_GET_ZOOM_FOCUS,
61
+ BC_CMD_ID_LOGOUT,
61
62
  BC_CMD_ID_PING,
62
63
  BC_CMD_ID_PTZ_CONTROL,
63
64
  BC_CMD_ID_PTZ_CONTROL_PRESET,
@@ -101,6 +102,7 @@ import {
101
102
  buildChannelExtensionXml,
102
103
  buildFloodlightManualXml,
103
104
  buildLoginXml,
105
+ buildLogoutXml,
104
106
  buildPreviewStopXml,
105
107
  buildPreviewStopXmlV11,
106
108
  buildPreviewXml,
@@ -134,7 +136,7 @@ import {
134
136
  talkTraceLog,
135
137
  traceLog,
136
138
  xmlEscape
137
- } from "./chunk-TZFZ5WJX.js";
139
+ } from "./chunk-ZE7D7LI4.js";
138
140
 
139
141
  // src/protocol/framing.ts
140
142
  function encodeHeader(h) {
@@ -692,7 +694,9 @@ var BcUdpStream = class extends EventEmitter {
692
694
  });
693
695
  sock.on("error", (e) => this.emit("error", e));
694
696
  sock.on("close", () => this.emit("close"));
695
- await new Promise((resolve) => sock.bind(0, "0.0.0.0", () => resolve()));
697
+ await new Promise(
698
+ (resolve) => sock.bind(0, "0.0.0.0", () => resolve())
699
+ );
696
700
  if (this.opts.mode === "direct") {
697
701
  this.remote = { host: this.opts.host, port: this.opts.port };
698
702
  this.clientId = this.opts.clientId;
@@ -703,7 +707,8 @@ var BcUdpStream = class extends EventEmitter {
703
707
  this.startTimers();
704
708
  }
705
709
  async discoveryUid(sock) {
706
- if (this.opts.mode !== "uid") throw new Error("Internal: discoveryUid called for non-uid mode");
710
+ if (this.opts.mode !== "uid")
711
+ throw new Error("Internal: discoveryUid called for non-uid mode");
707
712
  const method = this.opts.discoveryMethod ?? "local-direct";
708
713
  if (method === "local-broadcast" || method === "local-direct") {
709
714
  await this.discoveryUidLocal(sock, { localMode: method });
@@ -725,10 +730,13 @@ var BcUdpStream = class extends EventEmitter {
725
730
  }
726
731
  }
727
732
  }
728
- throw new Error("No non-loopback IPv4 address found (required for P2P discovery)");
733
+ throw new Error(
734
+ "No non-loopback IPv4 address found (required for P2P discovery)"
735
+ );
729
736
  }
730
737
  async discoveryUidP2p(sock, method) {
731
- if (this.opts.mode !== "uid") throw new Error("Internal: discoveryUidP2p called for non-uid mode");
738
+ if (this.opts.mode !== "uid")
739
+ throw new Error("Internal: discoveryUidP2p called for non-uid mode");
732
740
  const addr = sock.address();
733
741
  const localPort = typeof addr === "string" ? 0 : addr.port;
734
742
  const cid = Math.floor(Math.random() * 2147483647) | 0 || 82e3;
@@ -741,7 +749,11 @@ var BcUdpStream = class extends EventEmitter {
741
749
  reg: lookup.reg
742
750
  });
743
751
  const conn = method === "remote" ? "local" : method;
744
- const connected = await this.p2pConnect(sock, { ...reg, uid: this.opts.uid, cid }, method);
752
+ const connected = await this.p2pConnect(
753
+ sock,
754
+ { ...reg, uid: this.opts.uid, cid },
755
+ method
756
+ );
745
757
  await this.p2pConfirm(sock, reg.reg, {
746
758
  sid: reg.sid,
747
759
  conn,
@@ -760,13 +772,16 @@ var BcUdpStream = class extends EventEmitter {
760
772
  try {
761
773
  const answers = await dns.lookup(host, { family: 4, all: true });
762
774
  for (const a of answers) {
763
- if (a.address && !resolved.includes(a.address)) resolved.push(a.address);
775
+ if (a.address && !resolved.includes(a.address))
776
+ resolved.push(a.address);
764
777
  }
765
778
  } catch {
766
779
  }
767
780
  }
768
781
  if (resolved.length === 0) {
769
- throw new Error("P2P UID lookup failed: no p2p.reolink.com addresses resolved");
782
+ throw new Error(
783
+ "P2P UID lookup failed: no p2p.reolink.com addresses resolved"
784
+ );
770
785
  }
771
786
  const start = Date.now();
772
787
  let lastErr;
@@ -774,7 +789,12 @@ var BcUdpStream = class extends EventEmitter {
774
789
  const remaining = P2P_MAX_WAIT_MS - (Date.now() - start);
775
790
  if (remaining <= 0) break;
776
791
  try {
777
- const res = await this.p2pUidLookupOne(sock, uid, { host: ip, port: P2P_LOOKUP_PORT }, Math.min(remaining, 3e3));
792
+ const res = await this.p2pUidLookupOne(
793
+ sock,
794
+ uid,
795
+ { host: ip, port: P2P_LOOKUP_PORT },
796
+ Math.min(remaining, 3e3)
797
+ );
778
798
  return res;
779
799
  } catch (e) {
780
800
  lastErr = e instanceof Error ? e : new Error(String(e));
@@ -896,26 +916,64 @@ var BcUdpStream = class extends EventEmitter {
896
916
  async p2pConnect(sock, reg, method) {
897
917
  if (method === "remote") {
898
918
  const tasks = [];
899
- if (reg.dev) tasks.push(this.p2pClientInitiated(sock, { sid: reg.sid, cid: reg.cid, conn: "local", dest: reg.dev }));
900
- if (reg.dmap) tasks.push(this.p2pDeviceInitiated(sock, { sid: reg.sid, cid: reg.cid, conn: "local", expectFrom: reg.dmap }));
901
- if (tasks.length === 0) throw new Error("P2P remote discovery: missing dev/dmap addresses");
919
+ if (reg.dev)
920
+ tasks.push(
921
+ this.p2pClientInitiated(sock, {
922
+ sid: reg.sid,
923
+ cid: reg.cid,
924
+ conn: "local",
925
+ dest: reg.dev
926
+ })
927
+ );
928
+ if (reg.dmap)
929
+ tasks.push(
930
+ this.p2pDeviceInitiated(sock, {
931
+ sid: reg.sid,
932
+ cid: reg.cid,
933
+ conn: "local",
934
+ expectFrom: reg.dmap
935
+ })
936
+ );
937
+ if (tasks.length === 0)
938
+ throw new Error("P2P remote discovery: missing dev/dmap addresses");
902
939
  return await Promise.race(tasks);
903
940
  }
904
941
  if (method === "map") {
905
942
  if (!reg.dmap) throw new Error("P2P map discovery: missing dmap address");
906
- return await this.p2pDeviceInitiated(sock, { sid: reg.sid, cid: reg.cid, conn: "map", expectFrom: reg.dmap });
943
+ return await this.p2pDeviceInitiated(sock, {
944
+ sid: reg.sid,
945
+ cid: reg.cid,
946
+ conn: "map",
947
+ expectFrom: reg.dmap
948
+ });
907
949
  }
908
- if (!reg.relay) throw new Error("P2P relay discovery: missing relay address");
909
- return await this.p2pClientInitiated(sock, { sid: reg.sid, cid: reg.cid, conn: "relay", dest: reg.relay, requireConnMatch: true });
950
+ if (!reg.relay)
951
+ throw new Error("P2P relay discovery: missing relay address");
952
+ return await this.p2pClientInitiated(sock, {
953
+ sid: reg.sid,
954
+ cid: reg.cid,
955
+ conn: "relay",
956
+ dest: reg.relay,
957
+ requireConnMatch: true
958
+ });
910
959
  }
911
960
  async p2pClientInitiated(sock, params) {
912
961
  const tid = (Math.floor(Math.random() * 2147483647) | 0) >>> 0;
913
- const xml = buildC2dT({ sid: params.sid, conn: params.conn, cid: params.cid, mtu: this.mtu });
962
+ const xml = buildC2dT({
963
+ sid: params.sid,
964
+ conn: params.conn,
965
+ cid: params.cid,
966
+ mtu: this.mtu
967
+ });
914
968
  const pkt = encodeDiscoveryPacket(tid, xml);
915
969
  return await new Promise((resolve, reject) => {
916
970
  const timeout = setTimeout(() => {
917
971
  cleanup();
918
- reject(new Error(`P2P client-initiated (${params.conn}) timeout after ${P2P_MAX_WAIT_MS}ms`));
972
+ reject(
973
+ new Error(
974
+ `P2P client-initiated (${params.conn}) timeout after ${P2P_MAX_WAIT_MS}ms`
975
+ )
976
+ );
919
977
  }, P2P_MAX_WAIT_MS);
920
978
  const onMsg = (msg, rinfo) => {
921
979
  try {
@@ -925,10 +983,16 @@ var BcUdpStream = class extends EventEmitter {
925
983
  if (!cfm) return;
926
984
  if (cfm.cid !== params.cid) return;
927
985
  if (cfm.sid !== params.sid) return;
928
- if (params.requireConnMatch && (cfm.conn ?? "") !== params.conn) return;
986
+ if (params.requireConnMatch && (cfm.conn ?? "") !== params.conn)
987
+ return;
929
988
  if (cfm.did == null) return;
930
989
  cleanup();
931
- resolve({ did: cfm.did, rhost: rinfo.address, rport: rinfo.port, tid });
990
+ resolve({
991
+ did: cfm.did,
992
+ rhost: rinfo.address,
993
+ rport: rinfo.port,
994
+ tid
995
+ });
932
996
  } catch {
933
997
  }
934
998
  };
@@ -952,7 +1016,11 @@ var BcUdpStream = class extends EventEmitter {
952
1016
  return await new Promise((resolve, reject) => {
953
1017
  const timeout = setTimeout(() => {
954
1018
  cleanup();
955
- reject(new Error(`P2P device-initiated (${params.conn}) timeout after ${P2P_MAX_WAIT_MS}ms`));
1019
+ reject(
1020
+ new Error(
1021
+ `P2P device-initiated (${params.conn}) timeout after ${P2P_MAX_WAIT_MS}ms`
1022
+ )
1023
+ );
956
1024
  }, P2P_MAX_WAIT_MS);
957
1025
  let state = "wait_t";
958
1026
  let did;
@@ -961,9 +1029,16 @@ var BcUdpStream = class extends EventEmitter {
961
1029
  let rport;
962
1030
  let resendA;
963
1031
  const sendA = () => {
964
- if (state !== "wait_cfm" || did == null || tid == null || rhost == null || rport == null) return;
1032
+ if (state !== "wait_cfm" || did == null || tid == null || rhost == null || rport == null)
1033
+ return;
965
1034
  try {
966
- const aXml = buildC2dA({ sid: params.sid, conn: params.conn, cid: params.cid, did, mtu: this.mtu });
1035
+ const aXml = buildC2dA({
1036
+ sid: params.sid,
1037
+ conn: params.conn,
1038
+ cid: params.cid,
1039
+ did,
1040
+ mtu: this.mtu
1041
+ });
967
1042
  const aPkt = encodeDiscoveryPacket(tid, aXml);
968
1043
  sock.send(aPkt, rport, rhost);
969
1044
  } catch {
@@ -999,7 +1074,12 @@ var BcUdpStream = class extends EventEmitter {
999
1074
  if (cfm.did !== did) return;
1000
1075
  if ((cfm.conn ?? "") !== params.conn) return;
1001
1076
  cleanup();
1002
- resolve({ did, rhost: info.address, rport: info.port, tid: tid ?? 0 });
1077
+ resolve({
1078
+ did,
1079
+ rhost: info.address,
1080
+ rport: info.port,
1081
+ tid: tid ?? 0
1082
+ });
1003
1083
  } catch {
1004
1084
  }
1005
1085
  };
@@ -1012,7 +1092,13 @@ var BcUdpStream = class extends EventEmitter {
1012
1092
  });
1013
1093
  }
1014
1094
  async p2pConfirm(sock, reg, params) {
1015
- const xml = buildC2rCfm({ sid: params.sid, conn: params.conn, rsp: 0, cid: params.cid, did: params.did });
1095
+ const xml = buildC2rCfm({
1096
+ sid: params.sid,
1097
+ conn: params.conn,
1098
+ rsp: 0,
1099
+ cid: params.cid,
1100
+ did: params.did
1101
+ });
1016
1102
  for (let i = 0; i < 5; i++) {
1017
1103
  const tid = (Math.floor(Math.random() * 2147483647) | 0) >>> 0;
1018
1104
  const pkt = encodeDiscoveryPacket(tid, xml);
@@ -1024,8 +1110,12 @@ var BcUdpStream = class extends EventEmitter {
1024
1110
  }
1025
1111
  }
1026
1112
  async discoveryUidLocal(sock, opts) {
1027
- if (this.opts.mode !== "uid") throw new Error("Internal: discoveryUidLocal called for non-uid mode");
1028
- const ports = [BCUDP_DISCOVERY_PORT_LOCAL_ANY, BCUDP_DISCOVERY_PORT_LOCAL_UID];
1113
+ if (this.opts.mode !== "uid")
1114
+ throw new Error("Internal: discoveryUidLocal called for non-uid mode");
1115
+ const ports = [
1116
+ BCUDP_DISCOVERY_PORT_LOCAL_ANY,
1117
+ BCUDP_DISCOVERY_PORT_LOCAL_UID
1118
+ ];
1029
1119
  const broadcastHost = "255.255.255.255";
1030
1120
  const directHost = (this.opts.directHost ?? "").trim();
1031
1121
  const localMode = opts?.localMode ?? "local-broadcast";
@@ -1037,12 +1127,21 @@ var BcUdpStream = class extends EventEmitter {
1037
1127
  const addr = sock.address();
1038
1128
  const localPort = typeof addr === "string" ? 0 : addr.port;
1039
1129
  const cid = Math.floor(Math.random() * 2147483647) | 0 || 82e3;
1040
- const xml = buildC2dC({ uid: this.opts.uid, clientPort: localPort, cid, mtu: this.mtu });
1130
+ const xml = buildC2dC({
1131
+ uid: this.opts.uid,
1132
+ clientPort: localPort,
1133
+ cid,
1134
+ mtu: this.mtu
1135
+ });
1041
1136
  const reply = await new Promise((resolve, reject) => {
1042
1137
  const timeout = setTimeout(() => {
1043
1138
  if (retryTimer) clearInterval(retryTimer);
1044
1139
  sock.off("message", onMsg);
1045
- reject(new Error(`BCUDP discovery timeout after ${discoveryTimeout}ms (camera may be sleeping or unreachable)`));
1140
+ reject(
1141
+ new Error(
1142
+ `BCUDP discovery timeout after ${discoveryTimeout}ms (camera may be sleeping or unreachable)`
1143
+ )
1144
+ );
1046
1145
  }, discoveryTimeout);
1047
1146
  let retryTimer;
1048
1147
  let retryCount = 0;
@@ -1064,8 +1163,16 @@ var BcUdpStream = class extends EventEmitter {
1064
1163
  sock.off("message", onMsg);
1065
1164
  clearTimeout(timeout);
1066
1165
  if (retryTimer) clearInterval(retryTimer);
1067
- this.emit("debug", "discovery_finalize", { reason, ...sid != null ? { sid } : {}, ...d });
1068
- resolve({ ...d, ...sid != null ? { sid } : {}, ...tid != null ? { tid } : {} });
1166
+ this.emit("debug", "discovery_finalize", {
1167
+ reason,
1168
+ ...sid != null ? { sid } : {},
1169
+ ...d
1170
+ });
1171
+ resolve({
1172
+ ...d,
1173
+ ...sid != null ? { sid } : {},
1174
+ ...tid != null ? { tid } : {}
1175
+ });
1069
1176
  }, 250);
1070
1177
  };
1071
1178
  const sendT = (rhost, rport) => {
@@ -1074,11 +1181,22 @@ var BcUdpStream = class extends EventEmitter {
1074
1181
  if (discoveredSid == null) return;
1075
1182
  try {
1076
1183
  const tid = (Math.floor(Math.random() * 2147483647) | 0) >>> 0;
1077
- const tXml = buildC2dT({ ...discoveredSid != null ? { sid: discoveredSid } : {}, cid: discovered.cid, mtu: this.mtu, conn: "local" });
1184
+ const tXml = buildC2dT({
1185
+ ...discoveredSid != null ? { sid: discoveredSid } : {},
1186
+ cid: discovered.cid,
1187
+ mtu: this.mtu,
1188
+ conn: "local"
1189
+ });
1078
1190
  const tPkt = encodeDiscoveryPacket(tid, tXml);
1079
1191
  sock.send(tPkt, rport, rhost);
1080
1192
  sentT = true;
1081
- this.emit("debug", "discovery_t_send", { sid: discoveredSid, cid: discovered.cid, did: discovered.did, rhost, rport });
1193
+ this.emit("debug", "discovery_t_send", {
1194
+ sid: discoveredSid,
1195
+ cid: discovered.cid,
1196
+ did: discovered.did,
1197
+ rhost,
1198
+ rport
1199
+ });
1082
1200
  } catch (e) {
1083
1201
  this.emit("debug", "discovery_t_send_error", e);
1084
1202
  }
@@ -1086,11 +1204,23 @@ var BcUdpStream = class extends EventEmitter {
1086
1204
  const sendA = (tid, rhost, rport, dt) => {
1087
1205
  if (sentA) return;
1088
1206
  try {
1089
- const aXml = buildC2dA({ sid: dt.sid, conn: dt.conn ?? "local", cid: dt.cid, did: dt.did, mtu: this.mtu });
1207
+ const aXml = buildC2dA({
1208
+ sid: dt.sid,
1209
+ conn: dt.conn ?? "local",
1210
+ cid: dt.cid,
1211
+ did: dt.did,
1212
+ mtu: this.mtu
1213
+ });
1090
1214
  const aPkt = encodeDiscoveryPacket(tid, aXml);
1091
1215
  sock.send(aPkt, rport, rhost);
1092
1216
  sentA = true;
1093
- this.emit("debug", "discovery_a_send", { sid: dt.sid, cid: dt.cid, did: dt.did, rhost, rport });
1217
+ this.emit("debug", "discovery_a_send", {
1218
+ sid: dt.sid,
1219
+ cid: dt.cid,
1220
+ did: dt.did,
1221
+ rhost,
1222
+ rport
1223
+ });
1094
1224
  } catch (e) {
1095
1225
  this.emit("debug", "discovery_a_send_error", e);
1096
1226
  }
@@ -1099,12 +1229,22 @@ var BcUdpStream = class extends EventEmitter {
1099
1229
  try {
1100
1230
  const p = decodeBcUdpPacket(msg);
1101
1231
  if (p.kind !== "discovery") return;
1102
- this.emit("debug", "discovery_rx", { tid: p.tid, rhost: rinfo.address, rport: rinfo.port, xmlPreview: p.xml.slice(0, 120) });
1232
+ this.emit("debug", "discovery_rx", {
1233
+ tid: p.tid,
1234
+ rhost: rinfo.address,
1235
+ rport: rinfo.port,
1236
+ xmlPreview: p.xml.slice(0, 120)
1237
+ });
1103
1238
  const cfm = parseD2cCfm(p.xml);
1104
1239
  if (cfm) {
1105
1240
  discoveredSid = cfm.sid;
1106
1241
  if (!discovered && cfm.cid != null && cfm.did != null) {
1107
- discovered = { cid: cfm.cid, did: cfm.did, rhost: rinfo.address, rport: rinfo.port };
1242
+ discovered = {
1243
+ cid: cfm.cid,
1244
+ did: cfm.did,
1245
+ rhost: rinfo.address,
1246
+ rport: rinfo.port
1247
+ };
1108
1248
  }
1109
1249
  if (discovered) {
1110
1250
  sendT(rinfo.address, rinfo.port);
@@ -1116,9 +1256,20 @@ var BcUdpStream = class extends EventEmitter {
1116
1256
  gotT = true;
1117
1257
  discoveredSid = dt.sid;
1118
1258
  if (!discovered) {
1119
- discovered = { cid: dt.cid, did: dt.did, rhost: rinfo.address, rport: rinfo.port };
1259
+ discovered = {
1260
+ cid: dt.cid,
1261
+ did: dt.did,
1262
+ rhost: rinfo.address,
1263
+ rport: rinfo.port
1264
+ };
1120
1265
  }
1121
- this.emit("debug", "discovery_t_rx", { sid: dt.sid, cid: dt.cid, did: dt.did, rhost: rinfo.address, rport: rinfo.port });
1266
+ this.emit("debug", "discovery_t_rx", {
1267
+ sid: dt.sid,
1268
+ cid: dt.cid,
1269
+ did: dt.did,
1270
+ rhost: rinfo.address,
1271
+ rport: rinfo.port
1272
+ });
1122
1273
  sendA(p.tid, rinfo.address, rinfo.port, dt);
1123
1274
  maybeFinalize("t_a");
1124
1275
  return;
@@ -1126,9 +1277,20 @@ var BcUdpStream = class extends EventEmitter {
1126
1277
  const parsed = parseD2cCr(p.xml);
1127
1278
  if (!parsed) return;
1128
1279
  if (parsed.rsp !== 0) return;
1129
- this.emit("debug", "discovery_success", { retryCount, rhost: rinfo.address, rport: rinfo.port, sid: parsed.sid ?? discoveredSid, timer: parsed.timer });
1280
+ this.emit("debug", "discovery_success", {
1281
+ retryCount,
1282
+ rhost: rinfo.address,
1283
+ rport: rinfo.port,
1284
+ sid: parsed.sid ?? discoveredSid,
1285
+ timer: parsed.timer
1286
+ });
1130
1287
  discoveredTid = p.tid;
1131
- discovered = { cid: parsed.cid, did: parsed.did, rhost: rinfo.address, rport: rinfo.port };
1288
+ discovered = {
1289
+ cid: parsed.cid,
1290
+ did: parsed.did,
1291
+ rhost: rinfo.address,
1292
+ rport: rinfo.port
1293
+ };
1132
1294
  if (parsed.sid != null) discoveredSid = parsed.sid;
1133
1295
  sendT(rinfo.address, rinfo.port);
1134
1296
  if (gotT || sentA) {
@@ -1155,7 +1317,8 @@ var BcUdpStream = class extends EventEmitter {
1155
1317
  const hosts = (() => {
1156
1318
  if (localMode === "local-direct") {
1157
1319
  if (directHost) {
1158
- if (directFirstWindowMs > 0 && elapsedMs < directFirstWindowMs) return [directHost];
1320
+ if (directFirstWindowMs > 0 && elapsedMs < directFirstWindowMs)
1321
+ return [directHost];
1159
1322
  return [directHost, broadcastHost];
1160
1323
  }
1161
1324
  return [broadcastHost];
@@ -1214,12 +1377,18 @@ var BcUdpStream = class extends EventEmitter {
1214
1377
  }, 1e3);
1215
1378
  }
1216
1379
  sendHeartbeat() {
1217
- if (!this.sock || !this.remote || this.clientId == null || this.cameraId == null) return;
1380
+ if (!this.sock || !this.remote || this.clientId == null || this.cameraId == null)
1381
+ return;
1218
1382
  const tid = this.getKeepAliveTid();
1219
1383
  let xml;
1220
1384
  xml = buildC2dHb({ cid: this.clientId, did: this.cameraId });
1221
1385
  const pkt = encodeDiscoveryPacket(tid, xml);
1222
- this.emit("debug", "udp_hb_send", { tid, xml, host: this.remote.host, port: this.remote.port });
1386
+ this.emit("debug", "udp_hb_send", {
1387
+ tid,
1388
+ xml,
1389
+ host: this.remote.host,
1390
+ port: this.remote.port
1391
+ });
1223
1392
  this.sock.send(pkt, this.remote.port, this.remote.host);
1224
1393
  }
1225
1394
  buildAckPayload() {
@@ -1263,7 +1432,9 @@ var BcUdpStream = class extends EventEmitter {
1263
1432
  this.sock.send(this.lastAckPacket, this.remote.port, this.remote.host);
1264
1433
  this.ackSentCount++;
1265
1434
  if (this.ackSentCount % 100 === 0) {
1266
- this.emit("debug", "ack_sent_100", { latency: this.ackLatency.getValue() });
1435
+ this.emit("debug", "ack_sent_100", {
1436
+ latency: this.ackLatency.getValue()
1437
+ });
1267
1438
  }
1268
1439
  }
1269
1440
  scheduleAck(reason = "data") {
@@ -1338,7 +1509,11 @@ var BcUdpStream = class extends EventEmitter {
1338
1509
  const updateRemote = (reason) => {
1339
1510
  if (!this.remote || this.remote.host !== rhost || this.remote.port !== rport) {
1340
1511
  this.remote = { host: rhost, port: rport };
1341
- this.emit("debug", "remote_update", { reason, host: rhost, port: rport });
1512
+ this.emit("debug", "remote_update", {
1513
+ reason,
1514
+ host: rhost,
1515
+ port: rport
1516
+ });
1342
1517
  }
1343
1518
  };
1344
1519
  if (!this.remote) updateRemote("first_packet");
@@ -1357,7 +1532,9 @@ var BcUdpStream = class extends EventEmitter {
1357
1532
  this.flushReceived();
1358
1533
  this.updateAckPacket();
1359
1534
  if (this.packetsWant % 100 === 0) {
1360
- this.emit("debug", "udp_progress", { packetsWant: this.packetsWant });
1535
+ this.emit("debug", "udp_progress", {
1536
+ packetsWant: this.packetsWant
1537
+ });
1361
1538
  }
1362
1539
  }
1363
1540
  }
@@ -1367,7 +1544,11 @@ var BcUdpStream = class extends EventEmitter {
1367
1544
  this.emit("debug", "discovery_rx_connected", { tid: p.tid, xml: p.xml });
1368
1545
  const hb = parseD2cHb(p.xml);
1369
1546
  if (hb && this.clientId != null && this.cameraId != null && hb.cid === this.clientId && hb.did === this.cameraId) {
1370
- this.emit("debug", "discovery_hb_rx_connected", { ...hb, rhost, rport });
1547
+ this.emit("debug", "discovery_hb_rx_connected", {
1548
+ ...hb,
1549
+ rhost,
1550
+ rport
1551
+ });
1371
1552
  try {
1372
1553
  updateRemote("d2c_hb");
1373
1554
  this.sendHeartbeat();
@@ -1381,18 +1562,44 @@ var BcUdpStream = class extends EventEmitter {
1381
1562
  try {
1382
1563
  updateRemote("d2c_t");
1383
1564
  this.sid = dt.sid;
1384
- this.emit("debug", "discovery_t_rx_connected", { sid: dt.sid, cid: dt.cid, did: dt.did, rhost, rport });
1565
+ this.emit("debug", "discovery_t_rx_connected", {
1566
+ sid: dt.sid,
1567
+ cid: dt.cid,
1568
+ did: dt.did,
1569
+ rhost,
1570
+ rport
1571
+ });
1385
1572
  const now = Date.now();
1386
1573
  const throttleMs = 750;
1387
1574
  if (this.lastAcceptAtMs == null || now - this.lastAcceptAtMs >= throttleMs) {
1388
- const aXml = buildC2dA({ sid: dt.sid, conn: dt.conn ?? "local", cid: dt.cid, did: dt.did, mtu: this.mtu });
1575
+ const aXml = buildC2dA({
1576
+ sid: dt.sid,
1577
+ conn: dt.conn ?? "local",
1578
+ cid: dt.cid,
1579
+ did: dt.did,
1580
+ mtu: this.mtu
1581
+ });
1389
1582
  const aPkt = encodeDiscoveryPacket(p.tid, aXml);
1390
1583
  this.sock?.send(aPkt, rport, rhost);
1391
1584
  this.acceptSent = true;
1392
1585
  this.lastAcceptAtMs = now;
1393
- this.emit("debug", "discovery_a_send_connected", { sid: dt.sid, cid: dt.cid, did: dt.did, rhost, rport });
1586
+ this.emit("debug", "discovery_a_send_connected", {
1587
+ sid: dt.sid,
1588
+ cid: dt.cid,
1589
+ did: dt.did,
1590
+ rhost,
1591
+ rport
1592
+ });
1394
1593
  } else {
1395
- this.emit("debug", "discovery_a_skip_throttle", { sinceMs: now - this.lastAcceptAtMs, throttleMs, sid: dt.sid, cid: dt.cid, did: dt.did, rhost, rport });
1594
+ this.emit("debug", "discovery_a_skip_throttle", {
1595
+ sinceMs: now - this.lastAcceptAtMs,
1596
+ throttleMs,
1597
+ sid: dt.sid,
1598
+ cid: dt.cid,
1599
+ did: dt.did,
1600
+ rhost,
1601
+ rport
1602
+ });
1396
1603
  }
1397
1604
  } catch (e) {
1398
1605
  this.emit("debug", "discovery_a_send_connected_error", e);
@@ -1409,10 +1616,16 @@ var BcUdpStream = class extends EventEmitter {
1409
1616
  }
1410
1617
  const disc = parseD2cDisc(p.xml);
1411
1618
  if (disc && this.clientId != null && this.cameraId != null && disc.cid === this.clientId && disc.did === this.cameraId) {
1412
- this.emit("debug", "discovery_disc_rx_connected", { ...disc, rhost, rport });
1619
+ this.emit("debug", "discovery_disc_rx_connected", {
1620
+ ...disc,
1621
+ rhost,
1622
+ rport
1623
+ });
1413
1624
  this.emit(
1414
1625
  "error",
1415
- new Error(`BCUDP disconnected by camera (D2C_DISC${this.sid != null ? ` sid=${this.sid}` : ""})`)
1626
+ new Error(
1627
+ `BCUDP disconnected by camera (D2C_DISC${this.sid != null ? ` sid=${this.sid}` : ""})`
1628
+ )
1416
1629
  );
1417
1630
  void this.close();
1418
1631
  return;
@@ -1421,13 +1634,18 @@ var BcUdpStream = class extends EventEmitter {
1421
1634
  return;
1422
1635
  }
1423
1636
  write(buf) {
1424
- if (!this.sock || !this.remote || this.cameraId == null) throw new Error("BCUDP stream is not connected");
1637
+ if (!this.sock || !this.remote || this.cameraId == null)
1638
+ throw new Error("BCUDP stream is not connected");
1425
1639
  const maxPayload = this.mtu - BCUDP_DATA_HEADER_SIZE;
1426
1640
  for (let off = 0; off < buf.length; off += maxPayload) {
1427
1641
  const payload = buf.subarray(off, Math.min(buf.length, off + maxPayload));
1428
1642
  const packetId = this.sendPacketId >>> 0;
1429
1643
  this.sendPacketId = this.sendPacketId + 1 >>> 0;
1430
- const pkt = encodeDataPacket({ connectionId: this.cameraId, packetId, payload: Buffer.from(payload) });
1644
+ const pkt = encodeDataPacket({
1645
+ connectionId: this.cameraId,
1646
+ packetId,
1647
+ payload: Buffer.from(payload)
1648
+ });
1431
1649
  this.sent.set(packetId, { packetId, buf: pkt, ts: Date.now() });
1432
1650
  this.sock.send(pkt, this.remote.port, this.remote.host);
1433
1651
  }
@@ -1580,6 +1798,15 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1580
1798
  * even if the current client instance is idle/disconnected.
1581
1799
  */
1582
1800
  static streamingRegistry = /* @__PURE__ */ new Map();
1801
+ /**
1802
+ * Global (process-wide) CoverPreview serialization.
1803
+ *
1804
+ * Why:
1805
+ * - CoverPreview uses fixed msgNum=0 and some firmwares correlate requests loosely.
1806
+ * - Concurrent CoverPreview attempts can cause cross-talk (400 rejects / timeouts)
1807
+ * and may leave device-side sessions in a bad state.
1808
+ */
1809
+ static coverPreviewQueueTail = /* @__PURE__ */ new Map();
1583
1810
  opts;
1584
1811
  debugCfg;
1585
1812
  logger;
@@ -1598,6 +1825,14 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1598
1825
  socketClosed = false;
1599
1826
  pendingCloseInfo;
1600
1827
  lastDisconnectInfo;
1828
+ /** Tracks the most recent socket error code for inclusion in lastDisconnectInfo. */
1829
+ lastSocketErrorCode;
1830
+ /**
1831
+ * Promise lock to prevent parallel TCP connections.
1832
+ * When multiple callers race to connect, only the first one creates a socket;
1833
+ * others wait on the same promise.
1834
+ */
1835
+ tcpConnectPromise;
1601
1836
  msgNum = 0;
1602
1837
  loggedIn = false;
1603
1838
  // Public to allow ReolinkBaichuanApi to check login status
@@ -1689,6 +1924,23 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1689
1924
  isIdleDisconnectEnabled() {
1690
1925
  return this.opts.idleDisconnect === true;
1691
1926
  }
1927
+ /**
1928
+ * Enable or disable idle disconnect dynamically.
1929
+ *
1930
+ * This is useful when the battery status is discovered after connection
1931
+ * (e.g., during autodetect). Call with `true` for battery cameras to
1932
+ * preserve battery life by disconnecting when idle.
1933
+ *
1934
+ * @param enabled - true to enable idle disconnect, false to disable
1935
+ */
1936
+ setIdleDisconnect(enabled) {
1937
+ this.opts.idleDisconnect = enabled;
1938
+ if (enabled) {
1939
+ this.kickIdleDisconnectTimer();
1940
+ } else {
1941
+ this.clearIdleDisconnectTimer();
1942
+ }
1943
+ }
1692
1944
  clearIdleDisconnectTimer() {
1693
1945
  if (!this.idleDisconnectTimer) return;
1694
1946
  clearTimeout(this.idleDisconnectTimer);
@@ -1822,6 +2074,28 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1822
2074
  const channel = this.opts.channel ?? 0;
1823
2075
  return `${host}|${uid}|${channel}`;
1824
2076
  }
2077
+ getCoverPreviewQueueKey() {
2078
+ const host = (this.opts.host ?? "").trim();
2079
+ const uid = (this.opts.uid ?? "").trim().toUpperCase();
2080
+ return `coverpreview|${host}|${uid}`;
2081
+ }
2082
+ async withSerializedCoverPreview(fn) {
2083
+ const key = this.getCoverPreviewQueueKey();
2084
+ const prevTail = _BaichuanClient.coverPreviewQueueTail.get(key) ?? Promise.resolve();
2085
+ const run = prevTail.catch(() => void 0).then(fn);
2086
+ const tail = run.then(
2087
+ () => void 0,
2088
+ () => void 0
2089
+ );
2090
+ _BaichuanClient.coverPreviewQueueTail.set(key, tail);
2091
+ try {
2092
+ return await run;
2093
+ } finally {
2094
+ if (_BaichuanClient.coverPreviewQueueTail.get(key) === tail) {
2095
+ _BaichuanClient.coverPreviewQueueTail.delete(key);
2096
+ }
2097
+ }
2098
+ }
1825
2099
  recomputeGlobalStreamingContribution() {
1826
2100
  const shouldContribute = this.hasActiveVideoSubscriptionsInternal();
1827
2101
  if (shouldContribute === this.contributesToGlobalStreamingRegistry) return;
@@ -1856,6 +2130,62 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1856
2130
  this.emit("debug", event, data);
1857
2131
  }
1858
2132
  }
2133
+ getTcpSocketDebugSnapshot(sock) {
2134
+ if (!sock) return void 0;
2135
+ const s = sock;
2136
+ const snap = {
2137
+ destroyed: sock.destroyed,
2138
+ bytesRead: sock.bytesRead,
2139
+ bytesWritten: sock.bytesWritten
2140
+ };
2141
+ if (typeof s.readyState === "string") snap.readyState = s.readyState;
2142
+ if (typeof s.connecting === "boolean") snap.connecting = s.connecting;
2143
+ if (typeof s.pending === "boolean") snap.pending = s.pending;
2144
+ if (sock.localAddress != null) snap.localAddress = sock.localAddress;
2145
+ if (sock.localFamily != null) snap.localFamily = sock.localFamily;
2146
+ if (sock.localPort != null) snap.localPort = sock.localPort;
2147
+ if (sock.remoteAddress != null) snap.remoteAddress = sock.remoteAddress;
2148
+ if (sock.remoteFamily != null) snap.remoteFamily = sock.remoteFamily;
2149
+ if (sock.remotePort != null) snap.remotePort = sock.remotePort;
2150
+ if (typeof s.readableLength === "number")
2151
+ snap.readableLength = s.readableLength;
2152
+ if (typeof s.writableLength === "number")
2153
+ snap.writableLength = s.writableLength;
2154
+ if (typeof s.timeout === "number") snap.timeoutMs = s.timeout;
2155
+ return snap;
2156
+ }
2157
+ logSocketState(reason, extra) {
2158
+ try {
2159
+ const transport = this.transport;
2160
+ const sid = this.socketSessionId;
2161
+ const host = this.opts.host;
2162
+ const port = this.opts.port ?? BC_TCP_DEFAULT_PORT;
2163
+ const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
2164
+ this.logDebug("socket_state", {
2165
+ reason,
2166
+ transport,
2167
+ host,
2168
+ ...transport === "tcp" ? { port } : {},
2169
+ ...sid ? { sid } : {},
2170
+ ...shortUid ? { uid: shortUid } : {},
2171
+ socketClosed: this.socketClosed,
2172
+ socketConnected: this.isSocketConnected(),
2173
+ loggedIn: this.loggedIn,
2174
+ subscribed: this.subscribed,
2175
+ pending: this.pending.size,
2176
+ permits: this.permits.size,
2177
+ videoSubscriptions: this.videoSubscriptions.size,
2178
+ lastRxAtMs: this.lastRxAtMs,
2179
+ lastTxAtMs: this.lastTxAtMs,
2180
+ lastRxCmdId: this.lastRxInfo?.cmdId,
2181
+ lastTxCmdId: this.lastTxInfo?.cmdId,
2182
+ tcp: this.getTcpSocketDebugSnapshot(this.tcpSocket),
2183
+ udpConnected: this.udpSocket?.isConnected?.(),
2184
+ ...extra
2185
+ });
2186
+ } catch {
2187
+ }
2188
+ }
1859
2189
  getTransport() {
1860
2190
  return this.transport;
1861
2191
  }
@@ -1935,6 +2265,19 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
1935
2265
  getSocketSessionId() {
1936
2266
  return this.socketSessionId;
1937
2267
  }
2268
+ /**
2269
+ * Get the local IP address of the socket connection.
2270
+ * This is the IP address that the camera sees as the client IP.
2271
+ */
2272
+ getLocalAddress() {
2273
+ if (this.transport === "tcp" && this.tcpSocket) {
2274
+ return this.tcpSocket.localAddress;
2275
+ }
2276
+ if (this.transport === "udp" && this.udpSocket) {
2277
+ return this.udpSocket.localAddress?.();
2278
+ }
2279
+ return void 0;
2280
+ }
1938
2281
  /**
1939
2282
  * Check if the socket is connected and ready for operations.
1940
2283
  * For TCP: checks if socket exists and is not destroyed.
@@ -2093,7 +2436,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2093
2436
  if (waitMs <= 0) return;
2094
2437
  const sid = this.socketSessionId;
2095
2438
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
2096
- this.logFixed("udp_reconnect_cooldown", {
2439
+ this.logDebug("udp_reconnect_cooldown", {
2097
2440
  transport: "udp",
2098
2441
  host: this.opts.host,
2099
2442
  sid,
@@ -2103,6 +2446,33 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2103
2446
  await new Promise((resolve) => setTimeout(resolve, waitMs));
2104
2447
  }
2105
2448
  async connectTcp() {
2449
+ if (this.tcpConnectPromise) {
2450
+ this.logDebug(
2451
+ "tcp_connect_waiting",
2452
+ "Waiting for existing connection promise"
2453
+ );
2454
+ await this.tcpConnectPromise;
2455
+ return;
2456
+ }
2457
+ if (this.tcpSocket && !this.tcpSocket.destroyed) {
2458
+ this.transport = "tcp";
2459
+ return;
2460
+ }
2461
+ this.logDebug("tcp_connect_new", {
2462
+ hasSocket: !!this.tcpSocket,
2463
+ destroyed: this.tcpSocket?.destroyed
2464
+ });
2465
+ this.tcpConnectPromise = this.doConnectTcp();
2466
+ try {
2467
+ await this.tcpConnectPromise;
2468
+ } finally {
2469
+ this.tcpConnectPromise = void 0;
2470
+ }
2471
+ }
2472
+ /**
2473
+ * Internal TCP connection logic. Only called from connectTcp() with promise lock.
2474
+ */
2475
+ async doConnectTcp() {
2106
2476
  if (this.tcpSocket && !this.tcpSocket.destroyed) {
2107
2477
  this.transport = "tcp";
2108
2478
  return;
@@ -2122,7 +2492,8 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2122
2492
  const frames = this.parser.push(chunk);
2123
2493
  for (const f of frames) this.handleFrame(f);
2124
2494
  });
2125
- sock.on("close", () => {
2495
+ sock.on("close", (hadError) => {
2496
+ this.logSocketState("tcp_close_event", { hadError: Boolean(hadError) });
2126
2497
  const sid2 = this.socketSessionId;
2127
2498
  if (sock === this.tcpSocket) {
2128
2499
  this.tcpSocket = void 0;
@@ -2135,11 +2506,14 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2135
2506
  this.socketClosed = true;
2136
2507
  const pending = this.pendingCloseInfo;
2137
2508
  this.pendingCloseInfo = void 0;
2509
+ const errorCode = this.lastSocketErrorCode;
2510
+ this.lastSocketErrorCode = void 0;
2138
2511
  this.lastDisconnectInfo = {
2139
2512
  atMs: Date.now(),
2140
2513
  transport: "tcp",
2141
2514
  voluntary: pending != null,
2142
- reason: pending?.reason ?? "socket_closed"
2515
+ reason: pending?.reason ?? "socket_closed",
2516
+ ...errorCode != null && { errorCode }
2143
2517
  };
2144
2518
  const tcpDisconnectParts = [
2145
2519
  `transport=tcp`,
@@ -2168,7 +2542,17 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2168
2542
  });
2169
2543
  }
2170
2544
  });
2171
- sock.on("error", (err) => this.emit("error", err));
2545
+ sock.on("error", (err) => {
2546
+ const code = err?.code;
2547
+ this.lastSocketErrorCode = code;
2548
+ this.logSocketState("tcp_error", {
2549
+ error: {
2550
+ message: err?.message ?? String(err),
2551
+ code
2552
+ }
2553
+ });
2554
+ this.emit("error", err);
2555
+ });
2172
2556
  await new Promise((resolve, reject) => {
2173
2557
  sock.once("connect", () => resolve());
2174
2558
  sock.once("error", (e) => reject(e));
@@ -2186,6 +2570,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2186
2570
  "connected",
2187
2571
  `transport=tcp host=${this.opts.host} port=${port}${sid ? ` sid=${sid}` : ""}${remote ? ` remote=${remote}` : ""}${peer ? ` peer=${peer}` : ""}`
2188
2572
  );
2573
+ this.logSocketState("tcp_connected");
2189
2574
  this.startKeepAlive();
2190
2575
  this.kickIdleDisconnectTimer();
2191
2576
  }
@@ -2202,13 +2587,18 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2202
2587
  this.udpSocket = void 0;
2203
2588
  }
2204
2589
  if (!this.opts.uid) {
2205
- throw new Error(
2206
- "Baichuan UDP requested but `options.uid` is not set (required for BCUDP discovery)."
2207
- );
2590
+ const isLocalDirect = this.opts.udpDiscoveryMethod === "local-direct";
2591
+ const hasDirectHost = !!this.opts.host?.trim();
2592
+ if (!isLocalDirect || !hasDirectHost) {
2593
+ throw new Error(
2594
+ "Baichuan UDP requested but `options.uid` is not set (required for BCUDP discovery unless using local-direct with a direct host)."
2595
+ );
2596
+ }
2208
2597
  }
2209
2598
  const sock = new BcUdpStream({
2210
2599
  mode: "uid",
2211
- uid: this.opts.uid,
2600
+ // UID may be empty for local-direct with directHost
2601
+ uid: this.opts.uid ?? "",
2212
2602
  // If the camera IP/hostname is known, allow local-direct to try unicast before broadcast.
2213
2603
  ...this.opts.host?.trim() ? { directHost: this.opts.host.trim() } : {},
2214
2604
  ...this.opts.udpDiscoveryMethod ? { discoveryMethod: this.opts.udpDiscoveryMethod } : {}
@@ -2222,6 +2612,9 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2222
2612
  for (const f of frames) this.handleFrame(f);
2223
2613
  });
2224
2614
  sock.on("close", () => {
2615
+ this.logSocketState("udp_close_event", {
2616
+ udpConnected: sock.isConnected?.()
2617
+ });
2225
2618
  const sid2 = this.socketSessionId;
2226
2619
  if (sock === this.udpSocket) {
2227
2620
  this.udpSocket = void 0;
@@ -2233,11 +2626,14 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2233
2626
  this.socketClosed = true;
2234
2627
  const pending = this.pendingCloseInfo;
2235
2628
  this.pendingCloseInfo = void 0;
2629
+ const errorCode = this.lastSocketErrorCode;
2630
+ this.lastSocketErrorCode = void 0;
2236
2631
  this.lastDisconnectInfo = {
2237
2632
  atMs: Date.now(),
2238
2633
  transport: "udp",
2239
2634
  voluntary: pending != null,
2240
- reason: pending?.reason ?? "socket_closed"
2635
+ reason: pending?.reason ?? "socket_closed",
2636
+ ...errorCode != null && { errorCode }
2241
2637
  };
2242
2638
  const udpDisconnectParts = [
2243
2639
  `transport=udp`,
@@ -2274,6 +2670,10 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2274
2670
  });
2275
2671
  sock.on("error", (err) => {
2276
2672
  if (err?.message?.includes("D2C_DISC")) {
2673
+ this.logSocketState("udp_error_d2c_disc", {
2674
+ message: err.message,
2675
+ udpConnected: sock.isConnected?.()
2676
+ });
2277
2677
  const now = Date.now();
2278
2678
  const sid2 = this.socketSessionId;
2279
2679
  const shortUid2 = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
@@ -2300,7 +2700,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2300
2700
  this.udpReconnectCooldownUntilMs,
2301
2701
  now + nextBackoffMs
2302
2702
  );
2303
- this.logFixed("d2c_disc_backoff", {
2703
+ this.logDebug("d2c_disc_backoff", {
2304
2704
  transport: "udp",
2305
2705
  host: this.opts.host,
2306
2706
  sid: sid2,
@@ -2330,9 +2730,64 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2330
2730
  "connected",
2331
2731
  `transport=udp host=${this.opts.host}${sid ? ` sid=${sid}` : ""}${udpRemoteHost ? ` remote=${udpRemoteHost}` : ""} uid=${shortUid} udpDiscoveryMethod=${udpDiscoveryMethod}`
2332
2732
  );
2733
+ this.logSocketState("udp_connected", {
2734
+ udpConnected: sock.isConnected?.(),
2735
+ udpRemoteHost,
2736
+ udpDiscoveryMethod
2737
+ });
2333
2738
  this.startKeepAlive();
2334
2739
  this.kickIdleDisconnectTimer();
2335
2740
  }
2741
+ /**
2742
+ * Send a logout command to the camera to gracefully end the session.
2743
+ *
2744
+ * This notifies the camera that we're done, preventing "hung" sessions
2745
+ * that would otherwise wait for a timeout on the camera side.
2746
+ *
2747
+ * PCAP-confirmed: cmdId=2 with encrypted XML body.
2748
+ * Call this before close() for clean session termination.
2749
+ *
2750
+ * @returns true if logout was sent and acknowledged, false if failed or not logged in
2751
+ */
2752
+ async logout() {
2753
+ if (!this.loggedIn || !this.isSocketConnected()) {
2754
+ this.logDebug("logout_skip", {
2755
+ loggedIn: this.loggedIn,
2756
+ socketConnected: this.isSocketConnected()
2757
+ });
2758
+ return false;
2759
+ }
2760
+ try {
2761
+ this.logDebug("logout_start", { host: this.opts.host });
2762
+ const logoutXml = buildLogoutXml();
2763
+ const effectiveHostChannelId = this.hostChannelId;
2764
+ const response = await this.sendFrame({
2765
+ cmdId: BC_CMD_ID_LOGOUT,
2766
+ payloadXml: logoutXml,
2767
+ extensionXml: "",
2768
+ messageClass: BC_CLASS_MODERN_24,
2769
+ channelIdOverride: effectiveHostChannelId,
2770
+ timeoutMs: 5e3
2771
+ // Short timeout - camera should respond quickly
2772
+ });
2773
+ const responseCode = response.header.responseCode;
2774
+ const success = responseCode === 200;
2775
+ this.logDebug("logout_response", {
2776
+ responseCode,
2777
+ success
2778
+ });
2779
+ this.loggedIn = false;
2780
+ this.nonce = void 0;
2781
+ return success;
2782
+ } catch (e) {
2783
+ this.logDebug("logout_error", {
2784
+ error: e instanceof Error ? e.message : String(e)
2785
+ });
2786
+ this.loggedIn = false;
2787
+ this.nonce = void 0;
2788
+ return false;
2789
+ }
2790
+ }
2336
2791
  async close(options) {
2337
2792
  const hasSocket = Boolean(
2338
2793
  this.tcpSocket && !this.tcpSocket.destroyed || this.udpSocket
@@ -2350,7 +2805,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2350
2805
  const host = this.opts.host;
2351
2806
  const port = this.opts.port ?? BC_TCP_DEFAULT_PORT;
2352
2807
  const shortUid = this.opts.uid ? this.opts.uid.substring(0, 5) : void 0;
2353
- this.logFixed("closing", {
2808
+ this.logDebug("closing", {
2354
2809
  transport,
2355
2810
  host,
2356
2811
  ...transport === "tcp" ? { port } : {},
@@ -2365,15 +2820,26 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
2365
2820
  permits: this.permits.size,
2366
2821
  videoSubscriptions: this.videoSubscriptions.size
2367
2822
  });
2823
+ this.logSocketState("close_called", { reason });
2368
2824
  if ((process.env.BAICHUAN_LOG_CLOSE_STACK ?? "").trim() === "1") {
2369
2825
  const stack = new Error("BaichuanClient.close() stack").stack;
2370
2826
  if (stack) {
2371
2827
  const lines = stack.split("\n").slice(0, 10).join("\n");
2372
- this.logFixed("closing_stack", lines);
2828
+ this.logDebug("closing_stack", lines);
2373
2829
  }
2374
2830
  }
2375
2831
  } catch {
2376
2832
  }
2833
+ const skipLogout = options?.skipLogout ?? false;
2834
+ if (!skipLogout && this.loggedIn && this.isSocketConnected()) {
2835
+ try {
2836
+ await this.logout();
2837
+ } catch (e) {
2838
+ this.logDebug("close_logout_error", {
2839
+ error: e instanceof Error ? e.message : String(e)
2840
+ });
2841
+ }
2842
+ }
2377
2843
  this.stopKeepAlive();
2378
2844
  this.clearIdleDisconnectTimer();
2379
2845
  for (const id of Array.from(this.permits.keys())) {
@@ -3940,27 +4406,31 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
3940
4406
  * instead of JPEG.
3941
4407
  */
3942
4408
  async sendBinaryCoverPreview(params) {
3943
- const maxRetries = params.maxRetries ?? 5;
3944
- const retryDelayMs = params.retryDelayMs ?? 1e3;
3945
- let lastError;
3946
- for (let attempt = 0; attempt < maxRetries; attempt++) {
3947
- try {
3948
- return await this._sendBinaryCoverPreviewOnce(params);
3949
- } catch (e) {
3950
- const msg = e instanceof Error ? e.message : String(e);
3951
- lastError = e instanceof Error ? e : new Error(msg);
3952
- const is400Rejection = msg.includes("rejected") && (msg.includes("responseCode=400") || msg.includes("resp_code=400"));
3953
- if (is400Rejection && attempt < maxRetries - 1) {
3954
- console.log(
3955
- `[CoverPreview] Attempt ${attempt + 1} got 400, retrying in ${retryDelayMs}ms...`
3956
- );
3957
- await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
3958
- continue;
4409
+ return await this.withSerializedCoverPreview(async () => {
4410
+ const maxRetries = params.maxRetries ?? 5;
4411
+ const retryDelayMs = params.retryDelayMs ?? 1e3;
4412
+ let lastError;
4413
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
4414
+ try {
4415
+ return await this._sendBinaryCoverPreviewOnce(params);
4416
+ } catch (e) {
4417
+ const msg = e instanceof Error ? e.message : String(e);
4418
+ lastError = e instanceof Error ? e : new Error(msg);
4419
+ const is400Rejection = msg.includes("rejected") && (msg.includes("responseCode=400") || msg.includes("resp_code=400"));
4420
+ if (is400Rejection && attempt < maxRetries - 1) {
4421
+ this.logDebug("coverpreview_retry_400", {
4422
+ attempt: attempt + 1,
4423
+ maxRetries,
4424
+ retryDelayMs
4425
+ });
4426
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
4427
+ continue;
4428
+ }
4429
+ throw lastError;
3959
4430
  }
3960
- throw lastError;
3961
4431
  }
3962
- }
3963
- throw lastError ?? new Error("CoverPreview failed after all retries");
4432
+ throw lastError ?? new Error("CoverPreview failed after all retries");
4433
+ });
3964
4434
  }
3965
4435
  /**
3966
4436
  * Internal: single attempt for sendBinaryCoverPreview
@@ -4063,8 +4533,16 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
4063
4533
  return await new Promise((resolve, reject) => {
4064
4534
  let timeout;
4065
4535
  let done = false;
4536
+ const onClose = () => {
4537
+ fail(
4538
+ new Error(
4539
+ `Baichuan socket closed while waiting CoverPreview cmdId=${cmdId} msgNum=${msgNum}`
4540
+ )
4541
+ );
4542
+ };
4066
4543
  const cleanup = () => {
4067
4544
  this.off("frame", onFrame);
4545
+ this.off("close", onClose);
4068
4546
  if (timeout) clearTimeout(timeout);
4069
4547
  };
4070
4548
  const finish = (buf) => {
@@ -4183,6 +4661,7 @@ var BaichuanClient = class _BaichuanClient extends EventEmitter2 {
4183
4661
  }
4184
4662
  }, timeoutMs);
4185
4663
  this.on("frame", onFrame);
4664
+ this.on("close", onClose);
4186
4665
  try {
4187
4666
  this.logDebug("tx", {
4188
4667
  cmdId,
@@ -8261,7 +8740,10 @@ var logDebugStreamBlock = (params) => {
8261
8740
  const { logger, traceNativeStream, channel, tag, blockXml } = params;
8262
8741
  if (!traceNativeStream) return;
8263
8742
  if (!blockXml) {
8264
- (logger.warn ?? logger.log).call(logger, `[ReolinkBaichuanApi] getStreamMetadata(traceNativeStream): channel=${channel} tag=<${tag}> missing`);
8743
+ (logger.warn ?? logger.log).call(
8744
+ logger,
8745
+ `[ReolinkBaichuanApi] getStreamMetadata(traceNativeStream): channel=${channel} tag=<${tag}> missing`
8746
+ );
8265
8747
  return;
8266
8748
  }
8267
8749
  const raw = blockXml;
@@ -8297,7 +8779,8 @@ var buildStream = (params) => {
8297
8779
  const audio = Number(getXmlText(streamXml, "audio") ?? "0");
8298
8780
  const enabled = getXmlText(streamXml, "enable");
8299
8781
  const isEnabled = isEnabledFromText(enabled);
8300
- if (!isEnabled || !isPlausibleStream({ width, height, frameRate, bitRate })) return void 0;
8782
+ if (!isEnabled || !isPlausibleStream({ width, height, frameRate, bitRate }))
8783
+ return void 0;
8301
8784
  return {
8302
8785
  profile,
8303
8786
  audio,
@@ -8335,7 +8818,13 @@ var parseChannelStreamMetadataFromGetEncXml = (params) => {
8335
8818
  const mainMatch = xml.match(/<mainStream[^>]*>([\s\S]*?)<\/mainStream>/);
8336
8819
  if (mainMatch) {
8337
8820
  const mainXml = mainMatch[1] ?? "";
8338
- logDebugStreamBlock({ logger, traceNativeStream, channel, tag: "mainStream", blockXml: mainXml });
8821
+ logDebugStreamBlock({
8822
+ logger,
8823
+ traceNativeStream,
8824
+ channel,
8825
+ tag: "mainStream",
8826
+ blockXml: mainXml
8827
+ });
8339
8828
  const s = buildStream({ profile: "main", streamXml: mainXml });
8340
8829
  if (s) {
8341
8830
  streams.push(s);
@@ -8345,19 +8834,40 @@ var parseChannelStreamMetadataFromGetEncXml = (params) => {
8345
8834
  const subMatch = xml.match(/<subStream[^>]*>([\s\S]*?)<\/subStream>/);
8346
8835
  if (subMatch) {
8347
8836
  const subXml = subMatch[1] ?? "";
8348
- logDebugStreamBlock({ logger, traceNativeStream, channel, tag: "subStream", blockXml: subXml });
8837
+ logDebugStreamBlock({
8838
+ logger,
8839
+ traceNativeStream,
8840
+ channel,
8841
+ tag: "subStream",
8842
+ blockXml: subXml
8843
+ });
8349
8844
  const s = buildStream({ profile: "sub", streamXml: subXml });
8350
8845
  if (s) {
8351
8846
  streams.push(s);
8352
8847
  audioEnabled = audioEnabled && s.audio === 1;
8353
8848
  }
8354
8849
  }
8355
- const extLikeTags = ["extStream", "thirdStream", "externStream", "extraStream"];
8850
+ const extLikeTags = [
8851
+ "extStream",
8852
+ "thirdStream",
8853
+ "externStream",
8854
+ "extraStream"
8855
+ ];
8856
+ let extTagFound;
8356
8857
  for (const tag of extLikeTags) {
8357
- const extMatch = xml.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`));
8858
+ const extMatch = xml.match(
8859
+ new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`)
8860
+ );
8358
8861
  if (!extMatch) continue;
8862
+ extTagFound = tag;
8359
8863
  const extXml = extMatch[1] ?? "";
8360
- logDebugStreamBlock({ logger, traceNativeStream, channel, tag, blockXml: extXml });
8864
+ logDebugStreamBlock({
8865
+ logger,
8866
+ traceNativeStream,
8867
+ channel,
8868
+ tag,
8869
+ blockXml: extXml
8870
+ });
8361
8871
  const s = buildStream({ profile: "ext", streamXml: extXml });
8362
8872
  if (s) {
8363
8873
  streams.push(s);
@@ -8368,7 +8878,8 @@ var parseChannelStreamMetadataFromGetEncXml = (params) => {
8368
8878
  return {
8369
8879
  channel,
8370
8880
  streams,
8371
- audioEnabled
8881
+ audioEnabled,
8882
+ rawXml: xml
8372
8883
  };
8373
8884
  };
8374
8885
 
@@ -8975,7 +9486,6 @@ var isNvrHubModel = (model) => {
8975
9486
  return NVR_HUB_MODEL_PATTERNS.some((pattern) => pattern.test(normalized));
8976
9487
  };
8977
9488
  var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
8978
- client;
8979
9489
  logger;
8980
9490
  httpClient;
8981
9491
  cgiApi;
@@ -8983,6 +9493,39 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
8983
9493
  host;
8984
9494
  username;
8985
9495
  password;
9496
+ // ─────────────────────────────────────────────────────────────────────────────
9497
+ // SOCKET POOL - Tag-based socket management
9498
+ // ─────────────────────────────────────────────────────────────────────────────
9499
+ /**
9500
+ * Socket pool with tag-based allocation strategy.
9501
+ * Tags determine which sockets are shared vs dedicated:
9502
+ *
9503
+ * For standalone camera (channelCount=1):
9504
+ * - "general" - commands, events, ext stream (all share one socket)
9505
+ * - "streaming" - main + sub streams (share one socket)
9506
+ * - "replay:XXX" - dedicated per replay session
9507
+ *
9508
+ * For NVR (channelCount>1):
9509
+ * - "general" - commands, events (shared socket)
9510
+ * - "streaming:chN" - main + sub for channel N (one socket per channel)
9511
+ * - "replay:XXX" - dedicated per replay session
9512
+ */
9513
+ socketPool = /* @__PURE__ */ new Map();
9514
+ /** BaichuanClientOptions to use when creating new sockets */
9515
+ clientOptions;
9516
+ /**
9517
+ * Get the primary "general" socket. This is the default socket for commands and events.
9518
+ * Lazily created on first access if not already initialized.
9519
+ *
9520
+ * This getter maintains backward compatibility with existing code that uses `this.client`.
9521
+ */
9522
+ get client() {
9523
+ const entry = this.socketPool.get("general");
9524
+ if (!entry) {
9525
+ throw new Error("[ReolinkBaichuanApi] General socket not initialized");
9526
+ }
9527
+ return entry.client;
9528
+ }
8986
9529
  /**
8987
9530
  * Cached camera UID. May be initially undefined if not provided in the constructor.
8988
9531
  * Will be lazily populated on demand when needed (e.g. for recordings).
@@ -8995,10 +9538,36 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
8995
9538
  * Set during login or when capabilities are queried.
8996
9539
  */
8997
9540
  _channelCount;
8998
- rebootAfterDisconnectionsPerMinute;
9541
+ /**
9542
+ * Cached NVR/Hub detection result.
9543
+ * - true = NVR/Hub with multiple channels
9544
+ * - false = standalone camera
9545
+ * Set via setIsNvr() from the plugin or auto-detected via isNvrDevice().
9546
+ */
9547
+ _isNvr;
9548
+ /** Maximum dedicated sessions allowed before triggering a reboot (default: 7). */
9549
+ maxDedicatedSessionsBeforeReboot;
9550
+ sessionGuardRebootInFlight;
9551
+ sessionGuardLastRebootAtMs;
9552
+ /** Track last known session count and IDs for change detection. */
9553
+ lastKnownSessionCount;
9554
+ lastKnownSessionIds = /* @__PURE__ */ new Set();
9555
+ /** Reboot if too many voluntary disconnections per minute (default: 15). */
9556
+ rebootAfterDisconnectionsPerMinute = 15;
8999
9557
  disconnectStormVoluntaryAtMs = [];
9000
9558
  disconnectStormRebootInFlight;
9001
9559
  disconnectStormLastRebootAtMs;
9560
+ /**
9561
+ * ECONNRESET storm guard: reboot if too many consecutive ECONNRESET errors.
9562
+ * Default threshold: 10 consecutive ECONNRESET within 60 seconds.
9563
+ */
9564
+ rebootAfterConsecutiveEconnreset = 10;
9565
+ consecutiveEconnresetCount = 0;
9566
+ consecutiveEconnresetFirstAtMs;
9567
+ econnresetStormRebootInFlight;
9568
+ econnresetStormLastRebootAtMs;
9569
+ /** Periodic session check interval (every 60 seconds). */
9570
+ sessionGuardIntervalTimer;
9002
9571
  simpleEventListeners = /* @__PURE__ */ new Set();
9003
9572
  simpleEventSubscribed = false;
9004
9573
  simpleEventSubscribeInFlight;
@@ -9027,29 +9596,68 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9027
9596
  deviceCapabilitiesCache = /* @__PURE__ */ new Map();
9028
9597
  static CAPABILITIES_CACHE_TTL_MS = 5 * 60 * 1e3;
9029
9598
  // 5 minutes
9599
+ // ─────────────────────────────────────────────────────────────────────────────
9600
+ // SOCKET POOL CONSTANTS
9601
+ // ─────────────────────────────────────────────────────────────────────────────
9602
+ /** Keep replay/streaming sockets warm briefly to reduce clip switch latency. */
9603
+ static SOCKET_POOL_KEEPALIVE_MS = 1e4;
9030
9604
  /**
9031
- * Pool of dedicated BaichuanClient instances for streaming/replay operations.
9032
- * Each replay/stream operation gets its own dedicated socket to avoid interference
9033
- * when switching between clips or concurrent operations.
9034
- *
9035
- * Key: unique session ID (e.g., "replay:channel:fileName")
9036
- * Value: client, refCount, createdAt
9605
+ * Cooldown tracking to prevent session spam when login repeatedly fails.
9606
+ * Key: host address (since camera session limits are per-device, not per-sessionKey)
9607
+ * Value: object with failureCount, lastFailureAt, cooldownUntil
9037
9608
  */
9038
- dedicatedClients = /* @__PURE__ */ new Map();
9039
- /** Keep replay dedicated sockets warm briefly to reduce clip switch latency. */
9040
- // Keep replay sockets warm briefly for fast clip switches, but tear down quickly
9041
- // when clients stop requesting HLS segments (avoids looking like a stuck session).
9042
- static REPLAY_DEDICATED_KEEPALIVE_MS = 1e4;
9609
+ socketPoolCooldowns = /* @__PURE__ */ new Map();
9610
+ /** Base cooldown duration (ms) for socket failures. */
9611
+ static SOCKET_POOL_BASE_COOLDOWN_MS = 5e3;
9612
+ /** Max cooldown duration (ms) - caps exponential backoff. */
9613
+ static SOCKET_POOL_MAX_COOLDOWN_MS = 12e4;
9614
+ /** Failure count threshold before entering cooldown. */
9615
+ static SOCKET_POOL_FAILURE_THRESHOLD = 2;
9616
+ /** Time window (ms) to reset failure count if no failures occur. */
9617
+ static SOCKET_POOL_FAILURE_WINDOW_MS = 6e4;
9043
9618
  /**
9044
- * Get a summary of currently active dedicated sessions.
9619
+ * Get a summary of currently active sockets in the pool.
9045
9620
  * Useful for debugging/logging to see how many sockets are open.
9046
9621
  */
9622
+ getSocketPoolSummary() {
9623
+ return {
9624
+ count: this.socketPool.size,
9625
+ tags: Array.from(this.socketPool.keys())
9626
+ };
9627
+ }
9628
+ /**
9629
+ * @deprecated Use getSocketPoolSummary() instead.
9630
+ */
9047
9631
  getDedicatedSessionsSummary() {
9632
+ const summary = this.getSocketPoolSummary();
9048
9633
  return {
9049
- count: this.dedicatedClients.size,
9050
- keys: Array.from(this.dedicatedClients.keys())
9634
+ count: summary.count,
9635
+ keys: summary.tags
9051
9636
  };
9052
9637
  }
9638
+ /**
9639
+ * Get cooldown status for socket pool connections.
9640
+ * Useful for debugging when connections are being rate-limited.
9641
+ */
9642
+ getSocketPoolCooldownStatus() {
9643
+ const entry = this.socketPoolCooldowns.get(this.host);
9644
+ if (!entry) return null;
9645
+ const now = Date.now();
9646
+ const inCooldown = now < entry.cooldownUntil;
9647
+ return {
9648
+ host: this.host,
9649
+ inCooldown,
9650
+ failureCount: entry.failureCount,
9651
+ cooldownRemainingMs: inCooldown ? entry.cooldownUntil - now : 0,
9652
+ cooldownUntil: inCooldown ? new Date(entry.cooldownUntil) : null
9653
+ };
9654
+ }
9655
+ /**
9656
+ * @deprecated Use getSocketPoolCooldownStatus() instead.
9657
+ */
9658
+ getDedicatedClientCooldownStatus() {
9659
+ return this.getSocketPoolCooldownStatus();
9660
+ }
9053
9661
  /**
9054
9662
  * Cached per-channel data from cmd_id 145 push (NVR sends this automatically on connection).
9055
9663
  *
@@ -9135,6 +9743,11 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9135
9743
  */
9136
9744
  replayQueue = [];
9137
9745
  replayQueueProcessing = false;
9746
+ /**
9747
+ * Abort controller for the currently active replay stream.
9748
+ * When a new clip is requested, we signal the current one to stop.
9749
+ */
9750
+ activeReplayAbortController = null;
9138
9751
  /** Minimum delay between replay operations to give camera time to reset */
9139
9752
  REPLAY_COOLDOWN_MS = 500;
9140
9753
  lastReplayEndTime = 0;
@@ -9226,17 +9839,23 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9226
9839
  * continues producing data after the initial setup.
9227
9840
  *
9228
9841
  * @param setup - Function that sets up the stream. Called when it's this operation's turn.
9229
- * @returns Promise that resolves when setup is complete, with the result and a release function.
9842
+ * Receives an AbortSignal that will be triggered if a new clip is requested.
9843
+ * @returns Promise that resolves when setup is complete, with the result, release function, and abort signal.
9230
9844
  */
9231
9845
  enqueueStreamingReplayOperation(setup) {
9846
+ if (this.activeReplayAbortController) {
9847
+ this.logger?.debug?.(
9848
+ "[ReplayQueue] Signaling current replay stream to abort for new clip"
9849
+ );
9850
+ this.activeReplayAbortController.abort();
9851
+ this.activeReplayAbortController = null;
9852
+ }
9232
9853
  let resolvePromise;
9233
9854
  let rejectPromise;
9234
- const promise = new Promise(
9235
- (resolve, reject) => {
9236
- resolvePromise = resolve;
9237
- rejectPromise = reject;
9238
- }
9239
- );
9855
+ const promise = new Promise((resolve, reject) => {
9856
+ resolvePromise = resolve;
9857
+ rejectPromise = reject;
9858
+ });
9240
9859
  this.replayQueue.push({
9241
9860
  execute: () => {
9242
9861
  return new Promise((releaseSlot) => {
@@ -9244,26 +9863,33 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9244
9863
  const safeRelease = () => {
9245
9864
  if (released) return;
9246
9865
  released = true;
9866
+ if (this.activeReplayAbortController && this.activeReplayAbortController === abortController) {
9867
+ this.activeReplayAbortController = null;
9868
+ }
9247
9869
  releaseSlot();
9248
9870
  };
9871
+ const abortController = new AbortController();
9872
+ this.activeReplayAbortController = abortController;
9249
9873
  const safetyTimeout = setTimeout(
9250
9874
  () => {
9251
9875
  if (!released) {
9252
9876
  this.logger?.warn?.(
9253
9877
  "[ReplayQueue] Safety timeout: releasing queue slot after 10 minutes"
9254
9878
  );
9879
+ abortController.abort();
9255
9880
  safeRelease();
9256
9881
  }
9257
9882
  },
9258
9883
  10 * 60 * 1e3
9259
9884
  );
9260
- setup().then((result) => {
9885
+ setup(abortController.signal).then((result) => {
9261
9886
  resolvePromise({
9262
9887
  result,
9263
9888
  release: () => {
9264
9889
  clearTimeout(safetyTimeout);
9265
9890
  safeRelease();
9266
- }
9891
+ },
9892
+ abortSignal: abortController.signal
9267
9893
  });
9268
9894
  }).catch((e) => {
9269
9895
  clearTimeout(safetyTimeout);
@@ -9326,32 +9952,95 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9326
9952
  });
9327
9953
  }
9328
9954
  recordingsCacheTtlMs = 20 * 60 * 1e3;
9955
+ // ─────────────────────────────────────────────────────────────────────────────
9956
+ // SOCKET POOL MANAGEMENT
9957
+ // ─────────────────────────────────────────────────────────────────────────────
9329
9958
  /**
9330
- * Create or reuse a dedicated BaichuanClient for streaming/replay operations.
9331
- * Each streaming session gets its own socket to avoid interference when switching clips.
9959
+ * Determine the socket tag for a given sessionKey.
9960
+ * This implements the tag-based allocation strategy:
9961
+ *
9962
+ * - "general" - commands, events
9963
+ * - "streaming:ch{N}" - main + sub for channel N (closed when no streams)
9964
+ * - "streaming:ch{N}:ext" - ext for channel N (closed when no streams)
9965
+ * - "replay:deviceId:ch{N}" - dedicated per device+channel for replay
9332
9966
  *
9333
- * @param sessionKey - Unique key for this session (e.g., `replay:\$\{deviceId\}`)
9334
- * @returns The dedicated client and a release function to call when done
9967
+ * Always uses per-channel tagging for streams (works for both standalone and NVR).
9968
+ * Replay uses per-device+channel sockets to allow multiple users to watch
9969
+ * different clips simultaneously without interfering with each other.
9335
9970
  *
9336
- * IMPORTANT: A socket cannot do concurrent streaming. If a client already exists
9337
- * for this sessionKey (same device switching clips), we close the old socket
9338
- * immediately and create a new one. This ensures clean state for each clip.
9971
+ * @param sessionKey - The session key (e.g., "live:device:ch0:main", "replay:device:ch1:file")
9972
+ * @returns The socket pool tag to use
9339
9973
  */
9340
- async acquireDedicatedClient(sessionKey, logger) {
9974
+ resolveSocketTag(sessionKey) {
9975
+ const replayMatch = sessionKey.match(/^replay:([^:]+)(?::ch(\d+))?/);
9976
+ if (replayMatch) {
9977
+ const deviceId = replayMatch[1];
9978
+ const channel = replayMatch[2] ?? "0";
9979
+ return `replay:${deviceId}:ch${channel}`;
9980
+ }
9981
+ const liveMatch = sessionKey.match(/^live:[^:]+:ch(\d+):(\w+)$/);
9982
+ if (liveMatch && liveMatch[1] && liveMatch[2]) {
9983
+ const channel = parseInt(liveMatch[1], 10);
9984
+ const profile = liveMatch[2].toLowerCase();
9985
+ if (profile === "ext") {
9986
+ if (channel === 0) {
9987
+ return "general";
9988
+ }
9989
+ return `streaming:ch${channel}:ext`;
9990
+ }
9991
+ return `streaming:ch${channel}`;
9992
+ }
9993
+ return "general";
9994
+ }
9995
+ /**
9996
+ * Acquire a socket from the pool by tag.
9997
+ * Creates a new socket if needed, or reuses an existing one.
9998
+ *
9999
+ * @param tag - The socket pool tag (from resolveSocketTag)
10000
+ * @param logger - Optional logger for debug output
10001
+ * @returns The socket and a release function
10002
+ */
10003
+ async acquirePooledSocket(tag, logger) {
9341
10004
  const log = logger ?? this.logger;
9342
- const isReplayKey = sessionKey.startsWith("replay:");
9343
- const existing = this.dedicatedClients.get(sessionKey);
10005
+ const now = Date.now();
10006
+ const cooldownEntry = this.socketPoolCooldowns.get(this.host);
10007
+ if (cooldownEntry) {
10008
+ if (now - cooldownEntry.lastFailureAt > _ReolinkBaichuanApi.SOCKET_POOL_FAILURE_WINDOW_MS) {
10009
+ this.socketPoolCooldowns.delete(this.host);
10010
+ log?.debug?.(
10011
+ `[SocketPool] Cooldown reset for host=${this.host} (no failures for ${_ReolinkBaichuanApi.SOCKET_POOL_FAILURE_WINDOW_MS}ms)`
10012
+ );
10013
+ } else if (now < cooldownEntry.cooldownUntil) {
10014
+ const remainingMs = cooldownEntry.cooldownUntil - now;
10015
+ const error = new Error(
10016
+ `[SocketPool] Host ${this.host} is in cooldown for ${Math.ceil(remainingMs / 1e3)}s due to repeated login failures. tag=${tag}`
10017
+ );
10018
+ log?.warn?.(error.message);
10019
+ throw error;
10020
+ }
10021
+ }
10022
+ const existing = this.socketPool.get(tag);
9344
10023
  if (existing) {
9345
10024
  if (existing.idleCloseTimer) {
9346
10025
  clearTimeout(existing.idleCloseTimer);
9347
10026
  existing.idleCloseTimer = void 0;
9348
10027
  }
9349
- if (existing.refCount === 0) {
9350
- existing.refCount = 1;
10028
+ if (existing.pendingPromise) {
10029
+ const client2 = await existing.pendingPromise;
10030
+ existing.refCount++;
9351
10031
  existing.lastUsedAt = Date.now();
9352
10032
  log?.debug?.(
9353
- `[DedicatedClient] Reusing existing dedicated socket for sessionKey=${sessionKey}`
10033
+ `[SocketPool] Waited for pending socket creation for tag=${tag} (refCount=${existing.refCount})`
9354
10034
  );
10035
+ return {
10036
+ client: client2,
10037
+ release: () => this.releasePooledSocket(tag, logger)
10038
+ };
10039
+ }
10040
+ if (existing.refCount === 0) {
10041
+ existing.refCount = 1;
10042
+ existing.lastUsedAt = Date.now();
10043
+ log?.debug?.(`[SocketPool] Reusing idle socket for tag=${tag}`);
9355
10044
  try {
9356
10045
  if (!existing.client.loggedIn) {
9357
10046
  await existing.client.login();
@@ -9361,132 +10050,223 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9361
10050
  if (existing.client.loggedIn) {
9362
10051
  return {
9363
10052
  client: existing.client,
9364
- release: () => this.releaseDedicatedClient(sessionKey, logger)
10053
+ release: () => this.releasePooledSocket(tag, logger)
10054
+ };
10055
+ }
10056
+ } else {
10057
+ if (tag.startsWith("replay:")) {
10058
+ log?.debug?.(
10059
+ `[SocketPool] Preempting active replay socket for tag=${tag}`
10060
+ );
10061
+ } else {
10062
+ existing.refCount++;
10063
+ existing.lastUsedAt = Date.now();
10064
+ log?.debug?.(
10065
+ `[SocketPool] Reusing active socket for tag=${tag} (refCount=${existing.refCount})`
10066
+ );
10067
+ return {
10068
+ client: existing.client,
10069
+ release: () => this.releasePooledSocket(tag, logger)
9365
10070
  };
9366
10071
  }
9367
10072
  }
9368
- log?.log?.(
9369
- `[DedicatedClient] Closing existing socket for sessionKey=${sessionKey} (preempting active session)`
10073
+ log?.debug?.(
10074
+ `[SocketPool] Closing existing socket for tag=${tag} (recreating)`
9370
10075
  );
9371
- this.dedicatedClients.delete(sessionKey);
10076
+ this.socketPool.delete(tag);
9372
10077
  try {
9373
- await existing.client.close({ reason: "preempted by new session" });
9374
- log?.log?.(
9375
- `[DedicatedClient] Old socket closed successfully for sessionKey=${sessionKey}`
9376
- );
10078
+ await existing.client.close({
10079
+ reason: "socket pool recreation",
10080
+ skipLogout: true
10081
+ });
9377
10082
  } catch (e) {
9378
10083
  log?.warn?.(
9379
- `[DedicatedClient] Error closing old socket for sessionKey=${sessionKey}: ${e}`
10084
+ `[SocketPool] Error closing old socket for tag=${tag}: ${e}`
9380
10085
  );
9381
10086
  }
9382
10087
  }
9383
- log?.log?.(
9384
- `[DedicatedClient] Opening new dedicated socket for sessionKey=${sessionKey}`
9385
- );
9386
- const dedicatedClient = new BaichuanClient({
9387
- host: this.host,
9388
- username: this.username,
9389
- password: this.password,
9390
- logger: log,
9391
- debugOptions: this.client.getDebugConfig?.()
9392
- });
9393
- await dedicatedClient.login();
9394
- log?.log?.(
9395
- `[DedicatedClient] Dedicated socket logged in for sessionKey=${sessionKey}`
9396
- );
9397
- this.dedicatedClients.set(sessionKey, {
9398
- client: dedicatedClient,
9399
- refCount: 1,
9400
- createdAt: Date.now(),
9401
- lastUsedAt: Date.now(),
10088
+ log?.log?.(`[SocketPool] Creating new socket for tag=${tag}`);
10089
+ const entry = {
10090
+ client: void 0,
10091
+ // Will be set after login
10092
+ refCount: 0,
10093
+ createdAt: now,
10094
+ lastUsedAt: now,
9402
10095
  idleCloseTimer: void 0
9403
- });
10096
+ };
10097
+ entry.pendingPromise = (async () => {
10098
+ try {
10099
+ const clientOpts = log ? { ...this.clientOptions, logger: log } : this.clientOptions;
10100
+ const newClient = new BaichuanClient(clientOpts);
10101
+ await newClient.login();
10102
+ if (this.socketPoolCooldowns.has(this.host)) {
10103
+ log?.debug?.(
10104
+ `[SocketPool] Clearing cooldown for host=${this.host} after successful login`
10105
+ );
10106
+ this.socketPoolCooldowns.delete(this.host);
10107
+ }
10108
+ entry.client = newClient;
10109
+ entry.refCount = 1;
10110
+ entry.lastUsedAt = Date.now();
10111
+ delete entry.pendingPromise;
10112
+ log?.log?.(`[SocketPool] Socket connected for tag=${tag}`);
10113
+ void this.maybeRebootOnTooManySessions();
10114
+ return newClient;
10115
+ } catch (loginError) {
10116
+ const prevCooldown = this.socketPoolCooldowns.get(this.host);
10117
+ const failureCount = (prevCooldown?.failureCount ?? 0) + 1;
10118
+ const now2 = Date.now();
10119
+ let cooldownUntil = now2;
10120
+ if (failureCount >= _ReolinkBaichuanApi.SOCKET_POOL_FAILURE_THRESHOLD) {
10121
+ const backoffMs = Math.min(
10122
+ _ReolinkBaichuanApi.SOCKET_POOL_BASE_COOLDOWN_MS * Math.pow(
10123
+ 2,
10124
+ failureCount - _ReolinkBaichuanApi.SOCKET_POOL_FAILURE_THRESHOLD
10125
+ ),
10126
+ _ReolinkBaichuanApi.SOCKET_POOL_MAX_COOLDOWN_MS
10127
+ );
10128
+ cooldownUntil = now2 + backoffMs;
10129
+ log?.warn?.(
10130
+ `[SocketPool] Login failed for host=${this.host} (failure #${failureCount}). Entering cooldown for ${Math.ceil(backoffMs / 1e3)}s. tag=${tag}`
10131
+ );
10132
+ } else {
10133
+ log?.warn?.(
10134
+ `[SocketPool] Login failed for host=${this.host} (failure #${failureCount}/${_ReolinkBaichuanApi.SOCKET_POOL_FAILURE_THRESHOLD} before cooldown). tag=${tag}`
10135
+ );
10136
+ }
10137
+ this.socketPoolCooldowns.set(this.host, {
10138
+ failureCount,
10139
+ lastFailureAt: now2,
10140
+ cooldownUntil
10141
+ });
10142
+ this.socketPool.delete(tag);
10143
+ throw loginError;
10144
+ }
10145
+ })();
10146
+ this.socketPool.set(tag, entry);
10147
+ const client = await entry.pendingPromise;
9404
10148
  return {
9405
- client: dedicatedClient,
9406
- release: () => this.releaseDedicatedClient(sessionKey, logger)
10149
+ client,
10150
+ release: () => this.releasePooledSocket(tag, logger)
9407
10151
  };
9408
10152
  }
9409
10153
  /**
9410
- * Release a dedicated client. Always closes the socket immediately.
9411
- * This ensures clean teardown at the end of each clip.
10154
+ * Release a socket back to the pool.
10155
+ * For shared sockets (general, streaming), just decrements refCount.
10156
+ * For replay sockets, schedules idle close.
9412
10157
  */
9413
- async releaseDedicatedClient(sessionKey, logger) {
10158
+ async releasePooledSocket(tag, logger) {
9414
10159
  const log = logger ?? this.logger;
9415
- const entry = this.dedicatedClients.get(sessionKey);
10160
+ const entry = this.socketPool.get(tag);
9416
10161
  if (!entry) return;
9417
10162
  entry.refCount = Math.max(0, entry.refCount - 1);
9418
10163
  entry.lastUsedAt = Date.now();
10164
+ log?.debug?.(
10165
+ `[SocketPool] Released socket for tag=${tag} (refCount=${entry.refCount})`
10166
+ );
9419
10167
  if (entry.refCount > 0) return;
9420
- const isReplayKey = sessionKey.startsWith("replay:");
9421
- const allowReplayKeepAlive = /^replay:[^:]+$/.test(sessionKey);
9422
- if (isReplayKey && allowReplayKeepAlive) {
10168
+ const isReplayTag = tag.startsWith("replay:");
10169
+ const isStreamingTag = tag.startsWith("streaming:");
10170
+ const isGeneralTag = tag === "general";
10171
+ if (isGeneralTag) {
10172
+ return;
10173
+ }
10174
+ if (isStreamingTag) {
10175
+ if (entry.idleCloseTimer) return;
10176
+ entry.idleCloseTimer = setTimeout(async () => {
10177
+ const current = this.socketPool.get(tag);
10178
+ if (!current) return;
10179
+ if (current.refCount > 0) return;
10180
+ this.socketPool.delete(tag);
10181
+ log?.log?.(`[SocketPool] Closing idle streaming socket for tag=${tag}`);
10182
+ try {
10183
+ await current.client.close({
10184
+ reason: "streaming idle close",
10185
+ skipLogout: true
10186
+ });
10187
+ } catch {
10188
+ }
10189
+ }, 5e3);
10190
+ return;
10191
+ }
10192
+ if (isReplayTag) {
9423
10193
  if (entry.idleCloseTimer) return;
9424
10194
  entry.idleCloseTimer = setTimeout(async () => {
9425
- const current = this.dedicatedClients.get(sessionKey);
10195
+ const current = this.socketPool.get(tag);
9426
10196
  if (!current) return;
9427
10197
  if (current.refCount > 0) return;
9428
- this.dedicatedClients.delete(sessionKey);
10198
+ this.socketPool.delete(tag);
9429
10199
  log?.debug?.(
9430
- `[DedicatedClient] Closing idle replay socket for sessionKey=${sessionKey} (keepalive expired)`
10200
+ `[SocketPool] Closing idle replay socket for tag=${tag} (keepalive expired)`
9431
10201
  );
9432
10202
  try {
9433
10203
  await current.client.close({
9434
- reason: "replay idle keepalive expired"
10204
+ reason: "replay idle keepalive expired",
10205
+ skipLogout: true
9435
10206
  });
9436
10207
  } catch {
9437
10208
  }
9438
- }, _ReolinkBaichuanApi.REPLAY_DEDICATED_KEEPALIVE_MS);
10209
+ }, _ReolinkBaichuanApi.SOCKET_POOL_KEEPALIVE_MS);
9439
10210
  return;
9440
10211
  }
9441
- this.dedicatedClients.delete(sessionKey);
9442
- log?.log?.(
9443
- `[DedicatedClient] Closing socket for sessionKey=${sessionKey} (session ended)`
9444
- );
10212
+ this.socketPool.delete(tag);
9445
10213
  try {
9446
- await entry.client.close({ reason: "dedicated session ended" });
9447
- log?.log?.(
9448
- `[DedicatedClient] Socket closed successfully for sessionKey=${sessionKey}`
9449
- );
10214
+ await entry.client.close({
10215
+ reason: "socket pool release",
10216
+ skipLogout: true
10217
+ });
10218
+ log?.log?.(`[SocketPool] Closed socket for tag=${tag}`);
9450
10219
  } catch (e) {
9451
- log?.warn?.(
9452
- `[DedicatedClient] Error closing socket for sessionKey=${sessionKey}: ${e}`
9453
- );
10220
+ log?.warn?.(`[SocketPool] Error closing socket for tag=${tag}: ${e}`);
9454
10221
  }
9455
10222
  }
9456
10223
  /**
9457
- * Force-close a dedicated client if it exists.
9458
- * This is called BEFORE entering the queue to immediately terminate any existing stream
9459
- * for the same sessionKey. The existing stream will receive an error, release its queue slot,
9460
- * and the new request can then proceed.
9461
- *
9462
- * @param sessionKey - The session key to force-close (e.g., `replay:${deviceId}`)
9463
- * @param logger - Optional logger
9464
- * @returns true if a client was closed, false if no client existed
10224
+ * Force-close a socket by tag.
10225
+ * Used to preempt existing connections before acquiring a new one.
9465
10226
  */
9466
- async forceCloseDedicatedClient(sessionKey, logger) {
10227
+ async forceClosePooledSocket(tag, logger) {
9467
10228
  const log = logger ?? this.logger;
9468
- const entry = this.dedicatedClients.get(sessionKey);
10229
+ const entry = this.socketPool.get(tag);
9469
10230
  if (!entry) return false;
9470
10231
  if (entry.idleCloseTimer) {
9471
10232
  clearTimeout(entry.idleCloseTimer);
9472
10233
  entry.idleCloseTimer = void 0;
9473
10234
  }
9474
- log?.log?.(
9475
- `[DedicatedClient] Force-closing existing socket for sessionKey=${sessionKey} (new request preempting)`
9476
- );
9477
- this.dedicatedClients.delete(sessionKey);
10235
+ log?.debug?.(`[SocketPool] Force-closing socket for tag=${tag}`);
10236
+ this.socketPool.delete(tag);
9478
10237
  try {
9479
- await entry.client.close({ reason: "preempted by new request" });
9480
- log?.log?.(
9481
- `[DedicatedClient] Force-close complete for sessionKey=${sessionKey}`
9482
- );
10238
+ await entry.client.close({
10239
+ reason: "force closed",
10240
+ skipLogout: true
10241
+ });
10242
+ log?.log?.(`[SocketPool] Force-closed socket for tag=${tag}`);
9483
10243
  } catch (e) {
9484
- log?.warn?.(
9485
- `[DedicatedClient] Error during force-close for sessionKey=${sessionKey}: ${e}`
9486
- );
10244
+ log?.warn?.(`[SocketPool] Error during force-close for tag=${tag}: ${e}`);
9487
10245
  }
9488
10246
  return true;
9489
10247
  }
10248
+ /**
10249
+ * Cleanup all sockets in the pool. Called during API close.
10250
+ */
10251
+ async cleanupSocketPool() {
10252
+ const entries = Array.from(this.socketPool.entries());
10253
+ this.socketPool.clear();
10254
+ await Promise.allSettled(
10255
+ entries.map(async ([tag, entry]) => {
10256
+ try {
10257
+ if (entry.idleCloseTimer) {
10258
+ clearTimeout(entry.idleCloseTimer);
10259
+ }
10260
+ this.logger?.debug?.(`[SocketPool] Cleanup: closing tag=${tag}`);
10261
+ await entry.client.close({ reason: "API cleanup", skipLogout: true });
10262
+ } catch {
10263
+ }
10264
+ })
10265
+ );
10266
+ }
10267
+ // ─────────────────────────────────────────────────────────────────────────────
10268
+ // PUBLIC SESSION API (backward compatible)
10269
+ // ─────────────────────────────────────────────────────────────────────────────
9490
10270
  /**
9491
10271
  * Create a dedicated Baichuan client session for streaming.
9492
10272
  * This is useful for consumers that need isolated socket connections per stream.
@@ -9495,10 +10275,11 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9495
10275
  * @param logger - Optional logger for debug output
9496
10276
  * @returns Object with `client` (the dedicated BaichuanClient) and `release` function to call when done
9497
10277
  *
9498
- * The dedicated client is automatically cleaned up when:
9499
- * 1. `release()` is called explicitly
9500
- * 2. A new session is created with the same sessionKey (old one is closed first)
9501
- * 3. The API is closed via `close()`
10278
+ * Session keys are automatically mapped to socket pool tags:
10279
+ * - `live:device:ch0:ext` "general" socket (shared with commands/events)
10280
+ * - `live:device:ch0:main` "streaming" socket (standalone) or "streaming:ch0" (NVR)
10281
+ * - `live:device:ch0:sub` "streaming" socket (standalone) or "streaming:ch0" (NVR)
10282
+ * - `replay:device:...` → dedicated per-replay socket
9502
10283
  *
9503
10284
  * @example
9504
10285
  * ```typescript
@@ -9511,26 +10292,25 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9511
10292
  * ```
9512
10293
  */
9513
10294
  async createDedicatedSession(sessionKey, logger) {
9514
- return await this.acquireDedicatedClient(sessionKey, logger);
10295
+ const tag = this.resolveSocketTag(sessionKey);
10296
+ const log = logger ?? this.logger;
10297
+ log?.debug?.(
10298
+ `[SocketPool] createDedicatedSession sessionKey=${sessionKey} \u2192 tag=${tag}`
10299
+ );
10300
+ return await this.acquirePooledSocket(tag, logger);
10301
+ }
10302
+ /**
10303
+ * @deprecated Use forceClosePooledSocket via createDedicatedSession instead.
10304
+ * Force-close a dedicated client if it exists.
10305
+ */
10306
+ async forceCloseDedicatedClient(sessionKey, logger) {
10307
+ const tag = this.resolveSocketTag(sessionKey);
10308
+ return await this.forceClosePooledSocket(tag, logger);
9515
10309
  }
9516
10310
  /**
9517
- * Cleanup all dedicated clients. Called during API close.
10311
+ * @deprecated Cleanup handled by cleanupSocketPool now.
9518
10312
  */
9519
10313
  async cleanupDedicatedClients() {
9520
- const entries = Array.from(this.dedicatedClients.entries());
9521
- this.dedicatedClients.clear();
9522
- await Promise.allSettled(
9523
- entries.map(async ([key, entry]) => {
9524
- try {
9525
- if (entry.idleCloseTimer) {
9526
- clearTimeout(entry.idleCloseTimer);
9527
- }
9528
- this.logger?.debug?.(`[DedicatedClient] Cleanup: closing ${key}`);
9529
- await entry.client.close({ reason: "API cleanup" });
9530
- } catch {
9531
- }
9532
- })
9533
- );
9534
10314
  }
9535
10315
  dispatchSimpleEvent(evt) {
9536
10316
  const debugCfg = this.client.getDebugConfig?.();
@@ -9568,7 +10348,23 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9568
10348
  opts.logger,
9569
10349
  dbg.general || dbg.traceNativeStream || dbg.traceRecordings || dbg.traceTalk || dbg.traceEvents || dbg.debugRtsp
9570
10350
  );
9571
- this.client = new BaichuanClient(opts);
10351
+ this.clientOptions = {
10352
+ host: opts.host,
10353
+ username: opts.username,
10354
+ password: opts.password,
10355
+ ...opts.logger ? { logger: opts.logger } : {},
10356
+ ...opts.debugOptions ? { debugOptions: opts.debugOptions } : {},
10357
+ ...opts.uid ? { uid: opts.uid } : {}
10358
+ };
10359
+ const generalClient = new BaichuanClient(opts);
10360
+ this.socketPool.set("general", {
10361
+ client: generalClient,
10362
+ refCount: 1,
10363
+ // Always keep general socket "in use"
10364
+ createdAt: Date.now(),
10365
+ lastUsedAt: Date.now(),
10366
+ idleCloseTimer: void 0
10367
+ });
9572
10368
  this.host = opts.host;
9573
10369
  this.username = opts.username;
9574
10370
  this.password = opts.password;
@@ -9585,7 +10381,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9585
10381
  username: opts.username,
9586
10382
  password: opts.password,
9587
10383
  logger: this.logger,
9588
- debugConfig: this.client.getDebugConfig?.()
10384
+ debugConfig: generalClient.getDebugConfig?.()
9589
10385
  });
9590
10386
  this.client.on("event", (event) => {
9591
10387
  const mapped = mapToSimpleEvent(event);
@@ -9623,9 +10419,15 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9623
10419
  );
9624
10420
  }
9625
10421
  });
9626
- const v = opts.rebootAfterDisconnectionsPerMinute;
9627
- if (typeof v === "number" && Number.isFinite(v) && v > 0) {
9628
- this.rebootAfterDisconnectionsPerMinute = Math.floor(v);
10422
+ const maxSessions = opts.maxDedicatedSessionsBeforeReboot;
10423
+ if (typeof maxSessions === "number" && Number.isFinite(maxSessions) && maxSessions > 0) {
10424
+ this.maxDedicatedSessionsBeforeReboot = Math.floor(maxSessions);
10425
+ }
10426
+ const disconnectThreshold = opts.rebootAfterDisconnectionsPerMinute;
10427
+ if (typeof disconnectThreshold === "number" && Number.isFinite(disconnectThreshold)) {
10428
+ this.rebootAfterDisconnectionsPerMinute = Math.floor(disconnectThreshold);
10429
+ }
10430
+ if (this.rebootAfterDisconnectionsPerMinute > 0) {
9629
10431
  this.client.on("close", () => {
9630
10432
  try {
9631
10433
  void this.maybeRebootOnDisconnectStorm();
@@ -9633,6 +10435,24 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9633
10435
  }
9634
10436
  });
9635
10437
  }
10438
+ const econnresetThreshold = opts.rebootAfterConsecutiveEconnreset;
10439
+ if (typeof econnresetThreshold === "number" && Number.isFinite(econnresetThreshold)) {
10440
+ this.rebootAfterConsecutiveEconnreset = Math.floor(econnresetThreshold);
10441
+ }
10442
+ if (this.rebootAfterConsecutiveEconnreset > 0) {
10443
+ this.client.on("close", () => {
10444
+ try {
10445
+ void this.maybeRebootOnEconnresetStorm();
10446
+ } catch {
10447
+ }
10448
+ });
10449
+ }
10450
+ this.client.once("push", () => {
10451
+ void this.logActiveSessionsOnStartup();
10452
+ this.sessionGuardIntervalTimer = setInterval(() => {
10453
+ void this.maybeRebootOnTooManySessions();
10454
+ }, 6e4);
10455
+ });
9636
10456
  }
9637
10457
  /**
9638
10458
  * CGI forward: fetch RTSP URL for a channel via `GetRtspUrl`.
@@ -9720,24 +10540,228 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9720
10540
  });
9721
10541
  }
9722
10542
  /**
9723
- * Multifocal/NVR empirical stream diagnostics:
9724
- * probes RTSP/RTMP candidates + native streams, prints discovered resolutions,
9725
- * and saves one clip per working stream into a timestamped folder under outDir.
10543
+ * Multifocal/NVR empirical stream diagnostics:
10544
+ * probes RTSP/RTMP candidates + native streams, prints discovered resolutions,
10545
+ * and saves one clip per working stream into a timestamped folder under outDir.
10546
+ */
10547
+ async runMultifocalDiagnosticsConsecutively(params) {
10548
+ return await runMultifocalDiagnosticsConsecutively({
10549
+ ...params,
10550
+ api: this,
10551
+ logger: params.logger ?? this.logger,
10552
+ host: this.host,
10553
+ username: this.username,
10554
+ password: this.password
10555
+ });
10556
+ }
10557
+ /**
10558
+ * Log active sessions on the device at startup for debugging purposes.
10559
+ */
10560
+ async logActiveSessionsOnStartup() {
10561
+ const localSessionCount = this.socketPool.size;
10562
+ try {
10563
+ const ourIp = this.client.getLocalAddress?.();
10564
+ const onlineUsers = await this.getOnlineUserList({ timeoutMs: 5e3 });
10565
+ const rawLegacy = onlineUsers.body?.OnlineUserList?.item;
10566
+ const rawCurrent = onlineUsers.body?.OnlineUserList?.OnlineUser;
10567
+ const legacyItems = Array.isArray(rawLegacy) ? rawLegacy : rawLegacy ? [rawLegacy] : [];
10568
+ const currentItems = Array.isArray(rawCurrent) ? rawCurrent : rawCurrent ? [rawCurrent] : [];
10569
+ const allItems = currentItems.length > 0 ? currentItems.map((u) => ({
10570
+ ip: u.ipAddress,
10571
+ userName: u.userName,
10572
+ level: u.userLevel,
10573
+ sessionId: u.sessionId
10574
+ })) : legacyItems.map((u) => ({
10575
+ ip: u.ip,
10576
+ userName: u.userName,
10577
+ level: u.level,
10578
+ sessionId: void 0
10579
+ }));
10580
+ const ourSessions = ourIp ? allItems.filter((item) => item.ip === ourIp) : allItems;
10581
+ const deviceReportsZero = allItems.length === 0 && localSessionCount > 0;
10582
+ this.logger.log?.(
10583
+ `[ReolinkBaichuanApi] Startup session check: ${ourSessions.length} device session(s) from our IP (${ourIp ?? "unknown"}), ${allItems.length} total on device, ${localSessionCount} local${deviceReportsZero ? " [device may not support OnlineUserList]" : ""}`,
10584
+ {
10585
+ host: this.host,
10586
+ ourIp,
10587
+ localSessionCount,
10588
+ deviceReportsZero,
10589
+ ourSessions: ourSessions.map((s) => ({
10590
+ user: s.userName,
10591
+ ip: s.ip,
10592
+ sessionId: s.sessionId
10593
+ })),
10594
+ allSessions: allItems.map((s) => ({
10595
+ user: s.userName,
10596
+ ip: s.ip,
10597
+ sessionId: s.sessionId
10598
+ }))
10599
+ }
10600
+ );
10601
+ } catch (e) {
10602
+ this.logger.debug?.(
10603
+ "[ReolinkBaichuanApi] Could not query sessions at startup",
10604
+ e
10605
+ );
10606
+ }
10607
+ }
10608
+ /**
10609
+ * Check if too many sessions are open on the device and trigger a reboot if needed.
10610
+ * Called when acquiring a new dedicated client.
10611
+ * Uses the native Baichuan API to get the actual session count from the device.
10612
+ * Only counts sessions from our own IP address.
10613
+ */
10614
+ async maybeRebootOnTooManySessions() {
10615
+ const threshold = this.maxDedicatedSessionsBeforeReboot ?? 10;
10616
+ if (this.sessionGuardRebootInFlight) return;
10617
+ const cooldownMs = 10 * 6e4;
10618
+ const now = Date.now();
10619
+ if (this.sessionGuardLastRebootAtMs != null && now - this.sessionGuardLastRebootAtMs < cooldownMs) {
10620
+ return;
10621
+ }
10622
+ const ourIp = this.client.getLocalAddress?.();
10623
+ const ourDedicatedSessions = this.socketPool.size;
10624
+ let sessionCount;
10625
+ let sessionItems = [];
10626
+ let ourSessionCount = 0;
10627
+ let usedLocalFallback = false;
10628
+ try {
10629
+ const onlineUsers = await this.getOnlineUserList({ timeoutMs: 5e3 });
10630
+ const rawLegacy = onlineUsers.body?.OnlineUserList?.item;
10631
+ const rawCurrent = onlineUsers.body?.OnlineUserList?.OnlineUser;
10632
+ const legacyItems = Array.isArray(rawLegacy) ? rawLegacy : rawLegacy ? [rawLegacy] : [];
10633
+ const currentItems = Array.isArray(rawCurrent) ? rawCurrent : rawCurrent ? [rawCurrent] : [];
10634
+ const allItems = currentItems.length > 0 ? currentItems.map((u) => ({
10635
+ ip: u.ipAddress,
10636
+ name: u.userName,
10637
+ level: u.userLevel,
10638
+ sessionId: u.sessionId
10639
+ })) : legacyItems.map((u) => ({
10640
+ ip: u.ip,
10641
+ name: u.userName,
10642
+ level: u.level,
10643
+ sessionId: u.sessionId
10644
+ }));
10645
+ sessionItems = allItems;
10646
+ this.logger.debug?.(
10647
+ `[ReolinkBaichuanApi] Session guard: camera reports ${allItems.length} sessions, ourIp=${ourIp ?? "unknown"}`,
10648
+ {
10649
+ ourIp,
10650
+ sessions: allItems
10651
+ }
10652
+ );
10653
+ if (ourIp) {
10654
+ const ourSessions = allItems.filter((item) => item.ip === ourIp);
10655
+ ourSessionCount = ourSessions.length;
10656
+ sessionCount = ourSessionCount;
10657
+ const currentSessionIds = new Set(
10658
+ ourSessions.map((s) => s.sessionId).filter((id) => id != null)
10659
+ );
10660
+ if (this.lastKnownSessionCount !== void 0) {
10661
+ const newIds = [...currentSessionIds].filter(
10662
+ (id) => !this.lastKnownSessionIds.has(id)
10663
+ );
10664
+ const removedIds = [...this.lastKnownSessionIds].filter(
10665
+ (id) => !currentSessionIds.has(id)
10666
+ );
10667
+ if (newIds.length > 0 || removedIds.length > 0) {
10668
+ this.logger.log?.(
10669
+ `[ReolinkBaichuanApi] Session change detected: ${this.lastKnownSessionCount} -> ${ourSessionCount} sessions`,
10670
+ {
10671
+ newSessionIds: newIds,
10672
+ removedSessionIds: removedIds,
10673
+ currentSessions: ourSessions.map((s) => ({
10674
+ id: s.sessionId,
10675
+ ip: s.ip
10676
+ })),
10677
+ localDedicatedSessions: Array.from(this.socketPool.keys())
10678
+ }
10679
+ );
10680
+ }
10681
+ }
10682
+ this.lastKnownSessionCount = ourSessionCount;
10683
+ this.lastKnownSessionIds = currentSessionIds;
10684
+ if (allItems.length > 0) {
10685
+ this.logger.debug?.(
10686
+ `[ReolinkBaichuanApi] Session guard: ${ourSessionCount}/${allItems.length} sessions match ourIp=${ourIp}`
10687
+ );
10688
+ }
10689
+ } else {
10690
+ sessionCount = onlineUsers.body?.OnlineUserList?.itemNum ?? 0;
10691
+ ourSessionCount = sessionCount;
10692
+ }
10693
+ if (sessionCount === 0 && ourDedicatedSessions > 0) {
10694
+ sessionCount = ourDedicatedSessions;
10695
+ ourSessionCount = ourDedicatedSessions;
10696
+ usedLocalFallback = true;
10697
+ this.logger.debug?.(
10698
+ `[ReolinkBaichuanApi] Session guard: camera reports 0 sessions but we have ${ourDedicatedSessions} local dedicated sessions, using local fallback`
10699
+ );
10700
+ }
10701
+ } catch (e) {
10702
+ this.logger.debug?.(
10703
+ "[ReolinkBaichuanApi] Session guard: failed to query online users, using local count",
10704
+ e
10705
+ );
10706
+ sessionCount = this.socketPool.size;
10707
+ ourSessionCount = sessionCount;
10708
+ usedLocalFallback = true;
10709
+ }
10710
+ if (sessionCount < threshold) return;
10711
+ this.sessionGuardLastRebootAtMs = now;
10712
+ const localSessions = Array.from(this.socketPool.keys());
10713
+ const thresholdIsDefault = this.maxDedicatedSessionsBeforeReboot == null;
10714
+ (this.logger.warn ?? this.logger.log).call(
10715
+ this.logger,
10716
+ `[ReolinkBaichuanApi] Too many sessions from our IP (${ourIp ?? "unknown"}) on device host=${this.host} (${sessionCount} >= ${threshold}${thresholdIsDefault ? " [default]" : ""}${usedLocalFallback ? " [local fallback]" : ""}); rebooting device`,
10717
+ {
10718
+ host: this.host,
10719
+ ourIp,
10720
+ ourSessionCount,
10721
+ threshold,
10722
+ usedLocalFallback,
10723
+ thresholdFormula: thresholdIsDefault ? "default (10)" : "explicit",
10724
+ localDedicatedSessions: localSessions,
10725
+ allDeviceSessions: sessionItems
10726
+ }
10727
+ );
10728
+ this.sessionGuardRebootInFlight = this.rebootFromSessionGuard().catch((e) => {
10729
+ (this.logger.warn ?? this.logger.error).call(
10730
+ this.logger,
10731
+ "[ReolinkBaichuanApi] Session guard reboot failed",
10732
+ e
10733
+ );
10734
+ }).finally(() => {
10735
+ this.sessionGuardRebootInFlight = void 0;
10736
+ });
10737
+ }
10738
+ async rebootFromSessionGuard() {
10739
+ try {
10740
+ await this.reboot();
10741
+ return;
10742
+ } catch (e) {
10743
+ this.logger.debug?.(
10744
+ "[ReolinkBaichuanApi] Baichuan reboot failed, trying CGI",
10745
+ e
10746
+ );
10747
+ }
10748
+ try {
10749
+ await this.cgiApi.login();
10750
+ await this.cgiApi.Reboot();
10751
+ } catch (e) {
10752
+ throw e instanceof Error ? e : new Error(String(e ?? "session guard reboot failed"));
10753
+ }
10754
+ }
10755
+ /**
10756
+ * Check if there are too many voluntary disconnections and trigger a reboot if needed.
10757
+ * Called on every socket close event.
9726
10758
  */
9727
- async runMultifocalDiagnosticsConsecutively(params) {
9728
- return await runMultifocalDiagnosticsConsecutively({
9729
- ...params,
9730
- api: this,
9731
- logger: params.logger ?? this.logger,
9732
- host: this.host,
9733
- username: this.username,
9734
- password: this.password
9735
- });
9736
- }
9737
10759
  async maybeRebootOnDisconnectStorm() {
9738
10760
  const threshold = this.rebootAfterDisconnectionsPerMinute;
9739
- if (threshold == null) return;
9740
- const info = this.client.getLastDisconnectInfo?.();
10761
+ if (threshold <= 0) return;
10762
+ const entry = this.socketPool.get("general");
10763
+ if (!entry) return;
10764
+ const info = entry.client.getLastDisconnectInfo?.();
9741
10765
  if (!info?.voluntary) return;
9742
10766
  const now = Date.now();
9743
10767
  const windowMs = 6e4;
@@ -9749,54 +10773,90 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9749
10773
  if (this.disconnectStormVoluntaryAtMs.length < threshold) return;
9750
10774
  if (this.disconnectStormRebootInFlight) return;
9751
10775
  const cooldownMs = 10 * 6e4;
9752
- if (this.disconnectStormLastRebootAtMs != null && now - this.disconnectStormLastRebootAtMs < cooldownMs)
10776
+ if (this.disconnectStormLastRebootAtMs != null && now - this.disconnectStormLastRebootAtMs < cooldownMs) {
9753
10777
  return;
10778
+ }
9754
10779
  this.disconnectStormLastRebootAtMs = now;
9755
- (this.logger.warn ?? this.logger.error).call(
10780
+ (this.logger.warn ?? this.logger.log).call(
9756
10781
  this.logger,
9757
- "[ReolinkBaichuanApi] disconnect storm detected; rebooting device",
10782
+ `[ReolinkBaichuanApi] Disconnect storm detected for host=${this.host} (${this.disconnectStormVoluntaryAtMs.length} voluntary disconnects in 60s >= ${threshold}); rebooting device`,
9758
10783
  {
10784
+ host: this.host,
9759
10785
  transport: info.transport,
9760
10786
  reason: info.reason,
9761
10787
  voluntaryDisconnectsInWindow: this.disconnectStormVoluntaryAtMs.length,
9762
- windowMs,
9763
10788
  threshold,
9764
- cooldownMs,
9765
- method: "auto"
10789
+ windowMs,
10790
+ cooldownMs
9766
10791
  }
9767
10792
  );
9768
- this.disconnectStormRebootInFlight = this.rebootFromDisconnectStorm("auto").catch((e) => {
10793
+ this.disconnectStormRebootInFlight = this.rebootFromSessionGuard().catch((e) => {
9769
10794
  (this.logger.warn ?? this.logger.error).call(
9770
10795
  this.logger,
9771
- "[ReolinkBaichuanApi] disconnect-storm reboot failed",
10796
+ "[ReolinkBaichuanApi] Disconnect storm reboot failed",
9772
10797
  e
9773
10798
  );
9774
10799
  }).finally(() => {
9775
10800
  this.disconnectStormRebootInFlight = void 0;
9776
10801
  });
9777
10802
  }
9778
- async rebootFromDisconnectStorm(method) {
9779
- let lastErr;
9780
- if (method === "auto" || method === "baichuan") {
9781
- try {
9782
- await this.reboot();
9783
- return;
9784
- } catch (e) {
9785
- lastErr = e;
9786
- if (method === "baichuan") throw e;
9787
- }
10803
+ /**
10804
+ * Check if there are too many consecutive ECONNRESET errors and trigger a reboot if needed.
10805
+ * This guards against camera saturation where the device refuses all new connections.
10806
+ * Called on every socket close event.
10807
+ */
10808
+ async maybeRebootOnEconnresetStorm() {
10809
+ const threshold = this.rebootAfterConsecutiveEconnreset;
10810
+ if (threshold <= 0) return;
10811
+ const entry = this.socketPool.get("general");
10812
+ if (!entry) return;
10813
+ const info = entry.client.getLastDisconnectInfo?.();
10814
+ const isEconnreset = info?.errorCode === "ECONNRESET";
10815
+ if (!isEconnreset) {
10816
+ this.consecutiveEconnresetCount = 0;
10817
+ this.consecutiveEconnresetFirstAtMs = void 0;
10818
+ return;
9788
10819
  }
9789
- if (method === "auto" || method === "cgi") {
9790
- try {
9791
- await this.cgiApi.login();
9792
- await this.cgiApi.Reboot();
9793
- return;
9794
- } catch (e) {
9795
- lastErr = e;
9796
- if (method === "cgi") throw e;
9797
- }
10820
+ const now = Date.now();
10821
+ const windowMs = 6e4;
10822
+ if (this.consecutiveEconnresetFirstAtMs == null) {
10823
+ this.consecutiveEconnresetFirstAtMs = now;
9798
10824
  }
9799
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? "disconnect-storm reboot failed"));
10825
+ if (now - this.consecutiveEconnresetFirstAtMs > windowMs) {
10826
+ this.consecutiveEconnresetCount = 1;
10827
+ this.consecutiveEconnresetFirstAtMs = now;
10828
+ } else {
10829
+ this.consecutiveEconnresetCount++;
10830
+ }
10831
+ if (this.consecutiveEconnresetCount < threshold) return;
10832
+ if (this.econnresetStormRebootInFlight) return;
10833
+ const cooldownMs = 10 * 6e4;
10834
+ if (this.econnresetStormLastRebootAtMs != null && now - this.econnresetStormLastRebootAtMs < cooldownMs) {
10835
+ return;
10836
+ }
10837
+ this.econnresetStormLastRebootAtMs = now;
10838
+ (this.logger.warn ?? this.logger.log).call(
10839
+ this.logger,
10840
+ `[ReolinkBaichuanApi] ECONNRESET storm detected for host=${this.host} (${this.consecutiveEconnresetCount} consecutive ECONNRESET in 60s >= ${threshold}); rebooting device`,
10841
+ {
10842
+ host: this.host,
10843
+ consecutiveEconnreset: this.consecutiveEconnresetCount,
10844
+ threshold,
10845
+ windowMs,
10846
+ cooldownMs
10847
+ }
10848
+ );
10849
+ this.consecutiveEconnresetCount = 0;
10850
+ this.consecutiveEconnresetFirstAtMs = void 0;
10851
+ this.econnresetStormRebootInFlight = this.rebootFromSessionGuard().catch((e) => {
10852
+ (this.logger.warn ?? this.logger.error).call(
10853
+ this.logger,
10854
+ "[ReolinkBaichuanApi] ECONNRESET storm reboot failed",
10855
+ e
10856
+ );
10857
+ }).finally(() => {
10858
+ this.econnresetStormRebootInFlight = void 0;
10859
+ });
9800
10860
  }
9801
10861
  /**
9802
10862
  * Subscribe to minimal high-level events.
@@ -9941,12 +11001,19 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9941
11001
  /**
9942
11002
  * Determines if this device is an NVR/Hub (multiple channels) vs a standalone camera.
9943
11003
  * Checks:
9944
- * 1. Channel count > 1 (typical NVR detection)
9945
- * 2. Device model matches NVR/Hub patterns (for devices like Home Hub that report channelNum=1)
11004
+ * 1. Cached value from setIsNvr()
11005
+ * 2. Channel count > 3 (typical NVR detection)
11006
+ * 3. Device model matches NVR/Hub patterns (for devices like Home Hub that report channelNum=1)
9946
11007
  */
9947
11008
  async isNvrDevice() {
11009
+ if (this._isNvr !== void 0) {
11010
+ return this._isNvr;
11011
+ }
9948
11012
  const channelCount = await this.getChannelCount();
9949
- if (channelCount > 3) return true;
11013
+ if (channelCount > 3) {
11014
+ this._isNvr = true;
11015
+ return true;
11016
+ }
9950
11017
  try {
9951
11018
  const info = await this.getInfo(void 0, {
9952
11019
  tags: ["type"],
@@ -9956,23 +11023,76 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
9956
11023
  this.logger.debug?.(
9957
11024
  `[ReolinkBaichuanApi] isNvrDevice: type="${info.type}" matches NVR/Hub pattern`
9958
11025
  );
11026
+ this._isNvr = true;
9959
11027
  return true;
9960
11028
  }
9961
11029
  } catch {
9962
11030
  }
11031
+ this._isNvr = false;
9963
11032
  return false;
9964
11033
  }
11034
+ /**
11035
+ * Set the NVR/Hub flag explicitly.
11036
+ * Call this early (before streaming) to ensure correct socket pooling.
11037
+ * @param isNvr - true if this is an NVR/Hub, false for standalone camera
11038
+ */
11039
+ setIsNvr(isNvr) {
11040
+ this._isNvr = isNvr;
11041
+ this.logger.debug?.(`[ReolinkBaichuanApi] setIsNvr: ${isNvr}`);
11042
+ }
11043
+ /**
11044
+ * Enable or disable idle disconnect dynamically.
11045
+ *
11046
+ * This is useful when the battery status is discovered after connection
11047
+ * (e.g., during autodetect). Call with `true` for battery cameras to
11048
+ * preserve battery life by disconnecting when idle.
11049
+ *
11050
+ * @param enabled - true to enable idle disconnect, false to disable
11051
+ */
11052
+ setIdleDisconnect(enabled) {
11053
+ this.client.setIdleDisconnect(enabled);
11054
+ this.logger.debug?.(`[ReolinkBaichuanApi] setIdleDisconnect: ${enabled}`);
11055
+ }
9965
11056
  async login(maxEncryption) {
9966
11057
  await this.client.login(maxEncryption);
9967
11058
  }
11059
+ /**
11060
+ * Stop all active video streams on the main API client.
11061
+ * Called automatically during close() to ensure clean session termination.
11062
+ */
11063
+ async stopAllActiveStreams() {
11064
+ const activeStreams = Array.from(this.activeVideoMsgNums.keys());
11065
+ if (activeStreams.length === 0) {
11066
+ return;
11067
+ }
11068
+ this.logger?.debug?.(
11069
+ `[ReolinkBaichuanApi] Stopping ${activeStreams.length} active stream(s) before close`
11070
+ );
11071
+ await Promise.allSettled(
11072
+ activeStreams.map(async (key) => {
11073
+ const [ch, profile, variant] = key.split(":");
11074
+ try {
11075
+ await this.stopVideoStream(Number(ch), profile, {
11076
+ variant
11077
+ });
11078
+ } catch (e) {
11079
+ this.logger?.debug?.(
11080
+ `[ReolinkBaichuanApi] Error stopping stream ${key}: ${e instanceof Error ? e.message : String(e)}`
11081
+ );
11082
+ }
11083
+ })
11084
+ );
11085
+ }
9968
11086
  async close(options) {
11087
+ if (this.sessionGuardIntervalTimer) {
11088
+ clearInterval(this.sessionGuardIntervalTimer);
11089
+ this.sessionGuardIntervalTimer = void 0;
11090
+ }
9969
11091
  this.stopStatePolling();
9970
11092
  this.stopUdpSleepInference();
9971
11093
  await this.cleanup();
9972
- await this.cleanupDedicatedClients();
9973
- await this.client.close(
9974
- options?.reason ? { reason: options.reason } : void 0
9975
- );
11094
+ await this.stopAllActiveStreams();
11095
+ await this.cleanupSocketPool();
9976
11096
  }
9977
11097
  /**
9978
11098
  * Cleanup all RTSP servers and release resources.
@@ -10175,10 +11295,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
10175
11295
  logger?.info?.(
10176
11296
  `[DedicatedTalk] Creating session: ${sessionKey} (idleTimeout=${idleTimeoutMs}ms)`
10177
11297
  );
10178
- const { client: dedicatedClient, release } = await this.acquireDedicatedClient(sessionKey, logger);
10179
- const summary = this.getDedicatedSessionsSummary();
11298
+ const tag = this.resolveSocketTag(sessionKey);
11299
+ const { client: dedicatedClient, release } = await this.acquirePooledSocket(
11300
+ tag,
11301
+ logger
11302
+ );
11303
+ const summary = this.getSocketPoolSummary();
10180
11304
  logger?.info?.(
10181
- `[DedicatedTalk] Session created [sessions: ${summary.count} active${summary.count > 0 ? ` (${summary.keys.join(", ")})` : ""}]`
11305
+ `[DedicatedTalk] Session created [sessions: ${summary.count} active${summary.count > 0 ? ` (${summary.tags.join(", ")})` : ""}]`
10182
11306
  );
10183
11307
  try {
10184
11308
  const isUdp = dedicatedClient.getTransport?.() === "udp";
@@ -11301,8 +12425,12 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11301
12425
  const channel = params.channel;
11302
12426
  const streamType = params.streamType;
11303
12427
  const logger = params.logger ?? this.logger;
11304
- const sessionKey = params.deviceId ? `replay:${params.deviceId}` : `replay:standalone:${channel}:${Date.now()}`;
11305
- const { client: dedicatedClient, release: releaseDedicatedClient } = await this.acquireDedicatedClient(sessionKey, logger);
12428
+ const sessionKey = params.deviceId ? `replay:${params.deviceId}:ch${channel}` : `replay:standalone:ch${channel}:${Date.now()}`;
12429
+ const tag = this.resolveSocketTag(sessionKey);
12430
+ logger?.debug?.(
12431
+ `[startRecordingReplayStreamStandalone] sessionKey=${sessionKey} -> tag=${tag}`
12432
+ );
12433
+ const { client: dedicatedClient, release: releaseDedicatedClient } = await this.acquirePooledSocket(tag, logger);
11306
12434
  const uid = await this.ensureUidForRecordings(channel, void 0);
11307
12435
  const payloadXml = buildFileInfoListReplayByNameXml({
11308
12436
  channel,
@@ -11430,8 +12558,9 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
11430
12558
  const channel = params.channel;
11431
12559
  const streamType = params.streamType;
11432
12560
  const logger = params.logger ?? this.logger;
11433
- const sessionKey = params.deviceId ? `replay:${params.deviceId}` : `replay:nvr:${channel}:${Date.now()}`;
11434
- const { client: dedicatedClient, release: releaseDedicatedClient } = await this.acquireDedicatedClient(sessionKey, logger);
12561
+ const sessionKey = params.deviceId ? `replay:${params.deviceId}:ch${channel}` : `replay:nvr:ch${channel}:${Date.now()}`;
12562
+ const tag = this.resolveSocketTag(sessionKey);
12563
+ const { client: dedicatedClient, release: releaseDedicatedClient } = await this.acquirePooledSocket(tag, logger);
11435
12564
  let uid;
11436
12565
  try {
11437
12566
  uid = await this.ensureUidForRecordings(channel, void 0);
@@ -13175,6 +14304,9 @@ ${stderr}`)
13175
14304
  * After subscribing, events will be emitted via client.on("event", ...)
13176
14305
  */
13177
14306
  async subscribeEvents() {
14307
+ this.logger.debug?.(
14308
+ "[ReolinkBaichuanApi] subscribeEvents() called - checking session count before"
14309
+ );
13178
14310
  await this.client.login();
13179
14311
  const channel = this.client.getConfiguredChannel?.() ?? 0;
13180
14312
  const attempts = [
@@ -15448,7 +16580,8 @@ ${xml}`
15448
16580
  const result2 = {
15449
16581
  nativeStreams,
15450
16582
  rtmpStreams,
15451
- rtspStreams
16583
+ rtspStreams,
16584
+ rawEncXml: void 0
15452
16585
  };
15453
16586
  return cacheOrFallback(result2);
15454
16587
  }
@@ -15536,7 +16669,8 @@ ${xml}`
15536
16669
  return {
15537
16670
  nativeStreams,
15538
16671
  rtmpStreams,
15539
- rtspStreams
16672
+ rtspStreams,
16673
+ rawEncXml: widerMetadata?.rawXml
15540
16674
  };
15541
16675
  }
15542
16676
  const guessRtspEncodingPrefix = (m) => {
@@ -15798,7 +16932,8 @@ ${xml}`
15798
16932
  const result = {
15799
16933
  nativeStreams,
15800
16934
  rtmpStreams,
15801
- rtspStreams
16935
+ rtspStreams,
16936
+ rawEncXml: streamMetadata?.rawXml
15802
16937
  };
15803
16938
  return cacheOrFallback(result);
15804
16939
  }
@@ -15811,7 +16946,7 @@ ${xml}`
15811
16946
  * @returns Test results for all stream types and profiles
15812
16947
  */
15813
16948
  async testChannelStreams(channel, logger) {
15814
- const { testChannelStreams } = await import("./DiagnosticsTools-EC7DADEQ.js");
16949
+ const { testChannelStreams } = await import("./DiagnosticsTools-6WEMO4L4.js");
15815
16950
  return await testChannelStreams({
15816
16951
  api: this,
15817
16952
  channel: this.normalizeChannel(channel),
@@ -15827,7 +16962,7 @@ ${xml}`
15827
16962
  * @returns Complete diagnostics for all channels and streams
15828
16963
  */
15829
16964
  async collectMultifocalDiagnostics(logger) {
15830
- const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-EC7DADEQ.js");
16965
+ const { collectMultifocalDiagnostics } = await import("./DiagnosticsTools-6WEMO4L4.js");
15831
16966
  return await collectMultifocalDiagnostics({
15832
16967
  api: this,
15833
16968
  logger
@@ -16506,6 +17641,134 @@ ${xml}`
16506
17641
  });
16507
17642
  return parseXmlFragmentToJson(xml);
16508
17643
  }
17644
+ /**
17645
+ * Build the UI-friendly user sessions strings directly in the library.
17646
+ * This is intended for hosts (like Scrypted plugins) that want a stable, consistent
17647
+ * sessions view without re-implementing parsing/formatting logic.
17648
+ */
17649
+ async getOnlineUserSessionsForUi(options) {
17650
+ const sessions = await this.getOnlineUserList(options);
17651
+ let socketSessionId;
17652
+ try {
17653
+ socketSessionId = this.client?.getSocketSessionId?.();
17654
+ } catch {
17655
+ }
17656
+ const out = [];
17657
+ out.push(`Last updated: ${(/* @__PURE__ */ new Date()).toLocaleString()}`);
17658
+ if (socketSessionId) {
17659
+ out.push(`Current socket session ID: ${socketSessionId}`);
17660
+ }
17661
+ try {
17662
+ const summary = this.getDedicatedSessionsSummary();
17663
+ out.push(`Internal dedicated sessions (active): ${summary.count}`);
17664
+ for (const key of summary.keys) {
17665
+ out.push(` - ${key}`);
17666
+ }
17667
+ } catch {
17668
+ }
17669
+ out.push("");
17670
+ const looksLikeUserSession = (value) => {
17671
+ if (!value || typeof value !== "object") return false;
17672
+ const hasUser = value.userName !== void 0 || value.user !== void 0 || value.username !== void 0;
17673
+ const hasIp = value.ip !== void 0 || value.ipAddress !== void 0;
17674
+ const hasPort = value.port !== void 0;
17675
+ const hasSessionId = value.sessionId !== void 0;
17676
+ const hasId = value.id !== void 0;
17677
+ return hasUser && (hasIp || hasPort) || hasSessionId && (hasUser || hasIp || hasPort) || hasId && (hasUser || hasIp || hasPort);
17678
+ };
17679
+ const seen = /* @__PURE__ */ new Set();
17680
+ const keyFor = (session, group) => {
17681
+ const user = session?.userName ?? session?.user ?? session?.username;
17682
+ const ip = session?.ip ?? session?.ipAddress;
17683
+ const port = session?.port;
17684
+ const sessionId = session?.sessionId;
17685
+ const id = session?.id;
17686
+ return `${group ?? ""}|u:${String(user ?? "")}@${String(ip ?? "")}:${String(
17687
+ port ?? ""
17688
+ )}|sid:${String(sessionId ?? "")}|id:${String(id ?? "")}`;
17689
+ };
17690
+ const collected = [];
17691
+ const collect = (data, group) => {
17692
+ if (!data) return;
17693
+ if (Array.isArray(data)) {
17694
+ for (const item of data) {
17695
+ if (looksLikeUserSession(item)) {
17696
+ const k = keyFor(item, group);
17697
+ if (!seen.has(k)) {
17698
+ seen.add(k);
17699
+ collected.push(
17700
+ group ? { session: item, group } : { session: item }
17701
+ );
17702
+ }
17703
+ } else if (item && typeof item === "object") {
17704
+ collect(item, group);
17705
+ }
17706
+ }
17707
+ return;
17708
+ }
17709
+ if (typeof data !== "object") return;
17710
+ let foundNested = false;
17711
+ for (const [k, v] of Object.entries(data)) {
17712
+ if (Array.isArray(v)) {
17713
+ foundNested = true;
17714
+ collect(v, k);
17715
+ } else if (v && typeof v === "object") {
17716
+ foundNested = true;
17717
+ collect(v, group);
17718
+ }
17719
+ }
17720
+ if (!foundNested && looksLikeUserSession(data)) {
17721
+ const k = keyFor(data, group);
17722
+ if (!seen.has(k)) {
17723
+ seen.add(k);
17724
+ collected.push(group ? { session: data, group } : { session: data });
17725
+ }
17726
+ }
17727
+ };
17728
+ const format = (session, index, group) => {
17729
+ const parts = [];
17730
+ if (group) parts.push(`[${group}]`);
17731
+ parts.push(`Session ${index}:`);
17732
+ if (session.userName !== void 0)
17733
+ parts.push(`User: ${session.userName}`);
17734
+ if (session.user !== void 0) parts.push(`User: ${session.user}`);
17735
+ if (session.ip !== void 0) parts.push(`IP: ${session.ip}`);
17736
+ if (session.ipAddress !== void 0)
17737
+ parts.push(`IP: ${session.ipAddress}`);
17738
+ if (session.port !== void 0) parts.push(`Port: ${session.port}`);
17739
+ if (session.sessionId !== void 0)
17740
+ parts.push(`Session ID: ${session.sessionId}`);
17741
+ if (session.id !== void 0) parts.push(`ID: ${session.id}`);
17742
+ if (session.loginTime !== void 0)
17743
+ parts.push(`Login Time: ${session.loginTime}`);
17744
+ if (session.time !== void 0) parts.push(`Time: ${session.time}`);
17745
+ if (session.status !== void 0) parts.push(`Status: ${session.status}`);
17746
+ if (parts.length === (group ? 2 : 1)) {
17747
+ if (session && typeof session === "object") {
17748
+ const allFields = Object.entries(session).filter(([, v]) => v === null || typeof v !== "object").map(([k, v]) => `${k}: ${v}`).join(", ");
17749
+ if (allFields) parts.push(allFields);
17750
+ }
17751
+ }
17752
+ return parts.join(" | ");
17753
+ };
17754
+ collect(sessions);
17755
+ const onlineUserList = sessions.body?.OnlineUserList;
17756
+ const rawCurrent = onlineUserList?.OnlineUser;
17757
+ const rawLegacy = onlineUserList?.item;
17758
+ const currentItems = Array.isArray(rawCurrent) ? rawCurrent : rawCurrent ? [rawCurrent] : [];
17759
+ const legacyItems = Array.isArray(rawLegacy) ? rawLegacy : rawLegacy ? [rawLegacy] : [];
17760
+ const sessionIds = currentItems.map((s) => `${s.sessionId}@${s.ipAddress}`).join(", ");
17761
+ const legacyIds = legacyItems.map((s) => `${s.sessionId}@${s.ip}`).join(", ");
17762
+ this.logger.log?.(
17763
+ `[ReolinkBaichuanApi] getOnlineUserSessionsForUi: legacyItems=${legacyItems.length}, currentItems=${currentItems.length} [${sessionIds || legacyIds || "none"}]`
17764
+ );
17765
+ let count = 0;
17766
+ for (const entry of collected) {
17767
+ out.push(format(entry.session, ++count, entry.group));
17768
+ }
17769
+ if (count === 0) out.push("No active sessions found");
17770
+ return out;
17771
+ }
16509
17772
  // Placeholder cmdIds seen in PCAPs but without XML samples yet.
16510
17773
  // Expose as JSON (parsed from XML) for easy inspection.
16511
17774
  async getCmd123(channel, options) {
@@ -16862,18 +18125,12 @@ ${scheduleItems}
16862
18125
  ...params.isNvr != null ? { isNvr: params.isNvr } : {},
16863
18126
  ...params.deviceId != null ? { deviceId: params.deviceId } : {}
16864
18127
  };
16865
- const { result: replayResult, release: releaseQueueSlot } = await this.enqueueStreamingReplayOperation(async () => {
16866
- try {
16867
- return await this.startRecordingReplayStream(startParams);
16868
- } catch (e) {
16869
- if (!params.deviceId) throw e;
16870
- const sessionKey = `replay:${params.deviceId}`;
16871
- logger?.debug?.(
16872
- `[createRecordingReplayMp4Stream] startRecordingReplayStream failed; force-closing dedicated client and retrying once`
16873
- );
16874
- await this.forceCloseDedicatedClient(sessionKey, logger);
16875
- return await this.startRecordingReplayStream(startParams);
16876
- }
18128
+ const {
18129
+ result: replayResult,
18130
+ release: releaseQueueSlot,
18131
+ abortSignal
18132
+ } = await this.enqueueStreamingReplayOperation(async () => {
18133
+ return await this.startRecordingReplayStream(startParams);
16877
18134
  });
16878
18135
  const { stream, stop: stopReplay } = replayResult;
16879
18136
  const input = new PassThrough();
@@ -17017,6 +18274,20 @@ ${scheduleItems}
17017
18274
  },
17018
18275
  Math.max(1, seconds) * 1e3
17019
18276
  );
18277
+ if (abortSignal) {
18278
+ abortSignal.addEventListener(
18279
+ "abort",
18280
+ () => {
18281
+ if (!ended) {
18282
+ logger?.debug?.(
18283
+ `[createRecordingReplayMp4Stream] Abort signal received, stopping for new clip`
18284
+ );
18285
+ void stopAll();
18286
+ }
18287
+ },
18288
+ { once: true }
18289
+ );
18290
+ }
17020
18291
  output.on("close", () => {
17021
18292
  clearTimeout(timer);
17022
18293
  void stopAll();
@@ -17269,18 +18540,12 @@ ${scheduleItems}
17269
18540
  ...params.isNvr != null ? { isNvr: params.isNvr } : {},
17270
18541
  ...params.deviceId != null ? { deviceId: params.deviceId } : {}
17271
18542
  };
17272
- const { result: replayResult, release: releaseQueueSlot } = await this.enqueueStreamingReplayOperation(async () => {
17273
- try {
17274
- return await this.startRecordingReplayStream(startParams);
17275
- } catch (e) {
17276
- if (!params.deviceId) throw e;
17277
- const sessionKey = `replay:${params.deviceId}`;
17278
- logger?.debug?.(
17279
- `[createRecordingReplayHlsSession] startRecordingReplayStream failed; force-closing dedicated client and retrying once`
17280
- );
17281
- await this.forceCloseDedicatedClient(sessionKey, logger);
17282
- return await this.startRecordingReplayStream(startParams);
17283
- }
18543
+ const {
18544
+ result: replayResult,
18545
+ release: releaseQueueSlot,
18546
+ abortSignal
18547
+ } = await this.enqueueStreamingReplayOperation(async () => {
18548
+ return await this.startRecordingReplayStream(startParams);
17284
18549
  });
17285
18550
  const { stream, stop: stopReplay } = replayResult;
17286
18551
  const input = new PassThrough();
@@ -17465,6 +18730,20 @@ ${scheduleItems}
17465
18730
  },
17466
18731
  Math.max(1, seconds) * 1e3
17467
18732
  );
18733
+ if (abortSignal) {
18734
+ abortSignal.addEventListener(
18735
+ "abort",
18736
+ () => {
18737
+ if (!ended) {
18738
+ logger?.debug?.(
18739
+ `[createRecordingReplayHlsSession] Abort signal received, stopping for new clip`
18740
+ );
18741
+ void stopAll();
18742
+ }
18743
+ },
18744
+ { once: true }
18745
+ );
18746
+ }
17468
18747
  stream.on("error", (e) => {
17469
18748
  logger?.error?.(
17470
18749
  `[createRecordingReplayHlsSession] Stream error: ${e.message}`
@@ -18206,7 +19485,9 @@ async function discoverUidForHost(host, logger) {
18206
19485
  const directMatch = directDevices.find((d) => d.host === directTarget);
18207
19486
  const directUid = normalizeUid(directMatch?.uid);
18208
19487
  if (directUid) {
18209
- logger?.log?.(`[AutoDetect] UID discovered via UDP direct: ${maskUid(directUid)}`);
19488
+ logger?.log?.(
19489
+ `[AutoDetect] UID discovered via UDP direct: ${maskUid(directUid)}`
19490
+ );
18210
19491
  return directUid;
18211
19492
  }
18212
19493
  } catch {
@@ -18220,7 +19501,9 @@ async function discoverUidForHost(host, logger) {
18220
19501
  const match = devices.find((d) => d.host === ip || d.host === host);
18221
19502
  const uid = normalizeUid(match?.uid);
18222
19503
  if (uid) {
18223
- logger?.log?.(`[AutoDetect] UID discovered via UDP broadcast: ${maskUid(uid)}`);
19504
+ logger?.log?.(
19505
+ `[AutoDetect] UID discovered via UDP broadcast: ${maskUid(uid)}`
19506
+ );
18224
19507
  return uid;
18225
19508
  }
18226
19509
  } catch {
@@ -18236,7 +19519,13 @@ async function pingHost(host, timeoutMs = 3e3) {
18236
19519
  return new Promise((resolve) => {
18237
19520
  const { exec } = __require("child_process");
18238
19521
  const platform = process.platform;
18239
- const pingCmd = platform === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform === "darwin" ? `ping -c 1 -W ${timeoutMs} ${host}` : `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`;
19522
+ const pingCmd = platform === "win32" ? `ping -n 1 -w ${timeoutMs} ${host}` : platform === "darwin" ? (
19523
+ // macOS: -W is in milliseconds (Linux: seconds)
19524
+ `ping -c 1 -W ${timeoutMs} ${host}`
19525
+ ) : (
19526
+ // Linux/BSD-ish: -W is in seconds on most distros
19527
+ `ping -c 1 -W ${Math.max(1, Math.floor(timeoutMs / 1e3))} ${host}`
19528
+ );
18240
19529
  exec(pingCmd, (error) => {
18241
19530
  resolve(!error);
18242
19531
  });
@@ -18259,15 +19548,20 @@ function createBaichuanApi(inputs, transport) {
18259
19548
  return api2;
18260
19549
  }
18261
19550
  const uid = normalizeUid(inputs.uid);
18262
- if (!uid) {
18263
- throw new Error("UID is required for battery cameras (BCUDP)");
19551
+ const isLocalDirect = inputs.udpDiscoveryMethod === "local-direct";
19552
+ if (!uid && !isLocalDirect) {
19553
+ throw new Error(
19554
+ "UID is required for UDP cameras (BCUDP) unless using local-direct discovery"
19555
+ );
18264
19556
  }
18265
19557
  const api = new ReolinkBaichuanApi({
18266
19558
  ...base,
18267
19559
  transport: "udp",
18268
- uid,
18269
- ...inputs.udpDiscoveryMethod ? { udpDiscoveryMethod: inputs.udpDiscoveryMethod } : {},
18270
- idleDisconnect: true
19560
+ // UID is optional for local-direct, required for other methods
19561
+ ...uid ? { uid } : {},
19562
+ ...inputs.udpDiscoveryMethod ? { udpDiscoveryMethod: inputs.udpDiscoveryMethod } : {}
19563
+ // NOTE: idleDisconnect is NOT enabled here - it will be enabled dynamically
19564
+ // after detecting if the device has a battery (via setIdleDisconnect)
18271
19565
  });
18272
19566
  attachErrorHandler(api, transport, inputs);
18273
19567
  return api;
@@ -18280,7 +19574,9 @@ function attachErrorHandler(api, transport, inputs) {
18280
19574
  if (typeof msg === "string" && (msg.includes("Baichuan socket closed") || msg.includes("Baichuan UDP stream closed") || msg.includes("Not running"))) {
18281
19575
  return;
18282
19576
  }
18283
- inputs.logger?.log?.(`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`);
19577
+ inputs.logger?.log?.(
19578
+ `[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`
19579
+ );
18284
19580
  });
18285
19581
  api.client.on("close", () => {
18286
19582
  });
@@ -18291,7 +19587,13 @@ async function autoDetectDeviceType(inputs) {
18291
19587
  const { host, uid, logger } = inputs;
18292
19588
  const mode = inputs.mode ?? "auto";
18293
19589
  const maxRetriesRaw = inputs.maxRetries;
18294
- const maxRetries = Math.max(1, Math.min(10, typeof maxRetriesRaw === "number" && Number.isFinite(maxRetriesRaw) ? Math.floor(maxRetriesRaw) : 1));
19590
+ const maxRetries = Math.max(
19591
+ 1,
19592
+ Math.min(
19593
+ 10,
19594
+ typeof maxRetriesRaw === "number" && Number.isFinite(maxRetriesRaw) ? Math.floor(maxRetriesRaw) : 1
19595
+ )
19596
+ );
18295
19597
  const fmtErr = (e) => {
18296
19598
  const m = e?.message || e?.toString?.() || String(e);
18297
19599
  return typeof m === "string" ? m : String(m);
@@ -18317,7 +19619,9 @@ async function autoDetectDeviceType(inputs) {
18317
19619
  lastErr = e;
18318
19620
  const msg = fmtErr(e);
18319
19621
  const retryable = attempt < max && shouldRetry(e);
18320
- logger?.log?.(`[AutoDetect] ${label} attempt ${attempt}/${max} failed: ${msg}${retryable ? " (will retry)" : ""}`);
19622
+ logger?.log?.(
19623
+ `[AutoDetect] ${label} attempt ${attempt}/${max} failed: ${msg}${retryable ? " (will retry)" : ""}`
19624
+ );
18321
19625
  if (!retryable) throw e;
18322
19626
  await sleepMs2(350);
18323
19627
  }
@@ -18328,15 +19632,21 @@ async function autoDetectDeviceType(inputs) {
18328
19632
  logger?.log?.(`[AutoDetect] Pinging ${host}...`);
18329
19633
  const isReachable = await pingHost(host);
18330
19634
  if (!isReachable) {
18331
- logger?.log?.(`[AutoDetect] Host ${host} is not reachable via ping, but continuing with connection attempt...`);
19635
+ logger?.log?.(
19636
+ `[AutoDetect] Host ${host} is not reachable via ping, but continuing with connection attempt...`
19637
+ );
18332
19638
  } else {
18333
19639
  logger?.log?.(`[AutoDetect] Host ${host} is reachable`);
18334
19640
  }
18335
19641
  if (mode === "udp") {
18336
- logger?.log?.(`[AutoDetect] Forced mode=udp, skipping TCP and starting UDP discovery/login...`);
19642
+ logger?.log?.(
19643
+ `[AutoDetect] Forced mode=udp, skipping TCP and starting UDP discovery/login...`
19644
+ );
18337
19645
  let normalizedUid = effectiveUid;
18338
19646
  if (!normalizedUid) {
18339
- logger?.log?.(`[AutoDetect] UID not provided; attempting UDP discovery for UID...`);
19647
+ logger?.log?.(
19648
+ `[AutoDetect] UID not provided; attempting UDP discovery for UID...`
19649
+ );
18340
19650
  const discovered = await discoverUidForHost(host, logger);
18341
19651
  const normalizedDiscovered = normalizeUid(discovered);
18342
19652
  if (!normalizedDiscovered) {
@@ -18355,13 +19665,18 @@ async function autoDetectDeviceType(inputs) {
18355
19665
  `UDP(${m})`,
18356
19666
  maxRetries,
18357
19667
  async (attempt) => {
18358
- const api = createBaichuanApi({ ...inputs, uid: normalizedUid, udpDiscoveryMethod: m }, "udp");
19668
+ const api = createBaichuanApi(
19669
+ { ...inputs, uid: normalizedUid, udpDiscoveryMethod: m },
19670
+ "udp"
19671
+ );
18359
19672
  try {
18360
19673
  await api.login();
18361
19674
  return api;
18362
19675
  } catch (e) {
18363
19676
  try {
18364
- await api.close({ reason: `autodetect:udp_failed:${m}:attempt_${attempt}` });
19677
+ await api.close({
19678
+ reason: `autodetect:udp_failed:${m}:attempt_${attempt}`
19679
+ });
18365
19680
  } catch {
18366
19681
  }
18367
19682
  throw e;
@@ -18379,10 +19694,11 @@ async function autoDetectDeviceType(inputs) {
18379
19694
  const channelNumValue = typeof channelNum === "string" ? Number.parseInt(channelNum, 10) : channelNum;
18380
19695
  const hasDualLensChannelCount = (channelNumValue === 2 || channelNumValue === 3) && Number.isFinite(channelNumValue);
18381
19696
  const isMultifocal = isMultifocalByModel || hasDualLensChannelCount;
19697
+ const hasBattery = capabilities?.capabilities?.hasBattery === true;
18382
19698
  if (isMultifocal) {
18383
19699
  const detectionMethod = isMultifocalByModel ? "model match" : "channelNum fallback";
18384
19700
  logger?.log?.(
18385
- `[AutoDetect] UDP (${m}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}).`
19701
+ `[AutoDetect] UDP (${m}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}, hasBattery=${hasBattery}).`
18386
19702
  );
18387
19703
  return {
18388
19704
  type: "multifocal",
@@ -18395,9 +19711,12 @@ async function autoDetectDeviceType(inputs) {
18395
19711
  api: udpApi
18396
19712
  };
18397
19713
  }
18398
- logger?.log?.(`[AutoDetect] UDP (${m}) connection successful. Detected battery camera.`);
19714
+ const deviceType = hasBattery ? "battery-cam" : "camera";
19715
+ logger?.log?.(
19716
+ `[AutoDetect] UDP (${m}) connection successful. Detected ${deviceType} (hasBattery=${hasBattery}, model=${normalizedModel ?? "unknown"}).`
19717
+ );
18399
19718
  return {
18400
- type: "battery-cam",
19719
+ type: deviceType,
18401
19720
  transport: "udp",
18402
19721
  uid: normalizedUid,
18403
19722
  udpDiscoveryMethod: m,
@@ -18412,7 +19731,9 @@ async function autoDetectDeviceType(inputs) {
18412
19731
  logger?.log?.(`[AutoDetect] UDP (${m}) failed: ${msg}`);
18413
19732
  }
18414
19733
  }
18415
- throw new Error(`Forced UDP autodetect failed for all methods. ${udpErrors.join(" | ")}`);
19734
+ throw new Error(
19735
+ `Forced UDP autodetect failed for all methods. ${udpErrors.join(" | ")}`
19736
+ );
18416
19737
  }
18417
19738
  let tcpApi;
18418
19739
  try {
@@ -18427,7 +19748,9 @@ async function autoDetectDeviceType(inputs) {
18427
19748
  return api2;
18428
19749
  } catch (e) {
18429
19750
  try {
18430
- await api2.close({ reason: `autodetect:tcp_failed:attempt_${attempt}` });
19751
+ await api2.close({
19752
+ reason: `autodetect:tcp_failed:attempt_${attempt}`
19753
+ });
18431
19754
  } catch {
18432
19755
  }
18433
19756
  throw e;
@@ -18436,7 +19759,10 @@ async function autoDetectDeviceType(inputs) {
18436
19759
  shouldRetryTcp
18437
19760
  );
18438
19761
  const api = tcpApi;
18439
- if (!api) throw new Error("AutoDetect internal error: TCP API not initialized after successful login");
19762
+ if (!api)
19763
+ throw new Error(
19764
+ "AutoDetect internal error: TCP API not initialized after successful login"
19765
+ );
18440
19766
  const runProbeVariants = async (label, variants) => {
18441
19767
  let lastMsg;
18442
19768
  for (const v of variants) {
@@ -18447,30 +19773,67 @@ async function autoDetectDeviceType(inputs) {
18447
19773
  } catch (e) {
18448
19774
  const msg = fmtErr(e);
18449
19775
  lastMsg = msg;
18450
- logger?.log?.(`[AutoDetect] TCP probe ${label} failed (${v.variant}): ${msg}`);
19776
+ logger?.log?.(
19777
+ `[AutoDetect] TCP probe ${label} failed (${v.variant}): ${msg}`
19778
+ );
18451
19779
  }
18452
19780
  }
18453
19781
  if (lastMsg) {
18454
- logger?.log?.(`[AutoDetect] TCP probe ${label} failed (all variants): ${lastMsg}`);
19782
+ logger?.log?.(
19783
+ `[AutoDetect] TCP probe ${label} failed (all variants): ${lastMsg}`
19784
+ );
18455
19785
  }
18456
19786
  return void 0;
18457
19787
  };
18458
19788
  const infoProbe = await runProbeVariants(
18459
19789
  "getInfo",
18460
19790
  [
18461
- { variant: "cmd80 class=0x6414", op: () => api.getInfo(void 0, { timeoutMs: 2500, messageClass: BC_CLASS_MODERN_24 }) },
18462
- { variant: "cmd80 class=0x6614", op: () => api.getInfo(void 0, { timeoutMs: 3e3, messageClass: BC_CLASS_MODERN_20 }) },
18463
- { variant: "cmd318(ch0) class=0x6414", op: () => api.getInfo(0, { timeoutMs: 3e3, messageClass: BC_CLASS_MODERN_24 }) },
18464
- { variant: "cmd318(ch0) class=0x6614", op: () => api.getInfo(0, { timeoutMs: 3500, messageClass: BC_CLASS_MODERN_20 }) }
18465
- ]
18466
- );
18467
- const supportProbe = await runProbeVariants(
18468
- "getSupportInfo",
18469
- [
18470
- { variant: "cmd199 class=0x6414", op: () => api.getSupportInfo({ timeoutMs: 2500, messageClass: BC_CLASS_MODERN_24 }) },
18471
- { variant: "cmd199 class=0x6614", op: () => api.getSupportInfo({ timeoutMs: 3500, messageClass: BC_CLASS_MODERN_20 }) }
19791
+ {
19792
+ variant: "cmd80 class=0x6414",
19793
+ op: () => api.getInfo(void 0, {
19794
+ timeoutMs: 2500,
19795
+ messageClass: BC_CLASS_MODERN_24
19796
+ })
19797
+ },
19798
+ {
19799
+ variant: "cmd80 class=0x6614",
19800
+ op: () => api.getInfo(void 0, {
19801
+ timeoutMs: 3e3,
19802
+ messageClass: BC_CLASS_MODERN_20
19803
+ })
19804
+ },
19805
+ {
19806
+ variant: "cmd318(ch0) class=0x6414",
19807
+ op: () => api.getInfo(0, {
19808
+ timeoutMs: 3e3,
19809
+ messageClass: BC_CLASS_MODERN_24
19810
+ })
19811
+ },
19812
+ {
19813
+ variant: "cmd318(ch0) class=0x6614",
19814
+ op: () => api.getInfo(0, {
19815
+ timeoutMs: 3500,
19816
+ messageClass: BC_CLASS_MODERN_20
19817
+ })
19818
+ }
18472
19819
  ]
18473
19820
  );
19821
+ const supportProbe = await runProbeVariants("getSupportInfo", [
19822
+ {
19823
+ variant: "cmd199 class=0x6414",
19824
+ op: () => api.getSupportInfo({
19825
+ timeoutMs: 2500,
19826
+ messageClass: BC_CLASS_MODERN_24
19827
+ })
19828
+ },
19829
+ {
19830
+ variant: "cmd199 class=0x6614",
19831
+ op: () => api.getSupportInfo({
19832
+ timeoutMs: 3500,
19833
+ messageClass: BC_CLASS_MODERN_20
19834
+ })
19835
+ }
19836
+ ]);
18474
19837
  const deviceInfo = infoProbe?.value;
18475
19838
  const support = supportProbe?.value;
18476
19839
  const channelNumRaw = support?.channelNum;
@@ -18487,7 +19850,9 @@ async function autoDetectDeviceType(inputs) {
18487
19850
  const isMultifocal = isMultifocalByModel || hasDualLensChannelCount;
18488
19851
  if (isMultifocal) {
18489
19852
  const detectionMethod = isMultifocalByModel ? "model match" : "channelNum fallback";
18490
- logger?.log?.(`[AutoDetect] Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum})`);
19853
+ logger?.log?.(
19854
+ `[AutoDetect] Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum})`
19855
+ );
18491
19856
  return {
18492
19857
  type: "multifocal",
18493
19858
  transport: "tcp",
@@ -18499,7 +19864,9 @@ async function autoDetectDeviceType(inputs) {
18499
19864
  };
18500
19865
  }
18501
19866
  if (effectiveChannelNum > 1) {
18502
- logger?.log?.(`[AutoDetect] Detected NVR (${effectiveChannelNum} channels)`);
19867
+ logger?.log?.(
19868
+ `[AutoDetect] Detected NVR (${effectiveChannelNum} channels)`
19869
+ );
18503
19870
  return {
18504
19871
  type: "nvr",
18505
19872
  transport: "tcp",
@@ -18533,23 +19900,27 @@ async function autoDetectDeviceType(inputs) {
18533
19900
  if (!isTcpFailureThatShouldFallbackToUdp(tcpError)) {
18534
19901
  throw tcpError;
18535
19902
  }
18536
- logger?.log?.(`[AutoDetect] TCP failed, trying UDP (battery camera)...`);
19903
+ logger?.log?.(`[AutoDetect] TCP failed, trying UDP...`);
18537
19904
  let normalizedUid = effectiveUid;
18538
19905
  if (!normalizedUid) {
18539
- logger?.log?.(`[AutoDetect] UID not provided; attempting UDP broadcast discovery for UID...`);
19906
+ logger?.log?.(
19907
+ `[AutoDetect] UID not provided; attempting UDP broadcast discovery for UID...`
19908
+ );
18540
19909
  const discovered = await discoverUidForHost(host, logger);
18541
- if (!discovered) {
18542
- throw new Error(
18543
- `TCP connection failed and device likely requires UDP/BCUDP. UID is required for battery cameras (ip=${host}).`
18544
- );
19910
+ if (discovered) {
19911
+ const normalizedDiscovered = normalizeUid(discovered);
19912
+ if (normalizedDiscovered) {
19913
+ normalizedUid = normalizedDiscovered;
19914
+ logger?.log?.(
19915
+ `[AutoDetect] UID discovered via broadcast: ${normalizedUid}`
19916
+ );
19917
+ }
18545
19918
  }
18546
- const normalizedDiscovered = normalizeUid(discovered);
18547
- if (!normalizedDiscovered) {
18548
- throw new Error(
18549
- `TCP connection failed and device likely requires UDP/BCUDP, but UID discovery returned an empty UID (ip=${host}).`
19919
+ if (!normalizedUid) {
19920
+ logger?.log?.(
19921
+ `[AutoDetect] UID discovery failed; will try local-direct without UID first.`
18550
19922
  );
18551
19923
  }
18552
- normalizedUid = normalizedDiscovered;
18553
19924
  }
18554
19925
  try {
18555
19926
  const detectOverUdpApi = async (udpApi, udpDiscoveryMethod) => {
@@ -18563,31 +19934,38 @@ async function autoDetectDeviceType(inputs) {
18563
19934
  const channelNumValue = typeof channelNum === "string" ? Number.parseInt(channelNum, 10) : channelNum;
18564
19935
  const hasDualLensChannelCount = (channelNumValue === 2 || channelNumValue === 3) && Number.isFinite(channelNumValue);
18565
19936
  const isMultifocal = isMultifocalByModel || hasDualLensChannelCount;
19937
+ const hasBattery = capabilities?.capabilities?.hasBattery === true;
19938
+ udpApi.setIdleDisconnect(hasBattery);
18566
19939
  if (isMultifocal) {
18567
19940
  const detectionMethod = isMultifocalByModel ? "model match" : "channelNum fallback";
18568
19941
  logger?.log?.(
18569
- `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}).`
19942
+ `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected multi-focal device (${detectionMethod}: model=${normalizedModel ?? "unknown"}, channelNum=${channelNum}, hasBattery=${hasBattery}).`
18570
19943
  );
18571
19944
  return {
18572
19945
  type: "multifocal",
18573
19946
  transport: "udp",
18574
- uid: normalizedUid,
19947
+ uid: normalizedUid ?? "",
18575
19948
  udpDiscoveryMethod,
18576
19949
  deviceInfo,
18577
19950
  ...hostNetworkInfo ? { hostNetworkInfo } : {},
18578
19951
  channelNum,
19952
+ hasBattery,
18579
19953
  api: udpApi
18580
19954
  };
18581
19955
  }
18582
- logger?.log?.(`[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected battery camera.`);
19956
+ const deviceType = hasBattery ? "battery-cam" : "udp-camera";
19957
+ logger?.log?.(
19958
+ `[AutoDetect] UDP (${udpDiscoveryMethod}) connection successful. Detected ${deviceType} (hasBattery=${hasBattery}, model=${normalizedModel ?? "unknown"}).`
19959
+ );
18583
19960
  return {
18584
- type: "battery-cam",
19961
+ type: deviceType,
18585
19962
  transport: "udp",
18586
- uid: normalizedUid,
19963
+ uid: normalizedUid ?? "",
18587
19964
  udpDiscoveryMethod,
18588
19965
  deviceInfo,
18589
19966
  ...hostNetworkInfo ? { hostNetworkInfo } : {},
18590
19967
  channelNum: 1,
19968
+ hasBattery,
18591
19969
  api: udpApi
18592
19970
  };
18593
19971
  };
@@ -18600,13 +19978,22 @@ async function autoDetectDeviceType(inputs) {
18600
19978
  `UDP(${m})`,
18601
19979
  maxRetries,
18602
19980
  async (attempt) => {
18603
- const api = createBaichuanApi({ ...inputs, uid: normalizedUid, udpDiscoveryMethod: m }, "udp");
19981
+ const apiInputs = {
19982
+ ...inputs,
19983
+ udpDiscoveryMethod: m
19984
+ };
19985
+ if (normalizedUid) {
19986
+ apiInputs.uid = normalizedUid;
19987
+ }
19988
+ const api = createBaichuanApi(apiInputs, "udp");
18604
19989
  try {
18605
19990
  await api.login();
18606
19991
  return api;
18607
19992
  } catch (e) {
18608
19993
  try {
18609
- await api.close({ reason: `autodetect:udp_failed:${m}:attempt_${attempt}` });
19994
+ await api.close({
19995
+ reason: `autodetect:udp_failed:${m}:attempt_${attempt}`
19996
+ });
18610
19997
  } catch {
18611
19998
  }
18612
19999
  throw e;
@@ -18624,7 +20011,9 @@ async function autoDetectDeviceType(inputs) {
18624
20011
  logger?.log?.(`[AutoDetect] UDP (${m}) failed: ${msg}`);
18625
20012
  }
18626
20013
  }
18627
- throw new Error(`UDP discovery failed for all methods. ${udpErrors.join(" | ")}`);
20014
+ throw new Error(
20015
+ `UDP discovery failed for all methods. ${udpErrors.join(" | ")}`
20016
+ );
18628
20017
  } catch (udpError) {
18629
20018
  logger?.log?.(
18630
20019
  `[AutoDetect] Both TCP and UDP failed. TCP error: ${tcpError}, UDP error: ${udpError}`
@@ -18676,4 +20065,4 @@ export {
18676
20065
  isTcpFailureThatShouldFallbackToUdp,
18677
20066
  autoDetectDeviceType
18678
20067
  };
18679
- //# sourceMappingURL=chunk-YUBYINJF.js.map
20068
+ //# sourceMappingURL=chunk-ULSFEQSE.js.map