@apocaliss92/scrypted-reolink-native 0.4.9 → 0.4.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/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +15 -0
- package/src/camera.ts +40 -12
- package/src/connect.ts +77 -67
- package/src/main.ts +35 -5
- package/src/nvr.ts +4 -0
- package/src/utils.ts +2 -0
package/dist/plugin.zip
CHANGED
|
Binary file
|
package/package.json
CHANGED
package/src/baichuan-base.ts
CHANGED
|
@@ -208,6 +208,15 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
208
208
|
return false; // Default: standalone camera
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Check if this is a battery-powered device.
|
|
213
|
+
* Override in subclasses to return true for battery cameras.
|
|
214
|
+
* This is used to enable idle disconnect to preserve battery life.
|
|
215
|
+
*/
|
|
216
|
+
protected isBatteryDevice(): boolean {
|
|
217
|
+
return false; // Default: AC-powered
|
|
218
|
+
}
|
|
219
|
+
|
|
211
220
|
/**
|
|
212
221
|
* Check if debug logging is enabled
|
|
213
222
|
*/
|
|
@@ -324,6 +333,12 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
|
|
|
324
333
|
// NVR devices need separate sockets per channel
|
|
325
334
|
api.setIsNvr(this.isNvrDevice());
|
|
326
335
|
|
|
336
|
+
// Enable idle disconnect for battery cameras to preserve battery life
|
|
337
|
+
// AC-powered cameras (including UDP cameras like Elite Floodlight WiFi) don't need it
|
|
338
|
+
if (this.isBatteryDevice()) {
|
|
339
|
+
api.setIdleDisconnect(true);
|
|
340
|
+
}
|
|
341
|
+
|
|
327
342
|
// Verify socket is connected before returning
|
|
328
343
|
if (!api.client.isSocketConnected()) {
|
|
329
344
|
throw new Error("Socket not connected after login");
|
package/src/camera.ts
CHANGED
|
@@ -104,8 +104,10 @@ import {
|
|
|
104
104
|
export type CameraType =
|
|
105
105
|
| "battery"
|
|
106
106
|
| "regular"
|
|
107
|
+
| "udp" // UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
107
108
|
| "multi-focal"
|
|
108
|
-
| "multi-focal-battery"
|
|
109
|
+
| "multi-focal-battery"
|
|
110
|
+
| "multi-focal-udp"; // UDP multifocal without battery
|
|
109
111
|
|
|
110
112
|
export interface ReolinkCameraOptions {
|
|
111
113
|
type: CameraType;
|
|
@@ -738,6 +740,7 @@ export class ReolinkCamera
|
|
|
738
740
|
protected multiFocalDevice?: ReolinkNativeMultiFocalDevice;
|
|
739
741
|
thisDevice: Settings;
|
|
740
742
|
isBattery: boolean;
|
|
743
|
+
isUdpCamera: boolean; // UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
741
744
|
isMultiFocal: boolean;
|
|
742
745
|
isOnNvr: boolean;
|
|
743
746
|
protocol: BaichuanTransport;
|
|
@@ -769,7 +772,12 @@ export class ReolinkCamera
|
|
|
769
772
|
) {
|
|
770
773
|
const isBattery =
|
|
771
774
|
options.type === "battery" || options.type === "multi-focal-battery";
|
|
772
|
-
|
|
775
|
+
// UDP cameras include: battery cameras, NVR children, and UDP-only cameras (e.g., Elite Floodlight WiFi)
|
|
776
|
+
const isUdpCamera =
|
|
777
|
+
options.type === "udp" || options.type === "multi-focal-udp";
|
|
778
|
+
const transport = isBattery || isUdpCamera ? "udp" : "tcp";
|
|
779
|
+
// const transport =
|
|
780
|
+
// isBattery || isUdpCamera || !!options.nvrDevice ? "udp" : "tcp";
|
|
773
781
|
super(nativeId, transport);
|
|
774
782
|
this.plugin.camerasMap.set(this.id, this);
|
|
775
783
|
|
|
@@ -779,8 +787,11 @@ export class ReolinkCamera
|
|
|
779
787
|
this.thisDevice = sdk.systemManager.getDeviceById<Settings>(this.id);
|
|
780
788
|
|
|
781
789
|
this.isBattery = isBattery;
|
|
790
|
+
this.isUdpCamera = isUdpCamera; // UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
782
791
|
this.isMultiFocal =
|
|
783
|
-
options.type === "multi-focal" ||
|
|
792
|
+
options.type === "multi-focal" ||
|
|
793
|
+
options.type === "multi-focal-battery" ||
|
|
794
|
+
options.type === "multi-focal-udp";
|
|
784
795
|
this.isOnNvr = !!this.nvrDevice || !!this.multiFocalDevice?.nvrDevice;
|
|
785
796
|
this.protocol = transport;
|
|
786
797
|
|
|
@@ -1475,17 +1486,21 @@ export class ReolinkCamera
|
|
|
1475
1486
|
const { ipAddress, username, password, uid, discoveryMethod } =
|
|
1476
1487
|
this.storageSettings.values;
|
|
1477
1488
|
const debugOptions = this.getBaichuanDebugOptions();
|
|
1478
|
-
const normalizedUid = this.isBattery ? normalizeUid(uid) : undefined;
|
|
1479
1489
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1490
|
+
// UDP cameras (battery or UDP-only like Elite Floodlight WiFi) require UID
|
|
1491
|
+
// const requiresUid = this.isBattery || this.isUdpCamera;
|
|
1492
|
+
// const normalizedUid = requiresUid ? normalizeUid(uid) : undefined;
|
|
1493
|
+
const normalizedUid = normalizeUid(uid);
|
|
1494
|
+
|
|
1495
|
+
// if (requiresUid && !normalizedUid) {
|
|
1496
|
+
// throw new Error("UID is required for UDP cameras (BCUDP)");
|
|
1497
|
+
// }
|
|
1483
1498
|
|
|
1484
1499
|
// Prevent accidental connections to localhost (Node will default host=127.0.0.1 when host is undefined).
|
|
1485
1500
|
// This shows up as connect ECONNREFUSED 127.0.0.1:9000 and will never recover with socket resets.
|
|
1486
|
-
if (!
|
|
1487
|
-
|
|
1488
|
-
}
|
|
1501
|
+
// if (!requiresUid && !ipAddress) {
|
|
1502
|
+
// throw new Error("IP Address is required for TCP devices");
|
|
1503
|
+
// }
|
|
1489
1504
|
|
|
1490
1505
|
return {
|
|
1491
1506
|
host: ipAddress,
|
|
@@ -1494,8 +1509,9 @@ export class ReolinkCamera
|
|
|
1494
1509
|
uid: normalizedUid,
|
|
1495
1510
|
transport: this.protocol,
|
|
1496
1511
|
debugOptions,
|
|
1497
|
-
udpDiscoveryMethod:
|
|
1498
|
-
|
|
1512
|
+
udpDiscoveryMethod: discoveryMethod,
|
|
1513
|
+
// NOTE: idleDisconnect is NOT set here - the library handles it internally
|
|
1514
|
+
// based on the battery status detected during connection
|
|
1499
1515
|
};
|
|
1500
1516
|
}
|
|
1501
1517
|
|
|
@@ -1530,6 +1546,10 @@ export class ReolinkCamera
|
|
|
1530
1546
|
return this.storageSettings.values.debugLogs;
|
|
1531
1547
|
}
|
|
1532
1548
|
|
|
1549
|
+
protected isBatteryDevice(): boolean {
|
|
1550
|
+
return this.isBattery;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1533
1553
|
protected getDeviceName(): string {
|
|
1534
1554
|
return this.name || "Camera";
|
|
1535
1555
|
}
|
|
@@ -3287,6 +3307,14 @@ export class ReolinkCamera
|
|
|
3287
3307
|
const hideUid = !this.isBattery || this.isOnNvr || !!this.multiFocalDevice;
|
|
3288
3308
|
this.storageSettings.settings.uid.hide = hideUid;
|
|
3289
3309
|
this.storageSettings.settings.discoveryMethod.hide = hideUid;
|
|
3310
|
+
// Show UID and discovery method for UDP cameras (battery or UDP-only like Elite Floodlight WiFi)
|
|
3311
|
+
// Hide for NVR children or multifocal lenses (they use parent's connection)
|
|
3312
|
+
// const requiresUidSettings =
|
|
3313
|
+
// (this.isBattery || this.isUdpCamera) &&
|
|
3314
|
+
// !this.isOnNvr &&
|
|
3315
|
+
// !this.multiFocalDevice;
|
|
3316
|
+
// this.storageSettings.settings.uid.hide = !requiresUidSettings;
|
|
3317
|
+
// this.storageSettings.settings.discoveryMethod.hide = !requiresUidSettings;
|
|
3290
3318
|
|
|
3291
3319
|
if (this.isBattery && !this.storageSettings.values.mixinsSetup) {
|
|
3292
3320
|
try {
|
package/src/connect.ts
CHANGED
|
@@ -1,89 +1,99 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
BaichuanTransport as BaichuanTransportParent,
|
|
3
|
+
BaichuanClientOptions,
|
|
4
|
+
ReolinkBaichuanApi,
|
|
5
|
+
} from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
6
|
|
|
3
7
|
export type BaichuanTransport = BaichuanTransportParent;
|
|
4
8
|
|
|
5
9
|
export type BaichuanConnectInputs = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
host: string;
|
|
11
|
+
username: string;
|
|
12
|
+
password: string;
|
|
13
|
+
uid?: string;
|
|
14
|
+
logger?: Console;
|
|
15
|
+
debugOptions?: BaichuanClientOptions["debugOptions"];
|
|
16
|
+
udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
export function normalizeUid(uid?: string): string | undefined {
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const v = uid?.trim();
|
|
21
|
+
return v ? v : undefined;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export async function createBaichuanApi(props: {
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
inputs: BaichuanConnectInputs;
|
|
26
|
+
transport: BaichuanTransport;
|
|
23
27
|
}): Promise<ReolinkBaichuanApi> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const { inputs, transport } = props;
|
|
29
|
+
const { logger } = inputs;
|
|
30
|
+
const { ReolinkBaichuanApi } =
|
|
31
|
+
await import("@apocaliss92/reolink-baichuan-js");
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const base: BaichuanClientOptions = {
|
|
34
|
+
host: inputs.host,
|
|
35
|
+
username: inputs.username,
|
|
36
|
+
password: inputs.password,
|
|
37
|
+
logger,
|
|
38
|
+
debugOptions: inputs.debugOptions ?? {},
|
|
39
|
+
};
|
|
35
40
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Handle 'close' event to prevent unhandled rejections from pending promises
|
|
57
|
-
api.client.on("close", () => {
|
|
58
|
-
// Socket closed - pending promises will be rejected, but we've already handled errors above
|
|
59
|
-
// This handler prevents the close event from causing issues
|
|
60
|
-
});
|
|
61
|
-
} catch {
|
|
62
|
-
// ignore
|
|
41
|
+
const attachErrorHandler = (api: ReolinkBaichuanApi) => {
|
|
42
|
+
// Critical: BaichuanClient emits 'error'. If nobody listens, Node treats it as an
|
|
43
|
+
// uncaught exception. Ensure we always have a listener.
|
|
44
|
+
try {
|
|
45
|
+
api.client.on("error", (err: unknown) => {
|
|
46
|
+
if (!logger) return;
|
|
47
|
+
const msg =
|
|
48
|
+
(err as any)?.message || (err as any)?.toString?.() || String(err);
|
|
49
|
+
// Only log if it's not a recoverable error to avoid spam
|
|
50
|
+
if (
|
|
51
|
+
typeof msg === "string" &&
|
|
52
|
+
(msg.includes("Baichuan socket closed") ||
|
|
53
|
+
msg.includes("Baichuan UDP stream closed") ||
|
|
54
|
+
msg.includes("Not running"))
|
|
55
|
+
) {
|
|
56
|
+
// Silently ignore recoverable socket close errors and "Not running" errors
|
|
57
|
+
// "Not running" is common for UDP/battery cameras when sleeping or during initialization
|
|
58
|
+
return;
|
|
63
59
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
...base,
|
|
69
|
-
transport: "tcp",
|
|
70
|
-
});
|
|
71
|
-
attachErrorHandler(api);
|
|
72
|
-
return api;
|
|
73
|
-
}
|
|
60
|
+
logger.error(
|
|
61
|
+
`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
74
64
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
65
|
+
// Handle 'close' event to prevent unhandled rejections from pending promises
|
|
66
|
+
api.client.on("close", () => {
|
|
67
|
+
// Socket closed - pending promises will be rejected, but we've already handled errors above
|
|
68
|
+
// This handler prevents the close event from causing issues
|
|
69
|
+
});
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
78
72
|
}
|
|
73
|
+
};
|
|
79
74
|
|
|
75
|
+
if (transport === "tcp") {
|
|
80
76
|
const api = new ReolinkBaichuanApi({
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
uid,
|
|
84
|
-
idleDisconnect: true,
|
|
85
|
-
udpDiscoveryMethod: inputs.udpDiscoveryMethod,
|
|
77
|
+
...base,
|
|
78
|
+
transport: "tcp",
|
|
86
79
|
});
|
|
87
80
|
attachErrorHandler(api);
|
|
88
81
|
return api;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const uid = normalizeUid(inputs.uid);
|
|
85
|
+
if (!uid) {
|
|
86
|
+
throw new Error("UID is required for UDP cameras (BCUDP)");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const api = new ReolinkBaichuanApi({
|
|
90
|
+
...base,
|
|
91
|
+
transport: "udp",
|
|
92
|
+
uid,
|
|
93
|
+
// NOTE: idleDisconnect is NOT set here - the library handles it internally
|
|
94
|
+
// based on the battery status detected during connection/autodetect
|
|
95
|
+
udpDiscoveryMethod: inputs.udpDiscoveryMethod,
|
|
96
|
+
});
|
|
97
|
+
attachErrorHandler(api);
|
|
98
|
+
return api;
|
|
89
99
|
}
|
package/src/main.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
handleVideoClipRequest,
|
|
38
38
|
multifocalSuffix,
|
|
39
39
|
nvrSuffix,
|
|
40
|
+
udpCameraSuffix,
|
|
41
|
+
udpMultifocalSuffix,
|
|
40
42
|
} from "./utils";
|
|
41
43
|
import { randomBytes } from "crypto";
|
|
42
44
|
import { ReolinkCamera } from "./camera";
|
|
@@ -135,14 +137,25 @@ class ReolinkNativePlugin
|
|
|
135
137
|
|
|
136
138
|
// Handle multi-focal device case
|
|
137
139
|
if (detection.type === "multifocal") {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
settings.newCamera ||= name;
|
|
140
|
+
// Determine suffix based on transport and battery capability
|
|
141
|
+
// UDP transport does NOT always mean battery (e.g., some floodlight cameras use UDP but are AC-powered)
|
|
142
|
+
const isUdp = detection.transport === "udp";
|
|
142
143
|
|
|
144
|
+
// Get capabilities to check if device has battery
|
|
143
145
|
const { capabilities, objects, presets } =
|
|
144
146
|
await detectedApi.getDeviceCapabilities();
|
|
145
147
|
|
|
148
|
+
const hasBattery = capabilities?.hasBattery === true;
|
|
149
|
+
|
|
150
|
+
// Choose suffix based on transport and battery
|
|
151
|
+
if (isUdp) {
|
|
152
|
+
nativeId = `${identifier}${hasBattery ? batteryMultifocalSuffix : udpMultifocalSuffix}`;
|
|
153
|
+
} else {
|
|
154
|
+
nativeId = `${identifier}${multifocalSuffix}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
settings.newCamera ||= name;
|
|
158
|
+
|
|
146
159
|
const { interfaces } = getDeviceInterfaces({
|
|
147
160
|
capabilities,
|
|
148
161
|
logger: this.console,
|
|
@@ -201,10 +214,17 @@ class ReolinkNativePlugin
|
|
|
201
214
|
return nativeId;
|
|
202
215
|
}
|
|
203
216
|
|
|
204
|
-
// Create nativeId based on device type
|
|
217
|
+
// Create nativeId based on device type and transport
|
|
218
|
+
// UDP transport does NOT always mean battery (e.g., Elite Floodlight WiFi uses UDP but is AC-powered)
|
|
219
|
+
const isUdp = detection.transport === "udp";
|
|
205
220
|
if (detection.type === "battery-cam") {
|
|
221
|
+
// Battery camera (always UDP)
|
|
206
222
|
nativeId = `${identifier}${batteryCameraSuffix}`;
|
|
223
|
+
} else if (isUdp) {
|
|
224
|
+
// UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
225
|
+
nativeId = `${identifier}${udpCameraSuffix}`;
|
|
207
226
|
} else {
|
|
227
|
+
// Regular TCP camera
|
|
208
228
|
nativeId = `${identifier}${cameraSuffix}`;
|
|
209
229
|
}
|
|
210
230
|
|
|
@@ -315,8 +335,18 @@ class ReolinkNativePlugin
|
|
|
315
335
|
this,
|
|
316
336
|
"multi-focal-battery",
|
|
317
337
|
);
|
|
338
|
+
} else if (nativeId.endsWith(udpMultifocalSuffix)) {
|
|
339
|
+
// UDP multifocal without battery
|
|
340
|
+
return new ReolinkNativeMultiFocalDevice(
|
|
341
|
+
nativeId,
|
|
342
|
+
this,
|
|
343
|
+
"multi-focal-udp",
|
|
344
|
+
);
|
|
318
345
|
} else if (nativeId.endsWith(multifocalSuffix)) {
|
|
319
346
|
return new ReolinkNativeMultiFocalDevice(nativeId, this, "multi-focal");
|
|
347
|
+
} else if (nativeId.endsWith(udpCameraSuffix)) {
|
|
348
|
+
// UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
349
|
+
return new ReolinkCamera(nativeId, this, { type: "udp" });
|
|
320
350
|
} else {
|
|
321
351
|
return new ReolinkCamera(nativeId, this, { type: "regular" });
|
|
322
352
|
}
|
package/src/nvr.ts
CHANGED
|
@@ -218,6 +218,10 @@ export class ReolinkNativeNvrDevice
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
protected isNvrDevice(): boolean {
|
|
222
|
+
return true; // NVR/Hub always returns true
|
|
223
|
+
}
|
|
224
|
+
|
|
221
225
|
protected isDebugEnabled(): boolean {
|
|
222
226
|
return this.storageSettings.values.debugLogs || false;
|
|
223
227
|
}
|
package/src/utils.ts
CHANGED
|
@@ -37,8 +37,10 @@ export type OperationChannelMap = Partial<Record<OperationChannelType, number>>;
|
|
|
37
37
|
|
|
38
38
|
export const nvrSuffix = `-nvr`;
|
|
39
39
|
export const batteryCameraSuffix = `-battery-cam`;
|
|
40
|
+
export const udpCameraSuffix = `-udp-cam`; // UDP camera without battery (e.g., Elite Floodlight WiFi)
|
|
40
41
|
export const multifocalSuffix = `-multifocal`;
|
|
41
42
|
export const batteryMultifocalSuffix = `-battery-multifocal`;
|
|
43
|
+
export const udpMultifocalSuffix = `-udp-multifocal`; // UDP multifocal without battery
|
|
42
44
|
export const cameraSuffix = `-cam`;
|
|
43
45
|
export const sirenSuffix = `-siren`;
|
|
44
46
|
export const floodlightSuffix = `-floodlight`;
|