UNPKG

iobroker.lovelace

Version:

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

1,071 lines (997 loc) 155 kB
const fs = require('fs'); const crypto = require('crypto'); const WebSocket = require('ws'); const bodyParser = require('body-parser'); 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 handleAutoEntitiesCard = require('./modules/autoEntities').handleAutoEntitiesCard; const utils = require('./entities/utils'); const BrowserModModule = require('./modules/browser_mod'); const processBlind = require('./converters/cover').processBlind; const converterSwitch = require('./converters/switch'); const converterLight = require('./converters/light'); const converterBinarySensors = require('./converters/binary_sensor'); const converterSensors = require('./converters/sensor'); const processMediaPlayer = require('./converters/media_player').processMediaPlayer; const converterClimate = require('./converters/climate'); const converterWeather = require('./converters/weather'); const processImage = require('./converters/camera').processImage; const processLock = require('./converters/lock').processLock; const converterGeoLocation = require('./converters/geo_location'); const converterDatetime = require('./converters/input_datetime'); const converterAlarmCP = require('./converters/alarm_control_panel'); const converterInputSelect = require('./converters/input_select'); const convertFan = require('./converters/fan'); const entityData = require('./dataSingleton'); const bindings = require('./bindings'); const getFriendlyName = require('./entities/friendly_name').getFriendlyName; const iobState2EntityState = require('./converters/genericConverter').iobState2EntityState; const NUMERIC_DEVICE_CLASSES = require('./converters/genericConverter').numericDeviceClasses; const HistoryModule = require('./modules/history'); const ConversationModule = require('./modules/conversation'); const LogbookModule = require('./modules/logbook'); const PersistentNotifications = require('./modules/persistentNotifications'); const TodoModule = require('./modules/todo'); const PersonModule = require('./modules/person'); const StatisticsRecorderModule = require('./modules/statisticsRecorder'); const EntityRegistry = require('./modules/entityRegistry'); const DashboardModule = require('./modules/dashboard'); const DeviceRegistryModule = require('./modules/deviceRegistry'); const AreaRegistryModule = require('./modules/areaRegistry'); const EXIT_CODES = require('@iobroker/adapter-core').EXIT_CODES; const TIMEOUT_PASSWORD_ENTER = 180000; // 3 min const TIMEOUT_AUTH_CODE = 10000; // 10sec const ChannelDetector = require('@iobroker/type-detector').default; const { Types } = require('@iobroker/type-detector'); const path = require('node:path'); const ignoreIds = [/^system\./, /^script\./]; const ROOT_DIR = '../hass_frontend'; //frontend expects only YYYY.MM.DD -> omit the rest. const VERSION = fs .readFileSync(`${getRootPath()}version.txt`, 'utf8') .replace(/(\d{4})(\d{2})(\d{2})\.(\d).*/s, '$1.$2.$3'); const NO_TOKEN = 'no_token'; function getRootPath() { if (ROOT_DIR.match(/^\w:/) || ROOT_DIR.startsWith('/')) { return `${ROOT_DIR}/`; } return path.resolve(`${__dirname}/${ROOT_DIR}`) + path.sep; } 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); }); }); }; /* 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 - 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}, {...}] */ const staticOptions = { maxAge: 2678400 * 1000, // 31 days }; /** * WebServer class, handles incomming requests and manages websocket connections. */ class WebServer { /** * Constructor of the WebServer class. * * @param options object with options from the adapter. */ 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 = this.config.language || 'en'; this.detector = new ChannelDetector(); this.config.ttl = parseInt(this.config.ttl, 10) || 3600; this.words = options.words || {}; //setup entityData: entityData.adapter = this.adapter; entityData.log = this.adapter.log; entityData.words = this.words; entityData.server = this; //files that might be requested from frontend and are delivered using readFile. No authority check is applied here! this._requestableFiles = []; this._subscribed = []; this._server = options.server; this._app = options.app; this._auth_flows = {}; this.templateStates = {}; this._themes = {}; //themes storage this._currentTheme = this.config.defaultTheme || 'default'; this._currentThemeDark = this.config.defaultThemeDark || 'default'; this._darkMode = false; //object data for updates: this._objectData = { objects: {}, //id -> object storage ids: [], //array of object ids. rooms: [], functions: [], roomNames: {}, //id -> name storage funcNames: {}, updatedObjects: [], //id + object pairs on updates to handle burst updates after burst. usedKeys: [], //temporary storage for used keys (type-detector) }; //initialize modules. this._modules = { browserMod: new BrowserModModule({ adapter: this.adapter, objects: this._objectData.objects, }), conversation: new ConversationModule({ adapter: this.adapter, sendResponse: this._sendResponse, lang: this.lang, words: this.words, }), logbook: new LogbookModule({ adapter: this.adapter, getUsedEntityIDs: () => { const entities = []; this._flatJSON(this._lovelaceConfig ? this._lovelaceConfig.views : {}, entities); return entities; }, webSocketServer: this._wss, }), notifications: new PersistentNotifications({ adapter: this.adapter, server: this, }), todo: new TodoModule({ adapter: this.adapter, entityData: entityData, server: this, //for legacy shopping list.. is that still used at all? getWebsocketServer: () => this._wss, }), person: new PersonModule({ adapter: this.adapter, }), entityRegistry: new EntityRegistry({ adapter: this.adapter, entityData: entityData, sendResponse: this._sendResponse, sendUpdate: this._sendUpdate.bind(this), }), dashboard: new DashboardModule({ adapter: this.adapter, sendResponse: this._sendResponse, sendUpdate: this._sendUpdate.bind(this), }), deviceRegistry: new DeviceRegistryModule({ adapter: this.adapter, entityData, sendResponse: this._sendResponse, }), areaRegistry: new AreaRegistryModule({ adapter: this.adapter, rooms: this._objectData.rooms, sendResponse: this._sendResponse, sendUpdate: this._sendUpdate.bind(this), }), }; this._modules.history = new HistoryModule({ adapter: this.adapter, entityData: entityData, personModule: this._modules.person, }); this._modules.statisticsRecorder = new StatisticsRecorderModule({ adapter: this.adapter, server: this, log: this.log, personModule: this._modules.person, dataSingleton: entityData, }); this.converter = { [Types.socket]: converterSwitch.processSocket, [Types.light]: converterLight.processLight, [Types.dimmer]: converterLight.processLightAdvanced, [Types.ct]: converterLight.processLightAdvanced, [Types.hue]: converterLight.processLightAdvanced, [Types.rgb]: converterLight.processLightAdvanced, [Types.rgbSingle]: converterLight.processLightAdvanced, [Types.motion]: converterBinarySensors.processMotion, [Types.window]: converterBinarySensors.processWindow, [Types.windowTilt]: converterSensors.processWindowTilt, [Types.door]: converterBinarySensors.processDoor, [Types.button]: converterSwitch.processSocket, [Types.temperature]: converterSensors.processTemperature, [Types.humidity]: converterSensors.processHumidity, [Types.lock]: processLock, [Types.airCondition]: converterClimate.processThermostatOrAirConditioning, [Types.thermostat]: converterClimate.processThermostatOrAirConditioning, [Types.blind]: processBlind, [Types.blindButtons]: processBlind, [Types.weatherForecast]: converterWeather.processWeather, [Types.accuWeatherForecast]: converterWeather.processAccuWeather, [Types.location]: converterGeoLocation.processLocation, [Types.location_one]: converterGeoLocation.processLocation, [Types.media]: processMediaPlayer, [Types.image]: processImage, [Types.fireAlarm]: converterBinarySensors.processFireAlarm, }; if (this.adapter.config.updateTimeout !== undefined) { this.adapter.config.updateTimeout = Math.max(100, Math.min(this.adapter.config.updateTimeout, 30000)); } const concurrentPromises = [ this._modules.todo.init(), this._modules.person.init(), this._modules.entityRegistry.init(), this._modules.dashboard.init(), this.adapter .getForeignObjectAsync('system.config') .then(config => { this.lang = this.config.language || config.common.language; entityData.lang = this.lang; this.systemConfig = config.common; this.systemConfig.ts = config.ts; this._updateConstantEntities(); return this.adapter.getObjectAsync('configuration'); }) .then(config => { if (config && config.native && config.native.title) { this._lovelaceConfig = config.native; } else { this._lovelaceConfig = require('./defaultConfig'); } }) .then(() => this._modules.browserMod.init(this._lovelaceConfig)), this._readAllEntities(), this._listFiles(), this._initThemes(), ]; Promise.all(concurrentPromises) .then(() => { this.adapter.subscribeObjects('configuration'); this.adapter.subscribeStates('control.*'); this.adapter.subscribeStates('notifications.*'); this.adapter.subscribeStates('instances.*'); this.adapter.subscribeStates('conversation'); this._init(); for (const module of Object.values(this._modules)) { if (typeof module.augmentServices === 'function') { module.augmentServices(entityData.services); } } // check every minute if (this.config.auth !== false) { this._clearInterval = setInterval(() => this.clearAuth(), 60000); } this.adapter.setState('info.readyForClients', true, true); this.log.debug('Initialization done.'); }) .catch(err => { this.log.error(`Initialization error: ${err}`); if (typeof this.adapter.terminate === 'function') { this.adapter.terminate(EXIT_CODES.INVALID_ADAPTER_CONFIG); } else { process.exit(EXIT_CODES.INVALID_ADAPTER_CONFIG); } }); } /** * Generate all entities from object database using type-detector and custom settings. * * @returns {Promise<void>} resolves, when done. */ async _readAllEntities() { const smartDevices = await this._updateDevices(); for (const entity of smartDevices) { //fill entity into utils.fillEntityIntoCaches(entity); } await this._getManualEntities(); //creates manual entities. //now all entities are created. Check for icon urls: for (const entity of entityData.entities) { if (entity.attributes.entity_picture && !entity.attributes.entity_picture.match(/^data:image\//)) { const url = entity.attributes.entity_picture.replace(/^\./, ''); if (!entityData.entityIconUrls.includes(url)) { entityData.entityIconUrls.push(url); } } } await this._getAllStates(); await this._manageSubscribesFromConfig(); this.log.debug('entitiesUpdated for startup.'); await this.adapter.setStateAsync('info.entitiesUpdated', true, true); } /** * Remove auth sessions that expired. */ 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(); } /** * Generates entities from custom settings (TODO: name is misguiding!!) * * @returns {Promise<void>} resolves, when done. */ async _getManualEntities() { try { const doc = await this.adapter.getObjectViewAsync('system', 'custom', {}); 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`); const created = []; for (const id of ids) { const entities = await this._processManualEntity(id); for (const entity of entities) { created.push(entity); utils.fillEntityIntoCaches(entity); } } this._modules.entityRegistry.handleUpdatedEntities(created, false); } catch (e) { this.adapter.log.error(`Could not get object view for getAllEntities: ${e.toString()} - ${e.stack}`); } } // ------------------------------- START OF CONVERTERS ---------------------------------------- // // Process manually created entity /** * Create manual entities from custom-part. Process one object here. * * @param {string} id of ioBroker object * @returns {Promise<{context: {id: string, type: string}, attributes: {friendly_name: string}, entity_id: string}[]|entity[]|*[]>} manual entity */ async _processManualEntity(id) { try { const obj = await this.adapter.getForeignObjectAsync(id); if (!this._objectData.objects[id]) { this._objectData.objects[id] = obj; } 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'; obj.common.custom[this.adapter.namespace].states = { state: id, arm_state: `${this.adapter.namespace}.control.alarm_arm_state`, }; } const custom = obj.common.custom[this.adapter.namespace] || {}; const entityType = custom.entity || utils.autoDetermineEntityType(obj); const forcedEntityId = this._modules.entityRegistry.getEntityId(id); const entity_id = forcedEntityId || utils.createEntityNameFromCuston(obj, this.adapter.namespace); const entity = utils.processCommon(null, null, null, obj, entityType, entity_id); if ( custom.attr_assumed_state && ['switch', 'light', 'cover', 'climate', 'fan', 'humidifier', 'group', 'water_heater'].includes( entityType, ) ) { entity.attributes.assumed_state = true; } entity.context.STATE = { getId: id, setId: id }; if (obj && obj.common && obj.common.states && ['string', 'number'].includes(obj.common.type)) { entity.context.STATE.map2lovelace = obj.common.states; if (!(obj.common.states instanceof Array)) { entity.context.STATE.map2iob = {}; Object.keys(obj.common.states).forEach( k => (entity.context.STATE.map2iob[obj.common.states[k]] = k), ); } } utils.addID2entity(id, entity); if (custom.states && custom.states.stateRead) { entity.context.STATE.getId = custom.states.stateRead; utils.addID2entity(custom.states.stateRead, entity); } entity.isManual = true; if (custom.states) { if (custom.states.state && custom.states.state !== id) { this.log.error( `Please define custom settings on main object ${custom.states.state} and not on ${id}. Entity skipped`, ); return []; } custom.states.state = id; //get objects of all necessary additional ids here: for (const stateId of Object.values(custom.states)) { if (!this._objectData.objects[stateId]) { try { this._objectData.objects[stateId] = await this.adapter.getForeignObjectAsync(stateId); } catch (e) { this.adapter.log.warn( `Could not get object ${stateId} for manual entity ${entity_id} please check config in ${id}. Error: ${e}`, ); } } } utils.fillEntityFromStates(custom.states, entity); } for (const key of Object.keys(custom)) { if (key.startsWith('attr_')) { const attributeName = key.substring('attr_'.length); entity.attributes[attributeName] = custom[key]; } } this.log.debug(`Create manual ${entityType} device: ${entity.entity_id} - ${id}`); if (entityType === 'light') { return converterLight.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'input_datetime') { return converterDatetime.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'binary_sensor') { return converterBinarySensors.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'sensor') { return converterSensors.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'climate') { return converterClimate.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'geo_location') { return converterGeoLocation.processManualEntity(id, obj, entity, this._objectData.objects, custom); } 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', (crypto.webcrypto.getRandomValues(new Uint32Array(1))[0] * 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') { return converterAlarmCP.processManualEntity(id, obj, entity, this._objectData.objects, custom); } 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 = 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 = iobState2EntityState(entity, state ? state.val : undefined, 'initial'); } else if (entityType === 'input_select') { return converterInputSelect.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'fan') { return convertFan.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'todo') { return this._modules.todo.processManualEntity(id, obj, entity, this._objectData.objects, custom); } else if (entityType === 'switch') { return converterSwitch.processManualEntity(id, obj, entity, this._objectData.objects, custom); } 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')}`; } }, }, ]; } utils.addID2entity(id, entity); return [entity]; } catch (e) { this.adapter.log.error(`Could not process manual entity ${id}: ${e.toString()} - ${e.stack}`); } } /** * Process a single service call from the frontend. * * @param ws websocket connection to the frontend * @param {Record<string,any>} data data of the service call * @param {string} entity_id entity id connected to the call. Required to be a single id in this function. * @returns {Promise<void>} resolves when done. */ async _processSingleCall(ws, data, entity_id) { const user = this._modules.person.getUserIDFromName(ws.__auth?.username); const entity = entityData.entityId2Entity[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.message || 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, Number(data.service_data.value), false, { user }, () => this._sendResponse(ws, data.id), ); } else if ( data.service === 'trigger' || data.service === 'turn_on' || data.service === 'unlock' || data.service === 'press' ) { 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, Number(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}`); //TODO: just sending false here probably is wrong. The call is supported only be Waterheater entity. So... not really used, right now? 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.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 let val = data.service_data[data.service.substring(4)]; if (!val && ['datetime'].includes(entity.context.type)) { val = data.service_data.datetime; } if (entity.context.STATE.map2iob) { val = Number(entity.context.STATE.map2iob[val]); if (!val && val !== 0) { val = data.service_data[data.service.substring(4)]; //fallback if undefined. } } if (entity.context.stateType === 'number') { val = Number(val); } this.adapter.setForeignState(id, val, 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_')) { this.log.debug(`${data.service}: ${id} = ${data.service_data[data.service.substring(7)]}`); // select_option => service_data.option // select_source => service_data.source let val = data.service_data[data.service.substring(7)]; if (entity.context.STATE.map2iob) { val = Number(entity.context.STATE.map2iob[val]); if (!val && val !== 0) { val = data.service_data[data.service.substring(7)]; //fallback if undefined. } } this.adapter.setForeignState(id, val, 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)})`); //{'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.' }, }), ); } } /** * Process service calls. Extracts entity_id from data. Can be an array, too. Also hands the call to modules, if * they process service calls. * * @param ws websocket connection to the frontend * @param data data of the service call * @returns {Promise<void>} resolves when done. */ async _processCall(ws, data) { if (!data.service) { this.log.warn('Invalid service call. Make sure service looks like domain.service_name'); return; } if (data.domain === 'system_log' && data.service === 'write') { this.log.info(`Log from UI ${data.service_data.message}`); return this._sendResponse(ws, data.id); } //do that here, because no entity_id in service call! let handled = false; for (const module of Object.values(this._modules)) { if (typeof module.processServiceCall === 'function') { handled = (await module.processServiceCall(ws, data)) || handled; } } if (handled) { return; //already processed. } //handle new format and convert to old: if (data.target && data.target.entity_id) { data.service_data.entity_id = data.service_data.entity_id || data.target.entity_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 (!entityData.entityId2Entity[id]) { this.log.warn(`Unknown entity: ${id} for service call ${JSON.stringify(data)}`); } else { await this._processSingleCall(ws, data, id); } } } /** * Read states from iobroker state database and fill entity states / attributes. * Usually done to read initial values. * * @returns {Promise<void>} resolves when done. */ async _getAllStates() { let entity = entityData.entities.find(e => e.state === undefined); while (entity) { entity.state = 'unknown'; if (entity.context.STATE && entity.context.STATE.getId) { try { const user = this.config.defaultUser; const state = await this.adapter.getForeignStateAsync(entity.context.STATE.getId, { user }); if (state) { await this.onStateChange(entity.context.STATE.getId, state); } else { entity.state = 'unknown'; entity.last_changed = Date.now() / 1000; 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 = entityData.entities.find(e => e.state === undefined); } } /** * Handle a state change. * * @param id id of the state * @param state new state object * @param forceUpdate force entity.state update of all clients * @returns {Promise<void>} resolves when done. */ async onStateChange(id, state, forceUpdate = false) { if (state) { 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}.control.darkMode`) { if (this._darkMode !== state.val) { this._darkMode = !!state.val; this._sendUpdate('themes_updated'); } } } 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 = entityData.iobID2entity[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; utils.updateTimestamps(entity, state); if (entity.context.STATE.getParser) { entity.context.STATE.getParser(entity, 'state', state); } else { entity.state = iobState2EntityState(entity, 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; //only update if newer than already present time. utils.updateTimestamps(entity, state); if (attr.getParser) { attr.getParser(entity, attr, state); } else { utils.setJsonAttribute( entity.attributes, attr.attribute, iobState2EntityState(entity, state.val, attr.attribute), this.log, ); } } } } if (!updated && !forceUpdate) { return; //nothing happened -> do not notify UI. } this.updateEntityInFrontend(entity, state); }); } //check modules: for (const module of Object.values(this._modules)) { if (typeof module.onStateChange === 'function') { module.onStateChange(id, state, this._wss); } } } /** * Send entity update to all clients / frontends. * * @param entity entity that changed. * @param state ioBroker state, used to get the timestamp. */ updateEntityInFrontend(entity, state) { const t = { type: 'event', event: { a: {}, event_type: 'subscribe_entities', origin: 'LOCAL', time_fired: (state ? state.ts : Date.now()) / 1000, }, }; t.event.a[entity.entity_id] = this._getShortEntity(entity); this._wss && this._wss.clients.forEach(ws => { if (ws._subscribes && ws._subscribes.subscribe_entities) { ws._subscribes.subscribe_entities.forEach(id => { t.id = id; ws.send(JSON.stringify(t)); }); } }); } // ------------------------------- END OF CONVERTERS ---------------------------------------- // /** * Generate entities from indicators for a device, found by type detector. Store "deviceId" in context for later use. * * @param mainEntity main entity of the device * @param control control object from type detector * @param friendlyName friendly name of the device * @param room room name of the device * @param func function name of the device * @param objects all ioBroker objects * @returns {*[]} array of generated entities */ _generateEntitiesFromIndicators(mainEntity, control, friendlyName, room, func, objects) { const entities = []; const baseName = mainEntity.entity_id.split('.')[1]; //make battery have sensible entity id and make sure it is different from "host" device: const battery = converterBinarySensors.processBattery( control, friendlyName, room, func, objects, `binary_sensor.${baseName}_BatteryWarning`, ); if (battery) { battery.context.deviceId = mainEntity.context.id; entities.push(battery); } //create binary sensor from online/offline indicator: const online = converterBinarySensors.connectivityIndicator( control, friendlyName, room, func, objects, `binary_sensor.${baseName}_Connectivity`, ); if (online) { online.context.deviceId = mainEntity.context.id; entities.push(online); } //error: const error = converterBinarySensors.processError( control, friendlyName, room, func, objects, `binary_sensor.${baseName}_Error`, ); if (error) { error.context.deviceId = mainEntity.context.id; entities.push(error); }