homebridge-ecobee-security 0.5.0 → 0.7.1

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>
@@ -67,20 +67,20 @@
67
67
  <div class="mb-3">
68
68
  <label for="s-homeId" class="form-label">Home ID</label>
69
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>
70
+ <div class="hint mt-1">Only needed if your account has more than one home. The log lists your homes.</div>
71
71
  </div>
72
72
  <div class="mb-3">
73
73
  <div class="form-check form-switch">
74
74
  <input class="form-check-input" type="checkbox" role="switch" id="s-offerNight" data-key="offerNight" data-type="bool" data-default="false">
75
75
  <label class="form-check-label" for="s-offerNight">Offer Night</label>
76
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>
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
79
  </div>
80
80
  <div class="mb-0">
81
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>
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
84
  </div>
85
85
  </div>
86
86
 
@@ -91,19 +91,34 @@
91
91
  <input class="form-check-input" type="checkbox" role="switch" id="s-smokeSensor" data-key="smokeSensor" data-type="bool" data-default="true">
92
92
  <label class="form-check-label" for="s-smokeSensor">Smoke sensor</label>
93
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>
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
95
  </div>
96
96
  <div class="mb-3">
97
97
  <div class="form-check form-switch">
98
98
  <input class="form-check-input" type="checkbox" role="switch" id="s-airQuality" data-key="airQuality" data-type="bool" data-default="true">
99
99
  <label class="form-check-label" for="s-airQuality">Air quality sensors</label>
100
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>
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
102
  </div>
103
103
  <div class="mb-0" id="co2-box">
104
104
  <label for="s-co2Alert" class="form-label">CO2 alert level (ppm)</label>
105
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>
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>
107
122
  </div>
108
123
  </div>
109
124
 
@@ -113,12 +128,23 @@
113
128
  <input class="form-check-input" type="checkbox" role="switch" id="allow-holds">
114
129
  <label class="form-check-label" for="allow-holds">Allow security holds</label>
115
130
  </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.
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.
118
134
  <a href="https://github.com/seanfletchr/homebridge-ecobee-security#security-holds" target="_blank" rel="noopener">Learn more…</a></p>
119
135
  <div id="hold-plugins-box" hidden>
120
136
  <div class="mb-1">Allowed plugins</div>
121
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>
122
148
  </div>
123
149
  </div>
124
150
 
@@ -141,7 +167,7 @@
141
167
  $('signed-in').hidden = !signedIn;
142
168
  $('signin').hidden = signedIn;
143
169
  $('signed-in-text').textContent = justSignedIn
144
- ? '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.'
145
171
  : 'Signed in to ecobee.';
146
172
  $('sign-in-cancel').hidden = !block?.refreshToken;
147
173
  $('signin-hint').textContent = block?.refreshToken
@@ -177,6 +203,7 @@
177
203
  homebridge.toast.success('Signed in to ecobee.');
178
204
  await showStatus({ justSignedIn: true });
179
205
  await showSettings();
206
+ await showKeypad();
180
207
  await showHolds();
181
208
  }
182
209
 
@@ -302,6 +329,7 @@
302
329
  $('holds-card').hidden = !block.refreshToken;
303
330
  $('allow-holds').checked = block.allowHolds === true;
304
331
  $('hold-plugins-box').hidden = block.allowHolds !== true;
332
+ $('arm-away-now').checked = block.armAwayNowSwitch === true;
305
333
  const plugins = await homebridge.request('/hold-plugins').catch(() => []);
306
334
  const box = $('hold-plugins');
307
335
  box.textContent = '';
@@ -350,8 +378,70 @@
350
378
  await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
351
379
  });
352
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
+
353
442
  await showStatus();
354
443
  await showSettings();
444
+ await showKeypad();
355
445
  await showHolds();
356
446
  })();
357
447
  </script>
@@ -72,6 +72,16 @@ class UiServer extends HomebridgePluginUiServer {
72
72
  return [...plugins.values()];
73
73
  });
74
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
+
75
85
  this.ready();
76
86
  }
77
87
 
package/lib/control.js CHANGED
@@ -1,7 +1,11 @@
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>&plugin=<package> -> { ok, held, state, reason? }
4
- // POST /release?by=<name>&plugin=<package> -> { released, state, waiting? } (Away only once every holder has released)
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.
6
10
  // Holds need `allowHolds` and the asking plugin ticked in the settings (`isAllowed`); otherwise it
