feat(commandRegistry): unify command envelope — origin, unit shorthand, always-convert
Shared command-dispatch layer used by every EVOLV node:
- Always-convert: numeric strings ("60") and {value:"60"} now normalise +
convert like numbers; closes the gap where strings reached handlers raw.
- unit: 'm3/h' shorthand on descriptors; measure is derived from the unit
(legacy units:{measure,default} still accepted, measure re-derived).
Unrecognised declared unit throws at construction.
- msg.origin stamped on every dispatch (parent|GUI|fysical, default parent).
- Opt-in gated:true arbitration: accept only if origin in
source.config.mode.allowedSources[currentMode]; advisory allow-all when a
node has no mode model. Handles Set- or array-valued allowedSources.
+18 registry tests (45 total, green). All consumer nodes verified green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,14 +26,60 @@ function _describeUnit(unit) {
|
||||
try { return convert().describe(unit); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
// A numeric scalar is a finite number, or a non-empty string that parses to a
|
||||
// finite number ("60", "1.5"). Node-RED inject/`change` nodes and upstream MQTT
|
||||
// payloads routinely arrive as strings; treating them as non-numeric here is the
|
||||
// gap that let values reach a handler unconverted.
|
||||
function _asNumber(x) {
|
||||
if (typeof x === 'number') return Number.isFinite(x) ? x : null;
|
||||
if (typeof x === 'string' && x.trim() !== '') {
|
||||
const n = Number(x);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _extractValueAndUnit(msg) {
|
||||
if (!msg || typeof msg !== 'object') return null;
|
||||
const p = msg.payload;
|
||||
if (typeof p === 'number') return { value: p, unit: msg.unit };
|
||||
if (p && typeof p === 'object' && typeof p.value === 'number') {
|
||||
return { value: p.value, unit: p.unit ?? msg.unit };
|
||||
if (p && typeof p === 'object') {
|
||||
const value = _asNumber(p.value);
|
||||
if (value === null) return null;
|
||||
return { value, unit: p.unit ?? msg.unit };
|
||||
}
|
||||
return null;
|
||||
const value = _asNumber(p);
|
||||
if (value === null) return null;
|
||||
return { value, unit: msg.unit };
|
||||
}
|
||||
|
||||
// Derive the dimensional measure (e.g. 'volumeFlowRate') from a unit string.
|
||||
// Returns null when convert doesn't recognise the unit.
|
||||
function _measureOf(unit) {
|
||||
const desc = _describeUnit(unit);
|
||||
return desc ? desc.measure : null;
|
||||
}
|
||||
|
||||
// Command origin = which control authority issued this message (the rotatingMachine
|
||||
// `allowedSources` vocabulary: 'parent' = automation/parent controller, 'GUI' =
|
||||
// SCADA/HMI operator, 'fysical' = physical buttons). Default 'parent'. Named
|
||||
// `origin` on the message because `source` is already the domain instance handed
|
||||
// to handlers.
|
||||
const DEFAULT_ORIGIN = 'parent';
|
||||
|
||||
function _resolveOrigin(msg, descriptor) {
|
||||
const o = msg && typeof msg.origin === 'string' && msg.origin.trim() !== ''
|
||||
? msg.origin.trim()
|
||||
: (descriptor.defaultOrigin || DEFAULT_ORIGIN);
|
||||
return o;
|
||||
}
|
||||
|
||||
// allowedSources values may be a Set (post config processing, as rotatingMachine
|
||||
// stores them) or a plain array (raw config / other nodes). Accept both.
|
||||
function _setHas(coll, value) {
|
||||
if (!coll) return false;
|
||||
if (typeof coll.has === 'function') return coll.has(value);
|
||||
if (Array.isArray(coll)) return coll.includes(value);
|
||||
return false;
|
||||
}
|
||||
|
||||
class CommandRegistry {
|
||||
@@ -76,6 +122,8 @@ class CommandRegistry {
|
||||
payloadSchema: cmd.payloadSchema || null,
|
||||
description: typeof cmd.description === 'string' ? cmd.description : null,
|
||||
units,
|
||||
gated: cmd.gated === true,
|
||||
defaultOrigin: typeof cmd.defaultOrigin === 'string' ? cmd.defaultOrigin : null,
|
||||
handler: cmd.handler,
|
||||
};
|
||||
this._byKey.set(cmd.topic, descriptor);
|
||||
@@ -87,12 +135,25 @@ class CommandRegistry {
|
||||
}
|
||||
|
||||
_validateUnits(cmd) {
|
||||
if (cmd.units === undefined || cmd.units === null) return null;
|
||||
const { measure, default: def } = cmd.units;
|
||||
if (typeof measure !== 'string' || measure.length === 0 ||
|
||||
typeof def !== 'string' || def.length === 0) {
|
||||
// Two ways to declare the unit, normalised to the same internal shape:
|
||||
// unit: 'm3/h' (preferred — measure derived)
|
||||
// units: { default: 'm3/h' } (measure derived)
|
||||
// units: { measure, default: 'm3/h' } (legacy — measure ignored, derived)
|
||||
// The measure is always derived from the unit so it can never drift from it.
|
||||
let def;
|
||||
if (typeof cmd.unit === 'string') def = cmd.unit;
|
||||
else if (cmd.units === undefined || cmd.units === null) return null;
|
||||
else if (typeof cmd.units === 'string') def = cmd.units;
|
||||
else def = cmd.units.default;
|
||||
|
||||
if (typeof def !== 'string' || def.length === 0) {
|
||||
throw new TypeError(
|
||||
`command '${cmd.topic}' units requires { measure: string, default: string }`);
|
||||
`command '${cmd.topic}' requires a unit string (unit: 'm3/h' or units: { default: 'm3/h' })`);
|
||||
}
|
||||
const measure = _measureOf(def);
|
||||
if (!measure) {
|
||||
throw new TypeError(
|
||||
`command '${cmd.topic}' declares unit '${def}' which convert does not recognise`);
|
||||
}
|
||||
return { measure, default: def };
|
||||
}
|
||||
@@ -137,11 +198,31 @@ class CommandRegistry {
|
||||
return;
|
||||
}
|
||||
if (topic !== descriptor.topic) this._noteAlias(topic, descriptor.topic, log);
|
||||
// Always stamp the command origin so handlers + gating can rely on it.
|
||||
msg.origin = _resolveOrigin(msg, descriptor);
|
||||
if (!this._originAllowed(descriptor, source, msg.origin, log)) return;
|
||||
if (descriptor.units) this._normaliseUnits(descriptor, msg, log);
|
||||
if (!this._validatePayload(descriptor, msg, log)) return;
|
||||
return descriptor.handler(source, msg, ctx);
|
||||
}
|
||||
|
||||
// Mode-gated control-authority arbitration. Opt-in per command via
|
||||
// `gated: true`. The asset's mode (e.g. rotatingMachine's auto /
|
||||
// virtualControl / fysicalControl) decides which origins it accepts via
|
||||
// `source.config.mode.allowedSources[mode]`. Release = changing the mode.
|
||||
// Nodes without a mode model are advisory (allow-all) so this is inert
|
||||
// until a node opts in — never a silent behaviour change.
|
||||
_originAllowed(descriptor, source, origin, log) {
|
||||
if (!descriptor.gated) return true;
|
||||
const allowedSources = source && source.config && source.config.mode
|
||||
? source.config.mode.allowedSources : null;
|
||||
const mode = source ? source.currentMode : undefined;
|
||||
if (!allowedSources || !mode) return true; // no mode model → advisory
|
||||
if (_setHas(allowedSources[mode], origin)) return true;
|
||||
log.warn?.(`${descriptor.topic}: origin '${origin}' not allowed in mode '${mode}'`);
|
||||
return false;
|
||||
}
|
||||
|
||||
_noteAlias(alias, canonical, log) {
|
||||
const prev = this._deprecationCounts.get(alias) || 0;
|
||||
this._deprecationCounts.set(alias, prev + 1);
|
||||
|
||||
Reference in New Issue
Block a user