homebridge-ecobee-security 0.4.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
 
9
9
  <div id="signed-in" hidden>
10
10
  <div class="alert alert-success mb-2" role="status" id="signed-in-text">Signed in to ecobee.</div>
11
- <p class="hint mb-2" id="signed-in-hint">You only need to sign in again if the log says ecobee signed the plugin out (for example after a password change).</p>
11
+ <p class="hint mb-2" id="signed-in-hint">Sign in again only if the log says ecobee signed the plugin out, for example after a password change.</p>
12
12
  <button type="button" class="btn btn-secondary" id="sign-in-again">Sign in again</button>
13
13
  </div>
14
14
 
@@ -22,7 +22,7 @@
22
22
  <div class="mb-2">
23
23
  <label for="password" class="form-label">Password</label>
24
24
  <input type="password" class="form-control" id="password" autocomplete="current-password" required>
25
- <div class="hint mt-1">Sent only to ecobee to sign in. The plugin keeps a sign-in token, never your password.</div>
25
+ <div class="hint mt-1">Sent only to ecobee. The plugin saves a sign-in token, not your password.</div>
26
26
  </div>
27
27
  <button type="submit" class="btn btn-primary">Sign in</button>
28
28
  <button type="button" class="btn btn-link" id="sign-in-cancel" hidden>Cancel</button>
@@ -41,12 +41,12 @@
41
41
 
42
42
  <details class="mt-3" id="browser">
43
43
  <summary>Sign in on ecobee's website instead</summary>
44
- <p class="hint mt-2 mb-2">Use this if signing in above keeps failing (for example if ecobee asks for a CAPTCHA). You'll need a desktop browser with developer tools.</p>
44
+ <p class="hint mt-2 mb-2">Use this if signing in above keeps failing, for example if ecobee asks for a CAPTCHA. You need a desktop browser with developer tools.</p>
45
45
  <ol class="small ps-3">
46
46
  <li>In Chrome, open a new tab, open DevTools (Option-Command-I or F12), go to <b>Network</b> and tick <b>Preserve log</b>.</li>
47
47
  <li>In that tab, open <a id="browser-link" href="#" target="_blank" rel="noopener">this sign-in link</a> and sign in.</li>
48
48
  <li>When the page goes blank, type <code>code=</code> in the Network filter, right-click the entry and choose <b>Copy &gt; Copy URL</b>. It starts with <code>com.ecobee.athenamobile://</code></li>
49
- <li>Paste it here straight away (the code expires within a minute):</li>
49
+ <li>Paste it here within a minute, before it expires:</li>
50
50
  </ol>
51
51
  <form id="browser-form" class="d-flex gap-2">
52
52
  <input type="text" class="form-control" id="callback" placeholder="com.ecobee.athenamobile://…" required>
