@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/intercom.ts CHANGED
@@ -63,7 +63,7 @@ export class ReolinkBaichuanIntercom {
63
63
  });
64
64
  }
65
65
  catch (e) {
66
- logger.warn("Intercom: unable to fetch TalkAbility", e);
66
+ logger.warn("Intercom: unable to fetch TalkAbility", e?.message || String(e));
67
67
  }
68
68
  }
69
69
 
@@ -81,7 +81,7 @@ export class ReolinkBaichuanIntercom {
81
81
  await new Promise(resolve => setTimeout(resolve, 1000));
82
82
  }
83
83
  } catch (e) {
84
- logger.debug('Failed to check/wake camera for intercom, proceeding anyway', e);
84
+ logger.debug('Failed to check/wake camera for intercom, proceeding anyway', e?.message || String(e));
85
85
  }
86
86
  }
87
87
 
@@ -221,7 +221,7 @@ export class ReolinkBaichuanIntercom {
221
221
  await Promise.race([session.stop(), sleepMs(2000)]);
222
222
  }
223
223
  catch (e) {
224
- logger.warn("Intercom session stop error", e);
224
+ logger.warn("Intercom session stop error", e?.message || String(e));
225
225
  }
226
226
  }
227
227
  })().finally(() => {
package/src/main.ts CHANGED
@@ -6,6 +6,7 @@ import { CommonCameraMixin } from "./common";
6
6
  import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
7
7
  import { ReolinkNativeNvrDevice } from "./nvr";
8
8
  import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, extractThumbnailFromVideo, getDeviceInterfaces, handleVideoClipRequest, multifocalSuffix, nvrSuffix } from "./utils";
9
+ import { randomBytes } from "crypto";
9
10
 
10
11
  interface ThumbnailRequest {
11
12
  deviceId: string;
@@ -29,6 +30,7 @@ interface ThumbnailRequestInput {
29
30
 
30
31
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
31
32
  devices = new Map<string, BaseBaichuanClass>();
33
+ private deviceCreationPromises = new Map<string, Promise<BaseBaichuanClass>>();
32
34
  mixinsMap = new Map<string, CommonCameraMixin>();
33
35
  nvrDeviceId: string;
34
36
  private thumbnailQueue: ThumbnailRequest[] = [];
@@ -46,13 +48,36 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
46
48
  }
47
49
 
48
50
  async getDevice(nativeId: ScryptedNativeId): Promise<BaseBaichuanClass> {
51
+ // Return existing device if available
49
52
  if (this.devices.has(nativeId)) {
50
53
  return this.devices.get(nativeId)!;
51
54
  }
52
55
 
53
- const newCamera = this.createCamera(nativeId);
54
- this.devices.set(nativeId, newCamera);
55
- return newCamera;
56
+ // Check if device creation is already in progress to prevent race conditions
57
+ const existingPromise = this.deviceCreationPromises.get(nativeId);
58
+ if (existingPromise) {
59
+ return existingPromise;
60
+ }
61
+
62
+ // Create device creation promise to handle concurrent requests
63
+ const creationPromise = (async () => {
64
+ try {
65
+ // Double-check after async operation
66
+ if (this.devices.has(nativeId)) {
67
+ return this.devices.get(nativeId)!;
68
+ }
69
+
70
+ const newCamera = this.createCamera(nativeId);
71
+ this.devices.set(nativeId, newCamera);
72
+ return newCamera;
73
+ } finally {
74
+ // Clean up the promise after creation completes
75
+ this.deviceCreationPromises.delete(nativeId);
76
+ }
77
+ })();
78
+
79
+ this.deviceCreationPromises.set(nativeId, creationPromise);
80
+ return creationPromise;
56
81
  }
57
82
 
58
83
  async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> {
@@ -65,7 +90,6 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
65
90
  throw new Error('IP address, username, and password are required');
66
91
  }
67
92
 
68
- // Auto-detect device type (camera, battery-cam, or nvr)
69
93
  this.console.log(`[AutoDetect] Starting device type detection for ${ipAddress}...`);
70
94
  const { autoDetectDeviceType } = await import("@apocaliss92/reolink-baichuan-js");
71
95
 
@@ -78,19 +102,20 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
78
102
  logger: this.console,
79
103
  },
80
104
  );
