homebridge-ecobee-security 0.8.0 → 0.9.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 +50 -0
- package/README.md +260 -54
- package/config.schema.json +33 -11
- package/homebridge-ui/public/index.html +201 -50
- package/homebridge-ui/server.js +41 -0
- package/lib/camera/bridged.js +171 -0
- package/lib/camera/buffer.js +222 -0
- package/lib/camera/camera.js +663 -0
- package/lib/camera/h264.js +245 -0
- package/lib/camera/hap-link.js +292 -0
- package/lib/camera/hap-pairing.js +116 -0
- package/lib/camera/jitter.js +152 -0
- package/lib/camera/mkv.js +126 -0
- package/lib/camera/mp4info.js +182 -0
- package/lib/camera/recorder.js +397 -0
- package/lib/camera/session.js +537 -0
- package/lib/ecobee.js +198 -0
- package/lib/platform.js +286 -6
- package/lib/security.js +1 -1
- package/lib/vacation.js +244 -0
- package/package.json +4 -2
|
@@ -56,7 +56,6 @@
|
|
|
56
56
|
</div>
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
|
-
|
|
60
59
|
<div class="card card-body mb-3" id="system-card" hidden>
|
|
61
60
|
<h5 class="mb-3">Security system</h5>
|
|
62
61
|
<div class="mb-3">
|
|
@@ -72,38 +71,15 @@
|
|
|
72
71
|
<div class="mb-3">
|
|
73
72
|
<div class="form-check form-switch">
|
|
74
73
|
<input class="form-check-input" type="checkbox" role="switch" id="s-offerNight" data-key="offerNight" data-type="bool" data-default="false">
|
|
75
|
-
<label class="form-check-label" for="s-offerNight">
|
|
74
|
+
<label class="form-check-label" for="s-offerNight">Enable Night</label>
|
|
76
75
|
</div>
|
|
77
76
|
<div class="hint">Adds a Night mode to use in automations. ecobee has no Night mode, so Night arms ecobee's Stay.</div>
|
|
78
77
|
<div class="hint mt-1">Changing this recreates the security system in the Home app, so you need to set up its scenes and automations again.</div>
|
|
79
78
|
</div>
|
|
80
79
|
<div class="mb-0">
|
|
81
|
-
<label for="s-alarmTrigger" class="form-label">
|
|
82
|
-
<select class="form-select" id="s-alarmTrigger" data-key="alarmTrigger" data-type="select" data-default="incident"><option value="incident">
|
|
83
|
-
<div class="hint mt-1">
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<div class="card card-body mb-3" id="sensors-card" hidden>
|
|
88
|
-
<h5 class="mb-3">Sensors</h5>
|
|
89
|
-
<div class="mb-3">
|
|
90
|
-
<div class="form-check form-switch">
|
|
91
|
-
<input class="form-check-input" type="checkbox" role="switch" id="s-smokeSensor" data-key="smokeSensor" data-type="bool" data-default="true">
|
|
92
|
-
<label class="form-check-label" for="s-smokeSensor">Smoke sensor</label>
|
|
93
|
-
</div>
|
|
94
|
-
<div class="hint">Adds a Smoke Alarm accessory that detects smoke when ecobee reports a smoke alarm. Use it for Home app notifications and automations.</div>
|
|
95
|
-
</div>
|
|
96
|
-
<div class="mb-3">
|
|
97
|
-
<div class="form-check form-switch">
|
|
98
|
-
<input class="form-check-input" type="checkbox" role="switch" id="s-airQuality" data-key="airQuality" data-type="bool" data-default="true">
|
|
99
|
-
<label class="form-check-label" for="s-airQuality">Air quality sensors</label>
|
|
100
|
-
</div>
|
|
101
|
-
<div class="hint">Adds 2 sensors for each ecobee Premium thermostat: air quality (with VOC) and CO2. ecobee's own HomeKit integration doesn't include them. Updates every 5 minutes.</div>
|
|
102
|
-
</div>
|
|
103
|
-
<div class="mb-0" id="co2-box">
|
|
104
|
-
<label for="s-co2Alert" class="form-label">CO2 alert level (ppm)</label>
|
|
105
|
-
<input class="form-control" type="number" step="1" min="400" id="s-co2Alert" data-key="co2Alert" data-type="int" data-default="1000">
|
|
106
|
-
<div class="hint mt-1">At or above this level, the CO2 sensor reports abnormal CO2. Use it for notifications and automations, like turning on ventilation.</div>
|
|
80
|
+
<label for="s-alarmTrigger" class="form-label">Home app alarm</label>
|
|
81
|
+
<select class="form-select" id="s-alarmTrigger" data-key="alarmTrigger" data-type="select" data-default="incident"><option value="incident">When a sensor is tripped (entry delay starts)</option><option value="entryDelay">When the siren goes off (entry delay ran out)</option></select>
|
|
82
|
+
<div class="hint mt-1">Sets when the Home app's security tile shows Triggered. Its critical alert and "security system triggered" automations follow it. ecobee's own alerts and professional monitoring don't change. "When a sensor is tripped" alerts every time someone comes in while it'sarmed, even if they disarm in time.</div>
|
|
107
83
|
</div>
|
|
108
84
|
</div>
|
|
109
85
|
|
|
@@ -122,23 +98,6 @@
|
|
|
122
98
|
</div>
|
|
123
99
|
</div>
|
|
124
100
|
|
|
125
|
-
<div class="card card-body mb-3" id="occupancy-card" hidden>
|
|
126
|
-
<h5 class="mb-2">Home Occupied switch</h5>
|
|
127
|
-
<div class="form-check form-switch mb-1">
|
|
128
|
-
<input class="form-check-input" type="checkbox" role="switch" id="s-occupancySwitch" data-key="occupancySwitch" data-type="bool" data-default="false">
|
|
129
|
-
<label class="form-check-label" for="s-occupancySwitch">Home Occupied switch</label>
|
|
130
|
-
</div>
|
|
131
|
-
<p class="hint mb-2">Adds a switch to the Home app for your people-arrive and people-leave automations to set:
|
|
132
|
-
off (nobody home) arms Away, on (someone home) turns Away off. Switches don't ask for confirmation the way
|
|
133
|
-
automations that change the security system do. Restart the bridge after changing this.
|
|
134
|
-
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#home-occupied-switch" target="_blank" rel="noopener">Learn more…</a></p>
|
|
135
|
-
<div id="occupancy-box" hidden>
|
|
136
|
-
<label for="s-occupancyName" class="form-label mb-1">Name</label>
|
|
137
|
-
<input class="form-control" type="text" id="s-occupancyName" data-key="occupancyName" data-type="text" data-default="Home Occupied">
|
|
138
|
-
<div class="hint mt-1">Name shown in the Home app.</div>
|
|
139
|
-
</div>
|
|
140
|
-
</div>
|
|
141
|
-
|
|
142
101
|
<div class="card card-body mb-3" id="holds-card" hidden>
|
|
143
102
|
<h5 class="mb-2">Security holds</h5>
|
|
144
103
|
<div class="form-check form-switch mb-1">
|
|
@@ -165,6 +124,96 @@
|
|
|
165
124
|
</div>
|
|
166
125
|
</div>
|
|
167
126
|
|
|
127
|
+
<div class="card card-body mb-3" id="camera-card" hidden>
|
|
128
|
+
<h5 class="mb-2">Doorbell camera</h5>
|
|
129
|
+
<div class="form-check form-switch mb-1">
|
|
130
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-doorbellCamera" data-key="doorbellCamera" data-type="bool" data-default="false">
|
|
131
|
+
<label class="form-check-label" for="s-doorbellCamera">Doorbell camera with HomeKit Secure Video</label>
|
|
132
|
+
</div>
|
|
133
|
+
<p class="hint mb-2">Recordings, activity zones and Apple's person and package detection in the Home app, plus live view. Restart the bridge after changing this.
|
|
134
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#doorbell-camera" target="_blank" rel="noopener">Learn more…</a></p>
|
|
135
|
+
<div id="camera-box" hidden>
|
|
136
|
+
<div id="link-box" class="mb-2">
|
|
137
|
+
<div class="d-flex align-items-center gap-2 flex-wrap">
|
|
138
|
+
<span><strong>Doorbell link:</strong> <span id="link-status"></span></span>
|
|
139
|
+
<button type="button" class="btn btn-sm btn-secondary" id="link-unpair" hidden>Unlink</button>
|
|
140
|
+
<button type="button" class="btn btn-sm btn-secondary" id="link-open" hidden>Pair…</button>
|
|
141
|
+
</div>
|
|
142
|
+
<button type="button" class="btn btn-link btn-sm p-0" id="link-forget" hidden>Remove the link without asking the doorbell</button>
|
|
143
|
+
<div id="link-unpaired" hidden class="mt-2">
|
|
144
|
+
<p class="hint mb-2">First remove ecobee's own doorbell from the Home app: its automations and the sensors it connects go with it. Restart the bridge after linking.
|
|
145
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#doorbell-link" target="_blank" rel="noopener">Learn more…</a></p>
|
|
146
|
+
<button type="button" class="btn btn-secondary mb-2" id="link-find">Find doorbells</button>
|
|
147
|
+
<div id="link-found" class="mb-2"></div>
|
|
148
|
+
<form id="link-form" class="d-flex gap-2" hidden>
|
|
149
|
+
<input type="text" class="form-control" id="link-code" inputmode="numeric" autocomplete="off" maxlength="13" placeholder="Setup code (8 digits)" required>
|
|
150
|
+
<button type="submit" class="btn btn-primary" id="link-pair">Pair</button>
|
|
151
|
+
</form>
|
|
152
|
+
</div>
|
|
153
|
+
<div id="link-error" class="alert alert-danger mt-2 mb-0" role="alert" hidden></div>
|
|
154
|
+
</div>
|
|
155
|
+
<details>
|
|
156
|
+
<summary>Advanced</summary>
|
|
157
|
+
<div class="mt-2">
|
|
158
|
+
<label for="s-motionGrace" class="form-label mb-1">Motion grace period (seconds)</label>
|
|
159
|
+
<input class="form-control mb-1" type="number" step="1" min="6" max="120" id="s-motionGrace" data-key="motionGrace" data-type="int" data-default="15">
|
|
160
|
+
<div class="hint mb-3">How long a clip keeps recording after motion stops. Someone back in view within this time stays in the same clip.</div>
|
|
161
|
+
<label for="s-ffmpegPath" class="form-label mb-1">ffmpeg path</label>
|
|
162
|
+
<input class="form-control" type="text" id="s-ffmpegPath" data-key="ffmpegPath" data-type="text" data-default="" placeholder="/usr/local/bin/ffmpeg">
|
|
163
|
+
<div class="hint mt-1">Only if the camera can't find ffmpeg. It needs libfdk_aac and libx264.</div>
|
|
164
|
+
</div>
|
|
165
|
+
</details>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
<div class="card card-body mb-3" id="sensors-card" hidden>
|
|
170
|
+
<h5 class="mb-3">Switches and sensors</h5>
|
|
171
|
+
|
|
172
|
+
<div>
|
|
173
|
+
<h6 class="mb-1">Home Occupied</h6>
|
|
174
|
+
<div class="form-check form-switch mb-1">
|
|
175
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-occupancySwitch" data-key="occupancySwitch" data-type="bool" data-default="false">
|
|
176
|
+
<label class="form-check-label" for="s-occupancySwitch">Add a Home Occupied switch</label>
|
|
177
|
+
</div>
|
|
178
|
+
<p class="hint mb-2">Adds a switch to the Home app for your people-arrive and people-leave automations to set:
|
|
179
|
+
off (nobody home) arms Away, on (someone home) turns Away off. Switches don't ask for confirmation the way
|
|
180
|
+
automations that change the security system do. Restart the bridge after changing this.
|
|
181
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#home-occupied-switch" target="_blank" rel="noopener">Learn more…</a></p>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div class="mt-3">
|
|
185
|
+
<h6 class="mb-1">Vacation Mode</h6>
|
|
186
|
+
<div class="form-check form-switch mb-1">
|
|
187
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-vacationSwitch" data-key="vacationSwitch" data-type="bool" data-default="false">
|
|
188
|
+
<label class="form-check-label" for="s-vacationSwitch">Add a Vacation Mode switch</label>
|
|
189
|
+
</div>
|
|
190
|
+
<p class="hint mb-2">Shows when an ecobee vacation is on, for other accessories to follow. Turn it on to start a vacation now, until you turn it off; set dated vacations in the ecobee app. Restart the bridge after changing this.</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="mt-3">
|
|
194
|
+
<h6 class="mb-1">Smoke Alarm</h6>
|
|
195
|
+
<div class="form-check form-switch">
|
|
196
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-smokeSensor" data-key="smokeSensor" data-type="bool" data-default="true">
|
|
197
|
+
<label class="form-check-label" for="s-smokeSensor">Add a Smoke Alarm sensor</label>
|
|
198
|
+
</div>
|
|
199
|
+
<div class="hint">Adds a Smoke Alarm accessory that detects smoke when ecobee reports a smoke alarm. Use it for Home app notifications and automations.</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="mt-3">
|
|
203
|
+
<h6 class="mb-1">Air quality</h6>
|
|
204
|
+
<div class="form-check form-switch">
|
|
205
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-airQuality" data-key="airQuality" data-type="bool" data-default="true">
|
|
206
|
+
<label class="form-check-label" for="s-airQuality">Add air quality sensors</label>
|
|
207
|
+
</div>
|
|
208
|
+
<div class="hint">Adds 2 sensors for each ecobee Premium thermostat: air quality (with VOC) and CO2. ecobee's own HomeKit integration doesn't include them. Updates every 5 minutes.</div>
|
|
209
|
+
<div class="mt-3 mb-0" id="co2-box">
|
|
210
|
+
<label for="s-co2Alert" class="form-label">CO2 alert level (ppm)</label>
|
|
211
|
+
<input class="form-control" type="number" step="1" min="400" id="s-co2Alert" data-key="co2Alert" data-type="int" data-default="1000">
|
|
212
|
+
<div class="hint mt-1">At or above this level, the CO2 sensor reports abnormal CO2. Use it for notifications and automations, like turning on ventilation.</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
168
217
|
<script>
|
|
169
218
|
(async () => {
|
|
170
219
|
const PLATFORM = 'EcobeeSecurityPlatform';
|
|
@@ -296,7 +345,8 @@
|
|
|
296
345
|
const signedIn = Boolean(block?.refreshToken);
|
|
297
346
|
$('system-card').hidden = !signedIn;
|
|
298
347
|
$('sensors-card').hidden = !signedIn;
|
|
299
|
-
|
|
348
|
+
const link = signedIn ? await homebridge.request('/doorbell-link').catch(() => ({})) : {};
|
|
349
|
+
$('camera-card').hidden = !signedIn || link.hasDoorbell === false;
|
|
300
350
|
await showHomes(block);
|
|
301
351
|
for (const input of controls) {
|
|
302
352
|
const value = block?.[input.dataset.key];
|
|
@@ -307,7 +357,10 @@
|
|
|
307
357
|
}
|
|
308
358
|
}
|
|
309
359
|
$('co2-box').hidden = block?.airQuality === false;
|
|
310
|
-
$('
|
|
360
|
+
$('camera-box').hidden = block?.doorbellCamera !== true;
|
|
361
|
+
if (block?.doorbellCamera === true) {
|
|
362
|
+
await showLink();
|
|
363
|
+
}
|
|
311
364
|
}
|
|
312
365
|
|
|
313
366
|
/**
|
|
@@ -345,12 +398,15 @@
|
|
|
345
398
|
await setSetting(key, input.checked);
|
|
346
399
|
if (key === 'airQuality') {
|
|
347
400
|
$('co2-box').hidden = !input.checked;
|
|
348
|
-
} else if (key === '
|
|
349
|
-
$('
|
|
401
|
+
} else if (key === 'doorbellCamera') {
|
|
402
|
+
$('camera-box').hidden = !input.checked;
|
|
403
|
+
if (input.checked) {
|
|
404
|
+
await showLink();
|
|
405
|
+
}
|
|
350
406
|
}
|
|
351
407
|
} else if (type === 'int') {
|
|
352
408
|
const number = input.value.trim() === '' ? undefined : Number(input.value);
|
|
353
|
-
const valid = number === undefined || (Number.isInteger(number) && number >= Number(input.min));
|
|
409
|
+
const valid = number === undefined || (Number.isInteger(number) && number >= Number(input.min) && (!input.max || number <= Number(input.max)));
|
|
354
410
|
input.classList.toggle('is-invalid', !valid);
|
|
355
411
|
if (valid) {
|
|
356
412
|
await setSetting(key, number);
|
|
@@ -481,6 +537,101 @@
|
|
|
481
537
|
await showKeypad();
|
|
482
538
|
});
|
|
483
539
|
|
|
540
|
+
// ── Doorbell link ──
|
|
541
|
+
|
|
542
|
+
let linkChoice;
|
|
543
|
+
|
|
544
|
+
function linkError(message) {
|
|
545
|
+
$('link-error').textContent = message ?? '';
|
|
546
|
+
$('link-error').hidden = !message;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function showLink() {
|
|
550
|
+
const link = await homebridge.request('/doorbell-link').catch(() => ({ paired: false }));
|
|
551
|
+
$('link-unpaired').hidden = true;
|
|
552
|
+
$('link-open').hidden = link.paired;
|
|
553
|
+
$('link-unpair').hidden = !link.paired;
|
|
554
|
+
$('link-forget').hidden = true;
|
|
555
|
+
$('link-status').textContent = link.paired
|
|
556
|
+
? (link.state === 'down' ? 'Linked, but the doorbell is not answering right now. ' : 'Linked · ')
|
|
557
|
+
: 'Not linked. Pair it for an instant ring and motion; it replaces ecobee\'s own doorbell in the Home app. ';
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
$('link-open').addEventListener('click', () => {
|
|
561
|
+
$('link-unpaired').hidden = !$('link-unpaired').hidden;
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
$('link-find').addEventListener('click', async () => {
|
|
565
|
+
linkError();
|
|
566
|
+
$('link-form').hidden = true;
|
|
567
|
+
const box = $('link-found');
|
|
568
|
+
box.textContent = 'Looking…';
|
|
569
|
+
try {
|
|
570
|
+
const doorbells = await homebridge.request('/doorbell-find');
|
|
571
|
+
box.textContent = '';
|
|
572
|
+
linkChoice = undefined;
|
|
573
|
+
if (!doorbells.length) {
|
|
574
|
+
box.textContent = 'No doorbell found. Is ecobee\'s own doorbell removed from the Home app?';
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
for (const d of doorbells) {
|
|
578
|
+
const id = `doorbell-${d.id.replace(/[^a-z0-9]/gi, '-')}`;
|
|
579
|
+
const input = Object.assign(document.createElement('input'), { className: 'form-check-input', type: 'radio', name: 'doorbell', id, disabled: !d.availableToPair });
|
|
580
|
+
input.addEventListener('change', () => {
|
|
581
|
+
linkChoice = d.id;
|
|
582
|
+
$('link-form').hidden = false;
|
|
583
|
+
});
|
|
584
|
+
const label = Object.assign(document.createElement('label'), { className: 'form-check-label', htmlFor: id });
|
|
585
|
+
label.textContent = d.availableToPair ? d.name : `${d.name} (still paired: remove it from the Home app first)`;
|
|
586
|
+
const row = Object.assign(document.createElement('div'), { className: 'form-check' });
|
|
587
|
+
row.append(input, label);
|
|
588
|
+
box.append(row);
|
|
589
|
+
}
|
|
590
|
+
const only = doorbells.filter(d => d.availableToPair);
|
|
591
|
+
if (only.length === 1) {
|
|
592
|
+
box.querySelector('input:not(:disabled)').click();
|
|
593
|
+
}
|
|
594
|
+
} catch (error) {
|
|
595
|
+
box.textContent = '';
|
|
596
|
+
linkError(error.message);
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
$('link-form').addEventListener('submit', async (event) => {
|
|
601
|
+
event.preventDefault();
|
|
602
|
+
linkError();
|
|
603
|
+
homebridge.showSpinner();
|
|
604
|
+
try {
|
|
605
|
+
await homebridge.request('/doorbell-pair', { id: linkChoice, code: $('link-code').value });
|
|
606
|
+
$('link-code').value = '';
|
|
607
|
+
$('link-form').hidden = true;
|
|
608
|
+
$('link-found').textContent = '';
|
|
609
|
+
homebridge.toast.success('Linked. Restart the bridge to use it.');
|
|
610
|
+
await showLink();
|
|
611
|
+
} catch (error) {
|
|
612
|
+
linkError(error.message);
|
|
613
|
+
} finally {
|
|
614
|
+
homebridge.hideSpinner();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
async function unlink(force) {
|
|
619
|
+
linkError();
|
|
620
|
+
homebridge.showSpinner();
|
|
621
|
+
try {
|
|
622
|
+
await homebridge.request('/doorbell-unpair', { force });
|
|
623
|
+
homebridge.toast.success('Unlinked. Restart the bridge to apply it.');
|
|
624
|
+
await showLink();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
linkError(error.message);
|
|
627
|
+
$('link-forget').hidden = false;
|
|
628
|
+
} finally {
|
|
629
|
+
homebridge.hideSpinner();
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
$('link-unpair').addEventListener('click', () => unlink(false));
|
|
633
|
+
$('link-forget').addEventListener('click', () => unlink(true));
|
|
634
|
+
|
|
484
635
|
await showStatus();
|
|
485
636
|
await showSettings();
|
|
486
637
|
await showKeypad();
|
package/homebridge-ui/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
8
8
|
|
|
9
9
|
import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
|
|
10
10
|
|
|
11
|
+
import { findDoorbells, pairDoorbell, readPairing, unpairDoorbell } from '../lib/camera/hap-pairing.js';
|
|
11
12
|
import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn, SignInError } from '../lib/auth.js';
|
|
12
13
|
|
|
13
14
|
class UiServer extends HomebridgePluginUiServer {
|
|
@@ -92,6 +93,46 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
92
93
|
}
|
|
93
94
|
});
|
|
94
95
|
|
|
96
|
+
// The doorbell link: pairing with the doorbell's own HomeKit server. The setup code is used once
|
|
97
|
+
// and neither logged nor kept.
|
|
98
|
+
const pairingPath = () => join(this.homebridgeStoragePath, 'ecobee-security', 'doorbell-pairing.json');
|
|
99
|
+
|
|
100
|
+
this.onRequest('/doorbell-link', async () => {
|
|
101
|
+
const pairing = readPairing(pairingPath());
|
|
102
|
+
let link;
|
|
103
|
+
let store = {};
|
|
104
|
+
try {
|
|
105
|
+
store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
|
|
106
|
+
link = store.doorbellLink;
|
|
107
|
+
} catch {
|
|
108
|
+
// not started yet
|
|
109
|
+
}
|
|
110
|
+
// hasDoorbell stays undefined until the plugin has looked at the account.
|
|
111
|
+
return { paired: Boolean(pairing), id: pairing?.id, state: pairing ? link?.state : undefined, hasDoorbell: store.hasDoorbell, name: store.doorbellName };
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.onRequest('/doorbell-find', () => findDoorbells(5).catch((error) => {
|
|
115
|
+
throw new RequestError(`Could not look for doorbells: ${error.message}`);
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
this.onRequest('/doorbell-pair', async ({ id, code } = {}) => {
|
|
119
|
+
try {
|
|
120
|
+
await pairDoorbell({ id: String(id ?? ''), code, path: pairingPath() });
|
|
121
|
+
return { paired: true };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new RequestError(error.message);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
this.onRequest('/doorbell-unpair', async ({ force } = {}) => {
|
|
128
|
+
try {
|
|
129
|
+
await unpairDoorbell({ path: pairingPath(), force: force === true });
|
|
130
|
+
return { paired: false };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new RequestError(`The doorbell could not be reached to remove the link (${error.message}).`, { code: 'unreachable' });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
95
136
|
this.ready();
|
|
96
137
|
}
|
|
97
138
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Sensors the doorbell bridges to HomeKit (an ecobee SmartSensor, say), re-published as platform
|
|
2
|
+
// accessories on this bridge, with their values taken from the doorbell link.
|
|
3
|
+
|
|
4
|
+
const OFFLINE_AFTER = 60 * 1000;
|
|
5
|
+
const CHECK_EVERY = 5 * 1000;
|
|
6
|
+
|
|
7
|
+
export const HAP_BRIDGED = 'hap-bridged';
|
|
8
|
+
|
|
9
|
+
export class BridgedSensors {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {import('homebridge').API} options.api
|
|
13
|
+
* @param {import('homebridge').Logging} options.log
|
|
14
|
+
* @param {import('./hap-link.js').DoorbellHapLink} options.link
|
|
15
|
+
* @param {string} options.pluginName
|
|
16
|
+
* @param {string} options.platformName
|
|
17
|
+
* @param {import('homebridge').PlatformAccessory[]} options.cached
|
|
18
|
+
* @param {string} options.version
|
|
19
|
+
*/
|
|
20
|
+
constructor({ api, log, link, pluginName, platformName, cached, version }) {
|
|
21
|
+
this.api = api;
|
|
22
|
+
this.log = log;
|
|
23
|
+
this.link = link;
|
|
24
|
+
this.pluginName = pluginName;
|
|
25
|
+
this.platformName = platformName;
|
|
26
|
+
this.version = version;
|
|
27
|
+
/** @type {Map<string, import('homebridge').PlatformAccessory>} */
|
|
28
|
+
this.accessories = new Map(cached.map(a => [a.UUID, a]));
|
|
29
|
+
/** key ("aid.iid") -> Characteristic */
|
|
30
|
+
this.characteristics = new Map();
|
|
31
|
+
this.offline = false;
|
|
32
|
+
const { Characteristic } = api.hap;
|
|
33
|
+
// The sensor characteristics worth mirroring, by short HAP UUID.
|
|
34
|
+
this.mirrored = new Map([
|
|
35
|
+
['6A', Characteristic.ContactSensorState],
|
|
36
|
+
['22', Characteristic.MotionDetected],
|
|
37
|
+
['71', Characteristic.OccupancyDetected],
|
|
38
|
+
['75', Characteristic.StatusActive],
|
|
39
|
+
['79', Characteristic.StatusLowBattery],
|
|
40
|
+
['68', Characteristic.BatteryLevel],
|
|
41
|
+
['8F', Characteristic.ChargingState],
|
|
42
|
+
]);
|
|
43
|
+
const { Service } = api.hap;
|
|
44
|
+
this.serviceTypes = new Map([
|
|
45
|
+
['80', Service.ContactSensor],
|
|
46
|
+
['85', Service.MotionSensor],
|
|
47
|
+
['86', Service.OccupancySensor],
|
|
48
|
+
['96', Service.Battery],
|
|
49
|
+
]);
|
|
50
|
+
link.on('accessories', list => this.update(list));
|
|
51
|
+
link.on('characteristic', change => this.changed(change));
|
|
52
|
+
link.on('unpaired', () => this.removeAll());
|
|
53
|
+
this.timer = setInterval(() => this.checkOnline(), CHECK_EVERY);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
stop() {
|
|
57
|
+
clearInterval(this.timer);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
update(list) {
|
|
61
|
+
const { hap } = this.api;
|
|
62
|
+
const { Service, Characteristic } = hap;
|
|
63
|
+
const wanted = new Set();
|
|
64
|
+
this.characteristics.clear();
|
|
65
|
+
for (const info of list) {
|
|
66
|
+
const uuid = hap.uuid.generate(`ecobee-security:hap:${this.link.pairing?.id}:${info.aid}`);
|
|
67
|
+
wanted.add(uuid);
|
|
68
|
+
const name = info.name || 'Doorbell sensor';
|
|
69
|
+
let accessory = this.accessories.get(uuid);
|
|
70
|
+
const isNew = !accessory;
|
|
71
|
+
if (isNew) {
|
|
72
|
+
accessory = new this.api.platformAccessory(name, uuid);
|
|
73
|
+
accessory.context.kind = HAP_BRIDGED;
|
|
74
|
+
this.accessories.set(uuid, accessory);
|
|
75
|
+
}
|
|
76
|
+
accessory.displayName = name;
|
|
77
|
+
accessory.getService(Service.AccessoryInformation)
|
|
78
|
+
.setCharacteristic(Characteristic.Name, name)
|
|
79
|
+
.setCharacteristic(Characteristic.Manufacturer, info.manufacturer || 'ecobee')
|
|
80
|
+
.setCharacteristic(Characteristic.Model, info.model || 'Sensor')
|
|
81
|
+
.setCharacteristic(Characteristic.SerialNumber, info.serial || `${info.aid}`)
|
|
82
|
+
.setCharacteristic(Characteristic.FirmwareRevision, info.firmware || this.version);
|
|
83
|
+
|
|
84
|
+
const keep = new Set();
|
|
85
|
+
for (const svc of info.services) {
|
|
86
|
+
const Type = this.serviceTypes.get(svc.type);
|
|
87
|
+
if (!Type) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const subtype = String(svc.iid);
|
|
91
|
+
const serviceName = svc.name || name;
|
|
92
|
+
const service = accessory.getServiceById(Type, subtype) ?? accessory.addService(Type, serviceName, subtype);
|
|
93
|
+
service.setCharacteristic(Characteristic.Name, serviceName);
|
|
94
|
+
keep.add(service);
|
|
95
|
+
for (const c of svc.characteristics) {
|
|
96
|
+
const Class = this.mirrored.get(c.type);
|
|
97
|
+
if (!Class || c.value === undefined || c.value === null) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const characteristic = service.getCharacteristic(Class);
|
|
101
|
+
characteristic.updateValue(this.convert(Class, c.value));
|
|
102
|
+
this.characteristics.set(c.key, characteristic);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const stale of accessory.services.filter(s => s.UUID !== Service.AccessoryInformation.UUID && !keep.has(s))) {
|
|
106
|
+
accessory.removeService(stale);
|
|
107
|
+
}
|
|
108
|
+
if (isNew) {
|
|
109
|
+
this.api.registerPlatformAccessories(this.pluginName, this.platformName, [accessory]);
|
|
110
|
+
this.log.info(`"${name}" (bridged by the doorbell) added to HomeKit.`);
|
|
111
|
+
} else {
|
|
112
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const gone = [...this.accessories.entries()].filter(([uuid]) => !wanted.has(uuid));
|
|
116
|
+
if (gone.length) {
|
|
117
|
+
this.api.unregisterPlatformAccessories(this.pluginName, this.platformName, gone.map(([, a]) => a));
|
|
118
|
+
gone.forEach(([uuid, a]) => {
|
|
119
|
+
this.accessories.delete(uuid);
|
|
120
|
+
this.log.info(`"${a.displayName}" is no longer bridged by the doorbell; removed from HomeKit.`);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
this.offline = false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Without a pairing there is nothing to mirror: take the accessories out of HomeKit. */
|
|
127
|
+
removeAll() {
|
|
128
|
+
if (!this.accessories.size) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
this.api.unregisterPlatformAccessories(this.pluginName, this.platformName, [...this.accessories.values()]);
|
|
132
|
+
this.log.info('The doorbell link was removed; the sensors it bridged are removed from HomeKit.');
|
|
133
|
+
this.accessories.clear();
|
|
134
|
+
this.characteristics.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
convert(Class, value) {
|
|
138
|
+
const { Characteristic } = this.api.hap;
|
|
139
|
+
return Class === Characteristic.MotionDetected || Class === Characteristic.StatusActive ? Boolean(value) : Number(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
changed({ key, value, type }) {
|
|
143
|
+
const characteristic = this.characteristics.get(key);
|
|
144
|
+
if (!characteristic || value === null || value === undefined) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
characteristic.updateValue(this.convert(this.mirrored.get(type), value));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Down for a minute: the sensors show No Response until the link is back (the next change or read). */
|
|
151
|
+
checkOnline() {
|
|
152
|
+
const offline = !this.link.up && this.link.paired && this.link.downFor() > OFFLINE_AFTER;
|
|
153
|
+
if (offline === this.offline) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.offline = offline;
|
|
157
|
+
const error = new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
158
|
+
for (const accessory of this.accessories.values()) {
|
|
159
|
+
for (const service of accessory.services) {
|
|
160
|
+
for (const characteristic of service.characteristics) {
|
|
161
|
+
if ([...this.mirrored.values()].some(Class => characteristic.UUID === Class.UUID) && service.UUID !== this.api.hap.Service.AccessoryInformation.UUID) {
|
|
162
|
+
characteristic.updateValue(offline ? error : characteristic.value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (offline) {
|
|
168
|
+
this.log.warn('The doorbell link has been down for a minute; the sensors it bridges show No Response until it is back.');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|