inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
396 lines (377 loc) • 10.5 kB
text/typescript
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: {},
},
);
}