@apocaliss92/scrypted-reolink-native 0.4.32 → 0.4.34

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/src/intercom.ts CHANGED
@@ -7,7 +7,22 @@ import sdk, {
7
7
  ScryptedMimeTypes,
8
8
  } from "@scrypted/sdk";
9
9
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
10
- import { ReolinkCamera } from "./camera";
10
+
11
+ /**
12
+ * Abstraction over the camera-specific dependencies needed by the intercom engine.
13
+ * Both ReolinkCamera (internal) and IntercomMixin (standalone) can implement this.
14
+ */
15
+ export interface IntercomHost {
16
+ readonly blocksPerPayload: number;
17
+ readonly outputGain: number;
18
+ readonly maxBacklogMs: number;
19
+ readonly channel: number;
20
+ readonly isBatteryCamera: boolean;
21
+ readonly deviceId: string;
22
+ readonly logger: Console;
23
+ ensureApi(): Promise<ReolinkBaichuanApi>;
24
+ withRetry<T>(fn: () => Promise<T>): Promise<T>;
25
+ }
11
26
 
12
27
  // Keep this low: Reolink blocks are ~64ms at 16kHz (1025 samples).
13
28
  // A small backlog avoids multi-second latency when the pipeline stalls.
@@ -33,28 +48,10 @@ export class ReolinkBaichuanIntercom {
33
48
 
34
49
  private lastBacklogClampLogAtMs = 0;
35
50
 
36
- constructor(private camera: ReolinkCamera) {}
37
-
38
- get blocksPerPayload(): number {
39
- return Math.max(
40
- 1,
41
- Math.min(
42
- 8,
43
- this.camera.storageSettings.values.intercomBlocksPerPayload ?? 1,
44
- ),
45
- );
46
- }
47
-
48
- private get outputGain(): number {
49
- const configured = Number(this.camera.storageSettings.values.intercomGain);
50
- // Keep safe bounds: too high can clip and distort.
51
- if (Number.isFinite(configured))
52
- return Math.max(0.1, Math.min(10, configured));
53
- return 1.0;
54
- }
51
+ constructor(private host: IntercomHost) {}
55
52
 
56
53
  async start(media: MediaObject): Promise<void> {
57
- const logger = this.camera.getBaichuanLogger();
54
+ const logger = this.host.logger;
58
55
 
59
56
  const ffmpegInput =
60
57
  await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(
@@ -63,12 +60,12 @@ export class ReolinkBaichuanIntercom {
63
60
  );
64
61
 
65
62
  await this.stop();
66
- const channel = this.camera.storageSettings.values.rtspChannel;
63
+ const channel = this.host.channel;
67
64
 
68
65
  try {
69
66
  // Get the main API - library manages dedicated sockets internally
70
- const api = await this.camera.withBaichuanRetry(async () => {
71
- return await this.camera.ensureBaichuanClient();
67
+ const api = await this.host.withRetry(async () => {
68
+ return await this.host.ensureApi();
72
69
  });
73
70
 
74
71
  // Best-effort: log codec requirements exposed by the camera.
@@ -91,7 +88,7 @@ export class ReolinkBaichuanIntercom {
91
88
  }
92
89
 
93
90
  // For UDP/battery cameras, wake up the camera if it's sleeping before creating talk session
94
- if (this.camera.options?.type === "battery") {
91
+ if (this.host.isBatteryCamera) {
95
92
  try {
96
93
  const sleepStatus = api.getSleepStatus({ channel });
97
94
  if (sleepStatus.state === "sleeping") {
@@ -110,11 +107,12 @@ export class ReolinkBaichuanIntercom {
110
107
 
111
108
  // Use createDedicatedTalkSession - library manages dedicated socket internally
112
109
  // with auto-teardown on idle or when stop() is called
113
- const session = await this.camera.withBaichuanRetry(async () => {
110
+ const blocksPerPayload = this.host.blocksPerPayload;
111
+ const session = await this.host.withRetry(async () => {
114
112
  return await api.createDedicatedTalkSession(channel, {
115
- blocksPerPayload: this.blocksPerPayload,
113
+ blocksPerPayload,
116
114
  idleTimeoutMs: 30000, // Auto-teardown if no audio for 30s
117
- deviceId: this.camera.nativeId,
115
+ deviceId: this.host.deviceId,
118
116
  logger,
119
117
  });
120
118
  });
@@ -130,9 +128,7 @@ export class ReolinkBaichuanIntercom {
130
128
  // Configurable backlog to trade latency vs stability.
131
129
  // If the pipeline (ffmpeg decode + encode + send) can't keep up,
132
130
  // dropping old audio avoids accumulating multi-second latency.
133
- const configuredBacklog = Number(
134
- this.camera.storageSettings.values.intercomMaxBacklogMs,
135
- );
131
+ const configuredBacklog = Number(this.host.maxBacklogMs);
136
132
  if (Number.isFinite(configuredBacklog)) {
137
133
  this.maxBacklogMs = Math.max(20, Math.min(5000, configuredBacklog));
138
134
  } else {
@@ -177,12 +173,12 @@ export class ReolinkBaichuanIntercom {
177
173
  bytesNeeded,
178
174
  maxBacklogMs: this.maxBacklogMs,
179
175
  maxBacklogBytes: this.maxBacklogBytes,
180
- blocksPerPayload: this.blocksPerPayload,
176
+ blocksPerPayload,
181
177
  });
182
178
 
183
179
  // IMPORTANT: incoming audio from Scrypted/WebRTC is typically Opus.
184
180
  // We must decode to PCM before IMA ADPCM encoding, otherwise it will be noise.
185
- const gain = this.outputGain;
181
+ const gain = this.host.outputGain;
186
182
  const ffmpegArgs = this.buildFfmpegPcmArgs(ffmpegInput, {
187
183
  sampleRate,
188
184
  channels: 1,
@@ -260,7 +256,7 @@ export class ReolinkBaichuanIntercom {
260
256
  if (this.stopping) return this.stopping;
261
257
 
262
258
  this.stopping = (async () => {
263
- const logger = this.camera.getBaichuanLogger();
259
+ const logger = this.host.logger;
264
260
 
265
261
  const ffmpeg = this.ffmpeg;
266
262
  this.ffmpeg = undefined;
@@ -329,7 +325,7 @@ export class ReolinkBaichuanIntercom {
329
325
  bytesNeeded: number,
330
326
  blockSize: number,
331
327
  ): void {
332
- const logger = this.camera.getBaichuanLogger();
328
+ const logger = this.host.logger;
333
329
 
334
330
  if (this.session !== session) return;
335
331
 
package/src/main.ts CHANGED
@@ -11,7 +11,7 @@ if (typeof globalThis.File === "undefined") {
11
11
  };
12
12
  }
13
13
 
14
- import type { AutoDetectMode } from "@apocaliss92/reolink-baichuan-js" with {
14
+ import type { AutoDetectMode, ReolinkBaichuanApi } from "@apocaliss92/reolink-baichuan-js" with {
15
15
  "resolution-mode": "import"
16
16
  };
17
17
  import sdk, {
@@ -29,6 +29,11 @@ import sdk, {
29
29
  import { randomBytes } from "crypto";
30
30
  import { BaseBaichuanClass } from "./baichuan-base";
31
31
  import { ReolinkCamera } from "./camera";
32
+ import { createBaichuanApi, normalizeUid, type BaichuanTransport } from "./connect";
33
+ import {
34
+ ReolinkNativeIntercom,
35
+ INTERCOM_PROVIDER_NATIVE_ID,
36
+ } from "./intercom-provider";
32
37
  import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
33
38
  import { ReolinkNativeNvrDevice } from "./nvr";
34
39
  import {
@@ -49,19 +54,92 @@ class ReolinkNativePlugin
49
54
  devices = new Map<string, BaseBaichuanClass>();
50
55
  camerasMap = new Map<string, ReolinkCamera>();
51
56
  nvrDeviceId: string;
57
+ private intercomProvider?: ReolinkNativeIntercom;
58
+
59
+ // Shared Baichuan API connections for external devices (non-Reolink Native cameras).
60
+ // Keyed by Scrypted device ID. Multiple mixins on the same device share one connection.
61
+ private externalClients = new Map<
62
+ string,
63
+ { api: ReolinkBaichuanApi; refCount: number }
64
+ >();
52
65
 
53
66
  constructor(nativeId: string) {
54
67
  super(nativeId);
55
68
 
56
69
  const nvrDevice = sdk.systemManager.getDeviceByName("Scrypted NVR");
57
70
  this.nvrDeviceId = nvrDevice?.id;
71
+
72
+ // Register the intercom MixinProvider as a sub-device
73
+ sdk.deviceManager.onDeviceDiscovered({
74
+ nativeId: INTERCOM_PROVIDER_NATIVE_ID,
75
+ name: "Reolink Native Intercom",
76
+ interfaces: [ScryptedInterface.MixinProvider, ScryptedInterface.Settings],
77
+ type: ScryptedDeviceType.API,
78
+ providerNativeId: this.nativeId,
79
+ });
80
+ }
81
+
82
+ async acquireExternalClient(
83
+ deviceId: string,
84
+ config: {
85
+ host: string;
86
+ username: string;
87
+ password: string;
88
+ uid?: string;
89
+ transport: BaichuanTransport;
90
+ logger?: Console;
91
+ },
92
+ ): Promise<ReolinkBaichuanApi> {
93
+ const existing = this.externalClients.get(deviceId);
94
+ if (existing?.api?.isReady) {
95
+ existing.refCount++;
96
+ return existing.api;
97
+ }
98
+ // Close stale client if any
99
+ if (existing?.api) {
100
+ try {
101
+ await existing.api.close();
102
+ } catch {}
103
+ }
104
+ const api = await createBaichuanApi({
105
+ inputs: {
106
+ host: config.host,
107
+ username: config.username,
108
+ password: config.password,
109
+ uid: normalizeUid(config.uid),
110
+ logger: config.logger,
111
+ },
112
+ transport: config.transport,
113
+ });
114
+ this.externalClients.set(deviceId, { api, refCount: 1 });
115
+ return api;
116
+ }
117
+
118
+ async releaseExternalClient(deviceId: string): Promise<void> {
119
+ const entry = this.externalClients.get(deviceId);
120
+ if (!entry) return;
121
+ entry.refCount--;
122
+ if (entry.refCount <= 0) {
123
+ this.externalClients.delete(deviceId);
124
+ try {
125
+ await entry.api.close();
126
+ } catch {}
127
+ }
58
128
  }
59
129
 
60
130
  getScryptedDeviceCreator(): string {
61
131
  return "Reolink Native camera";
62
132
  }
63
133
 
64
- async getDevice(nativeId: ScryptedNativeId): Promise<BaseBaichuanClass> {
134
+ async getDevice(nativeId: ScryptedNativeId): Promise<any> {
135
+ if (nativeId === INTERCOM_PROVIDER_NATIVE_ID) {
136
+ if (!this.intercomProvider) {
137
+ this.intercomProvider = new ReolinkNativeIntercom(nativeId);
138
+ this.intercomProvider.plugin = this;
139
+ }
140
+ return this.intercomProvider;
141
+ }
142
+
65
143
  if (this.devices.has(nativeId)) {
66
144
  return this.devices.get(nativeId)!;
67
145
  }
package/src/utils.ts CHANGED
@@ -78,7 +78,6 @@ export const getDeviceInterfaces = (props: {
78
78
  hasFloodlight,
79
79
  hasPir,
80
80
  hasBattery,
81
- hasIntercom,
82
81
  isDoorbell,
83
82
  } = capabilities;
84
83
 
@@ -91,9 +90,6 @@ export const getDeviceInterfaces = (props: {
91
90
  if (hasBattery) {
92
91
  interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep, ScryptedInterface.Charger);
93
92
  }
94
- if (hasIntercom) {
95
- interfaces.push(ScryptedInterface.Intercom);
96
- }
97
93
  if (isDoorbell) {
98
94
  interfaces.push(ScryptedInterface.BinarySensor);
99
95
  }