homebridge-ecobee-security 0.5.0 → 0.7.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 +32 -30
- package/README.md +107 -55
- package/config.schema.json +39 -11
- package/homebridge-ui/public/index.html +105 -15
- package/homebridge-ui/server.js +10 -0
- package/lib/control.js +9 -4
- package/lib/ecobee.js +3 -2
- package/lib/halt.js +51 -0
- package/lib/keypad.js +240 -0
- package/lib/platform.js +90 -3
- package/lib/security.js +212 -5
- package/package.json +1 -1
package/homebridge-ui/server.js
CHANGED
|
@@ -72,6 +72,16 @@ class UiServer extends HomebridgePluginUiServer {
|
|
|
72
72
|
return [...plugins.values()];
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
// Locks the plugin found that report keypad unlocks (only that list leaves state.json).
|
|
76
|
+
this.onRequest('/keypad-locks', async () => {
|
|
77
|
+
try {
|
|
78
|
+
const store = JSON.parse(await readFile(join(this.homebridgeStoragePath, 'ecobee-security', 'state.json'), 'utf8'));
|
|
79
|
+
return store.keypadLocks ?? [];
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
75
85
|
this.ready();
|
|
76
86
|
}
|
|
77
87
|
|
package/lib/control.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
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>&plugin=<package>
|
|
4
|
-
//
|
|
3
|
+
// POST /hold?by=<name>&plugin=<package>[&halt=<endpoint>] -> { ok, held, state, reason? }
|
|
4
|
+
// `halt`: a Unix socket path or http(s) URL where the plugin answers POST /halt?by=<name>; it's
|
|
5
|
+
// called when the system is armed Away while it's out (`&armAt=`: back at base or stopped by then;
|
|
6
|
+
// a robot that answers `returning` is waited for, see SecuritySystem.haltRobots).
|
|
7
|
+
// POST /release?by=<name>&plugin=<package> -> { released, state, waiting? } (Away only once every holder has released;
|
|
8
|
+
// also how a robot sent back to its base says it's in, or stopped, so an extended exit delay can end)
|
|
5
9
|
// It's a Unix socket in the plugin's storage folder (mode 600), so only the homebridge user can use it.
|
|
6
10
|
// Holds need `allowHolds` and the asking plugin ticked in the settings (`isAllowed`); otherwise it
|
|
7
11
|
// answers 403 so the caller can log why. Every request is noted (`noteRequest`) so the settings page
|
|
@@ -23,6 +27,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
|
|
|
23
27
|
// Requests are allowed per plugin; one that doesn't say which it is goes by its `by` name.
|
|
24
28
|
const plugin = (url.searchParams.get('plugin') || '').slice(0, 100) || undefined;
|
|
25
29
|
const key = plugin ?? by;
|
|
30
|
+
const halt = (url.searchParams.get('halt') || '').slice(0, 500) || undefined;
|
|
26
31
|
const reply = (code, body) => {
|
|
27
32
|
res.writeHead(code, { 'content-type': 'application/json' });
|
|
28
33
|
res.end(JSON.stringify(body));
|
|
@@ -33,7 +38,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
|
|
|
33
38
|
const system = getSystem();
|
|
34
39
|
const releasing = url.pathname === '/release';
|
|
35
40
|
noteRequest?.({ key, plugin, by });
|
|
36
|
-
if (!(releasing && system?.holders().includes(by))) {
|
|
41
|
+
if (!(releasing && (system?.holders().includes(by) || system?.returningNames().includes(by)))) {
|
|
37
42
|
const allowed = isAllowed(key);
|
|
38
43
|
if (allowed !== true) {
|
|
39
44
|
const why = allowed === 'off'
|
|
@@ -47,7 +52,7 @@ export function startControlServer({ path, log, getSystem, isAllowed, noteReques
|
|
|
47
52
|
return reply(503, { error: 'not connected to ecobee yet' });
|
|
48
53
|
}
|
|
49
54
|
try {
|
|
50
|
-
reply(200, url.pathname === '/hold' ? await system.hold(by) : await system.release(by));
|
|
55
|
+
reply(200, url.pathname === '/hold' ? await system.hold(by, { plugin, halt }) : await system.release(by));
|
|
51
56
|
} catch (error) {
|
|
52
57
|
reply(502, { error: error.message });
|
|
53
58
|
}
|
package/lib/ecobee.js
CHANGED
|
@@ -163,10 +163,11 @@ export class EcobeeCloud {
|
|
|
163
163
|
* @param {string} homeId
|
|
164
164
|
* @param {string} armedState one of ArmedState
|
|
165
165
|
* @param {number} exitDelay seconds; the app sends the exit delay as a timestamp
|
|
166
|
+
* @param {number} [until] the exit delay's end (ms) instead of `exitDelay`
|
|
166
167
|
* @returns the home's monitoring state after the change
|
|
167
168
|
*/
|
|
168
|
-
async setArmedState(homeId, armedState, exitDelay = 0) {
|
|
169
|
-
const exitDelayUntil = new Date(
|
|
169
|
+
async setArmedState(homeId, armedState, exitDelay = 0, until = Date.now() + exitDelay * 1000) {
|
|
170
|
+
const exitDelayUntil = new Date(until).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
170
171
|
const data = await this.graphql('SetArmedStateForHome', SET_MUTATION, {
|
|
171
172
|
input: { homeId, armedState, exitDelayUntil },
|
|
172
173
|
});
|
package/lib/halt.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Ask a plugin that's out working (a robot vacuum) to head back to its base or stop where it is,
|
|
2
|
+
// because the system is being armed Away. Plugins give their halt endpoint with their hold request (`&halt=`): a Unix socket path,
|
|
3
|
+
// or an http(s) URL. POST <endpoint>/halt?by=<name>&armAt=<ISO time> -> 2xx, optionally JSON
|
|
4
|
+
// { halted, returning?, reason? }. `armAt` is when the motion sensors arm (the end of the exit delay),
|
|
5
|
+
// so a robot can head back to its base in time; without it, it stops where it is.
|
|
6
|
+
|
|
7
|
+
import { request as httpRequest } from 'node:http';
|
|
8
|
+
import { request as httpsRequest } from 'node:https';
|
|
9
|
+
|
|
10
|
+
const TIMEOUT = 5 * 1000;
|
|
11
|
+
|
|
12
|
+
/** Resolves `{ halted, returning?, reason? }`; rejects if the plugin can't be reached. */
|
|
13
|
+
export function sendHalt(endpoint, by, armAt) {
|
|
14
|
+
const query = `?by=${encodeURIComponent(by)}` + (armAt ? `&armAt=${encodeURIComponent(new Date(armAt).toISOString())}` : '');
|
|
15
|
+
const options = { method: 'POST', timeout: TIMEOUT };
|
|
16
|
+
let request = httpRequest;
|
|
17
|
+
if (/^https?:\/\//i.test(endpoint)) {
|
|
18
|
+
const url = new URL(endpoint);
|
|
19
|
+
Object.assign(options, {
|
|
20
|
+
protocol: url.protocol, hostname: url.hostname, port: url.port || undefined,
|
|
21
|
+
auth: url.username ? `${decodeURIComponent(url.username)}:${decodeURIComponent(url.password)}` : undefined,
|
|
22
|
+
path: `${url.pathname.replace(/\/$/, '')}/halt${query}`,
|
|
23
|
+
});
|
|
24
|
+
request = url.protocol === 'https:' ? httpsRequest : httpRequest;
|
|
25
|
+
} else {
|
|
26
|
+
Object.assign(options, { socketPath: endpoint, path: `/halt${query}` });
|
|
27
|
+
}
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const req = request(options, (res) => {
|
|
30
|
+
let body = '';
|
|
31
|
+
res.setEncoding('utf8');
|
|
32
|
+
res.on('data', chunk => { body += chunk; });
|
|
33
|
+
res.on('end', () => {
|
|
34
|
+
let json = {};
|
|
35
|
+
try {
|
|
36
|
+
json = body.trim() ? JSON.parse(body) : {};
|
|
37
|
+
} catch {
|
|
38
|
+
// The status code is what counts.
|
|
39
|
+
}
|
|
40
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
41
|
+
return reject(new Error(json.error ?? `answered ${res.statusCode}`));
|
|
42
|
+
}
|
|
43
|
+
resolve({ halted: json.halted ?? true, returning: json.returning === true, reason: json.reason });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
req.on('timeout', () => req.destroy(new Error('no answer')));
|
|
47
|
+
req.on('error', error => reject(error.code === 'ENOENT' || error.code === 'ECONNREFUSED'
|
|
48
|
+
? new Error('the plugin is not running') : error));
|
|
49
|
+
req.end();
|
|
50
|
+
});
|
|
51
|
+
}
|
package/lib/keypad.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// Disarm on a keypad unlock: watch the locks on the other bridges of this Homebridge for an unlock
|
|
2
|
+
// that the lock says was done with its keypad (HomeKit's Lock Last Known Action). Child bridges run
|
|
3
|
+
// in their own processes, so the locks are read over HAP on 127.0.0.1, which Homebridge allows with
|
|
4
|
+
// just the bridge's PIN in insecure mode (the Homebridge UI's accessory control uses the same).
|
|
5
|
+
// HAP events need a paired session, so the locks are polled; reading a few values locally every
|
|
6
|
+
// second costs next to nothing.
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
|
|
10
|
+
const POLL = 1000;
|
|
11
|
+
// No locks found (or a bridge went away): look again this often.
|
|
12
|
+
const RESCAN = 10 * 60 * 1000;
|
|
13
|
+
// Other child bridges may still be starting: rescan every 30 s for this long.
|
|
14
|
+
const GRACE = 3 * 60 * 1000;
|
|
15
|
+
const GRACE_RESCAN = 30 * 1000;
|
|
16
|
+
const TIMEOUT = 3000;
|
|
17
|
+
// An unlock whose "by keypad" comes in a later update than the state still counts within this.
|
|
18
|
+
const LABEL_LAG = 5000;
|
|
19
|
+
|
|
20
|
+
const INFO = '3E';
|
|
21
|
+
const LOCK = '45';
|
|
22
|
+
const NAME = '23';
|
|
23
|
+
const CONFIGURED_NAME = 'E3';
|
|
24
|
+
const SERIAL = '30';
|
|
25
|
+
const CURRENT = '1D';
|
|
26
|
+
const LAST_ACTION = '1C';
|
|
27
|
+
|
|
28
|
+
const UNSECURED = 0;
|
|
29
|
+
const UNSECURED_BY_KEYPAD = 5;
|
|
30
|
+
|
|
31
|
+
/** Apple's HAP types come as "45" or "00000045-0000-1000-8000-0026BB765291". */
|
|
32
|
+
function shortType(type) {
|
|
33
|
+
const t = String(type).toUpperCase();
|
|
34
|
+
if (t.includes('-') && !t.endsWith('-0000-1000-8000-0026BB765291')) {
|
|
35
|
+
return t;
|
|
36
|
+
}
|
|
37
|
+
return t.split('-')[0].replace(/^0+/, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class KeypadLocks {
|
|
41
|
+
/**
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.configPath Homebridge's config.json (bridge ports and PINs)
|
|
44
|
+
* @param {string} opts.ownPlatform this plugin's platform name (its own bridge is skipped)
|
|
45
|
+
* @param {(lock: {key: string, name: string, bridge: string}) => void} opts.onKeypadUnlock
|
|
46
|
+
* @param {(locks: {key: string, name: string, bridge: string}[]) => void} [opts.onLocks] the locks found, after each scan
|
|
47
|
+
*/
|
|
48
|
+
constructor({ configPath, ownPlatform, log, onKeypadUnlock, onLocks }) {
|
|
49
|
+
this.configPath = configPath;
|
|
50
|
+
this.ownPlatform = ownPlatform;
|
|
51
|
+
this.log = log;
|
|
52
|
+
this.onKeypadUnlock = onKeypadUnlock;
|
|
53
|
+
this.onLocks = onLocks;
|
|
54
|
+
/** @type {Map<string, object>} key → lock: { key, name, bridge, aid, current, last, state, lastAction, pendingUntil } */
|
|
55
|
+
this.locks = new Map();
|
|
56
|
+
this.scannedAt = 0;
|
|
57
|
+
this.warned = new Set();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
start() {
|
|
61
|
+
this.startedAt = Date.now();
|
|
62
|
+
this.timer = setInterval(() => this.poll(), POLL);
|
|
63
|
+
this.poll();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
stop() {
|
|
67
|
+
clearInterval(this.timer);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Every bridge in config.json with a fixed port, except this plugin's own. */
|
|
71
|
+
bridges() {
|
|
72
|
+
const config = JSON.parse(readFileSync(this.configPath, 'utf8'));
|
|
73
|
+
const main = config.bridge ?? {};
|
|
74
|
+
const list = [{ name: main.name ?? 'Homebridge', port: main.port, pin: main.pin }];
|
|
75
|
+
for (const block of [...(config.platforms ?? []), ...(config.accessories ?? [])]) {
|
|
76
|
+
const child = block?._bridge;
|
|
77
|
+
if (child?.port && block.platform !== this.ownPlatform) {
|
|
78
|
+
list.push({ name: child.name ?? block.platform ?? block.accessory, port: child.port, pin: child.pin ?? main.pin });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return list.filter(b => b.port && b.pin);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async request(bridge, path) {
|
|
85
|
+
const response = await fetch(`http://127.0.0.1:${bridge.port}${path}`, {
|
|
86
|
+
headers: { Authorization: bridge.pin },
|
|
87
|
+
signal: AbortSignal.timeout(TIMEOUT),
|
|
88
|
+
});
|
|
89
|
+
if (response.status === 401 || response.status === 470) {
|
|
90
|
+
const error = new Error('not allowed');
|
|
91
|
+
error.auth = true;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`HTTP ${response.status}`);
|
|
96
|
+
}
|
|
97
|
+
return response.json();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Find every lock that reports how it was last locked or unlocked. */
|
|
101
|
+
async scan() {
|
|
102
|
+
this.scannedAt = Date.now();
|
|
103
|
+
let bridges;
|
|
104
|
+
try {
|
|
105
|
+
bridges = this.bridges();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
this.warnOnce('config', `Keypad unlock: could not read the Homebridge config (${error.message}).`);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const found = new Map();
|
|
111
|
+
let denied = 0;
|
|
112
|
+
for (const bridge of bridges) {
|
|
113
|
+
let data;
|
|
114
|
+
try {
|
|
115
|
+
data = await this.request(bridge, '/accessories');
|
|
116
|
+
} catch (error) {
|
|
117
|
+
denied += error.auth ? 1 : 0;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const accessory of data.accessories ?? []) {
|
|
121
|
+
const services = accessory.services ?? [];
|
|
122
|
+
const lock = services.find(s => shortType(s.type) === LOCK);
|
|
123
|
+
const all = services.flatMap(s => s.characteristics ?? []);
|
|
124
|
+
const current = lock?.characteristics?.find(c => shortType(c.type) === CURRENT);
|
|
125
|
+
const last = all.find(c => shortType(c.type) === LAST_ACTION);
|
|
126
|
+
if (!current || !last) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const info = services.find(s => shortType(s.type) === INFO)?.characteristics ?? [];
|
|
130
|
+
const nameOf = chars => chars.find(c => shortType(c.type) === CONFIGURED_NAME)?.value
|
|
131
|
+
|| chars.find(c => shortType(c.type) === NAME)?.value;
|
|
132
|
+
const name = nameOf(lock.characteristics) || nameOf(info) || `Lock ${accessory.aid}`;
|
|
133
|
+
const serial = info.find(c => shortType(c.type) === SERIAL)?.value;
|
|
134
|
+
const key = serial ? String(serial) : `${bridge.name}/${name}`;
|
|
135
|
+
const known = this.locks.get(key);
|
|
136
|
+
found.set(key, {
|
|
137
|
+
key, name, bridge, aid: accessory.aid, current: current.iid, last: last.iid,
|
|
138
|
+
// Keep what we knew if it's the same lock on the same bridge; otherwise start from its first read.
|
|
139
|
+
...(known?.bridge.port === bridge.port && known.aid === accessory.aid
|
|
140
|
+
? { state: known.state, lastAction: known.lastAction, pendingUntil: known.pendingUntil } : {}),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (denied && denied === bridges.length) {
|
|
145
|
+
this.warnOnce('denied', 'Keypad unlock: Homebridge didn\'t let the plugin read the other bridges. Turn on insecure mode '
|
|
146
|
+
+ '(Homebridge Settings > Homebridge Insecure Mode / Accessory Control) and restart Homebridge.');
|
|
147
|
+
}
|
|
148
|
+
const before = [...this.locks.keys()].sort().join();
|
|
149
|
+
this.locks = found;
|
|
150
|
+
if ([...found.keys()].sort().join() !== before) {
|
|
151
|
+
if (found.size) {
|
|
152
|
+
this.log.info(`Keypad unlock: watching ${[...found.values()].map(l => `"${l.name}" (${l.bridge.name})`).join(', ')}.`);
|
|
153
|
+
this.warned.delete('none');
|
|
154
|
+
}
|
|
155
|
+
this.onLocks?.([...found.values()].map(({ key, name, bridge }) => ({ key, name, bridge: bridge.name })));
|
|
156
|
+
}
|
|
157
|
+
if (!found.size && !this.starting()) {
|
|
158
|
+
this.warnOnce('none', 'Keypad unlock: no lock on this Homebridge reports keypad unlocks; checking again every 10 minutes.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async poll() {
|
|
163
|
+
if (this.polling) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.polling = true;
|
|
167
|
+
try {
|
|
168
|
+
const every = this.starting() ? GRACE_RESCAN : RESCAN;
|
|
169
|
+
if (this.rescan || (!this.locks.size && Date.now() - this.scannedAt >= every)) {
|
|
170
|
+
this.rescan = false;
|
|
171
|
+
await this.scan();
|
|
172
|
+
}
|
|
173
|
+
const byBridge = new Map();
|
|
174
|
+
for (const lock of this.locks.values()) {
|
|
175
|
+
byBridge.set(lock.bridge.port, [...(byBridge.get(lock.bridge.port) ?? []), lock]);
|
|
176
|
+
}
|
|
177
|
+
await Promise.all([...byBridge.values()].map(locks => this.read(locks)));
|
|
178
|
+
} finally {
|
|
179
|
+
this.polling = false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async read(locks) {
|
|
184
|
+
const ids = locks.flatMap(l => [`${l.aid}.${l.current}`, `${l.aid}.${l.last}`]).join(',');
|
|
185
|
+
let values;
|
|
186
|
+
try {
|
|
187
|
+
const data = await this.request(locks[0].bridge, `/characteristics?id=${ids}`);
|
|
188
|
+
values = new Map((data.characteristics ?? []).map(c => [`${c.aid}.${c.iid}`, c.value]));
|
|
189
|
+
} catch {
|
|
190
|
+
// Bridge restarting (or the lock moved): forget the states, so an unlock isn't seen in
|
|
191
|
+
// whatever it reports when it's back, and look for the locks again (at most every 30 s).
|
|
192
|
+
locks.forEach(l => Object.assign(l, { state: undefined, lastAction: undefined, pendingUntil: undefined }));
|
|
193
|
+
if (Date.now() - this.scannedAt >= GRACE_RESCAN) {
|
|
194
|
+
this.rescan = true;
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const lock of locks) {
|
|
199
|
+
this.update(lock, values.get(`${lock.aid}.${lock.current}`), values.get(`${lock.aid}.${lock.last}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Only an unlock counts, never a label: the lock must go to unlocked while saying it was unlocked
|
|
205
|
+
* with its keypad (in the same update, or within a few seconds after). An app unlock passes through
|
|
206
|
+
* other labels, and a label left from an earlier keypad unlock has no state change to go with it.
|
|
207
|
+
*/
|
|
208
|
+
update(lock, state, lastAction) {
|
|
209
|
+
if (state === undefined || lastAction === undefined) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const first = lock.state === undefined;
|
|
213
|
+
const unlocked = !first && state === UNSECURED && lock.state !== UNSECURED;
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
if (state !== lock.state) {
|
|
216
|
+
lock.pendingUntil = unlocked ? now + LABEL_LAG : undefined;
|
|
217
|
+
}
|
|
218
|
+
lock.state = state;
|
|
219
|
+
lock.lastAction = lastAction;
|
|
220
|
+
if (lock.pendingUntil && now > lock.pendingUntil) {
|
|
221
|
+
lock.pendingUntil = undefined;
|
|
222
|
+
}
|
|
223
|
+
if (lock.pendingUntil && lastAction === UNSECURED_BY_KEYPAD) {
|
|
224
|
+
lock.pendingUntil = undefined;
|
|
225
|
+
this.onKeypadUnlock({ key: lock.key, name: lock.name, bridge: lock.bridge.name });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Within the start-up grace period, when other bridges may not be up yet. */
|
|
230
|
+
starting() {
|
|
231
|
+
return Date.now() - this.startedAt < GRACE;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
warnOnce(key, message) {
|
|
235
|
+
if (!this.warned.has(key)) {
|
|
236
|
+
this.warned.add(key);
|
|
237
|
+
this.log.warn(message);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
package/lib/platform.js
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { EcobeeCloud, SignInRequiredError } from './ecobee.js';
|
|
5
5
|
import { startControlServer } from './control.js';
|
|
6
6
|
import { AirQualitySensors } from './airquality.js';
|
|
7
|
+
import { KeypadLocks } from './keypad.js';
|
|
7
8
|
import { SecuritySystem } from './security.js';
|
|
8
9
|
import { VERSION } from './version.js';
|
|
9
10
|
|
|
@@ -14,6 +15,7 @@ const RETRY_START = 60 * 1000;
|
|
|
14
15
|
const AIR_QUALITY_RETRY = 10 * 60 * 1000;
|
|
15
16
|
const AIR_QUALITY = 'airQuality';
|
|
16
17
|
const SMOKE = 'smoke';
|
|
18
|
+
const ARM_NOW = 'armAwayNow';
|
|
17
19
|
|
|
18
20
|
export class EcobeeSecurityPlatform {
|
|
19
21
|
/**
|
|
@@ -75,6 +77,7 @@ export class EcobeeSecurityPlatform {
|
|
|
75
77
|
this.control?.close();
|
|
76
78
|
this.system?.stop();
|
|
77
79
|
this.airQuality?.stop();
|
|
80
|
+
this.keypad?.stop();
|
|
78
81
|
});
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -134,7 +137,10 @@ export class EcobeeSecurityPlatform {
|
|
|
134
137
|
const uuid = this.api.hap.uuid.generate(`ecobee-security:${home.id}`);
|
|
135
138
|
const smokeUuid = this.api.hap.uuid.generate(`ecobee-security:smoke:${home.id}`);
|
|
136
139
|
const wantSmoke = this.config.smokeSensor !== false;
|
|
137
|
-
const
|
|
140
|
+
const armNowUuid = this.api.hap.uuid.generate(`ecobee-security:arm-away-now:${home.id}`);
|
|
141
|
+
const wantArmNow = this.config.allowHolds === true && this.config.armAwayNowSwitch === true;
|
|
142
|
+
const stale = this.cached.filter(a => a.UUID !== uuid && a.context.kind !== AIR_QUALITY
|
|
143
|
+
&& !(wantSmoke && a.UUID === smokeUuid) && !(wantArmNow && a.UUID === armNowUuid));
|
|
138
144
|
if (stale.length) {
|
|
139
145
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
140
146
|
}
|
|
@@ -162,6 +168,7 @@ export class EcobeeSecurityPlatform {
|
|
|
162
168
|
save: () => this.save(),
|
|
163
169
|
offerNight: this.config.offerNight === true,
|
|
164
170
|
alarmAfterEntryDelay: this.config.alarmTrigger === 'entryDelay',
|
|
171
|
+
returnWait: this.config.taskWaitSeconds ?? 120,
|
|
165
172
|
smokeAccessory,
|
|
166
173
|
});
|
|
167
174
|
if (isNew) {
|
|
@@ -174,17 +181,97 @@ export class EcobeeSecurityPlatform {
|
|
|
174
181
|
} else if (smokeAccessory) {
|
|
175
182
|
this.api.updatePlatformAccessories([smokeAccessory]);
|
|
176
183
|
}
|
|
184
|
+
if (wantArmNow) {
|
|
185
|
+
this.setupArmNow(armNowUuid, home.id);
|
|
186
|
+
}
|
|
177
187
|
this.system.start();
|
|
178
188
|
const m = home.monitoring;
|
|
179
189
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
180
190
|
+ `${m.homeMonitoringSettings?.isProfessionalMonitoringEnabled ? 'on' : 'off'}).`);
|
|
191
|
+
if (this.store.out && this.config.allowHolds !== true) {
|
|
192
|
+
delete this.store.out;
|
|
193
|
+
this.save();
|
|
194
|
+
}
|
|
181
195
|
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
|
|
196
|
+
this.log.info(`Security holds are off; dropping the hold for ${this.system.holders().join(' and ')} without re-arming Away`
|
|
197
|
+
+ (this.store.hold.away ? ' (the Home app\'s Away request is dropped too; arm Away again if you still want it).' : '.'));
|
|
183
198
|
this.system.dropHold();
|
|
184
199
|
} else if (this.store.hold) {
|
|
185
|
-
this.log.info(`Still holding Stay for ${this.system.holders().join(' and ')} (since ${this.store.hold.since})
|
|
200
|
+
this.log.info(`Still holding Stay for ${this.system.holders().join(' and ')} (since ${this.store.hold.since})`
|
|
201
|
+
+ (this.store.hold.away ? '; the Home app asked for Away, which follows the release.' : '.'));
|
|
186
202
|
}
|
|
187
203
|
this.startAirQuality(home.id);
|
|
204
|
+
this.startKeypad();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Disarm when a lock on another bridge is unlocked with its keypad (`keypadDisarm`), for the locks
|
|
209
|
+
* ticked in `keypadLocks` (no list: every lock). The locks found go into state.json for the
|
|
210
|
+
* settings page.
|
|
211
|
+
*/
|
|
212
|
+
startKeypad() {
|
|
213
|
+
if (this.config.keypadDisarm !== true) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const allowed = this.config.keypadLocks;
|
|
217
|
+
this.keypad = new KeypadLocks({
|
|
218
|
+
configPath: this.api.user.configPath(),
|
|
219
|
+
ownPlatform: PLATFORM_NAME,
|
|
220
|
+
log: this.log,
|
|
221
|
+
onKeypadUnlock: ({ key, name }) => {
|
|
222
|
+
if (Array.isArray(allowed) && !allowed.includes(key)) {
|
|
223
|
+
this.log.info(`"${name}" was unlocked with its keypad; it isn't ticked in the settings, so the security system is left as it is.`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
this.system.keypadDisarm(`"${name}"`).catch(() => {});
|
|
227
|
+
},
|
|
228
|
+
onLocks: locks => {
|
|
229
|
+
this.store.keypadLocks = locks;
|
|
230
|
+
this.save();
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
this.keypad.start();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The Arm Away Now switch (its own accessory, so the Home app doesn't fold it into the security
|
|
238
|
+
* tile): sends robots that are out back to their base (or stops them) and arms Away now, then turns itself off. Away from the
|
|
239
|
+
* security tile waits for robots instead, because HomeKit can't tell who asked.
|
|
240
|
+
*/
|
|
241
|
+
setupArmNow(uuid, homeId) {
|
|
242
|
+
const { Service, Characteristic, HapStatusError, HAPStatus } = this.api.hap;
|
|
243
|
+
let accessory = this.cached.find(a => a.UUID === uuid);
|
|
244
|
+
const isNew = !accessory;
|
|
245
|
+
if (isNew) {
|
|
246
|
+
accessory = new this.api.platformAccessory('Arm Away Now', uuid);
|
|
247
|
+
accessory.context.kind = ARM_NOW;
|
|
248
|
+
}
|
|
249
|
+
accessory.getService(Service.AccessoryInformation)
|
|
250
|
+
.setCharacteristic(Characteristic.Manufacturer, 'ecobee')
|
|
251
|
+
.setCharacteristic(Characteristic.Model, 'Smart Security: Arm Away Now')
|
|
252
|
+
.setCharacteristic(Characteristic.SerialNumber, `${homeId}-arm-away-now`)
|
|
253
|
+
.setCharacteristic(Characteristic.FirmwareRevision, VERSION);
|
|
254
|
+
const sw = accessory.getService(Service.Switch) ?? accessory.addService(Service.Switch, accessory.displayName);
|
|
255
|
+
const off = () => setTimeout(() => sw.updateCharacteristic(Characteristic.On, false), 1000);
|
|
256
|
+
sw.getCharacteristic(Characteristic.On)
|
|
257
|
+
.onGet(() => false)
|
|
258
|
+
.onSet(async (on) => {
|
|
259
|
+
if (!on) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
await this.system.armAwayNow();
|
|
264
|
+
} catch {
|
|
265
|
+
throw new HapStatusError(HAPStatus.SERVICE_COMMUNICATION_FAILURE);
|
|
266
|
+
} finally {
|
|
267
|
+
off();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
if (isNew) {
|
|
271
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
272
|
+
} else {
|
|
273
|
+
this.api.updatePlatformAccessories([accessory]);
|
|
274
|
+
}
|
|
188
275
|
}
|
|
189
276
|
|
|
190
277
|
/**
|