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 +21 -0
- package/README.md +36 -20
- package/bin/ecobee-login.js +50 -36
- package/config.schema.json +54 -4
- package/homebridge-ui/public/index.html +182 -0
- package/homebridge-ui/server.js +66 -0
- package/lib/airquality.js +186 -0
- package/lib/auth.js +121 -0
- package/lib/ecobee.js +40 -0
- package/lib/platform.js +61 -1
- package/lib/security.js +138 -24
- package/lib/version.js +4 -0
- package/package.json +6 -2
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.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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 "<thermostat> 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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
package/bin/ecobee-login.js
CHANGED
|
@@ -1,56 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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 {
|
|
10
|
+
import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn } from '../lib/auth.js';
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
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().
|
|
76
|
+
main().then(() => rl.close(), error => {
|
|
63
77
|
console.error(`Sign-in failed: ${error.message}`);
|
|
64
78
|
process.exit(1);
|
|
65
79
|
});
|
package/config.schema.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"pluginAlias": "EcobeeSecurityPlatform",
|
|
3
3
|
"pluginType": "platform",
|
|
4
4
|
"singular": true,
|
|
5
|
-
"
|
|
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": {
|
|
20
|
-
|
|
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 > 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())();
|