buzz-me-in 2.0.0

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/homekit.js ADDED
@@ -0,0 +1,789 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HAP-NodeJS camera+doorbell accessory for the apartment intercom.
5
+ *
6
+ * Requires hap-nodejs >= 12.x and ffmpeg with libopus (libx264 for video).
7
+ *
8
+ * Inbound path (Twilio → HomeKit):
9
+ * raw mulaw/8kHz on PassThrough stdin
10
+ * → ffmpeg: decode mulaw, encode Opus; mux blank H.264 video
11
+ * → SRTP to HomeKit
12
+ *
13
+ * Outbound path (HomeKit → Twilio):
14
+ * SRTP return audio from iPhone → local UDP port
15
+ * → ffmpeg: decrypt, decode Opus, encode mulaw/8kHz
16
+ * → stdout → Node.js → Twilio WebSocket JSON envelope
17
+ */
18
+
19
+ const hap = require('hap-nodejs');
20
+ const qrcode = require('qrcode-terminal');
21
+ const crypto = require('crypto');
22
+ const net = require('net');
23
+ const os = require('os');
24
+ const { spawn } = require('child_process');
25
+
26
+ const config = require('./src/core/config');
27
+ const state = require('./src/core/state');
28
+ const { sendDtmfSequence, sendMulawAudio } = require('./src/core/mulaw-audio');
29
+ const {
30
+ removeReturnAudioSdp,
31
+ removeSdpDir,
32
+ writeReturnAudioSdp,
33
+ } = require('./src/core/return-audio-sdp');
34
+ const { createLogger } = require('./src/core/log');
35
+ const { hangUpCall } = require('./twilio-api');
36
+
37
+ const {
38
+ Accessory,
39
+ Service,
40
+ Characteristic,
41
+ uuid: hapUuid,
42
+ CameraController,
43
+ SRTPCryptoSuites,
44
+ H264Profile,
45
+ H264Level,
46
+ AudioStreamingCodecType,
47
+ AudioStreamingSamplerate,
48
+ StreamRequestTypes,
49
+ Categories,
50
+ } = hap;
51
+ const logger = createLogger({ component: 'homekit' });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Utilities
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function getLocalIp() {
58
+ for (const ifaces of Object.values(os.networkInterfaces())) {
59
+ for (const iface of ifaces) {
60
+ if (iface.family === 'IPv4' && !iface.internal) return iface.address;
61
+ }
62
+ }
63
+ return '127.0.0.1';
64
+ }
65
+
66
+ /** Bind a random OS-assigned port then immediately release it. */
67
+ function getAvailablePort() {
68
+ return new Promise((resolve, reject) => {
69
+ const srv = net.createServer();
70
+ srv.unref();
71
+ srv.listen(0, '0.0.0.0', () => {
72
+ const addr = srv.address();
73
+ if (!addr || typeof addr === 'string') {
74
+ reject(new Error('failed to allocate ephemeral port'));
75
+ return;
76
+ }
77
+ const { port } = addr;
78
+ srv.close(() => resolve(port));
79
+ });
80
+ srv.on('error', reject);
81
+ });
82
+ }
83
+
84
+ function randomSSRC() {
85
+ // ffmpeg's RTP muxer parses -ssrc as a signed 32-bit integer option.
86
+ return crypto.randomBytes(4).readUInt32BE(0) & 0x7fffffff;
87
+ }
88
+
89
+ /**
90
+ * ffmpeg -srtp_out_params expects the SRTP master key and salt
91
+ * concatenated and base64-encoded (16-byte key + 14-byte salt = 30 bytes).
92
+ */
93
+ function srtpParams(key, salt) {
94
+ return Buffer.concat([key, salt]).toString('base64');
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Minimal 1×1 black JPEG used for snapshot responses.
99
+ // Generated once at startup by ffmpeg; falls back to a hardcoded buffer.
100
+ // ---------------------------------------------------------------------------
101
+
102
+ let snapshotJpeg = null;
103
+
104
+ /** @returns {Promise<void>} */
105
+ function initSnapshot() {
106
+ return new Promise((resolve) => {
107
+ const ff = spawn('ffmpeg', [
108
+ '-f',
109
+ 'lavfi',
110
+ '-i',
111
+ 'color=black:s=1280x720',
112
+ '-vframes',
113
+ '1',
114
+ '-f',
115
+ 'mjpeg',
116
+ '-q:v',
117
+ '5',
118
+ 'pipe:1',
119
+ ]);
120
+ const chunks = [];
121
+ ff.stdout.on('data', (d) => chunks.push(d));
122
+ ff.stdout.on('end', () => {
123
+ snapshotJpeg = chunks.length ? Buffer.concat(chunks) : FALLBACK_JPEG;
124
+ resolve();
125
+ });
126
+ ff.on('error', () => {
127
+ snapshotJpeg = FALLBACK_JPEG;
128
+ resolve();
129
+ });
130
+ ff.stderr.resume(); // discard
131
+ });
132
+ }
133
+
134
+ // Hardcoded 1×1 black JPEG (used only if ffmpeg isn't available at init time)
135
+ const FALLBACK_JPEG = Buffer.from(
136
+ '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDB' +
137
+ 'kSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAAR' +
138
+ 'CAABAAEDASIAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAA' +
139
+ 'AAAAAAAAAAAAAAD/xAAUAQEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAA' +
140
+ 'AAAAAA/9oADAMBAAIRAxEAPwClAAH/2Q==',
141
+ 'base64'
142
+ );
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Active HAP streaming sessions
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Map from sessionID → {
150
+ * targetAddress, hkVideoPort, hkVideoKey, hkVideoSalt,
151
+ * hkAudioPort, hkAudioKey, hkAudioSalt,
152
+ * returnAudioPort, returnAudioKey, returnAudioSalt,
153
+ * ffIn, ffOut, sdpPath ← added on START
154
+ * }
155
+ */
156
+ const activeSessions = new Map();
157
+
158
+ // PassThrough stream set by server.js each time a Twilio call connects.
159
+ let currentMulawStream = null;
160
+ let onHapSessionStarted = null;
161
+
162
+ function getActiveCall() {
163
+ return state.getActiveCall();
164
+ }
165
+
166
+ function attachMulawStreamToSession(sessionID, stream) {
167
+ const session = activeSessions.get(sessionID);
168
+ if (
169
+ !session ||
170
+ !stream ||
171
+ stream.destroyed ||
172
+ !session.ffIn ||
173
+ !session.ffIn.stdin ||
174
+ session.ffIn.stdin.destroyed
175
+ ) {
176
+ return false;
177
+ }
178
+ if (session.mulawStream && session.mulawStream !== stream) {
179
+ try {
180
+ session.mulawStream.unpipe(session.ffIn.stdin);
181
+ } catch {}
182
+ }
183
+ session.mulawStream = stream;
184
+ stream.pipe(session.ffIn.stdin, { end: false });
185
+ logger.info('Bound mulaw stream to active HomeKit session', {
186
+ event: 'mulaw-stream-bound',
187
+ sessionId: sessionID,
188
+ });
189
+ return true;
190
+ }
191
+
192
+ async function prepareStreamSession(request) {
193
+ const returnAudioPort = await getAvailablePort();
194
+ const videoSsrc = randomSSRC();
195
+ const audioSsrc = randomSSRC();
196
+
197
+ // getAvailablePort() for our "video receive" slot -- we never actually
198
+ // receive video from HomeKit, but HAP requires us to declare a port.
199
+ const dummyVideoPort = await getAvailablePort();
200
+
201
+ activeSessions.set(request.sessionID, {
202
+ targetAddress: request.targetAddress,
203
+ hkVideoPort: request.video.port,
204
+ hkVideoKey: request.video.srtp_key,
205
+ hkVideoSalt: request.video.srtp_salt,
206
+ hkAudioPort: request.audio.port,
207
+ hkAudioKey: request.audio.srtp_key,
208
+ hkAudioSalt: request.audio.srtp_salt,
209
+ returnAudioPort,
210
+ returnAudioKey: request.audio.srtp_key,
211
+ returnAudioSalt: request.audio.srtp_salt,
212
+ videoSsrc,
213
+ audioSsrc,
214
+ });
215
+
216
+ return {
217
+ address: { address: getLocalIp(), type: 'v4' },
218
+ video: {
219
+ port: dummyVideoPort,
220
+ ssrc: videoSsrc,
221
+ srtp_key: request.video.srtp_key,
222
+ srtp_salt: request.video.srtp_salt,
223
+ },
224
+ audio: {
225
+ port: returnAudioPort,
226
+ ssrc: audioSsrc,
227
+ srtp_key: request.audio.srtp_key,
228
+ srtp_salt: request.audio.srtp_salt,
229
+ },
230
+ };
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Camera streaming delegate
235
+ // ---------------------------------------------------------------------------
236
+
237
+ const streamingDelegate = {
238
+ handleSnapshotRequest(_req, callback) {
239
+ callback(undefined, snapshotJpeg || FALLBACK_JPEG);
240
+ },
241
+
242
+ prepareStream(request, callback) {
243
+ prepareStreamSession(request)
244
+ .then((response) => callback(undefined, response))
245
+ .catch((error) => {
246
+ logger.error('Failed to prepare HomeKit stream', {
247
+ event: 'stream-prepare-failed',
248
+ reason: 'prepare-error',
249
+ sessionId: request.sessionID,
250
+ error,
251
+ });
252
+ callback(error);
253
+ });
254
+ },
255
+
256
+ handleStreamRequest(request, callback) {
257
+ const s = activeSessions.get(request.sessionID);
258
+ if (!s) {
259
+ callback();
260
+ return;
261
+ }
262
+
263
+ if (request.type === StreamRequestTypes.START) {
264
+ _startSession(request.sessionID, s, request, callback);
265
+ } else if (request.type === StreamRequestTypes.RECONFIGURE) {
266
+ // Static source — ignore bitrate/resolution change requests.
267
+ callback();
268
+ } else if (request.type === StreamRequestTypes.STOP) {
269
+ _stopSession(request.sessionID, /* hangUp= */ true);
270
+ callback();
271
+ }
272
+ },
273
+ };
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Session start: spawn inbound and outbound ffmpeg processes
277
+ // ---------------------------------------------------------------------------
278
+
279
+ function _startSession(sessionID, s, request, callback) {
280
+ const videoParams = srtpParams(s.hkVideoKey, s.hkVideoSalt);
281
+ const audioParams = srtpParams(s.hkAudioKey, s.hkAudioSalt);
282
+ const video = request.video;
283
+ const audio = request.audio;
284
+ const videoBitrate = Math.max(64, video.max_bit_rate || 200);
285
+ const videoBufferSize = Math.max(videoBitrate * 2, 128);
286
+ const mtu = video.mtu || 1316;
287
+
288
+ // -------------------------------------------------------------------------
289
+ // Inbound ffmpeg
290
+ //
291
+ // Input 0 – raw mulaw/8kHz from Twilio via stdin
292
+ // Let the raw audio demuxer derive PTS from sample count. Using wall-clock
293
+ // timestamps breaks when ffmpeg drains the buffered startup audio burst.
294
+ //
295
+ // Input 1 – synthetic black video (lavfi color source)
296
+ // Generates H.264 Baseline/3.1 frames at 15 fps.
297
+ // HomeKit requires a video track; there is no real camera feed.
298
+ // keyint_min=15 / -g 15 forces an IDR frame every second — HomeKit
299
+ // requests it when the live view is first opened; without frequent IDRs
300
+ // the video stays blank until the next natural keyframe.
301
+ //
302
+ // Two separate SRTP outputs — no muxing, no pts coupling between streams.
303
+ // -------------------------------------------------------------------------
304
+ const ffIn = spawn('ffmpeg', [
305
+ '-y',
306
+ '-loglevel',
307
+ 'warning',
308
+
309
+ // ---- Input 0: raw mulaw from Twilio ----
310
+ '-thread_queue_size',
311
+ '512',
312
+ '-f',
313
+ 'mulaw',
314
+ '-ar',
315
+ '8000',
316
+ '-ac',
317
+ '1',
318
+ '-i',
319
+ 'pipe:0',
320
+
321
+ // ---- Input 1: blank video ----
322
+ '-f',
323
+ 'lavfi',
324
+ '-i',
325
+ 'color=black:s=1280x720:r=15',
326
+
327
+ // ---- Video output → HomeKit SRTP ----
328
+ '-map',
329
+ '1:v',
330
+ '-c:v',
331
+ 'libx264',
332
+ '-profile:v',
333
+ 'baseline',
334
+ '-level:v',
335
+ '3.1',
336
+ '-preset',
337
+ 'ultrafast',
338
+ '-tune',
339
+ 'zerolatency',
340
+ '-pix_fmt',
341
+ 'yuv420p',
342
+ '-b:v',
343
+ `${videoBitrate}k`,
344
+ '-maxrate',
345
+ `${videoBitrate}k`,
346
+ '-bufsize',
347
+ `${videoBufferSize}k`,
348
+ '-g',
349
+ String(video.fps || 15),
350
+ '-keyint_min',
351
+ String(video.fps || 15),
352
+ '-payload_type',
353
+ String(video.pt),
354
+ '-ssrc',
355
+ String(s.videoSsrc),
356
+ '-f',
357
+ 'rtp',
358
+ '-srtp_out_suite',
359
+ 'AES_CM_128_HMAC_SHA1_80',
360
+ '-srtp_out_params',
361
+ videoParams,
362
+ `srtp://${s.targetAddress}:${s.hkVideoPort}?rtcpport=${s.hkVideoPort}&localrtcpport=${s.hkVideoPort}&pkt_size=${mtu}`,
363
+
364
+ // ---- Audio output → HomeKit SRTP (Opus/16kHz) ----
365
+ //
366
+ // Note on codec choice: libopus is in every standard ffmpeg build.
367
+ // If you prefer AAC-ELD (required by some older HomeKit devices), compile
368
+ // ffmpeg with --enable-libfdk-aac --enable-nonfree and change:
369
+ // '-c:a', 'libfdk_aac', '-profile:a', 'aac_eld',
370
+ // and update streamingOptions.audio.codecs below to AAC_ELD.
371
+ '-map',
372
+ '0:a',
373
+ '-c:a',
374
+ 'libopus',
375
+ '-ar',
376
+ '16000',
377
+ '-ac',
378
+ '1',
379
+ '-b:a',
380
+ '24k',
381
+ '-application',
382
+ 'voip',
383
+ '-frame_duration',
384
+ '20',
385
+ '-payload_type',
386
+ String(audio.pt),
387
+ '-ssrc',
388
+ String(s.audioSsrc),
389
+ '-f',
390
+ 'rtp',
391
+ '-srtp_out_suite',
392
+ 'AES_CM_128_HMAC_SHA1_80',
393
+ '-srtp_out_params',
394
+ audioParams,
395
+ `srtp://${s.targetAddress}:${s.hkAudioPort}?rtcpport=${s.hkAudioPort}&localrtcpport=${s.hkAudioPort}`,
396
+ ]);
397
+
398
+ ffIn.stderr.on('data', (d) => {
399
+ logger.warn('Inbound ffmpeg stderr', {
400
+ event: 'ffin-stderr',
401
+ reason: 'ffmpeg-stderr',
402
+ detail: d.toString('utf8').trim(),
403
+ sessionId: sessionID,
404
+ });
405
+ });
406
+ ffIn.on('close', (code) => {
407
+ logger.info('Inbound ffmpeg exited', {
408
+ event: 'ffin-exit',
409
+ reason: code === 0 ? 'clean-exit' : 'nonzero-exit',
410
+ exitCode: code,
411
+ sessionId: sessionID,
412
+ });
413
+ });
414
+ ffIn.stdin.on('error', () => {}); // suppress EPIPE when stream ends
415
+
416
+ // -------------------------------------------------------------------------
417
+ // Outbound ffmpeg
418
+ //
419
+ // iPhone → SRTP → returnAudioPort → ffmpeg (Opus decode + mulaw encode)
420
+ // → stdout → Node.js → Twilio WebSocket JSON envelope
421
+ //
422
+ // The SDP file tells ffmpeg how to receive and decrypt the SRTP stream.
423
+ //
424
+ // Use the negotiated Opus payload type from HomeKit's START request; older
425
+ // versions assumed 110, which breaks when the controller chooses otherwise.
426
+ // -------------------------------------------------------------------------
427
+ // ffIn is already spawned above; from here on, any throw (SDP write failure,
428
+ // ffOut spawn) must tear it down and fail the START, otherwise the inbound
429
+ // ffmpeg leaks and HomeKit's callback never fires.
430
+ let sdpPath;
431
+ let ffOut;
432
+ try {
433
+ sdpPath = writeReturnAudioSdp({
434
+ sessionID,
435
+ port: s.returnAudioPort,
436
+ payloadType: audio.pt,
437
+ srtpParams: srtpParams(s.returnAudioKey, s.returnAudioSalt),
438
+ });
439
+
440
+ ffOut = spawn('ffmpeg', [
441
+ '-y',
442
+ '-loglevel',
443
+ 'warning',
444
+ '-protocol_whitelist',
445
+ 'file,crypto,udp,rtp',
446
+ '-f',
447
+ 'sdp',
448
+ '-i',
449
+ sdpPath,
450
+ // Decode Opus → resample → encode mulaw/8kHz
451
+ '-ar',
452
+ '8000',
453
+ '-ac',
454
+ '1',
455
+ '-c:a',
456
+ 'pcm_mulaw',
457
+ '-f',
458
+ 'mulaw',
459
+ '-fflags',
460
+ '+nobuffer',
461
+ '-flush_packets',
462
+ '1',
463
+ 'pipe:1',
464
+ ]);
465
+
466
+ ffOut.stderr.on('data', (d) => {
467
+ logger.warn('Outbound ffmpeg stderr', {
468
+ event: 'ffout-stderr',
469
+ reason: 'ffmpeg-stderr',
470
+ detail: d.toString('utf8').trim(),
471
+ sessionId: sessionID,
472
+ });
473
+ });
474
+ ffOut.on('close', (code) => {
475
+ logger.info('Outbound ffmpeg exited', {
476
+ event: 'ffout-exit',
477
+ reason: code === 0 ? 'clean-exit' : 'nonzero-exit',
478
+ exitCode: code,
479
+ sessionId: sessionID,
480
+ });
481
+ });
482
+
483
+ // Forward each decoded mulaw chunk to Twilio as a media event.
484
+ ffOut.stdout.on('data', (chunk) => {
485
+ const activeCall = getActiveCall();
486
+ if (!activeCall || !activeCall.wsConnection || !activeCall.streamSid) return;
487
+ sendMulawAudio(activeCall, chunk);
488
+ state.markActivity(activeCall.callSid, 'homekit-outbound-media');
489
+ });
490
+
491
+ activeSessions.set(sessionID, { ...s, ffIn, ffOut, sdpPath });
492
+ attachMulawStreamToSession(sessionID, currentMulawStream);
493
+ } catch (error) {
494
+ logger.error('Failed to start HomeKit stream session', {
495
+ event: 'stream-start-failed',
496
+ reason: 'session-setup-threw',
497
+ sessionId: sessionID,
498
+ error,
499
+ });
500
+ activeSessions.delete(sessionID);
501
+ killFfmpeg(ffIn);
502
+ if (ffOut) killFfmpeg(ffOut);
503
+ if (sdpPath) removeReturnAudioSdp(sdpPath);
504
+ callback(error);
505
+ return;
506
+ }
507
+
508
+ const activeCall = getActiveCall();
509
+ if (activeCall && onHapSessionStarted) {
510
+ onHapSessionStarted(activeCall.callSid);
511
+ }
512
+
513
+ callback();
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Session stop: tear down ffmpeg, optionally hang up Twilio call
518
+ // ---------------------------------------------------------------------------
519
+
520
+ const FFMPEG_KILL_GRACE_MS = 2000;
521
+
522
+ /**
523
+ * SIGINT first for a clean ffmpeg exit, escalating to SIGKILL if the
524
+ * process is still alive after the grace period (a wedged ffmpeg would
525
+ * otherwise hold its UDP ports and CPU indefinitely).
526
+ * @param {import('child_process').ChildProcess} proc
527
+ */
528
+ function killFfmpeg(proc) {
529
+ if (proc.exitCode !== null || proc.signalCode !== null) return;
530
+ proc.kill('SIGINT');
531
+ const escalation = setTimeout(() => {
532
+ if (proc.exitCode === null && proc.signalCode === null) proc.kill('SIGKILL');
533
+ }, FFMPEG_KILL_GRACE_MS);
534
+ escalation.unref();
535
+ proc.once('exit', () => clearTimeout(escalation));
536
+ }
537
+
538
+ function _stopSession(sessionID, hangUp) {
539
+ const s = activeSessions.get(sessionID);
540
+ if (!s) return;
541
+ activeSessions.delete(sessionID);
542
+
543
+ if (s.ffIn) {
544
+ if (s.mulawStream || currentMulawStream) {
545
+ try {
546
+ (s.mulawStream || currentMulawStream).unpipe(s.ffIn.stdin);
547
+ } catch {}
548
+ }
549
+ killFfmpeg(s.ffIn);
550
+ }
551
+ if (s.ffOut) killFfmpeg(s.ffOut);
552
+ if (s.sdpPath) removeReturnAudioSdp(s.sdpPath);
553
+
554
+ const activeCall = getActiveCall();
555
+ if (hangUp && activeCall) {
556
+ hangUpCall(activeCall.callSid)
557
+ .then((result) => {
558
+ if (result && result.alreadyEnded) {
559
+ logger.info('Twilio call already ended while stopping HomeKit session', {
560
+ event: 'hangup-call-already-ended',
561
+ reason: 'twilio-call-not-in-progress',
562
+ callSid: activeCall.callSid,
563
+ sessionId: sessionID,
564
+ });
565
+ }
566
+ })
567
+ .catch((error) => {
568
+ logger.error('Failed to hang up call while stopping HomeKit session', {
569
+ event: 'hangup-call-failed',
570
+ reason: 'twilio-hangup-failed',
571
+ callSid: activeCall.callSid,
572
+ sessionId: sessionID,
573
+ error,
574
+ });
575
+ });
576
+ }
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Accessory construction
581
+ // ---------------------------------------------------------------------------
582
+
583
+ const accessory = new Accessory('Apartment Intercom', hapUuid.generate('homekit-intercom-v1'));
584
+
585
+ accessory
586
+ .getService(Service.AccessoryInformation)
587
+ .setCharacteristic(Characteristic.Manufacturer, 'DIY')
588
+ .setCharacteristic(Characteristic.Model, 'RPi Intercom')
589
+ .setCharacteristic(Characteristic.SerialNumber, 'RPI-001');
590
+
591
+ // ---- Doorbell ----
592
+ const doorbellService = accessory.addService(Service.Doorbell, 'Intercom Doorbell');
593
+
594
+ // ---- Lock (triggers DTMF unlock) ----
595
+ const lockService = accessory.addService(Service.LockMechanism, 'Intercom Lock');
596
+ lockService
597
+ .getCharacteristic(Characteristic.LockCurrentState)
598
+ .onGet(() => Characteristic.LockCurrentState.SECURED)
599
+ .setValue(Characteristic.LockCurrentState.SECURED);
600
+
601
+ lockService
602
+ .getCharacteristic(Characteristic.LockTargetState)
603
+ .onGet(() => Characteristic.LockTargetState.SECURED)
604
+ .onSet(async (value) => {
605
+ const activeCall = getActiveCall();
606
+ if (value === Characteristic.LockTargetState.UNSECURED && activeCall) {
607
+ try {
608
+ await sendDtmfSequence(activeCall, config.twilioUnlockDigits);
609
+ state.markActivity(activeCall.callSid, 'unlock-dtmf');
610
+ logger.info('Sent DTMF unlock over active media stream', {
611
+ event: 'unlock-requested',
612
+ callSid: activeCall.callSid,
613
+ digits: config.twilioUnlockDigits,
614
+ });
615
+ } catch (error) {
616
+ logger.error('Unlock door request failed', {
617
+ event: 'unlock-failed',
618
+ reason: 'dtmf-send-failed',
619
+ callSid: activeCall.callSid,
620
+ error,
621
+ });
622
+ }
623
+ // Reset the lock tile to Secured after 3 s so it's ready for next use.
624
+ setTimeout(() => {
625
+ lockService
626
+ .getCharacteristic(Characteristic.LockCurrentState)
627
+ .updateValue(Characteristic.LockCurrentState.SECURED);
628
+ lockService
629
+ .getCharacteristic(Characteristic.LockTargetState)
630
+ .updateValue(Characteristic.LockTargetState.SECURED);
631
+ }, 3000);
632
+ } else if (value === Characteristic.LockTargetState.UNSECURED) {
633
+ logger.warn('Unlock requested but no active call', {
634
+ event: 'unlock-no-active-call',
635
+ reason: 'no-active-call',
636
+ });
637
+ setTimeout(() => {
638
+ lockService
639
+ .getCharacteristic(Characteristic.LockTargetState)
640
+ .updateValue(Characteristic.LockTargetState.SECURED);
641
+ }, 500);
642
+ }
643
+ });
644
+
645
+ // ---- Camera controller ----
646
+ const cameraController = new CameraController({
647
+ cameraStreamCount: 2,
648
+ delegate: streamingDelegate,
649
+ streamingOptions: {
650
+ supportedCryptoSuites: [SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80],
651
+ video: {
652
+ resolutions: [
653
+ [1280, 720, 15],
654
+ [640, 360, 15],
655
+ [320, 240, 15],
656
+ ],
657
+ codec: {
658
+ profiles: [H264Profile.BASELINE],
659
+ levels: [H264Level.LEVEL3_1],
660
+ },
661
+ },
662
+ audio: {
663
+ twoWayAudio: true,
664
+ codecs: [
665
+ {
666
+ type: AudioStreamingCodecType.OPUS,
667
+ samplerate: AudioStreamingSamplerate.KHZ_16,
668
+ },
669
+ ],
670
+ },
671
+ },
672
+ });
673
+
674
+ accessory.configureController(cameraController);
675
+
676
+ accessory.publish({
677
+ username: config.hapUsername,
678
+ pincode: config.hapPincode,
679
+ port: config.hapPort,
680
+ category: Categories.VIDEO_DOORBELL,
681
+ advertiser: hap.MDNSAdvertiser.AVAHI,
682
+ });
683
+
684
+ logger.info('Accessory published', {
685
+ event: 'accessory-published',
686
+ });
687
+ // The setup QR encodes the pairing pincode, so only print it on an
688
+ // interactive terminal — never into journald/log files.
689
+ if (process.stdout.isTTY) {
690
+ logger.info('Accessory QR setup URI generated', {
691
+ event: 'accessory-qr-setup',
692
+ });
693
+ qrcode.generate(accessory.setupURI(), { small: true });
694
+ } else {
695
+ logger.info('Pairing QR suppressed on non-interactive stdout; pair with HAP_PINCODE from .env', {
696
+ event: 'accessory-qr-suppressed',
697
+ reason: 'stdout-not-tty',
698
+ });
699
+ }
700
+
701
+ // Kick off snapshot generation asynchronously (non-blocking)
702
+ initSnapshot().then(() =>
703
+ logger.info('Snapshot initialized', {
704
+ event: 'snapshot-ready',
705
+ })
706
+ );
707
+
708
+ // ---------------------------------------------------------------------------
709
+ // Exports called by server.js
710
+ // ---------------------------------------------------------------------------
711
+
712
+ /**
713
+ * Ring the HomeKit doorbell.
714
+ * Called when the Twilio WebSocket fires the 'start' event.
715
+ */
716
+ function triggerDoorbell() {
717
+ doorbellService.getCharacteristic(Characteristic.ProgrammableSwitchEvent).updateValue(0); // 0 = SINGLE_PRESS
718
+ logger.info('Doorbell triggered', {
719
+ event: 'doorbell-triggered',
720
+ });
721
+ }
722
+
723
+ /**
724
+ * Set (or replace) the PassThrough stream that delivers mulaw bytes.
725
+ * Called at the start of each new Twilio call.
726
+ */
727
+ function setMulawPassthrough(stream) {
728
+ currentMulawStream = stream;
729
+ let reboundCount = 0;
730
+ for (const [sessionID, session] of activeSessions.entries()) {
731
+ if (!session.ffIn || !session.ffIn.stdin || session.ffIn.stdin.destroyed) continue;
732
+ if (!attachMulawStreamToSession(sessionID, stream)) continue;
733
+ reboundCount++;
734
+ logger.info('Rebound mulaw stream to active HomeKit session', {
735
+ event: 'mulaw-stream-rebound',
736
+ sessionId: sessionID,
737
+ });
738
+ }
739
+ return reboundCount;
740
+ }
741
+
742
+ /**
743
+ * Forget the current mulaw stream if it matches the one being torn down.
744
+ * Without this, a HomeKit live view opened after the call ended would pipe
745
+ * a destroyed stream into ffmpeg and crash on the unhandled 'error' event.
746
+ */
747
+ function clearMulawPassthrough(stream) {
748
+ if (currentMulawStream === stream) currentMulawStream = null;
749
+ }
750
+
751
+ /**
752
+ * Force-close all active HAP sessions.
753
+ * Called when Twilio fires the 'stop' event (caller hung up).
754
+ * Does NOT call hangUpCall — the call is already gone.
755
+ */
756
+ function endHapSession() {
757
+ for (const sessionID of activeSessions.keys()) {
758
+ _stopSession(sessionID, /* hangUp= */ false);
759
+ }
760
+ }
761
+
762
+ function setOnHapSessionStarted(handler) {
763
+ onHapSessionStarted = typeof handler === 'function' ? handler : null;
764
+ }
765
+
766
+ /**
767
+ * Graceful shutdown: tear down streaming sessions and unpublish the
768
+ * accessory so the mDNS advertisement does not linger after exit.
769
+ */
770
+ function shutdown() {
771
+ endHapSession();
772
+ removeSdpDir();
773
+ accessory.destroy().catch((error) => {
774
+ logger.error('Failed to destroy HAP accessory during shutdown', {
775
+ event: 'accessory-destroy-failed',
776
+ reason: 'destroy-threw',
777
+ error,
778
+ });
779
+ });
780
+ }
781
+
782
+ module.exports = {
783
+ triggerDoorbell,
784
+ setMulawPassthrough,
785
+ clearMulawPassthrough,
786
+ endHapSession,
787
+ setOnHapSessionStarted,
788
+ shutdown,
789
+ };