@apocaliss92/scrypted-reolink-native 0.1.9 → 0.1.11

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/plugin.zip CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apocaliss92/scrypted-reolink-native",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Use any reolink camera with Scrypted, even older/unsupported models without HTTP protocol support",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
@@ -0,0 +1,478 @@
1
+ import type { ReolinkBaichuanApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import { ScryptedDeviceBase } from "@scrypted/sdk";
3
+ import { createBaichuanApi, type BaichuanTransport } from "./connect";
4
+
5
+ export interface BaichuanConnectionConfig {
6
+ host: string;
7
+ username: string;
8
+ password: string;
9
+ uid?: string;
10
+ transport: BaichuanTransport;
11
+ logger: Console;
12
+ debugOptions?: any;
13
+ }
14
+
15
+ export interface BaichuanConnectionCallbacks {
16
+ onError?: (err: unknown) => void;
17
+ onClose?: () => void | Promise<void>;
18
+ onSimpleEvent?: (ev: ReolinkSimpleEvent) => void;
19
+ getEventSubscriptionEnabled?: () => boolean;
20
+ }
21
+
22
+ /**
23
+ * Logger wrapper that adds device name, timestamp, and debug control
24
+ * Implements Console interface to be compatible with Baichuan API
25
+ */
26
+ export class BaichuanLogger implements Console {
27
+ private baseLogger: Console;
28
+ private deviceName: string;
29
+ private isDebugEnabledCallback: () => boolean;
30
+
31
+ constructor(baseLogger: Console, deviceName: string, isDebugEnabledCallback: () => boolean) {
32
+ this.baseLogger = baseLogger;
33
+ this.deviceName = deviceName;
34
+ this.isDebugEnabledCallback = isDebugEnabledCallback;
35
+ }
36
+
37
+ private formatMessage(level: string, ...args: any[]): string {
38
+ const timestamp = new Date().toLocaleString();
39
+ const prefix = `[${this.deviceName}] [${timestamp}] [${level}]`;
40
+ return `${prefix} ${args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ')}`;
41
+ }
42
+
43
+ log(...args: any[]): void {
44
+ this.baseLogger.log(this.formatMessage('LOG', ...args));
45
+ }
46
+
47
+ error(...args: any[]): void {
48
+ this.baseLogger.error(this.formatMessage('ERROR', ...args));
49
+ }
50
+
51
+ warn(...args: any[]): void {
52
+ this.baseLogger.warn(this.formatMessage('WARN', ...args));
53
+ }
54
+
55
+ debug(...args: any[]): void {
56
+ if (this.isDebugEnabledCallback()) {
57
+ this.baseLogger.debug(this.formatMessage('DEBUG', ...args));
58
+ }
59
+ }
60
+
61
+ isDebugEnabled(): boolean {
62
+ return this.isDebugEnabledCallback();
63
+ }
64
+
65
+ // Console interface implementation - delegate to baseLogger
66
+ assert(condition?: boolean, ...data: any[]): void {
67
+ this.baseLogger.assert(condition, ...data);
68
+ }
69
+
70
+ clear(): void {
71
+ this.baseLogger.clear();
72
+ }
73
+
74
+ count(label?: string): void {
75
+ this.baseLogger.count(label);
76
+ }
77
+
78
+ countReset(label?: string): void {
79
+ this.baseLogger.countReset(label);
80
+ }
81
+
82
+ dir(item?: any, options?: any): void {
83
+ this.baseLogger.dir(item, options);
84
+ }
85
+
86
+ dirxml(...data: any[]): void {
87
+ this.baseLogger.dirxml(...data);
88
+ }
89
+
90
+ group(...data: any[]): void {
91
+ this.baseLogger.group(...data);
92
+ }
93
+
94
+ groupCollapsed(...data: any[]): void {
95
+ this.baseLogger.groupCollapsed(...data);
96
+ }
97
+
98
+ groupEnd(): void {
99
+ this.baseLogger.groupEnd();
100
+ }
101
+
102
+ info(...data: any[]): void {
103
+ this.baseLogger.info(this.formatMessage('INFO', ...data));
104
+ }
105
+
106
+ table(tabularData?: any, properties?: string[]): void {
107
+ this.baseLogger.table(tabularData, properties);
108
+ }
109
+
110
+ time(label?: string): void {
111
+ this.baseLogger.time(label);
112
+ }
113
+
114
+ timeEnd(label?: string): void {
115
+ this.baseLogger.timeEnd(label);
116
+ }
117
+
118
+ timeLog(label?: string, ...data: any[]): void {
119
+ this.baseLogger.timeLog(label, ...data);
120
+ }
121
+
122
+ trace(...data: any[]): void {
123
+ this.baseLogger.trace(...data);
124
+ }
125
+
126
+ // Console properties
127
+ get memory(): any {
128
+ return (this.baseLogger as any).memory;
129
+ }
130
+
131
+ get Console(): any {
132
+ return (this.baseLogger as any).Console;
133
+ }
134
+
135
+ // Node.js specific
136
+ profile(label?: string): void {
137
+ if (typeof (this.baseLogger as any).profile === 'function') {
138
+ (this.baseLogger as any).profile(label);
139
+ }
140
+ }
141
+
142
+ profileEnd(label?: string): void {
143
+ if (typeof (this.baseLogger as any).profileEnd === 'function') {
144
+ (this.baseLogger as any).profileEnd(label);
145
+ }
146
+ }
147
+
148
+ timeStamp(label?: string): void {
149
+ if (typeof (this.baseLogger as any).timeStamp === 'function') {
150
+ (this.baseLogger as any).timeStamp(label);
151
+ }
152
+ }
153
+
154
+ context(...data: any[]): void {
155
+ if (typeof (this.baseLogger as any).context === 'function') {
156
+ (this.baseLogger as any).context(...data);
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Base class for managing Baichuan API connections with automatic reconnection,
163
+ * listener management, and event subscription handling.
164
+ */
165
+ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
166
+ protected baichuanApi: ReolinkBaichuanApi | undefined;
167
+ protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
168
+ protected connectionTime: number | undefined;
169
+
170
+ private errorListener?: (err: unknown) => void;
171
+ private closeListener?: () => void;
172
+ private lastDisconnectTime: number = 0;
173
+ private readonly reconnectBackoffMs: number = 2000; // 2 seconds minimum between reconnects
174
+ private eventSubscriptionActive: boolean = false;
175
+
176
+ /**
177
+ * Get the connection configuration for this instance
178
+ */
179
+ protected abstract getConnectionConfig(): BaichuanConnectionConfig;
180
+
181
+ /**
182
+ * Get callbacks for connection events
183
+ */
184
+ protected abstract getConnectionCallbacks(): BaichuanConnectionCallbacks;
185
+
186
+ /**
187
+ * Check if debug logging is enabled
188
+ */
189
+ protected abstract isDebugEnabled(): boolean;
190
+
191
+ /**
192
+ * Get the device name for logging
193
+ */
194
+ protected abstract getDeviceName(): string;
195
+
196
+ /**
197
+ * Get a Baichuan logger instance with formatting and debug control
198
+ * This logger implements Console interface and can be used everywhere
199
+ */
200
+ public getBaichuanLogger(): BaichuanLogger {
201
+ return new BaichuanLogger(this.console, this.getDeviceName(), () => this.isDebugEnabled());
202
+ }
203
+
204
+ /**
205
+ * Cleanup any additional resources (called before closing connection)
206
+ */
207
+ protected async onBeforeCleanup(): Promise<void> {
208
+ // Override in subclasses if needed
209
+ }
210
+
211
+ /**
212
+ * Ensure Baichuan client is connected and ready
213
+ */
214
+ async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
215
+ // Reuse existing client if socket is still connected and logged in
216
+ if (this.baichuanApi && this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
217
+ return this.baichuanApi;
218
+ }
219
+
220
+ // Prevent concurrent login storms
221
+ if (this.ensureClientPromise) return await this.ensureClientPromise;
222
+
223
+ // Apply backoff to avoid aggressive reconnection after disconnection
224
+ if (this.lastDisconnectTime > 0) {
225
+ const timeSinceDisconnect = Date.now() - this.lastDisconnectTime;
226
+ if (timeSinceDisconnect < this.reconnectBackoffMs) {
227
+ const waitTime = this.reconnectBackoffMs - timeSinceDisconnect;
228
+ const logger = this.getBaichuanLogger();
229
+ logger.log(`Waiting ${waitTime}ms before reconnection (backoff)`);
230
+ await new Promise(resolve => setTimeout(resolve, waitTime));
231
+ }
232
+ }
233
+
234
+ this.ensureClientPromise = (async () => {
235
+ const config = this.getConnectionConfig();
236
+
237
+ // Clean up old client if exists
238
+ if (this.baichuanApi) {
239
+ await this.cleanupBaichuanApi();
240
+ }
241
+
242
+ // Create new Baichuan client
243
+ // BaichuanLogger implements Console, so it can be used directly
244
+ const logger = this.getBaichuanLogger();
245
+ const api = await createBaichuanApi({
246
+ inputs: {
247
+ host: config.host,
248
+ username: config.username,
249
+ password: config.password,
250
+ uid: config.uid,
251
+ logger: logger as Console,
252
+ debugOptions: config.debugOptions,
253
+ },
254
+ transport: config.transport,
255
+ logger: logger as Console,
256
+ });
257
+
258
+ await api.login();
259
+
260
+ // Verify socket is connected before returning
261
+ if (!api.client.isSocketConnected()) {
262
+ throw new Error('Socket not connected after login');
263
+ }
264
+
265
+ // Attach listeners
266
+ this.attachBaichuanListeners(api);
267
+
268
+ this.baichuanApi = api;
269
+ this.connectionTime = Date.now();
270
+
271
+ return api;
272
+ })();
273
+
274
+ try {
275
+ return await this.ensureClientPromise;
276
+ }
277
+ finally {
278
+ // Allow future reconnects and avoid pinning rejected promises
279
+ this.ensureClientPromise = undefined;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Attach error and close listeners to Baichuan API
285
+ */
286
+ private attachBaichuanListeners(api: ReolinkBaichuanApi): void {
287
+ const logger = this.getBaichuanLogger();
288
+ const callbacks = this.getConnectionCallbacks();
289
+
290
+ // Error listener
291
+ this.errorListener = (err: unknown) => {
292
+ const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
293
+
294
+ // Only log if it's not a recoverable error to avoid spam
295
+ if (typeof msg === 'string' && (
296
+ msg.includes('Baichuan socket closed') ||
297
+ msg.includes('Baichuan UDP stream closed') ||
298
+ msg.includes('Not running')
299
+ )) {
300
+ logger.debug(`error (recoverable): ${msg}`);
301
+ return;
302
+ }
303
+ logger.error(`error: ${msg}`);
304
+
305
+ // Call custom error handler if provided
306
+ if (callbacks.onError) {
307
+ try {
308
+ callbacks.onError(err);
309
+ } catch {
310
+ // ignore
311
+ }
312
+ }
313
+ };
314
+
315
+ // Close listener
316
+ this.closeListener = async () => {
317
+ try {
318
+ const wasConnected = api.client.isSocketConnected();
319
+ const wasLoggedIn = api.client.loggedIn;
320
+ logger.log(`Connection state before close: connected=${wasConnected}, loggedIn=${wasLoggedIn}`);
321
+
322
+ // Try to get last message info if available
323
+ const client = api.client as any;
324
+ if (client?.lastRx || client?.lastTx) {
325
+ logger.debug(`Last message info: lastRx=${JSON.stringify(client.lastRx)}, lastTx=${JSON.stringify(client.lastTx)}`);
326
+ }
327
+ }
328
+ catch (e) {
329
+ logger.debug(`Could not get connection state: ${e}`);
330
+ }
331
+
332
+ const now = Date.now();
333
+ const timeSinceLastDisconnect = now - this.lastDisconnectTime;
334
+ this.lastDisconnectTime = now;
335
+
336
+ logger.log(`Socket closed, resetting client state for reconnection (last disconnect ${timeSinceLastDisconnect}ms ago)`);
337
+
338
+ // Cleanup
339
+ await this.cleanupBaichuanApi();
340
+
341
+ // Call custom close handler if provided
342
+ if (callbacks.onClose) {
343
+ try {
344
+ await callbacks.onClose();
345
+ } catch {
346
+ // ignore
347
+ }
348
+ }
349
+ };
350
+
351
+ // Attach listeners
352
+ api.client.on("error", this.errorListener);
353
+ api.client.on("close", this.closeListener);
354
+ }
355
+
356
+ /**
357
+ * Centralized cleanup method for Baichuan API
358
+ * Removes all listeners, closes connection, and resets state
359
+ */
360
+ async cleanupBaichuanApi(): Promise<void> {
361
+ if (!this.baichuanApi) {
362
+ return;
363
+ }
364
+
365
+ const api = this.baichuanApi;
366
+
367
+ // Unsubscribe from events first
368
+ await this.unsubscribeFromEvents();
369
+
370
+ // Call before cleanup hook
371
+ await this.onBeforeCleanup();
372
+
373
+ // Remove all listeners
374
+ if (this.closeListener) {
375
+ try {
376
+ api.client.off("close", this.closeListener);
377
+ } catch {
378
+ // ignore
379
+ }
380
+ this.closeListener = undefined;
381
+ }
382
+
383
+ if (this.errorListener) {
384
+ try {
385
+ api.client.off("error", this.errorListener);
386
+ } catch {
387
+ // ignore
388
+ }
389
+ this.errorListener = undefined;
390
+ }
391
+
392
+ // Close connection if still connected
393
+ try {
394
+ if (api.client.isSocketConnected()) {
395
+ await api.close();
396
+ }
397
+ } catch {
398
+ // ignore
399
+ }
400
+
401
+ // Reset state
402
+ this.baichuanApi = undefined;
403
+ this.ensureClientPromise = undefined;
404
+ }
405
+
406
+ /**
407
+ * Subscribe to Baichuan simple events
408
+ */
409
+ async subscribeToEvents(): Promise<void> {
410
+ const logger = this.getBaichuanLogger();
411
+ const callbacks = this.getConnectionCallbacks();
412
+
413
+ if (!callbacks.onSimpleEvent) {
414
+ return;
415
+ }
416
+
417
+ // If already subscribed and connection is valid, return
418
+ if (this.eventSubscriptionActive && this.baichuanApi) {
419
+ if (this.baichuanApi.client.isSocketConnected() && this.baichuanApi.client.loggedIn) {
420
+ logger.debug('Event subscription already active');
421
+ return;
422
+ }
423
+ // Connection is invalid, reset subscription state
424
+ this.eventSubscriptionActive = false;
425
+ }
426
+
427
+ // Unsubscribe first if handler exists (idempotent)
428
+ await this.unsubscribeFromEvents();
429
+
430
+ // Get Baichuan client connection
431
+ const api = await this.ensureBaichuanClient();
432
+
433
+ // Verify connection is ready
434
+ if (!api.client.isSocketConnected() || !api.client.loggedIn) {
435
+ logger.warn('Cannot subscribe to events: connection not ready');
436
+ return;
437
+ }
438
+
439
+ // Check if event subscription is enabled
440
+ if (callbacks.getEventSubscriptionEnabled && !callbacks.getEventSubscriptionEnabled()) {
441
+ logger.debug('Event subscription disabled');
442
+ return;
443
+ }
444
+
445
+ // Subscribe to events
446
+ try {
447
+ await api.onSimpleEvent(callbacks.onSimpleEvent);
448
+ this.eventSubscriptionActive = true;
449
+ logger.log('Subscribed to Baichuan events');
450
+ }
451
+ catch (e) {
452
+ logger.warn('Failed to subscribe to events', e);
453
+ this.eventSubscriptionActive = false;
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Unsubscribe from Baichuan simple events
459
+ */
460
+ async unsubscribeFromEvents(): Promise<void> {
461
+ const logger = this.getBaichuanLogger();
462
+ const callbacks = this.getConnectionCallbacks();
463
+
464
+ // Only unsubscribe if we have an active subscription
465
+ if (this.eventSubscriptionActive && this.baichuanApi && callbacks.onSimpleEvent) {
466
+ try {
467
+ this.baichuanApi.offSimpleEvent(callbacks.onSimpleEvent);
468
+ logger.debug('Unsubscribed from Baichuan events');
469
+ }
470
+ catch (e) {
471
+ logger.warn('Error unsubscribing from events', e);
472
+ }
473
+ }
474
+
475
+ this.eventSubscriptionActive = false;
476
+ }
477
+ }
478
+