@apocaliss92/scrypted-reolink-native 0.1.42 → 0.2.0

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/src/common.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { BaichuanClientOptions, DeviceCapabilities, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, StreamProfile, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
1
+ import type { BaichuanClientOptions, DeviceCapabilities, NativeVideoStreamVariant, PtzCommand, PtzPreset, ReolinkBaichuanApi, ReolinkSimpleEvent, ReolinkSupportedStream, SleepStatus, StreamProfile, StreamSamplingSelection } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
2
  import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom, MediaObject, MediaStreamUrl, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, OnOff, PanTiltZoom, PanTiltZoomCommand, Reboot, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera, VideoClip, VideoClipOptions, VideoClips, VideoClipThumbnailOptions, VideoTextOverlay, VideoTextOverlays } from "@scrypted/sdk";
3
3
  import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
4
  import path from 'path';
@@ -19,12 +19,13 @@ import { ReolinkPtzPresets } from "./presets";
19
19
  import {
20
20
  createRfc4571CompositeMediaObjectFromStreamManager,
21
21
  createRfc4571MediaObjectFromStreamManager,
22
- expectedVideoTypeFromUrlMediaStreamOptions,
22
+ extractVariantFromStreamId,
23
23
  parseStreamProfileFromId,
24
24
  selectStreamOption,
25
- StreamManager
25
+ StreamManager,
26
+ StreamManagerOptions
26
27
  } from "./stream-utils";
27
- import { floodlightSuffix, getDeviceInterfaces, getVideoClipWebhookUrls, pirSuffix, recordingsToVideoClips, sanitizeFfmpegOutput, sirenSuffix, updateDeviceInfo, vodSearchResultsToVideoClips } from "./utils";
28
+ import { floodlightSuffix, getDeviceInterfaces, pirSuffix, recordingsToVideoClips, sanitizeFfmpegOutput, sirenSuffix, updateDeviceInfo } from "./utils";
28
29
 
29
30
  export type CameraType = 'battery' | 'regular' | 'multi-focal' | 'multi-focal-battery';
30
31
 
@@ -47,7 +48,7 @@ class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff {
47
48
  this.camera.getBaichuanLogger().log(`Siren toggle: turnOff ok (device=${this.nativeId})`);
48
49
  }
49
50
  catch (e) {
50
- this.camera.getBaichuanLogger().warn(`Siren toggle: turnOff failed (device=${this.nativeId})`, e);
51
+ this.camera.getBaichuanLogger().warn(`Siren toggle: turnOff failed (device=${this.nativeId})`, e?.message || String(e));
51
52
  throw e;
52
53
  }
53
54
  }
@@ -60,7 +61,7 @@ class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff {
60
61
  this.camera.getBaichuanLogger().log(`Siren toggle: turnOn ok (device=${this.nativeId})`);
61
62
  }
62
63
  catch (e) {
63
- this.camera.getBaichuanLogger().warn(`Siren toggle: turnOn failed (device=${this.nativeId})`, e);
64
+ this.camera.getBaichuanLogger().warn(`Siren toggle: turnOn failed (device=${this.nativeId})`, e?.message || String(e));
64
65
  throw e;
65
66
  }
66
67
  }
@@ -79,7 +80,7 @@ class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brigh
79
80
  this.camera.getBaichuanLogger().log(`Floodlight toggle: setBrightness ok (device=${this.nativeId} brightness=${brightness})`);
80
81
  }
81
82
  catch (e) {
82
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: setBrightness failed (device=${this.nativeId} brightness=${brightness})`, e);
83
+ this.camera.getBaichuanLogger().warn(`Floodlight toggle: setBrightness failed (device=${this.nativeId} brightness=${brightness})`, e?.message || String(e));
83
84
  throw e;
84
85
  }
85
86
  }
@@ -92,7 +93,7 @@ class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brigh
92
93
  this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOff ok (device=${this.nativeId})`);
93
94
  }
94
95
  catch (e) {
95
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOff failed (device=${this.nativeId})`, e);
96
+ this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOff failed (device=${this.nativeId})`, e?.message || String(e));
96
97
  throw e;
97
98
  }
98
99
  }
@@ -105,7 +106,7 @@ class ReolinkCameraFloodlight extends ScryptedDeviceBase implements OnOff, Brigh
105
106
  this.camera.getBaichuanLogger().log(`Floodlight toggle: turnOn ok (device=${this.nativeId})`);
106
107
  }
