state.js: When moveTo catches a 'Movement aborted' or 'Transition aborted'
error, transition the FSM back to 'operational'. This ensures a subsequent
shutdown or emergency-stop sequence is accepted — previously the FSM stayed
stuck in 'accelerating'/'decelerating' and rejected stopping/idle
transitions, silently dropping shutdown commands issued mid-ramp. Also
emits a 'movementAborted' event for observability.
rotatingMachine.json: Add schema entries for functionality.distance,
functionality.distanceUnit, functionality.distanceDescription, and top-level
output.{process,dbase}. These keys are produced by buildConfig / the HTML
editor but were previously stripped by the validator with an
'Unknown key' warning on every deploy.
configs/index.js: Trim buildConfig so it no longer unconditionally injects
distanceUnit/distanceDescription — those keys are rotatingMachine-specific
and would otherwise produce Unknown-key warnings on every other node.
Verified via Docker-hosted Node-RED E2E: shutdown from accelerating now
reaches idle; emergency stop from accelerating reaches off; 0 Unknown-key
warnings in container logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
195 lines
7.1 KiB
JavaScript
195 lines
7.1 KiB
JavaScript
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<string>} 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'
|
|
}
|
|
};
|
|
|
|
// Add asset section if UI provides asset fields
|
|
if (uiConfig.supplier || uiConfig.category || uiConfig.assetType || uiConfig.model) {
|
|
config.asset = {
|
|
uuid: uiConfig.uuid || uiConfig.assetUuid || null,
|
|
tagCode: uiConfig.tagCode || uiConfig.assetTagCode || null,
|
|
supplier: uiConfig.supplier || 'Unknown',
|
|
category: uiConfig.category || 'sensor',
|
|
type: uiConfig.assetType || 'Unknown',
|
|
model: uiConfig.model || 'Unknown',
|
|
unit: uiConfig.unit || 'unitless'
|
|
};
|
|
}
|
|
|
|
// Merge domain-specific sections
|
|
Object.assign(config, domainConfig);
|
|
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* 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;
|