homebridge-freeathome-local-kdljns
Version:
Platform Plugin to manage free@home accessories via HomeBridge using local API
410 lines (337 loc) • 15 kB
text/typescript
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
}
}