homebridge-ecobee-security 0.3.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 +14 -0
- package/README.md +20 -8
- package/config.schema.json +48 -0
- package/lib/airquality.js +186 -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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- `offerNight`: adds Night to the Home app; it arms ecobee's Stay and keeps showing Night. Changing
|
|
6
|
+
the setting recreates the security system in HomeKit (the Home app doesn't pick up new modes on an
|
|
7
|
+
existing control).
|
|
8
|
+
- `alarmTrigger: "entryDelay"`: show Alarm triggered only once an intrusion has outlasted the entry
|
|
9
|
+
delay without a disarm, instead of as soon as ecobee opens the incident. Smoke still shows at once.
|
|
10
|
+
- `smokeSensor` (on by default): a separate Smoke Alarm accessory that detects smoke while ecobee has a smoke-alarm
|
|
11
|
+
incident open.
|
|
12
|
+
- Air quality (`airQuality`, on by default): for ecobee thermostats with an air quality sensor, an
|
|
13
|
+
Air Quality Sensor (level, VOC) and a Carbon Dioxide Sensor (ppm, alert at `co2Alert`, 1000 ppm).
|
|
14
|
+
- The accessory reports the plugin version as its firmware version.
|
|
15
|
+
- The hold API refuses a hold while any alarm incident is open, whichever `alarmTrigger` is set.
|
|
16
|
+
|
|
3
17
|
## 0.3.0
|
|
4
18
|
|
|
5
19
|
- Sign in from the plugin's settings page in the Homebridge UI with your ecobee email, password
|
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# homebridge-ecobee-security
|
|
2
2
|
|
|
3
3
|
Shows **ecobee Smart Security** in Apple Home as a security system: arm **Stay**, arm **Away**,
|
|
4
|
-
**disarm**, and **Alarm triggered** when ecobee has an open intrusion or smoke incident.
|
|
4
|
+
**disarm**, and **Alarm triggered** when ecobee has an open intrusion or smoke incident. It also adds
|
|
5
|
+
**air quality** sensors for ecobee thermostats that have one (ecobee Premium), which ecobee's own
|
|
6
|
+
HomeKit integration leaves out. Works with
|
|
5
7
|
Siri ("set the security system to away") and Home automations. ecobee removed Smart Security from
|
|
6
8
|
its HomeKit integration; this brings it back.
|
|
7
9
|
|
|
@@ -55,12 +57,25 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
55
57
|
"name": "Security System",
|
|
56
58
|
"refreshToken": "…",
|
|
57
59
|
"pollInterval": 10,
|
|
60
|
+
"offerNight": false,
|
|
61
|
+
"alarmTrigger": "incident",
|
|
62
|
+
"smokeSensor": true,
|
|
63
|
+
"airQuality": true,
|
|
64
|
+
"co2Alert": 1000,
|
|
58
65
|
"_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
|
|
59
66
|
}
|
|
60
67
|
```
|
|
61
68
|
|
|
62
69
|
`homeId` is only needed if the account has several homes (the log lists them).
|
|
63
70
|
|
|
71
|
+
| Setting | Default | |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `offerNight` | `false` | Adds Night to the Home app. ecobee has no Night mode, so Night arms Stay and the tile keeps showing Night until the state changes (a Stay armed elsewhere shows as Stay). HomeKit only picks up a changed list of modes on a new control, so changing this setting recreates the security system in the Home app: set up scenes and automations that use it again. |
|
|
74
|
+
| `alarmTrigger` | `"incident"` | When the tile enters **Triggered**: `"incident"` immediately, when ecobee opens an incident as a door or sensor trips while armed (mirrors the ecobee app); `"entryDelay"` only after the entry (disarm) delay runs out without a disarm. Smoke alarms always trigger immediately. |
|
|
75
|
+
| `smokeSensor` | `true` | Adds a separate **Smoke Alarm** accessory (a Smoke Sensor) that detects smoke while ecobee has a smoke-alarm incident open, for automations and Home app smoke notifications. Set to `false` to remove it. |
|
|
76
|
+
| `airQuality` | `true` | For each thermostat with an air quality sensor: an accessory "<thermostat> Air Quality" with an **Air Quality Sensor** (overall level; VOC as ecobee reports it, in ppb, which the Home app labels µg/m³) and a **Carbon Dioxide Sensor** (ppm). Read every 5 minutes. The level follows the ecobee app's Clean–Poor bar: its first two segments are Excellent, then Good, Fair, Inferior, Poor; Unknown while the sensor calibrates. |
|
|
77
|
+
| `co2Alert` | `1000` | CO2 (ppm) at or above which the Carbon Dioxide Sensor reports abnormal levels. |
|
|
78
|
+
|
|
64
79
|
## How it behaves
|
|
65
80
|
|
|
66
81
|
- **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
|
|
@@ -68,13 +83,10 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
68
83
|
(0 by default). Disarm needs no PIN.
|
|
69
84
|
- **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within the poll
|
|
70
85
|
interval (10 s by default), and quickly after an exit delay ends.
|
|
71
|
-
- **Alarm triggered** while ecobee lists an incident
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
- **Siri** calls Stay "Home": "set the security system to Home". Saying "arm … for stay" makes
|
|
76
|
-
Siri pick Away.
|
|
77
|
-
- **Night** isn't offered; ecobee has no Night mode.
|
|
86
|
+
- **Alarm triggered** while ecobee lists an open incident (see `alarmTrigger` above for when it
|
|
87
|
+
starts showing). Disarming clears it. Every change in the incident list is logged.
|
|
88
|
+
- **Siri:** the Home app calls Stay "Home". Asking Siri for Home or for Stay mode both arm Stay.
|
|
89
|
+
- **Night** is only offered with `offerNight`; ecobee has no Night mode of its own.
|
|
78
90
|
- After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
|
|
79
91
|
- **Hold for other plugins:** a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
|
|
80
92
|
(mode 600, homebridge user only) lets another plugin, e.g. homebridge-narwal, ask for a temporary
|
package/config.schema.json
CHANGED
|
@@ -32,6 +32,54 @@
|
|
|
32
32
|
"default": 10,
|
|
33
33
|
"minimum": 5,
|
|
34
34
|
"description": "How often to read the armed state, so changes made in the ecobee app or at the keypad show up in Apple Home."
|
|
35
|
+
},
|
|
36
|
+
"offerNight": {
|
|
37
|
+
"title": "Offer Night",
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"default": false,
|
|
40
|
+
"description": "Adds Night to the Home app, for automations or Siri that use it. ecobee has no Night mode, so Night arms Stay; the Home app keeps showing Night until the state changes. Changing this setting recreates the security system in the Home app, so scenes and automations that use it have to be set up again."
|
|
41
|
+
},
|
|
42
|
+
"alarmTrigger": {
|
|
43
|
+
"title": "Enter Triggered State",
|
|
44
|
+
"type": "string",
|
|
45
|
+
"default": "incident",
|
|
46
|
+
"oneOf": [
|
|
47
|
+
{
|
|
48
|
+
"title": "Immediately (mirror ecobee app)",
|
|
49
|
+
"enum": [
|
|
50
|
+
"incident"
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"title": "After the Entry/Disarm delay",
|
|
55
|
+
"enum": [
|
|
56
|
+
"entryDelay"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
"description": "ecobee opens an incident as soon as a door or sensor trips while armed. \"After the Entry/Disarm delay\" waits until that delay runs out without a disarm, so automations on Triggered don't fire while you're walking in to disarm. Smoke alarms always trigger immediately."
|
|
61
|
+
},
|
|
62
|
+
"smokeSensor": {
|
|
63
|
+
"title": "Smoke sensor",
|
|
64
|
+
"type": "boolean",
|
|
65
|
+
"default": true,
|
|
66
|
+
"description": "Adds a separate \"Smoke Alarm\" accessory to the Home app that detects smoke whenever ecobee reports a smoke-alarm incident, so you can use it in automations and get Home app smoke notifications."
|
|
67
|
+
},
|
|
68
|
+
"airQuality": {
|
|
69
|
+
"title": "Air quality sensors",
|
|
70
|
+
"type": "boolean",
|
|
71
|
+
"default": true,
|
|
72
|
+
"description": "For ecobee thermostats with an air quality sensor (ecobee Premium): adds an Air Quality Sensor (overall level and VOC) and a Carbon Dioxide Sensor (CO2 in ppm) per thermostat. ecobee's own HomeKit integration doesn't show these. Updated every 5 minutes."
|
|
73
|
+
},
|
|
74
|
+
"co2Alert": {
|
|
75
|
+
"title": "CO2 alert level (ppm)",
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"default": 1000,
|
|
78
|
+
"minimum": 400,
|
|
79
|
+
"description": "The Carbon Dioxide Sensor reports abnormal CO2 at or above this level, for Home app notifications and automations (for example, turning on ventilation).",
|
|
80
|
+
"condition": {
|
|
81
|
+
"functionBody": "return model.airQuality !== false;"
|
|
82
|
+
}
|
|
35
83
|
}
|
|
36
84
|
}
|
|
37
85
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Air quality from ecobee thermostats with the sensor (ecobee Premium): one accessory per thermostat
|
|
2
|
+
// with an Air Quality Sensor (overall level, VOC) and a Carbon Dioxide Sensor (ppm, high-CO2 alert).
|
|
3
|
+
// ecobee's own HomeKit integration doesn't expose these readings.
|
|
4
|
+
import { SignInRequiredError } from './ecobee.js';
|
|
5
|
+
|
|
6
|
+
const POLL = 5 * 60 * 1000; // ecobee updates thermostat runtime every few minutes
|
|
7
|
+
const FAULT_AFTER = 3;
|
|
8
|
+
|
|
9
|
+
export class AirQualitySensors {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {import('homebridge').API} options.api
|
|
13
|
+
* @param {import('homebridge').Logging} options.log
|
|
14
|
+
* @param {import('./ecobee.js').EcobeeCloud} options.cloud
|
|
15
|
+
* @param {string} options.homeId
|
|
16
|
+
* @param {import('homebridge').PlatformAccessory[]} options.cached accessories restored by Homebridge
|
|
17
|
+
* @param {(uuid: string, name: string) => import('homebridge').PlatformAccessory} options.create new, unregistered accessory
|
|
18
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.register
|
|
19
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.update
|
|
20
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.unregister
|
|
21
|
+
* @param {number} options.co2Alert ppm at or above which the CO2 sensor reports abnormal levels
|
|
22
|
+
* @param {string} options.version plugin version, shown as firmware
|
|
23
|
+
*/
|
|
24
|
+
constructor({ api, log, cloud, homeId, cached, create, register, update, unregister, co2Alert, version }) {
|
|
25
|
+
Object.assign(this, { api, log, cloud, homeId, cached, create, register, update, unregister, co2Alert, version });
|
|
26
|
+
this.C = api.hap.Characteristic;
|
|
27
|
+
this.sensors = new Map(); // thermostat id -> { accessory, air, co2 }
|
|
28
|
+
this.failures = 0;
|
|
29
|
+
this.stopped = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
uuid(thermostatId) {
|
|
33
|
+
return this.api.hap.uuid.generate(`ecobee-security:air-quality:${thermostatId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** First read: create, keep or remove accessories to match the thermostats with air quality. */
|
|
37
|
+
async start() {
|
|
38
|
+
const thermostats = (await this.cloud.getAirQuality(this.homeId))
|
|
39
|
+
.filter(t => t.airQuality?.hasAirQualityEnabled);
|
|
40
|
+
const wanted = new Set(thermostats.map(t => this.uuid(t.id)));
|
|
41
|
+
const stale = this.cached.filter(a => !wanted.has(a.UUID));
|
|
42
|
+
if (stale.length) {
|
|
43
|
+
this.unregister(stale);
|
|
44
|
+
}
|
|
45
|
+
// Build each accessory completely (services and first readings) before it's published.
|
|
46
|
+
const added = [];
|
|
47
|
+
const kept = [];
|
|
48
|
+
for (const t of thermostats) {
|
|
49
|
+
const uuid = this.uuid(t.id);
|
|
50
|
+
const cached = this.cached.find(a => a.UUID === uuid);
|
|
51
|
+
const accessory = cached ?? this.create(uuid, `${t.name} Air Quality`);
|
|
52
|
+
(cached ? kept : added).push(accessory);
|
|
53
|
+
this.sensors.set(t.id, this.setUp(accessory, t));
|
|
54
|
+
}
|
|
55
|
+
this.apply(thermostats);
|
|
56
|
+
if (added.length) {
|
|
57
|
+
this.register(added);
|
|
58
|
+
}
|
|
59
|
+
if (kept.length) {
|
|
60
|
+
this.update(kept);
|
|
61
|
+
}
|
|
62
|
+
for (const t of thermostats) {
|
|
63
|
+
this.log.info(`Air quality, ${t.name}: ${describe(t)}.`);
|
|
64
|
+
}
|
|
65
|
+
this.schedule(POLL);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop() {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
schedule(delay) {
|
|
74
|
+
if (!this.stopped && this.sensors.size) {
|
|
75
|
+
clearTimeout(this.timer);
|
|
76
|
+
this.timer = setTimeout(() => this.poll(), delay);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setUp(accessory, thermostat) {
|
|
81
|
+
const { Service } = this.api.hap;
|
|
82
|
+
const C = this.C;
|
|
83
|
+
accessory.getService(Service.AccessoryInformation)
|
|
84
|
+
.setCharacteristic(C.Manufacturer, 'ecobee')
|
|
85
|
+
.setCharacteristic(C.Model, 'Thermostat air quality')
|
|
86
|
+
.setCharacteristic(C.SerialNumber, thermostat.id)
|
|
87
|
+
.setCharacteristic(C.FirmwareRevision, this.version);
|
|
88
|
+
const air = accessory.getService(Service.AirQualitySensor)
|
|
89
|
+
?? accessory.addService(Service.AirQualitySensor, `${thermostat.name} Air Quality`);
|
|
90
|
+
// ecobee reports VOC in ppb, often above HomeKit's default 1000 limit.
|
|
91
|
+
air.getCharacteristic(C.VOCDensity).setProps({ maxValue: 10000 });
|
|
92
|
+
const co2 = accessory.getService(Service.CarbonDioxideSensor)
|
|
93
|
+
?? accessory.addService(Service.CarbonDioxideSensor, `${thermostat.name} CO2`);
|
|
94
|
+
co2.getCharacteristic(C.CarbonDioxideLevel);
|
|
95
|
+
for (const service of [air, co2]) {
|
|
96
|
+
if (!service.testCharacteristic(C.StatusFault)) {
|
|
97
|
+
service.addCharacteristic(C.StatusFault);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { accessory, air, co2, name: thermostat.name };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async poll() {
|
|
104
|
+
try {
|
|
105
|
+
this.apply(await this.cloud.getAirQuality(this.homeId));
|
|
106
|
+
if (this.failures >= FAULT_AFTER) {
|
|
107
|
+
this.log.info('Air quality readings are back.');
|
|
108
|
+
}
|
|
109
|
+
this.failures = 0;
|
|
110
|
+
this.setFault(false);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.failures += 1;
|
|
113
|
+
if (this.failures === FAULT_AFTER) {
|
|
114
|
+
this.setFault(true);
|
|
115
|
+
}
|
|
116
|
+
if (!(error instanceof SignInRequiredError)) {
|
|
117
|
+
this.log[this.failures >= FAULT_AFTER ? 'warn' : 'debug'](`Could not read air quality (${error.message}).`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.schedule(POLL);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setFault(on) {
|
|
124
|
+
const { StatusFault } = this.C;
|
|
125
|
+
for (const { air, co2 } of this.sensors.values()) {
|
|
126
|
+
air.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
|
|
127
|
+
co2.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
apply(thermostats) {
|
|
132
|
+
const { AirQuality, VOCDensity, CarbonDioxideLevel, CarbonDioxideDetected } = this.C;
|
|
133
|
+
for (const t of thermostats) {
|
|
134
|
+
const sensor = this.sensors.get(t.id);
|
|
135
|
+
if (!sensor) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const aq = t.airQuality ?? {};
|
|
139
|
+
const runtime = t.runtime ?? {};
|
|
140
|
+
sensor.air.updateCharacteristic(AirQuality, aq.isCalibrating ? AirQuality.UNKNOWN : level(aq.airQualityScore, AirQuality));
|
|
141
|
+
if (Number.isFinite(runtime.actualVOC) && runtime.actualVOC >= 0) {
|
|
142
|
+
sensor.air.updateCharacteristic(VOCDensity, Math.min(runtime.actualVOC, 10000));
|
|
143
|
+
}
|
|
144
|
+
if (Number.isFinite(runtime.actualCO2) && runtime.actualCO2 >= 0) {
|
|
145
|
+
sensor.co2.updateCharacteristic(CarbonDioxideLevel, Math.min(runtime.actualCO2, 100000));
|
|
146
|
+
sensor.co2.updateCharacteristic(CarbonDioxideDetected, runtime.actualCO2 >= this.co2Alert
|
|
147
|
+
? CarbonDioxideDetected.CO2_LEVELS_ABNORMAL : CarbonDioxideDetected.CO2_LEVELS_NORMAL);
|
|
148
|
+
}
|
|
149
|
+
this.log.debug(`Air quality, ${sensor.name}: ${describe(t)}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const LEVEL_NAMES = ['unknown', 'excellent', 'good', 'fair', 'inferior', 'poor'];
|
|
155
|
+
|
|
156
|
+
function describe(t) {
|
|
157
|
+
const aq = t.airQuality ?? {};
|
|
158
|
+
const r = t.runtime ?? {};
|
|
159
|
+
const name = aq.isCalibrating ? 'calibrating' : LEVEL_NAMES[level(aq.airQualityScore, { UNKNOWN: 0, EXCELLENT: 1, GOOD: 2, FAIR: 3, INFERIOR: 4, POOR: 5 })];
|
|
160
|
+
return `${name} (score ${Number.isFinite(aq.airQualityScore) ? aq.airQualityScore.toFixed(2) : '?'}), `
|
|
161
|
+
+ `CO2 ${r.actualCO2 ?? '?'} ppm, VOC ${r.actualVOC ?? '?'} ppb`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* ecobee's 0 (clean) … 1 (poor) score onto HomeKit's five levels, following the eight segments of
|
|
166
|
+
* the ecobee app's bar: 1–2 Excellent (the app calls 0.21 "Clean"), 3–4 Good, 5 Fair, 6 Inferior,
|
|
167
|
+
* 7–8 Poor.
|
|
168
|
+
*/
|
|
169
|
+
export function level(score, AirQuality) {
|
|
170
|
+
if (!Number.isFinite(score)) {
|
|
171
|
+
return AirQuality.UNKNOWN;
|
|
172
|
+
}
|
|
173
|
+
if (score < 0.25) {
|
|
174
|
+
return AirQuality.EXCELLENT;
|
|
175
|
+
}
|
|
176
|
+
if (score < 0.5) {
|
|
177
|
+
return AirQuality.GOOD;
|
|
178
|
+
}
|
|
179
|
+
if (score < 0.625) {
|
|
180
|
+
return AirQuality.FAIR;
|
|
181
|
+
}
|
|
182
|
+
if (score < 0.75) {
|
|
183
|
+
return AirQuality.INFERIOR;
|
|
184
|
+
}
|
|
185
|
+
return AirQuality.POOR;
|
|
186
|
+
}
|
package/lib/ecobee.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
export const AUTH = 'https://auth.ecobee.com';
|
|
4
4
|
export const GRAPHQL = 'https://federated-api.prod.federated-graph.generacapis.com/graphql';
|
|
5
|
+
// ecobee's older REST API; the app still reads thermostat runtime (CO2 ppm, VOC) from it.
|
|
6
|
+
export const REST = 'https://api.ecobee.com/1';
|
|
5
7
|
export const CLIENT_ID = 'CuGkX8Ai7OWD2V1F32rFa7UJGRVRsnbv';
|
|
6
8
|
export const REDIRECT_URI = 'com.ecobee.athenamobile://auth.ecobee.com/ios/com.ecobee.athenamobile/callback';
|
|
7
9
|
export const AUDIENCE = 'https://prod.ecobee.com/api/v1';
|
|
@@ -24,6 +26,9 @@ const STATE_QUERY = `query HomesSecurity { homes { id name capabilities monitori
|
|
|
24
26
|
+ 'homeMonitoringSettings { isProfessionalMonitoringEnabled armedStateSettings { '
|
|
25
27
|
+ 'armedAway { entryDelayDuration exitDelayDuration } armedStay { entryDelayDuration exitDelayDuration } } } } } }';
|
|
26
28
|
|
|
29
|
+
const AIR_QUALITY_QUERY = 'query ThermostatsAirQuality($homeID: ID!) { homes(id: $homeID) { id devices { id '
|
|
30
|
+
+ 'thermostats { id name airQuality { hasAirQualityEnabled carbonDioxideScore vocScore airQualityScore isCalibrating } } } } }';
|
|
31
|
+
|
|
27
32
|
const SET_MUTATION = 'mutation SetArmedStateForHome($input: SetArmedStateForHomeInput!) { '
|
|
28
33
|
+ `setArmedStateForHome(input: $input) { home { id monitoring { ${MONITORING_FIELDS} } } success } }`;
|
|
29
34
|
|
|
@@ -119,6 +124,41 @@ export class EcobeeCloud {
|
|
|
119
124
|
return data.homes ?? [];
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The home's thermostats with ecobee's air quality scores (0 = clean … 1 = poor), plus the
|
|
129
|
+
* runtime readings (`actualCO2` ppm, `actualVOC` ppb, `actualAQScore`) from the REST API.
|
|
130
|
+
*/
|
|
131
|
+
async getAirQuality(homeId) {
|
|
132
|
+
const data = await this.graphql('ThermostatsAirQuality', AIR_QUALITY_QUERY, { homeID: homeId });
|
|
133
|
+
const thermostats = data.homes?.[0]?.devices?.thermostats ?? [];
|
|
134
|
+
if (!thermostats.length) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const json = await this.rest('/thermostat', {
|
|
138
|
+
selectionType: 'thermostats', selectionMatch: thermostats.map(t => t.id).join(','), includeRuntime: true,
|
|
139
|
+
});
|
|
140
|
+
const runtime = new Map((json.thermostatList ?? []).map(t => [t.identifier, t.runtime ?? {}]));
|
|
141
|
+
return thermostats.map(t => ({ ...t, runtime: runtime.get(t.id) ?? {} }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async rest(path, selection, retry = true) {
|
|
145
|
+
const token = await this.token();
|
|
146
|
+
const url = `${REST}${path}?json=${encodeURIComponent(JSON.stringify({ selection }))}`;
|
|
147
|
+
const res = await fetch(url, {
|
|
148
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json;charset=UTF-8', 'user-agent': USER_AGENT },
|
|
149
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
150
|
+
});
|
|
151
|
+
if (res.status === 401 && retry) {
|
|
152
|
+
this.accessToken = undefined;
|
|
153
|
+
return this.rest(path, selection, false);
|
|
154
|
+
}
|
|
155
|
+
const json = await res.json().catch(() => ({}));
|
|
156
|
+
if (!res.ok || (json.status?.code ?? 0) !== 0) {
|
|
157
|
+
throw new Error(`${path}: HTTP ${res.status} ${json.status?.message ?? ''}`.trim());
|
|
158
|
+
}
|
|
159
|
+
return json;
|
|
160
|
+
}
|
|
161
|
+
|
|
122
162
|
/**
|
|
123
163
|
* @param {string} homeId
|
|
124
164
|
* @param {string} armedState one of ArmedState
|
package/lib/platform.js
CHANGED
|
@@ -3,12 +3,17 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { EcobeeCloud, SignInRequiredError } from './ecobee.js';
|
|
5
5
|
import { startControlServer } from './control.js';
|
|
6
|
+
import { AirQualitySensors } from './airquality.js';
|
|
6
7
|
import { SecuritySystem } from './security.js';
|
|
8
|
+
import { VERSION } from './version.js';
|
|
7
9
|
|
|
8
10
|
export const PLUGIN_NAME = 'homebridge-ecobee-security';
|
|
9
11
|
export const PLATFORM_NAME = 'EcobeeSecurityPlatform';
|
|
10
12
|
|
|
11
13
|
const RETRY_START = 60 * 1000;
|
|
14
|
+
const AIR_QUALITY_RETRY = 10 * 60 * 1000;
|
|
15
|
+
const AIR_QUALITY = 'airQuality';
|
|
16
|
+
const SMOKE = 'smoke';
|
|
12
17
|
|
|
13
18
|
export class EcobeeSecurityPlatform {
|
|
14
19
|
/**
|
|
@@ -56,8 +61,10 @@ export class EcobeeSecurityPlatform {
|
|
|
56
61
|
});
|
|
57
62
|
api.on('shutdown', () => {
|
|
58
63
|
clearTimeout(this.retryTimer);
|
|
64
|
+
clearTimeout(this.airQualityRetry);
|
|
59
65
|
this.control?.close();
|
|
60
66
|
this.system?.stop();
|
|
67
|
+
this.airQuality?.stop();
|
|
61
68
|
});
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -115,7 +122,9 @@ export class EcobeeSecurityPlatform {
|
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
const uuid = this.api.hap.uuid.generate(`ecobee-security:${home.id}`);
|
|
118
|
-
const
|
|
125
|
+
const smokeUuid = this.api.hap.uuid.generate(`ecobee-security:smoke:${home.id}`);
|
|
126
|
+
const wantSmoke = this.config.smokeSensor !== false;
|
|
127
|
+
const stale = this.cached.filter(a => a.UUID !== uuid && a.context.kind !== AIR_QUALITY && !(wantSmoke && a.UUID === smokeUuid));
|
|
119
128
|
if (stale.length) {
|
|
120
129
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
121
130
|
}
|
|
@@ -124,6 +133,12 @@ export class EcobeeSecurityPlatform {
|
|
|
124
133
|
if (isNew) {
|
|
125
134
|
accessory = new this.api.platformAccessory(this.config.name || 'Security System', uuid);
|
|
126
135
|
}
|
|
136
|
+
let smokeAccessory = wantSmoke ? this.cached.find(a => a.UUID === smokeUuid) : undefined;
|
|
137
|
+
const smokeIsNew = wantSmoke && !smokeAccessory;
|
|
138
|
+
if (smokeIsNew) {
|
|
139
|
+
smokeAccessory = new this.api.platformAccessory('Smoke Alarm', smokeUuid);
|
|
140
|
+
smokeAccessory.context.kind = SMOKE;
|
|
141
|
+
}
|
|
127
142
|
|
|
128
143
|
this.system = new SecuritySystem({
|
|
129
144
|
api: this.api,
|
|
@@ -134,12 +149,20 @@ export class EcobeeSecurityPlatform {
|
|
|
134
149
|
pollInterval: Math.max(5, Number(this.config.pollInterval) || 10),
|
|
135
150
|
store: this.store,
|
|
136
151
|
save: () => this.save(),
|
|
152
|
+
offerNight: this.config.offerNight === true,
|
|
153
|
+
alarmAfterEntryDelay: this.config.alarmTrigger === 'entryDelay',
|
|
154
|
+
smokeAccessory,
|
|
137
155
|
});
|
|
138
156
|
if (isNew) {
|
|
139
157
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
140
158
|
} else {
|
|
141
159
|
this.api.updatePlatformAccessories([accessory]);
|
|
142
160
|
}
|
|
161
|
+
if (smokeIsNew) {
|
|
162
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [smokeAccessory]);
|
|
163
|
+
} else if (smokeAccessory) {
|
|
164
|
+
this.api.updatePlatformAccessories([smokeAccessory]);
|
|
165
|
+
}
|
|
143
166
|
this.system.start();
|
|
144
167
|
const m = home.monitoring;
|
|
145
168
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
@@ -147,5 +170,42 @@ export class EcobeeSecurityPlatform {
|
|
|
147
170
|
if (this.store.hold) {
|
|
148
171
|
this.log.info(`Still holding Stay for ${this.store.hold.by} (since ${this.store.hold.since}).`);
|
|
149
172
|
}
|
|
173
|
+
this.startAirQuality(home.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Air quality sensors for thermostats that have them; failures here never affect the security system. */
|
|
177
|
+
async startAirQuality(homeId) {
|
|
178
|
+
const cached = this.cached.filter(a => a.context.kind === AIR_QUALITY);
|
|
179
|
+
if (this.config.airQuality === false) {
|
|
180
|
+
if (cached.length) {
|
|
181
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, cached);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const sensors = new AirQualitySensors({
|
|
186
|
+
api: this.api,
|
|
187
|
+
log: this.log,
|
|
188
|
+
cloud: this.cloud,
|
|
189
|
+
homeId,
|
|
190
|
+
cached,
|
|
191
|
+
co2Alert: Math.max(400, Number(this.config.co2Alert) || 1000),
|
|
192
|
+
version: VERSION,
|
|
193
|
+
create: (uuid, name) => {
|
|
194
|
+
const accessory = new this.api.platformAccessory(name, uuid);
|
|
195
|
+
accessory.context.kind = AIR_QUALITY;
|
|
196
|
+
return accessory;
|
|
197
|
+
},
|
|
198
|
+
register: accessories => this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories),
|
|
199
|
+
update: accessories => this.api.updatePlatformAccessories(accessories),
|
|
200
|
+
unregister: accessories => this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories),
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
await sensors.start();
|
|
204
|
+
this.airQuality = sensors;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sensors.stop();
|
|
207
|
+
this.log.warn(`Could not set up air quality (${error.message}); trying again in 10 minutes.`);
|
|
208
|
+
this.airQualityRetry = setTimeout(() => this.startAirQuality(homeId), AIR_QUALITY_RETRY);
|
|
209
|
+
}
|
|
150
210
|
}
|
|
151
211
|
}
|
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",
|