107
108
  catch (e) {
108
- this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOn failed (device=${this.nativeId})`, e);
109
+ this.camera.getBaichuanLogger().warn(`Floodlight toggle: turnOn failed (device=${this.nativeId})`, e?.message || String(e));
109
110
  throw e;
110
111
  }
111
112
  }
@@ -223,6 +224,12 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
223
224
  hide: true,
224
225
  defaultValue: 0,
225
226
  },
227
+ variantType: {
228
+ type: 'string',
229
+ hide: true,
230
+ defaultValue: 'default',
231
+ choices: ['default', 'autotrack', 'telephoto'] as NativeVideoStreamVariant[],
232
+ },
226
233
  capabilities: {
227
234
  json: true,
228
235
  hide: true,
@@ -231,65 +238,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
231
238
  json: true,
232
239
  hide: true,
233
240
  },
234
- // Multifocal composite stream PIP settings
235
- pipPosition: {
236
- title: 'PIP Position',
237
- description: 'Position of the tele lens overlay on the wider lens view',
238
- type: 'string',
239
- defaultValue: 'bottom-right',
240
- choices: [
241
- 'top-left',
242
- 'top-right',
243
- 'bottom-left',
244
- 'bottom-right',
245
- 'center',
246
- 'top-center',
247
- 'bottom-center',
248
- 'left-center',
249
- 'right-center',
250
- ],
251
- hide: true, // Only show for multifocal devices via getAdditionalSettings
252
- },
253
- pipSize: {
254
- title: 'PIP Size',
255
- description: 'Relative size of the PIP overlay (0.1 = 10%, 0.3 = 30%, etc.)',
256
- type: 'number',
257
- defaultValue: 0.25,
258
- hide: true,
259
- onPut: async () => {
260
- this.scheduleStreamManagerRestart('pipSize changed');
261
- },
262
- },
263
- pipMargin: {
264
- title: 'PIP Margin',
265
- description: 'Margin from edge in pixels',
266
- type: 'number',
267
- defaultValue: 10,
268
- hide: true,
269
- onPut: async () => {
270
- this.scheduleStreamManagerRestart('pipMargin changed');
271
- },
272
- },
273
- widerChannel: {
274
- title: 'Wider Channel',
275
- description: 'Channel number for wider lens (typically 0)',
276
- type: 'number',
277
- defaultValue: 0,
278
- hide: true,
279
- onPut: async () => {
280
- this.scheduleStreamManagerRestart('widerChannel changed');
281
- },
282
- },
283
- teleChannel: {
284
- title: 'Tele Channel',
285
- description: 'Channel number for tele lens (typically 1)',
286
- type: 'number',
287
- defaultValue: 1,
288
- hide: true,
289
- onPut: async () => {
290
- this.scheduleStreamManagerRestart('teleChannel changed');
291
- },
292
- },
293
241
  // Battery camera specific
294
242
  uid: {
295
243
  title: 'UID',
@@ -307,6 +255,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
307
255
  choices: ['local-direct', 'local-broadcast', 'remote', 'map', 'relay'],
308
256
  defaultValue: 'local-direct',
309
257
  hide: true,
258
+ subgroup: 'Advanced',
310
259
  onPut: async () => {
311
260
  await this.credentialsChanged();
312
261
  }
@@ -370,7 +319,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
370
319
  // Trigger reconnection
371
320
  await this.ensureClient();
372
321
  } catch (e) {
373
- logger.warn('Failed to reset client after debug logs change', e);
322
+ logger.warn('Failed to reset client after debug logs change', e?.message || String(e));
374
323
  }
375
324
  }, 2000);
376
325
  }
@@ -631,6 +580,48 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
631
580
  await this.runDiagnostics();
632
581
  },
633
582
  },
583
+ // Multifocal composite stream PIP settings
584
+ pipPosition: {
585
+ title: 'PIP Position',
586
+ description: 'Position of the tele lens overlay on the wider lens view',
587
+ type: 'string',
588
+ defaultValue: 'bottom-right',
589
+ group: 'Composite stream',
590
+ choices: [
591
+ 'top-left',
592
+ 'top-right',
593
+ 'bottom-left',
594
+ 'bottom-right',
595
+ 'center',
596
+ 'top-center',
597
+ 'bottom-center',
598
+ 'left-center',
599
+ 'right-center',
600
+ ],
601
+ hide: true, // Only show for multifocal devices via getAdditionalSettings
602
+ },
603
+ pipSize: {
604
+ title: 'PIP Size',
605
+ description: 'Relative size of the PIP overlay (0.1 = 10%, 0.3 = 30%, etc.)',
606
+ type: 'number',
607
+ defaultValue: 0.25,
608
+ group: 'Composite stream',
609
+ hide: true,
610
+ onPut: async () => {
611
+ this.scheduleStreamManagerRestart('pipSize changed');
612
+ },
613
+ },
614
+ pipMargin: {
615
+ title: 'PIP Margin',
616
+ description: 'Margin from edge in pixels',
617
+ type: 'number',
618
+ defaultValue: 10,
619
+ group: 'Composite stream',
620
+ hide: true,
621
+ onPut: async () => {
622
+ this.scheduleStreamManagerRestart('pipMargin changed');
623
+ },
624
+ },
634
625
  });
635
626
 
636
627
  ptzPresets = new ReolinkPtzPresets(this);
@@ -651,20 +642,19 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
651
642
 
652
643
  // Video stream properties
653
644
  protected cachedVideoStreamOptions?: UrlMediaStreamOptions[];
654
- protected fetchingStreams = false;
645
+ protected fetchingStreamsPromise: Promise<UrlMediaStreamOptions[]> | undefined;
655
646
  protected lastNetPortCacheAttempt: number = 0;
656
647
  protected netPortCacheBackoffMs: number = 5000; // 5 seconds backoff on failure
657
648
 
658
649
  // Client management (inherited from BaseBaichuanClass)
659
- protected readonly protocol: BaichuanTransport;
660
650
  private debugLogsResetTimeout: NodeJS.Timeout | undefined;
661
651
 
662
- // Abstract init method that subclasses must implement
663
652
  abstract init(): Promise<void>;
664
653
 
654
+ abstract reportDevices(): Promise<void>;
655
+
665
656
  motionTimeout?: NodeJS.Timeout;
666
657
  doorbellBinaryTimeout?: NodeJS.Timeout;
667
- initComplete?: boolean;
668
658
  resetBaichuanClient?(reason?: any): Promise<void>;
669
659
 
670
660
  protected nvrDevice?: ReolinkNativeNvrDevice;
@@ -672,6 +662,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
672
662
  thisDevice: Settings;
673
663
  isBattery: boolean;
674
664
  isMultiFocal: boolean;
665
+ isOnNvr: boolean;
666
+ protocol: BaichuanTransport;
675
667
  private streamManagerRestartTimeout: NodeJS.Timeout | undefined;
676
668
  private videoClipsAutoLoadInterval: NodeJS.Timeout | undefined;
677
669
  private videoClipsAutoLoadInProgress: boolean = false;
@@ -682,7 +674,9 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
682
674
  public plugin: ReolinkNativePlugin,
683
675
  public options: CommonCameraMixinOptions
684
676
  ) {
685
- super(nativeId);
677
+ const isBattery = options.type === 'battery' || options.type === 'multi-focal-battery';
678
+ const transport = isBattery || !!options.nvrDevice ? 'udp' : 'tcp';
679
+ super(nativeId, transport);
686
680
  this.plugin.mixinsMap.set(this.id, this);
687
681
 
688
682
  // Store NVR device reference if provided
@@ -690,9 +684,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
690
684
  this.multiFocalDevice = options.multiFocalDevice;
691
685
  this.thisDevice = sdk.systemManager.getDeviceById<Settings>(this.id);
692
686
 
693
- this.isBattery = options.type === 'battery' || options.type === 'multi-focal-battery';
687
+ this.isBattery = isBattery;
694
688
  this.isMultiFocal = options.type === 'multi-focal' || options.type === 'multi-focal-battery';
695
- this.protocol = this.isBattery ? 'udp' : 'tcp';
689
+ this.isOnNvr = !!this.nvrDevice || !!this.multiFocalDevice?.nvrDevice;
690
+ this.protocol = transport;
696
691
 
697
692
  setTimeout(async () => {
698
693
  await this.parentInit();
@@ -1003,7 +998,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1003
998
  return mo;
1004
999
  }
1005
1000
  } catch (e) {
1006
- logger.error(`getVideoClip: failed to get video clip ${videoId}`, e);
1001
+ logger.error(`getVideoClip: failed to get video clip ${videoId}`, e?.message || String(e));
1007
1002
  throw e;
1008
1003
  }
1009
1004
  }
@@ -1100,13 +1095,13 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1100
1095
  await fs.promises.writeFile(cachePath, buffer);
1101
1096
  logger.debug(`[Thumbnail] Cached: fileId=${thumbnailId}, size=${buffer.length} bytes`);
1102
1097
  } catch (e) {
1103
- logger.warn(`[Thumbnail] Failed to cache: fileId=${thumbnailId}`, e);
1098
+ logger.warn(`[Thumbnail] Failed to cache: fileId=${thumbnailId}`, e?.message || String(e));
1104
1099
  // Continue even if caching fails
1105
1100
  }
1106
1101
 
1107
1102
  return thumbnail;
1108
1103
  } catch (e) {
1109
- logger.error(`[Thumbnail] Error: fileId=${thumbnailId}`, e);
1104
+ logger.error(`[Thumbnail] Error: fileId=${thumbnailId}`, e?.message || String(e));
1110
1105
  throw e;
1111
1106
  }
1112
1107
  }
@@ -1136,7 +1131,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1136
1131
  logger.debug(`[getVideoClipRtmpUrl] NVR getVodUrl Download URL received: url="${url || 'none'}"`);
1137
1132
  if (url) return url;
1138
1133
  } catch (e: any) {
1139
- logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e.message}`);
1134
+ logger.error(`[getVideoClipRtmpUrl] getVodUrl Download failed: ${e?.message || String(e)}`);
1140
1135
  }
