homebridge-ecobee-security 0.2.0 → 0.4.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.4.0
4
+
5
+ - `offerNight`: adds Night to the Home app; it arms ecobee's Stay and keeps showing Night. Changing
6
+ the setting recreates the security system in HomeKit (the Home app doesn't pick up new modes on an
7
+ existing control).
8
+ - `alarmTrigger: "entryDelay"`: show Alarm triggered only once an intrusion has outlasted the entry
9
+ delay without a disarm, instead of as soon as ecobee opens the incident. Smoke still shows at once.
10
+ - `smokeSensor` (on by default): a separate Smoke Alarm accessory that detects smoke while ecobee has a smoke-alarm
11
+ incident open.
12
+ - Air quality (`airQuality`, on by default): for ecobee thermostats with an air quality sensor, an
13
+ Air Quality Sensor (level, VOC) and a Carbon Dioxide Sensor (ppm, alert at `co2Alert`, 1000 ppm).
14
+ - The accessory reports the plugin version as its firmware version.
15
+ - The hold API refuses a hold while any alarm incident is open, whichever `alarmTrigger` is set.
16
+
17
+ ## 0.3.0
18
+
19
+ - Sign in from the plugin's settings page in the Homebridge UI with your ecobee email, password
20
+ and MFA code. No terminal or browser developer tools needed; the website sign-in is still there
21
+ as a fallback.
22
+ - `bin/ecobee-login.js` signs in with email and password by default; `--browser` for the old way.
23
+
3
24
  ## 0.2.0
4
25
 
5
26
  - Control socket (`control.sock`) so other plugins can hold the system at Stay and release it to
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
1
  # homebridge-ecobee-security
2
2
 
3
3
  Shows **ecobee Smart Security** in Apple Home as a security system: arm **Stay**, arm **Away**,
4
- **disarm**, and **Alarm triggered** when ecobee has an open intrusion or smoke incident. Works with
4
+ **disarm**, and **Alarm triggered** when ecobee has an open intrusion or smoke incident. It also adds
5
+ **air quality** sensors for ecobee thermostats that have one (ecobee Premium), which ecobee's own
6
+ HomeKit integration leaves out. Works with
5
7
  Siri ("set the security system to away") and Home automations. ecobee removed Smart Security from
6
8
  its HomeKit integration; this brings it back.
7
9
 
@@ -27,21 +29,25 @@ Running it as a child bridge is recommended. Requires Node 22 or 24 and Homebrid
27
29
 
28
30
  ## Sign-in
29
31
 
30
- ecobee's login page shows a CAPTCHA to scripts and the account may use MFA, so the plugin doesn't
31
- take your password. You sign in once in Chrome and give the plugin a refresh token:
32
+ Open the plugin's **Settings** in the Homebridge UI and sign in with your ecobee email and password,
33
+ then the verification code if your account uses MFA. The plugin saves a refresh token in its config;
34
+ your password is sent only to ecobee and isn't stored. Restart the bridge afterwards.
32
35
 
33
- ```
34
- node <plugin folder>/bin/ecobee-login.js
35
- ```
36
+ The plugin renews its access token every 2 hours and keeps the current refresh token in
37
+ `<homebridge storage>/ecobee-security/state.json` (mode 600). A newly saved token always replaces the
38
+ stored one. You only need to sign in again if ecobee revokes the token (for example after a password
39
+ change or "sign out of all devices"); the log then says so and the accessory shows a fault.
40
+
41
+ **If sign-in fails** (for example ecobee asks for a CAPTCHA), the settings page also offers sign-in
42
+ on ecobee's website: you sign in in a desktop browser and paste the final redirect URL from its
43
+ developer tools.
36
44
 
37
- (any machine with Node 22+; on the Homebridge Pi: `/opt/homebridge/bin/node /var/lib/homebridge/node_modules/homebridge-ecobee-security/bin/ecobee-login.js`).
38
- It prints a link and step-by-step instructions, then the refresh token. Paste the token into the
39
- plugin settings and restart the bridge. The plugin renews its access token every 2 hours and keeps
40
- the current refresh token in `<homebridge storage>/ecobee-security/state.json` (mode 600). A newly
41
- pasted token always replaces the stored one.
45
+ **Without the Homebridge UI**, sign in from a terminal and paste the printed token into the config:
42
46
 
