homebridge-ecobee-security 0.0.0-stage → 0.2.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 ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - Control socket (`control.sock`) so other plugins can hold the system at Stay and release it to
6
+ Away afterwards (used by homebridge-narwal while the robot vacuum cleans).
7
+
8
+ ## 0.1.0
9
+
10
+ - 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,89 @@
1
- # Temporary Holding Version
1
+ # homebridge-ecobee-security
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ 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
+
33
+ ```
34
+ node <plugin folder>/bin/ecobee-login.js
35
+ ```
36
+
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.
42
+
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.
45
+
46
+ ## Config
47
+
48
+ ```json
49
+ {
50
+ "platform": "EcobeeSecurityPlatform",
51
+ "name": "Security System",
52
+ "refreshToken": "…",
53
+ "pollInterval": 10,
54
+ "_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
55
+ }
56
+ ```
57
+
58
+ `homeId` is only needed if the account has several homes (the log lists them).
59
+
60
+ ## How it behaves
61
+
62
+ - **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
63
+ delay the Home app shows "Arming…" (current Disarmed, target Away). Stay uses the Stay exit delay
64
+ (0 by default). Disarm needs no PIN.
65
+ - **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within the poll
66
+ 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.
74
+ - After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
75
+ - **Hold for other plugins:** a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
76
+ (mode 600, homebridge user only) lets another plugin, e.g. homebridge-narwal, ask for a temporary
77
+ Stay: `POST /hold?by=<name>` switches Away (or arming Away) to Stay and remembers it;
78
+ `POST /release?by=<name>` arms Away again — unless the state was changed by anyone in between, in
79
+ which case the hold is just dropped. The hold survives restarts (in `state.json`). `/hold`
80
+ answers `ok: false` when the alarm is triggered or the state is unknown.
81
+
82
+ ## API notes
83
+
84
+ [docs/api.md](docs/api.md) describes the sign-in flow and GraphQL calls the plugin uses, for anyone
85
+ fixing it after an ecobee change or adding features.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,65 @@
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';
6
+ import readline from 'node:readline';
7
+
8
+ import { AUDIENCE, AUTH, CLIENT_ID, REDIRECT_URI, SCOPE, tokenRequest } from '../lib/ecobee.js';
9
+
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
+ });
18
+ }
19
+
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));
29
+
30
+ console.log(`
31
+ 1. In Chrome, open a new tab, press Option-Command-I, go to Network and tick "Preserve log".
32
+ 2. Open this link in that tab and sign in (email, password, CAPTCHA, MFA code):
33
+
34
+ ${url.href}
35
+
36
+ 3. When the page goes blank, type code= in the Network filter, right-click the entry and
37
+ Copy > Copy URL (or: the last "resume" request > Headers > Response Headers > location).
38
+ It starts with com.ecobee.athenamobile://
39
+ 4. Paste it below straight away; the code expires quickly.
40
+ `);
41
+
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
+ }
54
+ console.log(`
55
+ Signed in. Paste this refresh token into the plugin settings ("Refresh token"), save, and restart
56
+ the bridge. Treat it like a password: it gives full access to your ecobee account.
57
+
58
+ ${tokens.refresh_token}
59
+ `);
60
+ }
61
+
62
+ main().catch(error => {
63
+ console.error(`Sign-in failed: ${error.message}`);
64
+ process.exit(1);
65
+ });
@@ -0,0 +1,36 @@
1
+ {
2
+ "pluginAlias": "EcobeeSecurityPlatform",
3
+ "pluginType": "platform",
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).",
6
+ "schema": {
7
+ "type": "object",
8
+ "required": ["refreshToken"],
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": { "type": "password" },
20
+ "description": "From bin/ecobee-login.js. Only needed again if ecobee signs the plugin out (password change, 'sign out everywhere')."
21
+ },
22
+ "homeId": {
23
+ "title": "Home ID",
24
+ "type": "string",
25
+ "description": "Only needed if the account has more than one home; the log lists them."
26
+ },
27
+ "pollInterval": {
28
+ "title": "Check every (seconds)",
29
+ "type": "integer",
30
+ "default": 10,
31
+ "minimum": 5,
32
+ "description": "How often to read the armed state, so changes made in the ecobee app or at the keypad show up in Apple Home."
33
+ }
34
+ }
35
+ }
36
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ import { EcobeeSecurityPlatform, PLATFORM_NAME } from './lib/platform.js';
2
+
3
+ export default (api) => {
4
+ api.registerPlatform(PLATFORM_NAME, EcobeeSecurityPlatform);
5
+ };
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
+ }
@@ -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
+ }
@@ -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,40 @@
1
1
  {
2
2
  "name": "homebridge-ecobee-security",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "displayName": "ecobee Smart Security",
4
+ "version": "0.2.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
+ "config.schema.json",
36
+ "README.md",
37
+ "CHANGELOG.md",
38
+ "LICENSE"
39
+ ]
40
+ }