@apocaliss92/nodelink-js 0.4.10 → 0.4.12

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.
package/dist/index.cjs CHANGED
@@ -34,7 +34,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
34
34
  function bcHeaderHasPayloadOffset(messageClass) {
35
35
  return messageClass === BC_CLASS_MODERN_24 || messageClass === BC_CLASS_MODERN_24_ALT || messageClass === BC_CLASS_FILE_DOWNLOAD;
36
36
  }
37
- var BC_TCP_DEFAULT_PORT, BC_MAGIC, BC_MAGIC_REV, BC_XML_KEY, BC_AES_IV, BC_CLASS_LEGACY, BC_CLASS_MODERN_20, BC_CLASS_MODERN_24, BC_CLASS_MODERN_24_ALT, BC_CLASS_FILE_DOWNLOAD, BC_CMD_ID_LOGIN, BC_CMD_ID_LOGOUT, BC_CMD_ID_VIDEO, BC_CMD_ID_VIDEO_STOP, BC_CMD_ID_FILE_INFO_LIST_REPLAY, BC_CMD_ID_FILE_INFO_LIST_STOP, BC_CMD_ID_FILE_INFO_LIST_DL_VIDEO, BC_CMD_ID_FILE_INFO_LIST_DOWNLOAD, BC_CMD_ID_FILE_INFO_LIST_OPEN, BC_CMD_ID_FILE_INFO_LIST_GET, BC_CMD_ID_FILE_INFO_LIST_CLOSE, BC_CMD_ID_FIND_REC_VIDEO_OPEN, BC_CMD_ID_FIND_REC_VIDEO_GET, BC_CMD_ID_FIND_REC_VIDEO_CLOSE, BC_CMD_ID_COVER_PREVIEW, BC_CMD_ID_COVER_STANDALONE_458, BC_CMD_ID_COVER_STANDALONE_459, BC_CMD_ID_COVER_STANDALONE_460, BC_CMD_ID_COVER_STANDALONE_461, BC_CMD_ID_COVER_STANDALONE_462, BC_CMD_ID_COVER_RESPONSE, BC_CMD_ID_TALK_ABILITY, BC_CMD_ID_TALK_RESET, BC_CMD_ID_TALK_CONFIG, BC_CMD_ID_TALK, BC_CMD_ID_PTZ_CONTROL, BC_CMD_ID_PTZ_CONTROL_PRESET, BC_CMD_ID_GET_PTZ_PRESET, BC_CMD_ID_GET_PTZ_POSITION, BC_CMD_ID_GET_ZOOM_FOCUS, BC_CMD_ID_SET_ZOOM_FOCUS, BC_CMD_ID_GET_BATTERY_INFO_LIST, BC_CMD_ID_GET_BATTERY_INFO, BC_CMD_ID_UDP_KEEP_ALIVE, BC_CMD_ID_GET_PIR_INFO, BC_CMD_ID_SET_PIR_INFO, BC_CMD_ID_GET_MOTION_ALARM, BC_CMD_ID_SET_MOTION_ALARM, BC_CMD_ID_ALARM_EVENT_LIST, BC_CMD_ID_GET_AI_ALARM, BC_CMD_ID_SET_AI_ALARM, BC_CMD_ID_GET_AUDIO_ALARM, BC_CMD_ID_AUDIO_ALARM_PLAY, BC_CMD_ID_GET_WHITE_LED, BC_CMD_ID_SET_WHITE_LED_STATE, BC_CMD_ID_SET_WHITE_LED_TASK, BC_CMD_ID_FLOODLIGHT_STATUS_LIST, BC_CMD_ID_ABILITY_INFO, BC_CMD_ID_SUPPORT, BC_CMD_ID_PING, BC_CMD_ID_CHANNEL_INFO_ALL, BC_CMD_ID_GET_OSD_DATETIME, BC_CMD_ID_GET_RECORD_CFG, BC_CMD_ID_GET_ABILITY_SUPPORT, BC_CMD_ID_GET_FTP_TASK, BC_CMD_ID_GET_RECORD, BC_CMD_ID_GET_HDD_INFO_LIST, BC_CMD_ID_GET_WIFI_SIGNAL, BC_CMD_ID_GET_WIFI, BC_CMD_ID_GET_ONLINE_USER_LIST, BC_CMD_ID_GET_DAY_RECORDS, BC_CMD_ID_GET_STREAM_INFO_LIST, BC_CMD_ID_GET_LED_STATE, BC_CMD_ID_GET_EMAIL_TASK, BC_CMD_ID_GET_AUDIO_TASK, BC_CMD_ID_GET_AUDIO_CFG, BC_CMD_ID_GET_DAY_NIGHT_THRESHOLD, BC_CMD_ID_GET_TIMELAPSE_CFG, BC_CMD_ID_GET_AI_DENOISE, BC_CMD_ID_GET_KIT_AP_CFG, BC_CMD_ID_GET_REC_ENC_CFG, BC_CMD_ID_GET_ACCESS_USER_LIST, BC_CMD_ID_GET_SLEEP_STATE, BC_CMD_ID_GET_VIDEO_INPUT, BC_CMD_ID_GET_SYSTEM_GENERAL, BC_CMD_ID_GET_SUPPORT, BC_CMD_ID_GET_AI_CFG, BC_CMD_ID_SET_AI_CFG, BC_CMD_ID_GET_SIREN_STATUS, BC_CMD_ID_SET_AUDIO_TASK, BC_CMD_ID_SET_VIDEO_INPUT, BC_CMD_ID_SET_DAY_NIGHT_THRESHOLD, BC_CMD_ID_GET_ENC, BC_CMD_ID_SET_ENC, BC_CMD_ID_GET_PRIVACY_MASK, BC_CMD_ID_SET_PRIVACY_MASK, BC_CMD_ID_SET_AI_DENOISE, BC_CMD_ID_SET_LED_STATE, BC_CMD_ID_SET_AUDIO_CFG, BC_CMD_ID_SET_RECORD, BC_CMD_ID_SET_RECORD_CFG, BC_CMD_ID_SET_EMAIL_TASK, BC_CMD_ID_GET_PUSH_TASK, BC_CMD_ID_SET_PUSH_TASK, BC_CMD_ID_GET_AUTO_FOCUS, BC_CMD_ID_SET_AUTO_FOCUS, BC_CMD_ID_CMD_123, BC_CMD_ID_CMD_209, BC_CMD_ID_CMD_265, BC_CMD_ID_CMD_440, BC_CMD_ID_PUSH_VIDEO_INPUT, BC_CMD_ID_PUSH_SERIAL, BC_CMD_ID_PUSH_NET_INFO, BC_CMD_ID_PUSH_DINGDONG_LIST, BC_CMD_ID_PUSH_SLEEP_STATUS, BC_CMD_ID_PUSH_COORDINATE_POINT_LIST, BC_CMD_ID_DING_DONG_CTRL, BC_CMD_ID_GET_DING_DONG_LIST, BC_CMD_ID_DING_DONG_OPT, BC_CMD_ID_GET_DING_DONG_CFG, BC_CMD_ID_SET_DING_DONG_CFG, BC_CMD_ID_QUICK_REPLY_PLAY, BC_CMD_ID_GET_DING_DONG_SILENT, BC_CMD_ID_SET_DING_DONG_SILENT;
37
+ var BC_TCP_DEFAULT_PORT, BC_MAGIC, BC_MAGIC_REV, BC_XML_KEY, BC_AES_IV, BC_CLASS_LEGACY, BC_CLASS_MODERN_20, BC_CLASS_MODERN_24, BC_CLASS_MODERN_24_ALT, BC_CLASS_FILE_DOWNLOAD, BC_CMD_ID_LOGIN, BC_CMD_ID_LOGOUT, BC_CMD_ID_VIDEO, BC_CMD_ID_VIDEO_STOP, BC_CMD_ID_FILE_INFO_LIST_REPLAY, BC_CMD_ID_FILE_INFO_LIST_STOP, BC_CMD_ID_FILE_INFO_LIST_DL_VIDEO, BC_CMD_ID_FILE_INFO_LIST_DOWNLOAD, BC_CMD_ID_FILE_INFO_LIST_OPEN, BC_CMD_ID_FILE_INFO_LIST_GET, BC_CMD_ID_FILE_INFO_LIST_CLOSE, BC_CMD_ID_FIND_REC_VIDEO_OPEN, BC_CMD_ID_FIND_REC_VIDEO_GET, BC_CMD_ID_FIND_REC_VIDEO_CLOSE, BC_CMD_ID_COVER_PREVIEW, BC_CMD_ID_COVER_STANDALONE_458, BC_CMD_ID_COVER_STANDALONE_459, BC_CMD_ID_COVER_STANDALONE_460, BC_CMD_ID_COVER_STANDALONE_461, BC_CMD_ID_COVER_STANDALONE_462, BC_CMD_ID_COVER_RESPONSE, BC_CMD_ID_TALK_ABILITY, BC_CMD_ID_TALK_RESET, BC_CMD_ID_TALK_CONFIG, BC_CMD_ID_TALK, BC_CMD_ID_PTZ_CONTROL, BC_CMD_ID_PTZ_CONTROL_PRESET, BC_CMD_ID_GET_PTZ_PRESET, BC_CMD_ID_GET_PTZ_POSITION, BC_CMD_ID_GET_ZOOM_FOCUS, BC_CMD_ID_SET_ZOOM_FOCUS, BC_CMD_ID_GET_BATTERY_INFO_LIST, BC_CMD_ID_GET_BATTERY_INFO, BC_CMD_ID_UDP_KEEP_ALIVE, BC_CMD_ID_GET_PIR_INFO, BC_CMD_ID_SET_PIR_INFO, BC_CMD_ID_GET_MOTION_ALARM, BC_CMD_ID_SET_MOTION_ALARM, BC_CMD_ID_ALARM_EVENT_LIST, BC_CMD_ID_GET_AI_ALARM, BC_CMD_ID_SET_AI_ALARM, BC_CMD_ID_GET_AUDIO_ALARM, BC_CMD_ID_AUDIO_ALARM_PLAY, BC_CMD_ID_GET_WHITE_LED, BC_CMD_ID_SET_WHITE_LED_STATE, BC_CMD_ID_SET_WHITE_LED_TASK, BC_CMD_ID_FLOODLIGHT_STATUS_LIST, BC_CMD_ID_ABILITY_INFO, BC_CMD_ID_SUPPORT, BC_CMD_ID_PING, BC_CMD_ID_CHANNEL_INFO_ALL, BC_CMD_ID_GET_OSD_DATETIME, BC_CMD_ID_SET_OSD_DATETIME, BC_CMD_ID_GET_RECORD_CFG, BC_CMD_ID_GET_ABILITY_SUPPORT, BC_CMD_ID_GET_FTP_TASK, BC_CMD_ID_GET_VERSION_INFO, BC_CMD_ID_GET_RECORD, BC_CMD_ID_GET_HDD_INFO_LIST, BC_CMD_ID_GET_WIFI_SIGNAL, BC_CMD_ID_GET_WIFI, BC_CMD_ID_GET_ONLINE_USER_LIST, BC_CMD_ID_GET_DAY_RECORDS, BC_CMD_ID_GET_STREAM_INFO_LIST, BC_CMD_ID_GET_LED_STATE, BC_CMD_ID_GET_EMAIL_TASK, BC_CMD_ID_GET_AUDIO_TASK, BC_CMD_ID_GET_AUDIO_CFG, BC_CMD_ID_GET_DAY_NIGHT_THRESHOLD, BC_CMD_ID_GET_TIMELAPSE_CFG, BC_CMD_ID_GET_AI_DENOISE, BC_CMD_ID_GET_KIT_AP_CFG, BC_CMD_ID_GET_REC_ENC_CFG, BC_CMD_ID_GET_ACCESS_USER_LIST, BC_CMD_ID_GET_SLEEP_STATE, BC_CMD_ID_GET_VIDEO_INPUT, BC_CMD_ID_GET_SYSTEM_GENERAL, BC_CMD_ID_GET_SUPPORT, BC_CMD_ID_GET_AI_CFG, BC_CMD_ID_SET_AI_CFG, BC_CMD_ID_GET_SIREN_STATUS, BC_CMD_ID_SET_AUDIO_TASK, BC_CMD_ID_SET_VIDEO_INPUT, BC_CMD_ID_SET_DAY_NIGHT_THRESHOLD, BC_CMD_ID_GET_ENC, BC_CMD_ID_SET_ENC, BC_CMD_ID_GET_PRIVACY_MASK, BC_CMD_ID_SET_PRIVACY_MASK, BC_CMD_ID_SET_AI_DENOISE, BC_CMD_ID_SET_LED_STATE, BC_CMD_ID_SET_AUDIO_CFG, BC_CMD_ID_SET_RECORD, BC_CMD_ID_SET_RECORD_CFG, BC_CMD_ID_SET_EMAIL_TASK, BC_CMD_ID_GET_PUSH_TASK, BC_CMD_ID_SET_PUSH_TASK, BC_CMD_ID_GET_AUTO_FOCUS, BC_CMD_ID_SET_AUTO_FOCUS, BC_CMD_ID_CMD_123, BC_CMD_ID_CMD_209, BC_CMD_ID_CMD_265, BC_CMD_ID_CMD_440, BC_CMD_ID_PUSH_VIDEO_INPUT, BC_CMD_ID_PUSH_SERIAL, BC_CMD_ID_PUSH_NET_INFO, BC_CMD_ID_PUSH_DINGDONG_LIST, BC_CMD_ID_PUSH_SLEEP_STATUS, BC_CMD_ID_PUSH_COORDINATE_POINT_LIST, BC_CMD_ID_DING_DONG_CTRL, BC_CMD_ID_GET_DING_DONG_LIST, BC_CMD_ID_DING_DONG_OPT, BC_CMD_ID_GET_DING_DONG_CFG, BC_CMD_ID_SET_DING_DONG_CFG, BC_CMD_ID_QUICK_REPLY_PLAY, BC_CMD_ID_GET_DING_DONG_SILENT, BC_CMD_ID_SET_DING_DONG_SILENT;
38
38
  var init_constants = __esm({
39
39
  "src/protocol/constants.ts"() {
40
40
  "use strict";
@@ -109,9 +109,11 @@ var init_constants = __esm({
109
109
  BC_CMD_ID_PING = 93;
110
110
  BC_CMD_ID_CHANNEL_INFO_ALL = 145;
111
111
  BC_CMD_ID_GET_OSD_DATETIME = 44;
112
+ BC_CMD_ID_SET_OSD_DATETIME = 45;
112
113
  BC_CMD_ID_GET_RECORD_CFG = 54;
113
114
  BC_CMD_ID_GET_ABILITY_SUPPORT = 58;
114
115
  BC_CMD_ID_GET_FTP_TASK = 70;
116
+ BC_CMD_ID_GET_VERSION_INFO = 80;
115
117
  BC_CMD_ID_GET_RECORD = 81;
116
118
  BC_CMD_ID_GET_HDD_INFO_LIST = 102;
117
119
  BC_CMD_ID_GET_WIFI_SIGNAL = 115;
@@ -480,6 +482,15 @@ function applyXmlTagPatch(xml, tag, value) {
480
482
  const re = new RegExp(`<${tag}>[^<]*</${tag}>`);
481
483
  return xml.replace(re, `<${tag}>${v}</${tag}>`);
482
484
  }
485
+ function upsertXmlTag(xml, tag, value) {
486
+ if (value === void 0) return xml;
487
+ const v = typeof value === "boolean" ? value ? 1 : 0 : value;
488
+ const re = new RegExp(`<${tag}>[^<]*</${tag}>`);
489
+ if (re.test(xml)) {
490
+ return xml.replace(re, `<${tag}>${v}</${tag}>`);
491
+ }
492
+ return `${xml}<${tag}>${v}</${tag}>`;
493
+ }
483
494
  function patchNestedTag(xml, parent, child, value) {
484
495
  if (value === void 0) return xml;
485
496
  const v = typeof value === "boolean" ? value ? 1 : 0 : value;
@@ -495,6 +506,15 @@ function applyStreamPatch(xml, streamTag, patch) {
495
506
  );
496
507
  return xml.replace(re, (_match, open, body, close) => {
497
508
  let next = body;
509
+ if (patch.audio !== void 0) {
510
+ next = applyXmlTagPatch(next, "audio", patch.audio);
511
+ }
512
+ if (patch.width !== void 0) {
513
+ next = applyXmlTagPatch(next, "width", patch.width);
514
+ }
515
+ if (patch.height !== void 0) {
516
+ next = applyXmlTagPatch(next, "height", patch.height);
517
+ }
498
518
  if (patch.bitRate !== void 0) {
499
519
  next = applyXmlTagPatch(next, "bitRate", patch.bitRate);
500
520
  }
@@ -506,6 +526,23 @@ function applyStreamPatch(xml, streamTag, patch) {
506
526
  const intVal = patch.videoEncType === "h265" ? 1 : 0;
507
527
  next = applyXmlTagPatch(next, "videoEncType", intVal);
508
528
  }
529
+ if (patch.encoderType !== void 0) {
530
+ next = upsertXmlTag(next, "encoderType", patch.encoderType);
531
+ }
532
+ if (patch.encoderProfile !== void 0) {
533
+ next = upsertXmlTag(next, "encoderProfile", patch.encoderProfile);
534
+ }
535
+ if (patch.gop !== void 0) {
536
+ const gopBlockRe = /(<gop[^>]*>)([\s\S]*?)(<\/gop>)/;
537
+ if (gopBlockRe.test(next)) {
538
+ next = next.replace(
539
+ gopBlockRe,
540
+ (_m, gOpen, gBody, gClose) => `${gOpen}${applyXmlTagPatch(gBody, "cur", patch.gop)}${gClose}`
541
+ );
542
+ } else {
543
+ next = `${next}<gop><cur>${patch.gop}</cur></gop>`;
544
+ }
545
+ }
509
546
  return `${open}${next}${close}`;
510
547
  });
511
548
  }
@@ -1117,10 +1154,15 @@ var init_BcMediaCodec = __esm({
1117
1154
  strict;
1118
1155
  amountSkipped = 0;
1119
1156
  logger;
1157
+ onUnknownChunk;
1120
1158
  constructor(strict = false, logger) {
1121
1159
  this.strict = strict;
1122
1160
  this.logger = logger;
1123
1161
  }
1162
+ /** Register a listener that fires for every unknown chunk before recovery. */
1163
+ setUnknownChunkListener(listener) {
1164
+ this.onUnknownChunk = listener;
1165
+ }
1124
1166
  /**
1125
1167
  * Push data into the codec buffer and try to parse complete BcMedia packets.
1126
1168
  * Returns an array of complete BcMedia packets found.
@@ -1181,6 +1223,13 @@ var init_BcMediaCodec = __esm({
1181
1223
  }
1182
1224
  }
1183
1225
  if (next > 0) {
1226
+ if (this.onUnknownChunk) {
1227
+ this.onUnknownChunk({
1228
+ magic,
1229
+ preview: Buffer.from(this.buffer.subarray(0, Math.min(256, next))),
1230
+ skipped: next
1231
+ });
1232
+ }
1184
1233
  this.amountSkipped += next;
1185
1234
  this.buffer = this.buffer.subarray(next);
1186
1235
  continue;
@@ -2085,6 +2134,10 @@ var init_BcMediaAnnexBDecoder = __esm({
2085
2134
  });
2086
2135
 
2087
2136
  // src/baichuan/stream/BaichuanVideoStream.ts
2137
+ var BaichuanVideoStream_exports = {};
2138
+ __export(BaichuanVideoStream_exports, {
2139
+ BaichuanVideoStream: () => BaichuanVideoStream
2140
+ });
2088
2141
  function removeEmulationPreventionBytes(rbsp) {
2089
2142
  const out = [];
2090
2143
  for (let i = 0; i < rbsp.length; i++) {
@@ -2218,6 +2271,15 @@ var init_BaichuanVideoStream = __esm({
2218
2271
  acceptAnyStreamType;
2219
2272
  lockedChannelId;
2220
2273
  bcMediaCodec;
2274
+ /**
2275
+ * Diagnostic-only accessor for the BcMedia codec. Used by tools that need to
2276
+ * inspect unknown chunks (for example to discover undocumented audio
2277
+ * sub-packets the parser currently skips). Not part of the supported public
2278
+ * surface — do not rely on it in application code.
2279
+ */
2280
+ get _bcMediaCodec() {
2281
+ return this.bcMediaCodec;
2282
+ }
2221
2283
  debugH264LogsLeft;
2222
2284
  debugSavedSamples;
2223
2285
  warnedNonAnnexBOnce = false;
@@ -2249,6 +2311,14 @@ var init_BaichuanVideoStream = __esm({
2249
2311
  // Stateful AES decryptor for fragmented BcMedia packets (full_aes mode)
2250
2312
  // In CFB mode, continuation frames must use the cipher state from previous frames.
2251
2313
  aesStreamDecryptor = null;
2314
+ // Latest frame dimensions reported by BcMedia InfoV1/V2 packets.
2315
+ // Used to attach width/height context to the `additionalHeader` event so
2316
+ // consumers can normalize box coordinates to a fraction of the stream size.
2317
+ latestFrameWidth;
2318
+ latestFrameHeight;
2319
+ // Teardown returned by ReolinkBaichuanApi._registerVideoStreamForDetection.
2320
+ // Called from stop() to detach the detection bridge.
2321
+ detectionTeardown;
2252
2322
  /**
2253
2323
  * Pending startup error stashed when emitSafeError is called before any
2254
2324
  * "error" listener is registered (e.g. camera returns 400 during start()).
@@ -2911,6 +2981,16 @@ var init_BaichuanVideoStream = __esm({
2911
2981
  }
2912
2982
  videoType = detectedCodec;
2913
2983
  }
2984
+ if (media.additionalHeader && media.additionalHeader.length > 0) {
2985
+ this.emit("additionalHeader", {
2986
+ raw: media.additionalHeader,
2987
+ frameType: "Iframe",
2988
+ videoType,
2989
+ microseconds: media.microseconds,
2990
+ ...this.latestFrameWidth !== void 0 ? { frameWidth: this.latestFrameWidth } : {},
2991
+ ...this.latestFrameHeight !== void 0 ? { frameHeight: this.latestFrameHeight } : {}
2992
+ });
2993
+ }
2914
2994
  const annexBData = videoType === "H265" ? convertToAnnexB2(media.data) : convertToAnnexB(media.data);
2915
2995
  const isKeyframe = true;
2916
2996
  maybeCacheParamSets(annexBData, "Iframe", videoType);
@@ -2992,6 +3072,18 @@ var init_BaichuanVideoStream = __esm({
2992
3072
  }
2993
3073
  } else if (media.type === "Pframe") {
2994
3074
  const chunk = media.data;
3075
+ if (media.additionalHeader && media.additionalHeader.length > 0) {
3076
+ const detected = detectVideoCodecFromNal(chunk);
3077
+ const videoTypeForHeader = detected ?? media.videoType;
3078
+ this.emit("additionalHeader", {
3079
+ raw: media.additionalHeader,
3080
+ frameType: "Pframe",
3081
+ videoType: videoTypeForHeader,
3082
+ microseconds: media.microseconds,
3083
+ ...this.latestFrameWidth !== void 0 ? { frameWidth: this.latestFrameWidth } : {},
3084
+ ...this.latestFrameHeight !== void 0 ? { frameHeight: this.latestFrameHeight } : {}
3085
+ });
3086
+ }
2995
3087
  let videoType = media.videoType;
2996
3088
  const detectedCodec = detectVideoCodecFromNal(chunk);
2997
3089
  if (detectedCodec && detectedCodec !== videoType) {
@@ -3034,6 +3126,8 @@ var init_BaichuanVideoStream = __esm({
3034
3126
  this.emit("audioFrame", media.data);
3035
3127
  }
3036
3128
  if (media.type === "InfoV1" || media.type === "InfoV2") {
3129
+ if (media.videoWidth > 0) this.latestFrameWidth = media.videoWidth;
3130
+ if (media.videoHeight > 0) this.latestFrameHeight = media.videoHeight;
3037
3131
  }
3038
3132
  }
3039
3133
  if (totalFramesReceived <= 10 || totalFramesReceived % 20 === 0 && (videoFramesEmitted > 0 || audioFramesEmitted > 0)) {
@@ -3053,6 +3147,12 @@ var init_BaichuanVideoStream = __esm({
3053
3147
  this.client.on("push", this.videoFrameHandler);
3054
3148
  this.active = true;
3055
3149
  this.startWatchdog();
3150
+ if (this.api && typeof this.api._registerVideoStreamForDetection === "function") {
3151
+ this.detectionTeardown = this.api._registerVideoStreamForDetection(this, {
3152
+ channel: this.channel,
3153
+ profile: this.profile
3154
+ });
3155
+ }
3056
3156
  this.lastMediaAtMs = Date.now();
3057
3157
  if (this.api) {
3058
3158
  try {
@@ -3150,6 +3250,13 @@ var init_BaichuanVideoStream = __esm({
3150
3250
  }
3151
3251
  }
3152
3252
  this.active = false;
3253
+ if (this.detectionTeardown) {
3254
+ try {
3255
+ this.detectionTeardown();
3256
+ } catch {
3257
+ }
3258
+ this.detectionTeardown = void 0;
3259
+ }
3153
3260
  this.emit("close");
3154
3261
  }
3155
3262
  isActive() {
@@ -7377,12 +7484,12 @@ var init_ReolinkCgiApi = __esm({
7377
7484
  "getVideoclipThumbnailJpeg",
7378
7485
  `Extracting thumbnail from VOD URL (FLV): ${vodUrl.substring(0, 100)}... (seek=${seekSeconds}s)`
7379
7486
  );
7380
- const { spawn: spawn12 } = await import("child_process");
7487
+ const { spawn: spawn13 } = await import("child_process");
7381
7488
  return new Promise((resolve, reject) => {
7382
7489
  const chunks = [];
7383
7490
  let stderr = "";
7384
7491
  let timedOut = false;
7385
- const ffmpeg = spawn12(ffmpegPath, [
7492
+ const ffmpeg = spawn13(ffmpegPath, [
7386
7493
  "-y",
7387
7494
  "-analyzeduration",
7388
7495
  "10000000",
@@ -8040,6 +8147,7 @@ __export(index_exports, {
8040
8147
  BC_CMD_ID_GET_SUPPORT: () => BC_CMD_ID_GET_SUPPORT,
8041
8148
  BC_CMD_ID_GET_SYSTEM_GENERAL: () => BC_CMD_ID_GET_SYSTEM_GENERAL,
8042
8149
  BC_CMD_ID_GET_TIMELAPSE_CFG: () => BC_CMD_ID_GET_TIMELAPSE_CFG,
8150
+ BC_CMD_ID_GET_VERSION_INFO: () => BC_CMD_ID_GET_VERSION_INFO,
8043
8151
  BC_CMD_ID_GET_VIDEO_INPUT: () => BC_CMD_ID_GET_VIDEO_INPUT,
8044
8152
  BC_CMD_ID_GET_WHITE_LED: () => BC_CMD_ID_GET_WHITE_LED,
8045
8153
  BC_CMD_ID_GET_WIFI: () => BC_CMD_ID_GET_WIFI,
@@ -8070,6 +8178,7 @@ __export(index_exports, {
8070
8178
  BC_CMD_ID_SET_ENC: () => BC_CMD_ID_SET_ENC,
8071
8179
  BC_CMD_ID_SET_LED_STATE: () => BC_CMD_ID_SET_LED_STATE,
8072
8180
  BC_CMD_ID_SET_MOTION_ALARM: () => BC_CMD_ID_SET_MOTION_ALARM,
8181
+ BC_CMD_ID_SET_OSD_DATETIME: () => BC_CMD_ID_SET_OSD_DATETIME,
8073
8182
  BC_CMD_ID_SET_PIR_INFO: () => BC_CMD_ID_SET_PIR_INFO,
8074
8183
  BC_CMD_ID_SET_PRIVACY_MASK: () => BC_CMD_ID_SET_PRIVACY_MASK,
8075
8184
  BC_CMD_ID_SET_PUSH_TASK: () => BC_CMD_ID_SET_PUSH_TASK,
@@ -8178,6 +8287,7 @@ __export(index_exports, {
8178
8287
  decideSleepInferenceTransition: () => decideSleepInferenceTransition,
8179
8288
  decideVideoclipTranscodeMode: () => decideVideoclipTranscodeMode,
8180
8289
  decodeHeader: () => decodeHeader,
8290
+ decodeMotionScopeBitmap: () => decodeMotionScopeBitmap,
8181
8291
  deriveAesKey: () => deriveAesKey,
8182
8292
  detectIosClient: () => detectIosClient,
8183
8293
  detectVideoCodecFromNal: () => detectVideoCodecFromNal,
@@ -8190,6 +8300,7 @@ __export(index_exports, {
8190
8300
  discoverViaUdpBroadcast: () => discoverViaUdpBroadcast,
8191
8301
  discoverViaUdpDirect: () => discoverViaUdpDirect,
8192
8302
  encodeHeader: () => encodeHeader,
8303
+ encodeMotionScopeBitmap: () => encodeMotionScopeBitmap,
8193
8304
  ensureXmlHeader: () => ensureXmlHeader,
8194
8305
  extractH264ParamSetsFromAccessUnit: () => extractH264ParamSetsFromAccessUnit,
8195
8306
  extractH265ParamSetsFromAccessUnit: () => extractH265ParamSetsFromAccessUnit,
@@ -8198,6 +8309,7 @@ __export(index_exports, {
8198
8309
  extractVpsFromAnnexB: () => extractVpsFromAnnexB,
8199
8310
  flattenAbilitiesForChannel: () => flattenAbilitiesForChannel,
8200
8311
  formatMjpegFrame: () => formatMjpegFrame,
8312
+ fullCoverageScope: () => fullCoverageScope,
8201
8313
  getConstructedVideoStreamOptions: () => getConstructedVideoStreamOptions,
8202
8314
  getGlobalLogger: () => getGlobalLogger,
8203
8315
  getH265NalType: () => getH265NalType,
@@ -8241,6 +8353,7 @@ __export(index_exports, {
8241
8353
  splitAnnexBToNals: () => splitAnnexBToNals,
8242
8354
  splitH265AnnexBToNalPayloads: () => splitAnnexBToNalPayloads2,
8243
8355
  testChannelStreams: () => testChannelStreams,
8356
+ upsertXmlTag: () => upsertXmlTag,
8244
8357
  xmlEscape: () => xmlEscape,
8245
8358
  xmlIndicatesFloodlight: () => xmlIndicatesFloodlight,
8246
8359
  zipDirectory: () => zipDirectory
@@ -9969,6 +10082,23 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
9969
10082
  * even if the current client instance is idle/disconnected.
9970
10083
  */
9971
10084
  static streamingRegistry = /* @__PURE__ */ new Map();
10085
+ /**
10086
+ * Per-device set of live BaichuanClient instances.
10087
+ *
10088
+ * Why: when a streaming client unsubscribes (e.g. RTSP grace timer expires
10089
+ * and SocketPool tears the streaming socket down), the global streaming
10090
+ * registry decrements but the GENERAL client of the same device has no
10091
+ * way of knowing — its idle-disconnect timer was last evaluated while
10092
+ * `isDeviceStreamingActive()` was still true (because the streaming socket
10093
+ * was still alive) and wasn't rescheduled. Without this registry the
10094
+ * general socket stays connected, the 60-second session-guard timer keeps
10095
+ * sending getOnlineUserList() to the camera, and a battery camera ends up
10096
+ * waking up every minute (issue #18).
10097
+ *
10098
+ * On streamingRegistry decrement-to-zero we walk this set and kick every
10099
+ * sibling's idle-disconnect timer so it can re-evaluate eligibility.
10100
+ */
10101
+ static deviceClients = /* @__PURE__ */ new Map();
9972
10102
  /**
9973
10103
  * Per-host D2C_DISC backoff state that persists across client instance recreation.
9974
10104
  *
@@ -10083,6 +10213,29 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10083
10213
  // AlarmEventList (cmdId=33) can be very chatty (often sent every second).
10084
10214
  // Track last per-channel alarm state so we only emit on transitions.
10085
10215
  alarmEventState = /* @__PURE__ */ new Map();
10216
+ /** Whether this instance is currently in BaichuanClient.deviceClients. */
10217
+ registeredInDeviceClients = false;
10218
+ registerInDeviceClients() {
10219
+ if (this.registeredInDeviceClients) return;
10220
+ const key = this.getDeviceRegistryKey();
10221
+ let set = _BaichuanClient.deviceClients.get(key);
10222
+ if (!set) {
10223
+ set = /* @__PURE__ */ new Set();
10224
+ _BaichuanClient.deviceClients.set(key, set);
10225
+ }
10226
+ set.add(this);
10227
+ this.registeredInDeviceClients = true;
10228
+ }
10229
+ unregisterFromDeviceClients() {
10230
+ if (!this.registeredInDeviceClients) return;
10231
+ const key = this.getDeviceRegistryKey();
10232
+ const set = _BaichuanClient.deviceClients.get(key);
10233
+ if (set) {
10234
+ set.delete(this);
10235
+ if (set.size === 0) _BaichuanClient.deviceClients.delete(key);
10236
+ }
10237
+ this.registeredInDeviceClients = false;
10238
+ }
10086
10239
  constructor(options) {
10087
10240
  super();
10088
10241
  this.opts = options;
@@ -10097,6 +10250,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10097
10250
  code: err?.code
10098
10251
  });
10099
10252
  });
10253
+ this.registerInDeviceClients();
10100
10254
  }
10101
10255
  newSocketSessionId(transport) {
10102
10256
  const short = (0, import_node_crypto2.randomUUID)().split("-")[0] ?? (0, import_node_crypto2.randomUUID)().slice(0, 8);
@@ -10353,6 +10507,18 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10353
10507
  activeStreamClients: nextCount
10354
10508
  });
10355
10509
  this.contributesToGlobalStreamingRegistry = shouldContribute;
10510
+ if (!shouldContribute && nextCount === 0) {
10511
+ const siblings = _BaichuanClient.deviceClients.get(key);
10512
+ if (siblings) {
10513
+ for (const sib of siblings) {
10514
+ if (sib === this) continue;
10515
+ try {
10516
+ sib.kickIdleDisconnectTimer();
10517
+ } catch {
10518
+ }
10519
+ }
10520
+ }
10521
+ }
10356
10522
  }
10357
10523
  /**
10358
10524
  * True if the device should be considered "awake" due to active streaming.
@@ -10817,6 +10983,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
10817
10983
  `transport=tcp host=${this.opts.host} port=${port}${sid ? ` sid=${sid}` : ""}${remote ? ` remote=${remote}` : ""}${peer ? ` peer=${peer}` : ""}`
10818
10984
  );
10819
10985
  this.logSocketState("tcp_connected");
10986
+ this.registerInDeviceClients();
10820
10987
  this.startKeepAlive();
10821
10988
  this.kickIdleDisconnectTimer();
10822
10989
  }
@@ -11133,6 +11300,7 @@ var BaichuanClient = class _BaichuanClient extends import_node_events2.EventEmit
11133
11300
  this.logDebug("udp_close_error", e);
11134
11301
  }
11135
11302
  }
11303
+ this.unregisterFromDeviceClients();
11136
11304
  }
11137
11305
  handleFrame(frame) {
11138
11306
  const now = Date.now();
@@ -16410,6 +16578,32 @@ var parseAbilityInfoXml = (xml) => {
16410
16578
  return abilities;
16411
16579
  };
16412
16580
 
16581
+ // src/reolink/baichuan/utils/versionInfo.ts
16582
+ init_xml();
16583
+ function parseVersionInfo(xml) {
16584
+ const out = {};
16585
+ const set = (key) => {
16586
+ const v = getXmlText(xml, key);
16587
+ if (v !== void 0) out[key] = v;
16588
+ };
16589
+ set("name");
16590
+ set("type");
16591
+ set("serialNumber");
16592
+ set("buildDay");
16593
+ set("hardwareVersion");
16594
+ set("cfgVersion");
16595
+ set("firmwareVersion");
16596
+ set("detail");
16597
+ set("IEClient");
16598
+ set("cc3200Version");
16599
+ set("spVersion");
16600
+ set("pakSuffix");
16601
+ set("itemNo");
16602
+ set("aiVersion");
16603
+ set("helpVersion");
16604
+ return out;
16605
+ }
16606
+
16413
16607
  // src/reolink/baichuan/utils/aiState.ts
16414
16608
  init_constants();
16415
16609
  init_xml();
@@ -16716,6 +16910,204 @@ var buildChannelPushDataLogSnapshot = (channelPushData) => {
16716
16910
  return { result: resultObj, storedChannels: Object.keys(resultObj) };
16717
16911
  };
16718
16912
 
16913
+ // src/reolink/baichuan/utils/detection.ts
16914
+ var lz4 = __toESM(require("lz4js"), 1);
16915
+
16916
+ // src/reolink/baichuan/utils/aiClassMap.ts
16917
+ function type1ToLabel(type1) {
16918
+ switch (type1) {
16919
+ case 1:
16920
+ return "people";
16921
+ case 2:
16922
+ return "vehicle";
16923
+ case 3:
16924
+ return "animal";
16925
+ case 11259375:
16926
+ return "face";
16927
+ default:
16928
+ return "unknown";
16929
+ }
16930
+ }
16931
+
16932
+ // src/reolink/baichuan/utils/detection.ts
16933
+ var MARKER_LENGTH = 8;
16934
+ var IFRAME_PREFIX_LENGTH = 8;
16935
+ var COUNTER_OFFSET = 8;
16936
+ var BASELINE_SIZE = 128;
16937
+ var FRAME_SIZE_TLV = Buffer.from([3, 4, 0]);
16938
+ var LZ4F_MAGIC = Buffer.from([4, 34, 77, 24]);
16939
+ var CONFIDENCE_DIVISOR = 100;
16940
+ var DEFAULT_AI_FRAME_WIDTH = 896;
16941
+ var DEFAULT_AI_FRAME_HEIGHT = 480;
16942
+ var LZ4_DECOMPRESS_MAX = 256 * 1024;
16943
+ function walkBoxes(buf, start, end, type1, type2, out) {
16944
+ let pos = start;
16945
+ while (pos + 3 <= end) {
16946
+ const t = buf[pos];
16947
+ if (t === 0) return;
16948
+ const length = buf[pos + 1] | buf[pos + 2] << 8;
16949
+ const recordEnd = pos + 3 + length;
16950
+ if (recordEnd > end) return;
16951
+ const isBoxType4 = t === 4 && (length === 10 || length === 13 || length === 14);
16952
+ const isBoxType2 = t === 2 && length === 10;
16953
+ if ((isBoxType4 || isBoxType2) && type1 !== 0 && type2 !== 0) {
16954
+ const x1 = buf.readUInt16LE(pos + 3);
16955
+ const y1 = buf.readUInt16LE(pos + 5);
16956
+ const x2 = buf.readUInt16LE(pos + 7);
16957
+ const y2 = buf.readUInt16LE(pos + 9);
16958
+ const conf = buf.readUInt16LE(pos + 11);
16959
+ if (x2 > x1 && y2 > y1) {
16960
+ out.push({ x1, y1, x2, y2, conf, label: type1ToLabel(type1) });
16961
+ }
16962
+ pos = recordEnd;
16963
+ continue;
16964
+ }
16965
+ if (type1 === 255 && type2 === 2 && t === 2 && length >= LZ4F_MAGIC.length && buf[pos + 3] === LZ4F_MAGIC[0] && buf[pos + 4] === LZ4F_MAGIC[1] && buf[pos + 5] === LZ4F_MAGIC[2] && buf[pos + 6] === LZ4F_MAGIC[3]) {
16966
+ try {
16967
+ const decompressed = lz4.decompress(
16968
+ buf.subarray(pos + 3, recordEnd),
16969
+ LZ4_DECOMPRESS_MAX
16970
+ );
16971
+ const decBuf = Buffer.from(decompressed);
16972
+ walkBoxes(decBuf, 0, decBuf.length, 0, 0, out);
16973
+ } catch {
16974
+ }
16975
+ pos = recordEnd;
16976
+ continue;
16977
+ }
16978
+ if (length > 0) {
16979
+ let nextT1 = type1;
16980
+ let nextT2 = type2;
16981
+ if (type1 === 0) nextT1 = t;
16982
+ else if (type2 === 0) nextT2 = t;
16983
+ walkBoxes(buf, pos + 3, recordEnd, nextT1, nextT2, out);
16984
+ }
16985
+ pos = recordEnd;
16986
+ }
16987
+ }
16988
+ function decodeDetectionHeader(raw, frameType) {
16989
+ const markerOffset = frameType === "Iframe" ? IFRAME_PREFIX_LENGTH : 0;
16990
+ const blockLength = raw.length - markerOffset;
16991
+ const empty = {
16992
+ state: "invalid-marker",
16993
+ markerOffset,
16994
+ blockLength,
16995
+ boxes: []
16996
+ };
16997
+ if (blockLength < MARKER_LENGTH) return empty;
16998
+ if (!hasStandardMarker(raw, markerOffset)) return empty;
16999
+ if (blockLength < COUNTER_OFFSET + 4) return empty;
17000
+ const counter = raw.readUInt32LE(markerOffset + COUNTER_OFFSET);
17001
+ const rawBoxes = [];
17002
+ walkBoxes(raw, markerOffset, raw.length, 0, 0, rawBoxes);
17003
+ let aiFrameWidth = DEFAULT_AI_FRAME_WIDTH;
17004
+ let aiFrameHeight = DEFAULT_AI_FRAME_HEIGHT;
17005
+ let frameSizeFound = false;
17006
+ const searchStart = markerOffset + MARKER_LENGTH;
17007
+ for (let i = searchStart; i + 7 <= raw.length; i++) {
17008
+ if (raw[i] === FRAME_SIZE_TLV[0] && raw[i + 1] === FRAME_SIZE_TLV[1] && raw[i + 2] === FRAME_SIZE_TLV[2]) {
17009
+ const w = raw.readUInt16LE(i + 3);
17010
+ const h = raw.readUInt16LE(i + 5);
17011
+ if (w >= 64 && w <= 8192 && h >= 64 && h <= 8192) {
17012
+ aiFrameWidth = w;
17013
+ aiFrameHeight = h;
17014
+ frameSizeFound = true;
17015
+ break;
17016
+ }
17017
+ }
17018
+ }
17019
+ const specificity = {
17020
+ face: 4,
17021
+ animal: 3,
17022
+ people: 2,
17023
+ vehicle: 1,
17024
+ unknown: 0
17025
+ };
17026
+ const dedup = /* @__PURE__ */ new Map();
17027
+ for (const rb of rawBoxes) {
17028
+ if (rb.x2 > aiFrameWidth || rb.y2 > aiFrameHeight) continue;
17029
+ const key = `${rb.x1}_${rb.y1}_${rb.x2}_${rb.y2}`;
17030
+ const prev = dedup.get(key);
17031
+ if (!prev || (specificity[rb.label] ?? 0) > (specificity[prev.label] ?? 0)) {
17032
+ dedup.set(key, rb);
17033
+ }
17034
+ }
17035
+ const boxes = [];
17036
+ for (const rb of dedup.values()) {
17037
+ boxes.push({
17038
+ x: rb.x1 / aiFrameWidth,
17039
+ y: rb.y1 / aiFrameHeight,
17040
+ width: (rb.x2 - rb.x1) / aiFrameWidth,
17041
+ height: (rb.y2 - rb.y1) / aiFrameHeight,
17042
+ ...rb.conf > 0 && rb.conf <= 100 ? { confidence: rb.conf / CONFIDENCE_DIVISOR } : {},
17043
+ ...rb.label !== "unknown" ? { label: rb.label } : {}
17044
+ });
17045
+ }
17046
+ let state;
17047
+ if (boxes.length > 0) state = "overlay-decoded";
17048
+ else if (blockLength === BASELINE_SIZE) state = "no-overlay";
17049
+ else state = "overlay-undecoded";
17050
+ return {
17051
+ state,
17052
+ markerOffset,
17053
+ blockLength,
17054
+ counter,
17055
+ ...frameSizeFound ? { aiFrameWidth, aiFrameHeight } : {},
17056
+ boxes
17057
+ };
17058
+ }
17059
+ function hasStandardMarker(raw, offset) {
17060
+ if (raw.length < offset + MARKER_LENGTH) return false;
17061
+ return raw[offset] === 255 && raw[offset + 2] === 0 && raw[offset + 3] === 1 && raw[offset + 4] === 11 && raw[offset + 5] === 0 && raw[offset + 6] === 1 && raw[offset + 7] === 8;
17062
+ }
17063
+
17064
+ // src/reolink/baichuan/utils/encOptions.ts
17065
+ function buildEncOptions(list, channel) {
17066
+ const result = { channel };
17067
+ const main = aggregateByType(list, "mainStream");
17068
+ const sub = aggregateByType(list, "subStream");
17069
+ const third = aggregateByType(list, "thirdStream");
17070
+ if (main) result.mainStream = main;
17071
+ if (sub) result.subStream = sub;
17072
+ if (third) result.thirdStream = third;
17073
+ return result;
17074
+ }
17075
+ function aggregateByType(list, type) {
17076
+ const seen = /* @__PURE__ */ new Map();
17077
+ for (const stream of list.streams) {
17078
+ for (const eb of stream.encodeTables) {
17079
+ if (eb.type !== type) continue;
17080
+ const mapped = mapEncodeTable(eb);
17081
+ if (!mapped) continue;
17082
+ const key = `${mapped.width}x${mapped.height}`;
17083
+ if (!seen.has(key)) seen.set(key, mapped);
17084
+ }
17085
+ }
17086
+ if (seen.size === 0) return void 0;
17087
+ return {
17088
+ type,
17089
+ resolutions: [...seen.values()],
17090
+ encoderTypes: ["vbr", "cbr"],
17091
+ encoderProfiles: ["high", "main", "baseline"]
17092
+ };
17093
+ }
17094
+ function mapEncodeTable(eb) {
17095
+ if (eb.width == null || eb.height == null) return void 0;
17096
+ const videoEncTypes = (eb.videoEncTypeList ?? (eb.videoEncType != null ? [eb.videoEncType] : [])).map(
17097
+ (t) => t === 0 ? "h264" : t === 1 ? "h265" : void 0
17098
+ ).filter((t) => t !== void 0);
17099
+ return {
17100
+ width: eb.width,
17101
+ height: eb.height,
17102
+ videoEncTypes,
17103
+ ...eb.defaultFramerate != null ? { defaultFramerate: eb.defaultFramerate } : {},
17104
+ ...eb.defaultBitrate != null ? { defaultBitrate: eb.defaultBitrate } : {},
17105
+ ...eb.defaultGop != null ? { defaultGop: eb.defaultGop } : {},
17106
+ framerateOptions: eb.framerateTable ?? [],
17107
+ bitrateOptions: eb.bitrateTable ?? []
17108
+ };
17109
+ }
17110
+
16719
17111
  // src/reolink/baichuan/utils/events.ts
16720
17112
  var mapToSimpleEvent = (event) => {
16721
17113
  const timestamp = event.timestamp ?? Date.now();
@@ -18839,6 +19231,21 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
18839
19231
  sessionGuardIntervalTimer;
18840
19232
  simpleEventListeners = /* @__PURE__ */ new Set();
18841
19233
  simpleEventSubscribed = false;
19234
+ // Detection events are sourced from BcMedia additionalHeader on active video
19235
+ // streams. Unlike simpleEvent, no Baichuan subscribe command is needed — the
19236
+ // data flows whenever a stream is open. Active streams register themselves via
19237
+ // _registerVideoStreamForDetection (called from BaichuanVideoStream.start).
19238
+ detectionEventListeners = /* @__PURE__ */ new Set();
19239
+ detectionEventStreamHooks = /* @__PURE__ */ new Map();
19240
+ // Auto-managed substream for `onObjectDetections` listeners. Reference-counted
19241
+ // by the listener set: the substream is opened on the first listener and torn
19242
+ // down with the last one. Mirrors `onSimpleEvent`'s subscribe/unsubscribe
19243
+ // lifecycle so a caller never has to manage a video stream just to read AI
19244
+ // detections.
19245
+ objectDetectionListeners = /* @__PURE__ */ new Set();
19246
+ objectDetectionStream;
19247
+ objectDetectionStreamStartInFlight;
19248
+ objectDetectionInternalListener;
18842
19249
  simpleEventSubscribeInFlight;
18843
19250
  simpleEventUnsubscribeInFlight;
18844
19251
  simpleEventResubscribeTimer;
@@ -20274,6 +20681,205 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
20274
20681
  }
20275
20682
  }
20276
20683
  }
