Compare commits
7 Commits
fix/valida
...
693517cc8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
693517cc8f | ||
|
|
086e5fe751 | ||
|
|
29b78a3f9b | ||
|
|
43f69066af | ||
|
|
e50be2ee66 | ||
|
|
75d16c620a | ||
|
|
024db5533a |
@@ -153,7 +153,7 @@
|
|||||||
100
|
100
|
||||||
],
|
],
|
||||||
"y": [
|
"y": [
|
||||||
52.14679487594751,
|
11.142207365162072,
|
||||||
20.746724065725342,
|
20.746724065725342,
|
||||||
31.960270693111905,
|
31.960270693111905,
|
||||||
45.6989826531509,
|
45.6989826531509,
|
||||||
@@ -411,7 +411,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
57.998168647814666,
|
25.971821741448165,
|
||||||
42.997354839160536,
|
42.997354839160536,
|
||||||
64.33911122026377
|
64.33911122026377
|
||||||
]
|
]
|
||||||
@@ -427,7 +427,7 @@
|
|||||||
"y": [
|
"y": [
|
||||||
8.219999984177646,
|
8.219999984177646,
|
||||||
13.426327986363882,
|
13.426327986363882,
|
||||||
53.35067019159144,
|
25.288156424842576,
|
||||||
42.48429874246399,
|
42.48429874246399,
|
||||||
64.03769740244357
|
64.03769740244357
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ class ConfigManager {
|
|||||||
functionality: {
|
functionality: {
|
||||||
softwareType: nodeName.toLowerCase(),
|
softwareType: nodeName.toLowerCase(),
|
||||||
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
|
||||||
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
|
distance: uiConfig.hasDistance ? uiConfig.distance : null
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
process: uiConfig.processOutputFormat || 'process',
|
process: uiConfig.processOutputFormat || 'process',
|
||||||
|
|||||||
@@ -411,6 +411,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"mode": {
|
||||||
|
"current": {
|
||||||
|
"default": "analog",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{
|
||||||
|
"value": "analog",
|
||||||
|
"description": "Single-scalar input mode (classic 4-20mA / PLC style). msg.payload is a number; the node runs one offset/scaling/smoothing/outlier pipeline and emits one MeasurementContainer slot."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "digital",
|
||||||
|
"description": "Multi-channel input mode (MQTT / IoT JSON style). msg.payload is an object keyed by channel names declared under config.channels; the node routes each key through its own pipeline and emits N slots from one input message."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Selects how incoming msg.payload is interpreted."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"default": [],
|
||||||
|
"rules": {
|
||||||
|
"type": "array",
|
||||||
|
"itemType": "object",
|
||||||
|
"minLength": 0,
|
||||||
|
"description": "Channel map used in digital mode. Each entry is a self-contained pipeline definition: {key, type, position, unit, scaling?, smoothing?, outlierDetection?, distance?}. Ignored in analog mode."
|
||||||
|
}
|
||||||
|
},
|
||||||
"outlierDetection": {
|
"outlierDetection": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"default": false,
|
"default": false,
|
||||||
|
|||||||
@@ -91,7 +91,55 @@
|
|||||||
],
|
],
|
||||||
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
"description": "Defines the position of the measurement relative to its parent equipment or system."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distance": {
|
||||||
|
"default": null,
|
||||||
|
"rules": {
|
||||||
|
"type": "number",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional spatial offset from the parent equipment reference. Populated from the editor when hasDistance is enabled; null otherwise."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"distanceUnit": {
|
||||||
|
"default": "m",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Unit for the functionality.distance offset (e.g. 'm', 'cm')."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"distanceDescription": {
|
||||||
|
"default": "",
|
||||||
|
"rules": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Free-text description of what the distance offset represents (e.g. 'cable length from control panel to motor')."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"process": {
|
||||||
|
"default": "process",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "process", "description": "Delta-compressed process message (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the process payload emitted on output port 0."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dbase": {
|
||||||
|
"default": "influxdb",
|
||||||
|
"rules": {
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "influxdb", "description": "InfluxDB line-protocol payload (default)." },
|
||||||
|
{ "value": "json", "description": "Raw JSON payload." },
|
||||||
|
{ "value": "csv", "description": "CSV-formatted payload." }
|
||||||
|
],
|
||||||
|
"description": "Format of the telemetry payload emitted on output port 1."
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"asset": {
|
"asset": {
|
||||||
"uuid": {
|
"uuid": {
|
||||||
@@ -234,43 +282,9 @@
|
|||||||
},
|
},
|
||||||
"machineCurve": {
|
"machineCurve": {
|
||||||
"default": {
|
"default": {
|
||||||
"nq": {
|
"nq": {},
|
||||||
"1": {
|
"np": {}
|
||||||
"x": [
|
},
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"np": {
|
|
||||||
"1": {
|
|
||||||
"x": [
|
|
||||||
1,
|
|
||||||
2,
|
|
||||||
3,
|
|
||||||
4,
|
|
||||||
5
|
|
||||||
],
|
|
||||||
"y": [
|
|
||||||
10,
|
|
||||||
20,
|
|
||||||
30,
|
|
||||||
40,
|
|
||||||
50
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"rules": {
|
"rules": {
|
||||||
"type": "machineCurve",
|
"type": "machineCurve",
|
||||||
"description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve."
|
"description": "All machine curves must have a 'nq' and 'np' curve. nq stands for the flow curve, np stands for the power curve. Together they form the efficiency curve."
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
// Map a child's raw softwareType (the lowercased node name from
|
||||||
|
// buildConfig) to the "role" key that parent registerChild() handlers
|
||||||
|
// dispatch on. Without this, MGC/pumpingStation register-handlers (which
|
||||||
|
// branch on 'machine' / 'machinegroup' / 'pumpingstation' / 'measurement')
|
||||||
|
// silently miss every real production child because rotatingMachine
|
||||||
|
// reports softwareType='rotatingmachine' and machineGroupControl reports
|
||||||
|
// 'machinegroupcontrol'. Existing tests that pass already-aliased keys
|
||||||
|
// ('machine', 'machinegroup') stay green because those aren't in the
|
||||||
|
// alias map and pass through unchanged.
|
||||||
|
const SOFTWARE_TYPE_ALIASES = {
|
||||||
|
rotatingmachine: 'machine',
|
||||||
|
machinegroupcontrol: 'machinegroup',
|
||||||
|
};
|
||||||
|
|
||||||
class ChildRegistrationUtils {
|
class ChildRegistrationUtils {
|
||||||
constructor(mainClass) {
|
constructor(mainClass) {
|
||||||
this.mainClass = mainClass;
|
this.mainClass = mainClass;
|
||||||
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const softwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
const rawSoftwareType = (child.config.functionality.softwareType || '').toLowerCase();
|
||||||
|
const softwareType = SOFTWARE_TYPE_ALIASES[rawSoftwareType] || rawSoftwareType;
|
||||||
const name = child.config.general.name || child.config.general.id || 'unknown';
|
const name = child.config.general.name || child.config.general.id || 'unknown';
|
||||||
const id = child.config.general.id || name;
|
const id = child.config.general.id || name;
|
||||||
|
|
||||||
|
|||||||
@@ -141,11 +141,17 @@ class MeasurementContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isUnitCompatible(measurementType, unit) {
|
isUnitCompatible(measurementType, unit) {
|
||||||
const desc = this._describeUnit(unit);
|
// Unknown type (not in measureMap): accept any unit. This lets user-
|
||||||
if (!desc) return false;
|
// defined measurement types (e.g. 'humidity', 'co2', arbitrary IoT
|
||||||
|
// channels in digital mode) pass through without being rejected just
|
||||||
|
// because their unit string ('%', 'ppm', …) is not a known physical
|
||||||
|
// unit to the convert module. Known types are still validated strictly.
|
||||||
const normalizedType = this._normalizeType(measurementType);
|
const normalizedType = this._normalizeType(measurementType);
|
||||||
const expectedMeasure = this.measureMap[normalizedType];
|
const expectedMeasure = this.measureMap[normalizedType];
|
||||||
if (!expectedMeasure) return true;
|
if (!expectedMeasure) return true;
|
||||||
|
|
||||||
|
const desc = this._describeUnit(unit);
|
||||||
|
if (!desc) return false;
|
||||||
return desc.measure === expectedMeasure;
|
return desc.measure === expectedMeasure;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,8 +193,13 @@ class AssetMenu {
|
|||||||
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
return normalizeApiCategory(key, node.softwareType || key, payload.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-dispatching populate (matches the wireEvents version). The
|
||||||
|
// load path below explicitly walks supplier -> type -> model ->
|
||||||
|
// unit in order using saved node.* values, so auto-dispatched
|
||||||
|
// change events (which previously cascaded through wireEvents'
|
||||||
|
// listeners and double-populated everything) are no longer needed.
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
||||||
const previous = selectEl.value;
|
if (!selectEl) return;
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value, label: value });
|
||||||
@@ -227,9 +232,6 @@ class AssetMenu {
|
|||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const categoryKey = resolveCategoryKey();
|
const categoryKey = resolveCategoryKey();
|
||||||
@@ -305,6 +307,28 @@ class AssetMenu {
|
|||||||
getEventInjectionCode(nodeName) {
|
getEventInjectionCode(nodeName) {
|
||||||
return `
|
return `
|
||||||
// Asset event wiring for ${nodeName}
|
// Asset event wiring for ${nodeName}
|
||||||
|
//
|
||||||
|
// The supplier -> type -> model -> unit chain is a strict downward
|
||||||
|
// cascade: each select rebuilds the next based on the currently
|
||||||
|
// selected value above it. Two earlier bugs in this code:
|
||||||
|
//
|
||||||
|
// 1. populate() auto-dispatched a synthetic 'change' event whenever
|
||||||
|
// the value of the rebuilt select differed from before the
|
||||||
|
// rebuild. That triggered the *child* select's listener mid-way
|
||||||
|
// through the *parent* listener, which then continued and
|
||||||
|
// blindly overwrote the child select with empty content. Net
|
||||||
|
// effect: model dropdown showed 'Awaiting Type Selection' even
|
||||||
|
// though a type was clearly selected.
|
||||||
|
//
|
||||||
|
// 2. Each downstream wipe ran unconditionally inside the parent
|
||||||
|
// handler, instead of being driven by the actual current value
|
||||||
|
// of the child select.
|
||||||
|
//
|
||||||
|
// Fix: populate() no longer dispatches change. Cascade is explicit
|
||||||
|
// via cascadeFromSupplier() / cascadeFromType() / cascadeFromModel()
|
||||||
|
// which are called from each handler. The same helpers run on
|
||||||
|
// initial load so behaviour is identical whether the user picked the
|
||||||
|
// value or it came from a saved node.
|
||||||
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
window.EVOLV.nodes.${nodeName}.assetMenu.wireEvents = function(node) {
|
||||||
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
|
||||||
const categories = menuAsset.categories || {};
|
const categories = menuAsset.categories || {};
|
||||||
@@ -316,11 +340,17 @@ class AssetMenu {
|
|||||||
unit: document.getElementById('node-input-unit')
|
unit: document.getElementById('node-input-unit')
|
||||||
};
|
};
|
||||||
|
|
||||||
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
|
// populate(): rebuild a <select> with a placeholder + items.
|
||||||
const previous = selectEl.value;
|
// No change-event dispatch — cascading is done explicitly by the
|
||||||
|
// caller via cascadeFrom*() so the order of operations is
|
||||||
|
// predictable.
|
||||||
|
function populate(selectEl, items, selectedValue, mapFn, placeholderText) {
|
||||||
|
if (!selectEl) return;
|
||||||
|
if (!Array.isArray(items)) items = [];
|
||||||
|
if (!placeholderText) placeholderText = 'Select...';
|
||||||
const mapper = typeof mapFn === 'function'
|
const mapper = typeof mapFn === 'function'
|
||||||
? mapFn
|
? mapFn
|
||||||
: (value) => ({ value, label: value });
|
: (value) => ({ value: value, label: value });
|
||||||
|
|
||||||
selectEl.innerHTML = '';
|
selectEl.innerHTML = '';
|
||||||
|
|
||||||
@@ -331,11 +361,9 @@ class AssetMenu {
|
|||||||
placeholder.selected = true;
|
placeholder.selected = true;
|
||||||
selectEl.appendChild(placeholder);
|
selectEl.appendChild(placeholder);
|
||||||
|
|
||||||
items.forEach((item) => {
|
items.forEach(function (item) {
|
||||||
const option = mapper(item);
|
const option = mapper(item);
|
||||||
if (!option || typeof option.value === 'undefined') {
|
if (!option || typeof option.value === 'undefined') return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = option.value;
|
opt.value = option.value;
|
||||||
opt.textContent = option.label;
|
opt.textContent = option.label;
|
||||||
@@ -344,111 +372,112 @@ class AssetMenu {
|
|||||||
|
|
||||||
if (selectedValue) {
|
if (selectedValue) {
|
||||||
selectEl.value = selectedValue;
|
selectEl.value = selectedValue;
|
||||||
if (!selectEl.value) {
|
if (!selectEl.value) selectEl.value = '';
|
||||||
selectEl.value = '';
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
selectEl.value = '';
|
selectEl.value = '';
|
||||||
}
|
}
|
||||||
if (selectEl.value !== previous) {
|
|
||||||
selectEl.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveCategoryKey = () => {
|
function resolveCategoryKey() {
|
||||||
if (node.softwareType && categories[node.softwareType]) {
|
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
|
||||||
return node.softwareType;
|
if (node.category && categories[node.category]) return node.category;
|
||||||
}
|
|
||||||
if (node.category && categories[node.category]) {
|
|
||||||
return node.category;
|
|
||||||
}
|
|
||||||
return defaultCategory;
|
return defaultCategory;
|
||||||
};
|
}
|
||||||
|
function getActiveCategory() {
|
||||||
const getActiveCategory = () => {
|
|
||||||
const key = resolveCategoryKey();
|
const key = resolveCategoryKey();
|
||||||
return key ? categories[key] : null;
|
return key ? categories[key] : null;
|
||||||
};
|
}
|
||||||
|
|
||||||
node.category = resolveCategoryKey();
|
node.category = resolveCategoryKey();
|
||||||
|
|
||||||
elems.supplier.addEventListener('change', () => {
|
// Lookup helpers — read from the *currently selected* values in the
|
||||||
const category = getActiveCategory();
|
// DOM, not from node.* (which may not yet be in sync).
|
||||||
const supplier = category
|
function findSupplier() {
|
||||||
? category.suppliers.find(
|
const cat = getActiveCategory();
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
if (!cat || !Array.isArray(cat.suppliers)) return null;
|
||||||
)
|
const id = String(elems.supplier.value);
|
||||||
: null;
|
return cat.suppliers.find(function (s) {
|
||||||
|
return String(s.id || s.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findType(supplier) {
|
||||||
|
if (!supplier || !Array.isArray(supplier.types)) return null;
|
||||||
|
const id = String(elems.type.value);
|
||||||
|
return supplier.types.find(function (t) {
|
||||||
|
return String(t.id || t.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
function findModel(type) {
|
||||||
|
if (!type || !Array.isArray(type.models)) return null;
|
||||||
|
const id = String(elems.model.value);
|
||||||
|
return type.models.find(function (m) {
|
||||||
|
return String(m.id || m.name) === id;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Cascade rebuild functions ==========================
|
||||||
|
// Each one rebuilds the dropdown for the *level it owns* plus all
|
||||||
|
// levels below it, using the current values in the DOM. Called by
|
||||||
|
// the corresponding change handler AND by initial load so both
|
||||||
|
// paths produce identical state.
|
||||||
|
|
||||||
|
function cascadeFromSupplier() {
|
||||||
|
const supplier = findSupplier();
|
||||||
const types = supplier ? supplier.types : [];
|
const types = supplier ? supplier.types : [];
|
||||||
populate(
|
populate(
|
||||||
elems.type,
|
elems.type,
|
||||||
types,
|
types,
|
||||||
node.assetType,
|
node.assetType,
|
||||||
(type) => ({ value: type.id || type.name, label: type.name }),
|
function (t) { return { value: t.id || t.name, label: t.name }; },
|
||||||
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
supplier ? 'Select...' : 'Awaiting Supplier Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
// After repopulating type, propagate down. cascadeFromType()
|
||||||
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
|
// will read the new elems.type.value (which was set by populate
|
||||||
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
|
// to either the saved node.assetType or '') and rebuild model.
|
||||||
});
|
cascadeFromType();
|
||||||
|
}
|
||||||
|
|
||||||
elems.type.addEventListener('change', () => {
|
function cascadeFromType() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const models = type ? type.models : [];
|
const models = type ? type.models : [];
|
||||||
populate(
|
populate(
|
||||||
elems.model,
|
elems.model,
|
||||||
models,
|
models,
|
||||||
node.model,
|
node.model,
|
||||||
(model) => ({ value: model.id || model.name, label: model.name }),
|
function (m) { return { value: m.id || m.name, label: m.name }; },
|
||||||
type ? 'Select...' : 'Awaiting Type Selection'
|
type ? 'Select...' : 'Awaiting Type Selection'
|
||||||
);
|
);
|
||||||
node.modelMetadata = null;
|
node.modelMetadata = null;
|
||||||
populate(
|
cascadeFromModel();
|
||||||
elems.unit,
|
}
|
||||||
[],
|
|
||||||
'',
|
|
||||||
undefined,
|
|
||||||
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
elems.model.addEventListener('change', () => {
|
function cascadeFromModel() {
|
||||||
const category = getActiveCategory();
|
const supplier = findSupplier();
|
||||||
const supplier = category
|
const type = findType(supplier);
|
||||||
? category.suppliers.find(
|
const model = findModel(type);
|
||||||
(item) => String(item.id || item.name) === String(elems.supplier.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const type = supplier
|
|
||||||
? supplier.types.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.type.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const model = type
|
|
||||||
? type.models.find(
|
|
||||||
(item) => String(item.id || item.name) === String(elems.model.value)
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
node.modelMetadata = model;
|
node.modelMetadata = model;
|
||||||
node.modelName = model ? model.name : '';
|
node.modelName = model ? model.name : '';
|
||||||
populate(
|
populate(
|
||||||
elems.unit,
|
elems.unit,
|
||||||
model ? model.units || [] : [],
|
model ? (model.units || []) : [],
|
||||||
node.unit,
|
node.unit,
|
||||||
(unit) => ({ value: unit, label: unit }),
|
function (u) { return { value: u, label: u }; },
|
||||||
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
|
model ? 'Select...' : (type ? 'Awaiting Model Selection' : 'Awaiting Type Selection')
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
elems.supplier.addEventListener('change', cascadeFromSupplier);
|
||||||
|
elems.type.addEventListener('change', cascadeFromType);
|
||||||
|
elems.model.addEventListener('change', cascadeFromModel);
|
||||||
|
|
||||||
|
// Expose the cascades so loadData() (or future code) can re-run
|
||||||
|
// them after async data arrives without duplicating logic.
|
||||||
|
window.EVOLV.nodes.${nodeName}.assetMenu._cascade = {
|
||||||
|
fromSupplier: cascadeFromSupplier,
|
||||||
|
fromType: cascadeFromType,
|
||||||
|
fromModel: cascadeFromModel,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,15 +85,46 @@ class state{
|
|||||||
this.emitter.emit("movementComplete", { position: targetPosition });
|
this.emitter.emit("movementComplete", { position: targetPosition });
|
||||||
await this.transitionToState("operational");
|
await this.transitionToState("operational");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(error);
|
// Abort path: only return to 'operational' when explicitly requested
|
||||||
|
// (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC
|
||||||
|
// demand-update aborts must NOT auto-transition — doing so causes a
|
||||||
|
// bounce loop where every tick aborts → operational → new move →
|
||||||
|
// abort → operational → ... and the pump never reaches its setpoint.
|
||||||
|
const msg = typeof error === 'string' ? error : error?.message;
|
||||||
|
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
|
||||||
|
if (this._returnToOperationalOnAbort) {
|
||||||
|
this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`);
|
||||||
|
try {
|
||||||
|
await this.transitionToState("operational");
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug(`Movement aborted; staying in current state (routine abort).`);
|
||||||
|
}
|
||||||
|
this._returnToOperationalOnAbort = false;
|
||||||
|
this.emitter.emit("movementAborted", { position: targetPosition });
|
||||||
|
} else {
|
||||||
|
this.logger.error(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------- State Transition Methods -------- //
|
// -------- State Transition Methods -------- //
|
||||||
|
|
||||||
abortCurrentMovement(reason = "group override") {
|
/**
|
||||||
|
* @param {string} reason - human-readable abort reason
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.returnToOperational=false] - when true the FSM
|
||||||
|
* transitions back to 'operational' after the abort so a subsequent
|
||||||
|
* shutdown/emergency-stop sequence can proceed. Set to false (default)
|
||||||
|
* for routine demand updates where the caller will send a new movement
|
||||||
|
* immediately — auto-transitioning would cause a bounce loop.
|
||||||
|
*/
|
||||||
|
abortCurrentMovement(reason = "group override", options = {}) {
|
||||||
if (this.abortController && !this.abortController.signal.aborted) {
|
if (this.abortController && !this.abortController.signal.aborted) {
|
||||||
this.logger.warn(`Aborting movement: ${reason}`);
|
this.logger.warn(`Aborting movement: ${reason}`);
|
||||||
|
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user