buzz-me-in 2.0.1 → 2.1.0-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
@@ -202,6 +202,51 @@ After pairing you will see:
202
202
  - A **doorbell** tile (rings when the intercom calls)
203
203
  - A **lock** tile (tap to unlock — plays the DTMF unlock sequence, default `w9w`, configurable via `TWILIO_UNLOCK_DIGITS`)
204
204
 
205
+ #### Re-pairing later, or checking the pairing
206
+
207
+ Once the server runs under systemd its stdout is not a terminal, so it never prints the setup QR.
208
+ Two flags read the pairing state directly instead. Neither starts the server, contacts HomeKit or
209
+ signals the running service, so both are safe against a live deployment:
210
+
211
+ ```bash
212
+ buzz-me-in --check # persist directory, accessory file, and paired client count
213
+ buzz-me-in --qr # the setup QR, for Add Accessory → More options
214
+ ```
215
+
216
+ Three things to know before the QR works over SSH:
217
+
218
+ - **`ssh -t` is required.** `ssh host "command"` allocates no pty, so stdout is not a TTY and the
219
+ command refuses by design: the URI encodes the setup code and must never reach a pipe or a log.
220
+ An interactive `ssh host` session already has a pty.
221
+ - **Run it from the working directory**, the one holding `.env` and `persist/`. Pairing state is
222
+ read relative to the current directory, so elsewhere it reports finding nothing while the service
223
+ is perfectly healthy.
224
+ - **Use a dark terminal background.** The QR is drawn with block characters that take the terminal's
225
+ foreground colour for the _light_ modules, so it scans on a dark background and comes out inverted
226
+ on a light one. If your phone will not lock onto it, switch profile before assuming anything is
227
+ broken.
228
+
229
+ Putting those together, from the machine you are sitting at:
230
+
231
+ ```bash
232
+ ssh -t <pi-host> 'cd <working-directory> && buzz-me-in --qr'
233
+ ```
234
+
235
+ `--check` has no such constraints, prints no secret, and is safe to pipe or paste:
236
+
237
+ ```bash
238
+ $ buzz-me-in --check
239
+ persistDir : /home/<user>/intercom/persist
240
+ accessoryInfo : AccessoryInfo.XXXXXXXXXXXX.json
241
+ category : 18 (video doorbell)
242
+ setupID : present
243
+ pairedClients : 4
244
+ ```
245
+
246
+ `pairedClients: 0` on an accessory you believe is paired means the pairing is gone, not that the
247
+ network is broken. See [docs/testing.md](docs/testing.md) and `tests/homekit-shutdown.test.cjs` for
248
+ the failure that used to cause exactly that.
249
+
205
250
  ### 6. Install systemd units (production)
206
251
 
207
252
  Install the tunnel as a service using cloudflared's built-in installer. It copies the config to `/etc/cloudflared/config.yml`, writes its own systemd unit, and enables and starts it in one step. The explicit `--config` matters: under `sudo`, cloudflared searches root's config locations and would not find files in your home directory.
@@ -422,10 +467,11 @@ curl https://intercom.yourdomain.com/readyz
422
467
 
