7 Commits

Author SHA1 Message Date
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
8 changed files with 251 additions and 128 deletions

View File

@@ -153,7 +153,7 @@
100
],
"y": [
52.14679487594751,
11.142207365162072,
20.746724065725342,
31.960270693111905,
45.6989826531509,
@@ -411,7 +411,7 @@
"y": [
8.219999984177646,
13.426327986363882,
57.998168647814666,
25.971821741448165,
42.997354839160536,
64.33911122026377
]
@@ -427,7 +427,7 @@
"y": [
8.219999984177646,
13.426327986363882,
53.35067019159144,
25.288156424842576,
42.48429874246399,
64.03769740244357
]

View File

@@ -109,7 +109,7 @@ class ConfigManager {
functionality: {
softwareType: nodeName.toLowerCase(),
positionVsParent: uiConfig.positionVsParent || 'atEquipment',
distance: uiConfig.hasDistance ? uiConfig.distance : undefined
distance: uiConfig.hasDistance ? uiConfig.distance : null
},
output: {
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": {
"enabled": {
"default": false,

View File

@@ -91,6 +91,54 @@
],
"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": {
@@ -234,42 +282,8 @@
},
"machineCurve": {
"default": {
"nq": {
"1": {
"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
]
}
}
"nq": {},
"np": {}
},
"rules": {
"type": "machineCurve",

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 {
constructor(mainClass) {
this.mainClass = mainClass;
@@ -15,7 +29,8 @@ class ChildRegistrationUtils {
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 id = child.config.general.id || name;

View File

@@ -141,11 +141,17 @@ class MeasurementContainer {
}
isUnitCompatible(measurementType, unit) {
const desc = this._describeUnit(unit);
if (!desc) return false;
// Unknown type (not in measureMap): accept any unit. This lets user-
// 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 expectedMeasure = this.measureMap[normalizedType];
if (!expectedMeasure) return true;
const desc = this._describeUnit(unit);
if (!desc) return false;
return desc.measure === expectedMeasure;
}

View File

@@ -193,8 +193,13 @@ class AssetMenu {
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...') {
const previous = selectEl.value;
if (!selectEl) return;
const mapper = typeof mapFn === 'function'
? mapFn
: (value) => ({ value, label: value });
@@ -227,9 +232,6 @@ class AssetMenu {
} else {
selectEl.value = '';
}
if (selectEl.value !== previous) {
selectEl.dispatchEvent(new Event('change'));
}
}
const categoryKey = resolveCategoryKey();
@@ -305,6 +307,28 @@ class AssetMenu {
getEventInjectionCode(nodeName) {
return `
// 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) {
const menuAsset = window.EVOLV.nodes.${nodeName}.menuData.asset || {};
const categories = menuAsset.categories || {};
@@ -316,11 +340,17 @@ class AssetMenu {
unit: document.getElementById('node-input-unit')
};
function populate(selectEl, items = [], selectedValue, mapFn, placeholderText = 'Select...') {
const previous = selectEl.value;
// populate(): rebuild a <select> with a placeholder + items.
// 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'
? mapFn
: (value) => ({ value, label: value });
: (value) => ({ value: value, label: value });
selectEl.innerHTML = '';
@@ -331,11 +361,9 @@ class AssetMenu {
placeholder.selected = true;
selectEl.appendChild(placeholder);
items.forEach((item) => {
items.forEach(function (item) {
const option = mapper(item);
if (!option || typeof option.value === 'undefined') {
return;
}
if (!option || typeof option.value === 'undefined') return;
const opt = document.createElement('option');
opt.value = option.value;
opt.textContent = option.label;
@@ -344,111 +372,112 @@ class AssetMenu {
if (selectedValue) {
selectEl.value = selectedValue;
if (!selectEl.value) {
selectEl.value = '';
}
if (!selectEl.value) selectEl.value = '';
} else {
selectEl.value = '';
}
if (selectEl.value !== previous) {
selectEl.dispatchEvent(new Event('change'));
}
}
const resolveCategoryKey = () => {
if (node.softwareType && categories[node.softwareType]) {
return node.softwareType;
}
if (node.category && categories[node.category]) {
return node.category;
}
function resolveCategoryKey() {
if (node.softwareType && categories[node.softwareType]) return node.softwareType;
if (node.category && categories[node.category]) return node.category;
return defaultCategory;
};
const getActiveCategory = () => {
}
function getActiveCategory() {
const key = resolveCategoryKey();
return key ? categories[key] : null;
};
}
node.category = resolveCategoryKey();
elems.supplier.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? category.suppliers.find(
(item) => String(item.id || item.name) === String(elems.supplier.value)
)
: null;
// Lookup helpers — read from the *currently selected* values in the
// DOM, not from node.* (which may not yet be in sync).
function findSupplier() {
const cat = getActiveCategory();
if (!cat || !Array.isArray(cat.suppliers)) return null;
const id = String(elems.supplier.value);
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 : [];
populate(
elems.type,
types,
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'
);
node.modelMetadata = null;
populate(elems.model, [], '', undefined, 'Awaiting Type Selection');
populate(elems.unit, [], '', undefined, 'Awaiting Type Selection');
});
// After repopulating type, propagate down. cascadeFromType()
// will read the new elems.type.value (which was set by populate
// to either the saved node.assetType or '') and rebuild model.
cascadeFromType();
}
elems.type.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? 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;
function cascadeFromType() {
const supplier = findSupplier();
const type = findType(supplier);
const models = type ? type.models : [];
populate(
elems.model,
models,
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'
);
node.modelMetadata = null;
populate(
elems.unit,
[],
'',
undefined,
type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
);
});
cascadeFromModel();
}
elems.model.addEventListener('change', () => {
const category = getActiveCategory();
const supplier = category
? 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 model = type
? type.models.find(
(item) => String(item.id || item.name) === String(elems.model.value)
)
: null;
function cascadeFromModel() {
const supplier = findSupplier();
const type = findType(supplier);
const model = findModel(type);
node.modelMetadata = model;
node.modelName = model ? model.name : '';
populate(
elems.unit,
model ? model.units || [] : [],
model ? (model.units || []) : [],
node.unit,
(unit) => ({ value: unit, label: unit }),
model ? 'Select...' : type ? 'Awaiting Model Selection' : 'Awaiting Type Selection'
function (u) { return { value: u, label: u }; },
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 });
await this.transitionToState("operational");
} catch (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 -------- //
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) {
this.logger.warn(`Aborting movement: ${reason}`);
this._returnToOperationalOnAbort = Boolean(options.returnToOperational);
this.abortController.abort();
}
}