homebridge-ecobee-security 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +36 -20
- package/bin/ecobee-login.js +50 -36
- package/config.schema.json +54 -4
- package/homebridge-ui/public/index.html +182 -0
- package/homebridge-ui/server.js +66 -0
- package/lib/airquality.js +186 -0
- package/lib/auth.js +121 -0
- package/lib/ecobee.js +40 -0
- package/lib/platform.js +61 -1
- package/lib/security.js +138 -24
- package/lib/version.js +4 -0
- package/package.json +6 -2
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Air quality from ecobee thermostats with the sensor (ecobee Premium): one accessory per thermostat
|
|
2
|
+
// with an Air Quality Sensor (overall level, VOC) and a Carbon Dioxide Sensor (ppm, high-CO2 alert).
|
|
3
|
+
// ecobee's own HomeKit integration doesn't expose these readings.
|
|
4
|
+
import { SignInRequiredError } from './ecobee.js';
|
|
5
|
+
|
|
6
|
+
const POLL = 5 * 60 * 1000; // ecobee updates thermostat runtime every few minutes
|
|
7
|
+
const FAULT_AFTER = 3;
|
|
8
|
+
|
|
9
|
+
export class AirQualitySensors {
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} options
|
|
12
|
+
* @param {import('homebridge').API} options.api
|
|
13
|
+
* @param {import('homebridge').Logging} options.log
|
|
14
|
+
* @param {import('./ecobee.js').EcobeeCloud} options.cloud
|
|
15
|
+
* @param {string} options.homeId
|
|
16
|
+
* @param {import('homebridge').PlatformAccessory[]} options.cached accessories restored by Homebridge
|
|
17
|
+
* @param {(uuid: string, name: string) => import('homebridge').PlatformAccessory} options.create new, unregistered accessory
|
|
18
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.register
|
|
19
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.update
|
|
20
|
+
* @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.unregister
|
|
21
|
+
* @param {number} options.co2Alert ppm at or above which the CO2 sensor reports abnormal levels
|
|
22
|
+
* @param {string} options.version plugin version, shown as firmware
|
|
23
|
+
*/
|
|
24
|
+
constructor({ api, log, cloud, homeId, cached, create, register, update, unregister, co2Alert, version }) {
|
|
25
|
+
Object.assign(this, { api, log, cloud, homeId, cached, create, register, update, unregister, co2Alert, version });
|
|
26
|
+
this.C = api.hap.Characteristic;
|
|
27
|
+
this.sensors = new Map(); // thermostat id -> { accessory, air, co2 }
|
|
28
|
+
this.failures = 0;
|
|
29
|
+
this.stopped = false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
uuid(thermostatId) {
|
|
33
|
+
return this.api.hap.uuid.generate(`ecobee-security:air-quality:${thermostatId}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** First read: create, keep or remove accessories to match the thermostats with air quality. */
|
|
37
|
+
async start() {
|
|
38
|
+
const thermostats = (await this.cloud.getAirQuality(this.homeId))
|
|
39
|
+
.filter(t => t.airQuality?.hasAirQualityEnabled);
|
|
40
|
+
const wanted = new Set(thermostats.map(t => this.uuid(t.id)));
|
|
41
|
+
const stale = this.cached.filter(a => !wanted.has(a.UUID));
|
|
42
|
+
if (stale.length) {
|
|
43
|
+
this.unregister(stale);
|
|
44
|
+
}
|
|
45
|
+
// Build each accessory completely (services and first readings) before it's published.
|
|
46
|
+
const added = [];
|
|
47
|
+
const kept = [];
|
|
48
|
+
for (const t of thermostats) {
|
|
49
|
+
const uuid = this.uuid(t.id);
|
|
50
|
+
const cached = this.cached.find(a => a.UUID === uuid);
|
|
51
|
+
const accessory = cached ?? this.create(uuid, `${t.name} Air Quality`);
|
|
52
|
+
(cached ? kept : added).push(accessory);
|
|
53
|
+
this.sensors.set(t.id, this.setUp(accessory, t));
|
|
54
|
+
}
|
|
55
|
+
this.apply(thermostats);
|
|
56
|
+
if (added.length) {
|
|
57
|
+
this.register(added);
|
|
58
|
+
}
|
|
59
|
+
if (kept.length) {
|
|
60
|
+
this.update(kept);
|
|
61
|
+
}
|
|
62
|
+
for (const t of thermostats) {
|
|
63
|
+
this.log.info(`Air quality, ${t.name}: ${describe(t)}.`);
|
|
64
|
+
}
|
|
65
|
+
this.schedule(POLL);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
stop() {
|
|
69
|
+
this.stopped = true;
|
|
70
|
+
clearTimeout(this.timer);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
schedule(delay) {
|
|
74
|
+
if (!this.stopped && this.sensors.size) {
|
|
75
|
+
clearTimeout(this.timer);
|
|
76
|
+
this.timer = setTimeout(() => this.poll(), delay);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
setUp(accessory, thermostat) {
|
|
81
|
+
const { Service } = this.api.hap;
|
|
82
|
+
const C = this.C;
|
|
83
|
+
accessory.getService(Service.AccessoryInformation)
|
|
84
|
+
.setCharacteristic(C.Manufacturer, 'ecobee')
|
|
85
|
+
.setCharacteristic(C.Model, 'Thermostat air quality')
|
|
86
|
+
.setCharacteristic(C.SerialNumber, thermostat.id)
|
|
87
|
+
.setCharacteristic(C.FirmwareRevision, this.version);
|
|
88
|
+
const air = accessory.getService(Service.AirQualitySensor)
|
|
89
|
+
?? accessory.addService(Service.AirQualitySensor, `${thermostat.name} Air Quality`);
|
|
90
|
+
// ecobee reports VOC in ppb, often above HomeKit's default 1000 limit.
|
|
91
|
+
air.getCharacteristic(C.VOCDensity).setProps({ maxValue: 10000 });
|
|
92
|
+
const co2 = accessory.getService(Service.CarbonDioxideSensor)
|
|
93
|
+
?? accessory.addService(Service.CarbonDioxideSensor, `${thermostat.name} CO2`);
|
|
94
|
+
co2.getCharacteristic(C.CarbonDioxideLevel);
|
|
95
|
+
for (const service of [air, co2]) {
|
|
96
|
+
if (!service.testCharacteristic(C.StatusFault)) {
|
|
97
|
+
service.addCharacteristic(C.StatusFault);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { accessory, air, co2, name: thermostat.name };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async poll() {
|
|
104
|
+
try {
|
|
105
|
+
this.apply(await this.cloud.getAirQuality(this.homeId));
|
|
106
|
+
if (this.failures >= FAULT_AFTER) {
|
|
107
|
+
this.log.info('Air quality readings are back.');
|
|
108
|
+
}
|
|
109
|
+
this.failures = 0;
|
|
110
|
+
this.setFault(false);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
this.failures += 1;
|
|
113
|
+
if (this.failures === FAULT_AFTER) {
|
|
114
|
+
this.setFault(true);
|
|
115
|
+
}
|
|
116
|
+
if (!(error instanceof SignInRequiredError)) {
|
|
117
|
+
this.log[this.failures >= FAULT_AFTER ? 'warn' : 'debug'](`Could not read air quality (${error.message}).`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.schedule(POLL);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
setFault(on) {
|
|
124
|
+
const { StatusFault } = this.C;
|
|
125
|
+
for (const { air, co2 } of this.sensors.values()) {
|
|
126
|
+
air.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
|
|
127
|
+
co2.updateCharacteristic(StatusFault, on ? StatusFault.GENERAL_FAULT : StatusFault.NO_FAULT);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
apply(thermostats) {
|
|
132
|
+
const { AirQuality, VOCDensity, CarbonDioxideLevel, CarbonDioxideDetected } = this.C;
|
|
133
|
+
for (const t of thermostats) {
|
|
134
|
+
const sensor = this.sensors.get(t.id);
|
|
135
|
+
if (!sensor) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const aq = t.airQuality ?? {};
|
|
139
|
+
const runtime = t.runtime ?? {};
|
|
140
|
+
sensor.air.updateCharacteristic(AirQuality, aq.isCalibrating ? AirQuality.UNKNOWN : level(aq.airQualityScore, AirQuality));
|
|
141
|
+
if (Number.isFinite(runtime.actualVOC) && runtime.actualVOC >= 0) {
|
|
142
|
+
sensor.air.updateCharacteristic(VOCDensity, Math.min(runtime.actualVOC, 10000));
|
|
143
|
+
}
|
|
144
|
+
if (Number.isFinite(runtime.actualCO2) && runtime.actualCO2 >= 0) {
|
|
145
|
+
sensor.co2.updateCharacteristic(CarbonDioxideLevel, Math.min(runtime.actualCO2, 100000));
|
|
146
|
+
sensor.co2.updateCharacteristic(CarbonDioxideDetected, runtime.actualCO2 >= this.co2Alert
|
|
147
|
+
? CarbonDioxideDetected.CO2_LEVELS_ABNORMAL : CarbonDioxideDetected.CO2_LEVELS_NORMAL);
|
|
148
|
+
}
|
|
149
|
+
this.log.debug(`Air quality, ${sensor.name}: ${describe(t)}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const LEVEL_NAMES = ['unknown', 'excellent', 'good', 'fair', 'inferior', 'poor'];
|
|
155
|
+
|
|
156
|
+
function describe(t) {
|
|
157
|
+
const aq = t.airQuality ?? {};
|
|
158
|
+
const r = t.runtime ?? {};
|
|
159
|
+
const name = aq.isCalibrating ? 'calibrating' : LEVEL_NAMES[level(aq.airQualityScore, { UNKNOWN: 0, EXCELLENT: 1, GOOD: 2, FAIR: 3, INFERIOR: 4, POOR: 5 })];
|
|
160
|
+
return `${name} (score ${Number.isFinite(aq.airQualityScore) ? aq.airQualityScore.toFixed(2) : '?'}), `
|
|
161
|
+
+ `CO2 ${r.actualCO2 ?? '?'} ppm, VOC ${r.actualVOC ?? '?'} ppb`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* ecobee's 0 (clean) … 1 (poor) score onto HomeKit's five levels, following the eight segments of
|
|
166
|
+
* the ecobee app's bar: 1–2 Excellent (the app calls 0.21 "Clean"), 3–4 Good, 5 Fair, 6 Inferior,
|
|
167
|
+
* 7–8 Poor.
|
|
168
|
+
*/
|
|
169
|
+
export function level(score, AirQuality) {
|
|
170
|
+
if (!Number.isFinite(score)) {
|
|
171
|
+
return AirQuality.UNKNOWN;
|
|
172
|
+
}
|
|
173
|
+
if (score < 0.25) {
|
|
174
|
+
return AirQuality.EXCELLENT;
|
|
175
|
+
}
|
|
176
|
+
if (score < 0.5) {
|
|
177
|
+
return AirQuality.GOOD;
|
|
178
|
+
}
|
|
179
|
+
if (score < 0.625) {
|
|
180
|
+
return AirQuality.FAIR;
|
|
181
|
+
}
|
|
182
|
+
if (score < 0.75) {
|
|
183
|
+
return AirQuality.INFERIOR;
|
|
184
|
+
}
|
|
185
|
+
return AirQuality.POOR;
|
|
186
|
+
}
|
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/ecobee.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
export const AUTH = 'https://auth.ecobee.com';
|
|
4
4
|
export const GRAPHQL = 'https://federated-api.prod.federated-graph.generacapis.com/graphql';
|
|
5
|
+
// ecobee's older REST API; the app still reads thermostat runtime (CO2 ppm, VOC) from it.
|
|
6
|
+
export const REST = 'https://api.ecobee.com/1';
|
|
5
7
|
export const CLIENT_ID = 'CuGkX8Ai7OWD2V1F32rFa7UJGRVRsnbv';
|
|
6
8
|
export const REDIRECT_URI = 'com.ecobee.athenamobile://auth.ecobee.com/ios/com.ecobee.athenamobile/callback';
|
|
7
9
|
export const AUDIENCE = 'https://prod.ecobee.com/api/v1';
|
|
@@ -24,6 +26,9 @@ const STATE_QUERY = `query HomesSecurity { homes { id name capabilities monitori
|
|
|
24
26
|
+ 'homeMonitoringSettings { isProfessionalMonitoringEnabled armedStateSettings { '
|
|
25
27
|
+ 'armedAway { entryDelayDuration exitDelayDuration } armedStay { entryDelayDuration exitDelayDuration } } } } } }';
|
|
26
28
|
|
|
29
|
+
const AIR_QUALITY_QUERY = 'query ThermostatsAirQuality($homeID: ID!) { homes(id: $homeID) { id devices { id '
|
|
30
|
+
+ 'thermostats { id name airQuality { hasAirQualityEnabled carbonDioxideScore vocScore airQualityScore isCalibrating } } } } }';
|
|
31
|
+
|
|
27
32
|
const SET_MUTATION = 'mutation SetArmedStateForHome($input: SetArmedStateForHomeInput!) { '
|
|
28
33
|
+ `setArmedStateForHome(input: $input) { home { id monitoring { ${MONITORING_FIELDS} } } success } }`;
|
|
29
34
|
|
|
@@ -119,6 +124,41 @@ export class EcobeeCloud {
|
|
|
119
124
|
return data.homes ?? [];
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The home's thermostats with ecobee's air quality scores (0 = clean … 1 = poor), plus the
|
|
129
|
+
* runtime readings (`actualCO2` ppm, `actualVOC` ppb, `actualAQScore`) from the REST API.
|
|
130
|
+
*/
|
|
131
|
+
async getAirQuality(homeId) {
|
|
132
|
+
const data = await this.graphql('ThermostatsAirQuality', AIR_QUALITY_QUERY, { homeID: homeId });
|
|
133
|
+
const thermostats = data.homes?.[0]?.devices?.thermostats ?? [];
|
|
134
|
+
if (!thermostats.length) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
const json = await this.rest('/thermostat', {
|
|
138
|
+
selectionType: 'thermostats', selectionMatch: thermostats.map(t => t.id).join(','), includeRuntime: true,
|
|
139
|
+
});
|
|
140
|
+
const runtime = new Map((json.thermostatList ?? []).map(t => [t.identifier, t.runtime ?? {}]));
|
|
141
|
+
return thermostats.map(t => ({ ...t, runtime: runtime.get(t.id) ?? {} }));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async rest(path, selection, retry = true) {
|
|
145
|
+
const token = await this.token();
|
|
146
|
+
const url = `${REST}${path}?json=${encodeURIComponent(JSON.stringify({ selection }))}`;
|
|
147
|
+
const res = await fetch(url, {
|
|
148
|
+
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json;charset=UTF-8', 'user-agent': USER_AGENT },
|
|
149
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
150
|
+
});
|
|
151
|
+
if (res.status === 401 && retry) {
|
|
152
|
+
this.accessToken = undefined;
|
|
153
|
+
return this.rest(path, selection, false);
|
|
154
|
+
}
|
|
155
|
+
const json = await res.json().catch(() => ({}));
|
|
156
|
+
if (!res.ok || (json.status?.code ?? 0) !== 0) {
|
|
157
|
+
throw new Error(`${path}: HTTP ${res.status} ${json.status?.message ?? ''}`.trim());
|
|
158
|
+
}
|
|
159
|
+
return json;
|
|
160
|
+
}
|
|
161
|
+
|
|
122
162
|
/**
|
|
123
163
|
* @param {string} homeId
|
|
124
164
|
* @param {string} armedState one of ArmedState
|
package/lib/platform.js
CHANGED
|
@@ -3,12 +3,17 @@ import { join } from 'node:path';
|
|
|
3
3
|
|
|
4
4
|
import { EcobeeCloud, SignInRequiredError } from './ecobee.js';
|
|
5
5
|
import { startControlServer } from './control.js';
|
|
6
|
+
import { AirQualitySensors } from './airquality.js';
|
|
6
7
|
import { SecuritySystem } from './security.js';
|
|
8
|
+
import { VERSION } from './version.js';
|
|
7
9
|
|
|
8
10
|
export const PLUGIN_NAME = 'homebridge-ecobee-security';
|
|
9
11
|
export const PLATFORM_NAME = 'EcobeeSecurityPlatform';
|
|
10
12
|
|
|
11
13
|
const RETRY_START = 60 * 1000;
|
|
14
|
+
const AIR_QUALITY_RETRY = 10 * 60 * 1000;
|
|
15
|
+
const AIR_QUALITY = 'airQuality';
|
|
16
|
+
const SMOKE = 'smoke';
|
|
12
17
|
|
|
13
18
|
export class EcobeeSecurityPlatform {
|
|
14
19
|
/**
|
|
@@ -56,8 +61,10 @@ export class EcobeeSecurityPlatform {
|
|
|
56
61
|
});
|
|
57
62
|
api.on('shutdown', () => {
|
|
58
63
|
clearTimeout(this.retryTimer);
|
|
64
|
+
clearTimeout(this.airQualityRetry);
|
|
59
65
|
this.control?.close();
|
|
60
66
|
this.system?.stop();
|
|
67
|
+
this.airQuality?.stop();
|
|
61
68
|
});
|
|
62
69
|
}
|
|
63
70
|
|
|
@@ -115,7 +122,9 @@ export class EcobeeSecurityPlatform {
|
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
const uuid = this.api.hap.uuid.generate(`ecobee-security:${home.id}`);
|
|
118
|
-
const
|
|
125
|
+
const smokeUuid = this.api.hap.uuid.generate(`ecobee-security:smoke:${home.id}`);
|
|
126
|
+
const wantSmoke = this.config.smokeSensor !== false;
|
|
127
|
+
const stale = this.cached.filter(a => a.UUID !== uuid && a.context.kind !== AIR_QUALITY && !(wantSmoke && a.UUID === smokeUuid));
|
|
119
128
|
if (stale.length) {
|
|
120
129
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
121
130
|
}
|
|
@@ -124,6 +133,12 @@ export class EcobeeSecurityPlatform {
|
|
|
124
133
|
if (isNew) {
|
|
125
134
|
accessory = new this.api.platformAccessory(this.config.name || 'Security System', uuid);
|
|
126
135
|
}
|
|
136
|
+
let smokeAccessory = wantSmoke ? this.cached.find(a => a.UUID === smokeUuid) : undefined;
|
|
137
|
+
const smokeIsNew = wantSmoke && !smokeAccessory;
|
|
138
|
+
if (smokeIsNew) {
|
|
139
|
+
smokeAccessory = new this.api.platformAccessory('Smoke Alarm', smokeUuid);
|
|
140
|
+
smokeAccessory.context.kind = SMOKE;
|
|
141
|
+
}
|
|
127
142
|
|
|
128
143
|
this.system = new SecuritySystem({
|
|
129
144
|
api: this.api,
|
|
@@ -134,12 +149,20 @@ export class EcobeeSecurityPlatform {
|
|
|
134
149
|
pollInterval: Math.max(5, Number(this.config.pollInterval) || 10),
|
|
135
150
|
store: this.store,
|
|
136
151
|
save: () => this.save(),
|
|
152
|
+
offerNight: this.config.offerNight === true,
|
|
153
|
+
alarmAfterEntryDelay: this.config.alarmTrigger === 'entryDelay',
|
|
154
|
+
smokeAccessory,
|
|
137
155
|
});
|
|
138
156
|
if (isNew) {
|
|
139
157
|
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
140
158
|
} else {
|
|
141
159
|
this.api.updatePlatformAccessories([accessory]);
|
|
142
160
|
}
|
|
161
|
+
if (smokeIsNew) {
|
|
162
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [smokeAccessory]);
|
|
163
|
+
} else if (smokeAccessory) {
|
|
164
|
+
this.api.updatePlatformAccessories([smokeAccessory]);
|
|
165
|
+
}
|
|
143
166
|
this.system.start();
|
|
144
167
|
const m = home.monitoring;
|
|
145
168
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
@@ -147,5 +170,42 @@ export class EcobeeSecurityPlatform {
|
|
|
147
170
|
if (this.store.hold) {
|
|
148
171
|
this.log.info(`Still holding Stay for ${this.store.hold.by} (since ${this.store.hold.since}).`);
|
|
149
172
|
}
|
|
173
|
+
this.startAirQuality(home.id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Air quality sensors for thermostats that have them; failures here never affect the security system. */
|
|
177
|
+
async startAirQuality(homeId) {
|
|
178
|
+
const cached = this.cached.filter(a => a.context.kind === AIR_QUALITY);
|
|
179
|
+
if (this.config.airQuality === false) {
|
|
180
|
+
if (cached.length) {
|
|
181
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, cached);
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const sensors = new AirQualitySensors({
|
|
186
|
+
api: this.api,
|
|
187
|
+
log: this.log,
|
|
188
|
+
cloud: this.cloud,
|
|
189
|
+
homeId,
|
|
190
|
+
cached,
|
|
191
|
+
co2Alert: Math.max(400, Number(this.config.co2Alert) || 1000),
|
|
192
|
+
version: VERSION,
|
|
193
|
+
create: (uuid, name) => {
|
|
194
|
+
const accessory = new this.api.platformAccessory(name, uuid);
|
|
195
|
+
accessory.context.kind = AIR_QUALITY;
|
|
196
|
+
return accessory;
|
|
197
|
+
},
|
|
198
|
+
register: accessories => this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories),
|
|
199
|
+
update: accessories => this.api.updatePlatformAccessories(accessories),
|
|
200
|
+
unregister: accessories => this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories),
|
|
201
|
+
});
|
|
202
|
+
try {
|
|
203
|
+
await sensors.start();
|
|
204
|
+
this.airQuality = sensors;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
sensors.stop();
|
|
207
|
+
this.log.warn(`Could not set up air quality (${error.message}); trying again in 10 minutes.`);
|
|
208
|
+
this.airQualityRetry = setTimeout(() => this.startAirQuality(homeId), AIR_QUALITY_RETRY);
|
|
209
|
+
}
|
|
150
210
|
}
|
|
151
211
|
}
|