homebridge-ecobee-security 0.4.2 → 0.5.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ - The `pollInterval` setting is gone: the security state is read every 10 s, as often as the ecobee
6
+ app does (it has no live updates either). A value left in the config is ignored.
7
+ - Settings: the whole page is now cards (account, Security system, Sensors, Security holds), with
8
+ **Security holds** at the bottom; the standard settings form is no longer shown.
9
+ - Settings: a **Security holds** section with the **Allow security holds** switch and a checkbox per
10
+ plugin (`holdPlugins`): installed plugins that declare they use holds, and any that asked. Hold
11
+ requests can name their plugin (`&plugin=<package>`); unticked plugins are refused.
12
+ - Declares the hold socket in package.json (`"homebridge-security-hold": { "socket": … }`), so
13
+ plugins that support the hold, like homebridge-narwal-freo, list it in their settings.
14
+ - Hold API: several plugins (e.g. two robot vacuums) can hold Stay at once; Away comes back only
15
+ when the last one releases. `/release` answers `waiting: [names]` while others still hold.
16
+ - New setting **Allow security holds** (`allowHolds`, off by default): other plugins can only hold
17
+ Stay with it turned on; otherwise `/hold` and `/release` answer 403 with the reason, and a hold
18
+ left over from before is dropped without re-arming Away. **If you use homebridge-narwal-freo's
19
+ security hold, turn this on and tick Narwal Freo.**
20
+ - Hold and release decide on ecobee's live state, read at the moment of the request, not the last
21
+ poll: a disarm in the ecobee app seconds before a robot finishes is no longer armed over with
22
+ Away. If ecobee can't be read, a hold is refused and a release doesn't arm (the caller retries).
23
+
3
24
  ## 0.4.2
4
25
 
5
26
  - New plugin icon.
package/README.md CHANGED
@@ -58,10 +58,10 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
58
58
  "platform": "EcobeeSecurityPlatform",
59
59
  "name": "Security System",
60
60
  "refreshToken": "…",
61
- "pollInterval": 10,
62
61
  "offerNight": false,
63
62
  "alarmTrigger": "incident",
64
63
  "smokeSensor": true,
64
+ "allowHolds": false,
65
65
  "airQuality": true,
66
66
  "co2Alert": 1000,
67
67
  "_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
@@ -75,6 +75,7 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
75
75
  | `offerNight` | `false` | Adds Night to the Home app. ecobee has no Night mode, so Night arms Stay and the tile keeps showing Night until the state changes (a Stay armed elsewhere shows as Stay). HomeKit only picks up a changed list of modes on a new control, so changing this setting recreates the security system in the Home app: set up scenes and automations that use it again. |
76
76
  | `alarmTrigger` | `"incident"` | When the tile enters **Triggered**: `"incident"` immediately, when ecobee opens an incident as a door or sensor trips while armed (mirrors the ecobee app); `"entryDelay"` only after the entry (disarm) delay runs out without a disarm. Smoke alarms always trigger immediately. |
77
77
  | `smokeSensor` | `true` | Adds a separate **Smoke Alarm** accessory (a Smoke Sensor) that detects smoke while ecobee has a smoke-alarm incident open, for automations and Home app smoke notifications. Set to `false` to remove it. |
