const fs = require('fs'); const path = require('path'); /** * Current config version. All config JSONs should declare this version. * Bump this when the config schema changes. */ const CURRENT_CONFIG_VERSION = '1.0.0'; class ConfigManager { constructor(relPath = '.') { this.configDir = path.resolve(__dirname, relPath); /** * Migration functions keyed by "fromVersion->toVersion". * Each function receives a config object and returns the migrated config. * * Example: * this.migrations['1.0.0->1.1.0'] = (config) => { * config.newSection = { enabled: false }; * return config; * }; */ this.migrations = {}; } /** * Load a configuration file by name. * Automatically checks the config version and migrates if needed. * @param {string} configName - Name of the config file (without .json extension) * @returns {Object} Parsed configuration object (migrated to current version if necessary) */ getConfig(configName) { try { const configPath = path.resolve(this.configDir, `${configName}.json`); const configData = fs.readFileSync(configPath, 'utf8'); let config = JSON.parse(configData); // Auto-migrate if version is behind current const configVersion = config.version || '0.0.0'; if (configVersion !== CURRENT_CONFIG_VERSION) { config = this.migrateConfig(config, configVersion, CURRENT_CONFIG_VERSION); } return config; } catch (error) { if (error.message && error.message.startsWith('Failed to load config')) { throw error; } throw new Error(`Failed to load config '${configName}': ${error.message}`); } } /** * Get list of available configuration files * @returns {Array} Array of config names (without .json extension) */ getAvailableConfigs() { try { const resolvedDir = path.resolve(this.configDir); const files = fs.readdirSync(resolvedDir); return files .filter(file => file.endsWith('.json')) .map(file => path.basename(file, '.json')); } catch (error) { throw new Error(`Failed to read config directory: ${error.message}`); } } /** * Check if a specific config exists * @param {string} configName - Name of the config file * @returns {boolean} True if config exists */ hasConfig(configName) { const configPath = path.resolve(this.configDir, `${configName}.json`); return fs.existsSync(configPath); } /** * Build a runtime config by merging base schema + node schema + UI overrides. * Eliminates the need for each nodeClass to manually construct general/asset/functionality sections. * * @param {string} nodeName - Node type name (e.g., 'valve', 'measurement') * @param {object} uiConfig - Raw config from Node-RED UI * @param {string} nodeId - Node-RED node ID (from node.id) * @param {object} [domainConfig={}] - Domain-specific config sections (e.g., { scaling: {...}, smoothing: {...} }) * @returns {object} Merged runtime config * * @example * const cfgMgr = new ConfigManager(); * const config = cfgMgr.buildConfig('measurement', uiConfig, node.id, { * scaling: { enabled: uiConfig.scaling, inputMin: uiConfig.i_min, ... }, * smoothing: { smoothWindow: uiConfig.count, ... } * }); */ buildConfig(nodeName, uiConfig, nodeId, domainConfig = {}) { // Build base sections from UI config (common to ALL nodes) const config = { general: { name: uiConfig.name || nodeName, id: nodeId, unit: uiConfig.unit || 'unitless', logging: { enabled: uiConfig.enableLog !== undefined ? uiConfig.enableLog : true, logLevel: uiConfig.logLevel || 'info' } }, functionality: { softwareType: nodeName.toLowerCase(), positionVsParent: uiConfig.positionVsParent || 'atEquipment', distance: uiConfig.hasDistance ? uiConfig.distance : null }, output: { process: uiConfig.processOutputFormat || 'process', dbase: uiConfig.dbaseOutputFormat || 'influxdb' } }; // Asset section is emitted per-key: only fields the editor actually // set propagate to the domain config. Schemas that omit a key (e.g. // rotatingMachine deliberately drops asset.supplier/category/type // because those come from the asset registry at runtime) no longer // get those keys injected and then stripped by ValidationUtils with // a warning. Empty strings from HTML defaults stay falsy → omitted → // schema default applies. const asset = {}; const uuid = uiConfig.uuid || uiConfig.assetUuid; const tagCode = uiConfig.tagCode || uiConfig.assetTagCode; if (uuid) asset.uuid = uuid; if (tagCode) asset.tagCode = tagCode; if (uiConfig.supplier) asset.supplier = uiConfig.supplier; if (uiConfig.category) asset.category = uiConfig.category; if (uiConfig.assetType) asset.type = uiConfig.assetType; if (uiConfig.model) asset.model = uiConfig.model; if (uiConfig.unit) asset.unit = uiConfig.unit; if (Object.keys(asset).length > 0) config.asset = asset; // Merge domain-specific sections. Must be a DEEP merge: domainConfig // commonly returns subsets of `general` / `asset` (e.g. {general: // {unit}}, {asset: {curveUnits}}) and a shallow assign would wipe out // sibling keys this method just populated — notably `general.id` // (nodeId) and `asset.model`, causing child-registration id collisions // and curve-lookup failures downstream. ConfigManager._deepMerge(config, domainConfig); return config; } static _isPlainObject(v) { return Object.prototype.toString.call(v) === '[object Object]'; } /** * In-place recursive merge. Arrays and primitives in `src` replace `dst`; * plain objects are merged key-by-key so siblings on `dst` survive. */ static _deepMerge(dst, src) { if (!ConfigManager._isPlainObject(src)) return dst; for (const key of Object.keys(src)) { const v = src[key]; if (Array.isArray(v)) { dst[key] = [...v]; } else if (ConfigManager._isPlainObject(v)) { if (!ConfigManager._isPlainObject(dst[key])) dst[key] = {}; ConfigManager._deepMerge(dst[key], v); } else { dst[key] = v; } } return dst; } /** * Migrate a config object from one version to another by applying * registered migration functions in sequence. * @param {object} config - The config object to migrate * @param {string} fromVersion - Current version of the config * @param {string} toVersion - Target version * @returns {object} Migrated config with updated version field */ migrateConfig(config, fromVersion, toVersion) { const migrationKey = `${fromVersion}->${toVersion}`; const migrationFn = this.migrations[migrationKey]; if (migrationFn) { config = migrationFn(config); } // Stamp the current version so it won't re-migrate config.version = toVersion; return config; } /** * Get the base config schema (shared across all nodes). * @returns {object} Base config schema */ getBaseConfig() { return this.getConfig('baseConfig'); } createEndpoint(nodeName) { try { // Load the config for this node const config = this.getConfig(nodeName); // Convert config to JSON const configJSON = JSON.stringify(config, null, 2); // Assemble the complete script return ` // Create the namespace structure window.EVOLV = window.EVOLV || {}; window.EVOLV.nodes = window.EVOLV.nodes || {}; window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {}; // Inject the pre-loaded config data directly into the namespace window.EVOLV.nodes.${nodeName}.config = ${configJSON}; console.log('${nodeName} config loaded and endpoint created'); `; } catch (error) { throw new Error(`Failed to create endpoint for '${nodeName}': ${error.message}`); } } } module.exports = ConfigManager;