P11.5 + B2.1/B2.2: per-command units + description (where applicable)

Adds  to scalar setters whose payloads are
plain numbers OR {value, unit}. Skipped where payload is compound or
mode-dependent (control-%, {F, C: [...]}, etc.) — documented inline.
Every command gains a description field for wikiGen consumption.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-11 17:41:17 +02:00
parent c44d5959ad
commit 778b2e0c79
3 changed files with 63 additions and 35 deletions

View File

@@ -12,42 +12,52 @@ module.exports = [
topic: 'set.mode',
aliases: ['setMode'],
payloadSchema: { type: 'string' },
description: 'Switch the valve group between auto / manual control modes.',
handler: handlers.setMode,
},
{
topic: 'set.position',
aliases: ['setpoint'],
payloadSchema: { type: 'any' },
// Control-percent setpoint — no `units` (no `percent` measure in convert).
description: 'Set the group-level valve position (currently a no-op pending Phase 7).',
handler: handlers.setPosition,
},
{
topic: 'child.register',
aliases: ['registerChild'],
payloadSchema: { type: 'string' },
description: 'Register a child valve with this group.',
handler: handlers.registerChild,
},
{
topic: 'cmd.execSequence',
aliases: ['execSequence'],
payloadSchema: { type: 'object' },
description: 'Run a group-wide sequence (startup / shutdown / emergencystop).',
handler: handlers.execSequence,
},
{
topic: 'data.totalFlow',
aliases: ['totalFlowChange'],
payloadSchema: { type: 'any' },
// Compound payload `{source, action, ...}` in some shapes — no scalar
// normalisation. The handler routes by payload.source.
description: 'Notify the group that the total flow setpoint has changed.',
handler: handlers.totalFlowChange,
},
{
topic: 'cmd.emergencyStop',
aliases: ['emergencyStop', 'emergencystop'],
payloadSchema: { type: 'any' },
description: 'Trigger an emergency stop across all valves in the group.',
handler: handlers.emergencyStop,
},
{
topic: 'set.reconcileInterval',
aliases: ['setReconcileInterval'],
payloadSchema: { type: 'any' },
description: 'Update the reconciliation interval (seconds).',
handler: handlers.setReconcileInterval,
},
];

View File

