buzz-me-in 2.2.0-beta.4 → 2.2.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/.env.example +12 -0
- package/README.md +6 -2
- package/package.json +13 -2
- package/server.js +37 -0
- package/src/core/config.js +45 -0
package/.env.example
CHANGED
|
@@ -66,6 +66,18 @@ SHUTDOWN_GRACE_MS=10000
|
|
|
66
66
|
# unlock bothers you.
|
|
67
67
|
HOMEKIT_HANGUP_GRACE_MS=8000
|
|
68
68
|
|
|
69
|
+
# Optional labels for numbers you expect to call in, so the journal says who
|
|
70
|
+
# rang rather than only a number. The intercom panel is normally the only
|
|
71
|
+
# caller; anything else is a wrong number or a dialler that found the number.
|
|
72
|
+
# Comma-separated <E.164>=<label> pairs. Labels may contain spaces, up to 64
|
|
73
|
+
# characters, and each number may appear once.
|
|
74
|
+
# Purely descriptive: an unlabelled caller is logged as "unknown" and still
|
|
75
|
+
# rings the doorbell. Nothing is ever rejected on the strength of this, because
|
|
76
|
+
# the building's dialler could change without warning and a silently blocked
|
|
77
|
+
# visitor is much worse than a labelled unknown one.
|
|
78
|
+
# Leave unset to omit the caller field entirely.
|
|
79
|
+
#KNOWN_CALLERS=+15551234567=Intercom,+15557654321=Building management
|
|
80
|
+
|
|
69
81
|
# Logging
|
|
70
82
|
# LOG_LEVEL controls minimum emitted level: debug | info | warn | error
|
|
71
83
|
# Default is info when unset/invalid.
|
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# buzz-me-in
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/buzz-me-in)
|
|
4
|
+
[](https://www.npmjs.com/package/buzz-me-in/v/beta)
|
|
5
|
+
[](https://www.npmjs.com/package/buzz-me-in)
|
|
6
|
+
[](https://github.com/atdr/buzz-me-in)
|
|
5
7
|
[](https://github.com/atdr/buzz-me-in/actions/workflows/ci.yml)
|
|
6
8
|
[](https://codecov.io/gh/atdr/buzz-me-in)
|
|
7
9
|
|
|
10
|
+
Apartment intercom to Apple HomeKit, bridged over Twilio.
|
|
11
|
+
|
|
8
12
|
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.
|
|
9
13
|
|
|
10
14
|
## Project docs
|
package/package.json
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buzz-me-in",
|
|
3
|
-
"version": "2.2.0
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "HomeKit-compatible apartment intercom via Twilio media streams",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"homekit",
|
|
7
|
+
"intercom",
|
|
8
|
+
"twilio",
|
|
9
|
+
"doorbell",
|
|
10
|
+
"smart-home",
|
|
11
|
+
"raspberry-pi",
|
|
12
|
+
"hap-nodejs",
|
|
13
|
+
"ffmpeg",
|
|
14
|
+
"nodejs"
|
|
15
|
+
],
|
|
5
16
|
"main": "server.js",
|
|
6
17
|
"bin": {
|
|
7
18
|
"buzz-me-in": "./server.js"
|
|
@@ -42,7 +53,7 @@
|
|
|
42
53
|
"node": ">=20"
|
|
43
54
|
},
|
|
44
55
|
"dependencies": {
|
|
45
|
-
"dotenv": "^
|
|
56
|
+
"dotenv": "^18.0.1",
|
|
46
57
|
"hap-nodejs": "^0.14.2",
|
|
47
58
|
"qrcode-terminal": "^0.12.0",
|
|
48
59
|
"twilio": "^6.1.0",
|
package/server.js
CHANGED
|
@@ -165,6 +165,33 @@ function buildTwiml(callSid) {
|
|
|
165
165
|
</Response>`;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* A repeated form field parses to an array, so take the first value. A sender
|
|
170
|
+
* can repeat From freely; the signature covers the whole body, not its shape.
|
|
171
|
+
*
|
|
172
|
+
* @param {string | string[] | undefined} value
|
|
173
|
+
* @returns {string | undefined}
|
|
174
|
+
*/
|
|
175
|
+
function firstFormValue(value) {
|
|
176
|
+
if (Array.isArray(value)) return value.length ? value[0] : undefined;
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Label for a calling number, from KNOWN_CALLERS. Returns 'unknown' for a
|
|
182
|
+
* number we have no label for, and null when nothing was configured, so the
|
|
183
|
+
* field is absent rather than misleadingly saying 'unknown' on every call.
|
|
184
|
+
*
|
|
185
|
+
* @param {string | string[] | undefined} from
|
|
186
|
+
* @returns {string | null}
|
|
187
|
+
*/
|
|
188
|
+
function describeCaller(from) {
|
|
189
|
+
if (config.knownCallers.size === 0) return null;
|
|
190
|
+
const number = firstFormValue(from);
|
|
191
|
+
if (!number) return 'unknown';
|
|
192
|
+
return config.knownCallers.get(number.trim()) || 'unknown';
|
|
193
|
+
}
|
|
194
|
+
|
|
168
195
|
async function handleTwimlRequest(req, res) {
|
|
169
196
|
twimlLogger.info('Incoming TwiML request', {
|
|
170
197
|
event: 'twiml-request',
|
|
@@ -200,9 +227,18 @@ async function handleTwimlRequest(req, res) {
|
|
|
200
227
|
const formData = parseFormUrlEncoded(rawBody);
|
|
201
228
|
const callSid = normalizeCallSid(formData.CallSid);
|
|
202
229
|
const body = buildTwiml(callSid);
|
|
230
|
+
// Who rang. Logged here rather than on twiml-request, which fires before the
|
|
231
|
+
// body is read and before the signature is checked: an unverified From is
|
|
232
|
+
// whatever the sender typed. forwardedFrom is logged as Twilio reports it,
|
|
233
|
+
// but carriers fill it inconsistently: on EE it is simply the dialled number
|
|
234
|
+
// on every call, so it cannot tell a diverted call from a direct one.
|
|
235
|
+
const caller = describeCaller(formData.From);
|
|
203
236
|
twimlLogger.info('TwiML response generated', {
|
|
204
237
|
event: 'twiml-response',
|
|
205
238
|
callSid: callSid || undefined,
|
|
239
|
+
from: firstFormValue(formData.From) || undefined,
|
|
240
|
+
forwardedFrom: firstFormValue(formData.ForwardedFrom) || undefined,
|
|
241
|
+
caller: caller || undefined,
|
|
206
242
|
});
|
|
207
243
|
res.writeHead(200, {
|
|
208
244
|
'Content-Type': 'text/xml',
|
|
@@ -506,6 +542,7 @@ module.exports = {
|
|
|
506
542
|
main,
|
|
507
543
|
beginShutdown,
|
|
508
544
|
buildTwiml,
|
|
545
|
+
describeCaller,
|
|
509
546
|
handleRequest,
|
|
510
547
|
isAuthorizedForStatus,
|
|
511
548
|
isValidTwilioRequest,
|
package/src/core/config.js
CHANGED
|
@@ -96,6 +96,50 @@ if (!MEDIA_SIGNATURE_MODES.includes(twilioMediaSignatureMode)) {
|
|
|
96
96
|
fail(`TWILIO_MEDIA_SIGNATURE_MODE must be one of: ${MEDIA_SIGNATURE_MODES.join(', ')}`);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// Optional labels for numbers we expect to see calling in, so the journal says
|
|
100
|
+
// who rang rather than only a number. The intercom panel is the only caller
|
|
101
|
+
// that should normally appear; anything else is a wrong number or a dialler
|
|
102
|
+
// finding an open PSTN number, and being able to tell them apart at a glance is
|
|
103
|
+
// the point. Purely descriptive: nothing is rejected on the strength of it, and
|
|
104
|
+
// an unknown caller still rings the doorbell, because the building's dialler
|
|
105
|
+
// could change without warning and a silently blocked visitor is far worse
|
|
106
|
+
// than a labelled unknown one.
|
|
107
|
+
//
|
|
108
|
+
// Format: comma-separated E.164=Label pairs, e.g.
|
|
109
|
+
// KNOWN_CALLERS=+15551234567=Intercom,+15557654321=Building management
|
|
110
|
+
const E164 = /^\+[1-9]\d{1,14}$/;
|
|
111
|
+
|
|
112
|
+
function parseKnownCallers(name) {
|
|
113
|
+
const raw = optional(name);
|
|
114
|
+
const callers = new Map();
|
|
115
|
+
if (!raw) return callers;
|
|
116
|
+
|
|
117
|
+
for (const entry of raw.split(',')) {
|
|
118
|
+
const trimmed = entry.trim();
|
|
119
|
+
if (!trimmed) continue;
|
|
120
|
+
const separator = trimmed.indexOf('=');
|
|
121
|
+
if (separator === -1) {
|
|
122
|
+
fail(`${name} entries must be <E.164>=<label>, got "${trimmed}"`);
|
|
123
|
+
}
|
|
124
|
+
const number = trimmed.slice(0, separator).trim();
|
|
125
|
+
const label = trimmed.slice(separator + 1).trim();
|
|
126
|
+
if (!E164.test(number)) {
|
|
127
|
+
fail(`${name} entry "${trimmed}" does not start with an E.164 number`);
|
|
128
|
+
}
|
|
129
|
+
if (!label) {
|
|
130
|
+
fail(`${name} entry "${trimmed}" has an empty label`);
|
|
131
|
+
}
|
|
132
|
+
if (label.length > 64) {
|
|
133
|
+
fail(`${name} label for ${number} is longer than 64 characters`);
|
|
134
|
+
}
|
|
135
|
+
if (callers.has(number)) {
|
|
136
|
+
fail(`${name} lists ${number} more than once`);
|
|
137
|
+
}
|
|
138
|
+
callers.set(number, label);
|
|
139
|
+
}
|
|
140
|
+
return callers;
|
|
141
|
+
}
|
|
142
|
+
|
|
99
143
|
const twilioWebhookBaseUrl = sanitizeBaseUrl(
|
|
100
144
|
optional('TWILIO_WEBHOOK_BASE_URL') || `https://${tunnelHostname}`
|
|
101
145
|
);
|
|
@@ -139,6 +183,7 @@ module.exports = {
|
|
|
139
183
|
twilioMediaPayloadMaxBytes: parsePositiveInt('TWILIO_MEDIA_PAYLOAD_MAX_BYTES', '512'),
|
|
140
184
|
shutdownGraceMs: parsePositiveInt('SHUTDOWN_GRACE_MS', '10000'),
|
|
141
185
|
homekitHangupGraceMs: parsePositiveInt('HOMEKIT_HANGUP_GRACE_MS', '8000'),
|
|
186
|
+
knownCallers: parseKnownCallers('KNOWN_CALLERS'),
|
|
142
187
|
streamAuthSecret,
|
|
143
188
|
streamAuthTtlSec: 90,
|
|
144
189
|
statusApiToken,
|