buzz-me-in 2.1.0 → 2.1.2-beta.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/README.md +3 -0
- package/homekit.js +62 -27
- package/package.json +2 -1
- package/server.js +78 -41
- package/src/core/cli.js +5 -2
package/README.md
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Apartment intercom to Apple HomeKit, bridged over Twilio.
|
|
4
4
|
|
|
5
|
+
[](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
|
|
6
|
+
[](https://codecov.io/gh/atdr/buzz-me-in)
|
|
7
|
+
|
|
5
8
|
A Raspberry Pi server that bridges an apartment intercom system into Apple HomeKit. When someone presses the intercom buzzer, your iPhone/HomePod receives a doorbell notification with a live camera tile. You can hear the caller, speak back, and unlock the door — all from the Home app or via Siri.
|
|
6
9
|
|
|
7
10
|
## Project docs
|
package/homekit.js
CHANGED
|
@@ -673,37 +673,68 @@ const cameraController = new CameraController({
|
|
|
673
673
|
|
|
674
674
|
accessory.configureController(cameraController);
|
|
675
675
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
port: config.hapPort,
|
|
680
|
-
category: Categories.VIDEO_DOORBELL,
|
|
681
|
-
advertiser: hap.MDNSAdvertiser.AVAHI,
|
|
682
|
-
});
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
// Startup
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
683
679
|
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
680
|
+
let started = false;
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Put the accessory on the network and prepare the snapshot.
|
|
684
|
+
*
|
|
685
|
+
* Everything above this is in-memory construction; every side effect that
|
|
686
|
+
* reaches outside the process lives here. That split is deliberate: it lets
|
|
687
|
+
* server.js, a test, or the coverage run require this module without binding
|
|
688
|
+
* `HAP_PORT`, advertising a second accessory over Avahi alongside a live
|
|
689
|
+
* service, or spawning ffmpeg. server.js calls this from main().
|
|
690
|
+
*
|
|
691
|
+
* @returns {void}
|
|
692
|
+
*/
|
|
693
|
+
function start() {
|
|
694
|
+
if (started) {
|
|
695
|
+
logger.warn('Accessory start requested twice; ignoring', {
|
|
696
|
+
event: 'accessory-start-repeated',
|
|
697
|
+
reason: 'already-started',
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
started = true;
|
|
702
|
+
|
|
703
|
+
accessory.publish({
|
|
704
|
+
username: config.hapUsername,
|
|
705
|
+
pincode: config.hapPincode,
|
|
706
|
+
port: config.hapPort,
|
|
707
|
+
category: Categories.VIDEO_DOORBELL,
|
|
708
|
+
advertiser: hap.MDNSAdvertiser.AVAHI,
|
|
692
709
|
});
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
event: 'accessory-qr-suppressed',
|
|
697
|
-
reason: 'stdout-not-tty',
|
|
710
|
+
|
|
711
|
+
logger.info('Accessory published', {
|
|
712
|
+
event: 'accessory-published',
|
|
698
713
|
});
|
|
699
|
-
|
|
714
|
+
// The setup QR encodes the pairing pincode, so only print it on an
|
|
715
|
+
// interactive terminal — never into journald/log files.
|
|
716
|
+
if (process.stdout.isTTY) {
|
|
717
|
+
logger.info('Accessory QR setup URI generated', {
|
|
718
|
+
event: 'accessory-qr-setup',
|
|
719
|
+
});
|
|
720
|
+
qrcode.generate(accessory.setupURI(), { small: true });
|
|
721
|
+
} else {
|
|
722
|
+
logger.info(
|
|
723
|
+
'Pairing QR suppressed on non-interactive stdout; reprint it with buzz-me-in --qr',
|
|
724
|
+
{
|
|
725
|
+
event: 'accessory-qr-suppressed',
|
|
726
|
+
reason: 'stdout-not-tty',
|
|
727
|
+
}
|
|
728
|
+
);
|
|
729
|
+
}
|
|
700
730
|
|
|
701
|
-
// Kick off snapshot generation asynchronously (non-blocking)
|
|
702
|
-
initSnapshot().then(() =>
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
);
|
|
731
|
+
// Kick off snapshot generation asynchronously (non-blocking)
|
|
732
|
+
initSnapshot().then(() =>
|
|
733
|
+
logger.info('Snapshot initialized', {
|
|
734
|
+
event: 'snapshot-ready',
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
}
|
|
707
738
|
|
|
708
739
|
// ---------------------------------------------------------------------------
|
|
709
740
|
// Exports called by server.js
|
|
@@ -778,6 +809,9 @@ function setOnHapSessionStarted(handler) {
|
|
|
778
809
|
function shutdown() {
|
|
779
810
|
endHapSession();
|
|
780
811
|
removeSdpDir();
|
|
812
|
+
// Nothing to tear down if start() never ran: unpublish() on an accessory
|
|
813
|
+
// that was never published has no HAP server or advertiser to stop.
|
|
814
|
+
if (!started) return;
|
|
781
815
|
accessory.unpublish().catch((error) => {
|
|
782
816
|
logger.error('Failed to unpublish HAP accessory during shutdown', {
|
|
783
817
|
event: 'accessory-unpublish-failed',
|
|
@@ -788,6 +822,7 @@ function shutdown() {
|
|
|
788
822
|
}
|
|
789
823
|
|
|
790
824
|
module.exports = {
|
|
825
|
+
start,
|
|
791
826
|
triggerDoorbell,
|
|
792
827
|
setMulawPassthrough,
|
|
793
828
|
clearMulawPassthrough,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buzz-me-in",
|
|
3
|
-
"version": "2.1.0",
|
|
3
|
+
"version": "2.1.2-beta.0",
|
|
4
4
|
"description": "HomeKit-compatible apartment intercom via Twilio media streams",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"check": "node --check server.js homekit.js twilio-api.js src/core/*.js",
|
|
23
23
|
"test": "DOTENV_CONFIG_PATH=/dev/null node --test",
|
|
24
24
|
"typecheck": "tsc --noEmit",
|
|
25
|
+
"coverage": "DOTENV_CONFIG_PATH=/dev/null node --test --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-include='server.js' --test-coverage-include='homekit.js' --test-coverage-include='twilio-api.js' --require ./tests/helpers/coverage-preload.cjs --test-reporter=lcov --test-reporter-destination=lcov.info --test-reporter=spec --test-reporter-destination=stdout",
|
|
25
26
|
"prepare": "husky || true"
|
|
26
27
|
},
|
|
27
28
|
"license": "MIT",
|
package/server.js
CHANGED
|
@@ -3,23 +3,29 @@
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
5
|
// CLI flags are handled before every other require, and moving this down breaks
|
|
6
|
-
// them: requiring ./
|
|
7
|
-
//
|
|
8
|
-
// and requiring ./
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// them: requiring ./src/core/config throws on any missing env var, which under
|
|
7
|
+
// systemd lives in EnvironmentFile and so is absent from an interactive shell,
|
|
8
|
+
// and requiring ./homekit pulls in that same config. A read-only query must
|
|
9
|
+
// touch neither. tests/cli.test.cjs pins this ordering.
|
|
10
|
+
//
|
|
11
|
+
// The require.main guard keeps process.argv out of it when this file is loaded
|
|
12
|
+
// as a library rather than run: a test or the coverage run would otherwise have
|
|
13
|
+
// the test runner's own argv parsed as intercom flags. Everything below module
|
|
14
|
+
// scope is likewise inert until main() runs.
|
|
11
15
|
//
|
|
12
16
|
// Writes are synchronous because process.exit() can truncate a pending async
|
|
13
17
|
// write to a pipe.
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
if (require.main === module) {
|
|
19
|
+
const cliFs = require('fs');
|
|
20
|
+
const cliExit = require('./src/core/cli').run({
|
|
21
|
+
argv: process.argv.slice(2),
|
|
22
|
+
write: (text) => cliFs.writeSync(1, text),
|
|
23
|
+
writeErr: (text) => cliFs.writeSync(2, text),
|
|
24
|
+
isTTY: Boolean(process.stdout.isTTY),
|
|
25
|
+
cwd: process.cwd(),
|
|
26
|
+
});
|
|
27
|
+
if (cliExit !== null) process.exit(cliExit);
|
|
28
|
+
}
|
|
23
29
|
|
|
24
30
|
const http = require('http');
|
|
25
31
|
const twilio = require('twilio');
|
|
@@ -369,12 +375,53 @@ function isAuthorizedForStatus(req) {
|
|
|
369
375
|
// Start server
|
|
370
376
|
// ---------------------------------------------------------------------------
|
|
371
377
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
378
|
+
/**
|
|
379
|
+
* Every side effect that reaches outside the process lives here: publishing the
|
|
380
|
+
* HomeKit accessory, binding the HTTP/WebSocket port, and installing the
|
|
381
|
+
* process-level signal and crash handlers. Module scope above only builds
|
|
382
|
+
* objects and registers in-process callbacks, so requiring this file from a
|
|
383
|
+
* test or the coverage run advertises nothing, binds nothing, and cannot
|
|
384
|
+
* install an uncaughtException handler that would swallow a test failure.
|
|
385
|
+
*
|
|
386
|
+
* homekit.start() comes first so the accessory is on the network before the
|
|
387
|
+
* port accepts a Twilio stream, which is the order the module-scope version
|
|
388
|
+
* produced and the order README's startup log example shows.
|
|
389
|
+
*
|
|
390
|
+
* @returns {void}
|
|
391
|
+
*/
|
|
392
|
+
function main() {
|
|
393
|
+
homekit.start();
|
|
394
|
+
|
|
395
|
+
wsserver.listen(HTTP_SERVER_PORT, () => {
|
|
396
|
+
logger.info('Server listening', {
|
|
397
|
+
event: 'server-start',
|
|
398
|
+
port: HTTP_SERVER_PORT,
|
|
399
|
+
});
|
|
376
400
|
});
|
|
377
|
-
|
|
401
|
+
|
|
402
|
+
process.on('SIGINT', () => beginShutdown('SIGINT'));
|
|
403
|
+
process.on('SIGTERM', () => beginShutdown('SIGTERM'));
|
|
404
|
+
|
|
405
|
+
// Exit on unexpected errors instead of continuing in an undefined state;
|
|
406
|
+
// systemd restarts the service.
|
|
407
|
+
process.on('uncaughtException', (err) => {
|
|
408
|
+
logger.error('Uncaught exception; exiting', {
|
|
409
|
+
event: 'uncaught-exception',
|
|
410
|
+
reason: 'uncaught-exception',
|
|
411
|
+
error: err,
|
|
412
|
+
});
|
|
413
|
+
process.exit(1);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
process.on('unhandledRejection', (reason) => {
|
|
417
|
+
logger.error('Unhandled promise rejection; exiting', {
|
|
418
|
+
event: 'unhandled-rejection',
|
|
419
|
+
reason: 'unhandled-rejection',
|
|
420
|
+
error: reason instanceof Error ? reason : new Error(String(reason)),
|
|
421
|
+
});
|
|
422
|
+
process.exit(1);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
378
425
|
|
|
379
426
|
function beginShutdown(signal) {
|
|
380
427
|
if (shuttingDown) return;
|
|
@@ -415,25 +462,15 @@ function beginShutdown(signal) {
|
|
|
415
462
|
});
|
|
416
463
|
}
|
|
417
464
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
process.on('unhandledRejection', (reason) => {
|
|
433
|
-
logger.error('Unhandled promise rejection; exiting', {
|
|
434
|
-
event: 'unhandled-rejection',
|
|
435
|
-
reason: 'unhandled-rejection',
|
|
436
|
-
error: reason instanceof Error ? reason : new Error(String(reason)),
|
|
437
|
-
});
|
|
438
|
-
process.exit(1);
|
|
439
|
-
});
|
|
465
|
+
if (require.main === module) main();
|
|
466
|
+
|
|
467
|
+
module.exports = {
|
|
468
|
+
main,
|
|
469
|
+
beginShutdown,
|
|
470
|
+
buildTwiml,
|
|
471
|
+
handleRequest,
|
|
472
|
+
isAuthorizedForStatus,
|
|
473
|
+
isValidTwilioRequest,
|
|
474
|
+
parseFormUrlEncoded,
|
|
475
|
+
readRequestBody,
|
|
476
|
+
};
|
package/src/core/cli.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Nothing here starts a server, contacts HomeKit, or signals a running
|
|
12
12
|
// instance. run() is called from server.js BEFORE any other require, because
|
|
13
|
-
// requiring ./
|
|
14
|
-
//
|
|
13
|
+
// requiring ./src/core/config throws on a missing env var and ./homekit pulls
|
|
14
|
+
// in that same config. Neither entry point acts on being required any more —
|
|
15
|
+
// server.js starts from main() and homekit.js publishes from start() — so a
|
|
16
|
+
// --qr run that reached them would no longer advertise a second accessory, but
|
|
17
|
+
// it would still die on the missing env.
|
|
15
18
|
|
|
16
19
|
const fs = require('fs');
|
|
17
20
|
const path = require('path');
|