1141
1136
 
1142
1137
  throw new Error(`No streaming URL found from NVR for file ${fileId} after trying Playback and Download methods`);
@@ -1271,7 +1266,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1271
1266
 
1272
1267
  logger.log(`Completed auto-loading video clips and thumbnails`);
1273
1268
  } catch (e) {
1274
- logger.error('Error during auto-loading video clips:', e);
1269
+ logger.error('Error during auto-loading video clips:', e?.message || String(e));
1275
1270
  } finally {
1276
1271
  this.videoClipsAutoLoadInProgress = false;
1277
1272
  this.videoClipsAutoLoadMode = false;
@@ -1279,7 +1274,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1279
1274
  }
1280
1275
 
1281
1276
  async reboot(): Promise<void> {
1282
- const api = await this.ensureBaichuanClient();
1277
+ const api = await this.ensureClient();
1283
1278
  await api.reboot();
1284
1279
  }
1285
1280
 
@@ -1293,6 +1288,12 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1293
1288
  throw new Error('UID is required for battery cameras (BCUDP)');
1294
1289
  }
1295
1290
 
1291
+ // Prevent accidental connections to localhost (Node will default host=127.0.0.1 when host is undefined).
1292
+ // This shows up as connect ECONNREFUSED 127.0.0.1:9000 and will never recover with socket resets.
1293
+ if (!this.isBattery && !ipAddress) {
1294
+ throw new Error('IP Address is required for TCP devices');
1295
+ }
1296
+
1296
1297
  return {
1297
1298
  host: ipAddress,
1298
1299
  username,
@@ -1304,23 +1305,33 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1304
1305
  };
1305
1306
  }
1306
1307
 
1308
+ protected getStreamClientInputs(): BaichuanConnectionConfig {
1309
+ const { ipAddress, username, password } = this.storageSettings.values;
1310
+ const debugOptions = this.getBaichuanDebugOptions();
1311
+
1312
+ return {
1313
+ host: ipAddress,
1314
+ username,
1315
+ password,
1316
+ transport: this.transport,
1317
+ debugOptions,
1318
+ };
1319
+ }
1320
+
1307
1321
  protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
