inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
347 lines (338 loc) • 9.31 kB
text/typescript
/* eslint-disable @typescript-eslint/no-empty-function */
import { messageIsPing } from "@zwave-js/cc/NoOperationCC";
import type { ZWaveError } from "@zwave-js/core";
import type { Message } from "@zwave-js/serial";
import {
assign,
createMachine,
Interpreter,
MachineOptions,
sendParent,
StateMachine,
} from "xstate";
import { SendDataMulticastBridgeRequest } from "../serialapi/transport/SendDataBridgeMessages";
import { SendDataMulticastRequest } from "../serialapi/transport/SendDataMessages";
import {
isSendData,
isTransmitReport,
SendDataMessage,
} from "../serialapi/transport/SendDataShared";
import type { CommandQueueEvent } from "./CommandQueueMachine";
import {
createMessageDroppedUnexpectedError,
sendDataErrorToZWaveError,
ServiceImplementations,
} from "./StateMachineShared";
import type { Transaction } from "./Transaction";
/*
The command queue is a small wrapper around the Serial API Command Machine
which does basic queue handling and aborts timed out send data commands.
It does not care about node status etc.
*/
export interface TransactionMachineContext {
transaction: Transaction;
sendDataAttempts: number;
result?: Message;
error?: ZWaveError;
}
export type TransactionMachineEvent =
| (CommandQueueEvent & { type: "command_success" })
| (CommandQueueEvent & { type: "command_failure" })
| (CommandQueueEvent & { type: "command_error" })
| { type: "NIF"; nodeId: number }
// Re-transmit the current message immediately
| { type: "resend" }
// Immediately stop the transaction
| { type: "stop" };
const guards: NonNullable<
MachineOptions<TransactionMachineContext, TransactionMachineEvent>["guards"]
> = {
mayRetry: (ctx, evt: any) => {
const msg = ctx.transaction.parts.current;
if (!isSendData(msg)) return false;
if (
msg instanceof SendDataMulticastRequest ||
msg instanceof SendDataMulticastBridgeRequest
) {
// Don't try to resend multicast messages if they were already transmitted.
// One or more nodes might have already reacted
if (evt.reason === "callback NOK") {
return false;
}
}
return msg.maxSendAttempts > ctx.sendDataAttempts;
},
currentMessageIsSendData: (ctx) =>
isSendData(ctx.transaction.parts.current),
currentTransactionIsPingForNode: (ctx, evt) => {
const msg = ctx.transaction.parts.current;
return (
!!msg &&
messageIsPing(msg) &&
msg.getNodeId() === (evt as any).nodeId
);
},
hasMessage: (ctx) => !!ctx.transaction.parts.current,
};
const every = (...guards: string[]) => ({
type: "every",
guards,
});
export type TransactionMachine = StateMachine<
TransactionMachineContext,
any,
TransactionMachineEvent,
any,
any,
any,
any
>;
export type TransactionMachineInterpreter = Interpreter<
TransactionMachineContext,
any,
TransactionMachineEvent
>;
export function createTransactionMachine(
id: string,
transaction: Transaction,
implementations: ServiceImplementations,
): TransactionMachine {
return createMachine<TransactionMachineContext, TransactionMachineEvent>(
{
preserveActionOrder: true,
id,
initial: "init",
context: {
transaction,
sendDataAttempts: 0,
},
on: {
NIF: {
// Pings are not retransmitted and won't receive a response if the node wake up after the ping was sent
// Therefore resolve pending pings so the communication may proceed immediately
cond: "currentTransactionIsPingForNode",
actions: "resolvePing",
target: "done",
internal: true,
},
resend: {
// The driver asked to re-transmit the current message again immediately
// without increasing the retry counter
target: "execute",
internal: true,
},
stop: "done",
},
states: {
init: {
always: {
actions: "startTransaction",
target: "nextMessage",
},
},
// The following states are repeated for every message in this transaction
nextMessage: {
invoke: {
// The message generator asynchronously generates a new message to send
// or undefined if it has reached the end. Invoking this promise doubles
// as waiting for the node response without introducing additional states
id: "nextMessage",
src: "nextMessage",
onDone: [
{
cond: "hasMessage",
target: "attemptMessage",
// Each newly generated message gets its own sendData attempts
actions: "resetSendDataAttempts",
},
// When the transaction generator is empty, we're done with this transaction
{ target: "done" },
],
// If the next message cannot be generated because of an error, the transaction is also done
onError: { target: "done" },
},
},
// Increase send data counter before sending the message
attemptMessage: {
always: [
{
cond: "currentMessageIsSendData",
actions: "incrementSendDataAttempts",
target: "execute",
},
{
target: "execute",
},
],
},
execute: {
entry: "sendToCommandQueue",
on: {
command_success: [
// On success, resolve the transaction and wait for the driver's GO for the next one
{
actions: "rememberCommandSuccess",
target: "nextMessage",
},
],
command_failure: [
// On failure, retry SendData commands if possible
{
cond: every(
"currentMessageIsSendData",
"mayRetry",
),
target: "retryWait",
},
// Otherwise reject the transaction
{
actions: "rememberCommandFailure",
target: "nextMessage",
},
],
command_error: [
// On failure, retry SendData commands if possible
{
cond: every(
"currentMessageIsSendData",
"mayRetry",
),
target: "retryWait",
},
// Otherwise reject the transaction
{
actions: "rememberCommandError",
target: "nextMessage",
},
],
},
},
retryWait: {
invoke: {
id: "notify",
src: "notifyRetry",
},
after: {
500: "attemptMessage",
},
},
done: {
type: "final",
// Notify the parent machine so it can clean up
entry: sendParent({
type: "transaction_done",
id,
}),
},
},
},
{
actions: {
startTransaction: (ctx) => {
ctx.transaction.parts.start();
},
resetSendDataAttempts: assign({
sendDataAttempts: (_) => 0,
}),
incrementSendDataAttempts: assign({
sendDataAttempts: (ctx) => ctx.sendDataAttempts + 1,
}),
sendToCommandQueue: sendParent((ctx) => ({
type: "forward",
to: "QUEUE",
from: id,
payload: {
type: "add",
transaction: ctx.transaction,
},
})),
resolvePing: (ctx) => {
// To resolve a ping, exit the message generator early by throwing something that's not an error
ctx.transaction.parts
.self!.throw(undefined as any)
.catch(() => {});
},
rememberCommandSuccess: assign({
result: (_, evt: any) => evt.result,
error: (_) => undefined,
}),
rememberCommandFailure: assign((ctx, evt: any) => {
// For messages that were sent to a node, a NOK callback still contains useful info we need to evaluate
if (
(isSendData(ctx.transaction.parts.current) ||
isTransmitReport(evt.result)) &&
evt.reason === "callback NOK"
) {
return {
...ctx,
result: evt.result,
error: undefined,
};
} else {
return {
...ctx,
result: undefined,
error: sendDataErrorToZWaveError(
evt.reason,
ctx.transaction,
evt.result,
),
};
}
}),
rememberCommandError: assign({
result: (_) => undefined,
error: (_, evt: any) =>
createMessageDroppedUnexpectedError(evt.error),
}),
unsetCommandResult: assign({
result: (_) => undefined,
error: (_) => undefined,
}),
},
services: {
// This service is used to return something to the yielded message generator
// Depending on the outcome of the last command, the generator will either be thrown or continued with the result
nextMessage: (ctx: TransactionMachineContext) => {
if (ctx.error) {
implementations.rejectTransaction(
ctx.transaction,
ctx.error,
);
return Promise.resolve();
} else {
// self can be undefined if the transaction was expired while in flight
// In that case, resolve to nothing immediately to end the Transaction machine
return (
ctx.transaction.parts.self?.next(
ctx.result as any,
) ?? Promise.resolve()
);
}
},
notifyRetry: (ctx) => {
implementations.notifyRetry?.(
"SendData",
undefined,
ctx.transaction.message,
ctx.sendDataAttempts,
(ctx.transaction.message as SendDataMessage)
.maxSendAttempts,
500,
);
return Promise.resolve();
},
},
guards: {
...guards,
every: (ctx, event, { cond }) => {
const keys = (cond as any).guards as string[];
return keys.every((guardKey: string) =>
guards[guardKey]?.(ctx, event, undefined as any),
);
},
},
delays: {},
},
);
}