// platform.ts import { API, APIEvent, DynamicPlatformPlugin, Logging, PlatformAccessory, PlatformConfig, } from 'homebridge'; import { FibaroAccessory } from './fibaroAccessory'; import { FibaroClient } from './fibaro-api'; import { SetFunctions } from './setFunctions'; import { GetFunctions } from './getFunctions'; import * as constants from './constants'; import { Poller } from './pollerupdate'; /** * HomebridgePlatform * This class is the main constructor for your plugin, this is where you should * parse the user config and discover/register accessories with Homebridge. */ export class FibaroHC implements DynamicPlatformPlugin { public readonly Service = this.api.hap.Service; public readonly Characteristic = this.api.hap.Characteristic; // this is used to track restored cached accessories public readonly accessories: PlatformAccessory[] = []; public updateSubscriptions: Array = []; public poller?: Poller; public scenes: Record = {}; public climateZones: Record = {}; public info: Record = {}; public fibaroClient?: FibaroClient; public setFunctions?: SetFunctions; public getFunctions?: GetFunctions; public loginTimeout: NodeJS.Timeout | null = null; public rooms: { id: number; name: string }[] = []; public devices; constructor( public readonly log: Logging, public readonly config: PlatformConfig, public readonly api: API, ) { if (!this.setupConfig()) { return; } this.initializeFibaroClient(); if (this.config.pollerperiod > 0) { this.poller = new Poller(this, this.config.pollerperiod); } this.getFunctions = new GetFunctions(this); this.log.debug('Finished initializing platform:', this.config.name); api.on(APIEvent.DID_FINISH_LAUNCHING, async () => { log.debug('Executed didFinishLaunching callback'); this.login(); }); } private setupConfig() : boolean { if (!this.config) { this.log.error('Fibaro HC configuration: cannot find configuration for the plugin'); return false; } this.setupPollerPeriod(); this.setupThermostatMaxTemp(); this.setupDefaultConfigValues(); this.setupUrl(); return true; } private setupPollerPeriod() { let pollerPeriod = this.config.pollerperiod; if (pollerPeriod === undefined) { pollerPeriod = constants.DEFAULT_POLLER_PERIOD; } else { const parsedValue = typeof pollerPeriod === 'string' ? parseFloat(pollerPeriod) : pollerPeriod; if (isNaN(parsedValue) || parsedValue < 0 || parsedValue > 100) { pollerPeriod = constants.DEFAULT_POLLER_PERIOD; } else { pollerPeriod = parsedValue; } } this.config.pollerperiod = pollerPeriod; } private setupThermostatMaxTemp() { const thermostatMaxTemp = this.config.thermostatmaxtemperature ? parseInt(this.config.thermostatmaxtemperature) : constants.DEFAULT_THERMOSTAT_MAX_TEMP; if (isNaN(thermostatMaxTemp) || thermostatMaxTemp < 0 || thermostatMaxTemp > constants.DEFAULT_THERMOSTAT_MAX_TEMP) { this.config.thermostatmaxtemperature = constants.DEFAULT_THERMOSTAT_MAX_TEMP; } } private setupDefaultConfigValues() { this.config.markDeadDevices = this.config.markDeadDevices ?? false; this.config.thermostattimeout = this.config.thermostattimeout ?? constants.TIME_OFFSET.toString(); this.config.switchglobalvariables = this.config.switchglobalvariables ?? ''; this.config.dimmerglobalvariables = this.config.dimmerglobalvariables ?? ''; this.config.securitysystem = this.config.securitysystem === 'enabled' ? 'enabled' : 'disabled'; this.config.FibaroTemperatureUnit = this.config.FibaroTemperatureUnit ?? 'C'; this.config.addRoomNameToDeviceName = this.config.addRoomNameToDeviceName ?? 'disabled'; this.config.logsLevel = this.config.logsLevel ?? '1'; this.config.doorbellDeviceId = this.config.doorbellDeviceId ?? '0'; } private setupUrl() { if (validUrl(this.config.url)) { this.config.url = this.config.url.startsWith('http') ? this.config.url : 'http://' + this.config.url; } else if (validUrl(this.config.host)) { this.config.url = this.config.host.startsWith('http') ? this.config.host : 'http://' + this.config.host; } else { this.log.error('Fibaro HC configuration: cannot find valid url in configuration file.'); throw new Error('Invalid URL configuration'); } } private initializeFibaroClient() { this.fibaroClient = new FibaroClient( this.config.url, this.config.username, this.config.password, this.log, this.config.adminUsername, this.config.adminPassword, ); if (!this.fibaroClient || this.fibaroClient.status === false) { this.log.error('Cannot connect to Fibaro Home Center. Check credentials, url or ca.cer file'); throw new Error('Failed to initialize Fibaro Client'); } } async login() { try { if (!this.fibaroClient) { return; } await this.fetchInfo(); await this.processScenes(); await this.processZones(); this.setFunctions = new SetFunctions(this); await this.fetchDevicesAndRooms(); this.processDevices(); this.log.info('Successfully logged in.'); } catch (e) { this.handleLoginError(e); } } private async fetchInfo() { this.info = (await this.fibaroClient!.getInfo()).body; } private async processScenes() { const { body: scenes } = await this.fibaroClient!.getScenes(); scenes.forEach((s) => { this.scenes[s.name] = s.id; if (s.name.startsWith('_')) { const device = { name: s.name.substring(1), roomID: 0, id: s.id, type: 'scene' }; this.addAccessory(device); } }); } private async processZones() { if (this.isOldApi()) { await this.processHeatingZones(); } else { await this.processClimateZones(); } } private async processHeatingZones() { const { body: heatingZones } = await this.fibaroClient!.getHeatingZones(); heatingZones.forEach((s) => { this.climateZones[s.name] = s.id; const device = { name: s.name, roomID: 0, id: s.id, type: 'heatingZone', properties: s.properties }; this.addAccessory(device); }); } private async processClimateZones() { const { body: climateZones } = await this.fibaroClient!.getClimateZones(); climateZones.forEach((s) => { this.climateZones[s.name] = s.id; const device = { name: s.name, roomID: 0, id: s.id, type: 'climateZone', properties: s.properties }; this.addAccessory(device); }); } private async fetchDevicesAndRooms() { this.devices = (await this.fibaroClient!.getDevices()).body; this.rooms = (await this.fibaroClient!.getRooms()).body; } private handleLoginError(e: unknown) { this.log.error('Error getting data from Home Center: ', e); this.log.error('Make sure you provide the correct data: URL or IP, username and password' + ' and that HC is enabled and available on the same network as Homebridge.' + ' Using https may be mandatory if you configured HC to use it.'); this.log.error('Next try in 5 minutes'); if (this.loginTimeout) { clearTimeout(this.loginTimeout); } this.loginTimeout = setTimeout(() => { this.login(); }, constants.RETRY_DELAY_LOGIN); } /** * 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: PlatformAccessory) { if (this.config.logsLevel === constants.CONFIG_LOGS_LEVEL_VERBOSE){ this.log.info('Loading accessory from cache:', accessory.displayName); } // To enable the removing of cached accessories no more present on Fibaro Home Center accessory.context.reviewed = false; // add the restored accessory to the accessories cache so we can track if it has already been registered this.accessories.push(accessory); } processDevices() { this.log.debug('Processing devices'); this.devices.map((s) => { if (s.visible === true && !s.name.startsWith('_')) { if (this.config.addRoomNameToDeviceName === 'enabled' && this.rooms) { // patch device name with the room name const room = this.getRoomNameById(s.roomID); if (room !== undefined && room !== '') { s.name = s.name + ' - ' + room; } else { s.name = s.name + ' - ' + 'no-room'; } } else if (this.config.addRoomNameToDeviceName === 'enabledBefore' && this.rooms) { // patch device name with the room name const room = this.getRoomNameById(s.roomID); if (room !== undefined && room !== '') { s.name = room + ' ' + s.name; } } this.addAccessory(s); } }); // Create Global Variable Switches and Dimmers const createGlobalVariableDevices = (param, type) => { if (param && param !== '') { const globalVariables = param.split(','); for (let i = 0; i < globalVariables.length; i++) { const device = { name: globalVariables[i], roomID: 0, id: globalVariables[i], type: type }; this.addAccessory(device); } } }; createGlobalVariableDevices(this.config.switchglobalvariables, 'G'); createGlobalVariableDevices(this.config.dimmerglobalvariables, 'D'); // Create Security System accessory if (this.config.securitysystem === 'enabled') { const device = { name: 'FibaroSecuritySystem', roomID: 0, id: 0, type: 'securitySystem' }; this.addAccessory(device); } // Remove not reviewd accessories: cached accessories no more present in Home Center this.accessories.forEach((a) => { if (!a.context.reviewed) { this.removeAccessory(a); } }); // Start the poller update mechanism if (this.poller) { this.poller.poll(); } } addAccessory(device) { if (device === undefined) { return; } const uuidV1 = this.api.hap.uuid.generate(device.name + device.roomID); // add a new seed for uuidV2 to avoid conflicts with devices with same name in same room // manage collision with climate/heating zones (id starting from 1) const seed = device.id.toString() + ((device.type==='heatingZone' || device.type==='climateZone') ? 'hc' : ''); const uuidV2 = this.api.hap.uuid.generate(seed); // check if the accessory already exists based on old uuid seed (device.name + device.roomID) // if yes, keep it to preserve compatibility with HomeKit automations // if not, check if the accessory already exists based on new uuid seed (device.id) // if not, create a new accessory with new uuid seed (device.id) const existingAccessoryV1 = this.accessories.find(accessory => accessory.UUID === uuidV1); const existingAccessoryV2 = this.accessories.find(accessory => accessory.UUID === uuidV2); const existingAccessory = existingAccessoryV1 ? existingAccessoryV1 : existingAccessoryV2; if (existingAccessory) { // the accessory already exists if (this.config.logsLevel === constants.CONFIG_LOGS_LEVEL_VERBOSE){ this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName); } // Update context existingAccessory.context.device = device; existingAccessory.context.reviewed = true; this.api.updatePlatformAccessories([existingAccessory]); // Create accessory handler const fa = new FibaroAccessory(this, existingAccessory, device); if (!fa.isValid) { this.removeAccessory(existingAccessory); } } else { // the accessory does not yet exist, so we need to create it const accessory = new this.api.platformAccessory(device.name, uuidV2); // Create context accessory.context.device = device; accessory.context.reviewed = true; // Create accessory handler const fa = new FibaroAccessory(this, accessory, device); if (fa.isValid) { // link the accessory to the platform this.api.registerPlatformAccessories(constants.PLUGIN_NAME, constants.PLATFORM_NAME, [accessory]); this.accessories.push(accessory); this.log.info('Adding new accessory:', device.name); } } } removeAccessory(accessory) { this.log.info('Remove accessory: ', accessory.displayName); this.api.unregisterPlatformAccessories(constants.PLUGIN_NAME, constants.PLATFORM_NAME, [accessory]); this.accessories.forEach((element, index) => { if (element.UUID === accessory.uuid) { this.accessories.splice(index, 1); } }); } findServiceByName(name, service) { const a = this.accessories.find((accessory) => { const s = accessory.getService(service); if (s && s.displayName === name) { return true; } else { return false; } }); if (a) { return a.getService(service); } else { return undefined; } } isOldApi() { return this.info && this.info.serialNumber && (this.info.serialNumber.indexOf('HC2-') === 0 || this.info.serialNumber.indexOf('HCL-') === 0); } getRoomNameById(roomId: number): string | undefined { return this.rooms.find(r => r.id === roomId)?.name; } } function validUrl(str) { const pattern = new RegExp( '^(https?:\\/\\/)?' + // protocol '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path '(\\?[;&a-z\\d%_.~+=-]*)?' + // query string '(\\#[-a-z\\d_]*)?$', // fragment locator 'i', ); return pattern.test(str); }