UNPKG

iobroker.lovelace

Version:

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

580 lines (538 loc) 19.1 kB
const entityData = require('./../dataSingleton'); const getEntityId = require('./entity_id').getEntityId; const getEntityType = require('./entity_id').getEntityType; const getFriendlyName = require('./friendly_name').getFriendlyName; /** * Creates objects-Object used in entity creation from an array of ids. * * @param {Array<string>} ids array of ids * @returns {Promise<{string: ioBroker.Object}>} object with id as key and ioBroker.Object as values. */ async function createObjectsFromArrayOfIds(ids) { const objects = {}; const objectsUnsorted = await Promise.all(ids.map(id => entityData.adapter.getForeignObjectAsync(id))); for (const obj of objectsUnsorted) { if (obj) { //might be null if object for id does not exist. objects[obj._id] = obj; } } return objects; } /** * Finds out if id is part of this enum or not. * * @param e {ioBroker.Enum} enum object * @param id {string} id to check * @returns {boolean} true if id is part of this enum. */ function isIdInEnum(e, id) { for (const member of e.common.members) { if (id.startsWith(member)) { return true; } } return false; } /** * Find enum in enums array for id. * * @param enums {Array<ioBroker.Enum>} array of enums * @param id {string} id to find * @returns {object | undefined} enum object or undefined if not found. */ function findEnumForId(enums, id) { for (const e of enums) { if (isIdInEnum(e, id)) { return e; } } } /** * Gets smartName for object or for id from objects cache. * * @param {{String: ioBroker.Object}|ioBroker.Object} objects id to object cache * @param {string} id id of object to get the smartName for. * @param {string} lang language to use for smartName * @returns {string|undefined} smartName or undefined if not found. */ function getSmartName(objects, id, lang) { let object = objects[id]; if (!id) { object = objects; } if (!object || !object.common || !object.common.smartName) { return undefined; } if (typeof object.common.smartName === 'object') { return object.common.smartName[lang] || object.common.smartName.en; } //if not object, just return the smartName. return object.common.smartName; } const cache = {}; /** * Get name of enum * * @param {ioBroker.Enum} obj enum object * @param {string} [lang] or 'en' if not set. * @param {boolean} [force] if true, will not use cache. * @returns {string} name of enum */ function getEnumName(obj, lang, force = false) { if (!obj) { return null; } if (obj._id && cache[obj._id] && !force) { return cache[obj._id]; } let name = getSmartName(obj, null, lang); name = name || obj?.common?.name; if (name && typeof name === 'object') { name = name[lang] || name.en; } if (!name) { const parts = obj?._id?.split('.') || []; parts.unshift(); //remove "enum" parts.unshift(); //remove "functions" / "rooms" name = ''; for (const part of parts) { name += ` ${part[0].toUpperCase()}${part.substring(1)}`; } name = name.substring(1); //remove first space. } cache[obj._id] = name; return name; } /** * Internal function of setJsonAttribute * * @param obj object to set attribute in * @param parts parts of attribute name * @param index current index * @param value value to set */ function _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]] = {}; } } _setJsonAttribute(obj[parts[index]], parts, index + 1, value); } } /** * sets attribute of entity. * * @param obj object to set attribute in * @param path path to attribute * @param value value to set */ function setJsonAttribute(obj, path, value) { if (!path) { entityData.log.error(`Invalid attribute name for ${JSON.stringify(obj)} = ${value}`); return; } const parts = path.split('.'); if (parts.length === 1) { obj[path] = value; } else { _setJsonAttribute(obj, parts, 0, value); } } /** * Extract all entity ids from a string and add them to the array alreadyPresentEntityIds. * * @param {string} str string to extract entity ids from * @param {Array<string>} alreadyPresentEntityIds array of already present entity ids */ function extractValidEntityIds(str, alreadyPresentEntityIds = []) { const entityRegEx = /([a-zA-Z0-9А-Яа-я_]+\.[a-zA-Z0-9А-Яа-я_]+)/g; let match = entityRegEx.exec(str); while (match !== null) { const id = match[1]; //check if id valid, if not already present and if we have an entity for the id: if (id && alreadyPresentEntityIds.indexOf(id) === -1 && entityData.entityId2Entity[id]) { alreadyPresentEntityIds.push(id); } match = entityRegEx.exec(str); } } /** * Get Icon if object has one. Supports data:image, url, and local icon path. * * @param obj {ioBroker.Object} ioBroker object * @param prefix {string} prefix to add to local icons * @returns {string|null|*} icon url or null if no icon. */ function _getObjectIcon(obj, prefix) { prefix = prefix || '.'; //http://localhost:8081'; if (!obj || !obj.common || !obj.common.icon) { return null; } let icon; if (!obj.common.icon.match(/^data:image\//)) { if (obj.common.icon.startsWith('http')) { //icon is full URL. Use that. return obj.common.icon; } if (obj.common.icon.indexOf('.') !== -1) { let instance; if (obj.type === 'instance') { icon = `${prefix}/adapter/${obj.common.name}/${obj.common.icon}`; } else if (obj._id && obj._id.match(/^system\.adapter\./)) { instance = obj._id.split('.', 3); if (obj.common.icon[0] === '/') { instance[2] += obj.common.icon; } else { instance[2] += `/${obj.common.icon}`; } icon = `${prefix}/adapter/${instance[2]}`; } else { instance = obj._id.split('.', 2); if (obj.common.icon[0] === '/') { instance[0] += obj.common.icon; } else { instance[0] += `/${obj.common.icon}`; } icon = `${prefix}/adapter/${instance[0]}`; } } else { return null; // '<i class="material-icons iob-list-icon">' + obj.common.icon + '</i>'; } } else { // base 64 image icon = obj.common.icon; } return icon; } /** * ioBroker ID needs to be subscribed, if entity is used in vis in order to update. * * @param id of ioBroker object * @param entity complete entity */ function addID2entity(id, entity) { if (!entity.context.ids) { entity.context.ids = []; } if (!entity.context.ids.includes(id)) { entity.context.ids.push(id); } } /** * Removes entity from cached storages or adjusts to new id. * * @param entity entity to remove * @param newId if set, will just replay entity_id. */ function removeEntity(entity, newId) { // need to process those: // entityData.entities = []; // entityData.entityId2Entity = {}; // entityData.iobID2entity = {}; // entityData._entityIconUrls = []; //stores icon urls that may be accessed without token. if (!entity) { return; } //entityId2Entity: if (newId) { entityData.entityId2Entity[newId] = entity; } else { delete entityData.entityId2Entity[entity.entity_id]; } //entities if (!newId) { let foundIndex = entityData.entities.findIndex(x => x.entity_id === entity.entity_id); while (foundIndex !== -1) { entityData.entities.splice(foundIndex, 1); foundIndex = entityData.entities.findIndex(x => x.entity_id === entity.entity_id); } } //_entityIconUrls if (!newId && entity.attributes.entity_picture) { const urlIndex = entityData.entityIconUrls.findIndex(x => x === entity.attributes.entity_picture); if (urlIndex !== -1) { entityData.entityIconUrls.splice(urlIndex, 1); } } //iobID2entity for (const key of Object.keys(entityData.iobID2entity)) { const entities = entityData.iobID2entity[key]; let foundIndex = entities.findIndex(x => x.entity_id === entity.entity_id); while (foundIndex !== -1) { entities.splice(foundIndex, 1); foundIndex = entities.findIndex(x => x.entity_id === entity.entity_id); } if (newId) { entities.push(newId); } } } /** * Find all entities that are affected by a change in an enum (or that are members of an enum, if oldEnum is empty) * * @param {ioBroker.Enum} newEnum updated enum * @param {ioBroker.Enum} [oldEnum] old version of enum * @returns {{ids: Array<string>, entities: Array<object>}} array of entities that are affected by the change. */ function findEntitiesFromEnumChange(newEnum, oldEnum) { const membersNew = newEnum && newEnum.common ? newEnum.common.members || [] : []; const membersOld = oldEnum && oldEnum.common ? oldEnum.common.members || [] : []; let entities = []; const ids = []; for (const id of membersNew) { if (!membersOld.includes(id)) { //new id! const affectedEntities = entityData.iobID2entity[id]; if (affectedEntities && affectedEntities.length) { entities = entities.concat(affectedEntities); } else { ids.push(id); } } } for (const id of membersOld) { if (!membersNew.includes(id)) { //removed id! const affectedEntities = entityData.iobID2entity[id]; if (affectedEntities && affectedEntities.length) { entities = entities.concat(affectedEntities); } else { ids.push(id); } } } return { ids, entities }; } /** * generate a bare entity from parameters. Already adds the entity to a bunch of arrays. So be sure to use it! * * @param {string|null} name friendly name of entity, if empty, will be read from object or generated from room & func or id. * @param {object|null} room room object of device for name generation * @param {object|null} func function object of device for name generation * @param {object} obj ioBroker object, used to read id, name, icon, unit, lovelace specific settings. * @param {string} entityType lovelace domain of entity, for example light, sensor, ... * @param [entity_id] predefined entity id. If empty, will be generated from name * @returns {{context: {id: string, type: string}, attributes: {friendly_name: string}, entity_id: string}} new entity object */ function processCommon(name, room, func, obj, entityType, entity_id) { const entity = { entity_id: getEntityId(entityType, entity_id, obj), plattform: entityType, attributes: { friendly_name: getFriendlyName(name, obj, getEnumName(room), getEnumName(func)), }, //make sure times are initialized: last_changed: 0, last_updated: 0, context: { id: obj._id, type: getEntityType(entityType, entity_id, obj), room: getEnumName(room), roomId: room ? room._id : null, func: getEnumName(func), funcId: func ? func._id : null, ids: [obj._id], stateType: obj.common?.type, deviceId: obj._id, aliases: getSmartName(obj, obj._id, entityData.lang)?.split(',') || [], }, }; if (obj.common && obj.common.unit) { entity.attributes.unit_of_measurement = obj.common.unit; } if (obj.common && obj.common.icon) { entity.attributes.entity_picture = _getObjectIcon(obj); } addID2entity(obj._id, entity); return entity; } /** * Fill entity functions from states Object -> allows users to add generic iobroker ids as attributes. Useful for additional info (currently, only). * * @param {Record<string, string>} states ids of iobroker states - "state" goes into entity-state, can also have stateRead. Rest creates attributes from key. * @param {object} entity entity to fill. */ function fillEntityFromStates(states, entity) { //state: entity.context.STATE = { setId: states.state || null, getId: states.stateRead || states.state || null }; //attributes: entity.context.ATTRIBUTES = entity.context.ATTRIBUTES || []; for (const key of Object.keys(states)) { const id = states[key]; if (id) { addID2entity(id, entity); if (!key.endsWith('Read')) { if (key !== 'state' && key !== 'stateRead') { const attr = entity.context.ATTRIBUTES.find(a => a.attribute === key); if (!attr) { entity.context.ATTRIBUTES.push({ attribute: key, getId: states[`${key}Read`] || id }); } } } } } } /** * Determine entity type if none set. * * @param {ioBroker.Object} obj ioBroker object to determine entity type for. * @returns {string} entity type */ function autoDetermineEntityType(obj) { if (obj.common) { if (obj.common.write) { if (obj.common.states) { return 'input_select'; } if (obj.common.role === 'date') { return 'input_datetime'; } if (obj.common.type === 'number') { return 'input_number'; } else if (obj.common.type === 'boolean') { return 'input_boolean'; } else { return 'input_text'; } } else { if (obj.common.type === 'boolean') { return 'binary_sensor'; } else { return 'sensor'; } } } else { return 'sensor'; //sensor can be everything. } } /** * Fill entity into entityData-entity caches. * * @param entity entity to fill into caches */ function fillEntityIntoCaches(entity) { const foundIndex = entityData.entities.findIndex(x => x.entity_id === entity.entity_id); if (foundIndex !== -1) { entityData.log.warn( `Got duplicate for entity ${entity.entity_id}. Overwriting old value. Was for ${entityData.entities[foundIndex].context.id} and new one is for ${entity.context.id}`, ); entityData.entities[foundIndex] = entity; } else { entityData.entities.push(entity); } entityData.entityId2Entity[entity.entity_id] = entity; for (const id of entity.context.ids) { entityData.iobID2entity[id] = entityData.iobID2entity[id] || []; const foundIndex = entityData.iobID2entity[id].findIndex(e => e.entity_id === entity.entity_id); if (foundIndex === -1) { entityData.iobID2entity[id].push(entity); } else { entityData.iobID2entity[id][foundIndex] = entity; } } } /** * Creates name of entity from "custom" part of ioBroker object. * * @param {ioBroker.Object} obj - ioBroker object with custom part * @param {string} namespace - namespace of adapter * @returns {string} entity id */ function createEntityNameFromCustom(obj, namespace) { if (!obj.common || !obj.common.custom || !obj.common.custom[namespace]) { const entityType = autoDetermineEntityType(obj); return getEntityId(entityType, null, obj); } else { if (!obj.common.custom[namespace].entity) { obj.common.custom[namespace].entity = autoDetermineEntityType(obj); } if (!obj.common.custom[namespace].name) { obj.common.custom[namespace].name = obj._id.replace(/\./g, '_'); } return `${obj.common.custom[namespace].entity}.${obj.common.custom[namespace].name}`; } } /** * Update timestamps in entity from state in ioBroker. * * @param entity {object} entity to update * @param state {ioBroker.State} state to update from * @param newTS {boolean} if true, will update lc and lu, if false, will update last_changed and last_updated. */ function updateTimestamps(entity, state, newTS = false) { let lc; let lu; if (!state) { //if we don't know anything, all is now. lc = Date.now(); lu = Date.now(); } else { lu = state.ts || Date.now(); lc = state.lc || state.ts || Date.now(); } try { const ts = new Date(lc).getTime(); if (isNaN(ts)) { throw 'Invalid Date'; } } catch (e) { entityData.adapter.log.debug(`Invalid lc time for ${state._id} in ${entity.entity_id}: ${e}`); lc = Date.now(); } try { const ts = new Date(lc).getTime(); if (isNaN(ts)) { throw 'Invalid Date'; } } catch (e) { entityData.adapter.log.debug(`Invalid lu time for ${state._id} in ${entity.entity_id}: ${e}`); lu = Date.now(); } let lcToCheck = 'last_changed'; let luToCheck = 'last_updated'; if (newTS) { lcToCheck = 'lc'; luToCheck = 'lu'; } if ( lc / 1000 > entity[lcToCheck] || isNaN(entity[lcToCheck]) || new Date(entity[lcToCheck] * 1000).toString() === 'Invalid Date' ) { entity[lcToCheck] = lc / 1000; } if ( lu / 1000 > entity[luToCheck] || isNaN(entity[luToCheck]) || new Date(entity[luToCheck] * 1000).toString() === 'Invalid Date' ) { entity[luToCheck] = lu / 1000; } } //required to read user name exports.getEnumName = getEnumName; //required by other converters: exports.processCommon = processCommon; exports.addID2entity = addID2entity; exports.fillEntityFromStates = fillEntityFromStates; //other tools: exports.createObjectsFromArrayOfIds = createObjectsFromArrayOfIds; exports.createEntityNameFromCuston = createEntityNameFromCustom; exports.autoDetermineEntityType = autoDetermineEntityType; exports.setJsonAttribute = setJsonAttribute; exports.getSmartName = getSmartName; exports.fillEntityIntoCaches = fillEntityIntoCaches; exports.extractValidEntityIds = extractValidEntityIds; //used to manage subscriptions. exports.updateTimestamps = updateTimestamps; //used for intelligent update: exports.removeEntity = removeEntity; exports.findEnumForId = findEnumForId; exports.findEntitiesFromEnumChange = findEntitiesFromEnumChange;