@apocaliss92/scrypted-reolink-native 0.1.7 → 0.1.8
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 +48 -8
- package/src/nvr.ts +416 -1
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,12 @@ 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
|
+
const logger = this.getLogger();
|
|
1337
|
+
return await this.nvrDevice.ensureBaichuanClient();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1332
1340
|
// Reuse existing client if socket is still connected and logged in
|
|
1333
1341
|
if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
1334
1342
|
return this.baichuanApi;
|
|
@@ -1337,6 +1345,17 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1337
1345
|
// Prevent concurrent login storms
|
|
1338
1346
|
if (this.ensureClientPromise) return await this.ensureClientPromise;
|
|
1339
1347
|
|
|
1348
|
+
// Apply backoff to avoid aggressive reconnection after disconnection
|
|
1349
|
+
if (this.lastDisconnectTime > 0) {
|
|
1350
|
+
const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
|
|
1351
|
+
if (timeSinceDisconnect < this.reconnectBackoffMs) {
|
|
1352
|
+
const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
|
|
1353
|
+
const logger = this.getLogger();
|
|
1354
|
+
logger.log(`[BaichuanClient] Waiting ${waitTime}ms before reconnection (backoff)`);
|
|
1355
|
+
await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1340
1359
|
this.ensureClientPromise = (async () => {
|
|
1341
1360
|
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
1342
1361
|
|
|
@@ -1355,11 +1374,8 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1355
1374
|
|
|
1356
1375
|
const isConnected = this.baichuanApi.client.isSocketConnected();
|
|
1357
1376
|
if (!isConnected) {
|
|
1358
|
-
// Socket is closed, clean up
|
|
1359
1377
|
try {
|
|
1360
|
-
|
|
1361
|
-
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1362
|
-
}
|
|
1378
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1363
1379
|
}
|
|
1364
1380
|
catch {
|
|
1365
1381
|
// ignore
|
|
@@ -1425,21 +1441,45 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1425
1441
|
this.closeListener = () => {
|
|
1426
1442
|
const logger = this.getLogger();
|
|
1427
1443
|
if (this.baichuanApi === api) {
|
|
1428
|
-
|
|
1444
|
+
const now = Date.now();
|
|
1445
|
+
const timeSinceLastDisconnect = now - this.lastDisconnectTime;
|
|
1446
|
+
this.lastDisconnectTime = now;
|
|
1447
|
+
|
|
1448
|
+
logger.log(`[BaichuanClient] Socket closed, resetting client state for reconnection (last disconnect ${timeSinceLastDisconnect}ms ago)`);
|
|
1449
|
+
|
|
1450
|
+
// Reset client state
|
|
1429
1451
|
this.baichuanApi = undefined;
|
|
1430
1452
|
this.ensureClientPromise = undefined;
|
|
1431
1453
|
this.closeListener = undefined;
|
|
1454
|
+
|
|
1455
|
+
// Remove event handler to prevent operations during reconnection
|
|
1456
|
+
try {
|
|
1457
|
+
if (this.onSimpleEvent) {
|
|
1458
|
+
api.offSimpleEvent(this.onSimpleEvent);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
// ignore
|
|
1463
|
+
}
|
|
1432
1464
|
}
|
|
1433
1465
|
};
|
|
1434
1466
|
api.client.on("close", this.closeListener);
|
|
1435
1467
|
|
|
1436
1468
|
// Re-attach event handler if enabled
|
|
1469
|
+
// Note: We don't reattach here immediately to avoid operations being called
|
|
1470
|
+
// during reconnection. subscribeToEvents() will be called when needed.
|
|
1471
|
+
// However, if events were already subscribed, we need to reattach them.
|
|
1472
|
+
// We'll let subscribeToEvents() handle this, but we can also try here if needed.
|
|
1437
1473
|
if (this.isEventDispatchEnabled?.() && this.onSimpleEvent) {
|
|
1438
1474
|
try {
|
|
1439
|
-
|
|
1475
|
+
// Verify connection is fully ready before subscribing
|
|
1476
|
+
if (api.client.isSocketConnected() && api.client.loggedIn) {
|
|
1477
|
+
api.onSimpleEvent(this.onSimpleEvent);
|
|
1478
|
+
}
|
|
1440
1479
|
}
|
|
1441
|
-
catch {
|
|
1442
|
-
|
|
1480
|
+
catch (e) {
|
|
1481
|
+
const logger = this.getLogger();
|
|
1482
|
+
logger.warn(`[BaichuanClient] Failed to reattach event handler after reconnection, will retry via subscribeToEvents()`, e);
|
|
1443
1483
|
}
|
|
1444
1484
|
}
|
|
1445
1485
|
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, 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";
|
|
@@ -34,6 +34,8 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
34
34
|
});
|
|
35
35
|
plugin: ReolinkNativePlugin;
|
|
36
36
|
nvrApi: ReolinkCgiApi | undefined;
|
|
37
|
+
baichuanApi: ReolinkBaichuanApi | undefined;
|
|
38
|
+
baichuanApiPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
37
39
|
discoveredDevices = new Map<string, {
|
|
38
40
|
device: Device;
|
|
39
41
|
description: string;
|
|
@@ -45,6 +47,14 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
45
47
|
lastDevicesStatusCheck: number | undefined;
|
|
46
48
|
cameraNativeMap = new Map<string, ReolinkNativeCamera | ReolinkNativeBatteryCamera>();
|
|
47
49
|
processing = false;
|
|
50
|
+
private eventSubscriptionActive = false;
|
|
51
|
+
private onSimpleEventHandler?: (ev: ReolinkSimpleEvent) => void;
|
|
52
|
+
private closeListener?: () => void;
|
|
53
|
+
private errorListener?: (err: unknown) => void;
|
|
54
|
+
private lastDisconnectTime: number = 0;
|
|
55
|
+
private lastErrorBeforeClose: { error: string; timestamp: number } | undefined;
|
|
56
|
+
private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
|
|
57
|
+
private resubscribeTimeout?: NodeJS.Timeout;
|
|
48
58
|
|
|
49
59
|
constructor(nativeId: string, plugin: ReolinkNativePlugin) {
|
|
50
60
|
super(nativeId);
|
|
@@ -73,6 +83,49 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
73
83
|
}
|
|
74
84
|
}
|
|
75
85
|
this.nvrApi = undefined;
|
|
86
|
+
|
|
87
|
+
// Clear any pending resubscribe timeout
|
|
88
|
+
if (this.resubscribeTimeout) {
|
|
89
|
+
clearTimeout(this.resubscribeTimeout);
|
|
90
|
+
this.resubscribeTimeout = undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Unsubscribe from events first
|
|
94
|
+
await this.unsubscribeFromAllEvents();
|
|
95
|
+
|
|
96
|
+
if (this.baichuanApi) {
|
|
97
|
+
// Remove close listener
|
|
98
|
+
if (this.closeListener) {
|
|
99
|
+
try {
|
|
100
|
+
this.baichuanApi.client.off("close", this.closeListener);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
this.closeListener = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Remove error listener
|
|
109
|
+
if (this.errorListener) {
|
|
110
|
+
try {
|
|
111
|
+
this.baichuanApi.client.off("error", this.errorListener);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
this.errorListener = undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
if (this.baichuanApi.client.isSocketConnected()) {
|
|
121
|
+
await this.baichuanApi.close();
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// ignore
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
this.baichuanApi = undefined;
|
|
128
|
+
this.baichuanApiPromise = undefined;
|
|
76
129
|
}
|
|
77
130
|
|
|
78
131
|
async ensureClient(): Promise<ReolinkCgiApi> {
|
|
@@ -96,11 +149,373 @@ export class ReolinkNativeNvrDevice extends ScryptedDeviceBase implements Settin
|
|
|
96
149
|
return this.nvrApi;
|
|
97
150
|
}
|
|
98
151
|
|
|
152
|
+
async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
|
|
153
|
+
// Reuse existing client if socket is still connected and logged in
|
|
154
|
+
if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
155
|
+
return this.baichuanApi;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Prevent concurrent login storms
|
|
159
|
+
if (this.baichuanApiPromise) return await this.baichuanApiPromise;
|
|
160
|
+
|
|
161
|
+
this.baichuanApiPromise = (async () => {
|
|
162
|
+
const { ipAddress, username, password } = this.storageSettings.values;
|
|
163
|
+
if (!ipAddress || !username || !password) {
|
|
164
|
+
throw new Error('Missing NVR credentials');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clean up old client if exists
|
|
168
|
+
if (this.baichuanApi) {
|
|
169
|
+
// Remove close listener from old client
|
|
170
|
+
if (this.closeListener) {
|
|
171
|
+
try {
|
|
172
|
+
this.baichuanApi.client.off("close", this.closeListener);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
this.closeListener = undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Remove error listener from old client
|
|
181
|
+
if (this.errorListener) {
|
|
182
|
+
try {
|
|
183
|
+
this.baichuanApi.client.off("error", this.errorListener);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// ignore
|
|
187
|
+
}
|
|
188
|
+
this.errorListener = undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
if (this.baichuanApi.client.isSocketConnected()) {
|
|
193
|
+
await this.baichuanApi.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// ignore
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Create new Baichuan client
|
|
202
|
+
const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
|
|
203
|
+
this.baichuanApi = new ReolinkBaichuanApi({
|
|
204
|
+
host: ipAddress,
|
|
205
|
+
username,
|
|
206
|
+
password,
|
|
207
|
+
transport: 'tcp',
|
|
208
|
+
logger: this.getLogger(),
|
|
209
|
+
// rebootAfterDisconnectionsPerMinute: 5,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await this.baichuanApi.login();
|
|
213
|
+
|
|
214
|
+
// Verify socket is connected before returning
|
|
215
|
+
if (!this.baichuanApi.client.isSocketConnected()) {
|
|
216
|
+
throw new Error('Socket not connected after login');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Listen for errors to understand why socket might close
|
|
220
|
+
this.errorListener = (err: unknown) => {
|
|
221
|
+
const logger = this.getLogger();
|
|
222
|
+
const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
|
|
223
|
+
|
|
224
|
+
// Store last error before close
|
|
225
|
+
this.lastErrorBeforeClose = {
|
|
226
|
+
error: msg,
|
|
227
|
+
timestamp: Date.now()
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// Only log if it's not a recoverable error to avoid spam
|
|
231
|
+
if (typeof msg === 'string' && (
|
|
232
|
+
msg.includes('Baichuan socket closed') ||
|
|
233
|
+
msg.includes('Baichuan UDP stream closed') ||
|
|
234
|
+
msg.includes('Not running')
|
|
235
|
+
)) {
|
|
236
|
+
// Log even recoverable errors for debugging
|
|
237
|
+
logger.debug(`[NVR BaichuanClient] error (recoverable): ${msg}`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
logger.error(`[NVR BaichuanClient] error: ${msg}`);
|
|
241
|
+
};
|
|
242
|
+
this.baichuanApi.client.on("error", this.errorListener);
|
|
243
|
+
|
|
244
|
+
// Listen for socket disconnection to reset client state
|
|
245
|
+
this.closeListener = () => {
|
|
246
|
+
const logger = this.getLogger();
|
|
247
|
+
const now = Date.now();
|
|
248
|
+
const timeSinceLastDisconnect = now - this.lastDisconnectTime;
|
|
249
|
+
this.lastDisconnectTime = now;
|
|
250
|
+
|
|
251
|
+
// Log detailed information about the close
|
|
252
|
+
const errorInfo = this.lastErrorBeforeClose
|
|
253
|
+
? ` (last error: ${this.lastErrorBeforeClose.error} at ${new Date(this.lastErrorBeforeClose.timestamp).toISOString()}, ${now - this.lastErrorBeforeClose.timestamp}ms before close)`
|
|
254
|
+
: '';
|
|
255
|
+
|
|
256
|
+
logger.log(`[NVR BaichuanClient] Socket closed, resetting client state (last disconnect ${timeSinceLastDisconnect}ms ago)${errorInfo}`);
|
|
257
|
+
|
|
258
|
+
// Log connection state before close
|
|
259
|
+
try {
|
|
260
|
+
const wasConnected = this.baichuanApi?.client.isSocketConnected();
|
|
261
|
+
const wasLoggedIn = this.baichuanApi?.client.loggedIn;
|
|
262
|
+
logger.log(`[NVR BaichuanClient] Connection state before close: connected=${wasConnected}, loggedIn=${wasLoggedIn}`);
|
|
263
|
+
|
|
264
|
+
// Try to get last message info if available
|
|
265
|
+
const client = this.baichuanApi?.client as any;
|
|
266
|
+
if (client?.lastRx || client?.lastTx) {
|
|
267
|
+
logger.log(`[NVR BaichuanClient] Last message info: lastRx=${JSON.stringify(client.lastRx)}, lastTx=${JSON.stringify(client.lastTx)}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (e) {
|
|
271
|
+
logger.debug(`[NVR BaichuanClient] Could not get connection state: ${e}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Clear any pending resubscribe timeout
|
|
275
|
+
if (this.resubscribeTimeout) {
|
|
276
|
+
clearTimeout(this.resubscribeTimeout);
|
|
277
|
+
this.resubscribeTimeout = undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const wasSubscribed = this.eventSubscriptionActive;
|
|
281
|
+
const api = this.baichuanApi; // Save reference before clearing
|
|
282
|
+
|
|
283
|
+
// Reset state
|
|
284
|
+
this.baichuanApi = undefined;
|
|
285
|
+
this.baichuanApiPromise = undefined;
|
|
286
|
+
this.eventSubscriptionActive = false;
|
|
287
|
+
this.onSimpleEventHandler = undefined;
|
|
288
|
+
|
|
289
|
+
// Remove event handler from closed client
|
|
290
|
+
if (api && this.onSimpleEventHandler) {
|
|
291
|
+
try {
|
|
292
|
+
api.offSimpleEvent(this.onSimpleEventHandler);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Remove close listener (it will be re-added on next connection)
|
|
300
|
+
if (api && this.closeListener) {
|
|
301
|
+
try {
|
|
302
|
+
api.client.off("close", this.closeListener);
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// ignore
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Remove error listener
|
|
310
|
+
if (api && this.errorListener) {
|
|
311
|
+
try {
|
|
312
|
+
api.client.off("error", this.errorListener);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// ignore
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.closeListener = undefined;
|
|
320
|
+
this.errorListener = undefined;
|
|
321
|
+
this.lastErrorBeforeClose = undefined;
|
|
322
|
+
|
|
323
|
+
// Try to resubscribe when connection is restored (async, don't block)
|
|
324
|
+
// Only if we had an active subscription and enough time has passed
|
|
325
|
+
if (wasSubscribed && timeSinceLastDisconnect >= this.reconnectBackoffMs) {
|
|
326
|
+
this.resubscribeTimeout = setTimeout(async () => {
|
|
327
|
+
this.resubscribeTimeout = undefined;
|
|
328
|
+
try {
|
|
329
|
+
await this.subscribeToAllEvents();
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
logger.warn('Failed to resubscribe to events after reconnection', e);
|
|
333
|
+
}
|
|
334
|
+
}, this.reconnectBackoffMs); // Wait for backoff period before resubscribing
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
this.baichuanApi.client.on("close", this.closeListener);
|
|
338
|
+
|
|
339
|
+
return this.baichuanApi;
|
|
340
|
+
})();
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
return await this.baichuanApiPromise;
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
// Allow future reconnects and avoid pinning rejected promises
|
|
347
|
+
this.baichuanApiPromise = undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async subscribeToAllEvents(): Promise<void> {
|
|
352
|
+
const logger = this.getLogger();
|
|
353
|
+
|
|
354
|
+
// Apply backoff to avoid aggressive reconnection after disconnection
|
|
355
|
+
// if (this.lastDisconnectTime > 0) {
|
|
356
|
+
// const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
|
|
357
|
+
// if (timeSinceDisconnect < this.reconnectBackoffMs) {
|
|
358
|
+
// const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
|
|
359
|
+
// logger.log(`[NVR] Waiting ${waitTime}ms before subscribing to events (backoff)`);
|
|
360
|
+
// await new Promise(resolve => setTimeout(resolve, waitTime));
|
|
361
|
+
// }
|
|
362
|
+
// }
|
|
363
|
+
|
|
364
|
+
// If already subscribed, return
|
|
365
|
+
if (this.eventSubscriptionActive && this.onSimpleEventHandler && this.baichuanApi) {
|
|
366
|
+
// Verify connection is still valid
|
|
367
|
+
if (this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
368
|
+
logger.log('Event subscription already active');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Connection is invalid, unsubscribe first
|
|
372
|
+
try {
|
|
373
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEventHandler);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
// ignore
|
|
377
|
+
}
|
|
378
|
+
this.eventSubscriptionActive = false;
|
|
379
|
+
this.onSimpleEventHandler = undefined;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Unsubscribe first if handler exists
|
|
383
|
+
if (this.onSimpleEventHandler && this.baichuanApi) {
|
|
384
|
+
try {
|
|
385
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEventHandler);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
// ignore
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Get Baichuan client connection
|
|
393
|
+
const api = await this.ensureBaichuanClient();
|
|
394
|
+
|
|
395
|
+
// Verify connection is ready
|
|
396
|
+
if (!api.client.isSocketConnected() || !api.client.loggedIn) {
|
|
397
|
+
logger.warn('Cannot subscribe to events: connection not ready');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Create event handler that distributes events to cameras
|
|
402
|
+
this.onSimpleEventHandler = (ev: ReolinkSimpleEvent) => {
|
|
403
|
+
try {
|
|
404
|
+
if (this.storageSettings.values.debugEvents) {
|
|
405
|
+
logger.log(`NVR Baichuan event: ${JSON.stringify(ev)}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Find camera for this channel
|
|
409
|
+
const channel = ev?.channel;
|
|
410
|
+
if (channel === undefined) {
|
|
411
|
+
if (this.storageSettings.values.debugEvents) {
|
|
412
|
+
logger.debug('Event has no channel, ignoring');
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Find camera with matching channel
|
|
418
|
+
let targetCamera: ReolinkNativeCamera | ReolinkNativeBatteryCamera | undefined;
|
|
419
|
+
for (const camera of this.cameraNativeMap.values()) {
|
|
420
|
+
if (camera && camera.storageSettings.values.rtspChannel === channel) {
|
|
421
|
+
targetCamera = camera;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (!targetCamera) {
|
|
427
|
+
if (this.storageSettings.values.debugEvents) {
|
|
428
|
+
logger.debug(`No camera found for channel ${channel}, ignoring event`);
|
|
429
|
+
}
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Convert event to camera's processEvents format
|
|
434
|
+
const objects: string[] = [];
|
|
435
|
+
let motion = false;
|
|
436
|
+
|
|
437
|
+
switch (ev?.type) {
|
|
438
|
+
case 'motion':
|
|
439
|
+
motion = true;
|
|
440
|
+
break;
|
|
441
|
+
case 'doorbell':
|
|
442
|
+
// Handle doorbell if camera supports it
|
|
443
|
+
try {
|
|
444
|
+
if (typeof (targetCamera as any).handleDoorbellEvent === 'function') {
|
|
445
|
+
(targetCamera as any).handleDoorbellEvent();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
logger.warn(`Error handling doorbell event for camera channel ${channel}`, e);
|
|
450
|
+
}
|
|
451
|
+
motion = true;
|
|
452
|
+
break;
|
|
453
|
+
case 'people':
|
|
454
|
+
case 'vehicle':
|
|
455
|
+
case 'animal':
|
|
456
|
+
case 'face':
|
|
457
|
+
case 'package':
|
|
458
|
+
case 'other':
|
|
459
|
+
objects.push(ev.type);
|
|
460
|
+
motion = true;
|
|
461
|
+
break;
|
|
462
|
+
default:
|
|
463
|
+
if (this.storageSettings.values.debugEvents) {
|
|
464
|
+
logger.debug(`Unknown event type: ${ev?.type}`);
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Process events on the target camera
|
|
470
|
+
targetCamera.processEvents({ motion, objects }).catch((e) => {
|
|
471
|
+
logger.warn(`Error processing events for camera channel ${channel}`, e);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
logger.warn('Error in NVR onSimpleEvent handler', e);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Subscribe to events
|
|
480
|
+
try {
|
|
481
|
+
await api.onSimpleEvent(this.onSimpleEventHandler);
|
|
482
|
+
this.eventSubscriptionActive = true;
|
|
483
|
+
logger.log('Subscribed to all events for NVR cameras');
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
logger.warn('Failed to subscribe to events', e);
|
|
487
|
+
this.eventSubscriptionActive = false;
|
|
488
|
+
this.onSimpleEventHandler = undefined;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async unsubscribeFromAllEvents(): Promise<void> {
|
|
493
|
+
const logger = this.getLogger();
|
|
494
|
+
|
|
495
|
+
if (this.onSimpleEventHandler && this.baichuanApi) {
|
|
496
|
+
try {
|
|
497
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEventHandler);
|
|
498
|
+
logger.log('Unsubscribed from all events');
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
logger.warn('Error unsubscribing from events', e);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.eventSubscriptionActive = false;
|
|
506
|
+
this.onSimpleEventHandler = undefined;
|
|
507
|
+
}
|
|
508
|
+
|
|
99
509
|
async init() {
|
|
100
510
|
const api = await this.ensureClient();
|
|
101
511
|
const logger = this.getLogger();
|
|
102
512
|
await this.updateDeviceInfo();
|
|
103
513
|
|
|
514
|
+
// Subscribe to events for all cameras
|
|
515
|
+
this.subscribeToAllEvents().catch((e) => {
|
|
516
|
+
logger.warn('Failed to subscribe to events during init', e);
|
|
517
|
+
});
|
|
518
|
+
|
|
104
519
|
setInterval(async () => {
|
|
105
520
|
if (this.processing || !api) {
|
|
106
521
|
return;
|