iobroker.homeconnect
Version:
Adapter for Homeconnect devices
1,286 lines (1,251 loc) • 55.5 kB
JavaScript
'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 {