@mysterycode/node-red-sacn
Version:
Implementation of sACN (Streaming ACN) for Node-RED
412 lines (329 loc) • 11.1 kB
text/typescript
import { Node, NodeAPI, NodeDef } from "node-red";
import { NodeMessage } from "@node-red/registry";
type SceneControllerAction = "save" | "play" | "reset";
interface DMXValues {
[key: number]: number | undefined;
}
interface Universes {
[key: number]: DMXValues;
}
interface Scene {
scene: number;
data: Universes;
}
export type Config = NodeDef;
type MessageInPayload =
| {
[key: number | string]: number[] | DMXValues;
}
| number[]
| Universes;
interface MessageIn extends NodeMessage {
payload: MessageInPayload;
action: SceneControllerAction;
universe?: number;
scene: number;
isSingleUniverse?: boolean;
}
interface MessageOut extends NodeMessage {
scene: number;
reset?: boolean;
universe?: number | undefined;
payload: DMXValues | Universes;
}
class NodeHandler {
protected node: Node<Config>;
protected config: Config;
constructor(node: Node<Config>, config: Config) {
this.node = node;
this.config = config;
this.node.on("input", (msg) => {
const message: MessageIn = msg as MessageIn;
switch (message.action || "undefined") {
case "save":
if (this.validateSave(message)) {
this.handleSave(message);
}
break;
case "play":
if (this.validatePlay(message)) {
this.handlePlay(message);
}
break;
case "reset":
if (this.validateReset(message)) {
this.handleReset(message);
}
break;
default:
// validate action
if (message.action === undefined) {
this.node.warn('Message-Object is missing the "action"-property.');
break;
}
this.node.warn(
`The given "action"-property "${message.action as string}" (${typeof message.action}) is not supported.`,
);
break;
}
});
}
protected validateUniverse(universe: number | undefined): void {
if (universe === undefined || isNaN(universe) || universe < 1 || universe > 65279) {
throw new Error(`The universe number '${universe}' (${typeof universe}) is invalid or not between 1 and 65279.`);
}
}
protected validateChannel(
channel: number,
universe: number,
firstChannel: number = 1,
lastChannel: number = 512,
): void {
if (isNaN(channel) || channel < firstChannel || channel > lastChannel) {
throw new Error(
`Channel number '${channel}' (${typeof channel}) of universe '${universe}' is invalid or not between ${firstChannel} and ${lastChannel}.`,
);
}
}
protected validateValue(value: number, channel: number, universe: number): void {
if (isNaN(value) || value < 0 || value > 255) {
throw new Error(
`Value '${value}' (${typeof value}) for channel '${channel}' of universe '${universe}' is invalid or not between 0 and 255.`,
);
}
}
protected parseUniverseObject(input: object, universe: number): DMXValues {
const keys = Object.keys(input);
const output: DMXValues = {};
const startIndex = keys.includes("0") || keys.includes(0 as keyof object) ? 0 : 1;
const endIndex = startIndex === 0 ? 511 : 512;
if (keys.length !== 512) {
throw new Error(`Universe ${universe} contains ${keys.length} values, but not 512 values.`);
}
let channel: unknown;
for (channel in input) {
let value: unknown = input[channel as keyof object];
if (typeof channel === "string") {
channel = parseInt(channel, 10);
}
this.validateChannel(channel as number, universe, startIndex, endIndex);
if (typeof value === "string") {
value = parseInt(value);
}
this.validateValue(value as number, channel as number, universe);
output[channel as number] = value as number;
}
return output;
}
protected parseUniverseArray(input: unknown[], universe: number): DMXValues {
const output: DMXValues = {};
if (input.length !== 512) {
throw new Error(`Universe ${universe} contains ${input.length} values, but not 512 values.`);
}
input.forEach((value, channel) => {
this.validateChannel(channel, universe, 0, 511);
if (typeof value === "string") {
value = parseInt(value, 10);
}
this.validateValue(value as number, channel, universe);
output[channel] = value as number;
});
return output;
}
protected parseUniverseData(input: unknown, universe: number | undefined): Universes {
const output: Universes = {};
if (input === undefined || input === null || (typeof input !== "object" && !Array.isArray(input))) {
throw new Error(`Payload must be an object or an array. Type "${typeof input}" is not supported.`);
}
if (Array.isArray(input)) {
if (typeof universe === "string") {
universe = parseInt(universe, 10);
}
this.validateUniverse(universe);
output[universe!] = this.parseUniverseArray(input, universe!);
} else if (typeof input === "object") {
let containsPossibleNumericValues = false;
let containsPossibleUniverses = false;
let containsDifferent = false;
for (const key in input) {
const item = input[key as keyof object];
if (typeof item === "string" || !isNaN(item as number)) {
containsPossibleNumericValues = true;
} else if (Array.isArray(item) || typeof item === "object") {
containsPossibleUniverses = true;
} else {
containsDifferent = true;
}
}
if (containsDifferent) {
throw new Error("Payload contains invalid data.");
}
if (containsPossibleUniverses && containsPossibleNumericValues) {
throw new Error("Payload contains objects or arrays parallel to numeric values");
}
if (containsPossibleNumericValues) {
if (typeof universe === "string") {
universe = parseInt(universe, 10);
}
this.validateUniverse(universe);
output[universe!] = this.parseUniverseObject(input, universe!);
} else if (containsPossibleUniverses) {
let key: unknown;
for (key in input) {
if (typeof key === "string") {
key = parseInt(key, 10);
}
this.validateUniverse(key as number);
const item = input[key as keyof object];
if (Array.isArray(item)) {
output[key as number] = this.parseUniverseArray(item, key as number);
} else if (typeof item === "object") {
output[key as number] = this.parseUniverseObject(item, key as number);
} else {
throw new Error(`Data for universe ${key as number} is invalid.`);
}
}
}
}
return output;
}
protected validateScene(message: MessageIn) {
// validate scene
if (message.scene === undefined) {
this.node.warn('Message-Object is missing the "scene"-property.');
return false;
}
if (isNaN(message.scene)) {
this.node.warn(
`The given "scene"-property "${message.scene}" (${typeof message.scene}) is not a positive number.`,
);
return false;
}
return true;
}
protected validateSave(message: MessageIn) {
if (!this.validateScene(message)) {
return false;
}
if (typeof message.payload !== "object" && !Array.isArray(message.payload)) {
this.node.warn("The given payload has to be an object or array.");
return false;
}
try {
message.payload = this.parseUniverseData(message.payload, message.universe);
} catch (e) {
if (e instanceof Error) {
this.node.warn(e.message);
}
return false;
}
return true;
}
protected validatePlay(message: MessageIn) {
if (!this.validateScene(message)) {
return false;
}
return true;
}
protected validateReset(_: MessageIn) {
// nothing required yet.
return true;
}
protected handleSave(message: MessageIn) {
const scene: Scene = {
scene: message.scene,
data: message.payload as Universes,
};
this.node.context().set(`scene-${message.scene}`, scene);
}
protected handlePlay(message: MessageIn): void {
const data: Scene | null = this.node.context().get(`scene-${message.scene}`) as Scene | null;
if (!data) {
this.node.warn(`Cannot play scene no. ${message.scene} since it has not been recorded yet.`);
return;
}
this.node.context().set("playingScene", message.scene);
let payload: Universes | DMXValues;
let universe: number | undefined = undefined;
const universes = Object.keys(data.data);
if (universes.length === 1) {
universe = parseInt(universes[0], 10);
payload = data.data[universe];
} else {
payload = data.data;
}
this.node.send({
topic: message.topic ?? `Scene ${message.scene}`,
scene: message.scene,
payload: payload,
universe: universe,
} as MessageOut);
this.node.status({
fill: "green",
shape: "dot",
text: message.topic ?? `Scene ${message.scene}`,
});
}
protected getNulledUniverse(): DMXValues {
const universe: DMXValues = {};
for (let ch = 1; ch <= 512; ch++) {
universe[ch] = 0;
}
return universe;
}
protected handleReset(message: MessageIn): void {
// TODO
const resetScene = (scene: number) => {
const data: Scene | null = this.node.context().get(`scene-${scene}`) as Scene | null;
if (this.node.context().get("playingScene") === scene) {
this.node.context().set("playingScene", null);
this.node.status({
fill: "green",
shape: "ring",
text: "Standby",
});
if (data) {
const universes = Object.keys(data.data);
let universe: number | undefined = undefined;
let payload: DMXValues | Universes;
if (universes.length === 1) {
universe = parseInt(universes[0], 10);
payload = this.getNulledUniverse();
} else {
payload = {};
universes.forEach((universe) => {
payload[parseInt(universe, 10)] = this.getNulledUniverse();
});
}
this.node.send({
topic: `Scene ${scene}`,
scene: scene,
payload: payload,
universe: universe,
reset: true,
} as MessageOut);
}
}
this.node.context().set(`scene-${scene}`, undefined);
};
if (message.scene) {
resetScene(message.scene);
} else {
this.node
.context()
.keys()
.forEach((key) => {
const matches = key.match(/^scene-(\d+)/);
if (matches !== null) {
resetScene(parseInt(matches[1], 10));
}
});
}
}
}
export default (RED: NodeAPI): void => {
RED.nodes.registerType("scene-controller", function (this: Node<Config>, config: Config) {
RED.nodes.createNode(this, config);
new NodeHandler(this, config);
});
};