homebridge-ecobee-security 0.2.0 → 0.4.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 +21 -0
- package/README.md +36 -20
- package/bin/ecobee-login.js +50 -36
- package/config.schema.json +54 -4
- package/homebridge-ui/public/index.html +182 -0
- package/homebridge-ui/server.js +66 -0
- package/lib/airquality.js +186 -0
- package/lib/auth.js +121 -0
- package/lib/ecobee.js +40 -0
- package/lib/platform.js +61 -1
- package/lib/security.js +138 -24
- package/lib/version.js +4 -0
- package/package.json +6 -2
package/lib/security.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ArmedState, SignInRequiredError } from './ecobee.js';
|
|
2
|
+
import { VERSION } from './version.js';
|
|
2
3
|
|
|
3
4
|
// HomeKit SecuritySystemCurrentState / TargetState values.
|
|
4
5
|
const Hk = {
|
|
@@ -11,9 +12,11 @@ const Hk = {
|
|
|
11
12
|
|
|
12
13
|
const HK_NAMES = ['Armed Stay', 'Armed Away', 'Armed Night', 'Disarmed', 'Alarm triggered'];
|
|
13
14
|
|
|
15
|
+
// ecobee has no Night mode; with `offerNight`, Night arms Stay.
|
|
14
16
|
const TO_ECOBEE = {
|
|
15
17
|
[Hk.STAY]: ArmedState.STAY,
|
|
16
18
|
[Hk.AWAY]: ArmedState.AWAY,
|
|
19
|
+
[Hk.NIGHT]: ArmedState.STAY,
|
|
17
20
|
[Hk.DISARMED]: ArmedState.DISARMED,
|
|
18
21
|
};
|
|
19
22
|
|
|
@@ -23,9 +26,12 @@ const FROM_ECOBEE = {
|
|
|
23
26
|
[ArmedState.DISARMED]: Hk.DISARMED,
|
|
24
27
|
};
|
|
25
28
|
|
|
26
|
-
// Incident statuses that mean the alarm is over. Anything else counts as
|
|
29
|
+
// Incident statuses that mean the alarm is over. Anything else counts as open.
|
|
27
30
|
const CLOSED_INCIDENT = /RESOLV|CLOS|CANCEL|DISMISS|END|FALSE|COMPLETE|EXPIRE/i;
|
|
28
31
|
|
|
32
|
+
// Entry delay (seconds) to assume when ecobee's settings don't say.
|
|
33
|
+
const DEFAULT_ENTRY_DELAY = 30;
|
|
34
|
+
|
|
29
35
|
const MAX_BACKOFF = 5 * 60 * 1000;
|
|
30
36
|
const FAULT_AFTER = 3;
|
|
31
37
|
|
|
@@ -38,10 +44,15 @@ export class SecuritySystem {
|
|
|
38
44
|
* @param {import('./ecobee.js').EcobeeCloud} options.cloud
|
|
39
45
|
* @param {object} options.home first GraphQL home object
|
|
40
46
|
* @param {number} options.pollInterval seconds
|
|
41
|
-
* @param {object} options.store persisted plugin state (`hold`
|
|
47
|
+
* @param {object} options.store persisted plugin state (`hold` and `night` live here)
|
|
42
48
|
* @param {() => void} options.save persist `store`
|
|
49
|
+
* @param {boolean} [options.offerNight] offer HomeKit's Night, which arms ecobee's Stay
|
|
50
|
+
* @param {boolean} [options.alarmAfterEntryDelay] show Alarm triggered only once an intrusion
|
|
51
|
+
* incident has outlasted the entry delay, instead of as soon as ecobee opens it
|
|
52
|
+
* @param {import('homebridge').PlatformAccessory} [options.smokeAccessory] separate accessory for a Smoke
|
|
53
|
+
* Sensor that shows ecobee's smoke-alarm incidents (none: no smoke sensor)
|
|
43
54
|
*/
|
|
44
|
-
constructor({ api, log, accessory, cloud, home, pollInterval, store, save }) {
|
|
55
|
+
constructor({ api, log, accessory, cloud, home, pollInterval, store, save, offerNight = false, alarmAfterEntryDelay = false, smokeAccessory }) {
|
|
45
56
|
this.api = api;
|
|
46
57
|
this.store = store;
|
|
47
58
|
this.save = save;
|
|
@@ -54,6 +65,10 @@ export class SecuritySystem {
|
|
|
54
65
|
this.failures = 0;
|
|
55
66
|
this.commandAt = 0;
|
|
56
67
|
this.stopped = false;
|
|
68
|
+
this.offerNight = offerNight;
|
|
69
|
+
this.alarmAfterEntryDelay = alarmAfterEntryDelay;
|
|
70
|
+
this.incidents = [];
|
|
71
|
+
this.firstSeen = new Map();
|
|
57
72
|
|
|
58
73
|
const { Service, Characteristic } = api.hap;
|
|
59
74
|
this.C = Characteristic;
|
|
@@ -61,19 +76,49 @@ export class SecuritySystem {
|
|
|
61
76
|
accessory.getService(Service.AccessoryInformation)
|
|
62
77
|
.setCharacteristic(Characteristic.Manufacturer, 'ecobee')
|
|
63
78
|
.setCharacteristic(Characteristic.Model, 'Smart Security')
|
|
64
|
-
.setCharacteristic(Characteristic.SerialNumber, home.id)
|
|
79
|
+
.setCharacteristic(Characteristic.SerialNumber, home.id)
|
|
80
|
+
.setCharacteristic(Characteristic.FirmwareRevision, VERSION);
|
|
65
81
|
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
// HomeKit keeps a control's list of modes from when it was first published, so turning Night
|
|
83
|
+
// on or off only reaches the Home app as a new service (subtype 'night' or none). The old one
|
|
84
|
+
// is removed, which means scenes and automations that used it have to be set up again.
|
|
85
|
+
const subtype = offerNight ? 'night' : undefined;
|
|
86
|
+
const stale = accessory.services.filter(s => s.UUID === Service.SecuritySystem.UUID && s.subtype !== subtype);
|
|
87
|
+
if (stale.length) {
|
|
88
|
+
stale.forEach(s => accessory.removeService(s));
|
|
89
|
+
log.warn(`"Offer Night" was turned ${offerNight ? 'on' : 'off'}, so the security system was recreated in HomeKit `
|
|
90
|
+
+ 'to show it. Set up again any Home app scenes and automations that use it.');
|
|
91
|
+
}
|
|
92
|
+
this.service = accessory.services.find(s => s.UUID === Service.SecuritySystem.UUID)
|
|
93
|
+
?? accessory.addService(Service.SecuritySystem, accessory.displayName, subtype);
|
|
68
94
|
this.service.getCharacteristic(Characteristic.SecuritySystemCurrentState)
|
|
69
|
-
.setProps({ validValues: [Hk.STAY, Hk.AWAY, Hk.DISARMED, Hk.TRIGGERED] });
|
|
95
|
+
.setProps({ validValues: offerNight ? [Hk.STAY, Hk.AWAY, Hk.NIGHT, Hk.DISARMED, Hk.TRIGGERED] : [Hk.STAY, Hk.AWAY, Hk.DISARMED, Hk.TRIGGERED] });
|
|
70
96
|
this.service.getCharacteristic(Characteristic.SecuritySystemTargetState)
|
|
71
|
-
.setProps({ validValues: [Hk.STAY, Hk.AWAY, Hk.DISARMED] })
|
|
97
|
+
.setProps({ validValues: offerNight ? [Hk.STAY, Hk.AWAY, Hk.NIGHT, Hk.DISARMED] : [Hk.STAY, Hk.AWAY, Hk.DISARMED] })
|
|
72
98
|
.onSet(value => this.setTarget(value));
|
|
73
99
|
if (!this.service.testCharacteristic(Characteristic.StatusFault)) {
|
|
74
100
|
this.service.addCharacteristic(Characteristic.StatusFault);
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
// Smoke Sensor for ecobee's smoke-alarm detection, on its own accessory: on the security
|
|
104
|
+
// accessory the Home app merges it into the security tile. (0.4.0 test builds put it there.)
|
|
105
|
+
const merged = accessory.getService(Service.SmokeSensor);
|
|
106
|
+
if (merged) {
|
|
107
|
+
accessory.removeService(merged);
|
|
108
|
+
}
|
|
109
|
+
if (smokeAccessory) {
|
|
110
|
+
smokeAccessory.getService(Service.AccessoryInformation)
|
|
111
|
+
.setCharacteristic(Characteristic.Manufacturer, 'ecobee')
|
|
112
|
+
.setCharacteristic(Characteristic.Model, 'Smart Security smoke detection')
|
|
113
|
+
.setCharacteristic(Characteristic.SerialNumber, `${home.id}-smoke`)
|
|
114
|
+
.setCharacteristic(Characteristic.FirmwareRevision, VERSION);
|
|
115
|
+
this.smoke = smokeAccessory.getService(Service.SmokeSensor)
|
|
116
|
+
?? smokeAccessory.addService(Service.SmokeSensor, smokeAccessory.displayName);
|
|
117
|
+
if (!this.smoke.testCharacteristic(Characteristic.StatusFault)) {
|
|
118
|
+
this.smoke.addCharacteristic(Characteristic.StatusFault);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
77
122
|
this.apply(home);
|
|
78
123
|
}
|
|
79
124
|
|
|
@@ -84,6 +129,7 @@ export class SecuritySystem {
|
|
|
84
129
|
stop() {
|
|
85
130
|
this.stopped = true;
|
|
86
131
|
clearTimeout(this.timer);
|
|
132
|
+
clearTimeout(this.alarmTimer);
|
|
87
133
|
}
|
|
88
134
|
|
|
89
135
|
schedule(delay) {
|
|
@@ -137,7 +183,9 @@ export class SecuritySystem {
|
|
|
137
183
|
|
|
138
184
|
setFault(on) {
|
|
139
185
|
const { StatusFault } = this.C;
|
|
140
|
-
|
|
186
|
+
const value = on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT;
|
|
187
|
+
this.service.updateCharacteristic(StatusFault, value);
|
|
188
|
+
this.smoke?.updateCharacteristic(StatusFault, value);
|
|
141
189
|
}
|
|
142
190
|
|
|
143
191
|
/** Apply a GraphQL home (or just its monitoring object) to HomeKit. */
|
|
@@ -156,10 +204,19 @@ export class SecuritySystem {
|
|
|
156
204
|
return;
|
|
157
205
|
}
|
|
158
206
|
|
|
159
|
-
|
|
207
|
+
let armed = FROM_ECOBEE[this.monitoring.armedState] ?? Hk.DISARMED;
|
|
160
208
|
const desired = this.monitoring.delayedArmedState?.desiredArmedState;
|
|
161
|
-
|
|
162
|
-
|
|
209
|
+
let target = desired ? (FROM_ECOBEE[desired] ?? armed) : armed;
|
|
210
|
+
// Stay that was armed as Night shows as Night until the state moves on.
|
|
211
|
+
if (this.store.night && (!this.offerNight || target !== Hk.STAY)) {
|
|
212
|
+
delete this.store.night;
|
|
213
|
+
this.save();
|
|
214
|
+
}
|
|
215
|
+
if (this.store.night) {
|
|
216
|
+
target = Hk.NIGHT;
|
|
217
|
+
armed = armed === Hk.STAY ? Hk.NIGHT : armed;
|
|
218
|
+
}
|
|
219
|
+
const current = this.alarmSounding() ? Hk.TRIGGERED : armed;
|
|
163
220
|
|
|
164
221
|
const { SecuritySystemCurrentState, SecuritySystemTargetState } = this.C;
|
|
165
222
|
const before = this.current;
|
|
@@ -169,17 +226,22 @@ export class SecuritySystem {
|
|
|
169
226
|
}
|
|
170
227
|
this.current = current;
|
|
171
228
|
this.target = target;
|
|
172
|
-
if (this.store.hold && (target !== Hk.STAY ||
|
|
173
|
-
this.log.info(`Security system changed to ${HK_NAMES[
|
|
229
|
+
if (this.store.hold && (target !== Hk.STAY || this.incidents.length)) {
|
|
230
|
+
this.log.info(`Security system changed to ${HK_NAMES[this.incidents.length ? Hk.TRIGGERED : target]} during the `
|
|
174
231
|
+ `${this.store.hold.by} hold; it won't be put back to Away.`);
|
|
175
232
|
this.dropHold();
|
|
176
233
|
}
|
|
177
234
|
this.service.updateCharacteristic(SecuritySystemTargetState, target);
|
|
178
235
|
this.service.updateCharacteristic(SecuritySystemCurrentState, current);
|
|
236
|
+
if (this.smoke) {
|
|
237
|
+
const { SmokeDetected } = this.C;
|
|
238
|
+
const smoke = this.incidents.some(i => i.detectedSmokeAlarm);
|
|
239
|
+
this.smoke.updateCharacteristic(SmokeDetected, smoke ? SmokeDetected.SMOKE_DETECTED : SmokeDetected.SMOKE_NOT_DETECTED);
|
|
240
|
+
}
|
|
179
241
|
}
|
|
180
242
|
|
|
181
243
|
noteIncidents(incidents) {
|
|
182
|
-
|
|
244
|
+
this.incidents = incidents.filter(i => !CLOSED_INCIDENT.test(i.status ?? ''));
|
|
183
245
|
const summary = JSON.stringify(incidents);
|
|
184
246
|
if (summary !== this.incidentSummary) {
|
|
185
247
|
if (incidents.length || this.incidentSummary !== undefined) {
|
|
@@ -187,7 +249,50 @@ export class SecuritySystem {
|
|
|
187
249
|
}
|
|
188
250
|
this.incidentSummary = summary;
|
|
189
251
|
}
|
|
190
|
-
|
|
252
|
+
// ecobee's incidents carry no time we read, so the entry delay counts from when we first saw one.
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
const ids = new Set(this.incidents.map(i => i.id));
|
|
255
|
+
this.incidents.forEach(i => this.firstSeen.has(i.id) || this.firstSeen.set(i.id, now));
|
|
256
|
+
[...this.firstSeen.keys()].filter(id => !ids.has(id)).forEach(id => this.firstSeen.delete(id));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether HomeKit should show Alarm triggered. By default any open incident counts (ecobee opens
|
|
261
|
+
* one as soon as a door opens while armed). With `alarmAfterEntryDelay` an intrusion only counts
|
|
262
|
+
* once it has outlasted the entry delay without a disarm; smoke counts at once.
|
|
263
|
+
*/
|
|
264
|
+
alarmSounding() {
|
|
265
|
+
clearTimeout(this.alarmTimer);
|
|
266
|
+
if (!this.alarmAfterEntryDelay) {
|
|
267
|
+
return this.incidents.length > 0;
|
|
268
|
+
}
|
|
269
|
+
const now = Date.now();
|
|
270
|
+
const delay = this.entryDelay() * 1000;
|
|
271
|
+
let wait = Infinity;
|
|
272
|
+
for (const incident of this.incidents) {
|
|
273
|
+
const due = (incident.detectedSmokeAlarm ? 0 : this.firstSeen.get(incident.id) + delay) - now;
|
|
274
|
+
if (due <= 0) {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
wait = Math.min(wait, due);
|
|
278
|
+
}
|
|
279
|
+
if (wait < Infinity && !this.stopped) {
|
|
280
|
+
// Re-check when the entry delay runs out, without waiting for the next poll.
|
|
281
|
+
this.alarmTimer = setTimeout(() => this.apply({}), wait + 100);
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
entryDelay() {
|
|
287
|
+
const settings = this.settings?.armedStateSettings;
|
|
288
|
+
const state = this.monitoring?.armedState;
|
|
289
|
+
if (state === ArmedState.AWAY) {
|
|
290
|
+
return settings?.armedAway?.entryDelayDuration ?? DEFAULT_ENTRY_DELAY;
|
|
291
|
+
}
|
|
292
|
+
if (state === ArmedState.STAY) {
|
|
293
|
+
return settings?.armedStay?.entryDelayDuration ?? DEFAULT_ENTRY_DELAY;
|
|
294
|
+
}
|
|
295
|
+
return 0;
|
|
191
296
|
}
|
|
192
297
|
|
|
193
298
|
exitDelay(value) {
|
|
@@ -195,14 +300,14 @@ export class SecuritySystem {
|
|
|
195
300
|
if (value === Hk.AWAY) {
|
|
196
301
|
return settings?.armedAway?.exitDelayDuration ?? 60;
|
|
197
302
|
}
|
|
198
|
-
if (value === Hk.STAY) {
|
|
303
|
+
if (value === Hk.STAY || value === Hk.NIGHT) {
|
|
199
304
|
return settings?.armedStay?.exitDelayDuration ?? 0;
|
|
200
305
|
}
|
|
201
306
|
return 0;
|
|
202
307
|
}
|
|
203
308
|
|
|
204
309
|
async setTarget(value) {
|
|
205
|
-
if (
|
|
310
|
+
if (TO_ECOBEE[value] === undefined || (value === Hk.NIGHT && !this.offerNight)) {
|
|
206
311
|
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
207
312
|
}
|
|
208
313
|
if (this.store.hold) {
|
|
@@ -228,11 +333,20 @@ export class SecuritySystem {
|
|
|
228
333
|
async arm(value, source) {
|
|
229
334
|
const armedState = TO_ECOBEE[value];
|
|
230
335
|
const delay = this.exitDelay(value);
|
|
231
|
-
|
|
336
|
+
const mode = { [Hk.STAY]: 'Stay', [Hk.AWAY]: 'Away', [Hk.NIGHT]: 'Night (ecobee Stay)' }[value];
|
|
337
|
+
this.log.info(`${source}: ${value === Hk.DISARMED ? 'disarm' : `arm ${mode}`}`
|
|
232
338
|
+ (delay ? ` (exit delay ${delay} s)` : ''));
|
|
233
339
|
try {
|
|
234
340
|
const monitoring = await this.cloud.setArmedState(this.homeId, armedState, delay);
|
|
235
341
|
this.commandAt = Date.now();
|
|
342
|
+
if ((value === Hk.NIGHT) !== Boolean(this.store.night)) {
|
|
343
|
+
if (value === Hk.NIGHT) {
|
|
344
|
+
this.store.night = true;
|
|
345
|
+
} else {
|
|
346
|
+
delete this.store.night;
|
|
347
|
+
}
|
|
348
|
+
this.save();
|
|
349
|
+
}
|
|
236
350
|
if (monitoring) {
|
|
237
351
|
this.apply({ monitoring });
|
|
238
352
|
}
|
|
@@ -252,16 +366,16 @@ export class SecuritySystem {
|
|
|
252
366
|
/**
|
|
253
367
|
* Another plugin (the robot vacuum) is about to move around the house. If the system is
|
|
254
368
|
* armed Away (or arming Away), switch it to Stay and remember to put it back. Returns
|
|
255
|
-
* `{ ok, held, state }`: `ok` false means it isn't safe to start (alarm
|
|
256
|
-
* state isn't known yet).
|
|
369
|
+
* `{ ok, held, state }`: `ok` false means it isn't safe to start (an alarm incident is open,
|
|
370
|
+
* or the state isn't known yet).
|
|
257
371
|
*/
|
|
258
372
|
hold(by) {
|
|
259
373
|
return this.serial(async () => {
|
|
260
374
|
if (!this.monitoring || this.failures >= FAULT_AFTER) {
|
|
261
375
|
return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
|
|
262
376
|
}
|
|
263
|
-
if (this.
|
|
264
|
-
return { ok: false, held: false, state: HK_NAMES[this.current], reason: '
|
|
377
|
+
if (this.incidents.length) {
|
|
378
|
+
return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'an alarm incident is open' };
|
|
265
379
|
}
|
|
266
380
|
if (this.store.hold) {
|
|
267
381
|
return { ok: true, held: true, state: HK_NAMES[this.target] };
|
|
@@ -282,7 +396,7 @@ export class SecuritySystem {
|
|
|
282
396
|
if (!this.store.hold) {
|
|
283
397
|
return { released: false, state: HK_NAMES[this.target] };
|
|
284
398
|
}
|
|
285
|
-
if (this.target !== Hk.STAY || this.
|
|
399
|
+
if (this.target !== Hk.STAY || this.incidents.length) {
|
|
286
400
|
this.log.info(`${by} is done, but the security system is ${HK_NAMES[this.current]}; leaving it.`);
|
|
287
401
|
this.dropHold();
|
|
288
402
|
return { released: false, state: HK_NAMES[this.target] };
|
package/lib/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ecobee-security",
|
|
3
3
|
"displayName": "ecobee Smart Security",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "ecobee Smart Security as a HomeKit security system: arm Stay / Away, disarm, alarm state (unofficial, ecobee cloud)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
@@ -32,9 +32,13 @@
|
|
|
32
32
|
"index.js",
|
|
33
33
|
"lib",
|
|
34
34
|
"bin",
|
|
35
|
+
"homebridge-ui",
|
|
35
36
|
"config.schema.json",
|
|
36
37
|
"README.md",
|
|
37
38
|
"CHANGELOG.md",
|
|
38
39
|
"LICENSE"
|
|
39
|
-
]
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@homebridge/plugin-ui-utils": "^2.2.6"
|
|
43
|
+
}
|
|
40
44
|
}
|