@apocaliss92/scrypted-reolink-native 0.0.1

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.
@@ -0,0 +1,417 @@
1
+ import type { ReolinkBaichuanApi } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { FFmpegInput, MediaObject, ScryptedMimeTypes } from "@scrypted/sdk";
3
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
4
+
5
+ import type { ReolinkNativeCamera } from "./camera";
6
+
7
+ // Keep this low: Reolink blocks are ~64ms at 16kHz (1025 samples).
8
+ // A small backlog avoids multi-second latency when the pipeline stalls.
9
+ // Aim for ~1 block of latency (a block is ~64ms at 16kHz for Reolink talk).
10
+ // This clamps the internal buffer to (approximately) one block.
11
+ const DEFAULT_MAX_BACKLOG_MS = 40;
12
+
13
+ export class ReolinkBaichuanIntercom {
14
+ private session: Awaited<ReturnType<ReolinkBaichuanApi["createTalkSession"]>> | undefined;
15
+ private ffmpeg: ChildProcessWithoutNullStreams | undefined;
16
+ private stopping: Promise<void> | undefined;
17
+ private loggedCodecInfo = false;
18
+
19
+ private readonly maxBacklogMs = DEFAULT_MAX_BACKLOG_MS;
20
+ private maxBacklogBytes: number | undefined;
21
+
22
+ private sendChain: Promise<void> = Promise.resolve();
23
+ private pcmBuffer: Buffer = Buffer.alloc(0);
24
+
25
+ constructor(private camera: ReolinkNativeCamera) {
26
+ }
27
+
28
+ get blocksPerPayload(): number {
29
+ return Math.max(1, Math.min(8, this.camera.storageSettings.values.intercomBlocksPerPayload ?? 1));
30
+ }
31
+
32
+ async start(media: MediaObject): Promise<void> {
33
+ this.camera.markActivity();
34
+ const logger = this.camera.getLogger();
35
+
36
+ const ffmpegInput = await sdk.mediaManager.convertMediaObjectToJSON<FFmpegInput>(
37
+ media,
38
+ ScryptedMimeTypes.FFmpegInput,
39
+ );
40
+
41
+ await this.stop();
42
+
43
+ const api = await this.camera.ensureClient();
44
+ const channel = this.camera.getRtspChannel();
45
+
46
+ // Best-effort: log codec requirements exposed by the camera.
47
+ // This mirrors neolink's source of truth: TalkAbility (cmd_id=10).
48
+ if (!this.loggedCodecInfo) {
49
+ this.loggedCodecInfo = true;
50
+ try {
51
+ const ability = await api.getTalkAbility(channel);
52
+ const audioConfigs = ability.audioConfigList?.map((c) => ({
53
+ audioType: c.audioType,
54
+ sampleRate: c.sampleRate,
55
+ samplePrecision: c.samplePrecision,
56
+ lengthPerEncoder: c.lengthPerEncoder,
57
+ soundTrack: c.soundTrack,
58
+ }));
59
+ logger.log("Intercom TalkAbility", {
60
+ channel,
61
+ duplexList: ability.duplexList,
62
+ audioStreamModeList: ability.audioStreamModeList,
63
+ audioConfigList: audioConfigs,
64
+ });
65
+ }
66
+ catch (e) {
67
+ logger.warn("Intercom: unable to fetch TalkAbility", e);
68
+ }
69
+ }
70
+
71
+ const session = await this.camera.withBaichuanRetry(async () => api.createTalkSession(channel, {
72
+ blocksPerPayload: this.blocksPerPayload,
73
+ }));
74
+
75
+ this.session = session;
76
+ this.pcmBuffer = Buffer.alloc(0);
77
+ this.sendChain = Promise.resolve();
78
+
79
+ const { audioConfig, blockSize, fullBlockSize } = session.info;
80
+ const sampleRate = audioConfig.sampleRate;
81
+
82
+ // Mirror native-api.ts: receive PCM s16le from the forwarder and encode IMA ADPCM in JS.
83
+ const samplesPerBlock = blockSize * 2 + 1;
84
+ const bytesNeeded = samplesPerBlock * 2; // Int16 PCM
85
+ this.maxBacklogBytes = Math.max(
86
+ bytesNeeded,
87
+ // bytes/sec = sampleRate * channels * 2 (s16)
88
+ Math.floor((this.maxBacklogMs / 1000) * sampleRate * 1 * 2),
89
+ );
90
+
91
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
92
+ await this.stop();
93
+ throw new Error(`Invalid talk sampleRate: ${sampleRate}`);
94
+ }
95
+ if (!Number.isFinite(blockSize) || blockSize <= 0 || !Number.isFinite(fullBlockSize) || fullBlockSize !== blockSize + 4) {
96
+ await this.stop();
97
+ throw new Error(`Invalid talk block sizes: blockSize=${blockSize} fullBlockSize=${fullBlockSize}`);
98
+ }
99
+
100
+ logger.log("Starting intercom (baichuan/native-api flow)", {
101
+ channel,
102
+ audioType: audioConfig.audioType,
103
+ sampleRate: audioConfig.sampleRate,
104
+ samplePrecision: audioConfig.samplePrecision,
105
+ lengthPerEncoder: audioConfig.lengthPerEncoder,
106
+ soundTrack: audioConfig.soundTrack,
107
+ blockSize,
108
+ fullBlockSize,
109
+ samplesPerBlock,
110
+ bytesNeeded,
111
+ maxBacklogMs: this.maxBacklogMs,
112
+ maxBacklogBytes: this.maxBacklogBytes,
113
+ blocksPerPayload: this.blocksPerPayload,
114
+ });
115
+
116
+ // IMPORTANT: incoming audio from Scrypted/WebRTC is typically Opus.
117
+ // We must decode to PCM before IMA ADPCM encoding, otherwise it will be noise.
118
+ const ffmpegArgs = this.buildFfmpegPcmArgs(ffmpegInput, {
119
+ sampleRate,
120
+ channels: 1,
121
+ });
122
+
123
+ logger.log("Intercom ffmpeg decode args", ffmpegArgs);
124
+
125
+ const ffmpeg = spawn("ffmpeg", ffmpegArgs, {
126
+ stdio: ["ignore", "pipe", "pipe"],
127
+ });
128
+
129
+ if (this.session !== session) {
130
+ try { ffmpeg.kill("SIGKILL"); } catch { }
131
+ return;
132
+ }
133
+
134
+ this.ffmpeg = ffmpeg;
135
+
136
+ ffmpeg.stdout.on("data", (chunk: Buffer) => {
137
+ if (this.session !== session) return;
138
+ if (!chunk?.length) return;
139
+ this.enqueuePcm(session, chunk, bytesNeeded, blockSize);
140
+ });
141
+
142
+ let stderrLines = 0;
143
+ ffmpeg.stderr.on("data", (d: Buffer) => {
144
+ // Avoid spamming logs.
145
+ if (stderrLines++ < 12) {
146
+ logger.warn("Intercom ffmpeg", d.toString().trim());
147
+ }
148
+ });
149
+
150
+ ffmpeg.on("exit", (code, signal) => {
151
+ logger.warn(`Intercom ffmpeg exited code=${code} signal=${signal}`);
152
+ this.stop().catch(() => { });
153
+ });
154
+
155
+ logger.log("Intercom started (ffmpeg decode -> PCM -> IMA ADPCM)");
156
+ }
157
+
158
+ stop(): Promise<void> {
159
+ if (this.stopping) return this.stopping;
160
+
161
+ this.stopping = (async () => {
162
+ const logger = this.camera.getLogger();
163
+
164
+ const ffmpeg = this.ffmpeg;
165
+ this.ffmpeg = undefined;
166
+
167
+ const session = this.session;
168
+ this.session = undefined;
169
+
170
+ this.pcmBuffer = Buffer.alloc(0);
171
+
172
+ const sleepMs = async (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
173
+
174
+ if (ffmpeg && ffmpeg.exitCode == null) {
175
+ try {
176
+ ffmpeg.kill("SIGKILL");
177
+ }
178
+ catch {
179
+ // ignore
180
+ }
181
+
182
+ try {
183
+ await Promise.race([
184
+ new Promise<void>((resolve) => ffmpeg.once("exit", () => resolve())),
185
+ sleepMs(1000),
186
+ ]);
187
+ }
188
+ catch {
189
+ // ignore
190
+ }
191
+ }
192
+
193
+ try {
194
+ await Promise.race([this.sendChain, sleepMs(250)]);
195
+ }
196
+ catch {
197
+ // ignore
198
+ }
199
+ this.sendChain = Promise.resolve();
200
+
201
+ if (session) {
202
+ try {
203
+ await Promise.race([session.stop(), sleepMs(2000)]);
204
+ }
205
+ catch (e) {
206
+ logger.warn("Intercom session stop error", e);
207
+ }
208
+ }
209
+ })().finally(() => {
210
+ this.stopping = undefined;
211
+ });
212
+
213
+ return this.stopping;
214
+ }
215
+
216
+ private clamp16(x: number): number {
217
+ if (x > 32767) return 32767;
218
+ if (x < -32768) return -32768;
219
+ return x | 0;
220
+ }
221
+
222
+ private enqueuePcm(
223
+ session: Awaited<ReturnType<ReolinkBaichuanApi["createTalkSession"]>>,
224
+ pcmChunk: Buffer,
225
+ bytesNeeded: number,
226
+ blockSize: number,
227
+ ): void {
228
+ const logger = this.camera.getLogger();
229
+
230
+ this.sendChain = this.sendChain
231
+ .then(async () => {
232
+ if (this.session !== session) return;
233
+
234
+ this.pcmBuffer = this.pcmBuffer.length
235
+ ? Buffer.concat([this.pcmBuffer, pcmChunk])
236
+ : pcmChunk;
237
+
238
+ // Cap backlog to keep latency bounded (drop oldest samples).
239
+ const maxBytes = this.maxBacklogBytes ?? bytesNeeded;
240
+ if (this.pcmBuffer.length > maxBytes) {
241
+ // Align to 16-bit samples.
242
+ const keep = maxBytes - (maxBytes % 2);
243
+ this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
244
+ }
245
+
246
+ while (this.pcmBuffer.length >= bytesNeeded) {
247
+ const chunk = this.pcmBuffer.subarray(0, bytesNeeded);
248
+ this.pcmBuffer = this.pcmBuffer.subarray(bytesNeeded);
249
+
250
+ const pcmSamples = new Int16Array(
251
+ chunk.buffer,
252
+ chunk.byteOffset,
253
+ chunk.length / 2,
254
+ );
255
+
256
+ const adpcmChunk = this.encodeImaAdpcm(pcmSamples, blockSize);
257
+ await session.sendAudio(adpcmChunk);
258
+ }
259
+ })
260
+ .catch((e) => {
261
+ logger.warn("Intercom PCM->ADPCM pipeline error", e);
262
+ });
263
+ }
264
+
265
+ private buildFfmpegPcmArgs(
266
+ ffmpegInput: FFmpegInput,
267
+ options: {
268
+ sampleRate: number;
269
+ channels: number;
270
+ },
271
+ ): string[] {
272
+ const inputArgs = ffmpegInput.inputArguments ?? [];
273
+
274
+ // FFmpegInput may already contain one or more "-i" entries.
275
+ // For intercom decode, we only need a single input and only the first audio stream.
276
+ const sanitizedArgs: string[] = [];
277
+ let chosenInput: string | undefined;
278
+
279
+ for (let i = 0; i < inputArgs.length; i++) {
280
+ const arg = inputArgs[i];
281
+ if (arg === "-i") {
282
+ const maybeUrl = inputArgs[i + 1];
283
+ if (typeof maybeUrl === "string") {
284
+ if (!chosenInput) {
285
+ chosenInput = maybeUrl;
286
+ }
287
+ // Skip all inputs after the first.
288
+ i++;
289
+ continue;
290
+ }
291
+ }
292
+
293
+ sanitizedArgs.push(arg);
294
+ }
295
+
296
+ const url = chosenInput ?? ffmpegInput.url;
297
+ if (!url) {
298
+ throw new Error("FFmpegInput missing url/input");
299
+ }
300
+
301
+ return [
302
+ ...sanitizedArgs,
303
+ "-i", url,
304
+ // Ensure we only decode the first input's audio stream.
305
+ "-map", "0:a:0?",
306
+
307
+ // Low-latency decode settings.
308
+ "-fflags", "nobuffer",
309
+ "-flags", "low_delay",
310
+ "-flush_packets", "1",
311
+
312
+ "-vn", "-sn", "-dn",
313
+ "-acodec", "pcm_s16le",
314
+ "-ar", options.sampleRate.toString(),
315
+ "-ac", options.channels.toString(),
316
+ "-f", "s16le",
317
+ "pipe:1",
318
+ ];
319
+ }
320
+
321
+ private encodeImaAdpcm(pcm: Int16Array, blockSizeBytes: number): Buffer {
322
+ const samplesPerBlock = blockSizeBytes * 2 + 1;
323
+ const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
324
+ const outBlocks: Buffer[] = [];
325
+
326
+ const imaIndexTable = Int8Array.from([
327
+ -1, -1, -1, -1, 2, 4, 6, 8,
328
+ -1, -1, -1, -1, 2, 4, 6, 8,
329
+ ]);
330
+
331
+ const imaStepTable = Int16Array.from([
332
+ 7, 8, 9, 10, 11, 12, 13, 14, 16, 17,
333
+ 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
334
+ 50, 55, 60, 66, 73, 80, 88, 97, 107, 118,
335
+ 130, 143, 157, 173, 190, 209, 230, 253, 279, 307,
336
+ 337, 371, 408, 449, 494, 544, 598, 658, 724, 796,
337
+ 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066,
338
+ 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358,
339
+ 5894, 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899,
340
+ 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767,
341
+ ]);
342
+
343
+ let sampleIndex = 0;
344
+
345
+ for (let b = 0; b < totalBlocks; b++) {
346
+ const block = Buffer.alloc(4 + blockSizeBytes);
347
+
348
+ // Block header
349
+ const first = pcm[sampleIndex] ?? 0;
350
+ let predictor = first;
351
+ let index = 0;
352
+
353
+ block.writeInt16LE(predictor, 0);
354
+ block.writeUInt8(index, 2);
355
+ block.writeUInt8(0, 3);
356
+
357
+ sampleIndex++;
358
+
359
+ // Encode samples into nibbles
360
+ const codes = new Uint8Array(blockSizeBytes * 2);
361
+ for (let i = 0; i < codes.length; i++) {
362
+ const sample = pcm[sampleIndex] ?? predictor;
363
+ sampleIndex++;
364
+
365
+ let diff = sample - predictor;
366
+ let sign = 0;
367
+ if (diff < 0) {
368
+ sign = 8;
369
+ diff = -diff;
370
+ }
371
+
372
+ let step = imaStepTable[index] ?? 7;
373
+ let delta = 0;
374
+ let vpdiff = step >> 3;
375
+
376
+ if (diff >= step) {
377
+ delta |= 4;
378
+ diff -= step;
379
+ vpdiff += step;
380
+ }
381
+ step >>= 1;
382
+ if (diff >= step) {
383
+ delta |= 2;
384
+ diff -= step;
385
+ vpdiff += step;
386
+ }
387
+ step >>= 1;
388
+ if (diff >= step) {
389
+ delta |= 1;
390
+ vpdiff += step;
391
+ }
392
+
393
+ if (sign) predictor -= vpdiff;
394
+ else predictor += vpdiff;
395
+
396
+ predictor = this.clamp16(predictor);
397
+
398
+ index += imaIndexTable[delta] ?? 0;
399
+ if (index < 0) index = 0;
400
+ if (index > 88) index = 88;
401
+
402
+ codes[i] = (delta | sign) & 0x0f;
403
+ }
404
+
405
+ // Pack nibble: low nibble first, then high nibble
406
+ for (let i = 0; i < blockSizeBytes; i++) {
407
+ const lo = codes[i * 2] ?? 0;
408
+ const hi = codes[i * 2 + 1] ?? 0;
409
+ block[4 + i] = (lo & 0x0f) | ((hi & 0x0f) << 4);
410
+ }
411
+
412
+ outBlocks.push(block);
413
+ }
414
+
415
+ return Buffer.concat(outBlocks);
416
+ }
417
+ }
package/src/main.ts ADDED
@@ -0,0 +1,126 @@
1
+ import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceInformation, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
2
+ import { ReolinkNativeCamera } from "./camera";
3
+
4
+ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
5
+ devices = new Map<string, ReolinkNativeCamera>();
6
+
7
+ getScryptedDeviceCreator(): string {
8
+ return 'Reolink Native camera';
9
+ }
10
+
11
+ getCameraInterfaces() {
12
+ return [
13
+ ScryptedInterface.Reboot,
14
+ ScryptedInterface.VideoCameraConfiguration,
15
+ ScryptedInterface.Camera,
16
+ ScryptedInterface.AudioSensor,
17
+ ScryptedInterface.MotionSensor,
18
+ ScryptedInterface.VideoTextOverlays,
19
+ ];
20
+ }
21
+
22
+ async getDevice(nativeId: ScryptedNativeId): Promise<ReolinkNativeCamera> {
23
+ if (this.devices.has(nativeId)) {
24
+ return this.devices.get(nativeId);
25
+ }
26
+
27
+ const newCamera = this.createCamera(nativeId);
28
+ this.devices.set(nativeId, newCamera);
29
+ return newCamera;
30
+ }
31
+
32
+ async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> {
33
+ const ipAddress = settings.ip?.toString();
34
+ let info: DeviceInformation = {};
35
+
36
+ const username = settings.username?.toString();
37
+ const password = settings.password?.toString();
38
+
39
+ 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
+ });
46
+
47
+ try {
48
+ const deviceInfo = await api.getInfo();
49
+ const name = deviceInfo?.name;
50
+ const rtspChannel = 0;
51
+ const { abilities, capabilities } = await api.getDeviceCapabilities(rtspChannel);
52
+
53
+ this.console.log(JSON.stringify({ abilities, capabilities, deviceInfo }));
54
+
55
+ nativeId = deviceInfo.serialNumber;
56
+
57
+ settings.newCamera ||= name;
58
+
59
+ await sdk.deviceManager.onDeviceDiscovered({
60
+ nativeId,
61
+ name,
62
+ interfaces: this.getCameraInterfaces(),
63
+ type: ScryptedDeviceType.Camera,
64
+ providerNativeId: this.nativeId,
65
+ });
66
+
67
+ const device = await this.getDevice(nativeId) as ReolinkNativeCamera;
68
+ device.info = info;
69
+ device.storageSettings.values.username = username;
70
+ device.storageSettings.values.password = password;
71
+ device.storageSettings.values.rtspChannel = rtspChannel;
72
+ device.storageSettings.values.ipAddress = ipAddress;
73
+ device.storageSettings.values.capabilities = capabilities;
74
+ device.updateDeviceInfo();
75
+
76
+ return nativeId;
77
+ }
78
+ catch (e) {
79
+ this.console.error('Error adding Reolink camera', e);
80
+ await api.close();
81
+ throw e;
82
+ }
83
+ finally {
84
+ await api.close();
85
+ }
86
+ }
87
+ }
88
+
89
+ async releaseDevice(id: string, nativeId: ScryptedNativeId): Promise<void> {
90
+ if (this.devices.has(nativeId)) {
91
+ const device = this.devices.get(nativeId);
92
+ await device.release();
93
+ this.devices.delete(nativeId);
94
+ }
95
+ }
96
+
97
+ async getCreateDeviceSettings(): Promise<Setting[]> {
98
+ return [
99
+ {
100
+ key: 'ip',
101
+ title: 'IP Address',
102
+ placeholder: '192.168.2.222',
103
+ },
104
+ {
105
+ key: 'username',
106
+ title: 'Username',
107
+ },
108
+ {
109
+ key: 'password',
110
+ title: 'Password',
111
+ type: 'password',
112
+ },
113
+ {
114
+ key: 'uid',
115
+ title: 'UID',
116
+ description: 'Reolink UID (required for battery cameras)',
117
+ }
118
+ ]
119
+ }
120
+
121
+ createCamera(nativeId: string) {
122
+ return new ReolinkNativeCamera(nativeId, this);
123
+ }
124
+ }
125
+
126
+ export default ReolinkNativePlugin;