@apocaliss92/scrypted-reolink-native 0.1.7 → 0.1.9
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera-battery.ts +9 -4
- package/src/camera.ts +1 -1
- package/src/common.ts +47 -8
- package/src/nvr.ts +351 -14
- package/logs.txt +0 -7361
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/camera-battery.ts
CHANGED
|
@@ -36,10 +36,15 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
async takePicture(options?: RequestPictureOptions): Promise<MediaObject> {
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
39
|
+
// Allow new snapshot if:
|
|
40
|
+
// 1. forceNewSnapshot is true, OR
|
|
41
|
+
// 2. Camera is awake AND last snapshot was taken at least 10 seconds ago
|
|
42
|
+
const minSnapshotIntervalMs = 10_000; // 10 seconds
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const shouldTakeNewSnapshot = this.forceNewSnapshot ||
|
|
45
|
+
(!this.sleeping && this.lastPicture && (now - this.lastPicture.atMs >= minSnapshotIntervalMs));
|
|
46
|
+
|
|
47
|
+
if (!shouldTakeNewSnapshot && this.lastPicture) {
|
|
43
48
|
this.console.log(`Returning cached snapshot, taken at ${new Date(this.lastPicture.atMs).toLocaleString()}`);
|
|
44
49
|
return this.lastPicture.mo;
|
|
45
50
|
}
|
package/src/camera.ts
CHANGED
|
@@ -209,7 +209,7 @@ export class ReolinkNativeCamera extends CommonCameraMixin {
|
|
|
209
209
|
try {
|
|
210
210
|
return this.withBaichuanRetry(async () => {
|
|
211
211
|
const client = await this.ensureClient();
|
|
212
|
-
const snapshotBuffer = await client.getSnapshot();
|
|
212
|
+
const snapshotBuffer = await client.getSnapshot(this.storageSettings.values.rtspChannel);
|
|
213
213
|
const mo = await this.createMediaObject(snapshotBuffer, 'image/jpeg');
|
|
214
214
|
|
|
215
215
|
return mo;
|
package/src/common.ts
CHANGED
|
@@ -487,6 +487,8 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
487
487
|
protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
488
488
|
protected connectionTime: number | undefined;
|
|
489
489
|
private closeListener?: () => void;
|
|
490
|
+
private lastDisconnectTime: number = 0;
|
|
491
|
+
private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
|
|
490
492
|
protected readonly protocol: BaichuanTransport;
|
|
491
493
|
private debugLogsResetTimeout: NodeJS.Timeout | undefined;
|
|
492
494
|
|
|
@@ -1329,6 +1331,11 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1329
1331
|
|
|
1330
1332
|
// Client management
|
|
1331
1333
|
async ensureClient(): Promise<ReolinkBaichuanApi> {
|
|
1334
|
+
// If camera is connected to NVR, use NVR's shared Baichuan connection
|
|
1335
|
+
if (this.nvrDevice) {
|
|
1336
|
+
return await this.nvrDevice.ensureBaichuanClient();
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1332
1339
|
// Reuse existing client if socket is still connected and logged in
|
|
1333
1340
|
if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
1334
1341
|
return this.baichuanApi;
|
|
@@ -1337,6 +1344,17 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1337
1344
|
// Prevent concurrent login storms
|
|
1338
1345
|
if (this.ensureClientPromise) return await this.ensureClientPromise;
|
|
1339
1346
|
|
|
1347
|
+
// Apply backoff to avoid aggressive reconnection after disconnection
|
|
1348
|
+
if (this.lastDisconnectTime > 0) {
|
|
1349
|
+
const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
|
|
1350
|
+
if (timeSinceDisconnect < this.reconnectBackoffMs) {
|
|
1351
|
+
const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
|
|
1352
|
+
const logger = this.getLogger();
|
|
1353
|
+
logger.log(`[BaichuanClient] Waiting ${waitTime}ms before reconnection (backoff)`);
|
|
1354
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1340
1358
|
this.ensureClientPromise = (async () => {
|
|
1341
1359
|
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
1342
1360
|
|
|
@@ -1355,11 +1373,8 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1355
1373
|
|
|
1356
1374
|
const isConnected = this.baichuanApi.client.isSocketConnected();
|
|
1357
1375
|
if (!isConnected) {
|
|
1358
|
-
// Socket is closed, clean up
|
|
1359
1376
|
try {
|
|
1360
|
-
|
|
1361
|
-
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1362
|
-
}
|
|
1377
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1363
1378
|
}
|
|
1364
1379
|
catch {
|
|
1365
1380
|
// ignore
|
|
@@ -1425,21 +1440,45 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1425
1440
|
this.closeListener = () => {
|
|
1426
1441
|
const logger = this.getLogger();
|
|
1427
1442
|
if (this.baichuanApi === api) {
|
|
1428
|
-
|
|
1443
|
+
const now = Date.now();
|
|
1444
|
+
const timeSinceLastDisconnect = now - this.lastDisconnectTime;
|
|
1445
|
+
this.lastDisconnectTime = now;
|
|
1446
|
+
|
|
1447
|
+
logger.log(`[BaichuanClient] Socket closed, resetting client state for reconnection (last disconnect ${timeSinceLastDisconnect}ms ago)`);
|
|
1448
|
+
|
|
1449
|
+
// Reset client state
|
|
1429
1450
|
this.baichuanApi = undefined;
|
|
1430
1451
|
this.ensureClientPromise = undefined;
|
|
1431
1452
|
this.closeListener = undefined;
|
|
1453
|
+
|
|
1454
|
+
// Remove event handler to prevent operations during reconnection
|
|
1455
|
+
try {
|
|
1456
|
+
if (this.onSimpleEvent) {
|
|
1457
|
+
api.offSimpleEvent(this.onSimpleEvent);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
// ignore
|
|
1462
|
+
}
|
|
1432
1463
|
}
|
|
1433
1464
|
};
|
|
1434
1465
|
api.client.on("close", this.closeListener);
|
|
1435
1466
|
|
|
1436
1467
|
// Re-attach event handler if enabled
|
|
1468
|
+
// Note: We don't reattach here immediately to avoid operations being called
|
|
1469
|
+
// during reconnection. subscribeToEvents() will be called when needed.
|
|
1470
|
+
// However, if events were already subscribed, we need to reattach them.
|
|
1471
|
+
// We'll let subscribeToEvents() handle this, but we can also try here if needed.
|
|
1437
1472
|
if (this.isEventDispatchEnabled?.() && this.onSimpleEvent) {
|
|
1438
1473
|
try {
|
|
1439
|
-
|
|
1474
|
+
// Verify connection is fully ready before subscribing
|
|
1475
|
+
if (api.client.isSocketConnected() && api.client.loggedIn) {
|
|
1476
|
+
api.onSimpleEvent(this.onSimpleEvent);
|
|
1477
|
+
}
|
|
1440
1478
|
}
|
|
1441
|
-
catch {
|
|
1442
|
-
|
|
1479
|
+
catch (e) {
|
|
1480
|
+
const logger = this.getLogger();
|
|
1481
|
+
logger.warn(`[BaichuanClient] Failed to reattach event handler after reconnection, will retry via subscribeToEvents()`, e);
|
|
1443
1482
|
}
|
|
1444
1483
|
}
|
|
1445
1484
|
return api;
|
package/src/nvr.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DeviceInfoResponse, DeviceInputData, ReolinkCgiApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
1
|
+
import type { DeviceInfoResponse, DeviceInputData, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
2
|
import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
|
3
3
|
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
|
4
4
|
import { ReolinkNativeCamera } from "./camera";
|
|
@@ -14,6 +14,17 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
14
14
|
type: 'boolean',
|
|
15
15
|
immediate: true,
|
|
16
16
|
},
|
|
17
|
+
eventSource: {
|
|
18
|
+
title: 'Event Source',
|
|
19
|
+
description: 'Select the source for camera events: Native (Baichuan) or CGI (HTTP polling)',
|
|
20
|
+
type: 'string',
|
|
21
|
+
choices: ['Native', 'CGI'],
|
|
22
|
+
defaultValue: 'Native',
|
|
23
|
+
immediate: true,
|
|
24
|
+
onPut: async () => {
|
|
25
|
+
await this.reinitEventSubscriptions();
|
|
26
|
+
}
|
|
27
|
+
},
|
|
17
28
|
ipAddress: {
|
|
18
29
|
title: 'IP address',
|
|
19
30
|
type: 'string',
|
|
@@ -34,6 +45,8 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
34
45
|
});
|
|
35
46
|
plugin: ReolinkNativePlugin;
|
|
36
47
|
nvrApi: ReolinkCgiApi | undefined;
|
|
48
|
+
baichuanApi: ReolinkBaichuanApi | undefined;
|
|
49
|
+
baichuanApiPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
37
50
|
discoveredDevices = new Map<string, {
|
|
38
51
|
device: Device;
|
|
39
52
|
description: string;
|
|
@@ -44,7 +57,11 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
44
57
|
lastErrorsCheck: number | undefined;
|
|
45
58
|
lastDevicesStatusCheck: number | undefined;
|
|
46
59
|
cameraNativeMap = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
|
|
60
|
+
private channelToNativeIdMap = new Map<number, string>();
|
|
47
61
|
processing = false;
|
|
62
|
+
private eventSubscriptionActive = false;
|
|
63
|
+
private errorListener?: (err: unknown) => void;
|
|
64
|
+
private closeListener?: () => void;
|
|
48
65
|
|
|
49
66
|
constructor(nativeId: string, plugin: ReolinkNativePlugin) {
|
|
50
67
|
super(nativeId);
|
|
@@ -64,7 +81,98 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
64
81
|
return this.console;
|
|
65
82
|
}
|
|
66
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Centralized cleanup method for Baichuan API
|
|
86
|
+
* Removes all listeners, closes connection, and resets state
|
|
87
|
+
*/
|
|
88
|
+
private async cleanupBaichuanApi(): Promise<void> {
|
|
89
|
+
if (!this.baichuanApi) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const api = this.baichuanApi;
|
|
94
|
+
|
|
95
|
+
// Unsubscribe from events first
|
|
96
|
+
await this.unsubscribeFromAllEvents();
|
|
97
|
+
|
|
98
|
+
// Remove all listeners
|
|
99
|
+
try {
|
|
100
|
+
api.client.off("close", this.closeListener);
|
|
101
|
+
} catch {
|
|
102
|
+
// ignore
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
api.client.off("error", this.errorListener);
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Close connection if still connected
|
|
112
|
+
try {
|
|
113
|
+
if (api.client.isSocketConnected()) {
|
|
114
|
+
await api.close();
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// ignore
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Reset state
|
|
121
|
+
this.baichuanApi = undefined;
|
|
122
|
+
this.baichuanApiPromise = undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Attach error and close listeners to Baichuan API
|
|
127
|
+
*/
|
|
128
|
+
private attachBaichuanListeners(api: ReolinkBaichuanApi): void {
|
|
129
|
+
const logger = this.getLogger();
|
|
130
|
+
|
|
131
|
+
// Error listener
|
|
132
|
+
this.errorListener = (err: unknown) => {
|
|
133
|
+
const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
|
|
134
|
+
|
|
135
|
+
// Only log if it's not a recoverable error to avoid spam
|
|
136
|
+
if (typeof msg === 'string' && (
|
|
137
|
+
msg.includes('Baichuan socket closed') ||
|
|
138
|
+
msg.includes('Baichuan UDP stream closed') ||
|
|
139
|
+
msg.includes('Not running')
|
|
140
|
+
)) {
|
|
141
|
+
logger.debug(`[NVR BaichuanClient] error (recoverable): ${msg}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
logger.error(`[NVR BaichuanClient] error: ${msg}`);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
// Close listener
|
|
148
|
+
this.closeListener = async () => {
|
|
149
|
+
try {
|
|
150
|
+
const wasConnected = api.client.isSocketConnected();
|
|
151
|
+
const wasLoggedIn = api.client.loggedIn;
|
|
152
|
+
logger.log(`[NVR BaichuanClient] Connection state before close: connected=${wasConnected}, loggedIn=${wasLoggedIn}`);
|
|
153
|
+
|
|
154
|
+
// Try to get last message info if available
|
|
155
|
+
const client = api.client as any;
|
|
156
|
+
if (client?.lastRx || client?.lastTx) {
|
|
157
|
+
logger.log(`[NVR BaichuanClient] Last message info: lastRx=${JSON.stringify(client.lastRx)}, lastTx=${JSON.stringify(client.lastTx)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
logger.debug(`[NVR BaichuanClient] Could not get connection state: ${e}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Cleanup and reinit
|
|
165
|
+
await this.cleanupBaichuanApi();
|
|
166
|
+
await this.reinit();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Attach listeners
|
|
170
|
+
api.client.on("error", this.errorListener);
|
|
171
|
+
api.client.on("close", this.closeListener);
|
|
172
|
+
}
|
|
173
|
+
|
|
67
174
|
async reinit() {
|
|
175
|
+
// Cleanup CGI API
|
|
68
176
|
if (this.nvrApi) {
|
|
69
177
|
try {
|
|
70
178
|
await this.nvrApi.logout();
|
|
@@ -73,6 +181,9 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
73
181
|
}
|
|
74
182
|
}
|
|
75
183
|
this.nvrApi = undefined;
|
|
184
|
+
|
|
185
|
+
// Cleanup Baichuan API (this handles all listeners and connection)
|
|
186
|
+
await this.cleanupBaichuanApi();
|
|
76
187
|
}
|
|
77
188
|
|
|
78
189
|
async ensureClient(): Promise<ReolinkCgiApi> {
|
|
@@ -96,11 +207,242 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
96
207
|
return this.nvrApi;
|
|
97
208
|
}
|
|
98
209
|
|
|
210
|
+
private forwardNativeEvent(ev: ReolinkSimpleEvent): void {
|
|
211
|
+
const logger = this.getLogger();
|
|
212
|
+
|
|
213
|
+
const eventSource = this.storageSettings.values.eventSource || 'Native';
|
|
214
|
+
if (eventSource !== 'Native') {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
if (this.storageSettings.values.debugEvents) {
|
|
220
|
+
logger.log(`NVR Baichuan event: ${JSON.stringify(ev)}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Find camera for this channel
|
|
224
|
+
const channel = ev?.channel;
|
|
225
|
+
if (channel === undefined) {
|
|
226
|
+
if (this.storageSettings.values.debugEvents) {
|
|
227
|
+
logger.debug('Event has no channel, ignoring');
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const nativeId = this.channelToNativeIdMap.get(channel);
|
|
233
|
+
const targetCamera = nativeId ? this.cameraNativeMap.get(nativeId) : undefined;
|
|
234
|
+
|
|
235
|
+
if (!targetCamera) {
|
|
236
|
+
if (this.storageSettings.values.debugEvents) {
|
|
237
|
+
logger.debug(`No camera found for channel ${channel}, ignoring event`);
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Convert event to camera's processEvents format
|
|
243
|
+
const objects: string[] = [];
|
|
244
|
+
let motion = false;
|
|
245
|
+
|
|
246
|
+
switch (ev?.type) {
|
|
247
|
+
case 'motion':
|
|
248
|
+
motion = true;
|
|
249
|
+
break;
|
|
250
|
+
case 'doorbell':
|
|
251
|
+
// Handle doorbell if camera supports it
|
|
252
|
+
try {
|
|
253
|
+
if (typeof (targetCamera as any).handleDoorbellEvent === 'function') {
|
|
254
|
+
(targetCamera as any).handleDoorbellEvent();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
logger.warn(`Error handling doorbell event for camera channel ${channel}`, e);
|
|
259
|
+
}
|
|
260
|
+
motion = true;
|
|
261
|
+
break;
|
|
262
|
+
case 'people':
|
|
263
|
+
case 'vehicle':
|
|
264
|
+
case 'animal':
|
|
265
|
+
case 'face':
|
|
266
|
+
case 'package':
|
|
267
|
+
case 'other':
|
|
268
|
+
objects.push(ev.type);
|
|
269
|
+
motion = true;
|
|
270
|
+
break;
|
|
271
|
+
default:
|
|
272
|
+
if (this.storageSettings.values.debugEvents) {
|
|
273
|
+
logger.debug(`Unknown event type: ${ev?.type}`);
|
|
274
|
+
}
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Process events on the target camera
|
|
279
|
+
targetCamera.processEvents({ motion, objects }).catch((e) => {
|
|
280
|
+
logger.warn(`Error processing events for camera channel ${channel}`, e);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
logger.warn('Error in NVR Native event forwarder', e);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async onSimpleEventHandler(ev: ReolinkSimpleEvent) {
|
|
289
|
+
this.forwardNativeEvent(ev);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
|
|
293
|
+
// Reuse existing client if socket is still connected and logged in
|
|
294
|
+
if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
295
|
+
return this.baichuanApi;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Prevent concurrent login storms
|
|
299
|
+
if (this.baichuanApiPromise) return await this.baichuanApiPromise;
|
|
300
|
+
|
|
301
|
+
this.baichuanApiPromise = (async () => {
|
|
302
|
+
const { ipAddress, username, password } = this.storageSettings.values;
|
|
303
|
+
if (!ipAddress || !username || !password) {
|
|
304
|
+
throw new Error('Missing NVR credentials');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Clean up old client if exists
|
|
308
|
+
if (this.baichuanApi) {
|
|
309
|
+
await this.cleanupBaichuanApi();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Create new Baichuan client
|
|
313
|
+
const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
|
|
314
|
+
this.baichuanApi = new ReolinkBaichuanApi({
|
|
315
|
+
host: ipAddress,
|
|
316
|
+
username,
|
|
317
|
+
password,
|
|
318
|
+
transport: 'tcp',
|
|
319
|
+
logger: this.getLogger(),
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
await this.baichuanApi.login();
|
|
323
|
+
|
|
324
|
+
// Verify socket is connected before returning
|
|
325
|
+
if (!this.baichuanApi.client.isSocketConnected()) {
|
|
326
|
+
throw new Error('Socket not connected after login');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Attach listeners (error and close)
|
|
330
|
+
this.attachBaichuanListeners(this.baichuanApi);
|
|
331
|
+
|
|
332
|
+
return this.baichuanApi;
|
|
333
|
+
})();
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
return await this.baichuanApiPromise;
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
// Allow future reconnects and avoid pinning rejected promises
|
|
340
|
+
this.baichuanApiPromise = undefined;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async subscribeToAllEvents(): Promise<void> {
|
|
345
|
+
const logger = this.getLogger();
|
|
346
|
+
|
|
347
|
+
// If already subscribed and connection is valid, return
|
|
348
|
+
if (this.eventSubscriptionActive && this.baichuanApi) {
|
|
349
|
+
if (this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
350
|
+
logger.log('Event subscription already active');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Connection is invalid, reset subscription state
|
|
354
|
+
this.eventSubscriptionActive = false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Unsubscribe first if handler exists (idempotent)
|
|
358
|
+
await this.unsubscribeFromAllEvents();
|
|
359
|
+
|
|
360
|
+
// Get Baichuan client connection
|
|
361
|
+
const api = await this.ensureBaichuanClient();
|
|
362
|
+
|
|
363
|
+
// Verify connection is ready
|
|
364
|
+
if (!api.client.isSocketConnected() || !api.client.loggedIn) {
|
|
365
|
+
logger.warn('Cannot subscribe to events: connection not ready');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Subscribe to events
|
|
370
|
+
try {
|
|
371
|
+
await api.onSimpleEvent(this.onSimpleEventHandler);
|
|
372
|
+
this.eventSubscriptionActive = true;
|
|
373
|
+
logger.log('Subscribed to all events for NVR cameras');
|
|
374
|
+
}
|
|
375
|
+
catch (e) {
|
|
376
|
+
logger.warn('Failed to subscribe to events', e);
|
|
377
|
+
this.eventSubscriptionActive = false;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async unsubscribeFromAllEvents(): Promise<void> {
|
|
382
|
+
const logger = this.getLogger();
|
|
383
|
+
|
|
384
|
+
// Only unsubscribe if we have an active subscription
|
|
385
|
+
if (this.eventSubscriptionActive && this.baichuanApi) {
|
|
386
|
+
try {
|
|
387
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEventHandler);
|
|
388
|
+
logger.log('Unsubscribed from all events');
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
logger.warn('Error unsubscribing from events', e);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
this.eventSubscriptionActive = false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Reinitialize event subscriptions based on selected event source
|
|
400
|
+
*/
|
|
401
|
+
private async reinitEventSubscriptions(): Promise<void> {
|
|
402
|
+
const logger = this.getLogger();
|
|
403
|
+
const { eventSource } = this.storageSettings.values;
|
|
404
|
+
|
|
405
|
+
// Unsubscribe from Native events if switching away
|
|
406
|
+
if (eventSource !== 'Native') {
|
|
407
|
+
await this.unsubscribeFromAllEvents();
|
|
408
|
+
} else {
|
|
409
|
+
|
|
410
|
+
this.subscribeToAllEvents().catch((e) => {
|
|
411
|
+
logger.warn('Failed to subscribe to Native events', e);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
logger.log(`Event source set to: ${eventSource}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Forward events from CGI source to cameras
|
|
420
|
+
*/
|
|
421
|
+
private forwardCgiEvents(eventsRes: Record<number, EventsResponse>): void {
|
|
422
|
+
const logger = this.getLogger();
|
|
423
|
+
|
|
424
|
+
if (this.storageSettings.values.debugEvents) {
|
|
425
|
+
logger.debug(`CGI Events call result: ${JSON.stringify(eventsRes)}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Use channel map for efficient lookup
|
|
429
|
+
for (const [channel, nativeId] of this.channelToNativeIdMap.entries()) {
|
|
430
|
+
const targetCamera = nativeId ? this.cameraNativeMap.get(nativeId) : undefined;
|
|
431
|
+
const cameraEventsData = eventsRes[channel];
|
|
432
|
+
if (cameraEventsData && targetCamera) {
|
|
433
|
+
targetCamera.processEvents(cameraEventsData);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
99
438
|
async init() {
|
|
100
439
|
const api = await this.ensureClient();
|
|
101
440
|
const logger = this.getLogger();
|
|
102
441
|
await this.updateDeviceInfo();
|
|
103
442
|
|
|
443
|
+
// Initialize event subscriptions based on selected source
|
|
444
|
+
await this.reinitEventSubscriptions();
|
|
445
|
+
|
|
104
446
|
setInterval(async () => {
|
|
105
447
|
if (this.processing || !api) {
|
|
106
448
|
return;
|
|
@@ -127,21 +469,14 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
127
469
|
await this.discoverDevices(true);
|
|
128
470
|
}
|
|
129
471
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (
|
|
133
|
-
|
|
472
|
+
// Only fetch and forward CGI events if CGI is selected as event source
|
|
473
|
+
const { eventSource } = this.storageSettings.values;
|
|
474
|
+
if (eventSource === 'CGI') {
|
|
475
|
+
const eventsRes = await api.getAllChannelsEvents();
|
|
476
|
+
this.forwardCgiEvents(eventsRes.parsed);
|
|
134
477
|
}
|
|
135
|
-
this.cameraNativeMap.forEach((camera) => {
|
|
136
|
-
if (camera) {
|
|
137
|
-
const channel = camera.storageSettings.values.rtspChannel;
|
|
138
|
-
const cameraEventsData = eventsRes?.parsed[channel];
|
|
139
|
-
if (cameraEventsData) {
|
|
140
|
-
camera.processEvents(cameraEventsData);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
});
|
|
144
478
|
|
|
479
|
+
// Always fetch battery info (not event-related)
|
|
145
480
|
const { batteryInfoData, response } = await api.getAllChannelsBatteryInfo();
|
|
146
481
|
|
|
147
482
|
if (this.storageSettings.values.debugEvents) {
|
|
@@ -272,6 +607,8 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
272
607
|
}
|
|
273
608
|
};
|
|
274
609
|
|
|
610
|
+
this.channelToNativeIdMap.set(channel, nativeId);
|
|
611
|
+
|
|
275
612
|
if (sdk.deviceManager.getNativeIds().includes(nativeId)) {
|
|
276
613
|
continue;
|
|
277
614
|
}
|