diff --git a/src/commands/index.js b/src/commands/index.js index 4199a04..c94a6ec 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -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, }, ]; diff --git a/src/specificClass.js b/src/specificClass.js index 4fc4432..6e470c6 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -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); - } - - _resolveRegistrationContext(child, arg) { - const fromArg = String(arg || '').trim(); - if (KNOWN_POSITIONS.has(fromArg)) { - return { positionVsParent: fromArg, softwareType: child?.config?.functionality?.softwareType || null }; + 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)); } - 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(); }, diff --git a/test/integration/flow-distribution.integration.test.js b/test/integration/flow-distribution.integration.test.js index f203c55..49eed23 100644 --- a/test/integration/flow-distribution.integration.test.js +++ b/test/integration/flow-distribution.integration.test.js @@ -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(); +});