feat: digital (MQTT) mode + fix silent dispatcher bug for camelCase methods

Runtime:
- Fix silent no-op when user selected any camelCase smoothing or outlier
  method from the editor. validateEnum in generalFunctions lowercases enum
  values (zScore -> zscore, lowPass -> lowpass, ...) but the dispatcher
  compared against camelCase keys. Effect: 5 of 11 smoothing methods
  (lowPass, highPass, weightedMovingAverage, bandPass, savitzkyGolay) and
  2 of 3 outlier methods (zScore, modifiedZScore) silently fell through.
  Users got the raw last value or no outlier filtering with no error log.
  Review any pre-2026-04-13 flows that relied on these methods.
  Fix: normalize method names to lowercase on both sides of the lookup.

- New Channel class (src/channel.js) — self-contained per-channel pipeline:
  outlier -> offset -> scaling -> smoothing -> min/max -> constrain -> emit.
  Pure domain logic, no Node-RED deps, reusable by future nodes that need
  the same signal-conditioning chain.

Digital mode:
- config.mode.current = 'digital' opts in. config.channels declares one
  entry per expected JSON key; each channel has its own type, position,
  unit, distance, and optional scaling/smoothing/outlierDetection blocks
  that override the top-level analog-mode fields. One MQTT-shaped payload
  ({t:22.5, h:45, p:1013}) dispatches N independent pipelines and emits N
  MeasurementContainer slots from a single input message.
- Backward compatible: absent mode config = analog = pre-digital behaviour.
  Every existing measurement flow keeps working unchanged.

UI:
- HTML editor: new Mode dropdown and Channels JSON textarea. The Node-RED
  help panel is rewritten end-to-end with topic reference, port contracts,
  per-mode configuration, smoothing/outlier method tables, and a note
  about the pre-fix behaviour.
- README.md rewritten (was a one-line stub).

Tests (12 -> 71, all green):
- test/basic/smoothing-methods.basic.test.js (+16): every smoothing method
  including the formerly-broken camelCase ones.
- test/basic/outlier-detection.basic.test.js (+10): every outlier method,
  fall-through, toggle.
- test/basic/scaling-and-interpolation.basic.test.js (+10): offset,
  interpolateLinear, constrain, handleScaling edge cases, min/max
  tracking, updateOutputPercent fallback, updateOutputAbs emit dedup.
- test/basic/calibration-and-stability.basic.test.js (+11): calibrate
  (stable and unstable), isStable, evaluateRepeatability refusals,
  toggleSimulation, tick simulation on/off.
- test/integration/digital-mode.integration.test.js (+12): channel build
  (including malformed entries), payload dispatch, multi-channel emit,
  unknown keys, per-channel scaling/smoothing/outlier, empty channels,
  non-numeric value rejection, getDigitalOutput shape, analog-default
  back-compat.

E2E verified on Dockerized Node-RED: analog regression unchanged; digital
mode deploys with three channels, dispatches MQTT-style payload, emits
per-channel events, accumulates per-channel smoothing, ignores unknown
keys.

Depends on generalFunctions commit e50be2e (permissive unit check +
mode/channels schema).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-04-13 13:43:03 +02:00
parent 0918be7705
commit 495b4cf400
10 changed files with 1367 additions and 45 deletions

View File

