From d6f8af4395500443adc0c256740e2858986a138a Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 13 Apr 2026 14:00:34 +0200 Subject: [PATCH] fix(editor): make Input Mode the top-level switch, hide wrong-mode fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 = 'Digital mode with no channels — no measurements will be emitted.'; + 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 = '' + missing.join('; ') + ''; + } else { + channelsHint.innerHTML = '' + parsed.length + ' channel(s) defined: ' + parsed.map((c) => c.key).join(', ') + ''; + } + } catch (e) { + channelsHint.innerHTML = 'Invalid JSON: ' + e.message + ''; + } + } + 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"); + } + } }, }); @@ -179,69 +246,76 @@ +
-
- - -
Digital mode only. One entry per payload key. See README for schema.
+ +
+
+ + +
One entry per payload key. Each channel has its own type / position / unit / scaling / smoothing / outlier detection. See README for the full schema.
+
+
-
+ +
+
+ +
+ + + Enable input scaling? +
- -
- - - Enable input scaling? -
+ +
+ + +
- -
- - -
+
+ + +
-
- - -
+ +
+ + +
- -
- - -
+ +
+ + +
+
+ + +
- -
- - -
-
- - -
+ +
+ + + Activate internal simulation? +
- -
- - - Activate internal simulation? -
+ +
+ + +
- -
- - -
- - -
- - -
Number of samples for smoothing
+ +
+ + +
Number of samples for smoothing
+

diff --git a/src/nodeClass.js b/src/nodeClass.js index df7b60f..2893b47 100644 --- a/src/nodeClass.js +++ b/src/nodeClass.js @@ -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;