diff --git a/src/configs/machineGroupControl.json b/src/configs/machineGroupControl.json index e3e760a..95f5ac3 100644 --- a/src/configs/machineGroupControl.json +++ b/src/configs/machineGroupControl.json @@ -141,6 +141,15 @@ } } }, + "planner": { + "useRendezvous": { + "default": true, + "rules": { + "type": "boolean", + "description": "If true, every dispatch is routed through the rendezvous planner regardless of control strategy: per-pump moves are delayed so all pumps reach their setpoint at the same wall-clock instant t* = max(eta_i). If false, all flowmovement commands fire immediately and each pump ramps at its own speed (legacy behaviour)." + } + } + }, "mode": { "current": { "default": "optimalControl", @@ -201,7 +210,7 @@ "rules": { "type": "object", "schema": { - "optimalcontrol": { + "optimalControl": { "default": ["parent", "GUI", "physical", "API"], "rules": { "type": "set", @@ -209,13 +218,21 @@ "description": "Command sources allowed in optimalControl mode." } }, - "prioritycontrol": { + "priorityControl": { "default": ["parent", "GUI", "physical", "API"], "rules": { "type": "set", "itemType": "string", "description": "Command sources allowed in priorityControl mode." } + }, + "maintenance": { + "default": ["parent", "GUI"], + "rules": { + "type": "set", + "itemType": "string", + "description": "Command sources allowed in maintenance mode. Status/inspection only — physical/HMI and API writes are dropped." + } } }, "description": "Specifies the valid command sources recognized by the machine group controller for each mode." diff --git a/src/state/state.js b/src/state/state.js index c5bd755..a3e2eaa 100644 --- a/src/state/state.js +++ b/src/state/state.js @@ -23,6 +23,13 @@ class state{ this.delayedMove = null; this.mode = this.config.mode.current; + // Monotonic counter incremented on every EXTERNAL abort (i.e. one + // initiated outside the in-flight sequence — typically MGC reacting + // to a new demand). executeSequence captures the value at entry and + // breaks its for-loop if the counter advances mid-sequence, so a + // shutdown that was already past its ramp-down step doesn't barge + // through stopping → coolingdown when a re-engage arrives. + this.sequenceAbortToken = 0; // Log initialization this.logger.info("State class initialized."); @@ -151,6 +158,14 @@ class state{ if (this.abortController && !this.abortController.signal.aborted) { this.logger.warn(`Aborting movement: ${reason}`); this._returnToOperationalOnAbort = Boolean(options.returnToOperational); + // Only external aborts (returnToOperational=false) advance the + // sequence-abort token. Sequence-internal aborts (e.g. shutdown's + // own setpoint(0) being pre-empted by a fresher shutdown/estop) + // come from inside executeSequence and must not terminate their + // own loop. + if (!options.returnToOperational) { + this.sequenceAbortToken += 1; + } this.abortController.abort(); } } diff --git a/src/state/stateManager.js b/src/state/stateManager.js index 9b3139f..a62c70a 100644 --- a/src/state/stateManager.js +++ b/src/state/stateManager.js @@ -39,6 +39,11 @@ class stateManager { constructor(config, logger) { this.currentState = config.state.current; + // Wall-clock entry timestamp into currentState. Used by + // getRemainingTransitionS() so callers (e.g. MGC movement planner) + // can compute exact remaining time for timed states without + // approximating from the full configured duration. + this.stateEnteredAt = Date.now(); this.availableStates = config.state.available; this.descriptions = config.state.descriptions; this.logger = logger; @@ -63,7 +68,18 @@ class stateManager { getCurrentState() { return this.currentState; } - + + // Seconds remaining in the current timed state (warmingup, coolingdown, + // starting, stopping, …). Returns 0 for untimed states or once the + // configured duration has elapsed. The MGC movement planner uses this to + // compute exact rendezvous time for protected (non-interruptible) states. + getRemainingTransitionS() { + const d = this.transitionTimes?.[this.currentState] || 0; + if (d <= 0) return 0; + const elapsed = (Date.now() - this.stateEnteredAt) / 1000; + return Math.max(0, d - elapsed); + } + transitionTo(newState,signal) { return new Promise((resolve, reject) => { if (signal && signal.aborted) { @@ -89,6 +105,7 @@ class stateManager { if (transitionDuration > 0) { const timeoutId = setTimeout(() => { this.currentState = newState; + this.stateEnteredAt = Date.now(); resolve(`Transition from ${this.currentState} to ${newState} completed in ${transitionDuration}s.`); }, transitionDuration * 1000); if (signal) { @@ -99,6 +116,7 @@ class stateManager { } } else { this.currentState = newState; + this.stateEnteredAt = Date.now(); resolve(`Immediate transition to ${this.currentState} completed.`); } }); diff --git a/test/basic/stateManagerRemaining.basic.test.js b/test/basic/stateManagerRemaining.basic.test.js new file mode 100644 index 0000000..024a611 --- /dev/null +++ b/test/basic/stateManagerRemaining.basic.test.js @@ -0,0 +1,77 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const StateManager = require('../../src/state/stateManager'); + +// Minimal config that satisfies the stateManager constructor's expectations. +// Real configs come from configs/.json; we hand-roll one here so the +// test doesn't drag the whole node-config plumbing in for a 30-line getter. +function makeConfig(initial = 'idle', times = { idle: 0, warmingup: 5 }) { + return { + state: { + current: initial, + available: ['idle', 'warmingup', 'operational'], + descriptions: { idle: 'off', warmingup: 'warming', operational: 'running' }, + allowedTransitions: { + idle: new Set(['warmingup']), + warmingup: new Set(['operational']), + operational: new Set(['idle']), + }, + activeStates: new Set(['operational']), + }, + time: times, + }; +} + +const noopLogger = { debug() {}, info() {}, warn() {}, error() {} }; + +test('getRemainingTransitionS returns 0 for untimed initial state', () => { + const sm = new StateManager(makeConfig('idle'), noopLogger); + assert.equal(sm.getRemainingTransitionS(), 0); +}); + +test('getRemainingTransitionS returns ≈full duration just after entering a timed state', async () => { + const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger); + // Force-enter "warmingup" via the constructor's state machinery: simulate + // by manually setting fields the way transitionTo would. + sm.currentState = 'warmingup'; + sm.stateEnteredAt = Date.now(); + const remaining = sm.getRemainingTransitionS(); + assert.ok(remaining > 4.9 && remaining <= 5.0, `expected ~5s, got ${remaining}`); +}); + +test('getRemainingTransitionS decays with elapsed time', async () => { + const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger); + sm.currentState = 'warmingup'; + sm.stateEnteredAt = Date.now() - 2000; // pretend we entered 2s ago + const remaining = sm.getRemainingTransitionS(); + assert.ok(remaining > 2.9 && remaining <= 3.0, `expected ~3s, got ${remaining}`); +}); + +test('getRemainingTransitionS clamps to 0 once duration has elapsed', () => { + const sm = new StateManager(makeConfig('idle', { idle: 0, warmingup: 5 }), noopLogger); + sm.currentState = 'warmingup'; + sm.stateEnteredAt = Date.now() - 60_000; // a minute ago, way past 5s + assert.equal(sm.getRemainingTransitionS(), 0); +}); + +test('transitionTo refreshes stateEnteredAt on the immediate branch', async () => { + const sm = new StateManager(makeConfig('idle', { idle: 0 }), noopLogger); + const before = sm.stateEnteredAt; + await new Promise((r) => setTimeout(r, 10)); + await sm.transitionTo('warmingup'); + assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance on transition'); +}); + +test('transitionTo refreshes stateEnteredAt on the timed branch', async () => { + // Tiny duration so the test stays fast. + const sm = new StateManager(makeConfig('idle', { idle: 0.05, warmingup: 0 }), noopLogger); + const before = sm.stateEnteredAt; + await new Promise((r) => setTimeout(r, 10)); + await sm.transitionTo('warmingup'); + assert.ok(sm.stateEnteredAt > before, 'stateEnteredAt should advance after timed transition'); + // And remaining should now be 0 (we're in warmingup, but warmingup duration is 0). + assert.equal(sm.getRemainingTransitionS(), 0); +});