homebridge-ecobee-security 0.7.1 → 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 +66 -0
- package/README.md +267 -21
- package/config.schema.json +43 -6
- package/homebridge-ui/public/index.html +227 -34
- package/homebridge-ui/server.js +51 -0
- package/lib/airquality.js +56 -2
- 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 +226 -5
- package/lib/platform.js +349 -3
- package/lib/security.js +194 -20
- 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">
|
|
@@ -64,46 +63,23 @@
|
|
|
64
63
|
<input class="form-control" type="text" id="s-name" data-key="name" data-type="text" data-default="Security System">
|
|
65
64
|
<div class="hint mt-1">Name shown in the Home app.</div>
|
|
66
65
|
</div>
|
|
67
|
-
<div class="mb-3">
|
|
68
|
-
<label for="s-homeId" class="form-label">Home
|
|
69
|
-
<
|
|
70
|
-
<div class="hint mt-1">
|
|
66
|
+
<div class="mb-3" id="home-box" hidden>
|
|
67
|
+
<label for="s-homeId" class="form-label">Home</label>
|
|
68
|
+
<select class="form-select" id="s-homeId" data-key="homeId" data-type="select" data-default=""></select>
|
|
69
|
+
<div class="hint mt-1">Your ecobee account has more than one home. Pick the one whose security system to add.</div>
|
|
71
70
|
</div>
|
|
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
|
|
|
@@ -148,6 +124,96 @@
|
|
|
148
124
|
</div>
|
|
149
125
|
</div>
|
|
150
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
|
+
|
|
151
217
|
<script>
|
|
152
218
|
(async () => {
|
|
153
219
|
const PLATFORM = 'EcobeeSecurityPlatform';
|
|
@@ -279,6 +345,9 @@
|
|
|
279
345
|
const signedIn = Boolean(block?.refreshToken);
|
|
280
346
|
$('system-card').hidden = !signedIn;
|
|
281
347
|
$('sensors-card').hidden = !signedIn;
|
|
348
|
+
const link = signedIn ? await homebridge.request('/doorbell-link').catch(() => ({})) : {};
|
|
349
|
+
$('camera-card').hidden = !signedIn || link.hasDoorbell === false;
|
|
350
|
+
await showHomes(block);
|
|
282
351
|
for (const input of controls) {
|
|
283
352
|
const value = block?.[input.dataset.key];
|
|
284
353
|
if (input.dataset.type === 'bool') {
|
|
@@ -288,6 +357,30 @@
|
|
|
288
357
|
}
|
|
289
358
|
}
|
|
290
359
|
$('co2-box').hidden = block?.airQuality === false;
|
|
360
|
+
$('camera-box').hidden = block?.doorbellCamera !== true;
|
|
361
|
+
if (block?.doorbellCamera === true) {
|
|
362
|
+
await showLink();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The home list, from what the plugin last saw. Only offered when there's a choice: one home (or
|
|
368
|
+
* none seen yet) hides it, unless a home is already set.
|
|
369
|
+
*/
|
|
370
|
+
async function showHomes(block) {
|
|
371
|
+
const homes = await homebridge.request('/homes').catch(() => []);
|
|
372
|
+
const select = $('s-homeId');
|
|
373
|
+
select.textContent = '';
|
|
374
|
+
const option = (value, text) => select.append(Object.assign(document.createElement('option'), { value, textContent: text }));
|
|
375
|
+
option('', 'Automatic (the first home with Smart Security)');
|
|
376
|
+
for (const home of homes) {
|
|
377
|
+
option(home.id, home.security ? home.name : `${home.name} (no Smart Security)`);
|
|
378
|
+
}
|
|
379
|
+
const set = block?.homeId;
|
|
380
|
+
if (set && !homes.some(h => h.id === set)) {
|
|
381
|
+
option(set, homes.length ? `${set} (not on this account)` : set);
|
|
382
|
+
}
|
|
383
|
+
$('home-box').hidden = homes.length < 2 && !set;
|
|
291
384
|
}
|
|
292
385
|
|
|
293
386
|
/** Write one setting. Top-level keys are cleared with undefined, never deleted (the UI merges shallowly). */
|
|
@@ -305,16 +398,21 @@
|
|
|
305
398
|
await setSetting(key, input.checked);
|
|
306
399
|
if (key === 'airQuality') {
|
|
307
400
|
$('co2-box').hidden = !input.checked;
|
|
401
|
+
} else if (key === 'doorbellCamera') {
|
|
402
|
+
$('camera-box').hidden = !input.checked;
|
|
403
|
+
if (input.checked) {
|
|
404
|
+
await showLink();
|
|
405
|
+
}
|
|
308
406
|
}
|
|
309
407
|
} else if (type === 'int') {
|
|
310
408
|
const number = input.value.trim() === '' ? undefined : Number(input.value);
|
|
311
|
-
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)));
|
|
312
410
|
input.classList.toggle('is-invalid', !valid);
|
|
313
411
|
if (valid) {
|
|
314
412
|
await setSetting(key, number);
|
|
315
413
|
}
|
|
316
414
|
} else if (type === 'select') {
|
|
317
|
-
await setSetting(key, input.value);
|
|
415
|
+
await setSetting(key, input.value === '' ? undefined : input.value);
|
|
318
416
|
} else {
|
|
319
417
|
await setSetting(key, input.value.trim() === '' ? undefined : input.value.trim());
|
|
320
418
|
}
|
|
@@ -439,6 +537,101 @@
|
|
|
439
537
|
await showKeypad();
|
|
440
538
|
});
|
|
441
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
|
+
|
|
442
635
|
await showStatus();
|
|
443
636
|
await showSettings();
|
|
444
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 {
|
|
@@ -82,6 +83,56 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
82
83
|
}
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
// The homes on the account, as the plugin last saw them (only that list leaves state.json).
|
|
87
|
+
this.onRequest('/homes', async () => {
|
|
88
|
+
try {
|
|
89
|
+
const store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
|
|
90
|
+
return store.homes ?? [];
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
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
|
+
|
|
85
136
|
this.ready();
|
|
86
137
|
}
|
|
87
138
|
|
package/lib/airquality.js
CHANGED
|
@@ -26,6 +26,7 @@ export class AirQualitySensors {
|
|
|
26
26
|
this.C = api.hap.Characteristic;
|
|
27
27
|
this.sensors = new Map(); // thermostat id -> { accessory, air, co2 }
|
|
28
28
|
this.failures = 0;
|
|
29
|
+
this.offline = false; // after repeated failed reads the sensors show No Response
|
|
29
30
|
this.stopped = false;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -97,21 +98,33 @@ export class AirQualitySensors {
|
|
|
97
98
|
service.addCharacteristic(C.StatusFault);
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
const sensor = { accessory, air, co2, name: thermostat.name, out: false };
|
|
102
|
+
for (const [service, characteristic] of this.readings(sensor)) {
|
|
103
|
+
service.getCharacteristic(characteristic).onGet(() => {
|
|
104
|
+
if (this.offline || sensor.out) {
|
|
105
|
+
throw this.noResponse();
|
|
106
|
+
}
|
|
107
|
+
return service.getCharacteristic(characteristic).value;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return sensor;
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
async poll() {
|
|
104
114
|
try {
|
|
105
|
-
|
|
115
|
+
const thermostats = await this.cloud.getAirQuality(this.homeId);
|
|
106
116
|
if (this.failures >= FAULT_AFTER) {
|
|
107
117
|
this.log.info('Air quality readings are back.');
|
|
108
118
|
}
|
|
109
119
|
this.failures = 0;
|
|
110
120
|
this.setFault(false);
|
|
121
|
+
this.setOffline(false);
|
|
122
|
+
this.apply(thermostats);
|
|
111
123
|
} catch (error) {
|
|
112
124
|
this.failures += 1;
|
|
113
125
|
if (this.failures === FAULT_AFTER) {
|
|
114
126
|
this.setFault(true);
|
|
127
|
+
this.setOffline(true);
|
|
115
128
|
}
|
|
116
129
|
if (!(error instanceof SignInRequiredError)) {
|
|
117
130
|
this.log[this.failures >= FAULT_AFTER ? 'warn' : 'debug'](`Could not read air quality (${error.message}).`);
|
|
@@ -128,6 +141,37 @@ export class AirQualitySensors {
|
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
noResponse() {
|
|
145
|
+
return new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The readings of one sensor accessory, as [service, characteristic] pairs. */
|
|
149
|
+
readings({ air, co2 }) {
|
|
150
|
+
const C = this.C;
|
|
151
|
+
return [[air, C.AirQuality], [air, C.VOCDensity], [co2, C.CarbonDioxideLevel], [co2, C.CarbonDioxideDetected]];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stale readings show as No Response (like the security tile) once reads have failed for a while.
|
|
156
|
+
* Back online, the last values are put back until apply() brings the new ones.
|
|
157
|
+
*/
|
|
158
|
+
setOffline(on) {
|
|
159
|
+
if (this.offline === on) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.offline = on;
|
|
163
|
+
for (const sensor of this.sensors.values()) {
|
|
164
|
+
this.show(sensor, on || sensor.out);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** No Response for one sensor accessory's readings, or their last values. */
|
|
169
|
+
show(sensor, noResponse) {
|
|
170
|
+
for (const [service, characteristic] of this.readings(sensor)) {
|
|
171
|
+
service.updateCharacteristic(characteristic, noResponse ? this.noResponse() : service.getCharacteristic(characteristic).value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
apply(thermostats) {
|
|
132
176
|
const { AirQuality, VOCDensity, CarbonDioxideLevel, CarbonDioxideDetected } = this.C;
|
|
133
177
|
for (const t of thermostats) {
|
|
@@ -137,6 +181,16 @@ export class AirQualitySensors {
|
|
|
137
181
|
}
|
|
138
182
|
const aq = t.airQuality ?? {};
|
|
139
183
|
const runtime = t.runtime ?? {};
|
|
184
|
+
// ecobee's cloud can't reach this thermostat: its readings are old.
|
|
185
|
+
const out = runtime.connected === false;
|
|
186
|
+
if (out !== sensor.out) {
|
|
187
|
+
sensor.out = out;
|
|
188
|
+
this.log.debug(`Air quality, ${sensor.name}: ${out ? "ecobee can't reach the thermostat" : 'thermostat reachable again'}.`);
|
|
189
|
+
this.show(sensor, out || this.offline);
|
|
190
|
+
}
|
|
191
|
+
if (out) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
140
194
|
sensor.air.updateCharacteristic(AirQuality, aq.isCalibrating ? AirQuality.UNKNOWN : level(aq.airQualityScore, AirQuality));
|
|
141
195
|
if (Number.isFinite(runtime.actualVOC) && runtime.actualVOC >= 0) {
|
|
142
196
|
sensor.air.updateCharacteristic(VOCDensity, Math.min(runtime.actualVOC, 10000));
|
|
@@ -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
|
+
}
|