UNPKG

iobroker.lovelace

Version:

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

455 lines (432 loc) 21.9 kB
const { WebSocket } = require('ws'); const iobState2EntityState = require('../converters/genericConverter').iobState2EntityState; const updateTimestamps = require('../entities/utils').updateTimestamps; /** * getHistory from history instances. * * @param adapter app adapter * @param {Array<object | undefined>} entities entities that are queried. * @param {number} start javascript timestamp * @param {number} end javascript timestamp * @param {boolean|undefined} noAttributes if attributes should also be gathered or not. * @param {string} user user id of iobroker user to use for ACL tests. * @returns {Promise<*[]>} array of history data for each entity. */ async function getHistory(adapter, entities, start, end, noAttributes, user) { const totalResult = {}; if (!adapter.config.history) { adapter.log.warn(`History instance is not selected in the settings`); } else { for (const entity of entities) { if (typeof entity === 'object') { const id = entity.context.STATE.getId || entity.context.STATE.setId; try { let stateResult; const options = { start, end, count: adapter.config.historyMaxCount, aggregate: 'onchange', user, }; if (id) { stateResult = await adapter.sendToAsync(adapter.config.history, 'getHistory', { id, options, }); } else { stateResult = { result: [] }; } const attributesResult = {}; //don't get attributes results if not requested. if (!noAttributes && entity.context.ATTRIBUTES) { for (const attribute of entity.context.ATTRIBUTES) { const id = attribute.getId || attribute.setId; if (!noAttributes && id) { attributesResult[attribute.attribute] = await adapter.sendToAsync( adapter.config.history, 'getHistory', { id, options }, ); } else { attributesResult[attribute.attribute] = { result: [] }; } } } /** * match attributes to states by ts: * * @param state {ioBroker.State} state to get attribute values for. * @param attributesResult {object} result of attribute history. * @param attributesUsed {Array<string>} list of attributes already used. * @param attributeValues {object} object to fill with attribute values. * @returns {{}} object with attribute values. */ const getAttributeValues = (state, attributesResult, attributesUsed = [], attributeValues = {}) => { const ts = state.ts; if (entity.context.ATTRIBUTES) { for (const attribute of entity.context.ATTRIBUTES) { if (attributesUsed.indexOf(attribute.attribute) >= 0) { continue; //already found value for that attribute. } let found = false; let best = null; let bestDiff = 10000; const results = attributesResult[attribute.attribute].result; for (const result of results) { if (result.val !== null) { const diff = Math.abs(result.ts - ts); if (diff < bestDiff) { best = result; bestDiff = diff; found = true; } } } if (found) { attributeValues[attribute.attribute] = typeof attribute.historyParser === 'function' ? attribute.historyParser(id, best.val) : best.val; best.used = true; //will be checked later, all "unused" attributes will be added without state later. } /*else { //let's try to leave attribute empty, for now, if no value in history that is closer than 10 seconds. attributeValues[attribute.attribute] = entity.attributes[attribute.attribute]; //use current value as default if none found. }*/ } } for (const key of Object.keys(entity.attributes)) { if ( !entity.context.ATTRIBUTES || !entity.context.ATTRIBUTES.find(a => a.attribute === key) ) { attributeValues[key] = entity.attributes[key]; //make sure to copy static attributes here. } } return attributeValues; }; //console.log(JSON.stringify(stateResult.result)); const historyPerEntity = []; const attributesUsed = []; for (const e of stateResult.result) { const result = { s: typeof entity.context.STATE.historyParser === 'function' ? entity.context.STATE.historyParser(id, e.val).toString() : iobState2EntityState(entity, e.val), a: noAttributes ? {} : getAttributeValues(e, attributesResult, attributesUsed), lc: 1, lu: 1, }; updateTimestamps(result, e, true); historyPerEntity.push(result); } //add unused attribute values: if (!noAttributes && entity.context.ATTRIBUTES) { for (const attribute of entity.context.ATTRIBUTES) { //find other attributes for this type. Will use this attribute as "state" for the getAttributeValues function. // so we don't want to find this attribute again. Will add all matching attributes anyway. So in later runs this attribute will be "empty", i.e. all used. attributesUsed.push(attribute.attribute); const results = attributesResult[attribute.attribute].result; for (const result of results) { if (!result.used) { const attributeValues = {}; attributeValues[attribute.attribute] = typeof attribute.historyParser === 'function' ? attribute.historyParser(id, result.val) : result.val; result.used = true; //fill in other attributes: const data = { //state: null, lc: 1, lu: 1, a: noAttributes ? {} : getAttributeValues( result, attributesResult, attributesUsed, attributeValues, ), }; updateTimestamps(data, result, true); historyPerEntity.push(data); } } } } //make sure there is at least one in there, frontend crashes otherwise: if (historyPerEntity.length === 0) { historyPerEntity.push({ s: entity.state, a: {}, lu: start / 1000, }); } totalResult[entity.entity_id] = historyPerEntity; } catch (e) { adapter.log.warn(`Could not get history for ${entity.entity_id} - Error in request: ${e}`); //totalResult[entity.entity_id] = []; } } else { adapter.log.warn(`Cannot get history - Unknown entity: ${entity}`); //if no object -> entity is just the entity_id. //totalResult[entity] = []; } } } return totalResult; } /** * send history response to the client that requested them * * @param ws websocket connection of the client * @param id {number} message id * @param historyData {object} history data to send * @param parameters {object} parameters of the request */ function sendHistoryResponse(ws, id, historyData, parameters) { if (!historyData) { historyData = {}; for (const entityId of parameters.entityIds) { historyData[entityId] = []; } } let startTime = parameters.startTime / 1000; let endTime = startTime; for (const stateArray of Object.values(historyData)) { for (const state of stateArray) { if (state.lu < startTime) { startTime = state.lu; } if (state.lu > endTime) { endTime = state.lu; } if (state.s === null || state.s === undefined) { state.s = 'unknown'; } /*if (parameters.noAttributes) { delete state.a; } if (parameters.minimalResponse) { delete state.lc; }*/ } } const response = { id: Number(id), type: 'event', event: { states: historyData, }, start_time: startTime, end_time: endTime, }; //console.log('Sending history message for ' + JSON.stringify(parameters)); //console.log(JSON.stringify(response, null, 2)); ws.send(JSON.stringify(response)); } /** * convert .ts of state e to ISOString with try/catch and now as fallback. * * @param {ioBroker.state} e state to convert * @returns {string} ISOString of the state timestamp */ function _convertStateTStoISOString(e) { try { return new Date((e.lu || e.lc) * 1000).toISOString(); } catch (error) { this.log.debug(`Could not convert state timestamp to ISOString: ${error}`); return new Date().toISOString(); } } /** * History module to handle history requests. */ class HistoryModule { /** * Create a new history module. * * @param options {object} options with adapter, entityData, and personModule. */ constructor(options) { this.adapter = options.adapter; this.entityData = options.entityData; this.personModule = options.personModule; } /** * Process a request from the frontend to the history api. * * @param req {object} request object * @param res {object} response object * @returns {Promise<void>} resolves when request is processed. */ async processRequest(req, res) { //this.adapter.log.debug(`Get history for ${req.query.filter_entity_id} from ${req.params.start} to ${req.query.end_time} LEGACY`); const entityIDs = req.query.filter_entity_id.split(',').map(id => id.trim()); const entities = []; for (const id of entityIDs) { const entity = this.entityData.entityId2Entity[id]; entities.push(entity || id); } const newResult = await getHistory( this.adapter, entities, new Date(req.params.start).getTime(), new Date(req.query.end_time).getTime(), req.query.noAttributes, this.personModule.getUserIDFromName(req._user), ); const oldResult = []; for (const [entity_id, states] of Object.entries(newResult)) { const entityResult = []; for (const state of states) { const ts = _convertStateTStoISOString(state); entityResult.push({ entity_id, state: String(state.s), last_changed: ts, last_updated: ts, attributes: state.a, }); } oldResult.push(entityResult); } //console.log('Legacy history result: ' + JSON.stringify(oldResult, null, 2)); res.json(oldResult); } //{"type":"history/history_during_period","start_time":"2022-07-08T08:09:12.022Z","end_time":"2022-07-08T09:09:12.022Z","significant_changes_only":false,"include_start_time_state":true,"minimal_response":true,"no_attributes":true,"entity_ids":["binary_sensor.TestFeuerAlarm"],"id":29} /** * Process a history message from the frontend. * * @param ws websocket connection of the client * @param message {object} message to process * @returns {Promise<boolean>} resolves with true if message was processed. */ async processMessage(ws, message) { if (message.type && message.type.startsWith('history/')) { //maybe handle these parameters, too: //include_start_time_state true/false //minimal_response true/false (?) //significant_changes_only true/false let parameters; if (message.type === 'history/stream') { // new: // {"type":"history/stream","entity_ids":["input_boolean.testschalter"],"start_time":"2023-07-04T18:10:49.546Z","minimal_response":true,"significant_changes_only":true,"no_attributes":true,"id":38} // -> is subscription!!! -> send confirmation of subscription to client here: ws.send(JSON.stringify({ id: Number(message.id), type: 'result', success: true, result: null })); //say that subscription was successfull. //keep subscription and id: parameters = { entityIds: message.entity_ids, startTime: new Date(message.start_time).getTime(), minimalResponse: message.minimal_response, significantChangesOnly: message.significant_changes_only, noAttributes: message.no_attributes, id: Number(message.id), }; // add subscription here. ws._subscribes.history = ws._subscribes.history || []; ws._subscribes.history.push(parameters); } else if (message.type === 'history/history_during_period') { //same, but no subscription: parameters = { entityIds: message.entity_ids, startTime: new Date(message.start_time).getTime(), minimalResponse: message.minimal_response, significantChangesOnly: message.significant_changes_only, noAttributes: message.no_attributes, id: Number(message.id), }; } else { this.adapter.log.warn(`Unknown history message type: ${message.type}`); } if (!this.adapter.config.history) { this.adapter.log.warn(`History instance is not selected in the settings -> logbook won't work`); sendHistoryResponse(ws, message.id, null, parameters); return true; } //console.log('Getting history ' + JSON.stringify(message)); const entities = []; for (const id of parameters.entityIds) { const entity = this.entityData.entityId2Entity[id]; entities.push(entity || id); } const historyData = await getHistory( this.adapter, entities, parameters.startTime, Date.now(), parameters.noAttributes, this.personModule.getUserIDFromName(ws.__auth?.username), ); sendHistoryResponse(ws, message.id, historyData, parameters); return true; } return false; } /** * Process a state change and send it to the clients that are subscribed to history. * * @param {string} id state id * @param {ioBroker.state|null} state new state or null if deleted * @param websocketServer {WebSocket.Server} websocket server to send updates to. */ onStateChange(id, state, websocketServer) { if (state) { //check if the state update needs to be added to any logbook: if (websocketServer) { for (const client of websocketServer.clients) { if (client._subscribes.history && client.readyState === WebSocket.OPEN) { for (const parameters of client._subscribes.history) { const states = {}; //found a client with active subscription -> update. //this.adapter.log.debug('History subscription ' + msgId + ' found.'); const entityIdsAffected = this.entityData.iobID2entity[id] || []; for (const entity of entityIdsAffected) { if (parameters.entityIds.includes(entity.entity_id)) { this.adapter.log.debug( `History subscription ${parameters.id} is for right entity, sending update.`, ); if (id === entity.context.STATE.getId || id === entity.context.STATE.setId) { states[entity.entity_id] = [ { s: typeof entity.context.STATE.historyParser === 'function' ? entity.context.STATE.historyParser(id, state?.val).toString() : iobState2EntityState(entity, state?.val), lu: (state?.ts ? state.ts : Date.now()) / 1000, }, ]; } else if (!parameters.noAttributes) { //add attributes: for (const attribute of entity.context.ATTRIBUTES) { if (id === attribute.getId || id === attribute.setId) { //use state from entity if attribute did change. states[entity.entity_id] = states[entity.entity_id] || [ { s: entity.state !== undefined ? entity.state : 'unknown', lu: (state?.ts ? state.ts : Date.now()) / 1000, a: {}, }, ]; states[entity.entity_id][0].a = states[entity.entity_id][0].a || {}; states[entity.entity_id][0].a[attribute.attribute] = typeof attribute.historyParser === 'function' ? attribute.historyParser(id, state?.val) : state?.val; } } } } } if (Object.keys(states).length > 0) { sendHistoryResponse(client, parameters.id, states, parameters); } } } } } } } } module.exports = HistoryModule;