UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

347 lines (338 loc) 9.31 kB
/* 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: {}, }, ); }