@apocaliss92/scrypted-reolink-native 0.1.3 → 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/nvr.ts ADDED
@@ -0,0 +1,364 @@
1
+ import type { DeviceInfoResponse, DeviceInputData, ReolinkCgiApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
3
+ import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
+ import { ReolinkNativeCamera } from "./camera";
5
+ import { ReolinkNativeBatteryCamera } from "./camera-battery";
6
+ import { normalizeUid } from "./connect";
7
+ import ReolinkNativePlugin from "./main";
8
+ import { getDeviceInterfaces } from "./utils";
9
+
10
+ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settings, DeviceDiscovery, DeviceProvider, Reboot {
11
+ storageSettings = new StorageSettings(this, {
12
+ debugEvents: {
13
+ title: 'Debug Events',
14
+ type: 'boolean',
15
+ immediate: true,
16
+ },
17
+ ipAddress: {
18
+ title: 'IP address',
19
+ type: 'string',
20
+ onPut: async () => await this.reinit()
21
+ },
22
+ username: {
23
+ title: 'Username',
24
+ placeholder: 'admin',
25
+ defaultValue: 'admin',
26
+ type: 'string',
27
+ onPut: async () => await this.reinit()
28
+ },
29
+ password: {
30
+ title: 'Password',
31
+ type: 'password',
32
+ onPut: async () => await this.reinit()
33
+ },
34
+ });
35
+ plugin: ReolinkNativePlugin;
36
+ nvrApi: ReolinkCgiApi | undefined;
37
+ discoveredDevices = new Map<string, {
38
+ device: Device;
39
+ description: string;
40
+ rtspChannel: number;
41
+ deviceData: DeviceInfoResponse;
42
+ }>();
43
+ lastHubInfoCheck: number | undefined;
44
+ lastErrorsCheck: number | undefined;
45
+ lastDevicesStatusCheck: number | undefined;
46
+ cameraNativeMap = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
47
+ processing = false;
48
+
49
+ constructor(nativeId: string, plugin: ReolinkNativePlugin) {
50
+ super(nativeId);
51
+ this.plugin = plugin;
52
+
53
+ setTimeout(async () => {
54
+ await this.init();
55
+ }, 5000);
56
+ }
57
+
58
+ async reboot(): Promise<void> {
59
+ const api = await this.ensureClient();
60
+ await api.Reboot();
61
+ }
62
+
63
+ getLogger() {
64
+ return this.console;
65
+ }
66
+
67
+ async reinit() {
68
+ if (this.nvrApi) {
69
+ try {
70
+ await this.nvrApi.logout();
71
+ } catch {
72
+ // ignore
73
+ }
74
+ }
75
+ this.nvrApi = undefined;
76
+ }
77
+
78
+ async ensureClient(): Promise<ReolinkCgiApi> {
79
+ if (this.nvrApi) {
80
+ return this.nvrApi;
81
+ }
82
+
83
+ const { ipAddress, username, password } = this.storageSettings.values;
84
+ if (!ipAddress || !username || !password) {
85
+ throw new Error('Missing NVR credentials');
86
+ }
87
+
88
+ const { ReolinkCgiApi } = await import("@apocaliss92/reolink-baichuan-js");
89
+ this.nvrApi = new ReolinkCgiApi({
90
+ host: ipAddress,
91
+ username,
92
+ password,
93
+ });
94
+
95
+ await this.nvrApi.login();
96
+ return this.nvrApi;
97
+ }
98
+
99
+ async init() {
100
+ const api = await this.ensureClient();
101
+ const logger = this.getLogger();
102
+
103
+ setInterval(async () => {
104
+ if (this.processing || !api) {
105
+ return;
106
+ }
107
+ this.processing = true;
108
+ try {
109
+ const now = Date.now();
110
+
111
+ if (!this.lastErrorsCheck || (now - this.lastErrorsCheck > 60 * 1000)) {
112
+ this.lastErrorsCheck = now;
113
+ // Note: ReolinkCgiApi doesn't have checkErrors, skip for now
114
+ }
115
+
116
+ if (!this.lastHubInfoCheck || now - this.lastHubInfoCheck > 1000 * 60 * 5) {
117
+ logger.log('Starting Hub info data fetch');
118
+ this.lastHubInfoCheck = now;
119
+ const { hubData } = await api.getHubInfo();
120
+ const { devicesData, channelsResponse, response } = await api.getDevicesInfo();
121
+ logger.log('Hub info data fetched');
122
+ if (this.storageSettings.values.debugEvents) {
123
+ logger.log(`${JSON.stringify({ hubData, devicesData, channelsResponse, response })}`);
124
+ }
125
+
126
+ await this.discoverDevices(true);
127
+ }
128
+
129
+ const eventsRes = await api.getAllChannelsEvents();
130
+
131
+ if (this.storageSettings.values.debugEvents) {
132
+ logger.debug(`Events call result: ${JSON.stringify(eventsRes)}`);
133
+ }
134
+ this.cameraNativeMap.forEach((camera) => {
135
+ if (camera) {
136
+ const channel = camera.storageSettings.values.rtspChannel;
137
+ const cameraEventsData = eventsRes?.parsed[channel];
138
+ if (cameraEventsData) {
139
+ camera.processEvents(cameraEventsData);
140
+ }
141
+ }
142
+ });
143
+
144
+ const { batteryInfoData, response } = await api.getAllChannelsBatteryInfo();
145
+
146
+ if (this.storageSettings.values.debugEvents) {
147
+ logger.debug(`Battery info call result: ${JSON.stringify({ batteryInfoData, response })}`);
148
+ }
149
+
150
+ this.cameraNativeMap.forEach((camera) => {
151
+ if (camera) {
152
+ const channel = camera.storageSettings.values.rtspChannel;
153
+ const cameraBatteryData = batteryInfoData[channel];
154
+ if (cameraBatteryData) {
155
+ (camera as ReolinkNativeBatteryCamera).updateSleepingState({
156
+ reason: 'NVR',
157
+ state: cameraBatteryData.sleeping ? 'sleeping' : 'awake',
158
+ idleMs: 0,
159
+ lastRxAtMs: 0,
160
+ }).catch(() => { });
161
+ }
162
+ }
163
+ });
164
+ } catch (e) {
165
+ this.console.error('Error on events flow', e);
166
+ } finally {
167
+ this.processing = false;
168
+ }
169
+ }, 1000);
170
+ }
171
+
172
+ updateDeviceInfo(deviceInfo: Record<string, string>) {
173
+ const info = this.info || {};
174
+ info.ip = this.storageSettings.values.ipAddress;
175
+ info.serialNumber = deviceInfo?.serialNumber || deviceInfo?.itemNo;
176
+ info.firmware = deviceInfo?.firmwareVersion || deviceInfo?.firmVer;
177
+ info.version = deviceInfo?.hardwareVersion || deviceInfo?.boardInfo;
178
+ info.model = deviceInfo?.type || deviceInfo?.typeInfo;
179
+ info.manufacturer = 'Reolink native';
180
+ info.managementUrl = `http://${info.ip}`;
181
+ this.info = info;
182
+ }
183
+
184
+ async getSettings(): Promise<Setting[]> {
185
+ const settings = await this.storageSettings.getSettings();
186
+ return settings;
187
+ }
188
+
189
+ async putSetting(key: string, value: SettingValue): Promise<void> {
190
+ return this.storageSettings.putSetting(key, value);
191
+ }
192
+
193
+ async releaseDevice(id: string, nativeId: string) {
194
+ this.cameraNativeMap.delete(nativeId);
195
+ }
196
+
197
+ async getDevice(nativeId: string): Promise<ReolinkNativeCamera | ReolinkNativeBatteryCamera> {
198
+ let device = this.cameraNativeMap.get(nativeId);
199
+
200
+ if (!device) {
201
+ if (nativeId.endsWith('-battery-cam')) {
202
+ device = new ReolinkNativeBatteryCamera(nativeId, this.plugin, this);
203
+ } else {
204
+ device = new ReolinkNativeCamera(nativeId, this.plugin, this);
205
+ }
206
+ this.cameraNativeMap.set(nativeId, device);
207
+ }
208
+
209
+ return device;
210
+ }
211
+
212
+ buildNativeId(channel: number, serialNumber?: string, isBattery?: boolean): string {
213
+ const suffix = isBattery ? '-battery-cam' : '-cam';
214
+ if (serialNumber) {
215
+ return `${this.nativeId}-ch${channel}-${serialNumber}${suffix}`;
216
+ }
217
+ return `${this.nativeId}-ch${channel}${suffix}`;
218
+ }
219
+
220
+ getCameraInterfaces() {
221
+ return [
222
+ ScryptedInterface.VideoCameraConfiguration,
223
+ ScryptedInterface.Camera,
224
+ ScryptedInterface.MotionSensor,
225
+ ScryptedInterface.VideoTextOverlays,
226
+ ScryptedInterface.VideoCamera,
227
+ ScryptedInterface.Settings,
228
+ ScryptedInterface.ObjectDetector,
229
+ ];
230
+ }
231
+
232
+ async syncEntitiesFromRemote() {
233
+ const api = await this.ensureClient();
234
+ const logger = this.getLogger();
235
+
236
+ logger.log('Starting channels discovery using getDevicesInfo...');
237
+
238
+ const { devicesData, channels } = await api.getDevicesInfo();
239
+
240
+ logger.log(`getDevicesInfo completed. Found ${channels.length} channels.`);
241
+
242
+ // Process each channel that was successfully discovered
243
+ for (const channel of channels) {
244
+ try {
245
+ const { channelStatus, channelInfo, abilities } = devicesData[channel];
246
+ const name = channelStatus?.name;
247
+ const uid = channelStatus?.uid;
248
+ const isBattery = !!(abilities?.battery?.ver ?? 0);
249
+
250
+ const nativeId = this.buildNativeId(channel, uid, isBattery);
251
+ const interfaces = [ScryptedInterface.VideoCamera];
252
+ if (isBattery) {
253
+ interfaces.push(ScryptedInterface.Battery);
254
+ }
255
+ const type = abilities.supportDoorbellLight ? ScryptedDeviceType.Doorbell : ScryptedDeviceType.Camera;
256
+
257
+ const device: Device = {
258
+ nativeId,
259
+ name,
260
+ providerNativeId: this.nativeId,
261
+ interfaces,
262
+ type,
263
+ info: {
264
+ manufacturer: 'Reolink',
265
+ model: channelInfo?.typeInfo,
266
+ serialNumber: uid,
267
+ }
268
+ };
269
+
270
+ if (sdk.deviceManager.getNativeIds().includes(nativeId)) {
271
+ continue;
272
+ }
273
+
274
+ if (this.discoveredDevices.has(nativeId)) {
275
+ continue;
276
+ }
277
+
278
+ this.discoveredDevices.set(nativeId, {
279
+ device,
280
+ description: `${name} (Channel ${channel})`,
281
+ rtspChannel: channel,
282
+ deviceData: devicesData[channel],
283
+ });
284
+
285
+ logger.debug(`Discovered channel ${channel}: ${name}`);
286
+ } catch (e: any) {
287
+ logger.debug(`Error processing channel ${channel}: ${e?.message || String(e)}`);
288
+ }
289
+ }
290
+
291
+ logger.log(`Channel discovery completed. Found ${this.discoveredDevices.size} devices.`);
292
+ }
293
+
294
+ async discoverDevices(scan?: boolean): Promise<DiscoveredDevice[]> {
295
+ if (scan) {
296
+ await this.syncEntitiesFromRemote();
297
+ }
298
+
299
+ return [...this.discoveredDevices.values()].map(d => ({
300
+ ...d.device,
301
+ description: d.description,
302
+ }));
303
+ }
304
+
305
+ async adoptDevice(adopt: AdoptDevice): Promise<string> {
306
+ const entry = this.discoveredDevices.get(adopt.nativeId);
307
+
308
+ if (!entry)
309
+ throw new Error('device not found');
310
+
311
+ await this.onDeviceEvent(ScryptedInterface.DeviceDiscovery, await this.discoverDevices());
312
+
313
+ const isBattery = entry.device.interfaces.includes(ScryptedInterface.Battery);
314
+ const { channelStatus } = entry.deviceData;
315
+
316
+ const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
317
+ const transport = 'tcp';
318
+ const uid = channelStatus?.uid;
319
+ const normalizedUid = isBattery && uid ? normalizeUid(uid) : undefined;
320
+ const baichuanApi = new ReolinkBaichuanApi({
321
+ host: this.storageSettings.values.ipAddress,
322
+ username: this.storageSettings.values.username,
323
+ password: this.storageSettings.values.password,
324
+ transport,
325
+ channel: entry.rtspChannel,
326
+ ...(normalizedUid ? { uid: normalizedUid } : {}),
327
+ });
328
+ await baichuanApi.login();
329
+ const { capabilities, objects, presets } = await baichuanApi.getDeviceCapabilities(entry.rtspChannel);
330
+ const { interfaces, type } = getDeviceInterfaces({
331
+ capabilities,
332
+ logger: this.console,
333
+ });
334
+
335
+ const actualDevice: Device = {
336
+ ...entry.device,
337
+ interfaces,
338
+ type
339
+ };
340
+
341
+ await sdk.deviceManager.onDeviceDiscovered(actualDevice);
342
+
343
+ const device = await this.getDevice(adopt.nativeId);
344
+ this.console.log('Adopted device', entry, device?.name);
345
+ const { username, password, ipAddress } = this.storageSettings.values;
346
+
347
+ device.storageSettings.values.rtspChannel = entry.rtspChannel;
348
+ device.classes = objects;
349
+ device.presets = presets;
350
+ device.storageSettings.values.username = username;
351
+ device.storageSettings.values.password = password;
352
+ device.storageSettings.values.rtspChannel = entry.rtspChannel;
353
+ device.storageSettings.values.ipAddress = ipAddress;
354
+ device.storageSettings.values.capabilities = capabilities;
355
+ device.storageSettings.values.uid = entry.deviceData.channelStatus.uid;
356
+ device.storageSettings.values.isFromNvr = true;
357
+
358
+ device.updateDeviceInfo();
359
+
360
+ this.discoveredDevices.delete(adopt.nativeId);
361
+ return device?.id;
362
+ }
363
+ }
364
+
package/src/presets.ts CHANGED
@@ -90,7 +90,7 @@ export class ReolinkPtzPresets {
90
90
 
91
91
  async refreshPtzPresets(): Promise<PtzPreset[]> {
92
92
  const client = await this.camera.ensureClient();
93
- const channel = this.camera.getRtspChannel();
93
+ const channel = this.camera.storageSettings.values.rtspChannel;
94
94
  const presets = await client.getPtzPresets(channel);
95
95
  this.setCachedPtzPresets(presets);
96
96
  this.syncEnabledPresetsSettingAndCaps(presets);
@@ -99,14 +99,14 @@ export class ReolinkPtzPresets {
99
99
 
100
100
  async moveToPreset(presetId: number): Promise<void> {
101
101
  const client = await this.camera.ensureClient();
102
- const channel = this.camera.getRtspChannel();
102
+ const channel = this.camera.storageSettings.values.rtspChannel;
103
103
  await client.moveToPtzPreset(channel, presetId);
104
104
  }
105
105
 
106
106
  /** Create a new PTZ preset at current position. */
107
107
  async createPtzPreset(name: string, presetId?: number): Promise<PtzPreset> {
108
108
  const client = await this.camera.ensureClient();
109
- const channel = this.camera.getRtspChannel();
109
+ const channel = this.camera.storageSettings.values.rtspChannel;
110
110
  const trimmed = String(name ?? '').trim();
111
111
  if (!trimmed) throw new Error('Preset name is required');
112
112
  const existing = await client.getPtzPresets(channel);
@@ -150,7 +150,7 @@ export class ReolinkPtzPresets {
150
150
  /** Overwrite an existing preset with the current PTZ position (and keep its current name). */
151
151
  async updatePtzPresetToCurrentPosition(presetId: number): Promise<void> {
152
152
  const client = await this.camera.ensureClient();
153
- const channel = this.camera.getRtspChannel();
153
+ const channel = this.camera.storageSettings.values.rtspChannel;
154
154
 
155
155
  const current = this.getCachedPtzPresets();
156
156
  const found = current.find((p) => p.id === presetId);
@@ -163,7 +163,7 @@ export class ReolinkPtzPresets {
163
163
  /** Best-effort delete/disable a preset (firmware dependent). */
164
164
  async deletePtzPreset(presetId: number): Promise<void> {
165
165
  const client = await this.camera.ensureClient();
166
- const channel = this.camera.getRtspChannel();
166
+ const channel = this.camera.storageSettings.values.rtspChannel;
167
167
  await client.deletePtzPreset(channel, presetId);
168
168
 
169
169
  // Keep enabled preset list clean (remove deleted id), but do not rewrite names for others.
@@ -181,7 +181,7 @@ export class ReolinkPtzPresets {
181
181
  /** Rename a preset while trying to preserve its position (will move camera to that preset first). */
182
182
  async renamePtzPreset(presetId: number, newName: string): Promise<void> {
183
183
  const client = await this.camera.ensureClient();
184
- const channel = this.camera.getRtspChannel();
184
+ const channel = this.camera.storageSettings.values.rtspChannel;
185
185
  const trimmed = String(newName ?? '').trim();
186
186
  if (!trimmed) throw new Error('Preset name is required');
187
187
 
@@ -97,14 +97,18 @@ export async function fetchVideoStreamOptionsFromApi(
97
97
  }
98
98
 
99
99
  export async function buildVideoStreamOptionsFromRtspRtmp(
100
- client: ReolinkBaichuanApi,
101
- channel: number,
102
- ipAddress: string,
103
- username: string,
104
- password: string,
105
- cachedNetPort?: { rtsp?: { port?: number; enable?: number }; rtmp?: { port?: number; enable?: number } },
100
+ props: {
101
+ client: ReolinkBaichuanApi,
102
+ ipAddress: string,
103
+ cachedNetPort: { rtsp?: { port?: number; enable?: number }; rtmp?: { port?: number; enable?: number } },
104
+ isFromNvr: boolean,
105
+ rtspChannel: number,
106
+ logger: Console
107
+ },
106
108
  ): Promise<UrlMediaStreamOptions[]> {
107
- const streams: UrlMediaStreamOptions[] = [];
109
+ const { client, ipAddress, cachedNetPort, rtspChannel, logger } = props;
110
+ const rtspStreams: UrlMediaStreamOptions[] = [];
111
+ const rtmpStreams: UrlMediaStreamOptions[] = [];
108
112
 
109
113
  // Use cached net port if provided, otherwise fetch it
110
114
  const netPort = cachedNetPort || await client.getNetPort();
@@ -113,13 +117,8 @@ export async function buildVideoStreamOptionsFromRtspRtmp(
113
117
  const rtspPort = netPort.rtsp?.port ?? 554;
114
118
  const rtmpPort = netPort.rtmp?.port ?? 1935;
115
119
 
116
- if (!rtspEnabled && !rtmpEnabled) {
117
- // If neither RTSP nor RTMP are enabled, return empty array
118
- return streams;
119
- }
120
-
121
120
  // Get stream metadata to build options
122
- const streamMetadata = await client.getStreamMetadata(channel);
121
+ const streamMetadata = await client.getStreamMetadata(rtspChannel);
123
122
  const list = streamMetadata?.streams || [];
124
123
 
125
124
  for (const stream of list) {
@@ -134,12 +133,12 @@ export async function buildVideoStreamOptionsFromRtspRtmp(
134
133
  if (rtspEnabled && profile !== 'ext') {
135
134
  // RTSP format: rtsp://ip:port/h264Preview_XX_profile
136
135
  // XX is 1-based channel with 2-digit padding
137
- const channelStr = String(channel + 1).padStart(2, '0');
136
+ const channelStr = String(rtspChannel + 1).padStart(2, '0');
138
137
  const profileStr = profile === 'main' ? 'main' : 'sub';
139
138
  const rtspPath = `/h264Preview_${channelStr}_${profileStr}`;
140
139
  const rtspId = `h264Preview_${channelStr}_${profileStr}`;
141
140
 
142
- streams.push({
141
+ rtspStreams.push({
143
142
  name: `RTSP ${rtspId}`,
144
143
  id: rtspId,
145
144
  container: 'rtsp',
@@ -160,14 +159,14 @@ export async function buildVideoStreamOptionsFromRtspRtmp(
160
159
  const rtmpId = `${streamName}.bcs`; // ID for Scrypted (main.bcs, sub.bcs, ext.bcs)
161
160
 
162
161
  // Use channel directly (0-based) in path, matching reolink_aio behavior
163
- const rtmpPath = `/bcs/channel${channel}_${streamName}.bcs`;
162
+ const rtmpPath = `/bcs/channel${rtspChannel}_${streamName}.bcs`;
164
163
  const rtmpUrl = new URL(`rtmp://${ipAddress}:${rtmpPort}${rtmpPath}`);
165
164
  const params = rtmpUrl.searchParams;
166
- params.set('channel', channel.toString());
165
+ params.set('channel', rtspChannel.toString());
167
166
  params.set('stream', streamType.toString());
168
167
  // Credentials will be added by addRtspCredentials as user/password query params
169
168
 
170
- streams.push({
169
+ rtmpStreams.push({
171
170
  name: `RTMP ${rtmpId}`,
172
171
  id: rtmpId,
173
172
  container: 'rtmp',
@@ -177,12 +176,14 @@ export async function buildVideoStreamOptionsFromRtspRtmp(
177
176
  }
178
177
  }
179
178
 
180
- // Sort streams: RTMP first, then RTSP
181
- streams.sort((a, b) => {
182
- if (a.container === 'rtmp' && b.container !== 'rtmp') return -1;
183
- if (a.container !== 'rtmp' && b.container === 'rtmp') return 1;
184
- return 0;
185
- });
179
+
180
+ const nativeStreams = await fetchVideoStreamOptionsFromApi(client, rtspChannel, logger);
181
+
182
+ const streams: UrlMediaStreamOptions[] = [
183
+ ...rtspStreams,
184
+ ...rtmpStreams,
185
+ ...nativeStreams,
186
+ ];
186
187
 
187
188
  return streams;
188
189
  }
@@ -378,7 +379,7 @@ export class StreamManager {
378
379
  async closeAllStreams(reason?: string): Promise<void> {
379
380
  const servers = Array.from(this.nativeRfcServers.values());
380
381
  this.nativeRfcServers.clear();
381
-
382
+
382
383
  await Promise.allSettled(
383
384
  servers.map(async (server) => {
384
385
  try {