homebridge-freeathome-local-api
Version:
Control your free@home setup using the local API provided by your System Access Point
377 lines • 20.1 kB
JavaScript
import { SystemAccessPoint, } from "freeathome-local-api-client";
import { globalAgent } from "node:https";
import { Agent, setGlobalDispatcher } from "undici";
import { WeatherStationBrightnessSensorAccessory } from "./brightnessSensorAccessory.js";
import { ContactSensorAccessory } from "./contactSensorAccessory.js";
import { DimmerAccessory } from "./dimmerAccessory.js";
import { DoorOpenerAccessory } from "./doorOpenerAccessory.js";
import { isFreeAtHomeAccessory, } from "./freeAtHomeContext.js";
import { experimentallySupportedFunctionIDs, FunctionID, } from "./functionId.js";
import { MotionSensorAccessory } from "./motionSensorAccessory.js";
import { RadiatorActuatorAccessory } from "./radiatorActuatorAccessory.js";
import { RoomTemperatureControllerAccessory } from "./roomTemperatureControllerAccessory.js";
import { SceneAccessory } from "./sceneAccessory.js";
import { SceneSensorAccessory } from "./sceneSensorAccessory.js";
import { PLATFORM_NAME, PLUGIN_NAME } from "./settings.js";
import { ShutterActuatorAccessory } from "./shutterActuatorAccessory.js";
import { SmokeDetectorAccessory } from "./smokeDetectorAccessory.js";
import { StaircaseLightSensorAccessory } from "./staircaseLightSensor.js";
import { SwitchActuatorAccessory } from "./switchActuatorAccessory.js";
import { SwitchSensorAccessory } from "./switchSensorAccessory.js";
import { WeatherStationTemperatureSensorAccessory } from "./temperatureSensorAccessory.js";
import { TriggerSensorAccessory } from "./triggerSensorAccessory.js";
import { AccessoryType } from "./typeMappings.js";
import { APP_VERSION, EmptyGuid } from "./util.js";
const DelayFactor = 200;
/** The free@home Homebridge platform. */
export class FreeAtHomeHomebridgePlatform {
log;
config;
api;
/** The service reference */
Service;
/** The characteristic reference */
Characteristic;
/** The list of restored cached accessories */
accessories = [];
removedAccessories = [];
/** The system access point */
sysap;
fahAccessories = new Map();
fahLogger;
webSocketSubscription;
wsConnectionAttempt = 0;
maxWsRetryCount;
get experimentalMode() {
return this.config.experimental;
}
/**
* 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(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
// log plugin version
this.log.debug("Homebridge free@home Local API Plugin ", APP_VERSION);
this.Service = this.api.hap.Service;
this.Characteristic = this.api.hap.Characteristic;
// set maximum reconnection attempt count
this.maxWsRetryCount = this.config.maxWsRetryCount ?? 10;
// Create a logger for the free@home Local API Client
this.fahLogger = {
debug: (message, ...optionalParams) => log.debug(message, ...optionalParams),
error: (message, ...optionalParams) => log.error(message, ...optionalParams),
log: (message, ...optionalParams) => log.info(message, ...optionalParams),
warn: (message, ...optionalParams) => log.warn(message, ...optionalParams),
};
// Create a system access point instance
this.sysap = new SystemAccessPoint(this.config.host, this.config.user, this.config.password, this.config.tlsEnabled, this.config.verboseErrors, this.fahLogger);
// TLS Verification
setGlobalDispatcher(new Agent({
connect: {
rejectUnauthorized: !this.config.disableCertificateVerification,
},
}));
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, reason) => {
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) => 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.
*/
configureAccessory(accessory) {
// 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 disallowed devices
if (!this.isAllowedChannel(accessory.context.deviceSerial, accessory.context.channelId)) {
this.removedAccessories.push(accessory);
this.log.warn("Removing accessory from cache (channel not on allow list):", 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. */
async discoverDevices() {
// Get the SysAP configuration
const config = await this.sysap.getConfiguration();
// Enmumerate the devices
for (const serial of Object.keys(config[EmptyGuid].devices)) {
// Filter unsupported devices by serial range
if (!serial.startsWith("ABB") && // free@home default
!serial.startsWith("0007") && // new free@home devices
!serial.startsWith("E11") && // alarm services
!serial.startsWith("7EB1") && // weather station
!serial.startsWith("FFFF480") // Scenes
// !serial.startsWith("FFFF4000") // Light groups
)
continue;
// Filter devices without channels
const device = config[EmptyGuid].devices[serial];
if (!device.channels)
continue;
// 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
for (const channelId of Object.keys(device.channels)) {
try {
this.processChannel(serial, device, channelId, device.channels[channelId], locationConfiguredOnDeviceLevel);
}
catch (error) {
this.log.error(`Error processing discovered channel ${serial}/${channelId}`, error);
}
}
}
}
processChannel(serial, device, channelId, channel, locationConfiguredOnDeviceLevel) {
// We are enumerating the keys of the channels object. Neither the channels object nor the channelId can possibly be undefined.
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(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.
/* c8 ignore next */
if (!isFreeAtHomeAccessory(accessory, this.fahLogger))
return;
// create accessory
this.createAccessory(serial, channel.functionID, // The functionID is guaranteed to be defined by the isViableChannel function.
channelId, accessory);
}
isViableChannel(serial, channelId, channel, locationConfiguredOnDeviceLevel) {
// Filter unsupported channels
if (!(channel.functionID &&
Object.values(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 allowed devices
if (!this.isAllowedChannel(serial, channelId)) {
this.log.debug(`Ignored ${serial} (${channelId}): Channel is not listed on the allow list.`);
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())) {
return false;
}
return true;
}
createAccessory(serial, functionID, channelId, accessory) {
const accessoryType = this.resolveAccessoryType(serial, channelId);
switch (functionID.toUpperCase()) {
case FunctionID.FID_SWITCH_SENSOR:
case FunctionID.FID_DIMMING_SENSOR:
case FunctionID.FID_DES_DOOR_RINGING_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:
case FunctionID.FID_PANEL_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:
case FunctionID.FID_DIMMING_ACTUATOR_TYPE0:
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:
case FunctionID.FID_SPECIAL_SCENE_PANIC:
case FunctionID.FID_SPECIAL_SCENE_ALL_OFF:
case FunctionID.FID_SPECIAL_SCENE_ALL_BLINDS_UP:
case FunctionID.FID_SPECIAL_SCENE_ALL_BLINDS_DOWN:
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_WELCOME_IP_DOOR_OPEN_SENSOR:
this.fahAccessories.set(`${serial}_${channelId}`, new ContactSensorAccessory(this, accessory, 2));
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}'!`);
}
}
isAllowedChannel(device, channel) {
if (!this.config.allowedChannels)
return true;
return this.config.allowedChannels.some((e) => e.toUpperCase() === `${device.toUpperCase()}/*` ||
e.toUpperCase() === `${device.toUpperCase()}/${channel.toUpperCase()}`);
}
isIgnoredChannel(device, channel) {
if (!this.config.ignoredChannels)
return false;
return this.config.ignoredChannels.some((e) => e.toUpperCase() === `${device.toUpperCase()}/*` ||
e.toUpperCase() === `${device.toUpperCase()}/${channel.toUpperCase()}`);
}
processWebSocketMesage(message) {
// Get data point identifiers
const datapoints = Object.keys(message[EmptyGuid].datapoints);
for (const datapoint of datapoints) {
// Ignore data points that have an unexpected format
const match = /^([a-z0-9]{12})\/(ch[\da-f]{4})\/([io]dp\d{4})$/i.exec(datapoint);
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]);
}
}
resolveAccessoryType(device, channel) {
if (!this.config.typeMappings)
return AccessoryType.Undefined;
const key = `${device.toUpperCase()}/${channel.toUpperCase()}`;
const mappings = this.config.typeMappings.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;
}
}
//# sourceMappingURL=platform.js.map