inventoresed
Version:
Z-Wave driver written entirely in JavaScript/TypeScript
516 lines (480 loc) • 13.7 kB
text/typescript
import { messageIsPing } from "@zwave-js/cc/NoOperationCC";
import {
CommandClasses,
MessagePriority,
ZWaveError,
ZWaveErrorCodes,
} from "@zwave-js/core";
import type { Message } from "@zwave-js/serial";
import { SortedList } from "alcalzone-shared/sorted-list";
import {
Action,
ActorRef,
ActorRefFrom,
assign,
AssignAction,
createMachine,
forwardTo,
Interpreter,
MachineOptions,
PureAction,
spawn,
StateMachine,
} from "xstate";
import { pure, raise, send, stop } from "xstate/lib/actions";
import { InterviewStage, NodeStatus } from "../node/_Types";
import {
CommandQueueEvent,
createCommandQueueMachine,
} from "./CommandQueueMachine";
import type {
SerialAPICommandDoneData,
SerialAPICommandMachineParams,
} from "./SerialAPICommandMachine";
import type { ServiceImplementations } from "./StateMachineShared";
import type { Transaction } from "./Transaction";
import {
createTransactionMachine,
TransactionMachine,
} from "./TransactionMachine";
import type { ZWaveOptions } from "./ZWaveOptions";
export type SendDataErrorData =
| (SerialAPICommandDoneData & {
type: "failure";
})
| {
type: "failure";
reason: "node timeout";
result?: undefined;
};
export interface ActiveTransaction {
transaction: Transaction;
machine: ActorRefFrom<TransactionMachine>;
}
export interface SendThreadContext {
queue: SortedList<Transaction>;
commandQueue: ActorRef<any, any>;
activeTransactions: Map<string, ActiveTransaction>;
counter: number;
paused: boolean;
}
export type SendThreadEvent =
| { type: "add"; transaction: Transaction }
| { type: "trigger" }
| { type: "unsolicited"; message: Message }
| { type: "sortQueue" }
| { type: "NIF"; nodeId: number }
// Execute the given reducer function for each transaction in the queue
// and the current transaction and react accordingly. The reducer must not have
// side-effects because it may be executed multiple times for each transaction
| { type: "reduce"; reducer: TransactionReducer }
// Re-transmit the current transaction immediately
| { type: "resend" }
// These events are forwarded to the SerialAPICommand machine
| { type: "ACK" }
| { type: "CAN" }
| { type: "NAK" }
| { type: "message"; message: Message }
| (CommandQueueEvent &
(
| { type: "command_success" }
| { type: "command_failure" }
| { type: "command_error" }
))
| { type: "pause" | "unpause" }
| {
type: "forward";
from: string;
to: string;
payload: any;
}
| {
type: "transaction_done";
id: string;
};
export type SendThreadMachine = StateMachine<
SendThreadContext,
any,
SendThreadEvent,
any,
any,
any,
any
>;
export type SendThreadInterpreter = Interpreter<
SendThreadContext,
any,
SendThreadEvent
>;
export type TransactionReducerResult =
| {
// Silently drop the transaction
type: "drop";
}
| {
// Do nothing (useful especially for the current transaction)
type: "keep";
}
| {
// Reject the transaction with the given error
type: "reject";
message: string;
code: ZWaveErrorCodes;
}
| {
// Resolve the transaction with the given message
type: "resolve";
message?: Message;
}
| {
// Changes the priority (and tag) of the transaction if a new one is given,
// and moves the current transaction back to the queue
type: "requeue";
priority?: MessagePriority;
tag?: any;
};
export type TransactionReducer = (
transaction: Transaction,
source: "queue" | "active",
) => TransactionReducerResult;
export type SendThreadMachineParams = {
timeouts: SerialAPICommandMachineParams["timeouts"] &
Pick<ZWaveOptions["timeouts"], "report">;
attempts: SerialAPICommandMachineParams["attempts"];
};
const finalizeTransaction: PureAction<SendThreadContext, any> = pure(
(ctx, evt: any) => [
stop(evt.id),
assign((ctx: SendThreadContext) => {
// Pause the send thread if necessary
const transaction = ctx.activeTransactions.get(evt.id)?.transaction;
if (transaction?.pauseSendThread) ctx.paused = true;
// Remove the last reference to the actor
ctx.activeTransactions.delete(evt.id);
return ctx;
}) as any,
],
);
const forwardToCommandQueue = forwardTo<any, any>((ctx) => ctx.commandQueue);
const sortQueue: AssignAction<SendThreadContext, any> = assign({
queue: (ctx) => {
const queue = ctx.queue;
const items = [...queue];
queue.clear();
// Since the send queue is a sorted list, sorting is done on insert/add
queue.add(...items);
return queue;
},
});
const guards: NonNullable<
MachineOptions<SendThreadContext, SendThreadEvent>["guards"]
> = {
mayStartTransaction: (ctx, evt: any, meta) => {
// We may not send anything if the send thread is paused
if (ctx.paused) return false;
const nextTransaction = ctx.queue.peekStart();
// We can't send anything if the queue is empty
if (!nextTransaction) return false;
const message = nextTransaction.message;
const targetNode = message.getNodeUnsafe(nextTransaction.driver);
// The send queue is sorted automatically. If the first message is for a sleeping node, all messages in the queue are.
// There are a few exceptions:
// 1. Pings may be used to determine whether a node is really asleep.
// 2. Responses to nonce requests must be sent independent of the node status, because some sleeping nodes may try to send us encrypted messages.
// If we don't send them, they block the send queue
// 3. Nodes that can sleep but do not support wakeup: https://github.com/zwave-js/node-zwave-js/discussions/1537
// We need to try and send messages to them even if they are asleep, because we might never hear from them
// While the queue is busy, we may not start any transaction, except nonce responses to the node we're currently communicating with
if (meta.state.matches("busy")) {
if (nextTransaction.priority === MessagePriority.Nonce) {
for (const active of ctx.activeTransactions.values()) {
if (
active.transaction.message.getNodeId() ===
nextTransaction.message.getNodeId()
) {
return true;
}
}
}
return false;
}
// While not busy, always reply to nonce requests and Supervision Get requests
if (
nextTransaction.priority === MessagePriority.Nonce ||
nextTransaction.priority === MessagePriority.Supervision
) {
return true;
}
// And send pings
if (messageIsPing(message)) return true;
// Or controller messages
if (!targetNode) return true;
return (
targetNode.status !== NodeStatus.Asleep ||
(!targetNode.supportsCC(CommandClasses["Wake Up"]) &&
targetNode.interviewStage >= InterviewStage.NodeInfo)
);
},
hasNoActiveTransactions: (ctx) => ctx.activeTransactions.size === 0,
};
export function createSendThreadMachine(
implementations: ServiceImplementations,
params: SendThreadMachineParams,
): SendThreadMachine {
const notifyUnsolicited: Action<SendThreadContext, any> = (
_: any,
evt: any,
) => {
implementations.notifyUnsolicited(evt.message);
};
const reduce: PureAction<SendThreadContext, any> = pure((ctx, evt) => {
const dropQueued: Transaction[] = [];
const stopActive: Transaction[] = [];
const requeue: Transaction[] = [];
const reduceTransaction: (
...args: Parameters<TransactionReducer>
) => void = (transaction, source) => {
const reducerResult = (
evt as SendThreadEvent & {
type: "reduce";
}
).reducer(transaction, source);
switch (reducerResult.type) {
case "drop":
(source === "queue" ? dropQueued : stopActive).push(
transaction,
);
break;
case "requeue":
if (reducerResult.priority != undefined) {
transaction.priority = reducerResult.priority;
}
if (reducerResult.tag != undefined) {
transaction.tag = reducerResult.tag;
}
if (source === "active") stopActive.push(transaction);
requeue.push(transaction);
break;
case "resolve":
implementations.resolveTransaction(
transaction,
reducerResult.message,
);
(source === "queue" ? dropQueued : stopActive).push(
transaction,
);
break;
case "reject":
implementations.rejectTransaction(
transaction,
new ZWaveError(
reducerResult.message,
reducerResult.code,
undefined,
transaction.stack,
),
);
(source === "queue" ? dropQueued : stopActive).push(
transaction,
);
break;
}
};
const { queue, activeTransactions } = ctx;
for (const transaction of queue) {
reduceTransaction(transaction, "queue");
}
for (const { transaction } of activeTransactions.values()) {
reduceTransaction(transaction, "active");
}
// Now we know what to do with the transactions
queue.remove(...dropQueued, ...requeue);
queue.add(...requeue.map((t) => t.clone()));
return [
assign((ctx: SendThreadContext) => ({
...ctx,
queue,
})),
...stopActive.map((t) =>
send<SendThreadContext, any, any>(
{ type: "remove", transaction: t },
{ to: ctx.commandQueue as any },
),
),
];
});
const spawnTransaction: AssignAction<SendThreadContext, any> = assign(
(ctx) => {
const newCounter = (ctx.counter + 1) % 0xffffffff;
const id = "T" + newCounter.toString(16).padStart(8, "0");
const transaction = ctx.queue.shift()!;
const machine = spawn(
createTransactionMachine(id, transaction, implementations),
{
name: id,
},
);
ctx.activeTransactions.set(id, { machine, transaction });
return {
...ctx,
counter: newCounter,
};
},
);
const ret = createMachine<SendThreadContext, SendThreadEvent>(
{
id: "SendThread",
initial: "init",
preserveActionOrder: true,
context: {
commandQueue: undefined as any,
queue: new SortedList(),
activeTransactions: new Map(),
counter: 0,
paused: false,
},
on: {
// Forward low-level events and unidentified messages to the command queue
ACK: { actions: forwardToCommandQueue },
CAN: { actions: forwardToCommandQueue },
NAK: { actions: forwardToCommandQueue },
// messages may come back as "unsolicited", these might be expected updates
// we need to run them through the serial API machine to avoid mismatches
message: { actions: forwardToCommandQueue },
// Forward NIFs to each transaction machine to resolve potential waiting pings
NIF: {
actions: pure((ctx, evt) => {
const activeTransactionMachinesForNode = [
...ctx.activeTransactions.values(),
]
.filter(
({ transaction }) =>
transaction.message.getNodeId() ===
evt.nodeId,
)
.map((a) => a.machine.id);
return [
...activeTransactionMachinesForNode.map(
(id) => send(evt, { to: id }) as any,
),
// Sort the send queue and evaluate again whether the next message may be sent
sortQueue,
raise("trigger") as any,
];
}),
},
// handle newly added messages
add: {
actions: [
assign({
queue: (ctx, evt) => {
ctx.queue.add(evt.transaction);
return ctx.queue;
},
}),
raise("trigger") as any,
],
},
reduce: {
// Reducing may reorder the queue, so raise a trigger afterwards
actions: [reduce, raise("trigger") as any],
},
// Return unsolicited messages to the driver
unsolicited: {
actions: notifyUnsolicited,
},
// Accept external commands to sort the queue
sortQueue: {
actions: [sortQueue, raise("trigger") as any],
},
// Accept external commands to pause/unpause the send queue
pause: {
actions: [assign({ paused: () => true }) as any],
},
unpause: {
actions: [
assign({ paused: () => false }),
raise("trigger") as any,
],
},
// forward events between child machinies
forward: {
actions: send(
(_, evt) => ({ ...evt.payload, from: evt.from }),
{
to: (_, evt) => evt.to,
},
),
},
// Stop transactions when they are done
transaction_done: {
actions: [finalizeTransaction, raise("trigger") as any],
},
},
states: {
init: {
entry: assign<SendThreadContext, any>({
commandQueue: () =>
spawn(
createCommandQueueMachine(
implementations,
params,
),
{
name: "QUEUE",
},
),
}),
// Spawn the command queue when starting the send thread
always: "idle",
},
// While idle, any transaction may be started
idle: {
id: "idle",
always: {
cond: "mayStartTransaction",
// Use the first transaction in the queue as the current one
actions: spawnTransaction,
target: "busy",
},
on: {
// On trigger, re-evaluate the conditions to enter "busy"
trigger: { target: "idle" },
},
},
// While busy, only nonces may be sent
busy: {
id: "busy",
always: [
{
cond: "hasNoActiveTransactions",
target: "idle",
},
{
cond: "mayStartTransaction",
// Use the first transaction in the queue as the current one
actions: spawnTransaction,
target: "busy",
},
],
on: {
// On trigger, re-evaluate the conditions to go spawn transactions or back to idle
trigger: { target: "busy" },
},
},
},
},
{
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),
);
},
},
},
);
return ret;
}