@apocaliss92/scrypted-reolink-native 0.3.17 → 0.4.0

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.
@@ -1,23 +1,27 @@
1
- import type { BaichuanClientOptions, ReolinkBaichuanApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
1
+ import type {
2
+ BaichuanClientOptions,
3
+ ReolinkBaichuanApi,
4
+ ReolinkSimpleEvent,
5
+ } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
6
  import { ScryptedDeviceBase } from "@scrypted/sdk";
3
7
  import { createBaichuanApi, type BaichuanTransport } from "./connect";
4
8
  import { StreamManager } from "./stream-utils";
5
9
 
6
10
  export interface BaichuanConnectionConfig {
7
- host: string;
8
- username: string;
9
- password: string;
10
- uid?: string;
11
- transport: BaichuanTransport;
12
- debugOptions?: any;
13
- udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
11
+ host: string;
12
+ username: string;
13
+ password: string;
14
+ uid?: string;
15
+ transport: BaichuanTransport;
16
+ debugOptions?: any;
17
+ udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
14
18
  }
15
19
 
16
20
  export interface BaichuanConnectionCallbacks {
17
- onError?: (err: unknown) => void;
18
- onClose?: () => void | Promise<void>;
19
- onSimpleEvent?: (ev: ReolinkSimpleEvent) => void;
20
- getEventSubscriptionEnabled?: () => boolean;
21
+ onError?: (err: unknown) => void;
22
+ onClose?: () => void | Promise<void>;
23
+ onSimpleEvent?: (ev: ReolinkSimpleEvent) => void;
24
+ getEventSubscriptionEnabled?: () => boolean;
21
25
  }
22
26
 
23
27
  /**
@@ -25,134 +29,138 @@ export interface BaichuanConnectionCallbacks {
25
29
  * Implements Console interface to be compatible with Baichuan API
26
30
  */
27
31
  export class BaichuanLogger implements Console {
28
- private baseLogger: Console;
29
- private deviceName: string;
30
- private isDebugEnabledCallback: () => boolean;
31
-
32
- constructor(baseLogger: Console, deviceName: string, isDebugEnabledCallback: () => boolean) {
33
- this.baseLogger = baseLogger;
34
- this.deviceName = deviceName;
35
- this.isDebugEnabledCallback = isDebugEnabledCallback;
36
- }
37
-
38
- private formatMessage(level: string, ...args: any[]): string {
39
- const timestamp = new Date().toLocaleString();
40
- const prefix = `[${this.deviceName}] [${timestamp}] [${level}]`;
41
- return `${prefix} ${args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ')}`;
42
- }
43
-
44
- log(...args: any[]): void {
45
- this.baseLogger.log(this.formatMessage('LOG', ...args));
46
- }
47
-
48
- error(...args: any[]): void {
49
- this.baseLogger.error(this.formatMessage('ERROR', ...args));
50
- }
51
-
52
- warn(...args: any[]): void {
53
- this.baseLogger.warn(this.formatMessage('WARN', ...args));
54
- }
55
-
56
- debug(...args: any[]): void {
57
- if (this.isDebugEnabledCallback()) {
58
- this.baseLogger.debug(this.formatMessage('DEBUG', ...args));
59
- }
60
- }
61
-
62
- // Console interface implementation - delegate to baseLogger
63
- assert(condition?: boolean, ...data: any[]): void {
64
- this.baseLogger.assert(condition, ...data);
65
- }
66
-
67
- clear(): void {
68
- this.baseLogger.clear();
69
- }
70
-
71
- count(label?: string): void {
72
- this.baseLogger.count(label);
73
- }
74
-
75
- countReset(label?: string): void {
76
- this.baseLogger.countReset(label);
77
- }
78
-
79
- dir(item?: any, options?: any): void {
80
- this.baseLogger.dir(item, options);
81
- }
82
-
83
- dirxml(...data: any[]): void {
84
- this.baseLogger.dirxml(...data);
85
- }
32
+ private baseLogger: Console;
33
+ private deviceName: string;
34
+ private isDebugEnabledCallback: () => boolean;
35
+
36
+ constructor(
37
+ baseLogger: Console,
38
+ deviceName: string,
39
+ isDebugEnabledCallback: () => boolean,
40
+ ) {
41
+ this.baseLogger = baseLogger;
42
+ this.deviceName = deviceName;
43
+ this.isDebugEnabledCallback = isDebugEnabledCallback;
44
+ }
45
+
46
+ private formatMessage(level: string, ...args: any[]): string {
47
+ const timestamp = new Date().toLocaleString();
48
+ const prefix = `[${this.deviceName}] [${timestamp}] [${level}]`;
49
+ return `${prefix} ${args.map((arg) => (typeof arg === "object" ? JSON.stringify(arg) : String(arg))).join(" ")}`;
50
+ }
51
+
52
+ log(...args: any[]): void {
53
+ this.baseLogger.log(this.formatMessage("LOG", ...args));
54
+ }
55
+
56
+ error(...args: any[]): void {
57
+ this.baseLogger.error(this.formatMessage("ERROR", ...args));
58
+ }
59
+
60
+ warn(...args: any[]): void {
61
+ this.baseLogger.warn(this.formatMessage("WARN", ...args));
62
+ }
63
+
64
+ debug(...args: any[]): void {
65
+ if (this.isDebugEnabledCallback()) {
66
+ this.baseLogger.debug(this.formatMessage("DEBUG", ...args));
67
+ }
68
+ }
69
+
70
+ // Console interface implementation - delegate to baseLogger
71
+ assert(condition?: boolean, ...data: any[]): void {
72
+ this.baseLogger.assert(condition, ...data);
73
+ }
74
+
75
+ clear(): void {
76
+ this.baseLogger.clear();
77
+ }
78
+
79
+ count(label?: string): void {
80
+ this.baseLogger.count(label);
81
+ }
82
+
83
+ countReset(label?: string): void {
84
+ this.baseLogger.countReset(label);
85
+ }
86
+
87
+ dir(item?: any, options?: any): void {
88
+ this.baseLogger.dir(item, options);
89
+ }
90
+
91
+ dirxml(...data: any[]): void {
92
+ this.baseLogger.dirxml(...data);
93
+ }
94
+
95
+ group(...data: any[]): void {
96
+ this.baseLogger.group(...data);
97
+ }
98
+
99
+ groupCollapsed(...data: any[]): void {
100
+ this.baseLogger.groupCollapsed(...data);
101
+ }
102
+
103
+ groupEnd(): void {
104
+ this.baseLogger.groupEnd();
105
+ }
106
+
107
+ info(...data: any[]): void {
108
+ this.baseLogger.info(this.formatMessage("INFO", ...data));
109
+ }
110
+
111
+ table(tabularData?: any, properties?: string[]): void {
112
+ this.baseLogger.table(tabularData, properties);
113
+ }
114
+
115
+ time(label?: string): void {
116
+ this.baseLogger.time(label);
117
+ }
118
+
119
+ timeEnd(label?: string): void {
120
+ this.baseLogger.timeEnd(label);
121
+ }
122
+
123
+ timeLog(label?: string, ...data: any[]): void {
124
+ this.baseLogger.timeLog(label, ...data);
125
+ }
126
+
127
+ trace(...data: any[]): void {
128
+ this.baseLogger.trace(...data);
129
+ }
130
+
131
+ // Console properties
132
+ get memory(): any {
133
+ return (this.baseLogger as any).memory;
134
+ }
86
135
 
87
- group(...data: any[]): void {
88
- this.baseLogger.group(...data);
89
- }
90
-
91
- groupCollapsed(...data: any[]): void {
92
- this.baseLogger.groupCollapsed(...data);
93
- }
94
-
95
- groupEnd(): void {
96
- this.baseLogger.groupEnd();
97
- }
98
-
99
- info(...data: any[]): void {
100
- this.baseLogger.info(this.formatMessage('INFO', ...data));
101
- }
102
-
103
- table(tabularData?: any, properties?: string[]): void {
104
- this.baseLogger.table(tabularData, properties);
105
- }
106
-
107
- time(label?: string): void {
108
- this.baseLogger.time(label);
109
- }
110
-
111
- timeEnd(label?: string): void {
112
- this.baseLogger.timeEnd(label);
113
- }
114
-
115
- timeLog(label?: string, ...data: any[]): void {
116
- this.baseLogger.timeLog(label, ...data);
117
- }
118
-
119
- trace(...data: any[]): void {
120
- this.baseLogger.trace(...data);
121
- }
122
-
123
- // Console properties
124
- get memory(): any {
125
- return (this.baseLogger as any).memory;
126
- }
127
-
128
- get Console(): any {
129
- return (this.baseLogger as any).Console;
130
- }
136
+ get Console(): any {
137
+ return (this.baseLogger as any).Console;
138
+ }
131
139
 
132
- // Node.js specific
133
- profile(label?: string): void {
134
- if (typeof (this.baseLogger as any).profile === 'function') {
135
- (this.baseLogger as any).profile(label);
136
- }
140
+ // Node.js specific
141
+ profile(label?: string): void {
142
+ if (typeof (this.baseLogger as any).profile === "function") {
143
+ (this.baseLogger as any).profile(label);
137
144
  }
145
+ }
138
146
 
139
- profileEnd(label?: string): void {
140
- if (typeof (this.baseLogger as any).profileEnd === 'function') {
141
- (this.baseLogger as any).profileEnd(label);
142
- }
147
+ profileEnd(label?: string): void {
148
+ if (typeof (this.baseLogger as any).profileEnd === "function") {
149
+ (this.baseLogger as any).profileEnd(label);
143
150
  }
151
+ }
144
152
 
145
- timeStamp(label?: string): void {
146
- if (typeof (this.baseLogger as any).timeStamp === 'function') {
147
- (this.baseLogger as any).timeStamp(label);
148
- }
153
+ timeStamp(label?: string): void {
154
+ if (typeof (this.baseLogger as any).timeStamp === "function") {
155
+ (this.baseLogger as any).timeStamp(label);
149
156
  }
157
+ }
150
158
 
151
- context(...data: any[]): void {
152
- if (typeof (this.baseLogger as any).context === 'function') {
153
- (this.baseLogger as any).context(...data);
154
- }
159
+ context(...data: any[]): void {
160
+ if (typeof (this.baseLogger as any).context === "function") {
161
+ (this.baseLogger as any).context(...data);
155
162
  }
163
+ }
156
164
  }
157
165
 
158
166
  /**
@@ -160,712 +168,775 @@ export class BaichuanLogger implements Console {
160
168
  * listener management, and event subscription handling.
161
169
  */
162
170
  export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
163
- protected baichuanApi: ReolinkBaichuanApi | undefined;
164
- protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
165
- protected connectionTime: number | undefined;
166
- transport: BaichuanTransport;
167
- // Map of stream clients keyed by streamKey (profile + variantType)
168
- private streamClients = new Map<string, ReolinkBaichuanApi>();
169
-
170
- constructor(nativeId: string, transport: BaichuanTransport) {
171
- super(nativeId);
172
- this.transport = transport
173
- }
174
-
175
- private errorListener?: (err: unknown) => void;
176
- private closeListener?: () => void;
177
- private lastDisconnectTime: number = 0;
178
- private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
179
- private eventSubscriptionActive: boolean = false;
180
- private lastEventTime: number = 0;
181
- private pingInterval?: NodeJS.Timeout;
182
- private autoRenewInterval?: NodeJS.Timeout;
183
- private eventCheckInterval?: NodeJS.Timeout;
184
- private consecutivePingFailures: number = 0;
185
-
186
- /**
187
- * Get the connection configuration for this instance
188
- */
189
- protected abstract getConnectionConfig(): BaichuanConnectionConfig;
190
-
191
- /**
192
- * Get callbacks for connection events
193
- */
194
- protected abstract getConnectionCallbacks(): BaichuanConnectionCallbacks;
195
-
196
- /**
197
- * Check if debug logging is enabled
198
- */
199
- protected abstract isDebugEnabled(): boolean;
200
-
201
- /**
202
- * Get the device name for logging
203
- */
204
- protected abstract getDeviceName(): string;
205
-
206
- /**
207
- * Get connection inputs for creating a stream client for a specific streamKey
208
- * This method is called by createStreamClient to get the inputs needed to create a new client
209
- * @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
210
- */
211
- protected abstract getStreamClientInputs(): BaichuanConnectionConfig;
212
-
213
- /**
214
- * Get StreamManager if available (optional, only for devices that support streaming)
215
- * Override in subclasses that have a StreamManager
216
- */
217
- protected getStreamManager?(): StreamManager | undefined;
218
-
219
- /**
220
- * Get a Baichuan logger instance with formatting and debug control
221
- * This logger implements Console interface and can be used everywhere
222
- */
223
- public getBaichuanLogger(): BaichuanLogger {
224
- return new BaichuanLogger(this.console, this.getDeviceName(), () => this.isDebugEnabled());
225
- }
226
-
227
- /**
228
- * Cleanup any additional resources (called before closing connection)
229
- */
230
- protected async onBeforeCleanup(): Promise<void> {
231
- // Override in subclasses if needed
171
+ protected baichuanApi: ReolinkBaichuanApi | undefined;
172
+ protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
173
+ protected connectionTime: number | undefined;
174
+ transport: BaichuanTransport;
175
+
176
+ constructor(nativeId: string, transport: BaichuanTransport) {
177
+ super(nativeId);
178
+ this.transport = transport;
179
+ }
180
+
181
+ private errorListener?: (err: unknown) => void;
182
+ private closeListener?: () => void;
183
+ private lastDisconnectTime: number = 0;
184
+ private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
185
+ private eventSubscriptionActive: boolean = false;
186
+ private lastEventTime: number = 0;
187
+ private pingInterval?: NodeJS.Timeout;
188
+ private autoRenewInterval?: NodeJS.Timeout;
189
+ private eventCheckInterval?: NodeJS.Timeout;
190
+ private consecutivePingFailures: number = 0;
191
+
192
+ /**
193
+ * Get the connection configuration for this instance
194
+ */
195
+ protected abstract getConnectionConfig(): BaichuanConnectionConfig;
196
+
197
+ /**
198
+ * Get callbacks for connection events
199
+ */
200
+ protected abstract getConnectionCallbacks(): BaichuanConnectionCallbacks;
201
+
202
+ /**
203
+ * Check if debug logging is enabled
204
+ */
205
+ protected abstract isDebugEnabled(): boolean;
206
+
207
+ /**
208
+ * Get the device name for logging
209
+ */
210
+ protected abstract getDeviceName(): string;
211
+
212
+ /**
213
+ * Get StreamManager if available (optional, only for devices that support streaming)
214
+ * Override in subclasses that have a StreamManager
215
+ */
216
+ protected getStreamManager?(): StreamManager | undefined;
217
+
218
+ /**
219
+ * Get a Baichuan logger instance with formatting and debug control
220
+ * This logger implements Console interface and can be used everywhere
221
+ */
222
+ public getBaichuanLogger(): BaichuanLogger {
223
+ return new BaichuanLogger(this.console, this.getDeviceName(), () =>
224
+ this.isDebugEnabled(),
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Cleanup any additional resources (called before closing connection)
230
+ */
231
+ protected async onBeforeCleanup(): Promise<void> {
232
+ // Override in subclasses if needed
233
+ }
234
+
235
+ /**
236
+ * Ensure Baichuan client is connected and ready
237
+ */
238
+ async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
239
+ // Prevent concurrent login storms - check promise first
240
+ if (this.ensureClientPromise) return await this.ensureClientPromise;
241
+
242
+ // Reuse existing client if socket is still connected and logged in
243
+ // Check this AFTER checking the promise to avoid race conditions
244
+ if (this.baichuanApi) {
245
+ const isConnected = this.baichuanApi.client.isSocketConnected();
246
+ const isLoggedIn = this.baichuanApi.client.loggedIn;
247
+
248
+ // Only reuse if both conditions are true
249
+ if (isConnected && isLoggedIn) {
250
+ return this.baichuanApi;
251
+ }
252
+
253
+ // If socket is not connected or not logged in, cleanup the stale client
254
+ // This prevents leaking connections when the socket appears connected but isn't
255
+ const logger = this.getBaichuanLogger();
256
+ logger.log(
257
+ `Stale client detected: connected=${isConnected}, loggedIn=${isLoggedIn}, cleaning up`,
258
+ );
259
+ await this.cleanupBaichuanApi();
260
+ }
261
+
262
+ // Apply backoff to avoid aggressive reconnection after disconnection
263
+ if (this.lastDisconnectTime > 0) {
264
+ const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
265
+ if (timeSinceDisconnect < this.reconnectBackoffMs) {
266
+ const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
267
+ const logger = this.getBaichuanLogger();
268
+ logger.log(`Waiting ${waitTime}ms before reconnection (backoff)`);
269
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
270
+ }
232
271
  }
233
272
 
234
- /**
235
- * Ensure Baichuan client is connected and ready
236
- */
237
- async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
238
- // Prevent concurrent login storms - check promise first
239
- if (this.ensureClientPromise) return await this.ensureClientPromise;
240
-
241
- // Reuse existing client if socket is still connected and logged in
242
- // Check this AFTER checking the promise to avoid race conditions
243
- if (this.baichuanApi) {
244
- const isConnected = this.baichuanApi.client.isSocketConnected();
245
- const isLoggedIn = this.baichuanApi.client.loggedIn;
246
-
247
- // Only reuse if both conditions are true
248
- if (isConnected && isLoggedIn) {
249
- return this.baichuanApi;
250
- }
251
-
252
- // If socket is not connected or not logged in, cleanup the stale client
253
- // This prevents leaking connections when the socket appears connected but isn't
254
- const logger = this.getBaichuanLogger();
255
- logger.log(`Stale client detected: connected=${isConnected}, loggedIn=${isLoggedIn}, cleaning up`);
256
- await this.cleanupBaichuanApi();
257
- }
258
-
259
- // Apply backoff to avoid aggressive reconnection after disconnection
260
- if (this.lastDisconnectTime > 0) {
261
- const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
262
- if (timeSinceDisconnect < this.reconnectBackoffMs) {
263
- const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
264
- const logger = this.getBaichuanLogger();
265
- logger.log(`Waiting ${waitTime}ms before reconnection (backoff)`);
266
- await new Promise(resolve => setTimeout(resolve, waitTime));
267
- }
268
- }
273
+ this.ensureClientPromise = (async () => {
274
+ const config = this.getConnectionConfig();
269
275
 
270
- this.ensureClientPromise = (async () => {
271
- const config = this.getConnectionConfig();
276
+ // Clean up old client if exists
277
+ if (this.baichuanApi) {
278
+ await this.cleanupBaichuanApi();
279
+ }
272
280
 
273
- // Clean up old client if exists
274
- if (this.baichuanApi) {
275
- await this.cleanupBaichuanApi();
276
- }
281
+ // Create new Baichuan client
282
+ // BaichuanLogger implements Console, so it can be used directly
283
+ const logger = this.getBaichuanLogger();
284
+ try {
285
+ const api = await createBaichuanApi({
286
+ inputs: {
287
+ host: config.host,
288
+ username: config.username,
289
+ password: config.password,
290
+ uid: config.uid,
291
+ logger,
292
+ debugOptions: config.debugOptions,
293
+ udpDiscoveryMethod: config.udpDiscoveryMethod,
294
+ },
295
+ transport: config.transport,
296
+ });
277
297
 
278
- // Create new Baichuan client
279
- // BaichuanLogger implements Console, so it can be used directly
280
- const logger = this.getBaichuanLogger();
281
- try {
282
- const api = await createBaichuanApi({
283
- inputs: {
284
- host: config.host,
285
- username: config.username,
286
- password: config.password,
287
- uid: config.uid,
288
- logger,
289
- debugOptions: config.debugOptions,
290
- udpDiscoveryMethod: config.udpDiscoveryMethod,
291
- },
292
- transport: config.transport,
293
- });
294
-
295
- await api.login();
296
-
297
- // Verify socket is connected before returning
298
- if (!api.client.isSocketConnected()) {
299
- throw new Error('Socket not connected after login');
300
- }
301
-
302
- // Attach listeners
303
- this.attachBaichuanListeners(api);
304
-
305
- this.baichuanApi = api;
306
- this.connectionTime = Date.now();
307
-
308
- // Start ping and auto-renewal for TCP connections
309
- if (this.transport === 'tcp') {
310
- // this.startConnectionMaintenance(api);
311
- }
312
-
313
- // Start event check for all connections
314
- this.startEventCheck(api);
315
-
316
- return api;
317
- }
318
- catch (e) {
319
- // Apply backoff for connection failures too, otherwise multiple callers can hammer connect().
320
- this.lastDisconnectTime = Date.now();
321
- // Ensure state is reset so next attempt is clean.
322
- await this.cleanupBaichuanApi();
323
- throw e;
324
- }
325
- })();
298
+ await api.login();
326
299
 
327
- try {
328
- return await this.ensureClientPromise;
300
+ // Verify socket is connected before returning
301
+ if (!api.client.isSocketConnected()) {
302
+ throw new Error("Socket not connected after login");
329
303
  }
330
- finally {
331
- // Allow future reconnects and avoid pinning rejected promises
332
- this.ensureClientPromise = undefined;
333
- }
334
- }
335
-
336
- /**
337
- * Attach error and close listeners to Baichuan API
338
- */
339
- private attachBaichuanListeners(api: ReolinkBaichuanApi): void {
340
- const logger = this.getBaichuanLogger();
341
- const callbacks = this.getConnectionCallbacks();
342
-
343
- // Error listener
344
- this.errorListener = (err: unknown) => {
345
- const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
346
-
347
- // Only log if it's not a recoverable error to avoid spam
348
- if (typeof msg === 'string' && (
349
- msg.includes('Baichuan socket closed') ||
350
- msg.includes('Baichuan UDP stream closed') ||
351
- msg.includes('Not running')
352
- )) {
353
- logger.debug(`error (recoverable): ${msg}`);
354
- return;
355
- }
356
- logger.error(`error: ${msg}`);
357
-
358
- // Call custom error handler if provided
359
- if (callbacks.onError) {
360
- try {
361
- callbacks.onError(err);
362
- } catch {
363
- // ignore
364
- }
365
- }
366
- };
367
-
368
- // Close listener
369
- this.closeListener = async () => {
370
- // Prevent multiple concurrent cleanup operations
371
- if (!this.baichuanApi || this.baichuanApi !== api) {
372
- // This close event is for a different/old client, ignore it
373
- logger.debug('Close event for stale client, ignoring');
374
- return;
375
- }
376
-
377
- try {
378
- const wasConnected = api.client.isSocketConnected();
379
- const wasLoggedIn = api.client.loggedIn;
380
- logger.log(`Connection state before close: connected=${wasConnected}, loggedIn=${wasLoggedIn}`);
381
-
382
- // Try to get last message info if available
383
- const client = api.client as any;
384
- if (client?.lastRx || client?.lastTx) {
385
- logger.debug(`Last message info: lastRx=${JSON.stringify(client.lastRx)}, lastTx=${JSON.stringify(client.lastTx)}`);
386
- }
387
- }
388
- catch (e) {
389
- logger.debug(`Could not get connection state: ${e?.message || String(e)}`);
390
- }
391
-
392
- const now = Date.now();
393
- const timeSinceLastDisconnect = now - this.lastDisconnectTime;
394
- this.lastDisconnectTime = now;
395
-
396
- logger.log(`Socket closed, resetting client state for reconnection (last disconnect ${timeSinceLastDisconnect}ms ago)`);
397
-
398
- // Mark as disconnected immediately to prevent reuse
399
- // This prevents race conditions where ensureBaichuanClient might check
400
- // isSocketConnected() before cleanup completes
401
- const currentApi = this.baichuanApi;
402
- if (currentApi === api) {
403
- // Only cleanup if this is still the current API instance
404
- // This prevents cleanup of a new connection that was created
405
- // while the old one was closing
406
- await this.cleanupBaichuanApi();
407
- }
408
-
409
- // Call custom close handler if provided
410
- if (callbacks.onClose) {
411
- try {
412
- await callbacks.onClose();
413
- } catch {
414
- // ignore
415
- }
416
- }
417
- };
418
304
 
419
305
  // Attach listeners
420
- api.client.on("error", this.errorListener);
421
- api.client.on("close", this.closeListener);
422
- }
423
-
424
- /**
425
- * Centralized cleanup method for Baichuan API
426
- * Removes all listeners, closes connection, and resets state
427
- */
428
- async cleanupBaichuanApi(): Promise<void> {
429
- if (!this.baichuanApi) {
430
- return;
431
- }
432
-
433
- const api = this.baichuanApi;
434
-
435
- // Unsubscribe from events first
436
- await this.unsubscribeFromEvents();
437
-
438
- // Call before cleanup hook
439
- await this.onBeforeCleanup();
306
+ this.attachBaichuanListeners(api);
440
307
 
441
- // Cleanup all stream clients
442
- await this.cleanupStreamClients();
308
+ this.baichuanApi = api;
309
+ this.connectionTime = Date.now();
443
310
 
444
- // Remove all listeners
445
- if (this.closeListener) {
446
- try {
447
- api.client.off("close", this.closeListener);
448
- } catch {
449
- // ignore
450
- }
451
- this.closeListener = undefined;
311
+ // Start ping and auto-renewal for TCP connections
312
+ if (this.transport === "tcp") {
313
+ // this.startConnectionMaintenance(api);
452
314
  }
453
315
 
454
- if (this.errorListener) {
455
- try {
456
- api.client.off("error", this.errorListener);
457
- } catch {
458
- // ignore
459
- }
460
- this.errorListener = undefined;
461
- }
316
+ // Start event check for all connections
317
+ this.startEventCheck(api);
462
318
 
463
- // Close connection if still connected
319
+ return api;
320
+ } catch (e) {
321
+ // Apply backoff for connection failures too, otherwise multiple callers can hammer connect().
322
+ this.lastDisconnectTime = Date.now();
323
+ // Ensure state is reset so next attempt is clean.
324
+ await this.cleanupBaichuanApi();
325
+ throw e;
326
+ }
327
+ })();
328
+
329
+ try {
330
+ return await this.ensureClientPromise;
331
+ } finally {
332
+ // Allow future reconnects and avoid pinning rejected promises
333
+ this.ensureClientPromise = undefined;
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Attach error and close listeners to Baichuan API
339
+ */
340
+ private attachBaichuanListeners(api: ReolinkBaichuanApi): void {
341
+ const logger = this.getBaichuanLogger();
342
+ const callbacks = this.getConnectionCallbacks();
343
+
344
+ // Error listener
345
+ this.errorListener = (err: unknown) => {
346
+ const msg =
347
+ (err as any)?.message || (err as any)?.toString?.() || String(err);
348
+
349
+ // Only log if it's not a recoverable error to avoid spam
350
+ if (
351
+ typeof msg === "string" &&
352
+ (msg.includes("Baichuan socket closed") ||
353
+ msg.includes("Baichuan UDP stream closed") ||
354
+ msg.includes("Not running"))
355
+ ) {
356
+ logger.debug(`error (recoverable): ${msg}`);
357
+ return;
358
+ }
359
+ logger.error(`error: ${msg}`);
360
+
361
+ // Call custom error handler if provided
362
+ if (callbacks.onError) {
464
363
  try {
465
- if (api.client.isSocketConnected()) {
466
- await api.close();
467
- }
364
+ callbacks.onError(err);
468
365
  } catch {
469
- // ignore
366
+ // ignore
470
367
  }
368
+ }
369
+ };
370
+
371
+ // Close listener
372
+ this.closeListener = async () => {
373
+ // Prevent multiple concurrent cleanup operations
374
+ if (!this.baichuanApi || this.baichuanApi !== api) {
375
+ // This close event is for a different/old client, ignore it
376
+ logger.debug("Close event for stale client, ignoring");
377
+ return;
378
+ }
379
+
380
+ try {
381
+ const wasConnected = api.client.isSocketConnected();
382
+ const wasLoggedIn = api.client.loggedIn;
383
+ logger.log(
384
+ `Connection state before close: connected=${wasConnected}, loggedIn=${wasLoggedIn}`,
385
+ );
471
386
 
472
- // Stop ping and auto-renewal intervals
473
- this.stopConnectionMaintenance();
474
-
475
- // Stop event check interval
476
- this.stopEventCheck();
477
-
478
- // Reset state
479
- this.baichuanApi = undefined;
480
- this.ensureClientPromise = undefined;
481
- }
482
-
483
- /**
484
- * Get all active Baichuan connections (main + stream clients)
485
- */
486
- private getAllActiveConnections(): ReolinkBaichuanApi[] {
487
- const connections: ReolinkBaichuanApi[] = [];
488
-
489
- // Add main connection if exists and is valid
490
- if (this.baichuanApi) {
491
- const isConnected = this.baichuanApi.client.isSocketConnected();
492
- const isLoggedIn = this.baichuanApi.client.loggedIn;
493
- if (isConnected && isLoggedIn) {
494
- connections.push(this.baichuanApi);
495
- }
387
+ // Try to get last message info if available
388
+ const client = api.client as any;
389
+ if (client?.lastRx || client?.lastTx) {
390
+ logger.debug(
391
+ `Last message info: lastRx=${JSON.stringify(client.lastRx)}, lastTx=${JSON.stringify(client.lastTx)}`,
392
+ );
496
393
  }
497
-
498
- // Add all stream clients that are valid
499
- for (const streamClient of this.streamClients.values()) {
500
- const isConnected = streamClient.client.isSocketConnected();
501
- const isLoggedIn = streamClient.client.loggedIn;
502
- if (isConnected && isLoggedIn) {
503
- connections.push(streamClient);
504
- }
394
+ } catch (e) {
395
+ logger.debug(
396
+ `Could not get connection state: ${e?.message || String(e)}`,
397
+ );
398
+ }
399
+
400
+ const now = Date.now();
401
+ const timeSinceLastDisconnect = now - this.lastDisconnectTime;
402
+ this.lastDisconnectTime = now;
403
+
404
+ logger.log(
405
+ `Socket closed, resetting client state for reconnection (last disconnect ${timeSinceLastDisconnect}ms ago)`,
406
+ );
407
+
408
+ // Mark as disconnected immediately to prevent reuse
409
+ // This prevents race conditions where ensureBaichuanClient might check
410
+ // isSocketConnected() before cleanup completes
411
+ const currentApi = this.baichuanApi;
412
+ if (currentApi === api) {
413
+ // Only cleanup if this is still the current API instance
414
+ // This prevents cleanup of a new connection that was created
415
+ // while the old one was closing
416
+ await this.cleanupBaichuanApi();
417
+ }
418
+
419
+ // Call custom close handler if provided
420
+ if (callbacks.onClose) {
421
+ try {
422
+ await callbacks.onClose();
423
+ } catch {
424
+ // ignore
425
+ }
426
+ }
427
+ };
428
+
429
+ // Attach listeners
430
+ api.client.on("error", this.errorListener);
431
+ api.client.on("close", this.closeListener);
432
+ }
433
+
434
+ /**
435
+ * Centralized cleanup method for Baichuan API
436
+ * Removes all listeners, closes connection, and resets state
437
+ */
438
+ async cleanupBaichuanApi(): Promise<void> {
439
+ if (!this.baichuanApi) {
440
+ return;
441
+ }
442
+
443
+ const api = this.baichuanApi;
444
+
445
+ // Unsubscribe from events first
446
+ await this.unsubscribeFromEvents();
447
+
448
+ // Call before cleanup hook
449
+ await this.onBeforeCleanup();
450
+
451
+ // Remove all listeners
452
+ if (this.closeListener) {
453
+ try {
454
+ api.client.off("close", this.closeListener);
455
+ } catch {
456
+ // ignore
457
+ }
458
+ this.closeListener = undefined;
459
+ }
460
+
461
+ if (this.errorListener) {
462
+ try {
463
+ api.client.off("error", this.errorListener);
464
+ } catch {
465
+ // ignore
466
+ }
467
+ this.errorListener = undefined;
468
+ }
469
+
470
+ // Close connection if still connected
471
+ try {
472
+ if (api.client.isSocketConnected()) {
473
+ await api.close();
474
+ }
475
+ } catch {
476
+ // ignore
477
+ }
478
+
479
+ // Stop ping and auto-renewal intervals
480
+ this.stopConnectionMaintenance();
481
+
482
+ // Stop event check interval
483
+ this.stopEventCheck();
484
+
485
+ // Reset state
486
+ this.baichuanApi = undefined;
487
+ this.ensureClientPromise = undefined;
488
+ }
489
+
490
+ /**
491
+ * Get all active Baichuan connections (main client only now)
492
+ */
493
+ private getAllActiveConnections(): ReolinkBaichuanApi[] {
494
+ const connections: ReolinkBaichuanApi[] = [];
495
+
496
+ // Add main connection if exists and is valid
497
+ if (this.baichuanApi) {
498
+ const isConnected = this.baichuanApi.client.isSocketConnected();
499
+ const isLoggedIn = this.baichuanApi.client.loggedIn;
500
+ if (isConnected && isLoggedIn) {
501
+ connections.push(this.baichuanApi);
502
+ }
503
+ }
504
+
505
+ return connections;
506
+ }
507
+
508
+ /**
509
+ * Start ping and auto-renewal maintenance for TCP connections
510
+ */
511
+ private startConnectionMaintenance(api: ReolinkBaichuanApi): void {
512
+ const logger = this.getBaichuanLogger();
513
+
514
+ // Stop any existing intervals
515
+ this.stopConnectionMaintenance();
516
+
517
+ // Ping every 30 seconds to keep all connections alive
518
+ this.pingInterval = setInterval(async () => {
519
+ if (!this.baichuanApi || this.baichuanApi !== api) {
520
+ return; // Connection changed, stop this interval
521
+ }
522
+
523
+ try {
524
+ // Get all active connections (main + stream clients)
525
+ const allConnections = this.getAllActiveConnections();
526
+ logger.debug(`Pinging ${allConnections.length} connections`);
527
+
528
+ if (allConnections.length === 0) {
529
+ this.consecutivePingFailures++;
530
+ logger.debug(
531
+ `No active connections found, failures=${this.consecutivePingFailures}`,
532
+ );
533
+
534
+ if (this.consecutivePingFailures >= 3) {
535
+ logger.log("No active connections detected, renewing connection");
536
+ await this.cleanupBaichuanApi();
537
+ this.consecutivePingFailures = 0;
538
+ }
539
+ return;
505
540
  }
506
541
 
507
- return connections;
508
- }
509
-
510
- /**
511
- * Start ping and auto-renewal maintenance for TCP connections
512
- */
513
- private startConnectionMaintenance(api: ReolinkBaichuanApi): void {
514
- const logger = this.getBaichuanLogger();
515
-
516
- // Stop any existing intervals
517
- this.stopConnectionMaintenance();
518
-
519
- // Ping every 30 seconds to keep all connections alive
520
- this.pingInterval = setInterval(async () => {
521
- if (!this.baichuanApi || this.baichuanApi !== api) {
522
- return; // Connection changed, stop this interval
523
- }
524
-
542
+ // Ping all connections using the specific ping method
543
+ const pingResults = await Promise.allSettled(
544
+ allConnections.map(async (conn) => {
525
545
  try {
526
- // Get all active connections (main + stream clients)
527
- const allConnections = this.getAllActiveConnections();
528
- logger.debug(`Pinging ${allConnections.length} connections`);
529
-
530
- if (allConnections.length === 0) {
531
- this.consecutivePingFailures++;
532
- logger.debug(`No active connections found, failures=${this.consecutivePingFailures}`);
533
-
534
- if (this.consecutivePingFailures >= 3) {
535
- logger.log('No active connections detected, renewing connection');
536
- await this.cleanupBaichuanApi();
537
- this.consecutivePingFailures = 0;
538
- }
539
- return;
540
- }
541
-
542
- // Ping all connections using the specific ping method
543
- const pingResults = await Promise.allSettled(
544
- allConnections.map(async (conn) => {
545
- try {
546
- await conn.ping();
547
- return { success: true, conn };
548
- } catch (e) {
549
- return { success: false, conn, error: e };
550
- }
551
- })
552
- );
553
-
554
- // Check results
555
- const failedPings = pingResults.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && !r.value.success));
556
-
557
- if (failedPings.length > 0) {
558
- this.consecutivePingFailures++;
559
- logger.debug(`Ping failed for ${failedPings.length}/${allConnections.length} connections, failures=${this.consecutivePingFailures}`);
560
-
561
- if (this.consecutivePingFailures >= 3) {
562
- logger.log(`Multiple ping failures detected (${failedPings.length} connections), renewing connection`);
563
- await this.cleanupBaichuanApi();
564
- this.consecutivePingFailures = 0;
565
- }
566
- } else {
567
- // All pings successful, reset failure counter
568
- this.consecutivePingFailures = 0;
569
- if (allConnections.length > 1) {
570
- logger.debug(`Ping successful for all ${allConnections.length} connections`);
571
- }
572
- }
546
+ await conn.ping();
547
+ return { success: true, conn };
573
548
  } catch (e) {
574
- logger.debug(`Error in ping check: ${e?.message || String(e)}`);
549
+ return { success: false, conn, error: e };
575
550
  }
576
- }, 30_000); // Every 30 seconds
551
+ }),
552
+ );
577
553
 
578
- // Auto-renewal every 5 minutes if no active streams
579
- this.autoRenewInterval = setInterval(async () => {
580
- if (!this.baichuanApi || this.baichuanApi !== api) {
581
- return; // Connection changed, stop this interval
582
- }
554
+ // Check results
555
+ const failedPings = pingResults.filter(
556
+ (r) =>
557
+ r.status === "rejected" ||
558
+ (r.status === "fulfilled" && !r.value.success),
559
+ );
583
560
 
584
- try {
585
- // Check if there are active streams
586
- const hasActiveStreams = this.getStreamManager?.()?.hasActiveStreams() ?? false;
587
-
588
- if (!hasActiveStreams) {
589
- logger.log('No active streams detected, renewing connection (auto-renewal)');
590
- await this.cleanupBaichuanApi();
591
- } else {
592
- logger.debug('Active streams detected, skipping auto-renewal');
593
- }
594
- } catch (e) {
595
- logger.debug(`Error in auto-renewal check: ${e?.message || String(e)}`);
596
- }
597
- }, 5 * 60_000); // Every 5 minutes
598
- }
561
+ if (failedPings.length > 0) {
562
+ this.consecutivePingFailures++;
563
+ logger.debug(
564
+ `Ping failed for ${failedPings.length}/${allConnections.length} connections, failures=${this.consecutivePingFailures}`,
565
+ );
599
566
 
600
- /**
601
- * Stop ping and auto-renewal maintenance
602
- */
603
- private stopConnectionMaintenance(): void {
604
- if (this.pingInterval) {
605
- clearInterval(this.pingInterval);
606
- this.pingInterval = undefined;
567
+ if (this.consecutivePingFailures >= 3) {
568
+ logger.log(
569
+ `Multiple ping failures detected (${failedPings.length} connections), renewing connection`,
570
+ );
571
+ await this.cleanupBaichuanApi();
572
+ this.consecutivePingFailures = 0;
573
+ }
574
+ } else {
575
+ // All pings successful, reset failure counter
576
+ this.consecutivePingFailures = 0;
577
+ if (allConnections.length > 1) {
578
+ logger.debug(
579
+ `Ping successful for all ${allConnections.length} connections`,
580
+ );
581
+ }
607
582
  }
608
- if (this.autoRenewInterval) {
609
- clearInterval(this.autoRenewInterval);
610
- this.autoRenewInterval = undefined;
583
+ } catch (e) {
584
+ logger.debug(`Error in ping check: ${e?.message || String(e)}`);
585
+ }
586
+ }, 30_000); // Every 30 seconds
587
+
588
+ // Auto-renewal every 5 minutes if no active streams
589
+ this.autoRenewInterval = setInterval(async () => {
590
+ if (!this.baichuanApi || this.baichuanApi !== api) {
591
+ return; // Connection changed, stop this interval
592
+ }
593
+
594
+ try {
595
+ // Check if there are active streams
596
+ const hasActiveStreams =
597
+ this.getStreamManager?.()?.hasActiveStreams() ?? false;
598
+
599
+ if (!hasActiveStreams) {
600
+ logger.log(
601
+ "No active streams detected, renewing connection (auto-renewal)",
602
+ );
603
+ await this.cleanupBaichuanApi();
604
+ } else {
605
+ logger.debug("Active streams detected, skipping auto-renewal");
611
606
  }
612
- this.consecutivePingFailures = 0;
613
- }
614
-
615
- /**
616
- * Start event check to monitor if events are being received
617
- */
618
- private startEventCheck(api: ReolinkBaichuanApi): void {
619
- const logger = this.getBaichuanLogger();
620
-
621
- // Stop any existing interval
622
- this.stopEventCheck();
623
-
624
- // Check every minute if events are being received
625
- this.eventCheckInterval = setInterval(async () => {
626
- if (!this.baichuanApi || this.baichuanApi !== api) {
627
- return; // Connection changed, stop this interval
628
- }
629
-
630
- // Only check if event subscription is active
631
- if (!this.eventSubscriptionActive) {
632
- return;
633
- }
634
-
635
- try {
636
- const now = Date.now();
637
- const timeSinceLastEvent = now - this.lastEventTime;
638
- const fiveMinutesMs = 5 * 60 * 1000;
639
-
640
- if (this.lastEventTime > 0 && timeSinceLastEvent > fiveMinutesMs) {
641
- logger.log(`No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, restarting event listener`);
642
- // Restart event subscription
643
- await this.unsubscribeFromEvents(true);
644
- await this.subscribeToEvents(true);
645
- } else if (this.lastEventTime === 0) {
646
- // If lastEventTime is 0, it means we just subscribed but haven't received any events yet
647
- // Wait a bit longer before considering it a problem
648
- const timeSinceSubscription = now - (this.connectionTime || now);
649
- if (timeSinceSubscription > fiveMinutesMs) {
650
- logger.log(`No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), restarting event listener`);
651
- await this.unsubscribeFromEvents(true);
652
- await this.subscribeToEvents(true);
653
- }
654
- }
655
- } catch (e) {
656
- logger.debug(`Error in event check: ${e?.message || String(e)}`);
607
+ } catch (e) {
608
+ logger.debug(`Error in auto-renewal check: ${e?.message || String(e)}`);
609
+ }
610
+ }, 5 * 60_000); // Every 5 minutes
611
+ }
612
+
613
+ /**
614
+ * Stop ping and auto-renewal maintenance
615
+ */
616
+ private stopConnectionMaintenance(): void {
617
+ if (this.pingInterval) {
618
+ clearInterval(this.pingInterval);
619
+ this.pingInterval = undefined;
620
+ }
621
+ if (this.autoRenewInterval) {
622
+ clearInterval(this.autoRenewInterval);
623
+ this.autoRenewInterval = undefined;
624
+ }
625
+ this.consecutivePingFailures = 0;
626
+ }
627
+
628
+ /**
629
+ * Start event check to monitor if events are being received
630
+ */
631
+ private startEventCheck(api: ReolinkBaichuanApi): void {
632
+ const logger = this.getBaichuanLogger();
633
+
634
+ // Stop any existing interval
635
+ this.stopEventCheck();
636
+
637
+ // Check every minute if events are being received
638
+ this.eventCheckInterval = setInterval(async () => {
639
+ if (!this.baichuanApi || this.baichuanApi !== api) {
640
+ return; // Connection changed, stop this interval
641
+ }
642
+
643
+ // Only check if event subscription is active
644
+ if (!this.eventSubscriptionActive) {
645
+ return;
646
+ }
647
+
648
+ try {
649
+ const now = Date.now();
650
+ const timeSinceLastEvent = now - this.lastEventTime;
651
+ const fiveMinutesMs = 5 * 60 * 1000;
652
+
653
+ if (this.lastEventTime > 0 && timeSinceLastEvent > fiveMinutesMs) {
654
+ logger.log(
655
+ `No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, restarting event listener`,
656
+ );
657
+ // Restart event subscription
658
+ await this.unsubscribeFromEvents(true);
659
+ await this.subscribeToEvents(true);
660
+ } else if (this.lastEventTime === 0) {
661
+ // If lastEventTime is 0, it means we just subscribed but haven't received any events yet
662
+ // Wait a bit longer before considering it a problem
663
+ const timeSinceSubscription = now - (this.connectionTime || now);
664
+ if (timeSinceSubscription > fiveMinutesMs) {
665
+ logger.log(
666
+ `No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), restarting event listener`,
667
+ );
668
+ await this.unsubscribeFromEvents(true);
669
+ await this.subscribeToEvents(true);
670
+ }
671
+ }
672
+ } catch (e) {
673
+ logger.debug(`Error in event check: ${e?.message || String(e)}`);
674
+ }
675
+ }, 5_000); // Check every minute
676
+ }
677
+
678
+ /**
679
+ * Stop event check interval
680
+ */
681
+ private stopEventCheck(): void {
682
+ if (this.eventCheckInterval) {
683
+ clearInterval(this.eventCheckInterval);
684
+ this.eventCheckInterval = undefined;
685
+ }
686
+ this.lastEventTime = 0;
687
+ }
688
+
689
+ /**
690
+ * Subscribe to Baichuan simple events
691
+ */
692
+ async subscribeToEvents(silent: boolean = false): Promise<void> {
693
+ const logger = this.getBaichuanLogger();
694
+ const callbacks = this.getConnectionCallbacks();
695
+
696
+ if (!callbacks.onSimpleEvent) {
697
+ return;
698
+ }
699
+
700
+ // If already subscribed and connection is valid, return
701
+ if (this.eventSubscriptionActive && this.baichuanApi) {
702
+ if (
703
+ this.baichuanApi.client.isSocketConnected() &&
704
+ this.baichuanApi.client.loggedIn
705
+ ) {
706
+ logger.debug("Event subscription already active");
707
+ return;
708
+ }
709
+ // Connection is invalid, reset subscription state
710
+ this.eventSubscriptionActive = false;
711
+ }
712
+
713
+ // Unsubscribe first if handler exists (idempotent)
714
+ await this.unsubscribeFromEvents(silent);
715
+
716
+ // Get Baichuan client connection
717
+ const api = await this.ensureBaichuanClient();
718
+
719
+ // Verify connection is ready
720
+ if (!api.client.isSocketConnected() || !api.client.loggedIn) {
721
+ logger.warn("Cannot subscribe to events: connection not ready");
722
+ return;
723
+ }
724
+
725
+ // Check if event subscription is enabled
726
+ if (
727
+ callbacks.getEventSubscriptionEnabled &&
728
+ !callbacks.getEventSubscriptionEnabled()
729
+ ) {
730
+ logger.debug("Event subscription disabled");
731
+ return;
732
+ }
733
+
734
+ // Subscribe to events with wrapper to track last event time
735
+ try {
736
+ const originalHandler = callbacks.onSimpleEvent;
737
+ const wrappedHandler = (ev: ReolinkSimpleEvent) => {
738
+ // Update last event time
739
+ this.lastEventTime = Date.now();
740
+ // Call original handler
741
+ originalHandler(ev);
742
+ };
743
+
744
+ await api.onSimpleEvent(wrappedHandler);
745
+ this.eventSubscriptionActive = true;
746
+ this.lastEventTime = Date.now(); // Initialize on subscription
747
+ logger.debug("Subscribed to Baichuan events");
748
+ } catch (e) {
749
+ logger.warn("Failed to subscribe to events", e?.message || String(e));
750
+ this.eventSubscriptionActive = false;
751
+ }
752
+ }
753
+
754
+ /**
755
+ * Unsubscribe from Baichuan simple events
756
+ * @param silent If true, don't log unsubscription messages
757
+ */
758
+ async unsubscribeFromEvents(silent: boolean = false): Promise<void> {
759
+ const logger = this.getBaichuanLogger();
760
+ const callbacks = this.getConnectionCallbacks();
761
+
762
+ // Only unsubscribe if we have an active subscription
763
+ if (
764
+ this.eventSubscriptionActive &&
765
+ this.baichuanApi &&
766
+ callbacks.onSimpleEvent
767
+ ) {
768
+ try {
769
+ this.baichuanApi.offSimpleEvent(callbacks.onSimpleEvent);
770
+ logger.debug("Unsubscribed from Baichuan events");
771
+ } catch (e) {
772
+ logger.warn("Error unsubscribing from events", e?.message || String(e));
773
+ }
774
+ }
775
+
776
+ this.eventSubscriptionActive = false;
777
+ }
778
+
779
+ /**
780
+ * Create or get a dedicated Baichuan API session for streaming (used by StreamManager).
781
+ * Always returns the main client - the library internally manages dedicated sockets.
782
+ */
783
+ async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
784
+ // Always return the main client - the library handles dedicated sockets internally
785
+ return await this.ensureBaichuanClient();
786
+ }
787
+
788
+ /**
789
+ * Refresh the list of active user sessions from the device.
790
+ * This method uses storageSettings which must be defined in subclasses.
791
+ * Uses ensureClient() if available (for camera with nvrDevice), otherwise ensureBaichuanClient().
792
+ */
793
+ protected async refreshUserSessionsList(): Promise<void> {
794
+ const logger = this.getBaichuanLogger();
795
+
796
+ try {
797
+ // Use ensureClient() if available (e.g., camera with nvrDevice), otherwise use ensureBaichuanClient()
798
+ const api = (this as any).ensureClient
799
+ ? await (this as any).ensureClient()
800
+ : await this.ensureBaichuanClient();
801
+
802
+ logger.log("[Sessions] Fetching active user sessions from device...");
803
+
804
+ const sessions = await api.getOnlineUserList();
805
+
806
+ // Get current socket session ID if available
807
+ let socketSessionId: string | undefined;
808
+ try {
809
+ socketSessionId = api.client.getSocketSessionId();
810
+ } catch {
811
+ // getSocketSessionId might not be available on all clients
812
+ }
813
+
814
+ // Format sessions as array of readable strings
815
+ const sessionStrings: string[] = [];
816
+
817
+ // Add header with timestamp and socket session ID
818
+ const timestamp = new Date().toLocaleString();
819
+ sessionStrings.push(`Last updated: ${timestamp}`);
820
+ if (socketSessionId) {
821
+ sessionStrings.push(`Current socket session ID: ${socketSessionId}`);
822
+ }
823
+ sessionStrings.push(""); // Empty line separator
824
+
825
+ // Parse sessions data (handle different response formats)
826
+ let sessionCount = 0;
827
+ const parseSessions = (data: any, prefix: string = ""): void => {
828
+ if (Array.isArray(data)) {
829
+ data.forEach((session: any, index: number) => {
830
+ sessionCount++;
831
+ const sessionInfo = formatSessionInfo(session, index + 1);
832
+ sessionStrings.push(sessionInfo);
833
+ });
834
+ } else if (data && typeof data === "object") {
835
+ // Handle nested objects
836
+ for (const [key, value] of Object.entries(data)) {
837
+ if (Array.isArray(value)) {
838
+ value.forEach((session: any, index: number) => {
839
+ sessionCount++;
840
+ const sessionInfo = formatSessionInfo(session, index + 1, key);
841
+ sessionStrings.push(sessionInfo);
842
+ });
843
+ } else if (value && typeof value === "object") {
844
+ parseSessions(value, prefix ? `${prefix}.${key}` : key);
845
+ } else {
846
+ // Single session object
847
+ sessionCount++;
848
+ const sessionInfo = formatSessionInfo(data, 1);
849
+ sessionStrings.push(sessionInfo);
850
+ return; // Only process once
657
851
  }
658
- }, 5_000); // Check every minute
659
- }
660
-
661
- /**
662
- * Stop event check interval
663
- */
664
- private stopEventCheck(): void {
665
- if (this.eventCheckInterval) {
666
- clearInterval(this.eventCheckInterval);
667
- this.eventCheckInterval = undefined;
852
+ }
668
853
  }
669
- this.lastEventTime = 0;
670
- }
671
-
672
- /**
673
- * Subscribe to Baichuan simple events
674
- */
675
- async subscribeToEvents(silent: boolean = false): Promise<void> {
676
- const logger = this.getBaichuanLogger();
677
- const callbacks = this.getConnectionCallbacks();
678
-
679
- if (!callbacks.onSimpleEvent) {
680
- return;
854
+ };
855
+
856
+ // Helper function to format session info as readable string
857
+ const formatSessionInfo = (
858
+ session: any,
859
+ index: number,
860
+ group?: string,
861
+ ): string => {
862
+ const parts: string[] = [];
863
+
864
+ if (group) {
865
+ parts.push(`[${group}]`);
681
866
  }
867
+ parts.push(`Session ${index}:`);
682
868
 
683
- // If already subscribed and connection is valid, return
684
- if (this.eventSubscriptionActive && this.baichuanApi) {
685
- if (this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
686
- logger.debug('Event subscription already active');
687
- return;
688
- }
689
- // Connection is invalid, reset subscription state
690
- this.eventSubscriptionActive = false;
869
+ // Extract common fields
870
+ if (session.userName !== undefined) {
871
+ parts.push(`User: ${session.userName}`);
691
872
  }
692
-
693
- // Unsubscribe first if handler exists (idempotent)
694
- await this.unsubscribeFromEvents(silent);
695
-
696
- // Get Baichuan client connection
697
- const api = await this.ensureBaichuanClient();
698
-
699
- // Verify connection is ready
700
- if (!api.client.isSocketConnected() || !api.client.loggedIn) {
701
- logger.warn('Cannot subscribe to events: connection not ready');
702
- return;
873
+ if (session.user !== undefined) {
874
+ parts.push(`User: ${session.user}`);
703
875
  }
704
-
705
- // Check if event subscription is enabled
706
- if (callbacks.getEventSubscriptionEnabled && !callbacks.getEventSubscriptionEnabled()) {
707
- logger.debug('Event subscription disabled');
708
- return;
876
+ if (session.ip !== undefined) {
877
+ parts.push(`IP: ${session.ip}`);
709
878
  }
710
-
711
- // Subscribe to events with wrapper to track last event time
712
- try {
713
- const originalHandler = callbacks.onSimpleEvent;
714
- const wrappedHandler = (ev: ReolinkSimpleEvent) => {
715
- // Update last event time
716
- this.lastEventTime = Date.now();
717
- // Call original handler
718
- originalHandler(ev);
719
- };
720
-
721
- await api.onSimpleEvent(wrappedHandler);
722
- this.eventSubscriptionActive = true;
723
- this.lastEventTime = Date.now(); // Initialize on subscription
724
- logger.debug('Subscribed to Baichuan events');
879
+ if (session.ipAddress !== undefined) {
880
+ parts.push(`IP: ${session.ipAddress}`);
725
881
  }
726
- catch (e) {
727
- logger.warn('Failed to subscribe to events', e?.message || String(e));
728
- this.eventSubscriptionActive = false;
882
+ if (session.port !== undefined) {
883
+ parts.push(`Port: ${session.port}`);
729
884
  }
730
- }
731
-
732
- /**
733
- * Unsubscribe from Baichuan simple events
734
- * @param silent If true, don't log unsubscription messages
735
- */
736
- async unsubscribeFromEvents(silent: boolean = false): Promise<void> {
737
- const logger = this.getBaichuanLogger();
738
- const callbacks = this.getConnectionCallbacks();
739
-
740
- // Only unsubscribe if we have an active subscription
741
- if (this.eventSubscriptionActive && this.baichuanApi && callbacks.onSimpleEvent) {
742
- try {
743
- this.baichuanApi.offSimpleEvent(callbacks.onSimpleEvent);
744
- logger.debug('Unsubscribed from Baichuan events');
745
- }
746
- catch (e) {
747
- logger.warn('Error unsubscribing from events', e?.message || String(e));
748
- }
885
+ if (session.sessionId !== undefined) {
886
+ parts.push(`Session ID: ${session.sessionId}`);
749
887
  }
750
-
751
- this.eventSubscriptionActive = false;
752
- }
753
-
754
- private async createNewClient(): Promise<ReolinkBaichuanApi> {
755
- const config = this.getStreamClientInputs();
756
- const logger = this.getBaichuanLogger();
757
-
758
- const api = await createBaichuanApi({
759
- inputs: {
760
- host: config.host,
761
- username: config.username,
762
- password: config.password,
763
- uid: config.uid,
764
- logger,
765
- debugOptions: config.debugOptions,
766
- udpDiscoveryMethod: config.udpDiscoveryMethod,
767
- },
768
- transport: config.transport,
769
- });
770
-
771
- await api.login();
772
-
773
- return api;
774
- }
775
-
776
- /**
777
- * Create or get a dedicated Baichuan API session for streaming (used by StreamManager).
778
- * Returns an existing client if one exists for the same streamKey, otherwise creates a new one.
779
- * @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
780
- */
781
- async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
782
- const logger = this.getBaichuanLogger();
783
-
784
- // Check if a client already exists for this streamKey
785
- const existingClient = this.streamClients.get(streamKey);
786
- if (existingClient) {
787
- // Verify the client is still valid (connected and logged in)
788
- const isConnected = existingClient.client.isSocketConnected();
789
- const isLoggedIn = existingClient.client.loggedIn;
790
-
791
- if (isConnected && isLoggedIn) {
792
- // Return existing valid client
793
- logger.log(`Reusing existing stream client for streamKey=${streamKey}`);
794
- return existingClient;
795
- }
796
-
797
- logger.log(`Stale stream client detected for streamKey=${streamKey}, recreating`);
798
- try {
799
- if (existingClient.client.isSocketConnected()) {
800
- await existingClient.close();
801
- }
802
- } catch {
803
- // ignore cleanup errors
804
- }
805
- this.streamClients.delete(streamKey);
888
+ if (session.id !== undefined) {
889
+ parts.push(`ID: ${session.id}`);
890
+ }
891
+ if (session.loginTime !== undefined) {
892
+ parts.push(`Login Time: ${session.loginTime}`);
893
+ }
894
+ if (session.time !== undefined) {
895
+ parts.push(`Time: ${session.time}`);
896
+ }
897
+ if (session.status !== undefined) {
898
+ parts.push(`Status: ${session.status}`);
806
899
  }
807
900
 
808
- // Create new client for this streamKey
809
- const api = await this.createNewClient();
810
-
811
- // Store in map for future reuse
812
- this.streamClients.set(streamKey, api);
813
- logger.log(`Created new stream client for streamKey=${streamKey}`);
901
+ // If no common fields found, show all fields
902
+ if (parts.length === (group ? 2 : 1)) {
903
+ const allFields = Object.entries(session)
904
+ .map(([key, value]) => `${key}: ${value}`)
905
+ .join(", ");
906
+ parts.push(allFields);
907
+ }
814
908
 
815
- // Clean up when client closes
816
- api.client.once('close', () => {
817
- const currentClient = this.streamClients.get(streamKey);
818
- if (currentClient === api) {
819
- this.streamClients.delete(streamKey);
820
- }
821
- });
909
+ return parts.join(" | ");
910
+ };
822
911
 
823
- return api;
824
- }
912
+ parseSessions(sessions);
825
913
 
826
- /**
827
- * Teardown a single stream client by streamKey.
828
- *
829
- * This is useful for non-stream features (e.g. intercom) that still use createStreamClient()
830
- * and need to ensure the underlying Baichuan session is torn down at the end of the operation.
831
- */
832
- async teardownStreamClient(streamKey: string, options?: { timeoutMs?: number }): Promise<void> {
833
- const api = this.streamClients.get(streamKey);
834
- // Remove first to avoid re-use while closing (and to ensure teardown even if close hangs).
835
- this.streamClients.delete(streamKey);
836
- if (!api) return;
837
-
838
- const timeoutMs = options?.timeoutMs ?? 2000;
839
- const sleepMs = async (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
914
+ // If no sessions found, add a message
915
+ if (sessionCount === 0) {
916
+ sessionStrings.push("No active sessions found");
917
+ }
840
918
 
841
- try {
842
- await Promise.race([
843
- api.close({ reason: `teardownStreamClient:${streamKey}` }),
844
- sleepMs(timeoutMs),
845
- ]);
846
- } catch {
847
- // ignore
848
- }
849
- }
919
+ // Update the setting with the formatted session strings
920
+ // Note: storageSettings must be defined in subclasses
921
+ (this as any).storageSettings.values.userSessions = sessionStrings;
850
922
 
851
- /**
852
- * Cleanup all stream clients (called during device cleanup)
853
- */
854
- async cleanupStreamClients(): Promise<void> {
855
- const clients = Array.from(this.streamClients.values());
856
- this.streamClients.clear();
857
-
858
- await Promise.allSettled(
859
- clients.map(async (api) => {
860
- try {
861
- if (api.client.isSocketConnected()) {
862
- await api.close();
863
- }
864
- } catch {
865
- // ignore cleanup errors
866
- }
867
- })
923
+ logger.log(`[Sessions] Retrieved ${sessionCount} active session(s)`);
924
+ if (socketSessionId) {
925
+ logger.debug(
926
+ `[Sessions] Current socket session ID: ${socketSessionId}`,
868
927
  );
869
- }
928
+ }
929
+ } catch (e) {
930
+ const errorMsg = e?.message || String(e);
931
+ logger.error(`[Sessions] Failed to fetch user sessions: ${errorMsg}`);
932
+
933
+ // Update setting with error message
934
+ // Note: storageSettings must be defined in subclasses
935
+ (this as any).storageSettings.values.userSessions = [
936
+ `Error fetching sessions: ${errorMsg}`,
937
+ `Timestamp: ${new Date().toLocaleString()}`,
938
+ ];
939
+ throw e;
940
+ }
941
+ }
870
942
  }
871
-