43
- You only need to sign in again if ecobee revokes the token (for example after a password change or
44
- "sign out of all devices"); the log then says so and the accessory shows a fault.
47
+ ```
48
+ node <plugin folder>/bin/ecobee-login.js # email, password, MFA code
49
+ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
50
+ ```
45
51
 
46
52
  ## Config
47
53
 
@@ -51,12 +57,25 @@ You only need to sign in again if ecobee revokes the token (for example after a
51
57
  "name": "Security System",
52
58
  "refreshToken": "…",
53
59
  "pollInterval": 10,
60
+ "offerNight": false,
61
+ "alarmTrigger": "incident",
62
+ "smokeSensor": true,
63
+ "airQuality": true,
64
+ "co2Alert": 1000,
54
65
  "_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
55
66
  }
56
67
  ```
57
68
 
58
69
  `homeId` is only needed if the account has several homes (the log lists them).
59
70
 
71
+ | Setting | Default | |
72
+ |---|---|---|
73
+ | `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. |
74
+ | `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. |
75
+ | `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. |
76
+ | `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. |
77
+ | `co2Alert` | `1000` | CO2 (ppm) at or above which the Carbon Dioxide Sensor reports abnormal levels. |
78
+
60
79
  ## How it behaves
61
80
 
62
81
  - **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
@@ -64,13 +83,10 @@ You only need to sign in again if ecobee revokes the token (for example after a
64
83
  (0 by default). Disarm needs no PIN.
65
84
  - **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within the poll
66
85
  interval (10 s by default), and quickly after an exit delay ends.
67
- - **Alarm triggered** while ecobee lists an incident whose status isn't resolved/closed/cancelled.
68
- ecobee opens an incident as soon as a door opens while armed, during the entry delay, so the
69
- tile shows Alarm triggered from that moment, as the ecobee app does; disarming clears it.
70
- Every change in the incident list is logged.
71
- - **Siri** calls Stay "Home": "set the security system to Home". Saying "arm … for stay" makes
72
- Siri pick Away.
73
- - **Night** isn't offered; ecobee has no Night mode.
86
+ - **Alarm triggered** while ecobee lists an open incident (see `alarmTrigger` above for when it
87
+ starts showing). Disarming clears it. Every change in the incident list is logged.
88
+ - **Siri:** the Home app calls Stay "Home". Asking Siri for Home or for Stay mode both arm Stay.
89
+ - **Night** is only offered with `offerNight`; ecobee has no Night mode of its own.
74
90
  - After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
75
91
  - **Hold for other plugins:** a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
76
92
  (mode 600, homebridge user only) lets another plugin, e.g. homebridge-narwal, ask for a temporary
@@ -1,56 +1,70 @@
1
1
  #!/usr/bin/env node
2
- // Browser sign-in for homebridge-ecobee-security. ecobee's login page shows a CAPTCHA to scripts,
3
- // so you sign in yourself in Chrome and paste the final redirect URL here. Prints a refresh token
4
- // to paste into the plugin settings (Homebridge UI > Plugins > ecobee Smart Security > Settings).
5
- import { createHash, randomBytes } from 'node:crypto';
2
+ // Sign-in for homebridge-ecobee-security from a terminal, for when the Homebridge UI isn't
3
+ // available (its plugin settings page has the same sign-in). Prints a refresh token to paste into
4
+ // the plugin config.
5
+ //
6
+ // ecobee-login.js email, password and MFA code (the password is never saved)
7
+ // ecobee-login.js --browser sign in on ecobee's web page instead and paste the callback URL
6
8
  import readline from 'node:readline';
7
9
 
8
- import { AUDIENCE, AUTH, CLIENT_ID, REDIRECT_URI, SCOPE, tokenRequest } from '../lib/ecobee.js';
10
+ import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn } from '../lib/auth.js';
9
11
 
10
- function ask(question) {
11
- return new Promise(resolve => {
12
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
- rl.question(question, answer => {
14
- rl.close();
15
- resolve(answer.trim());
16
- });
17
- });
12
+ // One reader for the whole run. Lines are queued, so piped input that arrives before the next
13
+ // question isn't lost.
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
15
+ let muted = false;
16
+ rl._writeToOutput = text => {
17
+ if (!muted) {
18
+ rl.output.write(text);
19
+ }
20
+ };
21
+ const lines = [];
22
+ const waiting = [];
23
+ rl.on('line', line => (waiting.length ? waiting.shift()(line) : lines.push(line)));
24
+ rl.on('close', () => waiting.splice(0).forEach(resolve => resolve('')));
25
+
26
+ async function ask(question, { hidden = false } = {}) {
27
+ rl.output.write(question);
28
+ // Hide what's typed after the prompt.
29
+ muted = hidden;
30
+ const answer = lines.length ? lines.shift() : await new Promise(resolve => waiting.push(resolve));
31
+ muted = false;
32
+ if (hidden) {
33
+ rl.output.write('\n');
34
+ }
35
+ return answer.trim();
18
36
  }
19
37
 
20
- async function main() {
21
- const verifier = randomBytes(32).toString('base64url');
22
- const state = randomBytes(24).toString('base64url');
23
- const url = new URL('/authorize', AUTH);
24
- Object.entries({
25
- audience: AUDIENCE, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPE,
26
- response_type: 'code', prompt: 'login', state, nonce: randomBytes(16).toString('hex'),
27
- code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256',
28
- }).forEach(([k, v]) => url.searchParams.set(k, v));
38
+ async function withPassword() {
39
+ console.log('\nSign in to ecobee. Your password goes only to auth.ecobee.com and isn\'t saved.\n');
40
+ const username = await ask('ecobee email: ');
41
+ const password = await ask('Password (hidden): ', { hidden: true });
42
+ const { tokens, mfa } = await passwordSignIn(username, password);
43
+ if (tokens) {
44
+ return tokens;
45
+ }
46
+ const code = await ask(mfa.method === 'oob' ? 'Code ecobee just sent you: ' : 'Code from your authenticator app: ');
47
+ return finishMfa(mfa, code);
48
+ }
29
49
 
50
+ async function withBrowser() {
51
+ const link = browserSignInLink();
30
52
  console.log(`
