diff --git a/packages/zwave-js/src/lib/driver/Driver.ts b/packages/zwave-js/src/lib/driver/Driver.ts index 77ae094cee18..756dbcc8c888 100644 --- a/packages/zwave-js/src/lib/driver/Driver.ts +++ b/packages/zwave-js/src/lib/driver/Driver.ts @@ -3647,34 +3647,50 @@ export class Driver extends TypedEventEmitter } } else if (this._controller.status !== ControllerStatus.Unresponsive) { // The controller was responsive before this transaction failed. - // Mark it as unresponsive and try to soft-reset it. - this.controller.setStatus( - ControllerStatus.Unresponsive, - ); - this._recoveryPhase = ControllerRecoveryPhase.CallbackTimeout; + if (this.maySoftReset()) { + // Mark it as unresponsive and try to soft-reset it. + this.controller.setStatus( + ControllerStatus.Unresponsive, + ); - this.driverLog.print( - "Controller missed Send Data callback. Attempting to recover...", - "warn", - ); + this._recoveryPhase = ControllerRecoveryPhase.CallbackTimeout; - // Re-queue the transaction. - // Its message generator may have finished, so reset that too. - transaction.reset(); - this.queue.add(transaction.clone()); + this.driverLog.print( + "Controller missed Send Data callback. Attempting to recover...", + "warn", + ); - // Execute the soft-reset asynchronously - void this.softReset().then(() => { - // The controller responded. It is no longer unresponsive - this._controller?.setStatus(ControllerStatus.Ready); + // Re-queue the transaction. + // Its message generator may have finished, so reset that too. + transaction.reset(); + this.queue.add(transaction.clone()); - this._recoveryPhase = - ControllerRecoveryPhase.CallbackTimeoutAfterReset; - }).catch(() => { - // Soft-reset failed. Just reject the original transaction. + // Execute the soft-reset asynchronously + void this.softReset().then(() => { + // The controller responded. It is no longer unresponsive + this._controller?.setStatus(ControllerStatus.Ready); + + this._recoveryPhase = + ControllerRecoveryPhase.CallbackTimeoutAfterReset; + }).catch(() => { + // Soft-reset failed. Just reject the original transaction. + this.rejectTransaction(transaction, error); + + this.driverLog.print( + "Automatic controller recovery failed. Returning to normal operation and hoping for the best.", + "warn", + ); + this._recoveryPhase = ControllerRecoveryPhase.None; + this._controller?.setStatus(ControllerStatus.Ready); + }); + } else { + this.driverLog.print( + "Controller missed Send Data callback. Cannot recover automatically because the soft reset feature is unsupported or disabled.", + "warn", + ); this.rejectTransaction(transaction, error); - }); + } return true; } else { diff --git a/packages/zwave-js/src/lib/test/driver/sendDataMissingCallbackAbort.test.ts b/packages/zwave-js/src/lib/test/driver/sendDataMissingCallbackAbort.test.ts index 49e7b69118da..8c8ba4c2db4a 100644 --- a/packages/zwave-js/src/lib/test/driver/sendDataMissingCallbackAbort.test.ts +++ b/packages/zwave-js/src/lib/test/driver/sendDataMissingCallbackAbort.test.ts @@ -367,3 +367,112 @@ integrationTest( }, }, ); + +integrationTest( + "With soft-reset disabled, transmissions do not get stuck after a missing Send Data callback", + { + // debug: true, + + // provisioningDirectory: path.join( + // __dirname, + // "__fixtures/supervision_binary_switch", + // ), + + controllerCapabilities: { + // Soft-reset cannot be disabled on 700+ series + libraryVersion: "Z-Wave 6.84.0", + }, + + additionalDriverOptions: { + enableSoftReset: false, + testingHooks: { + skipNodeInterview: true, + }, + }, + + customSetup: async (driver, mockController, mockNode) => { + // This is almost a 1:1 copy of the default behavior, except that the callback never gets sent + const handleBrokenSendData: MockControllerBehavior = { + async onHostMessage(host, controller, msg) { + // If the controller is operating normally, defer to the default behavior + if (!shouldTimeOut) return false; + + if (msg instanceof SendDataRequest) { + // Check if this command is legal right now + const state = controller.state.get( + MockControllerStateKeys.CommunicationState, + ) as MockControllerCommunicationState | undefined; + if ( + state != undefined + && state !== MockControllerCommunicationState.Idle + ) { + throw new Error( + "Received SendDataRequest while not idle", + ); + } + + // Put the controller into sending state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Sending, + ); + + // Notify the host that the message was sent + const res = new SendDataResponse(host, { + wasSent: true, + }); + await controller.sendToHost(res.serialize()); + + return true; + } else if (msg instanceof SendDataAbort) { + // Put the controller into idle state + controller.state.set( + MockControllerStateKeys.CommunicationState, + MockControllerCommunicationState.Idle, + ); + + // We only timeout once in this test + shouldTimeOut = false; + + return true; + } + }, + }; + mockController.defineBehavior(handleBrokenSendData); + }, + testBody: async (t, driver, node, mockController, mockNode) => { + // Circumvent the options validation so the test doesn't take forever + driver.options.timeouts.sendDataAbort = 1000; + driver.options.timeouts.sendDataCallback = 1500; + + shouldTimeOut = true; + + const firstCommand = node.commandClasses.Basic.set(99).catch((e) => + e.code + ); + const followupCommand = node.commandClasses.Basic.set(0); + + await wait(2500); + + // Transmission should have been aborted + mockController.assertReceivedHostMessage( + (msg) => msg.functionType === FunctionType.SendDataAbort, + ); + // but the stick should NOT have been soft-reset + t.throws(() => + mockController.assertReceivedHostMessage( + (msg) => msg.functionType === FunctionType.SoftReset, + ) + ); + mockController.clearReceivedHostMessages(); + + // The first command should be failed + t.is(await firstCommand, ZWaveErrorCodes.Controller_Timeout); + + // The followup command should eventually succeed + await followupCommand; + + t.pass(); + }, + }, +);