Skip to content

Commit

Permalink
fix: abort transmission after response timeout, do not retry, do not …
Browse files Browse the repository at this point in the history
…restart controller unless callback is missing (#6408)
  • Loading branch information
AlCalzone authored Oct 17, 2023
1 parent 15ed348 commit 61b3fc9
Show file tree
Hide file tree
Showing 9 changed files with 592 additions and 44 deletions.
6 changes: 3 additions & 3 deletions docs/api/driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,10 +684,10 @@ interface ZWaveOptions extends ZWaveHostOptions {
byte: number; // >=1, default: 150 ms
/**
* How long to wait for a controller response. Usually this timeout should never elapse,
* so this is merely a safeguard against the driver stalling.
* How long to wait for a controller response. Usually this should never elapse, but when it does,
* the driver will abort the transmission and try to recover the controller if it is unresponsive.
*/
response: number; // [500...60000], default: 30000 ms
response: number; // [500...60000], default: 10000 ms
/** How long to wait for a callback from the host for a SendData[Multicast]Request */
sendDataCallback: number; // >=10000, default: 65000 ms
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/error/ZWaveError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,17 @@ export function isMissingControllerACK(
&& e.context === "ACK";
}

export function isMissingControllerResponse(
e: unknown,
): e is ZWaveError & {
code: ZWaveErrorCodes.Controller_Timeout;
context: "response";
} {
return isZWaveError(e)
&& e.code === ZWaveErrorCodes.Controller_Timeout
&& e.context === "response";
}

export function isMissingControllerCallback(
e: unknown,
): e is ZWaveError & {
Expand Down
62 changes: 45 additions & 17 deletions packages/zwave-js/src/lib/driver/Driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
highResTimestamp,
isMissingControllerACK,
isMissingControllerCallback,
isMissingControllerResponse,
isZWaveError,
messageRecordToLines,
securityClassIsS2,
Expand Down Expand Up @@ -250,8 +251,8 @@ const defaultOptions: ZWaveOptions = {
ack: 1000,
byte: 150,
// Ideally we'd want to have this as low as possible, but some
// 500 series controllers can take upwards of 10 seconds to respond sometimes.
response: 30000,
// 500 series controllers can take several seconds to respond sometimes.
response: 10000,
report: 1000, // ReportTime timeout SHOULD be set to CommandTime + 1 second
nonce: 5000,
sendDataCallback: 65000, // as defined in INS13954
Expand Down Expand Up @@ -3556,17 +3557,21 @@ export class Driver extends TypedEventEmitter<DriverEventCallbacks>
* Handles the case that the controller didn't send the callback for a SendData in time
* Returns `true` if the transaction failure was handled, `false` if it needs to be rejected.
*/
private handleMissingSendDataCallback(
private handleMissingSendDataResponseOrCallback(
transaction: Transaction,
error: ZWaveError & {
code: ZWaveErrorCodes.Controller_Timeout;
context: "callback";
context: "callback" | "response";
},
): boolean {
if (!this._controller) return false;

if (
this._recoveryPhase
// The SendData response can time out on older controllers trying to reach a dead node.
// In this case, we do not want to reset the controller, but just mark the node as dead.
error.context === "response"
// Also do this if the callback is timing out even after restarting the controller
|| this._recoveryPhase
=== ControllerRecoveryPhase.CallbackTimeoutAfterReset
) {
const node = this.getNodeUnsafe(transaction.message);
Expand Down Expand Up @@ -4849,7 +4854,7 @@ ${handlers.length} left`,
msg,
// Ignore the number of attempts while jammed
attemptNumber - jammedAttempts,
e.code,
e,
)
) {
// Retry the command
Expand Down Expand Up @@ -4909,19 +4914,29 @@ ${handlers.length} left`,
private mayRetrySerialAPICommand(
msg: Message,
attemptNumber: number,
errorCode: ZWaveErrorCodes,
error: ZWaveError,
): boolean {
// Only retry Send Data, nothing else
if (!isSendData(msg)) return false;

// Don't try to resend SendData commands when the response times out
if (
msg instanceof SendDataMulticastRequest
|| msg instanceof SendDataMulticastBridgeRequest
error.code === ZWaveErrorCodes.Controller_Timeout
&& error.context === "response"
) {
// Don't try to resend multicast messages if they were already transmitted.
// One or more nodes might have already reacted
if (errorCode === ZWaveErrorCodes.Controller_CallbackNOK) {
return false;
}
return false;
}

// Don't try to resend multicast messages if they were already transmitted.
// One or more nodes might have already reacted
if (
(msg instanceof SendDataMulticastRequest
|| msg instanceof SendDataMulticastBridgeRequest)
&& error.code === ZWaveErrorCodes.Controller_CallbackNOK
) {
return false;
}

return attemptNumber < msg.maxSendAttempts;
}

Expand All @@ -4936,7 +4951,8 @@ ${handlers.length} left`,
const machine = createSerialAPICommandMachine(
msg,
{
sendData: this.writeSerial.bind(this),
sendData: (data) => this.writeSerial(data),
sendDataAbort: () => this.abortSendData(false),
notifyUnsolicited: (msg) => {
void this.handleUnsolicitedMessage(msg);
},
Expand Down Expand Up @@ -5024,6 +5040,15 @@ ${handlers.length} left`,
}
});

// Uncomment this for debugging state machine transitions
// this.serialAPIInterpreter.onTransition((state) => {
// if (state.changed) {
// this.driverLog.print(
// `CMDMACHINE: ${JSON.stringify(state.toStrings())}`,
// );
// }
// });

this.serialAPIInterpreter.start();

return result;
Expand Down Expand Up @@ -5663,9 +5688,12 @@ ${handlers.length} left`,
if (this.handleMissingControllerACK(transaction, error)) return;
} else if (
isSendData(transaction.message)
&& isMissingControllerCallback(error)
&& (isMissingControllerResponse(error)
|| isMissingControllerCallback(error))
) {
if (this.handleMissingSendDataCallback(transaction, error)) return;
if (
this.handleMissingSendDataResponseOrCallback(transaction, error)
) return;
}

this.rejectTransaction(transaction, error);
Expand Down
41 changes: 32 additions & 9 deletions packages/zwave-js/src/lib/driver/SerialAPICommandMachine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ interface TestMachineStateSchema {
waitForACK: {};
waitForResponse: {};
waitForCallback: {};
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// waitForCallbackAfterTimeout: {};
unsolicited: {};
success: {};
failure: {};
Expand Down Expand Up @@ -227,7 +229,11 @@ const testMachine = Machine<
{ target: "failure" },
],
RESPONSE_TIMEOUT: [
{ target: "sending", cond: "maySendAgain" },
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// {
// target: "waitForCallbackAfterTimeout",
// cond: "expectsCallback",
// },
{ target: "failure" },
],
UNSOLICITED: "unsolicited",
Expand All @@ -247,6 +253,13 @@ const testMachine = Machine<
UNSOLICITED: "unsolicited",
},
},
// FIXME: This is relevant for SendData, but we're not using SendData messages in this test
// waitForCallbackAfterTimeout: {
// on: {
// CALLBACK_NOK: "failure",
// CALLBACK_TIMEOUT: "failure",
// },
// },
unsolicited: {
meta: {
test: ({
Expand Down Expand Up @@ -386,13 +399,22 @@ testPlans.forEach((plan) => {
);

plan.paths.forEach((path) => {
// Uncomment this and change the path description to only run a single test
// if (
// !path.description.includes(
// `CREATE ({"resp":true,"cb":true}) → SEND_FAILURE → SEND_FAILURE → SEND_SUCCESS → ACK → RESPONSE_TIMEOUT → CALLBACK_NOK`,
// )
// ) {
// return;
// }
test.serial(`${planDescription} ${path.description}`, async (t) => {
// eslint-disable-next-line prefer-const
let context: TestContext;
const sendData = sinon.stub().callsFake(() => {
context.sendDataPromise = createDeferredPromise();
return context.sendDataPromise;
});
const sendDataAbort = sinon.stub();
const notifyRetry = sinon.stub();
const timestamp = () => 0;
const logOutgoingMessage = () => {};
Expand All @@ -402,6 +424,7 @@ testPlans.forEach((plan) => {

const implementations: SerialAPICommandServiceImplementations = {
sendData,
sendDataAbort,
notifyRetry,
timestamp,
logOutgoingMessage,
Expand Down Expand Up @@ -483,11 +506,11 @@ testPlans.forEach((plan) => {
});
});

test.serial("coverage", (t) => {
testModel.testCoverage({
filter: (stateNode) => {
return !!stateNode.meta;
},
});
t.pass();
});
// test.serial("coverage", (t) => {
// testModel.testCoverage({
// filter: (stateNode) => {
// return !!stateNode.meta;
// },
// });
// t.pass();
// });
53 changes: 45 additions & 8 deletions packages/zwave-js/src/lib/driver/SerialAPICommandMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
createMachine,
raise,
} from "xstate";
import { isSendData } from "../serialapi/transport/SendDataShared";
import type { ZWaveOptions } from "./ZWaveOptions";

/* eslint-disable @typescript-eslint/ban-types */
Expand Down Expand Up @@ -93,6 +94,7 @@ export type SerialAPICommandDoneData =
export interface SerialAPICommandServiceImplementations {
timestamp: () => number;
sendData: (data: Buffer) => Promise<void>;
sendDataAbort: () => Promise<void>;
notifyRetry: (
lastError: SerialAPICommandError | undefined,
message: Message,
Expand Down Expand Up @@ -148,9 +150,13 @@ export function getSerialAPICommandMachineConfig(
timestamp,
logOutgoingMessage,
notifyUnsolicited,
sendDataAbort,
}: Pick<
SerialAPICommandServiceImplementations,
"timestamp" | "logOutgoingMessage" | "notifyUnsolicited"
| "timestamp"
| "logOutgoingMessage"
| "notifyUnsolicited"
| "sendDataAbort"
>,
attemptsConfig: SerialAPICommandMachineParams["attempts"],
): SerialAPICommandMachineConfig {
Expand Down Expand Up @@ -255,22 +261,42 @@ export function getSerialAPICommandMachineConfig(
],
},
after: {
RESPONSE_TIMEOUT: {
target: "retry",
actions: assign({
lastError: (_) => "response timeout",
}),
},
// Do not retry when a response times out
RESPONSE_TIMEOUT: [
{
cond: "isSendData",
target: "waitForCallback",
actions: [
() => sendDataAbort(),
assign({
lastError: (_) => "response timeout",
}),
],
},
{
target: "failure",
actions: assign({
lastError: (_) => "response timeout",
}),
},
],
},
},
waitForCallback: {
always: [{ target: "success", cond: "expectsNoCallback" }],
on: {
callback: [
{
// Preserve "response timeout" errors
// A NOK callback afterwards is expected, but we're not interested in it
target: "failure",
cond: "callbackIsNOKAfterTimedOutResponse",
},
{
target: "failure",
cond: "callbackIsNOK",
actions: assign({
// Preserve "response timeout" errors
lastError: (_) => "callback NOK",
result: (_, evt) => (evt as any).message,
}),
Expand Down Expand Up @@ -342,7 +368,10 @@ export function getSerialAPICommandMachineOptions(
{
sendData,
notifyRetry,
}: Pick<SerialAPICommandServiceImplementations, "sendData" | "notifyRetry">,
}: Pick<
SerialAPICommandServiceImplementations,
"sendData" | "sendDataAbort" | "notifyRetry"
>,
timeoutConfig: SerialAPICommandMachineParams["timeouts"],
): SerialAPICommandMachineOptions {
return {
Expand All @@ -365,6 +394,7 @@ export function getSerialAPICommandMachineOptions(
},
guards: {
mayRetry: (ctx) => ctx.attempts < ctx.maxAttempts,
isSendData: (ctx) => isSendData(ctx.msg),
expectsNoResponse: (ctx) => !ctx.msg.expectsResponse(),
expectsNoCallback: (ctx) => !ctx.msg.expectsCallback(),
isExpectedMessage: (ctx, evt, meta) =>
Expand All @@ -378,6 +408,13 @@ export function getSerialAPICommandMachineOptions(
// assume responses without success indication to be OK
&& isSuccessIndicator(evt.message)
&& !evt.message.isOK(),
callbackIsNOKAfterTimedOutResponse: (ctx, evt) =>
evt.type === "callback"
// assume callbacks without success indication to be OK
&& isSuccessIndicator(evt.message)
&& !evt.message.isOK()
&& isSendData(ctx.msg)
&& ctx.lastError === "response timeout",
callbackIsNOK: (ctx, evt) =>
evt.type === "callback"
// assume callbacks without success indication to be OK
Expand Down
6 changes: 3 additions & 3 deletions packages/zwave-js/src/lib/driver/ZWaveOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ export interface ZWaveOptions extends ZWaveHostOptions {
byte: number; // >=1, default: 150 ms

/**
* How long to wait for a controller response. Usually this timeout should never elapse,
* so this is merely a safeguard against the driver stalling.
* How long to wait for a controller response. Usually this should never elapse, but when it does,
* the driver will abort the transmission and try to recover the controller if it is unresponsive.
*/
response: number; // [500...60000], default: 30000 ms
response: number; // [500...60000], default: 10000 ms

/** How long to wait for a callback from the host for a SendData[Multicast]Request */
sendDataCallback: number; // >=10000, default: 65000 ms
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ integrationTest(
}),
);

await wait(100);
await wait(200);

const sensorValue = MultilevelSensorCCValues.value(
"Air temperature",
Expand Down
Loading

0 comments on commit 61b3fc9

Please sign in to comment.