fix(editor): make Input Mode the top-level switch, hide wrong-mode fields
Prior behaviour: the Mode dropdown existed but nothing consumed it in the editor — analog fields (Scaling, Source Min/Max, Smoothing, …) were always visible, and the Channels JSON editor was always visible too. For a legacy node with no saved mode the dropdown defaulted blank so users reported "I cant even select digital or analog". Changes: - Initialize the Mode <select> from node.mode with an 'analog' fallback for legacy nodes (safe default — matches pre-digital behaviour). - Wrap analog-only fields and digital-only fields in labelled containers and toggle their display based on the selected mode. Mode change is live — no redeploy needed to see the right form. - Inline hint under the Mode dropdown tells the user what payload shape is expected for the current mode. - Channels JSON gets live validation — shows channel count + names on valid JSON, warns on missing key/type, errors on invalid JSON. - Label function appends ' [digital]' so the node visibly differs in a flow from an analog sibling. - oneditsave is mode-aware: only warns about incomplete scaling ranges in analog mode; in digital mode warns if the channels array is empty or unparseable. Runtime friendliness: - nodeClass node-status now shows 'digital · N channel(s)' on startup in digital mode, and 'digital · N/M ch updated' after each incoming msg so the editor has a live heartbeat even when there is no single scalar. - When analog mode receives an object payload (or digital receives a number), the node logs an actionable warn suggesting the mode switch instead of silently dropping the message. Explicit, not auto-detected: mode remains a deployment-time choice because the two modes take different editor config (scaling/smoothing vs channels map). Auto-detecting at runtime would leave the node unconfigured in whichever mode the user hadn't anticipated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
268
measurement.html
268
measurement.html
@@ -67,62 +67,110 @@
|
||||
icon: "font-awesome/fa-sliders",
|
||||
|
||||
label: function () {
|
||||
return (this.positionIcon || "") + " " + (this.assetType || "Measurement");
|
||||
const modeTag = this.mode === 'digital' ? ' [digital]' : '';
|
||||
return (this.positionIcon || "") + " " + (this.assetType || "Measurement") + modeTag;
|
||||
},
|
||||
|
||||
oneditprepare: function() {
|
||||
const node = this;
|
||||
|
||||
// === Mode selector — TOP-LEVEL hierarchy ===
|
||||
// The Input Mode drives whether analog-pipeline fields or the
|
||||
// digital channels editor are shown. Initialize the <select> from
|
||||
// the saved node value, fall back to 'analog' for legacy nodes
|
||||
// that were saved before the mode field existed.
|
||||
const modeSelect = document.getElementById('node-input-mode');
|
||||
const initialMode = (node.mode === 'digital' || node.mode === 'analog') ? node.mode : 'analog';
|
||||
modeSelect.value = initialMode;
|
||||
|
||||
const analogBlock = document.getElementById('analog-only-fields');
|
||||
const digitalBlock = document.getElementById('digital-only-fields');
|
||||
const modeHint = document.getElementById('mode-hint');
|
||||
|
||||
function applyMode(mode) {
|
||||
const isDigital = mode === 'digital';
|
||||
analogBlock.style.display = isDigital ? 'none' : 'block';
|
||||
digitalBlock.style.display = isDigital ? 'block' : 'none';
|
||||
if (modeHint) {
|
||||
modeHint.textContent = isDigital
|
||||
? 'msg.payload must be an OBJECT, e.g. {"temperature": 22.5, "humidity": 45}. Define each key below.'
|
||||
: 'msg.payload must be a NUMBER (or numeric string). Configure scaling/smoothing below.';
|
||||
}
|
||||
validateChannelsJson();
|
||||
}
|
||||
|
||||
modeSelect.addEventListener('change', (e) => applyMode(e.target.value));
|
||||
applyMode(initialMode);
|
||||
|
||||
// === Channels JSON live validation (digital only) ===
|
||||
const channelsArea = document.getElementById('node-input-channels');
|
||||
const channelsHint = document.getElementById('channels-validation');
|
||||
if (channelsArea && typeof node.channels === 'string') {
|
||||
channelsArea.value = node.channels;
|
||||
}
|
||||
function validateChannelsJson() {
|
||||
if (!channelsHint) return;
|
||||
if (modeSelect.value !== 'digital') { channelsHint.textContent = ''; return; }
|
||||
const raw = (channelsArea.value || '').trim();
|
||||
if (!raw || raw === '[]') {
|
||||
channelsHint.innerHTML = '<span style="color:#b45309;">Digital mode with no channels — no measurements will be emitted.</span>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) throw new Error('must be an array');
|
||||
const missing = parsed
|
||||
.map((c, i) => (c && c.key && c.type ? null : `entry ${i}: missing key or type`))
|
||||
.filter(Boolean);
|
||||
if (missing.length) {
|
||||
channelsHint.innerHTML = '<span style="color:#b45309;">' + missing.join('; ') + '</span>';
|
||||
} else {
|
||||
channelsHint.innerHTML = '<span style="color:#047857;">' + parsed.length + ' channel(s) defined: ' + parsed.map((c) => c.key).join(', ') + '</span>';
|
||||
}
|
||||
} catch (e) {
|
||||
channelsHint.innerHTML = '<span style="color:#b91c1c;">Invalid JSON: ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
if (channelsArea) channelsArea.addEventListener('input', validateChannelsJson);
|
||||
|
||||
// === Asset / logger / position placeholders (dynamic menus) ===
|
||||
const waitForMenuData = () => {
|
||||
if (window.EVOLV?.nodes?.measurement?.initEditor) {
|
||||
window.EVOLV.nodes.measurement.initEditor(this);
|
||||
window.EVOLV.nodes.measurement.initEditor(node);
|
||||
} else {
|
||||
setTimeout(waitForMenuData, 50);
|
||||
}
|
||||
};
|
||||
// Wait for the menu data to be ready before initializing the editor
|
||||
waitForMenuData();
|
||||
|
||||
// THIS IS NODE SPECIFIC --------------- Initialize the dropdowns and other specific UI elements -------------- this should be derived from the config in the future (make config based menu)
|
||||
// Populate smoothing methods dropdown
|
||||
|
||||
// === Smoothing method dropdown (analog only) ===
|
||||
const smoothMethodSelect = document.getElementById('node-input-smooth_method');
|
||||
const options = window.EVOLV?.nodes?.measurement?.config?.smoothing?.smoothMethod?.rules?.values || [];
|
||||
|
||||
// Clear existing options
|
||||
smoothMethodSelect.innerHTML = '';
|
||||
|
||||
// Add empty option
|
||||
const emptyOption = document.createElement('option');
|
||||
emptyOption.value = '';
|
||||
emptyOption.textContent = 'Select method...';
|
||||
smoothMethodSelect.appendChild(emptyOption);
|
||||
|
||||
// Add smoothing method options
|
||||
options.forEach(option => {
|
||||
const optionElement = document.createElement('option');
|
||||
optionElement.value = option.value;
|
||||
optionElement.textContent = option.value;
|
||||
optionElement.title = option.description; // Add tooltip with full description
|
||||
smoothMethodSelect.appendChild(optionElement);
|
||||
const optionElement = document.createElement('option');
|
||||
optionElement.value = option.value;
|
||||
optionElement.textContent = option.value;
|
||||
optionElement.title = option.description;
|
||||
smoothMethodSelect.appendChild(optionElement);
|
||||
});
|
||||
|
||||
// Set current value if it exists
|
||||
if (this.smooth_method) {
|
||||
smoothMethodSelect.value = this.smooth_method;
|
||||
if (node.smooth_method) smoothMethodSelect.value = node.smooth_method;
|
||||
|
||||
// === Scale rows toggle (analog only) ===
|
||||
const chk = document.getElementById('node-input-scaling');
|
||||
const rowMin = document.getElementById('row-input-i_min');
|
||||
const rowMax = document.getElementById('row-input-i_max');
|
||||
function toggleScalingRows() {
|
||||
const show = chk.checked;
|
||||
rowMin.style.display = show ? 'block' : 'none';
|
||||
rowMax.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// --- Scale rows toggle ---
|
||||
const chk = document.getElementById('node-input-scaling');
|
||||
const rowMin = document.getElementById('row-input-i_min');
|
||||
const rowMax = document.getElementById('row-input-i_max');
|
||||
|
||||
function toggleScalingRows() {
|
||||
const show = chk.checked;
|
||||
rowMin.style.display = show ? 'block' : 'none';
|
||||
rowMax.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// wire and initialize
|
||||
chk.addEventListener('change', toggleScalingRows);
|
||||
toggleScalingRows();
|
||||
chk.addEventListener('change', toggleScalingRows);
|
||||
toggleScalingRows();
|
||||
|
||||
//------------------- END OF CUSTOM config UI ELEMENTS ------------------- //
|
||||
},
|
||||
@@ -144,12 +192,20 @@
|
||||
window.EVOLV.nodes.measurement.positionMenu.saveEditor(this);
|
||||
}
|
||||
|
||||
// Save basic properties
|
||||
["smooth_method", "mode", "channels"].forEach(
|
||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).value || "")
|
||||
);
|
||||
// Mode is the top-level switch. Always save it first; its value
|
||||
// drives which other fields are meaningful.
|
||||
node.mode = document.getElementById('node-input-mode').value || 'analog';
|
||||
|
||||
// Save numeric and boolean properties
|
||||
// Channels JSON (digital). We store the raw string and let the
|
||||
// server-side nodeClass.js parse it so we can surface parse errors
|
||||
// at deploy time instead of silently dropping bad config.
|
||||
node.channels = document.getElementById('node-input-channels').value || '[]';
|
||||
|
||||
// Analog smoothing method.
|
||||
node.smooth_method = document.getElementById('node-input-smooth_method').value || '';
|
||||
|
||||
// Save checkbox properties (always safe to read regardless of mode;
|
||||
// these elements exist in the DOM even when their section is hidden).
|
||||
["scaling", "simulator"].forEach(
|
||||
(field) => (node[field] = document.getElementById(`node-input-${field}`).checked)
|
||||
);
|
||||
@@ -158,11 +214,22 @@
|
||||
(field) => (node[field] = parseFloat(document.getElementById(`node-input-${field}`).value) || 0)
|
||||
);
|
||||
|
||||
// Validation checks
|
||||
if (node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
||||
// Mode-dependent validation. In digital mode we don't care about
|
||||
// scaling completeness (the channels have their own per-channel
|
||||
// scaling); in analog mode we still warn about half-filled ranges.
|
||||
if (node.mode === 'analog' && node.scaling && (isNaN(node.i_min) || isNaN(node.i_max))) {
|
||||
RED.notify("Scaling enabled, but input range is incomplete!", "error");
|
||||
}
|
||||
|
||||
if (node.mode === 'digital') {
|
||||
try {
|
||||
const parsed = JSON.parse(node.channels || '[]');
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
RED.notify("Digital mode: no channels defined. The node will emit nothing.", "warning");
|
||||
}
|
||||
} catch (e) {
|
||||
RED.notify("Digital mode: Channels JSON is invalid (" + e.message + ")", "error");
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -179,69 +246,76 @@
|
||||
<option value="digital">digital — object payload with many channel keys (MQTT/IoT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row" id="mode-hint" style="margin-left:105px; font-size:12px; color:#666;"></div>
|
||||
|
||||
<div class="form-row" id="row-input-channels">
|
||||
<label for="node-input-channels"><i class="fa fa-list"></i> Channels (JSON)</label>
|
||||
<textarea id="node-input-channels" rows="6" style="width:60%; font-family:monospace;" placeholder='[{"key":"temperature","type":"temperature","position":"atEquipment","unit":"C","scaling":{"enabled":false,"inputMin":0,"inputMax":1,"absMin":-50,"absMax":150,"offset":0},"smoothing":{"smoothWindow":5,"smoothMethod":"mean"}}]'></textarea>
|
||||
<div class="form-tips">Digital mode only. One entry per payload key. See README for schema.</div>
|
||||
<!-- ===================== DIGITAL MODE FIELDS ===================== -->
|
||||
<div id="digital-only-fields">
|
||||
<div class="form-row" id="row-input-channels">
|
||||
<label for="node-input-channels"><i class="fa fa-list"></i> Channels (JSON)</label>
|
||||
<textarea id="node-input-channels" rows="6" style="width:60%; font-family:monospace;" placeholder='[{"key":"temperature","type":"temperature","position":"atEquipment","unit":"C","scaling":{"enabled":false,"inputMin":0,"inputMax":1,"absMin":-50,"absMax":150,"offset":0},"smoothing":{"smoothWindow":5,"smoothMethod":"mean"}}]'></textarea>
|
||||
<div class="form-tips">One entry per payload key. Each channel has its own type / position / unit / scaling / smoothing / outlier detection. See README for the full schema.</div>
|
||||
</div>
|
||||
<div class="form-row" id="channels-validation" style="margin-left:105px; font-size:12px;"></div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<!-- ===================== ANALOG MODE FIELDS ===================== -->
|
||||
<div id="analog-only-fields">
|
||||
<hr>
|
||||
<!-- Scaling Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-scaling"
|
||||
><i class="fa fa-compress"></i> Scaling</label>
|
||||
<input type="checkbox" id="node-input-scaling" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Enable input scaling?</span>
|
||||
</div>
|
||||
|
||||
<!-- Scaling Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-scaling"
|
||||
><i class="fa fa-compress"></i> Scaling</label>
|
||||
<input type="checkbox" id="node-input-scaling" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Enable input scaling?</span>
|
||||
</div>
|
||||
<!-- Source Min/Max (only if scaling is true) -->
|
||||
<div class="form-row" id="row-input-i_min">
|
||||
<label for="node-input-i_min"><i class="fa fa-arrow-down"></i> Source Min</label>
|
||||
<input type="number" id="node-input-i_min" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<!-- Source Min/Max (only if scaling is true) -->
|
||||
<div class="form-row" id="row-input-i_min">
|
||||
<label for="node-input-i_min"><i class="fa fa-arrow-down"></i> Source Min</label>
|
||||
<input type="number" id="node-input-i_min" placeholder="0" />
|
||||
</div>
|
||||
<div class="form-row" id="row-input-i_max">
|
||||
<label for="node-input-i_max"><i class="fa fa-arrow-up"></i> Source Max</label>
|
||||
<input type="number" id="node-input-i_max" placeholder="3000" />
|
||||
</div>
|
||||
|
||||
<div class="form-row" id="row-input-i_max">
|
||||
<label for="node-input-i_max"><i class="fa fa-arrow-up"></i> Source Max</label>
|
||||
<input type="number" id="node-input-i_max" placeholder="3000" />
|
||||
</div>
|
||||
<!-- Offset -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-i_offset"><i class="fa fa-adjust"></i> Input Offset</label>
|
||||
<input type="number" id="node-input-i_offset" placeholder="0" />
|
||||
</div>
|
||||
|
||||
<!-- Offset -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-i_offset"><i class="fa fa-adjust"></i> Input Offset</label>
|
||||
<input type="number" id="node-input-i_offset" placeholder="0" />
|
||||
</div>
|
||||
<!-- Output / Process Min/Max -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
|
||||
<input type="number" id="node-input-o_min" placeholder="0" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
|
||||
<input type="number" id="node-input-o_max" placeholder="1" />
|
||||
</div>
|
||||
|
||||
<!-- Output / Process Min/Max -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_min"><i class="fa fa-tag"></i> Process Min</label>
|
||||
<input type="number" id="node-input-o_min" placeholder="0" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="node-input-o_max"><i class="fa fa-tag"></i> Process Max</label>
|
||||
<input type="number" id="node-input-o_max" placeholder="1" />
|
||||
</div>
|
||||
<!-- Simulator Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-simulator"><i class="fa fa-cog"></i> Simulator</label>
|
||||
<input type="checkbox" id="node-input-simulator" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Activate internal simulation?</span>
|
||||
</div>
|
||||
|
||||
<!-- Simulator Checkbox -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-simulator"><i class="fa fa-cog"></i> Simulator</label>
|
||||
<input type="checkbox" id="node-input-simulator" style="width:20px; vertical-align:baseline;"/>
|
||||
<span>Activate internal simulation?</span>
|
||||
</div>
|
||||
<!-- Smoothing Method -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-smooth_method"><i class="fa fa-line-chart"></i> Smoothing</label>
|
||||
<select id="node-input-smooth_method" style="width:60%;">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Smoothing Method -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-smooth_method"><i class="fa fa-line-chart"></i> Smoothing</label>
|
||||
<select id="node-input-smooth_method" style="width:60%;">
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Smoothing Window -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-count">Window</label>
|
||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||
<div class="form-tips">Number of samples for smoothing</div>
|
||||
<!-- Smoothing Window -->
|
||||
<div class="form-row">
|
||||
<label for="node-input-count">Window</label>
|
||||
<input type="number" id="node-input-count" placeholder="10" style="width:60px;"/>
|
||||
<div class="form-tips">Number of samples for smoothing</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
@@ -97,10 +97,18 @@ class nodeClass {
|
||||
*/
|
||||
_bindEvents() {
|
||||
|
||||
// Analog mode: the classic 'mAbs' event pushes a green dot with the
|
||||
// current value + unit to the editor.
|
||||
this.source.emitter.on('mAbs', (val) => {
|
||||
this.node.status({ fill: 'green', shape: 'dot', text: `${val} ${this.config.general.unit}` });
|
||||
});
|
||||
|
||||
|
||||
// Digital mode: summarise how many channels have ticked a value.
|
||||
// This runs on every accepted channel update so the editor shows live
|
||||
// activity instead of staying blank when no single scalar exists.
|
||||
if (this.source.mode === 'digital') {
|
||||
this.node.status({ fill: 'blue', shape: 'ring', text: `digital · ${this.source.channels.size} channel(s)` });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +176,16 @@ class nodeClass {
|
||||
// digital -> object payload keyed by channel name
|
||||
if (this.source.mode === 'digital') {
|
||||
if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||
this.source.handleDigitalPayload(msg.payload);
|
||||
const summary = this.source.handleDigitalPayload(msg.payload);
|
||||
// Summarise what actually got accepted on the node status so
|
||||
// the editor shows a heartbeat per message.
|
||||
const accepted = Object.values(summary).filter((s) => s.ok).length;
|
||||
const total = Object.keys(summary).length;
|
||||
this.node.status({ fill: 'green', shape: 'dot',
|
||||
text: `digital · ${accepted}/${total} ch updated` });
|
||||
} else if (typeof msg.payload === 'number') {
|
||||
// Helpful hint: the user probably configured the wrong mode.
|
||||
this.source.logger?.warn(`digital mode received a number (${msg.payload}); expected an object like {key: value, ...}. Switch Input Mode to 'analog' in the editor or send an object payload.`);
|
||||
} else {
|
||||
this.source.logger?.warn(`digital mode expects an object payload; got ${typeof msg.payload}`);
|
||||
}
|
||||
@@ -180,6 +197,11 @@ class nodeClass {
|
||||
} else {
|
||||
this.source.logger?.warn(`Invalid numeric measurement payload: ${msg.payload}`);
|
||||
}
|
||||
} else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
||||
// Helpful hint: the payload is object-shaped but the node is
|
||||
// configured analog. Most likely the user wanted digital mode.
|
||||
const keys = Object.keys(msg.payload).slice(0, 3).join(', ');
|
||||
this.source.logger?.warn(`analog mode received an object payload (keys: ${keys}). Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user