78
+ | `allowHolds` | `false` | Lets other Homebridge plugins ask for a **security hold** (see below), e.g. so a robot vacuum can clean while you're away without setting off the motion sensors. Off: hold requests are refused, and a hold left over from before is dropped without re-arming Away. |
78
79
  | `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. |
79
80
  | `co2Alert` | `1000` | CO2 (ppm) at or above which the Carbon Dioxide Sensor reports abnormal levels. |
80
81
 
@@ -83,19 +84,64 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
83
84
  - **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
84
85
  delay the Home app shows "Arming…" (current Disarmed, target Away). Stay uses the Stay exit delay
85
86
  (0 by default). Disarm needs no PIN.
86
- - **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within the poll
87
- interval (10 s by default), and quickly after an exit delay ends.
87
+ - **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within 10 s, about
88
+ as fast as in the ecobee app (it polls too), and quickly after an exit delay ends.
88
89
  - **Alarm triggered** while ecobee lists an open incident (see `alarmTrigger` above for when it
89
90
  starts showing). Disarming clears it. Every change in the incident list is logged.
90
91
  - **Siri:** the Home app calls Stay "Home". Asking Siri for Home or for Stay mode both arm Stay.
91
92
  - **Night** is only offered with `offerNight`; ecobee has no Night mode of its own.
92
93
  - After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
93
- - **Hold for other plugins:** a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
94
- (mode 600, homebridge user only) lets another plugin, e.g. homebridge-narwal, ask for a temporary
95
- Stay: `POST /hold?by=<name>` switches Away (or arming Away) to Stay and remembers it;
96
- `POST /release?by=<name>` arms Away again — unless the state was changed by anyone in between, in
97
- which case the hold is just dropped. The hold survives restarts (in `state.json`). `/hold`
98
- answers `ok: false` when the alarm is triggered or the state is unknown.
94
+ - **Security holds** for other plugins, with `allowHolds`: see below.
95
+
96
+ ## Security holds
97
+
98
+ A security hold lets another Homebridge plugin, such as a robot vacuum, keep the motion sensors from
99
+ going off while it moves around the house when you're away. The plugin can only *ask*; this plugin
100
+ decides, by four rules:
101
+
102
+ 1. **Hold:** if the system is armed Away (or arming Away), it's switched to Stay. If an alarm is in
103
+ progress, or ecobee can't be read, the hold is refused. Otherwise nothing changes.
104
+ 2. **Release:** once **every** plugin that asked is done, and the system is still in Stay, it's
105
+ armed Away again (with the normal exit delay).
106
+ 3. **Your change wins:** anything someone else does during a hold — disarm, arm Away, a change in
107
+ the Home app, an alarm — cancels it, and the system is left as it is.
108
+ 4. **No timers:** nothing changes unless a plugin asks or you act.
109
+
110
+ Every decision uses ecobee's state read at that moment, not the last poll. Holds are off until you
111
+ turn on **Allow security holds** in the settings' **Security holds** section and tick each plugin that
112
+ may ask (`holdPlugins`): it lists installed plugins that declare they use holds, and any plugin that
113
+ has asked. A plugin that isn't ticked is refused (it can still release a hold it already has).
114
+ Turning holds off drops a hold left over from before (without arming Away).
115
+
116
+ **Limits** that follow from these rules:
117
+
118
+ - **Your own Stay looks like the hold's Stay.** If you disarm and re-arm Stay in the ecobee app or at
119
+ the keypad within a few seconds (between two reads), or while the bridge is restarting, the
120
+ release still arms Away: ecobee doesn't say who last changed the state. Changes in the Home app are
121
+ always seen.
122
+ - **Your change wins even with a robot still out.** After you cancel a hold (say, disarm), arming
123
+ Away while the robot is still cleaning can set off the motion sensors: a plugin only asks for a
124
+ hold when it starts.
125
+ - **Plugins identify themselves.** Any plugin running as the homebridge user can reach the socket,
126
+ and the plugin name it gives is its own claim: the list keeps honest plugins apart, it isn't a
127
+ lock. Two requests with the same name share one hold.
128
+ - **A plugin that never releases leaves the system in Stay** until you change it.
129
+
130
+ **API** (for plugin authors): a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
131
+ (mode 600, homebridge user only), declared in `package.json` as
132
+ `"homebridge-security-hold": { "socket": "ecobee-security/control.sock" }` so plugins that support
133
+ holds, like homebridge-narwal-freo, can list it. Plugins that ask for holds can declare
134
+ `"homebridge-security-hold": { "client": true }` to show up in the list before their first request.
135
+ The full API: [Security hold](https://github.com/seanfletchr/homebridge-narwal-freo/blob/main/docs/security-hold.md).
136
+
137
+ - `POST /hold?by=<name>&plugin=<package>` → `{ "ok": true, "held": true|false, "state": "…" }`,
138
+ or `"ok": false` with a `"reason"`: don't start. `plugin` is optional; without it the plugin is
139
+ listed (and allowed) under its `by` name.
140
+ - `POST /release?by=<name>&plugin=<package>` → `{ "released": true|false, "state": "…", "waiting": [names] }`;
141
+ `waiting` lists holders still out.
142
+ - With `allowHolds` off or the plugin not ticked, both answer 403 (a release from a current holder
143
+ is always accepted); if ecobee can't be read (release) or isn't connected
144
+ yet, a 5xx with `"error"` — try again later. Holds survive restarts (in `state.json`).
99
145
 
100
146
  ## API notes
101
147
 
@@ -26,18 +26,11 @@
26
26
  "type": "string",
27
27
  "description": "Only needed if the account has more than one home; the log lists them."
28
28
  },
29
- "pollInterval": {
30
- "title": "Check every (seconds)",
31
- "type": "integer",
32
- "default": 10,
33
- "minimum": 5,
34
- "description": "How often to read the armed state, so changes made in the ecobee app or at the keypad show up in Apple Home."
35
- },
36
29
  "offerNight": {
37
30
  "title": "Offer Night",
38
31
  "type": "boolean",
39
32
  "default": false,
40
- "description": "Adds Night to the Home app, for automations or Siri that use it. ecobee has no Night mode, so Night arms Stay; the Home app keeps showing Night until the state changes. Changing this setting recreates the security system in the Home app, so scenes and automations that use it have to be set up again."
33
+ "description": "Adds the Night setting for more specific automations. ecobee has no \"Night\" mode, so Night arms Stay. Changing this recreates the security system in the Home app; scenes and automations using it must be set up again."
41
34
  },
42
35
  "alarmTrigger": {
43
36
  "title": "Enter Triggered State",
@@ -65,6 +58,20 @@
65
58
  "default": true,
66
59
  "description": "Adds a separate \"Smoke Alarm\" accessory to the Home app that detects smoke whenever ecobee reports a smoke-alarm incident, so you can use it in automations and get Home app smoke notifications."
67
60
  },
61
+ "allowHolds": {
62
+ "title": "Allow security holds",
63
+ "type": "boolean",
64
+ "default": false,
65
+ "description": "Lets other Homebridge plugins (for example a robot vacuum, so it can clean while you're away) switch Away to Stay for a while and back to Away when they're done. Each plugin also has to be allowed in holdPlugins (the settings page lists the ones that asked). See \"Security holds\" in the README."
66
+ },
67
+ "holdPlugins": {
68
+ "title": "Plugins allowed to ask for holds",
69
+ "type": "array",
70
+ "items": {
71
+ "type": "string"
72
+ },
73
+ "description": "Package names (or, for a plugin that doesn't say, the name it asks with). Without this list, every plugin may ask once Allow security holds is on."
74
+ },
68
75
  "airQuality": {
69
76
  "title": "Air quality sensors",
70
77
  "type": "boolean",
@@ -56,6 +56,72 @@
56
56
  </div>
57
57
  </div>
58
58
 
59
+
60
+ <div class="card card-body mb-3" id="system-card" hidden>
61
+ <h5 class="mb-3">Security system</h5>
62
+ <div class="mb-3">
63
+ <label for="s-name" class="form-label">Name</label>
64
+ <input class="form-control" type="text" id="s-name" data-key="name" data-type="text" data-default="Security System">
65
+ <div class="hint mt-1">Name shown in the Home app.</div>
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 the account has more than one home; the log lists them.</div>
71
+ </div>
72
+ <div class="mb-3">
73
+ <div class="form-check form-switch">
74
+ <input class="form-check-input" type="checkbox" role="switch" id="s-offerNight" data-key="offerNight" data-type="bool" data-default="false">
75
+ <label class="form-check-label" for="s-offerNight">Offer Night</label>
76
+ </div>
77
+ <div class="hint">Adds the Night setting for more specific automations. ecobee has no "Night" mode, so Night arms Stay.</div>
78
+ <div class="hint mt-1">Changing this recreates the security system in the Home app; scenes and automations using it must be set up again.</div>
79
+ </div>
80
+ <div class="mb-0">
81
+ <label for="s-alarmTrigger" class="form-label">Enter Triggered State</label>
82
+ <select class="form-select" id="s-alarmTrigger" data-key="alarmTrigger" data-type="select" data-default="incident"><option value="incident">Immediately (mirror ecobee app)</option><option value="entryDelay">After the Entry/Disarm delay</option></select>
83
+ <div class="hint mt-1">ecobee opens an incident as soon as a door or sensor trips while armed. "After the Entry/Disarm delay" waits until that delay runs out without a disarm, so automations on Triggered don't fire while you're walking in to disarm. Smoke alarms always trigger immediately.</div>
84
+ </div>
85
+ </div>
86
+
87
+ <div class="card card-body mb-3" id="sensors-card" hidden>
88
+ <h5 class="mb-3">Sensors</h5>
89
+ <div class="mb-3">
90
+ <div class="form-check form-switch">
91
+ <input class="form-check-input" type="checkbox" role="switch" id="s-smokeSensor" data-key="smokeSensor" data-type="bool" data-default="true">
92
+ <label class="form-check-label" for="s-smokeSensor">Smoke sensor</label>
93
+ </div>
94
+ <div class="hint">Adds a separate "Smoke Alarm" accessory to the Home app that detects smoke whenever ecobee reports a smoke-alarm incident, so you can use it in automations and get Home app smoke notifications.</div>
95
+ </div>
96
+ <div class="mb-3">
97
+ <div class="form-check form-switch">
98
+ <input class="form-check-input" type="checkbox" role="switch" id="s-airQuality" data-key="airQuality" data-type="bool" data-default="true">
99
+ <label class="form-check-label" for="s-airQuality">Air quality sensors</label>
100
+ </div>
101
+ <div class="hint">For ecobee thermostats with an air quality sensor (ecobee Premium): adds an Air Quality Sensor (overall level and VOC) and a Carbon Dioxide Sensor (CO2 in ppm) per thermostat. ecobee's own HomeKit integration doesn't show these. Updated every 5 minutes.</div>
102
+ </div>
103
+ <div class="mb-0" id="co2-box">
104
+ <label for="s-co2Alert" class="form-label">CO2 alert level (ppm)</label>
105
+ <input class="form-control" type="number" step="1" min="400" id="s-co2Alert" data-key="co2Alert" data-type="int" data-default="1000">
106
+ <div class="hint mt-1">The Carbon Dioxide Sensor reports abnormal CO2 at or above this level, for Home app notifications and automations (for example, turning on ventilation).</div>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="card card-body mb-3" id="holds-card" hidden>
111
+ <h5 class="mb-2">Security holds</h5>
112
+ <div class="form-check form-switch mb-1">
113
+ <input class="form-check-input" type="checkbox" role="switch" id="allow-holds">
114
+ <label class="form-check-label" for="allow-holds">Allow security holds</label>
115
+ </div>
116
+ <p class="hint mb-2">Lets supported plugins, like a robot vacuum, switch Away to Home while they work and back to
117
+ Away when they're done.
118
+ <a href="https://github.com/seanfletchr/homebridge-ecobee-security#security-holds" target="_blank" rel="noopener">Learn more…</a></p>
119
+ <div id="hold-plugins-box" hidden>
120
+ <div class="mb-1">Allowed plugins</div>
121
+ <div id="hold-plugins"></div>
122
+ </div>
123
+ </div>
124
+
59
125
  <script>
60
126
  (async () => {
61
127
  const PLATFORM = 'EcobeeSecurityPlatform';
@@ -100,11 +166,8 @@
100
166
  const blocks = await homebridge.getPluginConfig();
101
167
  const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
102
168
  block.refreshToken = refreshToken;
103
- // Re-show the settings form afterwards so it picks up the new token.
104
- homebridge.hideSchemaForm();
105
169
  await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
106
170
  await homebridge.savePluginConfig();
107
- homebridge.showSchemaForm();
108
171
  passwordForm.reset();
109
172
  mfaForm.reset();
110
173
  passwordForm.hidden = false;
@@ -113,6 +176,8 @@
113
176
  $('browser').open = false;
114
177
  homebridge.toast.success('Signed in to ecobee.');
115
178
  await showStatus({ justSignedIn: true });
179
+ await showSettings();
180
+ await showHolds();
116
181
  }
117
182
 
118
183
  passwordForm.addEventListener('submit', async event => {
@@ -176,7 +241,117 @@
176
241
  }
177
242
  });
178
243
 
244
+
245
+ // ── Settings cards ──
246
+
247
+ const controls = [...document.querySelectorAll('[data-key]')];
248
+
249
+ /** Fill the cards from the config; show defaults where nothing is set. */
250
+ async function showSettings() {
251
+ const [block] = await homebridge.getPluginConfig();
252
+ const signedIn = Boolean(block?.refreshToken);
253
+ $('system-card').hidden = !signedIn;
254
+ $('sensors-card').hidden = !signedIn;
255
+ for (const input of controls) {
256
+ const value = block?.[input.dataset.key];
257
+ if (input.dataset.type === 'bool') {
258
+ input.checked = value ?? input.dataset.default === 'true';
259
+ } else {
260
+ input.value = value ?? input.dataset.default;
261
+ }
262
+ }
263
+ $('co2-box').hidden = block?.airQuality === false;
264
+ }
265
+
266
+ /** Write one setting. Top-level keys are cleared with undefined, never deleted (the UI merges shallowly). */
267
+ async function setSetting(key, value) {
268
+ const blocks = await homebridge.getPluginConfig();
269
+ const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
270
+ block[key] = value;
271
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
272
+ }
273
+
274
+ for (const input of controls) {
275
+ input.addEventListener('change', async () => {
276
+ const { key, type } = input.dataset;
277
+ if (type === 'bool') {
278
+ await setSetting(key, input.checked);
279
+ if (key === 'airQuality') {
280
+ $('co2-box').hidden = !input.checked;
281
+ }
282
+ } else if (type === 'int') {
283
+ const number = input.value.trim() === '' ? undefined : Number(input.value);
284
+ const valid = number === undefined || (Number.isInteger(number) && number >= Number(input.min));
285
+ input.classList.toggle('is-invalid', !valid);
286
+ if (valid) {
287
+ await setSetting(key, number);
288
+ }
289
+ } else if (type === 'select') {
290
+ await setSetting(key, input.value);
291
+ } else {
292
+ await setSetting(key, input.value.trim() === '' ? undefined : input.value.trim());
293
+ }
294
+ });
295
+ }
296
+
297
+ // ── Security holds ──
298
+
299
+ async function showHolds() {
300
+ const blocks = await homebridge.getPluginConfig();
301
+ const block = blocks[0] ?? {};
302
+ $('holds-card').hidden = !block.refreshToken;
303
+ $('allow-holds').checked = block.allowHolds === true;
304
+ $('hold-plugins-box').hidden = block.allowHolds !== true;
305
+ const plugins = await homebridge.request('/hold-plugins').catch(() => []);
306
+ const box = $('hold-plugins');
307
+ box.textContent = '';
308
+ if (!plugins.length) {
309
+ const p = document.createElement('p');
310
+ p.className = 'hint mb-0';
311
+ p.textContent = 'No plugin has asked for a hold yet.';
312
+ box.append(p);
313
+ return;
314
+ }
315
+ // Without a list (settings from before it), every plugin is allowed.
316
+ const allowed = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
317
+ for (const plugin of plugins) {
318
+ const id = `hold-${plugin.key.replace(/[^a-z0-9]/gi, '-')}`;
319
+ const input = Object.assign(document.createElement('input'), {
320
+ className: 'form-check-input', type: 'checkbox', id, checked: allowed.includes(plugin.key),
321
+ });
322
+ input.addEventListener('change', () => setAllowed(plugins, plugin.key, input.checked));
323
+ const label = Object.assign(document.createElement('label'), { className: 'form-check-label', htmlFor: id });
324
+ label.textContent = plugin.displayName === plugin.key ? plugin.key : `${plugin.displayName} (${plugin.key})`;
325
+ const row = Object.assign(document.createElement('div'), { className: 'form-check mb-1' });
326
+ row.append(input, label);
327
+ if (plugin.names.length || plugin.lastAsked) {
328
+ const hint = Object.assign(document.createElement('div'), { className: 'hint' });
329
+ const when = plugin.lastAsked ? `last asked ${new Date(plugin.lastAsked).toLocaleString()}` : '';
330
+ hint.textContent = [plugin.names.length ? `for ${plugin.names.join(', ')}` : '', when].filter(Boolean).join(', ');
331
+ row.append(hint);
332
+ }
333
+ box.append(row);
334
+ }
335
+ }
336
+
337
+ async function setAllowed(plugins, key, on) {
338
+ const blocks = await homebridge.getPluginConfig();
339
+ const block = blocks[0];
340
+ const current = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
341
+ block.holdPlugins = on ? [...new Set([...current, key])] : current.filter(k => k !== key);
342
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
343
+ }
344
+
345
+ $('allow-holds').addEventListener('change', async () => {
346
+ const blocks = await homebridge.getPluginConfig();
347
+ const block = blocks[0];
348
+ block.allowHolds = $('allow-holds').checked;
349
+ $('hold-plugins-box').hidden = !block.allowHolds;
350
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
351
+ });
352
+
179
353
  await showStatus();
180
- homebridge.showSchemaForm();
354
+ await showSettings();
355
+ await showHolds();
181
356
  })();
182
357
  </script>
@@ -1,6 +1,11 @@
1
1
  // Server side of the plugin's settings page (spawned by the Homebridge UI while the settings are
2
2
  // open). Does the ecobee sign-in and hands only the refresh token back to the page, which saves it
3
3
  // in the plugin config. The MFA token and PKCE verifier stay here, in memory.
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
4
9
  import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
5
10
 
6
11
  import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn, SignInError } from '../lib/auth.js';
@@ -47,6 +52,26 @@ class UiServer extends HomebridgePluginUiServer {
47
52
  return { refreshToken: tokens.refresh_token };
48
53
  }));
49
54
 
55
+ // Plugins that use security holds: installed ones that declare it, and any that have asked.
56
+ // Only the request log leaves state.json (it also holds the ecobee sign-in).
57
+ this.onRequest('/hold-plugins', async () => {
58
+ const plugins = new Map();
59
+ for (const p of installedHoldClients(this.homebridgeStoragePath)) {
60
+ plugins.set(p.name, { key: p.name, displayName: p.displayName, names: [] });
61
+ }
62
+ let store = {};
63
+ try {
64
+ store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
65
+ } catch {
66
+ // Not started yet: only the installed ones.
67
+ }
68
+ for (const [key, r] of Object.entries(store.holdRequesters ?? {})) {
69
+ const known = plugins.get(key);
70
+ plugins.set(key, { key, displayName: known?.displayName ?? key, names: r.names ?? [], lastAsked: r.lastAsked });
71
+ }
72
+ return [...plugins.values()];
73
+ });
74
+
50
75
  this.ready();
51
76
  }
52
77
 
@@ -63,4 +88,41 @@ class UiServer extends HomebridgePluginUiServer {
63
88
  }
64
89
  }
65
90
 
91
+ /** Installed plugins that declare they ask for holds: "homebridge-security-hold": { "client": true }. */
92
+ function installedHoldClients(storagePath) {
93
+ const folders = new Set([fileURLToPath(new URL('../../', import.meta.url))]);
94
+ if (storagePath) {
95
+ folders.add(join(storagePath, 'node_modules'));
96
+ }
97
+ const found = new Map();
98
+ const scan = folder => {
99
+ let entries = [];
100
+ try {
101
+ entries = readdirSync(folder, { withFileTypes: true });
102
+ } catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ const dir = join(folder, entry.name);
107
+ if (entry.name.startsWith('@')) {
108
+ scan(dir);
109
+ continue;
110
+ }
111
+ if (!entry.name.includes('homebridge')) {
112
+ continue;
113
+ }
114
+ try {
115
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
116
+ if (pkg['homebridge-security-hold']?.client === true && pkg.name && !found.has(pkg.name)) {
117
+ found.set(pkg.name, { name: pkg.name, displayName: pkg.displayName || pkg.name });
118
+ }
119
+ } catch {
120
+ // not a package
121
+ }
122
+ }
123
+ };
124
+ folders.forEach(scan);
125
+ return [...found.values()];
126
+ }
127
+
66
128
  (() => new UiServer())();
package/lib/control.js CHANGED
@@ -1,13 +1,17 @@
1
1
  // A local control socket so other Homebridge plugins (each child bridge is its own process)
2
2
  // can ask for a temporary Stay while they move around the house:
3
- // POST /hold?by=<name> -> { ok, held, state, reason? }
4
- // POST /release?by=<name> -> { released, state }
3
+ // POST /hold?by=<name>&plugin=<package> -> { ok, held, state, reason? }
4
+ // POST /release?by=<name>&plugin=<package> -> { released, state, waiting? } (Away only once every holder has released)
5
5
  // It's a Unix socket in the plugin's storage folder (mode 600), so only the homebridge user can use it.
6
+ // Holds need `allowHolds` and the asking plugin ticked in the settings (`isAllowed`); otherwise it
7
+ // answers 403 so the caller can log why. Every request is noted (`noteRequest`) so the settings page
8
+ // can list the plugins that asked. A release is always accepted from a current holder, so taking a
9
+ // plugin's permission away can't leave the system stuck in Stay.
6
10
 
7
11
  import { chmodSync, rmSync } from 'node:fs';
8
12
  import { createServer } from 'node:http';
9
13
 
10
- export function startControlServer({ path, log, getSystem }) {
14
+ export function startControlServer({ path, log, getSystem, isAllowed, noteRequest }) {
11
15
  try {
12
16
  rmSync(path, { force: true });
13
17
  } catch (error) {
@@ -16,14 +20,29 @@ export function startControlServer({ path, log, getSystem }) {
16
20
  const server = createServer(async (req, res) => {
17
21
  const url = new URL(req.url, 'http://localhost');
18
22
  const by = (url.searchParams.get('by') || 'another plugin').slice(0, 40);
23
+ // Requests are allowed per plugin; one that doesn't say which it is goes by its `by` name.
24
+ const plugin = (url.searchParams.get('plugin') || '').slice(0, 100) || undefined;
25
+ const key = plugin ?? by;
19
26
  const reply = (code, body) => {
20
27
  res.writeHead(code, { 'content-type': 'application/json' });
21
28
  res.end(JSON.stringify(body));
22
29
  };
23
- const system = getSystem();
24
30
  if (req.method !== 'POST' || !['/hold', '/release'].includes(url.pathname)) {
25
31
  return reply(404, { error: 'POST /hold or /release' });
26
32
  }
33
+ const system = getSystem();
34
+ const releasing = url.pathname === '/release';
35
+ noteRequest?.({ key, plugin, by });
36
+ if (!(releasing && system?.holders().includes(by))) {
37
+ const allowed = isAllowed(key);
38
+ if (allowed !== true) {
39
+ const why = allowed === 'off'
40
+ ? 'security holds are turned off in the ecobee Smart Security settings'
41
+ : `${key} isn't allowed to ask for security holds in the ecobee Smart Security settings`;
42
+ log.warn(`${by}${plugin ? ` (${plugin})` : ''} asked for a security hold, but ${why}.`);
43
+ return reply(403, { ok: false, error: why });
44
+ }
45
+ }
27
46
  if (!system) {
28
47
  return reply(503, { error: 'not connected to ecobee yet' });
29
48
  }
