diff --git a/src/control/levelBased.js b/src/control/levelBased.js index aed0e1b..1668cf4 100644 --- a/src/control/levelBased.js +++ b/src/control/levelBased.js @@ -253,6 +253,26 @@ async function run(ctx, controlState, direction) { `Level-based: level=${level} dir=${direction} armed=${shiftArmed} hold=${shiftHold} pct=${percControl}` ); + // We are past every off-gate, so the station is engaged and the computed + // demand is meant to drive pumps. If no machine group is registered the + // demand has nowhere to go and the pumps stay silent — the signature of a + // dropped Port 2 parent↔group registration (e.g. after a partial redeploy + // that recreated this node). Warn once until a group reappears so the + // failure isn't invisible. + const groupCount = machineGroups ? Object.keys(machineGroups).length : 0; + if (groupCount === 0) { + if (host && !host._warnedNoMachineGroup) { + logger?.warn?.( + `Level-based control engaged (demand ${percControl.toFixed(1)} %) but no machine group is registered — ` + + `pumps cannot be driven. The parent↔group registration was likely lost on a partial redeploy; ` + + `redeploy/restart fully to re-run the Port 2 registration handshake.` + ); + host._warnedNoMachineGroup = true; + } + } else if (host) { + host._warnedNoMachineGroup = false; + } + await _applyMachineGroupLevelControl(machineGroups, percControl, logger); } diff --git a/src/control/manual.js b/src/control/manual.js index e86f890..bb39c83 100644 --- a/src/control/manual.js +++ b/src/control/manual.js @@ -28,6 +28,18 @@ async function forwardDemand(ctx, demand) { } } } + + // Neither a group nor a direct machine is registered, so the operator's + // demand silently goes nowhere. Surface it — the usual cause is a dropped + // Port 2 parent↔child registration after a partial redeploy. + const noGroups = !machineGroups || Object.keys(machineGroups).length === 0; + const noMachines = !machines || Object.keys(machines).length === 0; + if (noGroups && noMachines) { + logger?.warn?.( + `Manual demand ${demand} not forwarded — no machine group or machine is registered to this pumping station. ` + + `Check the parent↔child Port 2 registration (redeploy/restart fully to restore it).` + ); + } } module.exports = { diff --git a/test/basic/control-levelBased.basic.test.js b/test/basic/control-levelBased.basic.test.js index 6c0fc86..96eedaf 100644 --- a/test/basic/control-levelBased.basic.test.js +++ b/test/basic/control-levelBased.basic.test.js @@ -182,3 +182,51 @@ test('no valid level → warns and returns without mutating percControl or calli assert.equal(g._calls.handleInput.length, 0); } }); + +// Regression: a station engaged above startLevel but with no machine group +// registered (e.g. the Port 2 parent↔group registration was dropped by a +// partial redeploy) computes a real demand that goes nowhere. The strategy +// must surface this once, not fail silently. See the 2026-05-27 "PS not +// reacting to level" trace. +test('engaged with NO machine group registered → warns once (throttled via host)', async () => { + const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); // level 3 > startLevel 2 → engaged + ctx.machineGroups = {}; // registration lost + ctx.host = {}; + const warns = []; + ctx.logger.warn = (m) => warns.push(m); + + const state = { percControl: 0 }; + await levelBased.run(ctx, state); + + assert.ok(state.percControl > 0, 'demand is computed even though there is no group'); + assert.equal(warns.length, 1, 'warns exactly once'); + assert.match(warns[0], /no machine group is registered/i); + assert.equal(ctx.host._warnedNoMachineGroup, true); + + // Subsequent ticks while still group-less stay quiet (no log spam). + await levelBased.run(ctx, state); + assert.equal(warns.length, 1, 'throttled: no repeat warning on the next tick'); +}); + +test('warning re-arms after a group reappears then disappears again', async () => { + const ctx = makeCtx(3, { levelbased: { holdLevel: 2 } }); + ctx.host = {}; + const warns = []; + ctx.logger.warn = (m) => warns.push(m); + const state = { percControl: 0 }; + + ctx.machineGroups = {}; + await levelBased.run(ctx, state); + assert.equal(warns.length, 1); + + // Group registers again → flag clears, no new warning. + ctx.machineGroups = { a: makeGroup('A') }; + await levelBased.run(ctx, state); + assert.equal(warns.length, 1); + assert.equal(ctx.host._warnedNoMachineGroup, false); + + // Group lost again → warns once more. + ctx.machineGroups = {}; + await levelBased.run(ctx, state); + assert.equal(warns.length, 2, 're-armed after recovery'); +});