Compare commits

8 Commits

Author SHA1 Message Date
znetsixe
4252292ae1 pumpingStation schema: rename basin/control thresholds to wiki naming
Matches the 5-threshold convention (dryRunLevel, minLevel, startLevel,
maxLevel, overflowLevel) introduced in the pumpingStation wiki:

  basin.heightInlet              → basin.inflowLevel
  basin.heightOutlet             → basin.outflowLevel
  basin.heightOverflow           → basin.overflowLevel
  control.levelbased.stopLevel   → control.levelbased.minLevel
  control.levelbased.maxFlowLevel → control.levelbased.maxLevel
  control.levelbased.minFlowLevel → removed (redundant with startLevel)
  control.levelbased.startLevel  → unchanged

Description strings tightened to reference the semantic role instead
of generic "min level to scale flow" prose.

Breaking change for existing saved flows. Ties in with pumpingStation
commit a218945 which updates the consumer code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 16:14:15 +02:00
znetsixe
693517cc8f fix: conditional abort recovery — don't auto-transition on routine aborts
The unconditional transition to 'operational' after every movement abort
caused a bounce loop when MGC called abortActiveMovements on each demand
tick: abort→operational→new-flowmovement→abort→operational→... endlessly.
Pumps never reached their setpoint.

Fix: abortCurrentMovement now takes an options.returnToOperational flag
(default false). Routine MGC aborts leave the pump in accelerating/
decelerating — the pump continues its residual movement and reaches
operational naturally. Shutdown/emergency-stop paths pass
returnToOperational:true so the FSM unblocks for the stopping transition.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:01:41 +02:00
znetsixe
086e5fe751 fix: remove bogus machineCurve default that poisoned prediction splines
The schema default for machineCurve.nq had a dummy pressure slice at
key "1" with x=[1..5] y=[10..50]. configUtils.updateConfig deep-merges
defaults into the real config, so this fake slice survived alongside the
real pressure slices (70000, 80000, ..., 390000 Pa). The predict class
then included it in its pressure-dimension spline, pulling all
interpolated y-values toward the dummy data at low pressures and
producing NEGATIVE flow predictions (e.g. -243 m³/h) where the real
curve is strictly positive.

Fix: default to empty objects {nq: {}, np: {}} so the deep merge adds
nothing. The validateMachineCurve function already returns the whole
default if the real curve is missing or invalid, so the empty default
doesn't break the no-curve-data path — it just stops poisoning the
real curve data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:27:59 +02:00
znetsixe
29b78a3f9b fix(childRegistrationUtils): alias rotatingmachine/machinegroupcontrol so production parents see them
The MGC and pumpingStation registerChild handlers dispatch on
softwareType === 'machine' / 'machinegroup' / 'pumpingstation' /
'measurement'. But buildConfig sets functionality.softwareType to the
lowercased node name, so in production rotatingMachine reports
'rotatingmachine' and machineGroupControl reports 'machinegroupcontrol'.
Result: the MGC <-> rotatingMachine and pumpingStation <-> MGC wiring
silently never hit the right branch in production, even though every
unit test passes (tests pass an already-aliased softwareType manually).

Fix: tiny SOFTWARE_TYPE_ALIASES map at the central registerChild
dispatcher in childRegistrationUtils. Real production names get
translated to the dispatch keys parents already check for, while tests
that pass already-aliased keys are unaffected (their values aren't in
the alias map and pass through unchanged).

  rotatingmachine        -> machine
  machinegroupcontrol    -> machinegroup

Verified end-to-end on Dockerized Node-RED: MGC now reports
'3 machine(s) connected' when wired to 3 rotatingMachine ports;
pumpingStation registers MGC as a machinegroup child and listens to
its predicted-flow stream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:53:21 +02:00
znetsixe
43f69066af fix(asset-menu): supplier->type->model cascade lost the model dropdown
Reproduction (any node using assetMenu — measurement, rotatingMachine,
pumpingStation, monster, …):
  open node -> pick Vega supplier -> pick Pressure type
  -> model dropdown stays "Awaiting Type Selection"

Root cause: two interacting bugs in the chained dropdown wiring.

1. populate() inside both wireEvents() and loadData() auto-dispatched a
   synthetic 'change' event whenever the value of the rebuilt <select>
   differed from before the rebuild. That meant rebuilding 'type' inside
   the supplier change handler could fire the *type* change handler
   mid-way through, populate the model dropdown, and then return — only
   for the supplier handler to continue and unconditionally call
   populate(elems.model, [], '', undefined, 'Awaiting Type Selection'),
   wiping the model dropdown back to empty.

2. loadData() ran the same auto-dispatch path, so on initial open of a
   saved node the synthetic change cascaded through wireEvents listeners
   AND loadData's own sequential populate calls double-populated each
   level. The visible state depended on which path won the race.

Fix: convert the chain to an explicit downward cascade.

- populate() no longer dispatches change events. It simply rebuilds the
  <select> with placeholder + options and assigns the requested value.
