UNPKG

iobroker.e3oncan

Version:

Collect data on CAN bus for Viessmann E3 devices, e.g. Vitocal, Vitocharge, Energy Meters E380CA and E3100CB

869 lines (838 loc) 34.4 kB
const E3 = require('./codecs'); const E3DidsDict = require('./didsE3.json'); const E380DidsDict = require('./didsE380.json'); const E3100CBDidsDict = require('./didsE3100CB.json'); const enums = require('./enums'); /** * Perform data storage for specific DID from and to ioBroker objects data base */ class storageDids { /** * @param {object} config Device UDS worker configuration */ constructor(config) { this.config = config; this.didsWritablesId = 'udsDidsWritable'; this.didsCommonId = 'udsDidsCommon'; this.didsSpecId = 'udsDidsSpecific'; this.didsMetaDataId = 'udsDidsMetaData'; this.didsE380Id = 'didsE380'; this.didsE3100cbId = 'didsE3100CB'; this.didsWritable = {}; this.didsDictE3 = {}; // Common dids imported from project open3e this.didsDictDevCom = {}; // Dids of this device matching the E3 common list this.didsDictDevSpec = {}; // Dids specific for this device this.dids = {}; // Consolidated list of dids available for this device this.didsDevSpecAvail = false; // true, if device specific dids are available this.didsMetaDict = {}; // Colloction of meta data od dids, e.g. unit, descriptiom this.didsScanDone = false; // true, if a data point scan has been performed this.imperialStr = String(enums.enums['Units']['1']); } /** * Setup states in ioBroker object tree * * @param {object} ctx Caller context * @param {string} opMode Initial mode of operation of device worker */ async initStates(ctx, opMode) { if (['standby', 'normal', 'udsDidScan', 'service77'].includes(opMode)) { switch (this.config.device) { case 'e380': await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsE380Id}`, { type: 'state', common: { name: `${this.config.stateBase} all available datapoints`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); break; case 'e3100cb': await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsE3100cbId}`, { type: 'state', common: { name: `${this.config.stateBase} all available datapoints`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); break; default: await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsWritablesId}`, { type: 'state', common: { name: `${this.config.stateBase} list of datapoints writable via WriteByDid`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsCommonId}`, { type: 'state', common: { name: `${this.config.stateBase} all available datapoints`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsSpecId}`, { type: 'state', common: { name: `${this.config.stateBase} available datapoints specific to this device`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.didsMetaDataId}`, { type: 'state', common: { name: `${this.config.stateBase} meta data of dids of this device`, role: 'state', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); } } } /** * Merge common and device specific DIDs to one object * * @param {object} didsCommon Caller context * @param {object} didsDevSpecific Initial mode of operation */ async mergeDids(didsCommon, didsDevSpecific) { const dids = await JSON.parse(JSON.stringify(didsCommon)); for (const [id, did] of Object.entries(didsDevSpecific)) { if (id != 'Version') { dids[id] = did; } } return dids; } /** * Return known DIDs from data base * * @param {object} ctx Caller context * @param {string} opMode Initial mode of operation */ async readKnownDids(ctx, opMode) { // Read common and device specific dids list known from dids scan // If scanned dids are avalilable: return complete list of dids for this device // else return list of common dids for all devices and list of writable dids switch (this.config.device) { case 'e380': try { const baseId = `${this.config.stateBase}.info.`; this.didsWritable = {}; this.didsDictDevCom = await JSON.parse((await ctx.getStateAsync(baseId + this.didsE380Id)).val); this.didsDictDevSpec = {}; } catch { this.didsWritable = {}; this.didsDictDevCom = JSON.parse(JSON.stringify(E380DidsDict)); this.didsDictDevSpec = {}; } if (Object.keys(this.didsDictDevCom).length == 0) { // No dids scan results available yet this.didsDictDevCom = JSON.parse(JSON.stringify(E380DidsDict)); this.didsDictDevCom.Version = '20240218'; // First available version } if (this.didsDictDevCom.Version < '20240320') { // Add data points for E380 on CAN address=98 (available since version 20240320): ctx.log.info('Adding support for energy meter E380 with CAN-address=98'); this.didsDictDevCom = await this.mergeDids(this.didsDictDevCom, E380DidsDict); } this.didsDevSpecAvail = true; // Dids are available even on first start this.dids = await this.mergeDids(this.didsDictDevCom, this.didsDictDevSpec); break; case 'e3100cb': try { const baseId = `${this.config.stateBase}.info.`; this.didsWritable = {}; this.didsDictDevCom = await JSON.parse((await ctx.getStateAsync(baseId + this.didsE3100cbId)).val); this.didsDictDevSpec = {}; } catch { this.didsWritable = {}; this.didsDictDevCom = JSON.parse(JSON.stringify(E3100CBDidsDict)); this.didsDictDevSpec = {}; } if (Object.keys(this.didsDictDevCom).length == 0) { // No dids scan results available yet this.didsDictDevCom = JSON.parse(JSON.stringify(E3100CBDidsDict)); } this.didsDevSpecAvail = true; // Dids are available even on first start this.dids = await this.mergeDids(this.didsDictDevCom, this.didsDictDevSpec); break; default: if (opMode != 'udsDevScan') { try { const baseId = `${this.config.stateBase}.info.`; this.didsWritable = await JSON.parse( (await ctx.getStateAsync(baseId + this.didsWritablesId)).val, ); this.didsDictDevCom = await JSON.parse( (await ctx.getStateAsync(baseId + this.didsCommonId)).val, ); this.didsDictDevSpec = await JSON.parse( (await ctx.getStateAsync(baseId + this.didsSpecId)).val, ); this.didsDevSpecAvail = true; this.didsMetaDict = await JSON.parse( (await ctx.getStateAsync(baseId + this.didsMetaDataId)).val, ); } catch { // Device specific data not available yet this.didsWritable = {}; this.didsDictDevCom = E3DidsDict; this.didsDictDevSpec = {}; this.didsDevSpecAvail = false; this.didsMetaDict = {}; } if (Object.keys(this.didsDictDevCom).length == 0) { // No dids scan results available yet this.didsDictDevCom = E3DidsDict; } } else { // UDS device scan this.didsWritable = {}; this.didsDictDevCom[ctx.udsDidForScan] = E3DidsDict[ctx.udsDidForScan]; this.didsDictDevCom[ctx.udsDidForUnits] = E3DidsDict[ctx.udsDidForUnits]; this.didsDictDevSpec = {}; this.didsMetaDict = {}; } if (opMode == 'udsDidScan') { this.didsDictDevCom = {}; this.didsMetaDict = {}; this.dids = E3DidsDict; } else { this.dids = await this.mergeDids(this.didsDictDevCom, this.didsDictDevSpec); } } this.didsScanDone = Object.keys(this.didsWritable).length > 0; } /** * Store known DIDs to data base * * @param {object} ctx Caller context */ async storeKnownDids(ctx) { const baseId = `${this.config.stateBase}.info.`; switch (this.config.device) { case 'e380': await ctx.setStateAsync(baseId + this.didsE380Id, { val: JSON.stringify(this.didsDictDevCom), ack: true, }); break; case 'e3100cb': await ctx.setStateAsync(baseId + this.didsE3100cbId, { val: JSON.stringify(this.didsDictDevCom), ack: true, }); break; default: await ctx.setStateAsync(baseId + this.didsWritablesId, { val: JSON.stringify(this.didsWritable), ack: true, }); await ctx.setStateAsync(baseId + this.didsCommonId, { val: JSON.stringify(this.didsDictDevCom), ack: true, }); await ctx.setStateAsync(baseId + this.didsSpecId, { val: JSON.stringify(this.didsDictDevSpec), ack: true, }); await ctx.setStateAsync(baseId + this.didsMetaDataId, { val: JSON.stringify(this.didsMetaDict), ack: true, }); } } /** * Convert integer to hex string of length 2 * * @param {number} d integer */ toHex(d) { return `00${Number(d).toString(16)}`.slice(-2); } /** * Convert byte array to hex string * * @param {Array} arr byte array */ arr2Hex(arr) { let hs = ''; for (const v in arr) { hs += this.toHex(arr[v]); } return hs; } /** * Convert hex string, e.g. '21A8' to byte array: [33,168] * * @param {string} hs hex string */ toByteArray(hs) { const ba = []; for (let i = 0; i < hs.length / 2; i++) { ba.push(parseInt(hs.slice(2 * i, 2 * i + 2), 16)); } return ba; } /** * Return DID as string * * @param {string} did DID */ getDidStr(did) { let didStr = ''; if (this.config.device == 'e3100cb') { didStr = String(did); } else { didStr = `000${String(did)}`; didStr = didStr.slice(-4); } return didStr; } /** * Return structure of definition of DID * * @param {object} ctx Caller context * @param {Array} didStruct structure of DID * @param {object} obj remaining part to be evaluated */ async getDidStruct(ctx, didStruct, obj) { try { if (typeof obj == 'object') { if (obj.codec) { await didStruct.push([obj.codec, obj.len]); } if (Object.keys(obj).length <= 100) { for (const itm of Object.values(obj)) { if (itm.codec) { await didStruct.push([itm.codec, itm.len]); } await this.getDidStruct(ctx, didStruct, itm); } } else { ctx.log.error(`Did valuation aborted. Too many members (${String(Object.keys(obj).length)})`); } } return didStruct; } catch (e) { ctx.log.error(`Evaluation of did ${JSON.stringify(didStruct)} failed. err=${e.message}`); } } /** * Return parsed content of state * * @param {object} ctx Caller context * @param {string} stateId id of requested state */ async getObjectVal(ctx, stateId) { let val = null; try { val = await JSON.parse((await ctx.getStateAsync(stateId)).val); } catch (e) { ctx.log.silly(`Reading of did ${stateId} failed. err=${e.message}`); } return val; } /** * Store content of DID to data base * * @param {object} ctx Caller context * @param {string} did DID * @param {string} idStr DID string * @param {string} stateId id of affected state * @param {object} obj DIDs content * @param {string} type Type of content (number or object) * @param {string} role role of object * @param {boolean} forceExtendObject Force to override object data */ async storeObject(ctx, did, idStr, stateId, obj, type, role, forceExtendObject = false) { try { if (ctx.suppressStateStorage && !(await ctx.objectExists(stateId))) { // During scan using suppressStateStorage=true only store (override) existing states // This is to force metadata updates, if any return; } if (forceExtendObject || !(await ctx.objectExists(stateId))) { // Override object properties, e.g. data type let metaData = { desc: '', unit: '' }; metaData = await this.getMetaData(this.didsMetaDict, stateId); await ctx.extendObject(stateId, { type: 'state', common: { name: idStr, type: type, role: role, read: true, write: true, unit: metaData.unit ?? '', desc: metaData.desc ?? '', }, native: {}, }); } if (type == 'number') { await ctx.setStateAsync(stateId, obj, true); } else { await ctx.setStateAsync(stateId, JSON.stringify(obj), true); } } catch (e) { ctx.log.error(`Storing of did ${stateId}.${String(did)} failed. err=${e.message}`); } } /** * Store content of DID to data base using json format * * @param {object} ctx Caller context * @param {string} did DID * @param {string} idStr DID string * @param {string} stateId id of affected state * @param {object} obj DIDs content * @param {boolean} forceExtendObject Force to override object data */ async storeObjectJson(ctx, did, idStr, stateId, obj, forceExtendObject = false) { await this.storeObject(ctx, did, idStr, stateId, obj, 'string', 'json', forceExtendObject); } /** * Store meta data of a codec element to dictionary * * @param {object} idDict Dict of meta data of codec * @param {string} key Dict key */ async getMetaData(idDict, key) { const didKey = key.replace(/TopologyElement\.\d*/, 'TopologyElement').replace(/\.json|\.tree|\.raw/, ''); if (didKey in idDict) { return idDict[didKey]; } return { desc: '', unit: '' }; } /** * Store meta data of a codec element to dictionary * * @param {object} idDict Dict of meta data of codec * @param {object} cdc Element of codec * @param {string} key Dict key * @param {string} unitsStr UnitAndFormats configImperialuration string of device */ async storeMetaData(idDict, cdc, key, unitsStr) { idDict[key] = {}; idDict[key]['desc'] = cdc.desc; if ('unit' in cdc) { let unit = cdc.unit; if (unit == '°C' && unitsStr && unitsStr.includes(this.imperialStr)) { unit = '°F'; // Devices uses imperial units => use °F instead of °C } idDict[key]['unit'] = unit; } if ('acc' in cdc) { idDict[key]['acc'] = cdc.acc; } } /** * Collect meta data of a codec to dictionary * * @param {object} didInfo Codec * @param {string} key Dict key * @param {string} unitsStr UnitAndFormats configuration string of device */ async setupCodecById(didInfo, key, unitsStr) { if (Array.isArray(didInfo)) { for (const e of didInfo.values()) { await this.storeMetaData(this.didsMetaDict, e.args, `${key}${e.id}`, unitsStr); if ('subTypes' in e.args) { await this.setupCodecById(e.args.subTypes, `${key}${e.id}.`, unitsStr); } } } else { await this.storeMetaData(this.didsMetaDict, didInfo.args, `${key}${didInfo.id}`, unitsStr); if ('subTypes' in didInfo.args) { await this.setupCodecById(didInfo.args.subTypes, `${key}${didInfo.id}.`, unitsStr); } } } /** * Store content of DID to data base using tree format, i.e. splitting object down to single elements * * @param {object} ctx Caller context * @param {string} did DID * @param {string} idStr DID string * @param {string} stateId id of affected state * @param {object} obj DIDs content * @param {boolean} forceExtendObject Force to override object data */ async storeObjectTree(ctx, did, idStr, stateId, obj, forceExtendObject = false) { if (typeof obj == 'object') { if (Object.keys(obj).length <= 100) { for (const [key, itm] of Object.entries(obj)) { await this.storeObjectTree( ctx, did, idStr, `${String(stateId)}.${String(key).replace(ctx.FORBIDDEN_CHARS, '_').replace('.', '_')}`, itm, forceExtendObject, ); // No FORBIDDEN_CHARS and no '.' in state id allowed } } else { ctx.log.error( `Did valuation aborted. Too many members (${String(Object.keys(obj).length)}) ${stateId}.${String( did, )}`, ); } } else { const type = typeof obj === 'number' ? 'number' : 'string'; await this.storeObject(ctx, did, idStr, stateId, obj, type, 'state', forceExtendObject); } } /** * Store content of DID to data base using tree format, i.e. splitting object down to single elements * * @param {object} ctx Caller context * @param {object} ctxWorker Worker context * @param {string} did DID * @param {object} cdi contect of codec * @param {object} data raw data */ async decodeDid(ctx, ctxWorker, did, cdi, data) { let codec; const res = {}; try { codec = await new E3.O3Ecodecs[cdi.codec]( cdi.len, cdi.id, cdi.args, ctxWorker.config.devUnits == 'n/a' ? ctx.udsMasterDevUnits : ctxWorker.config.devUnits, ); } catch (e) { ctx.log.warn(`Could not retreive codec for ${ctxWorker.config.stateBase}.${String(did)}. err=${e.message}`); codec = 'RawCodec'; } // No FORBIDDEN_CHARS and no '.' in state allowed: res.idStr = cdi.id.replace(ctx.FORBIDDEN_CHARS, '_').replace('.', '_'); try { res.val = await codec.decode(data); } catch (e) { res.val = this.arr2Hex(data); ctx.log.warn(`Codec failed: ${ctxWorker.config.stateBase}.${String(did)}. err=${e.message}`); } return res; } } /** * Perform data storage from and to ioBroker objects data base */ class storage { /** * @param {object} config Device UDS worker configuration */ constructor(config) { this.config = config; this.storageDids = new storageDids({ stateBase: this.config.stateBase, device: this.config.device, }); this.opModes = ['standby', 'udsDevScan', 'udsDidScan', 'normal', 'TEST', 'service77']; this.opMode = this.opModes[0]; this.udsScanResult = null; } /** * Setup states in ioBroker object tree * * @param {object} ctx Caller context * @param {string} opMode Initial mode of operation of device worker */ async initStates(ctx, opMode) { await this.setOpMode(opMode); if (opMode != 'udsDevScan') { await ctx.setObjectNotExistsAsync(this.config.stateBase, { type: 'device', common: { name: this.config.stateBase, }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info`, { type: 'channel', common: { name: `${this.config.stateBase} informations`, }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.info.${this.config.statId}`, { type: 'state', common: { name: `${this.config.stateBase} statistics about communication`, role: 'info.status', type: 'json', read: true, write: true, def: JSON.stringify({}), }, native: {}, }); await this.storageDids.initStates(ctx, opMode); if (opMode != 'service77') { await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.json`, { type: 'channel', common: { name: `${this.config.stateBase} JSON`, }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.tree`, { type: 'channel', common: { name: `${this.config.stateBase} TREE`, }, native: {}, }); await ctx.setObjectNotExistsAsync(`${this.config.stateBase}.raw`, { type: 'channel', common: { name: `${this.config.stateBase} RAW`, }, native: {}, }); } } await this.storageDids.readKnownDids(ctx, opMode); } /** * Set operation mode * * @param {string} mode Mode of operation of device worker */ async setOpMode(mode) { if (this.opModes.includes(mode)) { this.opMode = mode; } } /** * Return current operation mode */ async getOpMode() { return this.opMode; } /** * Store statistical data to data base * * @param {object} ctx Adapter context * @param {object} ctxWorker Worker context * @param {boolean} forceStore Force storage */ async storeStatistics(ctx, ctxWorker, forceStore) { if (['standby', 'normal', 'udsDidScan', 'service77'].includes(await this.getOpMode())) { const ts = new Date().getTime(); if (!forceStore && ts < ctxWorker.stat.nextTs) { return; } // Min. time step not reached. Do not store. if (ctxWorker.stat) { ctx.setStateAsync( `${ctxWorker.config.stateBase}.info.${this.config.statId}`, JSON.stringify(ctxWorker.stat), true, ); } ctxWorker.stat.nextTs = ts + ctxWorker.stat.tsMinStep; } } /** * Encode data for given did, return raw data * * @param {object} ctx Caller context * @param {object} ctxWorker Worker context * @param {string} did DID * @param {object} data raw data */ async encodeDataCAN(ctx, ctxWorker, did, data) { // Encode data for given did let val; if (did in this.storageDids.dids) { const cdi = this.storageDids.dids[did]; // Infos about did codec try { const codec = await new E3.O3Ecodecs[cdi.codec]( cdi.len, cdi.id, cdi.args, ctxWorker.config.devUnits == 'n/a' ? ctx.udsMasterDevUnits : ctxWorker.config.devUnits, ); val = await codec.encode(data); } catch (e) { await ctx.log.warn( `encodeDataCAN(): Could not encode data for ${ctxWorker.config.stateBase}.${String(did)}. err=${ e.message }`, ); val = null; } } else { await ctx.log.warn(`encodeDataCAN(): Did not found for ${ctxWorker.config.stateBase}.${String(did)}`); val = null; } return val; } /** * Decode CAN data for given did * * @param {object} ctx Caller context * @param {object} ctxWorker Worker context * @param {string} did DID * @param {Array} data raw data */ async decodeDataCAN(ctx, ctxWorker, did, data) { if (this.opMode == this.opModes[0]) { return; } const raw = this.storageDids.arr2Hex(data); let idStr, val, common, cdi; if (did in this.storageDids.dids) { cdi = this.storageDids.dids[did]; // Infos about did codec if (cdi.len == data.length) { const res = await this.storageDids.decodeDid(ctx, ctxWorker, did, cdi, data); idStr = res.idStr; val = res.val; common = true; } else { // did length is diffetent from common did length => device specific did idStr = 'DeviceSpecific'; val = raw; common = false; } } else { idStr = 'DeviceSpecific'; val = raw; common = false; } if (val != null) { let didStr; let stateIdJson; let stateIdTree; let stateIdRaw; didStr = this.storageDids.getDidStr(did); stateIdJson = `${this.config.stateBase}.json.${didStr}_${idStr}`; stateIdTree = `${this.config.stateBase}.tree.${didStr}_${idStr}`; stateIdRaw = `${this.config.stateBase}.raw.${didStr}_${idStr}`; let didInfo; switch (this.opMode) { case this.opModes[0]: // 'standby' break; case this.opModes[1]: // 'udsDevScan' this.udsScanResult = { did: did, didInfo: { id: idStr, len: data.length, }, val: val, common: common, }; if (ctxWorker.callback) { await ctxWorker.callback(ctx, ctxWorker, ['ok', this.udsScanResult]); } break; case this.opModes[2]: // 'udsDidScan' if (common) { didInfo = { id: idStr, len: cdi.len, codec: cdi.codec, args: cdi.args, }; } else { didInfo = { id: idStr, len: data.length, codec: 'RawCodec', args: {}, }; } this.udsScanResult = { did: did, didInfo: didInfo, val: val, common: common, }; if (ctxWorker.callback) { await ctxWorker.callback(ctx, ctxWorker, ['ok', this.udsScanResult]); // Redo decoding to take care on variant dids: const rescb = await this.storageDids.decodeDid( ctx, ctxWorker, did, this.udsScanResult.didInfo, data, ); idStr = await rescb.idStr; val = await rescb.val; didStr = this.storageDids.getDidStr(did); stateIdJson = `${this.config.stateBase}.json.${didStr}_${idStr}`; stateIdTree = `${this.config.stateBase}.tree.${didStr}_${idStr}`; stateIdRaw = `${this.config.stateBase}.raw.${didStr}_${idStr}`; } // Use the scan result didInfo (may have been updated by callback for variant DIDs) // and ensure the id matches the sanitized idStr used for the state paths: didInfo = { ...this.udsScanResult.didInfo, id: idStr }; await this.storageDids.setupCodecById( didInfo, `${this.config.stateBase}.${didStr}_`, await (!this.config.devUnits || this.config.devUnits == 'n/a' ? ctx.udsMasterDevUnits : this.config.devUnits), // Use udsMasterDevUnits if config.devUnits equals 'n/a' or is undefined ); // Add dids meta data to dictionary await this.storageDids.storeObjectTree(ctx, did, idStr, stateIdTree, val, true); await this.storageDids.storeObjectJson(ctx, did, idStr, stateIdJson, val, true); await this.storageDids.storeObjectJson(ctx, did, idStr, stateIdRaw, raw, true); await this.storeStatistics(ctx, ctxWorker, false); break; case this.opModes[3]: // 'normal' await this.storageDids.storeObjectTree(ctx, did, idStr, stateIdTree, val); await this.storageDids.storeObjectJson(ctx, did, idStr, stateIdJson, val); await this.storageDids.storeObjectJson(ctx, did, idStr, stateIdRaw, raw); await this.storeStatistics(ctx, ctxWorker, false); break; case this.opModes[4]: // 'TEST' return val; case this.opModes[5]: // 'service77' - user specific mode for writeDataByIdentifier await this.storeStatistics(ctx, ctxWorker, true); break; default: ctx.log.error('Invalid opMode at class storage. Change to "standby"'); this.opMode = this.opModes[0]; } } } } module.exports = { storageDids, storage, };