@apocaliss92/scrypted-reolink-native 0.1.2 → 0.1.4

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/connect.ts CHANGED
@@ -86,8 +86,6 @@ export async function createBaichuanApi(props: {
86
86
  }
87
87
  };
88
88
 
89
- logger.log('Connecting with options:', JSON.stringify(base, null, 2));
90
-
91
89
  if (transport === "tcp") {
92
90
  const api = new ReolinkBaichuanApi({
93
91
  ...base,
@@ -119,6 +117,142 @@ export type UdpFallbackInfo = {
119
117
  tcpError: unknown;
120
118
  };
121
119
 
120
+ export type DeviceType = 'camera' | 'battery-cam' | 'nvr';
121
+
122
+ export type AutoDetectResult = {
123
+ type: DeviceType;
124
+ transport: BaichuanTransport;
125
+ uid?: string;
126
+ deviceInfo?: Record<string, string>;
127
+ channelNum?: number;
128
+ };
129
+
130
+ /**
131
+ * Simple ping check to verify IP is reachable
132
+ */
133
+ async function pingHost(host: string, timeoutMs: number = 3000): Promise<boolean> {
134
+ return new Promise((resolve) => {
135
+ const { exec } = require('child_process');
136
+ const platform = process.platform;
137
+ const pingCmd = platform === 'win32' ? `ping -n 1 -w ${timeoutMs} ${host}` : `ping -c 1 -W ${Math.floor(timeoutMs / 1000)} ${host}`;
138
+
139
+ exec(pingCmd, (error: any) => {
140
+ resolve(!error);
141
+ });
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Auto-detect device type by trying TCP first, then UDP if needed.
147
+ * - First: Ping the IP to verify it's reachable
148
+ * - TCP success: Check if NVR (multiple channels) or regular camera
149
+ * - TCP failure: Try UDP (always battery camera)
150
+ */
151
+ export async function autoDetectDeviceType(
152
+ inputs: BaichuanConnectInputs,
153
+ logger: Console,
154
+ ): Promise<AutoDetectResult> {
155
+ const { host, username, password, uid } = inputs;
156
+
157
+ // Ping the host first to verify it's reachable
158
+ logger.log(`[AutoDetect] Pinging ${host}...`);
159
+ const isReachable = await pingHost(host);
160
+ if (!isReachable) {
161
+ logger.warn(`[AutoDetect] Host ${host} is not reachable via ping, but continuing with connection attempt...`);
162
+ } else {
163
+ logger.log(`[AutoDetect] Host ${host} is reachable`);
164
+ }
165
+
166
+ // Try TCP first
167
+ let tcpApi: ReolinkBaichuanApi | undefined;
168
+ try {
169
+ logger.log(`[AutoDetect] Trying TCP connection to ${host}...`);
170
+ tcpApi = await createBaichuanApi({
171
+ inputs: { host, username, password, logger },
172
+ transport: 'tcp',
173
+ logger,
174
+ });
175
+ await tcpApi.login();
176
+
177
+ // Get device info to check if it's an NVR
178
+ const deviceInfo = await tcpApi.getInfo();
179
+ const { support } = await tcpApi.getDeviceCapabilities(0);
180
+ const channelNum = support?.channelNum ?? 1;
181
+
182
+ logger.log(`[AutoDetect] TCP connection successful. channelNum=${channelNum}`);
183
+
184
+ // If channelNum > 1, it's likely an NVR
185
+ if (channelNum > 1) {
186
+ logger.log(`[AutoDetect] Detected NVR (${channelNum} channels)`);
187
+ await tcpApi.close();
188
+ return {
189
+ type: 'nvr',
190
+ transport: 'tcp',
191
+ deviceInfo,
192
+ channelNum,
193
+ };
194
+ }
195
+
196
+ // Single channel device - regular camera
197
+ logger.log(`[AutoDetect] Detected regular camera (single channel)`);
198
+ await tcpApi.close();
199
+ return {
200
+ type: 'camera',
201
+ transport: 'tcp',
202
+ deviceInfo,
203
+ channelNum: 1,
204
+ };
205
+ } catch (tcpError) {
206
+ // TCP failed, try UDP (battery camera)
207
+ if (tcpApi) {
208
+ try {
209
+ await tcpApi.close();
210
+ } catch {
211
+ // ignore
212
+ }
213
+ }
214
+
215
+ if (!isTcpFailureThatShouldFallbackToUdp(tcpError)) {
216
+ // Not a transport error, rethrow
217
+ throw tcpError;
218
+ }
219
+
220
+ logger.log(`[AutoDetect] TCP failed, trying UDP (battery camera)...`);
221
+ const normalizedUid = normalizeUid(uid);
222
+ if (!normalizedUid) {
223
+ throw new Error(
224
+ `TCP connection failed and device likely requires UDP/BCUDP. UID is required for battery cameras (ip=${host}).`
225
+ );
226
+ }
227
+
228
+ try {
229
+ const udpApi = await createBaichuanApi({
230
+ inputs: { host, username, password, uid: normalizedUid, logger },
231
+ transport: 'udp',
232
+ logger,
233
+ });
234
+ await udpApi.login();
235
+
236
+ const deviceInfo = await udpApi.getInfo();
237
+ logger.log(`[AutoDetect] UDP connection successful. Detected battery camera.`);
238
+ await udpApi.close();
239
+
240
+ return {
241
+ type: 'battery-cam',
242
+ transport: 'udp',
243
+ uid: normalizedUid,
244
+ deviceInfo,
245
+ channelNum: 1,
246
+ };
247
+ } catch (udpError) {
248
+ logger.error(`[AutoDetect] Both TCP and UDP failed. TCP error: ${tcpError}, UDP error: ${udpError}`);
249
+ throw new Error(
250
+ `Failed to connect via both TCP and UDP. TCP: ${(tcpError as any)?.message || tcpError}, UDP: ${(udpError as any)?.message || udpError}`
251
+ );
252
+ }
253
+ }
254
+ }
255
+
122
256
  // export async function connectBaichuanWithTcpUdpFallback(
123
257
  // inputs: BaichuanConnectInputs,
124
258
  // onUdpFallback?: (info: UdpFallbackInfo) => void,
package/src/intercom.ts CHANGED
@@ -39,7 +39,7 @@ export class ReolinkBaichuanIntercom {
39
39
  );
40
40
 
41
41
  await this.stop();
42
- const channel = this.camera.getRtspChannel();
42
+ const channel = this.camera.storageSettings.values.rtspChannel;
43
43
 
44
44
  // Best-effort: log codec requirements exposed by the camera.
45
45
  // This mirrors neolink's source of truth: TalkAbility (cmd_id=10).
package/src/main.ts CHANGED
@@ -1,19 +1,20 @@
1
- import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedNativeId, Setting } from "@scrypted/sdk";
1
+ import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
2
2
  import { ReolinkNativeCamera } from "./camera";
3
3
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
4
- import { createBaichuanApi } from "./connect";
4
+ import { ReolinkNativeNvrDevice } from "./nvr";
5
+ import { autoDetectDeviceType, createBaichuanApi } from "./connect";
5
6
  import { getDeviceInterfaces } from "./utils";
6
7
 
7
8
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
8
- devices = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
9
+ devices = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera | ReolinkNativeNvrDevice>();
9
10
 
10
11
  getScryptedDeviceCreator(): string {
11
12
  return 'Reolink Native camera';
12
13
  }
13
14
 
14
- async getDevice(nativeId: ScryptedNativeId): Promise<ReolinkNativeCamera | ReolinkNativeBatteryCamera> {
15
+ async getDevice(nativeId: ScryptedNativeId): Promise<ReolinkNativeCamera | ReolinkNativeBatteryCamera | ReolinkNativeNvrDevice> {
15
16
  if (this.devices.has(nativeId)) {
16
- return this.devices.get(nativeId);
17
+ return this.devices.get(nativeId)!;
17
18
  }
18
19
 
19
20
  const newCamera = this.createCamera(nativeId);
@@ -23,88 +24,145 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
23
24
 
24
25
  async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> {
25
26
  const ipAddress = settings.ip?.toString();
26
- let info: DeviceInformation = {};
27
-
28
27
  const username = settings.username?.toString();
29
28
  const password = settings.password?.toString();
30
29
  const uid = settings.uid?.toString();
31
- const isBatteryCam = settings.isBatteryCam === true || settings.isBatteryCam?.toString() === 'true';
32
30
 
33
- if (isBatteryCam && !uid) {
34
- throw new Error('UID is required for battery cameras (BCUDP)');
31
+ if (!ipAddress || !username || !password) {
32
+ throw new Error('IP address, username, and password are required');
35
33
  }
36
34
 
37
- if (ipAddress && username && password) {
38
- const api = await createBaichuanApi(
39
- {
40
- transport: isBatteryCam ? 'udp' : 'tcp',
41
- logger: this.console,
42
- inputs: {
43
- host: ipAddress,
44
- username,
45
- password,
46
- uid,
47
- logger: this.console,
48
- }
49
- }
50
- );
35
+ // Auto-detect device type (camera, battery-cam, or nvr)
36
+ this.console.log(`[AutoDetect] Starting device type detection for ${ipAddress}...`);
37
+ const detection = await autoDetectDeviceType(
38
+ {
39
+ host: ipAddress,
40
+ username,
41
+ password,
42
+ uid,
43
+ logger: this.console,
44
+ },
45
+ this.console
46
+ );
47
+
48
+ this.console.log(`[AutoDetect] Detected device type: ${detection.type} (transport: ${detection.transport})`);
49
+
50
+ // Handle NVR case
51
+ if (detection.type === 'nvr') {
52
+ const deviceInfo = detection.deviceInfo || {};
53
+ const name = deviceInfo?.name || 'Reolink NVR';
54
+ const serialNumber = deviceInfo?.serialNumber || deviceInfo?.itemNo || `nvr-${Date.now()}`;
55
+ nativeId = `${serialNumber}-nvr`;
56
+
57
+ settings.newCamera ||= name;
58
+
59
+ await sdk.deviceManager.onDeviceDiscovered({
60
+ nativeId,
61
+ name,
62
+ interfaces: [
63
+ ScryptedInterface.Settings,
64
+ ScryptedInterface.DeviceDiscovery,
65
+ ScryptedInterface.DeviceProvider,
66
+ ScryptedInterface.Reboot,
67
+ ],
68
+ type: ScryptedDeviceType.Builtin,
69
+ providerNativeId: this.nativeId,
70
+ });
71
+
72
+ const device = await this.getDevice(nativeId);
73
+ if (!(device instanceof ReolinkNativeNvrDevice)) {
74
+ throw new Error('Expected NVR device but got different type');
75
+ }
76
+ device.storageSettings.values.ipAddress = ipAddress;
77
+ device.storageSettings.values.username = username;
78
+ device.storageSettings.values.password = password;
79
+ device.updateDeviceInfo(deviceInfo);
51
80
 
52
- await api.login();
81
+ return nativeId;
82
+ }
53
83
 
54
- try {
55
- const deviceInfo = await api.getInfo();
56
- const name = deviceInfo?.name;
57
- const rtspChannel = 0;
58
- const { abilities, capabilities, objects, presets } = await api.getDeviceCapabilities(rtspChannel);
59
-
60
- this.console.log(JSON.stringify({ abilities, capabilities, deviceInfo }));
61
-
62
- nativeId = `${deviceInfo.serialNumber}${isBatteryCam ? '-battery-cam' : '-cam'}`;
63
-
64
- settings.newCamera ||= name;
65
-
66
- const { interfaces, type } = getDeviceInterfaces({
67
- capabilities,
68
- logger: this.console,
69
- });
70
-
71
- await sdk.deviceManager.onDeviceDiscovered({
72
- nativeId,
73
- name,
74
- interfaces,
75
- type,
76
- providerNativeId: this.nativeId,
77
- });
78
-
79
- const device = await this.getDevice(nativeId);
80
- device.info = info;
81
- device.classes = objects;
82
- device.presets = presets;
83
- device.storageSettings.values.username = username;
84
- device.storageSettings.values.password = password;
85
- device.storageSettings.values.rtspChannel = rtspChannel;
86
- device.storageSettings.values.ipAddress = ipAddress;
87
- if (isBatteryCam && uid) (device as ReolinkNativeBatteryCamera).storageSettings.values.uid = uid;
88
- device.storageSettings.values.capabilities = capabilities;
89
- device.updateDeviceInfo();
90
-
91
- return nativeId;
92
- }
93
- catch (e) {
94
- this.console.error('Error adding Reolink camera', e);
95
- await api.close();
96
- throw e;
97
- }
98
- finally {
99
- await api.close();
84
+ // For camera and battery-cam, create the device
85
+ const deviceInfo = detection.deviceInfo || {};
86
+ const name = deviceInfo?.name || 'Reolink Camera';
87
+ const serialNumber = deviceInfo?.serialNumber || deviceInfo?.itemNo || `unknown-${Date.now()}`;
88
+
89
+ // Create nativeId based on device type
90
+ if (detection.type === 'battery-cam') {
91
+ nativeId = `${serialNumber}-battery-cam`;
92
+ } else {
93
+ nativeId = `${serialNumber}-cam`;
94
+ }
95
+
96
+ settings.newCamera ||= name;
97
+
98
+ // Create API connection to get capabilities
99
+ const api = await createBaichuanApi({
100
+ inputs: {
101
+ host: ipAddress,
102
+ username,
103
+ password,
104
+ uid: detection.uid,
105
+ logger: this.console,
106
+ },
107
+ transport: detection.transport,
108
+ logger: this.console,
109
+ });
110
+
111
+ try {
112
+ await api.login();
113
+ const rtspChannel = 0;
114
+ const { abilities, capabilities, objects, presets } = await api.getDeviceCapabilities(rtspChannel);
115
+
116
+ this.console.log(JSON.stringify({ abilities, capabilities, deviceInfo }));
117
+
118
+ const { interfaces, type } = getDeviceInterfaces({
119
+ capabilities,
120
+ logger: this.console,
121
+ });
122
+
123
+ await sdk.deviceManager.onDeviceDiscovered({
124
+ nativeId,
125
+ name,
126
+ interfaces,
127
+ type,
128
+ providerNativeId: this.nativeId,
129
+ });
130
+
131
+ const device = await this.getDevice(nativeId);
132
+ if (device instanceof ReolinkNativeNvrDevice) {
133
+ // NVR devices are handled separately above
134
+ throw new Error('NVR device should not reach this code path');
100
135
  }
136
+
137
+ // Type guard: device is either ReolinkNativeCamera or ReolinkNativeBatteryCamera
138
+ device.info = deviceInfo;
139
+ device.classes = objects;
140
+ device.presets = presets;
141
+ device.storageSettings.values.username = username;
142
+ device.storageSettings.values.password = password;
143
+ device.storageSettings.values.rtspChannel = rtspChannel;
144
+ device.storageSettings.values.ipAddress = ipAddress;
145
+ device.storageSettings.values.capabilities = capabilities;
146
+ device.storageSettings.values.uid = detection.uid;
147
+ device.updateDeviceInfo();
148
+
149
+ return nativeId;
150
+ }
151
+ catch (e) {
152
+ this.console.error('Error adding Reolink device', e);
153
+ throw e;
154
+ }
155
+ finally {
156
+ await api.close();
101
157
  }
102
158
  }
103
159
 
104
160
  async releaseDevice(id: string, nativeId: ScryptedNativeId): Promise<void> {
105
161
  if (this.devices.has(nativeId)) {
106
162
  const device = this.devices.get(nativeId);
107
- await device.release();
163
+ if (device && 'release' in device && typeof device.release === 'function') {
164
+ await device.release();
165
+ }
108
166
  this.devices.delete(nativeId);
109
167
  }
110
168
  }
@@ -116,12 +174,6 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
116
174
  title: 'IP Address',
117
175
  placeholder: '192.168.2.222',
118
176
  },
119
- {
120
- key: 'isBatteryCam',
121
- title: 'Is Battery Camera',
122
- description: 'Enable for Reolink battery cameras. Uses UDP/BCUDP for discovery and streaming. Requires UID.',
123
- type: 'boolean',
124
- },
125
177
  {
126
178
  key: 'username',
127
179
  title: 'Username',
@@ -134,7 +186,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
134
186
  {
135
187
  key: 'uid',
136
188
  title: 'UID',
137
- description: 'Reolink UID (required for battery cameras)',
189
+ description: 'Reolink UID (optional, required for battery cameras if TCP connection fails)',
138
190
  }
139
191
  ]
140
192
  }
@@ -142,8 +194,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
142
194
  createCamera(nativeId: string) {
143
195
  if (nativeId.endsWith('-battery-cam')) {
144
196
  return new ReolinkNativeBatteryCamera(nativeId, this);
197
+ } else if (nativeId.endsWith('-nvr')) {
198
+ return new ReolinkNativeNvrDevice(nativeId, this);
199
+ } else {
200
+ return new ReolinkNativeCamera(nativeId, this);
145
201
  }
146
- return new ReolinkNativeCamera(nativeId, this);
147
202
  }
148
203
  }
149
204