- New cascadeFromSupplier / cascadeFromType / cascadeFromModel helpers
  read the *current DOM value* of each upstream <select>, look up the
  matching item in menuData, and rebuild the next level — then call the
  next cascade explicitly. Order is now deterministic and the parent
  handler can never wipe the child after the child was populated.
- Each <select>'s native 'change' listener is just the corresponding
  cascade function. Same code path runs for user picks AND for initial
  load, so saved-node restore behaves identically to a fresh pick.
- The cascades are exposed under window.EVOLV.nodes.<name>.assetMenu._cascade
  so loadData (or future sync code) can re-run them after async data
  arrives without duplicating logic.

No new DOM dependencies, no test framework changes. Existing
generalFunctions tests still 52/61 (same 9 pre-existing failures
unrelated to this change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 14:50:45 +02:00
znetsixe
e50be2ee66 feat: permissive unit check for user-defined measurement types + measurement digital-mode schema
MeasurementContainer.isUnitCompatible now short-circuits to accept any unit
when the measurement type is not in the built-in measureMap. Known types
(pressure, flow, power, temperature, volume, length, mass, energy) still
validate strictly. This unblocks user-defined types in the measurement
node's new digital/MQTT mode — e.g. 'humidity' with unit '%', 'co2' with
'ppm' — without forcing those units into the convert-module unit system.

measurement.json schema: add 'mode.current' (analog | digital) and
'channels' (array) so the validator stops stripping them from the runtime
config. Ignored in analog mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:42:31 +02:00
znetsixe
75d16c620a fix: make movement abort unblock subsequent FSM transitions + add rotatingMachine schema keys
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>
2026-04-13 13:21:18 +02:00
znetsixe
024db5533a fix: correct 3 anomalous power values in hidrostal-H05K-S03R curve
At pressures 1600, 3200, and 3300 mbar, flow values had leaked into the
np (power) section. Replaced with linearly interpolated values from
adjacent pressure levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:37:06 +02:00
9 changed files with 265 additions and 166 deletions

View File

@@ -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
] ]

View File

@@ -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',

View File

@@ -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,

View File

@@ -235,7 +235,7 @@
"description": "Unit used for level related setpoints and thresholds." "description": "Unit used for level related setpoints and thresholds."
} }
}, },
"heightInlet": { "inflowLevel": {
"default": 2, "default": 2,
"rules": { "rules": {
"type": "number", "type": "number",
@@ -243,7 +243,7 @@
"description": "Height of the inlet pipe measured from the basin floor (m)." "description": "Height of the inlet pipe measured from the basin floor (m)."
} }
}, },
"heightOutlet": { "outflowLevel": {
"default": 0.2, "default": 0.2,
"rules": { "rules": {
"type": "number", "type": "number",
@@ -251,7 +251,7 @@
"description": "Height of the outlet pipe measured from the basin floor (m)." "description": "Height of the outlet pipe measured from the basin floor (m)."
} }
}, },
"heightOverflow": { "overflowLevel": {
"default": 2.5, "default": 2.5,
"rules": { "rules": {
"type": "number", "type": "number",
@@ -433,36 +433,28 @@
} }
}, },
"levelbased": { "levelbased": {
"minLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "Below this level the MGC shuts down all pumps (unconditional stop). Above dryRunLevel (safety), below startLevel (DEAD ZONE)."
}
},
"startLevel": { "startLevel": {
"default": 1, "default": 1,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,
"description": "start of pump / group when level reaches this in meters starting from bottom." "description": "Level at which the pump demand ramp begins at 0 %. Demand scales linearly from startLevel (0 %) to maxLevel (100 %)."
} }
}, },
"stopLevel": { "maxLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "stop of pump / group when level reaches this in meters starting from bottom"
}
},
"minFlowLevel": {
"default": 1,
"rules": {
"type": "number",
"min": 0,
"description": "min level to scale the flow lineair"
}
},
"maxFlowLevel": {
"default": 4, "default": 4,
"rules": { "rules": {
"type": "number", "type": "number",
"min": 0, "min": 0,
"description": "max level to scale the flow lineair" "description": "Level at which the pump demand saturates at 100 %. Above this, demand stays clamped."
} }
} }
}, },
@@ -638,14 +630,6 @@
"description": "Volume percentage below which dry run protection activates." "description": "Volume percentage below which dry run protection activates."
} }
}, },
"dryRunDebounceSeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Time the low-volume condition must persist before dry-run protection engages (seconds)."
}
},
"enableOverfillProtection": { "enableOverfillProtection": {
"default": true, "default": true,
"rules": { "rules": {
@@ -662,14 +646,6 @@
"description": "Volume percentage above which overfill protection activates." "description": "Volume percentage above which overfill protection activates."
} }
}, },
"overfillDebounceSeconds": {
"default": 30,
"rules": {
"type": "number",
"min": 0,
"description": "Time the high-volume condition must persist before overfill protection engages (seconds)."
}
},
"timeleftToFullOrEmptyThresholdSeconds": { "timeleftToFullOrEmptyThresholdSeconds": {
"default": 0, "default": 0,
"rules": { "rules": {

View File

@@ -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."

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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,
};
}; };
`; `;
} }

View File

@@ -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();
} }
} }