@apocaliss92/scrypted-reolink-native 0.1.6 → 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/logs.txt +7361 -0
- package/package.json +1 -1
- package/src/camera-battery.ts +9 -4
- package/src/camera.ts +1 -1
- package/src/common.ts +74 -9
- package/src/nvr.ts +416 -1
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
|
@@ -3,7 +3,7 @@ import sdk, { BinarySensor, Brightness, Camera, Device, DeviceProvider, Intercom
|
|
|
3
3
|
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
|
4
4
|
import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
5
5
|
import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
|
|
6
|
-
import { convertDebugLogsToApiOptions, DebugLogOption, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
|
|
6
|
+
import { convertDebugLogsToApiOptions, DebugLogDisplayNames, DebugLogOption, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
|
|
7
7
|
import { ReolinkBaichuanIntercom } from "./intercom";
|
|
8
8
|
import ReolinkNativePlugin from "./main";
|
|
9
9
|
import { ReolinkNativeNvrDevice } from "./nvr";
|
|
@@ -486,6 +486,9 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
486
486
|
protected baichuanApi: ReolinkBaichuanApi | undefined;
|
|
487
487
|
protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
488
488
|
protected connectionTime: number | undefined;
|
|
489
|
+
private closeListener?: () => void;
|
|
490
|
+
private lastDisconnectTime: number = 0;
|
|
491
|
+
private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
|
|
489
492
|
protected readonly protocol: BaichuanTransport;
|
|
490
493
|
private debugLogsResetTimeout: NodeJS.Timeout | undefined;
|
|
491
494
|
|
|
@@ -877,7 +880,7 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
877
880
|
|
|
878
881
|
isEventLogsEnabled(): boolean {
|
|
879
882
|
const debugLogs = this.storageSettings.values.debugLogs || [];
|
|
880
|
-
return debugLogs.includes(DebugLogOption.EventLogs);
|
|
883
|
+
return debugLogs.includes(DebugLogDisplayNames[DebugLogOption.EventLogs]);
|
|
881
884
|
}
|
|
882
885
|
|
|
883
886
|
// BinarySensor interface implementation (for doorbell)
|
|
@@ -1328,6 +1331,12 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1328
1331
|
|
|
1329
1332
|
// Client management
|
|
1330
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
|
+
|
|
1331
1340
|
// Reuse existing client if socket is still connected and logged in
|
|
1332
1341
|
if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
|
|
1333
1342
|
return this.baichuanApi;
|
|
@@ -1336,18 +1345,37 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1336
1345
|
// Prevent concurrent login storms
|
|
1337
1346
|
if (this.ensureClientPromise) return await this.ensureClientPromise;
|
|
1338
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
|
+
|
|
1339
1359
|
this.ensureClientPromise = (async () => {
|
|
1340
1360
|
const { ipAddress, username, password, uid } = this.storageSettings.values;
|
|
1341
1361
|
|
|
1342
1362
|
// Only tear down previous session if it exists and is not connected
|
|
1343
1363
|
if (this.baichuanApi) {
|
|
1364
|
+
// Remove close listener from old client
|
|
1365
|
+
if (this.closeListener) {
|
|
1366
|
+
try {
|
|
1367
|
+
this.baichuanApi.client.off("close", this.closeListener);
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
// ignore
|
|
1371
|
+
}
|
|
1372
|
+
this.closeListener = undefined;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1344
1375
|
const isConnected = this.baichuanApi.client.isSocketConnected();
|
|
1345
1376
|
if (!isConnected) {
|
|
1346
|
-
// Socket is closed, clean up
|
|
1347
1377
|
try {
|
|
1348
|
-
|
|
1349
|
-
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1350
|
-
}
|
|
1378
|
+
this.baichuanApi.offSimpleEvent(this.onSimpleEvent);
|
|
1351
1379
|
}
|
|
1352
1380
|
catch {
|
|
1353
1381
|
// ignore
|
|
@@ -1408,13 +1436,50 @@ export abstract class CommonCameraMixin extends ScryptedDeviceBase implements Vi
|
|
|
1408
1436
|
this.baichuanApi = api;
|
|
1409
1437
|
this.connectionTime = Date.now();
|
|
1410
1438
|
|
|
1439
|
+
// Listen for socket disconnection to reset client state
|
|
1440
|
+
// This ensures ensureClient() will create a new connection on next call
|
|
1441
|
+
this.closeListener = () => {
|
|
1442
|
+
const logger = this.getLogger();
|
|
1443
|
+
if (this.baichuanApi === api) {
|
|
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
|
|
1451
|
+
this.baichuanApi = undefined;
|
|
1452
|
+
this.ensureClientPromise = undefined;
|
|
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
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
};
|
|
1466
|
+
api.client.on("close", this.closeListener);
|
|
1467
|
+
|
|
1411
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.
|
|
1412
1473
|
if (this.isEventDispatchEnabled?.() && this.onSimpleEvent) {
|
|
1413
1474
|
try {
|
|
1414
|
-
|
|
1475
|
+
// Verify connection is fully ready before subscribing
|
|
1476
|
+
if (api.client.isSocketConnected() && api.client.loggedIn) {
|
|
1477
|
+
api.onSimpleEvent(this.onSimpleEvent);
|
|
1478
|
+
}
|
|
1415
1479
|
}
|
|
1416
|
-
catch {
|
|
1417
|
-
|
|
1480
|
+
catch (e) {
|
|
1481
|
+
const logger = this.getLogger();
|
|
1482
|
+
logger.warn(`[BaichuanClient] Failed to reattach event handler after reconnection, will retry via subscribeToEvents()`, e);
|
|
1418
1483
|
}
|
|
1419
1484
|
}
|
|
1420
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;
|