homebridge-ecobee-security 0.0.0-stage → 0.3.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/bin/ecobee-login.js +79 -0
- package/config.schema.json +38 -0
- package/homebridge-ui/public/index.html +182 -0
- package/homebridge-ui/server.js +66 -0
- package/index.js +5 -0
- package/lib/auth.js +121 -0
- package/lib/control.js +38 -0
- package/lib/ecobee.js +157 -0
- package/lib/platform.js +151 -0
- package/lib/security.js +303 -0
- package/package.json +42 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Sign in from the plugin's settings page in the Homebridge UI with your ecobee email, password
|
|
6
|
+
and MFA code. No terminal or browser developer tools needed; the website sign-in is still there
|
|
7
|
+
as a fallback.
|
|
8
|
+
- `bin/ecobee-login.js` signs in with email and password by default; `--browser` for the old way.
|
|
9
|
+
|
|
10
|
+
## 0.2.0
|
|
11
|
+
|
|
12
|
+
- Control socket (`control.sock`) so other plugins can hold the system at Stay and release it to
|
|
13
|
+
Away afterwards (used by homebridge-narwal while the robot vacuum cleans).
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
- First release: arm Stay / Away, disarm and alarm state in Apple Home, browser sign-in script.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sean Fletcher
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,93 @@
|
|
|
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
|
|
5
|
+
Siri ("set the security system to away") and Home automations. ecobee removed Smart Security from
|
|
6
|
+
its HomeKit integration; this brings it back.
|
|
7
|
+
|
|
8
|
+
Unofficial. It uses the same cloud API as the ecobee iOS app (Auth0 sign-in at `auth.ecobee.com`,
|
|
9
|
+
GraphQL at `federated-api.prod.federated-graph.generacapis.com`), which ecobee can change at any time.
|
|
10
|
+
No dependencies.
|
|
11
|
+
|
|
12
|
+
> [!WARNING]
|
|
13
|
+
> Not affiliated with or endorsed by ecobee. The plugin signs in as the ecobee iOS app, which
|
|
14
|
+
> ecobee may not allow, and it can stop working whenever ecobee changes its app or API.
|
|
15
|
+
> If you have **professional monitoring**, testing an alarm (opening a door while armed) can
|
|
16
|
+
> lead to a call from the monitoring centre or a dispatch, just as it would with the ecobee app.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
In the Homebridge UI, search the Plugins tab for **ecobee Smart Security**, or:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
npm install -g homebridge-ecobee-security
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Running it as a child bridge is recommended. Requires Node 22 or 24 and Homebridge 1.8 or 2.x.
|
|
27
|
+
|
|
28
|
+
## Sign-in
|
|
29
|
+
|
|
30
|
+
Open the plugin's **Settings** in the Homebridge UI and sign in with your ecobee email and password,
|
|
31
|
+
then the verification code if your account uses MFA. The plugin saves a refresh token in its config;
|
|
32
|
+
your password is sent only to ecobee and isn't stored. Restart the bridge afterwards.
|
|
33
|
+
|
|
34
|
+
The plugin renews its access token every 2 hours and keeps the current refresh token in
|
|
35
|
+
`<homebridge storage>/ecobee-security/state.json` (mode 600). A newly saved token always replaces the
|
|
36
|
+
stored one. You only need to sign in again if ecobee revokes the token (for example after a password
|
|
37
|
+
change or "sign out of all devices"); the log then says so and the accessory shows a fault.
|
|
38
|
+
|
|
39
|
+
**If sign-in fails** (for example ecobee asks for a CAPTCHA), the settings page also offers sign-in
|
|
40
|
+
on ecobee's website: you sign in in a desktop browser and paste the final redirect URL from its
|
|
41
|
+
developer tools.
|
|
42
|
+
|
|
43
|
+
**Without the Homebridge UI**, sign in from a terminal and paste the printed token into the config:
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
node <plugin folder>/bin/ecobee-login.js # email, password, MFA code
|
|
47
|
+
node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Config
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"platform": "EcobeeSecurityPlatform",
|
|
55
|
+
"name": "Security System",
|
|
56
|
+
"refreshToken": "…",
|
|
57
|
+
"pollInterval": 10,
|
|
58
|
+
"_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`homeId` is only needed if the account has several homes (the log lists them).
|
|
63
|
+
|
|
64
|
+
## How it behaves
|
|
65
|
+
|
|
66
|
+
- **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
|
|
67
|
+
delay the Home app shows "Arming…" (current Disarmed, target Away). Stay uses the Stay exit delay
|
|
68
|
+
(0 by default). Disarm needs no PIN.
|
|
69
|
+
- **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within the poll
|
|
70
|
+
interval (10 s by default), and quickly after an exit delay ends.
|
|
71
|
+
- **Alarm triggered** while ecobee lists an incident whose status isn't resolved/closed/cancelled.
|
|
72
|
+
ecobee opens an incident as soon as a door opens while armed, during the entry delay, so the
|
|
73
|
+
tile shows Alarm triggered from that moment, as the ecobee app does; disarming clears it.
|
|
74
|
+
Every change in the incident list is logged.
|
|
75
|
+
- **Siri** calls Stay "Home": "set the security system to Home". Saying "arm … for stay" makes
|
|
76
|
+
Siri pick Away.
|
|
77
|
+
- **Night** isn't offered; ecobee has no Night mode.
|
|
78
|
+
- After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
|
|
79
|
+
- **Hold for other plugins:** a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
|
|
80
|
+
(mode 600, homebridge user only) lets another plugin, e.g. homebridge-narwal, ask for a temporary
|
|
81
|
+
Stay: `POST /hold?by=<name>` switches Away (or arming Away) to Stay and remembers it;
|
|
82
|
+
`POST /release?by=<name>` arms Away again — unless the state was changed by anyone in between, in
|
|
83
|
+
which case the hold is just dropped. The hold survives restarts (in `state.json`). `/hold`
|
|
84
|
+
answers `ok: false` when the alarm is triggered or the state is unknown.
|
|
85
|
+
|
|
86
|
+
## API notes
|
|
87
|
+
|
|
88
|
+
[docs/api.md](docs/api.md) describes the sign-in flow and GraphQL calls the plugin uses, for anyone
|
|
89
|
+
fixing it after an ecobee change or adding features.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
|
|
10
|
+
import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn } from '../lib/auth.js';
|
|
11
|
+
|
|
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();
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
}
|
|
49
|
+
|
|
50
|
+
async function withBrowser() {
|
|
51
|
+
const link = browserSignInLink();
|
|
52
|
+
console.log(`
|
|
53
|
+
1. In Chrome, open a new tab, press Option-Command-I, go to Network and tick "Preserve log".
|
|
54
|
+
2. Open this link in that tab and sign in (email, password, CAPTCHA, MFA code):
|
|
55
|
+
|
|
56
|
+
${link.url}
|
|
57
|
+
|
|
58
|
+
3. When the page goes blank, type code= in the Network filter, right-click the entry and
|
|
59
|
+
Copy > Copy URL (or: the last "resume" request > Headers > Response Headers > location).
|
|
60
|
+
It starts with com.ecobee.athenamobile://
|
|
61
|
+
4. Paste it below straight away; the code expires quickly.
|
|
62
|
+
`);
|
|
63
|
+
return finishBrowserSignIn(link, await ask('Callback URL: '));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function main() {
|
|
67
|
+
const tokens = process.argv.includes('--browser') ? await withBrowser() : await withPassword();
|
|
68
|
+
console.log(`
|
|
69
|
+
Signed in. Paste this refresh token into the plugin settings ("Refresh token"), save, and restart
|
|
70
|
+
the bridge. Treat it like a password: it gives full access to your ecobee account.
|
|
71
|
+
|
|
72
|
+
${tokens.refresh_token}
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
main().then(() => rl.close(), error => {
|
|
77
|
+
console.error(`Sign-in failed: ${error.message}`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "EcobeeSecurityPlatform",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
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.",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "Security System",
|
|
14
|
+
"description": "Name shown in the Home app."
|
|
15
|
+
},
|
|
16
|
+
"refreshToken": {
|
|
17
|
+
"title": "Refresh token",
|
|
18
|
+
"type": "string",
|
|
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')."
|
|
23
|
+
},
|
|
24
|
+
"homeId": {
|
|
25
|
+
"title": "Home ID",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Only needed if the account has more than one home; the log lists them."
|
|
28
|
+
},
|
|
29
|
+
"pollInterval": {
|
|
30
|
+
"title": "Check every (seconds)",
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"default": 10,
|
|
33
|
+
"minimum": 5,
|
|
34
|
+
"description": "How often to read the armed state, so changes made in the ecobee app or at the keypad show up in Apple Home."
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -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())();
|
package/index.js
ADDED
package/lib/auth.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// ecobee sign-in, shared by bin/ecobee-login.js and the Homebridge UI settings page.
|
|
2
|
+
//
|
|
3
|
+
// Password sign-in goes straight to Auth0's token endpoint as the ecobee app's client (no login
|
|
4
|
+
// page, so no CAPTCHA), followed by the account's MFA code if it has MFA. The browser sign-in is
|
|
5
|
+
// the fallback: the user signs in on ecobee's page and pastes the app callback URL.
|
|
6
|
+
// Only the refresh token is kept; passwords and codes are passed through and never stored.
|
|
7
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
import { AUDIENCE, AUTH, CLIENT_ID, REDIRECT_URI, SCOPE } from './ecobee.js';
|
|
10
|
+
|
|
11
|
+
const TIMEOUT = 20 * 1000;
|
|
12
|
+
|
|
13
|
+
/** A sign-in step failed; `message` is ecobee's reason, fit to show the user. */
|
|
14
|
+
export class SignInError extends Error {
|
|
15
|
+
constructor(message, code) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function post(path, body) {
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await fetch(`${AUTH}${path}`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ client_id: CLIENT_ID, ...body }),
|
|
28
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
29
|
+
});
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new SignInError(`Could not reach ecobee (${error.message})`, 'network');
|
|
32
|
+
}
|
|
33
|
+
const json = await res.json().catch(() => ({}));
|
|
34
|
+
return { status: res.status, json };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function fail({ status, json }) {
|
|
38
|
+
const reason = json.error_description || json.error || `HTTP ${status}`;
|
|
39
|
+
return new SignInError(reason.endsWith('.') ? reason : `${reason}.`, json.error ?? `http_${status}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tokens(json) {
|
|
43
|
+
if (!json.refresh_token) {
|
|
44
|
+
throw new SignInError('Signed in, but ecobee returned no refresh token.', 'no_refresh_token');
|
|
45
|
+
}
|
|
46
|
+
return json;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Step 1 of password sign-in. Resolves `{ tokens }` when no MFA is needed, otherwise
|
|
51
|
+
* `{ mfa }` to pass to finishMfa() together with the code ecobee asks for. `mfa.method` is
|
|
52
|
+
* 'otp' (authenticator app) or 'oob' (a code ecobee sends by SMS or email).
|
|
53
|
+
*/
|
|
54
|
+
export async function passwordSignIn(username, password) {
|
|
55
|
+
const first = await post('/oauth/token', { grant_type: 'password', username, password, audience: AUDIENCE, scope: SCOPE });
|
|
56
|
+
if (first.status === 200) {
|
|
57
|
+
return { tokens: tokens(first.json) };
|
|
58
|
+
}
|
|
59
|
+
if (first.json.error !== 'mfa_required') {
|
|
60
|
+
throw fail(first);
|
|
61
|
+
}
|
|
62
|
+
const mfaToken = first.json.mfa_token;
|
|
63
|
+
const challenge = await post('/mfa/challenge', { mfa_token: mfaToken, challenge_type: 'otp oob' });
|
|
64
|
+
if (challenge.status !== 200) {
|
|
65
|
+
throw fail(challenge);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
mfa: {
|
|
69
|
+
token: mfaToken,
|
|
70
|
+
method: challenge.json.challenge_type === 'oob' ? 'oob' : 'otp',
|
|
71
|
+
oobCode: challenge.json.oob_code,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Step 2 of password sign-in: the MFA code. */
|
|
77
|
+
export async function finishMfa(mfa, code) {
|
|
78
|
+
const otp = String(code).replace(/\s+/g, '');
|
|
79
|
+
const result = mfa.method === 'oob'
|
|
80
|
+
? await post('/oauth/token', { grant_type: 'http://auth0.com/oauth/grant-type/mfa-oob', mfa_token: mfa.token, oob_code: mfa.oobCode, binding_code: otp })
|
|
81
|
+
: await post('/oauth/token', { grant_type: 'http://auth0.com/oauth/grant-type/mfa-otp', mfa_token: mfa.token, otp });
|
|
82
|
+
if (result.status !== 200) {
|
|
83
|
+
throw fail(result);
|
|
84
|
+
}
|
|
85
|
+
return tokens(result.json);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Browser sign-in, step 1: the link to open, plus what finishBrowserSignIn() needs later. */
|
|
89
|
+
export function browserSignInLink() {
|
|
90
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
91
|
+
const state = randomBytes(24).toString('base64url');
|
|
92
|
+
const url = new URL('/authorize', AUTH);
|
|
93
|
+
Object.entries({
|
|
94
|
+
audience: AUDIENCE, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPE,
|
|
95
|
+
response_type: 'code', prompt: 'login', state, nonce: randomBytes(16).toString('hex'),
|
|
96
|
+
code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256',
|
|
97
|
+
}).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
98
|
+
return { url: url.href, verifier, state };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Browser sign-in, step 2: the com.ecobee.athenamobile://…?code=… URL the user copied. */
|
|
102
|
+
export async function finishBrowserSignIn(link, callbackUrl) {
|
|
103
|
+
let callback;
|
|
104
|
+
try {
|
|
105
|
+
callback = new URL(String(callbackUrl).trim());
|
|
106
|
+
} catch {
|
|
107
|
+
throw new SignInError('That isn\'t a URL. It should start with com.ecobee.athenamobile://', 'bad_url');
|
|
108
|
+
}
|
|
109
|
+
if (callback.searchParams.get('state') !== link.state) {
|
|
110
|
+
throw new SignInError('That URL is from a different sign-in attempt. Get a new link and try again.', 'bad_state');
|
|
111
|
+
}
|
|
112
|
+
const code = callback.searchParams.get('code');
|
|
113
|
+
if (!code) {
|
|
114
|
+
throw new SignInError('There\'s no code= in that URL.', 'bad_url');
|
|
115
|
+
}
|
|
116
|
+
const result = await post('/oauth/token', { grant_type: 'authorization_code', code, code_verifier: link.verifier, redirect_uri: REDIRECT_URI });
|
|
117
|
+
if (result.status !== 200) {
|
|
118
|
+
throw fail(result);
|
|
119
|
+
}
|
|
120
|
+
return tokens(result.json);
|
|
121
|
+
}
|
package/lib/control.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// A local control socket so other Homebridge plugins (each child bridge is its own process)
|
|
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 }
|
|
5
|
+
// It's a Unix socket in the plugin's storage folder (mode 600), so only the homebridge user can use it.
|
|
6
|
+
|
|
7
|
+
import { chmodSync, rmSync } from 'node:fs';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
|
|
10
|
+
export function startControlServer({ path, log, getSystem }) {
|
|
11
|
+
rmSync(path, { force: true });
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
const url = new URL(req.url, 'http://localhost');
|
|
14
|
+
const by = (url.searchParams.get('by') || 'another plugin').slice(0, 40);
|
|
15
|
+
const reply = (code, body) => {
|
|
16
|
+
res.writeHead(code, { 'content-type': 'application/json' });
|
|
17
|
+
res.end(JSON.stringify(body));
|
|
18
|
+
};
|
|
19
|
+
const system = getSystem();
|
|
20
|
+
if (req.method !== 'POST' || !['/hold', '/release'].includes(url.pathname)) {
|
|
21
|
+
return reply(404, { error: 'POST /hold or /release' });
|
|
22
|
+
}
|
|
23
|
+
if (!system) {
|
|
24
|
+
return reply(503, { error: 'not connected to ecobee yet' });
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
reply(200, url.pathname === '/hold' ? await system.hold(by) : await system.release(by));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
reply(502, { error: error.message });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.on('error', error => log.error(`Control socket ${path}: ${error.message}`));
|
|
33
|
+
server.listen(path, () => {
|
|
34
|
+
chmodSync(path, 0o600);
|
|
35
|
+
log.debug(`Control socket listening at ${path}`);
|
|
36
|
+
});
|
|
37
|
+
return server;
|
|
38
|
+
}
|
package/lib/ecobee.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// ecobee cloud client: Auth0 refresh-token sign-in + the app's GraphQL API (see docs in README).
|
|
2
|
+
|
|
3
|
+
export const AUTH = 'https://auth.ecobee.com';
|
|
4
|
+
export const GRAPHQL = 'https://federated-api.prod.federated-graph.generacapis.com/graphql';
|
|
5
|
+
export const CLIENT_ID = 'CuGkX8Ai7OWD2V1F32rFa7UJGRVRsnbv';
|
|
6
|
+
export const REDIRECT_URI = 'com.ecobee.athenamobile://auth.ecobee.com/ios/com.ecobee.athenamobile/callback';
|
|
7
|
+
export const AUDIENCE = 'https://prod.ecobee.com/api/v1';
|
|
8
|
+
export const SCOPE = 'openid profile offline_access smartWrite piiWrite piiRead';
|
|
9
|
+
|
|
10
|
+
const APP_VERSION = '26.18.0+607111';
|
|
11
|
+
const USER_AGENT = 'ecobee/607111 CFNetwork/3896.100.1.2.1 Darwin/27.0.0';
|
|
12
|
+
const TIMEOUT = 20 * 1000;
|
|
13
|
+
|
|
14
|
+
export const ArmedState = {
|
|
15
|
+
STAY: 'ARMED_STAY',
|
|
16
|
+
AWAY: 'ARMED_AWAY',
|
|
17
|
+
DISARMED: 'DISARMED',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MONITORING_FIELDS = 'armedState delayedArmedState { desiredArmedState delayedUntil }';
|
|
21
|
+
|
|
22
|
+
const STATE_QUERY = `query HomesSecurity { homes { id name capabilities monitoring { ${MONITORING_FIELDS} `
|
|
23
|
+
+ 'incidents { id status detectedIntrusion detectedSmokeAlarm } '
|
|
24
|
+
+ 'homeMonitoringSettings { isProfessionalMonitoringEnabled armedStateSettings { '
|
|
25
|
+
+ 'armedAway { entryDelayDuration exitDelayDuration } armedStay { entryDelayDuration exitDelayDuration } } } } } }';
|
|
26
|
+
|
|
27
|
+
const SET_MUTATION = 'mutation SetArmedStateForHome($input: SetArmedStateForHomeInput!) { '
|
|
28
|
+
+ `setArmedStateForHome(input: $input) { home { id monitoring { ${MONITORING_FIELDS} } } success } }`;
|
|
29
|
+
|
|
30
|
+
/** The refresh token was rejected; a new browser sign-in is needed. */
|
|
31
|
+
export class SignInRequiredError extends Error {}
|
|
32
|
+
|
|
33
|
+
export class EcobeeCloud {
|
|
34
|
+
/**
|
|
35
|
+
* @param {object} options
|
|
36
|
+
* @param {string} options.refreshToken
|
|
37
|
+
* @param {(token: string) => void} options.onRefreshToken called when Auth0 hands out a new refresh token
|
|
38
|
+
* @param {import('homebridge').Logging} options.log
|
|
39
|
+
*/
|
|
40
|
+
constructor({ refreshToken, onRefreshToken, log }) {
|
|
41
|
+
this.refreshToken = refreshToken;
|
|
42
|
+
this.onRefreshToken = onRefreshToken;
|
|
43
|
+
this.log = log;
|
|
44
|
+
this.accessToken = undefined;
|
|
45
|
+
this.expiresAt = 0;
|
|
46
|
+
this.renewing = undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async token() {
|
|
50
|
+
if (this.accessToken && Date.now() < this.expiresAt - 5 * 60 * 1000) {
|
|
51
|
+
return this.accessToken;
|
|
52
|
+
}
|
|
53
|
+
// Several polls/commands may need a token at once; renew only once.
|
|
54
|
+
this.renewing ??= this.renew().finally(() => {
|
|
55
|
+
this.renewing = undefined;
|
|
56
|
+
});
|
|
57
|
+
return this.renewing;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async renew() {
|
|
61
|
+
if (!this.refreshToken) {
|
|
62
|
+
throw new SignInRequiredError('no refresh token');
|
|
63
|
+
}
|
|
64
|
+
const json = await tokenRequest({ grant_type: 'refresh_token', refresh_token: this.refreshToken });
|
|
65
|
+
this.accessToken = json.access_token;
|
|
66
|
+
this.expiresAt = Date.now() + (json.expires_in ?? 7200) * 1000;
|
|
67
|
+
if (json.refresh_token && json.refresh_token !== this.refreshToken) {
|
|
68
|
+
this.refreshToken = json.refresh_token;
|
|
69
|
+
this.onRefreshToken(json.refresh_token);
|
|
70
|
+
}
|
|
71
|
+
this.log.debug(`Signed in to ecobee (token valid ${Math.round((json.expires_in ?? 7200) / 60)} min)`);
|
|
72
|
+
return this.accessToken;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async graphql(operationName, query, variables, retry = true) {
|
|
76
|
+
const token = await this.token();
|
|
77
|
+
const res = await fetch(GRAPHQL, {
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'content-type': 'application/json',
|
|
81
|
+
accept: 'application/json',
|
|
82
|
+
authorization: `Bearer ${token}`,
|
|
83
|
+
'apollographql-client-name': 'com.ecobee.athenamobile-apollo-ios',
|
|
84
|
+
'apollographql-client-version': APP_VERSION,
|
|
85
|
+
'x-apollo-operation-name': operationName,
|
|
86
|
+
'user-agent': USER_AGENT,
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ operationName, query, variables }),
|
|
89
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
90
|
+
});
|
|
91
|
+
if (res.status === 401 && retry) {
|
|
92
|
+
this.accessToken = undefined;
|
|
93
|
+
return this.graphql(operationName, query, variables, false);
|
|
94
|
+
}
|
|
95
|
+
const text = await res.text();
|
|
96
|
+
let json;
|
|
97
|
+
try {
|
|
98
|
+
json = JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`${operationName}: HTTP ${res.status}, unexpected reply ${text.slice(0, 120).replace(/\s+/g, ' ')}`);
|
|
101
|
+
}
|
|
102
|
+
if (json.errors?.length) {
|
|
103
|
+
const message = json.errors.map(e => e.message).join('; ');
|
|
104
|
+
if (retry && /unauth|expired|jwt/i.test(message)) {
|
|
105
|
+
this.accessToken = undefined;
|
|
106
|
+
return this.graphql(operationName, query, variables, false);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`${operationName}: ${message}`);
|
|
109
|
+
}
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new Error(`${operationName}: HTTP ${res.status}`);
|
|
112
|
+
}
|
|
113
|
+
return json.data;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** All homes on the account with their security state. */
|
|
117
|
+
async getHomes() {
|
|
118
|
+
const data = await this.graphql('HomesSecurity', STATE_QUERY);
|
|
119
|
+
return data.homes ?? [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {string} homeId
|
|
124
|
+
* @param {string} armedState one of ArmedState
|
|
125
|
+
* @param {number} exitDelay seconds; the app sends the exit delay as a timestamp
|
|
126
|
+
* @returns the home's monitoring state after the change
|
|
127
|
+
*/
|
|
128
|
+
async setArmedState(homeId, armedState, exitDelay = 0) {
|
|
129
|
+
const exitDelayUntil = new Date(Date.now() + exitDelay * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
130
|
+
const data = await this.graphql('SetArmedStateForHome', SET_MUTATION, {
|
|
131
|
+
input: { homeId, armedState, exitDelayUntil },
|
|
132
|
+
});
|
|
133
|
+
const result = data.setArmedStateForHome;
|
|
134
|
+
if (!result?.success) {
|
|
135
|
+
throw new Error(`ecobee refused ${armedState}`);
|
|
136
|
+
}
|
|
137
|
+
return result.home?.monitoring;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function tokenRequest(body) {
|
|
142
|
+
const res = await fetch(`${AUTH}/oauth/token`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json' },
|
|
145
|
+
body: JSON.stringify({ client_id: CLIENT_ID, ...body }),
|
|
146
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
147
|
+
});
|
|
148
|
+
const json = await res.json().catch(() => ({}));
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
const message = `ecobee sign-in failed (${res.status} ${json.error ?? ''}: ${json.error_description ?? ''})`;
|
|
151
|
+
if (json.error === 'invalid_grant' || json.error === 'unauthorized_client') {
|
|
152
|
+
throw new SignInRequiredError(message);
|
|
153
|
+
}
|
|
154
|
+
throw new Error(message);
|
|
155
|
+
}
|
|
156
|
+
return json;
|
|
157
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { EcobeeCloud, SignInRequiredError } from './ecobee.js';
|
|
5
|
+
import { startControlServer } from './control.js';
|
|
6
|
+
import { SecuritySystem } from './security.js';
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_NAME = 'homebridge-ecobee-security';
|
|
9
|
+
export const PLATFORM_NAME = 'EcobeeSecurityPlatform';
|
|
10
|
+
|
|
11
|
+
const RETRY_START = 60 * 1000;
|
|
12
|
+
|
|
13
|
+
export class EcobeeSecurityPlatform {
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('homebridge').Logging} log
|
|
16
|
+
* @param {import('homebridge').PlatformConfig} config
|
|
17
|
+
* @param {import('homebridge').API} api
|
|
18
|
+
*/
|
|
19
|
+
constructor(log, config, api) {
|
|
20
|
+
this.log = log;
|
|
21
|
+
this.config = config ?? {};
|
|
22
|
+
this.api = api;
|
|
23
|
+
this.cached = [];
|
|
24
|
+
|
|
25
|
+
const dir = join(api.user.storagePath(), 'ecobee-security');
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
this.storePath = join(dir, 'state.json');
|
|
28
|
+
this.store = this.load();
|
|
29
|
+
|
|
30
|
+
// A refresh token newly pasted into the settings wins over the one we've been keeping.
|
|
31
|
+
const configured = this.config.refreshToken?.trim();
|
|
32
|
+
if (configured && configured !== this.store.configuredToken) {
|
|
33
|
+
this.store.refreshToken = configured;
|
|
34
|
+
this.store.configuredToken = configured;
|
|
35
|
+
this.save();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!this.store.refreshToken) {
|
|
39
|
+
log.error('No ecobee refresh token. Sign in with bin/ecobee-login.js (see the plugin README), '
|
|
40
|
+
+ 'paste the refresh token into the plugin settings and restart the bridge.');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this.cloud = new EcobeeCloud({
|
|
45
|
+
refreshToken: this.store.refreshToken,
|
|
46
|
+
onRefreshToken: (token) => {
|
|
47
|
+
this.store.refreshToken = token;
|
|
48
|
+
this.save();
|
|
49
|
+
},
|
|
50
|
+
log,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
api.on('didFinishLaunching', () => {
|
|
54
|
+
this.control = startControlServer({ path: join(dir, 'control.sock'), log, getSystem: () => this.system });
|
|
55
|
+
this.launch();
|
|
56
|
+
});
|
|
57
|
+
api.on('shutdown', () => {
|
|
58
|
+
clearTimeout(this.retryTimer);
|
|
59
|
+
this.control?.close();
|
|
60
|
+
this.system?.stop();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {import('homebridge').PlatformAccessory} accessory */
|
|
65
|
+
configureAccessory(accessory) {
|
|
66
|
+
this.cached.push(accessory);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
load() {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(readFileSync(this.storePath, 'utf8'));
|
|
72
|
+
} catch {
|
|
73
|
+
return {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
save() {
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(this.storePath, JSON.stringify(this.store, null, 2), { mode: 0o600 });
|
|
80
|
+
chmodSync(this.storePath, 0o600);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
this.log.warn(`Could not save plugin state: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async launch(retryIn = RETRY_START) {
|
|
87
|
+
try {
|
|
88
|
+
await this.setup();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof SignInRequiredError) {
|
|
91
|
+
this.log.error(`${error.message}. Sign in again with bin/ecobee-login.js (see the plugin README), `
|
|
92
|
+
+ 'paste the new refresh token into the plugin settings and restart the bridge.');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.log.warn(`Startup failed (${error.message}); retrying in ${retryIn / 1000} s.`);
|
|
96
|
+
this.retryTimer = setTimeout(() => this.launch(Math.min(retryIn * 2, 30 * 60 * 1000)), retryIn);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async setup() {
|
|
101
|
+
const homes = await this.cloud.getHomes();
|
|
102
|
+
const wanted = this.config.homeId?.trim();
|
|
103
|
+
const home = wanted
|
|
104
|
+
? homes.find(h => h.id === wanted)
|
|
105
|
+
: homes.find(h => h.capabilities?.includes('HOME_MONITORING') && h.monitoring) ?? homes.find(h => h.monitoring);
|
|
106
|
+
if (!home) {
|
|
107
|
+
this.log.error(wanted
|
|
108
|
+
? `No home with id ${wanted} on this ecobee account. Homes: ${homes.map(h => `${h.name} (${h.id})`).join(', ')}`
|
|
109
|
+
: 'No home with Smart Security on this ecobee account.');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!wanted && homes.length > 1) {
|
|
113
|
+
this.log.warn(`Several homes on this account; using "${home.name}". Set "homeId" in the plugin settings to pick another: `
|
|
114
|
+
+ homes.map(h => `${h.name} (${h.id})`).join(', '));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const uuid = this.api.hap.uuid.generate(`ecobee-security:${home.id}`);
|
|
118
|
+
const stale = this.cached.filter(a => a.UUID !== uuid);
|
|
119
|
+
if (stale.length) {
|
|
120
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
121
|
+
}
|
|
122
|
+
let accessory = this.cached.find(a => a.UUID === uuid);
|
|
123
|
+
const isNew = !accessory;
|
|
124
|
+
if (isNew) {
|
|
125
|
+
accessory = new this.api.platformAccessory(this.config.name || 'Security System', uuid);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.system = new SecuritySystem({
|
|
129
|
+
api: this.api,
|
|
130
|
+
log: this.log,
|
|
131
|
+
accessory,
|
|
132
|
+
cloud: this.cloud,
|
|
133
|
+
home,
|
|
134
|
+
pollInterval: Math.max(5, Number(this.config.pollInterval) || 10),
|
|
135
|
+
store: this.store,
|
|
136
|
+
save: () => this.save(),
|
|
137
|
+
});
|
|
138
|
+
if (isNew) {
|
|
139
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
140
|
+
} else {
|
|
141
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
142
|
+
}
|
|
143
|
+
this.system.start();
|
|
144
|
+
const m = home.monitoring;
|
|
145
|
+
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
146
|
+
+ `${m.homeMonitoringSettings?.isProfessionalMonitoringEnabled ? 'on' : 'off'}).`);
|
|
147
|
+
if (this.store.hold) {
|
|
148
|
+
this.log.info(`Still holding Stay for ${this.store.hold.by} (since ${this.store.hold.since}).`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/lib/security.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { ArmedState, SignInRequiredError } from './ecobee.js';
|
|
2
|
+
|
|
3
|
+
// HomeKit SecuritySystemCurrentState / TargetState values.
|
|
4
|
+
const Hk = {
|
|
5
|
+
STAY: 0,
|
|
6
|
+
AWAY: 1,
|
|
7
|
+
NIGHT: 2,
|
|
8
|
+
DISARMED: 3,
|
|
9
|
+
TRIGGERED: 4,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const HK_NAMES = ['Armed Stay', 'Armed Away', 'Armed Night', 'Disarmed', 'Alarm triggered'];
|
|
13
|
+
|
|
14
|
+
const TO_ECOBEE = {
|
|
15
|
+
[Hk.STAY]: ArmedState.STAY,
|
|
16
|
+
[Hk.AWAY]: ArmedState.AWAY,
|
|
17
|
+
[Hk.DISARMED]: ArmedState.DISARMED,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const FROM_ECOBEE = {
|
|
21
|
+
[ArmedState.STAY]: Hk.STAY,
|
|
22
|
+
[ArmedState.AWAY]: Hk.AWAY,
|
|
23
|
+
[ArmedState.DISARMED]: Hk.DISARMED,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Incident statuses that mean the alarm is over. Anything else counts as triggered.
|
|
27
|
+
const CLOSED_INCIDENT = /RESOLV|CLOS|CANCEL|DISMISS|END|FALSE|COMPLETE|EXPIRE/i;
|
|
28
|
+
|
|
29
|
+
const MAX_BACKOFF = 5 * 60 * 1000;
|
|
30
|
+
const FAULT_AFTER = 3;
|
|
31
|
+
|
|
32
|
+
export class SecuritySystem {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {import('homebridge').API} options.api
|
|
36
|
+
* @param {import('homebridge').Logging} options.log
|
|
37
|
+
* @param {import('homebridge').PlatformAccessory} options.accessory
|
|
38
|
+
* @param {import('./ecobee.js').EcobeeCloud} options.cloud
|
|
39
|
+
* @param {object} options.home first GraphQL home object
|
|
40
|
+
* @param {number} options.pollInterval seconds
|
|
41
|
+
* @param {object} options.store persisted plugin state (`hold` lives here)
|
|
42
|
+
* @param {() => void} options.save persist `store`
|
|
43
|
+
*/
|
|
44
|
+
constructor({ api, log, accessory, cloud, home, pollInterval, store, save }) {
|
|
45
|
+
this.api = api;
|
|
46
|
+
this.store = store;
|
|
47
|
+
this.save = save;
|
|
48
|
+
this.queue = Promise.resolve();
|
|
49
|
+
this.log = log;
|
|
50
|
+
this.accessory = accessory;
|
|
51
|
+
this.cloud = cloud;
|
|
52
|
+
this.homeId = home.id;
|
|
53
|
+
this.pollInterval = pollInterval * 1000;
|
|
54
|
+
this.failures = 0;
|
|
55
|
+
this.commandAt = 0;
|
|
56
|
+
this.stopped = false;
|
|
57
|
+
|
|
58
|
+
const { Service, Characteristic } = api.hap;
|
|
59
|
+
this.C = Characteristic;
|
|
60
|
+
|
|
61
|
+
accessory.getService(Service.AccessoryInformation)
|
|
62
|
+
.setCharacteristic(Characteristic.Manufacturer, 'ecobee')
|
|
63
|
+
.setCharacteristic(Characteristic.Model, 'Smart Security')
|
|
64
|
+
.setCharacteristic(Characteristic.SerialNumber, home.id);
|
|
65
|
+
|
|
66
|
+
this.service = accessory.getService(Service.SecuritySystem)
|
|
67
|
+
?? accessory.addService(Service.SecuritySystem, accessory.displayName);
|
|
68
|
+
this.service.getCharacteristic(Characteristic.SecuritySystemCurrentState)
|
|
69
|
+
.setProps({ validValues: [Hk.STAY, Hk.AWAY, Hk.DISARMED, Hk.TRIGGERED] });
|
|
70
|
+
this.service.getCharacteristic(Characteristic.SecuritySystemTargetState)
|
|
71
|
+
.setProps({ validValues: [Hk.STAY, Hk.AWAY, Hk.DISARMED] })
|
|
72
|
+
.onSet(value => this.setTarget(value));
|
|
73
|
+
if (!this.service.testCharacteristic(Characteristic.StatusFault)) {
|
|
74
|
+
this.service.addCharacteristic(Characteristic.StatusFault);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
this.apply(home);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
start() {
|
|
81
|
+
this.schedule(this.pollInterval);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
stop() {
|
|
85
|
+
this.stopped = true;
|
|
86
|
+
clearTimeout(this.timer);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
schedule(delay) {
|
|
90
|
+
if (this.stopped) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
clearTimeout(this.timer);
|
|
94
|
+
this.timer = setTimeout(() => this.poll(), delay);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async poll() {
|
|
98
|
+
const startedAt = Date.now();
|
|
99
|
+
let next = this.pollInterval;
|
|
100
|
+
try {
|
|
101
|
+
const homes = await this.cloud.getHomes();
|
|
102
|
+
const home = homes.find(h => h.id === this.homeId);
|
|
103
|
+
if (!home) {
|
|
104
|
+
throw new Error('the home is no longer on this ecobee account');
|
|
105
|
+
}
|
|
106
|
+
// A command that finished while this poll was running is newer than what we just read.
|
|
107
|
+
if (startedAt >= this.commandAt) {
|
|
108
|
+
this.apply(home);
|
|
109
|
+
}
|
|
110
|
+
if (this.failures >= FAULT_AFTER) {
|
|
111
|
+
this.log.info('ecobee is reachable again.');
|
|
112
|
+
}
|
|
113
|
+
this.failures = 0;
|
|
114
|
+
this.setFault(false);
|
|
115
|
+
const until = Date.parse(home.monitoring?.delayedArmedState?.delayedUntil ?? '');
|
|
116
|
+
if (until > Date.now()) {
|
|
117
|
+
// Catch the end of an exit delay promptly.
|
|
118
|
+
next = Math.min(next, until - Date.now() + 1500);
|
|
119
|
+
}
|
|
120
|
+
} catch (error) {
|
|
121
|
+
this.failures += 1;
|
|
122
|
+
if (error instanceof SignInRequiredError) {
|
|
123
|
+
this.setFault(true);
|
|
124
|
+
this.log.error(`${error.message}. Sign in again: run bin/ecobee-login.js (see the plugin README), `
|
|
125
|
+
+ 'paste the new refresh token into the plugin settings and restart the bridge.');
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
next = Math.min(this.pollInterval * 2 ** this.failures, MAX_BACKOFF);
|
|
129
|
+
if (this.failures === FAULT_AFTER) {
|
|
130
|
+
this.setFault(true);
|
|
131
|
+
}
|
|
132
|
+
const say = this.failures >= FAULT_AFTER ? 'warn' : 'debug';
|
|
133
|
+
this.log[say](`Could not read the security state (${error.message}); retrying in ${Math.round(next / 1000)} s.`);
|
|
134
|
+
}
|
|
135
|
+
this.schedule(next);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setFault(on) {
|
|
139
|
+
const { StatusFault } = this.C;
|
|
140
|
+
this.service.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Apply a GraphQL home (or just its monitoring object) to HomeKit. */
|
|
144
|
+
apply(home) {
|
|
145
|
+
const monitoring = home.monitoring ?? {};
|
|
146
|
+
if (monitoring.homeMonitoringSettings) {
|
|
147
|
+
this.settings = monitoring.homeMonitoringSettings;
|
|
148
|
+
}
|
|
149
|
+
if (monitoring.incidents) {
|
|
150
|
+
this.noteIncidents(monitoring.incidents);
|
|
151
|
+
}
|
|
152
|
+
if (monitoring.armedState) {
|
|
153
|
+
this.monitoring = monitoring;
|
|
154
|
+
}
|
|
155
|
+
if (!this.monitoring) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const armed = FROM_ECOBEE[this.monitoring.armedState] ?? Hk.DISARMED;
|
|
160
|
+
const desired = this.monitoring.delayedArmedState?.desiredArmedState;
|
|
161
|
+
const target = desired ? (FROM_ECOBEE[desired] ?? armed) : armed;
|
|
162
|
+
const current = this.triggered ? Hk.TRIGGERED : armed;
|
|
163
|
+
|
|
164
|
+
const { SecuritySystemCurrentState, SecuritySystemTargetState } = this.C;
|
|
165
|
+
const before = this.current;
|
|
166
|
+
if (before !== current || this.target !== target) {
|
|
167
|
+
const pending = target !== armed ? ` (arming ${HK_NAMES[target]} until ${this.monitoring.delayedArmedState?.delayedUntil})` : '';
|
|
168
|
+
this.log.info(`Security system: ${HK_NAMES[current]}${pending}`);
|
|
169
|
+
}
|
|
170
|
+
this.current = current;
|
|
171
|
+
this.target = target;
|
|
172
|
+
if (this.store.hold && (target !== Hk.STAY || current === Hk.TRIGGERED)) {
|
|
173
|
+
this.log.info(`Security system changed to ${HK_NAMES[current === Hk.TRIGGERED ? current : target]} during the `
|
|
174
|
+
+ `${this.store.hold.by} hold; it won't be put back to Away.`);
|
|
175
|
+
this.dropHold();
|
|
176
|
+
}
|
|
177
|
+
this.service.updateCharacteristic(SecuritySystemTargetState, target);
|
|
178
|
+
this.service.updateCharacteristic(SecuritySystemCurrentState, current);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
noteIncidents(incidents) {
|
|
182
|
+
const active = incidents.filter(i => !CLOSED_INCIDENT.test(i.status ?? ''));
|
|
183
|
+
const summary = JSON.stringify(incidents);
|
|
184
|
+
if (summary !== this.incidentSummary) {
|
|
185
|
+
if (incidents.length || this.incidentSummary !== undefined) {
|
|
186
|
+
this.log.info(`Incidents: ${incidents.length ? summary : 'none'}`);
|
|
187
|
+
}
|
|
188
|
+
this.incidentSummary = summary;
|
|
189
|
+
}
|
|
190
|
+
this.triggered = active.length > 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
exitDelay(value) {
|
|
194
|
+
const settings = this.settings?.armedStateSettings;
|
|
195
|
+
if (value === Hk.AWAY) {
|
|
196
|
+
return settings?.armedAway?.exitDelayDuration ?? 60;
|
|
197
|
+
}
|
|
198
|
+
if (value === Hk.STAY) {
|
|
199
|
+
return settings?.armedStay?.exitDelayDuration ?? 0;
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async setTarget(value) {
|
|
205
|
+
if (!TO_ECOBEE[value]) {
|
|
206
|
+
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
207
|
+
}
|
|
208
|
+
if (this.store.hold) {
|
|
209
|
+
this.log.info(`Home app changed the security system during the ${this.store.hold.by} hold; it won't be put back to Away.`);
|
|
210
|
+
this.dropHold();
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
await this.serial(() => this.arm(value, 'Home app'));
|
|
214
|
+
} catch (error) {
|
|
215
|
+
// Put the Home app's target back to what ecobee really has.
|
|
216
|
+
setTimeout(() => this.service.updateCharacteristic(this.C.SecuritySystemTargetState, this.target ?? Hk.DISARMED), 500);
|
|
217
|
+
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Run hold/release/Home app commands one at a time. */
|
|
222
|
+
serial(fn) {
|
|
223
|
+
const run = this.queue.then(fn, fn);
|
|
224
|
+
this.queue = run.catch(() => {});
|
|
225
|
+
return run;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async arm(value, source) {
|
|
229
|
+
const armedState = TO_ECOBEE[value];
|
|
230
|
+
const delay = this.exitDelay(value);
|
|
231
|
+
this.log.info(`${source}: ${value === Hk.DISARMED ? 'disarm' : `arm ${armedState === ArmedState.STAY ? 'Stay' : 'Away'}`}`
|
|
232
|
+
+ (delay ? ` (exit delay ${delay} s)` : ''));
|
|
233
|
+
try {
|
|
234
|
+
const monitoring = await this.cloud.setArmedState(this.homeId, armedState, delay);
|
|
235
|
+
this.commandAt = Date.now();
|
|
236
|
+
if (monitoring) {
|
|
237
|
+
this.apply({ monitoring });
|
|
238
|
+
}
|
|
239
|
+
this.schedule(2000);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
this.log.error(`Could not change the security system: ${error.message}`);
|
|
242
|
+
this.schedule(2000);
|
|
243
|
+
throw error;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
dropHold() {
|
|
248
|
+
delete this.store.hold;
|
|
249
|
+
this.save();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Another plugin (the robot vacuum) is about to move around the house. If the system is
|
|
254
|
+
* armed Away (or arming Away), switch it to Stay and remember to put it back. Returns
|
|
255
|
+
* `{ ok, held, state }`: `ok` false means it isn't safe to start (alarm triggered, or the
|
|
256
|
+
* state isn't known yet).
|
|
257
|
+
*/
|
|
258
|
+
hold(by) {
|
|
259
|
+
return this.serial(async () => {
|
|
260
|
+
if (!this.monitoring || this.failures >= FAULT_AFTER) {
|
|
261
|
+
return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
|
|
262
|
+
}
|
|
263
|
+
if (this.current === Hk.TRIGGERED) {
|
|
264
|
+
return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'the alarm is triggered' };
|
|
265
|
+
}
|
|
266
|
+
if (this.store.hold) {
|
|
267
|
+
return { ok: true, held: true, state: HK_NAMES[this.target] };
|
|
268
|
+
}
|
|
269
|
+
if (this.target !== Hk.AWAY) {
|
|
270
|
+
return { ok: true, held: false, state: HK_NAMES[this.target] };
|
|
271
|
+
}
|
|
272
|
+
await this.arm(Hk.STAY, `${by} hold`);
|
|
273
|
+
this.store.hold = { by, since: new Date().toISOString() };
|
|
274
|
+
this.save();
|
|
275
|
+
return { ok: true, held: true, state: HK_NAMES[this.target] };
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The vacuum is back: put the system back to Away if we moved it and nobody has changed it since. */
|
|
280
|
+
release(by) {
|
|
281
|
+
return this.serial(async () => {
|
|
282
|
+
if (!this.store.hold) {
|
|
283
|
+
return { released: false, state: HK_NAMES[this.target] };
|
|
284
|
+
}
|
|
285
|
+
if (this.target !== Hk.STAY || this.current === Hk.TRIGGERED) {
|
|
286
|
+
this.log.info(`${by} is done, but the security system is ${HK_NAMES[this.current]}; leaving it.`);
|
|
287
|
+
this.dropHold();
|
|
288
|
+
return { released: false, state: HK_NAMES[this.target] };
|
|
289
|
+
}
|
|
290
|
+
// Drop the hold first so apply() doesn't take our own Away for someone else's change.
|
|
291
|
+
const hold = this.store.hold;
|
|
292
|
+
this.dropHold();
|
|
293
|
+
try {
|
|
294
|
+
await this.arm(Hk.AWAY, `${by} done`);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
this.store.hold = hold;
|
|
297
|
+
this.save();
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
return { released: true, state: HK_NAMES[this.target] };
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ecobee-security",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"displayName": "ecobee Smart Security",
|
|
4
|
+
"version": "0.3.0",
|
|
5
|
+
"description": "ecobee Smart Security as a HomeKit security system: arm Stay / Away, disarm, alarm state (unofficial, ecobee cloud)",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Sean Fletcher",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/seanfletchr/homebridge-ecobee-security.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/seanfletchr/homebridge-ecobee-security/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/seanfletchr/homebridge-ecobee-security#readme",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"homebridge-plugin",
|
|
20
|
+
"homebridge",
|
|
21
|
+
"homekit",
|
|
22
|
+
"ecobee",
|
|
23
|
+
"smart security",
|
|
24
|
+
"security system",
|
|
25
|
+
"alarm"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": "^22.0.0 || ^24.0.0",
|
|
29
|
+
"homebridge": "^1.8.0 || ^2.0.0"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"index.js",
|
|
33
|
+
"lib",
|
|
34
|
+
"bin",
|
|
35
|
+
"homebridge-ui",
|
|
36
|
+
"config.schema.json",
|
|
37
|
+
"README.md",
|
|
38
|
+
"CHANGELOG.md",
|
|
39
|
+
"LICENSE"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@homebridge/plugin-ui-utils": "^2.2.6"
|
|
43
|
+
}
|
|
44
|
+
}
|