UNPKG

iobroker.viessmann

Version:

Connect a viessmann heating system over vcontrold

824 lines (767 loc) 32.1 kB
'use strict'; /* * Created with @iobroker/create-adapter v2.3.0 */ const utils = require('@iobroker/adapter-core'); const net = require('net'); const xml2js = require('xml2js'); const fs = require('fs'); const { Client } = require('ssh2'); //Hilfsobjekt zum abfragen der Werte const datapoints = {}; let toPoll = {}; //Zähler für Hilfsobjekt let step = -1; //Hilfsarray zum setzen von Werten let setcommands = []; //helpers for timeout let timerWait = null; let timerReconnect = null; let wait = false; const client = new net.Socket(); const parser = new xml2js.Parser(); //development herlpers const log_catch_err = false; class Viessmann extends utils.Adapter { /** * @param [options] */ constructor(options) { super({ ...options, name: 'viessmann', }); this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); this.on('unload', this.onUnload.bind(this)); this.ready = false; } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { // Initialize your adapter here this.startAdapter(); this.ready = true; } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * * @param callback */ onUnload(callback) { try { this.ready = false; this.setState('info.connection', false, true); client.end(); client.destroy(); // kill client after server's response // Here you must clear all timeouts or intervals that may still be active clearTimeout(timerWait); clearTimeout(timerReconnect); this.log.info('cleaned everything up...'); callback(); } catch (e) { console.log(e); callback(); } } /** * Is called if a subscribed state changes * * @param id * @param state */ onStateChange(id, state) { if (state) { if (id === `${this.namespace}.input.force_polling_interval`) { this.log.info(`Force polling interval: ${state.val}`); this.force(state.val); } else { this.log.info(`state ${id} changed: ${state.val} (ack = ${state.ack})`); setcommands.push(String(`set${id.substring(16, id.length)} ${state.val}`)); } } else { // The state was deleted this.log.info(`state ${id} deleted`); } } //############################################################################################## // is called when databases are connected and adapter received configuration. // start here! async startAdapter() { if (!this.config.datapoints.gets) { this.readxml(); } else if (this.config.new_read) { this.log.info(`Start read new XML...`); const obj = await this.getForeignObjectAsync(`system.adapter.${this.namespace}`); if (!obj) { this.log.warn(`No instance found! ${JSON.stringify(obj)}`); return; } obj.native.datapoints = {}; await this.setForeignObjectAsync(`system.adapter.${this.namespace}`, obj); this.log.info(`Try to read new XML files!`); this.readxml(); } else { this.main(); } } //##########IMPORT XML FILE################################################################################## async readxml() { this.log.debug('try to read xml files'); if (this.config.ip === '127.0.0.1') { this.vcontrold_read(`${this.config.path}/vcontrold.xml`); } else { //Create a SSH connection const ssh_session = new Client(); this.log.debug('try to create a ssh session'); ssh_session.connect({ host: this.config.ip, username: this.config.user_name, password: this.config.password, }); ssh_session.on('ready', () => { this.log.debug('FTP session ready'); ssh_session.sftp((err, sftp) => { if (err) { this.log.warn(`cannot create a SFTP session ${err}`); this.setState('info.connection', false, true); ssh_session.end(); } else { const moveVcontroldFrom = `${this.config.path}/vcontrold.xml`; const moveVcontroldTo = `${__dirname}/vcontrold.xml`; const moveVitoFrom = `${this.config.path}/vito.xml`; const moveVitoTo = `${__dirname}/vito.xml`; this.log.debug(`Try to copy Vito from: ${moveVitoFrom} to: ${__dirname}`); sftp.fastGet(moveVitoFrom, moveVitoTo, {}, err => { if (err) { this.log.warn(`cannot read vito.xml from Server: ${err}`); this.setState('info.connection', false, true); ssh_session.end(); } this.log.debug('Copy vito.xml from server to host successfully'); sftp.fastGet(moveVcontroldFrom, moveVcontroldTo, {}, err => { if (err) { this.log.warn(`cannot read vcontrold.xml from Server: ${err}`); this.vcontrold_read(moveVcontroldTo); ssh_session.end(); } this.log.debug('Copy vcontrold.xml from server to host successfully'); this.vcontrold_read(moveVcontroldTo); }); }); } }); }); ssh_session.on('close', () => { this.log.debug('SSH connection closed'); }); ssh_session.on('error', err => { this.log.warn(`check your SSH login dates ${err}`); }); } } async vcontrold_read(path) { fs.readFile(path, 'utf8', (err, data) => { if (err) { this.log.warn(`cannot read vcontrold.xml ${err}`); this.vito_read(); } else { parser.parseString(data, (err, result) => { if (err) { this.log.warn(`cannot parse vcontrold.xml --> cannot use units ${err}`); this.vito_read(); } else { let temp; try { temp = JSON.stringify(result); temp = JSON.parse(temp); } catch (e) { this.log.warn(`check vcontrold.xml structure: ${e}`); this.setState('info.connection', false, true); this.vito_read(); return; } const units = {}; const types = {}; for (const i in temp['V-Control'].units[0].unit) { try { for (const e in temp['V-Control'].units[0].unit[i].entity) { this.log.debug(`Numbers of entitys ${e}`); const obj = new Object(); obj.unit = temp['V-Control'].units[0].unit[i].entity[0]; units[temp['V-Control'].units[0].unit[i].abbrev[0]] = obj; } } catch (e) { this.log.warn(`check vcontrold.xml structure cannot read units: ${e}`); } try { for (const e in temp['V-Control'].units[0].unit[i].type) { this.log.debug(`Numbers of types ${e}`); const obj = new Object(); obj.type = temp['V-Control'].units[0].unit[i].type[0]; types[temp['V-Control'].units[0].unit[i].abbrev[0]] = obj; } } catch (e) { this.log.warn(`check vcontrold.xml structure cannot read types: ${e}`); } } this.log.debug(`Types in vcontrold.xml: ${JSON.stringify(types)}`); this.log.debug(`Units in vcontrold.xml: ${JSON.stringify(units)}`); this.log.info('read vcontrold.xml successfull'); this.vito_read(units, types); } }); } }); } async vito_read(units, types) { const path_ssh = `${__dirname}/vito.xml`; const path_host = `${this.config.path}/vito.xml`; let path = ''; if (this.config.ip === '127.0.0.1') { path = path_host; } else { path = path_ssh; } fs.readFile(path, 'utf8', (err, data) => { if (err) { this.log.warn(`cannot read vito.xml ${err}`); this.setState('info.connection', false, true); } else { parser.parseString(data, async (err, result) => { if (err) { this.log.warn(`cannot parse vito.xml ${err}`); this.setState('info.connection', false, true); } else { try { let temp = JSON.stringify(result); temp = JSON.parse(temp); const dp = await this.getImport(temp, units, types); await this.extendForeignObjectAsync(`system.adapter.${this.namespace}`, { native: { datapoints: dp, new_read: false }, }); this.log.info('read vito.xml successfull'); this.main(); } catch (e) { this.log.warn(`check vito.xml structure ${e}`); this.setState('info.connection', false, true); } } }); } }); } //########################################################################################################### //######IMPORT STATES######################################################################################## async getImport(json, units, types) { datapoints['gets'] = {}; datapoints['sets'] = {}; datapoints['system'] = {}; if (typeof json.vito.commands[0].command === 'object') { datapoints.system['-ID'] = json.vito.devices[0].device[0].$.ID; datapoints.system['-name'] = json.vito.devices[0].device[0].$.name; datapoints.system['-protocol'] = json.vito.devices[0].device[0].$.protocol; for (const i in json.vito.commands[0].command) { const poll = -1; const get_command = json.vito.commands[0].command[i].$.name; const desc = json.vito.commands[0].command[i].description[0]; if (get_command.substring(0, 3) === 'get' && get_command.length > 3) { const obj_get = new Object(); obj_get.name = get_command.substring(3, get_command.length); try { obj_get.unit = units[json.vito.commands[0].command[i].unit[0]].unit; } catch (e) { if (log_catch_err) { this.log.error(e); } this.log.error(e); obj_get.unit = ''; } try { obj_get.type = this.get_type(types[json.vito.commands[0].command[i].unit[0]].type); } catch (e) { if (log_catch_err) { this.log.error(e); } this.log.error(e); obj_get.type = 'mixed'; } obj_get.description = desc; obj_get.polling = poll; obj_get.command = get_command; datapoints.gets[get_command.substring(3, get_command.length)] = obj_get; continue; } if (get_command.substring(0, 3) === 'set' && get_command.length > 3) { const obj_set = new Object(); obj_set.name = get_command.substring(3, get_command.length); obj_set.description = desc; obj_set.polling = 'nicht möglich'; try { obj_set.type = this.get_type(types[json.vito.commands[0].command[i].unit[0]].type); } catch (e) { if (log_catch_err) { this.log.error(e); } this.log.error(e); obj_set.type = 'mixed'; } obj_set.command = get_command; datapoints.sets[get_command.substring(3, get_command.length)] = obj_set; continue; } } this.log.debug(`Objects are: ${JSON.stringify(datapoints)}`); return datapoints; } } //########################################################################################################### //######GET TYPES######################################################################################## get_type(types) { switch (types) { case 'enum': return 'string'; // eslint-disable-next-line no-unreachable break; case 'systime': return 'string'; // eslint-disable-next-line no-unreachable break; case 'cycletime': return 'string'; // eslint-disable-next-line no-unreachable break; case 'errstate': return 'string'; // eslint-disable-next-line no-unreachable break; case 'char': return 'number'; // eslint-disable-next-line no-unreachable break; case 'uchar': return 'number'; // eslint-disable-next-line no-unreachable break; case 'int': return 'number'; // eslint-disable-next-line no-unreachable break; case 'uint': return 'number'; // eslint-disable-next-line no-unreachable break; case 'short': return 'number'; // eslint-disable-next-line no-unreachable break; case 'ushort': return 'number'; // eslint-disable-next-line no-unreachable break; default: return 'mixed'; } } //########################################################################################################### //######SET STATES########################################################################################### async addState(pfad, name, unit, beschreibung, type, write, callback) { await this.setObjectNotExistsAsync( pfad + name, { type: 'state', common: { name: name, unit: unit, type: type, desc: beschreibung, read: true, write: write, }, native: {}, }, callback, ); } //########################################################################################################### //######CONFIG STATES######################################################################################## setAllObjects(callback) { this.getStatesOf((err, states) => { const configToDelete = []; const configToAdd = []; //let id; const pfadget = 'get.'; const pfadset = 'set.'; let count = 0; if (this.config.datapoints) { if (this.config.states_only) { for (const i in this.config.datapoints.gets) { if ( this.config.datapoints.gets[i].polling !== -1 && this.config.datapoints.gets[i].polling != '-1' ) { configToAdd.push(this.config.datapoints.gets[i].name); } } } else { for (const i in this.config.datapoints.gets) { configToAdd.push(this.config.datapoints.gets[i].name); } } for (const i in this.config.datapoints.sets) { configToAdd.push(this.config.datapoints.sets[i].name); } } if (states) { for (let i = 0; i < states.length; i++) { const name = states[i].common.name; if ( typeof name == 'object' || name === 'connection' || name === 'lastPoll' || name === 'timeout_connection' || name === 'Force polling interval' ) { continue; } const clean = states[i]._id; if (name.length < 1) { this.log.warn(`No states found for ${JSON.stringify(states[i])}`); continue; } //id = name.replace(/[.\s]+/g, '_'); const pos = configToAdd.indexOf(name); if (pos !== -1) { configToAdd.splice(pos, 1); } else { configToDelete.push(clean); } } } if (configToAdd.length) { for (const i in this.config.datapoints.gets) { if (configToAdd.indexOf(this.config.datapoints.gets[i].name) !== -1) { count++; this.addState( pfadget, this.config.datapoints.gets[i].name, this.config.datapoints.gets[i].unit, this.config.datapoints.gets[i].description, this.config.datapoints.gets[i].type, false, () => { if (!--count && callback) { callback(); } }, ); } } for (const i in this.config.datapoints.sets) { if (configToAdd.indexOf(this.config.datapoints.sets[i].name) !== -1) { count++; this.addState( pfadset, this.config.datapoints.sets[i].name, '', this.config.datapoints.sets[i].description, this.config.datapoints.sets[i].type, true, () => { if (!--count && callback) { callback(); } }, ); } } } if (configToDelete.length) { for (let e = 0; e < configToDelete.length; e++) { this.log.debug(`States to delete: ${configToDelete[e]}`); this.delObject(configToDelete[e]); } } if (!count && callback) { callback(); } }); } //########################################################################################################### //######POLLING############################################################################################## stepPolling() { if (wait) { this.log.warn(`Wait for feedback from Vcontrold...`); return; } clearTimeout(timerWait); step = -1; let actualMinWaitTime = 1000000; const time = Date.now(); if (setcommands.length > 0) { const cmd = setcommands.shift(); this.log.debug(`Set command: ${cmd}`); client.write(`${cmd}\n`); wait = true; return; } for (const i in toPoll) { if (typeof toPoll[i].lastPoll === 'undefined') { toPoll[i].lastPoll = time; } const nextRun = toPoll[i].lastPoll + toPoll[i].polling * 1000; const nextDiff = nextRun - time; if (time < nextRun) { if (actualMinWaitTime > nextDiff) { actualMinWaitTime = nextDiff; } continue; } if (nextDiff < actualMinWaitTime) { actualMinWaitTime = nextDiff; step = i; } } if (step == Object.keys(toPoll)[Object.keys(toPoll).length - 1] || step === -1) { this.setState('info.lastPoll', Math.floor(time / 1000), true); } if (step === -1) { this.log.debug(`Wait for next run: ${actualMinWaitTime} in ms`); timerWait = setTimeout(() => { this.stepPolling(); }, actualMinWaitTime); } else { this.log.debug(`Next poll: ${toPoll[step].command} (For Object: ${step})`); toPoll[step].lastPoll = Date.now(); client.write(`${toPoll[step].command}\n`); wait = true; } } //########################################################################################################### //######CONFIGURE POLLING COMMANDS########################################################################### commands() { let obj = new Object(); obj.name = 'Dummy'; obj.command = 'heartbeat'; obj.description = 'keep the adapter to stay alive'; obj.polling = 60; obj.lastpoll = 0; toPoll['heartbeat'] = obj; for (const i in this.config.datapoints.gets) { if (this.config.datapoints.gets[i].polling > -1) { this.log.debug(`Commands for polling: ${this.config.datapoints.gets[i].command}`); obj = new Object(); obj.name = this.config.datapoints.gets[i].name; obj.command = this.config.datapoints.gets[i].command; obj.description = this.config.datapoints.gets[i].description; obj.polling = this.config.datapoints.gets[i].polling; obj.lastpoll = 0; toPoll[i] = obj; } } } //########################################################################################################### //######CUT ANSWER########################################################################################### split_unit(v) { // test if string starts with non digits, then just pass it if (typeof v === 'string' && v !== '' && /^\D.*$/.test(v) && !/^-?/.test(v)) { return v; } else if (typeof v === 'string' && v !== '') { const split = v.match(/^([-.\d]+(?:\.\d+)?)(.*)$/); if (this.isDate(split[1])) { return v; } return split[1].trim(); } // catch the rest return v; } isDate(val) { const d = new Date(val); return !isNaN(d.valueOf()); } roundNumber(num, scale) { const number = Math.round(num * Math.pow(10, scale)) / Math.pow(10, scale); if (num - number > 0) { return ( number + Math.floor((2 * Math.round((num - number) * Math.pow(10, scale + 1))) / 10) / Math.pow(10, scale) ); } return number; } connectSystem() { const ip = this.config.ip; const port = this.config.port || 3002; const time_out = 120000; this.log.info(`Connecting...`); client.setTimeout(time_out); client.connect(port, ip); wait = false; } getReconnectTime() { const reconnectDefault = 5; let reconnect = parseFloat(this.config.reconnect); if (isNaN(reconnect) || reconnect < 0.1) { this.log.warn('Reconnect time configuration is not a number or <0.1. Using default setting.'); reconnect = reconnectDefault; } return reconnect * 60000; } reconnectSystem(reconnect = null) { if (this.ready == false) { return; } client.destroy(); clearTimeout(timerWait); clearTimeout(timerReconnect); if (reconnect == null) { reconnect = this.getReconnectTime(); } this.log.info(`Reconnecting in ${reconnect} ms.`); timerReconnect = setTimeout(() => { this.connectSystem(); }, reconnect); } //########################################################################################################### //######MAIN################################################################################################# async main() { this.setState('info.timeout_connection', false, true); this.setState('info.connection', false, true); toPoll = {}; setcommands = []; const answer = this.config.answer; let err_count = 0; clearTimeout(timerReconnect); this.setAllObjects(); this.connectSystem(); client.on('close', () => { this.setState('info.connection', false, true, err => { if (err) { this.log.error(err); } }); this.log.info('Disconnected from Viessmann system!'); this.reconnectSystem(); }); client.on('ready', () => { this.setState('info.connection', true, true, err => { if (err) { this.log.error(err); } }); this.log.info('Connected to Viessmann system!'); this.setState('info.timeout_connection', false, true); this.commands(); this.stepPolling(); }); client.on('data', data => { data = String(data); const ok = /OK/; const fail = /ERR/; const vctrld = /vctrld>/; if (ok.test(data)) { this.log.debug('Send command okay!'); wait = false; this.stepPolling(); } else if (fail.test(data) && step !== 'heartbeat') { this.log.warn(`Vctrld send ERROR: ${data}`); err_count++; if (err_count > 5 && this.config.errors) { this.setState('info.connection', false, true); this.log.warn('Vctrld send too many errors, restart connection!'); this.reconnectSystem(10000); } else { wait = false; this.stepPolling(); } } else if (data == 'vctrld>') { return; } else if (step == -1) { return; } else if (step == 'heartbeat') { wait = false; this.stepPolling(); } else if (step == '') { return; } else { wait = false; this.log.debug(`Received: ${data}`); err_count = 0; if (vctrld.test(data)) { data = data.substring(0, data.length - 7); } try { data = data.replace(/\n$/, ''); if (answer) { data = this.split_unit(data); if (!isNaN(data)) { data = this.roundNumber(parseFloat(data), 2); } this.setState(`get.${toPoll[step].name}`, data, true, err => { if (err) { this.log.error(err); } this.stepPolling(); }); } else { this.setState(`get.${toPoll[step].name}`, data, true, err => { if (err) { this.log.error(err); } this.stepPolling(); }); } } catch (e) { if (log_catch_err) { this.log.error(e); } this.setState(`get.${toPoll[step].name}`, data, true, err => { if (err) { this.log.error(err); } this.stepPolling(); }); } err_count = 0; } }); client.on('error', e => { this.setState('info.connection', false, true); this.log.warn(`Connection error--> ${e}`); this.reconnectSystem(); }); client.on('timeout', () => { this.setState('info.connection', false, true); this.log.warn('Timeout connection error!'); this.setState('info.timeout_connection', true, true); this.reconnectSystem(); }); // in this viessmann all states changes inside the adapters namespace are subscribed this.subscribeStates('set.*'); this.subscribeStates('input.*'); } //#############HELPERS####################################################################################### force(id) { try { const force_step = id.slice(3); toPoll[force_step].lastPoll = 0; this.stepPolling(); } catch (e) { this.log.debug(e); this.log.warn(`Force polling interval: ${id} not incude in get states`); } } //########################################################################################################### } if (require.main !== module) { // Export the constructor in compact mode /** * @param [options] */ module.exports = options => new Viessmann(options); } else { // otherwise start the instance directly new Viessmann(); }