homebridge-ecobee-security 0.4.2 → 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 +52 -29
- package/README.md +113 -15
- package/config.schema.json +51 -16
- package/homebridge-ui/public/index.html +274 -9
- package/homebridge-ui/server.js +72 -0
- package/lib/control.js +29 -5
- package/lib/ecobee.js +3 -2
- package/lib/halt.js +51 -0
- package/lib/keypad.js +240 -0
- package/lib/platform.js +128 -5
- package/lib/security.js +256 -9
- package/package.json +4 -1
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
|
/**
|
|
@@ -60,7 +62,13 @@ export class EcobeeSecurityPlatform {
|
|
|
60
62
|
});
|
|
61
63
|
|
|
62
64
|
api.on('didFinishLaunching', () => {
|
|
63
|
-
this.control = startControlServer({
|
|
65
|
+
this.control = startControlServer({
|
|
66
|
+
path: join(dir, 'control.sock'),
|
|
67
|
+
log,
|
|
68
|
+
getSystem: () => this.system,
|
|
69
|
+
isAllowed: key => this.holdAllowed(key),
|
|
70
|
+
noteRequest: request => this.noteHoldRequest(request),
|
|
71
|
+
});
|
|
64
72
|
this.launch();
|
|
65
73
|
});
|
|
66
74
|
api.on('shutdown', () => {
|
|
@@ -69,6 +77,7 @@ export class EcobeeSecurityPlatform {
|
|
|
69
77
|
this.control?.close();
|
|
70
78
|
this.system?.stop();
|
|
71
79
|
this.airQuality?.stop();
|
|
80
|
+
this.keypad?.stop();
|
|
72
81
|
});
|
|
73
82
|
}
|
|
74
83
|
|
|
@@ -128,7 +137,10 @@ export class EcobeeSecurityPlatform {
|
|
|
128
137
|
const uuid = this.api.hap.uuid.generate(`ecobee-security:${home.id}`);
|
|
129
138
|
const smokeUuid = this.api.hap.uuid.generate(`ecobee-security:smoke:${home.id}`);
|
|
130
139
|
const wantSmoke = this.config.smokeSensor !== false;
|
|
131
|
-
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));
|
|
132
144
|
if (stale.length) {
|
|
133
145
|
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
134
146
|
}
|
|
@@ -150,11 +162,13 @@ export class EcobeeSecurityPlatform {
|
|
|
150
162
|
accessory,
|
|
151
163
|
cloud: this.cloud,
|
|
152
164
|
home,
|
|
153
|
-
|
|
165
|
+
// The ecobee app reads the security state about every 10 s; polling faster would stand out.
|
|
166
|
+
pollInterval: 10,
|
|
154
167
|
store: this.store,
|
|
155
168
|
save: () => this.save(),
|
|
156
169
|
offerNight: this.config.offerNight === true,
|
|
157
170
|
alarmAfterEntryDelay: this.config.alarmTrigger === 'entryDelay',
|
|
171
|
+
returnWait: this.config.taskWaitSeconds ?? 120,
|
|
158
172
|
smokeAccessory,
|
|
159
173
|
});
|
|
160
174
|
if (isNew) {
|
|
@@ -167,14 +181,123 @@ export class EcobeeSecurityPlatform {
|
|
|
167
181
|
} else if (smokeAccessory) {
|
|
168
182
|
this.api.updatePlatformAccessories([smokeAccessory]);
|
|
169
183
|
}
|
|
184
|
+
if (wantArmNow) {
|
|
185
|
+
this.setupArmNow(armNowUuid, home.id);
|
|
186
|
+
}
|
|
170
187
|
this.system.start();
|
|
171
188
|
const m = home.monitoring;
|
|
172
189
|
this.log.info(`Connected to "${home.name}" (${m.armedState}; professional monitoring `
|
|
173
190
|
+ `${m.homeMonitoringSettings?.isProfessionalMonitoringEnabled ? 'on' : 'off'}).`);
|
|
174
|
-
if (this.store.
|
|
175
|
-
|
|
191
|
+
if (this.store.out && this.config.allowHolds !== true) {
|
|
192
|
+
delete this.store.out;
|
|
193
|
+
this.save();
|
|
194
|
+
}
|
|
195
|
+
if (this.store.hold && this.config.allowHolds !== true) {
|
|
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).' : '.'));
|
|
198
|
+
this.system.dropHold();
|
|
199
|
+
} else if (this.store.hold) {
|
|
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.' : '.'));
|
|
176
202
|
}
|
|
177
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
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* May this plugin (or, if it doesn't say, this name) ask for a hold? `true`, `'off'` (holds are off),
|
|
279
|
+
* or `'not allowed'`. Without a `holdPlugins` list (settings from before it), every plugin may.
|
|
280
|
+
*/
|
|
281
|
+
holdAllowed(key) {
|
|
282
|
+
if (this.config.allowHolds !== true) {
|
|
283
|
+
return 'off';
|
|
284
|
+
}
|
|
285
|
+
const list = this.config.holdPlugins;
|
|
286
|
+
return !Array.isArray(list) || list.includes(key) ? true : 'not allowed';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Remember who asked, for the settings page's list of plugins. */
|
|
290
|
+
noteHoldRequest({ key, plugin, by }) {
|
|
291
|
+
const requesters = this.store.holdRequesters ??= {};
|
|
292
|
+
const entry = requesters[key] ??= { names: [] };
|
|
293
|
+
if (plugin) {
|
|
294
|
+
entry.plugin = plugin;
|
|
295
|
+
}
|
|
296
|
+
if (!entry.names.includes(by)) {
|
|
297
|
+
entry.names = [...entry.names, by].slice(-10);
|
|
298
|
+
}
|
|
299
|
+
entry.lastAsked = new Date().toISOString();
|
|
300
|
+
this.save();
|
|
178
301
|
}
|
|
179
302
|
|
|
180
303
|
/** Air quality sensors for thermostats that have them; failures here never affect the security system. */
|