7
11
  // answers 403 so the caller can log why. Every request is noted (`noteRequest`) so the settings page
@@ -23,6 +27,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
23
27
  // Requests are allowed per plugin; one that doesn't say which it is goes by its `by` name.
24
28
  const plugin = (url.searchParams.get('plugin') || '').slice(0, 100) || undefined;
25
29
  const key = plugin ?? by;
30
+ const halt = (url.searchParams.get('halt') || '').slice(0, 500) || undefined;
26
31
  const reply = (code, body) => {
27
32
  res.writeHead(code, { 'content-type': 'application/json' });
28
33
  res.end(JSON.stringify(body));
@@ -33,7 +38,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
33
38
  const system = getSystem();
34
39
  const releasing = url.pathname === '/release';
35
40
  noteRequest?.({ key, plugin, by });
36
- if (!(releasing && system?.holders().includes(by))) {
41
+ if (!(releasing && (system?.holders().includes(by) || system?.returningNames().includes(by)))) {
37
42
  const allowed = isAllowed(key);
38
43
  if (allowed !== true) {
39
44
  const why = allowed === 'off'
@@ -47,7 +52,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
47
52
  return reply(503, { error: 'not connected to ecobee yet' });
48
53
  }
49
54
  try {
50
- 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));
51
56
  } catch (error) {
52
57
  reply(502, { error: error.message });
53
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
+ }
package/lib/keypad.js ADDED
@@ -0,0 +1,240 @@
1
+ // Disarm on a keypad unlock: watch the locks on the other bridges of this Homebridge for an unlock
2
+ // that the lock says was done with its keypad (HomeKit's Lock Last Known Action). Child bridges run
3
+ // in their own processes, so the locks are read over HAP on 127.0.0.1, which Homebridge allows with
4
+ // just the bridge's PIN in insecure mode (the Homebridge UI's accessory control uses the same).
5
+ // HAP events need a paired session, so the locks are polled; reading a few values locally every
6
+ // second costs next to nothing.
7
+
8
+ import { readFileSync } from 'node:fs';
9
+
10
+ const POLL = 1000;
11
+ // No locks found (or a bridge went away): look again this often.
12
+ const RESCAN = 10 * 60 * 1000;
13
+ // Other child bridges may still be starting: rescan every 30 s for this long.
14
+ const GRACE = 3 * 60 * 1000;
15
+ const GRACE_RESCAN = 30 * 1000;
16
+ const TIMEOUT = 3000;
17
+ // An unlock whose "by keypad" comes in a later update than the state still counts within this.
18
+ const LABEL_LAG = 5000;
19
+
20
+ const INFO = '3E';
21
+ const LOCK = '45';
22
+ const NAME = '23';
23
+ const CONFIGURED_NAME = 'E3';
24
+ const SERIAL = '30';
25
+ const CURRENT = '1D';
26
+ const LAST_ACTION = '1C';
27
+
28
+ const UNSECURED = 0;
29
+ const UNSECURED_BY_KEYPAD = 5;
30
+
31
+ /** Apple's HAP types come as "45" or "00000045-0000-1000-8000-0026BB765291". */
32
+ function shortType(type) {
33
+ const t = String(type).toUpperCase();
34
+ if (t.includes('-') && !t.endsWith('-0000-1000-8000-0026BB765291')) {
35
+ return t;
36
+ }
37
+ return t.split('-')[0].replace(/^0+/, '');
38
+ }
39
+
40
+ export class KeypadLocks {
41
+ /**
42
+ * @param {object} opts
43
+ * @param {string} opts.configPath Homebridge's config.json (bridge ports and PINs)
44
+ * @param {string} opts.ownPlatform this plugin's platform name (its own bridge is skipped)
45
+ * @param {(lock: {key: string, name: string, bridge: string}) => void} opts.onKeypadUnlock
46
+ * @param {(locks: {key: string, name: string, bridge: string}[]) => void} [opts.onLocks] the locks found, after each scan
47
+ */
48
+ constructor({ configPath, ownPlatform, log, onKeypadUnlock, onLocks }) {
49
+ this.configPath = configPath;
50
+ this.ownPlatform = ownPlatform;
51
+ this.log = log;
52
+ this.onKeypadUnlock = onKeypadUnlock;
53
+ this.onLocks = onLocks;
54
+ /** @type {Map<string, object>} key → lock: { key, name, bridge, aid, current, last, state, lastAction, pendingUntil } */
55
+ this.locks = new Map();
56
+ this.scannedAt = 0;
57
+ this.warned = new Set();
58
+ }
59
+
60
+ start() {
61
+ this.startedAt = Date.now();
62
+ this.timer = setInterval(() => this.poll(), POLL);
63
+ this.poll();
64
+ }
65
+
66
+ stop() {
67
+ clearInterval(this.timer);
68
+ }
69
+
70
+ /** Every bridge in config.json with a fixed port, except this plugin's own. */
71
+ bridges() {
72
+ const config = JSON.parse(readFileSync(this.configPath, 'utf8'));
73
+ const main = config.bridge ?? {};
74
+ const list = [{ name: main.name ?? 'Homebridge', port: main.port, pin: main.pin }];
75
+ for (const block of [...(config.platforms ?? []), ...(config.accessories ?? [])]) {
76
+ const child = block?._bridge;
77
+ if (child?.port && block.platform !== this.ownPlatform) {
78
+ list.push({ name: child.name ?? block.platform ?? block.accessory, port: child.port, pin: child.pin ?? main.pin });
79
+ }
80
+ }
81
+ return list.filter(b => b.port && b.pin);
82
+ }
83
+
84
+ async request(bridge, path) {
85
+ const response = await fetch(`http://127.0.0.1:${bridge.port}${path}`, {
86
+ headers: { Authorization: bridge.pin },
87
+ signal: AbortSignal.timeout(TIMEOUT),
88
+ });
89
+ if (response.status === 401 || response.status === 470) {
90
+ const error = new Error('not allowed');
91
+ error.auth = true;
92
+ throw error;
93
+ }
94
+ if (!response.ok) {
95
+ throw new Error(`HTTP ${response.status}`);
96
+ }
97
+ return response.json();
98
+ }
99
+
100
+ /** Find every lock that reports how it was last locked or unlocked. */
101
+ async scan() {
102
+ this.scannedAt = Date.now();
103
+ let bridges;
104
+ try {
105
+ bridges = this.bridges();
106
+ } catch (error) {
107
+ this.warnOnce('config', `Keypad unlock: could not read the Homebridge config (${error.message}).`);
108
+ return;
109
+ }
110
+ const found = new Map();
111
+ let denied = 0;
112
+ for (const bridge of bridges) {
113
+ let data;
114
+ try {
115
+ data = await this.request(bridge, '/accessories');
116
+ } catch (error) {
117
+ denied += error.auth ? 1 : 0;
118
+ continue;
119
+ }
120
+ for (const accessory of data.accessories ?? []) {
121
+ const services = accessory.services ?? [];
122
+ const lock = services.find(s => shortType(s.type) === LOCK);
123
+ const all = services.flatMap(s => s.characteristics ?? []);
124
+ const current = lock?.characteristics?.find(c => shortType(c.type) === CURRENT);
125
+ const last = all.find(c => shortType(c.type) === LAST_ACTION);
126
+ if (!current || !last) {
127
+ continue;
128
+ }
129
+ const info = services.find(s => shortType(s.type) === INFO)?.characteristics ?? [];
130
+ const nameOf = chars => chars.find(c => shortType(c.type) === CONFIGURED_NAME)?.value
131
+ || chars.find(c => shortType(c.type) === NAME)?.value;
132
+ const name = nameOf(lock.characteristics) || nameOf(info) || `Lock ${accessory.aid}`;
133
+ const serial = info.find(c => shortType(c.type) === SERIAL)?.value;
134
+ const key = serial ? String(serial) : `${bridge.name}/${name}`;
135
+ const known = this.locks.get(key);
136
+ found.set(key, {
137
+ key, name, bridge, aid: accessory.aid, current: current.iid, last: last.iid,
138
+ // Keep what we knew if it's the same lock on the same bridge; otherwise start from its first read.
139
+ ...(known?.bridge.port === bridge.port && known.aid === accessory.aid
140
+ ? { state: known.state, lastAction: known.lastAction, pendingUntil: known.pendingUntil } : {}),
141
+ });
142
+ }
143
+ }
144
+ if (denied && denied === bridges.length) {
145
+ this.warnOnce('denied', 'Keypad unlock: Homebridge didn\'t let the plugin read the other bridges. Turn on insecure mode '
146
+ + '(Homebridge Settings > Homebridge Insecure Mode / Accessory Control) and restart Homebridge.');
147
+ }
148
+ const before = [...this.locks.keys()].sort().join();
149
+ this.locks = found;
150
+ if ([...found.keys()].sort().join() !== before) {
151
+ if (found.size) {
152
+ this.log.info(`Keypad unlock: watching ${[...found.values()].map(l => `"${l.name}" (${l.bridge.name})`).join(', ')}.`);
153
+ this.warned.delete('none');
154
+ }
155
+ this.onLocks?.([...found.values()].map(({ key, name, bridge }) => ({ key, name, bridge: bridge.name })));
156
+ }
157
+ if (!found.size && !this.starting()) {
158
+ this.warnOnce('none', 'Keypad unlock: no lock on this Homebridge reports keypad unlocks; checking again every 10 minutes.');
159
+ }
160
+ }
161
+
162
+ async poll() {
163
+ if (this.polling) {
164
+ return;
165
+ }
166
+ this.polling = true;
167
+ try {
168
+ const every = this.starting() ? GRACE_RESCAN : RESCAN;
169
+ if (this.rescan || (!this.locks.size && Date.now() - this.scannedAt >= every)) {
170
+ this.rescan = false;
171
+ await this.scan();
172
+ }
173
+ const byBridge = new Map();
174
+ for (const lock of this.locks.values()) {
175
+ byBridge.set(lock.bridge.port, [...(byBridge.get(lock.bridge.port) ?? []), lock]);
176
+ }
177
+ await Promise.all([...byBridge.values()].map(locks => this.read(locks)));
178
+ } finally {
179
+ this.polling = false;
180
+ }
181
+ }
182
+
183
+ async read(locks) {
184
+ const ids = locks.flatMap(l => [`${l.aid}.${l.current}`, `${l.aid}.${l.last}`]).join(',');
185
+ let values;
186
+ try {
187
+ const data = await this.request(locks[0].bridge, `/characteristics?id=${ids}`);
188
+ values = new Map((data.characteristics ?? []).map(c => [`${c.aid}.${c.iid}`, c.value]));
189
+ } catch {
190
+ // Bridge restarting (or the lock moved): forget the states, so an unlock isn't seen in
191
+ // whatever it reports when it's back, and look for the locks again (at most every 30 s).
192
+ locks.forEach(l => Object.assign(l, { state: undefined, lastAction: undefined, pendingUntil: undefined }));
193
+ if (Date.now() - this.scannedAt >= GRACE_RESCAN) {
194
+ this.rescan = true;
195
+ }
196
+ return;
197
+ }
198
+ for (const lock of locks) {
199
+ this.update(lock, values.get(`${lock.aid}.${lock.current}`), values.get(`${lock.aid}.${lock.last}`));
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Only an unlock counts, never a label: the lock must go to unlocked while saying it was unlocked
205
+ * with its keypad (in the same update, or within a few seconds after). An app unlock passes through
206
+ * other labels, and a label left from an earlier keypad unlock has no state change to go with it.
207
+ */
208
+ update(lock, state, lastAction) {
209
+ if (state === undefined || lastAction === undefined) {
210
+ return;
211
+ }
212
+ const first = lock.state === undefined;
213
+ const unlocked = !first && state === UNSECURED && lock.state !== UNSECURED;
214
+ const now = Date.now();
215
+ if (state !== lock.state) {
216
+ lock.pendingUntil = unlocked ? now + LABEL_LAG : undefined;
217
+ }
218
+ lock.state = state;
219
+ lock.lastAction = lastAction;
220
+ if (lock.pendingUntil && now > lock.pendingUntil) {
221
+ lock.pendingUntil = undefined;
222
+ }
223
+ if (lock.pendingUntil && lastAction === UNSECURED_BY_KEYPAD) {
224
+ lock.pendingUntil = undefined;
225
+ this.onKeypadUnlock({ key: lock.key, name: lock.name, bridge: lock.bridge.name });
226
+ }
227
+ }
228
+
229
+ /** Within the start-up grace period, when other bridges may not be up yet. */
230
+ starting() {
231
+ return Date.now() - this.startedAt < GRACE;
232
+ }
233
+
234
+ warnOnce(key, message) {
235
+ if (!this.warned.has(key)) {
236
+ this.warned.add(key);
237
+ this.log.warn(message);
238
+ }
239
+ }
240
+ }