const { getFormatter } = require('./formatters'); //this class will handle the output events for the node red node class OutputUtils { // `options.alwaysEmit` is an optional list of field keys that bypass delta // compression: they are re-emitted on every tick even when unchanged. Use it // sparingly for slowly-varying values that must still trace as a continuous // line downstream (e.g. a pump's realized control position `ctrl`, which sits // constant in steady state and otherwise produces ~1 point per long stretch — // invisible in a Grafana timeseries with createEmpty:false). Defaults to none, // so existing nodes keep pure delta-compression behaviour. constructor(options = {}) { this.output = {}; this.alwaysEmit = new Set(options.alwaysEmit || []); } checkForChanges(output, format) { if (!output || typeof output !== 'object') { return {}; } this.output[format] = this.output[format] || {}; const changedFields = {}; for (const key in output) { if (!Object.prototype.hasOwnProperty.call(output, key)) continue; const forced = this.alwaysEmit.has(key) && output[key] !== undefined; if (forced || output[key] !== this.output[format][key]) { let value = output[key]; // For fields: if the value is an object (and not a Date), stringify it. if (value !== null && typeof value === 'object' && !(value instanceof Date)) { changedFields[key] = JSON.stringify(value); } else { changedFields[key] = value; } } } // Update the saved output state. this.output[format] = { ...this.output[format], ...changedFields }; return changedFields; } formatMsg(output, config, format) { let msg = {}; // Compare output with last output and only include changed values const changedFields = this.checkForChanges(output,format); if (Object.keys(changedFields).length > 0) { // Fall back to `_` when `general.name` is unset — // the original convention before name became a registered config field. const measurement = config.general.name || `${config.functionality?.softwareType}_${config.general.id}`; const flatTags = this.flattenTags(this.extractRelevantConfig(config)); const formatterName = this.resolveFormatterName(config, format); const formatter = getFormatter(formatterName); const payload = formatter.format(measurement, { fields: changedFields, tags: flatTags, config, channel: format, }); msg = this.wrapMessage(measurement, payload); return msg; } return null; } resolveFormatterName(config, channel) { const outputConfig = config.output || {}; if (channel === 'process') { return outputConfig.process || 'process'; } if (channel === 'influxdb') { return outputConfig.dbase || 'influxdb'; } return outputConfig[channel] || channel; } wrapMessage(measurement, payload) { return { topic: measurement, payload, }; } flattenTags(obj) { const result = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { const value = obj[key]; // Skip tags that carry no information. When a config field is unset, // extractRelevantConfig hands us `undefined`; stringifying that wrote // literal `category="undefined"` / `geoLocation="undefined"` tags that // clutter every Grafana legend and needlessly inflate tag cardinality. // Drop null / undefined / empty-string before they reach InfluxDB. if (value === null || value === undefined || value === '') continue; if (typeof value === 'object' && !(value instanceof Date)) { // Recursively flatten the nested object. const flatChild = this.flattenTags(value); for (const childKey in flatChild) { if (Object.prototype.hasOwnProperty.call(flatChild, childKey)) { result[`${key}_${childKey}`] = String(flatChild[childKey]); } } } else { // InfluxDB tags must be strings. result[key] = String(value); } } } return result; } extractRelevantConfig(config) { return { // general properties id: config.general?.id, // functionality properties softwareType: config.functionality?.softwareType, role: config.functionality?.role, positionVsParent: config.functionality?.positionVsParent, // asset properties (exclude machineCurve) uuid: config.asset?.uuid, tagcode: config.asset?.tagCode || config.asset?.tagcode, geoLocation: config.asset?.geoLocation, category: config.asset?.category, type: config.asset?.type, model: config.asset?.model, unit: config.general?.unit, }; } } module.exports = OutputUtils;