@apocaliss92/scrypted-reolink-native 0.4.8 → 0.4.10

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.8",
3
+ "version": "0.4.10",
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",
@@ -199,6 +199,24 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
199
199
  */
200
200
  protected abstract getConnectionCallbacks(): BaichuanConnectionCallbacks;
201
201
 
202
+ /**
203
+ * Check if this is an NVR/Hub device (multiple channels).
204
+ * Override in subclasses to return true for NVR devices.
205
+ * This is used for socket pooling to allocate separate sockets per channel.
206
+ */
207
+ protected isNvrDevice(): boolean {
208
+ return false; // Default: standalone camera
209
+ }
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
+
202
220
  /**
203
221
  * Check if debug logging is enabled
204
222
  */
@@ -236,8 +254,16 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
236
254
  * Ensure Baichuan client is connected and ready
237
255
  */
238
256
  async ensureBaichuanClient(): Promise<ReolinkBaichuanApi> {
257
+ const logger = this.getBaichuanLogger();
258
+ const caller = new Error().stack?.split("\n")[2]?.trim() ?? "unknown";
259
+
239
260
  // Prevent concurrent login storms - check promise first
240
- if (this.ensureClientPromise) return await this.ensureClientPromise;
261
+ if (this.ensureClientPromise) {
262
+ logger.debug(
263
+ `ensureBaichuanClient: waiting on existing promise (caller: ${caller})`,
264
+ );
265
+ return await this.ensureClientPromise;
266
+ }
241
267
 
242
268
  // Reuse existing client if socket is still connected and logged in
243
269
  // Check this AFTER checking the promise to avoid race conditions
@@ -247,18 +273,22 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
247
273
 
248
274
  // Only reuse if both conditions are true
249
275
  if (isConnected && isLoggedIn) {
276
+ logger.debug(
277
+ `ensureBaichuanClient: reusing existing client (caller: ${caller})`,
278
+ );
250
279
  return this.baichuanApi;
251
280
  }
252
281
 
253
282
  // If socket is not connected or not logged in, cleanup the stale client
254
283
  // This prevents leaking connections when the socket appears connected but isn't
255
- const logger = this.getBaichuanLogger();
256
284
  logger.log(
257
- `Stale client detected: connected=${isConnected}, loggedIn=${isLoggedIn}, cleaning up`,
285
+ `Stale client detected: connected=${isConnected}, loggedIn=${isLoggedIn}, cleaning up (caller: ${caller})`,
258
286
  );
259
287
  await this.cleanupBaichuanApi();
260
288
  }
261
289
 
290
+ logger.log(`ensureBaichuanClient: creating NEW client (caller: ${caller})`);
291
+
262
292
  // IMPORTANT: Assign the promise BEFORE the backoff to prevent parallel reconnections
263
293
  this.ensureClientPromise = (async () => {
264
294
  // Apply backoff to avoid aggressive reconnection after disconnection
@@ -299,6 +329,16 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
299
329
 
300
330
  await api.login();
301
331
 
332
+ // Set NVR flag BEFORE any streaming to ensure correct socket pooling
333
+ // NVR devices need separate sockets per channel
334
+ api.setIsNvr(this.isNvrDevice());
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
+
302
342
  // Verify socket is connected before returning
303
343
  if (!api.client.isSocketConnected()) {
304
344
  throw new Error("Socket not connected after login");
@@ -657,8 +697,15 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
657
697
  `No events received in the last ${Math.round(timeSinceLastEvent / 60_000)} minutes, restarting event listener`,
658
698
  );
659
699
  // Restart event subscription
700
+ logger.debug(
701
+ "Restarting event listener: calling unsubscribeFromEvents...",
702
+ );
660
703
  await this.unsubscribeFromEvents(true);
704
+ logger.debug(
705
+ "Restarting event listener: calling subscribeToEvents...",
706
+ );
661
707
  await this.subscribeToEvents(true);
708
+ logger.debug("Restarting event listener: done");
662
709
  } else if (this.lastEventTime === 0) {
663
710
  // If lastEventTime is 0, it means we just subscribed but haven't received any events yet
664
711
  // Wait a bit longer before considering it a problem
@@ -667,8 +714,17 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
667
714
  logger.log(
668
715
  `No events received since subscription (${Math.round(timeSinceSubscription / 60_000)} minutes ago), restarting event listener`,
669
716
  );
717
+ logger.debug(
718
+ "Restarting event listener (no events since sub): calling unsubscribeFromEvents...",
719
+ );
670
720
  await this.unsubscribeFromEvents(true);
721
+ logger.debug(
722
+ "Restarting event listener (no events since sub): calling subscribeToEvents...",
723
+ );
671
724
  await this.subscribeToEvents(true);
725
+ logger.debug(
726
+ "Restarting event listener (no events since sub): done",
727
+ );
672
728
  }
673
729
  }
674
730
  } catch (e) {
@@ -694,6 +750,13 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
694
750
  async subscribeToEvents(silent: boolean = false): Promise<void> {
695
751
  const logger = this.getBaichuanLogger();
696
752
  const callbacks = this.getConnectionCallbacks();
753
+ const existingClientInfo = this.baichuanApi
754
+ ? `connected=${this.baichuanApi.client.isSocketConnected()}, loggedIn=${this.baichuanApi.client.loggedIn}`
755
+ : "no client";
756
+
757
+ logger.debug(
758
+ `subscribeToEvents() called: silent=${silent}, existingClient=[${existingClientInfo}], eventSubscriptionActive=${this.eventSubscriptionActive}`,
759
+ );
697
760
 
698
761
  if (!callbacks.onSimpleEvent) {
699
762
  return;
@@ -716,7 +779,11 @@ export abstract class BaseBaichuanClass extends ScryptedDeviceBase {
716
779
  await this.unsubscribeFromEvents(silent);
717
780
 
718
781
  // Get Baichuan client connection
782
+ logger.debug("subscribeToEvents: calling ensureBaichuanClient...");
719
783
  const api = await this.ensureBaichuanClient();
784
+ logger.debug(
785
+ `subscribeToEvents: ensureBaichuanClient returned, reused=${api === this.baichuanApi}`,
786
+ );
720
787
 
721
788
  // Verify connection is ready
722
789
  if (!api.client.isSocketConnected() || !api.client.loggedIn) {
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`;