Compare commits

...

3 Commits

Author SHA1 Message Date
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
6 changed files with 103 additions and 7 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

@@ -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": {

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

@@ -85,7 +85,21 @@ 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: return to 'operational' so a subsequent shutdown/emergency
// sequence can proceed. Without this, the FSM remains stuck in
// accelerating/decelerating and blocks stopping/idle transitions.
const msg = typeof error === 'string' ? error : error?.message;
if (msg === 'Transition aborted' || msg === 'Movement aborted') {
this.logger.debug(`Movement aborted; returning to 'operational' to unblock further transitions.`);
try {
await this.transitionToState("operational");
} catch (e) {
this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`);
}
this.emitter.emit("movementAborted", { position: targetPosition });
} else {
this.logger.error(error);
}
} }
} }