UNPKG

iobroker.homeconnect

Version:
1,286 lines (1,251 loc) 55.5 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 axiosRateLimit = require('axios-rate-limit'); const helper = require('./lib/helper'); const limiting = require('./lib/rateLimiting'); const constants = require('./lib/constants'); const qs = require('qs'); const { EventSource } = require('eventsource'); 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.createDataPoint = helper.createDataPoint; this.createDevices = helper.createDevices; this.createObjects = helper.createObjects; this.createFolders = helper.createFolders; this.createFolders = helper.createFolders; this.createOwnRequest = helper.createOwnRequest; this.createLimit = limiting.createLimit; this.getRateLimit = limiting.getRateLimit; this.checkToken = limiting.checkToken; this.checkBlock = limiting.checkBlock; this.setLimitCounter = limiting.setLimitCounter; this.checkLimitCounter = limiting.checkLimitCounter; this.userAgent = 'ioBroker v1.0.0'; this.headers = { 'user-agent': this.userAgent, Accept: 'application/vnd.bsh.sdk.v1+json', 'Accept-Language': 'de-DE', }; this.deviceArray = []; this.typeJson = {}; this.stateCheck = []; //this.refreshStatusInterval = null; this.reLoginTimeout = null; //this.reconnectInterval = null; this.reconnectTimeout = null; this.refreshTokenInterval = null; this.availablePrograms = {}; this.availableProgramOptions = {}; this.eventSourceState = null; this.currentSelected = {}; this.sleepTimer = null; this.rateLimiting = {}; this.tokenRateLimiting = {}; this.rateLimitingInterval = null; // @ts-expect-error //Nothing this.requestClient = axiosRateLimit( axios.create({ withCredentials: true, }), { maxRequests: 15, perMilliseconds: 1000 }, ); } /** * 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); 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; } await this.createLimit(); await this.getRateLimit(constants); this.session = {}; //this.subscribeStates('*'); const sessionState = await this.getStateAsync('auth.session'); if (sessionState && sessionState.val && typeof sessionState.val === 'string') { this.log.debug('Found current session'); //this.session = JSON.parse(this.decrypt(sessionState.val)); this.session = JSON.parse(sessionState.val); } if (this.session.refresh_token) { await this.refreshToken(); } else { if (!this.config.clientID) { this.log.warn('Please enter your Client ID in the instance settings'); return; } this.log.debug('Start login via device flow'); await this.login(); } if (this.session.access_token) { this.headers.authorization = `Bearer ${this.session.access_token}`; await this.getDeviceList(); this.subscribeStates('*'); await this.startEventStream(); // this.refreshStatusInterval = this.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 = this.setInterval(async () => { // this.startEventStream(); // }, 60 * 60 * 1000); //every 60 minutes this.refreshTokenInterval = this.setInterval( async () => { await this.refreshToken(); this.startEventStream(); }, (this.session.expires_in - 200) * 1000, ); } this.setLimitInterval(); } setLimitInterval() { this.rateLimitingInterval = this.setInterval(async () => { await this.checkLimitCounter(); }, 60 * 1000); } async login() { 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(`Device Auth: ${JSON.stringify(res.data)}`); return res.data; }) .catch(error => { this.log.error(`Device authorization failed: ${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 URL received from device authorization'); return; } this.log.warn('===================================================='); this.log.warn('Please open this URL in your browser and login:'); this.log.warn(deviceAuth.verification_uri_complete); this.log.warn('Waiting for approval...'); this.log.warn('===================================================='); await this.setState('auth.verificationUrl', { val: deviceAuth.verification_uri_complete, ack: true }); let tokenReceived = false; while (!tokenReceived) { 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(`Token: ${JSON.stringify(res.data)}`); this.session = res.data; this.log.info('Login successful - token received'); await this.setState('info.connection', true, true); this.session.next = new Date().getTime() + parseInt(this.session.expires_in) * 1000; await this.setState('auth.session', { val: JSON.stringify(this.session), ack: true }); tokenReceived = true; }) .catch(async error => { const errCode = error.response?.data?.error; if (errCode === 'authorization_pending' || errCode === 'slow_down') { this.log.debug('Waiting for user approval...'); } else { this.log.error(error); this.log.error('Please open this URL in your browser and login:'); this.log.error(deviceAuth.verification_uri_complete); if (error.response) { this.log.error(JSON.stringify(error.response.data)); if (error.response.status === 429) { this.log.info('The maximum number of requests has been reached!'); if (error.response.headers) { if (error.response.headers['retry-after']) { this.log.error(`API retry-after: ${this.convertRetryAfter(error.response.headers['retry-after'])}`); } } } } } await this.sleep(10000); }); } } async getDeviceList() { 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(`Homeappliances: ${JSON.stringify(res.data)}`); let count = 1; if (res.data.data.homeappliances.length > 2) { this.log.info(`Found ${res.data.data.homeappliances.length} devices. Start slow update!`); } else { 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.typeJson[haID] = device.type; await this.createFolders(haID); this.deviceArray.push(haID); const name = device.name; await this.createDevices(haID, name, device); if (this.config.ownRequest) { await this.createOwnRequest(haID); } else { await this.delObjectAsync(`${haID}.own_request`, { recursive: true }); } if (device.connected) { await this.fetchDeviceInformationFirst(haID); } if ( (count % 2 == 0 && count != res.data.data.homeappliances.length) || (this.rateLimiting.requestsMinutesCount > 45 && count != res.data.data.homeappliances.length) ) { this.log.info(`Wait 1 minute!`); await this.sleep(61 * 1000); } ++count; } }) .catch(error => { this.log.error(`getDeviceList: ${error}`); if (error.response) { this.log.error(JSON.stringify(error.response.data)); if (error.response.status === 429) { this.log.info('The maximum number of requests has been reached!'); if (error.response.headers) { if (error.response.headers['retry-after']) { this.log.error(`API retry-after: ${this.convertRetryAfter(error.response.headers['retry-after'])}`); } } } } }); } async fetchDeviceInformationFirst(haId) { await this.getAPIValues(haId, '/status', true); await this.getAPIValues(haId, '/settings', true); await this.getAPIValues(haId, '/programs/active', true); await this.getAPIValues(haId, '/programs/selected', true); await this.getAPIValues(haId, '/programs', true); this.updateOptions(haId, '/programs/active'); this.updateOptions(haId, '/programs/selected'); } async fetchDeviceInformation(haId) { //this.getAPIValues(haId, '/events'); // Response empty //this.getAPIValues(haId, '/images'); this.getAPIValues(haId, '/status'); this.getAPIValues(haId, '/settings'); this.getAPIValues(haId, '/programs/active'); this.getAPIValues(haId, '/programs/selected'); } async getAPIValues(haId, url, first) { this.log.debug(`GET: ${haId} - ${url}`); if (first == null) { await this.sleep(Math.floor(Math.random() * 1500)); } if (!(await this.checkBlock())) { return; } await this.setLimitCounter('OK', haId, 'NOK', url, 'GET'); const header = Object.assign({}, this.headers); header['Accept-Language'] = this.config.language; const returnValue = await this.requestClient({ method: 'get', url: `https://api.home-connect.com/api/homeappliances/${haId}${url}`, headers: header, }) .then(res => { this.log.debug(`Homeappliances device: ${JSON.stringify(res.data)}`); return res.data; }) .catch(error => { if (error.response) { if (error.response.status === 429) { this.log.info('The maximum number of requests has been reached!'); if (error.response.headers) { if (error.response.headers['retry-after']) { this.log.error(`API retry-after: ${this.convertRetryAfter(error.response.headers['retry-after'])}`); } } } const description = error.response.data.error ? error.response.data.error.description : ''; this.log.info(`${haId}${url}: ${description}.`); this.log.debug(`Homeappliances device: ${JSON.stringify(error.response.data)}`); } else { this.log.info(error); } return; }); if (!returnValue || returnValue.error) { await this.setErrorResponse(true); returnValue && this.log.debug(`Error: ${returnValue.error}`); return; } try { this.log.debug(`URL: ${url}`); this.log.debug(`returnValue: ${JSON.stringify(returnValue)}`); if (url.indexOf('/settings/') !== -1) { let defaults; let type = 'string'; let role = 'state'; defaults = ''; if (returnValue.data.type === 'Int' || returnValue.data.type === 'Double') { type = 'number'; role = 'value'; defaults = 0; } if (returnValue.data.type === 'Boolean') { type = 'boolean'; role = 'switch'; defaults = false; } const common = { name: returnValue.data.name, type: type, role: role, write: true, read: true, def: defaults, }; if (returnValue.data.constraints && returnValue.data.constraints.allowedvalues) { const states = {}; for (let index in returnValue.data.constraints.allowedvalues) { const val = returnValue.data.constraints.allowedvalues[index]; states[val] = returnValue.data.constraints.displayvalues[index] || val; } common.states = states; } const folder = `.settings.${returnValue.data.key.replace(/\./g, '_')}`; this.log.debug(`Extend Settings: ${haId}${folder}`); let value = null; value = returnValue.data.value != null ? returnValue.data.value : value; await this.createDataPoint(haId + folder, common, 'state', value, true, null); 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 defaults; let type = 'string'; let role = 'state'; defaults = ''; if (option.type === 'Int' || option.type === 'Double') { type = 'number'; role = 'value'; defaults = 0; } if (option.type === 'Boolean') { type = 'boolean'; role = 'switch'; defaults = false; } let common = { name: option.name, type: type, role: role, write: true, read: true, def: defaults, }; if (option.unit && option.unit != '') { common.unit = option.unit; } if (option.constraints && option.constraints.min != null && typeof option.constraints.min === 'number') { common.min = option.constraints.min; common.def = option.constraints.min; } if (option.constraints && option.constraints.max != null && 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.createDataPoint(haId + folder, common, 'state', null, true, null); 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.setState(haId + folder, value, true); } const key = returnValue.data.key.split('.').pop(); const com = { name: returnValue.data.name, desc: returnValue.data.desc ? returnValue.data.desc : returnValue.data.name, }; if (!this.stateCheck.includes(`${this.namespace}.${haId}.programs.selected.options.${key}`)) { await this.createDataPoint(`${haId}.programs.selected.options.${key}`, com, 'folder', null, true, null); } folder = `.programs.selected.options.${key}.${option.key.replace(/\./g, '_')}`; await this.createDataPoint(haId + folder, common, 'state', null, true, null); } } 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) { //TODO override channel as state - WHY??? 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}`; /** const common = { name: this.currentSelected[haId].name, type: 'mixed', role: 'state', write: true, read: true, }; */ this.log.debug(`FOLDER: ${JSON.stringify(this.currentSelected[haId])}`); //await this.createDataPoint(haId + folder, common, 'state', null, true, null); if (this.currentSelected[haId].name) { const common = { name: this.currentSelected[haId].name, desc: this.currentSelected[haId].name, }; await this.createDataPoint(haId + folder, common, 'folder', null, true, null); } } this.log.debug(`Create State: ${haId}${folder}.${subElement.key.replace(/\./g, '_')}`); let defaults; let type = 'mixed'; let role = 'state'; defaults = ''; if (typeof subElement.value === 'boolean') { type = 'boolean'; role = 'switch'; defaults = false; } if (typeof subElement.value === 'number') { type = 'number'; role = 'value'; defaults = 0; } let common = { name: subElement.name, type: type, role: role, write: true, read: true, def: defaults, }; if (subElement.unit && subElement.unit != '') { common.unit = subElement.unit; } if ( subElement.constraints && subElement.constraints.min != null && typeof subElement.constraints.min === 'number' ) { common.min = subElement.constraints.min; common.def = subElement.constraints.min; } if ( subElement.constraints && subElement.constraints.max != null && typeof subElement.constraints.max === 'number' ) { common.max = subElement.constraints.max; } const path = `${haId + folder}.${subElement.key.replace(/\./g, '_')}`; await this.createDataPoint(path, common, 'state', null, true, null); let value = null; if (subElement.value !== undefined) { this.log.debug('Set api value'); value = subElement.value; //check if value is an object if (typeof value === 'object' && value !== null) { value = JSON.stringify(value); } } if (value == null) { this.log.debug(`failed set state - Path: ${path} - ${JSON.stringify(subElement)}`); this.log.debug(`Value: '${value}'`); } await this.createDataPoint(path, common, 'state', value, true, null); } } 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; } for (const rootItem of rootItems) { const common = { name: rootItem.key, type: 'string', role: 'state', write: true, read: true, states: {}, }; if (this.availablePrograms[haId]) { for (const program of this.availablePrograms[haId]) { common.states[program.key] = program.name; } } await this.createDataPoint( `${haId + rootItem.folder}.${rootItem.key.replace(/\./g, '_')}`, common, 'state', null, true, null, ); } } } 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(`search: ${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) ) { this.stateCheck = this.stateCheck.filter(r => r !== keyName); 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.setState(`${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.setState(`${haId}.programs.active.options.BSH_Common_Option_RemainingProgramTime`, 0, true); } } } } this.setTimeout(() => this.getAPIValues(haId, `${url}/options`), 0); //ToDo Why 0 } async putAPIValues(haId, url, data) { this.log.debug(`Put ${JSON.stringify(data)} to ${url} for ${haId}`); if (!(await this.checkBlock())) { return; } let start = 'NOK'; if (data && data.data && data.data.key) { if (data.data.key.indexOf('StopProgram') !== -1) { start = 'Stop'; } else if ( data.data.key.indexOf('Root_ActiveProgram') !== -1 || data.data.key.indexOf('StartInRelative') !== -1 ) { start = 'Stop'; } } await this.setLimitCounter('OK', haId, start, url, 'PUT'); 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(`Put data: ${JSON.stringify(res.data)}`); return res.data; }) .catch(error => { this.setLimitCounter('ERR', haId, start, null, null); this.log.error(`Put: ${error}`); if (error.response) { if (error.response.headers && error.response.headers['rate-limit-type'] === 'start') { this.log.error(JSON.stringify(error.response.headers)); this.log.error(`Block time ${error.response.headers['retry-after']} second(s)`); } if (error.response.status === 409) { this.log.info( 'Command cannot be executed for the home appliance, the error response contains the error details', ); } else if (error.response.status === 429) { this.log.info('The number of requests for a specific endpoint exceeded the quota of the client'); } else if (error.response.status === 403) { this.log.info('Scope has not been granted or home appliance is not assigned to HC account'); } this.log.error(JSON.stringify(error.response.data)); } }); } async deleteAPIValues(haId, url) { if (!(await this.checkBlock())) { return; } await this.setLimitCounter('OK', haId, 'Stop', url, 'DELETE'); await this.requestClient({ method: 'DELETE', url: `https://api.home-connect.com/api/homeappliances/${haId}${url}`, headers: this.headers, }) .then(res => { this.log.debug(`deleteAPIValues: ${JSON.stringify(res.data)}`); return res.data; }) .catch(error => { this.setLimitCounter('ERR', haId, 'NOK', null, null); this.log.error(`deleteAPIValues: ${error}`); if (error.response) { if (error.response.status === 429) { this.log.info('The maximum number of requests has been reached!'); if (error.response.headers) { if (error.response.headers['retry-after']) { this.log.error(`API retry-after: ${this.convertRetryAfter(error.response.headers['retry-after'])}`); } } } 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'; this.startRemoveEventListener(); this.eventSourceState = new EventSource(baseUrl, { fetch: (input, init) => fetch(input, { ...init, headers: { ...init.headers, Authorization: `Bearer ${this.session.access_token}`, Accept: 'text/event-stream', }, }), }); // Error handling this.eventSourceState.onerror = err => { if (err.code) { this.log.error(`${err.code} ${err.message}`); } else { this.log.debug(`EventSource error: ${JSON.stringify(err)}`); this.log.debug('Undefined Error from Homeconnect this happens sometimes.'); } if (err.code !== undefined) { this.log.error(`Start Event Stream Error: ${JSON.stringify(err)}`); if (err.code === 401) { this.refreshToken(); // Most likely the token has expired, try to refresh the token this.log.info('Token abgelaufen'); } else if (err.code === 429) { this.log.info('Too many requests. Please wait 24h.'); } else { this.log.error(`Error: ${err.code}`); this.log.error(`Error: ${JSON.stringify(err)}`); if (err.code >= 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(); const val = { type: e.type, data: e.data, lastEventId: e.lastEventId, // is empty...why? timestamp: e.timeStamp, origin: e.origin, }; this.log.debug(`KEEP-ALIVE: ${JSON.stringify(val)}`); }, false, ); this.resetReconnectTimeout(); } resetReconnectTimeout() { this.reconnectTimeout && this.clearInterval(this.reconnectTimeout); this.reconnectTimeout = this.setInterval(() => { this.log.info('Keep Alive failed Reconnect EventStream'); this.startEventStream(); }, 70000); } async processEvent(msg) { try { this.log.debug(`event: ${JSON.stringify(msg.data)}`); this.log.debug(`eventType: ${JSON.stringify(msg.type)}`); this.log.debug(`lastEventId: ${msg.lastEventId}`); const stream = msg; //eslint-disable-next-line no-useless-escape const lastEventId = stream.lastEventId.replace(/\.?\-001*$/, ''); if (!stream) { this.log.debug(`No Return: ${stream.data}`); 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); for (let element of parseMessage.items) { 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 { if (element.uri && typeof element.uri === 'string') { 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(`Path folder: ${folder}`); if (stream.type === 'NOTIFY') { if (folder.includes('.selected.options')) { folder = folder.replace('.selected.options', '.active.options'); } } this.log.debug(`Path: ${haId}.${folder}.${key}:${element.value}`); let value = null; if (element.value !== undefined) { this.log.debug('Set value'); value = element.value; } const common = { name: key, type: 'mixed', role: 'state', write: true, read: true, }; if (element.unit && element.unit != '') { common.unit = element.unit; } await this.createDataPoint(`${haId}.${folder}.${key}`, common, 'state', value, true, null); } } catch (error) { this.log.error(`Parsemessage: ${error}`); } } async refreshToken() { if (!this.session) { this.log.error('No session found relogin'); await this.login(); return; } if (!this.checkToken(false)) { 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(`RefreshToken: ${JSON.stringify(res.data)}`); this.session = res.data; this.headers.authorization = `Bearer ${this.session.access_token}`; this.setState('info.connection', true, true); this.session.next = new Date().getTime() + parseInt(this.session.expires_in) * 1000; //await this.setState('auth.session', { val: this.encrypt(JSON.stringify(this.session)), ack: true }); this.setState('auth.session', { val: JSON.stringify(this.session), ack: true }); }) .catch(async error => { if (error.response) { this.log.error(JSON.stringify(error.response.data)); if (error.response.status === 429) { this.log.info('The maximum number of requests has been reached!'); if (error.response.headers) { if (error.response.headers['retry-after']) { this.log.error(`API retry-after: ${this.convertRetryAfter(error.response.headers['retry-after'])}`); } } } if (error.response.data.error === 'invalid_grant') { await this.setState('auth.session', '', true); this.setState('info.connection', false, true); this.reLoginTimeout && this.clearTimeout(this.reLoginTimeout); this.refreshTokenInterval && this.clearInterval(this.refreshTokenInterval); this.rateLimitingInterval && this.clearInterval(this.rateLimitingInterval); this.sleepTimer && this.clearTimeout(this.sleepTimer); this.startRemoveEventListener(); this.log.info('Refresh token invalid. Starting device authorization flow...'); await this.login(); return; } } this.log.error('refresh token failed'); this.log.error(error); this.log.error('Restart adapter in 20min'); this.reconnectTimeout && this.clearInterval(this.reconnectTimeout); this.reLoginTimeout && this.clearTimeout(this.reLoginTimeout); this.refreshTokenInterval && this.clearInterval(this.refreshTokenInterval); this.startRemoveEventListener(); this.setState('info.connection', false, true); this.reLoginTimeout = this.setTimeout( async () => { this.restart(); }, 1000 * 60 * 20, ); }); } startRemoveEventListener() { 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); this.eventSourceState = null; } } /** * @param ms milliseconds */ sleep(ms) { // @ts-ignore return new Promise(resolve => { this.sleepTimer = this.setTimeout(() => { resolve(true); }, 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.refreshStatusInterval && this.clearTimeout(this.refreshStatusInterval); this.reLoginTimeout && this.clearTimeout(this.reLoginTimeout); //this.reconnectInterval && this.clearInterval(this.reconnectInterval); this.refreshTokenInterval && this.clearInterval(this.refreshTokenInterval); this.rateLimitingInterval && this.clearInterval(this.rateLimitingInterval); this.sleepTimer && this.clearTimeout(this.sleepTimer); this.startRemoveEventListener(); 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.ack) { const idArray = id.split('.') || []; const commands = idArray.pop(); if (commands === 'request_json') { this.sendOwnRequest(idArray[2], state.val); return; } else if (commands === 'response') { return; } const command = commands ? commands.replace(/_/g, '.') : ''; const haId = idArray[2]; if (state.val != null && !Number.isNaN(state.val) && !Number.isNaN(parseFloat(state.val.toString()))) { state.val = parseFloat(state.val.toString()); } if (state.val === 'true') { state.val = true; } if (state.val === 'false') { state.val = false; } if (id.indexOf('.commands.') !== -1) { this.log.debug(`onStateChange - ${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.toString().indexOf('.') === -1) { this.log.warn(`No valid state val: ${JSON.stringify(state)}`); return; } const key = state.val.toString().split('.').pop(); const states = await this.getStatesAsync(`${pre}.${haId}.programs.selected.options.${key}.*`); if (typeof states !== 'object') { this.log.error(`Missing States: ${pre}.${haId}.programs.selected.options.${key}.*`); return; } const allIds = Object.keys(states); const options = []; for (const keyName of allIds) { if ( keyName.indexOf('BSH_Common_Option_ProgramProgress') === -1 && keyName.indexOf('BSH_Common_Option_RemainingProgramTime') === -1 ) { const idArray = keyName.split('.'); const commandOptions = idArray.pop(); const commandOption = commandOptions ? commandOptions.replace(/_/g, '.') : ''; if ( ((this.availableProgramOptions[state.val] && this.availableProgramOptions[state.val].includes(commandOption)) || commandOption === 'BSH.Common.Option.StartInRelative') && states && 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 commands = idArray.pop(); const command = commands ? commands.replace(/_/g, '.') : ''; const stop = ['isBlocked', 'limitJson', 'reason', 'connection', 'session', 'response', 'request.json']; if (stop.includes(command)) { this.log.debug(`Catch state - ${id} - ${command}`); return; } 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 && typeof state.val === 'string' && (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.setState(`${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.setState(`${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.toString().indexOf('.') !== -1 ) { this.getObject(id, async (err, obj) => { if (obj && state.val != null) { const common = obj.common; const valArray = state.val.toString().split('.'); common.states = {}; common.states[state.val.toString()] = valArray[valArray.length - 1]; this.log.debug(`Extend common option: ${id}`); await this.createDataPoint(id, common, 'state', null, true, null); } }); } } } } } async sendOwnRequest(haId, json) { if (json && json.startsWith('{')) { let val = {}; try {