package/lib/platform.js CHANGED
@@ -60,7 +60,13 @@ export class EcobeeSecurityPlatform {
60
60
  });
61
61
 
62
62
  api.on('didFinishLaunching', () => {
63
- this.control = startControlServer({ path: join(dir, 'control.sock'), log, getSystem: () => this.system });
63
+ this.control = startControlServer({
64
+ path: join(dir, 'control.sock'),
65
+ log,
66
+ getSystem: () => this.system,
67
+ isAllowed: key => this.holdAllowed(key),
68
+ noteRequest: request => this.noteHoldRequest(request),
69
+ });
64
70
  this.launch();
65
71
  });
66
72
  api.on('shutdown', () => {
@@ -150,7 +156,8 @@ export class EcobeeSecurityPlatform {
150
156
  accessory,
151
157
  cloud: this.cloud,
152
158
  home,
153
- pollInterval: Math.max(5, Number(this.config.pollInterval) || 10),
159
+ // The ecobee app reads the security state about every 10 s; polling faster would stand out.
160
+ pollInterval: 10,
154
161
  store: this.store,
155
162
  save: () => this.save(),
156
163
  offerNight: this.config.offerNight === true,
@@ -171,12 +178,41 @@ export class EcobeeSecurityPlatform {
171
178
  const m = home.monitoring;
172
179
  this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
173
180
  + `${m.homeMonitoringSettings?.isProfessionalMonitoringEnabled ? 'on' : 'off'}).`);
174
- if (this.store.hold) {
175
- this.log.info(`Still holding Stay for ${this.store.hold.by} (since ${this.store.hold.since}).`);
181
+ if (this.store.hold && this.config.allowHolds !== true) {
182
+ this.log.info(`Security holds are off; dropping the hold for ${this.system.holders().join(' and ')} without re-arming Away.`);
183
+ this.system.dropHold();
184
+ } else if (this.store.hold) {
185
+ this.log.info(`Still holding Stay for ${this.system.holders().join(' and ')} (since ${this.store.hold.since}).`);
176
186
  }
177
187
  this.startAirQuality(home.id);
178
188
  }
179
189
 
190
+ /**
191
+ * May this plugin (or, if it doesn't say, this name) ask for a hold? `true`, `'off'` (holds are off),
192
+ * or `'not allowed'`. Without a `holdPlugins` list (settings from before it), every plugin may.
193
+ */
194
+ holdAllowed(key) {
195
+ if (this.config.allowHolds !== true) {
196
+ return 'off';
197
+ }
198
+ const list = this.config.holdPlugins;
199
+ return !Array.isArray(list) || list.includes(key) ? true : 'not allowed';
200
+ }
201
+
202
+ /** Remember who asked, for the settings page's list of plugins. */
203
+ noteHoldRequest({ key, plugin, by }) {
204
+ const requesters = this.store.holdRequesters ??= {};
205
+ const entry = requesters[key] ??= { names: [] };
206
+ if (plugin) {
207
+ entry.plugin = plugin;
208
+ }
209
+ if (!entry.names.includes(by)) {
210
+ entry.names = [...entry.names, by].slice(-10);
211
+ }
212
+ entry.lastAsked = new Date().toISOString();
213
+ this.save();
214
+ }
215
+
180
216
  /** Air quality sensors for thermostats that have them; failures here never affect the security system. */
181
217
  async startAirQuality(homeId) {
182
218
  const cached = this.cached.filter(a => a.context.kind === AIR_QUALITY);
package/lib/security.js CHANGED
@@ -228,7 +228,7 @@ export class SecuritySystem {
228
228
  this.target = target;
229
229
  if (this.store.hold && (target !== Hk.STAY || this.incidents.length)) {
230
230
  this.log.info(`Security system changed to ${HK_NAMES[this.incidents.length ? Hk.TRIGGERED : target]} during the `
231
- + `${this.store.hold.by} hold; it won't be put back to Away.`);
231
+ + `${this.holders().join(' and ')} hold; it won't be put back to Away.`);
232
232
  this.dropHold();
233
233
  }
234
234
  this.service.updateCharacteristic(SecuritySystemTargetState, target);
@@ -311,7 +311,7 @@ export class SecuritySystem {
311
311
  throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
312
312
  }
313
313
  if (this.store.hold) {
314
- this.log.info(`Home app changed the security system during the ${this.store.hold.by} hold; it won't be put back to Away.`);
314
+ this.log.info(`Home app changed the security system during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
315
315
  this.dropHold();
316
316
  }
317
317
  try {
@@ -358,11 +358,32 @@ export class SecuritySystem {
358
358
  }
359
359
  }
360
360
 
361
+ /** Read the state now (for hold decisions). Resolves false if ecobee can't be read. */
362
+ async refresh() {
363
+ try {
364
+ const home = (await this.cloud.getHomes()).find(h => h.id === this.homeId);
365
+ if (!home) {
366
+ throw new Error('the home is no longer on this ecobee account');
367
+ }
368
+ this.apply(home);
369
+ return true;
370
+ } catch (error) {
371
+ this.log.warn(`Could not read the security state for a hold (${error.message}).`);
372
+ return false;
373
+ }
374
+ }
375
+
361
376
  dropHold() {
362
377
  delete this.store.hold;
363
378
  this.save();
364
379
  }
365
380
 
381
+ /** Who is holding Stay (several robots can be out at once). Up to 0.4.x `by` was a single name. */
382
+ holders() {
383
+ const by = this.store.hold?.by;
384
+ return Array.isArray(by) ? by : by ? [by] : [];
385
+ }
386
+
366
387
  /**
367
388
  * Another plugin (the robot vacuum) is about to move around the house. If the system is
368
389
  * armed Away (or arming Away), switch it to Stay and remember to put it back. Returns
@@ -371,20 +392,26 @@ export class SecuritySystem {
371
392
  */
372
393
  hold(by) {
373
394
  return this.serial(async () => {
374
- if (!this.monitoring || this.failures >= FAULT_AFTER) {
395
+ // Decide on ecobee's live state, not the last poll: a disarm moments ago must not become a Stay.
396
+ if (!(await this.refresh()) || !this.monitoring) {
375
397
  return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
376
398
  }
377
399
  if (this.incidents.length) {
378
400
  return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'an alarm incident is open' };
379
401
  }
380
402
  if (this.store.hold) {
403
+ if (!this.holders().includes(by)) {
404
+ this.store.hold = { ...this.store.hold, by: [...this.holders(), by] };
405
+ this.save();
406
+ this.log.info(`${by} joined the hold; Away comes back once ${this.holders().join(' and ')} are done.`);
407
+ }
381
408
  return { ok: true, held: true, state: HK_NAMES[this.target] };
382
409
  }
383
410
  if (this.target !== Hk.AWAY) {
384
411
  return { ok: true, held: false, state: HK_NAMES[this.target] };
385
412
  }
386
413
  await this.arm(Hk.STAY, `${by} hold`);
387
- this.store.hold = { by, since: new Date().toISOString() };
414
+ this.store.hold = { by: [by], since: new Date().toISOString() };
388
415
  this.save();
389
416
  return { ok: true, held: true, state: HK_NAMES[this.target] };
390
417
  });
@@ -393,9 +420,22 @@ export class SecuritySystem {
393
420
  /** The vacuum is back: put the system back to Away if we moved it and nobody has changed it since. */
394
421
  release(by) {
395
422
  return this.serial(async () => {
423
+ // A change since the last poll (say, a disarm in the ecobee app) drops the hold in apply(),
424
+ // so a fresh read is what keeps us from arming Away over it. Can't read it: don't arm.
425
+ if (!(await this.refresh())) {
426
+ throw new Error('could not read the security state; not arming Away');
427
+ }
396
428
  if (!this.store.hold) {
397
429
  return { released: false, state: HK_NAMES[this.target] };
398
430
  }
431
+ const others = this.holders().filter(name => name !== by);
432
+ if (others.length) {
433
+ // Someone else is still out: keep Stay until the last one is done.
434
+ this.store.hold = { ...this.store.hold, by: others };
435
+ this.save();
436
+ this.log.info(`${by} is done; staying in Stay for ${others.join(' and ')}.`);
437
+ return { released: false, state: HK_NAMES[this.target], waiting: others };
438
+ }
399
439
  if (this.target !== Hk.STAY || this.incidents.length) {
400
440
  this.log.info(`${by} is done, but the security system is ${HK_NAMES[this.current]}; leaving it.`);
401
441
  this.dropHold();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ecobee-security",
3
3
  "displayName": "ecobee Smart Security",
4
- "version": "0.4.2",
4
+ "version": "0.5.0",
5
5
  "description": "ecobee Smart Security as a HomeKit security system: arm Stay / Away, disarm, alarm state (unofficial, ecobee cloud)",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -41,5 +41,8 @@
41
41
  ],
42
42
  "dependencies": {
43
43
  "@homebridge/plugin-ui-utils": "^2.2.6"
44
+ },
45
+ "homebridge-security-hold": {
46
+ "socket": "ecobee-security/control.sock"
44
47
  }
45
48
  }