@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/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +478 -0
- package/src/camera-battery.ts +40 -36
- package/src/camera.ts +6 -9
- package/src/common.ts +108 -231
- package/src/connect.ts +1 -2
- package/src/debug-options.ts +1 -1
- package/src/intercom.ts +3 -3
- package/src/nvr.ts +66 -217
- package/src/stream-utils.ts +19 -8
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
+
|