@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/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.4.9",
3
+ "version": "0.4.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",
@@ -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
- const transport = isBattery || !!options.nvrDevice ? "udp" : "tcp";
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" || options.type === "multi-focal-battery";
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
- if (this.isBattery && !normalizedUid) {
1481
- throw new Error("UID is required for battery cameras (BCUDP)");
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 (!this.isBattery && !ipAddress) {
1487
- throw new Error("IP Address is required for TCP devices");
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
- discoveryMethod as BaichuanClientOptions["udpDiscoveryMethod"],
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 { BaichuanTransport as BaichuanTransportParent, BaichuanClientOptions, ReolinkBaichuanApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
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
- host: string;
7
- username: string;
8
- password: string;
9
- uid?: string;
10
- logger?: Console;
11
- debugOptions?: BaichuanClientOptions['debugOptions'];
12
- udpDiscoveryMethod?: BaichuanClientOptions["udpDiscoveryMethod"];
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
- const v = uid?.trim();
17
- return v ? v : undefined;
20
+ const v = uid?.trim();
21
+ return v ? v : undefined;
18
22
  }
19
23
 
20
24
  export async function createBaichuanApi(props: {
21
- inputs: BaichuanConnectInputs,
22
- transport: BaichuanTransport,
25
+ inputs: BaichuanConnectInputs;
26
+ transport: BaichuanTransport;
23
27
  }): Promise<ReolinkBaichuanApi> {
24
- const { inputs, transport } = props;
25
- const { logger } = inputs;
26
- const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
28
+ const { inputs, transport } = props;
29
+ const { logger } = inputs;
30
+ const { ReolinkBaichuanApi } =
31
+ await import("@apocaliss92/reolink-baichuan-js");
27
32
 
28
- const base: BaichuanClientOptions = {
29
- host: inputs.host,
30
- username: inputs.username,
31
- password: inputs.password,
32
- logger,
33
- debugOptions: inputs.debugOptions ?? {}
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
- const attachErrorHandler = (api: ReolinkBaichuanApi) => {
37
- // Critical: BaichuanClient emits 'error'. If nobody listens, Node treats it as an
38
- // uncaught exception. Ensure we always have a listener.
39
- try {
40
- api.client.on("error", (err: unknown) => {
41
- if (!logger) return;
42
- const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
43
- // Only log if it's not a recoverable error to avoid spam
44
- if (typeof msg === 'string' && (
45
- msg.includes('Baichuan socket closed') ||
46
- msg.includes('Baichuan UDP stream closed') ||
47
- msg.includes('Not running')
48
- )) {
49
- // Silently ignore recoverable socket close errors and "Not running" errors
50
- // "Not running" is common for UDP/battery cameras when sleeping or during initialization
51
- return;
52
- }
53
- logger.error(`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`);
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
- if (transport === "tcp") {
67
- const api = new ReolinkBaichuanApi({
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
- const uid = normalizeUid(inputs.uid);
76
- if (!uid) {
77
- throw new Error("UID is required for battery cameras (BCUDP)");
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
- ...base,
82
- transport: "udp",
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
- const isBattery = detection.transport === "udp";
139
- nativeId = `${identifier}${isBattery ? batteryMultifocalSuffix : multifocalSuffix}`;
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`;