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 +10 -0
- package/README.md +38 -19
- package/config.schema.json +2 -2
- package/homebridge-ui/public/index.html +2 -2
- package/lib/comfort.js +279 -124
- package/lib/platform.js +9 -14
- package/package.json +1 -1
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
|
|
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
|
|
563
|
-
|
|
564
|
-
read from the thermostats' program; with several thermostats, a setting
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
-
|
|
574
|
-
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
package/config.schema.json
CHANGED
|
@@ -188,10 +188,10 @@
|
|
|
188
188
|
}
|
|
189
189
|
},
|
|
190
190
|
"comfortSetting": {
|
|
191
|
-
"title": "Add Comfort Setting
|
|
191
|
+
"title": "Add Comfort Setting",
|
|
192
192
|
"type": "boolean",
|
|
193
193
|
"default": false,
|
|
194
|
-
"description": "One
|
|
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
|
|
282
|
+
<label class="form-check-label" for="s-comfortSetting">Add Comfort Setting</label>
|
|
283
283
|
</div>
|
|
284
|
-
<p class="hint mb-2">One
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 {(
|
|
23
|
-
* @param {(
|
|
24
|
-
* @param {(
|
|
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,
|
|
28
|
-
|
|
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.
|
|
31
|
-
this.
|
|
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.
|
|
38
|
-
this.
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
.
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
entry.name = name;
|
|
116
|
-
entry.labelled = name;
|
|
122
|
+
return undefined;
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
|
|
120
|
-
return
|
|
125
|
+
nameOf(ref) {
|
|
126
|
+
return ref === undefined ? 'the schedule' : (this.climates.get(ref)?.name ?? ref);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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: ${
|
|
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.
|
|
188
|
-
this.
|
|
189
|
-
this.
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
-
|
|
248
|
-
|
|
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 (
|
|
413
|
+
if (!this.check()) {
|
|
258
414
|
return;
|
|
259
415
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
429
|
+
return;
|
|
271
430
|
}
|
|
272
|
-
|
|
273
|
-
|
|
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)
|
|
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
|
|
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 (
|
|
937
|
-
startComfort(homeId
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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.
|
|
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",
|