UNPKG

hap-homematic

Version:

provides a homekit bridge to the ccu

1,586 lines (1,458 loc) 64.3 kB
/* * File: index.js * Project: hap-homematic * File Created: Tuesday, 10th March 2020 7:15:57 pm * Author: Thomas Kluge (th.kluge@me.com) * ----- * The MIT License (MIT) * * Copyright (c) Thomas Kluge <th.kluge@me.com> (https://github.com/thkl) * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. * ========================================================================== */ const os = require('os') const path = require('path') const fs = require('fs') const http = require('http') const crypto = require('crypto') const https = require('https') const url = require('url') const qs = require('querystring') const uuid = require('hap-nodejs').uuid const Logger = require(path.join(__dirname, '..', 'logger.js')) const Rega = require(path.join(__dirname, '..', 'HomeMaticRegaRequest.js')) const sockjs = require('sockjs') process.title = 'hap-homematic-config' class ConfigurationService { constructor(logger) { this.log = logger this.configServerPort = 9874 this.contentTypesByExtension = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.jpg': 'image/jpeg', '.png': 'image/png', '.json': 'application/json; charset=utf-8', '.mp3': 'audio/mpeg', '.gif': 'image/gid', '.gz': 'application/gzip', '.ico': 'image/x-icon', '.woff2': 'font/opentype', '.woff': 'font/opentype', '.ttf': 'font/opentype', '.mp4': 'video/mp4' } this.programs = [] this.variables = [] this.pluginAccessories = [] this.bridges = [] this.allVariables = [] this.compatibleDevices = [] let config = this.loadSettings() if (config === undefined) { config = {} } this.useAuth = config.useCCCAuthentication || false this.useTLS = config.useTLS || false this.interfaceWatchdog = config.interfaceWatchdog || 300 this.enableMonitoring = config.enableMonitoring || false this.disableHistory = config.disableHistory || false this.forceCache = config.forceCache || false this.forceRefresh = false } shutdown() { this.server.close() this.log.close() } sendFile(unsafeSuffix, response) { var safeSuffix = path.normalize(unsafeSuffix).replace(/^(\.\.(\/|\\|$))+/, '') var safeFilePath = path.join(__dirname, 'html', safeSuffix) if (safeFilePath.endsWith('/')) { safeFilePath = path.join(safeFilePath, 'index.html') } if (fs.existsSync(safeFilePath)) { let stat = fs.statSync(safeFilePath) let contentType = this.contentTypesByExtension[path.extname(safeFilePath)] response.writeHead(200, { 'Content-Type': contentType || 'text/html', 'Content-Length': stat.size, 'Last-Modified': new Date() }) var readStream = fs.createReadStream(safeFilePath) readStream.pipe(response) } else { this.log.warn('File not found %s', safeFilePath) response.writeHead(404, { 'Content-Type': 'text/plain' }) response.end('ERROR File does not exist') } } sendJSON(object, response) { response.writeHead(200, { 'Content-Type': 'application/json' }) response.end(JSON.stringify(object)) } async run() { let self = this function serverHandler(request, response) { if (request.url === '/restore/' && request.method.toLowerCase() === 'post') { self.processRestore(request, response) } else if (request.method === 'POST') { var body = '' request.on('data', (data) => { body += data if (body.length > 1e6) { request.connection.destroy() } }) request.on('end', () => { let parsed = url.parse(request.url, true) let post = qs.parse(body) let filename = parsed.pathname if (filename === '/api/') { self.processApiCall(post, response) } else { self.sendFile(filename, response) } }) } else { let parsed = url.parse(request.url, true) let filename = parsed.pathname if (filename === '/api/') { self.processApiCall(parsed.query, response) } else { self.sendFile(filename, response) } } } this.log.info('[Config] launching configuration service') let keyFile = '/etc/config/server.pem' let certFile = '/etc/config/server.pem' if ((this.useTLS === true) && (fs.existsSync(keyFile)) && (fs.existsSync(certFile))) { // Just use Homematics TLS Certificate :o) const privateKey = fs.readFileSync(keyFile, 'utf8') const certificate = fs.readFileSync(certFile, 'utf8') const credentials = { key: privateKey, cert: certificate } try { this.server = https.createServer(credentials, serverHandler) } catch (e) { // fallback this.server = http.createServer(serverHandler) } } else { this.server = http.createServer(serverHandler) } this.sockjs_server = sockjs.createServer({ sockjs_url: './assets/js/sockjs.min.js', log: function (message) { self.log.debug(message) } }) this.sockjs_server.on('connection', function (conn) { conn.on('close', function () { self.log.debug('Socked Close Message for %s', conn.id) self.handleSocketRequest(conn, { command: 'close' }) }) conn.on('data', function (message) { try { self.handleSocketRequest(conn, JSON.parse(message)) } catch (e) { } }) }) this.sockjs_server.installHandlers(this.server, { prefix: '/websockets' }) this.server.listen(this.configServerPort, () => { self.log.info('[Config] running %s configuration server on port %s', (this.useTLS ? 'secure' : ''), self.configServerPort) }) this.connections = {} self.log.info('Config Start heartBeat') this.heartBeat() self.log.info('Config Server is running') } sendMessageToSockets(message) { let self = this Object.keys(this.connections).map((connId) => { let conn = self.connections[connId] try { if (conn) { conn.write(JSON.stringify(message)) } } catch (error) { if (conn) { try { conn.close() } catch (error) { } } } }) } async handleSocketRequest(conn, message) { if (message.command === 'hello') { this.log.debug('[Config] add %s to sockets', conn.id) this.connections[conn.id] = conn // Send hello let sysData = await this.getSystemInfo() conn.write(JSON.stringify({ message: 'ackn', payload: sysData })) } if (message.command === 'close') { if (this.connections[conn.id] !== undefined) { this.log.debug('[Config] remove %s from sockets', conn.id) delete this.connections[conn.id] } } } fetchVersion() { let packageFile = path.join(__dirname, '..', '..', 'package.json') this.log.debug('[Config] Check Version from %s', packageFile) if (fs.existsSync(packageFile)) { try { this.packageData = JSON.parse(fs.readFileSync(packageFile)) this.log.debug('[Config] version is %s', this.packageData.version) return this.packageData.version } catch (e) { return 'no version found' } } return 'no version found' } getSupportData(address) { // first get the device file let deviceFile = path.join(process.env.UIX_CONFIG_PATH, 'devices.json') if (fs.existsSync(deviceFile)) { try { let objDev = JSON.parse(fs.readFileSync(deviceFile)) var result = {} if ((objDev) && (objDev.devices)) { objDev.devices.map(device => { var id = 1000 // make it random var digits = Math.floor(Math.random() * 9000000000) + 1000000000 var dummyAdr = digits.toString() + 'ABCD' if (device.address === address) { result.devices = [] let tmpD = { id: id, intf: 0, intfName: '', name: device.type, address: dummyAdr, type: device.type, channels: [] } id = id + 1 device.channels.map(channel => { let chn = channel.address.split(':').slice(1, 2)[0] let tmpC = { id: id, name: dummyAdr + ':' + chn, intf: 0, address: dummyAdr + ':' + chn, type: channel.type, access: channel.access } tmpD.channels.push(tmpC) id = id + 1 }) result.devices.push(tmpD) } }) return result } } catch (e) { return 'error while creating the file ' + e.stack } } return 'devices.json not found' } async getSystemInfo() { var result = {} result.cpu = os.cpus() result.mem = os.freemem() result.uptime = os.uptime() result.hapuptime = process.uptime() result.version = this.fetchVersion() result.update = result.version // set to the same version per default. // check my version this.log.debug('[Config] Check Registry Version') let strRegData = '' try { strRegData = await this.getHTTP('https://registry.npmjs.org/hap-homematic', {}) let oReg = JSON.parse(strRegData) if (oReg) { result.update = oReg['dist-tags'].latest this.log.debug('[Config] Found Registry Version %s', oReg['dist-tags'].latest) } else { this.log.debug('[Config] Unable to parse result %s', strRegData) } } catch (e) { this.log.debug('[Config] Unable to parse result %s', strRegData) } result.debug = this.log.isDebugEnabled() result.useAuth = this.useAuth result.useTLS = this.useTLS result.enableMonitoring = this.enableMonitoring result.disableHistory = this.disableHistory result.forceRefresh = this.forceRefresh result.forceCache = this.forceCache result.interfaceWatchdog = this.interfaceWatchdog this.forceRefresh = false return result } // make it fucking Node8 compatible getHTTP(urlStr, options) { return new Promise((resolve, reject) => { if (!options) { options = {} } var q = url.parse(urlStr, true) options.path = q.pathname options.host = q.hostname options.port = q.port https.get(options, (resp) => { let data = '' resp.on('data', (chunk) => { data += chunk }) resp.on('end', () => { resolve(data) }) }).on('error', (err) => { reject(err) }) }) } async updateSystem() { // first create a backup of users config let backupFile = await this.generateBackup() // move the backup to the config folder let version = this.fetchVersion() let autoBackupFile = path.join(process.env.UIX_CONFIG_PATH, 'hap-autobackup_' + version + '.tar.gz') try { if (fs.existsSync(backupFile)) { if (fs.existsSync(autoBackupFile)) { fs.unlinkSync(autoBackupFile) } fs.copyFileSync(backupFile, autoBackupFile) } // get the update command and run it } catch (e) { return { 'error': 'autobackup failed' } } let packageFile = path.join(__dirname, '..', '..', 'package.json') if (fs.existsSync(packageFile)) { try { let packageData = JSON.parse(fs.readFileSync(packageFile)) if ((packageData) && (packageData.scripts)) { let updateScript = packageData.scripts.update let restartScript = packageData.scripts.restart if ((updateScript) && (restartScript)) { const childprocess = require('child_process') childprocess.execSync(updateScript) setTimeout(() => { childprocess.execSync(restartScript) }, 500) } } } catch (e) { let message = 'unable to get the update command ' + e.stack return { 'error': message } } } } restartSystem() { // get the update command and run it let packageFile = path.join(__dirname, '..', '..', 'package.json') if (fs.existsSync(packageFile)) { try { let packageData = JSON.parse(fs.readFileSync(packageFile)) if ((packageData) && (packageData.scripts)) { let restartScript = packageData.scripts.restart this.log.debug('restart command will be %s called in 500ms', restartScript) if (restartScript) { const childprocess = require('child_process') setTimeout(() => { childprocess.execSync(restartScript) }, 500) } } } catch (e) { return { 'error': 'unable to get the restart command' } } } } deviceWithUUID(uuid) { var result this.pluginAccessories.map(device => { if (device.UUID === uuid) { result = device } }) return result } specialDeviceWithUUID(uuid) { var result this.pluginSpecial.map(device => { if (device.UUID === uuid) { result = device } }) return result } bridgeWithId(uuid) { var result this.bridges.map(bridge => { if (bridge.id === uuid) { result = bridge } }) return result } variableWithName(varName) { var result this.allVariables.map(variable => { if ((variable.isCompatible === true) && (variable.name === varName)) { result = variable } }) return result } programWithName(progName) { var result this.compatiblePrograms.map(program => { if (program.name === progName) { result = program } }) return result } serviceSettingsFor(channelAddress) { var result = {} result.service = [] let self = this if (this.compatibleDevices) { this.compatibleDevices.map(device => { if (device.channels) { device.channels.map(channel => { if (channel.address === channelAddress) { let s1 = self.services[channel.type] if (s1) { s1.map(item => { // make sure we do not filter this device if ((item.filterDevice) && (item.filterDevice.indexOf(device.type) === -1)) { result.service.push(item) } }) } let s2 = self.services[device.type + ':' + channel.type] if (s2) { s2.map(item => { result.service.push(item) }) } } }) } }) if (this.pluginSpecial) { // also map the special devices this.pluginSpecial.map(spdevice => { let chadr = spdevice.serial + ':' + spdevice.channel if (chadr === channelAddress) { // find service self.services['SPECIAL'].map(item => { result.service.push(item) }) } }) } } return result } getVariableServiceList() { var result = [] if ((this.services) && (this.services['VARIABLE'])) { this.services['VARIABLE'].map(item => { result.push(item) }) } return result } loadSettings() { let configFile = path.join(process.env.UIX_CONFIG_PATH, 'config.json') if (fs.existsSync(configFile)) { return JSON.parse(fs.readFileSync(configFile)) } return undefined } saveSettings(configData) { let configFile = path.join(process.env.UIX_CONFIG_PATH, 'config.json') fs.writeFileSync(configFile, JSON.stringify(configData, ' ', 1)) } loadGraph(graph) { var hostname = os.hostname() let result = [] let key = graph.item let id = graph.id if (id) { let hdidParts = id.split(':') if (hdidParts.length > 1) { let config = this.loadSettings() let cachePath = config.cache if (cachePath !== undefined) { cachePath = path.join(cachePath, 'evehistory') } else { cachePath = process.env.UIX_CONFIG_PATH } let filename = hostname + '_' + hdidParts[0] + '_' + hdidParts[1] + '_persist.json' let filePath = path.join(cachePath, filename) if (fs.existsSync(filePath)) { try { let dta = JSON.parse(fs.readFileSync(filePath)) if ((dta) && (dta.history)) { // filter only the last 24 hours var ts = Math.round(new Date().getTime() / 1000) var tsYesterday = ts - (24 * 3600) let filtered = dta.history.filter(item => item.time > tsYesterday) filtered.map((item) => { if (item[key]) { result.push({ timestamp: item.time, value: item[key] }) } }) } } catch (e) { this.log.error('[Config] parsing error for graph data %s', e) } } else { this.log.debug('[Config] unable to load graph data from %s', filePath) } } } return result } checkGraphes() { this.graphes = [] let self = this let config = this.loadSettings() if ((config) && (config.mappings)) { Object.keys(config.mappings).map((mapping) => { let hkDeviceMapping = config.mappings[mapping] if ((hkDeviceMapping.settings) && (hkDeviceMapping.settings.showGraph) && (hkDeviceMapping.settings.showGraph !== 'DONT_SHOW')) { self.graphes.push({ id: mapping, item: hkDeviceMapping.settings.showGraph, name: hkDeviceMapping.name }) } }) } // check if we have saved files this.graphes.map((graph) => { let id = graph.id let cachePath = config.cache if (cachePath !== undefined) { cachePath = path.join(cachePath, 'evehistory') } else { cachePath = process.env.UIX_CONFIG_PATH } var hostname = os.hostname() let hdidParts = id.split(':') if (hdidParts.length > 1) { let filename = hostname + '_' + hdidParts[0] + '_' + hdidParts[1] + '_persist.json' let filePath = path.join(cachePath, filename) self.log.debug('Check Historyfile %s', filePath) if (!fs.existsSync(filePath)) { self.log.warn('File not exists removing graph') self.graphes = self.graphes.filter(item => item.id !== id) } } }) } async saveDevice(data) { let name = data.name let channel = data.address var isSpecial if (channel === 'new:special') { isSpecial = uuid.generate('special_' + name) channel = isSpecial + ':0' } let settings = (data.settings) ? JSON.parse(data.settings) : {} let instance = uuid.generate('0') if (settings.instanceIDs !== undefined) { this.log.debug('[Config] settings up instances') instance = [] Object.keys(settings.instanceIDs).map((oKey) => { instance.push(settings.instanceIDs[oKey]) }) } else { // if not in settings ... so use the first we'vfound if (data['instanceIDs[0]']) { instance = data['instanceIDs[0]'] } } let service = data.serviceClass if ((name) && (channel) && (service)) { var configData = this.loadSettings() // generate the containers if not here yet if (configData === undefined) { configData = {} } if (configData.mappings === undefined) { configData.mappings = {} } if (configData.channels === undefined) { configData.channels = [] } // There is a Special Array so put this also in if (isSpecial !== undefined) { if (configData.special === undefined) { configData.special = [] } configData.special.push(isSpecial) } // remove settings which are not part of the class settings let clazzFile = path.join(__dirname, '..', 'services', service + '.js') if (fs.existsSync(clazzFile)) { let oClazz = require(clazzFile) let oClazzSettings = await oClazz.configurationItems() Object.keys(settings).map((key) => { if (Object.keys(oClazzSettings).indexOf(key) === -1) { delete settings[key] this.log.debug('[Config] removed %s which is not part of %s settings.', key, service) } }) } else { this.log.debug('[Config] clazzFile %s not found', clazzFile) } // Add the mapping configData.mappings[channel] = { name: name, Service: service, instance: instance, settings: settings } if (configData.channels.indexOf(channel) === -1) { // Add the Channel if not here .. otherwise just override the config configData.channels.push(channel) } // Save the stuff this.saveSettings(configData) return { 'result': 'saved' } } else { return { 'result': 'error saving' } } } createapplicancesWizzard(instanceID, listChannelz) { let self = this let configData = this.loadSettings() if (configData === undefined) { configData = {} } if (configData.mappings === undefined) { configData.mappings = {} } if (configData.channels === undefined) { configData.channels = [] } if ((configData.instances) && (configData.instances[instanceID])) { configData.instances[instanceID].publishDevices = true } listChannelz.map(aChannel => { // get the default service let sList = self.services[aChannel.type] if (sList) { if (configData.channels.indexOf(aChannel.address) === -1) { configData.channels.push(aChannel.address) } let serviceClazz = sList[0].serviceClazz configData.mappings[aChannel.address] = { name: aChannel.name, Service: serviceClazz, instance: instanceID, settings: {} } } }) this.saveSettings(configData) this.process.send({ topic: 'reloadApplicances' }) return { 'result': 'saved' } } savePublishingFlag(bridges) { let self = this var configData = this.loadSettings() || { instances: { '0': { 'name': 'default' } } } self.log.debug('[Config] savePublishingFlag old Data %s', JSON.stringify(configData)) bridges.map(bridgeId => { self.log.debug('[Config] savePublishingFlag %s', bridgeId) let oBridge = configData.instances[bridgeId] oBridge.publishDevices = true }) self.log.debug('[Config] savePublishingFlag new Data %s', JSON.stringify(configData)) this.saveSettings(configData) } randomMac() { var mac = '12:34:56' for (var i = 0; i < 6; i++) { if (i % 2 === 0) mac += ':' mac += Math.floor(Math.random() * 16).toString(16) } return mac.toUpperCase() } generatePin() { let code = Math.floor(10000000 + Math.random() * 90000000) + '' code = code.split('') code.splice(3, 0, '-') code.splice(6, 0, '-') code = code.join('') return code } generateSetupID() { const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ' const bytes = crypto.randomBytes(4) let setupID = '' for (var i = 0; i < 4; i++) { var index = bytes.readUInt8(i) % 26 setupID += chars.charAt(index) } return setupID } createMultipleInstances(payload) { let self = this try { this.log.debug('[Config] payload Data %s', payload) let data = JSON.parse(payload) // load all data var configData = this.loadSettings() if (configData === undefined) { configData = {} } if (configData.instances === undefined) { configData.instances = {} } this.log.debug('[Config] payload %s', data) Object.keys(data).map(roomId => { let bridgeData = data[roomId] if (bridgeData.create === true) { var isUnique = true let name = bridgeData.name let roomId = parseInt(bridgeData.roomID) // check unique name Object.keys(configData.instances).map(bridgeId => { let bridge = configData.instances[bridgeId] if (bridge.name === name) { isUnique = false } }) if (isUnique === true) { let newUUID = uuid.generate(String(Math.random())) let mac = self.randomMac() let instData = { 'name': name, 'user': mac, 'pincode': self.generatePin(), 'roomId': roomId, 'setupID': self.generateSetupID() } self.log.debug('[Config] will create instance %s', JSON.stringify(instData)) configData.instances[newUUID] = instData } } }) this.saveSettings(configData) this.process.send({ topic: 'reloadApplicances' }) return ({ message: 'created', payload: configData.instances }) } catch (e) { this.log.error(e) return { 'error': e } } } createInstance(query) { let self = this return new Promise((resolve, reject) => { let name = query.name let publish = query.publish let roomId = (query.roomId) ? parseInt(query.roomId) : undefined var configData = this.loadSettings() if (configData === undefined) { configData = {} } if (configData.instances === undefined) { configData.instances = {} } var isUnique = true Object.keys(configData.instances).map(bridgeId => { let bridge = configData.instances[bridgeId] if (bridge.name === name) { isUnique = false } }) if (isUnique === true) { let newUUID = uuid.generate(String(Math.random())) let mac = self.randomMac() configData.instances[newUUID] = { 'name': name, 'user': mac, 'pincode': self.generatePin(), 'roomId': roomId, 'setupID': self.generateSetupID() } self.saveSettings(configData) } else { reject(new Error('name not unique')) } if ((publish === true) || (publish === 'true')) { self.process.send({ topic: 'reloadApplicances' }) } setTimeout(() => { resolve(self.bridges) }, 2000) }) } removeInstance(uuid) { let bridge = this.bridgeWithId(uuid) if ((bridge !== undefined) && (bridge.id !== 'b6589fc6-ab0d-4c82-8f12-099d1c2d40ab')) { // first set all devices to the default bridge let config = this.loadSettings() if (config.mappings !== undefined) { Object.keys(config.mappings).map(deviceId => { let device = config.mappings[deviceId] if (device.instance === uuid) { device.instance = 'b6589fc6-ab0d-4c82-8f12-099d1c2d40ab' } }) } delete config.instances[uuid] this.saveSettings(config) this.process.send({ topic: 'reloadApplicances' }) } } removeDeletedDevice(channelID) { let configData = this.loadSettings() this.log.debug('[Config] will remove deleted device with address %s', channelID) if ((configData) && (configData.channels)) { let index = configData.channels.indexOf(channelID) if (index > -1) { this.log.debug('[Config] channel data found .. remove') configData.channels.splice(index, 1) } } if ((configData) && (configData.mappings)) { this.log.debug('[Config] remove mapping configuration') delete configData.mappings[channelID] } this.saveSettings(configData) // send the main system a message to remove the persistent data this.process.send({ topic: 'remove', uuid: null }) return { 'result': 'deleted' } } removeDevice(uuid) { let device = this.deviceWithUUID(uuid) if (!device) { this.log.debug('[Config] check special device for removal') device = this.specialDeviceWithUUID(uuid) // check if its a special device } if (device) { // remove the channel and the settings let configData = this.loadSettings() let address = device.serial + ':' + device.channel this.log.debug('[Config] will remove device with address %s', address) if ((configData) && (configData.channels)) { let index = configData.channels.indexOf(address) if (index > -1) { this.log.debug('[Config] channel data found .. remove') configData.channels.splice(index, 1) } } if ((configData) && (configData.mappings)) { this.log.debug('[Config] remove mapping configuration') delete configData.mappings[address] } // remove it from special if its there if ((configData) && (configData.special)) { let index = configData.special.indexOf(device.serial) if (index > -1) { this.log.debug('[Config] special entry found ... remove') configData.special.splice(index, 1) } } this.saveSettings(configData) // send the main system a message to remove the persistent data this.process.send({ topic: 'remove', uuid: uuid }) return { 'result': 'deleted' } } else { return { 'result': 'not found' } } } editInstance(query) { this.log.debug('[Config] edit Instance') let uuid = query.uuid let name = query.displayName let roomId = query.roomId this.log.debug('[Config] updating %s with %s', uuid, name) let bridge = this.bridgeWithId(uuid) this.log.debug('[Config] bridge %s', (bridge !== undefined)) if ((bridge) && (name)) { // change the name in the config and reload everything let config = this.loadSettings() if ((config) && (config.instances)) { let instance = config.instances[uuid] instance.name = name if (roomId) { instance.roomId = parseInt(roomId) } this.saveSettings(config) this.process.send({ topic: 'reloadApplicances', uuid: uuid }) return { 'result': 'saved' } } } return { 'result': 'error name not filled or bridge not found' } } deactivateInstance(query) { this.log.debug('[Config] deactivating Instance') let uuid = query.uuid let bridge = this.bridgeWithId(uuid) this.log.debug('[Config] bridge %s', (bridge !== undefined)) if (bridge) { // change the name in the config and reload everything let config = this.loadSettings() if ((config) && (config.instances)) { let instance = config.instances[uuid] delete instance.publishDevices this.saveSettings(config) this.process.send({ topic: 'reloadApplicances', uuid: uuid }) return { 'result': 'saved' } } } return { 'result': 'bridge not found' } } resetInstance(query) { this.log.debug('[Config] resetting Instance') let uuid = query.uuid let bridge = this.bridgeWithId(uuid) this.log.debug('[Config] bridge %s', (bridge !== undefined)) if (bridge) { // we have to remove - $config/persist/AccessoryInfo.$mac.json and IdentifierCache.$mac.json let mac = bridge.user.replace(':', '') let ainfoFile = path.join(process.env.UIX_CONFIG_PATH, 'persist', 'AccessoryInfo' + mac + '.json') if (fs.existsSync(ainfoFile)) { fs.unlinkSync(ainfoFile) } let aICacheFile = path.join(process.env.UIX_CONFIG_PATH, 'persist', 'IdentifierCache' + mac + '.json') if (fs.existsSync(aICacheFile)) { fs.unlinkSync(aICacheFile) } // then reboot the instances this.process.send({ topic: 'reloadApplicances', uuid: uuid }) } } saveObject(query, objectType) { this.log.debug('[Config] save %s', objectType) let serial = query.serial let newName = query.name || serial let instance = query.instanceID var settings = {} if (query.settings) { try { settings = JSON.parse(query.settings) } catch (e) { } } let serviceClass = query.serviceClass let bridge = this.bridgeWithId(instance) if ((serial) && (newName) && (bridge)) { let config = this.loadSettings() // add or save variable if (!config[objectType]) { config[objectType] = [] } if (config[objectType].indexOf(serial) === -1) { config[objectType].push(serial) } if (config.mappings === undefined) { config.mappings = {} } // remove settings which are not part of the class settings let clazzFile = path.join(__dirname, '..', 'services', serviceClass + '.js') if (fs.existsSync(clazzFile)) { let oClazz = require(clazzFile) let oClazzSettings = oClazz.configurationItems() Object.keys(settings).map((key) => { if (Object.keys(oClazzSettings).indexOf(key) === -1) { delete settings[key] this.log.debug('[Config] removed %s which is not part of %s settings.', key, serviceClass) } }) } else { this.log.debug('[Config] clazzFile %s not found', clazzFile) } // add mapping data config.mappings[serial + ':0'] = { name: newName, instance: instance, Service: serviceClass, settings: settings } this.saveSettings(config) this.process.send({ topic: 'reloadApplicances', uuid: uuid }) return { 'result': 'saved' } } else { return { 'result': 'error name or serial or instance not found' } } } removeObject(serial, uuid, objectType) { if (serial) { this.log.debug('[Config] try to remove %s %s', objectType, serial) // remove the channel and the settings let configData = this.loadSettings() if ((configData) && (configData[objectType])) { let index = configData[objectType].indexOf(serial) if (index > -1) { configData[objectType].splice(index, 1) } } if ((configData) && (configData.mappings)) { delete configData.mappings[serial + ':0'] } this.saveSettings(configData) // send the main system a message to remove the persistent data this.process.send({ topic: 'remove', uuid: uuid }) return { 'result': 'deleted' } } else { return { 'result': 'not found' } } } saveVariableTrigger(datapoint, autoUpdateVarTriggerHelper) { if (datapoint) { let configData = this.loadSettings() configData.VariableUpdateEvent = datapoint configData.autoUpdateVarTriggerHelper = ((autoUpdateVarTriggerHelper === true) || (autoUpdateVarTriggerHelper === 'true')) this.saveSettings(configData) this.process.send({ topic: 'reloadApplicances', uuid: uuid }) return { 'result': 'saved' } } else { return { 'result': 'missing argument' } } } getRoombyId(roomID) { return this.pluginRooms.filter(room => room.id === roomID)[0] || undefined } generateRoomListWithSupportedDevices() { let result = [] if (this.pluginRooms) { this.pluginRooms.map(room => { let oRoom = { id: room.id, name: room.name, devices: [] } let cList = room.channels this.compatibleDevices.map(device => { var dCList = [] device.channels.map(channel => { if ((cList.indexOf(channel.id) > -1) && (channel.isSuported === true)) { dCList.push(channel) } }) if (dCList.length > 0) { let oDevice = { id: device.id, name: device.name, type: device.type, channels: dCList } oRoom.devices.push(oDevice) } }) result.push(oRoom) }) } return result } saveGlobalSettings(query) { if (query.settings) { let oSettings = JSON.parse(query.settings) let config = this.loadSettings() if (config === undefined) { config = {} } config.useCCCAuthentication = ((oSettings.useAuth === true) || (oSettings.useAuth === 'true')) config.useTLS = ((oSettings.useTLS === true) || (oSettings.useTLS === 'true')) config.enableMonitoring = ((oSettings.enableMonitoring === true) || (oSettings.enableMonitoring === 'true')) config.disableHistory = ((oSettings.disableHistory === true) || (oSettings.disableHistory === 'true')) // make sure we have the value set if (oSettings.interfaceWatchdog) { if ((oSettings.interfaceWatchdog > 0) && (oSettings.interfaceWatchdog < 300)) { oSettings.interfaceWatchdog = 300 // min is 300seconds } config.interfaceWatchdog = oSettings.interfaceWatchdog } this.saveSettings(config) } } ccuPost(port, path, body) { return new Promise((resolve, reject) => { const options = { hostname: '127.0.0.1', port: port, path: path, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': body.length } } const req = http.request(options, (res) => { var data = '' res.on('data', (d) => { data = data + d }) res.on('end', () => { resolve(data) }) }) req.on('error', (error) => { reject(error) }) req.write(body) req.end() }) } existsDevice(channelAdr) { return this.compatibleDevices.filter(device => { return (channelAdr.indexOf(device.address) > -1) }).length > 0 } checkDevicesStillExists() { // load the channels from config this.log.debug('Check Lost and found') let config = this.loadSettings() let lostChannels = [] let result = [] let self = this if ((config !== undefined) && (config.channels !== undefined)) { // match the list config.channels.forEach((channelAdr) => { self.log.debug('probing channel %s', channelAdr) if (!self.existsDevice(channelAdr)) { if (!channelAdr.endsWith(':0')) { // uuid:0 are special channels so we will skip them self.log.debug('%s was removed from the ccu', channelAdr) lostChannels.push(channelAdr) } } else { self.log.debug('%s still exists', channelAdr) } }) } lostChannels.forEach(channelAdr => { let dta = config.mappings[channelAdr] dta.address = channelAdr result.push(dta) }) return result } async ccuGetDatapoints(channelID) { let script = "Write('{\"datapoints\":[');string sid;boolean dpf = true;var x = dom.GetObject(" script += channelID script += ");if (x) {foreach(sid, x.DPs().EnumUsedIDs()) {if (dpf) {dpf=false;} else {Write(',');}Write('\"');Write(dom.GetObject(sid).Name());Write('\"');}}Write(']}');" let result = await this.ccuPost(8181, '/tclrega.exe', script) try { const pos = result.lastIndexOf('<xml><exec>') const response = (result.substring(0, pos)) return JSON.parse(response) } catch (e) { return {} } } async ccuCGICall(sid, method, parameters) { let lParameters = { '_session_id_': sid } if (parameters) { Object.keys(parameters).map((key) => { lParameters[key] = parameters[key] }) } let body = { 'version': '1.1', 'method': method, 'params': lParameters } let result = await this.ccuPost(80, '/api/homematic.cgi', JSON.stringify(body)) try { return JSON.parse(result) } catch (e) { return {} } } async renewCCUSession(sid) { await this.ccuCGICall(sid, 'Session.renew') } isValidCCUSession(sid) { let self = this return new Promise((resolve, reject) => { // first remove the @ char let regex = /@([0-9a-zA-Z]{10})@/g let prts = regex.exec(sid) if ((prts) && (prts.length > 1)) { let script = 'Write(system.GetSessionVarStr(\'' + prts[1] + '\'));' let rega = new Rega(self.log, '127.0.0.1') rega.script(script).then(regaResult => { let rgx = /^([0-9]*);([0-9])*;([^;]*);([^;]*);([^;]*);$/ let usrPrts = rgx.exec(regaResult) self.log.debug('[Config] check auth %s', usrPrts) if ((usrPrts) && (usrPrts.length > 2)) { self.renewCCUSession(prts[1]) // renew the session in ccu resolve(parseInt(usrPrts[2]) >= 8) } else { resolve(false) } }) } else { resolve(false) } }) } async getCCUFirewallConfiguration() { // get the /etc/config/firewall.conf let config = {} let fireWallConfig = path.join('/', 'etc', 'config', 'firewall.conf') if (fs.existsSync(fireWallConfig)) { let dta = fs.readFileSync(fireWallConfig) if (dta) { let rgxMode = /MODE.=.([a-zA-Z_]{1,})/ let rgxModeParts = rgxMode.exec(dta) if ((rgxModeParts) && (rgxModeParts.length > 1)) { config.mode = rgxModeParts[1] } let rgxPorts = /USERPORTS.=.([0-9 ]{1,})/ let rgxPortsParts = rgxPorts.exec(dta) if ((rgxPortsParts) && (rgxPortsParts.length > 1)) { config.userports = rgxPortsParts[1].split(' ') } } else { this.log.error('[Config] firewallConfig not readable') } } else { this.log.error('[Config] unable to find firewall config %s', fireWallConfig) } return config } fetchUpdateChangelog() { return new Promise(async (resolve, reject) => { let version = this.fetchVersion() let rgx = RegExp('([a-zA-Z 0-9.:\n=*-]{1,})(?=Changelog for ' + version + ')') let strChangeLog = await this.getHTTP('https://raw.githubusercontent.com/thkl/hap-homematic/master/CHANGELOG.md', { headers: { 'Cache-Control': 'no-cache' } }) let rst = rgx.exec(strChangeLog) resolve(((rst) && (rst.length > 0)) ? rst[0] : 'No remote changelog found') }) } generateBackup() { let self = this return new Promise((resolve, reject) => { this.log.info('[Config] creating backup') let backupFile = '/tmp/hap_homematic_backup.tar.gz' // remove the old backup if there is one if (fs.existsSync(backupFile)) { this.log.warn('[Config] old backup found. will remove this') fs.unlinkSync(backupFile) } let backupCommand = 'tar -C ' + process.env.UIX_CONFIG_PATH + ' -czvf ' + backupFile + ' --exclude="*persist.json" --exclude="hap-autobackup_*.*" .' this.log.info('[Config] running %s', backupCommand) const childprocess = require('child_process') childprocess.exec(backupCommand, (error, stdout, stderr) => { self.log.info('[Config] creating backup done will return %s', stdout) if (error) { reject(error) } resolve(backupFile) }) }) } checkAndExtractUploadedConfig(tmpFile) { // create a tmp directory and extract the file let tmpDir = path.join('/', 'tmp', 'haptmp') if (fs.existsSync(tmpDir)) { // clean up by removing old stuff this.deleteFolderRecursive(tmpDir) } fs.mkdirSync(tmpDir) // extract the files there const childprocess = require('child_process') try { childprocess.execSync('tar -xzf ' + tmpFile + ' -C ' + tmpDir) } catch (e) { this.log.error('[Config] error while extracting the upload') return false } // check config.json try { let tmpConfig = JSON.parse(fs.readFileSync(path.join(tmpDir, 'config.json'))) if (tmpConfig) { // move the config to my folder let myConfigFile = path.join(process.env.UIX_CONFIG_PATH, 'config.json') if (fs.existsSync(myConfigFile)) { fs.unlinkSync(myConfigFile) } fs.copyFileSync(path.join(tmpDir, 'config.json'), path.join(process.env.UIX_CONFIG_PATH, 'config.json')) // copy the persistent files let rgx1 = new RegExp(os.hostname + '_.*.pstore') let rgx2 = new RegExp(os.hostname + '_.*_persist.json') fs.readdir(tmpDir, (err, files) => { if (!err) { files.forEach(file => { if ((file.match(rgx1)) || (file.match(rgx2))) { fs.copyFileSync(path.join(tmpDir, file), path.join(process.env.UIX_CONFIG_PATH, file)) } }) } }) // create the new persist folder let persistFolder = path.join(process.env.UIX_CONFIG_PATH, 'persist') if (!fs.existsSync(persistFolder)) { fs.mkdirSync(persistFolder) // copy all persist data to the config path fs.readdir(path.join(tmpDir, 'persist'), (err, files) => { if (!err) { files.forEach(file => { fs.copyFileSync(path.join(tmpDir, 'persist', file), path.join(persistFolder, file)) }) } }) } // remove the uploaded file fs.unlinkSync(tmpFile) return true } else { return false } } catch (e) { return false } } async heartBeat() { let self = this if (Object.keys(this.connections).length > 0) { let sysData = await this.getSystemInfo() this.sendMessageToSockets({ message: 'heartbeat', payload: sysData }) } setTimeout(() => { self.heartBeat() }, 180 * 1000) } extractSid(sid) { let regex = /@([0-9a-zA-Z]{10})@/g let prts = regex.exec(sid) if ((prts) && (prts.length > 1)) { return prts[1] } else { return undefined } } deleteFolderRecursive(pathRemove) { let self = this if (fs.existsSync(pathRemove)) { fs.readdirSync(pathRemove).forEach((file, index) => { const curPath = path.join(pathRemove, file) if (fs.lstatSync(curPath).isDirectory()) { // recurse self.deleteFolderRecursive(curPath) } else { // delete file fs.unlinkSync(curPath) } }) fs.rmdirSync(pathRemove) } } processRestore(request, response) { const formidable = require('formidable') const form = formidable({ multiples: false }) let self = this form.parse(request, (err, fields, files) => { if (!err) { if ((fields) && (fields['method'] === 'restore')) { // Check method if (self.checkSid(fields['sid'], response)) { // check session if ((files.file) && (files.file.path)) { if (self.checkAndExtractUploadedConfig(files.file.path)) { self.restartSystem() } } } } } response.writeHead(200, 'OK') response.end('OK') }) } async checkSid(sid, response) { if (this.useAuth === true) { // check if the provide sid is valid and the user has level 8 let validuser = await this.isValidCCUSession(sid) if (validuser === false) { this.log.error('[Config] invalid user') response.writeHead(401, 'Unauthorized') response.end('Unauthorized') return false } } return true } async processApiCall(query, response) { // if we are using ccu's authentication system let isValidUserSession = await this.checkSid(query.sid, response) if (isValidUserSession === false) { return } if (query.method) { let sid = this.extractSid(query.sid) var readStream switch (query.method) { case 'ccuGetDatapoints': let dps = await this.ccuGetDatapoints(query.cid) this.sendJSON(dps, response) break case 'refresh': this.sendObjects(sid) this.sendJSON({ result: 'ok' }, response) break case 'refreshCache': this.process.send({ topic: 'refreshCache' }) this.sendJSON({ result: 'initiated' }, response) break /** returns all known devices */ case 'devicelist': this.sendJSON(this.pluginAccessories, response) break /** returns all known variables */ case 'variablelist': let srvList = this.getVariableServiceList() this.sendJSON({ variables: this.pluginVariables, trigger: this.pluginVariableTrigger, services: srvList }, response) break /** returns all known programs */ case 'programlist': this.sendJSON({ programs: this.pluginPrograms }, response) break case 'speciallist': this.sendJSON({ special: this.plu