homebridge-ecobee-security 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +56 -10
- package/config.schema.json +15 -8
- package/homebridge-ui/public/index.html +179 -4
- package/homebridge-ui/server.js +62 -0
- package/lib/control.js +23 -4
- package/lib/platform.js +40 -4
- package/lib/security.js +44 -4
- package/package.json +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
- The `pollInterval` setting is gone: the security state is read every 10 s, as often as the ecobee
|
|
6
|
+
app does (it has no live updates either). A value left in the config is ignored.
|
|
7
|
+
- Settings: the whole page is now cards (account, Security system, Sensors, Security holds), with
|
|
8
|
+
**Security holds** at the bottom; the standard settings form is no longer shown.
|
|
9
|
+
- Settings: a **Security holds** section with the **Allow security holds** switch and a checkbox per
|
|
10
|
+
plugin (`holdPlugins`): installed plugins that declare they use holds, and any that asked. Hold
|
|
11
|
+
requests can name their plugin (`&plugin=<package>`); unticked plugins are refused.
|
|
12
|
+
- Declares the hold socket in package.json (`"homebridge-security-hold": { "socket": … }`), so
|
|
13
|
+
plugins that support the hold, like homebridge-narwal-freo, list it in their settings.
|
|
14
|
+
- Hold API: several plugins (e.g. two robot vacuums) can hold Stay at once; Away comes back only
|
|
15
|
+
when the last one releases. `/release` answers `waiting: [names]` while others still hold.
|
|
16
|
+
- New setting **Allow security holds** (`allowHolds`, off by default): other plugins can only hold
|
|
17
|
+
Stay with it turned on; otherwise `/hold` and `/release` answer 403 with the reason, and a hold
|
|
18
|
+
left over from before is dropped without re-arming Away. **If you use homebridge-narwal-freo's
|
|
19
|
+
security hold, turn this on and tick Narwal Freo.**
|
|
20
|
+
- Hold and release decide on ecobee's live state, read at the moment of the request, not the last
|
|
21
|
+
poll: a disarm in the ecobee app seconds before a robot finishes is no longer armed over with
|
|
22
|
+
Away. If ecobee can't be read, a hold is refused and a release doesn't arm (the caller retries).
|
|
23
|
+
|
|
24
|
+
## 0.4.2
|
|
25
|
+
|
|
26
|
+
- New plugin icon.
|
|
27
|
+
- Declare HomeKit (HAP) support with the `supports-hap` keyword.
|
|
28
|
+
|
|
3
29
|
## 0.4.1
|
|
4
30
|
|
|
5
31
|
- Plugin icon in the README.
|
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<p align="center"><img src="icon.png" alt="" width="
|
|
1
|
+
<p align="center"><img src="icon.png" alt="" width="100" height="100"></p>
|
|
2
2
|
|
|
3
3
|
# homebridge-ecobee-security
|
|
4
4
|
|
|
@@ -58,10 +58,10 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
58
58
|
"platform": "EcobeeSecurityPlatform",
|
|
59
59
|
"name": "Security System",
|
|
60
60
|
"refreshToken": "…",
|
|
61
|
-
"pollInterval": 10,
|
|
62
61
|
"offerNight": false,
|
|
63
62
|
"alarmTrigger": "incident",
|
|
64
63
|
"smokeSensor": true,
|
|
64
|
+
"allowHolds": false,
|
|
65
65
|
"airQuality": true,
|
|
66
66
|
"co2Alert": 1000,
|
|
67
67
|
"_bridge": { "username": "…", "port": 51926, "name": "Ecobee Security Bridge" }
|
|
@@ -75,6 +75,7 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
75
75
|
| `offerNight` | `false` | Adds Night to the Home app. ecobee has no Night mode, so Night arms Stay and the tile keeps showing Night until the state changes (a Stay armed elsewhere shows as Stay). HomeKit only picks up a changed list of modes on a new control, so changing this setting recreates the security system in the Home app: set up scenes and automations that use it again. |
|
|
76
76
|
| `alarmTrigger` | `"incident"` | When the tile enters **Triggered**: `"incident"` immediately, when ecobee opens an incident as a door or sensor trips while armed (mirrors the ecobee app); `"entryDelay"` only after the entry (disarm) delay runs out without a disarm. Smoke alarms always trigger immediately. |
|
|
77
77
|
| `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. |
|
|
78
|
+
| `allowHolds` | `false` | Lets other Homebridge plugins ask for a **security hold** (see below), e.g. so a robot vacuum can clean while you're away without setting off the motion sensors. Off: hold requests are refused, and a hold left over from before is dropped without re-arming Away. |
|
|
78
79
|
| `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. |
|
|
79
80
|
| `co2Alert` | `1000` | CO2 (ppm) at or above which the Carbon Dioxide Sensor reports abnormal levels. |
|
|
80
81
|
|
|
@@ -83,19 +84,64 @@ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
|
|
|
83
84
|
- **Arm Away** uses ecobee's Away exit delay (60 s by default), just like the app. During the
|
|
84
85
|
delay the Home app shows "Arming…" (current Disarmed, target Away). Stay uses the Stay exit delay
|
|
85
86
|
(0 by default). Disarm needs no PIN.
|
|
86
|
-
- **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within
|
|
87
|
-
|
|
87
|
+
- **Changes from elsewhere** (ecobee app, keypad, ecobee automations) show up within 10 s, about
|
|
88
|
+
as fast as in the ecobee app (it polls too), and quickly after an exit delay ends.
|
|
88
89
|
- **Alarm triggered** while ecobee lists an open incident (see `alarmTrigger` above for when it
|
|
89
90
|
starts showing). Disarming clears it. Every change in the incident list is logged.
|
|
90
91
|
- **Siri:** the Home app calls Stay "Home". Asking Siri for Home or for Stay mode both arm Stay.
|
|
91
92
|
- **Night** is only offered with `offerNight`; ecobee has no Night mode of its own.
|
|
92
93
|
- After three failed reads in a row the accessory shows a fault; reads back off up to 5 minutes.
|
|
93
|
-
- **
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
- **Security holds** for other plugins, with `allowHolds`: see below.
|
|
95
|
+
|
|
96
|
+
## Security holds
|
|
97
|
+
|
|
98
|
+
A security hold lets another Homebridge plugin, such as a robot vacuum, keep the motion sensors from
|
|
99
|
+
going off while it moves around the house when you're away. The plugin can only *ask*; this plugin
|
|
100
|
+
decides, by four rules:
|
|
101
|
+
|
|
102
|
+
1. **Hold:** if the system is armed Away (or arming Away), it's switched to Stay. If an alarm is in
|
|
103
|
+
progress, or ecobee can't be read, the hold is refused. Otherwise nothing changes.
|
|
104
|
+
2. **Release:** once **every** plugin that asked is done, and the system is still in Stay, it's
|
|
105
|
+
armed Away again (with the normal exit delay).
|
|
106
|
+
3. **Your change wins:** anything someone else does during a hold — disarm, arm Away, a change in
|
|
107
|
+
the Home app, an alarm — cancels it, and the system is left as it is.
|
|
108
|
+
4. **No timers:** nothing changes unless a plugin asks or you act.
|
|
109
|
+
|
|
110
|
+
Every decision uses ecobee's state read at that moment, not the last poll. Holds are off until you
|
|
111
|
+
turn on **Allow security holds** in the settings' **Security holds** section and tick each plugin that
|
|
112
|
+
may ask (`holdPlugins`): it lists installed plugins that declare they use holds, and any plugin that
|
|
113
|
+
has asked. A plugin that isn't ticked is refused (it can still release a hold it already has).
|
|
114
|
+
Turning holds off drops a hold left over from before (without arming Away).
|
|
115
|
+
|
|
116
|
+
**Limits** that follow from these rules:
|
|
117
|
+
|
|
118
|
+
- **Your own Stay looks like the hold's Stay.** If you disarm and re-arm Stay in the ecobee app or at
|
|
119
|
+
the keypad within a few seconds (between two reads), or while the bridge is restarting, the
|
|
120
|
+
release still arms Away: ecobee doesn't say who last changed the state. Changes in the Home app are
|
|
121
|
+
always seen.
|
|
122
|
+
- **Your change wins even with a robot still out.** After you cancel a hold (say, disarm), arming
|
|
123
|
+
Away while the robot is still cleaning can set off the motion sensors: a plugin only asks for a
|
|
124
|
+
hold when it starts.
|
|
125
|
+
- **Plugins identify themselves.** Any plugin running as the homebridge user can reach the socket,
|
|
126
|
+
and the plugin name it gives is its own claim: the list keeps honest plugins apart, it isn't a
|
|
127
|
+
lock. Two requests with the same name share one hold.
|
|
128
|
+
- **A plugin that never releases leaves the system in Stay** until you change it.
|
|
129
|
+
|
|
130
|
+
**API** (for plugin authors): a Unix socket at `<homebridge storage>/ecobee-security/control.sock`
|
|
131
|
+
(mode 600, homebridge user only), declared in `package.json` as
|
|
132
|
+
`"homebridge-security-hold": { "socket": "ecobee-security/control.sock" }` so plugins that support
|
|
133
|
+
holds, like homebridge-narwal-freo, can list it. Plugins that ask for holds can declare
|
|
134
|
+
`"homebridge-security-hold": { "client": true }` to show up in the list before their first request.
|
|
135
|
+
The full API: [Security hold](https://github.com/seanfletchr/homebridge-narwal-freo/blob/main/docs/security-hold.md).
|
|
136
|
+
|
|
137
|
+
- `POST /hold?by=<name>&plugin=<package>` → `{ "ok": true, "held": true|false, "state": "…" }`,
|
|
138
|
+
or `"ok": false` with a `"reason"`: don't start. `plugin` is optional; without it the plugin is
|
|
139
|
+
listed (and allowed) under its `by` name.
|
|
140
|
+
- `POST /release?by=<name>&plugin=<package>` → `{ "released": true|false, "state": "…", "waiting": [names] }`;
|
|
141
|
+
`waiting` lists holders still out.
|
|
142
|
+
- With `allowHolds` off or the plugin not ticked, both answer 403 (a release from a current holder
|
|
143
|
+
is always accepted); if ecobee can't be read (release) or isn't connected
|
|
144
|
+
yet, a 5xx with `"error"` — try again later. Holds survive restarts (in `state.json`).
|
|
99
145
|
|
|
100
146
|
## API notes
|
|
101
147
|
|
package/config.schema.json
CHANGED
|
@@ -26,18 +26,11 @@
|
|
|
26
26
|
"type": "string",
|
|
27
27
|
"description": "Only needed if the account has more than one home; the log lists them."
|
|
28
28
|
},
|
|
29
|
-
"pollInterval": {
|
|
30
|
-
"title": "Check every (seconds)",
|
|
31
|
-
"type": "integer",
|
|
32
|
-
"default": 10,
|
|
33
|
-
"minimum": 5,
|
|
34
|
-
"description": "How often to read the armed state, so changes made in the ecobee app or at the keypad show up in Apple Home."
|
|
35
|
-
},
|
|
36
29
|
"offerNight": {
|
|
37
30
|
"title": "Offer Night",
|
|
38
31
|
"type": "boolean",
|
|
39
32
|
"default": false,
|
|
40
|
-
"description": "Adds
|
|
33
|
+
"description": "Adds the Night setting for more specific automations. ecobee has no \"Night\" mode, so Night arms Stay. Changing this recreates the security system in the Home app; scenes and automations using it must be set up again."
|
|
41
34
|
},
|
|
42
35
|
"alarmTrigger": {
|
|
43
36
|
"title": "Enter Triggered State",
|
|
@@ -65,6 +58,20 @@
|
|
|
65
58
|
"default": true,
|
|
66
59
|
"description": "Adds a separate \"Smoke Alarm\" accessory to the Home app that detects smoke whenever ecobee reports a smoke-alarm incident, so you can use it in automations and get Home app smoke notifications."
|
|
67
60
|
},
|
|
61
|
+
"allowHolds": {
|
|
62
|
+
"title": "Allow security holds",
|
|
63
|
+
"type": "boolean",
|
|
64
|
+
"default": false,
|
|
65
|
+
"description": "Lets other Homebridge plugins (for example a robot vacuum, so it can clean while you're away) switch Away to Stay for a while and back to Away when they're done. Each plugin also has to be allowed in holdPlugins (the settings page lists the ones that asked). See \"Security holds\" in the README."
|
|
66
|
+
},
|
|
67
|
+
"holdPlugins": {
|
|
68
|
+
"title": "Plugins allowed to ask for holds",
|
|
69
|
+
"type": "array",
|
|
70
|
+
"items": {
|
|
71
|
+
"type": "string"
|
|
72
|
+
},
|
|
73
|
+
"description": "Package names (or, for a plugin that doesn't say, the name it asks with). Without this list, every plugin may ask once Allow security holds is on."
|
|
74
|
+
},
|
|
68
75
|
"airQuality": {
|
|
69
76
|
"title": "Air quality sensors",
|
|
70
77
|
"type": "boolean",
|
|
@@ -56,6 +56,72 @@
|
|
|
56
56
|
</div>
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
|
+
|
|
60
|
+
<div class="card card-body mb-3" id="system-card" hidden>
|
|
61
|
+
<h5 class="mb-3">Security system</h5>
|
|
62
|
+
<div class="mb-3">
|
|
63
|
+
<label for="s-name" class="form-label">Name</label>
|
|
64
|
+
<input class="form-control" type="text" id="s-name" data-key="name" data-type="text" data-default="Security System">
|
|
65
|
+
<div class="hint mt-1">Name shown in the Home app.</div>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="mb-3">
|
|
68
|
+
<label for="s-homeId" class="form-label">Home ID</label>
|
|
69
|
+
<input class="form-control" type="text" id="s-homeId" data-key="homeId" data-type="text" data-default="">
|
|
70
|
+
<div class="hint mt-1">Only needed if the account has more than one home; the log lists them.</div>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="mb-3">
|
|
73
|
+
<div class="form-check form-switch">
|
|
74
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-offerNight" data-key="offerNight" data-type="bool" data-default="false">
|
|
75
|
+
<label class="form-check-label" for="s-offerNight">Offer Night</label>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="hint">Adds the Night setting for more specific automations. ecobee has no "Night" mode, so Night arms Stay.</div>
|
|
78
|
+
<div class="hint mt-1">Changing this recreates the security system in the Home app; scenes and automations using it must be set up again.</div>
|
|
79
|
+
</div>
|
|
80
|
+
<div class="mb-0">
|
|
81
|
+
<label for="s-alarmTrigger" class="form-label">Enter Triggered State</label>
|
|
82
|
+
<select class="form-select" id="s-alarmTrigger" data-key="alarmTrigger" data-type="select" data-default="incident"><option value="incident">Immediately (mirror ecobee app)</option><option value="entryDelay">After the Entry/Disarm delay</option></select>
|
|
83
|
+
<div class="hint mt-1">ecobee opens an incident as soon as a door or sensor trips while armed. "After the Entry/Disarm delay" waits until that delay runs out without a disarm, so automations on Triggered don't fire while you're walking in to disarm. Smoke alarms always trigger immediately.</div>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="card card-body mb-3" id="sensors-card" hidden>
|
|
88
|
+
<h5 class="mb-3">Sensors</h5>
|
|
89
|
+
<div class="mb-3">
|
|
90
|
+
<div class="form-check form-switch">
|
|
91
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-smokeSensor" data-key="smokeSensor" data-type="bool" data-default="true">
|
|
92
|
+
<label class="form-check-label" for="s-smokeSensor">Smoke sensor</label>
|
|
93
|
+
</div>
|
|
94
|
+
<div class="hint">Adds a separate "Smoke Alarm" accessory to the Home app that detects smoke whenever ecobee reports a smoke-alarm incident, so you can use it in automations and get Home app smoke notifications.</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="mb-3">
|
|
97
|
+
<div class="form-check form-switch">
|
|
98
|
+
<input class="form-check-input" type="checkbox" role="switch" id="s-airQuality" data-key="airQuality" data-type="bool" data-default="true">
|
|
99
|
+
<label class="form-check-label" for="s-airQuality">Air quality sensors</label>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="hint">For ecobee thermostats with an air quality sensor (ecobee Premium): adds an Air Quality Sensor (overall level and VOC) and a Carbon Dioxide Sensor (CO2 in ppm) per thermostat. ecobee's own HomeKit integration doesn't show these. Updated every 5 minutes.</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="mb-0" id="co2-box">
|
|
104
|
+
<label for="s-co2Alert" class="form-label">CO2 alert level (ppm)</label>
|
|
105
|
+
<input class="form-control" type="number" step="1" min="400" id="s-co2Alert" data-key="co2Alert" data-type="int" data-default="1000">
|
|
106
|
+
<div class="hint mt-1">The Carbon Dioxide Sensor reports abnormal CO2 at or above this level, for Home app notifications and automations (for example, turning on ventilation).</div>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div class="card card-body mb-3" id="holds-card" hidden>
|
|
111
|
+
<h5 class="mb-2">Security holds</h5>
|
|
112
|
+
<div class="form-check form-switch mb-1">
|
|
113
|
+
<input class="form-check-input" type="checkbox" role="switch" id="allow-holds">
|
|
114
|
+
<label class="form-check-label" for="allow-holds">Allow security holds</label>
|
|
115
|
+
</div>
|
|
116
|
+
<p class="hint mb-2">Lets supported plugins, like a robot vacuum, switch Away to Home while they work and back to
|
|
117
|
+
Away when they're done.
|
|
118
|
+
<a href="https://github.com/seanfletchr/homebridge-ecobee-security#security-holds" target="_blank" rel="noopener">Learn more…</a></p>
|
|
119
|
+
<div id="hold-plugins-box" hidden>
|
|
120
|
+
<div class="mb-1">Allowed plugins</div>
|
|
121
|
+
<div id="hold-plugins"></div>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
59
125
|
<script>
|
|
60
126
|
(async () => {
|
|
61
127
|
const PLATFORM = 'EcobeeSecurityPlatform';
|
|
@@ -100,11 +166,8 @@
|
|
|
100
166
|
const blocks = await homebridge.getPluginConfig();
|
|
101
167
|
const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
|
|
102
168
|
block.refreshToken = refreshToken;
|
|
103
|
-
// Re-show the settings form afterwards so it picks up the new token.
|
|
104
|
-
homebridge.hideSchemaForm();
|
|
105
169
|
await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
|
|
106
170
|
await homebridge.savePluginConfig();
|
|
107
|
-
homebridge.showSchemaForm();
|
|
108
171
|
passwordForm.reset();
|
|
109
172
|
mfaForm.reset();
|
|
110
173
|
passwordForm.hidden = false;
|
|
@@ -113,6 +176,8 @@
|
|
|
113
176
|
$('browser').open = false;
|
|
114
177
|
homebridge.toast.success('Signed in to ecobee.');
|
|
115
178
|
await showStatus({ justSignedIn: true });
|
|
179
|
+
await showSettings();
|
|
180
|
+
await showHolds();
|
|
116
181
|
}
|
|
117
182
|
|
|
118
183
|
passwordForm.addEventListener('submit', async event => {
|
|
@@ -176,7 +241,117 @@
|
|
|
176
241
|
}
|
|
177
242
|
});
|
|
178
243
|
|
|
244
|
+
|
|
245
|
+
// ── Settings cards ──
|
|
246
|
+
|
|
247
|
+
const controls = [...document.querySelectorAll('[data-key]')];
|
|
248
|
+
|
|
249
|
+
/** Fill the cards from the config; show defaults where nothing is set. */
|
|
250
|
+
async function showSettings() {
|
|
251
|
+
const [block] = await homebridge.getPluginConfig();
|
|
252
|
+
const signedIn = Boolean(block?.refreshToken);
|
|
253
|
+
$('system-card').hidden = !signedIn;
|
|
254
|
+
$('sensors-card').hidden = !signedIn;
|
|
255
|
+
for (const input of controls) {
|
|
256
|
+
const value = block?.[input.dataset.key];
|
|
257
|
+
if (input.dataset.type === 'bool') {
|
|
258
|
+
input.checked = value ?? input.dataset.default === 'true';
|
|
259
|
+
} else {
|
|
260
|
+
input.value = value ?? input.dataset.default;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
$('co2-box').hidden = block?.airQuality === false;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Write one setting. Top-level keys are cleared with undefined, never deleted (the UI merges shallowly). */
|
|
267
|
+
async function setSetting(key, value) {
|
|
268
|
+
const blocks = await homebridge.getPluginConfig();
|
|
269
|
+
const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
|
|
270
|
+
block[key] = value;
|
|
271
|
+
await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const input of controls) {
|
|
275
|
+
input.addEventListener('change', async () => {
|
|
276
|
+
const { key, type } = input.dataset;
|
|
277
|
+
if (type === 'bool') {
|
|
278
|
+
await setSetting(key, input.checked);
|
|
279
|
+
if (key === 'airQuality') {
|
|
280
|
+
$('co2-box').hidden = !input.checked;
|
|
281
|
+
}
|
|
282
|
+
} else if (type === 'int') {
|
|
283
|
+
const number = input.value.trim() === '' ? undefined : Number(input.value);
|
|
284
|
+
const valid = number === undefined || (Number.isInteger(number) && number >= Number(input.min));
|
|
285
|
+
input.classList.toggle('is-invalid', !valid);
|
|
286
|
+
if (valid) {
|
|
287
|
+
await setSetting(key, number);
|
|
288
|
+
}
|
|
289
|
+
} else if (type === 'select') {
|
|
290
|
+
await setSetting(key, input.value);
|
|
291
|
+
} else {
|
|
292
|
+
await setSetting(key, input.value.trim() === '' ? undefined : input.value.trim());
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Security holds ──
|
|
298
|
+
|
|
299
|
+
async function showHolds() {
|
|
300
|
+
const blocks = await homebridge.getPluginConfig();
|
|
301
|
+
const block = blocks[0] ?? {};
|
|
302
|
+
$('holds-card').hidden = !block.refreshToken;
|
|
303
|
+
$('allow-holds').checked = block.allowHolds === true;
|
|
304
|
+
$('hold-plugins-box').hidden = block.allowHolds !== true;
|
|
305
|
+
const plugins = await homebridge.request('/hold-plugins').catch(() => []);
|
|
306
|
+
const box = $('hold-plugins');
|
|
307
|
+
box.textContent = '';
|
|
308
|
+
if (!plugins.length) {
|
|
309
|
+
const p = document.createElement('p');
|
|
310
|
+
p.className = 'hint mb-0';
|
|
311
|
+
p.textContent = 'No plugin has asked for a hold yet.';
|
|
312
|
+
box.append(p);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Without a list (settings from before it), every plugin is allowed.
|
|
316
|
+
const allowed = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
|
|
317
|
+
for (const plugin of plugins) {
|
|
318
|
+
const id = `hold-${plugin.key.replace(/[^a-z0-9]/gi, '-')}`;
|
|
319
|
+
const input = Object.assign(document.createElement('input'), {
|
|
320
|
+
className: 'form-check-input', type: 'checkbox', id, checked: allowed.includes(plugin.key),
|
|
321
|
+
});
|
|
322
|
+
input.addEventListener('change', () => setAllowed(plugins, plugin.key, input.checked));
|
|
323
|
+
const label = Object.assign(document.createElement('label'), { className: 'form-check-label', htmlFor: id });
|
|
324
|
+
label.textContent = plugin.displayName === plugin.key ? plugin.key : `${plugin.displayName} (${plugin.key})`;
|
|
325
|
+
const row = Object.assign(document.createElement('div'), { className: 'form-check mb-1' });
|
|
326
|
+
row.append(input, label);
|
|
327
|
+
if (plugin.names.length || plugin.lastAsked) {
|
|
328
|
+
const hint = Object.assign(document.createElement('div'), { className: 'hint' });
|
|
329
|
+
const when = plugin.lastAsked ? `last asked ${new Date(plugin.lastAsked).toLocaleString()}` : '';
|
|
330
|
+
hint.textContent = [plugin.names.length ? `for ${plugin.names.join(', ')}` : '', when].filter(Boolean).join(', ');
|
|
331
|
+
row.append(hint);
|
|
332
|
+
}
|
|
333
|
+
box.append(row);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function setAllowed(plugins, key, on) {
|
|
338
|
+
const blocks = await homebridge.getPluginConfig();
|
|
339
|
+
const block = blocks[0];
|
|
340
|
+
const current = Array.isArray(block.holdPlugins) ? block.holdPlugins : plugins.map(p => p.key);
|
|
341
|
+
block.holdPlugins = on ? [...new Set([...current, key])] : current.filter(k => k !== key);
|
|
342
|
+
await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
$('allow-holds').addEventListener('change', async () => {
|
|
346
|
+
const blocks = await homebridge.getPluginConfig();
|
|
347
|
+
const block = blocks[0];
|
|
348
|
+
block.allowHolds = $('allow-holds').checked;
|
|
349
|
+
$('hold-plugins-box').hidden = !block.allowHolds;
|
|
350
|
+
await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
|
|
351
|
+
});
|
|
352
|
+
|
|
179
353
|
await showStatus();
|
|
180
|
-
|
|
354
|
+
await showSettings();
|
|
355
|
+
await showHolds();
|
|
181
356
|
})();
|
|
182
357
|
</script>
|
package/homebridge-ui/server.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Server side of the plugin's settings page (spawned by the Homebridge UI while the settings are
|
|
2
2
|
// open). Does the ecobee sign-in and hands only the refresh token back to the page, which saves it
|
|
3
3
|
// in the plugin config. The MFA token and PKCE verifier stay here, in memory.
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
4
9
|
import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
|
|
5
10
|
|
|
6
11
|
import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn, SignInError } from '../lib/auth.js';
|
|
@@ -47,6 +52,26 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
47
52
|
return { refreshToken: tokens.refresh_token };
|
|
48
53
|
}));
|
|
49
54
|
|
|
55
|
+
// Plugins that use security holds: installed ones that declare it, and any that have asked.
|
|
56
|
+
// Only the request log leaves state.json (it also holds the ecobee sign-in).
|
|
57
|
+
this.onRequest('/hold-plugins', async () => {
|
|
58
|
+
const plugins = new Map();
|
|
59
|
+
for (const p of installedHoldClients(this.homebridgeStoragePath)) {
|
|
60
|
+
plugins.set(p.name, { key: p.name, displayName: p.displayName, names: [] });
|
|
61
|
+
}
|
|
62
|
+
let store = {};
|
|
63
|
+
try {
|
|
64
|
+
store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
|
|
65
|
+
} catch {
|
|
66
|
+
// Not started yet: only the installed ones.
|
|
67
|
+
}
|
|
68
|
+
for (const [key, r] of Object.entries(store.holdRequesters ?? {})) {
|
|
69
|
+
const known = plugins.get(key);
|
|
70
|
+
plugins.set(key, { key, displayName: known?.displayName ?? key, names: r.names ?? [], lastAsked: r.lastAsked });
|
|
71
|
+
}
|
|
72
|
+
return [...plugins.values()];
|
|
73
|
+
});
|
|
74
|
+
|
|
50
75
|
this.ready();
|
|
51
76
|
}
|
|
52
77
|
|
|
@@ -63,4 +88,41 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
63
88
|
}
|
|
64
89
|
}
|
|
65
90
|
|
|
91
|
+
/** Installed plugins that declare they ask for holds: "homebridge-security-hold": { "client": true }. */
|
|
92
|
+
function installedHoldClients(storagePath) {
|
|
93
|
+
const folders = new Set([fileURLToPath(new URL('../../', import.meta.url))]);
|
|
94
|
+
if (storagePath) {
|
|
95
|
+
folders.add(join(storagePath, 'node_modules'));
|
|
96
|
+
}
|
|
97
|
+
const found = new Map();
|
|
98
|
+
const scan = folder => {
|
|
99
|
+
let entries = [];
|
|
100
|
+
try {
|
|
101
|
+
entries = readdirSync(folder, { withFileTypes: true });
|
|
102
|
+
} catch {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const dir = join(folder, entry.name);
|
|
107
|
+
if (entry.name.startsWith('@')) {
|
|
108
|
+
scan(dir);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!entry.name.includes('homebridge')) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
116
|
+
if (pkg['homebridge-security-hold']?.client === true && pkg.name && !found.has(pkg.name)) {
|
|
117
|
+
found.set(pkg.name, { name: pkg.name, displayName: pkg.displayName || pkg.name });
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// not a package
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
folders.forEach(scan);
|
|
125
|
+
return [...found.values()];
|
|
126
|
+
}
|
|
127
|
+
|
|
66
128
|
(() => new UiServer())();
|
package/lib/control.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// A local control socket so other Homebridge plugins (each child bridge is its own process)
|
|
2
2
|
// can ask for a temporary Stay while they move around the house:
|
|
3
|
-
// POST /hold?by=<name> -> { ok, held, state, reason? }
|
|
4
|
-
// POST /release?by=<name> -> { released, state }
|
|
3
|
+
// POST /hold?by=<name>&plugin=<package> -> { ok, held, state, reason? }
|
|
4
|
+
// POST /release?by=<name>&plugin=<package> -> { released, state, waiting? } (Away only once every holder has released)
|
|
5
5
|
// It's a Unix socket in the plugin's storage folder (mode 600), so only the homebridge user can use it.
|
|
6
|
+
// Holds need `allowHolds` and the asking plugin ticked in the settings (`isAllowed`); otherwise it
|
|
7
|
+
// answers 403 so the caller can log why. Every request is noted (`noteRequest`) so the settings page
|
|
8
|
+
// can list the plugins that asked. A release is always accepted from a current holder, so taking a
|
|
9
|
+
// plugin's permission away can't leave the system stuck in Stay.
|
|
6
10
|
|
|
7
11
|
import { chmodSync, rmSync } from 'node:fs';
|
|
8
12
|
import { createServer } from 'node:http';
|
|
9
13
|
|
|
10
|
-
export function startControlServer({ path, log, getSystem }) {
|
|
14
|
+
export function startControlServer({ path, log, getSystem, isAllowed, noteRequest }) {
|
|
11
15
|
try {
|
|
12
16
|
rmSync(path, { force: true });
|
|
13
17
|
} catch (error) {
|
|
@@ -16,14 +20,29 @@ export function startControlServer({ path, log, getSystem }) {
|
|
|
16
20
|
const server = createServer(async (req, res) => {
|
|
17
21
|
const url = new URL(req.url, 'http://localhost');
|
|
18
22
|
const by = (url.searchParams.get('by') || 'another plugin').slice(0, 40);
|
|
23
|
+
// Requests are allowed per plugin; one that doesn't say which it is goes by its `by` name.
|
|
24
|
+
const plugin = (url.searchParams.get('plugin') || '').slice(0, 100) || undefined;
|
|
25
|
+
const key = plugin ?? by;
|
|
19
26
|
const reply = (code, body) => {
|
|
20
27
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
21
28
|
res.end(JSON.stringify(body));
|
|
22
29
|
};
|
|
23
|
-
const system = getSystem();
|
|
24
30
|
if (req.method !== 'POST' || !['/hold', '/release'].includes(url.pathname)) {
|
|
25
31
|
return reply(404, { error: 'POST /hold or /release' });
|
|
26
32
|
}
|
|
33
|
+
const system = getSystem();
|
|
34
|
+
const releasing = url.pathname === '/release';
|
|
35
|
+
noteRequest?.({ key, plugin, by });
|
|
36
|
+
if (!(releasing && system?.holders().includes(by))) {
|
|
37
|
+
const allowed = isAllowed(key);
|
|
38
|
+
if (allowed !== true) {
|
|
39
|
+
const why = allowed === 'off'
|
|
40
|
+
? 'security holds are turned off in the ecobee Smart Security settings'
|
|
41
|
+
: `${key} isn't allowed to ask for security holds in the ecobee Smart Security settings`;
|
|
42
|
+
log.warn(`${by}${plugin ? ` (${plugin})` : ''} asked for a security hold, but ${why}.`);
|
|
43
|
+
return reply(403, { ok: false, error: why });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
27
46
|
if (!system) {
|
|
28
47
|
return reply(503, { error: 'not connected to ecobee yet' });
|
|
29
48
|
}
|
package/lib/platform.js
CHANGED
|
@@ -60,7 +60,13 @@ export class EcobeeSecurityPlatform {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
api.on('didFinishLaunching', () => {
|
|
63
|
-
this.control = startControlServer({
|
|
63
|
+
this.control = startControlServer({
|
|
64
|
+
path: join(dir, 'control.sock'),
|
|
65
|
+
log,
|
|
66
|
+
getSystem: () => this.system,
|
|
67
|
+
isAllowed: key => this.holdAllowed(key),
|
|
68
|
+
noteRequest: request => this.noteHoldRequest(request),
|
|
69
|
+
});
|
|
64
70
|
this.launch();
|
|
65
71
|
});
|
|
66
72
|
api.on('shutdown', () => {
|
|
@@ -150,7 +156,8 @@ export class EcobeeSecurityPlatform {
|
|
|
150
156
|
accessory,
|
|
151
157
|
cloud: this.cloud,
|
|
152
158
|
home,
|
|
153
|
-
|
|
159
|
+
// The ecobee app reads the security state about every 10 s; polling faster would stand out.
|
|
160
|
+
pollInterval: 10,
|
|
154
161
|
store: this.store,
|
|
155
162
|
save: () => this.save(),
|
|
156
163
|
offerNight: this.config.offerNight === true,
|
|
@@ -171,12 +178,41 @@ export class EcobeeSecurityPlatform {
|
|
|
171
178
|
const m = home.monitoring;
|
|
172
179
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
173
180
|
+ `${m.homeMonitoringSettings?.isProfessionalMonitoringEnabled ? 'on' : 'off'}).`);
|
|
174
|
-
if (this.store.hold) {
|
|
175
|
-
this.log.info(`
|
|
181
|
+
if (this.store.hold && this.config.allowHolds !== true) {
|
|
182
|
+
this.log.info(`Security holds are off; dropping the hold for ${this.system.holders().join(' and ')} without re-arming Away.`);
|
|
183
|
+
this.system.dropHold();
|
|
184
|
+
} else if (this.store.hold) {
|
|
185
|
+
this.log.info(`Still holding Stay for ${this.system.holders().join(' and ')} (since ${this.store.hold.since}).`);
|
|
176
186
|
}
|
|
177
187
|
this.startAirQuality(home.id);
|
|
178
188
|
}
|
|
179
189
|
|
|
190
|
+
/**
|
|
191
|
+
* May this plugin (or, if it doesn't say, this name) ask for a hold? `true`, `'off'` (holds are off),
|
|
192
|
+
* or `'not allowed'`. Without a `holdPlugins` list (settings from before it), every plugin may.
|
|
193
|
+
*/
|
|
194
|
+
holdAllowed(key) {
|
|
195
|
+
if (this.config.allowHolds !== true) {
|
|
196
|
+
return 'off';
|
|
197
|
+
}
|
|
198
|
+
const list = this.config.holdPlugins;
|
|
199
|
+
return !Array.isArray(list) || list.includes(key) ? true : 'not allowed';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Remember who asked, for the settings page's list of plugins. */
|
|
203
|
+
noteHoldRequest({ key, plugin, by }) {
|
|
204
|
+
const requesters = this.store.holdRequesters ??= {};
|
|
205
|
+
const entry = requesters[key] ??= { names: [] };
|
|
206
|
+
if (plugin) {
|
|
207
|
+
entry.plugin = plugin;
|
|
208
|
+
}
|
|
209
|
+
if (!entry.names.includes(by)) {
|
|
210
|
+
entry.names = [...entry.names, by].slice(-10);
|
|
211
|
+
}
|
|
212
|
+
entry.lastAsked = new Date().toISOString();
|
|
213
|
+
this.save();
|
|
214
|
+
}
|
|
215
|
+
|
|
180
216
|
/** Air quality sensors for thermostats that have them; failures here never affect the security system. */
|
|
181
217
|
async startAirQuality(homeId) {
|
|
182
218
|
const cached = this.cached.filter(a => a.context.kind === AIR_QUALITY);
|
package/lib/security.js
CHANGED
|
@@ -228,7 +228,7 @@ export class SecuritySystem {
|
|
|
228
228
|
this.target = target;
|
|
229
229
|
if (this.store.hold && (target !== Hk.STAY || this.incidents.length)) {
|
|
230
230
|
this.log.info(`Security system changed to ${HK_NAMES[this.incidents.length ? Hk.TRIGGERED : target]} during the `
|
|
231
|
-
+ `${this.
|
|
231
|
+
+ `${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
232
232
|
this.dropHold();
|
|
233
233
|
}
|
|
234
234
|
this.service.updateCharacteristic(SecuritySystemTargetState, target);
|
|
@@ -311,7 +311,7 @@ export class SecuritySystem {
|
|
|
311
311
|
throw new this.api.hap.HapStatusError(this.api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST);
|
|
312
312
|
}
|
|
313
313
|
if (this.store.hold) {
|
|
314
|
-
this.log.info(`Home app changed the security system during the ${this.
|
|
314
|
+
this.log.info(`Home app changed the security system during the ${this.holders().join(' and ')} hold; it won't be put back to Away.`);
|
|
315
315
|
this.dropHold();
|
|
316
316
|
}
|
|
317
317
|
try {
|
|
@@ -358,11 +358,32 @@ export class SecuritySystem {
|
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/** Read the state now (for hold decisions). Resolves false if ecobee can't be read. */
|
|
362
|
+
async refresh() {
|
|
363
|
+
try {
|
|
364
|
+
const home = (await this.cloud.getHomes()).find(h => h.id === this.homeId);
|
|
365
|
+
if (!home) {
|
|
366
|
+
throw new Error('the home is no longer on this ecobee account');
|
|
367
|
+
}
|
|
368
|
+
this.apply(home);
|
|
369
|
+
return true;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
this.log.warn(`Could not read the security state for a hold (${error.message}).`);
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
361
376
|
dropHold() {
|
|
362
377
|
delete this.store.hold;
|
|
363
378
|
this.save();
|
|
364
379
|
}
|
|
365
380
|
|
|
381
|
+
/** Who is holding Stay (several robots can be out at once). Up to 0.4.x `by` was a single name. */
|
|
382
|
+
holders() {
|
|
383
|
+
const by = this.store.hold?.by;
|
|
384
|
+
return Array.isArray(by) ? by : by ? [by] : [];
|
|
385
|
+
}
|
|
386
|
+
|
|
366
387
|
/**
|
|
367
388
|
* Another plugin (the robot vacuum) is about to move around the house. If the system is
|
|
368
389
|
* armed Away (or arming Away), switch it to Stay and remember to put it back. Returns
|
|
@@ -371,20 +392,26 @@ export class SecuritySystem {
|
|
|
371
392
|
*/
|
|
372
393
|
hold(by) {
|
|
373
394
|
return this.serial(async () => {
|
|
374
|
-
|
|
395
|
+
// Decide on ecobee's live state, not the last poll: a disarm moments ago must not become a Stay.
|
|
396
|
+
if (!(await this.refresh()) || !this.monitoring) {
|
|
375
397
|
return { ok: false, held: false, state: 'unknown', reason: 'the security state is unknown' };
|
|
376
398
|
}
|
|
377
399
|
if (this.incidents.length) {
|
|
378
400
|
return { ok: false, held: false, state: HK_NAMES[this.current], reason: 'an alarm incident is open' };
|
|
379
401
|
}
|
|
380
402
|
if (this.store.hold) {
|
|
403
|
+
if (!this.holders().includes(by)) {
|
|
404
|
+
this.store.hold = { ...this.store.hold, by: [...this.holders(), by] };
|
|
405
|
+
this.save();
|
|
406
|
+
this.log.info(`${by} joined the hold; Away comes back once ${this.holders().join(' and ')} are done.`);
|
|
407
|
+
}
|
|
381
408
|
return { ok: true, held: true, state: HK_NAMES[this.target] };
|
|
382
409
|
}
|
|
383
410
|
if (this.target !== Hk.AWAY) {
|
|
384
411
|
return { ok: true, held: false, state: HK_NAMES[this.target] };
|
|
385
412
|
}
|
|
386
413
|
await this.arm(Hk.STAY, `${by} hold`);
|
|
387
|
-
this.store.hold = { by, since: new Date().toISOString() };
|
|
414
|
+
this.store.hold = { by: [by], since: new Date().toISOString() };
|
|
388
415
|
this.save();
|
|
389
416
|
return { ok: true, held: true, state: HK_NAMES[this.target] };
|
|
390
417
|
});
|
|
@@ -393,9 +420,22 @@ export class SecuritySystem {
|
|
|
393
420
|
/** The vacuum is back: put the system back to Away if we moved it and nobody has changed it since. */
|
|
394
421
|
release(by) {
|
|
395
422
|
return this.serial(async () => {
|
|
423
|
+
// A change since the last poll (say, a disarm in the ecobee app) drops the hold in apply(),
|
|
424
|
+
// so a fresh read is what keeps us from arming Away over it. Can't read it: don't arm.
|
|
425
|
+
if (!(await this.refresh())) {
|
|
426
|
+
throw new Error('could not read the security state; not arming Away');
|
|
427
|
+
}
|
|
396
428
|
if (!this.store.hold) {
|
|
397
429
|
return { released: false, state: HK_NAMES[this.target] };
|
|
398
430
|
}
|
|
431
|
+
const others = this.holders().filter(name => name !== by);
|
|
432
|
+
if (others.length) {
|
|
433
|
+
// Someone else is still out: keep Stay until the last one is done.
|
|
434
|
+
this.store.hold = { ...this.store.hold, by: others };
|
|
435
|
+
this.save();
|
|
436
|
+
this.log.info(`${by} is done; staying in Stay for ${others.join(' and ')}.`);
|
|
437
|
+
return { released: false, state: HK_NAMES[this.target], waiting: others };
|
|
438
|
+
}
|
|
399
439
|
if (this.target !== Hk.STAY || this.incidents.length) {
|
|
400
440
|
this.log.info(`${by} is done, but the security system is ${HK_NAMES[this.current]}; leaving it.`);
|
|
401
441
|
this.dropHold();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ecobee-security",
|
|
3
3
|
"displayName": "ecobee Smart Security",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"homepage": "https://github.com/seanfletchr/homebridge-ecobee-security#readme",
|
|
18
18
|
"keywords": [
|
|
19
19
|
"homebridge-plugin",
|
|
20
|
+
"supports-hap",
|
|
20
21
|
"homebridge",
|
|
21
22
|
"homekit",
|
|
22
23
|
"ecobee",
|
|
@@ -40,5 +41,8 @@
|
|
|
40
41
|
],
|
|
41
42
|
"dependencies": {
|
|
42
43
|
"@homebridge/plugin-ui-utils": "^2.2.6"
|
|
44
|
+
},
|
|
45
|
+
"homebridge-security-hold": {
|
|
46
|
+
"socket": "ecobee-security/control.sock"
|
|
43
47
|
}
|
|
44
48
|
}
|