@apocaliss92/scrypted-reolink-native 0.1.42 → 0.2.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.
- package/build-lib.sh +31 -0
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +2 -1
- package/src/baichuan-base.ts +149 -30
- package/src/camera-battery.ts +5 -58
- package/src/camera.ts +5 -2
- package/src/common.ts +471 -240
- package/src/intercom.ts +3 -3
- package/src/main.ts +38 -21
- package/src/multiFocal.ts +238 -144
- package/src/nvr.ts +194 -160
- package/src/stream-utils.ts +232 -101
- package/src/utils.ts +19 -19
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apocaliss92/scrypted-reolink-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"scripts": {
|
|
8
|
+
"build:lib": "./build-lib.sh",
|
|
8
9
|
"scrypted-setup-project": "scrypted-setup-project",
|
|
9
10
|
"prescrypted-setup-project": "scrypted-package-json",
|
|
10
11
|
"build": "scrypted-webpack",
|
package/src/baichuan-base.ts
CHANGED
|
@@ -162,7 +162,15 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
162
162
|
protected baichuanApi: ReolinkBaichuanApi | undefined;
|
|
163
163
|
protected ensureClientPromise: Promise<ReolinkBaichuanApi> | undefined;
|
|
164
164
|
protected connectionTime: number | undefined;
|
|
165
|
-
|
|
165
|
+
transport: BaichuanTransport;
|
|
166
|
+
// Map of stream clients keyed by streamKey (profile + variantType)
|
|
167
|
+
private streamClients = new Map<string, ReolinkBaichuanApi>();
|
|
168
|
+
|
|
169
|
+
constructor(nativeId: string, transport: BaichuanTransport) {
|
|
170
|
+
super(nativeId);
|
|
171
|
+
this.transport = transport
|
|
172
|
+
}
|
|
173
|
+
|
|
166
174
|
private errorListener?: (err: unknown) => void;
|
|
167
175
|
private closeListener?: () => void;
|
|
168
176
|
private lastDisconnectTime: number = 0;
|
|
@@ -189,6 +197,13 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
189
197
|
*/
|
|
190
198
|
protected abstract getDeviceName(): string;
|
|
191
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Get connection inputs for creating a stream client for a specific streamKey
|
|
202
|
+
* This method is called by createStreamClient to get the inputs needed to create a new client
|
|
203
|
+
* @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
|
|
204
|
+
*/
|
|
205
|
+
protected abstract getStreamClientInputs(): BaichuanConnectionConfig;
|
|
206
|
+
|
|
192
207
|
/**
|
|
193
208
|
* Get a Baichuan logger instance with formatting and debug control
|
|
194
209
|
* This logger implements Console interface and can be used everywhere
|
|
@@ -216,11 +231,11 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
216
231
|
if (this.baichuanApi) {
|
|
217
232
|
const isConnected = this.baichuanApi.client.isSocketConnected();
|
|
218
233
|
const isLoggedIn = this.baichuanApi.client.loggedIn;
|
|
219
|
-
|
|
234
|
+
|
|
220
235
|
// Only reuse if both conditions are true
|
|
221
236
|
if (isConnected && isLoggedIn) {
|
|
222
|
-
|
|
223
|
-
|
|
237
|
+
return this.baichuanApi;
|
|
238
|
+
}
|
|
224
239
|
|
|
225
240
|
// If socket is not connected or not logged in, cleanup the stale client
|
|
226
241
|
// This prevents leaking connections when the socket appears connected but isn't
|
|
@@ -251,33 +266,42 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
251
266
|
// Create new Baichuan client
|
|
252
267
|
// BaichuanLogger implements Console, so it can be used directly
|
|
253
268
|
const logger = this.getBaichuanLogger();
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
269
|
+
try {
|
|
270
|
+
const api = await createBaichuanApi({
|
|
271
|
+
inputs: {
|
|
272
|
+
host: config.host,
|
|
273
|
+
username: config.username,
|
|
274
|
+
password: config.password,
|
|
275
|
+
uid: config.uid,
|
|
276
|
+
logger,
|
|
277
|
+
debugOptions: config.debugOptions,
|
|
278
|
+
udpDiscoveryMethod: config.udpDiscoveryMethod,
|
|
279
|
+
},
|
|
280
|
+
transport: config.transport,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await api.login();
|
|
284
|
+
|
|
285
|
+
// Verify socket is connected before returning
|
|
286
|
+
if (!api.client.isSocketConnected()) {
|
|
287
|
+
throw new Error('Socket not connected after login');
|
|
288
|
+
}
|
|
273
289
|
|
|
274
|
-
|
|
275
|
-
|
|
290
|
+
// Attach listeners
|
|
291
|
+
this.attachBaichuanListeners(api);
|
|
276
292
|
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
this.baichuanApi = api;
|
|
294
|
+
this.connectionTime = Date.now();
|
|
279
295
|
|
|
280
|
-
|
|
296
|
+
return api;
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
// Apply backoff for connection failures too, otherwise multiple callers can hammer connect().
|
|
300
|
+
this.lastDisconnectTime = Date.now();
|
|
301
|
+
// Ensure state is reset so next attempt is clean.
|
|
302
|
+
await this.cleanupBaichuanApi();
|
|
303
|
+
throw e;
|
|
304
|
+
}
|
|
281
305
|
})();
|
|
282
306
|
|
|
283
307
|
try {
|
|
@@ -310,7 +334,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
310
334
|
return;
|
|
311
335
|
}
|
|
312
336
|
logger.error(`error: ${msg}`);
|
|
313
|
-
|
|
337
|
+
|
|
314
338
|
// Call custom error handler if provided
|
|
315
339
|
if (callbacks.onError) {
|
|
316
340
|
try {
|
|
@@ -359,7 +383,7 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
359
383
|
// Only cleanup if this is still the current API instance
|
|
360
384
|
// This prevents cleanup of a new connection that was created
|
|
361
385
|
// while the old one was closing
|
|
362
|
-
|
|
386
|
+
await this.cleanupBaichuanApi();
|
|
363
387
|
}
|
|
364
388
|
|
|
365
389
|
// Call custom close handler if provided
|
|
@@ -394,6 +418,9 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
394
418
|
// Call before cleanup hook
|
|
395
419
|
await this.onBeforeCleanup();
|
|
396
420
|
|
|
421
|
+
// Cleanup all stream clients
|
|
422
|
+
await this.cleanupStreamClients();
|
|
423
|
+
|
|
397
424
|
// Remove all listeners
|
|
398
425
|
if (this.closeListener) {
|
|
399
426
|
try {
|
|
@@ -498,5 +525,97 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
498
525
|
|
|
499
526
|
this.eventSubscriptionActive = false;
|
|
500
527
|
}
|
|
528
|
+
|
|
529
|
+
private async createNewClient(): Promise<ReolinkBaichuanApi> {
|
|
530
|
+
const config = this.getStreamClientInputs();
|
|
531
|
+
const logger = this.getBaichuanLogger();
|
|
532
|
+
|
|
533
|
+
const api = await createBaichuanApi({
|
|
534
|
+
inputs: {
|
|
535
|
+
host: config.host,
|
|
536
|
+
username: config.username,
|
|
537
|
+
password: config.password,
|
|
538
|
+
uid: config.uid,
|
|
539
|
+
logger,
|
|
540
|
+
debugOptions: config.debugOptions,
|
|
541
|
+
udpDiscoveryMethod: config.udpDiscoveryMethod,
|
|
542
|
+
},
|
|
543
|
+
transport: config.transport,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
await api.login();
|
|
547
|
+
|
|
548
|
+
return api;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Create or get a dedicated Baichuan API session for streaming (used by StreamManager).
|
|
553
|
+
* Returns an existing client if one exists for the same streamKey, otherwise creates a new one.
|
|
554
|
+
* @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
|
|
555
|
+
*/
|
|
556
|
+
async createStreamClient(streamKey: string): Promise<ReolinkBaichuanApi> {
|
|
557
|
+
const logger = this.getBaichuanLogger();
|
|
558
|
+
|
|
559
|
+
// Check if a client already exists for this streamKey
|
|
560
|
+
const existingClient = this.streamClients.get(streamKey);
|
|
561
|
+
if (existingClient) {
|
|
562
|
+
// Verify the client is still valid (connected and logged in)
|
|
563
|
+
const isConnected = existingClient.client.isSocketConnected();
|
|
564
|
+
const isLoggedIn = existingClient.client.loggedIn;
|
|
565
|
+
|
|
566
|
+
if (isConnected && isLoggedIn) {
|
|
567
|
+
// Return existing valid client
|
|
568
|
+
logger.log(`Reusing existing stream client for streamKey=${streamKey}`);
|
|
569
|
+
return existingClient;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
logger.log(`Stale stream client detected for streamKey=${streamKey}, recreating`);
|
|
573
|
+
try {
|
|
574
|
+
if (existingClient.client.isSocketConnected()) {
|
|
575
|
+
await existingClient.close();
|
|
576
|
+
}
|
|
577
|
+
} catch {
|
|
578
|
+
// ignore cleanup errors
|
|
579
|
+
}
|
|
580
|
+
this.streamClients.delete(streamKey);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Create new client for this streamKey
|
|
584
|
+
const api = await this.createNewClient();
|
|
585
|
+
|
|
586
|
+
// Store in map for future reuse
|
|
587
|
+
this.streamClients.set(streamKey, api);
|
|
588
|
+
logger.log(`Created new stream client for streamKey=${streamKey}`);
|
|
589
|
+
|
|
590
|
+
// Clean up when client closes
|
|
591
|
+
api.client.once('close', () => {
|
|
592
|
+
const currentClient = this.streamClients.get(streamKey);
|
|
593
|
+
if (currentClient === api) {
|
|
594
|
+
this.streamClients.delete(streamKey);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
return api;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Cleanup all stream clients (called during device cleanup)
|
|
603
|
+
*/
|
|
604
|
+
async cleanupStreamClients(): Promise<void> {
|
|
605
|
+
const clients = Array.from(this.streamClients.values());
|
|
606
|
+
this.streamClients.clear();
|
|
607
|
+
|
|
608
|
+
await Promise.allSettled(
|
|
609
|
+
clients.map(async (api) => {
|
|
610
|
+
try {
|
|
611
|
+
if (api.client.isSocketConnected()) {
|
|
612
|
+
await api.close();
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
// ignore cleanup errors
|
|
616
|
+
}
|
|
617
|
+
})
|
|
618
|
+
);
|
|
619
|
+
}
|
|
501
620
|
}
|
|
502
621
|
|
package/src/camera-battery.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
import type { ReolinkBaichuanApi, SleepStatus } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
-
import sdk, {
|
|
3
|
-
type MediaObject,
|
|
4
|
-
RequestPictureOptions,
|
|
5
|
-
ResponsePictureOptions
|
|
6
|
-
} from "@scrypted/sdk";
|
|
7
1
|
import {
|
|
8
2
|
CommonCameraMixin,
|
|
9
3
|
} from "./common";
|
|
10
|
-
import { DebugLogOption } from "./debug-options";
|
|
11
4
|
import type ReolinkNativePlugin from "./main";
|
|
12
|
-
import { ReolinkNativeNvrDevice } from "./nvr";
|
|
13
5
|
import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
|
|
6
|
+
import { ReolinkNativeNvrDevice } from "./nvr";
|
|
14
7
|
|
|
15
8
|
export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
16
9
|
doorbellBinaryTimeout?: NodeJS.Timeout;
|
|
@@ -46,6 +39,10 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
46
39
|
return this.resetBaichuanClient();
|
|
47
40
|
}
|
|
48
41
|
|
|
42
|
+
async reportDevices(): Promise<void> {
|
|
43
|
+
// Do nothing
|
|
44
|
+
}
|
|
45
|
+
|
|
49
46
|
private stopPeriodicTasks(): void {
|
|
50
47
|
if (this.sleepCheckTimer) {
|
|
51
48
|
clearInterval(this.sleepCheckTimer);
|
|
@@ -97,56 +94,6 @@ export class ReolinkNativeBatteryCamera extends CommonCameraMixin {
|
|
|
97
94
|
logger.log(`Periodic tasks started: sleep check every 5s, battery update every ${batteryUpdateIntervalMinutes} minutes`);
|
|
98
95
|
}
|
|
99
96
|
|
|
100
|
-
async updateSleepingState(sleepStatus: SleepStatus): Promise<void> {
|
|
101
|
-
try {
|
|
102
|
-
if (this.isDebugEnabled()) {
|
|
103
|
-
this.getBaichuanLogger().debug('getSleepStatus result:', JSON.stringify(sleepStatus));
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (sleepStatus.state === 'sleeping') {
|
|
107
|
-
if (!this.sleeping) {
|
|
108
|
-
this.getBaichuanLogger().log(`Camera is sleeping: ${sleepStatus.reason}`);
|
|
109
|
-
this.sleeping = true;
|
|
110
|
-
}
|
|
111
|
-
} else if (sleepStatus.state === 'awake') {
|
|
112
|
-
// Camera is awake
|
|
113
|
-
const wasSleeping = this.sleeping;
|
|
114
|
-
if (wasSleeping) {
|
|
115
|
-
this.getBaichuanLogger().log(`Camera woke up: ${sleepStatus.reason}`);
|
|
116
|
-
this.sleeping = false;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (wasSleeping) {
|
|
120
|
-
this.alignAuxDevicesState().catch(() => { });
|
|
121
|
-
if (this.forceNewSnapshot) {
|
|
122
|
-
this.takePicture().catch(() => { });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
} else {
|
|
126
|
-
// Unknown state
|
|
127
|
-
this.getBaichuanLogger().debug(`Sleep status unknown: ${sleepStatus.reason}`);
|
|
128
|
-
}
|
|
129
|
-
} catch (e) {
|
|
130
|
-
// Silently ignore errors in sleep check to avoid spam
|
|
131
|
-
this.getBaichuanLogger().debug('Error in updateSleepingState:', e);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
async updateOnlineState(isOnline: boolean): Promise<void> {
|
|
136
|
-
try {
|
|
137
|
-
if (this.isDebugEnabled()) {
|
|
138
|
-
this.getBaichuanLogger().debug('updateOnlineState result:', isOnline);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (isOnline !== this.online) {
|
|
142
|
-
this.online = isOnline;
|
|
143
|
-
}
|
|
144
|
-
} catch (e) {
|
|
145
|
-
// Silently ignore errors in sleep check to avoid spam
|
|
146
|
-
this.getBaichuanLogger().debug('Error in updateOnlineState:', e);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
97
|
async checkRecordingAction(newBatteryLevel: number) {
|
|
151
98
|
const nvrDeviceId = this.plugin.nvrDeviceId;
|
|
152
99
|
if (nvrDeviceId && this.mixins.includes(nvrDeviceId)) {
|
package/src/camera.ts
CHANGED
|
@@ -22,7 +22,6 @@ export const b64ToMo = async (b64: string) => {
|
|
|
22
22
|
export class ReolinkNativeCamera extends CommonCameraMixin {
|
|
23
23
|
videoStreamOptions: Promise<UrlMediaStreamOptions[]>;
|
|
24
24
|
motionTimeout?: NodeJS.Timeout;
|
|
25
|
-
initComplete: boolean = false;
|
|
26
25
|
doorbellBinaryTimeout?: NodeJS.Timeout;
|
|
27
26
|
ptzCapabilities?: any;
|
|
28
27
|
|
|
@@ -43,13 +42,17 @@ export class ReolinkNativeCamera extends CommonCameraMixin {
|
|
|
43
42
|
});
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
async reportDevices(): Promise<void> {
|
|
46
|
+
// Do nothing
|
|
47
|
+
}
|
|
48
|
+
|
|
46
49
|
async resetBaichuanClient(reason?: any): Promise<void> {
|
|
47
50
|
try {
|
|
48
51
|
this.unsubscribedToEvents?.();
|
|
49
52
|
await this.baichuanApi?.close();
|
|
50
53
|
}
|
|
51
54
|
catch (e) {
|
|
52
|
-
this.getBaichuanLogger().warn('Error closing Baichuan client during reset', e);
|
|
55
|
+
this.getBaichuanLogger().warn('Error closing Baichuan client during reset', e?.message || String(e));
|
|
53
56
|
}
|
|
54
57
|
finally {
|
|
55
58
|
this.baichuanApi = undefined;
|