@@ -56,6 +56,98 @@
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 your account has more than one home. The log lists your homes.</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 a Night mode to use in automations. ecobee has no Night mode, so Night arms ecobee's Stay.</div>
78
+ <div class="hint mt-1">Changing this recreates the security system in the Home app, so you need to set up its scenes and automations 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 delay</option></select>
83
+ <div class="hint mt-1">Immediately: as soon as a door or sensor trips while armed, like the ecobee app. After the entry delay: only if no one disarms in time, so automations don't run while you walk in. 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 Smoke Alarm accessory that detects smoke when ecobee reports a smoke alarm. Use it for Home app notifications and automations.</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">Adds 2 sensors for each ecobee Premium thermostat: air quality (with VOC) and CO2. ecobee's own HomeKit integration doesn't include them. Updates 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">At or above this level, the CO2 sensor reports abnormal CO2. Use it for notifications and automations, like turning on ventilation.</div>
107
+ </div>
108
+ </div>
109
+
110
+ <div class="card card-body mb-3" id="keypad-card" hidden>
111
+ <h5 class="mb-2">Door keypads</h5>
112
+ <div class="form-check form-switch mb-1">
113
+ <input class="form-check-input" type="checkbox" role="switch" id="keypad-disarm">
114
+ <label class="form-check-label" for="keypad-disarm">Disarm when a door is unlocked with its keypad</label>
115
+ </div>
116
+ <p class="hint mb-2">Entering a code on a smart lock's keypad turns the security system off, so family and guests
117
+ can use their lock codes. Restart the bridge after changing this.
118
+ <a href="https://github.com/seanfletchr/homebridge-ecobee-security#keypad-unlock" target="_blank" rel="noopener">Learn more…</a></p>
119
+ <div id="keypad-locks-box" hidden>
120
+ <div class="mb-1">Locks that disarm</div>
121
+ <div id="keypad-locks"></div>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="card card-body mb-3" id="holds-card" hidden>
126
+ <h5 class="mb-2">Security holds</h5>
127
+ <div class="form-check form-switch mb-1">
128
+ <input class="form-check-input" type="checkbox" role="switch" id="allow-holds">
129
+ <label class="form-check-label" for="allow-holds">Allow security holds</label>
130
+ </div>
131
+ <p class="hint mb-2">Lets supported plugins, like a robot vacuum, keep the system from arming Away while they work:
132
+ Away is switched to Home, and comes back when they're done. Away asked for in the Home app meanwhile
133
+ waits until they're done.
134
+ <a href="https://github.com/seanfletchr/homebridge-ecobee-security#security-holds" target="_blank" rel="noopener">Learn more…</a></p>
135
+ <div id="hold-plugins-box" hidden>
136
+ <div class="mb-1">Allowed plugins</div>
137
+ <div id="hold-plugins"></div>
138
+ <div class="form-check form-switch mt-3 mb-1">
139
+ <input class="form-check-input" type="checkbox" role="switch" id="arm-away-now">
140
+ <label class="form-check-label" for="arm-away-now">Arm Away Now switch</label>
141
+ </div>
142
+ <div class="hint">Adds a switch to the Home app that arms Away now and asks any task that's running to stop (a robot goes back to its base). Restart the
143
+ bridge after changing this.</div>
144
+ <label for="s-taskWaitSeconds" class="form-label mt-3 mb-1">Extra time for tasks to finish (seconds)</label>
145
+ <input class="form-control" type="number" step="10" min="0" max="300" id="s-taskWaitSeconds" data-key="taskWaitSeconds" data-type="int" data-default="120">
146
+ <div class="hint mt-1">If Away is armed while a task is running, like a robot heading back to its base, the countdown
147
+ waits up to this much longer, and arms as soon as it's done. 0: no waiting.</div>
148
+ </div>
149
+ </div>
150
+
59
151
  <script>
60
152
  (async () => {
61
153
  const PLATFORM = 'EcobeeSecurityPlatform';
@@ -75,7 +167,7 @@
75
167
  $('signed-in').hidden = !signedIn;
76
168
  $('signin').hidden = signedIn;
77
169
  $('signed-in-text').textContent = justSignedIn
78
- ? 'Signed in and saved. Restart the bridge this plugin runs on (or Homebridge) to use the new sign-in.'
170
+ ? 'Signed in and saved. Restart this plugin\'s bridge, or Homebridge, to use the new sign-in.'
79
171
  : 'Signed in to ecobee.';
80
172
  $('sign-in-cancel').hidden = !block?.refreshToken;
81
173
  $('signin-hint').textContent = block?.refreshToken
@@ -100,11 +192,8 @@
100
192
  const blocks = await homebridge.getPluginConfig();
101
193
  const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
102
194
  block.refreshToken = refreshToken;
103
- // Re-show the settings form afterwards so it picks up the new token.
104
- homebridge.hideSchemaForm();
105
195
  await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
106
196
  await homebridge.savePluginConfig();
107
- homebridge.showSchemaForm();
108
197
  passwordForm.reset();
109
198
  mfaForm.reset();
110
199
  passwordForm.hidden = false;
@@ -113,6 +202,9 @@
113
202
  $('browser').open = false;
114
203
  homebridge.toast.success('Signed in to ecobee.');
115
204
  await showStatus({ justSignedIn: true });
205
+ await showSettings();
206
+ await showKeypad();
207
+ await showHolds();
116
208
  }
117
209
 
118
210
  passwordForm.addEventListener('submit', async event => {
@@ -176,7 +268,180 @@
176
268
  }
177
269
  });
178
270
 
