@apocaliss92/scrypted-reolink-native 0.0.1 → 0.0.2

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.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Reolink Native plugin for Scrypted",
5
5
  "author": "@apocaliss92",
6
6
  "license": "Apache",
package/src/camera.ts CHANGED
@@ -7,6 +7,7 @@ import { ReolinkBaichuanIntercom } from "./intercom";
7
7
  import ReolinkNativePlugin from "./main";
8
8
  import { ReolinkPtzPresets } from "./presets";
9
9
  import { parseStreamProfileFromId, StreamManager } from './stream-utils';
10
+ import { connectBaichuanWithTcpUdpFallback, createBaichuanApi, maskUid } from './connect';
10
11
 
11
12
  export const moToB64 = async (mo: MediaObject) => {
12
13
  const bufferImage = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
@@ -164,8 +165,7 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
164
165
  floodlight: ReolinkCameraFloodlight;
165
166
  pirSensor: ReolinkCameraPirSensor;
166
167
  private baichuanApi: ReolinkBaichuanApi | undefined;
167
- private baichuanInitPromise: Promise<ReolinkBaichuanApi> | undefined;
168
- private refreshDeviceStatePromise: Promise<void> | undefined;
168
+ private refreshingState = false;
169
169
 
170
170
  private subscribedToEvents = false;
171
171
  private onSimpleEvent: ((ev: ReolinkSimpleEvent) => void) | undefined;
@@ -179,6 +179,8 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
179
179
  private lastSnapshotTaken: number | undefined;
180
180
  private streamManager: StreamManager;
181
181
 
182
+ private udpFallbackAlerted = false;
183
+
182
184
  private dispatchEventsApplyTimer: NodeJS.Timeout | undefined;
183
185
  private dispatchEventsApplySeq = 0;
184
186
 
@@ -195,6 +197,11 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
195
197
  title: 'IP Address',
196
198
  type: 'string',
197
199
  },
200
+ uid: {
201
+ title: 'UID',
202
+ description: 'Reolink UID (required for battery cameras / BCUDP).',
203
+ type: 'string',
204
+ },
198
205
  username: {
199
206
  type: 'string',
200
207
  title: 'Username',
@@ -462,7 +469,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
462
469
  }
463
470
  }
464
471
  this.baichuanApi = undefined;
465
- this.baichuanInitPromise = undefined;
466
472
  this.subscribedToEvents = false;
467
473
  this.eventsApi = undefined;
468
474
  }