1308
1322
  return {
1309
- onError: undefined, // Use default error handling
1310
1323
  onClose: async () => {
1311
1324
  // Reset client state on close
1312
1325
  // The base class already handles cleanup
1313
1326
  // For battery cameras, don't auto-resubscribe after idle disconnects
1314
1327
  // (idle disconnects are normal for battery cameras to save power)
1315
- // Events will be resubscribed when ensureClient() is called for actual operations
1316
1328
  if (!this.isBattery) {
1317
- // For non-battery cameras, resubscribe to events after reconnection
1318
1329
  setTimeout(async () => {
1319
1330
  try {
1320
1331
  await this.subscribeToEvents();
1321
1332
  } catch (e) {
1322
1333
  const logger = this.getBaichuanLogger();
1323
- logger.warn('Failed to resubscribe to events after reconnection', e);
1334
+ logger.warn('Failed to resubscribe to events after reconnection', e?.message || String(e));
1324
1335
  }
1325
1336
  }, 1000);
1326
1337
  }
@@ -1339,8 +1350,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1339
1350
  }
1340
1351
 
1341
1352
  async withBaichuanRetry<T>(fn: () => Promise<T>): Promise<T> {
1342
- return await fn();
1343
-
1344
1353
  if (this.isBattery) {
1345
1354
  return await fn();
1346
1355
  } else {
@@ -1400,7 +1409,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1400
1409
  logger.log(`Diagnostics file: ${result.diagnosticsPath}`);
1401
1410
  logger.log(`Streams directory: ${result.streamsDir}`);
1402
1411
  } catch (e) {
1403
- logger.error('Failed to run diagnostics', e);
1412
+ logger.error('Failed to run diagnostics', e?.message || String(e));
1404
1413
  throw e;
1405
1414
  }
1406
1415
  }
@@ -1423,46 +1432,64 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1423
1432
  * - For TCP devices (regular + multifocal), this creates a new TCP session with its own client.
1424
1433
  * - For UDP/battery devices, this reuses the existing client via ensureClient().
1425
1434
  */
1426
- async createStreamClient(profile?: StreamProfile): Promise<ReolinkBaichuanApi> {
1427
- // Battery / BCUDP path: reuse the main client to avoid extra wake-ups and sockets.
1428
- if (this.isBattery) {
1429
- return await this.ensureClient();
1435
+ async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
1436
+ // Determine who should create the socket based on device hierarchy:
1437
+ // 1. Camera of multifocal with nvrDevice -> nvrDevice creates the socket
1438
+ // 2. Camera of multifocal (without nvrDevice) -> multiFocalDevice creates the socket
1439
+ // 3. Camera of nvr -> nvrDevice creates the socket
1440
+ // 4. Standalone camera -> camera creates its own socket (via base class)
1441
+
1442
+ // Case 1: Camera of multifocal with nvrDevice -> delegate to nvrDevice
1443
+ if (this.multiFocalDevice?.nvrDevice) {
1444
+ return await this.multiFocalDevice.nvrDevice.createStreamClient(streamKey);
1430
1445
  }
1431
1446
 
1432
- // For TCP path: create a new client ONLY for "ext" profile
1433
- // For other profiles (main, sub), reuse the main client
1434
- if (profile !== 'ext') {
1435
- return await this.ensureClient();
1447
+ // Case 2: Camera of multifocal (without nvrDevice) -> delegate to multiFocalDevice
1448
+ if (this.multiFocalDevice) {
1449
+ return await this.multiFocalDevice.createStreamClient(streamKey);
1436
1450
  }
1437
1451
 
1438
- // TCP path with ext profile: create a separate session for streaming (RFC4571/composite/NVR-friendly).
1439
- const { ipAddress, username, password } = this.storageSettings.values;
1440
- const logger = this.getBaichuanLogger();
1452
+ // Case 3: Camera of nvr -> delegate to nvrDevice
1453
+ if (this.nvrDevice) {
1454
+ return await this.nvrDevice.createStreamClient(streamKey);
1455
+ }
1441
1456
 
1442
- const debugOptions = this.getBaichuanDebugOptions();
1443
- const api = await createBaichuanApi(
1444
- {
1445
- inputs: {
1446
- host: ipAddress,
1447
- username,
1448
- password,
1449
- logger,
1450
- debugOptions,
1451
- },
1452
- transport: 'tcp',
1453
- },
1454
- );
1457
+ // Case 4: Standalone camera -> create its own socket using base class method
1458
+ // For battery cameras, reuse the main client
1459
+ // if (this.isBattery) {
1460
+ // return await this.ensureClient();
1461
+ // }
1455
1462
 
1456
- await api.login();
1457
- return api;
1463
+ // For TCP standalone cameras, use base class createStreamClient which manages stream clients per streamKey
1464
+ return await super.createStreamClient(streamKey);
1458
1465
  }
1459
1466
 
1460
1467
  public getAbilities(): DeviceCapabilities {
1461
1468
  if (this.multiFocalDevice) {
1462
- return this.multiFocalDevice.getInterfaces(this.storageSettings.values.rtspChannel).capabilities;
1469
+ const variantType = this.storageSettings.values.variantType;
1470
+ const ifaces = this.multiFocalDevice.getInterfaces(variantType);
1471
+ if (ifaces?.capabilities) return ifaces.capabilities;
1463
1472
  } else {
1464
- return this.storageSettings.values.capabilities;
1473
+ const caps = this.storageSettings.values.capabilities;
1474
+ if (caps) return caps;
1465
1475
  }
1476
+
1477
+ // Safe fallback to avoid crashes during init when connection hasn't succeeded yet.
1478
+ return {
1479
+ channel: this.storageSettings.values.rtspChannel ?? 0,
1480
+ ptzMode: 'none',
1481
+ hasPan: false,
1482
+ hasTilt: false,
1483
+ hasZoom: false,
1484
+ hasPresets: false,
1485
+ hasPtz: false,
1486
+ hasBattery: !!this.isBattery,
1487
+ hasIntercom: false,
1488
+ hasSiren: false,
1489
+ hasFloodlight: false,
1490
+ hasPir: false,
1491
+ isDoorbell: false,
1492
+ };
1466
1493
  }
1467
1494
 
1468
1495
  getBaichuanDebugOptions(): any | undefined {
@@ -1473,33 +1500,36 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1473
1500
  /**
1474
1501
  * Initialize or recreate the StreamManager, taking into account multifocal composite options.
1475
1502
  */
1476
- protected initStreamManager(logger: Console, forceRecreate: boolean = false): void {
1503
+ protected initStreamManager(logger?: Console, forceRecreate: boolean = false): void {
1477
1504
  const { username, password } = this.storageSettings.values;
1505
+ // Ensure logger is always valid - use provided logger or get from device, fallback to console
1506
+ const validLogger = logger || this.getBaichuanLogger() || console;
1478
1507
 
1479
- const baseOptions: any = {
1480
- createStreamClient: (profile?: StreamProfile) => this.createStreamClient(profile),
1481
- getLogger: () => logger,
1508
+ const baseOptions: StreamManagerOptions = {
1509
+ createStreamClient: this.createStreamClient.bind(this),
1510
+ logger: validLogger,
1482
1511
  credentials: {
1483
1512
  username,
1484
1513
  password,
1485
1514
  },
1486
- sharedConnection: this.isBattery,
1515
+ sharedConnection: this.isBattery || !!this.nvrDevice,
1487
1516
  };
1488
1517
 
1489
1518
  if (this.isMultiFocal) {
1490
- const values: any = this.storageSettings.values;
1491
- const pipPosition = values.pipPosition || 'bottom-right';
1492
- const pipSize = values.pipSize ?? 0.25;
1493
- const pipMargin = values.pipMargin ?? 10;
1494
- const widerChannel = values.widerChannel ?? 0;
1495
- const teleChannel = values.teleChannel ?? 1;
1519
+ const { pipPosition, pipSize, pipMargin, rtspChannel } = this.storageSettings.values;
1520
+
1521
+ // On NVR/Hub, TrackMix lenses are selected via stream variant, not via a separate channel.
1522
+ // Use rtspChannel for BOTH wide and tele so the library can request tele via streamType/variant.
1523
+ const wider = this.isOnNvr ? rtspChannel : undefined;
1524
+ const tele = this.isOnNvr ? rtspChannel : undefined;
1496
1525
 
1497
1526
  baseOptions.compositeOptions = {
1498
- widerChannel,
1499
- teleChannel,
1527
+ widerChannel: wider,
1528
+ teleChannel: tele,
1500
1529
  pipPosition,
1501
1530
  pipSize,
1502
1531
  pipMargin,
1532
+ onNvr: this.isOnNvr,
1503
1533
  };
1504
1534
  }
1505
1535
 
@@ -1523,10 +1553,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1523
1553
 
1524
1554
  this.streamManagerRestartTimeout = setTimeout(async () => {
1525
1555
  this.streamManagerRestartTimeout = undefined;
1526
- const restartLogger = this.getBaichuanLogger();
1556
+ const logger = this.getBaichuanLogger();
1527
1557
  try {
1528
- restartLogger.log('Restarting StreamManager due to PIP/composite settings change');
1529
- this.initStreamManager(restartLogger, true);
1558
+ logger.log('Restarting StreamManager due to PIP/composite settings change');
1559
+ this.initStreamManager(logger, true);
1530
1560
 
1531
1561
  // Invalidate snapshot cache for battery/multifocal-battery so that
1532
1562
  // the next snapshot reflects the new PIP/composite configuration.
@@ -1542,7 +1572,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1542
1572
  // best-effort
1543
1573
  }
1544
1574
  } catch (e) {
1545
- restartLogger.warn('Failed to restart StreamManager after settings change', e);
1575
+ logger.warn('Failed to restart StreamManager after settings change', e);
1546
1576
  }
1547
1577
  }, 500);
1548
1578
  }
@@ -1598,6 +1628,29 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1598
1628
  return;
1599
1629
  }
1600
1630
 
1631
+ // Handle battery/online/sleeping events separately from motion events
1632
+ switch (ev?.type) {
1633
+ case 'awake':
1634
+ case 'sleeping':
1635
+ // Update sleeping state for battery cameras or devices that support it
1636
+ this.updateSleepingState({
1637
+ reason: ev?.type === 'sleeping' ? 'sleeping' : 'awake',
1638
+ state: ev.type === 'sleeping' ? 'sleeping' : 'awake',
1639
+ }).catch((e) => {
1640
+ logger.warn('Error updating sleeping state', e);
1641
+ });
1642
+ return; // These events are handled, no need to process as motion events
1643
+
1644
+ case 'offline':
1645
+ case 'online':
1646
+ // Update online state for battery cameras or devices that support it
1647
+ this.updateOnlineState(ev.type === 'online').catch((e) => {
1648
+ logger.warn('Error updating online state', e);
1649
+ });
1650
+ return; // These events are handled, no need to process as motion events
1651
+ }
1652
+
1653
+ // Handle motion and object detection events
1601
1654
  const objects: string[] = [];
1602
1655
  let motion = false;
1603
1656
 
@@ -1632,11 +1685,21 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1632
1685
  }
1633
1686
  }
1634
1687
 
1688
+ /**
1689
+ * Subscribe to Baichuan events only if this is a standalone device (not a child of NVR or MultiFocal).
1690
+ * If this device has a parent (nvrDevice or multiFocalDevice), events will be forwarded from the parent.
1691
+ * This ensures that only the root device in the hierarchy subscribes to events, avoiding duplicate subscriptions.
1692
+ */
1635
1693
  async subscribeToEvents(): Promise<void> {
1694
+ // If this device has a parent (NVR or MultiFocal), don't subscribe - events will be forwarded from parent
1636
1695
  if (this.nvrDevice || this.multiFocalDevice) {
1696
+ const logger = this.getBaichuanLogger();
1697
+ logger.debug(`Device has parent (nvrDevice=${!!this.nvrDevice}, multiFocalDevice=${!!this.multiFocalDevice}), skipping event subscription (events will be forwarded from parent)`);
1637
1698
  return;
1638
1699
  }
1639
1700
 
1701
+ const api = await this.ensureClient();
1702
+
1640
1703
  const logger = this.getBaichuanLogger();
1641
1704
  const selection = Array.from(this.getDispatchEventsSelection?.() ?? new Set()).sort();
1642
1705
  const enabled = selection.length > 0;
@@ -1661,8 +1724,6 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1661
1724
  return;
1662
1725
  }
1663
1726
 
1664
- const api = await this.ensureClient();
1665
-
1666
1727
  try {
1667
1728
  await api.onSimpleEvent(this.onSimpleEvent);
1668
1729
  logger.log(`Subscribed to events (${selection.join(', ')}) on ${this.protocol} connection`);
@@ -1898,7 +1959,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1898
1959
  this.binaryState = false;
1899
1960
  }
1900
1961
 
1901
- async reportDevices(): Promise<void> {
1962
+ async reportDevicesParent(): Promise<void> {
1902
1963
  const abilities = this.getAbilities();
1903
1964
 
1904
1965
  const { hasSiren, hasFloodlight, hasPir } = abilities;
@@ -1946,6 +2007,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1946
2007
  type: ScryptedDeviceType.Switch,
1947
2008
  };
1948
2009
  sdk.deviceManager.onDeviceDiscovered(device);
2010
+
2011
+ this.reportDevices && await this.reportDevices();
1949
2012
  }
1950
2013
  }
1951
2014
 
@@ -1959,15 +2022,42 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1959
2022
  await this.storageSettings.putSetting(key, value);
1960
2023
  }
