homebridge-ecobee-security 0.4.2 → 0.7.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 { sendHalt } from './halt.js';
2
3
  import { VERSION } from './version.js';
3
4
 
4
5
  // HomeKit SecuritySystemCurrentState / TargetState values.
@@ -33,6 +34,8 @@ const CLOSED_INCIDENT = /RESOLV|CLOS|CANCEL|DISMISS|END|FALSE|COMPLETE|EXPIRE/i;
33
34
  const DEFAULT_ENTRY_DELAY = 30;
34
35
 
35
36
  const MAX_BACKOFF = 5 * 60 * 1000;
37
+ // Robots back (or stopped) before an extended exit delay ends: arm this long after, at the earliest.
38
+ const ARM_AFTER_RETURN = 10 * 1000;
36
39
  const FAULT_AFTER = 3;
37
40
 
38
41
  export class SecuritySystem {
@@ -44,7 +47,7 @@ export class SecuritySystem {
44
47
  * @param {import('./ecobee.js').EcobeeCloud} options.cloud
45
48
  * @param {object} options.home first GraphQL home object
46
49
  * @param {number} options.pollInterval seconds
47
- * @param {object} options.store persisted plugin state (`hold` and `night` live here)
50
+ * @param {object} options.store persisted plugin state (`hold`, `out` and `night` live here)
48
51
  * @param {() => void} options.save persist `store`
49
52
  * @param {boolean} [options.offerNight] offer HomeKit's Night, which arms ecobee's Stay
50
53
  * @param {boolean} [options.alarmAfterEntryDelay] show Alarm triggered only once an intrusion
@@ -52,7 +55,8 @@ export class SecuritySystem {
52
55
  * @param {import('homebridge').PlatformAccessory} [options.smokeAccessory] separate accessory for a Smoke
53
56
  * Sensor that shows ecobee's smoke-alarm incidents (none: no smoke sensor)
54
57
  */
55
- constructor({ api, log, accessory, cloud, home, pollInterval, store, save, offerNight = false, alarmAfterEntryDelay = false, smokeAccessory }) {
58
+ constructor({ api, log, accessory, cloud, home, pollInterval, store, save, offerNight = false, alarmAfterEntryDelay = false,
59
+ returnWait = 120, smokeAccessory }) {
56
60
  this.api = api;
57
61
  this.store = store;
58
62
  this.save = save;
@@ -67,6 +71,8 @@ export class SecuritySystem {
67
71
  this.stopped = false;
68
72
  this.offerNight = offerNight;
69
73
  this.alarmAfterEntryDelay = alarmAfterEntryDelay;
74
+ // Extra exit-delay time for robots heading back to their base when Away is armed (ms).
75
+ this.returnWait = Math.max(0, returnWait) * 1000;
70
76
  this.incidents = [];
71
77
  this.firstSeen = new Map();
72
78
 
@@ -228,10 +234,23 @@ export class SecuritySystem {
228
234
  this.target = target;
229
235
  if (this.store.hold && (target !== Hk.STAY || this.incidents.length)) {
230
236
  this.log.info(`Security system changed to ${HK_NAMES[this.incidents.length ? Hk.TRIGGERED : target]} during the `
231
- + `${this.store.hold.by} hold; it won't be put back to Away.`);
237
+ + `${this.holders().join(' and ')} hold; it won't be put back to Away.`);
232
238
  this.dropHold();
233
239
  }
234
- this.service.updateCharacteristic(SecuritySystemTargetState, target);
240
+ // Armed Away some other way (the ecobee app, the keypad, Arm Away Now) with robots out: send them
241
+ // back before the exit delay ends (a keypad's is only seen once armed: stop them now).
242
+ if (target === Hk.AWAY && this.outNames().length) {
243
+ const until = Date.parse(this.monitoring.delayedArmedState?.delayedUntil ?? '');
244
+ const why = `The security system ${until > Date.now() ? 'is arming' : 'was armed'} Away`;
245
+ this.serial(() => this.haltRobots(why, until > Date.now() ? until : undefined)).catch(() => {});
246
+ }
247
+ // Waiting for robots on their way back: over once Away is armed or no longer asked for.
248
+ if (this.store.returning && (target !== Hk.AWAY || armed === Hk.AWAY)) {
249
+ delete this.store.returning;
250
+ this.save();
251
+ }
252
+ // Away asked for in the Home app during a hold waits for the release: show it as arming Away.
253
+ this.service.updateCharacteristic(SecuritySystemTargetState, this.store.hold?.away ? Hk.AWAY : target);
235
254
  this.service.updateCharacteristic(SecuritySystemCurrentState, current);
236
255
  if (this.smoke) {
237
256
  const { SmokeDetected } = this.C;
@@ -310,8 +329,13 @@ export class SecuritySystem {
310
329
  if (TO_ECOBEE[value] === undefined || (value === Hk.NIGHT && !this.offerNight)) {
311
330
  throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
312
331
  }
332
+ // HomeKit can't say who asked (requests come through the home hub), so Away always waits for
333
+ // robots that are out; the Arm Away Now switch is the way to stop them and arm now.
334
+ if (value === Hk.AWAY && this.outNames().length && await this.serial(() => this.deferAway())) {
335
+ return;
336
+ }
313
337
  if (this.store.hold) {
314
- this.log.info(`Home app changed the security system during the ${this.store.hold.by} hold; it won't be put back to Away.`);
338
+ this.log.info(`Home app changed the security system during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
315
339
  this.dropHold();
316
340
  }
317
341
  try {
@@ -323,6 +347,166 @@ export class SecuritySystem {
323
347
  }
324
348
  }
325
349
 
350
+
351
+ /**
352
+ * Away from the Home app while robots are out (say, a scene run from far away while one cleans):
353
+ * the release arms Away anyway, so keep (or start) the hold and let it; Stay meanwhile. Returns
354
+ * true if Away now waits.
355
+ */
356
+ async deferAway() {
357
+ const out = this.outNames();
358
+ if (!out.length || this.incidents.length || (this.store.hold && this.target !== Hk.STAY)) {
359
+ return false;
360
+ }
361
+ if (!this.store.hold) {
362
+ // The robots started while the system wasn't Away, so nothing holds Stay for them yet.
363
+ if (this.target !== Hk.STAY || this.store.night) {
364
+ await this.arm(Hk.STAY, `${out.join(' and ')} out`);
365
+ }
366
+ this.store.hold = { by: out, since: new Date().toISOString() };
367
+ }
368
+ this.store.hold = { ...this.store.hold, away: true };
369
+ this.save();
370
+ this.log.info(`Home app: arm Away — waiting until ${this.holders().join(' and ')} ${this.holders().length > 1 ? 'are' : 'is'} done (security hold). `
371
+ + 'Use the Arm Away Now switch to stop and arm now.');
372
+ this.service.updateCharacteristic(this.C.SecuritySystemTargetState, Hk.AWAY);
373
+ return true;
374
+ }
375
+
376
+ /**
377
+ * The Arm Away Now switch: drop the hold and arm Away (normal exit delay); apply() then sends
378
+ * robots that are out back to their base (or stops them), as for Away from the ecobee app.
379
+ */
380
+ armAwayNow() {
381
+ return this.serial(async () => {
382
+ if (this.store.hold) {
383
+ this.dropHold();
384
+ }
385
+ await this.arm(Hk.AWAY, 'Arm Away Now');
386
+ });
387
+ }
388
+
389
+
390
+ /**
391
+ * A door was unlocked with its keypad: disarm, like Nest Guard with a Nest × Yale lock. Always sent,
392
+ * even if the last poll said Off: it may be up to 10 s old, and disarming twice does no harm.
393
+ * Retried once, since a missed disarm means an alarm.
394
+ */
395
+ keypadDisarm(lock) {
396
+ return this.serial(async () => {
397
+ if (this.store.hold) {
398
+ this.log.info(`${lock} was unlocked during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
399
+ this.dropHold();
400
+ }
401
+ try {
402
+ await this.arm(Hk.DISARMED, `${lock} unlocked with its keypad`);
403
+ } catch {
404
+ await new Promise(resolve => setTimeout(resolve, 2000));
405
+ await this.arm(Hk.DISARMED, `${lock} unlocked with its keypad (retry)`);
406
+ }
407
+ });
408
+ }
409
+
410
+ /** Robots (or other plugins) that asked for a hold and haven't released yet, held or not. */
411
+ outNames() {
412
+ return Object.keys(this.store.out ?? {});
413
+ }
414
+
415
+ /**
416
+ * The system is being armed Away with robots out. With `armAt` (when the motion sensors arm, the
417
+ * end of the exit delay), each robot heads back to its base if it can make it and stops where it
418
+ * is if it doesn't; without it, it stops now (pause, not dock). They're forgotten and the hold
419
+ * dropped right away; a robot that moves again asks for a new hold.
420
+ *
421
+ * Wait for them: ecobee accepts a later end for an exit delay that's counting down, so it's pushed
422
+ * back by `returnWait`, and brought forward again (never before `armAt`) once every robot that
423
+ * headed back has said it's in, or stopped (`/release`).
424
+ */
425
+ async haltRobots(reason, armAt) {
426
+ const out = Object.entries(this.store.out ?? {});
427
+ if (!out.length) {
428
+ return;
429
+ }
430
+ delete this.store.out;
431
+ if (this.store.hold) {
432
+ this.dropHold();
433
+ }
434
+ this.save();
435
+ if (armAt && this.returnWait > 0 && out.some(([, robot]) => robot.halt)) {
436
+ try {
437
+ await this.setExitDelayEnd(armAt + this.returnWait);
438
+ this.log.info(`Exit delay extended by ${Math.round(this.returnWait / 1000)} s so robots can get back to their base.`);
439
+ armAt += this.returnWait;
440
+ this.store.returning = { armAt: armAt - this.returnWait, names: [] };
441
+ this.save();
442
+ } catch (error) {
443
+ this.log.warn(`Could not extend the exit delay (${error.message}); robots have until it ends as usual.`);
444
+ }
445
+ }
446
+ const names = out.map(([by]) => by).join(' and ');
447
+ this.log.info(armAt ? `${reason}: asking ${names} to be back at base or stopped by ${new Date(armAt).toLocaleTimeString()}.`
448
+ : `${reason}: asking ${names} to stop.`);
449
+ await Promise.all(out.map(async ([by, robot]) => {
450
+ if (!robot.halt) {
451
+ this.log.warn(`${by} can't be asked to stop (${robot.plugin ?? 'its plugin'} doesn't support it); it may set off the motion sensors.`);
452
+ return;
453
+ }
454
+ try {
455
+ const result = await sendHalt(robot.halt, by, armAt);
456
+ const what = result.returning ? 'heading back to its base' : result.halted ? 'stopped' : `not stopped (${result.reason ?? 'no reason given'})`;
457
+ this.log[result.halted ? 'info' : 'warn'](`${by}: ${what}.`);
458
+ if (result.returning && this.store.returning) {
459
+ this.store.returning.names.push(by);
460
+ this.save();
461
+ }
462
+ } catch (error) {
463
+ this.log.warn(`Could not ask ${by} to stop (${error.message}); it may set off the motion sensors.`);
464
+ }
465
+ }));
466
+ if (this.store.returning && !this.store.returning.names.length) {
467
+ await this.armWhenBack('No robot is heading back');
468
+ }
469
+ }
470
+
471
+ /** Robots that were sent back to their base and haven't said they're in yet. */
472
+ returningNames() {
473
+ return this.store.returning?.names ?? [];
474
+ }
475
+
476
+ /**
477
+ * Every robot that headed back is in (or stopped): bring the end of the extended exit delay forward
478
+ * again, to its original end, or a few seconds from now if that has passed.
479
+ */
480
+ async armWhenBack(why) {
481
+ const { armAt } = this.store.returning;
482
+ delete this.store.returning;
483
+ this.save();
484
+ const delayed = this.monitoring?.delayedArmedState;
485
+ if (this.monitoring?.armedState === ArmedState.AWAY || delayed?.desiredArmedState !== ArmedState.AWAY) {
486
+ return;
487
+ }
488
+ const until = Math.max(armAt, Date.now() + ARM_AFTER_RETURN);
489
+ if (until >= Date.parse(delayed.delayedUntil ?? '')) {
490
+ return;
491
+ }
492
+ this.log.info(`${why}: arming Away at ${new Date(until).toLocaleTimeString()}.`);
493
+ try {
494
+ await this.setExitDelayEnd(until);
495
+ } catch (error) {
496
+ this.log.warn(`Could not bring the exit delay forward (${error.message}); it ends as extended.`);
497
+ }
498
+ }
499
+
500
+ /** Move the end of an Away exit delay (ecobee takes a new end while it counts down). */
501
+ async setExitDelayEnd(until) {
502
+ const monitoring = await this.cloud.setArmedState(this.homeId, ArmedState.AWAY, 0, until);
503
+ this.commandAt = Date.now();
504
+ if (monitoring) {
505
+ this.apply({ monitoring });
506
+ }
507
+ this.schedule(2000);
508
+ }
509
+
326
510
  /** Run hold/release/Home app commands one at a time. */
327
511
  serial(fn) {
328
512
  const run = this.queue.then(fn, fn);
@@ -358,34 +542,69 @@ export class SecuritySystem {
358
542
  }
359
543
  }
360
544
 
545
+ /** Read the state now (for hold decisions). Resolves false if ecobee can't be read. */
546
+ async refresh() {
547
+ try {
548
+ const home = (await this.cloud.getHomes()).find(h => h.id === this.homeId);
549
+ if (!home) {
550
+ throw new Error('the home is no longer on this ecobee account');
551
+ }
552
+ this.apply(home);
553
+ return true;
554
+ } catch (error) {
555
+ this.log.warn(`Could not read the security state for a hold (${error.message}).`);
556
+ return false;
557
+ }
558
+ }
559
+
361
560
  dropHold() {
362
561
  delete this.store.hold;
363
562
  this.save();
364
563
  }
365
564
 
565
+ /** Who is holding Stay (several robots can be out at once). Up to 0.4.x `by` was a single name. */
566
+ holders() {
567
+ const by = this.store.hold?.by;
568
+ return Array.isArray(by) ? by : by ? [by] : [];
569
+ }
570
+
366
571
  /**
367
572
  * Another plugin (the robot vacuum) is about to move around the house. If the system is
368
573
  * armed Away (or arming Away), switch it to Stay and remember to put it back. Returns
369
574
  * `{ ok, held, state }`: `ok` false means it isn't safe to start (an alarm incident is open,
370
575
  * or the state isn't known yet).
371
576
  */
372
- hold(by) {
577
+ hold(by, { plugin, halt } = {}) {
373
578
  return this.serial(async () => {
374
- if (!this.monitoring || this.failures >= FAULT_AFTER) {
579
+ // Decide on ecobee's live state, not the last poll: a disarm moments ago must not become a Stay.
580
+ if (!(await this.refresh()) || !this.monitoring) {
375
581
  return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
376
582
  }
377
583
  if (this.incidents.length) {
378
584
  return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'an alarm incident is open' };
379
585
  }
586
+ // Remember who's out (and how to stop them) until they release, held or not. Only once Stay is
587
+ // decided: a poll seeing Away in between would otherwise stop this very robot.
588
+ const noteOut = () => {
589
+ this.store.out = { ...this.store.out, [by]: { plugin, halt, since: this.store.out?.[by]?.since ?? new Date().toISOString() } };
590
+ this.save();
591
+ };
380
592
  if (this.store.hold) {
593
+ if (!this.holders().includes(by)) {
594
+ this.store.hold = { ...this.store.hold, by: [...this.holders(), by] };
595
+ this.save();
596
+ this.log.info(`${by} joined the hold; Away comes back once ${this.holders().join(' and ')} are done.`);
597
+ }
598
+ noteOut();
381
599
  return { ok: true, held: true, state: HK_NAMES[this.target] };
382
600
  }
383
601
  if (this.target !== Hk.AWAY) {
602
+ noteOut();
384
603
  return { ok: true, held: false, state: HK_NAMES[this.target] };
385
604
  }
386
605
  await this.arm(Hk.STAY, `${by} hold`);
387
- this.store.hold = { by, since: new Date().toISOString() };
388
- this.save();
606
+ this.store.hold = { by: [by], since: new Date().toISOString() };
607
+ noteOut();
389
608
  return { ok: true, held: true, state: HK_NAMES[this.target] };
390
609
  });
391
610
  }
@@ -393,9 +612,37 @@ export class SecuritySystem {
393
612
  /** The vacuum is back: put the system back to Away if we moved it and nobody has changed it since. */
394
613
  release(by) {
395
614
  return this.serial(async () => {
615
+ // A change since the last poll (say, a disarm in the ecobee app) drops the hold in apply(),
616
+ // so a fresh read is what keeps us from arming Away over it. Can't read it: don't arm.
617
+ if (!(await this.refresh())) {
618
+ throw new Error('could not read the security state; not arming Away');
619
+ }
620
+ if (this.store.out?.[by]) {
621
+ delete this.store.out[by];
622
+ this.save();
623
+ }
624
+ if (this.returningNames().includes(by)) {
625
+ this.store.returning.names = this.returningNames().filter(name => name !== by);
626
+ this.save();
627
+ const others = this.returningNames();
628
+ if (others.length) {
629
+ this.log.info(`${by} is back; still waiting for ${others.join(' and ')}.`);
630
+ } else {
631
+ await this.armWhenBack(`${by} is back`);
632
+ }
633
+ return { released: true, state: HK_NAMES[this.target] };
634
+ }
396
635
  if (!this.store.hold) {
397
636
  return { released: false, state: HK_NAMES[this.target] };
398
637
  }
638
+ const others = this.holders().filter(name => name !== by);
639
+ if (others.length) {
640
+ // Someone else is still out: keep Stay until the last one is done.
641
+ this.store.hold = { ...this.store.hold, by: others };
642
+ this.save();
643
+ this.log.info(`${by} is done; staying in Stay for ${others.join(' and ')}.`);
644
+ return { released: false, state: HK_NAMES[this.target], waiting: others };
645
+ }
399
646
  if (this.target !== Hk.STAY || this.incidents.length) {
400
647
  this.log.info(`${by} is done, but the security system is ${HK_NAMES[this.current]}; leaving it.`);
401
648
  this.dropHold();
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.2",
4
+ "version": "0.7.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",
@@ -41,5 +41,8 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@homebridge/plugin-ui-utils": "^2.2.6"
44
+ },
45
+ "homebridge-security-hold": {
46
+ "socket": "ecobee-security/control.sock"
44
47
  }
45
48
  }