UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

397 lines (384 loc) 9.46 kB
import type { Message } from "@zwave-js/serial"; import { isMultiStageCallback, isSuccessIndicator, MessageType, } from "@zwave-js/serial"; import { assign, createMachine, Interpreter, MachineConfig, MachineOptions, StateMachine, } from "xstate"; import { send } from "xstate/lib/actions"; import { respondUnsolicited, ServiceImplementations, } from "./StateMachineShared"; import type { ZWaveOptions } from "./ZWaveOptions"; /* eslint-disable @typescript-eslint/ban-types */ export interface SerialAPICommandStateSchema { states: { sending: {}; waitForACK: {}; waitForResponse: {}; waitForCallback: {}; retry: {}; retryWait: {}; failure: {}; success: {}; }; } /* eslint-enable @typescript-eslint/ban-types */ export type SerialAPICommandError = | "send failure" | "CAN" | "NAK" | "ACK timeout" | "response timeout" | "callback timeout" | "response NOK" | "callback NOK"; export interface SerialAPICommandContext { msg: Message; data: Buffer; attempts: number; maxAttempts: number; lastError?: SerialAPICommandError; result?: Message; txTimestamp?: number; } export type SerialAPICommandEvent = | { type: "ACK" } | { type: "CAN" } | { type: "NAK" } | { type: "message"; message: Message } // A message that might or might not be expected | { type: "response"; message: Message } // Gets forwarded when a response-type message is expected | { type: "callback"; message: Message } // Gets forwarded when a callback-type message is expected | { type: "unsolicited"; message: Message }; // A message that IS unexpected on the Serial API level export type SerialAPICommandDoneData = | { type: "success"; txTimestamp: number; result?: Message; } | ({ type: "failure"; } & ( | { reason: | "send failure" | "CAN" | "NAK" | "ACK timeout" | "response timeout" | "callback timeout"; result?: undefined; } | { reason: "response NOK" | "callback NOK"; result: Message; } )); function computeRetryDelay(ctx: SerialAPICommandContext): number { return 100 + 1000 * (ctx.attempts - 1); } const forwardMessage = send((_, evt: SerialAPICommandEvent) => { const msg = (evt as any).message as Message; return { type: msg.type === MessageType.Response ? "response" : "callback", message: msg, } as SerialAPICommandEvent; }); export type SerialAPICommandMachineConfig = MachineConfig< SerialAPICommandContext, SerialAPICommandStateSchema, SerialAPICommandEvent >; export type SerialAPICommandMachine = StateMachine< SerialAPICommandContext, SerialAPICommandStateSchema, SerialAPICommandEvent, any, any, any, any >; export type SerialAPICommandInterpreter = Interpreter< SerialAPICommandContext, SerialAPICommandStateSchema, SerialAPICommandEvent >; export type SerialAPICommandMachineOptions = Partial< MachineOptions<SerialAPICommandContext, SerialAPICommandEvent> >; export type SerialAPICommandMachineParams = { timeouts: Pick< ZWaveOptions["timeouts"], "ack" | "response" | "sendDataCallback" >; attempts: Pick<ZWaveOptions["attempts"], "controller">; }; export function getSerialAPICommandMachineConfig( message: Message, { timestamp, logOutgoingMessage, }: Pick<ServiceImplementations, "timestamp" | "logOutgoingMessage">, attemptsConfig: SerialAPICommandMachineParams["attempts"], ): SerialAPICommandMachineConfig { return { id: "serialAPICommand", initial: "sending", context: { msg: message, data: message.serialize(), attempts: 0, maxAttempts: attemptsConfig.controller, }, on: { // The state machine accepts any message. If it is expected // it will be forwarded to the correct states. If not, it // will be returned with the "unsolicited" event. message: [ { cond: "isExpectedMessage", actions: forwardMessage as any, }, { actions: respondUnsolicited, }, ], }, states: { sending: { // Every send attempt should increase the attempts by one // and remember the timestamp of transmission entry: [ assign({ attempts: (ctx) => ctx.attempts + 1, txTimestamp: (_) => timestamp(), }), (ctx) => logOutgoingMessage(ctx.msg), ], invoke: { id: "sendMessage", src: "send", onDone: "waitForACK", onError: { target: "retry", actions: assign({ lastError: (_) => "send failure", }), }, }, }, waitForACK: { on: { CAN: { target: "retry", actions: assign({ lastError: (_) => "CAN", }), }, NAK: { target: "retry", actions: assign({ lastError: (_) => "NAK", }), }, ACK: "waitForResponse", }, after: { ACK_TIMEOUT: { target: "retry", actions: assign({ lastError: (_) => "ACK timeout", }), }, }, }, waitForResponse: { always: [ { target: "waitForCallback", cond: "expectsNoResponse", }, ], on: { response: [ { target: "retry", cond: "responseIsNOK", actions: assign({ lastError: (_) => "response NOK", result: (_, evt) => (evt as any).message, }), }, { target: "waitForCallback", actions: assign({ result: (_, evt) => (evt as any).message, }), }, ], }, after: { RESPONSE_TIMEOUT: { target: "retry", actions: assign({ lastError: (_) => "response timeout", }), }, }, }, waitForCallback: { always: [{ target: "success", cond: "expectsNoCallback" }], on: { callback: [ { target: "failure", cond: "callbackIsNOK", actions: assign({ lastError: (_) => "callback NOK", result: (_, evt) => (evt as any).message, }), }, { target: "success", cond: "callbackIsFinal", actions: assign({ result: (_, evt) => (evt as any).message, }), }, { target: "waitForCallback" }, ], }, after: { CALLBACK_TIMEOUT: { target: "failure", actions: assign({ lastError: (_) => "callback timeout", }), }, }, }, retry: { always: [ { target: "retryWait", cond: "mayRetry" }, { target: "failure" }, ], }, retryWait: { invoke: { id: "notify", src: "notifyRetry", }, after: { RETRY_DELAY: "sending", }, }, success: { type: "final", data: { type: "success", txTimestamp: (ctx: SerialAPICommandContext) => ctx.txTimestamp!, result: (ctx: SerialAPICommandContext) => ctx.result, }, }, failure: { type: "final", data: { type: "failure", reason: (ctx: SerialAPICommandContext) => ctx.lastError, result: (ctx: SerialAPICommandContext) => ctx.result!, }, }, }, }; } export function getSerialAPICommandMachineOptions( { sendData, notifyRetry, }: Pick<ServiceImplementations, "sendData" | "notifyRetry">, timeoutConfig: SerialAPICommandMachineParams["timeouts"], ): SerialAPICommandMachineOptions { return { services: { send: (ctx) => { // Mark the message as sent immediately before actually sending ctx.msg.markAsSent(); return sendData(ctx.data); }, notifyRetry: (ctx) => { notifyRetry?.( "SerialAPI", ctx.lastError, ctx.msg, ctx.attempts, ctx.maxAttempts, computeRetryDelay(ctx), ); return Promise.resolve(); }, }, guards: { mayRetry: (ctx) => ctx.attempts < ctx.maxAttempts, expectsNoResponse: (ctx) => !ctx.msg.expectsResponse(), expectsNoCallback: (ctx) => !ctx.msg.expectsCallback(), isExpectedMessage: (ctx, evt, meta) => meta.state.matches("waitForResponse") ? ctx.msg.isExpectedResponse((evt as any).message) : meta.state.matches("waitForCallback") ? ctx.msg.isExpectedCallback((evt as any).message) : false, responseIsNOK: (ctx, evt) => evt.type === "response" && // assume responses without success indication to be OK isSuccessIndicator(evt.message) && !evt.message.isOK(), callbackIsNOK: (ctx, evt) => evt.type === "callback" && // assume callbacks without success indication to be OK isSuccessIndicator(evt.message) && !evt.message.isOK(), callbackIsFinal: (ctx, evt) => evt.type === "callback" && // assume callbacks without success indication to be OK (!isSuccessIndicator(evt.message) || evt.message.isOK()) && // assume callbacks without isFinal method to be final (!isMultiStageCallback(evt.message) || evt.message.isFinal()), }, delays: { RETRY_DELAY: (ctx) => computeRetryDelay(ctx), RESPONSE_TIMEOUT: timeoutConfig.response, CALLBACK_TIMEOUT: (ctx) => { return ( // Ask the message for its callback timeout ctx.msg.getCallbackTimeout() || // and fall back to default values timeoutConfig.sendDataCallback ); }, ACK_TIMEOUT: timeoutConfig.ack, }, }; } export function createSerialAPICommandMachine( message: Message, implementations: ServiceImplementations, params: SerialAPICommandMachineParams, ): SerialAPICommandMachine { return createMachine<SerialAPICommandContext, SerialAPICommandEvent>( getSerialAPICommandMachineConfig( message, implementations, params.attempts, ), getSerialAPICommandMachineOptions(implementations, params.timeouts), ); }