buzz-me-in 2.0.0 → 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 +47 -1
- package/homekit.js +12 -4
- package/package.json +1 -1
- package/server.js +19 -0
- package/src/core/cli.js +256 -0
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/homekit.js
CHANGED
|
@@ -766,14 +766,22 @@ function setOnHapSessionStarted(handler) {
|
|
|
766
766
|
/**
|
|
767
767
|
* Graceful shutdown: tear down streaming sessions and unpublish the
|
|
768
768
|
* accessory so the mDNS advertisement does not linger after exit.
|
|
769
|
+
*
|
|
770
|
+
* MUST be unpublish(), never destroy(). Both tear down the HAP server and the
|
|
771
|
+
* mDNS advertiser, but destroy() additionally calls
|
|
772
|
+
* Accessory.cleanupAccessoryData(), which deletes AccessoryInfo,
|
|
773
|
+
* IdentifierCache and ControllerStorage from persist/ — i.e. it erases every
|
|
774
|
+
* HomeKit pairing. destroy() means "this accessory is gone for good"; a
|
|
775
|
+
* restart is not that. Using it here silently unpaired the accessory on every
|
|
776
|
+
* clean stop, restart and reboot.
|
|
769
777
|
*/
|
|
770
778
|
function shutdown() {
|
|
771
779
|
endHapSession();
|
|
772
780
|
removeSdpDir();
|
|
773
|
-
accessory.
|
|
774
|
-
logger.error('Failed to
|
|
775
|
-
event: 'accessory-
|
|
776
|
-
reason: '
|
|
781
|
+
accessory.unpublish().catch((error) => {
|
|
782
|
+
logger.error('Failed to unpublish HAP accessory during shutdown', {
|
|
783
|
+
event: 'accessory-unpublish-failed',
|
|
784
|
+
reason: 'unpublish-threw',
|
|
777
785
|
error,
|
|
778
786
|
});
|
|
779
787
|
});
|
package/package.json
CHANGED
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;
|
package/src/core/cli.js
ADDED
|
@@ -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
|
+
};
|