Compare commits
1 Commits
b0e8bbb95d
...
5d79314229
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d79314229 |
21
CONTRACT.md
21
CONTRACT.md
@@ -10,10 +10,29 @@ Hand-maintained for Phase 3; the `## Inputs` table is generated from
|
|||||||
| `set.simulator` | `simulator` | none (payload ignored) | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. |
|
| `set.simulator` | `simulator` | none (payload ignored) | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. |
|
||||||
| `set.outlier-detection` | `outlierDetection` | none (payload ignored) | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled`. |
|
| `set.outlier-detection` | `outlierDetection` | none (payload ignored) | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled`. |
|
||||||
| `cmd.calibrate` | `calibrate` | none | Calls `source.calibrate()` — captures the current input as the zero/reference offset. |
|
| `cmd.calibrate` | `calibrate` | none | Calls `source.calibrate()` — captures the current input as the zero/reference offset. |
|
||||||
| `data.measurement` | `measurement` | mode-dependent — see below | Pushes a sensor reading into the pipeline. Analog: numeric scalar (number or numeric string) → `source.inputValue`. Digital: object payload keyed by channel name → `source.handleDigitalPayload(payload)`. Wrong shape for the configured mode logs a helpful warning suggesting the other mode. |
|
| `data.measurement` | `measurement` | mode-dependent — see **Payload shape** below | Pushes a sensor reading into the pipeline. Analog → `source.inputValue`; digital → `source.handleDigitalPayload(<flat map>)`. Wrong shape for the configured mode logs a helpful warning suggesting the other mode. |
|
||||||
|
|
||||||
Aliases log a one-time deprecation warning the first time they fire.
|
Aliases log a one-time deprecation warning the first time they fire.
|
||||||
|
|
||||||
|
### `data.measurement` payload shape
|
||||||
|
|
||||||
|
Both modes accept the same three forms, mirroring pumpingStation's
|
||||||
|
`set.inflow` contract:
|
||||||
|
|
||||||
|
- **Bare scalar** — `msg.payload = 12.5` (number or numeric string). The unit
|
||||||
|
falls back to `msg.unit`, and finally to the channel's configured unit
|
||||||
|
(the dropdown selection in the node editor).
|
||||||
|
- **Rich object** — `msg.payload = { value, unit?, timestamp? }`. Used per-
|
||||||
|
call to declare the unit of a single sample.
|
||||||
|
- **Digital map** (digital mode only) — `msg.payload = { <channelKey>: <bare scalar | rich object>, … }`. Each entry follows the rules above independently, so different channels in one message may carry different units.
|
||||||
|
|
||||||
|
When a supplied unit differs from the channel's configured unit, the value
|
||||||
|
is converted into the channel unit via `generalFunctions.convert` before it
|
||||||
|
enters the outlier / scaling / smoothing pipeline. If the supplied unit is
|
||||||
|
unknown or belongs to a different measure (e.g. `kg` on a `pressure`
|
||||||
|
channel), the handler logs a warning and uses the raw value treated as the
|
||||||
|
channel unit — the sample is not silently dropped.
|
||||||
|
|
||||||
## Outputs (msg.topic on Port 0/1/2)
|
## Outputs (msg.topic on Port 0/1/2)
|
||||||
|
|
||||||
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<script>
|
<script>
|
||||||
RED.nodes.registerType("measurement", {
|
RED.nodes.registerType("measurement", {
|
||||||
category: "EVOLV",
|
category: "EVOLV",
|
||||||
color: "#a9daee", // color for the node based on the S88 schema
|
color: "#D4A02E",
|
||||||
defaults: {
|
defaults: {
|
||||||
|
|
||||||
// Define default properties
|
// Define default properties
|
||||||
@@ -360,6 +360,7 @@
|
|||||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||||
<option value="influxdb">influxdb</option>
|
<option value="influxdb">influxdb</option>
|
||||||
|
<option value="frost">frost</option>
|
||||||
<option value="json">json</option>
|
<option value="json">json</option>
|
||||||
<option value="csv">csv</option>
|
<option value="csv">csv</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
// Handler functions for measurement commands. Each handler receives:
|
// Handler functions for measurement commands. Each handler receives:
|
||||||
// source: the domain (specificClass) instance — exposes toggleSimulation,
|
// source: the domain (specificClass) instance — exposes toggleSimulation,
|
||||||
// toggleOutlierDetection, calibrate, handleDigitalPayload, mode,
|
// toggleOutlierDetection, calibrate, handleDigitalPayload, mode,
|
||||||
// inputValue (settable), logger.
|
// inputValue (settable), analogChannel, channels (Map), logger.
|
||||||
// msg: the Node-RED input message.
|
// msg: the Node-RED input message.
|
||||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||||
//
|
//
|
||||||
// Handlers are pure functions: validation that goes beyond the registry's
|
// Handlers are pure functions: validation that goes beyond the registry's
|
||||||
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement)
|
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement,
|
||||||
// lives here.
|
// unit conversion into the channel's configured unit) lives here.
|
||||||
|
|
||||||
|
const { convert } = require('generalFunctions');
|
||||||
|
|
||||||
function _logger(source, ctx) {
|
function _logger(source, ctx) {
|
||||||
return ctx?.logger || source?.logger || null;
|
return ctx?.logger || source?.logger || null;
|
||||||
@@ -36,39 +38,116 @@ exports.dataMeasurement = (source, msg, ctx) => {
|
|||||||
return _handleAnalog(source, msg, log);
|
return _handleAnalog(source, msg, log);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- shared payload helpers ------------------------------------------------
|
||||||
|
|
||||||
|
// Extract { value, unit, timestamp } from a per-call item that may be
|
||||||
|
// - a bare number / numeric string (unit falls back to msgUnit, then channel)
|
||||||
|
// - an object { value, unit?, timestamp? } (pumpingStation set.inflow shape)
|
||||||
|
// Returns null when the shape is neither.
|
||||||
|
function _extractValueAndUnit(item, msgUnit) {
|
||||||
|
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
||||||
|
return {
|
||||||
|
value: Number(item.value),
|
||||||
|
unit: _trimmedString(item.unit),
|
||||||
|
timestamp: item.timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (typeof item === 'number' || (typeof item === 'string' && item.trim() !== '')) {
|
||||||
|
return {
|
||||||
|
value: Number(item),
|
||||||
|
unit: _trimmedString(msgUnit),
|
||||||
|
timestamp: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _trimmedString(v) {
|
||||||
|
return typeof v === 'string' && v.trim() ? v.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert `value` from `suppliedUnit` into `channelUnit`. When the supplied
|
||||||
|
// unit is missing or already matches, returns the value untouched. When the
|
||||||
|
// units are incompatible (different measures, unsupported abbr), logs a
|
||||||
|
// warning and returns the raw value treated as if it were channelUnit — the
|
||||||
|
// sender keeps responsibility for picking the right unit, but the pipeline
|
||||||
|
// does not silently drop the sample.
|
||||||
|
function _convertToChannelUnit(value, suppliedUnit, channelUnit, log, label) {
|
||||||
|
if (!suppliedUnit || !channelUnit || suppliedUnit === channelUnit) return value;
|
||||||
|
try {
|
||||||
|
return convert(value).from(suppliedUnit).to(channelUnit);
|
||||||
|
} catch (err) {
|
||||||
|
log?.warn?.(
|
||||||
|
`${label}: unit '${suppliedUnit}' is incompatible with channel unit '${channelUnit}' ` +
|
||||||
|
`(${err.message}). Using raw value as if it were ${channelUnit}.`
|
||||||
|
);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinguish a "rich" analog payload ({value, unit?, timestamp?}) from an
|
||||||
|
// object that almost certainly indicates the sender meant digital mode (a
|
||||||
|
// bag of channel-name keys). Used only for the helpful switch-mode warning.
|
||||||
|
function _looksLikeRichPayload(obj) {
|
||||||
|
return obj.value !== undefined || obj.unit !== undefined || obj.timestamp !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- mode handlers ---------------------------------------------------------
|
||||||
|
|
||||||
|
function _handleAnalog(source, msg, log) {
|
||||||
|
const p = msg.payload;
|
||||||
|
if (p !== null && typeof p === 'object' && !Array.isArray(p) && !_looksLikeRichPayload(p)) {
|
||||||
|
const keys = Object.keys(p).slice(0, 3).join(', ');
|
||||||
|
log?.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.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const extracted = _extractValueAndUnit(p, msg?.unit);
|
||||||
|
if (!extracted || !Number.isFinite(extracted.value)) {
|
||||||
|
log?.warn?.(`Invalid analog measurement payload: ${JSON.stringify(p)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channelUnit = source.analogChannel?.unit || null;
|
||||||
|
source.inputValue = _convertToChannelUnit(
|
||||||
|
extracted.value,
|
||||||
|
extracted.unit,
|
||||||
|
channelUnit,
|
||||||
|
log,
|
||||||
|
'data.measurement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function _handleDigital(source, msg, log) {
|
function _handleDigital(source, msg, log) {
|
||||||
const p = msg.payload;
|
const p = msg.payload;
|
||||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
|
||||||
return source.handleDigitalPayload(p);
|
|
||||||
}
|
|
||||||
if (typeof p === 'number') {
|
if (typeof p === 'number') {
|
||||||
// Helpful hint: the user probably configured the wrong mode.
|
|
||||||
log?.warn?.(
|
log?.warn?.(
|
||||||
`digital mode received a number (${p}); expected an object like {key: value, ...}. ` +
|
`digital mode received a number (${p}); expected an object like {key: value, ...}. ` +
|
||||||
`Switch Input Mode to 'analog' in the editor or send an object payload.`
|
`Switch Input Mode to 'analog' in the editor or send an object payload.`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
||||||
log?.warn?.(`digital mode expects an object payload; got ${typeof p}`);
|
log?.warn?.(`digital mode expects an object payload; got ${typeof p}`);
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
function _handleAnalog(source, msg, log) {
|
const flat = {};
|
||||||
const p = msg.payload;
|
for (const [key, item] of Object.entries(p)) {
|
||||||
if (typeof p === 'number' || (typeof p === 'string' && p.trim() !== '')) {
|
const extracted = _extractValueAndUnit(item, msg?.unit);
|
||||||
const parsed = Number(p);
|
if (!extracted || !Number.isFinite(extracted.value)) {
|
||||||
if (!Number.isNaN(parsed)) {
|
log?.warn?.(`digital channel '${key}' has invalid payload: ${JSON.stringify(item)}`);
|
||||||
source.inputValue = parsed;
|
continue;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
log?.warn?.(`Invalid numeric measurement payload: ${p}`);
|
const channelUnit = source.channels?.get?.(key)?.unit || null;
|
||||||
return;
|
flat[key] = _convertToChannelUnit(
|
||||||
}
|
extracted.value,
|
||||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
extracted.unit,
|
||||||
// Helpful hint: the payload is object-shaped but the node is analog.
|
channelUnit,
|
||||||
const keys = Object.keys(p).slice(0, 3).join(', ');
|
log,
|
||||||
log?.warn?.(
|
`data.measurement[${key}]`,
|
||||||
`analog mode received an object payload (keys: ${keys}). ` +
|
|
||||||
`Switch Input Mode to 'digital' in the editor and define channels, or feed a numeric payload.`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return source.handleDigitalPayload(flat);
|
||||||
}
|
}
|
||||||
|
|||||||
323
test/basic/commands-units.basic.test.js
Normal file
323
test/basic/commands-units.basic.test.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// Unit-handling tests for the measurement data.measurement command.
|
||||||
|
// Verifies that analog and digital modes accept the same payload shapes
|
||||||
|
// (bare scalar | rich object | per-channel map) and that supplied units
|
||||||
|
// are converted into the channel's configured (dropdown) unit.
|
||||||
|
//
|
||||||
|
// Run with: node --test test/basic/commands-units.basic.test.js
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { createRegistry } = require('generalFunctions');
|
||||||
|
const commands = require('../../src/commands');
|
||||||
|
|
||||||
|
// --- helpers ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeLogger() {
|
||||||
|
const calls = { warn: [], error: [], info: [], debug: [] };
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
warn: (m) => calls.warn.push(String(m)),
|
||||||
|
error: (m) => calls.error.push(String(m)),
|
||||||
|
info: (m) => calls.info.push(String(m)),
|
||||||
|
debug: (m) => calls.debug.push(String(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analog source mock: exposes analogChannel.unit so the handler can resolve
|
||||||
|
// the channel's configured (dropdown) unit. inputValueSets captures the
|
||||||
|
// value that was eventually written, after any unit conversion.
|
||||||
|
function makeAnalogSource({ unit = 'mbar' } = {}) {
|
||||||
|
const inputValueSets = [];
|
||||||
|
let _v = 0;
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
mode: 'analog',
|
||||||
|
logger: makeLogger(),
|
||||||
|
analogChannel: { unit },
|
||||||
|
get inputValue() { return _v; },
|
||||||
|
set inputValue(v) { _v = v; inputValueSets.push(v); },
|
||||||
|
},
|
||||||
|
inputValueSets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digital source mock: exposes channels.get(key).unit per channel so each
|
||||||
|
// digital entry can be converted independently. handleDigitalPayloadCalls
|
||||||
|
// captures the *flat* {key: convertedNumber} the handler ultimately passes.
|
||||||
|
function makeDigitalSource(channelUnits) {
|
||||||
|
const handleDigitalPayloadCalls = [];
|
||||||
|
const channels = new Map(Object.entries(channelUnits).map(([k, u]) => [k, { unit: u }]));
|
||||||
|
return {
|
||||||
|
source: {
|
||||||
|
mode: 'digital',
|
||||||
|
logger: makeLogger(),
|
||||||
|
channels,
|
||||||
|
handleDigitalPayload: (p) => { handleDigitalPayloadCalls.push(p); return { ok: true }; },
|
||||||
|
},
|
||||||
|
handleDigitalPayloadCalls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCtx({ logger = makeLogger() } = {}) {
|
||||||
|
return { logger, RED: { nodes: { getNode: () => undefined } }, node: {}, send: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRegistry(logger) {
|
||||||
|
return createRegistry(commands, { logger });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- analog ----------------------------------------------------------------
|
||||||
|
|
||||||
|
test('analog: bare number uses channel default unit (no conversion)', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch({ topic: 'data.measurement', payload: 1234 }, source, makeCtx());
|
||||||
|
|
||||||
|
assert.deepEqual(inputValueSets, [1234]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: { value, unit } same as channel passes through unchanged', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { value: 500, unit: 'mbar' } },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(inputValueSets, [500]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: { value, unit } different but compatible unit is converted', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
// 1 bar = 1000 mbar.
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { value: 1, unit: 'bar' } },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inputValueSets.length, 1);
|
||||||
|
assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6,
|
||||||
|
`expected 1 bar → 1000 mbar, got ${inputValueSets[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: msg.unit fallback works for bare-number payloads', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: 1, unit: 'bar' },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inputValueSets.length, 1);
|
||||||
|
assert.ok(Math.abs(inputValueSets[0] - 1000) < 1e-6,
|
||||||
|
`expected 1 bar → 1000 mbar via msg.unit, got ${inputValueSets[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: unit-measure mismatch warns and falls back to raw value', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { value: 42, unit: 'kg' } },
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(inputValueSets, [42]);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes("'kg'") && m.includes("'mbar'")),
|
||||||
|
`expected mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: unknown unit warns and falls back to raw value', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { value: 5, unit: 'gribbles' } },
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(inputValueSets, [5]);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes("'gribbles'")),
|
||||||
|
`expected unknown-unit warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: numeric string with msg.unit is converted', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: '2', unit: 'bar' },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inputValueSets.length, 1);
|
||||||
|
assert.ok(Math.abs(inputValueSets[0] - 2000) < 1e-6,
|
||||||
|
`expected '2' bar → 2000 mbar, got ${inputValueSets[0]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- digital ---------------------------------------------------------------
|
||||||
|
|
||||||
|
test('digital: per-channel { value, unit } converts each independently', async () => {
|
||||||
|
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
|
||||||
|
pIn: 'mbar',
|
||||||
|
pOut: 'Pa',
|
||||||
|
});
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{
|
||||||
|
topic: 'data.measurement',
|
||||||
|
payload: {
|
||||||
|
pIn: { value: 1, unit: 'bar' }, // → 1000 mbar
|
||||||
|
pOut: { value: 1.5, unit: 'bar' }, // → 150000 Pa
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(handleDigitalPayloadCalls.length, 1);
|
||||||
|
const flat = handleDigitalPayloadCalls[0];
|
||||||
|
assert.ok(Math.abs(flat.pIn - 1000) < 1e-6, `pIn expected 1000, got ${flat.pIn}`);
|
||||||
|
assert.ok(Math.abs(flat.pOut - 150000) < 1e-3, `pOut expected 150000, got ${flat.pOut}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital: bare-number entries use the channel default unit', async () => {
|
||||||
|
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
|
||||||
|
a: 'mbar',
|
||||||
|
b: 'mbar',
|
||||||
|
});
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { a: 500, b: 750 } },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(handleDigitalPayloadCalls[0], { a: 500, b: 750 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital: mixed rich + bare entries are converted per-channel', async () => {
|
||||||
|
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
|
||||||
|
a: 'mbar',
|
||||||
|
b: 'mbar',
|
||||||
|
});
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{
|
||||||
|
topic: 'data.measurement',
|
||||||
|
payload: {
|
||||||
|
a: { value: 1, unit: 'bar' }, // converted → 1000
|
||||||
|
b: 750, // passthrough
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = handleDigitalPayloadCalls[0];
|
||||||
|
assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`);
|
||||||
|
assert.equal(flat.b, 750);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital: msg.unit applies to bare entries when no per-channel unit is given', async () => {
|
||||||
|
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
|
||||||
|
a: 'mbar',
|
||||||
|
b: 'mbar',
|
||||||
|
});
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { a: 1, b: 2 }, unit: 'bar' },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = handleDigitalPayloadCalls[0];
|
||||||
|
assert.ok(Math.abs(flat.a - 1000) < 1e-6, `a expected 1000, got ${flat.a}`);
|
||||||
|
assert.ok(Math.abs(flat.b - 2000) < 1e-6, `b expected 2000, got ${flat.b}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('digital: unit-measure mismatch on one channel warns + falls back without affecting others', async () => {
|
||||||
|
const { source, handleDigitalPayloadCalls } = makeDigitalSource({
|
||||||
|
pressure: 'mbar',
|
||||||
|
flow: 'm3/h',
|
||||||
|
});
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{
|
||||||
|
topic: 'data.measurement',
|
||||||
|
payload: {
|
||||||
|
pressure: { value: 1, unit: 'bar' }, // converted → 1000
|
||||||
|
flow: { value: 100, unit: 'kg' }, // mismatch → raw 100, warn
|
||||||
|
},
|
||||||
|
},
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const flat = handleDigitalPayloadCalls[0];
|
||||||
|
assert.ok(Math.abs(flat.pressure - 1000) < 1e-6, `pressure expected 1000, got ${flat.pressure}`);
|
||||||
|
assert.equal(flat.flow, 100);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes("data.measurement[flow]") && m.includes("'kg'")),
|
||||||
|
`expected per-channel mismatch warning, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- backwards-compat -----------------------------------------------------
|
||||||
|
|
||||||
|
test('analog: { value } without unit uses channel default (rich-payload form)', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const reg = makeRegistry(makeLogger());
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { value: 42 } },
|
||||||
|
source,
|
||||||
|
makeCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(inputValueSets, [42]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('analog: object payload that is *not* rich still triggers switch-mode warn', async () => {
|
||||||
|
const { source, inputValueSets } = makeAnalogSource({ unit: 'mbar' });
|
||||||
|
const ctxLogger = makeLogger();
|
||||||
|
const reg = makeRegistry(ctxLogger);
|
||||||
|
|
||||||
|
await reg.dispatch(
|
||||||
|
{ topic: 'data.measurement', payload: { tempA: 21.5, tempB: 19.8 } },
|
||||||
|
source,
|
||||||
|
makeCtx({ logger: ctxLogger }),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(inputValueSets.length, 0);
|
||||||
|
assert.ok(
|
||||||
|
ctxLogger.calls.warn.some((m) => m.includes('analog mode') && m.includes('digital')),
|
||||||
|
`expected switch-to-digital warn, got: ${JSON.stringify(ctxLogger.calls.warn)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user