node-red-contrib-dmx-for-ha 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,537 @@
1
+ // ============================================================
2
+ // ha-mqtt-dmx — DMX Fixture Node Runtime
3
+ // Package: node-red-contrib-dmx-for-ha
4
+ // Author: DeSwaggy — Discord: @deswaggy
5
+ // ============================================================
6
+
7
+ module.exports = function (RED) {
8
+
9
+ // ── Gamma correction table ────────────────────────────────────
10
+ // Pre-computed CIE 1931 gamma 2.2 lookup — 0..255 → 0..255
11
+ const GAMMA_TABLE = (function () {
12
+ const t = new Array(256);
13
+ for (let i = 0; i < 256; i++) {
14
+ t[i] = Math.round(Math.pow(i / 255, 2.2) * 255);
15
+ }
16
+ return t;
17
+ })();
18
+
19
+ function HaMqttDmxNode(config) {
20
+ RED.nodes.createNode(this, config);
21
+ const node = this;
22
+
23
+ // ── Config & broker ───────────────────────────────────────
24
+ const cfg = RED.nodes.getNode(config.config);
25
+ if (!cfg) { node.error('DMX: no config node selected'); return; }
26
+
27
+ const broker = RED.nodes.getNode(cfg.broker);
28
+ if (!broker) { node.error('DMX: no MQTT broker in config'); return; }
29
+
30
+ broker.register(node);
31
+
32
+ // ── Node settings ─────────────────────────────────────────
33
+ const S = {
34
+ uidPrefix: config.uidPrefix || 'L',
35
+ uid: config.uid || '',
36
+ uidPostfix: config.uidPostfix || '',
37
+ deviceType: config.deviceType || 'Downlight',
38
+ colorMode: config.colorMode || 'rgbw',
39
+ area: config.area || '',
40
+ situation: config.situation || 'in',
41
+ subLocation: config.subLocation || '',
42
+ ch: {
43
+ red: parseInt(config.chRed) || 0,
44
+ green: parseInt(config.chGreen) || 0,
45
+ blue: parseInt(config.chBlue) || 0,
46
+ white: parseInt(config.chWhite) || 0,
47
+ warmWhite:parseInt(config.chWarmWhite)|| 0,
48
+ },
49
+ controllerNum: config.controllerNum || '1',
50
+ universe: config.universe || '1',
51
+ haIcon: config.haIcon || 'mdi:lightbulb',
52
+ showEffects: config.showEffects !== false,
53
+ transitions: config.transitions !== false,
54
+ groupSync: config.groupSync === true,
55
+ defaultState: config.defaultState || 'OFF',
56
+ dmxLimiter: parseInt(config.dmxLimiter) || 255,
57
+ minOutput: parseInt(config.minOutput) || 1,
58
+ brightBump: parseInt(config.brightBump) || 50,
59
+ ticksPerSec: parseInt(config.ticksPerSec) || 31,
60
+ flashShort: cfg.flashShort,
61
+ flashLong: cfg.flashLong,
62
+ diskDelay: cfg.diskDelay,
63
+ };
64
+
65
+ const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
66
+ const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
67
+ const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
68
+ const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
69
+ const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
70
+ const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
71
+ const dmxTopic = `${cfg.siteId}/${cfg.zone}/dmx/${S.universe}`;
72
+
73
+ // ── Context helpers ───────────────────────────────────────
74
+ function ctxGet(key, store) {
75
+ try { return node.context().get(key, store); }
76
+ catch(e) { return node.context().get(key); }
77
+ }
78
+ function ctxSet(key, val, store) {
79
+ try { node.context().set(key, val, store); }
80
+ catch(e) { node.context().set(key, val); }
81
+ }
82
+ function recall(ramKey, diskKey, fallback) {
83
+ const v = ctxGet(ramKey) ?? ctxGet(ramKey, 'disk');
84
+ return v !== null && v !== undefined ? v : fallback;
85
+ }
86
+
87
+ // ── Disk save timer ───────────────────────────────────────
88
+ let diskTimer = null;
89
+ function startDiskSave(onComplete) {
90
+ if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
91
+ diskTimer = setTimeout(() => { diskTimer = null; onComplete(); }, S.diskDelay * 1000);
92
+ }
93
+
94
+ // ── DMX helpers ───────────────────────────────────────────
95
+ function scaleToDmx(colorValue, brightness) {
96
+ brightness = brightness !== undefined ? brightness : 255;
97
+ const limited = Math.round((colorValue / 255) * (brightness / 255) * S.dmxLimiter);
98
+ const gamma = GAMMA_TABLE[Math.max(0, Math.min(255, limited))];
99
+ // Min output floor — only apply when both inputs are non-zero (intentionally on)
100
+ if (gamma === 0 && colorValue > 0 && brightness > 0 && S.minOutput > 0) {
101
+ return S.minOutput;
102
+ }
103
+ return gamma;
104
+ }
105
+
106
+ function buildDmxPayload(channel, value) {
107
+ if (channel == null || value == null) return null;
108
+ return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
109
+ }
110
+
111
+ function sendDmxChannels(channels) {
112
+ channels.forEach(function ([ch, val]) {
113
+ const payload = buildDmxPayload(ch, val);
114
+ if (payload === null) return;
115
+ broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
116
+ });
117
+ }
118
+
119
+ // ── MQTT helpers ──────────────────────────────────────────
120
+ function pub(topic, payload, retain) {
121
+ broker.publish({
122
+ topic,
123
+ payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
124
+ qos: cfg.qos,
125
+ retain: retain !== undefined ? retain : cfg.retain,
126
+ });
127
+ }
128
+
129
+ function pubState(payload) {
130
+ pub(statTopic, JSON.stringify(payload), false);
131
+ }
132
+
133
+ function setStatus(fill, shape, text) {
134
+ node.status({ fill, shape, text });
135
+ }
136
+
137
+ // ── Effects ───────────────────────────────────────────────
138
+ let effectTimer = null;
139
+ let preEffectState = null;
140
+
141
+ function stopEffect() {
142
+ if (effectTimer) {
143
+ clearInterval(effectTimer); clearTimeout(effectTimer);
144
+ effectTimer = null;
145
+ }
146
+ }
147
+
148
+ function savePreEffectState() {
149
+ preEffectState = {
150
+ state: ctxGet('state'),
151
+ brightness: ctxGet('brightness'),
152
+ red: ctxGet('red'),
153
+ green: ctxGet('green'),
154
+ blue: ctxGet('blue'),
155
+ white: ctxGet('white'),
156
+ warmWhite: ctxGet('warmWhite'),
157
+ };
158
+ }
159
+
160
+ function restoreAfterEffect() {
161
+ if (!preEffectState) return;
162
+ const p = preEffectState;
163
+ preEffectState = null;
164
+ const channels = buildColorChannels(
165
+ p.brightness || 255, p.red || 255, p.green || 255,
166
+ p.blue || 255, p.white || 255, p.warmWhite || 0
167
+ );
168
+ sendDmxChannels(channels);
169
+ pubState({ state: p.state || 'OFF', color_mode: S.colorMode });
170
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
171
+ }
172
+
173
+ function startEffect(label, intervalMs, tickFn) {
174
+ stopEffect();
175
+ savePreEffectState();
176
+ setStatus('blue', 'dot', `${fixtureId} effect: ${label}`);
177
+ effectTimer = setInterval(tickFn, intervalMs);
178
+ }
179
+
180
+ // ── Color channel builder ─────────────────────────────────
181
+ function buildColorChannels(brightness, r, g, b, w, ww) {
182
+ const mode = S.colorMode;
183
+ if (mode === 'rgbw') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)],[S.ch.white,scaleToDmx(w,brightness)]];
184
+ if (mode === 'rgbww') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)],[S.ch.white,scaleToDmx(w,brightness)],[S.ch.warmWhite,scaleToDmx(ww,brightness)]];
185
+ if (mode === 'rgb') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)]];
186
+ if (mode === 'color_temp') return [[S.ch.white,scaleToDmx(w,brightness)],[S.ch.warmWhite,scaleToDmx(ww,brightness)]];
187
+ if (mode === 'brightness') return [[S.ch.white,scaleToDmx(255,brightness)]];
188
+ if (mode === 'onoff') return [[S.ch.white,scaleToDmx(255,brightness)]];
189
+ return [];
190
+ }
191
+
192
+ // ── Initial bump ──────────────────────────────────────────
193
+ function sendInitialBump(targetChannels) {
194
+ const prevState = recall('state', 'state_disk', S.defaultState);
195
+ if (prevState !== 'OFF' || !S.brightBump || S.brightBump <= 0) return 0;
196
+ const bumpDmx = GAMMA_TABLE[Math.min(255, S.brightBump)];
197
+ targetChannels.forEach(function ([ch, to]) {
198
+ if (to > 0) sendDmxChannels([[ch, Math.min(bumpDmx, to)]]);
199
+ });
200
+ return bumpDmx;
201
+ }
202
+
203
+ // ── Transition ────────────────────────────────────────────
204
+ function runTransition(fromChannels, toChannels, durationSecs) {
205
+ stopEffect();
206
+ const totalTicks = Math.round(durationSecs * S.ticksPerSec);
207
+ const intervalMs = Math.round(1000 / S.ticksPerSec);
208
+ let tick = 0;
209
+
210
+ effectTimer = setInterval(function () {
211
+ tick++;
212
+ const progress = Math.min(1, tick / totalTicks);
213
+ const channels = fromChannels.map(function ([ch, from], i) {
214
+ const to = toChannels[i] ? toChannels[i][1] : 0;
215
+ return [ch, Math.round(from + (to - from) * progress)];
216
+ });
217
+ sendDmxChannels(channels);
218
+ if (tick >= totalTicks) {
219
+ stopEffect();
220
+ sendDmxChannels(toChannels);
221
+ }
222
+ }, intervalMs);
223
+ }
224
+
225
+ // ── State persistence ─────────────────────────────────────
226
+ function saveState(state, brightness, r, g, b, w, ww) {
227
+ ctxSet('state', state);
228
+ ctxSet('brightness', brightness);
229
+ ctxSet('red', r); ctxSet('green', g); ctxSet('blue', b);
230
+ ctxSet('white', w); ctxSet('warmWhite', ww);
231
+ startDiskSave(function () {
232
+ ctxSet('state', state, 'disk');
233
+ ctxSet('brightness', brightness, 'disk');
234
+ ctxSet('red', r, 'disk'); ctxSet('green', g, 'disk');
235
+ ctxSet('blue', b, 'disk'); ctxSet('white', w, 'disk');
236
+ ctxSet('warmWhite', ww, 'disk');
237
+ node.log(`${fixtureId} disk saved — state:${state}`);
238
+ });
239
+ }
240
+
241
+ // ── ON handler ────────────────────────────────────────────
242
+ function handleON(payload) {
243
+ stopEffect();
244
+ const brightness = payload.brightness !== undefined ? payload.brightness : (recall('brightness','brightness_disk',255));
245
+ const color = payload.color || {};
246
+ const r = color.r !== undefined ? color.r : recall('red', 'red_disk', 255);
247
+ const g = color.g !== undefined ? color.g : recall('green', 'green_disk', 255);
248
+ const b = color.b !== undefined ? color.b : recall('blue', 'blue_disk', 255);
249
+ const w = color.w !== undefined ? color.w : recall('white', 'white_disk', 255);
250
+ const ww = color.ww!== undefined ? color.ww: recall('warmWhite','warmWhite_disk', 0);
251
+
252
+ const toChannels = buildColorChannels(brightness, r, g, b, w, ww);
253
+
254
+ if (S.transitions && payload.transition && payload.transition > 0) {
255
+ const bump = sendInitialBump(toChannels);
256
+ const prevBright = recall('brightness', 'brightness_disk', 0);
257
+ const prevR = recall('red', 'red_disk', S.brightBump || r);
258
+ const prevG = recall('green', 'green_disk', S.brightBump || g);
259
+ const prevB = recall('blue', 'blue_disk', S.brightBump || b);
260
+ const prevW = recall('white', 'white_disk', S.brightBump || w);
261
+ const prevWW = recall('warmWhite','warmWhite_disk', 0);
262
+ const fromChannels = buildColorChannels(
263
+ bump || prevBright, prevR, prevG, prevB, prevW, prevWW
264
+ );
265
+ runTransition(fromChannels, toChannels, payload.transition);
266
+ } else {
267
+ sendInitialBump(toChannels);
268
+ sendDmxChannels(toChannels);
269
+ }
270
+
271
+ saveState('ON', brightness, r, g, b, w, ww);
272
+ pubState({ state: 'ON', color_mode: S.colorMode, brightness, color: { r, g, b, w, ww } });
273
+ setStatus('green', 'dot', `${fixtureId} ON bright:${brightness}`);
274
+ }
275
+
276
+ // ── OFF handler ───────────────────────────────────────────
277
+ function handleOFF(payload) {
278
+ stopEffect();
279
+ const brightness = recall('brightness', 'brightness_disk', 255);
280
+ const r = recall('red', 'red_disk', 255);
281
+ const g = recall('green', 'green_disk', 255);
282
+ const b = recall('blue', 'blue_disk', 255);
283
+ const w = recall('white', 'white_disk', 255);
284
+ const ww = recall('warmWhite', 'warmWhite_disk', 0);
285
+
286
+ const toChannels = buildColorChannels(0, r, g, b, w, ww);
287
+
288
+ if (S.transitions && payload && payload.transition && payload.transition > 0) {
289
+ const fromChannels = buildColorChannels(brightness, r, g, b, w, ww);
290
+ runTransition(fromChannels, toChannels, payload.transition);
291
+ } else {
292
+ sendDmxChannels(toChannels);
293
+ }
294
+
295
+ saveState('OFF', brightness, r, g, b, w, ww);
296
+ pubState({ state: 'OFF', color_mode: S.colorMode });
297
+ setStatus('grey', 'ring', `${fixtureId} OFF`);
298
+ }
299
+
300
+ // ── Effect dispatch ───────────────────────────────────────
301
+ function runEffect(effectName) {
302
+ if (effectName === 'none') {
303
+ stopEffect();
304
+ restoreAfterEffect();
305
+ return;
306
+ }
307
+ // Flash effects
308
+ if (effectName === 'flash_short' || effectName === 'flash_long') {
309
+ const dur = effectName === 'flash_short' ? S.flashShort : S.flashLong;
310
+ stopEffect(); savePreEffectState();
311
+ const allOn = buildColorChannels(255,255,255,255,255,255);
312
+ const allOff = buildColorChannels(0,0,0,0,0,0);
313
+ sendDmxChannels(allOn);
314
+ effectTimer = setTimeout(() => { effectTimer = null; sendDmxChannels(allOff); restoreAfterEffect(); }, dur * 1000);
315
+ return;
316
+ }
317
+ // Strobe
318
+ if (effectName === 'strobe') {
319
+ let on = false;
320
+ startEffect('strobe', 100, () => { on = !on; sendDmxChannels(buildColorChannels(on ? 255 : 0, 255,255,255,255,255)); });
321
+ return;
322
+ }
323
+ // Rainbow
324
+ if (effectName === 'rainbow') {
325
+ let deg = 0;
326
+ startEffect('Rainbow', Math.round(1000 / S.ticksPerSec), () => {
327
+ deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
328
+ const [r,g,b] = hsvToRgb(deg, 1, 1);
329
+ sendDmxChannels(buildColorChannels(255, r, g, b, 0, 0));
330
+ });
331
+ return;
332
+ }
333
+ // Rainbow RGBW
334
+ if (effectName === 'rainbow_rgbw') {
335
+ let deg = 0;
336
+ startEffect('Rainbow RGBW', Math.round(1000 / S.ticksPerSec), () => {
337
+ deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
338
+ const [r,g,b] = hsvToRgb(deg, 1, 1);
339
+ const w = Math.round(255 * Math.abs(Math.sin(deg * Math.PI / 180)));
340
+ sendDmxChannels(buildColorChannels(255, r, g, b, w, 0));
341
+ });
342
+ return;
343
+ }
344
+ // Fire
345
+ if (effectName === 'fire') {
346
+ startEffect('Fire', 80, () => {
347
+ const r = 200 + Math.floor(Math.random() * 55);
348
+ const g = Math.floor(Math.random() * 80);
349
+ sendDmxChannels(buildColorChannels(255, r, g, 0, 0, 0));
350
+ });
351
+ return;
352
+ }
353
+ // Flicker
354
+ if (effectName === 'flicker') {
355
+ startEffect('Flicker', 120, () => {
356
+ const bright = 180 + Math.floor(Math.random() * 75);
357
+ sendDmxChannels(buildColorChannels(bright, 255, 180, 50, 200, 0));
358
+ });
359
+ return;
360
+ }
361
+ // Twinkle
362
+ if (effectName === 'twinkle') {
363
+ let dir = 1; let bright = 0;
364
+ startEffect('Twinkle', Math.round(1000 / S.ticksPerSec), () => {
365
+ bright = Math.max(0, Math.min(255, bright + dir * 8));
366
+ if (bright >= 255 || bright <= 0) dir = -dir;
367
+ sendDmxChannels(buildColorChannels(bright, 255, 255, 255, 255, 0));
368
+ });
369
+ return;
370
+ }
371
+ // Police
372
+ if (effectName === 'police') {
373
+ let tick = 0;
374
+ startEffect('Police', 150, () => {
375
+ tick++;
376
+ const isRed = (tick % 4) < 2;
377
+ sendDmxChannels(buildColorChannels(255, isRed ? 255 : 0, 0, isRed ? 0 : 255, 0, 0));
378
+ });
379
+ return;
380
+ }
381
+ node.warn(`${fixtureId} — unknown effect: "${effectName}"`);
382
+ }
383
+
384
+ // ── HSV helper ────────────────────────────────────────────
385
+ function hsvToRgb(h, s, v) {
386
+ const i = Math.floor(h / 60) % 6;
387
+ const f = h / 60 - Math.floor(h / 60);
388
+ const p = Math.round(v * (1 - s) * 255);
389
+ const q = Math.round(v * (1 - f * s) * 255);
390
+ const t = Math.round(v * (1 - (1 - f) * s) * 255);
391
+ const vv = Math.round(v * 255);
392
+ return [[vv,t,p],[q,vv,p],[p,vv,t],[p,q,vv],[t,p,vv],[vv,p,q]][i];
393
+ }
394
+
395
+ // ── Device add ────────────────────────────────────────────
396
+ function handleDeviceAdd() {
397
+ if (ctxGet('state') === undefined && ctxGet('state', 'disk') === undefined) {
398
+ ctxSet('state', S.defaultState);
399
+ }
400
+
401
+ const effectList = S.showEffects ? [
402
+ 'none','flash_short','flash_long','strobe','rainbow','rainbow_rgbw',
403
+ 'fire','flicker','twinkle','police',
404
+ ] : [];
405
+
406
+ const discovery = {
407
+ unique_id: `${S.deviceType}(${fixtureId})`,
408
+ schema: 'json',
409
+ object_id: objectId,
410
+ optimistic: false,
411
+ enabled_by_default: cfg.enabledDefault,
412
+ icon: S.haIcon,
413
+ supported_color_modes: [S.colorMode],
414
+ brightness: true,
415
+ brightness_scale: 255,
416
+ effect: S.showEffects,
417
+ effect_list: effectList,
418
+ flash_time_short: S.flashShort,
419
+ flash_time_long: S.flashLong,
420
+ min_mireds: 153,
421
+ max_mireds: 500,
422
+ stat_t: statTopic,
423
+ cmd_t: cmdTopic,
424
+ name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
425
+ device: {
426
+ identifiers: `light-${fixtureId}`,
427
+ name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
428
+ model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
429
+ model_id: `referenced on plan as: (${fixtureId}`,
430
+ suggested_area: `${cfg.zone} ${S.area} ${S.subLocation}`,
431
+ hw_version: `DMX Controller in ${cfg.zone}. MQTT: ${dmxTopic}`,
432
+ serial_number: fixtureId,
433
+ sw_version: 'ha-mqtt-dmx: 0.1.0',
434
+ manufacturer: 'DeSwaggy — Discord: @deswaggy',
435
+ },
436
+ };
437
+
438
+ pub(cfgTopic, discovery, true);
439
+
440
+ broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
441
+ let payload;
442
+ try { payload = JSON.parse(rawPayload.toString()); }
443
+ catch(e) { node.warn(`${fixtureId} — failed to parse HA command`); return; }
444
+
445
+ if (payload.effect) {
446
+ runEffect(payload.effect);
447
+ } else if (payload.state === 'ON') {
448
+ handleON(payload);
449
+ } else if (payload.state === 'OFF') {
450
+ handleOFF(payload);
451
+ }
452
+ }, node.id);
453
+
454
+ setStatus('green', 'ring', `${fixtureId} discovery sent`);
455
+ node.log(`${fixtureId} device added`);
456
+
457
+ // Recovery state
458
+ setTimeout(function () {
459
+ const state = recall('state', 'state_disk', S.defaultState);
460
+ const brightness = recall('brightness', 'brightness_disk', 255);
461
+ const r = recall('red', 'red_disk', 255);
462
+ const g = recall('green', 'green_disk', 255);
463
+ const b = recall('blue', 'blue_disk', 255);
464
+ const w = recall('white', 'white_disk', 255);
465
+ const ww = recall('warmWhite', 'warmWhite_disk', 0);
466
+
467
+ if (state === 'ON') {
468
+ sendDmxChannels(buildColorChannels(brightness, r, g, b, w, ww));
469
+ } else {
470
+ sendDmxChannels(buildColorChannels(0, r, g, b, w, ww));
471
+ }
472
+ pubState({ state, color_mode: S.colorMode, brightness, color: { r, g, b, w, ww } });
473
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
474
+ node.log(`${fixtureId} recovery — state:${state}`);
475
+ }, 2000);
476
+ }
477
+
478
+ // ── Device remove ─────────────────────────────────────────
479
+ function handleDeviceRemove() {
480
+ stopEffect();
481
+ if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
482
+ ['state','brightness','red','green','blue','white','warmWhite'].forEach(function (k) {
483
+ ctxSet(k, null); ctxSet(k, null, 'disk');
484
+ });
485
+ pub(cfgTopic, '', true);
486
+ broker.unsubscribe(cmdTopic, node.id);
487
+ setStatus('red', 'ring', `${fixtureId} removed`);
488
+ node.log(`${fixtureId} device removed`);
489
+ }
490
+
491
+ // ── AUX from Group Node ───────────────────────────────────
492
+ function handleAux(msg) {
493
+ if (!msg.payload) return;
494
+ // groupSync=false only blocks effect sync — state commands always pass through
495
+ if (msg.payload.effect) {
496
+ if (S.groupSync) runEffect(msg.payload.effect);
497
+ // else: fixture ignores group effects and keeps its own
498
+ } else if (msg.payload.state === 'ON') {
499
+ handleON(msg.payload);
500
+ } else if (msg.payload.state === 'OFF') {
501
+ handleOFF(msg.payload);
502
+ }
503
+ }
504
+
505
+ // ── NR input entry point ──────────────────────────────────
506
+ node.on('input', function (msg, send, done) {
507
+ if (msg.dmx_trace != null) {
508
+ handleAux(msg);
509
+ } else {
510
+ const devReq = typeof msg.device === 'string'
511
+ ? msg.device
512
+ : (msg.device && msg.device.request);
513
+
514
+ if (devReq) {
515
+ switch (devReq) {
516
+ case 'add': handleDeviceAdd(); break;
517
+ case 'remove': handleDeviceRemove(); break;
518
+ default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
519
+ }
520
+ } else {
521
+ node.warn(`${fixtureId} — unrecognised message received and dropped. See node documentation.`);
522
+ }
523
+ }
524
+ done();
525
+ });
526
+
527
+ // ── Cleanup ───────────────────────────────────────────────
528
+ node.on('close', function (done) {
529
+ stopEffect();
530
+ if (diskTimer) clearTimeout(diskTimer);
531
+ broker.unsubscribe(cmdTopic, node.id);
532
+ broker.deregister(node, done);
533
+ });
534
+ }
535
+
536
+ RED.nodes.registerType('ha-mqtt-dmx', HaMqttDmxNode);
537
+ };