@apocaliss92/nodelink-js 0.1.17 → 0.1.20

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.
@@ -1196,6 +1196,22 @@ function detectVideoCodecFromNal(data) {
1196
1196
  if (nalStart < 0 || nalStart >= data.length) return null;
1197
1197
  const nalByte = data[nalStart];
1198
1198
  if (nalByte === void 0) return null;
1199
+ const forbiddenBit264 = nalByte >> 7 & 1;
1200
+ const h264Type = nalByte & 31;
1201
+ if (forbiddenBit264 === 0 && h264Type > 0 && h264Type <= 12) {
1202
+ if (h264Type === 7 || h264Type === 8) {
1203
+ return "H264";
1204
+ }
1205
+ if (h264Type === 5) {
1206
+ return "H264";
1207
+ }
1208
+ if (h264Type === 1) {
1209
+ const nalRefIdc = nalByte >> 5 & 3;
1210
+ if (nalRefIdc >= 1) {
1211
+ return "H264";
1212
+ }
1213
+ }
1214
+ }
1199
1215
  if (nalStart + 1 < data.length) {
1200
1216
  const nalByte2 = data[nalStart + 1];
1201
1217
  if (nalByte2 !== void 0) {
@@ -1209,22 +1225,12 @@ function detectVideoCodecFromNal(data) {
1209
1225
  if (hevcType === 19 || hevcType === 20 || hevcType === 21) {
1210
1226
  return "H265";
1211
1227
  }
1212
- if (hevcType <= 9) {
1228
+ if (hevcType <= 1 && nalByte <= 3) {
1213
1229
  return "H265";
1214
1230
  }
1215
1231
  }
1216
1232
  }
1217
1233
  }
1218
- const forbiddenBit264 = nalByte >> 7 & 1;
1219
- const h264Type = nalByte & 31;
1220
- if (forbiddenBit264 === 0 && h264Type > 0 && h264Type <= 12) {
1221
- if (h264Type === 7 || h264Type === 8) {
1222
- return "H264";
1223
- }
1224
- if (h264Type === 5 || h264Type === 1) {
1225
- return "H264";
1226
- }
1227
- }
1228
1234
  return null;
1229
1235
  }
1230
1236
  var ANNEXB_START_CODE_4B, BcMediaAnnexBDecoder;
@@ -15652,7 +15658,8 @@ var logDebugStreamBlock = (params) => {
15652
15658
  const bitRateText = getXmlText(raw, "bitRate") ?? getXmlText(raw, "BitRate");
15653
15659
  const videoEncTypeText = getXmlText(raw, "videoEncType") ?? getXmlText(raw, "VideoEncType");
15654
15660
  const audioText = getXmlText(raw, "audio") ?? getXmlText(raw, "Audio");
15655
- const enableText = getXmlText(raw, "enable") ?? getXmlText(raw, "Enable");
15661
+ const enableCheckRaw = raw.replace(/<smartH265[\s\S]*?<\/smartH265>/g, "");
15662
+ const enableText = getXmlText(enableCheckRaw, "enable") ?? getXmlText(enableCheckRaw, "Enable");
15656
15663
  const width = Number(widthText ?? "0");
15657
15664
  const height = Number(heightText ?? "0");
15658
15665
  const frameRate = Number(frameText ?? "0");
@@ -15676,7 +15683,8 @@ var buildStream = (params) => {
15676
15683
  const frameRate = Number(getXmlText(streamXml, "frame") ?? "0");
15677
15684
  const bitRate = Number(getXmlText(streamXml, "bitRate") ?? "0");
15678
15685
  const audio = Number(getXmlText(streamXml, "audio") ?? "0");
15679
- const enabled = getXmlText(streamXml, "enable");
15686
+ const enableXml = streamXml.replace(/<smartH265[\s\S]*?<\/smartH265>/g, "");
15687
+ const enabled = getXmlText(enableXml, "enable");
15680
15688
  const isEnabled = isEnabledFromText(enabled);
15681
15689
  if (!isEnabled || !isPlausibleStream({ width, height, frameRate, bitRate }))
15682
15690
  return void 0;
@@ -16365,14 +16373,16 @@ var DUAL_LENS_SINGLE_MOTION_MODELS = /* @__PURE__ */ new Set([
16365
16373
  "Reolink TrackMix",
16366
16374
  "Reolink TrackMix PoE",
16367
16375
  "Reolink TrackMix WiFi",
16368
- "RLC-81MA"
16376
+ "RLC-81MA",
16377
+ "TrackFlex Floodlight WiFi"
16369
16378
  ]);
16370
16379
  var DUAL_LENS_MODELS = /* @__PURE__ */ new Set([
16371
16380
  ...DUAL_LENS_DUAL_MOTION_MODELS,
16372
16381
  ...DUAL_LENS_SINGLE_MOTION_MODELS
16373
16382
  ]);
16374
16383
  var isDualLenseModel = (model) => {
16375
- return DUAL_LENS_MODELS.has(model) || model.toLowerCase().includes("trackmix");
16384
+ const lower = model.toLowerCase();
16385
+ return Array.from(DUAL_LENS_MODELS).some((m) => m.toLowerCase() === lower) || lower.includes("trackmix") || lower.includes("trackflex");
16376
16386
  };
16377
16387
  var NVR_HUB_EXACT_TYPES = ["NVR", "WIFI_NVR", "HOMEHUB"];
16378
16388
  var NVR_HUB_MODEL_PATTERNS = [
@@ -16456,6 +16466,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
16456
16466
  * Set via setIsNvr() from the plugin or auto-detected via isNvrDevice().
16457
16467
  */
16458
16468
  _isNvr;
16469
+ /**
16470
+ * Cached multi-focal detection result.
16471
+ * - true = dual-lens camera (e.g., TrackMix, TrackFlex) with multiple channels on a single device
16472
+ * - false = single-lens camera
16473
+ * Multi-focal cameras reject concurrent streaming TCP connections (response_code 430),
16474
+ * so all channels must multiplex on the same streaming socket.
16475
+ */
16476
+ _isMultiFocal;
16459
16477
  /** Maximum dedicated sessions allowed before triggering a reboot (default: 7). */
16460
16478
  maxDedicatedSessionsBeforeReboot;
16461
16479
  sessionGuardRebootInFlight;
@@ -16486,6 +16504,15 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
16486
16504
  simpleEventResubscribeTimer;
16487
16505
  simpleEventResubscribeInFlight;
16488
16506
  simpleEventResubscribeIntervalMs = 5 * 6e4;
16507
+ // Event watchdog: auto-recovery when events stop flowing or subscription fails
16508
+ simpleEventWatchdogTimer;
16509
+ simpleEventLastReceivedAt = 0;
16510
+ simpleEventWatchdogRecoveryAttempts = 0;
16511
+ simpleEventWatchdogLastRecoveryAt = 0;
16512
+ simpleEventWatchdogIntervalMs = 1e4;
16513
+ // check every 10s
16514
+ simpleEventWatchdogSilenceThresholdMs = 5 * 6e4;
16515
+ // 5 min without events
16489
16516
  statePollingInterval;
16490
16517
  udpSleepInferenceInterval;
16491
16518
  udpLastInferredSleepStateByChannel = /* @__PURE__ */ new Map();
@@ -16870,14 +16897,16 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
16870
16897
  * Determine the socket tag for a given sessionKey.
16871
16898
  * This implements the tag-based allocation strategy:
16872
16899
  *
16873
- * - "general" - commands, events
16874
- * - "streaming:ch{N}" - main + sub for channel N (closed when no streams)
16875
- * - "streaming:ch{N}:ext" - ext for channel N (closed when no streams)
16900
+ * - "general" - commands, events, ext on ch0
16901
+ * - "streaming:ch{N}" - main + sub for channel N (NVR/standalone single-lens)
16902
+ * - "streaming:ch{N}:ext" - ext for channel N (NVR/standalone, N>0)
16903
+ * - "streaming:a" - ch0 main + ch1 sub (multi-focal dedicated socket)
16904
+ * - "general" also carries ch1 main + ch0 sub for multi-focal (merged with commands/events)
16876
16905
  * - "replay:deviceId:ch{N}" - dedicated per device+channel for replay
16877
16906
  *
16878
- * Always uses per-channel tagging for streams (works for both standalone and NVR).
16879
- * Replay uses per-device+channel sockets to allow multiple users to watch
16880
- * different clips simultaneously without interfering with each other.
16907
+ * Multi-focal cameras (TrackMix, TrackFlex, Duo) reject two main or two sub
16908
+ * streams on the same TCP connection (response_code 430). Cross-channel pairing
16909
+ * ensures each socket always has a valid M+S combination using only 2 TCP connections.
16881
16910
  *
16882
16911
  * @param sessionKey - The session key (e.g., "live:device:ch0:main", "replay:device:ch1:file")
16883
16912
  * @returns The socket pool tag to use
@@ -16897,9 +16926,16 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
16897
16926
  if (channel === 0) {
16898
16927
  return "general";
16899
16928
  }
16900
- return `streaming:ch${channel}:ext`;
16929
+ if (this._isMultiFocal) {
16930
+ return "general";
16931
+ }
16932
+ return this._isNvr ? `streaming:ch${channel}:ext` : `streaming:ch${channel}:ext`;
16901
16933
  }
16902
- return `streaming:ch${channel}`;
16934
+ if (this._isMultiFocal) {
16935
+ const isSocketA = channel === 0 && profile === "main" || channel === 1 && profile === "sub";
16936
+ return isSocketA ? "streaming:a" : "general";
16937
+ }
16938
+ return this._isNvr ? `streaming:ch${channel}` : `streaming:ch${channel}`;
16903
16939
  }
16904
16940
  return "general";
16905
16941
  }
@@ -17224,6 +17260,14 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17224
17260
  async cleanupDedicatedClients() {
17225
17261
  }
17226
17262
  dispatchSimpleEvent(evt) {
17263
+ this.simpleEventLastReceivedAt = Date.now();
17264
+ if (this.simpleEventWatchdogRecoveryAttempts > 0) {
17265
+ (this.logger.info ?? this.logger.log).call(
17266
+ this.logger,
17267
+ `[ReolinkBaichuanApi] event watchdog: events flowing again after ${this.simpleEventWatchdogRecoveryAttempts} recovery attempt(s)`
17268
+ );
17269
+ this.simpleEventWatchdogRecoveryAttempts = 0;
17270
+ }
17227
17271
  const debugCfg = this.client.getDebugConfig?.();
17228
17272
  if (debugCfg) {
17229
17273
  const sid = this.client.getSocketSessionId?.();
@@ -17772,11 +17816,15 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17772
17816
  /**
17773
17817
  * Subscribe to minimal high-level events.
17774
17818
  * The API manages Baichuan subscribe/unsubscribe automatically.
17819
+ * Includes built-in watchdog: if no events arrive for 5 minutes while
17820
+ * the connection is alive, the subscription is automatically renewed.
17775
17821
  */
17776
17822
  async onSimpleEvent(callback) {
17777
17823
  this.simpleEventListeners.add(callback);
17778
17824
  await this.ensureSimpleEventSubscribed();
17825
+ this.simpleEventLastReceivedAt = Date.now();
17779
17826
  this.startSimpleEventResubscribeTimer();
17827
+ this.startSimpleEventWatchdog();
17780
17828
  }
17781
17829
  /**
17782
17830
  * Remove one callback, or all callbacks if omitted.
@@ -17790,6 +17838,7 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17790
17838
  }
17791
17839
  if (this.simpleEventListeners.size === 0) {
17792
17840
  this.stopSimpleEventResubscribeTimer();
17841
+ this.stopSimpleEventWatchdog();
17793
17842
  this.stopUdpSleepInference();
17794
17843
  await this.ensureSimpleEventUnsubscribed();
17795
17844
  } else {
@@ -17813,6 +17862,97 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17813
17862
  clearInterval(this.simpleEventResubscribeTimer);
17814
17863
  this.simpleEventResubscribeTimer = void 0;
17815
17864
  }
17865
+ /**
17866
+ * Event watchdog: monitors whether events are flowing and auto-recovers if they stop.
17867
+ *
17868
+ * Handles two failure modes:
17869
+ * 1. Subscription flag is true but no events arrive for 5+ minutes (device dropped subscription silently)
17870
+ * 2. Subscription flag is false because initial/retry subscribe failed, but connection is now alive
17871
+ *
17872
+ * Uses exponential backoff (30s → 60s → 120s → 240s → max 5min) to avoid hammering the device.
17873
+ */
17874
+ startSimpleEventWatchdog() {
17875
+ if (this.simpleEventWatchdogTimer) return;
17876
+ if (this.simpleEventListeners.size === 0) return;
17877
+ this.simpleEventWatchdogTimer = setInterval(() => {
17878
+ void this.simpleEventWatchdogTick();
17879
+ }, this.simpleEventWatchdogIntervalMs);
17880
+ }
17881
+ stopSimpleEventWatchdog() {
17882
+ if (!this.simpleEventWatchdogTimer) return;
17883
+ clearInterval(this.simpleEventWatchdogTimer);
17884
+ this.simpleEventWatchdogTimer = void 0;
17885
+ this.simpleEventWatchdogRecoveryAttempts = 0;
17886
+ this.simpleEventWatchdogLastRecoveryAt = 0;
17887
+ this.simpleEventLastReceivedAt = 0;
17888
+ }
17889
+ async simpleEventWatchdogTick() {
17890
+ if (this.simpleEventListeners.size === 0) return;
17891
+ if (!this.client.isSocketConnected?.() || !this.client.loggedIn) return;
17892
+ const now = Date.now();
17893
+ if (this.simpleEventSubscribed && this.simpleEventLastReceivedAt > 0) {
17894
+ const silence = now - this.simpleEventLastReceivedAt;
17895
+ if (silence < this.simpleEventWatchdogSilenceThresholdMs) return;
17896
+ (this.logger.warn ?? this.logger.log).call(
17897
+ this.logger,
17898
+ `[ReolinkBaichuanApi] event watchdog: no events for ${Math.round(silence / 6e4)} min, forcing resubscribe`,
17899
+ { host: this.host, silenceMs: silence }
17900
+ );
17901
+ try {
17902
+ this.simpleEventSubscribed = false;
17903
+ this.client.subscribed = false;
17904
+ await this.ensureSimpleEventSubscribed();
17905
+ this.simpleEventLastReceivedAt = Date.now();
17906
+ this.simpleEventWatchdogRecoveryAttempts = 0;
17907
+ (this.logger.info ?? this.logger.log).call(
17908
+ this.logger,
17909
+ `[ReolinkBaichuanApi] event watchdog: resubscribed successfully after silence`
17910
+ );
17911
+ } catch (e) {
17912
+ (this.logger.debug ?? this.logger.log).call(
17913
+ this.logger,
17914
+ `[ReolinkBaichuanApi] event watchdog: resubscribe after silence failed`,
17915
+ formatErrorForLog(e)
17916
+ );
17917
+ }
17918
+ return;
17919
+ }
17920
+ if (!this.simpleEventSubscribed) {
17921
+ const backoffMs = Math.min(
17922
+ 3e4 * Math.pow(2, this.simpleEventWatchdogRecoveryAttempts),
17923
+ this.simpleEventWatchdogSilenceThresholdMs
17924
+ );
17925
+ if (now - this.simpleEventWatchdogLastRecoveryAt < backoffMs) return;
17926
+ this.simpleEventWatchdogRecoveryAttempts++;
17927
+ this.simpleEventWatchdogLastRecoveryAt = now;
17928
+ const nextBackoff = Math.min(
17929
+ 3e4 * Math.pow(2, this.simpleEventWatchdogRecoveryAttempts),
17930
+ this.simpleEventWatchdogSilenceThresholdMs
17931
+ );
17932
+ (this.logger.info ?? this.logger.log).call(
17933
+ this.logger,
17934
+ `[ReolinkBaichuanApi] event watchdog: subscription inactive, attempting auto-recovery (attempt #${this.simpleEventWatchdogRecoveryAttempts}, next backoff ${Math.round(nextBackoff / 1e3)}s)`,
17935
+ { host: this.host }
17936
+ );
17937
+ try {
17938
+ await this.ensureSimpleEventSubscribed();
17939
+ if (this.simpleEventSubscribed) {
17940
+ this.simpleEventLastReceivedAt = Date.now();
17941
+ (this.logger.info ?? this.logger.log).call(
17942
+ this.logger,
17943
+ `[ReolinkBaichuanApi] event watchdog: auto-recovery successful after ${this.simpleEventWatchdogRecoveryAttempts} attempt(s)`
17944
+ );
17945
+ this.simpleEventWatchdogRecoveryAttempts = 0;
17946
+ }
17947
+ } catch (e) {
17948
+ (this.logger.debug ?? this.logger.log).call(
17949
+ this.logger,
17950
+ `[ReolinkBaichuanApi] event watchdog: recovery attempt #${this.simpleEventWatchdogRecoveryAttempts} failed`,
17951
+ formatErrorForLog(e)
17952
+ );
17953
+ }
17954
+ }
17955
+ }
17816
17956
  async renewSimpleEventSubscription() {
17817
17957
  if (this.simpleEventListeners.size === 0) return;
17818
17958
  if (this.simpleEventResubscribeInFlight)
@@ -17951,6 +18091,17 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
17951
18091
  this._isNvr = isNvr;
17952
18092
  this.logger.debug?.(`[ReolinkBaichuanApi] setIsNvr: ${isNvr}`);
17953
18093
  }
18094
+ /**
18095
+ * Set the multi-focal flag explicitly.
18096
+ * Call this early (before streaming) to ensure correct socket pooling.
18097
+ * Multi-focal cameras (TrackMix, TrackFlex, Duo, etc.) reject concurrent
18098
+ * streaming TCP connections, so all channels must share a single streaming socket.
18099
+ * @param isMultiFocal - true if this is a dual-lens/multi-focal camera
18100
+ */
18101
+ setIsMultiFocal(isMultiFocal) {
18102
+ this._isMultiFocal = isMultiFocal;
18103
+ this.logger.debug?.(`[ReolinkBaichuanApi] setIsMultiFocal: ${isMultiFocal}`);
18104
+ }
17954
18105
  /**
17955
18106
  * Enable or disable idle disconnect dynamically.
17956
18107
  *
@@ -18001,6 +18152,8 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18001
18152
  }
18002
18153
  this.stopStatePolling();
18003
18154
  this.stopUdpSleepInference();
18155
+ this.stopSimpleEventWatchdog();
18156
+ this.stopSimpleEventResubscribeTimer();
18004
18157
  await this.cleanup();
18005
18158
  await this.stopAllActiveStreams();
18006
18159
  await this.cleanupSocketPool();
@@ -23452,7 +23605,7 @@ ${xml}`
23452
23605
  });
23453
23606
  model = typeof info.type === "string" ? info.type.toLowerCase() : "";
23454
23607
  isMultiFocal = isDualLenseModel(model);
23455
- isTrackMix = model.includes("trackmix");
23608
+ isTrackMix = model.includes("trackmix") || model.includes("trackflex");
23456
23609
  } catch (e) {
23457
23610
  logDebug(
23458
23611
  "[ReolinkBaichuanApi] buildVideoStreamOptions: getInfo(type) failed",