Compare commits
3 Commits
1a16f9c4f1
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36eaa2f859 | ||
|
|
5d79314229 | ||
|
|
b0e8bbb95d |
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.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. |
|
||||
| `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.
|
||||
|
||||
### `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)
|
||||
|
||||
- **Port 0 (process):** `msg.topic = config.general.name`. Payload built by
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<script>
|
||||
RED.nodes.registerType("measurement", {
|
||||
category: "EVOLV",
|
||||
color: "#a9daee", // color for the node based on the S88 schema
|
||||
color: "#D4A02E",
|
||||
defaults: {
|
||||
|
||||
// Define default properties
|
||||
@@ -360,6 +360,7 @@
|
||||
<label for="node-input-dbaseOutputFormat"><i class="fa fa-database"></i> Database Output</label>
|
||||
<select id="node-input-dbaseOutputFormat" style="width:60%;">
|
||||
<option value="influxdb">influxdb</option>
|
||||
<option value="frost">frost</option>
|
||||
<option value="json">json</option>
|
||||
<option value="csv">csv</option>
|
||||
</select>
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
// Handler functions for measurement commands. Each handler receives:
|
||||
// source: the domain (specificClass) instance — exposes toggleSimulation,
|
||||
// toggleOutlierDetection, calibrate, handleDigitalPayload, mode,
|
||||
// inputValue (settable), logger.
|
||||
// inputValue (settable), analogChannel, channels (Map), logger.
|
||||
// msg: the Node-RED input message.
|
||||
// ctx: { node, RED, send, logger } — provided by BaseNodeAdapter.
|
||||
//
|
||||
// Handlers are pure functions: validation that goes beyond the registry's
|
||||
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement)
|
||||
// lives here.
|
||||
// typeof-check ladder (e.g. mode-dependent dispatch for data.measurement,
|
||||
// unit conversion into the channel's configured unit) lives here.
|
||||
|
||||
const { convert } = require('generalFunctions');
|
||||
|
||||
function _logger(source, ctx) {
|
||||
return ctx?.logger || source?.logger || null;
|
||||
@@ -36,39 +38,116 @@ exports.dataMeasurement = (source, msg, ctx) => {
|
||||
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) {
|
||||
const p = msg.payload;
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
return source.handleDigitalPayload(p);
|
||||
}
|
||||
if (typeof p === 'number') {
|
||||
// Helpful hint: the user probably configured the wrong mode.
|
||||
log?.warn?.(
|
||||
`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.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!p || typeof p !== 'object' || Array.isArray(p)) {
|
||||
log?.warn?.(`digital mode expects an object payload; got ${typeof p}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function _handleAnalog(source, msg, log) {
|
||||
const p = msg.payload;
|
||||
if (typeof p === 'number' || (typeof p === 'string' && p.trim() !== '')) {
|
||||
const parsed = Number(p);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
source.inputValue = parsed;
|
||||
return;
|
||||
const flat = {};
|
||||
for (const [key, item] of Object.entries(p)) {
|
||||
const extracted = _extractValueAndUnit(item, msg?.unit);
|
||||
if (!extracted || !Number.isFinite(extracted.value)) {
|
||||
log?.warn?.(`digital channel '${key}' has invalid payload: ${JSON.stringify(item)}`);
|
||||
continue;
|
||||
}
|
||||
log?.warn?.(`Invalid numeric measurement payload: ${p}`);
|
||||
return;
|
||||
}
|
||||
if (p && typeof p === 'object' && !Array.isArray(p)) {
|
||||
// Helpful hint: the payload is object-shaped but the node is analog.
|
||||
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.`
|
||||
const channelUnit = source.channels?.get?.(key)?.unit || null;
|
||||
flat[key] = _convertToChannelUnit(
|
||||
extracted.value,
|
||||
extracted.unit,
|
||||
channelUnit,
|
||||
log,
|
||||
`data.measurement[${key}]`,
|
||||
);
|
||||
}
|
||||
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)}`,
|
||||
);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ const commands = require('../../src/commands');
|
||||
const { createRegistry } = require('generalFunctions');
|
||||
const { makeNodeStub, makeREDStub } = require('../helpers/factories');
|
||||
|
||||
test('measurement topic accepts numeric strings and ignores non-numeric objects', async () => {
|
||||
test('measurement topic accepts numeric strings and rich analog object payloads', async () => {
|
||||
const inst = Object.create(NodeClass.prototype);
|
||||
const node = makeNodeStub();
|
||||
const calls = [];
|
||||
@@ -29,5 +29,5 @@ test('measurement topic accepts numeric strings and ignores non-numeric objects'
|
||||
await onInput({ topic: 'measurement', payload: '42' }, () => {}, () => {});
|
||||
await onInput({ topic: 'measurement', payload: { value: 42 } }, () => {}, () => {});
|
||||
|
||||
assert.deepEqual(calls, [42]);
|
||||
assert.deepEqual(calls, [42, 42]);
|
||||
});
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
|
||||
The registry lives in `src/commands/index.js`. Each descriptor maps a canonical `msg.topic` to a handler; aliases emit a one-time deprecation warning the first time they fire.
|
||||
|
||||
<!-- BEGIN AUTOGEN: topic-contract — populate via wiki-gen tool (TODO) -->
|
||||
<!-- BEGIN AUTOGEN: topic-contract -->
|
||||
|
||||
| Canonical topic | Aliases | Payload | Unit | Effect |
|
||||
|:---|:---|:---|:---|:---|
|
||||
| `set.simulator` | `simulator` | (ignored) | — | Toggles `source.toggleSimulation()` — flips `config.simulation.enabled`. |
|
||||
| `set.outlier-detection` | `outlierDetection` | (ignored) | — | Toggles `source.toggleOutlierDetection()` — flips `config.outlierDetection.enabled` and propagates the new value to `analogChannel.outlierDetection.enabled`. |
|
||||
| `cmd.calibrate` | `calibrate` | (ignored) | — | Calls `source.calibrate()` — if the rolling window is stable, captures the current output as the new `config.scaling.offset`. Aborts with a warn when unstable or when the calibration baseline is missing. |
|
||||
| `data.measurement` | `measurement` | mode-dependent (see below) | per channel (configured) | Push a raw sensor reading into the pipeline. Mode-dispatched in `handlers.dataMeasurement`: **analog** expects a number / numeric string → `source.inputValue = parsed`; **digital** expects an object keyed by channel name → `source.handleDigitalPayload(payload)`. Wrong shape for the configured mode logs a hint suggesting the other mode. |
|
||||
|---|---|---|---|---|
|
||||
| `set.simulator` | `simulator` | any | — | Toggle the built-in simulator on / off. |
|
||||
| `set.outlier-detection` | `outlierDetection` | any | — | Toggle / configure outlier detection on the measurement pipeline. |
|
||||
| `cmd.calibrate` | `calibrate` | any | — | Trigger a one-shot calibration of the measurement. |
|
||||
| `data.measurement` | `measurement` | any | — | Push a raw measurement (analog: number; digital: per-channel object). |
|
||||
|
||||
<!-- END AUTOGEN: topic-contract -->
|
||||
|
||||
|
||||
Reference in New Issue
Block a user