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/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 triggered.
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` lives here)
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
- this.service = accessory.getService(Service.SecuritySystem)
67
- ?? accessory.addService(Service.SecuritySystem, accessory.displayName);
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
- this.service.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
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
- const armed = FROM_ECOBEE[this.monitoring.armedState] ?? Hk.DISARMED;
207
+ let armed = FROM_ECOBEE[this.monitoring.armedState] ?? Hk.DISARMED;
160
208
  const desired = this.monitoring.delayedArmedState?.desiredArmedState;
161
- const target = desired ? (FROM_ECOBEE[desired] ?? armed) : armed;
162
- const current = this.triggered ? Hk.TRIGGERED : armed;
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 || current === Hk.TRIGGERED)) {
173
- this.log.info(`Security system changed to ${HK_NAMES[current === Hk.TRIGGERED ? current : target]} during the `
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
- const active = incidents.filter(i => !CLOSED_INCIDENT.test(i.status ?? ''));
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
- this.triggered = active.length > 0;
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 (!TO_ECOBEE[value]) {
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
- this.log.info(`${source}: ${value === Hk.DISARMED ? 'disarm' : `arm ${armedState === ArmedState.STAY ? 'Stay' : 'Away'}`}`
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 triggered, or the
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.current === Hk.TRIGGERED) {
264
- return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'the alarm is triggered' };
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.current === Hk.TRIGGERED) {
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
@@ -0,0 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ /** The plugin version, reported to HomeKit as the accessories' firmware version. */
4
+ export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
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.2.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
  }