105
+ const { ip, mac } = detection.hostNetworkInfo ?? {}
81
106
 
82
107
  this.console.log(`[AutoDetect] Detected device type: ${detection.type} (transport: ${detection.transport}). Device info: ${JSON.stringify(detection.deviceInfo)}`);
83
108
 
84
109
  // Use the API that was successfully used for detection
85
110
  const detectedApi = detection.api;
111
+ const deviceInfo = detection.deviceInfo || {};
112
+ const name = deviceInfo?.name || `Reolink ${detection.type}`;
113
+ const identifier = uid || mac || ip || name || randomBytes(4).toString('hex');
86
114
 
87
115
  // Handle multi-focal device case
88
116
  if (detection.type === 'multifocal') {
89
- const deviceInfo = detection.deviceInfo || {};
90
- const name = deviceInfo.name || 'Reolink Multi-Focal';
91
- const serialNumber = deviceInfo.serialNumber || deviceInfo.itemNo || `multifocal-${Date.now()}`;
92
117
  const isBattery = detection.transport === 'udp';
93
- nativeId = `${serialNumber}${isBattery ? batteryMultifocalSuffix : multifocalSuffix}`;
118
+ nativeId = `${identifier}${isBattery ? batteryMultifocalSuffix : multifocalSuffix}`;
94
119
 
95
120
  settings.newCamera ||= name;
96
121
 
@@ -126,10 +151,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
126
151
 
127
152
  // Handle NVR case
128
153
  if (detection.type === 'nvr') {
129
- const deviceInfo = detection.deviceInfo || {};
130
- const name = deviceInfo?.name || 'Reolink NVR';
131
- const serialNumber = deviceInfo?.serialNumber || deviceInfo?.itemNo || `nvr-${Date.now()}`;
132
- nativeId = `${serialNumber}${nvrSuffix}`;
154
+ nativeId = `${identifier}${nvrSuffix}`;
133
155
 
134
156
  settings.newCamera ||= name;
135
157
 
@@ -157,16 +179,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
157
179
  return nativeId;
158
180
  }
159
181
 
160
- // For camera and battery-cam, create the device
161
- const deviceInfo = detection.deviceInfo || {};
162
- const name = deviceInfo?.name || 'Reolink Camera';
163
- const serialNumber = deviceInfo?.serialNumber || deviceInfo?.itemNo || `unknown-${Date.now()}`;
164
-
165
182
  // Create nativeId based on device type
166
183
  if (detection.type === 'battery-cam') {
167
- nativeId = `${serialNumber}${batteryCameraSuffix}`;
184
+ nativeId = `${identifier}${batteryCameraSuffix}`;
168
185
  } else {
169
- nativeId = `${serialNumber}${cameraSuffix}`;
186
+ nativeId = `${identifier}${cameraSuffix}`;
170
187
  }
171
188
 
172
189
  settings.newCamera ||= name;
@@ -205,8 +222,8 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
205
222
  return nativeId;
206
223
  }
207
224
  catch (e) {
208
- this.console.error('Error adding Reolink device', e);
209
- throw e;
225
+ this.console.error('Error adding Reolink device', e?.message || String(e));
226
+ throw e;
210
227
  }
211
228
  }
212
229
 