271
+
272
+ // ── Settings cards ──
273
+
274
+ const controls = [...document.querySelectorAll('[data-key]')];
275
+
276
+ /** Fill the cards from the config; show defaults where nothing is set. */
277
+ async function showSettings() {
278
+ const [block] = await homebridge.getPluginConfig();
279
+ const signedIn = Boolean(block?.refreshToken);
280
+ $('system-card').hidden = !signedIn;
281
+ $('sensors-card').hidden = !signedIn;
282
+ for (const input of controls) {
283
+ const value = block?.[input.dataset.key];
284
+ if (input.dataset.type === 'bool') {
285
+ input.checked = value ?? input.dataset.default === 'true';
286
+ } else {
287
+ input.value = value ?? input.dataset.default;
288
+ }
289
+ }
290
+ $('co2-box').hidden = block?.airQuality === false;
291
+ }
292
+
293
+ /** Write one setting. Top-level keys are cleared with undefined, never deleted (the UI merges shallowly). */
294
+ async function setSetting(key, value) {
295
+ const blocks = await homebridge.getPluginConfig();
296
+ const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
297
+ block[key] = value;
298
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
299
+ }
300
+
301
+ for (const input of controls) {
302
+ input.addEventListener('change', async () => {
303
+ const { key, type } = input.dataset;
304
+ if (type === 'bool') {
305
+ await setSetting(key, input.checked);
306
+ if (key === 'airQuality') {
307
+ $('co2-box').hidden = !input.checked;
308
+ }
309
+ } else if (type === 'int') {
310
+ const number = input.value.trim() === '' ? undefined : Number(input.value);
311
+ const valid = number === undefined || (Number.isInteger(number) && number >= Number(input.min));
312
+ input.classList.toggle('is-invalid', !valid);
313
+ if (valid) {
314
+ await setSetting(key, number);
315
+ }
316
+ } else if (type === 'select') {
317
+ await setSetting(key, input.value);
318
+ } else {
319
+ await setSetting(key, input.value.trim() === '' ? undefined : input.value.trim());
320
+ }
321
+ });
322
+ }
323
+
324
+ // ── Security holds ──
325
+
326
+ async function showHolds() {
327
+ const blocks = await homebridge.getPluginConfig();
328
+ const block = blocks[0] ?? {};
329
+ $('holds-card').hidden = !block.refreshToken;
330
+ $('allow-holds').checked = block.allowHolds === true;
331
+ $('hold-plugins-box').hidden = block.allowHolds !== true;
332
+ $('arm-away-now').checked = block.armAwayNowSwitch === true;
333
+ const plugins = await homebridge.request('/hold-plugins').catch(() => []);
334
+ const box = $('hold-plugins');
335
+ box.textContent = '';
336
+ if (!plugins.length) {
337
+ const p = document.createElement('p');
338
+ p.className = 'hint mb-0';
339
+ p.textContent = 'No plugin has asked for a hold yet.';
340
+ box.append(p);
341
+ return;
342
+ }
343
+ // Without a list (settings from before it), every plugin is allowed.
344
+ const allowed = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
345
+ for (const plugin of plugins) {
346
+ const id = `hold-${plugin.key.replace(/[^a-z0-9]/gi, '-')}`;
347
+ const input = Object.assign(document.createElement('input'), {
348
+ className: 'form-check-input', type: 'checkbox', id, checked: allowed.includes(plugin.key),
349
+ });
350
+ input.addEventListener('change', () => setAllowed(plugins, plugin.key, input.checked));
351
+ const label = Object.assign(document.createElement('label'), { className: 'form-check-label', htmlFor: id });
352
+ label.textContent = plugin.displayName === plugin.key ? plugin.key : `${plugin.displayName} (${plugin.key})`;
353
+ const row = Object.assign(document.createElement('div'), { className: 'form-check mb-1' });
354
+ row.append(input, label);
355
+ if (plugin.names.length || plugin.lastAsked) {
356
+ const hint = Object.assign(document.createElement('div'), { className: 'hint' });
357
+ const when = plugin.lastAsked ? `last asked ${new Date(plugin.lastAsked).toLocaleString()}` : '';
358
+ hint.textContent = [plugin.names.length ? `for ${plugin.names.join(', ')}` : '', when].filter(Boolean).join(', ');
359
+ row.append(hint);
360
+ }
361
+ box.append(row);
362
+ }
363
+ }
364
+
365
+ async function setAllowed(plugins, key, on) {
366
+ const blocks = await homebridge.getPluginConfig();
367
+ const block = blocks[0];
368
+ const current = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
369
+ block.holdPlugins = on ? [...new Set([...current, key])] : current.filter(k => k !== key);
370
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
371
+ }
372
+
373
+ $('allow-holds').addEventListener('change', async () => {
374
+ const blocks = await homebridge.getPluginConfig();
375
+ const block = blocks[0];
376
+ block.allowHolds = $('allow-holds').checked;
377
+ $('hold-plugins-box').hidden = !block.allowHolds;
378
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
379
+ });
380
+
381
+ $('arm-away-now').addEventListener('change', async () => {
382
+ const blocks = await homebridge.getPluginConfig();
383
+ const block = blocks[0];
384
+ block.armAwayNowSwitch = $('arm-away-now').checked;
385
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
386
+ });
387
+
388
+ // ── Door keypads ──
389
+
390
+ async function showKeypad() {
391
+ const [block = {}] = await homebridge.getPluginConfig();
392
+ $('keypad-card').hidden = !block.refreshToken;
393
+ $('keypad-disarm').checked = block.keypadDisarm === true;
394
+ $('keypad-locks-box').hidden = block.keypadDisarm !== true;
395
+ const locks = await homebridge.request('/keypad-locks').catch(() => []);
396
+ const box = $('keypad-locks');
397
+ box.textContent = '';
398
+ if (!locks.length) {
399
+ const p = document.createElement('p');
400
+ p.className = 'hint mb-0';
401
+ p.textContent = block.keypadDisarm === true
402
+ ? 'No lock found yet. Locks show up here a minute after the bridge restarts; the log says if none report keypad unlocks.'
403
+ : 'Turn this on and restart the bridge to find your locks.';
404
+ box.append(p);
405
+ return;
406
+ }
407
+ // Without a list, every lock disarms.
408
+ const allowed = Array.isArray(block.keypadLocks) ? block.keypadLocks : locks.map(l => l.key);
409
+ for (const lock of locks) {
410
+ const id = `lock-${lock.key.replace(/[^a-z0-9]/gi, '-')}`;
411
+ const input = Object.assign(document.createElement('input'), {
412
+ className: 'form-check-input', type: 'checkbox', id, checked: allowed.includes(lock.key),
413
+ });
414
+ input.addEventListener('change', () => setLock(locks, lock.key, input.checked));
415
+ const label = Object.assign(document.createElement('label'), { className: 'form-check-label', htmlFor: id });
416
+ label.textContent = lock.name;
417
+ const hint = Object.assign(document.createElement('div'), { className: 'hint' });
418
+ hint.textContent = lock.bridge;
419
+ const row = Object.assign(document.createElement('div'), { className: 'form-check mb-1' });
420
+ row.append(input, label, hint);
421
+ box.append(row);
422
+ }
423
+ }
424
+
425
+ async function setLock(locks, key, on) {
426
+ const blocks = await homebridge.getPluginConfig();
427
+ const block = blocks[0];
428
+ const current = Array.isArray(block.keypadLocks) ? block.keypadLocks : locks.map(l => l.key);
429
+ block.keypadLocks = on ? [...new Set([...current, key])] : current.filter(k => k !== key);
430
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
431
+ }
432
+
433
+ $('keypad-disarm').addEventListener('change', async () => {
434
+ const blocks = await homebridge.getPluginConfig();
435
+ const block = blocks[0];
436
+ block.keypadDisarm = $('keypad-disarm').checked;
437
+ $('keypad-locks-box').hidden = !block.keypadDisarm;
438
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
439
+ await showKeypad();
440
+ });
441
+
179
442
  await showStatus();
