UNPKG

iobroker.homeconnect

Version:
1,321 lines (1,265 loc) 48.6 kB
'use strict'; /* * Created with @iobroker/create-adapter v2.1.1 */ // The adapter-core module gives you access to the core ioBroker functions // you need to create an adapter const utils = require('@iobroker/adapter-core'); const axios = require('axios'); const rateLimit = require('axios-rate-limit'); const qs = require('qs'); const EventSource = require('eventsource'); const tough = require('tough-cookie'); const { HttpsCookieAgent } = require('http-cookie-agent/http'); class Homeconnect extends utils.Adapter { /** * @param {Partial<utils.AdapterOptions>} [options={}] */ constructor(options) { super({ ...options, name: 'homeconnect', }); this.on('ready', this.onReady.bind(this)); this.on('stateChange', this.onStateChange.bind(this)); this.on('unload', this.onUnload.bind(this)); this.headers = { 'user-agent': this.userAgent, Accept: 'application/vnd.bsh.sdk.v1+json', 'Accept-Language': 'de-DE', }; this.deviceArray = []; this.fetchedDevice = {}; this.availablePrograms = {}; this.availableProgramOptions = {}; this.eventSourceState; this.currentSelected = {}; } /** * Is called when databases are connected and adapter received configuration. */ async onReady() { // Reset the connection indicator during startup this.setState('info.connection', false, true); if (this.config.resetAccess) { this.log.info('Reset access'); this.setState('auth.session', '', true); this.setState('dev.refreshToken', '', true); const adapterConfig = 'system.adapter.' + this.name + '.' + this.instance; this.getForeignObject(adapterConfig, (error, obj) => { if (obj) { obj.native.resetAccess = false; this.setForeignObject(adapterConfig, obj); } else { this.log.error('No reset possible no Adapterconfig found'); } }); return; } this.userAgent = 'ioBroker v1.0.0'; this.cookieJar = new tough.CookieJar(); this.requestClient = rateLimit( axios.create({ withCredentials: true, httpsAgent: new HttpsCookieAgent({ cookies: { jar: this.cookieJar, }, }), }), { maxRequests: 45, perMilliseconds: 60000 }, ); this.reLoginTimeout = null; this.refreshTokenTimeout = null; this.session = {}; this.subscribeStates('*'); const sessionState = await this.getStateAsync('auth.session'); if (sessionState && sessionState.val) { this.log.debug('Found current session'); this.session = JSON.parse(sessionState.val); } else { const refreshToken = await this.getStateAsync('dev.refreshToken'); if (refreshToken && refreshToken.val) { this.log.debug('Found old refreshtoken'); this.session.refresh_token = refreshToken.val; } } if (this.session.refresh_token) { await this.refreshToken(); } else { if (!this.config.username || !this.config.password || !this.config.clientID) { this.log.warn('please enter homeconnect app username and password and clientId in the instance settings'); return; } this.log.debug('Start normal login'); await this.login(); } if (this.session.access_token) { this.headers.authorization = 'Bearer ' + this.session.access_token; await this.getDeviceList(); await this.startEventStream(); // this.refreshStatusInterval = setInterval(async () => { // for (const haId of this.deviceArray) { // this.log.debug("Update status for " + haId); // this.getAPIValues(haId, "/status"); // } // }, 10 * 60 * 1000); //every 10 minutes //Workaround because sometimes no connect event for offline events // this.reconnectInterval = setInterval(async () => { // this.startEventStream(); // }, 60 * 60 * 1000); //every 60 minutes this.refreshTokenInterval = setInterval( async () => { await this.refreshToken(); this.startEventStream(); }, (this.session.expires_in - 200) * 1000, ); } } async login() { let loginUrl = ''; let tokenRequestSuccesful = false; const deviceAuth = await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/device_authorization', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: 'client_id=' + this.config.clientID + '&scope=IdentifyAppliance%20Monitor%20Settings%20Control', }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error(error); if (error.response) { this.log.error(JSON.stringify(error.response.data)); if (error.response.data.error === 'unauthorized_client') { this.log.error('Please check your clientID or wait 15 minutes until it is active'); } } }); if (!deviceAuth || !deviceAuth.verification_uri_complete) { this.log.error('No verification_uri_complete in device_authorization'); return; } const loginResponse = await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/device_login', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: qs.stringify({ user_code: deviceAuth.user_code, client_id: this.config.clientID, accept_language: 'de', region: 'EU', environment: 'PRD', lookup: 'true', email: this.config.username, password: this.config.password, }), }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); if (res.data.match('data-error-data="" >(.*)<')) { this.log.info('Normal Login response ' + res.data.match('data-error-data="" >(.*)<')[1]); this.log.info('Try new SingleKey Login'); const formData = await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/device_login', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: qs.stringify({ user_code: deviceAuth.user_code, client_id: this.config.clientID, accept_language: 'de', region: 'EU', environment: 'PRD', lookup: 'true', email: this.config.username, }), }) .then((res) => { this.log.debug(JSON.stringify(res.data)); loginUrl = res.request.path; return this.extractHidden(res.data); }) .catch((error) => { this.log.error(error); if (error.response) { this.log.error(JSON.stringify(error.response.data)); } }); const loginParams = qs.parse(loginUrl.split('?')[1]); const returnUrl = loginParams.returnUrl || loginParams.ReturnUrl; await this.requestClient({ method: 'post', maxBodyLength: Infinity, url: 'https://singlekey-id.com/auth/de-de/login/password', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: '*/*', 'hx-request': 'true', 'sec-fetch-site': 'same-origin', 'hx-boosted': 'true', 'accept-language': 'de-DE,de;q=0.9', 'sec-fetch-mode': 'cors', origin: 'https://singlekey-id.com', 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1', 'sec-fetch-dest': 'empty', }, params: loginParams, data: { Password: this.config.password, RememberMe: 'true', __RequestVerificationToken: formData['undefined'], }, }).catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); return await this.requestClient({ method: 'get', url: 'https://singlekey-id.com' + returnUrl, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); } this.log.info('Login details submitted'); return this.extractHidden(res.data); }) .catch((error) => { this.log.error('Please check username and password'); this.log.error(error); if (error.response) { this.log.error(JSON.stringify(error.response.data)); } }); const grantData = this.extractHidden(loginResponse); await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/device_grant', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: grantData, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return; }) .catch((error) => { this.log.error(error); if (error.response) { this.log.error(JSON.stringify(error.response.data)); } }); await this.sleep(6000); while (!tokenRequestSuccesful) { await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/token', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: qs.stringify({ grant_type: 'device_code', device_code: deviceAuth.device_code, client_id: this.config.clientID, }), }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.session = res.data; this.log.info('Token received succesfully'); await this.setStateAsync('info.connection', true, true); await this.setStateAsync('auth.session', JSON.stringify(this.session), true); tokenRequestSuccesful = true; }) .catch(async (error) => { this.log.error(error); this.log.error('Please check username and password or visit this site for manually login: '); this.log.error('Bitte überprüfe Benutzername und Passwort oder besuche diese Seite für manuelle Anmeldung: '); this.log.error(deviceAuth.verification_uri_complete); // this.log.error(error); if (error.response) { this.log.error(JSON.stringify(error.response.data)); } this.log.info('Wait 10 seconds to retry'); await this.sleep(10000); }); } } async getDeviceList() { this.deviceArray = []; this.log.debug('Get device list'); await this.requestClient({ method: 'get', url: 'https://api.home-connect.com/api/homeappliances', headers: this.headers, }) .then(async (res) => { this.log.debug(JSON.stringify(res.data)); this.log.info(`Found ${res.data.data.homeappliances.length} devices`); for (const device of res.data.data.homeappliances) { let haID = device.haId; if (!haID) { this.log.info('Invalid device ' + JSON.stringify(device)); continue; } haID = haID.replace(/\.?-001*$/, ''); this.deviceArray.push(haID); const name = device.name; await this.setObjectNotExistsAsync(haID, { type: 'device', common: { name: name, }, native: {}, }); await this.setObjectNotExistsAsync(haID + '.commands', { type: 'channel', common: { name: 'Commands', }, native: {}, }); await this.setObjectNotExistsAsync(haID + '.general', { type: 'channel', common: { name: 'General Information', }, native: {}, }); const remoteArray = [ { command: 'BSH_Common_Command_PauseProgram', name: 'True = Pause' }, { command: 'BSH_Common_Command_ResumeProgram', name: 'True = Resume' }, { command: 'BSH_Common_Command_StopProgram', name: 'True = Stop' }, ]; remoteArray.forEach((remote) => { this.setObjectNotExists(haID + '.commands.' + remote.command, { type: 'state', common: { name: remote.name || '', type: remote.type || 'boolean', role: remote.role || 'boolean', write: true, read: true, }, native: {}, }); }); for (const key in device) { await this.setObjectNotExistsAsync(haID + '.general.' + key, { type: 'state', common: { name: key, type: typeof device[key], role: 'indicator', write: false, read: true, }, native: {}, }); this.setState(haID + '.general.' + key, device[key], true); } if (device.connected) { this.fetchDeviceInformation(haID); } } }) .catch((error) => { this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); }); } async fetchDeviceInformation(haId) { this.getAPIValues(haId, '/status'); this.getAPIValues(haId, '/settings'); this.getAPIValues(haId, '/programs/active'); this.getAPIValues(haId, '/programs/selected'); if (!this.fetchedDevice[haId]) { this.fetchedDevice[haId] = true; this.getAPIValues(haId, '/programs'); this.updateOptions(haId, '/programs/active'); this.updateOptions(haId, '/programs/selected'); } } async getAPIValues(haId, url) { await this.sleep(Math.floor(Math.random() * 1500)); const returnValue = await this.requestClient({ method: 'get', url: 'https://api.home-connect.com/api/homeappliances/' + haId + url, headers: this.headers, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { if (error.response) { const description = error.response.data.error ? error.response.data.error.description : ''; this.log.info(`${haId}${url}: ${description}.`); this.log.debug(JSON.stringify(error.response.data)); } else { this.log.info(error); } return; }); if (!returnValue || returnValue.error) { returnValue && this.log.debug('Error: ' + returnValue.error); return; } try { this.log.debug(url); this.log.debug(JSON.stringify(returnValue)); if (url.indexOf('/settings/') !== -1) { let type = 'string'; if (returnValue.data.type === 'Int' || returnValue.data.type === 'Double') { type = 'number'; } if (returnValue.data.type === 'Boolean') { type = 'boolean'; } const common = { name: returnValue.data.name, type: type, role: 'indicator', write: true, read: true, }; if (returnValue.data.constraints && returnValue.data.constraints.allowedvalues) { const states = {}; returnValue.data.constraints.allowedvalues.forEach((element, index) => { states[element] = returnValue.data.constraints.displayvalues[index] || element; }); common.states = states; } const folder = '.settings.' + returnValue.data.key.replace(/\./g, '_'); this.log.debug('Extend Settings: ' + haId + folder); await this.extendObjectAsync(haId + folder, { type: 'state', common: common, native: {}, }); return; } if (url.indexOf('/programs/available/') !== -1) { if (returnValue.data.options) { this.availableProgramOptions[returnValue.data.key] = this.availableProgramOptions[returnValue.data.key] || []; for (const option of returnValue.data.options) { this.availableProgramOptions[returnValue.data.key].push(option.key); let type = 'string'; if (option.type === 'Int' || option.type === 'Double') { type = 'number'; } if (option.type === 'Boolean') { type = 'boolean'; } const common = { name: option.name, type: type, role: 'indicator', unit: option.unit || '', write: true, read: true, }; if (option.constraints && option.constraints.min && typeof option.constraints.min === 'number') { common.min = option.constraints.min; } if (option.constraints && option.constraints.max && typeof option.constraints.max === 'number') { common.max = option.constraints.max; } if (option.constraints && option.constraints.allowedvalues) { common.states = {}; for (const element of option.constraints.allowedvalues) { common.states[element] = option.constraints.displayvalues[option.constraints.allowedvalues.indexOf(element)]; } } let folder = '.programs.available.options.' + option.key.replace(/\./g, '_'); this.log.debug('Extend Options: ' + haId + folder); await this.setObjectNotExistsAsync(haId + folder, { type: 'state', common: common, native: {}, }).catch(() => { this.log.error('failed set state'); }); await this.extendObjectAsync(haId + folder, { type: 'state', common: common, native: {}, }); this.log.debug('Set default value'); if (option.constraints && option.constraints.default) { let value = option.constraints.default; if (option.constraints.default > option.constraints.max) { value = option.constraints.max; this.log.debug( `Default value ${option.constraints.default} is greater than max ${option.constraints.max}. Set to max.`, ); } await this.setStateAsync(haId + folder, value, true); } const key = returnValue.data.key.split('.').pop(); await this.setObjectNotExistsAsync(haId + '.programs.selected.options.' + key, { type: 'state', common: { name: returnValue.data.name, type: 'mixed', role: 'indicator', write: true, read: true }, native: {}, }) .then(() => {}) .catch(() => { this.log.error('failed set state'); }); folder = '.programs.selected.options.' + key + '.' + option.key.replace(/\./g, '_'); await this.extendObjectAsync(haId + folder, { type: 'state', common: common, native: {}, }); } } return; } if ('key' in returnValue.data) { returnValue.data = { items: [returnValue.data], }; } for (const item in returnValue.data) { if (Array.isArray(returnValue.data[item])) { for (const subElement of returnValue.data[item]) { let folder = url.replace(/\//g, '.'); if (url === '/programs/active') { subElement.value = subElement.key; subElement.key = 'BSH_Common_Root_ActiveProgram'; subElement.name = 'BSH_Common_Root_ActiveProgram'; } if (url === '/programs/selected') { if (subElement.key) { subElement.value = subElement.key; this.currentSelected[haId] = { key: subElement.value, name: subElement.name }; subElement.key = 'BSH_Common_Root_SelectedProgram'; subElement.name = 'BSH_Common_Root_SelectedProgram'; } else { this.log.warn('Empty sublement: ' + JSON.stringify(subElement)); } } if (url === '/programs') { this.log.debug(haId + ' available: ' + JSON.stringify(subElement)); if (this.availablePrograms[haId]) { this.availablePrograms[haId].push({ key: subElement.key, name: subElement.name || subElement.key, }); } else { this.availablePrograms[haId] = [ { key: subElement.key, name: subElement.name || subElement.key, }, ]; } this.getAPIValues(haId, '/programs/available/' + subElement.key); folder += '.available'; } if (url === '/settings') { this.getAPIValues(haId, '/settings/' + subElement.key); } if (url.indexOf('/programs/selected/') !== -1) { if (!this.currentSelected[haId]) { return; } if (!this.currentSelected[haId].key) { this.log.warn(JSON.stringify(this.currentSelected[haId]) + ' is selected but has no key selected '); return; } const key = this.currentSelected[haId].key.split('.').pop(); folder += '.' + key; await this.setObjectNotExistsAsync(haId + folder, { type: 'state', common: { name: this.currentSelected[haId].name, type: 'mixed', role: 'indicator', write: true, read: true, }, native: {}, }).catch(() => { this.log.error('failed set state'); }); } this.log.debug('Create State: ' + haId + folder + '.' + subElement.key.replace(/\./g, '_')); let type = 'mixed'; if (typeof subElement.value === 'boolean') { type = 'boolean'; } if (typeof subElement.value === 'number') { type = 'number'; } const common = { name: subElement.name, type: type, role: 'indicator', write: true, read: true, unit: subElement.unit || '', }; if ( subElement.constraints && subElement.constraints.min && typeof subElement.constraints.min === 'number' ) { common.min = subElement.constraints.min; } if ( subElement.constraints && subElement.constraints.max && typeof subElement.constraints.max === 'number' ) { common.max = subElement.constraints.max; } this.extendObject(haId + folder + '.' + subElement.key.replace(/\./g, '_'), { type: 'state', common: common, native: {}, }) .then(() => { if (subElement.value !== undefined) { this.log.debug('Set api value'); let value = subElement.value; //check if value is an object if (typeof value === 'object' && value !== null) { value = JSON.stringify(value); } this.setState(haId + folder + '.' + subElement.key.replace(/\./g, '_'), value, true).catch( (error) => { this.log.error('failed set state ' + haId + folder + '.' + subElement.key.replace(/\./g, '_')); this.log.error("Value: '" + value + "'"); this.log.error(error); }, ); } }) .catch(() => { this.log.error('failed set state'); }); } } else { this.log.info('No array: ' + item); } } if (url === '/programs') { const rootItems = [ { key: 'BSH_Common_Root_ActiveProgram', folder: '.programs.active', }, { key: 'BSH_Common_Root_SelectedProgram', folder: '.programs.selected', }, ]; if (!this.availablePrograms[haId]) { this.log.info('No available programs found for: ' + haId); return; } rootItems.forEach(async (rootItem) => { const common = { name: rootItem.key, type: 'string', role: 'indicator', write: true, read: true, states: {}, }; this.availablePrograms[haId].forEach((program) => { common.states[program.key] = program.name; }); await this.setObjectNotExistsAsync(haId + rootItem.folder + '.' + rootItem.key.replace(/\./g, '_'), { type: 'state', common: common, native: {}, }) .then(() => {}) .catch(() => { this.log.error('failed set state'); }); await this.extendObjectAsync(haId + rootItem.folder + '.' + rootItem.key.replace(/\./g, '_'), { type: 'state', common: common, native: {}, }); }); } } catch (error) { this.log.error(error); this.log.error(error.stack); this.log.error(url); this.log.error(JSON.stringify(returnValue)); } } async updateOptions(haId, url, forceDeletion) { const pre = this.name + '.' + this.instance; const states = await this.getStatesAsync(pre + '.' + haId + '.programs.*'); if (!states) { this.log.warn('No states found for: ' + pre + '.' + haId + '.programs.*'); return; } const allIds = Object.keys(states); let searchString = 'selected.options.'; if (url.indexOf('/active') !== -1) { searchString = 'active.options.'; this.log.debug(searchString); //delete only for active options this.log.debug('Delete: ' + haId + url.replace(/\//g, '.') + '.options'); for (const keyName of allIds) { if ( (keyName.indexOf(searchString) !== -1 && keyName.indexOf('BSH_Common_Option') === -1) || (forceDeletion && keyName.indexOf('BSH_Common_Option_RemainingProgramTime') === -1) ) { await this.delObjectAsync(keyName.split('.').slice(2).join('.')); } else if (keyName.indexOf('BSH_Common_Option_ProgramProgress') !== -1) { const programProgess = await this.getStateAsync( haId + '.programs.active.options.BSH_Common_Option_ProgramProgress', ); if (programProgess && programProgess.val !== 100) { await this.setStateAsync(haId + '.programs.active.options.BSH_Common_Option_ProgramProgress', 100, true); } } else if (keyName.indexOf('BSH_Common_Option_RemainingProgramTime') !== -1) { const remainTime = await this.getStateAsync( haId + '.programs.active.options.BSH_Common_Option_RemainingProgramTime', ); if (remainTime && remainTime.val !== 0) { await this.setStateAsync(haId + '.programs.active.options.BSH_Common_Option_RemainingProgramTime', 0, true); } } } } setTimeout(() => this.getAPIValues(haId, url + '/options'), 0); } async putAPIValues(haId, url, data) { this.log.debug(`Put ${JSON.stringify(data)} to ${url} for ${haId}`); await this.requestClient({ method: 'PUT', url: 'https://api.home-connect.com/api/homeappliances/' + haId + url, headers: this.headers, data: data, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error(error); if (error.response) { if (error.response.status === 403) { this.log.info('Homeconnect API has not the rights for this command and device'); } this.log.error(JSON.stringify(error.response.data)); } }); } async deleteAPIValues(haId, url) { await this.requestClient({ method: 'DELETE', url: 'https://api.home-connect.com/api/homeappliances/' + haId + url, headers: this.headers, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); return res.data; }) .catch((error) => { this.log.error(error); if (error.response) { if (error.response.status === 403) { this.log.info('Homeconnect API has not the rights for this command and device'); } this.log.error(JSON.stringify(error.response.data)); } }); } async startEventStream() { this.log.debug('Start EventStream'); const baseUrl = 'https://api.home-connect.com/api/homeappliances/events'; const header = { headers: { Authorization: 'Bearer ' + this.session.access_token, Accept: 'text/event-stream', }, }; if (this.eventSourceState) { this.eventSourceState.close(); this.eventSourceState.removeEventListener('PAIRED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('DEPAIRED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('STATUS', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('NOTIFY', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('EVENT', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('CONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('DISCONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('KEEP-ALIVE', (e) => this.resetReconnectTimeout(e.lastEventId), false); } this.eventSourceState = new EventSource(baseUrl, header); // Error handling this.eventSourceState.onerror = (err) => { if (err.status) { this.log.error(err.status + ' ' + err.message); } else { this.log.debug('EventSource error: ' + JSON.stringify(err)); this.log.debug('Undefined Error from Homeconnect this happens sometimes.'); } if (err.status !== undefined) { this.log.error('Start Event Stream Error: ' + JSON.stringify(err)); if (err.status === 401) { this.refreshToken(); // Most likely the token has expired, try to refresh the token this.log.info('Token abgelaufen'); } else if (err.status === 429) { this.log.info('Too many requests. Please wait 24h.'); } else { this.log.error('Error: ' + err.status); this.log.error('Error: ' + JSON.stringify(err)); if (err.status >= 500) { this.log.error('Homeconnect API are not available please try again later'); } } } }; this.eventSourceState.addEventListener('PAIRED', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('DEPAIRED', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('STATUS', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('NOTIFY', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('EVENT', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('CONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener('DISCONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.addEventListener( 'KEEP-ALIVE', (e) => { this.resetReconnectTimeout(); this.log.debug(e); }, false, ); this.resetReconnectTimeout(); } resetReconnectTimeout() { this.reconnectTimeout && clearInterval(this.reconnectTimeout); this.reconnectTimeout = setInterval(() => { this.log.info('Keep Alive failed Reconnect EventStream'); this.startEventStream(); }, 70000); } processEvent(msg) { try { this.log.debug('event: ' + JSON.stringify(msg)); const stream = msg; //eslint-disable-next-line no-useless-escape const lastEventId = stream.lastEventId.replace(/\.?\-001*$/, ''); if (!stream) { this.log.debug('No Return: ' + stream); return; } this.resetReconnectTimeout(); if (stream.type == 'DISCONNECTED') { this.log.info('DISCONNECTED: ' + lastEventId); this.setState(lastEventId + '.general.connected', false, true); this.updateOptions(lastEventId, '/programs/active'); return; } if (stream.type == 'CONNECTED' || stream.type == 'PAIRED') { this.log.info('CONNECTED: ' + lastEventId); this.setState(lastEventId + '.general.connected', true, true); if (this.config.disableFetchConnect) { return; } this.fetchDeviceInformation(lastEventId); return; } const parseMsg = msg.data; const parseMessage = JSON.parse(parseMsg); parseMessage.items.forEach(async (element) => { let haId = parseMessage.haId; //eslint-disable-next-line no-useless-escape haId = haId.replace(/\.?\-001*$/, ''); let folder; let key; if (stream.type === 'EVENT') { folder = 'events'; key = element.key.replace(/\./g, '_'); } else { folder = element.uri.split('/').splice(4); if (folder[folder.length - 1].indexOf('.') != -1) { folder.pop(); } folder = folder.join('.'); key = element.key.replace(/\./g, '_'); } this.log.debug(haId + '.' + folder + '.' + key + ':' + element.value); await this.setObjectNotExistsAsync(haId + '.' + folder + '.' + key, { type: 'state', common: { name: key, type: 'mixed', role: 'indicator', write: true, read: true, unit: element.unit || '', }, native: {}, }) .then(() => {}) .catch(() => { this.log.error('failed set state'); }); if (element.value !== undefined) { this.log.debug('Set event state '); await this.setStateAsync(haId + '.' + folder + '.' + key, element.value, true); } }); } catch (error) { this.log.error('Parsemessage: ' + error); this.log.error('Error Event: ' + JSON.stringify(msg)); } } async refreshToken() { if (!this.session) { this.log.error('No session found relogin'); await this.login(); return; } this.log.debug('Refresh Token'); await this.requestClient({ method: 'post', url: 'https://api.home-connect.com/security/oauth/token', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, data: 'grant_type=refresh_token&refresh_token=' + this.session.refresh_token, }) .then((res) => { this.log.debug(JSON.stringify(res.data)); this.session = res.data; this.headers.authorization = 'Bearer ' + this.session.access_token; this.setState('info.connection', true, true); this.setState('auth.session', JSON.stringify(this.session), true); }) .catch((error) => { this.log.error('refresh token failed'); this.log.error(error); error.response && this.log.error(JSON.stringify(error.response.data)); this.log.error('Restart adapter in 20min'); this.reconnectTimeout && clearInterval(this.reconnectTimeout); this.reLoginTimeout && clearTimeout(this.reLoginTimeout); this.reLoginTimeout = setTimeout( async () => { this.restart(); }, 1000 * 60 * 20, ); }); } extractHidden(body) { const returnObject = {}; const matches = this.matchAll(/<input (?=[^>]* name=["']([^'"]*)|)(?=[^>]* value=["']([^'"]*)|)/g, body); for (const match of matches) { returnObject[match[1]] = match[2]; } return returnObject; } matchAll(re, str) { let match; const matches = []; while ((match = re.exec(str))) { // add all matched groups matches.push(match); } return matches; } sleep(ms) { if (this.adapterStopped) { ms = 0; } return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Is called when adapter shuts down - callback has to be called under any circumstances! * @param {() => void} callback */ onUnload(callback) { try { this.setState('info.connection', false, true); this.refreshTimeout && clearTimeout(this.refreshTimeout); this.refreshStatusInterval && clearTimeout(this.refreshStatusInterval); this.reLoginTimeout && clearTimeout(this.reLoginTimeout); this.refreshTokenTimeout && clearTimeout(this.refreshTokenTimeout); this.reconnectInterval && clearInterval(this.updateInterval); this.refreshTokenInterval && clearInterval(this.refreshTokenInterval); if (this.eventSourceState) { this.eventSourceState.close(); this.eventSourceState.removeEventListener('STATUS', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('NOTIFY', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('EVENT', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('CONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('DISCONNECTED', (e) => this.processEvent(e), false); this.eventSourceState.removeEventListener('KEEP-ALIVE', () => this.resetReconnectTimeout(), false); } callback(); } catch (e) { this.log.error('Error onUnload: ' + e); callback(); } } /** * Is called if a subscribed state changes * @param {string} id * @param {ioBroker.State | null | undefined} state */ async onStateChange(id, state) { if (state) { if (state && !state.ack) { const idArray = id.split('.') || []; const command = idArray.pop().replace(/_/g, '.'); const haId = idArray[2]; if (!isNaN(state.val) && !isNaN(parseFloat(state.val))) { state.val = parseFloat(state.val); } if (state.val === 'true') { state.val = true; } if (state.val === 'false') { state.val = false; } if (id.indexOf('.commands.') !== -1) { this.log.debug(id + ' ' + state.val); if (id.indexOf('StopProgram') !== -1 && state.val) { this.deleteAPIValues(haId, '/programs/active'); } else { const data = { data: { key: command, value: state.val, }, }; this.putAPIValues(haId, '/commands/' + command, data).catch(() => { this.log.error('Put value failed ' + haId + '/commands/' + command + JSON.stringify(data)); this.log.error('Original state ' + id + ' change: ' + JSON.stringify(state)); }); } } if (id.indexOf('.settings.') !== -1) { const data = { data: { key: command, value: state.val, type: command, }, }; this.putAPIValues(haId, '/settings/' + command, data); } if (id.indexOf('.options.') !== -1) { const data = { data: { key: command, value: state.val, }, }; if (id.indexOf('selected') !== -1) { idArray.pop(); } const folder = idArray.slice(3, idArray.length).join('/'); if ( data.data.key === 'BSH.Common.Option.StartInRelative' || data.data.key === 'BSH.Common.Option.FinishInRelative' ) { this.log.warn('Relative time cannot be changed here. Please use the specific program options.'); } this.putAPIValues(haId, '/' + folder + '/' + command, data); } if (id.indexOf('BSH_Common_Root_') !== -1) { const pre = this.name + '.' + this.instance; if (!state.val) { this.log.warn('No state val: ' + JSON.stringify(state)); return; } if (!state.val.split) { this.log.warn('No valid state val: ' + JSON.stringify(state)); return; } const key = state.val.split('.').pop(); this.getStates(pre + '.' + haId + '.programs.selected.options.' + key + '.*', (err, states) => { const allIds = Object.keys(states); const options = []; allIds.forEach((keyName) => { if ( keyName.indexOf('BSH_Common_Option_ProgramProgress') === -1 && keyName.indexOf('BSH_Common_Option_RemainingProgramTime') === -1 ) { const idArray = keyName.split('.'); const commandOption = idArray.pop().replace(/_/g, '.'); if ( ((this.availableProgramOptions[state.val] && this.availableProgramOptions[state.val].includes(commandOption)) || commandOption === 'BSH.Common.Option.StartInRelative') && states[keyName] !== null ) { if ( (commandOption === 'BSH.Common.Option.StartInRelative' || commandOption === 'BSH.Common.Option.FinishInRelative') && command === 'BSH.Common.Root.SelectedProgram' ) { this.log.debug('Relative time cannot be changed here. Please use the specific program options.'); } else { options.push({ key: commandOption, value: states[keyName].val, }); } } else { this.log.debug(`Option ${commandOption} is not available for ${state.val}`); this.log.debug(`Available options: ${JSON.stringify(this.availableProgramOptions[state.val])}`); } } }); const data = { data: { key: state.val, options: options, }, }; if (id.indexOf('Active') !== -1) { this.putAPIValues(haId, '/programs/active', data) .catch(() => { this.log.info("Programm doesn't start with options. Try again without selected options."); this.putAPIValues(haId, '/programs/active', { data: { key: state.val, }, }).catch(() => { this.log.error('Put active failed ' + haId + state.val); }); }) .then(() => this.updateOptions(haId, '/programs/active')) .catch(() => { this.log.error('Error update active program'); }); } if (id.indexOf('Selected') !== -1) { if (state.val) { this.currentSelected[haId] = { key: state.val }; this.putAPIValues(haId, '/programs/selected', data) .then( () => { this.updateOptions(haId, '/programs/selected'); }, () => { this.log.warn('Setting selected program was not succesful'); }, ) .catch(() => { this.log.debug('No program selected found'); }); } else { this.log.warn('No state val: ' + JSON.stringify(state)); } } }); } } else { const idArray = id.split('.'); const command = idArray.pop().replace(/_/g, '.'); const haId = idArray[2]; this.log.debug('State changed: ' + id + ' ' + JSON.stringify(state) + ' ' + command); if (id.indexOf('BSH_Common_Root_') !== -1) { if (id.indexOf('Active') !== -1) { this.updateOptions(haId, '/programs/active'); } if (id.indexOf('Selected') !== -1) { if (state && state.val) { this.currentSelected[haId] = { key: state.val }; } else { this.log.debug('Selected program is empty: ' + JSON.stringify(state)); } this.updateOptions(haId, '/programs/selected'); } } if (id.indexOf('BSH_Common_Status_OperationState') !== -1) { if (state.val && (state.val.indexOf('.Finished') !== -1 || state.val.indexOf('.Aborting') !== -1)) { const remainTime = await this.getStateAsync( haId + '.programs.active.options.BSH_Common_Option_RemainingProgramTime', ); if (remainTime && remainTime.val !== 0) { await this.setStateAsync( haId + '.programs.active.options.BSH_Common_Option_RemainingProgramTime', 0, true, ); } const programProgess = await this.getStateAsync( haId + '.programs.active.options.BSH_Common_Option_ProgramProgress', ); if (programProgess && programProgess.val !== 100) { await this.setStateAsync(haId + '.programs.active.options.BSH_Common_Option_ProgramProgress', 100, true); } } } if (id.indexOf('.options.') !== -1 || id.indexOf('.events.') !== -1 || id.indexOf('.status.') !== -1) { if ( id.indexOf('BSH_Common_Option') === -1 && state && state.val && state.val.indexOf && state.val.indexOf('.') !== -1 ) { this.getObject(id, async (err, obj) => { if (obj) { const common = obj.common; const valArray = state.val.split('.'); common.states = {}; common.states[state.val] = valArray[valArray.length - 1]; this.log.debug('Extend common option: ' + id); await this.setObjectNotExistsAsync(id, { type: 'state', common: common, native: {}, }) .then(() => {}) .catch(() => { this.log.error('failed set state'); }); await this.extendObjectAsync(id, { type: 'state', common: common, native: {}, }); } }); } } } } } } if (require.main !== module) { // Export the constructor in compact mode /** * @param {Partial<utils.AdapterOptions>} [options={}] */ module.exports = (options) => new Homeconnect(options); } else { // otherwise start the instance directly new Homeconnect(); }