package/src/multiFocal.ts CHANGED
@@ -1,36 +1,24 @@
1
- import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { Device, DeviceProvider, Reboot, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
1
+ import type { DeviceCapabilities, DualLensChannelAnalysis, NativeVideoStreamVariant, ReolinkBaichuanApi, ReolinkSimpleEvent, SleepStatus, StreamProfile } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import type { BaichuanConnectionConfig } from "./baichuan-base";
3
+ import sdk, { Device, DeviceProvider, Reboot, ScryptedDeviceType, Settings } from "@scrypted/sdk";
3
4
  import { ReolinkNativeCamera } from "./camera";
4
5
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
5
6
  import { CameraType, CommonCameraMixin } from "./common";
6
7
  import ReolinkNativePlugin from "./main";
7
- import { batteryCameraSuffix, cameraSuffix, getDeviceInterfaces, updateDeviceInfo } from "./utils";
8
+ import { batteryCameraSuffix, cameraSuffix, getDeviceInterfaces } from "./utils";
9
+ import { ReolinkNativeNvrDevice } from "./nvr";
10
+ import { createBaichuanApi } from "./connect";
8
11
 
9
12
  export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements Settings, DeviceProvider, Reboot {
10
13
  plugin: ReolinkNativePlugin;
11
14
  cameraNativeMap = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
12
15
  private channelToNativeIdMap = new Map<number, string>();
13
- private initReinitTimeout: NodeJS.Timeout | undefined;
14
16
  isBattery: boolean;
15
17
 
16
- constructor(nativeId: string, plugin: ReolinkNativePlugin, type: CameraType) {
17
- super(nativeId, plugin, { type });
18
- this.plugin = plugin;
19
-
20
- this.scheduleInit();
21
- }
18
+ constructor(nativeId: string, plugin: ReolinkNativePlugin, type: CameraType, nvrDevice?: ReolinkNativeNvrDevice) {
19
+ super(nativeId, plugin, { type, nvrDevice });
22
20
 
23
- getAbilities(): DeviceCapabilities {
24
- const { capabilities } = this.storageSettings.values;
25
-
26
- return {
27
- ...capabilities,
28
- hasPan: false,
29
- hasTilt: false,
30
- hasZoom: false,
31
- hasPresets: false,
32
- hasIntercom: false,
33
- }
21
+ this.plugin = plugin;
34
22
  }
35
23
 
36
24
  protected async onBeforeCleanup(): Promise<void> {
@@ -41,141 +29,123 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
41
29
  return this.name || 'Multi-Focal Device';
42
30
  }
43
31
 
44
- async reinit(): Promise<void> {
45
- // Cancel any pending init/reinit
46
- if (this.initReinitTimeout) {
47
- clearTimeout(this.initReinitTimeout);
48
- this.initReinitTimeout = undefined;
49
- }
50
-
51
- // Schedule reinit with debounce
52
- this.scheduleInit(true);
53
- }
54
-
55
- private scheduleInit(isReinit: boolean = false): void {
56
- // Cancel any pending init/reinit
57
- if (this.initReinitTimeout) {
58
- clearTimeout(this.initReinitTimeout);
59
- }
60
-
61
- this.initReinitTimeout = setTimeout(async () => {
62
- const logger = this.getBaichuanLogger();
63
- if (isReinit) {
64
- logger.log('Reinitializing multi-focal device...');
65
- await this.cleanupBaichuanApi();
66
- }
67
- await this.init();
68
- this.initReinitTimeout = undefined;
69
- }, isReinit ? 500 : 2000);
70
- }
71
-
72
32
  async init(): Promise<void> {
73
33
  const logger = this.getBaichuanLogger();
34
+
74
35
  try {
75
36
  this.storageSettings.settings.uid.hide = !this.isBattery;
76
37
 
77
- await this.ensureBaichuanClient();
78
- await this.reportDevices();
38
+ await this.ensureClient();
39
+ // subscribeToEvents in common.ts will check if this device has a parent (nvrDevice)
40
+ // and skip subscription if needed - events will be forwarded from parent
79
41
  await this.subscribeToEvents();
80
42
  } catch (e) {
81
- logger.error('Failed to initialize multi-focal device', e);
82
- if (e instanceof Error) {
83
- logger.error(`Error message: ${e.message}`);
84
- logger.error(`Error stack: ${e.stack}`);
85
- } else {
86
- logger.error(`Error details: ${JSON.stringify(e)}`);
87
- }
43
+ logger.error('Failed to initialize multi-focal device', e?.message || String(e));
88
44
  }
89
45
  }
90
46
 
91
- getInterfaces(channel: number) {
47
+ getInterfaces(lensType?: NativeVideoStreamVariant) {
92
48
  const logger = this.getBaichuanLogger();
93
49
  const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
94
- const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
95
-
96
- const capabilities: DeviceCapabilities = {
97
- ...caps,
98
- hasPan: channelInfo.hasPan,
99
- hasTilt: channelInfo.hasTilt,
100
- hasZoom: channelInfo.hasZoom,
101
- hasPresets: channelInfo.hasPresets,
102
- hasIntercom: channelInfo.hasIntercom,
103
- };
50
+
51
+ let capabilities: DeviceCapabilities = { ...caps };
52
+
53
+ if (lensType) {
54
+ const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.variantType === lensType);
55
+
56
+ const hasPtz = channelInfo?.hasPan || channelInfo?.hasTilt || channelInfo?.hasZoom;
57
+
58
+ capabilities = {
59
+ ...capabilities,
60
+ hasPan: channelInfo.hasPan,
61
+ hasTilt: channelInfo.hasTilt,
62
+ hasZoom: channelInfo?.hasZoom,
63
+ hasPresets: channelInfo?.hasPresets || hasPtz,
64
+ hasIntercom: channelInfo?.hasIntercom,
65
+ hasPtz,
66
+ };
67
+ }
104
68
 
105
69
  const { interfaces } = getDeviceInterfaces({
106
70
  capabilities,
107
71
  logger,
108
72
  });
109
73
 
74
+ logger.debug(`Interfaces found for lens ${lensType}: ${JSON.stringify({ interfaces, capabilities, multifocalInfo })}`);
75
+
110
76
  return { interfaces, capabilities };
111
77
  }
112
78
 
113
79
  async reportDevices(): Promise<void> {
114
- const api = await this.ensureBaichuanClient();
115
80
  const logger = this.getBaichuanLogger();
116
- const { username, password, ipAddress, uid } = this.storageSettings.values;
117
-
118
- const { capabilities, support, abilities, features, objects, presets } = await api.getDeviceCapabilities();
119
-
120
- const multifocalInfo = await api.getDualLensChannelInfo();
121
- logger.log(`Sync entities from remote for ${multifocalInfo.channels.length} channels`);
122
-
123
- this.storageSettings.values.multifocalInfo = multifocalInfo;
124
- this.storageSettings.values.capabilities = capabilities;
125
-
126
- // TODO: Remove this after debugging
127
- logger.log(`Multichannel info: ${JSON.stringify({ multifocalInfo, capabilities, support, abilities, features, objects, presets })}`);
128
- // logger.debug(`Multichannel info: ${JSON.stringify({ multifocalInfo, capabilities, support, abilities, features, objects, presets })}`);
129
-
130
- for (const channelInfo of multifocalInfo?.channels ?? []) {
131
- const { channel, lensType } = channelInfo;
132
-
133
- const name = `${this.name} - ${lensType}`;
134
- const nativeId = `${this.nativeId}-channel${channel}${this.isBattery ? batteryCameraSuffix : cameraSuffix}`;
135
-
136
- this.channelToNativeIdMap.set(channel, nativeId);
137
- const { interfaces, capabilities: deviceCapabilities } = this.getInterfaces(channel);
138
-
139
- const device: Device = {
140
- providerNativeId: this.nativeId,
141
- name,
142
- nativeId,
143
- info: {
144
- ...this.info,
145
- metadata: {
146
- channel,
147
- lensType
148
- }
149
- },
150
- interfaces,
151
- type: ScryptedDeviceType.Camera,
152
- };
153
81
 
154
- await sdk.deviceManager.onDeviceDiscovered(device);
155
-
156
- // TODO: Remove this after debugging
157
- logger.log(`Discovering lens device ${nativeId}: ${JSON.stringify({ interfaces, deviceCapabilities })}`);
158
-
159
- const camera = await this.getDevice(nativeId);
160
-
161
- if (!camera) {
162
- logger.error(`Failed to get device ${nativeId}`);
163
- continue;
164
- }
82
+ try {
83
+ const api = await this.ensureClient();
84
+ const { username, password, ipAddress, uid, rtspChannel } = this.storageSettings.values;
85
+
86
+ const { capabilities, objects, presets } = await api.getDeviceCapabilities(rtspChannel, {
87
+ mergeDualLensOnSameChannel: true,
88
+ });
89
+ const multifocalInfo = await api.getDualLensChannelInfo(rtspChannel, {
90
+ onNvr: !!this.nvrDevice
91
+ });
92
+ logger.log(`Discovering ${multifocalInfo.channels.length} lenses`);
93
+ logger.debug({ multifocalInfo, capabilities });
94
+
95
+ this.storageSettings.values.multifocalInfo = multifocalInfo;
96
+ this.storageSettings.values.capabilities = capabilities;
97
+
98
+ for (const channelInfo of multifocalInfo?.channels ?? []) {
99
+ const { channel, lensType, variantType } = channelInfo;
100
+
101
+ const name = `${this.name} - ${lensType}`;
102
+ const nativeId = `${this.nativeId}-${lensType}${this.isBattery ? batteryCameraSuffix : cameraSuffix}`;
103
+
104
+ this.channelToNativeIdMap.set(channel, nativeId);
105
+ const { interfaces, capabilities: deviceCapabilities } = this.getInterfaces();
106
+
107
+ const device: Device = {
108
+ providerNativeId: this.nativeId,
109
+ name,
110
+ nativeId,
111
+ info: {
112
+ ...this.info,
113
+ metadata: {
114
+ channel,
115
+ lensType
116
+ }
117
+ },
118
+ interfaces,
119
+ type: ScryptedDeviceType.Camera,
120
+ };
121
+
122
+ await sdk.deviceManager.onDeviceDiscovered(device);
123
+
124
+ logger.log(`Discovering lens ${lensType}`);
125
+ logger.debug(`${JSON.stringify({ interfaces, deviceCapabilities })}`)
126
+
127
+ const camera = await this.getDevice(nativeId);
128
+
129
+ if (!camera) {
130
+ logger.error(`Failed to get device ${nativeId}`);
131
+ continue;
132
+ }
165
133
 
166
- camera.storageSettings.values.rtspChannel = channel;
167
- camera.classes = objects;
168
- camera.presets = presets;
169
- camera.storageSettings.values.username = username;
170
- camera.storageSettings.values.password = password;
171
- camera.storageSettings.values.ipAddress = ipAddress;
172
- camera.storageSettings.values.capabilities = deviceCapabilities;
173
- if (this.isBattery) {
134
+ camera.storageSettings.values.rtspChannel = channel;
135
+ camera.classes = objects;
136
+ camera.presets = presets;
137
+ camera.storageSettings.values.username = username;
138
+ camera.storageSettings.values.password = password;
139
+ camera.storageSettings.values.ipAddress = ipAddress;
140
+ camera.storageSettings.values.variantType = variantType;
141
+ camera.storageSettings.values.rtspChannel = channel;
142
+ camera.storageSettings.values.capabilities = deviceCapabilities;
174
143
  camera.storageSettings.values.uid = uid;
175
144
  }
145
+ } catch (e) {
146
+ logger.error('Failed to report devices', e?.message || String(e));
147
+ throw e;
176
148
  }
177
-
178
- await super.reportDevices();
179
149
  }
180
150
 
181
151
  async getDevice(nativeId: string) {
@@ -199,34 +169,122 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
199
169
  super.releaseDevice(id, nativeId);
200
170
  }
201
171
 
172
+ /**
173
+ * Forward events received from parent (NVR if child, or directly from Baichuan if standalone)
174
+ * to the MultiFocal device itself AND to ALL lens devices (camera children) of this MultiFocal.
175
+ * This ensures that:
176
+ * 1. The MultiFocal device itself receives events (it can have event handling capabilities)
177
+ * 2. All lenses receive the events, even if they share the same channel
178
+ * (e.g., wide and tele on the same channel on NVR).
179
+ * Only the root device (NVR or standalone MultiFocal) subscribes to events,
180
+ * and events are forwarded down the hierarchy.
181
+ */
202
182
  forwardNativeEvent(ev: ReolinkSimpleEvent): void {
203
183
  const logger = this.getBaichuanLogger();
204
- const channel = ev?.channel;
184
+ const eventChannel = ev?.channel;
205
185
 
206
- if (channel === undefined) {
207
- logger.debug('Event missing channel, ignoring');
208
- return;
186
+ // First, forward event to the MultiFocal device itself
187
+ try {
188
+ this.onSimpleEvent(ev);
189
+ } catch (e) {
190
+ logger.warn(`Error forwarding event to MultiFocal device itself:`, e?.message || String(e));
209
191
  }
210
192
 
211
- const nativeId = this.channelToNativeIdMap.get(channel);
212
- if (!nativeId) {
213
- logger.debug(`No camera found for channel ${channel}, ignoring event`);
193
+ // Then, forward event to all lens devices (camera children) of this MultiFocal
194
+ // Even if event has a specific channel, we forward to all lenses because:
195
+ // 1. On NVR, wide and tele lenses can share the same channel
196
+ // 2. Events might be relevant to all lenses of the MultiFocal device
197
+ const lensDevices = Array.from(this.cameraNativeMap.values());
198
+ const forwardedCount = lensDevices.length;
199
+
200
+ if (forwardedCount === 0) {
201
+ logger.debug(`No lens devices found for MultiFocal, event forwarded only to MultiFocal itself`);
214
202
  return;
215
203
  }
216
204
 
217
- const camera = this.cameraNativeMap.get(nativeId);
218
- if (!camera) {
219
- logger.debug(`Camera ${nativeId} not yet initialized, ignoring event`);
220
- return;
221
- }
205
+ logger.debug(`Forwarding event (channel=${eventChannel}) to MultiFocal itself and ${forwardedCount} lens device(s)`);
222
206
 
223
- camera.onSimpleEvent(ev);
207
+ // Forward event to all camera children (lens devices)
208
+ for (const camera of lensDevices) {
209
+ try {
210
+ // Each lens device will filter events based on its own channel if needed
211
+ camera.onSimpleEvent(ev);
212
+ } catch (e) {
213
+ logger.warn(`Error forwarding event to lens device ${camera.nativeId}:`, e?.message || String(e));
214
+ }
215
+ }
224
216
  }
225
217
 
226
218
  async unsubscribeFromAllEvents(): Promise<void> {
227
219
  await super.unsubscribeFromEvents();
228
220
  }
229
221
 
222
+ /**
223
+ * Update sleeping state for the MultiFocal device itself and propagate to all lens devices.
224
+ * This ensures that when the MultiFocal receives a sleeping/awake state update (from events or API calls),
225
+ * the state is synchronized across the MultiFocal and all its lens children.
226
+ */
227
+ async updateSleepingState(sleepStatus: SleepStatus): Promise<void> {
228
+ const logger = this.getBaichuanLogger();
229
+
230
+ // First, update the MultiFocal device's own sleeping state
231
+ await super.updateSleepingState(sleepStatus);
232
+
233
+ // Then, propagate the state to all lens devices (camera children)
234
+ const lensDevices = Array.from(this.cameraNativeMap.values());
235
+
236
+ if (lensDevices.length === 0) {
237
+ logger.debug(`No lens devices found for MultiFocal, sleeping state updated only for MultiFocal itself`);
238
+ return;
239
+ }
240
+
241
+ logger.debug(`Propagating sleeping state (state=${sleepStatus.state}) to ${lensDevices.length} lens device(s)`);
242
+
243
+ // Propagate sleeping state to all lens devices
244
+ await Promise.allSettled(
245
+ lensDevices.map(async (camera) => {
246
+ try {
247
+ await camera.updateSleepingState(sleepStatus);
248
+ } catch (e) {
249
+ logger.warn(`Error propagating sleeping state to lens device ${camera.nativeId}:`, e?.message || String(e));
250
+ }
251
+ })
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Update online state for the MultiFocal device itself and propagate to all lens devices.
257
+ * This ensures that when the MultiFocal receives an online/offline state update (from events or API calls),
258
+ * the state is synchronized across the MultiFocal and all its lens children.
259
+ */
260
+ async updateOnlineState(isOnline: boolean): Promise<void> {
261
+ const logger = this.getBaichuanLogger();
262
+
263
+ // First, update the MultiFocal device's own online state
264
+ await super.updateOnlineState(isOnline);
265
+
266
+ // Then, propagate the state to all lens devices (camera children)
267
+ const lensDevices = Array.from(this.cameraNativeMap.values());
268
+
269
+ if (lensDevices.length === 0) {
270
+ logger.debug(`No lens devices found for MultiFocal, online state updated only for MultiFocal itself`);
271
+ return;
272
+ }
273
+
274
+ logger.debug(`Propagating online state (isOnline=${isOnline}) to ${lensDevices.length} lens device(s)`);
275
+
276
+ // Propagate online state to all lens devices
277
+ await Promise.allSettled(
278
+ lensDevices.map(async (camera) => {
279
+ try {
280
+ await camera.updateOnlineState(isOnline);
281
+ } catch (e) {
282
+ logger.warn(`Error propagating online state to lens device ${camera.nativeId}:`, e?.message || String(e));
283
+ }
284
+ })
285
+ );
286
+ }
287
+
230
288
  public async runDiagnostics(): Promise<void> {
231
289
  const logger = this.getBaichuanLogger();
232
290
  logger.log(`Starting Multifocal diagnostics...`);
@@ -237,16 +295,52 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
237
295
  throw new Error('Missing device credentials');
238
296
  }
239
297
 
240
- const api = await this.ensureBaichuanClient();
298
+ const api = await this.ensureClient();
241
299
 
242
300
  const multifocalDiagnostics = await api.collectMultifocalDiagnostics(logger);
243
301
 
244
302
  logger.log(`NVR diagnostics completed successfully.`);
245
- logger.log(JSON.stringify(multifocalDiagnostics));
303
+ logger.debug(JSON.stringify(multifocalDiagnostics));
246
304
  } catch (e) {
247
- logger.error('Failed to run NVR diagnostics', e);
305
+ logger.error('Failed to run NVR diagnostics', e?.message || String(e));
248
306
  throw e;
249
307
  }
250
308
  }
309
+
310
+ async ensureClient(): Promise<ReolinkBaichuanApi> {
311
+ if (this.nvrDevice) {
312
+ return await this.nvrDevice.ensureBaichuanClient();
313
+ }
314
+
315
+ // Use base class implementation
316
+ return await this.ensureBaichuanClient();
317
+ }
318
+
319
+ protected getStreamClientInputs(): BaichuanConnectionConfig {
320
+ const { ipAddress, username, password } = this.storageSettings.values;
321
+ const debugOptions = this.getBaichuanDebugOptions();
322
+
323
+ return {
324
+ host: ipAddress,
325
+ username,
326
+ password,
327
+ transport: this.transport,
328
+ debugOptions,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Create a dedicated Baichuan API session for streaming (used by StreamManager).
334
+ * MultiFocal creates its own socket for stream clients, or delegates to NVR if on NVR.
335
+ */
336
+ async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
337
+ // If on NVR, delegate to NVR to create the socket
338
+ if (this.nvrDevice) {
339
+ return await this.nvrDevice.createStreamClient(streamKey);
340
+ }
341
+
342
+ // Otherwise, use base class createStreamClient which manages stream clients per streamKey
343
+ return await super.createStreamClient(streamKey);
344
+ }
251
345
  }
252
346