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 @@ - +
@@ -34,6 +34,16 @@ +
+ + +
- + +
+
+ + +