UNPKG

homebridge-freeathome-local-kdljns

Version:

Platform Plugin to manage free@home accessories via HomeBridge using local API

410 lines (337 loc) 15 kB
import axios, { Axios } from "axios" import compareVersions from "compare-versions" import FatHAPI from "freeathome-api" import { SystemAccessPointSettings, SystemAccessPointUser } from "freeathome-api/dist/lib/SystemAccessPointSettings" import { ClientConfiguration } from "freeathome-api/dist/lib/Configuration" import { Subscriber } from "freeathome-api/dist/lib/Subscriber" import { Logger, ConsoleLogger } from "freeathome-api/dist/lib/Logger" import { General, Message, Result } from "freeathome-api/dist/lib/constants" import { GuardedClient } from './GuardedClient' import { MessageBuilder } from './MessageBuilder' const https = require('https'); export class SystemAccessPoint { private configuration: ClientConfiguration private readonly subscriber: Subscriber private client: GuardedClient | undefined private messageBuilder: MessageBuilder | undefined private crypto: Crypto | undefined private online: boolean = false private settings: SystemAccessPointSettings | undefined private connectedAs: string | undefined private user: SystemAccessPointUser | undefined private keepAliveMessageId: number = 1 private pingTimeoutSeconds: number = 10000 private keepAliveTimer: NodeJS.Timeout | null = null private pingTimeout: NodeJS.Timeout | null = null private deviceData: any = {} private subscribed: boolean = false private axios: Axios private logger: Logger = new ConsoleLogger() /** * protocols, we need some smarter way in the future (enableTLS: true in config) */ private readonly _protocol1 = 'wss://' private readonly _protocol2 = 'https://' /** * ports will be set automagically (hopefully) */ private _port = '' /** * the API entry path */ private readonly _path2api = '/fhapi/v1/api' /** * default UUID - we will read the "real" uuid from config.json */ private _uuid = '00000000-0000-0000-0000-000000000000' /** * minimal version to use the local API */ private readonly _minversionAP = '2.6.0' constructor(configuration: ClientConfiguration, subscriber: Subscriber, logger: Logger | null) { this.configuration = configuration this.subscriber = subscriber if (logger !== undefined && logger !== null) { this.logger = logger } // ignore self signed certs at instance level this.axios = axios.create({ httpsAgent: new https.Agent({ rejectUnauthorized: false }) }); } private async createClient() { this.settings = await this.getSettings() let user: SystemAccessPointUser | undefined for (let tempUser of this.settings.users) { if (tempUser.name == this.configuration.username) { user = tempUser break } } if (user === undefined) { this.logger.error('The user does not exist in the System Access Point\'s configuration') throw new Error(`User ${this.configuration.username} does not exist`) } this.user = user let username = user!.jid.split('@')[0] /** * private readonly _protocol1 = 'wss://' * private _port = '' * private readonly _path2api = '/fhapi/v1/api' */ this.client = new GuardedClient(this.subscriber, { service: this._protocol1 + this.configuration.hostname + ((this._port!='')?':' + this._port:'') + this._path2api + '/ws', from: this.configuration.hostname, resource: 'freeathome-api', username: username, password: this.configuration.password }, this.logger) this.messageBuilder = new MessageBuilder(username) this.registerHandlers() } private async getSettings(): Promise<SystemAccessPointSettings> { let response = await this.axios.get(this._protocol2 + this.configuration.hostname + '/settings.json') if (response.status != 200) { this.logger.error("Unexpected status code from System Access Point while retrieving settings.json.") throw new Error("Unexpected status code from System Access Point while retrieving settings.json.") } if (!('flags' in response.data) || !('version' in response.data.flags)) { this.logger.error("Flags key does not exist in settings.json.") throw new Error("Flags key does not exist in settings.json.") } if (!('users' in response.data || !Array.isArray(response.data.users))) { this.logger.error("Users key does not exist in settings.json.") throw new Error("Users key does not exist in settings.json.") } return <SystemAccessPointSettings>response.data } private async getDeviceConfiguration(): Promise<any> { let _restpath = '/rest/configuration' let bwaToken = this.client!.getBWAToken() try { let response = await this.axios.get(this._protocol2 + this.configuration.hostname + this._path2api + _restpath, { headers: { 'Authorization': 'Basic ' + bwaToken } }) if (response.status != 200) { this.logger.error("Unexpected status code from System Access Point while retrieving " + _restpath) throw new Error("Unexpected status code from System Access Point while retrieving " + _restpath) } /** * retrieving the uuid from device config */ this._uuid = Object.keys(response.data)[0] ?? this._uuid this.deviceData = response.data[this._uuid]?.devices this.subscriber.broadcastMessage({ result: response.data, type: 'subscribed' }) return response.data } catch (e: any) { this.logger.error("Unexpected status code from System Access Point while retrieving " + _restpath + "\n" + e.toString()); return null } } /** * * onopen: (event: WebSocket.OpenEvent) => void; * onerror: (event: WebSocket.ErrorEvent) => void; * onclose: (event: WebSocket.CloseEvent) => void; * onmessage: (event: WebSocket.MessageEvent) => void; */ private registerHandlers() { if (this.client === undefined) { throw new Error("Unknown error occurred! this.client undefined.") } this.client.on('error', err => { this.logger.error(err.toString()) this.subscriber.broadcastMessage({ type: "error", result: err }) }) this.client.on('close', () => { this.logger.log('Access Point has gone offline') this.online = false this.subscribed = false this.subscriber.broadcastMessage({ 'type': 'subscribed', 'result': false }) this.disableKeepAliveMessages() }) this.client.guardedOn('message', async stanza => { this.logger.debug('Received stanza:', JSON.parse(stanza)) let astanza = JSON.parse(stanza)[this._uuid] ?? null this.heartBeat() if (astanza.datapoints) { this.handleEvent(astanza) } }) this.client.on('open', async address => { let connectedAs = 'Local API Websocket' this.logger.log("Connected as " + connectedAs) this.connectedAs = connectedAs this.logger.log("Retrieving configuration...") let deviceData = this.getDeviceConfiguration() }) this.client.on('ping', ping => { this.heartBeat() this.logger.debug('WS Ping:', ping) }) // Debug this.client.on('status', status => { this.logger.debug('Received new status:', status) }) this.client.on('input', input => { this.logger.debug('Received new input data:', input) }) this.client.on('output', output => { this.logger.debug('Received new output data:', output) }) } /** * heartbeat to detect if WS still send messages od my be death */ private async heartBeat() { if(this.pingTimeout){ clearTimeout(this.pingTimeout); } let self = this; this.logger.debug("*** heartBeat " + this.pingTimeoutSeconds); // Use `WebSocket#terminate()`, which immediately destroys the connection, // instead of `WebSocket#close()`, which waits for the close timer. // Delay should be equal to the interval at which your server // sends out pings plus a conservative assumption of the latency. this.pingTimeout = setTimeout(() => { //self.disconnect() }, this.pingTimeoutSeconds); } private handleEvent(stanza: any) { this.logger.debug("handleEvent: "); this.logger.debug(JSON.stringify(stanza)); for (const [key, value] of Object.entries(stanza.datapoints)) { if (key) { let telegram = key + '/' + value //this.logger.log('* ' + telegram) this.applyIncrementalUpdate(telegram.split('/')); } } } private unwrapEventData(item: Element): string { return ""; } private async sendMessage(message: any, value: string) { // await this.client!.send(message) let bwaToken = this.client!.getBWAToken() try { let response = await this.axios.put(this._protocol2 + this.configuration.hostname + this._path2api + '/rest/datapoint/' + this._uuid + '/' + message, value, { headers: { 'Authorization': 'Basic ' + bwaToken } }) if (response.status != 200) { this.logger.error("Unexpected status code from System Access Point while PUT ") throw new Error("Unexpected status code from System Access Point while PUT ") } } catch (e) { this.logger.error("Unexpected status code from System Access Point while PUT "); } } /** * create and connect WS client */ async connect() { await this.createClient() if (compareVersions(this.settings!.flags.version, this._minversionAP) < 0) { throw Error('Your System Access Point\'s firmware must be at least ' + this._minversionAP); } try { await this.client!.start() //this.sendKeepAliveMessages() this.heartBeat() } catch (e: any) { this.logger.error('Could not connect to System Access Point', e.toString()) throw Error("Could not connect to System Access Point") } } async disconnect() { this.logger.log("Disconnecting from the System Access Point..."); await this.client!.stop() } private async sendKeepAliveMessage() { //await this.sendMessage(this.keepAliveMessageId++) } private sendKeepAliveMessages() { this.keepAliveTimer = setInterval(() => this.sendKeepAliveMessage(), 15000) } private disableKeepAliveMessages() { if (this.keepAliveTimer !== null) { clearInterval(this.keepAliveTimer) this.keepAliveTimer = null } } private applyIncrementalUpdate(update: any) { if (update == null || !(update instanceof Object)) { throw new Error("Invalid Incremental Update") } let upd = Array() if (update.length == 4) { const serialNo = update[0] const channelNo = update[1] const datapointNo = update[2] const value = update[3] upd[serialNo] = Array(); if (!(serialNo in this.deviceData)) { this.deviceData[serialNo] = { serialNumber: serialNo, channels: {} } } else { upd[serialNo]['deviceId'] = this.deviceData[serialNo]['deviceId'] upd[serialNo]['typeName'] = this.deviceData[serialNo]['typeName'] } if (channelNo != null) { if (!(channelNo in this.deviceData[serialNo]['channels'])) { this.deviceData[serialNo]['channels'][channelNo] = { datapoints: {} } } if (datapointNo != null) { if (this.deviceData[serialNo]['channels'][channelNo]['datapoints'] != null) { // remote API can be removed here this.deviceData[serialNo]['channels'][channelNo]['datapoints'][datapointNo] = value } else { let channelKey = '' if (this.deviceData[serialNo]['channels'][channelNo]['outputs'][datapointNo] != null) { channelKey = 'outputs' }else if (this.deviceData[serialNo]['channels'][channelNo]['inputs'][datapointNo] != null) { channelKey = 'inputs' } // local API inputs & outputs - datapointNo csn oeitherr be in inputs or outputs upd[serialNo]['channels'] = [] upd[serialNo]['channels'][channelNo] = [] upd[serialNo]['channels'][channelNo][channelKey] = [] upd[serialNo]['channels'][channelNo][channelKey][datapointNo] = this.deviceData[serialNo]['channels'][channelNo][channelKey][datapointNo] upd[serialNo]['channels'][channelNo][channelKey][datapointNo].value = value } // we need this in BuschJaegerApPlatform.prototype.processUpdate = function(actuators) upd[serialNo]['serial'] = serialNo this.logger.debug("Updated Datapoint: " + serialNo + '/' + channelNo + '/' + datapointNo + '/' + value) } this.subscriber.broadcastMessage({ result: upd, type: 'update' }) } } } async setDatapoint(serialNo: string, channel: string, datapoint: string, value: string) { await this.sendMessage(this.messageBuilder!.buildSetDatapointMessage(serialNo, channel, datapoint, value), value) this.logger.log("Set Datapoint: " + serialNo + '/' + channel + '/' + datapoint + '/' + value) } getDeviceData(): any { if (Object.entries(this.deviceData).length === 0 && this.deviceData.constructor === Object) { throw new Error("Device Data was requested before we have initialized it") } return this.deviceData } }