From c0d503e836215ca369e28663970a36bca86e1b21 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:48:06 +0200 Subject: [PATCH 01/22] Rebuild the received universe from a zeroed base in sacn-in Incoming sACN payloads omit channels whose value is 0, so overlaying them onto the retained state left channels that faded to 0 stuck at their old value. This corrupted the full-universe output, the keepalive re-emit and change detection alike. Treat every received data packet as authoritative for all 512 slots: rebuild the full state from a zeroed base each frame and derive changes by diffing against the previous state, so a channel dropping to 0 is now detected and emitted correctly. --- dist/nodes/sacn-in/sacn-in.js | 46 ++++++++++++++----------------- src/nodes/sacn-in/sacn-in.ts | 52 ++++++++++++++++------------------- 2 files changed, 43 insertions(+), 55 deletions(-) diff --git a/dist/nodes/sacn-in/sacn-in.js b/dist/nodes/sacn-in/sacn-in.js index f35f6f9..81fd67e 100644 --- a/dist/nodes/sacn-in/sacn-in.js +++ b/dist/nodes/sacn-in/sacn-in.js @@ -50,8 +50,7 @@ class NodeHandler { }); if (config.mode === "passthrough") { this.sACN.on("packet", (packet) => { - const changed = this.hasChanges(packet.payload, packet.universe); - const payload = this.parsePayload(packet.payload, packet.universe); + const { payload, changed } = this.applyFrame(packet.payload, packet.universe); if (this.trigger === "always" || changed) { this.sendData({ universe: packet.universe, @@ -65,7 +64,7 @@ class NodeHandler { } else { this.sACN.on("changed", (data) => { - const payload = this.parsePayload(data.payload, data.universe); + const { payload } = this.applyFrame(data.payload, data.universe); if (this.trigger !== "always") { this.sendData({ universe: data.universe, @@ -128,32 +127,27 @@ class NodeHandler { } return universe; } - hasChanges(payload, universe) { - const full = this.data?.get(universe); - if (full === undefined) { - return true; - } - return Object.keys(payload).some((key) => { - const ch = parseInt(key, 10); - return full[ch] !== payload[ch]; - }); - } - parsePayload(payload, universe) { - const full = this.data?.get(universe) ?? this.getNulledUniverse(); - Object.keys(payload).forEach((key) => { + applyFrame(incoming, universe) { + const previous = this.data?.get(universe); + const full = this.getNulledUniverse(); + Object.keys(incoming).forEach((key) => { const ch = parseInt(key, 10); - full[ch] = payload[ch]; + if (ch >= 1 && ch <= 512) { + full[ch] = incoming[ch]; + } }); - this.data?.set(universe, full); - if (this.config.output === "changes") { - const changes = {}; - Object.keys(payload).forEach((key) => { - const ch = parseInt(key, 10); - changes[ch] = payload[ch]; - }); - return changes; + let changed = false; + const changes = {}; + for (let ch = 1; ch <= 512; ch++) { + const before = previous ? previous[ch] : 0; + if (before !== full[ch]) { + changed = true; + changes[ch] = full[ch]; + } } - return full; + this.data?.set(universe, full); + const payload = this.config.output === "changes" ? changes : full; + return { payload, changed }; } sendData(msg) { this.node.send(msg); diff --git a/src/nodes/sacn-in/sacn-in.ts b/src/nodes/sacn-in/sacn-in.ts index e4ddbe9..a75c733 100644 --- a/src/nodes/sacn-in/sacn-in.ts +++ b/src/nodes/sacn-in/sacn-in.ts @@ -98,8 +98,7 @@ class NodeHandler { // handle sacn packets according to the configured output trigger if (config.mode === "passthrough") { this.sACN.on("packet", (packet: Packet) => { - const changed = this.hasChanges(packet.payload, packet.universe); - const payload = this.parsePayload(packet.payload, packet.universe); + const { payload, changed } = this.applyFrame(packet.payload, packet.universe); if (this.trigger === "always" || changed) { this.sendData({ @@ -114,7 +113,7 @@ class NodeHandler { } else { // htp / ltp — merged output (this.sACN as MergingReceiver).on("changed", (data) => { - const payload = this.parsePayload(data.payload, data.universe); + const { payload } = this.applyFrame(data.payload, data.universe); // in "always" mode the packet listener below emits on every packet instead if (this.trigger !== "always") { @@ -205,40 +204,35 @@ class NodeHandler { return universe; } - protected hasChanges(payload: DMXValues, universe: number): boolean { - const full = this.data?.get(universe); - if (full === undefined) { - return true; - } + protected applyFrame(incoming: DMXValues, universe: number): { payload: DMXValues; changed: boolean } { + // an sACN data packet always describes the complete universe; channels that are + // absent from the received payload are deliberately 0, not unchanged. Rebuild the + // full state from a zeroed base so a channel fading to 0 is reflected correctly. + const previous = this.data?.get(universe); + const full = this.getNulledUniverse(); - return Object.keys(payload).some((key) => { + Object.keys(incoming).forEach((key) => { const ch = parseInt(key, 10); - return full[ch] !== payload[ch]; + if (ch >= 1 && ch <= 512) { + full[ch] = incoming[ch]; + } }); - } - protected parsePayload(payload: DMXValues, universe: number): DMXValues { - // always maintain the full state of the universe (needed for keepalive / clear) - const full: DMXValues = this.data?.get(universe) ?? this.getNulledUniverse(); - - Object.keys(payload).forEach((key) => { - const ch = parseInt(key, 10); - full[ch] = payload[ch]; - }); + let changed = false; + const changes: DMXValues = {}; + for (let ch = 1; ch <= 512; ch++) { + const before = previous ? previous[ch] : 0; + if (before !== full[ch]) { + changed = true; + changes[ch] = full[ch]; + } + } this.data?.set(universe, full); - if (this.config.output === "changes") { - const changes: DMXValues = {}; - Object.keys(payload).forEach((key) => { - const ch = parseInt(key, 10); - changes[ch] = payload[ch]; - }); - - return changes; - } + const payload = this.config.output === "changes" ? changes : full; - return full; + return { payload, changed }; } protected sendData(msg: NodeMessage): void { From cdf7c071667521f9be0d893739ad59c17f607c6b Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:49:30 +0200 Subject: [PATCH 02/22] Emit copies of the cached universe state from sacn-in sendData now clones the message payload before sending, so a downstream node can never mutate the receiver's internal universe cache by holding on to the emitted object. emitFull no longer needs its own defensive copy. --- dist/nodes/sacn-in/sacn-in.js | 5 ++++- src/nodes/sacn-in/sacn-in.ts | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/dist/nodes/sacn-in/sacn-in.js b/dist/nodes/sacn-in/sacn-in.js index 81fd67e..568ffa3 100644 --- a/dist/nodes/sacn-in/sacn-in.js +++ b/dist/nodes/sacn-in/sacn-in.js @@ -150,12 +150,15 @@ class NodeHandler { return { payload, changed }; } sendData(msg) { + if (msg.payload && typeof msg.payload === "object") { + msg = { ...msg, payload: { ...msg.payload } }; + } this.node.send(msg); this.resetKeepalive(); } emitFull(universe) { const full = this.data?.get(universe) ?? this.getNulledUniverse(); - this.sendData({ universe, payload: { ...full } }); + this.sendData({ universe, payload: full }); } resetKeepalive() { if (this.trigger !== "interval") { diff --git a/src/nodes/sacn-in/sacn-in.ts b/src/nodes/sacn-in/sacn-in.ts index a75c733..ece97d5 100644 --- a/src/nodes/sacn-in/sacn-in.ts +++ b/src/nodes/sacn-in/sacn-in.ts @@ -236,13 +236,19 @@ class NodeHandler { } protected sendData(msg: NodeMessage): void { + // never hand out a reference to the cached universe state; a downstream node + // must not be able to mutate our internal data by holding on to the payload + if (msg.payload && typeof msg.payload === "object") { + msg = { ...msg, payload: { ...(msg.payload as DMXValues) } }; + } + this.node.send(msg); this.resetKeepalive(); } protected emitFull(universe: number): void { const full = this.data?.get(universe) ?? this.getNulledUniverse(); - this.sendData({ universe, payload: { ...full } }); + this.sendData({ universe, payload: full }); } protected resetKeepalive(): void { From e2e1195f594eea87e2705d4c05a6b3b0d106c9f6 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:50:25 +0200 Subject: [PATCH 03/22] Handle receiver and sender socket errors without crashing Both nodes create an sACN socket but never listened for its "error" event. An emitted error with no listener is an uncaught exception in Node.js and could bring down the entire Node-RED runtime (e.g. a busy port or a bad interface address). Attach an error listener to each that logs the error and shows it on the node, so the failure is visible but the runtime and the remaining flows keep going. --- dist/nodes/sacn-in/sacn-in.js | 4 ++++ dist/nodes/sacn-out/sacn-out.js | 4 ++++ src/nodes/sacn-in/sacn-in.ts | 9 +++++++++ src/nodes/sacn-out/sacn-out.ts | 8 ++++++++ 4 files changed, 25 insertions(+) diff --git a/dist/nodes/sacn-in/sacn-in.js b/dist/nodes/sacn-in/sacn-in.js index 568ffa3..7b72fad 100644 --- a/dist/nodes/sacn-in/sacn-in.js +++ b/dist/nodes/sacn-in/sacn-in.js @@ -41,6 +41,10 @@ class NodeHandler { default: throw new Error("[node-red-sacn] None or invalid mode selected."); } + this.sACN.on("error", (err) => { + this.node.error(err); + this.node.status({ fill: "red", shape: "dot", text: err.message || "receiver error" }); + }); this.node.on("close", () => { this.sACN.close(); if (this.keepaliveTimer) { diff --git a/dist/nodes/sacn-out/sacn-out.js b/dist/nodes/sacn-out/sacn-out.js index 5b95719..4bc59f4 100644 --- a/dist/nodes/sacn-out/sacn-out.js +++ b/dist/nodes/sacn-out/sacn-out.js @@ -24,6 +24,10 @@ class NodeHandler { this.options.port = 5568; } this.sACN = new sacn_1.Sender(this.options); + this.sACN.on("error", (err) => { + this.node.error(err); + this.node.status({ fill: "red", shape: "dot", text: err.message || "sender error" }); + }); this.node.on("close", () => { this.sACN.close(); }); diff --git a/src/nodes/sacn-in/sacn-in.ts b/src/nodes/sacn-in/sacn-in.ts index ece97d5..4ee0035 100644 --- a/src/nodes/sacn-in/sacn-in.ts +++ b/src/nodes/sacn-in/sacn-in.ts @@ -83,6 +83,15 @@ class NodeHandler { throw new Error("[node-red-sacn] None or invalid mode selected."); } + // a socket error (bind failure, multicast membership, …) is emitted as an + // "error" event; without a listener Node.js would treat it as an uncaught + // exception and could take the whole runtime down. Log it and surface it on + // the node instead, and keep running so the rest of the flow is unaffected. + this.sACN.on("error", (err: Error) => { + this.node.error(err); + this.node.status({ fill: "red", shape: "dot", text: err.message || "receiver error" }); + }); + // run cleanup when node is closed this.node.on("close", () => { // close all connections; terminate the receiver diff --git a/src/nodes/sacn-out/sacn-out.ts b/src/nodes/sacn-out/sacn-out.ts index 13e0781..690894a 100644 --- a/src/nodes/sacn-out/sacn-out.ts +++ b/src/nodes/sacn-out/sacn-out.ts @@ -54,6 +54,14 @@ class NodeHandler { this.sACN = new Sender(this.options); + // a socket error is emitted as an "error" event; without a listener Node.js + // would treat it as an uncaught exception and could take the whole runtime + // down. Log it and surface it on the node instead, without throwing. + this.sACN.on("error", (err: Error) => { + this.node.error(err); + this.node.status({ fill: "red", shape: "dot", text: err.message || "sender error" }); + }); + this.node.on("close", () => { // close all connections; terminate the receiver this.sACN.close(); From 4be255e0cebb3144b2876de3645700403db4e2a3 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:50:49 +0200 Subject: [PATCH 04/22] Show the sending status on the sACN out node The output node gave no feedback at all about what it was doing. Display a node status with the configured universe and refresh rate so it is obvious at a glance that the sender is configured and active. --- dist/nodes/sacn-out/sacn-out.js | 9 +++++++++ src/nodes/sacn-out/sacn-out.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/dist/nodes/sacn-out/sacn-out.js b/dist/nodes/sacn-out/sacn-out.js index 4bc59f4..b382c9a 100644 --- a/dist/nodes/sacn-out/sacn-out.js +++ b/dist/nodes/sacn-out/sacn-out.js @@ -38,6 +38,15 @@ class NodeHandler { priority: config.priority || 100, }); }); + this.setStatus(); + } + setStatus() { + const rate = this.config.speed !== undefined && this.config.speed > 0 ? `${this.config.speed} Hz` : "once"; + this.node.status({ + fill: "green", + shape: "dot", + text: `Universe ${this.config.universe} · ${rate}`, + }); } } exports.default = (RED) => { diff --git a/src/nodes/sacn-out/sacn-out.ts b/src/nodes/sacn-out/sacn-out.ts index 690894a..0bb3d8e 100644 --- a/src/nodes/sacn-out/sacn-out.ts +++ b/src/nodes/sacn-out/sacn-out.ts @@ -74,6 +74,18 @@ class NodeHandler { priority: config.priority || 100, }); }); + + this.setStatus(); + } + + protected setStatus(): void { + const rate = this.config.speed !== undefined && this.config.speed > 0 ? `${this.config.speed} Hz` : "once"; + + this.node.status({ + fill: "green", + shape: "dot", + text: `Universe ${this.config.universe} · ${rate}`, + }); } } From a7e165d7d29babfd2c9c2a14d72c2a872676dd6a Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:51:23 +0200 Subject: [PATCH 05/22] Catch send failures on the sACN out node The send promise was discarded with void, so a rejected send became an unhandled promise rejection which can terminate the process. Handle the result instead: reaffirm the status on success and log the error plus show a red status on failure. --- dist/nodes/sacn-out/sacn-out.js | 10 +++++++++- src/nodes/sacn-out/sacn-out.ts | 20 +++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/dist/nodes/sacn-out/sacn-out.js b/dist/nodes/sacn-out/sacn-out.js index b382c9a..9a9b690 100644 --- a/dist/nodes/sacn-out/sacn-out.js +++ b/dist/nodes/sacn-out/sacn-out.js @@ -32,10 +32,18 @@ class NodeHandler { this.sACN.close(); }); this.node.on("input", (msg) => { - void this.sACN.send({ + this.sACN + .send({ payload: msg.payload, sourceName: config.sourceName, priority: config.priority || 100, + }) + .then(() => { + this.setStatus(); + }) + .catch((err) => { + this.node.error(err, msg); + this.node.status({ fill: "red", shape: "dot", text: err.message || "send failed" }); }); }); this.setStatus(); diff --git a/src/nodes/sacn-out/sacn-out.ts b/src/nodes/sacn-out/sacn-out.ts index 0bb3d8e..6f642be 100644 --- a/src/nodes/sacn-out/sacn-out.ts +++ b/src/nodes/sacn-out/sacn-out.ts @@ -68,11 +68,21 @@ class NodeHandler { }); this.node.on("input", (msg) => { - void this.sACN.send({ - payload: msg.payload as number[], - sourceName: config.sourceName, - priority: config.priority || 100, - }); + this.sACN + .send({ + payload: msg.payload as number[], + sourceName: config.sourceName, + priority: config.priority || 100, + }) + .then(() => { + this.setStatus(); + }) + .catch((err: Error) => { + // a failed send rejects the promise; handle it so it does not become + // an unhandled rejection (which would terminate the process) + this.node.error(err, msg); + this.node.status({ fill: "red", shape: "dot", text: err.message || "send failed" }); + }); }); this.setStatus(); From 4a6234522221d8df432626343bd3f3756ad52bae Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:51:43 +0200 Subject: [PATCH 06/22] Use the official E1.31 universe range in the scene controller The scene controller accepted universes up to 65279 while the receiver node uses the E1.31 usable range of 1 to 63999. Align the scene controller on 1 to 63999 so the whole package validates universes consistently. --- dist/nodes/scene-controller/scene-controller.js | 4 ++-- src/nodes/scene-controller/scene-controller.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dist/nodes/scene-controller/scene-controller.js b/dist/nodes/scene-controller/scene-controller.js index 3646cab..c3bcb71 100644 --- a/dist/nodes/scene-controller/scene-controller.js +++ b/dist/nodes/scene-controller/scene-controller.js @@ -35,8 +35,8 @@ class NodeHandler { }); } validateUniverse(universe) { - 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.`); + if (universe === undefined || isNaN(universe) || universe < 1 || universe > 63999) { + throw new Error(`The universe number '${universe}' (${typeof universe}) is invalid or not between 1 and 63999.`); } } validateChannel(channel, universe, firstChannel = 1, lastChannel = 512) { diff --git a/src/nodes/scene-controller/scene-controller.ts b/src/nodes/scene-controller/scene-controller.ts index 2246c70..6e0afbb 100644 --- a/src/nodes/scene-controller/scene-controller.ts +++ b/src/nodes/scene-controller/scene-controller.ts @@ -84,8 +84,8 @@ class NodeHandler { } 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.`); + if (universe === undefined || isNaN(universe) || universe < 1 || universe > 63999) { + throw new Error(`The universe number '${universe}' (${typeof universe}) is invalid or not between 1 and 63999.`); } } From 1c3a8f8d8a5b394a9660e69da9a2fe28e09222fa Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:52:46 +0200 Subject: [PATCH 07/22] Give the in and out nodes meaningful default labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both editor definitions defaulted the node name to "Scene-Controller", so a freshly dropped sACN in or sACN out node showed the wrong label on the canvas. Clear the default name and fall back to a short type-and-universe label ("sACN in · U1") when the user has not set a custom name. --- dist/nodes/sacn-in/sacn-in.html | 4 ++-- dist/nodes/sacn-out/sacn-out.html | 4 ++-- src/nodes/sacn-in/init.ts | 4 ++-- src/nodes/sacn-out/init.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dist/nodes/sacn-in/sacn-in.html b/dist/nodes/sacn-in/sacn-in.html index 5f684c4..0da8b71 100644 --- a/dist/nodes/sacn-in/sacn-in.html +++ b/dist/nodes/sacn-in/sacn-in.html @@ -163,7 +163,7 @@

References

color: "#dcc515", defaults: { name: { - value: "Scene-Controller", + value: "", }, universe: { value: 1, @@ -202,7 +202,7 @@

References

paletteLabel: "sACN in", icon: "font-awesome/fa-lightbulb-o", label: function () { - return this.name || "sACN"; + return this.name || `sACN in · U${this.universe}`; }, labelStyle: function () { return this.name ? "node_label_italic" : ""; diff --git a/dist/nodes/sacn-out/sacn-out.html b/dist/nodes/sacn-out/sacn-out.html index 4806009..ac1c05f 100644 --- a/dist/nodes/sacn-out/sacn-out.html +++ b/dist/nodes/sacn-out/sacn-out.html @@ -99,7 +99,7 @@

References

color: "#dcc515", defaults: { name: { - value: "Scene-Controller", + value: "", }, universe: { value: 1, @@ -131,7 +131,7 @@

References

paletteLabel: "sACN out", icon: "font-awesome/fa-lightbulb-o", label: function () { - return this.name || "sACN"; + return this.name || `sACN out · U${this.universe}`; }, labelStyle: function () { return this.name ? "node_label_italic" : ""; diff --git a/src/nodes/sacn-in/init.ts b/src/nodes/sacn-in/init.ts index 421a140..679865a 100644 --- a/src/nodes/sacn-in/init.ts +++ b/src/nodes/sacn-in/init.ts @@ -18,7 +18,7 @@ const def: EditorNodeDef = { color: "#dcc515", defaults: { name: { - value: "Scene-Controller", + value: "", }, universe: { value: 1, @@ -57,7 +57,7 @@ const def: EditorNodeDef = { paletteLabel: "sACN in", icon: "font-awesome/fa-lightbulb-o", label: function () { - return this.name || "sACN"; + return this.name || `sACN in · U${this.universe}`; }, labelStyle: function () { return this.name ? "node_label_italic" : ""; diff --git a/src/nodes/sacn-out/init.ts b/src/nodes/sacn-out/init.ts index b3e6d44..2f2d477 100644 --- a/src/nodes/sacn-out/init.ts +++ b/src/nodes/sacn-out/init.ts @@ -16,7 +16,7 @@ const def: EditorNodeDef = { color: "#dcc515", defaults: { name: { - value: "Scene-Controller", + value: "", }, universe: { value: 1, @@ -48,7 +48,7 @@ const def: EditorNodeDef = { paletteLabel: "sACN out", icon: "font-awesome/fa-lightbulb-o", label: function () { - return this.name || "sACN"; + return this.name || `sACN out · U${this.universe}`; }, labelStyle: function () { return this.name ? "node_label_italic" : ""; From c5756016b503c145628e403286958b401e14cd68 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:53:22 +0200 Subject: [PATCH 08/22] Align the sACN in form defaults with the node definition The universe field was a text input carrying numeric min/max/step attributes that a text input ignores; make it a number input like the out node. The mode dropdown pre-selected passthrough while the node default is htp; mark htp as the selected option so the form matches the actual default. --- dist/nodes/sacn-in/sacn-in.html | 6 +++--- src/nodes/sacn-in/form.html | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/dist/nodes/sacn-in/sacn-in.html b/dist/nodes/sacn-in/sacn-in.html index 0da8b71..b8ed902 100644 --- a/dist/nodes/sacn-in/sacn-in.html +++ b/dist/nodes/sacn-in/sacn-in.html @@ -11,7 +11,7 @@ - +
diff --git a/src/nodes/sacn-in/form.html b/src/nodes/sacn-in/form.html index 703afe8..22ae5a5 100644 --- a/src/nodes/sacn-in/form.html +++ b/src/nodes/sacn-in/form.html @@ -11,7 +11,7 @@ - +
From 22450dc94226f2edb1e660171e6624120723c66a Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:53:42 +0200 Subject: [PATCH 09/22] Install dependencies with npm ci in the CI workflows The code-style and TypeScript workflows used npm install, which can resolve different versions than the lockfile. Switch them to npm ci for reproducible installs from package-lock.json. --- .github/workflows/codestyle.yml | 2 +- .github/workflows/typescript.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index 20ef136..5a545f6 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -20,7 +20,7 @@ jobs: with: node-version: "24" cache: "npm" - - run: npm install + - run: npm ci - name: Run prettier run: | shopt -s globstar diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index d77aa24..08a691d 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -27,7 +27,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: "npm" - - run: npm install + - run: npm ci - run: | npx tsc --noEmit eslint: @@ -40,5 +40,5 @@ jobs: with: node-version: "24" cache: "npm" - - run: npm install + - run: npm ci - run: npx eslint . From 47f15e9a0a75a994abcfc150fade72fb41b4b7af Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:54:22 +0200 Subject: [PATCH 10/22] Gate the release on quality checks and pin the JSON action Add type-check, lint and build steps before publishing so a broken build can never be released, and pin the third-party github-action-json action to a specific commit instead of a moving main ref to remove that supply-chain risk. --- .github/workflows/release.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d12715..aa0db0e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,9 +19,16 @@ jobs: node-version: "24" registry-url: "https://registry.npmjs.com/" - run: npm ci + - name: Type-check + run: npx tsc --noEmit + - name: Lint + run: npx eslint . + - name: Build + run: npm run build - name: Read package.json id: package - uses: RadovanPelka/github-action-json@main + # pinned to a commit to avoid pulling an unreviewed moving ref + uses: RadovanPelka/github-action-json@c1e93581d313d6e1d56f7f67582d4051674ebc57 # main with: path: "package.json" - name: Remove old builds From 434e5e4c70b27b500b99affd7c9eac6a5abc4209 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:55:04 +0200 Subject: [PATCH 11/22] Fix spelling mistakes in the readme and out node label Correct "Paremeter", "wether" and "send by sACN" in the readme and the "IP-addresse" label of the out node. --- README.md | 10 +++++----- dist/nodes/sacn-out/locales/en-US/sacn-out.json | 2 +- src/nodes/sacn-out/locales/en-US.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e85b974..f91ebdf 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,15 @@ Copyright MysteryCode and other contributors under [GNU GENERAL PUBLIC LICENSE V ### sACN in -This node can be used to read one or multiple universes send by sACN. +This node can be used to read one or multiple universes sent by sACN. #### Parameters: -| Paremeter | Description | Possible Values | Default Value | Mandatory | +| Parameter | Description | Possible Values | Default Value | Mandatory | | ---------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------- | --------- | | universe | The universe that is meant to be observed. | `\d+` (`1` to `63999`) | `1` | yes | | mode | Defines whether the node returns the values of every read sACN package (passthrough mode), or merged values using HTP or LTP. | `passthrough`, `htp`, `ltp` | `htp` | yes | -| output | Defines wether the node sends only changed values or the whole universe. | `full`, `changes` | `full` | yes | +| output | Defines whether the node sends only changed values or the whole universe. | `full`, `changes` | `full` | yes | | IP-address | IP-Address of the network-interface that should be used for reading from sACN. | `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\` (_any valid ip-address_) | _empty_ | no | | port | The network port which should be used for reading sACN. | `\d+` | _empty_ (defaults to `5568`) | no | @@ -60,7 +60,7 @@ This node can be used to send one universe using sACN. #### Parameters: -| Paremeter | Description | Possible Values | Default Value | Mandatory | +| Parameter | Description | Possible Values | Default Value | Mandatory | | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------- | --------- | | universe | The universe that is meant to be observed. | `\d+` (`1` to `63999`) | `1` | yes | | source-name | The name for the sACN-sender that should be displayed within the network. | _any string below 50 characters_ | `Node-RED` | yes | @@ -81,7 +81,7 @@ This node can be used to record scenes and play them afterwards. #### Parameters: -| Paremeter | Description | Possible Values | Default Value | Mandatory | +| Parameter | Description | Possible Values | Default Value | Mandatory | | --------- | ----------- | --------------- | ------------- | --------- | #### Expected input: diff --git a/dist/nodes/sacn-out/locales/en-US/sacn-out.json b/dist/nodes/sacn-out/locales/en-US/sacn-out.json index 367be4b..94a7893 100644 --- a/dist/nodes/sacn-out/locales/en-US/sacn-out.json +++ b/dist/nodes/sacn-out/locales/en-US/sacn-out.json @@ -1,7 +1,7 @@ { "sacn-out": { "label": { - "interface": "IP-addresse of interface", + "interface": "IP-address of interface", "port": "port", "priority": "priority", "sourceName": "source-name", diff --git a/src/nodes/sacn-out/locales/en-US.json b/src/nodes/sacn-out/locales/en-US.json index 29db413..fca3dbb 100644 --- a/src/nodes/sacn-out/locales/en-US.json +++ b/src/nodes/sacn-out/locales/en-US.json @@ -1,6 +1,6 @@ { "label": { - "interface": "IP-addresse of interface", + "interface": "IP-address of interface", "port": "port", "priority": "priority", "sourceName": "source-name", From f2959c93cef464fd639dabf7ef228c8b5568c981 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:56:22 +0200 Subject: [PATCH 12/22] Document the new receiver options and runtime universe switch Add the output trigger, keepalive interval and blank-on-universe-change options to the sACN in parameter table, describe switching the observed universe at runtime via a message, note the Node-RED and Node.js version requirements, and correct the scene controller sections (empty parameter table and the universe input, which is only required for a single universe). --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f91ebdf..e7e0f7e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ for [Node-RED](https://nodered.org). ## Requirements -Required version of Node-RED: v4.0.5 +- Node-RED `>= 4.0.0` +- Node.js `>= 24.0.0` This package requires [`sacn`](https://www.npmjs.com/package/sacn) as library to interact by sACN. @@ -34,9 +35,16 @@ This node can be used to read one or multiple universes sent by sACN. | universe | The universe that is meant to be observed. | `\d+` (`1` to `63999`) | `1` | yes | | mode | Defines whether the node returns the values of every read sACN package (passthrough mode), or merged values using HTP or LTP. | `passthrough`, `htp`, `ltp` | `htp` | yes | | output | Defines whether the node sends only changed values or the whole universe. | `full`, `changes` | `full` | yes | +| trigger | Controls when a message is emitted: only on change, on every received packet, or on change plus a cyclic keepalive re-emit. | `changes`, `always`, `interval` | `changes` | yes | +| interval | Keepalive interval in milliseconds for the `interval` trigger; the full universe is re-emitted when no change arrives in time. | `\d+` | `1000` | no | +| clearOnUniverseChange | When the observed universe is switched at runtime, emit a full universe of zeros until real data for the new universe arrives. | `true`, `false` | `false` | no | | IP-address | IP-Address of the network-interface that should be used for reading from sACN. | `\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\` (_any valid ip-address_) | _empty_ | no | | port | The network port which should be used for reading sACN. | `\d+` | _empty_ (defaults to `5568`) | no | +#### Input: + +The observed universe can be switched at runtime by sending a message with a `universe` property (`1` to `63999`). The node stops listening on the previous universe and starts listening on the new one; invalid values are ignored and produce a warning. + #### Output for direct-mode: | Property | Description | @@ -81,8 +89,7 @@ This node can be used to record scenes and play them afterwards. #### Parameters: -| Parameter | Description | Possible Values | Default Value | Mandatory | -| --------- | ----------- | --------------- | ------------- | --------- | +This node has no configuration parameters; its behaviour is controlled entirely through the incoming message. #### Expected input: @@ -90,7 +97,7 @@ This node can be used to record scenes and play them afterwards. | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------- | | `action` | the action to be executed - `save` to save a preset, `play` to play a saved preset, `reset` to reset | yes | | `scene` | for action | yes, for actions `save`, `play` | -| `universe` | if only one universe is handled, this parameter is mandatory and contains the used universe | yes | +| `universe` | if only one universe is handled, this parameter is mandatory and contains the used universe | only for a single universe | | `payload` | contains the values to record. it might be an array (key 0-511) containing the values for a single universe,
an object (keys 1-512) containing the values for a single universe or
an object (any numeric keys) containing objects (keys 1-512) containing an universe each. | yes, for action `save` | #### Output for single universe: From 1549dac9dcd073f935abb21ce6bdf5cf929813c2 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:57:10 +0200 Subject: [PATCH 13/22] Document the protocol limitations in the readme Add a section spelling out what the underlying library does not provide: no universe discovery, no synchronization, no stream termination on stop, and DMX values represented as percentages rather than raw 8-bit values, so integrators can judge compatibility up front. --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index e7e0f7e..116a540 100644 --- a/README.md +++ b/README.md @@ -117,3 +117,12 @@ This node has no configuration parameters; its behaviour is controlled entirely | `payload` | Object containing one object per universe. DMX-Channel `1` starts at key `1`, not `0`. (`object>`) | | `scene` | The scene that is played (`number`) | | `reset` | Identifies a reset message for action `reset`, otherwise it does not exist. (`true`) | + +## Protocol notes and limitations + +This package builds on the [`sacn`](https://www.npmjs.com/package/sacn) library and inherits its scope. Be aware of the following when integrating it: + +- **No Universe Discovery.** The sender does not emit E1.31 Universe Discovery packets, so receivers relying on discovery will not see this source listed automatically. +- **No synchronization.** E1.31 universe synchronization (synchronized multi-universe updates) is not implemented. +- **No stream termination.** When a sender node is stopped or redeployed it simply closes its socket; it does not send packets with the `Stream_Terminated` flag. Receivers therefore hold the last received values until their own signal-loss timeout (typically ~2.5 s) elapses. +- **DMX values are percentages.** Channel values are expressed as a percentage (`0`–`100`, with up to two decimals) rather than as raw 8-bit values (`0`–`255`). From 78df22e58f799a44441fb924c1f7781189896784 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 09:57:29 +0200 Subject: [PATCH 14/22] Add safety and legal notices to the readme Spell out that this is not a certified safety system and must not be used for emergency lighting or the functional safety of machinery, and add notices for strobe/photosensitivity and laser control, with the relevant DE/AT/CH frameworks so operators know where responsibility lies. --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 116a540..847b808 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,14 @@ This package builds on the [`sacn`](https://www.npmjs.com/package/sacn) library - **No synchronization.** E1.31 universe synchronization (synchronized multi-universe updates) is not implemented. - **No stream termination.** When a sender node is stopped or redeployed it simply closes its socket; it does not send packets with the `Stream_Terminated` flag. Receivers therefore hold the last received values until their own signal-loss timeout (typically ~2.5 s) elapses. - **DMX values are percentages.** Channel values are expressed as a percentage (`0`–`100`, with up to two decimals) rather than as raw 8-bit values (`0`–`255`). + +## Safety and legal notice + +This package is network/protocol software for controlling DMX/sACN lighting. It is **not** a certified safety system, and sACN (E1.31) is an unauthenticated, best-effort protocol with no delivery guarantees. Operate it on a dedicated, segmented lighting network. Please observe the following before deploying it: + +- **No safety or emergency lighting.** Do not use this package to control safety, escape-route or emergency lighting. Such installations require certified, monitored systems (e.g. DE: DIN VDE 0108-100, DIN EN 1838; AT: TRVB E 102, ÖVE/ÖNORM E 8002; CH: SN EN 1838 and the VKF fire-protection guidelines). +- **No functional machine safety.** sACN/DMX is not a safety bus. Do not use it for the functional safety of machinery, kinetics, hoists or moving stage equipment (cf. Machinery Directive 2006/42/EC / Regulation (EU) 2023/1230, DIN 56950, EN ISO 13849, DGUV V3). +- **Strobe / photosensitivity.** The software can drive arbitrary strobe and flashing effects. Operators are responsible for protecting audiences and staff from photosensitive-epilepsy and glare hazards (e.g. DE: VStättVO, DGUV Information 215-313). +- **Lasers.** If DMX is used to control laser sources, the applicable laser-safety rules apply (e.g. DIN EN 60825; DE: OStrV / TROS Laserstrahlung, incl. an appointed laser safety officer). + +This is not legal advice. Responsibility for compliance, CE conformity of the controlled hardware and safe operation rests with the integrator and operator. From 0514703d1cd7b6c6720a21d76be6ea9cbc4f4b41 Mon Sep 17 00:00:00 2001 From: Florian Gail Date: Sun, 12 Jul 2026 10:08:30 +0200 Subject: [PATCH 15/22] Add an option to blank the sACN out universe on stop sACN has no stream-termination handling here, so on stop or redeploy receivers held the last look until their own signal-loss timeout. Add an opt-in setting that sends an all-zero frame before the socket is closed, so the output can be defined to go dark on shutdown. --- dist/nodes/sacn-out/locales/de/sacn-out.json | 3 +- .../sacn-out/locales/en-US/sacn-out.json | 3 +- dist/nodes/sacn-out/sacn-out.html | 10 +++++ dist/nodes/sacn-out/sacn-out.js | 27 ++++++++++++- src/nodes/sacn-out/form.html | 7 ++++ src/nodes/sacn-out/init.ts | 4 ++ src/nodes/sacn-out/locales/de.json | 3 +- src/nodes/sacn-out/locales/en-US.json | 3 +- src/nodes/sacn-out/sacn-out.ts | 38 +++++++++++++++++-- 9 files changed, 89 insertions(+), 9 deletions(-) diff --git a/dist/nodes/sacn-out/locales/de/sacn-out.json b/dist/nodes/sacn-out/locales/de/sacn-out.json index 23e4e70..48122d2 100644 --- a/dist/nodes/sacn-out/locales/de/sacn-out.json +++ b/dist/nodes/sacn-out/locales/de/sacn-out.json @@ -12,7 +12,8 @@ "speed_40": "Hoch (40Hz)", "speed_44": "Maximum (44Hz)", "speed_once": "Einmalig (0Hz)", - "universe": "Universum" + "universe": "Universum", + "blankOnClose": "Ausgabe beim Stoppen auf 0 setzen" } } } \ No newline at end of file diff --git a/dist/nodes/sacn-out/locales/en-US/sacn-out.json b/dist/nodes/sacn-out/locales/en-US/sacn-out.json index 94a7893..34b357d 100644 --- a/dist/nodes/sacn-out/locales/en-US/sacn-out.json +++ b/dist/nodes/sacn-out/locales/en-US/sacn-out.json @@ -12,7 +12,8 @@ "speed_40": "high (40Hz)", "speed_44": "maximum (44Hz)", "speed_once": "once (0Hz)", - "universe": "universe" + "universe": "universe", + "blankOnClose": "blank output on stop" } } } \ No newline at end of file diff --git a/dist/nodes/sacn-out/sacn-out.html b/dist/nodes/sacn-out/sacn-out.html index ac1c05f..a19cf57 100644 --- a/dist/nodes/sacn-out/sacn-out.html +++ b/dist/nodes/sacn-out/sacn-out.html @@ -41,6 +41,13 @@ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
diff --git a/dist/nodes/sacn-out/sacn-out.js b/dist/nodes/sacn-out/sacn-out.js index 72844f4..da96859 100644 --- a/dist/nodes/sacn-out/sacn-out.js +++ b/dist/nodes/sacn-out/sacn-out.js @@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); const sacn_1 = require("sacn"); const dmx_1 = require("../../lib/dmx"); const network_1 = require("../../lib/network"); +const interfaces_1 = require("../../lib/interfaces"); class NodeHandler { node; config; @@ -75,6 +76,7 @@ class NodeHandler { } } exports.default = (RED) => { + (0, interfaces_1.registerInterfaceEndpoint)(RED); RED.nodes.registerType("sacn-out", function (config) { RED.nodes.createNode(this, config); new NodeHandler(this, config); diff --git a/src/lib/interfaces.ts b/src/lib/interfaces.ts new file mode 100644 index 0000000..ccbb886 --- /dev/null +++ b/src/lib/interfaces.ts @@ -0,0 +1,37 @@ +import { networkInterfaces } from "node:os"; +import { NodeAPI } from "node-red"; + +export interface InterfaceInfo { + name: string; + address: string; +} + +/** all local IPv4 interfaces with their address, for the editor to offer */ +export function listInterfaces(): InterfaceInfo[] { + const result: InterfaceInfo[] = []; + const interfaces = networkInterfaces(); + + for (const name of Object.keys(interfaces)) { + for (const info of interfaces[name] ?? []) { + if (info.family === "IPv4") { + result.push({ name, address: info.address }); + } + } + } + + return result; +} + +let registered = false; + +/** register the admin endpoint that lists the interfaces; safe to call more than once */ +export function registerInterfaceEndpoint(RED: NodeAPI): void { + if (registered) { + return; + } + registered = true; + + RED.httpAdmin.get("/sacn/interfaces", RED.auth.needsPermission("flows.read"), (_req, res) => { + res.json(listInterfaces()); + }); +} diff --git a/src/lib/network.ts b/src/lib/network.ts index 3fba3eb..a5abcd3 100644 --- a/src/lib/network.ts +++ b/src/lib/network.ts @@ -1,3 +1,5 @@ +import { isIP } from "node:net"; + export interface NetworkConfig { interface?: string; port?: number; @@ -17,7 +19,8 @@ export function resolveNetworkOptions(config: NetworkConfig): NetworkOptions { port: config.port !== undefined && config.port > 0 ? config.port : DEFAULT_PORT, }; - if (config.interface !== undefined && config.interface.length > 7) { + // only bind to a specific interface when a valid IPv4 address is configured + if (config.interface !== undefined && isIP(config.interface) === 4) { options.iface = config.interface; } diff --git a/src/nodes/sacn-in/form.html b/src/nodes/sacn-in/form.html index 1612d09..1959635 100644 --- a/src/nodes/sacn-in/form.html +++ b/src/nodes/sacn-in/form.html @@ -74,7 +74,8 @@ - + +
- + +