423
468
  ```text
424
469
  .
425
- ├── server.js # HTTP routes + WebSocket server; graceful shutdown
470
+ ├── server.js # CLI flag dispatch; HTTP routes + WebSocket server; graceful shutdown
426
471
  ├── homekit.js # HAP-NodeJS camera+doorbell accessory; ffmpeg pipelines
427
472
  ├── twilio-api.js # Twilio REST API helpers (hangup)
428
473
  ├── src/core/
474
+ │ ├── cli.js # --qr/--check/--help/--version, dispatched before any other require
429
475
  │ ├── config.js # validated env/config loading
430
476
  │ ├── log.js # structured JSON-lines logger
431
477
  │ ├── media-stream.js # Twilio media stream protocol (start/media/stop)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buzz-me-in",
3
- "version": "2.0.1",
3
+ "version": "2.1.0-beta.0",
4
4
  "description": "HomeKit-compatible apartment intercom via Twilio media streams",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -2,6 +2,25 @@
2
2
  // based on https://github.com/twilio/media-streams/blob/master/node/basic/README.md
3
3
  'use strict';
4
4
 
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.
11
+ //
12
+ // Writes are synchronous because process.exit() can truncate a pending async
13
+ // 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);
23
+
5
24
  const http = require('http');
6
25
  const twilio = require('twilio');
7
26
  const WebSocketServer = require('websocket').server;
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ // Read-only CLI queries against the pairing state in persist/.
4
+ //
5
+ // These exist because the server only prints the HomeKit setup QR when stdout
6
+ // is a TTY (homekit.js), which under systemd is never. Recovering a lost
7
+ // pairing therefore had no supported path. They also expose pairedClients,
8
+ // which is the one number that reveals an accessory has been silently
9
+ // unpaired (see tests/homekit-shutdown.test.cjs for how that used to happen).
10
+ //
11
+ // Nothing here starts a server, contacts HomeKit, or signals a running
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.
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+
19
+ const PERSIST_DIR_NAME = 'persist';
20
+ const ACCESSORY_INFO_PREFIX = 'AccessoryInfo.';
21
+
22
+ const USAGE = `buzz-me-in - HomeKit-compatible apartment intercom via Twilio media streams
23
+
24
+ Usage:
25
+ buzz-me-in Start the server
26
+ buzz-me-in --qr Print the HomeKit setup QR for the accessory (terminal only)
27
+ buzz-me-in --check Report pairing state: persist directory, accessory, paired clients
28
+ buzz-me-in --help Show this message
29
+ buzz-me-in --version Print the version
30
+
31
+ --qr and --check read ${PERSIST_DIR_NAME}/ under the current working directory. They do
32
+ not start the server, contact HomeKit, or signal a running instance, so they
33
+ are safe to run against a live deployment.
34
+
35
+ --qr requires a terminal: the URI it encodes contains the setup code, so it is
36
+ refused on a pipe or a non-interactive SSH command. Use 'ssh -t'.
37
+ `;
38
+
39
+ /**
40
+ * Absolute path of the directory HAP-NodeJS persists pairing state into.
41
+ *
42
+ * HAP-NodeJS never calls HAPStorage.setCustomStoragePath, so HAPStorage calls
43
+ * node-persist's initSync() with no argument. That skips setOptions(), which is
44
+ * the only place a relative dir is made absolute, so the option stays the
45
+ * literal string 'persist' from node-persist's defaults and every fs call
46
+ * resolves it against process.cwd(). Hence <cwd>/persist, which under systemd
47
+ * is <WorkingDirectory>/persist. tests/cli.test.cjs pins that default so a
48
+ * node-persist upgrade cannot move it silently.
49
+ *
50
+ * @param {string} cwd
51
+ * @returns {string}
52
+ */
53
+ function resolvePersistDir(cwd) {
54
+ return path.resolve(cwd, PERSIST_DIR_NAME);
55
+ }
56
+
57
+ /**
58
+ * @typedef {object} AccessoryInfoFile
59
+ * @property {string} file Basename of the AccessoryInfo JSON.
60
+ * @property {string[]} allFiles Every AccessoryInfo JSON found, sorted.
61
+ * @property {any} info Parsed contents.
62
+ */
63
+
64
+ /**
65
+ * Locate and parse the persisted AccessoryInfo.
66
+ *
67
+ * @param {string} persistDir
68
+ * @returns {AccessoryInfoFile}
69
+ * @throws {Error & { code: string }} with code 'PERSIST_MISSING' or 'NO_ACCESSORY_INFO'.
70
+ */
71
+ function readAccessoryInfo(persistDir) {
72
+ /** @param {string} code @param {string} message */
73
+ const fail = (code, message) => {
74
+ const error = /** @type {Error & { code: string }} */ (new Error(message));
75
+ error.code = code;
76
+ throw error;
77
+ };
78
+
79
+ let entries;
80
+ try {
81
+ entries = fs.readdirSync(persistDir);
82
+ } catch {
83
+ fail('PERSIST_MISSING', `No pairing directory at ${persistDir}`);
84
+ throw new Error('unreachable');
85
+ }
86
+
87
+ const allFiles = entries.filter((name) => name.startsWith(ACCESSORY_INFO_PREFIX)).sort();
88
+ if (allFiles.length === 0) {
89
+ fail('NO_ACCESSORY_INFO', `No ${ACCESSORY_INFO_PREFIX}*.json in ${persistDir}`);
90
+ }
91
+
92
+ return {
93
+ file: allFiles[0],
94
+ allFiles,
95
+ info: JSON.parse(fs.readFileSync(path.join(persistDir, allFiles[0]), 'utf8')),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Build the X-HM:// setup URI for a persisted accessory.
101
+ *
102
+ * This delegates to HAP-NodeJS rather than reimplementing the encoding. The
103
+ * upstream zero-padding loop re-reads the string's length as it grows
104
+ * (`for (i = 0; i <= 9 - encodedPayload.length; i++)`), so it converges on nine
105
+ * characters from a seven-character payload but eight from a five-character
106
+ * one. Any local reimplementation therefore agrees only for some categories,
107
+ * and a QR that differs by one character scans to nothing.
108
+ *
109
+ * Accessory.prototype.setupURI reads only `_accessoryInfo.pincode`,
110
+ * `_accessoryInfo.category` and `_setupID`, so a plain object stands in for a
111
+ * published accessory: no HAP server, no mDNS advertiser, no storage. That is
112
+ * a private API, pinned by tests/cli.test.cjs so a rename fails there rather
113
+ * than producing an unscannable code here.
114
+ *
115
+ * @param {{ pincode: string, category: number, setupID: string }} info
116
+ * @returns {string}
117
+ */
118
+ function computeSetupUri(info) {
119
+ const { Accessory } = require('hap-nodejs');
120
+ /** @type {any} */
121
+ const publishedAccessoryStub = {
122
+ _accessoryInfo: { pincode: info.pincode, category: info.category },
123
+ _setupID: info.setupID,
124
+ };
125
+ return Accessory.prototype.setupURI.call(publishedAccessoryStub);
126
+ }
127
+
128
+ /**
129
+ * @param {any} info
130
+ * @returns {number}
131
+ */
132
+ function countPairedClients(info) {
133
+ return Object.keys(info?.pairedClients || {}).length;
134
+ }
135
+
136
+ /** @returns {string} */
137
+ function readVersion() {
138
+ const packageJson = path.join(__dirname, '..', '..', 'package.json');
139
+ return JSON.parse(fs.readFileSync(packageJson, 'utf8')).version;
140
+ }
141
+
142
+ /**
143
+ * @param {Error & { code?: string }} error
144
+ * @returns {string}
145
+ */
146
+ function explainLookupFailure(error) {
147
+ if (error.code !== 'PERSIST_MISSING' && error.code !== 'NO_ACCESSORY_INFO') throw error;
148
+ return (
149
+ `${error.message}\n` +
150
+ `\nPairing state lives in ./${PERSIST_DIR_NAME} under the server's working directory, so run\n` +
151
+ `this from there. On a systemd host:\n` +
152
+ `\n cd "$(systemctl show intercom -p WorkingDirectory --value)" && buzz-me-in\n`
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Handle a CLI invocation.
158
+ *
159
+ * @param {object} options
160
+ * @param {string[]} options.argv Arguments after the executable and script.
161
+ * @param {(text: string) => void} options.write stdout sink.
162
+ * @param {(text: string) => void} options.writeErr stderr sink.
163
+ * @param {boolean} options.isTTY Whether stdout is an interactive terminal.
164
+ * @param {string} options.cwd Directory to resolve persist/ against.
165
+ * @returns {number | null} An exit code, or null meaning "start the server".
166
+ */
167
+ function run({ argv, write, writeErr, isTTY, cwd }) {
168
+ if (argv.length === 0) return null;
169
+
170
+ const flag = argv[0];
171
+
172
+ if (flag === '--help' || flag === '-h') {
173
+ write(USAGE);
174
+ return 0;
175
+ }
176
+
177
+ if (flag === '--version' || flag === '-v') {
178
+ write(`${readVersion()}\n`);
179
+ return 0;
180
+ }
181
+
182
+ if (flag !== '--qr' && flag !== '--check') {
183
+ writeErr(`Unknown option: ${flag}\n\n${USAGE}`);
184
+ return 2;
185
+ }
186
+
187
+ // Checked before reading anything, so a piped --qr cannot be told whether an
188
+ // accessory exists either.
189
+ if (flag === '--qr' && !isTTY) {
190
+ writeErr(
191
+ 'Refusing to print the pairing QR to a non-TTY: it encodes the setup code.\n' +
192
+ "Run it from a terminal, e.g. ssh -t <host> 'cd <working-directory> && buzz-me-in --qr'\n"
193
+ );
194
+ return 1;
195
+ }
196
+
197
+ const persistDir = resolvePersistDir(cwd);
198
+
199
+ let accessory;
200
+ try {
201
+ accessory = readAccessoryInfo(persistDir);
202
+ } catch (error) {
203
+ writeErr(explainLookupFailure(/** @type {Error & { code?: string }} */ (error)));
204
+ return 1;
205
+ }
206
+
207
+ const { file, allFiles, info } = accessory;
208
+ const pairedClients = countPairedClients(info);
209
+
210
+ if (flag === '--check') {
211
+ write(
212
+ `persistDir : ${persistDir}\n` +
213
+ `accessoryInfo : ${allFiles.join(', ')}\n` +
214
+ `category : ${info.category}${info.category === 18 ? ' (video doorbell)' : ''}\n` +
215
+ `setupID : ${info.setupID ? 'present' : 'MISSING'}\n` +
216
+ `pairedClients : ${pairedClients}\n`
217
+ );
218
+ return 0;
219
+ }
220
+
221
+ // Two accessories means two pincodes, and printing the wrong one sends the
222
+ // user round a pairing loop with no indication why. HAP_USERNAME in .env
223
+ // decides which is live, and this command deliberately does not read .env.
224
+ if (allFiles.length > 1) {
225
+ writeErr(
226
+ `Found ${allFiles.length} accessories in ${persistDir}:\n` +
227
+ allFiles.map((name) => ` ${name}\n`).join('') +
228
+ '\nRefusing to guess which one is live. HAP_USERNAME in .env decides;\n' +
229
+ 'move the stale files aside and run --qr again.\n'
230
+ );
231
+ return 1;
232
+ }
233
+
234
+ if (!info.setupID || !info.pincode) {
235
+ writeErr(`${file} has no setupID or pincode; it cannot produce a setup QR.\n`);
236
+ return 1;
237
+ }
238
+
239
+ const uri = computeSetupUri(info);
240
+ const qrcode = require('qrcode-terminal');
241
+
242
+ write('\n Home app -> Add Accessory -> More options -> scan this:\n\n');
243
+ qrcode.generate(uri, { small: true }, (/** @type {string} */ rendered) => write(rendered));
244
+ write(`\n Paired clients right now: ${pairedClients}\n\n`);
245
+ return 0;
246
+ }
247
+
248
+ module.exports = {
249
+ PERSIST_DIR_NAME,
250
+ USAGE,
251
+ computeSetupUri,
252
+ countPairedClients,
253
+ readAccessoryInfo,
254
+ resolvePersistDir,
255
+ run,
256
+ };