standaardisation updates
This commit is contained in:
76
src/configs/index.js
Normal file
76
src/configs/index.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class ConfigManager {
|
||||
constructor(relPath = '.') {
|
||||
this.configDir = path.resolve(__dirname, relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a configuration file by name
|
||||
* @param {string} configName - Name of the config file (without .json extension)
|
||||
* @returns {Object} Parsed configuration object
|
||||
*/
|
||||
getConfig(configName) {
|
||||
try {
|
||||
const configPath = path.resolve(this.configDir, `${configName}.json`);
|
||||
const configData = fs.readFileSync(configPath, 'utf8');
|
||||
return JSON.parse(configData);
|
||||
} catch (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);
|
||||
}
|
||||
|
||||
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;
|
||||
385
src/configs/measurement.json
Normal file
385
src/configs/measurement.json
Normal file
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"general": {
|
||||
"name": {
|
||||
"default": "Measurement Configuration",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A human-readable name or label for this measurement configuration."
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "A unique identifier for this configuration. If not provided, defaults to null."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": "unitless",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The unit of measurement for this configuration (e.g., 'meters', 'seconds', 'unitless')."
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"logLevel": {
|
||||
"default": "info",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "debug",
|
||||
"description": "Log messages are printed for debugging purposes."
|
||||
},
|
||||
{
|
||||
"value": "info",
|
||||
"description": "Informational messages are printed."
|
||||
},
|
||||
{
|
||||
"value": "warn",
|
||||
"description": "Warning messages are printed."
|
||||
},
|
||||
{
|
||||
"value": "error",
|
||||
"description": "Error messages are printed."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates whether logging is active. If true, log messages will be generated."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functionality": {
|
||||
"softwareType": {
|
||||
"default": "measurement",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Specified software type for this configuration."
|
||||
}
|
||||
},
|
||||
"role": {
|
||||
"default": "Sensor",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "Indicates the role this configuration plays (e.g., sensor, controller, etc.)."
|
||||
}
|
||||
},
|
||||
"positionVsParent":{
|
||||
"default":"atEquipment",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "atEquipment",
|
||||
"description": "The measurement is taken at the equipment level, typically representing the overall state or performance of the equipment."
|
||||
},
|
||||
{
|
||||
"value": "upstream",
|
||||
"description": "The measurement is taken upstream, meaning it is related to inputs or conditions that affect the equipment's operation, such as supply conditions or environmental factors."
|
||||
},
|
||||
{
|
||||
"value": "downstream",
|
||||
"description": "The measurement is taken downstream, indicating it relates to outputs or results of the equipment's operation, such as product quality or performance metrics."
|
||||
}
|
||||
],
|
||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||
}
|
||||
}
|
||||
},
|
||||
"asset": {
|
||||
"uuid": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Asset tag number which is a universally unique identifier for this asset. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"tagCode":{
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Asset tag code which is a unique identifier for this asset. May be null if not assigned."
|
||||
}
|
||||
},
|
||||
"geoLocation": {
|
||||
"default": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"z": 0
|
||||
},
|
||||
"rules": {
|
||||
"type": "object",
|
||||
"description": "An object representing the asset's physical coordinates or location.",
|
||||
"schema": {
|
||||
"x": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "X coordinate of the asset's location."
|
||||
}
|
||||
},
|
||||
"y": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Y coordinate of the asset's location."
|
||||
}
|
||||
},
|
||||
"z": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "Z coordinate of the asset's location."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"supplier": {
|
||||
"default": "Unknown",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The supplier or manufacturer of the asset."
|
||||
}
|
||||
},
|
||||
"category": {
|
||||
"default": "sensor",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "sensor",
|
||||
"description": "A device that detects or measures a physical property and responds to it (e.g. temperature sensor)."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"default": "pressure",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A more specific classification within 'type'. For example, 'pressure' for a pressure sensor."
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"default": "Unknown",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "A user-defined or manufacturer-defined model identifier for the asset."
|
||||
}
|
||||
},
|
||||
"unit": {
|
||||
"default": "unitless",
|
||||
"rules": {
|
||||
"type": "string",
|
||||
"description": "The unit of measurement for this asset (e.g., 'meters', 'seconds', 'unitless')."
|
||||
}
|
||||
},
|
||||
"accuracy": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "The accuracy of the sensor, typically represented as a percentage or absolute value."
|
||||
}
|
||||
},
|
||||
"repeatability": {
|
||||
"default": null,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "The repeatability of the sensor, typically represented as a percentage or absolute value."
|
||||
}
|
||||
}
|
||||
},
|
||||
"scaling": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates whether input scaling is active. If true, input values will be scaled according to the parameters below."
|
||||
}
|
||||
},
|
||||
"inputMin": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The minimum expected input value before scaling."
|
||||
}
|
||||
},
|
||||
"inputMax": {
|
||||
"default": 1,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The maximum expected input value before scaling."
|
||||
}
|
||||
},
|
||||
"absMin": {
|
||||
"default": 50,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The absolute minimum value that can be read or displayed after scaling."
|
||||
}
|
||||
},
|
||||
"absMax": {
|
||||
"default": 100,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The absolute maximum value that can be read or displayed after scaling."
|
||||
}
|
||||
},
|
||||
"offset": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "A constant offset to apply to the scaled output (e.g., to calibrate zero-points)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"smoothing": {
|
||||
"smoothWindow": {
|
||||
"default": 10,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 1,
|
||||
"description": "Determines the size of the data window (number of samples) used for smoothing operations."
|
||||
}
|
||||
},
|
||||
"smoothMethod": {
|
||||
"default": "mean",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "none",
|
||||
"description": "No smoothing is applied; raw data is passed through."
|
||||
},
|
||||
{
|
||||
"value": "mean",
|
||||
"description": "Calculates the simple arithmetic mean (average) of the data points in a window."
|
||||
},
|
||||
{
|
||||
"value": "min",
|
||||
"description": "Selects the smallest (minimum) value among the data points in a window."
|
||||
},
|
||||
{
|
||||
"value": "max",
|
||||
"description": "Selects the largest (maximum) value among the data points in a window."
|
||||
},
|
||||
{
|
||||
"value": "sd",
|
||||
"description": "Computes the standard deviation to measure the variation or spread of the data."
|
||||
},
|
||||
{
|
||||
"value": "lowPass",
|
||||
"description": "Filters out high-frequency components, allowing only lower frequencies to pass."
|
||||
},
|
||||
{
|
||||
"value": "highPass",
|
||||
"description": "Filters out low-frequency components, allowing only higher frequencies to pass."
|
||||
},
|
||||
{
|
||||
"value": "weightedMovingAverage",
|
||||
"description": "Applies varying weights to each data point in a window before averaging."
|
||||
},
|
||||
{
|
||||
"value": "bandPass",
|
||||
"description": "Filters the signal to allow only frequencies within a specific range to pass."
|
||||
},
|
||||
{
|
||||
"value": "median",
|
||||
"description": "Selects the median (middle) value in a window, minimizing the effect of outliers."
|
||||
},
|
||||
{
|
||||
"value": "kalman",
|
||||
"description": "Applies a Kalman filter to combine noisy measurements over time for more accurate estimates."
|
||||
},
|
||||
{
|
||||
"value": "savitzkyGolay",
|
||||
"description": "Uses a polynomial smoothing filter on a moving window, which can also provide derivative estimates."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"simulation": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the system operates in simulation mode, generating simulated values instead of using real inputs."
|
||||
}
|
||||
},
|
||||
"safeCalibrationTime": {
|
||||
"default": 100,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 100,
|
||||
"description": "Time to wait before finalizing calibration in simulation mode (in milliseconds or appropriate unit)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"interpolation": {
|
||||
"percentMin": {
|
||||
"default": 0,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"min": 0,
|
||||
"description": "Minimum percentage for interpolation or data scaling operations."
|
||||
}
|
||||
},
|
||||
"percentMax": {
|
||||
"default": 100,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"max": 100,
|
||||
"description": "Maximum percentage for interpolation or data scaling operations."
|
||||
}
|
||||
}
|
||||
},
|
||||
"outlierDetection": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"rules": {
|
||||
"type": "boolean",
|
||||
"description": "Indicates whether outlier detection is enabled. If true, outliers will be identified and handled according to the method specified."
|
||||
}
|
||||
},
|
||||
"method": {
|
||||
"default": "zScore",
|
||||
"rules": {
|
||||
"type": "enum",
|
||||
"values": [
|
||||
{
|
||||
"value": "zScore",
|
||||
"description": "Uses the Z-score method to identify outliers based on standard deviations from the mean."
|
||||
},
|
||||
{
|
||||
"value": "iqr",
|
||||
"description": "Uses the Interquartile Range (IQR) method to identify outliers based on the spread of the middle 50% of the data."
|
||||
},
|
||||
{
|
||||
"value": "modifiedZScore",
|
||||
"description": "Uses a modified Z-score method that is more robust to small sample sizes."
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"threshold": {
|
||||
"default": 3,
|
||||
"rules": {
|
||||
"type": "number",
|
||||
"description": "The threshold value used by the selected outlier detection method. For example, a Z-score threshold of 3.0."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
106
src/helper/endpointUtils.js
Normal file
106
src/helper/endpointUtils.js
Normal file
@@ -0,0 +1,106 @@
|
||||
const MenuUtils = require('./menuUtils');
|
||||
|
||||
/**
|
||||
* Server-side helper for exposing MenuUtils to the browser via HTTP endpoints.
|
||||
*/
|
||||
class EndpointUtils {
|
||||
/**
|
||||
* @param {Object} options
|
||||
* @param {Function} options.MenuUtilsClass the MenuUtils constructor/function
|
||||
*/
|
||||
constructor({ MenuUtilsClass = MenuUtils } = {}) {
|
||||
this.MenuUtils = MenuUtilsClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers an HTTP GET endpoint that serves the client-side MenuUtils code
|
||||
* @param {object} RED the Node-RED API object
|
||||
* @param {string} nodeName the name of the node (used in the URL)
|
||||
* @param {object} customHelpers additional helper functions to inject
|
||||
*/
|
||||
createMenuUtilsEndpoint(RED, nodeName, customHelpers = {}) {
|
||||
RED.httpAdmin.get(`/${nodeName}/resources/menuUtils.js`, (req, res) => {
|
||||
console.log(`Serving menuUtils.js for ${nodeName} node`);
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
const browserCode = this.generateMenuUtilsCode(nodeName, customHelpers);
|
||||
res.send(browserCode);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the browser-side JavaScript that redefines MenuUtils and helper fns
|
||||
* @param {string} nodeName
|
||||
* @param {object} customHelpers map of name: functionString pairs
|
||||
* @returns {string} a JS snippet to run in the browser
|
||||
*/
|
||||
generateMenuUtilsCode(nodeName, customHelpers = {}) {
|
||||
// Default helper implementations to expose alongside MenuUtils
|
||||
const defaultHelpers = {
|
||||
validateRequired: `function(value) {
|
||||
return value != null && value.toString().trim() !== '';
|
||||
}`,
|
||||
formatDisplayValue: `function(value, unit) {
|
||||
return \`${'${'}value} ${'${'}unit || ''}\`.trim();
|
||||
}`,
|
||||
validateScaling: `function(min, max) {
|
||||
return !isNaN(min) && !isNaN(max) && Number(min) < Number(max);
|
||||
}`,
|
||||
validateUnit: `function(unit) {
|
||||
return typeof unit === 'string' && unit.trim() !== '';
|
||||
}`,
|
||||
};
|
||||
|
||||
// Merge any custom overrides
|
||||
const allHelpers = { ...defaultHelpers, ...customHelpers };
|
||||
|
||||
// Build the helpers code block
|
||||
const helpersCode = Object.entries(allHelpers)
|
||||
.map(([name, fnBody]) => ` ${name}: ${fnBody}`)
|
||||
.join(',\n');
|
||||
|
||||
// Introspect MenuUtils prototype to extract method definitions
|
||||
const proto = this.MenuUtils.prototype;
|
||||
const browserMethods = Object.getOwnPropertyNames(proto)
|
||||
.filter(key => key !== 'constructor')
|
||||
.map(methodName => {
|
||||
const fn = proto[methodName];
|
||||
const src = fn.toString();
|
||||
const isAsync = fn.constructor.name === 'AsyncFunction';
|
||||
// extract signature and body
|
||||
const signature = src.slice(src.indexOf('('));
|
||||
const prefix = isAsync ? 'async ' : '';
|
||||
return ` ${prefix}${methodName}${signature}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
// Return a complete JS snippet for the browser
|
||||
return `
|
||||
// Auto-generated MenuUtils for node: ${nodeName}
|
||||
window.EVOLV = window.EVOLV || {};
|
||||
window.EVOLV.nodes = window.EVOLV.nodes || {};
|
||||
window.EVOLV.nodes.${nodeName} = window.EVOLV.nodes.${nodeName} || {};
|
||||
|
||||
class MenuUtils {
|
||||
constructor(opts) {
|
||||
// Allow same options API as server-side
|
||||
this.useCloud = opts.useCloud || false;
|
||||
this.projectSettings = opts.projectSettings || {};
|
||||
// any other client-side initialization...
|
||||
}
|
||||
|
||||
${browserMethods}
|
||||
}
|
||||
|
||||
window.EVOLV.nodes.${nodeName}.utils = {
|
||||
menuUtils: new MenuUtils({}),
|
||||
helpers: {
|
||||
${helpersCode}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('Loaded EVOLV.nodes.${nodeName}.utils.menuUtils');
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = EndpointUtils;
|
||||
@@ -1,4 +1,5 @@
|
||||
class MenuUtils {
|
||||
|
||||
|
||||
initBasicToggles(elements) {
|
||||
// Toggle visibility for log level
|
||||
|
||||
242
src/menu/asset.js
Normal file
242
src/menu/asset.js
Normal file
@@ -0,0 +1,242 @@
|
||||
// asset.js
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
class AssetMenu {
|
||||
/** Define path where to find data of assets in constructor for now */
|
||||
constructor(relPath = '../../datasets/assetData') {
|
||||
this.baseDir = path.resolve(__dirname, relPath);
|
||||
this.assetData = this._loadJSON('assetData');
|
||||
}
|
||||
|
||||
_loadJSON(...segments) {
|
||||
const filePath = path.resolve(this.baseDir, ...segments) + '.json';
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to load ${filePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ADD THIS METHOD
|
||||
* Compiles all menu data from the file system into a single nested object.
|
||||
* This is run once on the server to pre-load everything.
|
||||
* @returns {object} A comprehensive object with all menu options.
|
||||
*/
|
||||
getAllMenuData() {
|
||||
// load the raw JSON once
|
||||
const data = this._loadJSON('assetData');
|
||||
const allData = {};
|
||||
|
||||
data.suppliers.forEach(sup => {
|
||||
allData[sup.name] = {};
|
||||
sup.categories.forEach(cat => {
|
||||
allData[sup.name][cat.name] = {};
|
||||
cat.types.forEach(type => {
|
||||
// here: store the full array of model objects, not just names
|
||||
allData[sup.name][cat.name][type.name] = type.models;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return allData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the static initEditor function to a string that can be served to the client
|
||||
* @param {string} nodeName - The name of the node type
|
||||
* @returns {string} JavaScript code as a string
|
||||
*/
|
||||
getClientInitCode(nodeName) {
|
||||
// step 1: get the two helper strings
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventsCode = this.getEventInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
|
||||
|
||||
return `
|
||||
// --- AssetMenu for ${nodeName} ---
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu =
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu || {};
|
||||
|
||||
${htmlCode}
|
||||
${dataCode}
|
||||
${eventsCode}
|
||||
${saveCode}
|
||||
|
||||
// wire it all up when the editor loads
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.initEditor = function(node) {
|
||||
// ------------------ BELOW sequence is important! -------------------------------
|
||||
console.log('Initializing asset properties for ${nodeName}…');
|
||||
this.injectHtml();
|
||||
// load the data and wire up events
|
||||
// this will populate the fields and set up the event listeners
|
||||
this.wireEvents(node);
|
||||
// this will load the initial data into the fields
|
||||
// this is important to ensure the fields are populated correctly
|
||||
this.loadData(node);
|
||||
};
|
||||
`;
|
||||
|
||||
}
|
||||
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
// initial population
|
||||
populate(elems.supplier, Object.keys(data), node.supplier);
|
||||
};
|
||||
`
|
||||
}
|
||||
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Event wiring for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.asset;
|
||||
const elems = {
|
||||
supplier: document.getElementById('node-input-supplier'),
|
||||
category: document.getElementById('node-input-category'),
|
||||
type: document.getElementById('node-input-assetType'),
|
||||
model: document.getElementById('node-input-model'),
|
||||
unit: document.getElementById('node-input-unit')
|
||||
};
|
||||
function populate(el, opts, sel) {
|
||||
const old = el.value;
|
||||
el.innerHTML = '<option value="">Select…</option>';
|
||||
(opts||[]).forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o; opt.textContent = o;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
el.value = sel||"";
|
||||
if(el.value!==old) el.dispatchEvent(new Event('change'));
|
||||
}
|
||||
elems.supplier.addEventListener('change', ()=>{
|
||||
populate(elems.category,
|
||||
elems.supplier.value? Object.keys(data[elems.supplier.value]||{}) : [],
|
||||
node.category);
|
||||
});
|
||||
elems.category.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value;
|
||||
populate(elems.type,
|
||||
(s&&c)? Object.keys(data[s][c]||{}) : [],
|
||||
node.assetType);
|
||||
});
|
||||
elems.type.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
populate(elems.model, md.map(m=>m.name), node.model);
|
||||
});
|
||||
elems.model.addEventListener('change', ()=>{
|
||||
const s=elems.supplier.value, c=elems.category.value, t=elems.type.value, m=elems.model.value;
|
||||
const md = (s&&c&&t)? data[s][c][t]||[] : [];
|
||||
const entry = md.find(x=>x.name===m);
|
||||
populate(elems.unit, entry? entry.units : [], node.unit);
|
||||
});
|
||||
};
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML template for asset fields
|
||||
*/
|
||||
getHtmlTemplate() {
|
||||
return `
|
||||
<!-- Asset Properties -->
|
||||
<hr />
|
||||
<h3>Asset selection</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-supplier"><i class="fa fa-industry"></i> Supplier</label>
|
||||
<select id="node-input-supplier" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-category"><i class="fa fa-sitemap"></i> Category</label>
|
||||
<select id="node-input-category" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-assetType"><i class="fa fa-puzzle-piece"></i> Type</label>
|
||||
<select id="node-input-assetType" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-model"><i class="fa fa-wrench"></i> Model</label>
|
||||
<select id="node-input-model" style="width:70%;"></select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-unit"><i class="fa fa-balance-scale"></i> Unit</label>
|
||||
<select id="node-input-unit" style="width:70%;"></select>
|
||||
</div>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client-side HTML injection code
|
||||
*/
|
||||
getHtmlInjectionCode(nodeName) {
|
||||
const htmlTemplate = this.getHtmlTemplate().replace(/`/g, '\\`').replace(/\$/g, '\\$');
|
||||
|
||||
return `
|
||||
// Asset HTML injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.injectHtml = function() {
|
||||
const placeholder = document.getElementById('asset-fields-placeholder');
|
||||
if (placeholder && !placeholder.hasChildNodes()) {
|
||||
placeholder.innerHTML = \`${htmlTemplate}\`;
|
||||
console.log('Asset HTML injected successfully');
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the JS that injects the saveEditor function
|
||||
*/
|
||||
getSaveInjectionCode(nodeName) {
|
||||
return `
|
||||
// Asset Save injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.assetMenu.saveEditor = function(node) {
|
||||
console.log('Saving asset properties for ${nodeName}…');
|
||||
const fields = ['supplier','category','assetType','model','unit'];
|
||||
const errors = [];
|
||||
fields.forEach(f => {
|
||||
const el = document.getElementById(\`node-input-\${f}\`);
|
||||
node[f] = el ? el.value : '';
|
||||
});
|
||||
if (node.assetType && !node.unit) errors.push('Unit must be set when type is specified.');
|
||||
if (!node.unit) errors.push('Unit is required.');
|
||||
errors.forEach(e=>RED.notify(e,'error'));
|
||||
|
||||
// --- DEBUG: show exactly what was saved ---
|
||||
const saved = fields.reduce((o,f) => { o[f] = node[f]; return o; }, {});
|
||||
console.log('→ assetMenu.saveEditor result:', saved);
|
||||
|
||||
return errors.length===0;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = AssetMenu;
|
||||
85
src/menu/index.js
Normal file
85
src/menu/index.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const AssetMenu = require('./asset.js');
|
||||
const LoggerMenu = require('./logger.js');
|
||||
const PhysicalPositionMenu = require('./physicalPosition.js');
|
||||
|
||||
class MenuManager {
|
||||
|
||||
constructor() {
|
||||
this.registeredMenus = new Map(); // Store menu type instances
|
||||
this.registerMenu('asset', new AssetMenu()); // Register asset menu by default
|
||||
this.registerMenu('logger', new LoggerMenu()); // Register logger menu by default
|
||||
this.registerMenu('position', new PhysicalPositionMenu()); // Register position menu by default
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a menu type with its handler instance
|
||||
* @param {string} menuType - The type of menu (e.g., 'asset', 'logging')
|
||||
* @param {object} menuHandler - The menu handler instance
|
||||
*/
|
||||
registerMenu(menuType, menuHandler) {
|
||||
this.registeredMenus.set(menuType, menuHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete endpoint script with data and initialization functions
|
||||
* @param {string} nodeName - The name of the node type
|
||||
* @param {Array<string>} menuTypes - Array of menu types to include
|
||||
* @returns {string} Complete JavaScript code to serve
|
||||
*/
|
||||
createEndpoint(nodeName, menuTypes) {
|
||||
// 1. Collect all menu data
|
||||
const menuData = {};
|
||||
menuTypes.forEach(menuType => {
|
||||
const handler = this.registeredMenus.get(menuType);
|
||||
if (handler && typeof handler.getAllMenuData === 'function') {
|
||||
menuData[menuType] = handler.getAllMenuData();
|
||||
}
|
||||
});
|
||||
|
||||
// Generate HTML injection code
|
||||
const htmlInjections = menuTypes.map(type => {
|
||||
const menu = this.registeredMenus.get(type);
|
||||
if (menu && menu.getHtmlInjectionCode) {
|
||||
return menu.getHtmlInjectionCode(nodeName);
|
||||
}
|
||||
return '';
|
||||
}).join('\n');
|
||||
|
||||
// 2. Collect all client initialization code
|
||||
const initFunctions = [];
|
||||
menuTypes.forEach(menuType => {
|
||||
const handler = this.registeredMenus.get(menuType);
|
||||
if (handler && typeof handler.getClientInitCode === 'function') {
|
||||
initFunctions.push(handler.getClientInitCode(nodeName));
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Convert menu data to JSON
|
||||
const menuDataJSON = JSON.stringify(menuData, null, 2);
|
||||
|
||||
// 4. 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 menu data directly into the namespace
|
||||
window.EVOLV.nodes.${nodeName}.menuData = ${menuDataJSON};
|
||||
|
||||
${initFunctions.join('\n\n')}
|
||||
|
||||
// Main initialization function that calls all menu initializers
|
||||
window.EVOLV.nodes.${nodeName}.initEditor = function(node) {
|
||||
${menuTypes.map(type => `
|
||||
if (window.EVOLV.nodes.${nodeName}.${type}Menu && window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor) {
|
||||
window.EVOLV.nodes.${nodeName}.${type}Menu.initEditor(node);
|
||||
}`).join('')}
|
||||
};
|
||||
|
||||
console.log('${nodeName} menu data and initializers loaded for: ${menuTypes.join(', ')}');
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MenuManager;
|
||||
134
src/menu/logger.js
Normal file
134
src/menu/logger.js
Normal file
@@ -0,0 +1,134 @@
|
||||
class LoggerMenu {
|
||||
constructor() {
|
||||
// no external data files for logger – all static
|
||||
}
|
||||
|
||||
// 1) Server‐side: return the static menuData
|
||||
getAllMenuData() {
|
||||
return {
|
||||
logLevels: [
|
||||
{ value: 'error', label: 'Error', description: 'Only error messages' },
|
||||
{ value: 'warn', label: 'Warn', description: 'Warning and error messages' },
|
||||
{ value: 'info', label: 'Info', description: 'Info, warning and error messages' },
|
||||
{ value: 'debug', label: 'Debug', description: 'All messages including debug' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Client‐side: inject the dropdown options
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// Logger data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.logger;
|
||||
const sel = document.getElementById('node-input-logLevel');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
data.logLevels.forEach(l => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = l.value;
|
||||
opt.textContent = l.label;
|
||||
opt.title = l.description;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.value = node.logLevel || 'info';
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
getHtmlInjectionCode(nodeName) {
|
||||
const tpl = `
|
||||
<h3>Internal logging</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-enableLog"><i class="fa fa-bug"></i>Logging</label>
|
||||
<input type="checkbox" id="node-input-enableLog"/>
|
||||
</div>
|
||||
<div class="form-row" id="row-logLevel">
|
||||
<label for="node-input-logLevel"><i class="fa fa-list"></i> Log Level</label>
|
||||
<select id="node-input-logLevel" style="width:60%;"></select>
|
||||
</div>
|
||||
`.replace(/`/g,'\\`').replace(/\$/g,'\\$');
|
||||
|
||||
return `
|
||||
// Logger HTML injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu.injectHtml = function() {
|
||||
const ph = document.getElementById('logger-fields-placeholder');
|
||||
if (ph && !ph.hasChildNodes()) {
|
||||
ph.innerHTML = \`${tpl}\`;
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 3) Client‐side: wire up the enable‐toggle behavior
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// Logger event wiring for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu.wireEvents = function(node) {
|
||||
const chk = document.getElementById('node-input-enableLog');
|
||||
const row = document.getElementById('row-logLevel');
|
||||
if (!chk || !row) return;
|
||||
const toggle = () => {
|
||||
row.style.display = chk.checked ? 'block' : 'none';
|
||||
};
|
||||
chk.checked = node.enableLog || false;
|
||||
toggle();
|
||||
chk.addEventListener('change', toggle);
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 4) Client‐side: save logic
|
||||
getSaveInjectionCode(nodeName) {
|
||||
return `
|
||||
// Logger Save injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu.saveEditor = function(node) {
|
||||
console.log('Saving logger properties for ${nodeName}…');
|
||||
const chk = document.getElementById('node-input-enableLog');
|
||||
const sel = document.getElementById('node-input-logLevel');
|
||||
node.enableLog = chk ? chk.checked : false;
|
||||
node.logLevel = sel ? sel.value : 'info';
|
||||
const errors = [];
|
||||
if (node.enableLog && !node.logLevel) {
|
||||
errors.push('Log level must be selected when logging is enabled.');
|
||||
}
|
||||
errors.forEach(e => RED.notify(e,'error'));
|
||||
// --- DEBUG: what was saved ---
|
||||
console.log('→ loggerMenu.saveEditor result:', {
|
||||
enableLog: node.enableLog,
|
||||
logLevel: node.logLevel
|
||||
});
|
||||
return errors.length === 0;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 5) Compose everything into one client‐side payload
|
||||
getClientInitCode(nodeName) {
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventCode = this.getEventInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
|
||||
return `
|
||||
// --- LoggerMenu for ${nodeName} ---
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu =
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu || {};
|
||||
|
||||
${htmlCode}
|
||||
${dataCode}
|
||||
${eventCode}
|
||||
${saveCode}
|
||||
|
||||
// oneditprepare calls this
|
||||
window.EVOLV.nodes.${nodeName}.loggerMenu.initEditor = function(node) {
|
||||
// ------------------ BELOW sequence is important! -------------------------------
|
||||
this.injectHtml();
|
||||
this.loadData(node);
|
||||
this.wireEvents(node);
|
||||
};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LoggerMenu;
|
||||
123
src/menu/physicalPosition.js
Normal file
123
src/menu/physicalPosition.js
Normal file
@@ -0,0 +1,123 @@
|
||||
|
||||
class PhysicalPositionMenu {
|
||||
|
||||
// 1) Server-side: provide the option groups
|
||||
getAllMenuData() {
|
||||
return {
|
||||
positionGroups: [
|
||||
{ group: 'Positional', options: [
|
||||
{ value: 'upstream', label: '⬅ Upstream' },
|
||||
{ value: 'atEquipment', label: '⚙️ At Equipment' },
|
||||
{ value: 'downstream', label: '➡ Downstream' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// 2) HTML template (pure markup)
|
||||
getHtmlTemplate() {
|
||||
return `
|
||||
<hr />
|
||||
<h3>Physical Position vs parent</h3>
|
||||
<div class="form-row">
|
||||
<label for="node-input-physicalAspect"><i class="fa fa-map-marker"></i>Position</label>
|
||||
<select id="node-input-physicalAspect" style="width:70%;">
|
||||
<!-- optgroups will be injected -->
|
||||
</select>
|
||||
</div>
|
||||
<hr />
|
||||
`;
|
||||
}
|
||||
|
||||
// 3) HTML injector
|
||||
getHtmlInjectionCode(nodeName) {
|
||||
const tpl = this.getHtmlTemplate()
|
||||
.replace(/`/g,'\\`').replace(/\$/g,'\\$');
|
||||
return `
|
||||
// PhysicalPosition HTML injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu.injectHtml = function() {
|
||||
const ph = document.getElementById('position-fields-placeholder');
|
||||
if (ph && !ph.hasChildNodes()) {
|
||||
ph.innerHTML = \`${tpl}\`;
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 4) Data-loader injector
|
||||
getDataInjectionCode(nodeName) {
|
||||
return `
|
||||
// PhysicalPosition data loader for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu.loadData = function(node) {
|
||||
const data = window.EVOLV.nodes.${nodeName}.menuData.position;
|
||||
const sel = document.getElementById('node-input-physicalAspect');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '';
|
||||
(data.positionGroups||[]).forEach(grp => {
|
||||
const optg = document.createElement('optgroup');
|
||||
optg.label = grp.group;
|
||||
grp.options.forEach(o=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value = o.value;
|
||||
opt.textContent = o.label;
|
||||
optg.appendChild(opt);
|
||||
});
|
||||
sel.appendChild(optg);
|
||||
});
|
||||
// default to “atEquipment” if not set
|
||||
sel.value = node.physicalAspect || 'atEquipment';
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 5) (no special events needed, but stub for symmetry)
|
||||
getEventInjectionCode(nodeName) {
|
||||
return `
|
||||
// PhysicalPosition events for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu.wireEvents = function(node) {
|
||||
// no dynamic behavior
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 6) Save-logic injector
|
||||
getSaveInjectionCode(nodeName) {
|
||||
return `
|
||||
// PhysicalPosition Save injection for ${nodeName}
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu.saveEditor = function(node) {
|
||||
const sel = document.getElementById('node-input-physicalAspect');
|
||||
node.physicalAspect = sel? sel.value : 'atEquipment';
|
||||
return true;
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
// 7) Compose everything into one client bundle
|
||||
getClientInitCode(nodeName) {
|
||||
const htmlCode = this.getHtmlInjectionCode(nodeName);
|
||||
const dataCode = this.getDataInjectionCode(nodeName);
|
||||
const eventCode = this.getEventInjectionCode(nodeName);
|
||||
const saveCode = this.getSaveInjectionCode(nodeName);
|
||||
|
||||
return `
|
||||
// --- PhysicalPositionMenu for ${nodeName} ---
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu =
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu || {};
|
||||
|
||||
${htmlCode}
|
||||
${dataCode}
|
||||
${eventCode}
|
||||
${saveCode}
|
||||
|
||||
// hook into oneditprepare
|
||||
window.EVOLV.nodes.${nodeName}.positionMenu.initEditor = function(node) {
|
||||
this.injectHtml();
|
||||
this.loadData(node);
|
||||
this.wireEvents(node);
|
||||
};
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PhysicalPositionMenu;
|
||||
Reference in New Issue
Block a user