diff --git a/src/state/state.js b/src/state/state.js index 19c63e9..e2dc103 100644 --- a/src/state/state.js +++ b/src/state/state.js @@ -85,17 +85,24 @@ class state{ this.emitter.emit("movementComplete", { position: targetPosition }); await this.transitionToState("operational"); } catch (error) { - // Abort path: return to 'operational' so a subsequent shutdown/emergency - // sequence can proceed. Without this, the FSM remains stuck in - // accelerating/decelerating and blocks stopping/idle transitions. + // Abort path: only return to 'operational' when explicitly requested + // (shutdown/emergency-stop needs it to unblock the FSM). Routine MGC + // demand-update aborts must NOT auto-transition — doing so causes a + // bounce loop where every tick aborts → operational → new move → + // abort → operational → ... and the pump never reaches its setpoint. const msg = typeof error === 'string' ? error : error?.message; if (msg === 'Transition aborted' || msg === 'Movement aborted') { - this.logger.debug(`Movement aborted; returning to 'operational' to unblock further transitions.`); - try { - await this.transitionToState("operational"); - } catch (e) { - this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`); + if (this._returnToOperationalOnAbort) { + this.logger.debug(`Movement aborted; returning to 'operational' (requested by caller).`); + try { + await this.transitionToState("operational"); + } catch (e) { + this.logger.debug(`Post-abort transition to operational failed: ${e?.message || e}`); + } + } else { + this.logger.debug(`Movement aborted; staying in current state (routine abort).`); } + this._returnToOperationalOnAbort = false; this.emitter.emit("movementAborted", { position: targetPosition }); } else { this.logger.error(error); @@ -105,9 +112,19 @@ class state{ // -------- State Transition Methods -------- // - abortCurrentMovement(reason = "group override") { + /** + * @param {string} reason - human-readable abort reason + * @param {object} [options] + * @param {boolean} [options.returnToOperational=false] - when true the FSM + * transitions back to 'operational' after the abort so a subsequent + * shutdown/emergency-stop sequence can proceed. Set to false (default) + * for routine demand updates where the caller will send a new movement + * immediately — auto-transitioning would cause a bounce loop. + */ + abortCurrentMovement(reason = "group override", options = {}) { if (this.abortController && !this.abortController.signal.aborted) { this.logger.warn(`Aborting movement: ${reason}`); + this._returnToOperationalOnAbort = Boolean(options.returnToOperational); this.abortController.abort(); } }