31
53
  1. In Chrome, open a new tab, press Option-Command-I, go to Network and tick "Preserve log".
32
54
  2. Open this link in that tab and sign in (email, password, CAPTCHA, MFA code):
33
55
 
34
- ${url.href}
56
+ ${link.url}
35
57
 
36
58
  3. When the page goes blank, type code= in the Network filter, right-click the entry and
37
59
  Copy > Copy URL (or: the last "resume" request > Headers > Response Headers > location).
38
60
  It starts with com.ecobee.athenamobile://
39
61
  4. Paste it below straight away; the code expires quickly.
40
62
  `);
63
+ return finishBrowserSignIn(link, await ask('Callback URL: '));
64
+ }
41
65
 
42
- const callback = new URL(await ask('Callback URL: '));
43
- if (callback.searchParams.get('state') !== state) {
44
- throw new Error('that URL is from a different sign-in attempt; run this again');
45
- }
46
- const code = callback.searchParams.get('code');
47
- if (!code) {
48
- throw new Error('no code= in that URL');
49
- }
50
- const tokens = await tokenRequest({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: REDIRECT_URI });
51
- if (!tokens.refresh_token) {
52
- throw new Error('signed in, but ecobee returned no refresh token');
53
- }
66
+ async function main() {
67
+ const tokens = process.argv.includes('--browser') ? await withBrowser() : await withPassword();
54
68
  console.log(`
55
69
  Signed in. Paste this refresh token into the plugin settings ("Refresh token"), save, and restart
56
70
  the bridge. Treat it like a password: it gives full access to your ecobee account.
