buzz-me-in 2.1.1 → 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 CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  Apartment intercom to Apple HomeKit, bridged over Twilio.
4
4
 
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/atdr/buzz-me-in/ci.yml?style=flat-square&label=CI)](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
6
+ [![Coverage](https://img.shields.io/codecov/c/github/atdr/buzz-me-in?style=flat-square&label=coverage)](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
- 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
- });
676
+ // ---------------------------------------------------------------------------
677
+ // Startup
678
+ // ---------------------------------------------------------------------------
683
679
 
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',
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
- qrcode.generate(accessory.setupURI(), { small: true });
694
- } else {
695
- logger.info('Pairing QR suppressed on non-interactive stdout; reprint it with buzz-me-in --qr', {
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
- logger.info('Snapshot initialized', {
704
- event: 'snapshot-ready',
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.1",
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 ./homekit calls accessory.publish() at module scope, which
7
- // would put a second accessory on the network alongside the running service,
8
- // and requiring ./src/core/config throws on any missing env var, which under
9
- // systemd lives in EnvironmentFile and so is absent from an interactive shell.
10
- // A read-only query must touch neither. tests/cli.test.cjs pins this ordering.
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
- const cliFs = require('fs');
15
- const cliExit = require('./src/core/cli').run({
16
- argv: process.argv.slice(2),
17
- write: (text) => cliFs.writeSync(1, text),
18
- writeErr: (text) => cliFs.writeSync(2, text),
19
- isTTY: Boolean(process.stdout.isTTY),
20
- cwd: process.cwd(),
21
- });
22
- if (cliExit !== null) process.exit(cliExit);
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
- wsserver.listen(HTTP_SERVER_PORT, () => {
373
- logger.info('Server listening', {
374
- event: 'server-start',
375
- port: HTTP_SERVER_PORT,
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
- process.on('SIGINT', () => beginShutdown('SIGINT'));
419
- process.on('SIGTERM', () => beginShutdown('SIGTERM'));
420
-
421
- // Exit on unexpected errors instead of continuing in an undefined state;
422
- // systemd restarts the service.
423
- process.on('uncaughtException', (err) => {
424
- logger.error('Uncaught exception; exiting', {
425
- event: 'uncaught-exception',
426
- reason: 'uncaught-exception',
427
- error: err,
428
- });
429
- process.exit(1);
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 ./homekit publishes an accessory at module scope and requiring
14
- // ./src/core/config throws on a missing env var.
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');