UNPKG

iobroker.lovelace

Version:

With this adapter you can build visualization for ioBroker with Home Assistant Lovelace UI

962 lines (888 loc) 136 kB
const fs = require('fs'); const crypto = require('crypto'); const WebSocket = require('ws'); const bodyParser = require('body-parser'); const SERVICES = require('./services'); const PANELS = require('./panels'); const multer = require('multer'); const mime = require('mime'); const yaml = require('js-yaml'); const axios = require('axios'); const jstz = require('jstimezonedetect'); const utils = require('./converters/utils'); const processBlind = require('./converters/cover').processBlind; const processSocket = require('./converters/switch').processSocket; const converterLight = require('./converters/light'); const processBinarySensors = require('./converters/binary_sensor'); const processSensors = require('./converters/sensor'); const processMediaPlayer = require('./converters/media_player').processMediaPlayer; const processThermostat = require('./converters/climate').processThermostat; const converterWeather = require('./converters/weather'); const processImage = require('./converters/camera').processImage; const processLock = require('./converters/lock').processLock; const processLocation = require('./converters/geo_location').processLocation; const bindings = require('./bindings'); const TIMEOUT_PASSWORD_ENTER = 180000; // 3 min const TIMEOUT_AUTH_CODE = 10000; // 10sec const {Types, ChannelDetector} = require('iobroker.type-detector'); const ignoreIds = [ /^system\./, /^script\./, ]; const express = require('express'); const ROOT_DIR = '../hass_frontend'; const VERSION = '0.102.1'; const NO_TOKEN = 'no_token'; function getRootPath() { if (ROOT_DIR.match(/^\w:/) || ROOT_DIR.startsWith('/')) { return ROOT_DIR + '/'; } else { return `${__dirname}/${ROOT_DIR}/`; } } const generateRandomToken = function (callback) { crypto.randomBytes(256, (ex, buffer) => { crypto.randomBytes(32, (ex, secret) => { if (ex) { return callback('server_error'); } const token = crypto .createHmac('sha256', secret) .update(buffer) .digest('hex'); callback(false, token); }); }); }; function padding(num) { return num < 10 ? '0' + num : num; } /* possible HASS entity types: - fan - input_boolean - light => STATE on/off, attributes: [brightness, hs_color([h,s]), min_mireds, max_mireds, color_temp, white_value, effect_list, effect, supported_features ], commands: turn_on(brightness_pct, hs_color, brightness, color_temp, white_value, effect), turn_off, toggle supported_features: brightness=0x01, colorTemp=0x02, effectList=0x04, color=0x10, whiteValue=0x80 - switch => STATE on/off, attributes: [brightness, hs_color], commands: turn_on, turn_off, toggle - group - automation - climate => STATE on/off, attributes: [current_temperature, operation_mode, operation_list, target_temp_step, target_temp_low, target_temp_high,min_temp, max_temp, temperature], commands: - cover - configurator - input_select - input_number - input_text - lock - media_player => STATE on/off/playing/paused/idle/standby/unknown, attributes: [media_content_type(music/game/music/tvshow/...), entity_picture(as cover), media_duration, supported_features, is_volume_muted, volume_level, media_duration, media_position, media_position_updated_at, media_title, media_artist, media_series_title, media_season, media_episode, app_name, source, source_list, sound_mode, sound_mode_list], commands: media_play_pause, media_next_track, media_play_pause, media_previous_track, volume_set(volume_level), turn_off, turn_on, volume_down, volume_mute(is_volume_muted), volume_up, select_source(source), select_sound_mode(sound_mode), features for supported_features: PAUSE 0x1, volume_set 0x4, volume_mute 0x8, media_previous_track 0x10, media_next_track 0x20, turn_on 0x80, turn_off 0x100, play_media 0x200, volume_down/volume_up 0x400, select_source 0x800, select_sound_mode (0x10000), play (0x4000) - scene - script - timer => STATE idle/paused/active, attributes: [remaining] - vacuum - water_heater - weblink - alarm_control_panel => STATE disarmed/armed/armed_home/armed_away/armed_night/armed_custom_bypass/pending/arming/disarming/triggered, attributes: [code_format], commands: alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent) - camera - history_graph - input_datetime - sun - updater - binary_sensor => STATE on/off - geo_location => attributes: [latitude, longitude, passive, icon, radius, entity_picture, gps_accuracy, source] - weather => STATE any-text(no icon)/clear-night/cloudy/fog/hail/lightning/lightning-rainy/partlycloudy/pouring/rainy/snowy/snowy-rainy/sunny/windy/windy-variant, attributes: [temperature, pressure, humidity, wind_speed, wind_bearing, forecast] forecast is an array with max 5 items [{datetime: something for new Date(aa), temperature, templow, condition(see STATE), precipitation}, {...}] */ class WebServer { constructor(options) { this._lovelaceConfig = null; this._ressourceConfig = []; //new place to store custom cards (modules) and stuff. this.adapter = options.adapter; this.config = this.adapter.config; this.log = this.adapter.log; this.lang = 'en'; this.detector = new ChannelDetector(); this.config.ttl = parseInt(this.config.ttl, 10) || 3600; this.words = options.words || {}; this._notifications = []; this._subscribed = []; this._entities = []; this._entity2ID = {}; this._ID2entity = {}; this._entityIconUrls = []; //stores icon urls that may be accessed without token. this._server = options.server; this._app = options.app; this._auth_flows = {}; this.templateStates = {}; this._processCommon = utils._processCommon.bind(this); //make processCommon operate on our members. this._addID2entity = utils._addID2entity.bind(this); //make addID2entity operate on our members. this._themes = {}; //themes storage this._currentTheme = this.config.defaultTheme || 'default'; this._currentThemeDark = this.config.defaultThemeDark || 'default'; this.converter = { [Types.socket]: processSocket.bind(this), [Types.light]: converterLight.processLight.bind(this), [Types.dimmer]: converterLight.processLightAdvanced.bind(this), [Types.ct]: converterLight.processLightAdvanced.bind(this), [Types.hue]: converterLight.processLightAdvanced.bind(this), [Types.rgb]: converterLight.processLightAdvanced.bind(this), [Types.rgbSingle]: converterLight.processLightAdvanced.bind(this), [Types.motion]: processBinarySensors.processMotion.bind(this), [Types.window]: processBinarySensors.processWindow.bind(this), [Types.windowTilt]: processSensors.processWindowTilt.bind(this), [Types.door]: processBinarySensors.processDoor.bind(this), [Types.button]: processSocket.bind(this), [Types.temperature]: processSensors.processTemperature.bind(this), [Types.humidity]: processSensors.processHumidity.bind(this), [Types.lock]: processLock.bind(this), [Types.thermostat]: processThermostat.bind(this), [Types.blind]: processBlind.bind(this), [Types.blindButtons]: processBlind.bind(this), [Types.weatherForecast]: converterWeather.processWeather.bind(this), [Types.accuWeatherForecast]: converterWeather.processAccuWeather.bind(this), [Types.location]: processLocation.bind(this), [Types.location_one]: processLocation.bind(this), [Types.media]: processMediaPlayer.bind(this), [Types.image]: processImage.bind(this), }; this.adapter.getForeignObjectAsync('system.config') .then(config => { this.lang = config.common.language; this.systemConfig = config.common; this._updateConstantEntities(); return this.adapter.getObjectAsync('configuration'); }) .then(config => { if (config && config.native && config.native.title) { this._lovelaceConfig = config.native; this._lovelaceConfig.hideToolbar = this.config.hideHeader; } else { this._lovelaceConfig = require('./defaultConfig'); } return this._readNotifications(); }) .then(() => this._readAllEntities()) .then(() => { this.adapter.subscribeObjects('configuration'); this.adapter.subscribeStates('control.*'); this.adapter.subscribeStates('notifications.*'); this.adapter.subscribeStates('conversation'); return this._init(); }) .then(() => { //setup theme selection button: try { try { this._themes = yaml.safeLoad(this.config.themes || '') || {}; } catch (depError) { if (depError.message.includes('yaml.safeLoad') && depError.message.includes('removed')) { this._themes = yaml.load(this.config.themes || '') || {}; } else { throw depError; } } } catch (e) { this.log.error(`Cannot parse themes: ${e}`); this._themes = {}; } const states = {'default': 'default'}; for (const themeName of Object.keys(this._themes)) { states[themeName] = themeName; } return this.adapter.extendObjectAsync(this.adapter.namespace + '.control.theme', {common: {states}}) .then(() => this.adapter.extendObjectAsync(this.adapter.namespace + '.control.themeDark', { common: {states}})); }) .then(() => this.adapter.getStateAsync(this.adapter.namespace + '.control.theme')) .then(state => { //remember currently selected theme, if valid. Select default otherwise. if (state && (this._themes[state.val] || state.val === 'default')) { this._currentTheme = state.val; } else { this._currentTheme = this.config.defaultTheme || 'default'; return this.adapter.setStateAsync(this.adapter.namespace + '.control.theme', this._currentTheme, true); } }) .then(() => this.adapter.getStateAsync(this.adapter.namespace + '.control.themeDark')) .then(state => { //remember currently selected theme, if valid. Select default otherwise. if (state && (this._themes[state.val] || state.val === 'default')) { this._currentThemeDark = state.val; } else { this._currentThemeDark = this.config.defaultThemeDark || 'default'; this.adapter.setStateAsync(this.adapter.namespace + '.control.themeDark', this._currentThemeDark, true); } }) .then(() => { // check every minute if (this.config.auth !== false) { this._clearInterval = setInterval(() => this.clearAuth(), 60000); } }); } async _readAllEntities() { const smartDevices = await this._updateDevices(); for (const dev of smartDevices) { const foundIndex = this._entities.findIndex(x => x.entity_id === dev.entity_id); if (foundIndex !== -1) { this._entities[foundIndex] = dev; } else { this._entities.push(dev); } } await this._getAllEntities(); //creates manual entities. //now all entities are created. Check for icon urls: for (const entity of this._entities) { if (entity.attributes.entity_picture && !entity.attributes.entity_picture.match(/^data:image\//)) { const url = entity.attributes.entity_picture.replace(/^\./, ''); if (!this._entityIconUrls.includes(url)) { this._entityIconUrls.push(url); } } } await this._getAllStates(); this._manageSubscribesFromConfig(); await this.adapter.setStateAsync('info.entitiesUpdated', true, true); } clearAuth() { const now = Date.now(); let changed = false; Object.keys(this._auth_flows).forEach(flowId => { const flow = this._auth_flows[flowId]; if (flow.auth_ttl) { if (now - flow.ts > flow.auth_ttl) { this.log.debug(`Deleted old flowId ${flow.username} ${flowId}`); delete this._auth_flows[flowId]; changed = true; } } else { if (now - flow.ts > TIMEOUT_PASSWORD_ENTER) { this.log.debug('Deleted old flowId (no password) ' + flowId); delete this._auth_flows[flowId]; changed = true; } } }); changed && this._saveAuth(); } _setJsonAttribute(obj, parts, index, value) { if (parts.length - 1 === index) { obj[parts[index]] = value; } else { // if a number if (typeof obj[parts[index]] !== 'object') { if (parts.length - 2 >= index && parts[index + 1] >= '0' && parts[index + 1] <= '9') { obj[parts[index]] = []; } else { obj[parts[index]] = {}; } } this._setJsonAttribute(obj[parts[index]], parts, index + 1, value); } } setJsonAttribute(obj, path, value) { if (!path) { this.log.error('Invalid attribute name for ' + JSON.stringify(obj) + ' = ' + value); return; } const parts = path.split('.'); if (parts.length === 1) { obj[path] = value; } else { this._setJsonAttribute(obj, parts, 0, value); } } async _getAllEntities() { try { const doc = await this.adapter.getObjectViewAsync('custom', 'state', {}); const ids = []; if (doc && doc.rows) { for (let i = 0, l = doc.rows.length; i < l; i++) { if (doc.rows[i].value) { const id = doc.rows[i].id; if (doc.rows[i].value[this.adapter.namespace]) { ids.push(id); } } } } ids.push(this.adapter.namespace + '.control.alarm'); await this._processEntities(ids, this._entities); } catch (e) { this.adapter.log.error(`Could not get object view for getAllEntities: ${e.toString()} - ${e.stack}`); } } _getSmartName(states, id) { if (!id) { if (!this.adapter.config.noCommon) { return states.common.smartName; } else { return (states && states.common && states.common.custom && states.common.custom[this.adapter.namespace]) ? states.common.custom[this.adapter.namespace].smartName : undefined; } } else if (!this.adapter.config.noCommon) { return states[id] && states[id].common ? states[id].common.smartName : null; } else { return (states[id] && states[id].common && states[id].common.custom && states[id].common.custom[this.adapter.namespace]) ? states[id].common.custom[this.adapter.namespace].smartName || null : null; } } // ------------------------------- START OF CONVERTERS ---------------------------------------- // _iobState2EntityState(id, val, type) { type = type || this._ID2entity[id][0].context.type; const pos = type.lastIndexOf('.'); if (pos !== -1) { type = type.substring(pos + 1); } if (type === 'light' || type === 'switch' || type==='input_boolean') { return val ? 'on' : 'off'; } else if (type === 'binary_sensor') { return val ? 'on' : 'off'; } else if (type === 'lock') { return val ? 'unlocked' : 'locked'; } else if (type === 'alarm_control_panel') { return val ? 'armed' : 'disarmed'; } else { return val === null || val === undefined ? 'unknown' : val; } } // Process manually created entity async _processManualEntity(id) { try { const obj = await this.adapter.getForeignObjectAsync(id); if (id === this.adapter.namespace + '.control.alarm') { obj.common.custom = obj.common.custom || {}; obj.common.custom[this.adapter.namespace] = obj.common.custom[this.adapter.namespace] || {}; obj.common.custom[this.adapter.namespace].name = obj.common.custom[this.adapter.namespace].name || 'defaultAlarm'; obj.common.custom[this.adapter.namespace].entity = 'alarm_control_panel'; } const entityType = obj.common.custom[this.adapter.namespace].entity; const idPart = obj.common.custom[this.adapter.namespace].name; const entity_id = idPart && typeof idPart === 'string' ? entityType + '.' + idPart : null; const entity = this._processCommon(null, null, null, obj, entityType, entity_id); entity.context.STATE = {getId: id, setId: id}; entity.isManual = true; if (entityType === 'light') { if (obj.common.type === 'number') { entity.attributes.supported_features = 1; let iobMaxValue = 100; if (obj.common.max) { iobMaxValue = obj.common.max; } else { this.adapter.log.warn(`no max value for light object '${id}' defined -> using fallback max = 100`); } entity.attributes.iob_max = iobMaxValue; const state = await this.adapter.getForeignStateAsync(id); entity.attributes.brightness = state ? state.val || 0 : 0; entity.context.ATTRIBUTES = [{attribute: 'brightness', getId: id}]; entity.context.COMMANDS = [{ service: 'turn_on', setId: id, on: iobMaxValue, parseCommand: (entity, command, data, user) => { let val = command.on; if (data.service_data.brightness >= 0) { entity.attributes.brightness = data.service_data.brightness; entity.attributes.brightness_pct = data.service_data.brightness / 255; val = data.service_data.brightness / 255 * iobMaxValue; } if (data.service_data.brightness_pct >= 0) { entity.attributes.brightness = (data.service_data.brightness_pct / 100) * 255; entity.attributes.brightness_pct = data.service_data.brightness_pct; val = data.service_data.brightness_pct / 100 * iobMaxValue; } return this.adapter.setForeignStateAsync(command.setId, val, false, {user}); } }, { service: 'turn_off', setId: id, off: obj.common.min || 0, parseCommand: (entity, command, data, user) => { return this.adapter.setForeignStateAsync(command.setId, command.off, false, {user}); } }]; } } else if (entityType === 'camera') { entity.context.STATE = {getValue: 'on'}; entity.context.ATTRIBUTES = [{getId: id, attribute: 'url'}]; entity.attributes.code_format = 'number'; entity.attributes.access_token = crypto .createHmac('sha256', (Math.random() * 1000000000).toString()) .update(Date.now().toString()) .digest('hex'); entity.attributes.model_name = 'Simulated URL'; entity.attributes.brand = 'ioBroker'; entity.attributes.motion_detection = false; } else if (entityType === 'alarm_control_panel') { // - alarm_control_panel => STATE disarmed/armed/armed_home/armed_away/armed_night/armed_custom_bypass/pending/arming/disarming/triggered, attributes: [code_format], commands: alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent) // we support only armed/disarmed entity.attributes.code_format = obj.common.custom[this.adapter.namespace].code_format || 'number'; // the code must be in the object in native as alarm_code } else if (entityType === 'input_number') { entity.attributes.min = obj.common.min !== undefined ? obj.common.min : 0; entity.attributes.max = obj.common.max !== undefined ? obj.common.max : 100; entity.attributes.step = obj.common.step || 1; entity.attributes.mode = obj.common.custom[this.adapter.namespace].mode || 'slider'; //or box, will become input box. const state = await this.adapter.getForeignStateAsync(id); entity.attributes.initial = state ? state.val || 0 : 0; } else if (entityType === 'input_boolean') { const state = await this.adapter.getForeignStateAsync(id); entity.attributes.initial = this._iobState2EntityState(null, state.val, entityType); } else if (entityType === 'input_select') { if (obj.common.states ) { if (typeof obj.common.states === 'string') { this.log.warn(obj._id + ': states is of type string. Problems might occur. Please fix states to be of type object.'); entity.context.STATE.map = {}; for (const kv of obj.common.states.split(';')) { const [key, value] = kv.split(':'); entity.context.STATE.map[key] = value; } } else { entity.context.STATE.map = obj.common.states ; } entity.attributes.options = Object.values(entity.context.STATE.map); } const state = await this.adapter.getForeignStateAsync(id); if (state) { if (entity.context.STATE.map && entity.context.STATE.map[state.val]) { entity.attributes.initial = entity.context.STATE.map[state.val]; } } } else if (entityType === 'switch') { entity.context.STATE.setId = id; entity.context.STATE.getId = id; entity.attributes.icon = 'mdi:light-switch'; entity.attributes.assumed_state = true; } else if (entityType === 'timer') { // - timer => STATE idle/paused/active, attributes: [remaining] entity.context.STATE = {getId: null, setId: null}; // will be simulated entity.context.lastValue = null; entity.attributes.remaining = 0; entity.context.ATTRIBUTES = [{ attribute: 'remaining', getId: id, setId: id, getParser: function (entity, attr, state) { state = state || {val: null}; // - timer => STATE idle/paused/active, attributes: [remaining] // if 0 => timer is off if (!state.val) { entity.state = 'idle'; } else if (entity.context.lastValue === null) { entity.state = 'active'; } else if (entity.context.lastValue === state.val) { // pause entity.state = 'paused'; } else { // active entity.state = 'active'; } entity.context.lastValue = state.val; // seconds to D HH:MM:SS if (typeof state.val === 'string' && state.val.indexOf(':') !== -1) { entity.attributes.remaining = state.val; } else { state.val = parseInt(state.val, 10); const hours = Math.floor(state.val / 3600); const minutes = Math.floor((state.val % 3600) / 60); const seconds = state.val % 60; entity.attributes.remaining = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } } }]; } this.log.debug(`Create manual device: ${entity.entity_id} - ${id}`); this._addID2entity(id, entity); return [entity]; } catch (e) { this.adapter.log.error(`Could not process manual entity ${id}: ${e.toString()} - ${e.stack}`); } } async _processSingleCall(ws, data, entity_id) { const user = await this._getUserId(ws.__auth.username || this.config.defaultUser); const entity = this._entity2ID[entity_id]; const id = entity.context.STATE.setId; if (entity.context.COMMANDS) { const command = entity.context.COMMANDS.find(c => c.service === data.service); if (command && command.parseCommand) { return command.parseCommand(entity, command, data, user) .then(result => this._sendResponse(ws, data.id, result)) .catch(e => this._sendResponse(ws, data.id, {result: false, error: e})); } } if (data.service === 'toggle') { this.log.debug('toggle ' + id); this.adapter.getForeignState(id, {user}, (err, state) => this.adapter.setForeignState(id, state ? !state.val : true, false, {user}, () => this._sendResponse(ws, data.id))); } else if (data.service === 'volume_set') { this.log.debug('volume_set ' + id); this.adapter.setForeignState(id, data.service_data.value, false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service === 'trigger' || data.service === 'turn_on' || data.service === 'unlock') { this.log.debug(`${data.service} ${id}`); this.adapter.setForeignState(id, true, false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service === 'turn_off' || data.service === 'lock') { this.log.debug(`${data.service} ${id}`); this.adapter.setForeignState(id, false, false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service === 'set_temperature') { this.log.debug('set_temperature ' + data.service_data.temperature); if (data.service_data.temperature !== undefined) { if (entity.context.ATTRIBUTES) { const attr = entity.context.ATTRIBUTES.find(attr => attr.attribute === 'temperature'); if (attr) { return this.adapter.setForeignState(attr.setId, data.service_data.temperature, false, {user}, () => this._sendResponse(ws, data.id)); } } } this.log.warn(`Cannot find attribute temperature in ${entity_id}`); this._sendResponse(ws, data.id); } else if (data.service === 'set_operation_mode') { this.log.debug(`set_operation_mode ${data.service_data.operation_mode}`); this.adapter.setForeignState(id, false, false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service === 'set_page') { this.log.debug(`set_page ${JSON.stringify(data.service_data.page)}`); if (typeof data.service_data.page === 'object') { this.adapter.setState('control.data', { val: data.service_data.page.title, ack: true }, () => { /*this.adapter.setState('control.instance', { val: self._instance, ack: true }, () => {*/ this.adapter.setState('control.command', { val: 'changedView', ack: true }); //}); }); } } else if (data.service.startsWith('set_') && data.service !== 'set_datetime') { this.log.debug(data.service + ': ' + id + ' = ' + data.service_data[data.service.substring(4)]); // set_value => service_data.value // set_operation_mode => service_data.operation_mode // set_temperature => service_data.temperature // set_speed => service_data.speed this.adapter.setForeignState(id, data.service_data[data.service.substring(4)], false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service === 'volume_mute') { this.log.debug(`volume_mute ${id} = ${data.service_data.is_volume_muted}`); // volume_mute => service_data.is_volume_muted this.adapter.setForeignState(id, data.service_data.is_volume_muted, false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service.startsWith('select_option')) { this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(7)]}`); //get list of options; this.adapter.getForeignObject(id, (err, obj) => { if (obj.common.states) { const valToState = Object.keys(obj.common.states).find(key => obj.common.states[key] === data.service_data.option); this.adapter.setForeignState(id, valToState, false, {user}, () => this._sendResponse(ws, data.id)); } }); //this.adapter.setForeignState(id, data.service_data[data.service.substring(7)], false, {user}, () => // this._sendResponse(ws, data.id)); } else if (data.service.startsWith('select_')) { this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(7)]}`); // select_option => service_data.option // select_source => service_data.source this.adapter.setForeignState(id, data.service_data[data.service.substring(7)], false, {user}, () => this._sendResponse(ws, data.id)); } else if (data.service.startsWith('alarm_')) { // alarm_arm_away, alarm_arm_home, alarm_arm_night, alarm_arm_custom_bypass, alarm_disarm (code will be sent) this.log.debug(data.service + ': ' + id + ' = ' + data.service_data.code ? 'XXX' : 'none'); this.adapter.getForeignObject(id, (err, obj) => { if (obj.native.alarm_code && obj.native.alarm_code.toString() !== data.service_data.code.toString()) { this._sendResponse(ws, data.id, {result: false, error: 'invalid code'}); } else { this.log.warn(`No code is defined! To provide the code add to object ${id} native.alarm_code with desired code`); this.adapter.setForeignState(id, data.service.startsWith('alarm_arm'), false, {user}, () => this._sendResponse(ws, data.id)); } }); } else if (data.service.endsWith('_say')) { this.adapter.setForeignState(id, data.service_data.message, false, {user}, () => { this._sendResponse(ws, data.id); }); } else { this.log.warn(`Unknown service: ${data.service} (${JSON.stringify(data)})`); // close_cover, open_cover // set_datetime => service_data.date, service_data.time //{'id": 21, "type": "result", "success": false, "error": {"code": "not_found", "message": "Service not found."}} ws.send(JSON.stringify({id, type: 'result', success: false, error: {code: 'not_found', message: 'Service not found.'}})); } } async _processCall(ws, data) { if (!data.service) { this.log.warn('Invalid service call. Make sure service looks like domain.service_name'); return; } if (data.service === 'dismiss') { this.log.debug('dismiss ' + data.service_data.notification_id); return this._clearNotification(data.service_data.notification_id).then(() => this._sendResponse(ws, data.id)); } if(data.domain === 'system_log' && data.service === 'write') { this.log.info('Log from UI ' + data.service_data.message); return this._sendResponse(ws, data.id); } let ids = [data.service_data.entity_id]; if (data.service_data.entity_id instanceof Array) { ids = data.service_data.entity_id; } delete data.service_data.entity_id; //make sure we do not use entity_id array in processSingleCall -> use param entity_id there. for (const id of ids) { if (!this._entity2ID[id]) { this.log.warn(`Unknown entity: ${id} for service call ${JSON.stringify(data)}`); } else { await this._processSingleCall(ws, data, id); } } } async _getAllStates() { let entity = this._entities.find(e => e.state === undefined); while (entity) { entity.state = 'unknown'; if (entity.context.STATE && entity.context.STATE.getId) { try { const user = await this._getUserId(this.config.defaultUser); //TODO: why is this always defaultUser? const state = await this.adapter.getForeignStateAsync(entity.context.STATE.getId, {user}); if (state) { this.onStateChange(entity.context.STATE.getId, state); } else { entity.state = 'unknown'; try { entity.last_changed = new Date().toISOString(); } catch (e) { this.adapter.log.warn(`Invalid last changed for ${entity.context.STATE.getId}`); } entity.last_updated = entity.last_changed; } } catch (e) { this.adapter.log.error(`Could not get state ${entity.context.STATE.getId}: ${e} - ${e.stack}`); } } else if (entity.context.type === 'switch') { entity.state = 'off'; } else if (entity.context.STATE.getValue !== undefined) { entity.state = entity.context.STATE.getValue; } else if (entity.context.type === 'climate') { entity.state = 'on'; } //handle attributes: if (entity.context.ATTRIBUTES) { const ids = entity.context.ATTRIBUTES.map(entry => entry.getId || ''); try { const states = await this.adapter.getForeignStatesAsync(ids); if (ids && ids.length) { entity.attributes = entity.attributes || {}; ids.forEach((id, i) => { const attribute = entity.context.ATTRIBUTES[i].attribute; if (attribute === 'remaining' && entity.context.type === 'timer') { if (!states[id].val) { entity.state = 'idle'; } else { entity.state = 'active'; } entity.context.lastValue = states[id].val; } else { this.onStateChange(id, states[id]); } }); } } catch (e) { this.adapter.log.error(`Could not update state: ${e} - ${e.stack}`); } } entity = this._entities.find(e => e.state === undefined); } } async onStateChange(id, state, forceUpdate = false) { if (state) { if (id === this.adapter.namespace + '.control.shopping_list') { return this._sendUpdate('shopping_list_updated'); } else if (id === this.adapter.namespace + '.notifications.list') { if (!state.ack) { await this._readNotifications(); } return this._sendUpdate('persistent_notifications_updated'); } else if (id === this.adapter.namespace + '.notifications.add') { return !state.ack && this.addNotification(state.val).then(() => this._sendUpdate('persistent_notifications_updated')); } else if (id === this.adapter.namespace + '.notifications.clear') { return !state.ack && this._clearNotification(state.val).then(() => this._sendUpdate('persistent_notifications_updated')); } else if (id === this.adapter.namespace + '.control.theme' || id === this.adapter.namespace + '.control.themeDark') { const dark = id.includes('Dark'); if (this._themes[state.val] || state.val === 'default') { this[dark ? '_currentThemeDark' : '_currentTheme'] = state.val; this._sendUpdate('themes_updated'); } } else if (id === this.adapter.namespace + '.conversation') { if (state.ack) { // send answer to conversation dialog this._wss && this._wss.clients.forEach(client => { if (client.__conversations && client.readyState === WebSocket.OPEN) { Object.keys(client.__conversations).forEach(conversation_id => { client.__conversations[conversation_id].timer && clearTimeout(client.__conversations[conversation_id].timer); const answer = { id: client.__conversations[conversation_id].id, type: 'result', success: true, result: { speech: { plain: { speech: state.val, extra_data: null } }, card: {} } }; client.send(JSON.stringify(answer)); }); } }); } } } const changedStates = {}; this._wss && this._wss.clients.forEach(client => { if (client.__templates && client.readyState === WebSocket.OPEN) { client.__templates.forEach(t => { if (t.ids.includes(id)) { const _state = state || {val: null}; if (changedStates[id] || (this.templateStates[id] && this.templateStates[id].val !== _state.val)) { this.templateStates[id] = _state; changedStates[id] = true; const event = { id: t.id, type: 'event', event: { result: bindings.formatBinding(t.template, this.templateStates) } }; client.send(JSON.stringify(event)); } } }); } }); const entities = this._ID2entity[id]; if (entities) { entities.forEach(entity => { let updated = false; if (state) { // {id: 2, type: "event", "event": {"event_type": "state_changed", "data": {"entity_id": "sun.sun", "old_state": {"entity_id": "sun.sun", "state": "above_horizon", "attributes": {"next_dawn": "2019-05-17T02:57:08+00:00", "next_dusk": "2019-05-16T19:44:32+00:00", "next_midnight": "2019-05-16T23:21:40+00:00", "next_noon": "2019-05-17T11:21:38+00:00", "next_rising": "2019-05-17T03:36:58+00:00", "next_setting": "2019-05-16T19:04:54+00:00", "elevation": 54.81, "azimuth": 216.35, "friendly_name": "Sun"}, "last_changed": "2019-05-16T09:09:53.424242+00:00", "last_updated": "2019-05-16T12:46:30.001390+00:00", "context": {id: "05356b1a7df54b2f939d3c7f8a3e05b4", "parent_id": null, "user_id": null}}, "new_state": {"entity_id": "sun.sun", "state": "above_horizon", "attributes": {"next_dawn": "2019-05-17T02:57:08+00:00", "next_dusk": "2019-05-16T19:44:32+00:00", "next_midnight": "2019-05-16T23:21:40+00:00", "next_noon": "2019-05-17T11:21:38+00:00", "next_rising": "2019-05-17T03:36:58+00:00", "next_setting": "2019-05-16T19:04:54+00:00", "elevation": 54.71, "azimuth": 216.72, "friendly_name": "Sun"}, "last_changed": "2019-05-16T09:09:53.424242+00:00", "last_updated": "2019-05-16T12:47:30.000414+00:00", "context": {id: "e738dc26af1d48b4964c6d9805179595", "parent_id": null, "user_id": null}}}, "origin": "LOCAL", "time_fired": "2019-05-16T12:47:30.000414+00:00", "context": {id: "e738dc26af1d48b4964c6d9805179595", "parent_id": null, "user_id": null}}} if (entity.context.STATE.getId === id) { updated = true; try { entity.last_changed = new Date(state.lc).toISOString(); } catch (e) { this.adapter.log.warn(`Invalid last changed for ${entity.context.STATE.getId}`); } try { entity.last_updated = new Date(state.ts).toISOString(); } catch (e) { this.adapter.log.warn(`Invalid timestamp for ${entity.context.STATE.getId}`); } if (entity.context.STATE.getParser) { entity.context.STATE.getParser(entity, 'state', state); } else { if (entity.context.type === 'light' && typeof (state.val) === 'number') { // is dimmer entity.attributes.brightness = state.val * 255 / entity.attributes.iob_max; entity.state = state.val > 0 ? 'on' : 'off'; } else if (entity.context.type === 'input_select') { if (entity.context.STATE.map) { entity.attributes.initial = this._iobState2EntityState(id, entity.context.STATE.map[state.val]); entity.state = this._iobState2EntityState(id, entity.context.STATE.map[state.val]); } } else { entity.state = this._iobState2EntityState(id, state.val); } } } //can have identical id for state and attributes. if (entity.context.ATTRIBUTES) { const attributes = entity.context.ATTRIBUTES.filter(e => e.getId === id); for (const attr of attributes) { updated = true; try { entity.last_changed = new Date(state.lc).toISOString(); } catch (e) { this.adapter.log.warn(`Invalid last changed for ${attr.getId}`); } try { entity.last_updated = new Date(state.ts).toISOString(); } catch (e) { this.adapter.log.warn(`Invalid timestamp for ${attr.getId}`); } if (attr.getParser) { attr.getParser(entity, attr, state); } else { if (entity.context.type === 'light' && typeof(state.val) === 'number'){ // is dimmer this.setJsonAttribute(entity.attributes, attr.attribute, this._iobState2EntityState(null, state.val * 255 / entity.attributes.iob_max, attr.attribute), this.log); } else { this.setJsonAttribute(entity.attributes, attr.attribute, this._iobState2EntityState(null, state.val, attr.attribute), this.log); } } } } } if (!updated && !forceUpdate) { return; //nothing happened -> do not notify UI. } let time_fired; try { time_fired = new Date(state ? state.ts : undefined).toISOString(); } catch (e) { time_fired = new Date().toISOString(); } const t = { type: 'event', event: { event_type: 'state_changed', data: { entity_id: entity.entity_id, new_state: entity }, origin: 'LOCAL', time_fired } }; const foundIndex = this._entities.findIndex(x => x.entity_id === entity.entity_id); if (foundIndex !== -1) { th