@@ -59,7 +73,7 @@ ${tokens.refresh_token}
59
73
  `);
60
74
  }
61
75
 
62
- main().catch(error => {
76
+ main().then(() => rl.close(), error => {
63
77
  console.error(`Sign-in failed: ${error.message}`);
64
78
  process.exit(1);
65
79
  });
@@ -2,10 +2,10 @@
2
2
  "pluginAlias": "EcobeeSecurityPlatform",
3
3
  "pluginType": "platform",
4
4
  "singular": true,
5
- "headerDisplay": "Shows ecobee Smart Security as a security system in Apple Home (arm Stay / Away, disarm, alarm state). Unofficial: uses the same ecobee cloud as the app. To get a refresh token, run bin/ecobee-login.js from this plugin's folder (see README).",
5
+ "customUi": true,
6
+ "headerDisplay": "Shows ecobee Smart Security as a security system in Apple Home (arm Stay / Away, disarm, alarm state). Unofficial: uses the same ecobee cloud as the app.",
6
7
  "schema": {
7
8
  "type": "object",
8
- "required": ["refreshToken"],
9
9
  "properties": {
10
10
  "name": {
11
11
  "title": "Name",
@@ -16,8 +16,10 @@
16
16
  "refreshToken": {
17
17
  "title": "Refresh token",
18
18
  "type": "string",
19
- "x-schema-form": { "type": "password" },
20
- "description": "From bin/ecobee-login.js. Only needed again if ecobee signs the plugin out (password change, 'sign out everywhere')."
19
+ "x-schema-form": {
20
+ "type": "password"
21
+ },
22
+ "description": "Filled in when you sign in above. Only needed again if ecobee signs the plugin out (password change, 'sign out everywhere')."
21
23
  },
22
24
  "homeId": {
23
25
  "title": "Home ID",
@@ -30,6 +32,54 @@
30
32
  "default": 10,
31
33
  "minimum": 5,
32
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
+ "offerNight": {
37
+ "title": "Offer Night",
38
+ "type": "boolean",
39
+ "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."
41
+ },
42
+ "alarmTrigger": {
43
+ "title": "Enter Triggered State",
44
+ "type": "string",
45
+ "default": "incident",
46
+ "oneOf": [
47
+ {
48
+ "title": "Immediately (mirror ecobee app)",
49
+ "enum": [
50
+ "incident"
51
+ ]
52
+ },
53
+ {
54
+ "title": "After the Entry/Disarm delay",
55
+ "enum": [
56
+ "entryDelay"
57
+ ]
58
+ }
59
+ ],
60
+ "description": "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."
61
+ },
62
+ "smokeSensor": {
63
+ "title": "Smoke sensor",
64
+ "type": "boolean",
65
+ "default": true,
66
+ "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
+ },
68
+ "airQuality": {
69
+ "title": "Air quality sensors",
70
+ "type": "boolean",
71
+ "default": true,
72
+ "description": "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."
73
+ },
74
+ "co2Alert": {
75
+ "title": "CO2 alert level (ppm)",
76
+ "type": "integer",
77
+ "default": 1000,
78
+ "minimum": 400,
79
+ "description": "The Carbon Dioxide Sensor reports abnormal CO2 at or above this level, for Home app notifications and automations (for example, turning on ventilation).",
80
+ "condition": {
81
+ "functionBody": "return model.airQuality !== false;"
82
+ }
33
83
  }
34
84
  }
35
85
  }
@@ -0,0 +1,182 @@
1
+ <style>
2
+ /* Inherit the theme's text colour; Bootstrap's muted grey is unreadable on Homebridge's dark themes. */
3
+ .hint { opacity: 0.75; font-size: 0.875em; }
4
+ </style>
5
+
6
+ <div class="card card-body mb-3">
7
+ <h5 class="mb-3">ecobee account</h5>
8
+
9
+ <div id="signed-in" hidden>
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>
12
+ <button type="button" class="btn btn-secondary" id="sign-in-again">Sign in again</button>
13
+ </div>
14
+
15
+ <div id="signin" hidden>
16
+ <p class="hint mb-3" id="signin-hint">Sign in with your ecobee account to connect the plugin.</p>
17
+ <form id="password-form" autocomplete="on">
18
+ <div class="mb-2">
19
+ <label for="email" class="form-label">ecobee email</label>
20
+ <input type="email" class="form-control" id="email" autocomplete="username" required>
21
+ </div>
22
+ <div class="mb-2">
23
+ <label for="password" class="form-label">Password</label>
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>
26
+ </div>
27
+ <button type="submit" class="btn btn-primary">Sign in</button>
28
+ <button type="button" class="btn btn-link" id="sign-in-cancel" hidden>Cancel</button>
29
+ </form>
30
+
31
+ <form id="mfa-form" hidden>
32
+ <div class="mb-2">
33
+ <label for="code" class="form-label" id="code-label">Verification code</label>
34
+ <input type="text" class="form-control" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="10" required>
35
+ </div>
36
+ <button type="submit" class="btn btn-primary">Verify</button>
37
+ <button type="button" class="btn btn-link" id="mfa-cancel">Start over</button>
38
+ </form>
39
+
40
+ <div id="error" class="alert alert-danger mt-3 mb-0" role="alert" hidden></div>
41
+
42
+ <details class="mt-3" id="browser">
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>
45
+ <ol class="small ps-3">
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
+ <li>In that tab, open <a id="browser-link" href="#" target="_blank" rel="noopener">this sign-in link</a> and sign in.</li>
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>
50
+ </ol>
51
+ <form id="browser-form" class="d-flex gap-2">
52
+ <input type="text" class="form-control" id="callback" placeholder="com.ecobee.athenamobile://…" required>
53
+ <button type="submit" class="btn btn-primary">Finish</button>
54
+ </form>
55
+ </details>
56
+ </div>
57
+ </div>
58
+
59
+ <script>
60
+ (async () => {
61
+ const PLATFORM = 'EcobeeSecurityPlatform';
62
+ const $ = id => document.getElementById(id);
63
+ const passwordForm = $('password-form');
64
+ const mfaForm = $('mfa-form');
65
+
66
+ function showError(message) {
67
+ $('error').textContent = message;
68
+ $('error').hidden = !message;
69
+ }
70
+
71
+ /** Show the signed-in summary, or the sign-in forms if there's no token (or the user asked). */
72
+ async function showStatus({ justSignedIn = false, signIn = false } = {}) {
73
+ const [block] = await homebridge.getPluginConfig();
74
+ const signedIn = Boolean(block?.refreshToken) && !signIn;
75
+ $('signed-in').hidden = !signedIn;
76
+ $('signin').hidden = signedIn;
77
+ $('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.'
79
+ : 'Signed in to ecobee.';
80
+ $('sign-in-cancel').hidden = !block?.refreshToken;
81
+ $('signin-hint').textContent = block?.refreshToken
82
+ ? 'Signing in again replaces the saved sign-in.'
83
+ : 'Sign in with your ecobee account to connect the plugin.';
84
+ }
85
+
86
+ async function call(path, body) {
87
+ showError('');
88
+ homebridge.showSpinner();
89
+ try {
90
+ return await homebridge.request(path, body);
91
+ } catch (error) {
92
+ showError(error.message || 'Sign-in failed.');
93
+ return undefined;
94
+ } finally {
95
+ homebridge.hideSpinner();
96
+ }
97
+ }
98
+
99
+ async function saveToken(refreshToken) {
100
+ const blocks = await homebridge.getPluginConfig();
101
+ const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
102
+ block.refreshToken = refreshToken;
103
+ // Re-show the settings form afterwards so it picks up the new token.
104
+ homebridge.hideSchemaForm();
105
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
106
+ await homebridge.savePluginConfig();
107
+ homebridge.showSchemaForm();
108
+ passwordForm.reset();
109
+ mfaForm.reset();
110
+ passwordForm.hidden = false;
111
+ mfaForm.hidden = true;
112
+ $('callback').value = '';
113
+ $('browser').open = false;
114
+ homebridge.toast.success('Signed in to ecobee.');
115
+ await showStatus({ justSignedIn: true });
116
+ }
117
+
118
+ passwordForm.addEventListener('submit', async event => {
119
+ event.preventDefault();
120
+ const result = await call('/password', { email: $('email').value, password: $('password').value });
121
+ $('password').value = '';
122
+ if (result?.refreshToken) {
123
+ await saveToken(result.refreshToken);
124
+ } else if (result?.mfa) {
125
+ $('code-label').textContent = result.mfa === 'oob'
126
+ ? 'Enter the code ecobee just sent you'
127
+ : 'Enter the code from your authenticator app';
128
+ passwordForm.hidden = true;
129
+ mfaForm.hidden = false;
130
+ $('code').focus();
131
+ }
132
+ });
133
+
134
+ mfaForm.addEventListener('submit', async event => {
135
+ event.preventDefault();
136
+ const result = await call('/mfa', { code: $('code').value });
137
+ $('code').value = '';
138
+ if (result?.refreshToken) {
139
+ await saveToken(result.refreshToken);
140
+ }
141
+ });
142
+
143
+ $('mfa-cancel').addEventListener('click', () => {
144
+ showError('');
145
+ mfaForm.reset();
146
+ mfaForm.hidden = true;
147
+ passwordForm.hidden = false;
148
+ });
149
+
150
+ $('sign-in-again').addEventListener('click', async () => {
151
+ showError('');
152
+ await showStatus({ signIn: true });
153
+ $('email').focus();
154
+ });
155
+
156
+ $('sign-in-cancel').addEventListener('click', async () => {
157
+ showError('');
158
+ passwordForm.reset();
159
+ await showStatus();
160
+ });
161
+
162
+ $('browser').addEventListener('toggle', async () => {
163
+ if ($('browser').open) {
164
+ const result = await call('/browser-link');
165
+ if (result?.url) {
166
+ $('browser-link').href = result.url;
167
+ }
168
+ }
169
+ });
170
+
171
+ $('browser-form').addEventListener('submit', async event => {
172
+ event.preventDefault();
173
+ const result = await call('/browser-finish', { callbackUrl: $('callback').value });
174
+ if (result?.refreshToken) {
175
+ await saveToken(result.refreshToken);
176
+ }
177
+ });
178
+
179
+ await showStatus();
180
+ homebridge.showSchemaForm();
181
+ })();
182
+ </script>
@@ -0,0 +1,66 @@
1
+ // Server side of the plugin's settings page (spawned by the Homebridge UI while the settings are
2
+ // open). Does the ecobee sign-in and hands only the refresh token back to the page, which saves it
3
+ // in the plugin config. The MFA token and PKCE verifier stay here, in memory.
4
+ import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
5
+
6
+ import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn, SignInError } from '../lib/auth.js';
7
+
8
+ class UiServer extends HomebridgePluginUiServer {
9
+ constructor() {
10
+ super();
11
+ this.mfa = undefined;
12
+ this.link = undefined;
13
+
14
+ this.onRequest('/password', ({ email, password } = {}) => this.guard(async () => {
15
+ if (!email || !password) {
16
+ throw new SignInError('Enter your ecobee email and password.', 'missing');
17
+ }
18
+ this.mfa = undefined;
19
+ const { tokens, mfa } = await passwordSignIn(String(email).trim(), String(password));
20
+ if (tokens) {
21
+ return { refreshToken: tokens.refresh_token };
22
+ }
23
+ this.mfa = mfa;
24
+ return { mfa: mfa.method };
25
+ }));
26
+
27
+ this.onRequest('/mfa', ({ code } = {}) => this.guard(async () => {
28
+ if (!this.mfa) {
29
+ throw new SignInError('The sign-in timed out. Enter your email and password again.', 'no_mfa');
30
+ }
31
+ const tokens = await finishMfa(this.mfa, code ?? '');
32
+ this.mfa = undefined;
33
+ return { refreshToken: tokens.refresh_token };
34
+ }));
35
+
36
+ this.onRequest('/browser-link', () => {
37
+ this.link = browserSignInLink();
38
+ return { url: this.link.url };
39
+ });
40
+
41
+ this.onRequest('/browser-finish', ({ callbackUrl } = {}) => this.guard(async () => {
42
+ if (!this.link) {
43
+ throw new SignInError('Get a sign-in link first.', 'no_link');
44
+ }
45
+ const tokens = await finishBrowserSignIn(this.link, callbackUrl ?? '');
46
+ this.link = undefined;
47
+ return { refreshToken: tokens.refresh_token };
48
+ }));
49
+
50
+ this.ready();
51
+ }
52
+
53
+ /** Turn sign-in failures into RequestErrors so the page can show ecobee's reason. */
54
+ async guard(fn) {
55
+ try {
56
+ return await fn();
57
+ } catch (error) {
58
+ if (error instanceof SignInError) {
59
+ throw new RequestError(error.message, { code: error.code });
60
+ }
61
+ throw new RequestError(`Sign-in failed: ${error.message}`, { code: 'unexpected' });
62
+ }
63
+ }
64
+ }
65
+
66
+ (() => new UiServer())();