20684
+ /**
20685
+ * Subscribe to per-frame detection events sourced from the BcMedia
20686
+ * `additionalHeader` block on active video streams.
20687
+ *
20688
+ * Mirrors {@link onSimpleEvent} but is fed by the streaming side-channel:
20689
+ * one event fires for every I-frame / P-frame that carries an overlay block.
20690
+ * Coordinates are reported in normalized [0, 1] fractions of the source
20691
+ * frame, so the same box renders correctly on mainStream, subStream, and
20692
+ * externStream.
20693
+ *
20694
+ * Unlike `onSimpleEvent`, no Baichuan subscribe command is involved — events
20695
+ * only flow while a video stream is open. The library hooks every
20696
+ * `BaichuanVideoStream` created via this API for the listener's lifetime.
20697
+ */
20698
+ onDetection(callback) {
20699
+ this.detectionEventListeners.add(callback);
20700
+ }
20701
+ /**
20702
+ * Remove a single detection callback, or all of them if `callback` is omitted.
20703
+ */
20704
+ offDetection(callback) {
20705
+ if (callback) {
20706
+ this.detectionEventListeners.delete(callback);
20707
+ } else {
20708
+ this.detectionEventListeners.clear();
20709
+ }
20710
+ }
20711
+ /**
20712
+ * Subscribe to AI object detections (people / vehicle / animal / face boxes
20713
+ * with class label and confidence) without managing a video stream yourself.
20714
+ *
20715
+ * Mirrors {@link onSimpleEvent} end-to-end: the API opens a dedicated
20716
+ * substream behind the scenes on the first listener, forwards every box-bearing
20717
+ * `additionalHeader` to your callback, and tears the stream down when the last
20718
+ * listener unsubscribes. The substream is the lightest profile (typically
20719
+ * 640×360) so the additional bandwidth/CPU overhead is minimal.
20720
+ *
20721
+ * Each event carries normalized `[0, 1]` box coordinates, a class label, and
20722
+ * a confidence score — render-ready without further conversion.
20723
+ */
20724
+ async onObjectDetections(callback) {
20725
+ this.objectDetectionListeners.add(callback);
20726
+ this.logger.debug?.(
20727
+ `[ReolinkBaichuanApi] onObjectDetections: registering listener (total=${this.objectDetectionListeners.size})`
20728
+ );
20729
+ await this.ensureObjectDetectionStream();
20730
+ }
20731
+ /**
20732
+ * Remove one detection callback, or all of them if `callback` is omitted.
20733
+ * When the last listener is removed the auto-managed substream is closed.
20734
+ */
20735
+ async offObjectDetections(callback) {
20736
+ if (callback) {
20737
+ this.objectDetectionListeners.delete(callback);
20738
+ } else {
20739
+ this.objectDetectionListeners.clear();
20740
+ }
20741
+ if (this.objectDetectionListeners.size === 0) {
20742
+ await this.tearDownObjectDetectionStream();
20743
+ }
20744
+ }
20745
+ async ensureObjectDetectionStream() {
20746
+ if (this.objectDetectionStream) return;
20747
+ if (this.objectDetectionStreamStartInFlight) {
20748
+ await this.objectDetectionStreamStartInFlight;
20749
+ return;
20750
+ }
20751
+ this.objectDetectionStreamStartInFlight = (async () => {
20752
+ const { BaichuanVideoStream: BaichuanVideoStream2 } = await Promise.resolve().then(() => (init_BaichuanVideoStream(), BaichuanVideoStream_exports));
20753
+ const sessionKey = `live:object-detections:ch0:sub`;
20754
+ const dedicated = await this.createDedicatedSession(sessionKey);
20755
+ const stream = new BaichuanVideoStream2({
20756
+ client: dedicated.client,
20757
+ api: this,
20758
+ channel: 0,
20759
+ profile: "sub",
20760
+ logger: this.logger
20761
+ });
20762
+ this.objectDetectionInternalListener = (event) => {
20763
+ for (const cb of this.objectDetectionListeners) {
20764
+ try {
20765
+ void Promise.resolve(cb(event)).catch((e) => {
20766
+ (this.logger.warn ?? this.logger.error).call(
20767
+ this.logger,
20768
+ "[ReolinkBaichuanApi] onObjectDetections handler error",
20769
+ formatErrorForLog(e)
20770
+ );
20771
+ });
20772
+ } catch (e) {
20773
+ (this.logger.warn ?? this.logger.error).call(
20774
+ this.logger,
20775
+ "[ReolinkBaichuanApi] onObjectDetections handler error",
20776
+ formatErrorForLog(e)
20777
+ );
20778
+ }
20779
+ }
20780
+ };
20781
+ this.detectionEventListeners.add(this.objectDetectionInternalListener);
20782
+ try {
20783
+ await stream.start();
20784
+ } catch (e) {
20785
+ if (this.objectDetectionInternalListener) {
20786
+ this.detectionEventListeners.delete(
20787
+ this.objectDetectionInternalListener
20788
+ );
20789
+ this.objectDetectionInternalListener = void 0;
20790
+ }
20791
+ await dedicated.release().catch(() => {
20792
+ });
20793
+ throw e;
20794
+ }
20795
+ this.objectDetectionStream = {
20796
+ stop: () => stream.stop(),
20797
+ release: () => dedicated.release()
20798
+ };
20799
+ this.logger.debug?.(
20800
+ `[ReolinkBaichuanApi] onObjectDetections: substream started (key=${sessionKey})`
20801
+ );
20802
+ })();
20803
+ try {
20804
+ await this.objectDetectionStreamStartInFlight;
20805
+ } finally {
20806
+ this.objectDetectionStreamStartInFlight = void 0;
20807
+ }
20808
+ }
20809
+ async tearDownObjectDetectionStream() {
20810
+ const handle = this.objectDetectionStream;
20811
+ this.objectDetectionStream = void 0;
20812
+ if (this.objectDetectionInternalListener) {
20813
+ this.detectionEventListeners.delete(this.objectDetectionInternalListener);
20814
+ this.objectDetectionInternalListener = void 0;
20815
+ }
20816
+ if (!handle) return;
20817
+ try {
20818
+ await handle.stop();
20819
+ } catch (e) {
20820
+ this.logger.debug?.(
20821
+ `[ReolinkBaichuanApi] onObjectDetections: stream stop error: ${formatErrorForLog(e)}`
20822
+ );
20823
+ }
20824
+ try {
20825
+ await handle.release();
20826
+ } catch (e) {
20827
+ this.logger.debug?.(
20828
+ `[ReolinkBaichuanApi] onObjectDetections: session release error: ${formatErrorForLog(e)}`
20829
+ );
20830
+ }
20831
+ this.logger.debug?.(
20832
+ `[ReolinkBaichuanApi] onObjectDetections: substream torn down`
20833
+ );
20834
+ }
20835
+ /**
20836
+ * Internal: invoked by BaichuanVideoStream when it starts so the API can hook
20837
+ * its `additionalHeader` event. Returns a teardown function the stream calls
20838
+ * on stop. Not intended for direct use by consumers.
20839
+ */
20840
+ _registerVideoStreamForDetection(stream, context) {
20841
+ const listener = (info) => {
20842
+ if (this.detectionEventListeners.size === 0) return;
20843
+ const decoded = decodeDetectionHeader(info.raw, info.frameType);
20844
+ const event = {
20845
+ channel: context.channel,
20846
+ microseconds: info.microseconds,
20847
+ profile: context.profile,
20848
+ boxes: decoded.boxes,
20849
+ ...info.frameWidth !== void 0 ? { frameWidth: info.frameWidth } : {},
20850
+ ...info.frameHeight !== void 0 ? { frameHeight: info.frameHeight } : {},
20851
+ decodeState: decoded.state,
20852
+ rawHeader: info.raw
20853
+ };
20854
+ this.dispatchDetectionEvent(event);
20855
+ };
20856
+ stream.on("additionalHeader", listener);
20857
+ const teardown = () => {
20858
+ stream.off("additionalHeader", listener);
20859
+ this.detectionEventStreamHooks.delete(stream);
20860
+ };
20861
+ this.detectionEventStreamHooks.set(stream, teardown);
20862
+ return teardown;
20863
+ }
20864
+ dispatchDetectionEvent(evt) {
20865
+ for (const cb of this.detectionEventListeners) {
20866
+ try {
20867
+ void Promise.resolve(cb(evt)).catch((e) => {
20868
+ (this.logger.warn ?? this.logger.error).call(
20869
+ this.logger,
20870
+ "[ReolinkBaichuanApi] onDetection handler error",
20871
+ formatErrorForLog(e)
20872
+ );
20873
+ });
20874
+ } catch (e) {
20875
+ (this.logger.warn ?? this.logger.error).call(
20876
+ this.logger,
20877
+ "[ReolinkBaichuanApi] onDetection handler error",
20878
+ formatErrorForLog(e)
20879
+ );
20880
+ }
20881
+ }
20882
+ }
20277
20883
  startSimpleEventResubscribeTimer() {
20278
20884
  if (this.simpleEventResubscribeTimer) return;
20279
20885
  if (this.simpleEventListeners.size === 0) return;
@@ -20656,6 +21262,9 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
20656
21262
  this.stopUdpSleepInference();
20657
21263
  this.stopSimpleEventWatchdog();
20658
21264
  this.stopSimpleEventResubscribeTimer();
21265
+ this.objectDetectionListeners.clear();
21266
+ await this.tearDownObjectDetectionStream().catch(() => {
21267
+ });
20659
21268
  await this.cleanup();
20660
21269
  await this.stopAllActiveStreams();
20661
21270
  await this.cleanupSocketPool();
@@ -21003,6 +21612,53 @@ var ReolinkBaichuanApi = class _ReolinkBaichuanApi {
21003
21612
  const xml = `<?xml version="1.0" encoding="UTF-8" ?><body><${tag} version="1.1"><enable>${params.enable ? 1 : 0}</enable></${tag}></body>`;
21004
21613
  await this.sendXml({ cmdId: 36, payloadXml: xml });
21005
21614
  }
21615
+ /**
21616
+ * Full port-config setter (cmd_id 36). Patches one or more of the six
21617
+ * service ports the camera serves — Server (Baichuan), HTTP, HTTPS,
21618
+ * RTSP, RTMP, ONVIF. Each entry takes an optional `port` (number) and
21619
+ * `enable` (boolean); fields the caller doesn't pass are left alone.
21620
+ *
21621
+ * Sends one block per port that has any field set, then issues a
21622
+ * single cmd_36 with the merged body. The camera accepts multiple
21623
+ * `<XxxPort>` siblings in the same payload.
21624
+ *
21625
+ * Wire format observed on E1 Zoom:
21626
+ *
21627
+ * <body>
21628
+ * <RtspPort version="1.1">
21629
+ * <rtspPort>554</rtspPort>
21630
+ * <enable>1</enable>
21631
+ * </RtspPort>
21632
+ * <HttpsPort version="1.1">
21633
+ * <enable>0</enable>
21634
+ * </HttpsPort>
21635
+ * ...
21636
+ * </body>
21637
+ */
21638
+ async setPortConfig(patch) {
21639
+ const blocks = [];
21640
+ const append = (tag, portField, cfg) => {
21641
+ if (!cfg) return;
21642
+ if (cfg.port === void 0 && cfg.enable === void 0) return;
21643
+ const inner = [];
21644
+ if (cfg.port !== void 0) {
21645
+ inner.push(`<${portField}>${cfg.port}</${portField}>`);
21646
+ }
21647
+ if (cfg.enable !== void 0) {
21648
+ inner.push(`<enable>${cfg.enable ? 1 : 0}</enable>`);
21649
+ }
21650
+ blocks.push(`<${tag} version="1.1">${inner.join("")}</${tag}>`);
21651
+ };
21652
+ append("ServerPort", "serverPort", patch.server);
21653
+ append("HttpPort", "httpPort", patch.http);
21654
+ append("HttpsPort", "httpsPort", patch.https);
21655
+ append("RtspPort", "rtspPort", patch.rtsp);
21656
+ append("RtmpPort", "rtmpPort", patch.rtmp);
21657
+ append("OnvifPort", "onvifPort", patch.onvif);
21658
+ if (blocks.length === 0) return;
21659
+ const xml = `<?xml version="1.0" encoding="UTF-8" ?><body>${blocks.join("")}</body>`;
21660
+ await this.sendXml({ cmdId: 36, payloadXml: xml });
21661
+ }
21006
21662
  /** GetDevInfo via Baichuan: host cmd_id 80, channel cmd_id 318 */
21007
21663
  async getInfo(channel, options) {
21008
21664
  const req = { cmdId: channel == null ? 80 : 318 };
@@ -23663,7 +24319,7 @@ ${stderr}`)
23663
24319
  * Convert a raw video keyframe to JPEG using ffmpeg.
23664
24320
  */
23665
24321
  async convertFrameToJpeg(params) {
23666
- const { spawn: spawn12 } = await import("child_process");
24322
+ const { spawn: spawn13 } = await import("child_process");
23667
24323
  const ffmpeg = params.ffmpegPath ?? "ffmpeg";
23668
24324
  const inputFormat = params.videoCodec === "H265" ? "hevc" : "h264";
23669
24325
  return new Promise((resolve, reject) => {
@@ -23685,7 +24341,7 @@ ${stderr}`)
23685
24341
  "2",
23686
24342
  "pipe:1"
23687
24343
  ];
