UNPKG

inventoresed

Version:

Z-Wave driver written entirely in JavaScript/TypeScript

396 lines (377 loc) 10.5 kB
import type { Message } from "@zwave-js/serial"; import { getErrorMessage } from "@zwave-js/shared"; import { SortedList } from "alcalzone-shared/sorted-list"; import { ActionObject, ActorRefFrom, assign, AssignAction, Interpreter, Machine, spawn, StateMachine, } from "xstate"; import { forwardTo, pure, raise, sendParent, stop } from "xstate/lib/actions"; import { isSendData } from "../serialapi/transport/SendDataShared"; import { createSerialAPICommandMachine, SerialAPICommandDoneData, SerialAPICommandMachine, SerialAPICommandMachineParams, } from "./SerialAPICommandMachine"; import { notifyUnsolicited, 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. */ /* eslint-disable @typescript-eslint/ban-types */ export interface CommandQueueStateSchema { states: { idle: {}; execute: {}; abortSendData: {}; executeDone: {}; }; } /* eslint-enable @typescript-eslint/ban-types */ export interface CommandQueueContext { queue: SortedList<Transaction>; callbackIDs: WeakMap<Transaction, string>; currentTransaction?: Transaction; abortActor?: ActorRefFrom<SerialAPICommandMachine>; } export type CommandQueueEvent = | { type: "trigger" } // Used internally to trigger sending from the idle state | { type: "add"; transaction: Transaction; from: string } // Adds a transaction to the command queue // These events are forwarded to the SerialAPICommand machine | { type: "ACK" } | { type: "CAN" } | { type: "NAK" } // Used for received messages. The message will be returned as unsolicited when it is not expected | { type: "message"; message: Message } | { type: "unsolicited"; message: Message } | { type: "remove"; transaction: Transaction } // Used to abort the given transaction and remove it from the command queue | { type: "command_error"; error: Error } // An unexpected error occurred during command execution | ({ type: "command_success" } & Omit< CommandQueueDoneData & { type: "success" }, "type" >) | ({ type: "command_failure" } & Omit< CommandQueueDoneData & { type: "failure" }, "type" >); // Success and errors are passed through from the API command machine export type CommandQueueDoneData = SerialAPICommandDoneData & { transaction: Transaction; }; export type CommandQueueMachine = StateMachine< CommandQueueContext, CommandQueueStateSchema, CommandQueueEvent, any, any, any, any >; export type CommandQueueInterpreter = Interpreter< CommandQueueContext, CommandQueueStateSchema, CommandQueueEvent >; const setCurrentTransaction: AssignAction<CommandQueueContext, any> = assign( (ctx) => ({ ...ctx, currentTransaction: ctx.queue.shift()!, }), ); const deleteCurrentTransaction: AssignAction<CommandQueueContext, any> = assign( (ctx) => { ctx.callbackIDs.delete(ctx.currentTransaction!); return { ...ctx, currentTransaction: undefined, }; }, ); const stopTransaction = sendParent((ctx: CommandQueueContext, evt: any) => ({ type: "forward", from: "QUEUE", to: ctx.callbackIDs.get(evt.transaction), payload: { type: "stop", }, })); const removeFromQueue: AssignAction<CommandQueueContext, any> = assign( (ctx, evt: any) => { ctx.queue.remove(evt.transaction); ctx.callbackIDs.delete(evt.transaction); return ctx; }, ); const stopAbortMachine = pure((ctx: CommandQueueContext) => { const ret: ActionObject<CommandQueueContext, any>[] = []; if (ctx.abortActor) { ret.push(stop(ctx.abortActor.id)); } ret.push(assign({ abortActor: (_) => undefined })); return ret; }); const notifyResult = sendParent((ctx: CommandQueueContext, evt: any) => ({ type: "forward", from: "QUEUE", to: ctx.callbackIDs.get(ctx.currentTransaction!), payload: { ...evt.data, type: evt.data.type === "success" ? "command_success" : "command_failure", }, })); const notifyError = sendParent((ctx: CommandQueueContext, evt: any) => ({ type: "forward", from: "QUEUE", to: ctx.callbackIDs.get(ctx.currentTransaction!), payload: { type: "command_error", error: evt.data, transaction: ctx.currentTransaction, }, })); export function createCommandQueueMachine( implementations: ServiceImplementations, params: SerialAPICommandMachineParams, ): CommandQueueMachine { const spawnAbortMachine: AssignAction<CommandQueueContext, any> = assign({ abortActor: (_) => spawn( createSerialAPICommandMachine( implementations.createSendDataAbort(), implementations, params, ), ), }); return Machine< CommandQueueContext, CommandQueueStateSchema, CommandQueueEvent >( { preserveActionOrder: true, id: "CommandQueue", initial: "idle", context: { queue: new SortedList(), callbackIDs: new WeakMap(), // currentTransaction: undefined, }, on: { add: { actions: [ assign((ctx, evt) => { ctx.queue.add(evt.transaction); ctx.callbackIDs.set(evt.transaction, evt.from); return ctx; }), raise("trigger") as any, ], }, // What to do when removing transactions depends on their state remove: [ // Abort ongoing SendData commands when the transaction is removed. The transaction machine will // stop on its own { cond: "isCurrentTransactionAndSendData", actions: [spawnAbortMachine, stopTransaction], }, // If the transaction to remove is the current transaction, but not SendData // we can't just end it because it would risk putting the driver and stick out of sync { cond: "isNotCurrentTransaction", actions: [stopTransaction, removeFromQueue], }, ], // Then a serial API machine is active, forward the message. Otherwise, return all messages as unsolicited. message: [ { cond: "isExecuting", actions: forwardTo("execute"), }, { actions: notifyUnsolicited }, ], unsolicited: [ // The Serial API has determined this message to be unsolicited // Forward it to the SendThreadMachine { actions: notifyUnsolicited }, ], // Forward low-level messages to the correct actor ACK: [ { cond: "isAbortingInFlight", actions: forwardTo((ctx) => ctx.abortActor!), }, { cond: "isAbortingWithTimeout", actions: forwardTo("executeSendDataAbort"), }, { cond: "isExecuting", actions: forwardTo("execute"), }, ], CAN: [ { cond: "isAbortingInFlight", actions: forwardTo((ctx) => ctx.abortActor!), }, { cond: "isAbortingWithTimeout", actions: forwardTo("executeSendDataAbort"), }, { cond: "isExecuting", actions: forwardTo("execute"), }, ], NAK: [ { cond: "isAbortingInFlight", actions: forwardTo((ctx) => ctx.abortActor!), }, { cond: "isAbortingWithTimeout", actions: forwardTo("executeSendDataAbort"), }, { cond: "isExecuting", actions: forwardTo("execute"), }, ], }, states: { idle: { entry: deleteCurrentTransaction, on: { trigger: "idle", }, always: { target: "execute", actions: setCurrentTransaction, cond: "queueNotEmpty", }, }, execute: { invoke: { id: "execute", src: "executeSerialAPICommand", onDone: [ // If the transition was aborted in flight, just silently ignore // the result. The transaction was meant to be dropped or will be // rejected anyways. { cond: "isAbortingInFlight", target: "executeDone", }, // On success, forward the response to our parent machine { cond: "executeSuccessful", actions: notifyResult, target: "executeDone", }, // On failure, abort timed out send attempts { cond: "isSendDataWithCallbackTimeout", target: "abortSendData", actions: notifyResult, }, // And just notify the parent about other failures { target: "executeDone", actions: notifyResult, }, ], onError: { target: "executeDone", actions: notifyError, }, }, }, abortSendData: { invoke: { id: "executeSendDataAbort", src: "executeSendDataAbort", onDone: "executeDone", }, }, executeDone: { always: { target: "idle", actions: [ // Delete the current transaction after we're done deleteCurrentTransaction, stopAbortMachine, ], }, }, }, }, { services: { executeSerialAPICommand: (ctx) => { try { return createSerialAPICommandMachine( ctx.currentTransaction!.parts.current!, implementations, params, ); } catch (e) { // If there is an error while creating the command machine (e.g. during message serialization) // wrap it in a rejected promise, so xstate can handle it implementations.log( `Unexpected error during SerialAPI command: ${getErrorMessage( e, true, )}`, "error", ); return Promise.reject(e); } }, executeSendDataAbort: (_) => createSerialAPICommandMachine( implementations.createSendDataAbort(), implementations, params, ), }, guards: { executeSuccessful: (_, evt: any) => evt.data?.type === "success", queueNotEmpty: (ctx) => ctx.queue.length > 0, isNotCurrentTransaction: (ctx, evt: any) => ctx.currentTransaction !== evt.transaction, isCurrentTransactionAndSendData: (ctx, evt: any) => ctx.currentTransaction === evt.transaction && isSendData(evt.transaction.message), currentTransactionIsSendData: (ctx) => isSendData(ctx.currentTransaction?.parts.current), isSendDataWithCallbackTimeout: (ctx, evt: any) => { return ( isSendData(ctx.currentTransaction?.parts.current) && evt.data?.type === "failure" && evt.data?.reason === "callback timeout" ); }, isExecuting: (ctx, evt, meta) => meta.state.matches("execute"), isAbortingWithTimeout: (ctx, evt, meta) => meta.state.matches("abortSendData"), isAbortingInFlight: (ctx) => ctx.abortActor != undefined, }, delays: {}, }, ); }