homebridge-ecobee-security 1.0.0 → 1.0.1

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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1
4
+
5
+ - **Comfort Setting** is now one TV-style tile with an input per comfort setting, instead of a switch per
6
+ setting. Picking an input holds that setting, switching the tile off goes back to your schedule, and the
7
+ tile shows the scheduled setting while off. It is its own HomeKit accessory, added to the Home app with
8
+ the bridge's setup code, with a streaming-device icon; settings can be hidden in the Home app's input list
9
+ (remembered) and follow ecobee's order. Breaking: the old switches are removed, so automations built on them need
10
+ rebuilding with the tile (Home app scenes can set the input).
11
+ - README: new Automation ideas section, and updated header and devices images.
12
+
3
13
  ## 1.0.0
4
14
 
5
15
  - New setting **Quick Responses** (`quickResponses`, `quickResponseMessages`) in the Doorbell camera card:
package/README.md CHANGED
@@ -54,6 +54,7 @@ GraphQL at `federated-api.prod.federated-graph.generacapis.com`), which ecobee c
54
54
  - [Home Occupied switch](#home-occupied-switch)
55
55
  - [Vacation Mode switch](#vacation-mode-switch)
56
56
  - [Smoke alarm and air quality](#smoke-alarm-and-air-quality)
57
+ - [Automation ideas](#automation-ideas)
57
58
  - [Troubleshooting](#troubleshooting)
58
59
  - [API notes](#api-notes)
59
60
  - [License](#license)
@@ -171,7 +172,7 @@ Sign-in: see [Sign-in](#sign-in).
171
172
  | `vacationSwitch` | `false` | Adds a **Vacation Mode** switch that is on while an ecobee vacation is running (see [Vacation Mode switch](#vacation-mode-switch)). |
172
173
  | `vacationWhenEmpty` | `false` | Starts a vacation when Home Occupied has been off for `vacationEmptyHours` (default 24; see [Vacations that follow Home Occupied](#vacations-that-follow-home-occupied)). |
173
174
  | `vacationEndOnArrival` | `false` | Ends running vacations when Home Occupied turns on. |
174
- | `comfortSetting` | `false` | Adds a **Comfort Setting** accessory with a switch per ecobee comfort setting (see [Comfort Setting](#comfort-setting)). |
175
+ | `comfortSetting` | `false` | Adds a **Comfort Setting** accessory, a TV-style tile with an input per ecobee comfort setting (see [Comfort Setting](#comfort-setting)). |
175
176
  | `comfortHoldType` | `"nextTransition"` | How long a comfort setting turned on is held: `"nextTransition"` or `"indefinite"`. |
176
177
  | `smokeSensor` | `true` | Adds a separate **Smoke Alarm** accessory (a Smoke Sensor) that detects smoke while ecobee has a smoke-alarm incident open, for automations and Home app smoke notifications. Set to `false` to remove it. |
177
178
  | `airQuality` | `true` | For each thermostat with an air quality sensor: an accessory "<thermostat> Air Quality" with an **Air Quality Sensor** (overall level; VOC as ecobee reports it, in ppb, which the Home app labels µg/m³) and a **Carbon Dioxide Sensor** (ppm). Read every 5 minutes. The level follows the ecobee app's Clean–Poor bar: its first two segments are Excellent, then Good, Fair, Inferior, Poor; Unknown while the sensor calibrates. |
@@ -559,30 +560,48 @@ they forget, the Home app's security notification shows the disarm, and you can
559
560
 
560
561
  ## Comfort Setting
561
562
 
562
- Turn on **Add Comfort Setting switches** (`comfortSetting`) and the plugin adds one accessory,
563
- **Comfort Setting**, with a switch for each of your ecobee comfort settings (Home, Away, Sleep and your own,
564
- read from the thermostats' program; with several thermostats, a setting any of them has). It is for
565
- automations such as "Home Occupied off → Away on".
566
-
567
- - Turning a switch **on** holds that comfort setting on the thermostats that have it, and the others
568
- turn off. How long: `comfortHoldType`, `"nextTransition"` (default, until the next scheduled change)
569
- or `"indefinite"` (until you change it).
570
- - Turning the active switch **off** goes back to your schedule, so all off means following the schedule.
571
- - A hold set in the ecobee app or on the thermostat lights the matching switch (read every minute). A
572
- temperature-only hold lights none.
573
- - During an ecobee vacation all switches are off and changes are refused (the log says so).
574
- - Like the other sensors, the switches show No Response while ecobee can't be reached. Restart the
575
- bridge after changing the setting.
576
-
577
- > [!NOTE]
578
- > The Home app shows the switches as one tile, and tapping the tile toggles all of them. Open the tile
579
- > and pick one, or use **Show as Separate Tiles** in the accessory's settings.
563
+ Turn on **Add Comfort Setting** (`comfortSetting`) and the plugin adds one accessory, **Comfort Setting**: a
564
+ single tile in the Home app (a HomeKit television) with an input for each of your ecobee comfort settings
565
+ (Home, Away, Sleep and your own, read from the thermostats' program; with several thermostats, a setting
566
+ any of them has).
567
+
568
+ - Picking a setting **holds** it on the thermostats that have it. How long: `comfortHoldType`,
569
+ `"nextTransition"` (default, until the next scheduled change) or `"indefinite"` (until you change it).
570
+ - Switching the tile **off** goes back to your schedule. The tile is on while a comfort hold runs; while
571
+ the thermostats follow their schedule it is off and shows the setting the schedule is on now.
572
+ Switching it on holds the setting it shows.
573
+ - A hold set in the ecobee app or on the thermostat shows on the tile (read every minute). A
574
+ temperature-only hold counts as following the schedule.
575
+ - During an ecobee vacation the tile is off and changes are refused (the log says so).
576
+ - Like the other sensors, the tile shows No Response while ecobee can't be reached.
577
+ - HomeKit allows one television per bridge, so the tile is its own accessory: add it to the Home app
578
+ with the bridge's setup code (the log says so when it is published, once ecobee has been read).
579
+ Restart the bridge after changing the setting.
580
+ - Home app scenes and automations can pick a comfort setting: add the tile and choose the setting
581
+ as its input.
582
+ - The Home app can't start an automation when the comfort setting changes (TV tiles aren't offered as
583
+ triggers).
584
+ - The tile appears with a streaming-device icon. In the Home app's
585
+ input list you can hide settings you don't use (remembered); the order follows the ecobee app.
586
+ - It replaces the one-switch-per-setting accessory of earlier versions, which is removed from the Home
587
+ app at the next restart; automations built on those switches have to be rebuilt with the tile.
580
588
 
581
589
  ## Smoke alarm and air quality
582
590
 
583
591
  - **Smoke Alarm** (`smokeSensor`, on by default) is its own accessory, a Smoke Sensor that detects smoke while ecobee has a smoke-alarm incident open. It is separate from the security tile (the Home app would otherwise merge it into the tile with a cloud icon).
584
592
  - **Air quality** (`airQuality`, on by default) adds, for each thermostat with an air quality sensor (ecobee Premium), an Air Quality Sensor and a Carbon Dioxide Sensor, read every 5 minutes; `co2Alert` sets the CO2 level (ppm) that reports abnormal levels. Details are in the settings table above. Temperature and humidity come from ecobee's own HomeKit integration.
585
593
 
594
+ ## Automation ideas
595
+
596
+ A few Home app automations built on this plugin's accessories:
597
+
598
+ | When | Do |
599
+ |---|---|
600
+ | **Vacation Mode** turns on | Close the blinds |
601
+ | **Doorbell** rings | Pause the Apple TV |
602
+ | 6:00 AM every day, if someone is home | Set the **security system** to Off |
603
+ | **Doorbell** detects motion, after sunset | Turn on the porch and entryway lights |
604
+
586
605
  ## Troubleshooting
587
606
 
588
607
  - **No Response** in the Home app: the security tile, Smoke Alarm and air quality sensors show it
@@ -188,10 +188,10 @@
188
188
  }
189
189
  },
190
190
  "comfortSetting": {
191
- "title": "Add Comfort Setting switches",
191
+ "title": "Add Comfort Setting",
192
192
  "type": "boolean",
193
193
  "default": false,
194
- "description": "One switch per ecobee comfort setting (Home, Away, Sleep and your own). Turning one on holds it; turning it off goes back to your schedule. Restart the bridge after changing this."
194
+ "description": "One tile in the Home app with a picker for your ecobee comfort settings (Home, Away, Sleep and your own). Picking one holds it; switching the tile off goes back to your schedule. It is added to the Home app on its own with the bridge's setup code. Restart the bridge after changing this."
195
195
  },
196
196
  "comfortHoldType": {
197
197
  "title": "Comfort setting hold",
@@ -279,9 +279,9 @@
279
279
  <h6 class="mb-1">Comfort Setting</h6>
280
280
  <div class="form-check form-switch mb-1">
281
281
  <input class="form-check-input" type="checkbox" role="switch" id="s-comfortSetting" data-key="comfortSetting" data-type="bool" data-default="false">
282
- <label class="form-check-label" for="s-comfortSetting">Add Comfort Setting switches</label>
282
+ <label class="form-check-label" for="s-comfortSetting">Add Comfort Setting</label>
283
283
  </div>
284
- <p class="hint mb-2">One switch per ecobee comfort setting (Home, Away, Sleep and your own). Turning one on holds it; turning it off goes back to your schedule. Restart the bridge after changing this.
284
+ <p class="hint mb-2">One tile with a picker for your ecobee comfort settings (Home, Away, Sleep and your own). Picking one holds it; switching the tile off goes back to your schedule. Added to the Home app on its own with the bridge's setup code, and it replaces the earlier switches (automations on them need rebuilding). Restart the bridge after changing this.
285
285
  <a href="https://github.com/seanfletchr/homebridge-ecobee-security#comfort-setting" target="_blank" rel="noopener">Learn more…</a></p>
286
286
  <div id="comfort-box" hidden>
287
287
  <label for="s-comfortHoldType" class="form-label mb-1">Hold</label>
package/lib/comfort.js CHANGED
@@ -1,12 +1,34 @@
1
- // "Comfort Setting" accessory: one switch per ecobee comfort setting (climate: Home, Away, Sleep and
2
- // your own), on for the one that is held. Turning one on holds it, turning the active one off goes back
3
- // to the schedule. Mirrors holds set in the ecobee app or on the thermostat. Reading is driven from
4
- // outside: the platform's shared tick calls handle() with each read of the thermostats.
1
+ // "Comfort Setting" accessory: one HomeKit Television tile with an input per ecobee comfort setting
2
+ // (climate: Home, Away, Sleep and your own). Active is on while a comfort hold runs and off while the
3
+ // thermostats follow their schedule; the selected input is the held setting, or the one the schedule is
4
+ // on now. Picking an input holds it, switching the tile off goes back to the schedule. Holds set in the
5
+ // ecobee app or on the thermostat show up too. HomeKit allows one television per bridge, so this is its
6
+ // own accessory, published once after the first successful read (so the inputs exist when it is added).
7
+ // Reading is driven from outside: the platform's shared tick calls handle() with each read of the thermostats.
5
8
  import { SignInRequiredError, vacationsOf } from './ecobee.js';
6
9
 
7
10
  const FAULT_AFTER = 3;
8
11
  const REVERT_DELAY = 1000;
9
12
  const CONFIRM_DELAY = 3000;
13
+ const DEBOUNCE_DELAY = 300;
14
+
15
+ // Fixed input identifiers for ecobee's built-in settings; others are hashed from the climateRef.
16
+ const FIXED = { home: 1, away: 2, sleep: 3 };
17
+
18
+ function wantedId(ref) {
19
+ if (ref in FIXED) {
20
+ return FIXED[ref];
21
+ }
22
+ const smart = /^smart(\d+)$/.exec(ref);
23
+ if (smart && Number(smart[1]) < 900) {
24
+ return 10 + Number(smart[1]);
25
+ }
26
+ let hash = 2166136261;
27
+ for (const ch of ref) {
28
+ hash = Math.imul(hash ^ ch.charCodeAt(0), 16777619) >>> 0;
29
+ }
30
+ return 1000 + (hash % 998999);
31
+ }
10
32
 
11
33
  export class ComfortSetting {
12
34
  /**
@@ -16,52 +38,44 @@ export class ComfortSetting {
16
38
  * @param {import('./ecobee.js').EcobeeCloud} options.cloud
17
39
  * @param {string} options.homeId
18
40
  * @param {string} options.holdType nextTransition or indefinite
19
- * @param {import('homebridge').PlatformAccessory | undefined} options.cached
20
41
  * @param {string} options.version
21
42
  * @param {() => boolean} options.cloudDown the security system can't reach ecobee
22
- * @param {(uuid: string, name: string) => import('homebridge').PlatformAccessory} options.create
23
- * @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.register
24
- * @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.update
43
+ * @param {(accessories: import('homebridge').PlatformAccessory[]) => void} options.publish as its own HomeKit accessory
44
+ * @param {() => string[]} options.hidden climateRefs hidden in the Home app's input list
45
+ * @param {(refs: string[]) => void} options.setHidden
25
46
  * @param {number} [options.confirmDelay]
47
+ * @param {number} [options.debounceDelay]
26
48
  */
27
- constructor({ api, log, cloud, homeId, holdType, cached, version, cloudDown, create, register, update, confirmDelay = CONFIRM_DELAY }) {
28
- Object.assign(this, { api, log, cloud, homeId, holdType, cached, version, cloudDown, create, register, update, confirmDelay });
49
+ constructor({ api, log, cloud, homeId, holdType, version, cloudDown, publish, hidden = () => [], setHidden = () => {},
50
+ confirmDelay = CONFIRM_DELAY, debounceDelay = DEBOUNCE_DELAY }) {
51
+ Object.assign(this, { api, log, cloud, homeId, holdType, version, cloudDown, publish, hidden, setHidden, confirmDelay, debounceDelay });
29
52
  this.C = api.hap.Characteristic;
30
- this.switches = new Map(); // climateRef -> { service, name, ids }
31
- this.active = undefined; // climateRef of the running hold
53
+ this.climates = new Map(); // climateRef -> { name, ids } (the union over the thermostats)
54
+ this.inputs = new Map(); // climateRef -> { service, id, name }
55
+ this.ids = new Map(); // climateRef -> identifier, never reassigned while running
56
+ this.active = undefined; // climateRef of the running hold, as last read
57
+ this.scheduled = undefined; // climateRef the schedule is on now
58
+ this.requested = undefined; // { want, seq }: the latest change sent and not yet confirmed
59
+ this.seq = 0;
60
+ this.confirms = 0; // confirmations in progress; reads wait until they are done
61
+ this.queue = Promise.resolve();
32
62
  this.onVacation = false;
33
63
  this.failures = 0;
34
64
  this.unreachable = false;
35
65
  this.stopped = false;
36
66
  this.known = false; // a first read has succeeded
37
- this.confirming = false; // a change is being confirmed; the switches keep what the user set
38
- this.registered = false;
67
+ this.published = false;
68
+ this.pending = undefined; // { active?, id? } written by HomeKit, acted on once it stops writing
39
69
  this.waits = new Set();
40
70
  }
41
71
 
42
- start() {
43
- const { Service } = this.api.hap;
44
- const C = this.C;
45
- const uuid = this.api.hap.uuid.generate(`ecobee-security:comfort:${this.homeId}`);
46
- this.accessory = this.cached ?? this.create(uuid, 'Comfort Setting');
47
- this.accessory.getService(Service.AccessoryInformation)
48
- .setCharacteristic(C.Manufacturer, 'ecobee')
49
- .setCharacteristic(C.Model, 'Comfort Setting')
50
- .setCharacteristic(C.SerialNumber, `${this.homeId}-comfort`)
51
- .setCharacteristic(C.FirmwareRevision, this.version);
52
- // Switches restored by Homebridge answer No Response until the first read.
53
- for (const service of this.accessory.services.filter(s => s.UUID === Service.Switch.UUID && s.subtype)) {
54
- this.bind(service, service.subtype, service.displayName);
55
- }
56
- if (this.cached) {
57
- this.registered = true;
58
- this.update([this.accessory]);
59
- }
60
- }
72
+ start() {}
61
73
 
62
74
  stop() {
63
75
  this.stopped = true;
64
76
  clearTimeout(this.revertTimer);
77
+ clearTimeout(this.debounceTimer);
78
+ this.debounceTimer = undefined;
65
79
  for (const t of this.waits) {
66
80
  clearTimeout(t);
67
81
  }
@@ -86,51 +100,140 @@ export class ComfortSetting {
86
100
  });
87
101
  }
88
102
 
89
- bind(service, ref, name) {
90
- const { On } = this.C;
91
- const entry = this.switches.get(ref) ?? { service, name, ids: [] };
92
- entry.service = service;
93
- entry.name = name;
94
- entry.labelled = undefined;
95
- this.switches.set(ref, entry);
96
- service.getCharacteristic(On)
97
- .onGet(() => {
98
- if (this.isOut() || !this.known) {
99
- throw this.noResponse();
100
- }
101
- return this.isOn(ref);
102
- })
103
- .onSet(value => this.set(ref, Boolean(value)));
104
- return entry;
103
+ idFor(ref) {
104
+ let id = this.ids.get(ref);
105
+ if (id === undefined) {
106
+ const used = new Set(this.ids.values());
107
+ id = wantedId(ref);
108
+ while (used.has(id)) {
109
+ id += 1;
110
+ }
111
+ this.ids.set(ref, id);
112
+ }
113
+ return id;
105
114
  }
106
115
 
107
- /** Name and ConfiguredName: with several switches of one type, the Home app labels them by ConfiguredName. */
108
- label(entry, name) {
109
- const { Name, ConfiguredName } = this.C;
110
- entry.service.setCharacteristic(Name, name);
111
- if (!entry.service.testCharacteristic(ConfiguredName)) {
112
- entry.service.addOptionalCharacteristic(ConfiguredName);
116
+ refFor(id) {
117
+ for (const [ref, entry] of this.inputs) {
118
+ if (entry.id === id) {
119
+ return ref;
120
+ }
113
121
  }
114
- entry.service.setCharacteristic(ConfiguredName, name);
115
- entry.name = name;
116
- entry.labelled = name;
122
+ return undefined;
117
123
  }
118
124
 
119
- isOn(ref) {
120
- return !this.onVacation && this.active === ref;
125
+ nameOf(ref) {
126
+ return ref === undefined ? 'the schedule' : (this.climates.get(ref)?.name ?? ref);
121
127
  }
122
128
 
123
- /** Shows each switch: No Response while ecobee can't be reached, otherwise its state. */
124
- show() {
125
- const { On } = this.C;
126
- for (const [ref, { service }] of this.switches) {
127
- service.updateCharacteristic(On, this.isOut() || !this.known ? this.noResponse() : this.isOn(ref));
129
+ /** The held setting: the latest change not yet confirmed, otherwise what ecobee last said. */
130
+ heldRef() {
131
+ return this.requested ? this.requested.want : this.active;
132
+ }
133
+
134
+ holdRunning() {
135
+ return !this.onVacation && this.heldRef() !== undefined;
136
+ }
137
+
138
+ /** The input shown: the held setting, otherwise the one the schedule is on now. */
139
+ shownId() {
140
+ const ref = [this.heldRef(), this.scheduled].find(r => r && this.inputs.has(r)) ?? this.inputs.keys().next().value;
141
+ return ref === undefined ? 0 : this.inputs.get(ref).id;
142
+ }
143
+
144
+ /** Builds the accessory with its Television service the first time a read has climates. */
145
+ ensure() {
146
+ if (this.accessory) {
147
+ return;
128
148
  }
149
+ const { api, homeId, version, C } = this;
150
+ const { Service } = api.hap;
151
+ const uuid = api.hap.uuid.generate(`ecobee-security:comfort-tv:${homeId}`);
152
+ this.accessory = new api.platformAccessory('Comfort Setting', uuid, api.hap.Categories.TV_STREAMING_STICK);
153
+ this.accessory.getService(Service.AccessoryInformation)
154
+ .setCharacteristic(C.Manufacturer, 'ecobee')
155
+ .setCharacteristic(C.Model, 'Comfort Setting')
156
+ .setCharacteristic(C.SerialNumber, `${homeId}-comfort`)
157
+ .setCharacteristic(C.FirmwareRevision, version);
158
+ const tv = this.accessory.addService(new Service.Television('Comfort Setting'));
159
+ this.tv = tv;
160
+ tv.setCharacteristic(C.ConfiguredName, 'Comfort Setting');
161
+ tv.setCharacteristic(C.SleepDiscoveryMode, C.SleepDiscoveryMode.ALWAYS_DISCOVERABLE);
162
+ tv.getCharacteristic(C.RemoteKey).onSet(() => {});
163
+ tv.getCharacteristic(C.Active)
164
+ .onGet(() => this.read(() => (this.holdRunning() ? 1 : 0)))
165
+ .onSet(value => this.write({ active: value === C.Active.ACTIVE }));
166
+ tv.getCharacteristic(C.ActiveIdentifier)
167
+ .onGet(() => this.read(() => this.shownId()))
168
+ .onSet(value => this.write({ id: Number(value) }));
129
169
  }
130
170
 
131
- /** Reads the thermostats: climates (the union), the running hold and a vacation. Logs a changed setting. */
132
- apply(thermostats) {
171
+ read(value) {
172
+ if (this.isOut() || !this.known) {
173
+ throw this.noResponse();
174
+ }
175
+ return value();
176
+ }
177
+
178
+ /** Adds, removes and renames the inputs to match the climates. */
179
+ reconcile(climates) {
180
+ if (!climates.size && !this.accessory) {
181
+ return;
182
+ }
183
+ this.ensure();
133
184
  const { Service } = this.api.hap;
185
+ const C = this.C;
186
+ for (const [ref, entry] of this.inputs) {
187
+ if (!climates.has(ref)) {
188
+ this.tv.removeLinkedService(entry.service);
189
+ this.accessory.removeService(entry.service);
190
+ this.inputs.delete(ref);
191
+ }
192
+ }
193
+ for (const [ref, { name }] of climates) {
194
+ let entry = this.inputs.get(ref);
195
+ if (!entry) {
196
+ const service = new Service.InputSource(name, ref);
197
+ const id = this.idFor(ref);
198
+ const visibility = this.hidden().includes(ref) ? C.TargetVisibilityState.HIDDEN : C.TargetVisibilityState.SHOWN;
199
+ service
200
+ .setCharacteristic(C.Identifier, id)
201
+ .setCharacteristic(C.IsConfigured, C.IsConfigured.CONFIGURED)
202
+ .setCharacteristic(C.InputSourceType, C.InputSourceType.OTHER)
203
+ .setCharacteristic(C.CurrentVisibilityState, visibility)
204
+ .setCharacteristic(C.TargetVisibilityState, visibility);
205
+ // The Home app's show/hide toggle in the input list: follow it and remember it.
206
+ service.getCharacteristic(C.TargetVisibilityState).onSet(value => {
207
+ service.updateCharacteristic(C.CurrentVisibilityState, value);
208
+ const hidden = new Set(this.hidden());
209
+ hidden[value === C.TargetVisibilityState.HIDDEN ? 'add' : 'delete'](ref);
210
+ this.setHidden([...hidden]);
211
+ });
212
+ this.accessory.addService(service);
213
+ this.tv.addLinkedService(service);
214
+ entry = { service, id, name: undefined };
215
+ this.inputs.set(ref, entry);
216
+ }
217
+ // New, or renamed in the ecobee app.
218
+ if (entry.name !== name) {
219
+ entry.service.setCharacteristic(C.Name, name).setCharacteristic(C.ConfiguredName, name);
220
+ entry.name = name;
221
+ }
222
+ }
223
+ // The inputs in ecobee's order (the climates as the thermostats list them).
224
+ const order = Buffer.concat([...climates.keys()].flatMap((ref, i) => {
225
+ const id = Buffer.alloc(4);
226
+ id.writeUInt32LE(this.inputs.get(ref).id);
227
+ return [...(i ? [Buffer.from([0x00, 0x00])] : []), Buffer.from([0x01, 0x04]), id];
228
+ })).toString('base64');
229
+ if (this.order !== order) {
230
+ this.order = order;
231
+ this.tv.updateCharacteristic(C.DisplayOrder, order);
232
+ }
233
+ }
234
+
235
+ /** Reads the thermostats: climates (the union), the running hold, the scheduled climate and a vacation. Logs a changed setting. */
236
+ apply(thermostats) {
134
237
  const climates = new Map();
135
238
  for (const t of thermostats) {
136
239
  for (const c of t.program?.climates ?? []) {
@@ -142,30 +245,9 @@ export class ComfortSetting {
142
245
  climates.set(c.climateRef, entry);
143
246
  }
144
247
  }
145
- let changed = false;
146
- for (const [ref, entry] of this.switches) {
147
- if (!climates.has(ref)) {
148
- this.accessory.removeService(entry.service);
149
- this.switches.delete(ref);
150
- changed = true;
151
- }
152
- }
153
- for (const [ref, { name, ids }] of climates) {
154
- let entry = this.switches.get(ref);
155
- if (!entry) {
156
- const service = new Service.Switch(name, ref);
157
- this.accessory.addService(service);
158
- entry = this.bind(service, ref, name);
159
- changed = true;
160
- }
161
- // New, restored from cache without a ConfiguredName, or renamed in the ecobee app.
162
- if (entry.labelled !== name) {
163
- this.label(entry, name);
164
- changed = true;
165
- }
166
- entry.ids = ids;
167
- }
168
- // A temperature-only hold has no climate: all off, the same as following the schedule.
248
+ this.reconcile(climates);
249
+ this.climates = climates;
250
+ // A temperature-only hold has no climate: the same as following the schedule.
169
251
  let active;
170
252
  for (const t of thermostats) {
171
253
  const hold = (t.events ?? []).find(e => e.type === 'hold' && e.running && e.holdClimateRef);
@@ -174,6 +256,7 @@ export class ComfortSetting {
174
256
  break;
175
257
  }
176
258
  }
259
+ this.scheduled = thermostats.map(t => t.program?.currentClimateRef).find(ref => ref && climates.has(ref));
177
260
  const was = this.known ? this.active : undefined;
178
261
  const wasKnown = this.known;
179
262
  this.active = active;
@@ -181,20 +264,20 @@ export class ComfortSetting {
181
264
  this.known = true;
182
265
  if (wasKnown && active !== was) {
183
266
  this.log.info(active
184
- ? `Comfort setting now: ${this.switches.get(active).name} (hold ${this.holdType === 'indefinite' ? 'until you change it' : 'until the next scheduled change'})`
267
+ ? `Comfort setting now: ${climates.get(active).name} (hold ${this.holdType === 'indefinite' ? 'until you change it' : 'until the next scheduled change'})`
185
268
  : 'Comfort setting now: following the schedule');
186
269
  }
187
- if (!this.registered && this.switches.size) {
188
- this.registered = true;
189
- this.register([this.accessory]);
190
- } else if (changed && this.registered) {
191
- this.update([this.accessory]);
270
+ if (!this.published && this.inputs.size) {
271
+ this.published = true;
272
+ this.publish([this.accessory]);
273
+ this.log.info('"Comfort Setting" is published as its own HomeKit accessory. If it isn\'t in the Home app yet, '
274
+ + 'add it with the setup code shown above.');
192
275
  }
193
276
  }
194
277
 
195
278
  /** One shared read of the thermostats (or its error) from the platform's tick. */
196
279
  handle(thermostats, error) {
197
- if (this.confirming) {
280
+ if (this.confirms > 0) {
198
281
  return;
199
282
  }
200
283
  if (error) {
@@ -216,65 +299,137 @@ export class ComfortSetting {
216
299
  this.show();
217
300
  }
218
301
 
219
- /** Shows the switches as they really are shortly after HomeKit's own update has landed. */
302
+ /** The tile: No Response while ecobee can't be reached, otherwise its state (including a change not yet confirmed). */
303
+ show() {
304
+ if (!this.tv || !this.published || this.debounceTimer) {
305
+ return; // not published yet, or HomeKit is still writing
306
+ }
307
+ const { Active, ActiveIdentifier } = this.C;
308
+ const out = this.isOut() || !this.known;
309
+ this.tv.updateCharacteristic(Active, out ? this.noResponse() : (this.holdRunning() ? 1 : 0));
310
+ this.tv.updateCharacteristic(ActiveIdentifier, out ? this.noResponse() : this.shownId());
311
+ }
312
+
313
+ /** Shows the state as it really is shortly after HomeKit's own update has landed. */
220
314
  revert() {
221
315
  clearTimeout(this.revertTimer);
222
316
  this.revertTimer = setTimeout(() => this.show(), REVERT_DELAY);
223
317
  }
224
318
 
225
- /** After a change, re-reads (fresh) and shows the real state; warns when it isn't what was asked for. */
226
- async confirm(want) {
227
- this.confirming = true;
319
+ /** After a change, re-reads (fresh) and shows the real state; warns when it isn't what was asked for. Only the latest change is judged. */
320
+ async confirm(want, seq) {
321
+ this.confirms += 1;
228
322
  try {
229
323
  await this.wait(this.confirmDelay);
230
- if (this.stopped) {
231
- return;
324
+ if (this.stopped || this.requested?.seq !== seq) {
325
+ return; // superseded by a newer change, which confirms for itself
232
326
  }
233
327
  try {
234
328
  this.apply(await this.cloud.getThermostats(this.homeId, 0));
235
329
  } catch (error) {
236
330
  this.log.debug(`Could not re-read the ecobee comfort setting (${error.message}).`);
237
331
  }
332
+ if (this.requested?.seq !== seq) {
333
+ return;
334
+ }
335
+ this.requested = undefined;
238
336
  if (this.active !== want) {
239
- this.log.warn('ecobee did not apply the comfort setting; showing its real state.');
337
+ this.log.warn(`ecobee did not apply the comfort setting (asked for ${this.nameOf(want)}, ecobee has ${this.nameOf(this.active)}); showing its real state.`);
240
338
  }
241
339
  } finally {
242
- this.confirming = false;
340
+ this.confirms -= 1;
341
+ }
342
+ if (this.confirms === 0) {
343
+ this.show();
243
344
  }
345
+ }
346
+
347
+ /** Holds a comfort setting (climateRef), or goes back to the schedule (undefined). Callers check() first. */
348
+ change(want) {
349
+ const seq = ++this.seq;
350
+ this.requested = { want, seq };
244
351
  this.show();
352
+ const run = this.queue.then(async () => {
353
+ try {
354
+ if (want) {
355
+ await this.cloud.setComfortHold(this.climates.get(want).ids, want, this.holdType);
356
+ } else {
357
+ await this.cloud.resumeProgram([...new Set([...this.climates.values()].flatMap(e => e.ids))]);
358
+ }
359
+ } catch (error) {
360
+ this.log.warn(`Could not change the ecobee comfort setting (${error.message}).`);
361
+ if (this.requested?.seq === seq) {
362
+ this.requested = undefined;
363
+ }
364
+ this.revert();
365
+ throw error;
366
+ }
367
+ this.confirm(want, seq);
368
+ });
369
+ this.queue = run.catch(() => {});
370
+ return run;
245
371
  }
246
372
 
247
- async set(ref, on) {
248
- const { On } = this.C;
373
+ /** Whether a change may go ahead: throws No Response while ecobee can't be reached, false (and says why) on vacation. */
374
+ check() {
249
375
  if (this.isOut()) {
250
376
  throw this.noResponse();
251
377
  }
252
378
  if (this.onVacation) {
253
379
  this.log.info('ecobee is on vacation; end the vacation to change the comfort setting.');
380
+ this.revert();
381
+ return false;
382
+ }
383
+ return true;
384
+ }
385
+
386
+ /**
387
+ * HomeKit writes Active and ActiveIdentifier separately (often both when an input is picked while the
388
+ * tile is off); the last values win and are acted on together once the writes stop.
389
+ */
390
+ write(part) {
391
+ this.log.debug(`Comfort Setting: HomeKit set ${Object.entries(part).map(([k, v]) => `${k === 'active' ? 'Active' : 'ActiveIdentifier'}=${typeof v === 'boolean' ? Number(v) : v}`).join(' ')}`);
392
+ if (this.isOut() || !this.known) {
393
+ throw this.noResponse();
394
+ }
395
+ this.pending = { ...this.pending, ...part };
396
+ clearTimeout(this.debounceTimer);
397
+ this.debounceTimer = setTimeout(() => {
398
+ this.debounceTimer = undefined;
399
+ const intent = this.pending;
400
+ this.pending = undefined;
401
+ this.flush(intent).catch(error => this.log.debug(`Comfort setting change failed (${error.message}).`));
402
+ }, this.debounceDelay);
403
+ }
404
+
405
+ async flush({ active, id }) {
406
+ if (this.stopped) {
407
+ return;
408
+ }
409
+ if (this.isOut()) {
254
410
  this.revert();
255
411
  return;
256
412
  }
257
- if (on === (this.active === ref)) {
413
+ if (!this.check()) {
258
414
  return;
259
415
  }
260
- const entry = this.switches.get(ref);
261
- try {
262
- if (on) {
263
- await this.cloud.setComfortHold(entry.ids, ref, this.holdType);
264
- } else {
265
- await this.cloud.resumeProgram([...new Set([...this.switches.values()].flatMap(e => e.ids))]);
416
+ // Picking an input means holding it; Active off wins and goes back to the schedule.
417
+ const on = active ?? (id !== undefined ? true : this.holdRunning());
418
+ const ref = on ? this.refFor(id === undefined ? this.shownId() : id) : undefined;
419
+ this.log.debug(`Comfort Setting: flush ${JSON.stringify({ active, id })} -> ${on ? `hold ${ref === undefined ? '(unknown input)' : this.nameOf(ref)}` : 'schedule'}`);
420
+ if (!on) {
421
+ if (this.heldRef() !== undefined) {
422
+ await this.change(undefined);
266
423
  }
267
- } catch (error) {
268
- this.log.warn(`Could not change the ecobee comfort setting (${error.message}).`);
424
+ return;
425
+ }
426
+ if (!ref) {
427
+ this.log.warn('That comfort setting is no longer in ecobee; showing its real state.');
269
428
  this.revert();
270
- throw this.noResponse();
429
+ return;
271
430
  }
272
- // The others go off at once; ecobee's own answer follows.
273
- for (const [other, { service }] of this.switches) {
274
- if (other !== ref || !on) {
275
- service.updateCharacteristic(On, false);
276
- }
431
+ if (this.heldRef() !== ref) {
432
+ await this.change(ref);
277
433
  }
278
- this.confirm(on ? ref : undefined);
279
434
  }
280
435
  }
package/lib/platform.js CHANGED
@@ -27,7 +27,6 @@ const AIR_QUALITY = 'airQuality';
27
27
  const SMOKE = 'smoke';
28
28
  const VACATION = 'vacation';
29
29
  const PRESENCE = 'presence';
30
- const COMFORT = 'comfort';
31
30
  const QUICK_RESPONSE = 'quickResponse';
32
31
  const MEMBERS_POLL = 30 * 1000;
33
32
  const ARM_NOW = 'armAwayNow';
@@ -173,15 +172,13 @@ export class EcobeeSecurityPlatform {
173
172
  const wantArmNow = this.config.allowHolds === true && this.config.armAwayNowSwitch === true;
174
173
  const occupancyUuid = this.api.hap.uuid.generate(`ecobee-security:occupancy:${home.id}`);
175
174
  const wantOccupancy = this.config.occupancySwitch === true;
176
- const comfortUuid = this.api.hap.uuid.generate(`ecobee-security:comfort:${home.id}`);
177
- const wantComfort = this.config.comfortSetting === true;
178
175
  // Member presence sensors stay for the members ticked in the settings (none while it's off).
179
176
  this.presenceIds = this.config.memberPresence === true && Array.isArray(this.config.presenceMembers)
180
177
  ? [...new Set(this.config.presenceMembers.filter(id => typeof id === 'string' && id))] : [];
181
178
  const presenceUuids = new Set(this.presenceIds.map(id => this.api.hap.uuid.generate(`ecobee-security:presence:${home.id}:${id}`)));
182
179
  const stale = this.cached.filter(a => a.UUID !== uuid && a.context.kind !== AIR_QUALITY && a.context.kind !== VACATION && a.context.kind !== HAP_BRIDGED
183
180
  && !(wantSmoke && a.UUID === smokeUuid) && !(wantArmNow && a.UUID === armNowUuid)
184
- && !(wantOccupancy && a.UUID === occupancyUuid) && !(wantComfort && a.UUID === comfortUuid)
181
+ && !(wantOccupancy && a.UUID === occupancyUuid)
185
182
  && !(a.context.kind === PRESENCE && presenceUuids.has(a.UUID))
186
183
  // Quick Responses are reconciled with the messages once the doorbell camera has started (startCameras).
187
184
  && !(a.context.kind === QUICK_RESPONSE && this.config.quickResponses === true));
@@ -255,7 +252,7 @@ export class EcobeeSecurityPlatform {
255
252
  }
256
253
  this.startAirQuality(home.id);
257
254
  this.startVacation(home.id);
258
- this.startComfort(home.id, comfortUuid);
255
+ this.startComfort(home.id);
259
256
  this.startThermostatTick(home.id);
260
257
  this.startMembers(home.id);
261
258
  this.startKeypad();
@@ -933,8 +930,8 @@ export class EcobeeSecurityPlatform {
933
930
  }
934
931
  }
935
932
 
936
- /** The Comfort Setting accessory (a switch per ecobee comfort setting); failures here never affect the security system. */
937
- startComfort(homeId, uuid) {
933
+ /** The Comfort Setting accessory (one TV-style tile with an input per ecobee comfort setting); failures here never affect the security system. */
934
+ startComfort(homeId) {
938
935
  if (this.comfort || this.config.comfortSetting !== true) {
939
936
  return;
940
937
  }
@@ -944,16 +941,14 @@ export class EcobeeSecurityPlatform {
944
941
  cloud: this.cloud,
945
942
  homeId,
946
943
  holdType: this.config.comfortHoldType === 'indefinite' ? 'indefinite' : 'nextTransition',
947
- cached: this.cached.find(a => a.UUID === uuid),
948
944
  version: VERSION,
949
945
  cloudDown: () => this.system?.cloudDown === true,
950
- create: (id, name) => {
951
- const accessory = new this.api.platformAccessory(name, id);
952
- accessory.context.kind = COMFORT;
953
- return accessory;
946
+ publish: accessories => this.api.publishExternalAccessories(PLUGIN_NAME, accessories),
947
+ hidden: () => this.store.comfortHidden ?? [],
948
+ setHidden: refs => {
949
+ this.store.comfortHidden = refs;
950
+ this.save();
954
951
  },
955
- register: accessories => this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, accessories),
956
- update: accessories => this.api.updatePlatformAccessories(accessories),
957
952
  });
958
953
  this.comfort.start();
959
954
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ecobee-security",
3
3
  "displayName": "ecobee Smart Security",
4
- "version": "1.0.0",
4
+ "version": "1.0.1",
5
5
  "description": "ecobee Smart Security as a HomeKit security system: arm Stay / Away, disarm, alarm state (unofficial, ecobee cloud)",
6
6
  "type": "module",
7
7
  "main": "index.js",