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.
@@ -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 stale = this.cached.filter(a => a.UUID !== uuid);
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
  }