@@ -494,9 +500,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
494
500
  async init() {
495
501
  const logger = this.getLogger();
496
502
 
497
- // Migrate older boolean value to the new multi-select format.
498
- this.migrateDispatchEventsSetting();
499
-
500
503
  // Initialize Baichuan API
501
504
  await this.ensureClient();
502
505
 
@@ -507,21 +510,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
507
510
  this.updateDeviceInfo();
508
511
  this.updatePtzCaps();
509
512
 
510
- const interfaces = await this.getDeviceInterfaces();
511
-
512
- const device: Device = {
513
- nativeId: this.nativeId,
514
- providerNativeId: this.plugin.nativeId,
515
- name: this.name,
516
- interfaces,
517
- type: this.type as ScryptedDeviceType,
518
- info: this.info,
519
- };
520
-
521
- logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
522
-
523
- await sdk.deviceManager.onDeviceDiscovered(device);
524
-
525
513
  // Start event subscription after discovery.
526
514
  try {
527
515
  if (this.isEventDispatchEnabled()) {
@@ -544,65 +532,67 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
544
532
  }
545
533
 
546
534
  async ensureClient(): Promise<ReolinkBaichuanApi> {
547
- if (this.baichuanInitPromise) {
548
- return this.baichuanInitPromise;
549
- }
550
-
551
535
  if (this.baichuanApi && this.baichuanApi.client.loggedIn) {
552
536
  return this.baichuanApi;
553
537
  }
554
538
 
555
- const { ipAddress, username, password } = this.storageSettings.values;
539
+ const { ipAddress, username, password, uid } = this.storageSettings.values;
556
540
 
557
541
  if (!ipAddress || !username || !password) {
558
542
  throw new Error('Missing camera credentials');
559
543
  }
560
544
 
561
- this.baichuanInitPromise = (async () => {
562
- if (this.baichuanApi) {
563
- await this.baichuanApi.close();
564
- }
545
+ if (this.baichuanApi) {
546
+ await this.baichuanApi.close();
547
+ }
565
548
 
566
- const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
567
- const debugOptions = this.getBaichuanDebugOptions();
568
- this.baichuanApi = new ReolinkBaichuanApi({
549
+ const debugOptions = this.getBaichuanDebugOptions();
550
+ const { api } = await connectBaichuanWithTcpUdpFallback(
551
+ {
569
552
  host: ipAddress,
570
553
  username,
571
554
  password,
555
+ uid,
572
556
  logger: this.console,
573
557
  ...(debugOptions ? { debugOptions } : {}),
574
- });
575
-
576
- await this.baichuanApi.login();
577
- return this.baichuanApi;
578
- })();
558
+ },
559
+ ({ uid: normalizedUid, uidMissing }) => {
560
+ const uidMsg = !uidMissing && normalizedUid ? `UID ${maskUid(normalizedUid)}` : 'UID MISSING';
561
+ if (!this.udpFallbackAlerted) {
562
+ this.udpFallbackAlerted = true;
563
+ this.log.a(
564
+ `Baichuan TCP failed for camera ${this.name} (${ipAddress}). This appears to be a battery camera: UID is required and UDP/BCUDP will be used (${uidMsg}).`,
565
+ );
566
+ }
567
+ },
568
+ );
579
569
 
580
- try {
581
- return await this.baichuanInitPromise;
582
- }
583
- finally {
584
- // If login failed, allow future retries.
585
- if (!this.baichuanApi?.client?.loggedIn) {
586
- this.baichuanInitPromise = undefined;
587
- }
588
- }
570
+ this.baichuanApi = api;
571
+ return api;
589
572
  }
590
573
 
591
574
  private async createStreamClient(): Promise<ReolinkBaichuanApi> {
592
- const { ipAddress, username, password } = this.storageSettings.values;
575
+ // Ensure the main client is initialized first so we know if this device needs UDP.
576
+ const primary = await this.ensureClient();
577
+ const transport = primary.client.getTransport();
578
+
579
+ const { ipAddress, username, password, uid } = this.storageSettings.values;
593
580
  if (!ipAddress || !username || !password) {
594
581
  throw new Error('Missing camera credentials');
595
582
  }
596
583
 
597
- const { ReolinkBaichuanApi } = await import('@apocaliss92/reolink-baichuan-js');
598
584
  const debugOptions = this.getBaichuanDebugOptions();
599
- const api = new ReolinkBaichuanApi({
600
- host: ipAddress,
601
- username,
602
- password,
603
- logger: this.console,
604
- ...(debugOptions ? { debugOptions } : {}),
605
- });
585
+ const api = await createBaichuanApi(
586
+ {
587
+ host: ipAddress,
588
+ username,
589
+ password,
590
+ uid,
591
+ logger: this.console,
592
+ ...(debugOptions ? { debugOptions } : {}),
593
+ },
594
+ transport,
595
+ );
606
596
  await api.login();
607
597
  return api;
608
598
  }
@@ -612,30 +602,53 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
612
602
  }
613
603
 
614
604
  private async refreshDeviceState(): Promise<void> {
615
- if (this.refreshDeviceStatePromise) return this.refreshDeviceStatePromise;
605
+ if (this.refreshingState) {
606
+ return;
607
+ }
608
+ this.refreshingState = true;
616
609
 
617
- this.refreshDeviceStatePromise = (async () => {
618
- const logger = this.getLogger();
619
- const api = await this.ensureClient();
620
- const channel = this.getRtspChannel();
610
+ const logger = this.getLogger();
611
+ const api = await this.ensureClient();
612
+ const channel = this.getRtspChannel();
621
613
 
622
- try {
623
- const { capabilities, abilities, support, presets } = await api.getDeviceCapabilities(channel);
624
- this.storageSettings.values.capabilities = capabilities;
625
- this.ptzPresets.setCachedPtzPresets(presets);
626
- this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
627
- }
628
- catch (e) {
629
- logger.warn('Failed to refresh abilities', e);
630
- }
614
+ try {
615
+ const { capabilities, abilities, support, presets } = await api.getDeviceCapabilities(channel);
616
+ this.storageSettings.values.capabilities = capabilities;
617
+ this.ptzPresets.setCachedPtzPresets(presets);
618
+ this.console.log(`Refreshed device capabilities: ${JSON.stringify({ capabilities, abilities, support, presets })}`);
619
+ }
620
+ catch (e) {
621
+ logger.error('Failed to refresh abilities', e);
622
+ }
631
623
 
632
- // Best-effort status refreshes.
633
- await this.refreshAuxDevicesStatus().catch(() => { });
634
- })().finally(() => {
635
- this.refreshDeviceStatePromise = undefined;
636
- });
624
+ // try {
625
+ // await this.refreshAuxDevicesStatus();
626
+ // }
627
+ // catch (e) {
628
+ // logger.error('Failed to refresh device status', e);
629
+ // }
630
+
631
+ try {
632
+
633
+ const interfaces = await this.getDeviceInterfaces();
634
+
635
+ const device: Device = {
636
+ nativeId: this.nativeId,
637
+ providerNativeId: this.plugin.nativeId,
638
+ name: this.name,
639
+ interfaces,
640
+ type: this.type as ScryptedDeviceType,
641
+ info: this.info,
642
+ };
643
+
644
+ logger.log(`Updating device interfaces: ${JSON.stringify(interfaces)}`);
645
+
646
+ await sdk.deviceManager.onDeviceDiscovered(device);
647
+ } catch (e) {
648
+ logger.error('Failed to update device interfaces', e);
649
+ }
637
650
 
638
- return this.refreshDeviceStatePromise;
651
+ this.refreshingState = false;
639
652
  }
640
653
 
641
654
  private async ensureBaichuanEventSubscription(): Promise<void> {
@@ -1283,13 +1296,6 @@ export class ReolinkNativeCamera extends ScryptedDeviceBase implements VideoCame
1283
1296
  return this.getDispatchEventsSelection().has('objects');
1284
1297
  }
1285
1298
 
1286
- private migrateDispatchEventsSetting(): void {
1287
- const cur = (this.storageSettings.values as any).dispatchEvents;
1288
- if (typeof cur === 'boolean') {
1289
- (this.storageSettings.values as any).dispatchEvents = cur ? ['motion', 'objects'] : [];
1290
- }
1291
- }
1292
-
1293
1299
  private scheduleApplyEventDispatchSettings(): void {
1294
1300
  // Debounce to avoid rapid apply loops while editing multi-select.
1295
1301
  this.dispatchEventsApplySeq++;
package/src/connect.ts ADDED
@@ -0,0 +1,146 @@
1
+ import type { BaichuanClientOptions, ReolinkBaichuanApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+
3
+ export type BaichuanTransport = "tcp" | "udp";
4
+
5
+ export type BaichuanConnectInputs = {
6
+ host: string;
7
+ username: string;
8
+ password: string;
9
+ uid?: string;
10
+ logger?: Console;
11
+ debugOptions?: unknown;
12
+ };
13
+
14
+ export function normalizeUid(uid?: string): string | undefined {
15
+ const v = uid?.trim();
16
+ return v ? v : undefined;
17
+ }
18
+
19
+ export function maskUid(uid: string): string {
20
+ const v = uid.trim();
21
+ if (v.length <= 8) return v;
22
+ return `${v.slice(0, 4)}…${v.slice(-4)}`;
23
+ }
24
+
25
+ export function isTcpFailureThatShouldFallbackToUdp(e: unknown): boolean {
26
+ const message = (e as any)?.message || (e as any)?.toString?.() || "";
27
+ if (typeof message !== "string") return false;
28
+
29
+ // Fallback only on transport/connection style failures.
30
+ // Wrong credentials won't be fixed by switching to UDP.
31
+ return (
32
+ message.includes("ECONNREFUSED") ||
33
+ message.includes("ETIMEDOUT") ||
34
+ message.includes("EHOSTUNREACH") ||
35
+ message.includes("ENETUNREACH") ||
36
+ message.includes("socket hang up") ||
37
+ message.includes("TCP connection timeout") ||
38
+ message.includes("Baichuan socket closed")
39
+ );
40
+ }
41
+
42
+ export async function createBaichuanApi(inputs: BaichuanConnectInputs, transport: BaichuanTransport): Promise<ReolinkBaichuanApi> {
43
+ const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
44
+
45
+ const base: BaichuanClientOptions = {
46
+ host: inputs.host,
47
+ username: inputs.username,
48
+ password: inputs.password,
49
+ logger: inputs.logger,
50
+ ...(inputs.debugOptions ? { debugOptions: inputs.debugOptions } : {}),
51
+ };
52
+
53
+ const attachErrorHandler = (api: ReolinkBaichuanApi) => {
54
+ // Critical: BaichuanClient emits 'error'. If nobody listens, Node treats it as an
55
+ // uncaught exception. Ensure we always have a listener.
56
+ try {
57
+ api.client.on("error", (err: unknown) => {
58
+ const logger = inputs.logger ?? console;
59
+ const msg = (err as any)?.message || (err as any)?.toString?.() || String(err);
60
+ logger.error(`[BaichuanClient] error (${transport}) ${inputs.host}: ${msg}`);
61
+ });
62
+ } catch {
63
+ // ignore
64
+ }
65
+ };
66
+
67
+ if (transport === "tcp") {
68
+ const api = new ReolinkBaichuanApi({
69
+ ...base,
70
+ keepAliveInterval: 10000,
71
+ tcpSocketKeepAlive: true,
72
+ transport: "tcp",
73
+ });
74
+ attachErrorHandler(api);
75
+ return api;
76
+ }
77
+
78
+ const uid = normalizeUid(inputs.uid);
79
+ if (!uid) {
80
+ throw new Error("UID is required for battery cameras (BCUDP)");
81
+ }
82
+
83
+ const api = new ReolinkBaichuanApi({
84
+ ...base,
85
+ transport: "udp",
86
+ udp: {
87
+ mode: "uid",
88
+ uid,
89
+ host: inputs.host,
90
+ broadcast: false,
91
+ },
92
+ });
93
+ attachErrorHandler(api);
94
+ return api;
95
+ }
96
+
97
+ export type UdpFallbackInfo = {
98
+ host: string;
99
+ uid?: string;
100
+ uidMissing: boolean;
101
+ tcpError: unknown;
102
+ };
103
+
104
+ export async function connectBaichuanWithTcpUdpFallback(
105
+ inputs: BaichuanConnectInputs,
106
+ onUdpFallback?: (info: UdpFallbackInfo) => void,
107
+ ): Promise<{ api: ReolinkBaichuanApi; transport: BaichuanTransport }> {
108
+ let tcpApi: ReolinkBaichuanApi | undefined;
109
+ try {
110
+ tcpApi = await createBaichuanApi(inputs, "tcp");
111
+ await tcpApi.login();
112
+ return { api: tcpApi, transport: "tcp" };
113
+ }
114
+ catch (e) {
115
+ try {
116
+ await tcpApi?.close();
117
+ }
118
+ catch {
119
+ // ignore
120
+ }
121
+
122
+ if (!isTcpFailureThatShouldFallbackToUdp(e)) {
123
+ throw e;
124
+ }
125
+
126
+ const uid = normalizeUid(inputs.uid);
127
+ const uidMissing = !uid;
128
+
129
+ onUdpFallback?.({
130
+ host: inputs.host,
131
+ uid,
132
+ uidMissing,
133
+ tcpError: e,
134
+ });
135
+
136
+ if (uidMissing) {
137
+ throw new Error(
138
+ `Baichuan TCP failed and this camera likely requires UDP/BCUDP. Set the Reolink UID in settings to continue (ip=${inputs.host}).`,
139
+ );
140
+ }
141
+
142
+ const udpApi = await createBaichuanApi(inputs, "udp");
143
+ await udpApi.login();
144
+ return { api: udpApi, transport: "udp" };
145
+ }
146
+ }
package/src/main.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
2
2
  import { ReolinkNativeCamera } from "./camera";
3
+ import { connectBaichuanWithTcpUdpFallback, maskUid } from "./connect";
3
4
 
4
5
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
5
6
  devices = new Map<string, ReolinkNativeCamera>();
@@ -35,14 +36,24 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
35
36
 
36
37
  const username = settings.username?.toString();
37
38
  const password = settings.password?.toString();
39
+ const uid = settings.uid?.toString();
38
40
 
39
41
  if (ipAddress && username && password) {
40
- const { ReolinkBaichuanApi } = await import("@apocaliss92/reolink-baichuan-js");
41
- const api = new ReolinkBaichuanApi({
42
- host: ipAddress,
43
- username,
44
- password,
45
- });
42
+ const { api } = await connectBaichuanWithTcpUdpFallback(
43
+ {
44
+ host: ipAddress,
45
+ username,
46
+ password,
47
+ uid,
48
+ logger: this.console,
49
+ },
50
+ ({ uid: normalizedUid, uidMissing }) => {
51
+ const uidMsg = !uidMissing && normalizedUid ? `UID ${maskUid(normalizedUid)}` : 'UID MISSING';
52
+ this.console.log(
53
+ `Baichuan TCP failed during discovery for ${ipAddress}; falling back to UDP/BCUDP (${uidMsg}).`,
54
+ );
55
+ },
56
+ );
46
57
 
47
58
  try {
48
59
  const deviceInfo = await api.getInfo();
@@ -70,6 +81,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
70
81
  device.storageSettings.values.password = password;
71
82
  device.storageSettings.values.rtspChannel = rtspChannel;
72
83
  device.storageSettings.values.ipAddress = ipAddress;
84
+ if (uid) device.storageSettings.values.uid = uid;
73
85
  device.storageSettings.values.capabilities = capabilities;
74
86
  device.updateDeviceInfo();
75
87