23688
- const proc = spawn12(ffmpeg, args, {
24344
+ const proc = spawn13(ffmpeg, args, {
23689
24345
  stdio: ["pipe", "pipe", "pipe"]
23690
24346
  });
23691
24347
  const chunks = [];
@@ -23828,7 +24484,7 @@ ${stderr}`)
23828
24484
  * Internal helper to mux video+audio into MP4 using ffmpeg.
23829
24485
  */
23830
24486
  async muxToMp4(params) {
23831
- const { spawn: spawn12 } = await import("child_process");
24487
+ const { spawn: spawn13 } = await import("child_process");
23832
24488
  const { randomUUID: randomUUID3 } = await import("crypto");
23833
24489
  const fs6 = await import("fs/promises");
23834
24490
  const os2 = await import("os");
@@ -23880,7 +24536,7 @@ ${stderr}`)
23880
24536
  outputPath
23881
24537
  );
23882
24538
  await new Promise((resolve, reject) => {
23883
- const p = spawn12(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] });
24539
+ const p = spawn13(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] });
23884
24540
  let stderr = "";
23885
24541
  p.stderr.on("data", (d) => {
23886
24542
  stderr += d.toString();
@@ -24687,6 +25343,27 @@ ${stderr}`)
24687
25343
  );
24688
25344
  }
24689
25345
  }
25346
+ async gotoPtzPreset(arg1, arg2) {
25347
+ const ch = arg2 === void 0 ? this.normalizeChannel(void 0) : this.normalizeChannel(arg1);
25348
+ const presetId = arg2 === void 0 ? arg1 : arg2;
25349
+ const channelId = ch;
25350
+ const payloadXml = buildPtzPresetXmlV2(channelId, presetId, "toPos");
25351
+ const extensionXml = buildChannelExtensionXml(channelId);
25352
+ const frame = await this.client.sendFrame({
25353
+ cmdId: BC_CMD_ID_PTZ_CONTROL_PRESET,
25354
+ channel: ch,
25355
+ channelIdOverride: channelId,
25356
+ extensionXml,
25357
+ payloadXml,
25358
+ messageClass: BC_CLASS_MODERN_24,
25359
+ streamType: 0
25360
+ });
25361
+ if (frame.header.responseCode !== 200) {
25362
+ throw new Error(
25363
+ `PTZ goto preset rejected (response_code ${frame.header.responseCode})`
25364
+ );
25365
+ }
25366
+ }
24690
25367
  async deletePtzPreset(arg1, arg2) {
24691
25368
  const ch = arg2 === void 0 ? this.normalizeChannel(void 0) : this.normalizeChannel(arg1);
24692
25369
  const presetId = arg2 === void 0 ? arg1 : arg2;
@@ -25338,22 +26015,62 @@ ${stderr}`)
25338
26015
  const channel = typeof arg1 === "number" ? arg1 : arg3;
25339
26016
  const enabled = typeof arg1 === "number" ? arg2 : arg1;
25340
26017
  const sensitivity = typeof arg1 === "number" ? arg3 : arg2;
25341
- const ch = this.normalizeChannel(channel);
26018
+ return await this.setMotionAlarmFull({
26019
+ ...channel !== void 0 ? { channel } : {},
26020
+ enabled,
26021
+ ...sensitivity !== void 0 ? { sensitivity } : {}
26022
+ });
26023
+ }
26024
+ /**
26025
+ * Set motion alarm with full control, including the detection-zone grid.
26026
+ *
26027
+ * Wire format observed on E1 Zoom (cmd_id=47 SetMdAlarm body):
26028
+ *
26029
+ * <MD version="1.1">
26030
+ * <channelId>0</channelId>
26031
+ * <enable>1</enable>
26032
+ * <usepir>0</usepir>
26033
+ * <width>60</width> <height>33</height>
26034
+ * <scope>
26035
+ * <columns>96</columns> <rows>64</rows>
26036
+ * <valueTable>{base64 6144-bit bitmap}</valueTable>
26037
+ * </scope>
26038
+ * ... other camera-specific fields ...
26039
+ * </MD>
26040
+ *
26041
+ * We do a read-modify-write of the GET response so any camera-specific
26042
+ * extension fields are preserved untouched. Pass `valueTable` to update
26043
+ * the detection zone — see `encodeMotionScopeBitmap` for the bitmap layout.
26044
+ *
26045
+ * @param channel - 0-based channel
26046
+ * @param enabled - toggle motion detection on/off (optional)
26047
+ * @param sensitivity - 0-50, higher = more sensitive (optional)
26048
+ * @param valueTable - base64-encoded grid bitmap; size must match
26049
+ * `<scope><columns>×<rows></scope>` from the GET (optional)
26050
+ */
26051
+ async setMotionAlarmFull(opts) {
26052
+ const ch = this.normalizeChannel(opts.channel);
25342
26053
  const currentXml = await this.sendXml({
25343
26054
  cmdId: BC_CMD_ID_GET_MOTION_ALARM,
25344
26055
  channel: ch
25345
26056
  });
25346
26057
  let modifiedXml = currentXml;
25347
- if (enabled !== void 0) {
26058
+ if (opts.enabled !== void 0) {
25348
26059
  modifiedXml = modifiedXml.replace(
25349
26060
  /<enable>[^<]*<\/enable>/,
25350
- `<enable>${enabled ? "1" : "0"}</enable>`
26061
+ `<enable>${opts.enabled ? "1" : "0"}</enable>`
25351
26062
  );
25352
26063
  }
25353
- if (sensitivity !== void 0) {
26064
+ if (opts.sensitivity !== void 0) {
25354
26065
  modifiedXml = modifiedXml.replace(
25355
26066
  /<sensitivityDefault>[^<]*<\/sensitivityDefault>/,
25356
- `<sensitivityDefault>${sensitivity}</sensitivityDefault>`
26067
+ `<sensitivityDefault>${opts.sensitivity}</sensitivityDefault>`
26068
+ );
26069
+ }
26070
+ if (opts.valueTable !== void 0) {
26071
+ modifiedXml = modifiedXml.replace(
26072
+ /<valueTable>[^<]*<\/valueTable>/,
26073
+ `<valueTable>${opts.valueTable}</valueTable>`
25357
26074
  );
25358
26075
  }
25359
26076
  await this.sendXml({
@@ -26697,12 +27414,24 @@ ${xml}`
26697
27414
  }
26698
27415
  /**
26699
27416
  * SetEnc via Baichuan (cmdId=57). Read-modify-write — preserves
26700
- * unspecified fields. Mirrors reolink_aio's `SetEnc`.
27417
+ * unspecified fields. Mirrors reolink_aio's `SetEnc` plus the additional
27418
+ * `width`/`height`/`encoderType`/`encoderProfile`/`gop`/`thirdStream`
27419
+ * fields observed in the official mobile app (see `pcap/resolution.pcapng`).
27420
+ *
27421
+ * Field meaning per stream:
27422
+ * - `audio` — 0/1 toggle
27423
+ * - `width`/`height` — resolution in pixels. Must be one of the
27424
+ * resolutions returned by {@link getStreamInfoList}.
27425
+ * - `bitRate` — kbps. Must match the table from `getStreamInfoList`.
27426
+ * - `frameRate` — fps. Must match the table from `getStreamInfoList`.
27427
+ * - `videoEncType` — `"h264"` or `"h265"`
27428
+ * - `encoderType` — `"vbr"` or `"cbr"`
27429
+ * - `encoderProfile` — `"high"`, `"main"`, or `"baseline"`
27430
+ * - `gop` — keyframe interval in seconds (sets `<gop><cur>`)
26701
27431
  *
26702
27432
  * @param channel - Channel number (0-based)
26703
- * @param patch - Fields to update on `mainStream` and/or `subStream`,
26704
- * plus a top-level `audio` toggle (0/1). Pass only what you want
26705
- * to change.
27433
+ * @param patch - Fields to update. Pass only the fields you want to change;
27434
+ * everything else is preserved from the device's current configuration.
26706
27435
  */
26707
27436
  async setEnc(channel, patch, options) {
26708
27437
  const ch = this.normalizeChannel(channel);
@@ -26719,6 +27448,7 @@ ${xml}`
26719
27448
  }
26720
27449
  xml = applyStreamPatch(xml, "mainStream", patch.mainStream);
26721
27450
  xml = applyStreamPatch(xml, "subStream", patch.subStream);
27451
+ xml = applyStreamPatch(xml, "thirdStream", patch.thirdStream);
26722
27452
  await this.sendXml({
26723
27453
  cmdId: BC_CMD_ID_SET_ENC,
26724
27454
  channel: ch,
@@ -27326,6 +28056,71 @@ ${xml}`
27326
28056
  `PCAP-derived settings GET failed for cmdId=${params.cmdId}: ${String(lastErr)}`
27327
28057
  );
27328
28058
  }
28059
+ /**
28060
+ * Update the OSD timestamp + channel-name overlay via cmd_id=45
28061
+ * (SetOsdDatetime). The schema is the same `<body><OsdDatetime>` +
28062
+ * `<OsdChannelName>` block returned by `getOsdDatetime` — we
28063
+ * read-modify-write so any extension fields the camera sent are
28064
+ * preserved.
28065
+ *
28066
+ * Position is in **camera pixel coordinates** (e.g. (1,1) for top-left,
28067
+ * not preset strings). Set `enable=0` to hide the overlay; the camera
28068
+ * keeps the stored position so re-enabling later restores it.
28069
+ */
28070
+ async setOsdDatetime(channel, patch, options) {
28071
+ const ch = this.normalizeChannel(channel);
28072
+ const timeoutOpts = options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {};
28073
+ let xml = await this.sendPcapDerivedSettingsGetXml({
28074
+ cmdId: BC_CMD_ID_GET_OSD_DATETIME,
28075
+ channel: ch,
28076
+ ...timeoutOpts
28077
+ });
28078
+ const patchBlock = (block, fields) => {
28079
+ const start = xml.indexOf(`<${block}`);
28080
+ if (start < 0) return;
28081
+ const end = xml.indexOf(`</${block}>`, start);
28082
+ if (end < 0) return;
28083
+ let body = xml.slice(start, end);
28084
+ for (const [tag, value] of Object.entries(fields)) {
28085
+ if (value === void 0) continue;
28086
+ const raw = typeof value === "boolean" ? value ? "1" : "0" : String(value);
28087
+ const escaped = raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
28088
+ if (body.includes(`<${tag}>`)) {
28089
+ body = body.replace(
28090
+ new RegExp(`<${tag}>[^<]*<\\/${tag}>`),
28091
+ `<${tag}>${escaped}</${tag}>`
28092
+ );
28093
+ } else {
28094
+ body += `<${tag}>${escaped}</${tag}>`;
28095
+ }
28096
+ }
28097
+ xml = xml.slice(0, start) + body + xml.slice(end);
28098
+ };
28099
+ if (patch.datetime) {
28100
+ patchBlock("OsdDatetime", {
28101
+ enable: patch.datetime.enable,
28102
+ topLeftX: patch.datetime.topLeftX,
28103
+ topLeftY: patch.datetime.topLeftY,
28104
+ language: patch.datetime.language
28105
+ });
28106
+ }
28107
+ if (patch.channelName) {
28108
+ patchBlock("OsdChannelName", {
28109
+ name: patch.channelName.name,
28110
+ enable: patch.channelName.enable,
28111
+ topLeftX: patch.channelName.topLeftX,
28112
+ topLeftY: patch.channelName.topLeftY,
28113
+ enWatermark: patch.channelName.enWatermark,
28114
+ enBgcolor: patch.channelName.enBgcolor
28115
+ });
28116
+ }
28117
+ await this.sendXml({
28118
+ cmdId: BC_CMD_ID_SET_OSD_DATETIME,
28119
+ channel: ch,
28120
+ payloadXml: ensureXmlHeader(xml),
28121
+ ...timeoutOpts
28122
+ });
28123
+ }
27329
28124
  async getOsdDatetime(channel, options) {
27330
28125
  const rawXml = await this.sendPcapDerivedSettingsGetXml({
27331
28126
  cmdId: BC_CMD_ID_GET_OSD_DATETIME,
@@ -27518,6 +28313,41 @@ ${xml}`
27518
28313
  });
27519
28314
  return { streams };
27520
28315
  }
28316
+ /**
28317
+ * Return the set of values `setEnc` will accept on each stream of `channel`.
28318
+ * Aggregates `getStreamInfoList` (cmd_146) into a UI-friendly shape:
28319
+ * per-stream resolutions with their allowed codecs/framerates/bitrates plus
28320
+ * the enumerated encoder modes/profiles Reolink exposes.
28321
+ *
28322
+ * Useful for populating selectors and validating user input before calling
28323
+ * `setEnc` — picking an unsupported combination causes the camera to reject
28324
+ * the SET_ENC command (responseCode != 200).
28325
+ */
28326
+ async getEncOptions(channel, options) {
28327
+ const list = await this.getStreamInfoList(channel, options);
28328
+ return buildEncOptions(list, channel);
28329
+ }
28330
+ /**
28331
+ * Read the camera's `<VersionInfo>` block (cmd_id=80). Returns the
28332
+ * friendly name, model code (e.g. `"E1 Zoom"`), serial number, firmware
28333
+ * version, hardware revision, build day, AI model bundle version, etc.
28334
+ *
28335
+ * This is the same info the Reolink mobile app shows in "About this
28336
+ * device" — distinct from `getSystemGeneral` (cmd_104) which carries
28337
+ * time/locale.
28338
+ *
28339
+ * No channel parameter: this command is device-global on NVRs/Hubs and
28340
+ * camera-global on standalone cameras. Pass an explicit channel via the
28341
+ * underlying `sendXml` only if a specific firmware demands it (none we've
28342
+ * tested do).
28343
+ */
28344
+ async getVersionInfo(options) {
28345
+ const xml = await this.sendXml({
28346
+ cmdId: BC_CMD_ID_GET_VERSION_INFO,
28347
+ ...options?.timeoutMs != null ? { timeoutMs: options.timeoutMs } : {}
28348
+ });
28349
+ return parseVersionInfo(xml);
28350
+ }
27521
28351
  async getLedState(channel, options) {
27522
28352
  const rawXml = await this.sendPcapDerivedSettingsGetXml({
27523
28353
  cmdId: BC_CMD_ID_GET_LED_STATE,
@@ -34596,6 +35426,17 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
34596
35426
  // Native stream
34597
35427
  nativeFanout = null;
34598
35428
  nativeStreamActive = false;
35429
+ // Set only by stopNativeStream() (explicit teardown) so the fanout's onEnd
35430
+ // callback can short-circuit cleanup/restart logic. NOT set by the inactivity-
35431
+ // timeout force-restart path — that flow wants onEnd to run and decide
35432
+ // whether to restart based on prestartStream / connected clients.
35433
+ nativeStreamStopping = false;
35434
+ // Pending retry timer for the unbounded auto-restart loop. When a stream
35435
+ // start fails transiently (camera in maintenance reboot, idle-disconnect
35436
+ // race, etc.) we keep trying with exponential backoff until either the
35437
+ // server is stopped or a frame finally arrives.
35438
+ nativeStreamRetryTimer;
35439
+ nativeStreamRetryDelayMs = 0;
34599
35440
  dedicatedSessionRelease;
34600
35441
  detectedVideoType;
34601
35442
  // Client tracking
@@ -34664,6 +35505,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
34664
35505
  if (!this.active) return;
34665
35506
  this.active = false;
34666
35507
  clearTimeout(this.stopGraceTimer);
35508
+ this.clearNativeStreamRetry();
34667
35509
  this.stopStreamHealthMonitor();
34668
35510
  for (const [id, sock] of this.clientSockets) {
34669
35511
  sock.destroy();
@@ -34992,6 +35834,46 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
34992
35834
  // -----------------------------------------------------------------------
34993
35835
  // Native stream management
34994
35836
  // -----------------------------------------------------------------------
35837
+ /**
35838
+ * Schedule another startNativeStream() attempt after the given delay.
35839
+ * Idempotent: a no-op if a retry is already scheduled, the server is no
35840
+ * longer active, or an explicit stop is in progress. Implements unbounded
35841
+ * exponential backoff (5s → 60s) so a camera that stays unreachable for
35842
+ * minutes (e.g. nightly maintenance reboot) eventually recovers without
35843
+ * manual intervention — see issue #16.
35844
+ */
35845
+ scheduleNativeStreamRetry(reason) {
35846
+ if (!this.active) return;
35847
+ if (this.nativeStreamStopping) return;
35848
+ if (this.nativeStreamRetryTimer) return;
35849
+ const delay = this.nativeStreamRetryDelayMs > 0 ? this.nativeStreamRetryDelayMs : 5e3;
35850
+ this.logger.info?.(
35851
+ `[Go2rtcTcpServer] scheduling native stream retry in ${(delay / 1e3).toFixed(0)}s (reason=${reason})`
35852
+ );
35853
+ this.nativeStreamRetryTimer = setTimeout(() => {
35854
+ this.nativeStreamRetryTimer = void 0;
35855
+ if (!this.active) return;
35856
+ if (this.nativeStreamStopping) return;
35857
+ this.startNativeStream().catch((err) => {
35858
+ this.logger.warn?.(
35859
+ `[Go2rtcTcpServer] retry of startNativeStream threw: ${err instanceof Error ? err.message : err}`
35860
+ );
35861
+ });
35862
+ }, delay);
35863
+ this.nativeStreamRetryDelayMs = Math.min(delay * 2, 6e4);
35864
+ }
35865
+ /**
35866
+ * Cancel any pending retry timer and reset the backoff. Called on explicit
35867
+ * stop and on first-frame-received so the next failure starts the backoff
35868
+ * window from scratch.
35869
+ */
35870
+ clearNativeStreamRetry() {
35871
+ if (this.nativeStreamRetryTimer) {
35872
+ clearTimeout(this.nativeStreamRetryTimer);
35873
+ this.nativeStreamRetryTimer = void 0;
35874
+ }
35875
+ this.nativeStreamRetryDelayMs = 0;
35876
+ }
34995
35877
  async startNativeStream() {
34996
35878
  if (this.nativeStreamActive) return;
34997
35879
  if (!this.api.isReady) {
@@ -35008,8 +35890,9 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
35008
35890
  await this.api.ensureConnected();
35009
35891
  } catch (e) {
35010
35892
  this.logger.warn?.(
35011
- `[Go2rtcTcpServer] ensureConnected failed, aborting stream start: ${e}`
35893
+ `[Go2rtcTcpServer] ensureConnected failed: ${e}`
35012
35894
  );
35895
+ this.scheduleNativeStreamRetry("ensureConnected failed");
35013
35896
  return;
35014
35897
  }
35015
35898
  }
@@ -35039,6 +35922,9 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
35039
35922
  ...dedicatedClient ? { client: dedicatedClient } : {}
35040
35923
  }),
35041
35924
  onFrame: (frame) => {
35925
+ if (!hadFrames) {
35926
+ this.clearNativeStreamRetry();
35927
+ }
35042
35928
  hadFrames = true;
35043
35929
  this.lastFrameAt = Date.now();
35044
35930
  this.totalFramesReceived++;
@@ -35082,7 +35968,7 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
35082
35968
  this.logger.warn?.(`[Go2rtcTcpServer] native stream error: ${error}`);
35083
35969
  },
35084
35970
  onEnd: () => {
35085
- if (!this.nativeStreamActive) return;
35971
+ if (this.nativeStreamStopping) return;
35086
35972
  this.nativeStreamActive = false;
35087
35973
  this.nativeFanout = null;
35088
35974
  this.stopStreamHealthMonitor();
@@ -35124,18 +36010,24 @@ var Go2rtcTcpServer = class _Go2rtcTcpServer extends import_node_events6.EventEm
35124
36010
  this.startStreamHealthMonitor();
35125
36011
  }
35126
36012
  async stopNativeStream() {
36013
+ this.nativeStreamStopping = true;
35127
36014
  this.nativeStreamActive = false;
36015
+ this.clearNativeStreamRetry();
35128
36016
  this.stopStreamHealthMonitor();
35129
36017
  const fanout = this.nativeFanout;
35130
36018
  this.nativeFanout = null;
35131
- if (fanout) {
35132
- await fanout.stop();
35133
- }
35134
- this.prebuffer = [];
35135
- if (this.dedicatedSessionRelease) {
35136
- await this.dedicatedSessionRelease().catch(() => {
35137
- });
35138
- this.dedicatedSessionRelease = void 0;
36019
+ try {
36020
+ if (fanout) {
36021
+ await fanout.stop();
36022
+ }
36023
+ this.prebuffer = [];
36024
+ if (this.dedicatedSessionRelease) {
36025
+ await this.dedicatedSessionRelease().catch(() => {
36026
+ });
36027
+ this.dedicatedSessionRelease = void 0;
36028
+ }
36029
+ } finally {
36030
+ this.nativeStreamStopping = false;
35139
36031
  }
35140
36032
  }
35141
36033
  // -----------------------------------------------------------------------
@@ -35325,10 +36217,15 @@ var BaichuanHttpStreamServer = class extends import_node_events7.EventEmitter {
35325
36217
  // Force a known frame rate on raw H.264 input so the muxer gets valid PTS/DTS.
35326
36218
  "-r",
35327
36219
  String(this.inputFps),
36220
+ // `+genpts` generates uniform PTS from `-r` for raw Annex-B input. We
36221
+ // deliberately do NOT pass `-use_wallclock_as_timestamps 1`: that
36222
+ // overrides the generated PTS with the host wallclock at frame ARRIVAL
36223
+ // time, and the network stream is bursty so the resulting PTS is
36224
+ // uneven. With `-r` forcing a target rate downstream, ffmpeg then
36225
+ // drops/duplicates frames to match — visible as the periodic stutter
36226
+ // reported on local-restreamer recordings (issue #11).
35328
36227
  "-fflags",
35329
36228
  "+genpts",
35330
- "-use_wallclock_as_timestamps",
35331
- "1",
35332
36229
  "-f",
35333
36230
  "h264",
35334
36231
  // Input format (H.264 Annex-B)
@@ -36007,8 +36904,195 @@ var BaichuanMjpegServer = class extends import_node_events9.EventEmitter {
36007
36904
  };
36008
36905
 
36009
36906
  // src/baichuan/stream/BaichuanWebRTCServer.ts
36010
- var import_node_events10 = require("events");
36907
+ var import_node_events11 = require("events");
36011
36908
  init_BcMediaAnnexBDecoder();
36909
+
36910
+ // src/baichuan/stream/AacToOpusTranscoder.ts
36911
+ var import_node_child_process11 = require("child_process");
36912
+ var import_node_dgram3 = require("dgram");
36913
+ var import_node_events10 = require("events");
36914
+ var AacToOpusTranscoder = class extends import_node_events10.EventEmitter {
36915
+ opts;
36916
+ socket = null;
36917
+ ffmpeg = null;
36918
+ port = 0;
36919
+ starting = null;
36920
+ stopped = false;
36921
+ constructor(options = {}) {
36922
+ super();
36923
+ this.opts = {
36924
+ ffmpegPath: options.ffmpegPath ?? "ffmpeg",
36925
+ opusSampleRate: options.opusSampleRate ?? 48e3,
36926
+ opusChannels: options.opusChannels ?? 2,
36927
+ opusFrameMs: options.opusFrameMs ?? 20,
36928
+ opusBitrate: options.opusBitrate ?? 64e3,
36929
+ ...options.logger !== void 0 ? { logger: options.logger } : {}
36930
+ };
36931
+ }
36932
+ log(level, message) {
36933
+ this.opts.logger?.(level, `[AacToOpusTranscoder] ${message}`);
36934
+ }
36935
+ /**
36936
+ * Allocate the UDP loopback socket and spawn ffmpeg. Must be awaited before
36937
+ * the first call to `feedAac`.
36938
+ */
36939
+ async start() {
36940
+ if (this.starting) return this.starting;
36941
+ this.starting = this._start();
36942
+ return this.starting;
36943
+ }
36944
+ async _start() {
36945
+ if (this.stopped) throw new Error("transcoder stopped");
36946
+ this.socket = (0, import_node_dgram3.createSocket)("udp4");
36947
+ await new Promise((resolve, reject) => {
36948
+ this.socket.once("error", reject);
36949
+ this.socket.bind({ address: "127.0.0.1", port: 0 }, () => {
36950
+ this.socket.removeListener("error", reject);
36951
+ this.port = this.socket.address().port;
36952
+ resolve();
36953
+ });
36954
+ });
36955
+ this.log("info", `UDP loopback bound on 127.0.0.1:${this.port}`);
36956
+ this.socket.on("message", (rtpPacket) => this.handleRtp(rtpPacket));
36957
+ this.socket.on("error", (err) => {
36958
+ this.log("error", `socket error: ${err.message}`);
36959
+ this.emit("error", err);
36960
+ });
36961
+ const args = [
36962
+ "-loglevel",
36963
+ "warning",
36964
+ "-fflags",
36965
+ "nobuffer",
36966
+ "-f",
36967
+ "aac",
36968
+ "-i",
36969
+ "pipe:0",
36970
+ "-c:a",
36971
+ "libopus",
36972
+ "-ar",
36973
+ String(this.opts.opusSampleRate),
36974
+ "-ac",
36975
+ String(this.opts.opusChannels),
36976
+ "-application",
36977
+ "audio",
36978
+ "-frame_duration",
36979
+ String(this.opts.opusFrameMs),
36980
+ "-b:a",
36981
+ String(this.opts.opusBitrate),
36982
+ // Important: disable VBR so the output rate matches the configured
36983
+ // bitrate consistently — easier on the browser jitter buffer.
36984
+ "-vbr",
36985
+ "off",
36986
+ "-f",
36987
+ "rtp",
36988
+ `rtp://127.0.0.1:${this.port}`
36989
+ ];
36990
+ this.log("info", `spawning ffmpeg with: ${this.opts.ffmpegPath} ${args.join(" ")}`);
36991
+ this.ffmpeg = (0, import_node_child_process11.spawn)(this.opts.ffmpegPath, args, {
36992
+ stdio: ["pipe", "ignore", "pipe"]
36993
+ });
36994
+ this.ffmpeg.on("error", (err) => {
36995
+ this.log("error", `ffmpeg spawn error: ${err.message}`);
36996
+ this.emit("error", err);
36997
+ });
36998
+ this.ffmpeg.stderr?.on("data", (chunk) => {
36999
+ this.log("debug", `ffmpeg stderr: ${chunk.toString().trim()}`);
37000
+ });
37001
+ this.ffmpeg.on("exit", (code) => {
37002
+ this.log(code === 0 ? "info" : "warn", `ffmpeg exited code=${code}`);
37003
+ this.emit("exit", code);
37004
+ });
37005
+ }
37006
+ /**
37007
+ * Feed one or more concatenated ADTS AAC frames to ffmpeg. Returns the
37008
+ * number of bytes written. Safe to call before a frame is fully buffered.
37009
+ */
37010
+ feedAac(buf) {
37011
+ if (this.stopped) return false;
37012
+ if (!this.ffmpeg?.stdin || !this.ffmpeg.stdin.writable) {
37013
+ this.log("debug", "drop AAC frame \u2014 ffmpeg stdin not writable");
37014
+ return false;
37015
+ }
37016
+ return this.ffmpeg.stdin.write(buf);
37017
+ }
37018
+ /**
37019
+ * Close stdin (ffmpeg exits cleanly on EOF) and tear down the UDP socket.
37020
+ */
37021
+ async stop() {
37022
+ if (this.stopped) return;
37023
+ this.stopped = true;
37024
+ try {
37025
+ this.ffmpeg?.stdin?.end();
37026
+ } catch {
37027
+ }
37028
+ if (this.ffmpeg && this.ffmpeg.exitCode === null) {
37029
+ await new Promise((resolve) => {
37030
+ const t = setTimeout(() => {
37031
+ this.ffmpeg?.kill("SIGKILL");
37032
+ resolve();
37033
+ }, 500);
37034
+ this.ffmpeg?.once("exit", () => {
37035
+ clearTimeout(t);
37036
+ resolve();
37037
+ });
37038
+ });
37039
+ }
37040
+ this.ffmpeg = null;
37041
+ if (this.socket) {
37042
+ try {
37043
+ this.socket.close();
37044
+ } catch {
37045
+ }
37046
+ this.socket = null;
37047
+ }
37048
+ this.log("info", "stopped");
37049
+ }
37050
+ /**
37051
+ * Parse an RTP packet ffmpeg emitted on the loopback socket and surface
37052
+ * its Opus payload. RTP header layout (RFC 3550):
37053
+ *
37054
+ * 0 1 2 3
37055
+ * 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
37056
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
37057
+ * |V=2|P|X| CC |M| PT | sequence number |
37058
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
37059
+ * | timestamp |
37060
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
37061
+ * | synchronization source (SSRC) identifier |
37062
+ * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
37063
+ *
37064
+ * We honor the CC, X (extension) and P (padding) bits to compute the
37065
+ * payload offset / length correctly even if ffmpeg ever emits those.
37066
+ */
37067
+ handleRtp(packet) {
37068
+ if (packet.length < 12) return;
37069
+ const b0 = packet[0];
37070
+ const b1 = packet[1];
37071
+ const version = b0 >> 6;
37072
+ if (version !== 2) return;
37073
+ const padding = (b0 & 32) !== 0;
37074
+ const extension = (b0 & 16) !== 0;
37075
+ const csrcCount = b0 & 15;
37076
+ const marker = (b1 & 128) !== 0;
37077
+ let offset = 12 + csrcCount * 4;
37078
+ if (extension) {
37079
+ if (packet.length < offset + 4) return;
37080
+ const extLen = packet.readUInt16BE(offset + 2);
37081
+ offset += 4 + extLen * 4;
37082
+ }
37083
+ let end = packet.length;
37084
+ if (padding) {
37085
+ const pad = packet[packet.length - 1];
37086
+ end -= pad;
37087
+ }
37088
+ if (offset >= end) return;
37089
+ const timestamp = packet.readUInt32BE(4);
37090
+ const payload = Buffer.from(packet.subarray(offset, end));
37091
+ this.emit("packet", { payload, timestamp, marker });
37092
+ }
37093
+ };
37094
+
37095
+ // src/baichuan/stream/BaichuanWebRTCServer.ts
36012
37096
  init_H264Converter();
36013
37097
  function parseAnnexBNalUnits(annexB) {
36014
37098
  const nalUnits = [];
@@ -36044,7 +37128,7 @@ function getH264NalType(nalUnit) {
36044
37128
  function getH265NalType2(nalUnit) {
36045
37129
  return nalUnit[0] >> 1 & 63;
36046
37130
  }
36047
- var BaichuanWebRTCServer = class extends import_node_events10.EventEmitter {
37131
+ var BaichuanWebRTCServer = class extends import_node_events11.EventEmitter {
36048
37132
  options;
36049
37133
  sessions = /* @__PURE__ */ new Map();
36050
37134
  sessionIdCounter = 0;
@@ -36282,6 +37366,14 @@ Error: ${err}`
36282
37366
  }
36283
37367
  session.dataChannel = null;
36284
37368
  }
37369
+ if (session.audioTranscoder) {
37370
+ try {
37371
+ await session.audioTranscoder.stop();
37372
+ } catch (err) {
37373
+ this.log("debug", `Error stopping audio transcoder: ${err}`);
37374
+ }
37375
+ session.audioTranscoder = null;
37376
+ }
36285
37377
  if (session.cleanup) {
36286
37378
  session.cleanup();
36287
37379
  }
@@ -36408,6 +37500,17 @@ Error: ${err}`
36408
37500
  }
36409
37501
  if (frame.audio) {
36410
37502
  session.stats.audioFrames++;
37503
+ if (session.stats.audioFrames === 1) {
37504
+ const head = frame.data && frame.data.length > 0 ? frame.data.subarray(0, Math.min(8, frame.data.length)).toString("hex") : "(empty)";
37505
+ this.log(
37506
+ "info",
37507
+ `First audio frame for ${session.id}: codec=${frame.codec ?? "?"} bytes=${frame.data?.length ?? 0} head=${head}`
37508
+ );
37509
+ }
37510
+ if (this.options.ffmpegPath !== "" && frame.data && frame.data.length > 0) {
37511
+ await this.ensureAudioTranscoder(session, werift);
37512
+ session.audioTranscoder?.feedAac(frame.data);
37513
+ }
36411
37514
  } else {
36412
37515
  if (frame.data) {
36413
37516
  if (!session.videoCodec && frame.videoType) {
@@ -36478,7 +37581,7 @@ Error: ${err}`
36478
37581
  if (now - lastLogTime >= 5e3) {
36479
37582
  this.log(
36480
37583
  "debug",
36481
- `WebRTC session ${session.id} [${session.videoCodec}]: sent ${session.stats.videoFrames} frames, ${packetsSentSinceLastLog} packets, ${Math.round(session.stats.bytesSent / 1024)} KB`
37584
+ `WebRTC session ${session.id} [${session.videoCodec}]: sent ${session.stats.videoFrames} video frames, ${packetsSentSinceLastLog} packets, ${Math.round(session.stats.bytesSent / 1024)} KB | audio frames=${session.stats.audioFrames}`
36482
37585
  );
36483
37586
  lastLogTime = now;
36484
37587
  packetsSentSinceLastLog = 0;
@@ -36494,6 +37597,77 @@ Error: ${err}`
36494
37597
  }
36495
37598
  this.log("info", `Native stream ended for session ${session.id}`);
36496
37599
  }
37600
+ /**
37601
+ * Lazily start the AAC → Opus transcoder for `session` and wire it to the
37602
+ * audio RTP track. ffmpeg writes RTP-formatted Opus packets back to a UDP
37603
+ * loopback the transcoder owns; we strip the RTP header and rewrap the
37604
+ * Opus payload with our audioTrack's SSRC so the browser receives a
37605
+ * coherent stream.
37606
+ */
37607
+ async ensureAudioTranscoder(session, werift) {
37608
+ if (session.audioTranscoder !== void 0) return;
37609
+ const { RtpPacket, RtpHeader } = werift;
37610
+ const ssrc = session.audioTrack?.ssrc ?? Math.floor(Math.random() * 4294967295);
37611
+ const transcoder = new AacToOpusTranscoder({
37612
+ ...this.options.ffmpegPath ? { ffmpegPath: this.options.ffmpegPath } : {},
37613
+ logger: (level, msg) => this.log(level, msg)
37614
+ });
37615
+ session.audioRtpSequence = Math.floor(Math.random() * 65535);
37616
+ session.audioRtpTimestampBase = 0;
37617
+ transcoder.on("packet", ({ payload, timestamp, marker }) => {
37618
+ try {
37619
+ if (session.audioRtpTimestampBase === void 0 || session.audioRtpTimestampBase === 0) {
37620
+ session.audioRtpTimestampBase = timestamp;
37621
+ }
37622
+ const localTs = timestamp - session.audioRtpTimestampBase >>> 0;
37623
+ const seq = session.audioRtpSequence;
37624
+ session.audioRtpSequence = seq + 1 & 65535;
37625
+ const header = new RtpHeader({
37626
+ version: 2,
37627
+ padding: false,
37628
+ extension: false,
37629
+ marker,
37630
+ // Werift assigns 111 to Opus by default in the receiver SDP, but it
37631
+ // also accepts other PTs. Use 111 for compatibility with the offer
37632
+ // we generated earlier.
37633
+ payloadType: 111,
37634
+ sequenceNumber: seq,
37635
+ timestamp: localTs,
37636
+ ssrc
37637
+ });
37638
+ const rtp = new RtpPacket(header, payload);
37639
+ session.audioTrack?.writeRtp(rtp);
37640
+ if (!session.audioStartedLogged) {
37641
+ session.audioStartedLogged = true;
37642
+ this.log(
37643
+ "info",
37644
+ `Audio RTP started for ${session.id} (PT=111, ssrc=${ssrc})`
37645
+ );
37646
+ }
37647
+ } catch (err) {
37648
+ this.log(
37649
+ "warn",
37650
+ `audio writeRtp failed for ${session.id}: ${err.message}`
37651
+ );
37652
+ }
37653
+ });
37654
+ transcoder.on("error", (err) => {
37655
+ this.log("error", `audio transcoder error for ${session.id}: ${err.message}`);
37656
+ });
37657
+ transcoder.on("exit", (code) => {
37658
+ this.log("info", `audio transcoder exited (${code}) for ${session.id}`);
37659
+ });
37660
+ try {
37661
+ await transcoder.start();
37662
+ session.audioTranscoder = transcoder;
37663
+ } catch (err) {
37664
+ this.log(
37665
+ "error",
37666
+ `failed to start audio transcoder for ${session.id}: ${err.message}`
37667
+ );
37668
+ session.audioTranscoder = null;
37669
+ }
37670
+ }
36497
37671
  /**
36498
37672
  * Send H.264 frame via RTP media track
36499
37673
  * Returns the number of RTP packets sent
@@ -36781,21 +37955,21 @@ Error: ${err}`
36781
37955
  `Sending ${codec} frame ${frameNumber}: ${packet.length} bytes, keyframe=${isKeyframe}`
36782
37956
  );
36783
37957
  }
36784
- const MAX_CHUNK_SIZE = 16e3;
37958
+ const CHUNK_HEADER_LEN = 4;
37959
+ const MAX_PAYLOAD_PER_CHUNK = 16e3 - CHUNK_HEADER_LEN;
36785
37960
  try {
36786
- if (packet.length <= MAX_CHUNK_SIZE) {
36787
- session.videoDataChannel.send(packet);
36788
- } else {
36789
- const totalChunks = Math.ceil(packet.length / MAX_CHUNK_SIZE);
36790
- for (let i = 0; i < totalChunks; i++) {
36791
- const start = i * MAX_CHUNK_SIZE;
36792
- const end = Math.min(start + MAX_CHUNK_SIZE, packet.length);
36793
- const chunk = packet.subarray(start, end);
36794
- const chunkHeader = Buffer.alloc(2);
36795
- chunkHeader.writeUInt8(i, 0);
36796
- chunkHeader.writeUInt8(totalChunks, 1);
36797
- session.videoDataChannel.send(Buffer.concat([chunkHeader, chunk]));
36798
- }
37961
+ const totalChunks = Math.max(
37962
+ 1,
37963
+ Math.ceil(packet.length / MAX_PAYLOAD_PER_CHUNK)
37964
+ );
37965
+ for (let i = 0; i < totalChunks; i++) {
37966
+ const start = i * MAX_PAYLOAD_PER_CHUNK;
37967
+ const end = Math.min(start + MAX_PAYLOAD_PER_CHUNK, packet.length);
37968
+ const chunk = packet.subarray(start, end);
37969
+ const chunkHeader = Buffer.alloc(CHUNK_HEADER_LEN);
37970
+ chunkHeader.writeUInt16BE(i, 0);
37971
+ chunkHeader.writeUInt16BE(totalChunks, 2);
37972
+ session.videoDataChannel.send(Buffer.concat([chunkHeader, chunk]));
36799
37973
  }
36800
37974
  return true;
36801
37975
  } catch (err) {
@@ -36946,12 +38120,12 @@ Error: ${err}`
36946
38120
  };
36947
38121
 
36948
38122
  // src/baichuan/stream/BaichuanHlsServer.ts
36949
- var import_node_events11 = require("events");
38123
+ var import_node_events12 = require("events");
36950
38124
  var import_node_fs = __toESM(require("fs"), 1);
36951
38125
  var import_promises3 = __toESM(require("fs/promises"), 1);
36952
38126
  var import_node_os3 = __toESM(require("os"), 1);
36953
38127
  var import_node_path3 = __toESM(require("path"), 1);
36954
- var import_node_child_process11 = require("child_process");
38128
+ var import_node_child_process12 = require("child_process");
36955
38129
  init_BcMediaAnnexBDecoder();
36956
38130
  init_H264Converter();
36957
38131
  init_H265Converter();
@@ -37026,7 +38200,7 @@ function getNalTypes(codec, annexB) {
37026
38200
  }
37027
38201
  });
37028
38202
  }
37029
- var BaichuanHlsServer = class extends import_node_events11.EventEmitter {
38203
+ var BaichuanHlsServer = class extends import_node_events12.EventEmitter {
37030
38204
  api;
37031
38205
  channel;
37032
38206
  profile;
@@ -37383,10 +38557,17 @@ var BaichuanHlsServer = class extends import_node_events11.EventEmitter {
37383
38557
  "-hide_banner",
37384
38558
  "-loglevel",
37385
38559
  "warning",
38560
+ // `+genpts` makes ffmpeg generate uniform PTS from the declared `-r`
38561
+ // when the raw H.264/H.265 input has none. We deliberately do NOT use
38562
+ // `-use_wallclock_as_timestamps 1` here: it replaces the generated
38563
+ // PTS with the host wallclock at FRAME ARRIVAL time, and because the
38564
+ // camera ships frames in bursty network reads, the resulting PTS
38565
+ // sequence is uneven. With `-r 25` (or anything else) forcing a
38566
+ // target rate downstream, ffmpeg then drops/duplicates frames to
38567
+ // match — visible as the periodic stutter / pulsing reported on
38568
+ // local-restreamer recordings (issue #11).
37386
38569
  "-fflags",
37387
38570
  "+genpts",
37388
- "-use_wallclock_as_timestamps",
37389
- "1",
37390
38571
  "-r",
37391
38572
  "25",
37392
38573
  "-f",
@@ -37421,7 +38602,7 @@ var BaichuanHlsServer = class extends import_node_events11.EventEmitter {
37421
38602
  this.segmentPattern,
37422
38603
  this.playlistPath
37423
38604
  );
37424
- const p = (0, import_node_child_process11.spawn)(this.ffmpegPath, args, {
38605
+ const p = (0, import_node_child_process12.spawn)(this.ffmpegPath, args, {
37425
38606
  stdio: ["pipe", "ignore", "pipe"]
37426
38607
  });
37427
38608
  p.on("error", (err) => {
@@ -38043,10 +39224,10 @@ async function autoDetectDeviceType(inputs) {
38043
39224
  }
38044
39225
 
38045
39226
  // src/multifocal/compositeRtspServer.ts
38046
- var import_node_events12 = require("events");
38047
- var import_node_child_process12 = require("child_process");
39227
+ var import_node_events13 = require("events");
39228
+ var import_node_child_process13 = require("child_process");
38048
39229
  var net5 = __toESM(require("net"), 1);
38049
- var CompositeRtspServer = class extends import_node_events12.EventEmitter {
39230
+ var CompositeRtspServer = class extends import_node_events13.EventEmitter {
38050
39231
  options;
38051
39232
  compositeStream = null;
38052
39233
  rtspServer = null;
@@ -38151,7 +39332,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38151
39332
  this.logger.log?.(
38152
39333
  `[CompositeRtspServer] Starting ffmpeg RTSP server: ${ffmpegArgs.join(" ")}`
38153
39334
  );
38154
- this.ffmpegProcess = (0, import_node_child_process12.spawn)("ffmpeg", ffmpegArgs, {
39335
+ this.ffmpegProcess = (0, import_node_child_process13.spawn)("ffmpeg", ffmpegArgs, {
38155
39336
  stdio: ["pipe", "pipe", "pipe"]
38156
39337
  });
38157
39338
  this.ffmpegProcess.on("error", (error) => {
@@ -38259,6 +39440,86 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38259
39440
  return this.connectedClients.size;
38260
39441
  }
38261
39442
  };
39443
+
39444
+ // src/reolink/baichuan/utils/motionZone.ts
39445
+ function decodeMotionScopeBitmap(valueTable, columns, rows, width = columns, height = rows) {
39446
+ const trimmed = valueTable.trim().replace(/[^A-Za-z0-9+/=]/g, "");
39447
+ const bytes = base64DecodeToBytes(trimmed);
39448
+ const totalBits = columns * rows;
39449
+ if (bytes.length * 8 < totalBits) {
39450
+ throw new Error(
39451
+ `valueTable too short: have ${bytes.length * 8} bits, need ${totalBits}`
39452
+ );
39453
+ }
39454
+ const w = Math.min(width, columns);
39455
+ const h = Math.min(height, rows);
39456
+ const cells = new Array(w * h);
39457
+ for (let r = 0; r < h; r++) {
39458
+ for (let c = 0; c < w; c++) {
39459
+ const bitIndex = r * columns + c;
39460
+ const byteIdx = bitIndex >> 3;
39461
+ const bitIdx = 7 - (bitIndex & 7);
39462
+ cells[r * w + c] = (bytes[byteIdx] ?? 0) >> bitIdx & 1 ? true : false;
39463
+ }
39464
+ }
39465
+ return { width: w, height: h, columns, rows, cells };
39466
+ }
39467
+ function encodeMotionScopeBitmap(scope) {
39468
+ const bytes = new Uint8Array(Math.ceil(scope.columns * scope.rows / 8));
39469
+ for (let r = 0; r < scope.height; r++) {
39470
+ for (let c = 0; c < scope.width; c++) {
39471
+ const on = scope.cells[r * scope.width + c];
39472
+ if (!on) continue;
39473
+ const bitIndex = r * scope.columns + c;
39474
+ const byteIdx = bitIndex >> 3;
39475
+ const bitIdx = 7 - (bitIndex & 7);
39476
+ bytes[byteIdx] = (bytes[byteIdx] ?? 0) | 1 << bitIdx;
39477
+ }
39478
+ }
39479
+ return base64EncodeBytes(bytes);
39480
+ }
39481
+ function fullCoverageScope(columns, rows, width = columns, height = rows) {
39482
+ return {
39483
+ width,
39484
+ height,
39485
+ columns,
39486
+ rows,
39487
+ cells: new Array(width * height).fill(true)
39488
+ };
39489
+ }
39490
+ var B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
39491
+ function base64EncodeBytes(bytes) {
39492
+ let out = "";
39493
+ for (let i = 0; i < bytes.length; i += 3) {
39494
+ const b0 = bytes[i] ?? 0;
39495
+ const b1 = bytes[i + 1] ?? 0;
39496
+ const b2 = bytes[i + 2] ?? 0;
39497
+ out += B64_ALPHABET[b0 >> 2];
39498
+ out += B64_ALPHABET[(b0 & 3) << 4 | b1 >> 4];
39499
+ out += i + 1 < bytes.length ? B64_ALPHABET[(b1 & 15) << 2 | b2 >> 6] : "=";
39500
+ out += i + 2 < bytes.length ? B64_ALPHABET[b2 & 63] : "=";
39501
+ }
39502
+ return out;
39503
+ }
39504
+ function base64DecodeToBytes(b64) {
39505
+ const padded = b64.padEnd(Math.ceil(b64.length / 4) * 4, "=");
39506
+ const stripped = padded.replace(/=+$/, "");
39507
+ const out = new Uint8Array(Math.floor(stripped.length * 6 / 8));
39508
+ let bits = 0;
39509
+ let value = 0;
39510
+ let outIdx = 0;
39511
+ for (const ch of stripped) {
39512
+ const idx = B64_ALPHABET.indexOf(ch);
39513
+ if (idx < 0) continue;
39514
+ value = value << 6 | idx;
39515
+ bits += 6;
39516
+ if (bits >= 8) {
39517
+ bits -= 8;
39518
+ out[outIdx++] = value >> bits & 255;
39519
+ }
39520
+ }
39521
+ return out.subarray(0, outIdx);
39522
+ }
38262
39523
  // Annotate the CommonJS export names for ESM import in node:
38263
39524
  0 && (module.exports = {
38264
39525
  AesStreamDecryptor,
@@ -38336,6 +39597,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38336
39597
  BC_CMD_ID_GET_SUPPORT,
38337
39598
  BC_CMD_ID_GET_SYSTEM_GENERAL,
38338
39599
  BC_CMD_ID_GET_TIMELAPSE_CFG,
39600
+ BC_CMD_ID_GET_VERSION_INFO,
38339
39601
  BC_CMD_ID_GET_VIDEO_INPUT,
38340
39602
  BC_CMD_ID_GET_WHITE_LED,
38341
39603
  BC_CMD_ID_GET_WIFI,
@@ -38366,6 +39628,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38366
39628
  BC_CMD_ID_SET_ENC,
38367
39629
  BC_CMD_ID_SET_LED_STATE,
38368
39630
  BC_CMD_ID_SET_MOTION_ALARM,
39631
+ BC_CMD_ID_SET_OSD_DATETIME,
38369
39632
  BC_CMD_ID_SET_PIR_INFO,
38370
39633
  BC_CMD_ID_SET_PRIVACY_MASK,
38371
39634
  BC_CMD_ID_SET_PUSH_TASK,
@@ -38474,6 +39737,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38474
39737
  decideSleepInferenceTransition,
38475
39738
  decideVideoclipTranscodeMode,
38476
39739
  decodeHeader,
39740
+ decodeMotionScopeBitmap,
38477
39741
  deriveAesKey,
38478
39742
  detectIosClient,
38479
39743
  detectVideoCodecFromNal,
@@ -38486,6 +39750,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38486
39750
  discoverViaUdpBroadcast,
38487
39751
  discoverViaUdpDirect,
38488
39752
  encodeHeader,
39753
+ encodeMotionScopeBitmap,
38489
39754
  ensureXmlHeader,
38490
39755
  extractH264ParamSetsFromAccessUnit,
38491
39756
  extractH265ParamSetsFromAccessUnit,
@@ -38494,6 +39759,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38494
39759
  extractVpsFromAnnexB,
38495
39760
  flattenAbilitiesForChannel,
38496
39761
  formatMjpegFrame,
39762
+ fullCoverageScope,
38497
39763
  getConstructedVideoStreamOptions,
38498
39764
  getGlobalLogger,
38499
39765
  getH265NalType,
@@ -38537,6 +39803,7 @@ var CompositeRtspServer = class extends import_node_events12.EventEmitter {
38537
39803
  splitAnnexBToNals,
38538
39804
  splitH265AnnexBToNalPayloads,
38539
39805
  testChannelStreams,
39806
+ upsertXmlTag,
38540
39807
  xmlEscape,
38541
39808
  xmlIndicatesFloodlight,
38542
39809
  zipDirectory