@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/.vscode/settings.json +1 -1
- package/README.md +12 -3
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +2 -2
- package/src/camera-battery.ts +72 -64
- package/src/camera.ts +11 -13
- package/src/common.ts +82 -65
- package/src/connect.ts +136 -0
- package/src/intercom.ts +1 -1
- package/src/main.ts +135 -80
- package/src/nvr.ts +364 -0
- package/src/presets.ts +6 -6
- package/src/stream-utils.ts +26 -25
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
package/src/stream-utils.ts
CHANGED
|
@@ -97,14 +97,18 @@ export async function fetchVideoStreamOptionsFromApi(
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
export async function buildVideoStreamOptionsFromRtspRtmp(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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${
|
|
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',
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 {
|