@@ -0,0 +1,222 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Measurement = require('../../src/specificClass');
/**
* Integration tests for digital mode.
*
* Digital mode accepts an object payload where each key maps to its own
* independently-configured Channel (scaling / smoothing / outlier / unit /
* position). A single inbound message can therefore emit N measurements
* into the MeasurementContainer in one go — the MQTT / JSON IoT pattern
* the analog-centric node previously did not support.
*/
function makeDigitalConfig(channels, overrides = {}) {
return {
general: { id: 'm-dig-1', name: 'weather-station', unit: 'unitless', logging: { enabled: false, logLevel: 'error' } },
asset: { type: 'pressure', unit: 'mbar', category: 'sensor', supplier: 'vendor', model: 'BME280' },
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
simulation: { enabled: false },
functionality: { positionVsParent: 'atEquipment', distance: null },
mode: { current: 'digital' },
channels,
...overrides,
};
}
test('analog-mode default: no channels built, handleDigitalPayload is a no-op', () => {
// Factory without mode config — defaults must stay analog.
const m = new Measurement({
general: { id: 'a', name: 'a', unit: 'bar', logging: { enabled: false, logLevel: 'error' } },
asset: { type: 'pressure', unit: 'bar', category: 'sensor', supplier: 'v', model: 'M' },
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 1, offset: 0 },
smoothing: { smoothWindow: 5, smoothMethod: 'none' },
simulation: { enabled: false },
functionality: { positionVsParent: 'atEquipment' },
});
assert.equal(m.mode, 'analog');
assert.equal(m.channels.size, 0);
// In analog mode, handleDigitalPayload must refuse and not mutate state.
const res = m.handleDigitalPayload({ temperature: 21 });
assert.deepEqual(res, {});
});
test('digital mode builds one Channel per config.channels entry', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
{ key: 'pressure', type: 'pressure', position: 'atEquipment', unit: 'mbar',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 800, absMax: 1200, offset: 0 },
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
]));
assert.equal(m.mode, 'digital');
assert.equal(m.channels.size, 3);
assert.ok(m.channels.has('temperature'));
assert.ok(m.channels.has('humidity'));
assert.ok(m.channels.has('pressure'));
});
test('digital payload routes each key to its own channel', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 'temperature', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
{ key: 'humidity', type: 'humidity', position: 'atEquipment', unit: '%',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
m.handleDigitalPayload({ temperature: 21.5, humidity: 65 });
const tempOut = m.channels.get('temperature').outputAbs;
const humidOut = m.channels.get('humidity').outputAbs;
assert.equal(tempOut, 21.5);
assert.equal(humidOut, 65);
});
test('digital payload emits on the MeasurementContainer per channel', async () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
const events = [];
m.measurements.emitter.on('temperature.measured.atequipment', (e) => events.push({ on: 't', value: e.value }));
m.measurements.emitter.on('humidity.measured.atequipment', (e) => events.push({ on: 'h', value: e.value }));
m.handleDigitalPayload({ t: 22, h: 50 });
await new Promise((r) => setImmediate(r));
assert.equal(events.filter((e) => e.on === 't').length, 1);
assert.equal(events.filter((e) => e.on === 'h').length, 1);
assert.equal(events.find((e) => e.on === 't').value, 22);
assert.equal(events.find((e) => e.on === 'h').value, 50);
});
test('digital payload with unmapped keys silently ignores them', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
const res = m.handleDigitalPayload({ t: 20, unknown: 999, extra: 'x' });
assert.equal(m.channels.get('t').outputAbs, 20);
assert.equal(res.t.ok, true);
assert.equal(res.unknown, undefined);
assert.equal(res.extra, undefined);
});
test('digital channel with scaling enabled maps input to abs range', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 'pt', type: 'pressure', position: 'atEquipment', unit: 'mbar',
scaling: { enabled: true, inputMin: 0, inputMax: 100, absMin: 0, absMax: 1000, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
m.handleDigitalPayload({ pt: 50 });
// 50% of [0..100] -> 50% of [0..1000] = 500
assert.equal(m.channels.get('pt').outputAbs, 500);
});
test('digital channel smoothing accumulates per-channel, independent of siblings', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 3, smoothMethod: 'mean' } },
]));
// Feed only temperature across 3 pushes; humidity never receives a value.
m.handleDigitalPayload({ t: 10 });
m.handleDigitalPayload({ t: 20 });
m.handleDigitalPayload({ t: 30 });
assert.equal(m.channels.get('t').outputAbs, 20); // mean(10,20,30)=20
assert.equal(m.channels.get('t').storedValues.length, 3);
// Humidity channel must be untouched.
assert.equal(m.channels.get('h').storedValues.length, 0);
assert.equal(m.channels.get('h').outputAbs, 0);
});
test('digital channel rejects non-numeric values in summary', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
const res = m.handleDigitalPayload({ t: 'banana' });
assert.equal(res.t.ok, false);
assert.equal(res.t.reason, 'non-numeric');
assert.equal(m.channels.get('t').outputAbs, 0);
});
test('digital channel supports per-channel outlier detection', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 10, smoothMethod: 'none' },
outlierDetection: { enabled: true, method: 'zscore', threshold: 3 } },
]));
// Seed a tight baseline then lob an obvious spike.
for (const v of [20, 20, 20, 20, 20, 20]) m.handleDigitalPayload({ t: v });
const baselineOut = m.channels.get('t').outputAbs;
m.handleDigitalPayload({ t: 1e6 });
assert.equal(m.channels.get('t').outputAbs, baselineOut, 'spike must be rejected as outlier');
});
test('getDigitalOutput produces one entry per channel', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 't', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: -50, absMax: 150, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
{ key: 'h', type: 'humidity', position: 'atEquipment', unit: '%',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
]));
m.handleDigitalPayload({ t: 25, h: 40 });
const out = m.getDigitalOutput();
assert.ok(out.channels.t);
assert.ok(out.channels.h);
assert.equal(out.channels.t.mAbs, 25);
assert.equal(out.channels.h.mAbs, 40);
assert.equal(out.channels.t.type, 'temperature');
assert.equal(out.channels.h.unit, '%');
});
test('digital mode with empty channels array still constructs cleanly', () => {
const m = new Measurement(makeDigitalConfig([]));
assert.equal(m.mode, 'digital');
assert.equal(m.channels.size, 0);
// No throw on empty payload.
assert.deepEqual(m.handleDigitalPayload({ anything: 1 }), {});
});
test('digital mode ignores malformed channel entries in config', () => {
const m = new Measurement(makeDigitalConfig([
{ key: 'valid', type: 'temperature', position: 'atEquipment', unit: 'C',
scaling: { enabled: false, inputMin: 0, inputMax: 1, absMin: 0, absMax: 100, offset: 0 },
smoothing: { smoothWindow: 1, smoothMethod: 'none' } },
null, // malformed
{ key: 'no_type' }, // missing type
{ type: 'pressure' }, // missing key
]));
assert.equal(m.channels.size, 1);
assert.ok(m.channels.has('valid'));
});