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/CHANGELOG.md +52 -29
- package/README.md +113 -15
- package/config.schema.json +51 -16
- package/homebridge-ui/public/index.html +274 -9
- package/homebridge-ui/server.js +72 -0
- package/lib/control.js +29 -5
- package/lib/ecobee.js +3 -2
- package/lib/halt.js +51 -0
- package/lib/keypad.js +240 -0
- package/lib/platform.js +128 -5
- package/lib/security.js +256 -9
- package/package.json +4 -1
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,
|
|
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.
|
|
237
|
+
+ `${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
232
238
|
this.dropHold();
|
|
233
239
|
}
|
|
234
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"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
|
}
|