homebridge-ecobee-security 0.7.0 → 0.8.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 +25 -0
- package/README.md +47 -4
- package/config.schema.json +16 -1
- package/homebridge-ui/public/index.html +47 -5
- package/homebridge-ui/server.js +10 -0
- package/lib/airquality.js +56 -2
- package/lib/ecobee.js +28 -5
- package/lib/platform.js +67 -1
- package/lib/security.js +216 -27
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- While the plugin can't reach ecobee (three failed reads in a row, or signed out), the security
|
|
6
|
+
tile, the Smoke Alarm and the air quality sensors show **No Response** in the Home app instead of
|
|
7
|
+
their last state. They come back with the next successful read.
|
|
8
|
+
- The security tile and Smoke Alarm also show **No Response** when ecobee's cloud can't reach the
|
|
9
|
+
thermostats Smart Security runs on: every thermostat unreachable for a minute. One dropping out
|
|
10
|
+
briefly doesn't count, and it comes back as soon as one is connected. A thermostat's air quality
|
|
11
|
+
sensors show No Response while ecobee can't reach it. Security holds are refused meanwhile.
|
|
12
|
+
- New setting **Home Occupied switch** (`occupancySwitch`, off by default; `occupancyName`): a switch
|
|
13
|
+
for your Home app people-arrive and people-leave automations. Off arms Away like the Home app tile
|
|
14
|
+
does (waiting for robots that are out), on turns Away off (never Home, Night or Off). Switches
|
|
15
|
+
don't need the confirmation that automations controlling a security system do.
|
|
16
|
+
- Settings page: the Home ID field is now a list of the homes on your ecobee account, shown only
|
|
17
|
+
when there's more than one (the list fills in once the plugin has started).
|
|
18
|
+
|
|
19
|
+
## 0.7.1
|
|
20
|
+
|
|
21
|
+
- Keypad unlock: the plugin now reads ecobee's current state first and only disarms when the system
|
|
22
|
+
is armed, arming or alarming. A disarm sent while it was already off was followed by ecobee camera
|
|
23
|
+
activity alerts.
|
|
24
|
+
- An intrusion incident only shows as **Alarm triggered** while the system is armed. ecobee also
|
|
25
|
+
opens one for a camera activity alert while the system is off, which briefly showed as an alarm in
|
|
26
|
+
the Home app (and counted as one for security holds). Smoke alarms still show at any time.
|
|
27
|
+
|
|
3
28
|
## 0.7.0
|
|
4
29
|
|
|
5
30
|
- New setting **Disarm when a door is unlocked with its keypad** (`keypadDisarm`, off by default,
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
69
69
|
}
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`homeId` is only needed if the account has several homes (the
|
|
72
|
+
`homeId` is only needed if the account has several homes (the settings page lists them).
|
|
73
73
|
|
|
74
74
|
| Setting | Default | |
|
|
75
75
|
|---|---|---|
|
|
@@ -80,6 +80,8 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
80
80
|
| `holdPlugins` | all | The plugins allowed to do that; the settings page lists them. |
|
|
81
81
|
| `armAwayNowSwitch` | `false` | Adds an **Arm Away Now** switch: arms Away immediately and asks any task in progress to stop (a robot goes home). |
|
|
82
82
|
| `taskWaitSeconds` | `120` | How much longer the Away countdown may wait for a task to finish, e.g. a robot getting home (0: don't wait). |
|
|
83
|
+
| `occupancySwitch` | `false` | Adds a **Home Occupied** switch for your presence automations (see [Home Occupied switch](#home-occupied-switch)). |
|
|
84
|
+
| `occupancyName` | `"Home Occupied"` | The switch's name in the Home app. |
|
|
83
85
|
| `keypadDisarm` | `false` | Turns the security system off when a door is unlocked with a code on its keypad (see [Keypad unlock](#keypad-unlock)). |
|
|
84
86
|
| `keypadLocks` | all | The locks that do that, by serial number; the settings page lists the ones found. |
|
|
85
87
|
| `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. |
|
|
@@ -92,12 +94,18 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
92
94
|
exit delay (0 by default). Off needs no PIN.
|
|
93
95
|
- **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within 10 s, about
|
|
94
96
|
as fast as in the ecobee app (it polls too), and quickly after an exit delay ends.
|
|
95
|
-
- **Alarm triggered** while ecobee lists an open incident
|
|
96
|
-
|
|
97
|
+
- **Alarm triggered** while ecobee lists an open incident: a smoke alarm at any time, an intrusion
|
|
98
|
+
only while armed (ecobee also opens one for a camera activity alert while the system is off; that
|
|
99
|
+
isn't shown as an alarm). See `alarmTrigger` above for when it starts showing. Disarming clears it. Every change in the incident list is logged.
|
|
97
100
|
- **Siri:** asking for Home or for Stay mode both set Home (ecobee's Stay).
|
|
98
101
|
- **Night** is only offered with `offerNight`; ecobee has no Night mode of its own.
|
|
99
|
-
- After three failed reads in a row the accessory shows a fault
|
|
102
|
+
- After three failed reads in a row the accessory shows a fault, and the security tile, Smoke Alarm and air
|
|
103
|
+
quality sensors show **No Response** instead of their last state until ecobee answers again; reads back off up to 5 minutes.
|
|
104
|
+
The security tile and Smoke Alarm also show No Response when ecobee can't reach your thermostats, which run
|
|
105
|
+
Smart Security, for a minute (all of them; one dropping out briefly doesn't count), and an air quality sensor
|
|
106
|
+
does when ecobee can't reach its thermostat.
|
|
100
107
|
- **Keypad unlock:** unlocking a door with its keypad can turn the system off: see below.
|
|
108
|
+
- **Home Occupied switch:** your arrive and leave automations can arm and disarm Away without a confirmation prompt: see below.
|
|
101
109
|
- **Security holds** let other plugins, like a robot vacuum, keep the system from arming Away while they work: see below.
|
|
102
110
|
|
|
103
111
|
## Keypad unlock
|
|
@@ -111,6 +119,8 @@ schedules, expiry), so the plugin needs no codes of its own.
|
|
|
111
119
|
unlocked (HomeKit's Lock Last Known Action), for example homebridge-nest-accfactory's Nest × Yale
|
|
112
120
|
locks. The settings page lists the locks found; untick the ones that shouldn't disarm. Locks
|
|
113
121
|
paired to Apple Home directly (not through Homebridge) can't be seen.
|
|
122
|
+
- **Only when armed:** the plugin checks ecobee's current state first and sends nothing if the system
|
|
123
|
+
is already off.
|
|
114
124
|
- **Only keypad unlocks** count: not the app, Siri, automations, a key or the thumb turn. Any code
|
|
115
125
|
that opens the lock disarms, yours included.
|
|
116
126
|
- **Speed:** the plugin checks the locks every second; the disarm reaches ecobee about as fast as
|
|
@@ -123,6 +133,39 @@ schedules, expiry), so the plugin needs no codes of its own.
|
|
|
123
133
|
127.0.0.1 with the bridge PIN, since each child bridge runs in its own process. The log says if
|
|
124
134
|
it isn't on.
|
|
125
135
|
|
|
136
|
+
## Home Occupied switch
|
|
137
|
+
|
|
138
|
+
Apple Home asks you to confirm (a notification you have to tap) whenever a people-arrive or
|
|
139
|
+
people-leave automation changes a security system. Automations that flip an ordinary switch don't
|
|
140
|
+
ask. Turn on **Home Occupied switch** (`occupancySwitch`) and the plugin adds a **Home Occupied**
|
|
141
|
+
switch (named by `occupancyName`) that those automations set instead; the plugin then changes the
|
|
142
|
+
security system itself.
|
|
143
|
+
|
|
144
|
+
In the Home app, create two automations:
|
|
145
|
+
|
|
146
|
+
1. **When the last person leaves** (People > Leave, "Everyone leaves") > turn **Home Occupied** off.
|
|
147
|
+
2. **When the first person arrives** (People > Arrive, "Anyone arrives") > turn **Home Occupied** on.
|
|
148
|
+
|
|
149
|
+
What each direction does:
|
|
150
|
+
|
|
151
|
+
- **Off (nobody home)** arms **Away** exactly as Away from the Home app does: the normal exit delay,
|
|
152
|
+
and if a robot or other task is out it [waits for it](#security-holds). Home, Night and Off are all
|
|
153
|
+
switched to Away; if it is already Away, or waiting to be, nothing happens.
|
|
154
|
+
- **On (someone home)** turns the system **off, but only if it is Away** (armed, arming, or waiting for
|
|
155
|
+
a task to finish). Home and Night, and a system that is already off, are left as they are, so
|
|
156
|
+
arriving never cancels the Home you set before bed.
|
|
157
|
+
- Only a real change counts: setting the switch to the value it already has does nothing. It starts as
|
|
158
|
+
on and is remembered across restarts; restarting the plugin never arms or disarms anything.
|
|
159
|
+
- The switch is your presence signal, not a mirror of the security system: arming or disarming
|
|
160
|
+
elsewhere doesn't move it.
|
|
161
|
+
- If ecobee can't be reached, the switch still keeps its new value; the log says the security system
|
|
162
|
+
was left as it was (disarming is retried once).
|
|
163
|
+
|
|
164
|
+
> [!CAUTION]
|
|
165
|
+
> HomeKit doesn't ask for authorisation to use a switch. Turning Home Occupied on, by hand, with Siri
|
|
166
|
+
> or from a shared home member's phone, turns off an Away system, so treat it as as sensitive as the
|
|
167
|
+
> security tile itself.
|
|
168
|
+
|
|
126
169
|
## Security holds
|
|
127
170
|
|
|
128
171
|
Things that move while you're out set off ecobee's motion sensors: a robot vacuum cleaning, or an AC
|
package/config.schema.json
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"homeId": {
|
|
25
25
|
"title": "Home ID",
|
|
26
26
|
"type": "string",
|
|
27
|
-
"description": "Only needed if your account has more than one home. The
|
|
27
|
+
"description": "Only needed if your ecobee account has more than one home. The settings page lists them."
|
|
28
28
|
},
|
|
29
29
|
"offerNight": {
|
|
30
30
|
"title": "Offer Night",
|
|
@@ -100,6 +100,21 @@
|
|
|
100
100
|
},
|
|
101
101
|
"description": "Serial numbers of the locks that disarm (the settings page lists the ones found). With no list, every lock does."
|
|
102
102
|
},
|
|
103
|
+
"occupancySwitch": {
|
|
104
|
+
"title": "Home Occupied switch",
|
|
105
|
+
"type": "boolean",
|
|
106
|
+
"default": false,
|
|
107
|
+
"description": "Adds a Home Occupied switch to the Home app. Your own people-arrive and people-leave automations set it, and it does the rest: off (nobody home) arms Away, on (someone home) turns Away off. Switches don't ask for confirmation the way automations that change the security system do. See \"Home Occupied switch\" in the README."
|
|
108
|
+
},
|
|
109
|
+
"occupancyName": {
|
|
110
|
+
"title": "Home Occupied switch name",
|
|
111
|
+
"type": "string",
|
|
112
|
+
"default": "Home Occupied",
|
|
113
|
+
"description": "Name shown in the Home app.",
|
|
114
|
+
"condition": {
|
|
115
|
+
"functionBody": "return model.occupancySwitch === true;"
|
|
116
|
+
}
|
|
117
|
+
},
|
|
103
118
|
"airQuality": {
|
|
104
119
|
"title": "Air quality sensors",
|
|
105
120
|
"type": "boolean",
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
<input class="form-control" type="text" id="s-name" data-key="name" data-type="text" data-default="Security System">
|
|
65
65
|
<div class="hint mt-1">Name shown in the Home app.</div>
|
|
66
66
|
</div>
|
|
67
|
-
<div class="mb-3">
|
|
68
|
-
<label for="s-homeId" class="form-label">Home
|
|
69
|
-
<
|
|
70
|
-
<div class="hint mt-1">
|
|
67
|
+
<div class="mb-3" id="home-box" hidden>
|
|
68
|
+
<label for="s-homeId" class="form-label">Home</label>
|
|
69
|
+
<select class="form-select" id="s-homeId" data-key="homeId" data-type="select" data-default=""></select>
|
|
70
|
+
<div class="hint mt-1">Your ecobee account has more than one home. Pick the one whose security system to add.</div>
|
|
71
71
|
</div>
|
|
72
72
|
<div class="mb-3">
|
|
73
73
|
<div class="form-check form-switch">
|
|
@@ -122,6 +122,23 @@
|
|
|
122
122
|
</div>
|
|
123
123
|
</div>
|
|
124
124
|
|
|
125
|
+
<div class="card card-body mb-3" id="occupancy-card" hidden>
|
|
126
|
+
<h5 class="mb-2">Home Occupied switch</h5>
|
|
127
|
+
<div class="form-check form-switch mb-1">
|
|
128
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-occupancySwitch" data-key="occupancySwitch" data-type="bool" data-default="false">
|
|
129
|
+
<label class="form-check-label" for="s-occupancySwitch">Home Occupied switch</label>
|
|
130
|
+
</div>
|
|
131
|
+
<p class="hint mb-2">Adds a switch to the Home app for your people-arrive and people-leave automations to set:
|
|
132
|
+
off (nobody home) arms Away, on (someone home) turns Away off. Switches don't ask for confirmation the way
|
|
133
|
+
automations that change the security system do. Restart the bridge after changing this.
|
|
134
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#home-occupied-switch" target="_blank" rel="noopener">Learn more…</a></p>
|
|
135
|
+
<div id="occupancy-box" hidden>
|
|
136
|
+
<label for="s-occupancyName" class="form-label mb-1">Name</label>
|
|
137
|
+
<input class="form-control" type="text" id="s-occupancyName" data-key="occupancyName" data-type="text" data-default="Home Occupied">
|
|
138
|
+
<div class="hint mt-1">Name shown in the Home app.</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
125
142
|
<div class="card card-body mb-3" id="holds-card" hidden>
|
|
126
143
|
<h5 class="mb-2">Security holds</h5>
|
|
127
144
|
<div class="form-check form-switch mb-1">
|
|
@@ -279,6 +296,8 @@
|
|
|
279
296
|
const signedIn = Boolean(block?.refreshToken);
|
|
280
297
|
$('system-card').hidden = !signedIn;
|
|
281
298
|
$('sensors-card').hidden = !signedIn;
|
|
299
|
+
$('occupancy-card').hidden = !signedIn;
|
|
300
|
+
await showHomes(block);
|
|
282
301
|
for (const input of controls) {
|
|
283
302
|
const value = block?.[input.dataset.key];
|
|
284
303
|
if (input.dataset.type === 'bool') {
|
|
@@ -288,6 +307,27 @@
|
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
309
|
$('co2-box').hidden = block?.airQuality === false;
|
|
310
|
+
$('occupancy-box').hidden = block?.occupancySwitch !== true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The home list, from what the plugin last saw. Only offered when there's a choice: one home (or
|
|
315
|
+
* none seen yet) hides it, unless a home is already set.
|
|
316
|
+
*/
|
|
317
|
+
async function showHomes(block) {
|
|
318
|
+
const homes = await homebridge.request('/homes').catch(() => []);
|
|
319
|
+
const select = $('s-homeId');
|
|
320
|
+
select.textContent = '';
|
|
321
|
+
const option = (value, text) => select.append(Object.assign(document.createElement('option'), { value, textContent: text }));
|
|
322
|
+
option('', 'Automatic (the first home with Smart Security)');
|
|
323
|
+
for (const home of homes) {
|
|
324
|
+
option(home.id, home.security ? home.name : `${home.name} (no Smart Security)`);
|
|
325
|
+
}
|
|
326
|
+
const set = block?.homeId;
|
|
327
|
+
if (set && !homes.some(h => h.id === set)) {
|
|
328
|
+
option(set, homes.length ? `${set} (not on this account)` : set);
|
|
329
|
+
}
|
|
330
|
+
$('home-box').hidden = homes.length < 2 && !set;
|
|
291
331
|
}
|
|
292
332
|
|
|
293
333
|
/** Write one setting. Top-level keys are cleared with undefined, never deleted (the UI merges shallowly). */
|
|
@@ -305,6 +345,8 @@
|
|
|
305
345
|
await setSetting(key, input.checked);
|
|
306
346
|
if (key === 'airQuality') {
|
|
307
347
|
$('co2-box').hidden = !input.checked;
|
|
348
|
+
} else if (key === 'occupancySwitch') {
|
|
349
|
+
$('occupancy-box').hidden = !input.checked;
|
|
308
350
|
}
|
|
309
351
|
} else if (type === 'int') {
|
|
310
352
|
const number = input.value.trim() === '' ? undefined : Number(input.value);
|
|
@@ -314,7 +356,7 @@
|
|
|
314
356
|
await setSetting(key, number);
|
|
315
357
|
}
|
|
316
358
|
} else if (type === 'select') {
|
|
317
|
-
await setSetting(key, input.value);
|
|
359
|
+
await setSetting(key, input.value === '' ? undefined : input.value);
|
|
318
360
|
} else {
|
|
319
361
|
await setSetting(key, input.value.trim() === '' ? undefined : input.value.trim());
|
|
320
362
|
}
|
package/homebridge-ui/server.js
CHANGED
|
@@ -82,6 +82,16 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
82
82
|
}
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
// The homes on the account, as the plugin last saw them (only that list leaves state.json).
|
|
86
|
+
this.onRequest('/homes', async () => {
|
|
87
|
+
try {
|
|
88
|
+
const store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
|
|
89
|
+
return store.homes ?? [];
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
85
95
|
this.ready();
|
|
86
96
|
}
|
|
87
97
|
|
package/lib/airquality.js
CHANGED
|
@@ -26,6 +26,7 @@ export class AirQualitySensors {
|
|
|
26
26
|
this.C = api.hap.Characteristic;
|
|
27
27
|
this.sensors = new Map(); // thermostat id -> { accessory, air, co2 }
|
|
28
28
|
this.failures = 0;
|
|
29
|
+
this.offline = false; // after repeated failed reads the sensors show No Response
|
|
29
30
|
this.stopped = false;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -97,21 +98,33 @@ export class AirQualitySensors {
|
|
|
97
98
|
service.addCharacteristic(C.StatusFault);
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
const sensor = { accessory, air, co2, name: thermostat.name, out: false };
|
|
102
|
+
for (const [service, characteristic] of this.readings(sensor)) {
|
|
103
|
+
service.getCharacteristic(characteristic).onGet(() => {
|
|
104
|
+
if (this.offline || sensor.out) {
|
|
105
|
+
throw this.noResponse();
|
|
106
|
+
}
|
|
107
|
+
return service.getCharacteristic(characteristic).value;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return sensor;
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
async poll() {
|
|
104
114
|
try {
|
|
105
|
-
|
|
115
|
+
const thermostats = await this.cloud.getAirQuality(this.homeId);
|
|
106
116
|
if (this.failures >= FAULT_AFTER) {
|
|
107
117
|
this.log.info('Air quality readings are back.');
|
|
108
118
|
}
|
|
109
119
|
this.failures = 0;
|
|
110
120
|
this.setFault(false);
|
|
121
|
+
this.setOffline(false);
|
|
122
|
+
this.apply(thermostats);
|
|
111
123
|
} catch (error) {
|
|
112
124
|
this.failures += 1;
|
|
113
125
|
if (this.failures === FAULT_AFTER) {
|
|
114
126
|
this.setFault(true);
|
|
127
|
+
this.setOffline(true);
|
|
115
128
|
}
|
|
116
129
|
if (!(error instanceof SignInRequiredError)) {
|
|
117
130
|
this.log[this.failures >= FAULT_AFTER ? 'warn' : 'debug'](`Could not read air quality (${error.message}).`);
|
|
@@ -128,6 +141,37 @@ export class AirQualitySensors {
|
|
|
128
141
|
}
|
|
129
142
|
}
|
|
130
143
|
|
|
144
|
+
noResponse() {
|
|
145
|
+
return new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The readings of one sensor accessory, as [service, characteristic] pairs. */
|
|
149
|
+
readings({ air, co2 }) {
|
|
150
|
+
const C = this.C;
|
|
151
|
+
return [[air, C.AirQuality], [air, C.VOCDensity], [co2, C.CarbonDioxideLevel], [co2, C.CarbonDioxideDetected]];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stale readings show as No Response (like the security tile) once reads have failed for a while.
|
|
156
|
+
* Back online, the last values are put back until apply() brings the new ones.
|
|
157
|
+
*/
|
|
158
|
+
setOffline(on) {
|
|
159
|
+
if (this.offline === on) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
this.offline = on;
|
|
163
|
+
for (const sensor of this.sensors.values()) {
|
|
164
|
+
this.show(sensor, on || sensor.out);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** No Response for one sensor accessory's readings, or their last values. */
|
|
169
|
+
show(sensor, noResponse) {
|
|
170
|
+
for (const [service, characteristic] of this.readings(sensor)) {
|
|
171
|
+
service.updateCharacteristic(characteristic, noResponse ? this.noResponse() : service.getCharacteristic(characteristic).value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
131
175
|
apply(thermostats) {
|
|
132
176
|
const { AirQuality, VOCDensity, CarbonDioxideLevel, CarbonDioxideDetected } = this.C;
|
|
133
177
|
for (const t of thermostats) {
|
|
@@ -137,6 +181,16 @@ export class AirQualitySensors {
|
|
|
137
181
|
}
|
|
138
182
|
const aq = t.airQuality ?? {};
|
|
139
183
|
const runtime = t.runtime ?? {};
|
|
184
|
+
// ecobee's cloud can't reach this thermostat: its readings are old.
|
|
185
|
+
const out = runtime.connected === false;
|
|
186
|
+
if (out !== sensor.out) {
|
|
187
|
+
sensor.out = out;
|
|
188
|
+
this.log.debug(`Air quality, ${sensor.name}: ${out ? "ecobee can't reach the thermostat" : 'thermostat reachable again'}.`);
|
|
189
|
+
this.show(sensor, out || this.offline);
|
|
190
|
+
}
|
|
191
|
+
if (out) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
140
194
|
sensor.air.updateCharacteristic(AirQuality, aq.isCalibrating ? AirQuality.UNKNOWN : level(aq.airQualityScore, AirQuality));
|
|
141
195
|
if (Number.isFinite(runtime.actualVOC) && runtime.actualVOC >= 0) {
|
|
142
196
|
sensor.air.updateCharacteristic(VOCDensity, Math.min(runtime.actualVOC, 10000));
|
package/lib/ecobee.js
CHANGED
|
@@ -21,7 +21,11 @@ export const ArmedState = {
|
|
|
21
21
|
|
|
22
22
|
const MONITORING_FIELDS = 'armedState delayedArmedState { desiredArmedState delayedUntil }';
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
// Smart Security runs on the thermostats; `connected` is false while ecobee's cloud can't reach one.
|
|
25
|
+
const DEVICE_FIELDS = 'devices { thermostats { id name legacyRuntime { connected } } } ';
|
|
26
|
+
|
|
27
|
+
const stateQuery = withDevices => `query HomesSecurity { homes { id name capabilities ${withDevices ? DEVICE_FIELDS : ''}`
|
|
28
|
+
+ `monitoring { ${MONITORING_FIELDS} `
|
|
25
29
|
+ 'incidents { id status detectedIntrusion detectedSmokeAlarm } '
|
|
26
30
|
+ 'homeMonitoringSettings { isProfessionalMonitoringEnabled armedStateSettings { '
|
|
27
31
|
+ 'armedAway { entryDelayDuration exitDelayDuration } armedStay { entryDelayDuration exitDelayDuration } } } } } }';
|
|
@@ -110,7 +114,11 @@ export class EcobeeCloud {
|
|
|
110
114
|
this.accessToken = undefined;
|
|
111
115
|
return this.graphql(operationName, query, variables, false);
|
|
112
116
|
}
|
|
113
|
-
|
|
117
|
+
const error = new Error(`${operationName}: ${message}`);
|
|
118
|
+
// The query itself was rejected (a field ecobee doesn't have), not the request.
|
|
119
|
+
error.validation = json.errors.some(e => e.extensions?.code === 'GRAPHQL_VALIDATION_FAILED'
|
|
120
|
+
|| /cannot query field|unknown (field|type)/i.test(e.message));
|
|
121
|
+
throw error;
|
|
114
122
|
}
|
|
115
123
|
if (!res.ok) {
|
|
116
124
|
throw new Error(`${operationName}: HTTP ${res.status}`);
|
|
@@ -118,10 +126,25 @@ export class EcobeeCloud {
|
|
|
118
126
|
return json.data;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* All homes on the account with their security state and thermostat connectivity. If ecobee
|
|
131
|
+
* rejects the connectivity fields, they're dropped for good (logged once) and the security state
|
|
132
|
+
* is read without them.
|
|
133
|
+
*/
|
|
122
134
|
async getHomes() {
|
|
123
|
-
|
|
124
|
-
|
|
135
|
+
if (!this.withoutDevices) {
|
|
136
|
+
try {
|
|
137
|
+
return (await this.graphql('HomesSecurity', stateQuery(true))).homes ?? [];
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (!error.validation) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
this.withoutDevices = true;
|
|
143
|
+
this.log.warn(`ecobee doesn't accept the thermostat connectivity check (${error.message}); `
|
|
144
|
+
+ 'carrying on without it.');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return (await this.graphql('HomesSecurity', stateQuery(false))).homes ?? [];
|
|
125
148
|
}
|
|
126
149
|
|
|
127
150
|
/**
|
package/lib/platform.js
CHANGED
|
@@ -16,6 +16,7 @@ const AIR_QUALITY_RETRY = 10 * 60 * 1000;
|
|
|
16
16
|
const AIR_QUALITY = 'airQuality';
|
|
17
17
|
const SMOKE = 'smoke';
|
|
18
18
|
const ARM_NOW = 'armAwayNow';
|
|
19
|
+
const OCCUPANCY = 'occupancy';
|
|
19
20
|
|
|
20
21
|
export class EcobeeSecurityPlatform {
|
|
21
22
|
/**
|
|
@@ -119,6 +120,9 @@ export class EcobeeSecurityPlatform {
|
|
|
119
120
|
|
|
120
121
|
async setup() {
|
|
121
122
|
const homes = await this.cloud.getHomes();
|
|
123
|
+
// For the settings page's home list (it only offers a choice when there's more than one).
|
|
124
|
+
this.store.homes = homes.map(h => ({ id: h.id, name: h.name, security: Boolean(h.monitoring) }));
|
|
125
|
+
this.save();
|
|
122
126
|
const wanted = this.config.homeId?.trim();
|
|
123
127
|
const home = wanted
|
|
124
128
|
? homes.find(h => h.id === wanted)
|
|
@@ -139,8 +143,11 @@ export class EcobeeSecurityPlatform {
|
|
|
139
143
|
const wantSmoke = this.config.smokeSensor !== false;
|
|
140
144
|
const armNowUuid = this.api.hap.uuid.generate(`ecobee-security:arm-away-now:${home.id}`);
|
|
141
145
|
const wantArmNow = this.config.allowHolds === true && this.config.armAwayNowSwitch === true;
|
|
146
|
+
const occupancyUuid = this.api.hap.uuid.generate(`ecobee-security:occupancy:${home.id}`);
|
|
147
|
+
const wantOccupancy = this.config.occupancySwitch === true;
|
|
142
148
|
const stale = this.cached.filter(a => a.UUID !== uuid && a.context.kind !== AIR_QUALITY
|
|
143
|
-
&& !(wantSmoke && a.UUID === smokeUuid) && !(wantArmNow && a.UUID === armNowUuid)
|
|
149
|
+
&& !(wantSmoke && a.UUID === smokeUuid) && !(wantArmNow && a.UUID === armNowUuid)
|
|
150
|
+
&& !(wantOccupancy && a.UUID === occupancyUuid));
|
|
144
151
|
if (stale.length) {
|
|
145
152
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
146
153
|
}
|
|
@@ -184,6 +191,13 @@ export class EcobeeSecurityPlatform {
|
|
|
184
191
|
if (wantArmNow) {
|
|
185
192
|
this.setupArmNow(armNowUuid, home.id);
|
|
186
193
|
}
|
|
194
|
+
if (wantOccupancy) {
|
|
195
|
+
this.setupOccupancy(occupancyUuid, home.id);
|
|
196
|
+
} else if (this.store.occupied !== undefined) {
|
|
197
|
+
// Not kept while the switch is off: it would be stale when the switch comes back.
|
|
198
|
+
delete this.store.occupied;
|
|
199
|
+
this.save();
|
|
200
|
+
}
|
|
187
201
|
this.system.start();
|
|
188
202
|
const m = home.monitoring;
|
|
189
203
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
@@ -274,6 +288,58 @@ export class EcobeeSecurityPlatform {
|
|
|
274
288
|
}
|
|
275
289
|
}
|
|
276
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The Home Occupied switch (its own accessory): the Home app's people-arrive and people-leave
|
|
293
|
+
* automations can set an ordinary switch without the confirmation prompt they need for a security
|
|
294
|
+
* system. Off (nobody home) arms Away like the tile does; on (someone home) disarms Away only. It
|
|
295
|
+
* is the user's presence signal, so it never follows the security system. The state is kept in
|
|
296
|
+
* the store (on until told otherwise); starting up does nothing.
|
|
297
|
+
*/
|
|
298
|
+
setupOccupancy(uuid, homeId) {
|
|
299
|
+
const { Service, Characteristic } = this.api.hap;
|
|
300
|
+
const name = this.config.occupancyName?.trim() || 'Home Occupied';
|
|
301
|
+
let accessory = this.cached.find(a => a.UUID === uuid);
|
|
302
|
+
const isNew = !accessory;
|
|
303
|
+
if (isNew) {
|
|
304
|
+
accessory = new this.api.platformAccessory(name, uuid);
|
|
305
|
+
accessory.context.kind = OCCUPANCY;
|
|
306
|
+
}
|
|
307
|
+
accessory.displayName = name;
|
|
308
|
+
accessory.getService(Service.AccessoryInformation)
|
|
309
|
+
.setCharacteristic(Characteristic.Manufacturer, 'ecobee')
|
|
310
|
+
.setCharacteristic(Characteristic.Model, 'Smart Security: Home Occupied')
|
|
311
|
+
.setCharacteristic(Characteristic.SerialNumber, `${homeId}-occupancy`)
|
|
312
|
+
.setCharacteristic(Characteristic.FirmwareRevision, VERSION);
|
|
313
|
+
this.occupancy = accessory.getService(Service.Switch) ?? accessory.addService(Service.Switch, name);
|
|
314
|
+
this.occupancy.setCharacteristic(Characteristic.Name, name);
|
|
315
|
+
this.occupancy.getCharacteristic(Characteristic.On)
|
|
316
|
+
.onGet(() => this.store.occupied !== false)
|
|
317
|
+
.onSet(on => this.setOccupied(Boolean(on), 'Home Occupied'));
|
|
318
|
+
if (isNew) {
|
|
319
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
320
|
+
} else {
|
|
321
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Someone is home or nobody is. Only a real change does anything. `source` is who says so: the
|
|
327
|
+
* switch today; another source (say, ecobee's own location sensing) would call this too and the
|
|
328
|
+
* switch follows it.
|
|
329
|
+
*/
|
|
330
|
+
setOccupied(occupied, source) {
|
|
331
|
+
if (occupied === (this.store.occupied !== false)) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
this.store.occupied = occupied;
|
|
335
|
+
this.save();
|
|
336
|
+
this.occupancy?.updateCharacteristic(this.api.hap.Characteristic.On, occupied);
|
|
337
|
+
this.log.info(`${source}: ${occupied ? 'someone is home' : 'nobody is home'}.`);
|
|
338
|
+
// Not awaited: Home app automations don't wait on the cloud, and a failure doesn't change the
|
|
339
|
+
// fact that was reported (the system logs it).
|
|
340
|
+
this.system.occupancyChanged(occupied, source).catch(() => {});
|
|
341
|
+
}
|
|
342
|
+
|
|
277
343
|
/**
|
|
278
344
|
* May this plugin (or, if it doesn't say, this name) ask for a hold? `true`, `'off'` (holds are off),
|
|
279
345
|
* or `'not allowed'`. Without a `holdPlugins` list (settings from before it), every plugin may.
|
package/lib/security.js
CHANGED
|
@@ -37,6 +37,8 @@ const MAX_BACKOFF = 5 * 60 * 1000;
|
|
|
37
37
|
// Robots back (or stopped) before an extended exit delay ends: arm this long after, at the earliest.
|
|
38
38
|
const ARM_AFTER_RETURN = 10 * 1000;
|
|
39
39
|
const FAULT_AFTER = 3;
|
|
40
|
+
// Every thermostat unreachable for this long (ms): the security system counts as offline.
|
|
41
|
+
const OFFLINE_AFTER = 60 * 1000;
|
|
40
42
|
|
|
41
43
|
export class SecuritySystem {
|
|
42
44
|
/**
|
|
@@ -67,13 +69,19 @@ export class SecuritySystem {
|
|
|
67
69
|
this.homeId = home.id;
|
|
68
70
|
this.pollInterval = pollInterval * 1000;
|
|
69
71
|
this.failures = 0;
|
|
72
|
+
// The tiles show No Response instead of the last state when either holds: the plugin can't reach
|
|
73
|
+
// ecobee (or is signed out), or ecobee's cloud can't reach the thermostats Smart Security runs on.
|
|
74
|
+
this.cloudDown = false;
|
|
75
|
+
this.thermostatsDown = false;
|
|
76
|
+
this.outSince = undefined;
|
|
77
|
+
this.outKey = '';
|
|
70
78
|
this.commandAt = 0;
|
|
71
79
|
this.stopped = false;
|
|
72
80
|
this.offerNight = offerNight;
|
|
73
81
|
this.alarmAfterEntryDelay = alarmAfterEntryDelay;
|
|
74
82
|
// Extra exit-delay time for robots heading back to their base when Away is armed (ms).
|
|
75
83
|
this.returnWait = Math.max(0, returnWait) * 1000;
|
|
76
|
-
this.
|
|
84
|
+
this.openIncidents = [];
|
|
77
85
|
this.firstSeen = new Map();
|
|
78
86
|
|
|
79
87
|
const { Service, Characteristic } = api.hap;
|
|
@@ -98,9 +106,11 @@ export class SecuritySystem {
|
|
|
98
106
|
this.service = accessory.services.find(s => s.UUID === Service.SecuritySystem.UUID)
|
|
99
107
|
?? accessory.addService(Service.SecuritySystem, accessory.displayName, subtype);
|
|
100
108
|
this.service.getCharacteristic(Characteristic.SecuritySystemCurrentState)
|
|
101
|
-
.setProps({ validValues: offerNight ? [Hk.STAY, Hk.AWAY, Hk.NIGHT, Hk.DISARMED, Hk.TRIGGERED] : [Hk.STAY, Hk.AWAY, Hk.DISARMED, Hk.TRIGGERED] })
|
|
109
|
+
.setProps({ validValues: offerNight ? [Hk.STAY, Hk.AWAY, Hk.NIGHT, Hk.DISARMED, Hk.TRIGGERED] : [Hk.STAY, Hk.AWAY, Hk.DISARMED, Hk.TRIGGERED] })
|
|
110
|
+
.onGet(() => this.read(this.current, Characteristic.SecuritySystemCurrentState));
|
|
102
111
|
this.service.getCharacteristic(Characteristic.SecuritySystemTargetState)
|
|
103
112
|
.setProps({ validValues: offerNight ? [Hk.STAY, Hk.AWAY, Hk.NIGHT, Hk.DISARMED] : [Hk.STAY, Hk.AWAY, Hk.DISARMED] })
|
|
113
|
+
.onGet(() => this.read(this.shownTarget, Characteristic.SecuritySystemTargetState))
|
|
104
114
|
.onSet(value => this.setTarget(value));
|
|
105
115
|
if (!this.service.testCharacteristic(Characteristic.StatusFault)) {
|
|
106
116
|
this.service.addCharacteristic(Characteristic.StatusFault);
|
|
@@ -120,6 +130,8 @@ export class SecuritySystem {
|
|
|
120
130
|
.setCharacteristic(Characteristic.FirmwareRevision, VERSION);
|
|
121
131
|
this.smoke = smokeAccessory.getService(Service.SmokeSensor)
|
|
122
132
|
?? smokeAccessory.addService(Service.SmokeSensor, smokeAccessory.displayName);
|
|
133
|
+
this.smoke.getCharacteristic(Characteristic.SmokeDetected)
|
|
134
|
+
.onGet(() => this.read(this.smokeShown, Characteristic.SmokeDetected, this.smoke));
|
|
123
135
|
if (!this.smoke.testCharacteristic(Characteristic.StatusFault)) {
|
|
124
136
|
this.smoke.addCharacteristic(Characteristic.StatusFault);
|
|
125
137
|
}
|
|
@@ -155,15 +167,15 @@ export class SecuritySystem {
|
|
|
155
167
|
if (!home) {
|
|
156
168
|
throw new Error('the home is no longer on this ecobee account');
|
|
157
169
|
}
|
|
170
|
+
const wasOffline = this.offline;
|
|
171
|
+
this.reachable();
|
|
172
|
+
this.noteThermostats(home.devices?.thermostats);
|
|
158
173
|
// A command that finished while this poll was running is newer than what we just read.
|
|
159
174
|
if (startedAt >= this.commandAt) {
|
|
160
175
|
this.apply(home);
|
|
176
|
+
} else if (wasOffline && !this.offline) {
|
|
177
|
+
this.apply({});
|
|
161
178
|
}
|
|
162
|
-
if (this.failures >= FAULT_AFTER) {
|
|
163
|
-
this.log.info('ecobee is reachable again.');
|
|
164
|
-
}
|
|
165
|
-
this.failures = 0;
|
|
166
|
-
this.setFault(false);
|
|
167
179
|
const until = Date.parse(home.monitoring?.delayedArmedState?.delayedUntil ?? '');
|
|
168
180
|
if (until > Date.now()) {
|
|
169
181
|
// Catch the end of an exit delay promptly.
|
|
@@ -172,14 +184,14 @@ export class SecuritySystem {
|
|
|
172
184
|
} catch (error) {
|
|
173
185
|
this.failures += 1;
|
|
174
186
|
if (error instanceof SignInRequiredError) {
|
|
175
|
-
this.
|
|
187
|
+
this.setOffline();
|
|
176
188
|
this.log.error(`${error.message}. Sign in again: run bin/ecobee-login.js (see the plugin README), `
|
|
177
189
|
+ 'paste the new refresh token into the plugin settings and restart the bridge.');
|
|
178
190
|
return;
|
|
179
191
|
}
|
|
180
192
|
next = Math.min(this.pollInterval * 2 ** this.failures, MAX_BACKOFF);
|
|
181
193
|
if (this.failures === FAULT_AFTER) {
|
|
182
|
-
this.
|
|
194
|
+
this.setOffline();
|
|
183
195
|
}
|
|
184
196
|
const say = this.failures >= FAULT_AFTER ? 'warn' : 'debug';
|
|
185
197
|
this.log[say](`Could not read the security state (${error.message}); retrying in ${Math.round(next / 1000)} s.`);
|
|
@@ -194,6 +206,95 @@ export class SecuritySystem {
|
|
|
194
206
|
this.smoke?.updateCharacteristic(StatusFault, value);
|
|
195
207
|
}
|
|
196
208
|
|
|
209
|
+
/** Offline for either reason; the tiles show No Response. */
|
|
210
|
+
get offline() {
|
|
211
|
+
return this.cloudDown || this.thermostatsDown;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** The plugin can't reach ecobee: the tiles show No Response. The fault stays set for as long. */
|
|
215
|
+
setOffline() {
|
|
216
|
+
const wasOffline = this.offline;
|
|
217
|
+
this.cloudDown = true;
|
|
218
|
+
this.setFault(true);
|
|
219
|
+
if (!wasOffline) {
|
|
220
|
+
this.pushNoResponse();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A read or command got through: ecobee is reachable (apply() then puts the real state back on the tiles). */
|
|
225
|
+
reachable() {
|
|
226
|
+
if (this.cloudDown) {
|
|
227
|
+
this.log.info('ecobee is reachable again.');
|
|
228
|
+
}
|
|
229
|
+
this.cloudDown = false;
|
|
230
|
+
this.failures = 0;
|
|
231
|
+
this.setFault(this.offline);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Pushed at once, so the tile changes without waiting for the Home app to read it. */
|
|
235
|
+
pushNoResponse() {
|
|
236
|
+
const { SecuritySystemCurrentState, SecuritySystemTargetState, SmokeDetected } = this.C;
|
|
237
|
+
this.service.updateCharacteristic(SecuritySystemCurrentState, this.noResponse());
|
|
238
|
+
this.service.updateCharacteristic(SecuritySystemTargetState, this.noResponse());
|
|
239
|
+
this.smoke?.updateCharacteristic(SmokeDetected, this.noResponse());
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* ecobee's view of the thermostats (their `connected` flags). The system counts as offline once
|
|
244
|
+
* every thermostat that reports a flag has been unreachable for OFFLINE_AFTER, without a break; a
|
|
245
|
+
* single thermostat dropping out for a moment is normal (debug log only). Back as soon as one is
|
|
246
|
+
* connected. Without the data (ecobee rejected the fields), there is no check. The next apply()
|
|
247
|
+
* puts the real state back on the tiles.
|
|
248
|
+
*/
|
|
249
|
+
noteThermostats(thermostats = []) {
|
|
250
|
+
const known = thermostats.filter(t => typeof t.legacyRuntime?.connected === 'boolean');
|
|
251
|
+
const out = known.filter(t => !t.legacyRuntime.connected).map(t => t.name ?? t.id);
|
|
252
|
+
const key = out.join(', ');
|
|
253
|
+
if (key !== this.outKey) {
|
|
254
|
+
this.outKey = key;
|
|
255
|
+
this.log.debug(out.length ? `ecobee can't reach: ${key}.` : 'ecobee reaches all thermostats.');
|
|
256
|
+
}
|
|
257
|
+
if (!known.length || out.length < known.length) {
|
|
258
|
+
this.outSince = undefined;
|
|
259
|
+
if (this.thermostatsDown) {
|
|
260
|
+
this.thermostatsDown = false;
|
|
261
|
+
this.log.info('Smart Security is back online.');
|
|
262
|
+
this.setFault(this.offline);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this.outSince ??= Date.now();
|
|
267
|
+
if (!this.thermostatsDown && Date.now() - this.outSince >= OFFLINE_AFTER) {
|
|
268
|
+
const wasOffline = this.offline;
|
|
269
|
+
this.thermostatsDown = true;
|
|
270
|
+
this.log.warn(`Smart Security is offline: ecobee can't reach the thermostats (${key}).`);
|
|
271
|
+
this.setFault(true);
|
|
272
|
+
if (!wasOffline) {
|
|
273
|
+
this.pushNoResponse();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
noResponse() {
|
|
279
|
+
return new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A Home app read: the last known value, or No Response while ecobee can't be reached. */
|
|
283
|
+
read(value, characteristic, service = this.service) {
|
|
284
|
+
if (this.offline) {
|
|
285
|
+
throw this.noResponse();
|
|
286
|
+
}
|
|
287
|
+
return value ?? service.getCharacteristic(characteristic).value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** What the Home app shows as the target, unless ecobee can't be reached. */
|
|
291
|
+
showTarget(value) {
|
|
292
|
+
this.shownTarget = value;
|
|
293
|
+
if (!this.offline) {
|
|
294
|
+
this.service.updateCharacteristic(this.C.SecuritySystemTargetState, value);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
197
298
|
/** Apply a GraphQL home (or just its monitoring object) to HomeKit. */
|
|
198
299
|
apply(home) {
|
|
199
300
|
const monitoring = home.monitoring ?? {};
|
|
@@ -250,17 +351,33 @@ export class SecuritySystem {
|
|
|
250
351
|
this.save();
|
|
251
352
|
}
|
|
252
353
|
// Away asked for in the Home app during a hold waits for the release: show it as arming Away.
|
|
253
|
-
this.
|
|
254
|
-
this.service.updateCharacteristic(SecuritySystemCurrentState, current);
|
|
354
|
+
this.shownTarget = this.store.hold?.away ? Hk.AWAY : target;
|
|
255
355
|
if (this.smoke) {
|
|
256
356
|
const { SmokeDetected } = this.C;
|
|
257
357
|
const smoke = this.incidents.some(i => i.detectedSmokeAlarm);
|
|
258
|
-
this.
|
|
358
|
+
this.smokeShown = smoke ? SmokeDetected.SMOKE_DETECTED : SmokeDetected.SMOKE_NOT_DETECTED;
|
|
359
|
+
}
|
|
360
|
+
// Pushed whether it changed or not, so the tiles come back from No Response. Not while offline:
|
|
361
|
+
// an alarm timer or a late command must not hide it.
|
|
362
|
+
if (this.offline) {
|
|
363
|
+
return;
|
|
259
364
|
}
|
|
365
|
+
this.service.updateCharacteristic(SecuritySystemTargetState, this.shownTarget);
|
|
366
|
+
this.service.updateCharacteristic(SecuritySystemCurrentState, current);
|
|
367
|
+
this.smoke?.updateCharacteristic(this.C.SmokeDetected, this.smokeShown);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Open incidents that are alarms: smoke always, intrusions only while armed. ecobee also opens an
|
|
372
|
+
* intrusion incident for a camera activity alert while the system is off; that isn't an alarm.
|
|
373
|
+
*/
|
|
374
|
+
get incidents() {
|
|
375
|
+
const armed = [ArmedState.STAY, ArmedState.AWAY].includes(this.monitoring?.armedState);
|
|
376
|
+
return this.openIncidents.filter(i => i.detectedSmokeAlarm || armed);
|
|
260
377
|
}
|
|
261
378
|
|
|
262
379
|
noteIncidents(incidents) {
|
|
263
|
-
this.
|
|
380
|
+
this.openIncidents = incidents.filter(i => !CLOSED_INCIDENT.test(i.status ?? ''));
|
|
264
381
|
const summary = JSON.stringify(incidents);
|
|
265
382
|
if (summary !== this.incidentSummary) {
|
|
266
383
|
if (incidents.length || this.incidentSummary !== undefined) {
|
|
@@ -270,8 +387,8 @@ export class SecuritySystem {
|
|
|
270
387
|
}
|
|
271
388
|
// ecobee's incidents carry no time we read, so the entry delay counts from when we first saw one.
|
|
272
389
|
const now = Date.now();
|
|
273
|
-
const ids = new Set(this.
|
|
274
|
-
this.
|
|
390
|
+
const ids = new Set(this.openIncidents.map(i => i.id));
|
|
391
|
+
this.openIncidents.forEach(i => this.firstSeen.has(i.id) || this.firstSeen.set(i.id, now));
|
|
275
392
|
[...this.firstSeen.keys()].filter(id => !ids.has(id)).forEach(id => this.firstSeen.delete(id));
|
|
276
393
|
}
|
|
277
394
|
|
|
@@ -325,24 +442,25 @@ export class SecuritySystem {
|
|
|
325
442
|
return 0;
|
|
326
443
|
}
|
|
327
444
|
|
|
328
|
-
|
|
445
|
+
/** @param {string} [source] who asked, for the log (the Home Occupied switch asks too) */
|
|
446
|
+
async setTarget(value, source = 'Home app') {
|
|
329
447
|
if (TO_ECOBEE[value] === undefined || (value === Hk.NIGHT && !this.offerNight)) {
|
|
330
448
|
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
331
449
|
}
|
|
332
450
|
// HomeKit can't say who asked (requests come through the home hub), so Away always waits for
|
|
333
451
|
// 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())) {
|
|
452
|
+
if (value === Hk.AWAY && this.outNames().length && await this.serial(() => this.deferAway(source))) {
|
|
335
453
|
return;
|
|
336
454
|
}
|
|
337
455
|
if (this.store.hold) {
|
|
338
|
-
this.log.info(
|
|
456
|
+
this.log.info(`${source} changed the security system during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
339
457
|
this.dropHold();
|
|
340
458
|
}
|
|
341
459
|
try {
|
|
342
|
-
await this.serial(() => this.arm(value,
|
|
460
|
+
await this.serial(() => this.arm(value, source));
|
|
343
461
|
} catch (error) {
|
|
344
462
|
// Put the Home app's target back to what ecobee really has.
|
|
345
|
-
setTimeout(() => this.
|
|
463
|
+
setTimeout(() => this.showTarget(this.target ?? Hk.DISARMED), 500);
|
|
346
464
|
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
347
465
|
}
|
|
348
466
|
}
|
|
@@ -353,7 +471,7 @@ export class SecuritySystem {
|
|
|
353
471
|
* the release arms Away anyway, so keep (or start) the hold and let it; Stay meanwhile. Returns
|
|
354
472
|
* true if Away now waits.
|
|
355
473
|
*/
|
|
356
|
-
async deferAway() {
|
|
474
|
+
async deferAway(source = 'Home app') {
|
|
357
475
|
const out = this.outNames();
|
|
358
476
|
if (!out.length || this.incidents.length || (this.store.hold && this.target !== Hk.STAY)) {
|
|
359
477
|
return false;
|
|
@@ -367,9 +485,9 @@ export class SecuritySystem {
|
|
|
367
485
|
}
|
|
368
486
|
this.store.hold = { ...this.store.hold, away: true };
|
|
369
487
|
this.save();
|
|
370
|
-
this.log.info(
|
|
488
|
+
this.log.info(`${source}: arm Away — waiting until ${this.holders().join(' and ')} ${this.holders().length > 1 ? 'are' : 'is'} done (security hold). `
|
|
371
489
|
+ 'Use the Arm Away Now switch to stop and arm now.');
|
|
372
|
-
this.
|
|
490
|
+
this.showTarget(Hk.AWAY);
|
|
373
491
|
return true;
|
|
374
492
|
}
|
|
375
493
|
|
|
@@ -388,9 +506,8 @@ export class SecuritySystem {
|
|
|
388
506
|
|
|
389
507
|
|
|
390
508
|
/**
|
|
391
|
-
* A door was unlocked with its keypad: disarm, like Nest Guard with a Nest × Yale lock.
|
|
392
|
-
*
|
|
393
|
-
* Retried once, since a missed disarm means an alarm.
|
|
509
|
+
* A door was unlocked with its keypad: disarm, like Nest Guard with a Nest × Yale lock. Retried
|
|
510
|
+
* once, since a missed disarm means an alarm.
|
|
394
511
|
*/
|
|
395
512
|
keypadDisarm(lock) {
|
|
396
513
|
return this.serial(async () => {
|
|
@@ -398,6 +515,13 @@ export class SecuritySystem {
|
|
|
398
515
|
this.log.info(`${lock} was unlocked during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
399
516
|
this.dropHold();
|
|
400
517
|
}
|
|
518
|
+
// Only disarm what's armed: a disarm sent while ecobee is already off isn't a no-op for ecobee
|
|
519
|
+
// (camera activity alerts followed it), so read the live state first. Can't read it: disarm anyway.
|
|
520
|
+
if (await this.refresh() && this.monitoring?.armedState === ArmedState.DISARMED
|
|
521
|
+
&& !this.monitoring.delayedArmedState?.desiredArmedState) {
|
|
522
|
+
this.log.info(`${lock} was unlocked with its keypad; the security system is already off.`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
401
525
|
try {
|
|
402
526
|
await this.arm(Hk.DISARMED, `${lock} unlocked with its keypad`);
|
|
403
527
|
} catch {
|
|
@@ -407,6 +531,64 @@ export class SecuritySystem {
|
|
|
407
531
|
});
|
|
408
532
|
}
|
|
409
533
|
|
|
534
|
+
/**
|
|
535
|
+
* The Home Occupied switch (or another source of presence) changed. Empty arms Away the way Away
|
|
536
|
+
* from the Home app does: waiting for robots that are out, with the normal exit delay. Occupied
|
|
537
|
+
* disarms an Away system only (armed, arming, or waiting on a hold), never Home, Night or Off.
|
|
538
|
+
* Failures are logged, not thrown: the presence fact stays true either way.
|
|
539
|
+
* @param {boolean} occupied
|
|
540
|
+
* @param {string} [source] who says so, for the log
|
|
541
|
+
*/
|
|
542
|
+
async occupancyChanged(occupied, source = 'Home Occupied') {
|
|
543
|
+
try {
|
|
544
|
+
if (occupied) {
|
|
545
|
+
await this.serial(() => this.disarmAway(source));
|
|
546
|
+
} else {
|
|
547
|
+
await this.armAwayEmpty(source);
|
|
548
|
+
}
|
|
549
|
+
} catch (error) {
|
|
550
|
+
this.log.warn(`${source}: could not ${occupied ? 'disarm' : 'arm Away'} (${error.message}). `
|
|
551
|
+
+ 'The security system is left as it is.');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async armAwayEmpty(source) {
|
|
556
|
+
// The live state, not the last poll: Away arrived by another route a moment ago must not restart.
|
|
557
|
+
await this.refresh();
|
|
558
|
+
// Under a hold the system shows Home, but Away is on its way back (or asked for).
|
|
559
|
+
if (this.target === Hk.AWAY || this.store.hold) {
|
|
560
|
+
this.log.info(`${source}: nobody home; the security system is already Away, or waiting to be.`);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
this.log.info(`${source}: nobody home; arming Away.`);
|
|
564
|
+
await this.setTarget(Hk.AWAY, source);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Someone is home: turn Away off. A held system (a robot is out) counts as Away, since the release
|
|
569
|
+
* would arm it again. Like keypadDisarm: decide on the live state and retry once.
|
|
570
|
+
*/
|
|
571
|
+
async disarmAway(source) {
|
|
572
|
+
const live = await this.refresh();
|
|
573
|
+
const away = this.target === Hk.AWAY || Boolean(this.store.hold);
|
|
574
|
+
if (!away) {
|
|
575
|
+
// Can't read ecobee: only the last state we saw can say, and that's no Away.
|
|
576
|
+
this.log.info(`${source}: someone is home; the security system is ${HK_NAMES[this.current]}`
|
|
577
|
+
+ `${live ? '' : ' (as last seen; ecobee could not be read)'}, so it is left as it is.`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (this.store.hold) {
|
|
581
|
+
this.log.info(`${source}: someone is home during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
582
|
+
this.dropHold();
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
await this.arm(Hk.DISARMED, `${source}: someone is home`);
|
|
586
|
+
} catch {
|
|
587
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
588
|
+
await this.arm(Hk.DISARMED, `${source}: someone is home (retry)`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
410
592
|
/** Robots (or other plugins) that asked for a hold and haven't released yet, held or not. */
|
|
411
593
|
outNames() {
|
|
412
594
|
return Object.keys(this.store.out ?? {});
|
|
@@ -501,6 +683,7 @@ export class SecuritySystem {
|
|
|
501
683
|
async setExitDelayEnd(until) {
|
|
502
684
|
const monitoring = await this.cloud.setArmedState(this.homeId, ArmedState.AWAY, 0, until);
|
|
503
685
|
this.commandAt = Date.now();
|
|
686
|
+
this.reachable();
|
|
504
687
|
if (monitoring) {
|
|
505
688
|
this.apply({ monitoring });
|
|
506
689
|
}
|
|
@@ -523,6 +706,7 @@ export class SecuritySystem {
|
|
|
523
706
|
try {
|
|
524
707
|
const monitoring = await this.cloud.setArmedState(this.homeId, armedState, delay);
|
|
525
708
|
this.commandAt = Date.now();
|
|
709
|
+
this.reachable();
|
|
526
710
|
if ((value === Hk.NIGHT) !== Boolean(this.store.night)) {
|
|
527
711
|
if (value === Hk.NIGHT) {
|
|
528
712
|
this.store.night = true;
|
|
@@ -549,10 +733,12 @@ export class SecuritySystem {
|
|
|
549
733
|
if (!home) {
|
|
550
734
|
throw new Error('the home is no longer on this ecobee account');
|
|
551
735
|
}
|
|
736
|
+
this.reachable();
|
|
737
|
+
this.noteThermostats(home.devices?.thermostats);
|
|
552
738
|
this.apply(home);
|
|
553
739
|
return true;
|
|
554
740
|
} catch (error) {
|
|
555
|
-
this.log.warn(`Could not read the security state
|
|
741
|
+
this.log.warn(`Could not read the security state (${error.message}).`);
|
|
556
742
|
return false;
|
|
557
743
|
}
|
|
558
744
|
}
|
|
@@ -580,6 +766,9 @@ export class SecuritySystem {
|
|
|
580
766
|
if (!(await this.refresh()) || !this.monitoring) {
|
|
581
767
|
return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
|
|
582
768
|
}
|
|
769
|
+
if (this.offline) {
|
|
770
|
+
return { ok: false, held: false, state: 'offline', reason: 'the security system is offline' };
|
|
771
|
+
}
|
|
583
772
|
if (this.incidents.length) {
|
|
584
773
|
return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'an alarm incident is open' };
|
|
585
774
|
}
|
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.8.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",
|