forked from pgerke/homebridge-freeathome-local-api
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathplatform.ts
539 lines (500 loc) · 18.9 KB
/
platform.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
import {
API,
DynamicPlatformPlugin,
Logger,
PlatformAccessory,
PlatformConfig,
Service,
Characteristic,
UnknownContext,
} from "homebridge";
import { PLATFORM_NAME, PLUGIN_NAME } from "./settings";
import {
Channel,
SystemAccessPoint,
Logger as FreeAtHomeLogger,
Configuration,
WebSocketMessage,
} from "freeathome-local-api-client";
import { SwitchActuatorAccessory } from "./switchActuatorAccessory";
import { FreeAtHomeContext, isFreeAtHomeAccessory } from "./freeAtHomeContext";
import { experimentallySupportedFunctionIDs, FunctionID } from "./functionId";
import { FreeAtHomeAccessory } from "./freeAtHomeAccessory";
import { DimmerAccessory } from "./dimmerAccessory";
import { Subscription } from "rxjs";
import { RoomTemperatureControllerAccessory } from "./roomTemperatureControllerAccessory";
import { EmptyGuid } from "./util";
import { SmokeDetectorAccessory } from "./smokeDetectorAccessory";
import { MotionSensorAccessory } from "./motionSensorAccessory";
import { DoorOpenerAccessory } from "./doorOpenerAccessory";
import { ShutterActuatorAccessory } from "./shutterActuatorAccessory";
import { AccessoryType, TypeMapping } from "./typeMappings";
import { globalAgent } from "https";
import { ContactSensorAccessory } from "./contactSensorAccessory";
import { SwitchSensorAccessory } from "./switchSensorAccessory";
import { SceneAccessory } from "./sceneAccessory";
import { SceneSensorAccessory } from "./sceneSensorAccessory";
import { StaircaseLightSensorAccessory } from "./staircaseLightSensor";
import { TriggerSensorAccessory } from "./triggerSensorAccessory";
import { WeatherStationTemperatureSensorAccessory } from "./temperatureSensorAccessory";
import { WeatherStationBrightnessSensorAccessory } from "./brightnessSensorAccessory";
import { RadiatorActuatorAccessory } from "./radiatorActuatorAccessory";
const DelayFactor = 200;
/** The free@home Homebridge platform. */
export class FreeAtHomeHomebridgePlatform implements DynamicPlatformPlugin {
/** The service reference */
public readonly Service: typeof Service = this.api.hap.Service;
/** The characteristic reference */
public readonly Characteristic: typeof Characteristic =
this.api.hap.Characteristic;
/** The list of restored cached accessories */
public readonly accessories: Array<PlatformAccessory<FreeAtHomeContext>> = [];
private readonly removedAccessories: Array<
PlatformAccessory<UnknownContext>
> = [];
/** The system access point */
public readonly sysap: SystemAccessPoint;
private readonly fahAccessories = new Map<string, FreeAtHomeAccessory>();
private readonly fahLogger: FreeAtHomeLogger;
private readonly webSocketSubscription: Subscription;
private wsConnectionAttempt = 0;
private readonly maxWsRetryCount: number;
private get experimentalMode(): boolean {
return this.config.experimental as boolean;
}
/**
* Constructs a new free@home Homebridge platform instance.
* @param log {Logger} The logger instance.
* @param config {PlatformConfig} The platform configuration.
* @param api {API} The API instance.
*/
constructor(
public readonly log: Logger,
public readonly config: PlatformConfig,
public readonly api: API
) {
// set maximum reconnection attempt count
this.maxWsRetryCount = (this.config.maxWsRetryCount as number) ?? 10;
// Create a logger for the free@home Local API Client
this.fahLogger = {
debug: (message?: unknown, ...optionalParams: unknown[]) =>
log.debug(<string>message, ...optionalParams),
error: (message?: unknown, ...optionalParams: unknown[]) =>
log.error(<string>message, ...optionalParams),
log: (message?: unknown, ...optionalParams: unknown[]) =>
log.info(<string>message, ...optionalParams),
warn: (message?: unknown, ...optionalParams: unknown[]) =>
log.warn(<string>message, ...optionalParams),
};
// Create a system access point instance
this.sysap = new SystemAccessPoint(
this.config.host as string,
this.config.user as string,
this.config.password as string,
this.config.tlsEnabled as boolean,
this.config.verboseErrors as boolean,
this.fahLogger
);
globalAgent.options.rejectUnauthorized =
!this.config.disableCertificateVerification;
// React to web socket events
this.sysap.on("websocket-open", () => {
this.wsConnectionAttempt = 0;
});
this.sysap.on("websocket-close", (code: number, reason: Buffer) => {
if (code === 1000) return;
this.log.warn(
`Websocket to System Access Point was closed with code ${code.toString()}: ${reason.toString()}`
);
if (this.wsConnectionAttempt >= this.maxWsRetryCount) {
this.log.error(
"Maximum retry count exceeded. Will not try to reconnect to websocket again."
);
return;
}
const delay = DelayFactor * 2 ** this.wsConnectionAttempt++;
this.log.info(
`Attempting to reconnect in ${delay}ms [${this.wsConnectionAttempt}/${this.maxWsRetryCount}]`
);
setTimeout(
() =>
this.sysap.connectWebSocket(
!this.config.disableCertificateVerification
),
delay
);
});
// Subscribe to web socket messages
this.webSocketSubscription = this.sysap
.getWebSocketMessages()
.subscribe((message: WebSocketMessage) =>
this.processWebSocketMesage(message)
);
// Experimental mode
if (this.experimentalMode) this.log.warn("Experimental Mode enabled!");
this.log.debug("Finished initializing platform:", this.config.name);
// When Homebridge has restored all cached accessories from disk we can start discovery of new accessories.
this.api.on("didFinishLaunching", () => {
log.debug("Executed didFinishLaunching callback");
// Unregister removed accessories
this.api.unregisterPlatformAccessories(
PLUGIN_NAME,
PLATFORM_NAME,
this.removedAccessories
);
// run discovery
this.discoverDevices()
.then(() =>
this.log.info(
`Discovery completed: ${this.fahAccessories.size} accessories detected`
)
)
.catch((error) => this.log.error("Device discovery failed", error));
// Connect to system access point web socket
this.sysap.connectWebSocket(!this.config.disableCertificateVerification);
});
this.api.on("shutdown", () => {
log.debug("Executed shutdown callback");
this.webSocketSubscription.unsubscribe();
});
}
/**
* Configures the specified accessory.
* @param accessory The accessory to be configured.
* @description
* This function is invoked when homebridge restores cached accessories from disk at startup.
* It should be used to setup event handlers for characteristics and update respective values.
*/
public configureAccessory(accessory: PlatformAccessory) {
// Remove cached non-free@home devices
if (!isFreeAtHomeAccessory(accessory, this.fahLogger)) {
this.removedAccessories.push(accessory);
this.log.warn(
"Removing accessory from cache (no free@home device):",
accessory.displayName
);
return;
}
// Remove cached ignored devices
if (
this.isIgnoredChannel(
accessory.context.deviceSerial,
accessory.context.channelId
)
) {
this.removedAccessories.push(accessory);
this.log.warn(
"Removing accessory from cache (channel on ignore list):",
accessory.displayName
);
return;
}
// add the restored accessory to the accessories cache so we can track if it has already been registered
this.log.info("Loading accessory from cache:", accessory.displayName);
this.accessories.push(accessory);
}
/** Discovers the supported free@home devices from the System Access Point. */
private async discoverDevices(): Promise<void> {
// Get the SysAP configuration
const config: Configuration = await this.sysap.getConfiguration();
// Enmumerate the devices
Object.keys(config[EmptyGuid].devices).forEach((serial: string) => {
// Filter unsupported devices by serial range
if (
!serial.startsWith("ABB") && // free@home default
!serial.startsWith("E11") && // alarm services
!serial.startsWith("7EB1") && // weather station
!serial.startsWith("FFFF4800") // Scenes
// !serial.startsWith("FFFF4000") // Light groups
)
return;
// Filter devices without channels
const device = config[EmptyGuid].devices[serial];
if (!device.channels) return;
// Room and Floor may be defined either on device or on channel level.
// Here we check if the location is defined on device level.
const locationConfiguredOnDeviceLevel = !!device.floor && !!device.room;
// Enumerate the channels
Object.keys(device.channels).forEach((channelId: string) => {
try {
// We are enumerating the keys of the channels object. Neither the channels object nor the channelId can possibly be undefined.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const channel = device.channels![channelId];
if (
!this.isViableChannel(
serial,
channelId,
channel,
locationConfiguredOnDeviceLevel
)
)
return;
// Create or restore the accessory
const uuid = this.api.hap.uuid.generate(`${serial}_${channelId}`);
let accessory = this.accessories.find((a) => a.UUID === uuid);
if (accessory) {
// the accessory already exists
this.log.info(
"Restoring existing accessory from cache:",
accessory.displayName
);
// Update context
accessory.context.deviceSerial = serial;
accessory.context.device = device;
accessory.context.channel = channel;
accessory.context.channelId = channelId;
this.api.updatePlatformAccessories([accessory]);
// it is possible to remove platform accessories at any time using `api.unregisterPlatformAccessories`, eg.:
// remove platform accessories when no longer present
// this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [existingAccessory]);
// this.log.info('Removing existing accessory from cache:', existingAccessory.displayName);
} else {
// the accessory does not yet exist, so we need to create it
this.log.info("Adding new accessory:", channel.displayName);
// create a new accessory
accessory = new this.api.platformAccessory<FreeAtHomeContext>(
channel.displayName ?? uuid,
uuid
);
accessory.context.deviceSerial = serial;
accessory.context.device = device;
accessory.context.channel = channel;
accessory.context.channelId = channelId;
// link the accessory to your platform
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [
accessory,
]);
}
// This check is used to apply the type guard so the accessory can be used as a free@home accesory without a cast.
// Given that the accessory context is constructed in the previous lines, it is impossible for the type check to fail.
// Consequently the branch can never be covered and is excluded from the coverage.
/* istanbul ignore next */
if (!isFreeAtHomeAccessory(accessory, this.fahLogger)) return;
// create accessory
this.createAccessory(
serial,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
channel.functionID!,
channelId,
accessory
);
} catch (error) {
this.log.error(
`Error processing discovered channel ${serial}/${channelId}`,
error
);
}
});
});
}
private isViableChannel(
serial: string,
channelId: string,
channel: Channel,
locationConfiguredOnDeviceLevel: boolean
): channel is Channel {
// Filter unsupported channels
if (
!(
channel.functionID &&
Object.values<string>(FunctionID).includes(
channel.functionID.toUpperCase()
)
)
) {
this.log.debug(
`Ignored ${serial} (${channelId}): FunctionID '${
channel.functionID ?? "<UNDEFINED>"
}' is not supported.`
);
return false;
}
// Filter unconfigured devices
if (!locationConfiguredOnDeviceLevel && !(channel.floor && channel.room)) {
this.log.debug(
`Ignored ${serial} (${channelId}): Floor and room are not configured.`
);
return false;
}
// Filter ignored devices
if (this.isIgnoredChannel(serial, channelId)) {
this.log.debug(
`Ignored ${serial} (${channelId}): Channel is listed on the ignore list.`
);
return false;
}
// Filter experimental devices
if (
!this.experimentalMode &&
experimentallySupportedFunctionIDs.includes(
channel.functionID.toUpperCase() as FunctionID
)
) {
return false;
}
return true;
}
private createAccessory(
serial: string,
functionID: string,
channelId: string,
accessory: PlatformAccessory<FreeAtHomeContext>
): void {
const accessoryType = this.resolveAccessoryType(serial, channelId);
switch (functionID.toUpperCase()) {
case FunctionID.FID_SWITCH_SENSOR:
case FunctionID.FID_DIMMING_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new SwitchSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_DES_AUTOMATIC_DOOR_OPENER_ACTUATOR:
case FunctionID.FID_SWITCH_ACTUATOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new SwitchActuatorAccessory(this, accessory, accessoryType)
);
return;
case FunctionID.FID_ROOM_TEMPERATURE_CONTROLLER_MASTER_WITHOUT_FAN:
this.fahAccessories.set(
`${serial}_${channelId}`,
new RoomTemperatureControllerAccessory(this, accessory)
);
return;
case FunctionID.FID_DIMMING_ACTUATOR:
case FunctionID.FID_RGB_ACTUATOR:
case FunctionID.FID_RGB_W_ACTUATOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new DimmerAccessory(this, accessory)
);
return;
case FunctionID.FID_SMOKE_DETECTOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new SmokeDetectorAccessory(this, accessory)
);
return;
case FunctionID.FID_MOVEMENT_DETECTOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new MotionSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_DES_DOOR_OPENER_ACTUATOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new DoorOpenerAccessory(this, accessory)
);
return;
case FunctionID.FID_SHUTTER_ACTUATOR:
case FunctionID.FID_BLIND_ACTUATOR:
case FunctionID.FID_ATTIC_WINDOW_ACTUATOR:
case FunctionID.FID_AWNING_ACTUATOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new ShutterActuatorAccessory(this, accessory)
);
return;
case FunctionID.FID_SCENE:
this.fahAccessories.set(
`${serial}_${channelId}`,
new SceneAccessory(this, accessory)
);
return;
case FunctionID.FID_SCENE_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new SceneSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_WINDOW_DOOR_SENSOR:
case FunctionID.FID_WINDOW_DOOR_POSITION_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new ContactSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_STAIRCASE_LIGHT_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new StaircaseLightSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_TRIGGER:
this.fahAccessories.set(
`${serial}_${channelId}`,
new TriggerSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_TEMPERATURE_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new WeatherStationTemperatureSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_BRIGHTNESS_SENSOR:
this.fahAccessories.set(
`${serial}_${channelId}`,
new WeatherStationBrightnessSensorAccessory(this, accessory)
);
return;
case FunctionID.FID_RADIATOR_ACTUATOR_MASTER:
this.fahAccessories.set(
`${serial}_${channelId}`,
new RadiatorActuatorAccessory(this, accessory)
);
return;
default:
this.log.error(
`${serial} (${channelId}): Cannot configure accessory for FunctionID '${functionID}'!`
);
}
}
private isIgnoredChannel(device: string, channel: string): boolean {
if (!this.config.ignoredChannels) return false;
return (this.config.ignoredChannels as Array<string>).some(
(e) =>
e.toUpperCase() === `${device.toUpperCase()}/*` ||
e.toUpperCase() === `${device.toUpperCase()}/${channel.toUpperCase()}`
);
}
private processWebSocketMesage(message: WebSocketMessage): void {
// Get data point identifiers
const datapoints = Object.keys(message[EmptyGuid].datapoints);
datapoints.forEach((datapoint) => {
// Ignore data points that have an unexpected format
const match = datapoint.match(
/^([a-z0-9]{12})\/(ch[\da-f]{4})\/([io]dp\d{4})$/i
);
if (!match) {
this.log.debug(`Ignored datapoint ${datapoint}: Unexpected format`);
return;
}
// Ignore the data point if we don't have an accessory for it or update the accessory
this.fahAccessories
.get(`${match[1]}_${match[2]}`)
?.updateDatapoint(match[3], message[EmptyGuid].datapoints[datapoint]);
});
}
private resolveAccessoryType(device: string, channel: string): AccessoryType {
if (!this.config.typeMappings) return AccessoryType.Undefined;
const key = `${device.toUpperCase()}/${channel.toUpperCase()}`;
const mappings = (this.config.typeMappings as Array<TypeMapping>).filter(
(e) => e.channel.toUpperCase() === key
);
switch (mappings.length) {
case 0:
return AccessoryType.Undefined;
case 1:
break;
default:
this.log.warn(
`Multiple type mappings are defined for channel '${key}'. The first mapping is used.`
);
break;
}
return AccessoryType[mappings[0].type] ?? AccessoryType.Undefined;
}
}