homebridge-ecobee-security 0.9.1 → 1.0.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/CHANGELOG.md +19 -0
- package/README.md +64 -5
- package/assets/quick-responses/LICENSE +19 -0
- package/assets/quick-responses/hang-on.wav +0 -0
- package/assets/quick-responses/leave-delivery.wav +0 -0
- package/assets/quick-responses/not-home.wav +0 -0
- package/config.schema.json +73 -0
- package/homebridge-ui/public/index.html +311 -3
- package/homebridge-ui/server.js +48 -1
- package/lib/camera/camera.js +64 -2
- package/lib/camera/ring.js +77 -0
- package/lib/camera/talk.js +71 -12
- package/lib/platform.js +62 -2
- package/lib/quick-responses.js +565 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
- New setting **Quick Responses** (`quickResponses`, `quickResponseMessages`) in the Doorbell camera card:
|
|
6
|
+
short messages the doorbell plays on its speaker. They appear as one "Quick Responses" tile in the Home app
|
|
7
|
+
with a switch per message (open the tile to pick one; tapping the tile itself plays nothing), which you can
|
|
8
|
+
add to the live view's Nearby Accessories or use from Siri and automations. New setting
|
|
9
|
+
`quickResponseVolume` (default 40%) sets how loud they play. Three come built in (Leave
|
|
10
|
+
Delivery, Hang On, Not Home), each with a Preview button in the settings; remove the ones you don't want
|
|
11
|
+
(and add them back). With Piper installed you can also reword them and add your own.
|
|
12
|
+
The doorbell needs to accept audio from the plugin, as for two-way talk, but two-way talk doesn't have to be
|
|
13
|
+
on. If the doorbell connection is down, a message waits up to 20 seconds for it to come back; it doesn't
|
|
14
|
+
play while another message is playing or someone is talking in a live view, and the log says why.
|
|
15
|
+
- The built-in messages come as recordings made with Piper's ryan voice (RyanSpeech, CC BY-NC-SA 4.0; see
|
|
16
|
+
`assets/quick-responses/LICENSE`). Changed or new wording needs [Piper](https://github.com/OHF-Voice/piper1-gpl)
|
|
17
|
+
on the Homebridge machine (`piperPath`, `piperVoice`; see "Custom wording with Piper" in the README).
|
|
18
|
+
- Two-way talk is no longer a beta.
|
|
19
|
+
- New **Doorbell Pressed** occupancy sensor on the video doorbell (with the doorbell link): Detected for 10
|
|
20
|
+
seconds after each press of any kind, for automations. It shows No Response while the link is down.
|
|
21
|
+
|
|
3
22
|
## 0.9.1
|
|
4
23
|
|
|
5
24
|
- New setting **Add Comfort Setting switches** (`comfortSetting`, `comfortHoldType`): a Comfort Setting
|
package/README.md
CHANGED
|
@@ -38,6 +38,7 @@ GraphQL at `federated-api.prod.federated-graph.generacapis.com`), which ecobee c
|
|
|
38
38
|
- **Vacation Mode** switch that follows, and starts, an ecobee vacation.
|
|
39
39
|
- **Security holds** let other plugins, like a robot vacuum, keep the system from arming Away while they work.
|
|
40
40
|
- **Doorbell camera** with HomeKit Secure Video, live view and snapshots; with the **doorbell link**, instant ring and motion, the sensors the doorbell bridges to HomeKit, and its Chime, Status Light and Night Vision switches.
|
|
41
|
+
- **Quick Responses**: short messages the doorbell plays on its speaker ("Leave Delivery", "Hang On", "Not Home"), one Quick Responses tile in the Home app, plus a **Doorbell Pressed** sensor for automations.
|
|
41
42
|
|
|
42
43
|
## Contents
|
|
43
44
|
|
|
@@ -49,6 +50,7 @@ GraphQL at `federated-api.prod.federated-graph.generacapis.com`), which ecobee c
|
|
|
49
50
|
- [Keypad unlock](#keypad-unlock)
|
|
50
51
|
- [Security holds](#security-holds)
|
|
51
52
|
- [Doorbell camera](#doorbell-camera)
|
|
53
|
+
- [Quick Responses](#quick-responses)
|
|
52
54
|
- [Home Occupied switch](#home-occupied-switch)
|
|
53
55
|
- [Vacation Mode switch](#vacation-mode-switch)
|
|
54
56
|
- [Smoke alarm and air quality](#smoke-alarm-and-air-quality)
|
|
@@ -150,8 +152,12 @@ Sign-in: see [Sign-in](#sign-in).
|
|
|
150
152
|
| `doorbellCamera` | `false` | Adds the doorbell camera (beta). Restart the bridge after changing it. |
|
|
151
153
|
| `motionGrace` | `15` | Seconds the motion sensor stays on after the doorbell's motion ends (6 to 120). |
|
|
152
154
|
| `ffmpegPath` | `/usr/local/bin/ffmpeg` if it exists, else `ffmpeg` | The ffmpeg to use. |
|
|
153
|
-
| `twoWayAudio` | `false` | Talk through the doorbell from the Home app's live view (
|
|
155
|
+
| `twoWayAudio` | `false` | Talk through the doorbell from the Home app's live view (see [Two-way talk](#two-way-talk)). |
|
|
154
156
|
| `twoWayAudioTest` | `false` | With `twoWayAudio`: plays a 3-second tone on the doorbell when a live view starts (config file only). |
|
|
157
|
+
| `quickResponses` | `false` | Adds a switch per message that plays it on the doorbell (see [Quick Responses](#quick-responses)). Restart the bridge after changing it. |
|
|
158
|
+
| `quickResponseVolume` | `40` | How loud messages play at the door, in percent (5 to 100). Doesn't change two-way talk. |
|
|
159
|
+
| `quickResponseMessages` | the three built-ins | The messages: a list of `{ "id", "name", "phrase" }`; a built-in with `"enabled": false` is removed but keeps its wording. `id` never changes (it is the switch's identity in the Home app), `name` is the tile name (14 characters at most), `phrase` is what is said. |
|
|
160
|
+
| `piperPath`, `piperVoice` | none | Piper and its voice, only for changed or new wording (see [Custom wording with Piper](#custom-wording-with-piper)). |
|
|
155
161
|
| `debugRecordings` | `false` | Keeps the last five recordings for troubleshooting (config file only; see [Troubleshooting](#troubleshooting)). |
|
|
156
162
|
|
|
157
163
|
### Switches and sensors settings
|
|
@@ -348,7 +354,8 @@ the Home app. You get:
|
|
|
348
354
|
occupancy and battery state;
|
|
349
355
|
- the doorbell's own switches: **Chime Doorbell**, **Camera Status Light** and **Night Vision Light**.
|
|
350
356
|
Changes in the Home app are sent to the doorbell, and changes made at the doorbell show up in the Home
|
|
351
|
-
app. While the link is down, setting one fails
|
|
357
|
+
app. While the link is down, setting one fails;
|
|
358
|
+
- a **Doorbell Pressed** sensor (see below).
|
|
352
359
|
|
|
353
360
|
The pairing is kept in `ecobee-security/doorbell-pairing.json` in the Homebridge folder. **Unlink** in the
|
|
354
361
|
settings removes it from the doorbell as well (restart the bridge afterwards). If the link drops, the
|
|
@@ -356,6 +363,13 @@ plugin looks for the doorbell again and reconnects by itself; the ring, motion,
|
|
|
356
363
|
sensors show **No Response** while it has been down for a minute. The video still comes from the
|
|
357
364
|
cloud session (the doorbell's own stream isn't used).
|
|
358
365
|
|
|
366
|
+
### Doorbell Pressed sensor
|
|
367
|
+
|
|
368
|
+
With the doorbell link, the video doorbell also has an occupancy sensor named **Doorbell Pressed**. Each press
|
|
369
|
+
(single, double or long) sets it to Detected for 10 seconds; another press starts the 10 seconds again.
|
|
370
|
+
The Home app can't start an automation from a video doorbell's ring, but it can from a sensor. It shows No
|
|
371
|
+
Response while the doorbell link is down.
|
|
372
|
+
|
|
359
373
|
### HomeKit Secure Video
|
|
360
374
|
|
|
361
375
|
The camera records for HomeKit Secure Video (a home hub and an iCloud+ plan are needed, as for any
|
|
@@ -378,11 +392,10 @@ person, animal and package detection, are then made by HomeKit.
|
|
|
378
392
|
|
|
379
393
|
To inspect recordings, see [Troubleshooting](#troubleshooting).
|
|
380
394
|
|
|
381
|
-
### Two-way talk
|
|
395
|
+
### Two-way talk
|
|
382
396
|
|
|
383
397
|
Turn on **Two-way talk** (`twoWayAudio`) and restart the bridge to talk through the doorbell from the Home app's
|
|
384
|
-
live view (the microphone button).
|
|
385
|
-
every firmware, and talking adds a little delay. The log says at start-up what the doorbell answered ("two-way
|
|
398
|
+
live view (the microphone button). Whether the doorbell accepts audio from the plugin can depend on its firmware, and talking adds a little delay. The log says at start-up what the doorbell answered ("two-way
|
|
386
399
|
talk: the camera answered audio sendrecv" is good; anything else means it can't take audio and the plugin carries
|
|
387
400
|
on receive-only, as before). The live view's picture and sound are not affected.
|
|
388
401
|
|
|
@@ -390,6 +403,52 @@ To check that audio reaches the doorbell, set `"twoWayAudioTest": true` in the c
|
|
|
390
403
|
switch), open the live view while standing at the door, and listen for a 3-second tone; the log says "test tone
|
|
391
404
|
sent". Turn it off afterwards.
|
|
392
405
|
|
|
406
|
+
## Quick Responses
|
|
407
|
+
|
|
408
|
+
Quick Responses are short messages the doorbell plays on its speaker, for the person at your door: "You can just leave it", "We'll be right there". Turn on **Quick Responses** (`quickResponses`, in the **Doorbell camera** card, which needs the doorbell camera) and restart the bridge. It needs the doorbell to accept audio from the plugin, the same as [Two-way talk](#two-way-talk) (the log says what it answered), but **Two-way talk** itself doesn't have to be on.
|
|
409
|
+
|
|
410
|
+
There are three built-in messages:
|
|
411
|
+
|
|
412
|
+
| Tile | Says |
|
|
413
|
+
|---|---|
|
|
414
|
+
| Leave Delivery | "Hi there. You can just leave it. Thanks!" |
|
|
415
|
+
| Hang On | "Just a sec, we'll be right there." |
|
|
416
|
+
| Not Home | "Hi there, no one can answer the door right now, but we'll be notified that you stopped by." |
|
|
417
|
+
|
|
418
|
+
The messages appear in the Home app as **one tile, "Quick Responses"**, which holds a switch per message. Tapping the tile itself turns all of them on at once, which the plugin recognises and ignores (it plays nothing and the switches turn back off; the log says so). **Open the tile** (long-press, or tap its settings) and pick one message instead. If you prefer a tile per message, turn on **Show as Separate Tiles** in the accessory's settings in the Home app. In the doorbell's live view, the group appears as one tile in **Nearby Accessories**: add it there and tap the message you want. Turning a message's switch on plays it and it turns itself off when the message ends. Siri works too ("Hey Siri, turn on Not Home"), and so do automations and scenes. Messages play at the volume you set (`quickResponseVolume`, 40% by default).
|
|
419
|
+
|
|
420
|
+
In the settings, each message is a row: the **title** (the tile name, 14 characters at most), **what is said**, and **Preview**, which plays it in your browser. Without Piper, the three built-in messages are fixed: the rows show their title and wording as text, and you can preview them. Once [Piper](#custom-wording-with-piper) is installed, each built-in row has **Replace** (edit its title and wording; **Restore** goes back to the shipped wording and audio) and **Add a message** adds your own, with a remove button. The built-in messages can be removed (×) and added back from the line under the rows, with or without Piper; a removed message's switch leaves the Home app after a bridge restart, and returns with the same identity when added back. Changing a title or the wording takes effect after a bridge restart.
|
|
421
|
+
|
|
422
|
+
An example automation: when someone rings and nobody is home, tell them so.
|
|
423
|
+
|
|
424
|
+
1. **Trigger:** An Accessory is Controlled > **Doorbell Pressed** detects occupancy ([Doorbell Pressed sensor](#doorbell-pressed-sensor)).
|
|
425
|
+
2. **Condition:** only if **Home Occupied** is off ([Home Occupied switch](#home-occupied-switch)).
|
|
426
|
+
3. **Action:** turn on **Not Home**.
|
|
427
|
+
|
|
428
|
+
How it works: the message is played into the plugin's session with the doorbell, so it can't play while the session is down, while the camera hasn't accepted audio from us, while another message is playing, or while someone is talking in a live view (the microphone has priority; a message that is playing stops when someone starts talking). The switch then turns itself back off after a second, and the log says why. One message plays at a time. The log line "Quick Response "<name>" played" shows how many audio packets were sent. Each switch's identity comes from the message's `id`, not its title or wording, so you can rename a message without breaking the automations that use it. Messages you remove from the settings disappear from the Home app after a restart.
|
|
429
|
+
|
|
430
|
+
### Custom wording with Piper
|
|
431
|
+
|
|
432
|
+
The three built-in messages come as recordings with the plugin. Anything else, meaning reworded built-ins and your own messages, is turned into speech on your Homebridge machine with [Piper](https://github.com/OHF-Voice/piper1-gpl), a local text-to-speech program that doesn't send your text anywhere. That is optional: without Piper the built-ins work as they are, and a message that needs it is left out, with a warning in the log.
|
|
433
|
+
|
|
434
|
+
1. Install Piper into a folder the Homebridge user can read, for example a Python virtual environment:
|
|
435
|
+
|
|
436
|
+
```sh
|
|
437
|
+
sudo -u homebridge python3 -m venv /var/lib/homebridge/piper
|
|
438
|
+
sudo -u homebridge /var/lib/homebridge/piper/bin/pip install piper-tts
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
2. Download a voice: an `.onnx` file and its `.onnx.json` file, side by side, from [rhasspy/piper-voices on Hugging Face](https://huggingface.co/rhasspy/piper-voices). The built-in messages use the English (US) **ryan**, **high** quality voice, which is a good place to start.
|
|
442
|
+
3. In the settings, under **Piper settings** in the Quick Responses box, fill in **Piper path** (the `piper` program, for example `/var/lib/homebridge/piper/bin/piper`) and **Piper voice** (the `.onnx` file). The page says whether Piper was found. **Preview** then plays your wording.
|
|
443
|
+
|
|
444
|
+
The plugin makes the audio for your messages when the bridge starts (one at a time, in the background) and keeps it in `ecobee-security/quick-responses` in the Homebridge folder, and removes it when no message uses it any more. A message shows up in the Home app once its audio is ready. All messages are spoken with the same speed and voice settings as the built-ins.
|
|
445
|
+
|
|
446
|
+
Tips: a bare "Hi!" can come out as "Aye". Writing "Hi there" usually helps, or give Piper the exact sounds: text between `[[` and `]]` is read as phonemes, for example `[[ hˈaɪ ðɛɹ ]]`. Phonemes are passed through unchanged.
|
|
447
|
+
|
|
448
|
+
### Voice licensing
|
|
449
|
+
|
|
450
|
+
The recordings of the built-in messages (in `assets/quick-responses`) were made with Piper's **ryan** voice, whose training data (RyanSpeech) is licensed **CC BY-NC-SA 4.0**. The recordings are therefore licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) too: free for non-commercial use, with attribution: "RyanSpeech (CC BY-NC-SA 4.0), synthesized with Piper". The rest of the plugin stays MIT licensed. A voice you use for your own wording has its own license; check it before sharing recordings made with it.
|
|
451
|
+
|
|
393
452
|
## Home Occupied switch
|
|
394
453
|
|
|
395
454
|
Apple Home asks you to confirm (a notification you have to tap) whenever a people-arrive or
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
The audio files in this folder (leave-delivery.wav, hang-on.wav, not-home.wav) are licensed under the
|
|
2
|
+
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International license (CC BY-NC-SA 4.0).
|
|
3
|
+
|
|
4
|
+
https://creativecommons.org/licenses/by-nc-sa/4.0/
|
|
5
|
+
Legal code: https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
|
|
6
|
+
|
|
7
|
+
You may copy and redistribute the recordings and adapt them, for non-commercial purposes only, if you give
|
|
8
|
+
appropriate credit, indicate changes, and share your adaptations under the same license.
|
|
9
|
+
|
|
10
|
+
Attribution:
|
|
11
|
+
|
|
12
|
+
RyanSpeech (CC BY-NC-SA 4.0), synthesized with Piper
|
|
13
|
+
|
|
14
|
+
The recordings were synthesized with the Piper text-to-speech program (https://github.com/OHF-Voice/piper1-gpl)
|
|
15
|
+
and its "ryan" voice (en_US-ryan-high), which was trained on the RyanSpeech dataset,
|
|
16
|
+
licensed CC BY-NC-SA 4.0.
|
|
17
|
+
|
|
18
|
+
The rest of this package is licensed under the MIT license (see the LICENSE file in the package root); this
|
|
19
|
+
license applies only to the files in this folder.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/config.schema.json
CHANGED
|
@@ -251,6 +251,79 @@
|
|
|
251
251
|
"functionBody": "return model.doorbellCamera === true && model.twoWayAudio === true;"
|
|
252
252
|
}
|
|
253
253
|
},
|
|
254
|
+
"quickResponses": {
|
|
255
|
+
"title": "Quick Responses",
|
|
256
|
+
"type": "boolean",
|
|
257
|
+
"default": false,
|
|
258
|
+
"description": "Short messages the doorbell can play. They appear as one Quick Responses tile in the Home app, with a switch per message, which you can add to the live view's Nearby Accessories. Restart the bridge after changing this. See \"Quick Responses\" in the README.",
|
|
259
|
+
"condition": {
|
|
260
|
+
"functionBody": "return model.doorbellCamera === true;"
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
"quickResponseVolume": {
|
|
264
|
+
"title": "Quick Response volume (%)",
|
|
265
|
+
"type": "integer",
|
|
266
|
+
"default": 40,
|
|
267
|
+
"minimum": 5,
|
|
268
|
+
"maximum": 100,
|
|
269
|
+
"description": "How loud messages play at the door.",
|
|
270
|
+
"condition": {
|
|
271
|
+
"functionBody": "return model.doorbellCamera === true && model.quickResponses === true;"
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"quickResponseMessages": {
|
|
275
|
+
"title": "Quick Response messages",
|
|
276
|
+
"type": "array",
|
|
277
|
+
"description": "The messages, each with an id that stays the same (it keeps the switch's identity in the Home app), a name of up to 14 characters, and what is said. With no list, the three built-in messages (Leave Delivery, Hang On, Not Home).",
|
|
278
|
+
"items": {
|
|
279
|
+
"type": "object",
|
|
280
|
+
"properties": {
|
|
281
|
+
"id": {
|
|
282
|
+
"title": "Id",
|
|
283
|
+
"type": "string"
|
|
284
|
+
},
|
|
285
|
+
"name": {
|
|
286
|
+
"title": "Name",
|
|
287
|
+
"type": "string",
|
|
288
|
+
"maxLength": 14
|
|
289
|
+
},
|
|
290
|
+
"phrase": {
|
|
291
|
+
"title": "What is said",
|
|
292
|
+
"type": "string"
|
|
293
|
+
},
|
|
294
|
+
"enabled": {
|
|
295
|
+
"title": "Enabled",
|
|
296
|
+
"type": "boolean",
|
|
297
|
+
"default": true,
|
|
298
|
+
"description": "Only for the built-in messages: false removes the switch from the Home app and keeps the wording."
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"required": [
|
|
302
|
+
"id",
|
|
303
|
+
"name",
|
|
304
|
+
"phrase"
|
|
305
|
+
]
|
|
306
|
+
},
|
|
307
|
+
"condition": {
|
|
308
|
+
"functionBody": "return model.doorbellCamera === true && model.quickResponses === true;"
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
"piperPath": {
|
|
312
|
+
"title": "Piper path",
|
|
313
|
+
"type": "string",
|
|
314
|
+
"description": "Only for changed or new wording: the piper program you installed (see \"Custom wording with Piper\" in the README).",
|
|
315
|
+
"condition": {
|
|
316
|
+
"functionBody": "return model.doorbellCamera === true && model.quickResponses === true;"
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
"piperVoice": {
|
|
320
|
+
"title": "Piper voice",
|
|
321
|
+
"type": "string",
|
|
322
|
+
"description": "Path to the voice (.onnx) Piper speaks with.",
|
|
323
|
+
"condition": {
|
|
324
|
+
"functionBody": "return model.doorbellCamera === true && model.quickResponses === true;"
|
|
325
|
+
}
|
|
326
|
+
},
|
|
254
327
|
"ffmpegPath": {
|
|
255
328
|
"title": "ffmpeg path",
|
|
256
329
|
"type": "string",
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
<style>
|
|
2
2
|
/* Inherit the theme's text colour; Bootstrap's muted grey is unreadable on Homebridge's dark themes. */
|
|
3
|
-
|
|
3
|
+
/* Fade the text colour rather than the element, so links in hints keep the theme's full accent colour. */
|
|
4
|
+
.hint { color: color-mix(in srgb, currentColor 75%, transparent); font-size: 0.875em; }
|
|
5
|
+
/* Quick Responses rows: title | wording | Preview | actions; the wording drops under the title in a narrow panel. */
|
|
6
|
+
#qr-rows { container-type: inline-size; }
|
|
7
|
+
.qr-row { display: grid; grid-template-columns: 9.5em 1fr auto auto; gap: 0.5rem 0.75rem; align-items: center; padding: 0.5rem 0; border-top: 1px solid rgba(127, 127, 127, 0.25); }
|
|
8
|
+
.qr-row:last-child { border-bottom: 1px solid rgba(127, 127, 127, 0.25); }
|
|
9
|
+
.qr-row .btn { text-transform: none; letter-spacing: normal; white-space: nowrap; }
|
|
10
|
+
.qr-title { overflow-wrap: anywhere; }
|
|
11
|
+
.qr-actions { display: flex; gap: 0.25rem; justify-content: flex-end; }
|
|
12
|
+
@container (max-width: 480px) {
|
|
13
|
+
.qr-row { grid-template-columns: 1fr auto auto; }
|
|
14
|
+
.qr-phrase { grid-column: 1 / -1; grid-row: 2; }
|
|
15
|
+
}
|
|
4
16
|
</style>
|
|
5
17
|
|
|
6
18
|
<div class="card card-body mb-3">
|
|
@@ -154,10 +166,39 @@
|
|
|
154
166
|
</div>
|
|
155
167
|
<div class="form-check form-switch mb-1">
|
|
156
168
|
<input class="form-check-input" type="checkbox" role="switch" id="s-twoWayAudio" data-key="twoWayAudio" data-type="bool" data-default="false">
|
|
157
|
-
<label class="form-check-label" for="s-twoWayAudio">Two-way talk
|
|
169
|
+
<label class="form-check-label" for="s-twoWayAudio">Two-way talk</label>
|
|
158
170
|
</div>
|
|
159
171
|
<p class="hint mb-2">Talk through the doorbell from the Home app's live view. Restart the bridge after changing this.
|
|
160
|
-
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#two-way-talk
|
|
172
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#two-way-talk" target="_blank" rel="noopener">Learn more…</a></p>
|
|
173
|
+
<div class="form-check form-switch mb-1">
|
|
174
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-quickResponses" data-key="quickResponses" data-type="bool" data-default="false">
|
|
175
|
+
<label class="form-check-label" for="s-quickResponses">Quick Responses</label>
|
|
176
|
+
</div>
|
|
177
|
+
<p class="hint mb-2">Short messages the doorbell can play. They appear as one Quick Responses tile in the Home app, with a switch per message, which you can add to the live view's Nearby Accessories. Restart the bridge after changing this.
|
|
178
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#quick-responses" target="_blank" rel="noopener">Learn more…</a></p>
|
|
179
|
+
<div id="qr-box" class="mb-2" hidden>
|
|
180
|
+
<div class="d-flex align-items-center gap-2 mb-1">
|
|
181
|
+
<label for="s-quickResponseVolume" class="form-label mb-0">Volume (%)</label>
|
|
182
|
+
<input class="form-control form-control-sm" style="width:6em" type="number" step="5" min="5" max="100" id="s-quickResponseVolume" data-key="quickResponseVolume" data-type="int" data-default="40">
|
|
183
|
+
</div>
|
|
184
|
+
<div class="hint mb-2">How loud messages play at the door.</div>
|
|
185
|
+
<div id="qr-rows"></div>
|
|
186
|
+
<p class="hint mb-2" id="qr-removed" hidden></p>
|
|
187
|
+
<p class="hint mb-2" id="qr-piper-hint" hidden>To change these or add your own, install Piper
|
|
188
|
+
(<a href="https://github.com/seanfletchr/homebridge-ecobee-security#custom-wording-with-piper" target="_blank" rel="noopener">see README</a>)</p>
|
|
189
|
+
<button type="button" class="btn btn-secondary btn-sm" id="qr-add" hidden>Add a message</button>
|
|
190
|
+
<div id="qr-error" class="alert alert-danger mt-2 mb-0" role="alert" hidden></div>
|
|
191
|
+
<details class="mt-2">
|
|
192
|
+
<summary>Piper settings</summary>
|
|
193
|
+
<div class="mt-2">
|
|
194
|
+
<label for="s-piperPath" class="form-label mb-1">Piper path</label>
|
|
195
|
+
<input class="form-control mb-2" type="text" id="s-piperPath" data-key="piperPath" data-type="text" data-default="" placeholder="/var/lib/homebridge/piper/bin/piper">
|
|
196
|
+
<label for="s-piperVoice" class="form-label mb-1">Piper voice</label>
|
|
197
|
+
<input class="form-control mb-1" type="text" id="s-piperVoice" data-key="piperVoice" data-type="text" data-default="" placeholder="/var/lib/homebridge/piper/en_US-ryan-high.onnx">
|
|
198
|
+
<div class="hint" id="piper-status"></div>
|
|
199
|
+
</div>
|
|
200
|
+
</details>
|
|
201
|
+
</div>
|
|
161
202
|
<details>
|
|
162
203
|
<summary>Advanced</summary>
|
|
163
204
|
<div class="mt-2">
|
|
@@ -423,6 +464,7 @@
|
|
|
423
464
|
if (block?.doorbellCamera === true) {
|
|
424
465
|
await showLink();
|
|
425
466
|
}
|
|
467
|
+
await showQuickResponses(block);
|
|
426
468
|
}
|
|
427
469
|
|
|
428
470
|
/**
|
|
@@ -546,6 +588,8 @@
|
|
|
546
588
|
showVacationOptions();
|
|
547
589
|
} else if (key === 'memberPresence') {
|
|
548
590
|
await showMembers();
|
|
591
|
+
} else if (key === 'quickResponses') {
|
|
592
|
+
await showQuickResponses();
|
|
549
593
|
} else if (key === 'doorbellCamera') {
|
|
550
594
|
$('camera-box').hidden = !input.checked;
|
|
551
595
|
if (input.checked) {
|
|
@@ -689,6 +733,270 @@
|
|
|
689
733
|
await showKeypad();
|
|
690
734
|
});
|
|
691
735
|
|
|
736
|
+
// ── Quick Responses ──
|
|
737
|
+
|
|
738
|
+
const NAME_MAX = 14;
|
|
739
|
+
let qrMessages = [];
|
|
740
|
+
let qrBuiltIns = new Map();
|
|
741
|
+
|
|
742
|
+
function qrError(message) {
|
|
743
|
+
$('qr-error').textContent = message ?? '';
|
|
744
|
+
$('qr-error').hidden = !message;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The messages: the saved list, or the built-ins while none is saved. */
|
|
748
|
+
async function loadMessages(block) {
|
|
749
|
+
const defaults = await homebridge.request('/quick-response-defaults').catch(() => []);
|
|
750
|
+
qrBuiltIns = new Map(defaults.map(m => [m.id, m]));
|
|
751
|
+
const saved = block?.quickResponseMessages;
|
|
752
|
+
qrMessages = (Array.isArray(saved) ? saved : defaults).map(m => ({
|
|
753
|
+
id: String(m.id), name: String(m.name ?? ''), phrase: String(m.phrase ?? ''), enabled: m.enabled !== false,
|
|
754
|
+
}));
|
|
755
|
+
// A built-in missing from a saved list is still a built-in: offered under Removed.
|
|
756
|
+
for (const builtIn of defaults) {
|
|
757
|
+
if (Array.isArray(saved) && !qrMessages.some(m => m.id === builtIn.id)) {
|
|
758
|
+
qrMessages.push({ ...builtIn, enabled: false });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function showQuickResponses(blockIn) {
|
|
764
|
+
const block = blockIn ?? (await homebridge.getPluginConfig())[0] ?? {};
|
|
765
|
+
$('qr-box').hidden = block.quickResponses !== true;
|
|
766
|
+
if (block.quickResponses !== true) {
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
await loadMessages(block);
|
|
770
|
+
piperFound = false;
|
|
771
|
+
renderRows();
|
|
772
|
+
await showPiperStatus();
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Writes the whole list (built-ins included), so a later change of the built-ins' defaults can't alter what you saved. */
|
|
776
|
+
async function saveMessages() {
|
|
777
|
+
await setSetting('quickResponseMessages', qrMessages.map(({ id, name, phrase, enabled }) => ({
|
|
778
|
+
id, name: name.trim(), phrase: phrase.trim(), ...(qrBuiltIns.has(id) && enabled === false ? { enabled: false } : {}),
|
|
779
|
+
})));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
let piperFound = false;
|
|
783
|
+
const replacing = new Set(); // built-ins being replaced (their title and wording are inputs)
|
|
784
|
+
|
|
785
|
+
const same = (message) => {
|
|
786
|
+
const builtIn = qrBuiltIns.get(message.id);
|
|
787
|
+
return builtIn && message.name.trim() === builtIn.name && message.phrase.trim() === builtIn.phrase;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
function renderRows() {
|
|
791
|
+
const box = $('qr-rows');
|
|
792
|
+
box.textContent = '';
|
|
793
|
+
$('qr-add').hidden = !piperFound;
|
|
794
|
+
$('qr-piper-hint').hidden = piperFound;
|
|
795
|
+
const removed = qrMessages.filter(m => m.enabled === false);
|
|
796
|
+
const note = $('qr-removed');
|
|
797
|
+
note.hidden = !removed.length;
|
|
798
|
+
note.textContent = '';
|
|
799
|
+
if (removed.length) {
|
|
800
|
+
note.append('Removed: ');
|
|
801
|
+
removed.forEach((message, index) => {
|
|
802
|
+
if (index) {
|
|
803
|
+
note.append(' \u00b7 ');
|
|
804
|
+
}
|
|
805
|
+
const link = Object.assign(document.createElement('a'), { href: '#', textContent: 'Add back' });
|
|
806
|
+
link.addEventListener('click', async (event) => {
|
|
807
|
+
event.preventDefault();
|
|
808
|
+
message.enabled = true;
|
|
809
|
+
renderRows();
|
|
810
|
+
await saveMessages();
|
|
811
|
+
});
|
|
812
|
+
note.append(`${message.name} \u00b7 `, link);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
for (const message of qrMessages.filter(m => m.enabled !== false)) {
|
|
816
|
+
const builtIn = qrBuiltIns.has(message.id);
|
|
817
|
+
// Built-ins are text unless Piper is there and they are being replaced (or already differ from the shipped wording).
|
|
818
|
+
const editable = !builtIn || (piperFound && (replacing.has(message.id) || !same(message)));
|
|
819
|
+
const row = Object.assign(document.createElement('div'), { className: 'qr-row' });
|
|
820
|
+
const nameWrap = Object.assign(document.createElement('div'), { className: 'qr-title' });
|
|
821
|
+
const phraseWrap = Object.assign(document.createElement('div'), { className: 'qr-phrase' });
|
|
822
|
+
|
|
823
|
+
if (editable) {
|
|
824
|
+
const name = Object.assign(document.createElement('input'), {
|
|
825
|
+
type: 'text', className: 'form-control form-control-sm', maxLength: NAME_MAX, value: message.name, placeholder: 'Title',
|
|
826
|
+
});
|
|
827
|
+
name.setAttribute('aria-label', 'Title');
|
|
828
|
+
const counter = Object.assign(document.createElement('div'), { className: 'hint text-end' });
|
|
829
|
+
counter.style.fontSize = '0.75em';
|
|
830
|
+
const count = () => {
|
|
831
|
+
counter.textContent = `${name.value.length}/${NAME_MAX}`;
|
|
832
|
+
};
|
|
833
|
+
count();
|
|
834
|
+
name.addEventListener('input', () => {
|
|
835
|
+
count();
|
|
836
|
+
message.name = name.value;
|
|
837
|
+
});
|
|
838
|
+
name.addEventListener('change', () => {
|
|
839
|
+
name.classList.toggle('is-invalid', !name.value.trim());
|
|
840
|
+
saveMessages();
|
|
841
|
+
});
|
|
842
|
+
nameWrap.append(name, counter);
|
|
843
|
+
|
|
844
|
+
const phrase = Object.assign(document.createElement('input'), {
|
|
845
|
+
type: 'text', className: 'form-control form-control-sm', value: message.phrase, placeholder: 'What the doorbell says',
|
|
846
|
+
});
|
|
847
|
+
phrase.setAttribute('aria-label', 'What the doorbell says');
|
|
848
|
+
phrase.addEventListener('input', () => {
|
|
849
|
+
message.phrase = phrase.value;
|
|
850
|
+
});
|
|
851
|
+
phrase.addEventListener('change', () => {
|
|
852
|
+
phrase.classList.toggle('is-invalid', !phrase.value.trim());
|
|
853
|
+
saveMessages();
|
|
854
|
+
});
|
|
855
|
+
phraseWrap.append(phrase);
|
|
856
|
+
} else {
|
|
857
|
+
nameWrap.textContent = message.name;
|
|
858
|
+
phraseWrap.className += ' hint';
|
|
859
|
+
phraseWrap.textContent = `\u201c${message.phrase}\u201d`;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const preview = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm btn-outline-secondary', textContent: 'Preview' });
|
|
863
|
+
preview.addEventListener('click', () => previewMessage(message, preview));
|
|
864
|
+
|
|
865
|
+
const slot = Object.assign(document.createElement('div'), { className: 'qr-actions' });
|
|
866
|
+
const remove = Object.assign(document.createElement('button'), { type: 'button', className: 'btn btn-sm btn-outline-secondary', textContent: '\u00d7' });
|
|
867
|
+
remove.setAttribute('aria-label', `Remove ${message.name || 'this message'}`);
|
|
868
|
+
remove.addEventListener('click', async () => {
|
|
869
|
+
// A built-in is only switched off (its wording stays for "Add back"); your own are deleted.
|
|
870
|
+
if (builtIn) {
|
|
871
|
+
message.enabled = false;
|
|
872
|
+
replacing.delete(message.id);
|
|
873
|
+
} else {
|
|
874
|
+
qrMessages = qrMessages.filter(m => m !== message);
|
|
875
|
+
}
|
|
876
|
+
renderRows();
|
|
877
|
+
await saveMessages();
|
|
878
|
+
});
|
|
879
|
+
if (builtIn && piperFound) {
|
|
880
|
+
const replace = editable;
|
|
881
|
+
const button = Object.assign(document.createElement('button'), {
|
|
882
|
+
type: 'button', className: 'btn btn-sm btn-outline-secondary', textContent: replace ? 'Restore' : 'Replace',
|
|
883
|
+
});
|
|
884
|
+
button.addEventListener('click', async () => {
|
|
885
|
+
if (replace) {
|
|
886
|
+
const shipped = qrBuiltIns.get(message.id);
|
|
887
|
+
message.name = shipped.name;
|
|
888
|
+
message.phrase = shipped.phrase;
|
|
889
|
+
replacing.delete(message.id);
|
|
890
|
+
} else {
|
|
891
|
+
replacing.add(message.id);
|
|
892
|
+
}
|
|
893
|
+
renderRows();
|
|
894
|
+
await saveMessages();
|
|
895
|
+
});
|
|
896
|
+
slot.append(button);
|
|
897
|
+
}
|
|
898
|
+
slot.append(remove);
|
|
899
|
+
|
|
900
|
+
row.append(nameWrap, phraseWrap, preview, slot);
|
|
901
|
+
box.append(row);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Preview plays through the Web Audio API: the settings page's iframe can't play data: URLs in every browser.
|
|
906
|
+
let audioContext;
|
|
907
|
+
let playing; // { source, button }
|
|
908
|
+
|
|
909
|
+
function stopPreview() {
|
|
910
|
+
if (!playing) {
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const { source, button } = playing;
|
|
914
|
+
playing = undefined;
|
|
915
|
+
button.textContent = 'Preview';
|
|
916
|
+
try {
|
|
917
|
+
source.stop();
|
|
918
|
+
} catch {
|
|
919
|
+
// already stopped
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const decode = (context, data) => new Promise((resolve, reject) => {
|
|
924
|
+
// Older Safari only has the callback form.
|
|
925
|
+
const promise = context.decodeAudioData(data, resolve, reject);
|
|
926
|
+
promise?.then?.(resolve, reject);
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
async function previewMessage(message, button) {
|
|
930
|
+
qrError();
|
|
931
|
+
const wasPlaying = playing?.button === button;
|
|
932
|
+
stopPreview();
|
|
933
|
+
if (wasPlaying) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
button.disabled = true;
|
|
937
|
+
try {
|
|
938
|
+
// Created and resumed inside the click, before anything is awaited that would end the user gesture.
|
|
939
|
+
audioContext ??= new (window.AudioContext || window.webkitAudioContext)();
|
|
940
|
+
const resumed = audioContext.resume();
|
|
941
|
+
const result = await homebridge.request('/quick-response-preview', {
|
|
942
|
+
id: message.id, phrase: message.phrase, ...piperFields(),
|
|
943
|
+
});
|
|
944
|
+
await resumed;
|
|
945
|
+
const bytes = Uint8Array.from(atob(result.audio), c => c.charCodeAt(0));
|
|
946
|
+
const buffer = await decode(audioContext, bytes.buffer);
|
|
947
|
+
const source = audioContext.createBufferSource();
|
|
948
|
+
source.buffer = buffer;
|
|
949
|
+
source.connect(audioContext.destination);
|
|
950
|
+
const mine = { source, button };
|
|
951
|
+
source.onended = () => {
|
|
952
|
+
if (playing === mine) {
|
|
953
|
+
stopPreview();
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
playing = mine;
|
|
957
|
+
button.textContent = 'Stop';
|
|
958
|
+
source.start();
|
|
959
|
+
} catch (error) {
|
|
960
|
+
qrError(error?.message || String(error) || 'Could not play the preview.');
|
|
961
|
+
} finally {
|
|
962
|
+
button.disabled = false;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
$('qr-add').addEventListener('click', async () => {
|
|
967
|
+
const bytes = crypto.getRandomValues(new Uint8Array(3));
|
|
968
|
+
const id = `m-${[...bytes].map(b => b.toString(16).padStart(2, '0')).join('')}`;
|
|
969
|
+
qrMessages.push({ id, name: '', phrase: '' });
|
|
970
|
+
renderRows();
|
|
971
|
+
$('qr-rows').lastElementChild?.querySelector('input')?.focus();
|
|
972
|
+
await saveMessages();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
function piperFields() {
|
|
976
|
+
return { piperPath: $('s-piperPath').value.trim(), piperVoice: $('s-piperVoice').value.trim() };
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function showPiperStatus() {
|
|
980
|
+
const { piperPath, piperVoice } = piperFields();
|
|
981
|
+
const status = $('piper-status');
|
|
982
|
+
let found = false;
|
|
983
|
+
if (!piperPath && !piperVoice) {
|
|
984
|
+
status.textContent = 'Piper not set up.';
|
|
985
|
+
} else {
|
|
986
|
+
const result = await homebridge.request('/piper-status', { piperPath, piperVoice }).catch(() => ({ found: false, reason: 'could not check' }));
|
|
987
|
+
found = result.found === true;
|
|
988
|
+
status.textContent = found ? 'Piper found' : `Piper not found (${result.reason})`;
|
|
989
|
+
}
|
|
990
|
+
if (found !== piperFound) {
|
|
991
|
+
piperFound = found;
|
|
992
|
+
renderRows();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
for (const id of ['s-piperPath', 's-piperVoice']) {
|
|
997
|
+
$(id).addEventListener('change', showPiperStatus);
|
|
998
|
+
}
|
|
999
|
+
|
|
692
1000
|
// ── Doorbell link ──
|
|
693
1001
|
|
|
694
1002
|
let linkChoice;
|