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 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 log lists them).
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 "&lt;thermostat&gt; 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 (see `alarmTrigger` above for when it
96
- starts showing). Disarming clears it. Every change in the incident list is logged.
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; reads back off up to 5 minutes.
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
@@ -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 log lists your homes."
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 ID</label>
69
- <input class="form-control" type="text" id="s-homeId" data-key="homeId" data-type="text" data-default="">
70
- <div class="hint mt-1">Only needed if your account has more than one home. The log lists your homes.</div>
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
  }
@@ -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
- return { accessory, air, co2, name: thermostat.name };
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
- this.apply(await this.cloud.getAirQuality(this.homeId));
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
- const STATE_QUERY = `query HomesSecurity { homes { id name capabilities monitoring { ${MONITORING_FIELDS} `
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
- throw new Error(`${operationName}: ${message}`);
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
- /** All homes on the account with their security state. */
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
- const data = await this.graphql('HomesSecurity', STATE_QUERY);
124
- return data.homes ?? [];
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.incidents = [];
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.setFault(true);
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.setFault(true);
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.service.updateCharacteristic(SecuritySystemTargetState, this.store.hold?.away ? Hk.AWAY : target);
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.smoke.updateCharacteristic(SmokeDetected, smoke ? SmokeDetected.SMOKE_DETECTED : SmokeDetected.SMOKE_NOT_DETECTED);
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.incidents = incidents.filter(i => !CLOSED_INCIDENT.test(i.status ?? ''));
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.incidents.map(i => i.id));
274
- this.incidents.forEach(i => this.firstSeen.has(i.id) || this.firstSeen.set(i.id, now));
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
- async setTarget(value) {
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(`Home app changed the security system during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
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, 'Home app'));
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.service.updateCharacteristic(this.C.SecuritySystemTargetState, this.target ?? Hk.DISARMED), 500);
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(`Home app: arm Away — waiting until ${this.holders().join(' and ')} ${this.holders().length > 1 ? 'are' : 'is'} done (security hold). `
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.service.updateCharacteristic(this.C.SecuritySystemTargetState, Hk.AWAY);
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. Always sent,
392
- * even if the last poll said Off: it may be up to 10 s old, and disarming twice does no harm.
393
- * Retried once, since a missed disarm means an alarm.
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 for a hold (${error.message}).`);
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.7.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",