@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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/camera.ts +1 -118
- package/src/intercom-mixin.ts +302 -0
- package/src/intercom-provider.ts +130 -0
- package/src/intercom.ts +31 -35
- package/src/main.ts +80 -2
- package/src/utils.ts +0 -4
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
71
|
-
return await this.
|
|
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.
|
|
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
|
|
110
|
+
const blocksPerPayload = this.host.blocksPerPayload;
|
|
111
|
+
const session = await this.host.withRetry(async () => {
|
|
114
112
|
return await api.createDedicatedTalkSession(channel, {
|
|
115
|
-
blocksPerPayload
|
|
113
|
+
blocksPerPayload,
|
|
116
114
|
idleTimeoutMs: 30000, // Auto-teardown if no audio for 30s
|
|
117
|
-
deviceId: this.
|
|
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
|
|
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.
|
|
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.
|
|
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<
|
|
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
|
}
|