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/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
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 .
diff --git a/README.md b/README.md
index e85b974..5f234f3 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.
@@ -25,18 +26,26 @@ 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 |
+| 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 |
+| values | Emit channel values as a percentage (0–100) or as raw DMX values (0–255). | `percent`, `absolute` | `percent` | 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 |
+#### 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 |
@@ -60,12 +69,14 @@ 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 |
| speed | Defines the frequency for sending sACN-packages | `once (0Hz)`, `24Hz`, `27Hz`, `30Hz`, `40Hz`, `44Hz` | `0Hz` | yes |
| priority | The priority that should be used for the sACN-sender. | `\d+` (`1` to `200`) | `100` | yes |
+| values | Interpret the payload as a percentage (0–100) or as raw DMX values (0–255). | `percent`, `absolute` | `percent` | yes |
+| blank-on-stop | Send an all-zero frame before closing so receivers go dark on stop. | `true`, `false` | `false` | no |
| IP-address | IP-Address of the network-interface that should be used for sending 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 |
@@ -81,8 +92,11 @@ 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 |
+| --------- | ------------------------------------------------------------------------------ | --------------------- | ------------- | --------- |
+| values | Interpret channel values as a percentage (0–100) or as raw DMX values (0–255). | `percent`, `absolute` | `percent` | yes |
+
+Recorded scenes are stored on disk (in a `sacn-scenes` directory inside the Node-RED user directory) and survive a restart.
#### Expected input:
@@ -90,7 +104,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:
@@ -110,3 +124,23 @@ This node can be used to record scenes and play them afterwards.
| `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`).
+
+## 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.
diff --git a/dist/lib/dmx.js b/dist/lib/dmx.js
new file mode 100644
index 0000000..598c8f3
--- /dev/null
+++ b/dist/lib/dmx.js
@@ -0,0 +1,25 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.MAX_ABSOLUTE = exports.MAX_PERCENT = exports.LAST_UNIVERSE = exports.FIRST_UNIVERSE = exports.LAST_CHANNEL = exports.FIRST_CHANNEL = void 0;
+exports.maxValue = maxValue;
+exports.fromPercent = fromPercent;
+exports.nulledUniverse = nulledUniverse;
+exports.FIRST_CHANNEL = 1;
+exports.LAST_CHANNEL = 512;
+exports.FIRST_UNIVERSE = 1;
+exports.LAST_UNIVERSE = 63999;
+exports.MAX_PERCENT = 100;
+exports.MAX_ABSOLUTE = 255;
+function maxValue(scale) {
+ return scale === "absolute" ? exports.MAX_ABSOLUTE : exports.MAX_PERCENT;
+}
+function fromPercent(percent, scale) {
+ return scale === "absolute" ? Math.round(percent * 2.55) : percent;
+}
+function nulledUniverse() {
+ const universe = {};
+ for (let channel = exports.FIRST_CHANNEL; channel <= exports.LAST_CHANNEL; channel++) {
+ universe[channel] = 0;
+ }
+ return universe;
+}
diff --git a/dist/lib/interfaces.js b/dist/lib/interfaces.js
new file mode 100644
index 0000000..df0c2e9
--- /dev/null
+++ b/dist/lib/interfaces.js
@@ -0,0 +1,27 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.listInterfaces = listInterfaces;
+exports.registerInterfaceEndpoint = registerInterfaceEndpoint;
+const node_os_1 = require("node:os");
+function listInterfaces() {
+ const result = [];
+ const interfaces = (0, node_os_1.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;
+function registerInterfaceEndpoint(RED) {
+ if (registered) {
+ return;
+ }
+ registered = true;
+ RED.httpAdmin.get("/sacn/interfaces", RED.auth.needsPermission("flows.read"), (_req, res) => {
+ res.json(listInterfaces());
+ });
+}
diff --git a/dist/lib/network.js b/dist/lib/network.js
new file mode 100644
index 0000000..e622fea
--- /dev/null
+++ b/dist/lib/network.js
@@ -0,0 +1,15 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.DEFAULT_PORT = void 0;
+exports.resolveNetworkOptions = resolveNetworkOptions;
+const node_net_1 = require("node:net");
+exports.DEFAULT_PORT = 5568;
+function resolveNetworkOptions(config) {
+ const options = {
+ port: config.port !== undefined && config.port > 0 ? config.port : exports.DEFAULT_PORT,
+ };
+ if (config.interface !== undefined && (0, node_net_1.isIP)(config.interface) === 4) {
+ options.iface = config.interface;
+ }
+ return options;
+}
diff --git a/dist/lib/scene-store.js b/dist/lib/scene-store.js
new file mode 100644
index 0000000..70d7770
--- /dev/null
+++ b/dist/lib/scene-store.js
@@ -0,0 +1,28 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.SceneStore = void 0;
+const node_fs_1 = require("node:fs");
+const node_path_1 = require("node:path");
+class SceneStore {
+ file;
+ constructor(baseDir, nodeId) {
+ const dir = (0, node_path_1.join)(baseDir, "sacn-scenes");
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
+ this.file = (0, node_path_1.join)(dir, `${nodeId}.json`);
+ }
+ load() {
+ if (!(0, node_fs_1.existsSync)(this.file)) {
+ return {};
+ }
+ try {
+ return JSON.parse((0, node_fs_1.readFileSync)(this.file, "utf8"));
+ }
+ catch {
+ return {};
+ }
+ }
+ save(scenes) {
+ (0, node_fs_1.writeFileSync)(this.file, JSON.stringify(scenes));
+ }
+}
+exports.SceneStore = SceneStore;
diff --git a/dist/nodes/sacn-in/locales/de/sacn-in.json b/dist/nodes/sacn-in/locales/de/sacn-in.json
index d0aa154..0ac2f79 100644
--- a/dist/nodes/sacn-in/locales/de/sacn-in.json
+++ b/dist/nodes/sacn-in/locales/de/sacn-in.json
@@ -16,7 +16,10 @@
"trigger_always": "Jedes Paket",
"trigger_interval": "Änderungen + zyklisch",
"interval": "Intervall (ms)",
- "clearOnUniverseChange": "Bei Universumswechsel auf 0 setzen"
+ "clearOnUniverseChange": "Bei Universumswechsel auf 0 setzen",
+ "values": "Werte",
+ "values_percent": "Prozent (0–100)",
+ "values_absolute": "Absolut (0–255)"
}
}
}
\ No newline at end of file
diff --git a/dist/nodes/sacn-in/locales/en-US/sacn-in.json b/dist/nodes/sacn-in/locales/en-US/sacn-in.json
index 678c53a..bf870e0 100644
--- a/dist/nodes/sacn-in/locales/en-US/sacn-in.json
+++ b/dist/nodes/sacn-in/locales/en-US/sacn-in.json
@@ -16,7 +16,10 @@
"trigger_always": "every packet",
"trigger_interval": "changes + cyclic",
"interval": "interval (ms)",
- "clearOnUniverseChange": "blank on universe change"
+ "clearOnUniverseChange": "blank on universe change",
+ "values": "values",
+ "values_percent": "percent (0–100)",
+ "values_absolute": "absolute (0–255)"
}
}
}
\ No newline at end of file
diff --git a/dist/nodes/sacn-in/sacn-in.html b/dist/nodes/sacn-in/sacn-in.html
index 5f684c4..aae65de 100644
--- a/dist/nodes/sacn-in/sacn-in.html
+++ b/dist/nodes/sacn-in/sacn-in.html
@@ -11,7 +11,7 @@
-
+