1961
2024
 
2025
+ async takePictureInternal(client: ReolinkBaichuanApi) {
2026
+ const { rtspChannel, variantType } = this.storageSettings.values;
2027
+ const logger = this.getBaichuanLogger();
2028
+ logger.log(`Taking new snapshot from camera: forceNewSnapshot=${this.forceNewSnapshot} channel=${rtspChannel} variant=${variantType}`);
2029
+
2030
+ const compositeOptions = this.isMultiFocal ? {
2031
+ widerChannel: this.isOnNvr ? rtspChannel : undefined,
2032
+ teleChannel: this.isOnNvr ? rtspChannel : undefined,
2033
+ pipPosition: this.storageSettings.values.pipPosition || 'bottom-right',
2034
+ pipSize: this.storageSettings.values.pipSize ?? 0.25,
2035
+ pipMargin: this.storageSettings.values.pipMargin ?? 10,
2036
+ onNvr: this.isOnNvr,
2037
+ } : undefined;
2038
+
2039
+ // For multifocal devices, request a composite snapshot by passing channel=undefined.
2040
+ const channelArg = this.isMultiFocal ? undefined : rtspChannel;
2041
+
2042
+ const snapshotBuffer = await client.getSnapshot(
2043
+ channelArg,
2044
+ {
2045
+ onNvr: this.isOnNvr,
2046
+ variant: variantType,
2047
+ ...(compositeOptions ? { compositeOptions } : {}),
2048
+ }
2049
+ );
2050
+ const mo = await this.createMediaObject(snapshotBuffer, 'image/jpeg');
2051
+
2052
+ return mo;
2053
+ }
2054
+
1962
2055
  async takePicture(options?: RequestPictureOptions) {
1963
2056
  if (!this.isBattery) {
1964
2057
  try {
1965
2058
  return this.withBaichuanRetry(async () => {
1966
2059
  const client = await this.ensureClient();
1967
- const snapshotBuffer = await client.getSnapshot(this.storageSettings.values.rtspChannel);
1968
- const mo = await this.createMediaObject(snapshotBuffer, 'image/jpeg');
1969
-
1970
- return mo;
2060
+ return await this.takePictureInternal(client);
1971
2061
  });
1972
2062
  } catch (e) {
1973
2063
  this.getBaichuanLogger().error('Error taking snapshot', e);
@@ -1985,16 +2075,11 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
1985
2075
  if (this.takePictureInFlight) {
1986
2076
  return await this.takePictureInFlight;
1987
2077
  }
1988
-
1989
- logger.log(`Taking new snapshot from camera (forceNewSnapshot: ${this.forceNewSnapshot})`);
1990
2078
  this.forceNewSnapshot = false;
1991
2079
 
1992
2080
  this.takePictureInFlight = (async () => {
1993
- const channel = this.storageSettings.values.rtspChannel;
1994
- const snapshotBuffer = await this.withBaichuanClient(async (api) => {
1995
- return await api.getSnapshot(channel);
1996
- });
1997
- const mo = await sdk.mediaManager.createMediaObject(snapshotBuffer, 'image/jpeg');
2081
+ const client = await this.ensureClient();
2082
+ const mo = await this.takePictureInternal(client);
1998
2083
  this.lastPicture = { mo, atMs: Date.now() };
1999
2084
  logger.log(`Snapshot taken at ${new Date(this.lastPicture.atMs).toLocaleString()}`);
2000
2085
  return mo;
@@ -2231,71 +2316,153 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2231
2316
  return this.cachedVideoStreamOptions;
2232
2317
  }
2233
2318
 
2234
- if (this.fetchingStreams) {
2235
- return [];
2319
+ // If there's already a fetch in progress, return the existing promise
2320
+ if (this.fetchingStreamsPromise) {
2321
+ return this.fetchingStreamsPromise;
2236
2322
  }
2237
2323
 
2238
- this.fetchingStreams = true;
2324
+ // Create and save the promise
2325
+ this.fetchingStreamsPromise = (async (): Promise<UrlMediaStreamOptions[]> => {
2326
+ try {
2327
+ let streams: UrlMediaStreamOptions[] = [];
2239
2328
 
2240
- let streams: UrlMediaStreamOptions[] = [];
2329
+ const client = await this.ensureClient();
2241
2330
 
2242
- const client = await this.ensureClient();
2331
+ const { rtspChannel, variantType } = this.storageSettings.values;
2243
2332
 
2244
- // For multifocal devices, use undefined channel to get composite streams
2245
- const isMultiFocal = this.options.type === 'multi-focal' || this.options.type === 'multi-focal-battery';
2246
- const channel = isMultiFocal ? undefined : this.storageSettings.values.rtspChannel;
2333
+ try {
2334
+ // Lens-scoped behavior: request streams only for the current lens/variant.
2335
+ // This keeps a single native_main and native_sub for the device.
2336
+ const lensParam: NativeVideoStreamVariant | undefined = variantType as any;
2337
+
2338
+ const { nativeStreams, rtmpStreams, rtspStreams } = await client.buildVideoStreamOptions({
2339
+ onNvr: this.isOnNvr,
2340
+ channel: rtspChannel,
2341
+ compositeOnly: this.isMultiFocal,
2342
+ ...(lensParam !== undefined ? { lens: lensParam } : {})
2343
+ });
2247
2344
 
2248
- try {
2249
- const { nativeStreams, rtmpStreams, rtspStreams } = await client.buildVideoStreamOptions(channel);
2250
-
2251
- let supportedStreams: ReolinkSupportedStream[] = [];
2252
- // Homehub RTMP is not efficient, crashes, offers native streams to not overload the hub
2253
- // if (this.nvrDevice && this.nvrDevice.info.model === 'HOMEHUB') {
2254
- supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2255
- // } else {
2256
- // supportedStreams = [...rtspStreams, ...rtmpStreams, ...nativeStreams];
2257
- // }
2258
-
2259
- for (const supportedStream of supportedStreams) {
2260
- const { id, metadata, url, name, container } = supportedStream;
2261
-
2262
- const codec = String(metadata.videoEncType || "").includes("264")
2263
- ? "h264"
2264
- : String(metadata.videoEncType || "").includes("265")
2265
- ? "h265"
2266
- : String(metadata.videoEncType || "").toLowerCase();
2267
-
2268
- streams.push({
2269
- id,
2270
- name,
2271
- url,
2272
- container,
2273
- video: { codec, width: metadata.width, height: metadata.height },
2274
- // audio: { codec: metadata.audioCodec }
2275
- })
2276
- }
2277
- } catch (e) {
2278
- if (!this.isRecoverableBaichuanError?.(e)) {
2279
- logger.warn('Failed to build RTSP/RTMP stream options, falling back to Native', e);
2280
- }
2281
- }
2345
+ // const urls = client.getRtspUrl(rtspChannel);
2346
+
2347
+ // let supportedStreams: ReolinkSupportedStream[] = [];
2348
+ const supportedStreams = [...nativeStreams, ...rtspStreams, ...rtmpStreams];
2349
+ // logger.log({ supportedStreams, variantType, lensParam, rtspChannel, onNvr: this.isOnNvr, nativeStreams: nativeStreams.map(s => ({ id: s.id, nativeVariant: s.nativeVariant, lens: s.lens })), rtspStreams: rtspStreams.map(s => ({ id: s.id, lens: s.lens })), rtmpStreams: rtmpStreams.map(s => ({ id: s.id, lens: s.lens })) });
2350
+
2351
+ for (const supportedStream of supportedStreams) {
2352
+ const { id, metadata, url, name, container, nativeVariant, lens } = supportedStream;
2353
+
2354
+ // Composite streams are re-encoded to H.264 by the library (ffmpeg/libx264).
2355
+ // Do not infer codec from underlying camera metadata.
2356
+ const isComposite = id.startsWith('composite_') || lens === 'composite';
2357
+ const codec = isComposite
2358
+ ? 'h264'
2359
+ : String(metadata.videoEncType || "").includes("264")
2360
+ ? "h264"
2361
+ : String(metadata.videoEncType || "").includes("265")
2362
+ ? "h265"
2363
+ : String(metadata.videoEncType || "").toLowerCase();
2364
+
2365
+ // Preserve variant information for native RTP streams by ensuring the URL contains it.
2366
+ let finalUrl = url;
2367
+ const variantFromIdOrUrl = extractVariantFromStreamId(id, url);
2368
+ const variantToInject = (nativeVariant && nativeVariant !== 'default')
2369
+ ? nativeVariant
2370
+ : variantFromIdOrUrl;
2371
+
2372
+ if (variantToInject && container === 'rtp') {
2373
+ try {
2374
+ const urlObj = new URL(url);
2375
+ if (!urlObj.searchParams.has('variant')) {
2376
+ urlObj.searchParams.set('variant', variantToInject);
2377
+ finalUrl = urlObj.toString();
2378
+ }
2379
+ } catch {
2380
+ // Invalid URL, use original
2381
+ }
2382
+ }
2282
2383
 
2283
- if (streams.length) {
2284
- logger.log('Fetched video stream options', streams.map((s) => s.name).join(', '));
2285
- logger.debug(JSON.stringify(streams));
2286
- this.cachedVideoStreamOptions = streams;
2287
- return streams;
2288
- }
2384
+ streams.push({
2385
+ id,
2386
+ name,
2387
+ url: finalUrl,
2388
+ container,
2389
+ video: { codec, width: metadata.width, height: metadata.height },
2390
+ // audio: { codec: metadata.audioCodec }
2391
+ })
2392
+ }
2393
+ } catch (e) {
2394
+ if (!this.isRecoverableBaichuanError?.(e)) {
2395
+ logger.warn('Failed to build RTSP/RTMP stream options, falling back to Native', e?.message || String(e));
2396
+ }
2397
+ }
2398
+
2399
+ if (streams.length) {
2400
+ logger.log('Fetched video stream options', streams.map((s) => s.name).join(', '));
2401
+ logger.debug(JSON.stringify(streams));
2402
+ this.cachedVideoStreamOptions = streams;
2403
+ return streams;
2404
+ }
2289
2405
 
2290
- this.fetchingStreams = false;
2406
+ return [];
2407
+ } finally {
2408
+ // Always clear the promise when done (success or failure)
2409
+ this.fetchingStreamsPromise = undefined;
2410
+ }
2411
+ })();
2412
+
2413
+ return this.fetchingStreamsPromise;
2291
2414
  }
2292
2415
 
2293
2416
  async getVideoStream(vso: RequestMediaStreamOptions): Promise<MediaObject> {
2294
2417
  if (!vso) throw new Error("video streams not set up or no longer exists.");
2295
2418
 
2296
2419
  const vsos = await this.getVideoStreamOptions();
2420
+ const logger = this.getBaichuanLogger();
2421
+
2422
+ logger.debug(`Available streams: ${vsos?.map(s => s.id).join(', ') || 'none'}`);
2423
+ logger.debug(`Requested stream ID: '${vso?.id}'`);
2424
+
2297
2425
  const selected = selectStreamOption(vsos, vso);
2298
2426
 
2427
+ // If the request explicitly asks for a variant (e.g. native_telephoto_main),
2428
+ // never override it with the device's variantType preference.
2429
+ // const requestedVariant = vso?.id ? extractVariantFromStreamId(vso.id, undefined) : undefined;
2430
+
2431
+ // If we have variantType set and the selected stream doesn't have the variant,
2432
+ // try to find a stream with the correct variant that matches the profile
2433
+ // const variantType = this.storageSettings.values.variantType;
2434
+ // if (!requestedVariant && variantType && variantType !== 'default') {
2435
+ // const profile = parseStreamProfileFromId(selected.id) || 'main';
2436
+
2437
+ // // On NVR, firmwares vary: some expose the tele lens as 'autotrack', others as 'telephoto'.
2438
+ // // When variantType is set, prefer that variant but fall back to the other tele variant if present.
2439
+ // const preferred = variantType as 'autotrack' | 'telephoto';
2440
+ // const fallbacks: Array<'autotrack' | 'telephoto'> = this.isOnNvr && preferred === 'telephoto'
2441
+ // ? ['telephoto', 'autotrack']
2442
+ // : this.isOnNvr && preferred === 'autotrack'
2443
+ // ? ['autotrack', 'telephoto']
2444
+ // : [preferred];
2445
+
2446
+ // const extractedVariant = extractVariantFromStreamId(selected.id, selected.url);
2447
+ // for (const v of fallbacks) {
2448
+ // const variantId = `native_${v}_${profile}`;
2449
+ // const variantStream = vsos?.find(s => s.id === variantId);
2450
+ // if (!variantStream) {
2451
+ // logger.debug(`Variant stream '${variantId}' not found in available streams`);
2452
+ // continue;
2453
+ // }
2454
+ // // Only use variant stream if the selected one doesn't already have a variant,
2455
+ // // or if the selected one has a different variant than what we want.
2456
+ // if (!extractedVariant || extractedVariant !== v) {
2457
+ // logger.log(`Preferring variant stream: '${variantId}' over '${selected.id}' (variantType='${variantType}')`);
2458
+ // selected = variantStream;
2459
+ // }
2460
+ // break;
2461
+ // }
2462
+ // }
2463
+
2464
+ logger.log(`Selected stream: id='${selected.id}', url='${selected.url}'`);
2465
+
2299
2466
  if (selected.url && (selected.container === 'rtsp' || selected.container === 'rtmp')) {
2300
2467
  const urlWithCredentials = this.addRtspCredentials(selected.url);
2301
2468
  const ret: MediaStreamUrl = {
@@ -2311,20 +2478,25 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2311
2478
  }
2312
2479
 
2313
2480
  // Check if this is a composite stream request (for multifocal devices)
2314
- const isComposite = selected.id?.startsWith('composite_');
2315
- if (isComposite && this.options && (this.options.type === 'multi-focal' || this.options.type === 'multi-focal-battery')) {
2481
+ // const isComposite = selected.id?.startsWith('composite_');
2482
+ // if (isComposite && this.options && (this.options.type === 'multi-focal' || this.options.type === 'multi-focal-battery')) {
2483
+ if (selected.id?.startsWith('composite_')) {
2316
2484
  const profile = parseStreamProfileFromId(selected.id.replace('composite_', '')) || 'main';
2317
- const streamKey = `composite_${profile}`;
2318
- const expectedVideoType = expectedVideoTypeFromUrlMediaStreamOptions(selected);
2485
+ // Include variantType in streamKey to ensure each variantType has its own unique socket
2486
+ // This is important for multifocal devices where different variantTypes may request composite streams
2487
+ const variantType = this.storageSettings.values.variantType || 'default';
2488
+ const streamKey = `composite_${variantType}_${profile}`;
2489
+
2490
+ logger.log(`Creating composite stream: profile=${profile}, variantType=${variantType}, streamKey=${streamKey}`);
2319
2491
 
2320
2492
  const createStreamFn = async () => {
2321
2493
  return await createRfc4571CompositeMediaObjectFromStreamManager({
2322
- streamManager: this.streamManager!,
2494
+ streamManager: this.streamManager,
2323
2495
  profile,
2324
2496
  streamKey,
2325
- expectedVideoType,
2326
2497
  selected,
2327
2498
  sourceId: this.id,
2499
+ variantType,
2328
2500
  });
2329
2501
  };
2330
2502
 
@@ -2334,28 +2506,34 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2334
2506
  // Regular stream for single channel
2335
2507
  const profile = parseStreamProfileFromId(selected.id) || 'main';
2336
2508
  const channel = this.storageSettings.values.rtspChannel;
2337
- const streamKey = `${channel}_${profile}`;
2338
- const expectedVideoType = expectedVideoTypeFromUrlMediaStreamOptions(selected);
2509
+ // Extract variant from stream ID or URL if present (e.g., "autotrack" from "native_autotrack_main" or "?variant=autotrack")
2510
+ let variant = extractVariantFromStreamId(selected.id, selected.url);
2511
+
2512
+ // Fallback: if no variant found in stream ID/URL, use variantType from device settings
2513
+ // This is important for multi-focal devices where the device has a variantType setting
2514
+ if (!variant && this.storageSettings.values.variantType && this.storageSettings.values.variantType !== 'default') {
2515
+ variant = this.storageSettings.values.variantType as 'autotrack' | 'telephoto';
2516
+ logger.log(`Using variant from device settings: '${variant}' (not found in stream ID/URL)`);
2517
+ }
2518
+
2519
+ logger.log(`Stream selection: id='${selected.id}', profile='${profile}', channel=${channel}, variant='${variant || 'default'}'`);
2520
+
2521
+ // Include variant in streamKey to distinguish streams with different variants
2522
+ const streamKey = variant ? `${channel}_${variant}_${profile}` : `${channel}_${profile}`;
2339
2523
 
2340
2524
  const createStreamFn = async () => {
2525
+ // Honor the requested variant. Some NVR firmwares label the tele lens as either
2526
+ // 'autotrack' or 'telephoto', and the library exposes both when available.
2527
+ logger.log(`Creating RFC4571 stream: channel=${channel}, profile=${profile}, variant=${variant || 'default'}, streamKey=${streamKey}`);
2528
+
2341
2529
  return await createRfc4571MediaObjectFromStreamManager({
2342
2530
  streamManager: this.streamManager!,
2343
2531
  channel,
2344
2532
  profile,
2345
2533
  streamKey,
2346
- expectedVideoType,
2534
+ variant,
2347
2535
  selected,
2348
2536
  sourceId: this.id,
2349
- // onDetectedCodec: (detectedCodec) => {
2350
- // const prev = this.cachedVideoStreamOptions ?? [];
2351
- // const next = prev.filter((s) => s.id !== nativeId);
2352
- // next.push({
2353
- // container: 'rtp',
2354
- // video: { codec: detectedCodec },
2355
- // url: ``
2356
- // });
2357
- // this.cachedVideoStreamOptions = next;
2358
- // },
2359
2537
  });
2360
2538
  };
2361
2539
 
@@ -2364,10 +2542,10 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2364
2542
 
2365
2543
  async ensureClient(): Promise<ReolinkBaichuanApi> {
2366
2544
  if (this.nvrDevice) {
2367
- return await this.nvrDevice.ensureBaichuanClient();
2545
+ return await this.nvrDevice.ensureClient();
2368
2546
  }
2369
2547
  if (this.multiFocalDevice) {
2370
- return await this.multiFocalDevice.ensureBaichuanClient();
2548
+ return await this.multiFocalDevice.ensureClient();
2371
2549
  }
2372
2550
 
2373
2551
  // Use base class implementation
@@ -2426,16 +2604,17 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2426
2604
  await sdk.deviceManager.onDeviceDiscovered(device);
2427
2605
 
2428
2606
  logger.log(`Device interfaces updated`);
2607
+ logger.debug(JSON.stringify({ hasNvr: !!this.nvrDevice, hasMultiFocal: !!this.multiFocalDevice, hasPlugin: !!this.plugin }));
2429
2608
  logger.debug(`${JSON.stringify(device)}`);
2430
2609
  } catch (e) {
2431
- logger.error('Failed to update device interfaces', e);
2610
+ logger.error('Failed to update device interfaces', e?.message || String(e));
2432
2611
  }
2433
2612
 
2434
- logger.log(`Refreshed device capabilities: ${JSON.stringify(capabilities)}`);
2435
- logger.debug(`Refreshed device capabilities: ${JSON.stringify({ abilities, support, presets, objects })}`);
2613
+ logger.log(`Refreshed device capabilities`);
2614
+ logger.debug(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets, objects })}`);
2436
2615
  }
2437
2616
  catch (e) {
2438
- logger.error('Failed to refresh abilities', e);
2617
+ logger.error('Failed to refresh abilities', e?.message || String(e));
2439
2618
  }
2440
2619
 
2441
2620
  this.refreshingState = false;
@@ -2444,23 +2623,27 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2444
2623
  async parentInit(): Promise<void> {
2445
2624
  const logger = this.getBaichuanLogger();
2446
2625
 
2626
+ this.init && await this.init();
2627
+
2447
2628
  try {
2448
2629
  await this.ensureClient();
2449
2630
  await this.updateDeviceInfo();
2450
2631
  }
2451
2632
  catch (e) {
2452
- logger.warn('Failed to update device info during init', e);
2633
+ logger.warn('Failed to update device info during init', e?.message || String(e));
2453
2634
  }
2454
2635
 
2636
+ await this.refreshDeviceState();
2637
+
2455
2638
  if (!this.multiFocalDevice) {
2456
2639
  try {
2457
- await this.refreshDeviceState();
2458
2640
  await this.reportDevices();
2459
2641
  }
2460
2642
  catch (e) {
2461
- logger.warn('Failed to connect/refresh during init', e);
2643
+ logger.warn('Failed to connect/refresh during init', e?.message || String(e));
2462
2644
  }
2463
2645
  }
2646
+
2464
2647
  this.storageSettings.settings.socketApiDebugLogs.hide = !!this.nvrDevice;
2465
2648
  this.storageSettings.settings.clipsSource.hide = !this.nvrDevice;
2466
2649
  this.storageSettings.settings.clipsSource.defaultValue = this.nvrDevice ? "NVR" : "Device";
@@ -2475,10 +2658,8 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2475
2658
  this.storageSettings.settings.pipPosition.hide = !this.isMultiFocal;
2476
2659
  this.storageSettings.settings.pipSize.hide = !this.isMultiFocal;
2477
2660
  this.storageSettings.settings.pipMargin.hide = !this.isMultiFocal;
2478
- this.storageSettings.settings.widerChannel.hide = !this.isMultiFocal;
2479
- this.storageSettings.settings.teleChannel.hide = !this.isMultiFocal;
2480
2661
 
2481
- this.storageSettings.settings.uid.hide = !this.isBattery;
2662
+ this.storageSettings.settings.uid.hide = !this.isBattery
2482
2663
  this.storageSettings.settings.discoveryMethod.hide = !this.isBattery && !this.nvrDevice;
2483
2664
 
2484
2665
  if (this.isBattery && !this.storageSettings.values.mixinsSetup) {
@@ -2492,7 +2673,7 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2492
2673
  }
2493
2674
  }
2494
2675
  catch (e) {
2495
- logger.warn('Failed to setup mixins during init', e);
2676
+ logger.warn('Failed to setup mixins during init', e?.message || String(e));
2496
2677
  }
2497
2678
  }
2498
2679
 
@@ -2503,8 +2684,12 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2503
2684
  logger.warn('Failed to subscribe to Baichuan events', e);
2504
2685
  }
2505
2686
 
2506
- // Initialize StreamManager (with composite options for multifocal devices)
2507
- this.initStreamManager(logger);
2687
+ try {
2688
+ this.initStreamManager();
2689
+ }
2690
+ catch (e) {
2691
+ logger.warn('Failed to initialize StreamManager', e);
2692
+ }
2508
2693
 
2509
2694
  const { hasIntercom, hasPtz } = this.getAbilities();
2510
2695
 
@@ -2529,23 +2714,69 @@ export abstract class CommonCameraMixin extends BaseBaichuanClass implements Vid
2529
2714
  this.updatePtzCaps();
2530
2715
  }
2531
2716
 
2532
- if (this.nvrDevice || this.multiFocalDevice) {
2717
+ const parentDevice = this.nvrDevice || this.multiFocalDevice;
2718
+ if (parentDevice) {
2533
2719
  this.storageSettings.settings.username.hide = true;
2534
2720
  this.storageSettings.settings.password.hide = true;
2535
2721
  this.storageSettings.settings.ipAddress.hide = true;
2536
2722
 
2537
- this.storageSettings.settings.username.defaultValue = this.nvrDevice.storageSettings.values.username;
2538
- this.storageSettings.settings.password.defaultValue = this.nvrDevice.storageSettings.values.password;
2539
- this.storageSettings.settings.ipAddress.defaultValue = this.nvrDevice.storageSettings.values.ipAddress;
2723
+ this.storageSettings.settings.username.defaultValue = parentDevice.storageSettings.values.username;
2724
+ this.storageSettings.settings.password.defaultValue = parentDevice.storageSettings.values.password;
2725
+ this.storageSettings.settings.ipAddress.defaultValue = parentDevice.storageSettings.values.ipAddress;
2540
2726
  }
2541
2727
 
2542
- await this.init();
2728
+ this.updateVideoClipsAutoLoad();
2543
2729
 
2544
- this.initComplete = true;
2730
+ this.onDeviceEvent(ScryptedInterface.Settings, '');
2731
+ }
2545
2732
 
2546
- // Initialize video clips auto-load if enabled
2547
- this.updateVideoClipsAutoLoad();
2733
+ async updateSleepingState(sleepStatus: SleepStatus): Promise<void> {
2734
+ try {
2735
+ if (this.isDebugEnabled()) {
2736
+ this.getBaichuanLogger().debug('getSleepStatus result:', JSON.stringify(sleepStatus));
2737
+ }
2738
+
2739
+ if (sleepStatus.state === 'sleeping') {
2740
+ if (!this.sleeping) {
2741
+ this.getBaichuanLogger().log(`Camera is sleeping: ${sleepStatus.reason}`);
2742
+ this.sleeping = true;
2743
+ }
2744
+ } else if (sleepStatus.state === 'awake') {
2745
+ // Camera is awake
2746
+ const wasSleeping = this.sleeping;
2747
+ if (wasSleeping) {
2748
+ this.getBaichuanLogger().log(`Camera woke up: ${sleepStatus.reason}`);
2749
+ this.sleeping = false;
2750
+ }
2751
+
2752
+ if (wasSleeping) {
2753
+ this.alignAuxDevicesState().catch(() => { });
2754
+ if (this.forceNewSnapshot) {
2755
+ this.takePicture().catch(() => { });
2756
+ }
2757
+ }
2758
+ } else {
2759
+ // Unknown state
2760
+ this.getBaichuanLogger().debug(`Sleep status unknown: ${sleepStatus.reason}`);
2761
+ }
2762
+ } catch (e) {
2763
+ // Silently ignore errors in sleep check to avoid spam
2764
+ this.getBaichuanLogger().debug('Error in updateSleepingState:', e);
2765
+ }
2548
2766
  }
2549
- }
2550
2767
 
2768
+ async updateOnlineState(isOnline: boolean): Promise<void> {
2769
+ try {
2770
+ if (this.isDebugEnabled()) {
2771
+ this.getBaichuanLogger().debug('updateOnlineState result:', isOnline);
2772
+ }
2551
2773
 
2774
+ if (isOnline !== this.online) {
2775
+ this.online = isOnline;
2776
+ }
2777
+ } catch (e) {
2778
+ // Silently ignore errors in sleep check to avoid spam
2779
+ this.getBaichuanLogger().debug('Error in updateOnlineState:', e);
2780
+ }
2781
+ }
2782
+ }