180
- homebridge.showSchemaForm();
443
+ await showSettings();
444
+ await showKeypad();
445
+ await showHolds();
181
446
  })();
182
447
  </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,36 @@ 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
+
75
+ // Locks the plugin found that report keypad unlocks (only that list leaves state.json).
76
+ this.onRequest('/keypad-locks', async () => {
77
+ try {
78
+ const store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
79
+ return store.keypadLocks ?? [];
80
+ } catch {
81
+ return [];
82
+ }
83
+ });
84
+
50
85
  this.ready();
51
86
  }
52
87
 
@@ -63,4 +98,41 @@ class UiServer extends HomebridgePluginUiServer {
63
98
  }
64
99
  }
65
100
 
101
+ /** Installed plugins that declare they ask for holds: "homebridge-security-hold": { "client": true }. */
102
+ function installedHoldClients(storagePath) {
103
+ const folders = new Set([fileURLToPath(new URL('../../', import.meta.url))]);
104
+ if (storagePath) {
105
+ folders.add(join(storagePath, 'node_modules'));
106
+ }
107
+ const found = new Map();
108
+ const scan = folder => {
109
+ let entries = [];
110
+ try {
111
+ entries = readdirSync(folder, { withFileTypes: true });
112
+ } catch {
113
+ return;
114
+ }
115
+ for (const entry of entries) {
116
+ const dir = join(folder, entry.name);
117
+ if (entry.name.startsWith('@')) {
118
+ scan(dir);
119
+ continue;
120
+ }
121
+ if (!entry.name.includes('homebridge')) {
122
+ continue;
123
+ }
124
+ try {
125
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
126
+ if (pkg['homebridge-security-hold']?.client === true && pkg.name && !found.has(pkg.name)) {
127
+ found.set(pkg.name, { name: pkg.name, displayName: pkg.displayName || pkg.name });
128
+ }
129
+ } catch {
130
+ // not a package
131
+ }
132
+ }
133
+ };
134
+ folders.forEach(scan);
135
+ return [...found.values()];
136
+ }
137
+
66
138
  (() => new UiServer())();