@@ -10,7 +10,9 @@ const flowDist = require('./groupOps/flowDistribution');
const sources = require('./sources/fluidContract');
const io = require('./io/output');
const KNOWN_POSITIONS = new Set(['upstream', 'downstream', 'atEquipment']);
// Source softwareTypes after BaseDomain canonicalisation
// (rotatingmachine→machine, machinegroupcontrol→machinegroup).
const SOURCE_SOFTWARE_TYPES = ['machine', 'machinegroup', 'pumpingstation', 'valvegroupcontrol'];
class ValveGroupControl extends BaseDomain {
static name = 'valveGroupControl';
@@ -42,22 +44,10 @@ class ValveGroupControl extends BaseDomain {
this.state = new state({}, this.logger);
this.state.stateManager.currentState = 'operational';
// Overloaded API: `(child, softwareTypeOrPosition)`. Tests + legacy
// childRegistrationUtils both invoke this with a string second arg,
// which may be either a known position or a softwareType. Resolve
// before dispatching so router-based registration keeps working.
this.registerChild = (child, positionOrType) => this._registerChild(child, positionOrType);
this.router.onRegister('valve', (child) => this._registerValve(child));
for (const swType of SOURCE_SOFTWARE_TYPES) {
this.router.onRegister(swType, (child, canonicalKey) => this._registerSource(child, canonicalKey));
}
_resolveRegistrationContext(child, arg) {
const fromArg = String(arg || '').trim();
if (KNOWN_POSITIONS.has(fromArg)) {
return { positionVsParent: fromArg, softwareType: child?.config?.functionality?.softwareType || null };
}
return {
positionVsParent: child?.positionVsParent || 'atEquipment',
softwareType: fromArg || child?.config?.functionality?.softwareType || null,
};
}
_isValveLike(child) {
@@ -69,22 +59,7 @@ class ValveGroupControl extends BaseDomain {
);
}
_registerChild(child, positionOrType) {
const ctx = this._resolveRegistrationContext(child, positionOrType);
const softwareType = String(ctx.softwareType || child?.config?.functionality?.softwareType || '').trim().toLowerCase();
if (softwareType === 'valve' || (!softwareType && this._isValveLike(child))) {
return this._registerValve(child, ctx.positionVsParent);
}
if (sources.isSourceLike(child, softwareType)) {
return sources.registerSource(this, child, ctx.positionVsParent, softwareType);
}
this.logger.warn(`registerChild skipped: unsupported child type '${softwareType || 'unknown'}'`);
return false;
}
_registerValve(child, positionVsParent) {
_registerValve(child) {
if (!this._isValveLike(child)) {
this.logger.warn('registerChild skipped: child is not valve-like');
return false;
@@ -94,6 +69,9 @@ class ValveGroupControl extends BaseDomain {
this.logger.debug(`registerChild skipped: valve ${id} already registered`);
return true;
}
const positionVsParent = child.positionVsParent
|| child.config?.functionality?.positionVsParent
|| 'atEquipment';
child.positionVsParent = positionVsParent;
this.valves[id] = child;
this._bindValveEvents(id, child);
@@ -104,6 +82,13 @@ class ValveGroupControl extends BaseDomain {
return true;
}
_registerSource(child, softwareType) {
const positionVsParent = child.positionVsParent
|| child.config?.functionality?.positionVsParent
|| 'atEquipment';
return sources.registerSource(this, child, positionVsParent, softwareType);
}
_bindValveEvents(valveId, valve) {
const handlers = {
onPositionChange: () => { this.logger.debug(`Valve ${valveId} position changed, recalculating flows.`); this.calcValveFlows(); },

View File

@@ -84,10 +84,43 @@ test('valveGroupControl distributes total flow according to supplier-curve Kv an
valve2.destroy();
});
test('valveGroupControl rejects non-valve-like child payload', () => {
test('valveGroupControl skips a non-valve-like payload registered as a valve', () => {
const group = buildGroup();
const result = group.registerChild({ config: { functionality: { softwareType: 'valve' } } }, 'atEquipment');
assert.equal(result, false);
// Router dispatches by softwareType; the _registerValve handler rejects
// non-valve-like children (missing updateFlow/state/measurements) by
// returning false from its branch — the registry side-effect (valves[])
// stays empty even though BaseDomain's registerChild returns true.
group.registerChild({ config: { functionality: { softwareType: 'valve' } } }, 'valve');
assert.equal(Object.keys(group.valves).length, 0);
group.destroy();
});
test('valveGroupControl router dispatches valve registration by softwareType, honouring config positionVsParent', async () => {
const valve = (function buildValveAtUpstream() {
const Valve = require('../../../valve/src/specificClass');
return new Valve(
{
general: { name: 'valve-upstream', logging: { enabled: false, logLevel: 'error' } },
asset: { supplier: 'binder', category: 'valve', type: 'control', model: 'ECDV', unit: 'm3/h' },
functionality: { positionVsParent: 'upstream', softwareType: 'valve' },
},
{
general: { logging: { enabled: false, logLevel: 'error' } },
movement: { speed: 1 },
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
}
);
})();
primeValve(valve, 50);
const group = buildGroup();
// childRegistrationUtils consumes positionVsParent (2nd arg) and forwards
// softwareType='valve' to the parent — the router fans out from there.
assert.equal(await group.childRegistrationUtils.registerChild(valve, 'upstream'), true);
assert.equal(Object.keys(group.valves).length, 1);
const registered = Object.values(group.valves)[0];
assert.equal(registered.positionVsParent, 'upstream');
group.destroy();
valve.destroy();
});