package/lib/control.js CHANGED
@@ -1,13 +1,21 @@
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>[&halt=<endpoint>] -> { ok, held, state, reason? }
4
+ // `halt`: a Unix socket path or http(s) URL where the plugin answers POST /halt?by=<name>; it's
5
+ // called when the system is armed Away while it's out (`&armAt=`: back at base or stopped by then;
6
+ // a robot that answers `returning` is waited for, see SecuritySystem.haltRobots).
7
+ // POST /release?by=<name>&plugin=<package> -> { released, state, waiting? } (Away only once every holder has released;
8
+ // also how a robot sent back to its base says it's in, or stopped, so an extended exit delay can end)
5
9
  // It's a Unix socket in the plugin's storage folder (mode 600), so only the homebridge user can use it.
10
+ // Holds need `allowHolds` and the asking plugin ticked in the settings (`isAllowed`); otherwise it
11
+ // answers 403 so the caller can log why. Every request is noted (`noteRequest`) so the settings page
12
+ // can list the plugins that asked. A release is always accepted from a current holder, so taking a
13
+ // plugin's permission away can't leave the system stuck in Stay.
6
14
 
7
15
  import { chmodSync, rmSync } from 'node:fs';
8
16
  import { createServer } from 'node:http';
9
17
 
10
- export function startControlServer({ path, log, getSystem }) {
18
+ export function startControlServer({ path, log, getSystem, isAllowed, noteRequest }) {
11
19
  try {
12
20
  rmSync(path, { force: true });
13
21
  } catch (error) {
@@ -16,19 +24,35 @@ export function startControlServer({ path, log, getSystem }) {
16
24
  const server = createServer(async (req, res) => {
17
25
  const url = new URL(req.url, 'http://localhost');
18
26
  const by = (url.searchParams.get('by') || 'another plugin').slice(0, 40);
27
+ // Requests are allowed per plugin; one that doesn't say which it is goes by its `by` name.
28
+ const plugin = (url.searchParams.get('plugin') || '').slice(0, 100) || undefined;
29
+ const key = plugin ?? by;
30
+ const halt = (url.searchParams.get('halt') || '').slice(0, 500) || undefined;
19
31
  const reply = (code, body) => {
20
32
  res.writeHead(code, { 'content-type': 'application/json' });
21
33
  res.end(JSON.stringify(body));
22
34
  };
23
- const system = getSystem();
24
35
  if (req.method !== 'POST' || !['/hold', '/release'].includes(url.pathname)) {
25
36
  return reply(404, { error: 'POST /hold or /release' });
26
37
  }
38
+ const system = getSystem();
39
+ const releasing = url.pathname === '/release';
40
+ noteRequest?.({ key, plugin, by });
41
+ if (!(releasing && (system?.holders().includes(by) || system?.returningNames().includes(by)))) {
42
+ const allowed = isAllowed(key);
43
+ if (allowed !== true) {
44
+ const why = allowed === 'off'
45
+ ? 'security holds are turned off in the ecobee Smart Security settings'
46
+ : `${key} isn't allowed to ask for security holds in the ecobee Smart Security settings`;
47
+ log.warn(`${by}${plugin ? ` (${plugin})` : ''} asked for a security hold, but ${why}.`);
48
+ return reply(403, { ok: false, error: why });
49
+ }
50
+ }
27
51
  if (!system) {
28
52
  return reply(503, { error: 'not connected to ecobee yet' });
29
53
  }
30
54
  try {
31
- reply(200, url.pathname === '/hold' ? await system.hold(by) : await system.release(by));
55
+ reply(200, url.pathname === '/hold' ? await system.hold(by, { plugin, halt }) : await system.release(by));
32
56
  } catch (error) {
33
57
  reply(502, { error: error.message });
34
58
  }
package/lib/ecobee.js CHANGED
@@ -163,10 +163,11 @@ export class EcobeeCloud {
163
163
  * @param {string} homeId
164
164
  * @param {string} armedState one of ArmedState
165
165
  * @param {number} exitDelay seconds; the app sends the exit delay as a timestamp
166
+ * @param {number} [until] the exit delay's end (ms) instead of `exitDelay`
166
167
  * @returns the home's monitoring state after the change
167
168
  */
168
- async setArmedState(homeId, armedState, exitDelay = 0) {
169
- const exitDelayUntil = new Date(Date.now() + exitDelay * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
169
+ async setArmedState(homeId, armedState, exitDelay = 0, until = Date.now() + exitDelay * 1000) {
170
+ const exitDelayUntil = new Date(until).toISOString().replace(/\.\d{3}Z$/, 'Z');
170
171
  const data = await this.graphql('SetArmedStateForHome', SET_MUTATION, {
171
172
  input: { homeId, armedState, exitDelayUntil },
172
173
  });
package/lib/halt.js ADDED
@@ -0,0 +1,51 @@
1
+ // Ask a plugin that's out working (a robot vacuum) to head back to its base or stop where it is,
2
+ // because the system is being armed Away. Plugins give their halt endpoint with their hold request (`&halt=`): a Unix socket path,
3
+ // or an http(s) URL. POST <endpoint>/halt?by=<name>&armAt=<ISO time> -> 2xx, optionally JSON
4
+ // { halted, returning?, reason? }. `armAt` is when the motion sensors arm (the end of the exit delay),
5
+ // so a robot can head back to its base in time; without it, it stops where it is.
6
+
7
+ import { request as httpRequest } from 'node:http';
8
+ import { request as httpsRequest } from 'node:https';
9
+
10
+ const TIMEOUT = 5 * 1000;
11
+
12
+ /** Resolves `{ halted, returning?, reason? }`; rejects if the plugin can't be reached. */
13
+ export function sendHalt(endpoint, by, armAt) {
14
+ const query = `?by=${encodeURIComponent(by)}` + (armAt ? `&armAt=${encodeURIComponent(new Date(armAt).toISOString())}` : '');
15
+ const options = { method: 'POST', timeout: TIMEOUT };
16
+ let request = httpRequest;
17
+ if (/^https?:\/\//i.test(endpoint)) {
18
+ const url = new URL(endpoint);
19
+ Object.assign(options, {
20
+ protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
21
+ auth: url.username ? `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}` : undefined,
22
+ path: `${url.pathname.replace(/\/$/, '')}/halt${query}`,
23
+ });
24
+ request = url.protocol === 'https:' ? httpsRequest : httpRequest;
25
+ } else {
26
+ Object.assign(options, { socketPath: endpoint, path: `/halt${query}` });
27
+ }
28
+ return new Promise((resolve, reject) => {
29
+ const req = request(options, (res) => {
30
+ let body = '';
31
+ res.setEncoding('utf8');
32
+ res.on('data', chunk => { body += chunk; });
33
+ res.on('end', () => {
34
+ let json = {};
35
+ try {
36
+ json = body.trim() ? JSON.parse(body) : {};
37
+ } catch {
38
+ // The status code is what counts.
39
+ }
40
+ if (res.statusCode < 200 || res.statusCode >= 300) {
41
+ return reject(new Error(json.error ?? `answered ${res.statusCode}`));
42
+ }
43
+ resolve({ halted: json.halted ?? true, returning: json.returning === true, reason: json.reason });
44
+ });
45
+ });
46
+ req.on('timeout', () => req.destroy(new Error('no answer')));
47
+ req.on('error', error => reject(error.code === 'ENOENT' || error.code === 'ECONNREFUSED'
48
+ ? new Error('the plugin is not running') : error));
49
+ req.end();
50
+ });
51
+ }