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,860 @@
1
+ // DMX GROUP NODE — HOME ASSISTANT in NODE-RED
2
+ // Discord: @deswaggy | Version: 0.3.8
3
+ // Extensions: "BETTER COMMENTS", "TODO TREE"
4
+
5
+ //! BIG & BRIGHT
6
+ //@ Topic Headers
7
+ //# Description / Explanations
8
+ //$ Logic
9
+ //^ Debug Comments
10
+ //? Breaks / Headers
11
+
12
+ // Naming conventions:
13
+ // <env_var> = Sub-flow environmental (Edit subflow template)
14
+ // <<flow_var>> = RAM flow memory (flow.set / flow.get, "memory")
15
+ // <<D_flow_var>> = Disk flow memory (flow.set / flow.get, "disk_values")
16
+ // <<<flow_env>>> = Flow-level environmental (Tab → gear → flow envs)
17
+
18
+ // Context stores:
19
+ // memory = fast, lost on reboot → used during live operation
20
+ // disk_values = persistent → written after a debounce delay
21
+
22
+ // Role of this node:
23
+ // Sits between the MQTT in node and one or more DMX fixture nodes (or child Group Nodes).
24
+ // Appears as a single HA light entity (e.g. "Bedroom 1", "Ceiling").
25
+ // On command: updates own HA state, then fans the payload downstream via output 2 (AUX)
26
+ // to all wired children — each child handles its own DMX/HA state independently.
27
+ // No DMX channels. No gamma correction. No fixture-level concerns.
28
+ //
29
+ // Star topology: MQTT in → Group Node → [DMX Node AUX | Group Node AUX] × N
30
+ // Recursive: Group Node → Group Node → Group Node → DMX Node (any depth)
31
+ //
32
+ //! IMPORTANT — NODE-RED FUNCTION NODE SETUP:
33
+ // This node requires EXACTLY 5 outputs configured in the NR function node settings.
34
+ // Output 1: HA MQTT (state topic + discovery config) → wire to MQTT Out node
35
+ // Output 2: MQTT subscribe / unsubscribe actions → wire to MQTT In node
36
+ // Output 3: Node status + debug → wire to Status / Debug node
37
+ // Output 5: AUX fan-out → all child DMX / Group Nodes → wire to children's input
38
+ // If outputs are misconfigured, state and fan-out messages will be silently dropped.
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ //!@ CONFIG — all env reads in one place
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ const CFG = {
45
+ //@ HA identity
46
+ discoveryPrefix: env.get("<HA_discovery_prefix>"),
47
+ siteId: env.get("<HA_site_unique_id>"),
48
+ component: env.get("<HA_component>"),
49
+ uidPrefix: env.get("<HA_unique_id_prefix>"),
50
+ uid: env.get("<HA_unique_id>"),
51
+ uidPostfix: env.get("<HA_unique_id_postfix>"),
52
+
53
+ //@ Group UID prefix — used in MQTT topic and HA entity unique_id
54
+ //^ LG = Light Group (default), PG = Power Group
55
+ //^ This is intentionally separate from the DMX fixture node's <HA_unique_id_prefix>
56
+ //^ so group topics are always correctly prefixed without manual env var changes.
57
+ groupUidPrefix: env.get("<GROUP_uid_prefix>") || "LG",
58
+
59
+ //@ MQTT
60
+ retain: env.get("<HA_MQTT_retain_flag>"),
61
+ qos: env.get("<HA_MQTT_QOS>"),
62
+ configTopic: env.get("<HA_config_topic>"),
63
+ commandTopic: env.get("<HA_command_topic>"),
64
+ stateTopic: env.get("<HA_state_topic>"),
65
+
66
+ //@ HA features
67
+ schema: env.get("<HA_schema>"),
68
+ optimistic: env.get("<HA_optimistic>"),
69
+ enabledByDefault: env.get("<HA_enabled_by_default>"),
70
+ icon: env.get("<HA_icon>"),
71
+ colorModes: env.get("<HA_supported_color_modes>"),
72
+ brightness: env.get("<HA_brightness>"),
73
+ transitionsEnabled: env.get("<HA_Transitions_Enabled>") !== "false",
74
+
75
+ //@ Effects
76
+ //^ Group Node only supports synced group effects — forwarded downstream so all
77
+ //^ children receive the command simultaneously, keeping siblings in sync.
78
+ //^ Individual fixture effects (sleep_transition etc.) are intentionally excluded.
79
+ effects: env.get("<HA_effects>"),
80
+ //^ <HA_group_effects_list> — comma-separated list of synced group effects.
81
+ //^ Set as "str" type in the subflow env menu (comma-separated, no spaces).
82
+ //^ Intentionally excludes sleep_transition and temperature_transition —
83
+ //^ those are intimate per-fixture effects, not group-level concerns.
84
+ //^ Recommended list (same regardless of color mode — group syncs all children):
85
+ //# flash_short,flash_long,strobe,rainbow,rainbow_rgbw,fire,flicker,twinkle,
86
+ //# color_chase,scan,random,police,christmas,halloween,calaveras,party,fireworks
87
+ groupEffectsList: (env.get("<HA_group_effects_list>") ||
88
+ "flash_short,flash_long,strobe,rainbow,rainbow_rgbw,fire,flicker,twinkle," +
89
+ "color_chase,scan,random,police,christmas,halloween,calaveras," +
90
+ "party,fireworks").split(','),
91
+ flashShort: env.get("<HA_effects_flash_time_Short_(seconds)>"),
92
+ flashLong: env.get("<HA_effects_flash_time_Long_(seconds)>"),
93
+
94
+ //@ Color temperature range — advertised to HA in discovery payload
95
+ minMireds: env.get("<HA_ct_min_mireds>") || 153,
96
+ maxMireds: env.get("<HA_ct_max_mireds>") || 500,
97
+
98
+ //@ Transition — flow-level globals (<<<...>>>)
99
+ transitionRateLimit: env.get("<<<DMX_NODE_FLOW_Transition_Rate_Limit>>>") || 1,
100
+ transitionDefaultSecs: env.get("<<<DMX_NODE_FLOW_Transition_HA_UI_Time_(seconds)>>>") || 1,
101
+
102
+ //@ Group Node — identity
103
+ //^ Human-readable name used in dmx_trace for fault-finding and in HA device info.
104
+ //^ Always cast to string — <HA_unique_id> may be an integer (e.g. 992)
105
+ groupName: String(env.get("<GROUP_name>") || env.get("<HA_unique_id>") || ""),
106
+
107
+ //@ Group Node — loop detection
108
+ //^ Max AUX hop depth before dropping the message and warning.
109
+ //^ Protects against accidental circular wiring in the NR flow.
110
+ //^ Set to 0 to disable (not recommended in production).
111
+ //^ Default: 10
112
+ groupMaxDepth: (() => {
113
+ const raw = parseInt(env.get("<DMX_Group_Max_Depth>"));
114
+ return isNaN(raw) ? 10 : raw;
115
+ })(),
116
+
117
+ //@ DMX zone — used in discovery payload descriptions (matches DMX Node convention)
118
+ dmxZone: env.get("<CONTROLLER_zone>"),
119
+
120
+ //@ Device metadata
121
+ device: {
122
+ type: env.get("<DEVICE_Type>"),
123
+ situation: env.get("<DEVICE_situation>"),
124
+ area: env.get("<DEVICE_area>"),
125
+ subLocation: env.get("<DEVICE_Sub_Location>"),
126
+ },
127
+
128
+ //@ Node metadata
129
+ nodeCode: env.get("<NODE_code>") || "DMX GROUP NODE",
130
+ nodeSw: env.get("<NODE_sw_v>"),
131
+
132
+ //@ Persistence
133
+ diskDelay: env.get("<CONTEXT_STORE_values_to_disk_delay_in_seconds>"),
134
+ defaultState: env.get("<GROUP_default_state>") || "OFF",
135
+ };
136
+
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ //@ TOPIC BUILDER
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+
141
+ /** Base MQTT topic path for this group entity.
142
+ * Uses CFG.groupUidPrefix (default "LG") — separate from fixture <HA_unique_id_prefix>
143
+ * so a Group Node always produces e.g. "homeassistant/light/LG-992/cmd"
144
+ * without requiring the user to manually set the prefix per node. */
145
+ const groupTopic = `${CFG.discoveryPrefix}/${CFG.component}/${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
146
+
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+ //@ MEMORY HELPERS
149
+ //# SHARED WITH DMX NODE — extract to shared utility module on node package build
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ /**
153
+ * Read a value from RAM → disk → fallback, in that priority order.
154
+ * @param {string} ramKey
155
+ * @param {string} diskKey
156
+ * @param {*} fallback
157
+ */
158
+ function recall(ramKey, diskKey, fallback) {
159
+ return flow.get(ramKey, "memory") || flow.get(diskKey, "disk_values") || fallback;
160
+ }
161
+
162
+ /** Write a key/value map to RAM memory in one call. */
163
+ function rememberRam(values) {
164
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "memory"));
165
+ }
166
+
167
+ /** Write a key/value map to disk memory in one call. */
168
+ function rememberDisk(values) {
169
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "disk_values"));
170
+ }
171
+
172
+ // ─────────────────────────────────────────────────────────────────────────────
173
+ //@ MQTT SEND HELPERS
174
+ //# SHARED WITH DMX NODE — extract to shared utility module on node package build
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+
177
+ /** Report group state back to HA on output 1. */
178
+ function sendState(payloadObj) {
179
+ node.send([{
180
+ retain: CFG.retain,
181
+ qos: CFG.qos,
182
+ topic: `${groupTopic}/${CFG.stateTopic}`,
183
+ payload: payloadObj,
184
+ }, null, null, null, null]);
185
+ }
186
+
187
+ /** Send a Node-RED status + debug message on output 3. */
188
+ function sendStatus(fill, shape, text) {
189
+ node.send([null, null, {
190
+ debug: { node: `Group: ${CFG.groupName}`, message: text },
191
+ status: { fill, shape, text },
192
+ }, null, null]);
193
+ }
194
+
195
+ // ─────────────────────────────────────────────────────────────────────────────
196
+ //@ DISK-SAVE TIMER
197
+ //# SHARED WITH DMX NODE — extract to shared utility module on node package build
198
+ // ─────────────────────────────────────────────────────────────────────────────
199
+
200
+ /**
201
+ * Debounced disk-write timer — resets on every new command.
202
+ * @param {Function} onComplete - called when the delay has elapsed
203
+ */
204
+ function startDiskSaveTimer(onComplete) {
205
+ const existing = context.get('timer_diskSave');
206
+ if (existing) {
207
+ clearInterval(existing);
208
+ context.set('timer_diskSave', null);
209
+ context.set('timer_diskSave_count', 0);
210
+ console.log("Group disk-save timer reset by new input.");
211
+ }
212
+
213
+ const maxLoops = CFG.diskDelay;
214
+ let count = 0;
215
+ sendStatus("green", "ring", "Disk-save timer running");
216
+
217
+ const timer = setInterval(() => {
218
+ count++;
219
+ if (count >= maxLoops) {
220
+ clearInterval(timer);
221
+ context.set('timer_diskSave', null);
222
+ context.set('timer_diskSave_count', 0);
223
+ onComplete();
224
+ sendStatus("yellow", "ring", "Disk-save complete — awaiting next HA command");
225
+ } else {
226
+ context.set('timer_diskSave_count', count);
227
+ }
228
+ }, 1000);
229
+
230
+ context.set('timer_diskSave', timer);
231
+ }
232
+
233
+ // ─────────────────────────────────────────────────────────────────────────────
234
+ //@ DMX TRACE — ancestry tracking and loop detection
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+
237
+ /**
238
+ * Build a dmx_trace object to attach to every forwarded payload.
239
+ * Appends this Group Node to the ancestry path.
240
+ *
241
+ * @param {Object|null} incomingTrace - dmx_trace from upstream, or null if this is the origin
242
+ * @returns {Object} dmx_trace for the forwarded msg
243
+ *
244
+ * Example output at depth 2:
245
+ * {
246
+ * source: "DMX Group Node: Ceiling",
247
+ * path: ["Bedroom 1"],
248
+ * depth: 2
249
+ * }
250
+ */
251
+ function buildTrace(incomingTrace) {
252
+ const path = incomingTrace ? [...incomingTrace.path, incomingTrace.source] : [];
253
+ const depth = incomingTrace ? incomingTrace.depth + 1 : 1;
254
+ return {
255
+ source: `DMX Group Node: ${CFG.groupName}`,
256
+ path,
257
+ depth,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Check if the incoming trace exceeds the configured max depth.
263
+ * Warns, sets node status red, and returns true if the message should be dropped.
264
+ *
265
+ * @param {Object} trace - dmx_trace from incoming msg
266
+ * @returns {boolean} true = drop the message
267
+ */
268
+ function isLoopDetected(trace) {
269
+ if (CFG.groupMaxDepth === 0) return false; //# 0 = disabled
270
+ if (trace.depth > CFG.groupMaxDepth) {
271
+ node.warn(
272
+ `DMX Group Node "${CFG.groupName}" — loop guard tripped! ` +
273
+ `Depth ${trace.depth} exceeds max ${CFG.groupMaxDepth}. ` +
274
+ `Payload dropped — check for circular wiring in your NR flow. ` +
275
+ `Ancestry path: [${trace.path.join(' → ')}]. ` +
276
+ `Adjust <DMX_Group_Max_Depth> to change the limit (0 = disabled).`
277
+ );
278
+ sendStatus("red", "ring", `Loop guard tripped — depth ${trace.depth} > max ${CFG.groupMaxDepth}`);
279
+ return true;
280
+ }
281
+ return false;
282
+ }
283
+
284
+ // ─────────────────────────────────────────────────────────────────────────────
285
+ //@ FAN-OUT — forward payload to all wired children via output 2
286
+ // ─────────────────────────────────────────────────────────────────────────────
287
+
288
+ /**
289
+ * Forward a command payload downstream to all wired children.
290
+ * Attaches dmx_trace so children know where the command came from
291
+ * and can detect loops if they are also Group Nodes.
292
+ *
293
+ * @param {Object} payload - HA command payload (state, brightness, color etc.)
294
+ * @param {Object} trace - dmx_trace to attach
295
+ * @param {Object} originalMsg - the original incoming msg (for _msgid passthrough)
296
+ */
297
+ function fanOut(payload, trace, originalMsg) {
298
+ const forwardMsg = {
299
+ topic: `${groupTopic}/${CFG.commandTopic}`,
300
+ dmx_trace: trace,
301
+ payload,
302
+ qos: CFG.qos,
303
+ retain: CFG.retain,
304
+ _msgid: originalMsg._msgid,
305
+ };
306
+ //# Output 5 is the AUX fan-out output — wire this to children's input
307
+ node.send([null, null, null, null, forwardMsg]);
308
+ console.log(`Fan-out — group:"${CFG.groupName}" depth:${trace.depth} state:${payload.state || payload.effect || "effect"}`);
309
+ }
310
+
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+ //@ COLOR RESOLVERS
313
+ //# Group Node resolvers use G-prefixed memory keys to avoid collisions
314
+ //# with sibling DMX fixture nodes in the shared flow context store.
315
+ // ─────────────────────────────────────────────────────────────────────────────
316
+
317
+ function resolveGroupOnoff() {
318
+ return { white: recall("<<G_white>>", "<<GD_white>>", 255) };
319
+ }
320
+
321
+ function resolveGroupBrightness(payload) {
322
+ return { brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255) };
323
+ }
324
+
325
+ function resolveGroupRgb(payload) {
326
+ return {
327
+ brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
328
+ red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
329
+ green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
330
+ blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
331
+ };
332
+ }
333
+
334
+ function resolveGroupRgbw(payload) {
335
+ return {
336
+ brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
337
+ red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
338
+ green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
339
+ blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
340
+ white: payload?.color?.w || recall("<<G_white>>", "<<GD_white>>", 255),
341
+ };
342
+ }
343
+
344
+ function resolveGroupRgbww(payload) {
345
+ return {
346
+ brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
347
+ red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
348
+ green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
349
+ blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
350
+ white: payload?.color?.w || recall("<<G_white>>", "<<GD_white>>", 255),
351
+ warmWhite: payload?.color?.ww || recall("<<G_warmWhite>>", "<<GD_warmWhite>>", 0),
352
+ };
353
+ }
354
+
355
+ function resolveGroupColorTemp(payload) {
356
+ return {
357
+ brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
358
+ colorTemp: payload?.color_temp || recall("<<G_colorTemp>>", "<<GD_colorTemp>>", 300),
359
+ };
360
+ }
361
+
362
+ // ─────────────────────────────────────────────────────────────────────────────
363
+ //@ STATE HANDLERS
364
+ //# Each handler:
365
+ //# 1. Resolves current values (payload → RAM → disk → default)
366
+ //# 2. Reports own state to HA
367
+ //# 3. Fans payload out to all wired children via output 2
368
+ //# 4. Saves resolved values to RAM, queues disk write
369
+ //#
370
+ //# No DMX. No gamma. No channel maths. State + fan-out only.
371
+ // ─────────────────────────────────────────────────────────────────────────────
372
+
373
+ const GROUP_HANDLERS = {
374
+
375
+ ON: {
376
+
377
+ onoff(payload, trace, originalMsg) {
378
+ const { white } = resolveGroupOnoff();
379
+ sendState({ state: "ON", color: { w: white }, color_mode: "onoff" });
380
+ fanOut({ state: "ON", color: { w: white }, color_mode: "onoff" }, trace, originalMsg);
381
+ rememberRam({ "<<G_State>>": "ON", "<<G_white>>": white });
382
+ startDiskSaveTimer(() => {
383
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_white>>": white });
384
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:ON`);
385
+ });
386
+ },
387
+
388
+ brightness(payload, trace, originalMsg) {
389
+ const { brightness } = resolveGroupBrightness(payload);
390
+ sendState({ state: "ON", brightness, color_mode: "brightness" });
391
+ fanOut({ state: "ON", brightness, color_mode: "brightness", transition: payload.transition }, trace, originalMsg);
392
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness });
393
+ startDiskSaveTimer(() => {
394
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness });
395
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:ON bright:${brightness}`);
396
+ });
397
+ },
398
+
399
+ rgb(payload, trace, originalMsg) {
400
+ const { brightness, red, green, blue } = resolveGroupRgb(payload);
401
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue }, color_mode: "rgb" });
402
+ fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue }, color_mode: "rgb", transition: payload.transition }, trace, originalMsg);
403
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue });
404
+ startDiskSaveTimer(() => {
405
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue });
406
+ console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} bright:${brightness}`);
407
+ });
408
+ },
409
+
410
+ rgbw(payload, trace, originalMsg) {
411
+ const { brightness, red, green, blue, white } = resolveGroupRgbw(payload);
412
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white }, color_mode: "rgbw" });
413
+ fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white }, color_mode: "rgbw", transition: payload.transition }, trace, originalMsg);
414
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue, "<<G_white>>": white });
415
+ startDiskSaveTimer(() => {
416
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue, "<<GD_white>>": white });
417
+ console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} W:${white} bright:${brightness}`);
418
+ });
419
+ },
420
+
421
+ rgbww(payload, trace, originalMsg) {
422
+ const { brightness, red, green, blue, white, warmWhite } = resolveGroupRgbww(payload);
423
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white, ww: warmWhite }, color_mode: "rgbww" });
424
+ fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white, ww: warmWhite }, color_mode: "rgbww", transition: payload.transition }, trace, originalMsg);
425
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue, "<<G_white>>": white, "<<G_warmWhite>>": warmWhite });
426
+ startDiskSaveTimer(() => {
427
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue, "<<GD_white>>": white, "<<GD_warmWhite>>": warmWhite });
428
+ console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} W:${white} WW:${warmWhite} bright:${brightness}`);
429
+ });
430
+ },
431
+
432
+ color_temp(payload, trace, originalMsg) {
433
+ const { brightness, colorTemp } = resolveGroupColorTemp(payload);
434
+ sendState({ state: "ON", brightness, color_temp: colorTemp, color_mode: "color_temp" });
435
+ fanOut({ state: "ON", brightness, color_temp: colorTemp, color_mode: "color_temp", transition: payload.transition }, trace, originalMsg);
436
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_colorTemp>>": colorTemp });
437
+ startDiskSaveTimer(() => {
438
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_colorTemp>>": colorTemp });
439
+ console.log(`Disk saved — Group:"${CFG.groupName}" colorTemp:${colorTemp} bright:${brightness}`);
440
+ });
441
+ },
442
+
443
+ //# Combo modes — HA tells us which sub-mode is active via payload.color_mode
444
+ "rgb,color_temp"(payload, trace, originalMsg) {
445
+ payload.color_mode === "color_temp"
446
+ ? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
447
+ : GROUP_HANDLERS.ON.rgb(payload, trace, originalMsg);
448
+ },
449
+
450
+ "rgbw,color_temp"(payload, trace, originalMsg) {
451
+ payload.color_mode === "color_temp"
452
+ ? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
453
+ : GROUP_HANDLERS.ON.rgbw(payload, trace, originalMsg);
454
+ },
455
+
456
+ "rgbww,color_temp"(payload, trace, originalMsg) {
457
+ payload.color_mode === "color_temp"
458
+ ? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
459
+ : GROUP_HANDLERS.ON.rgbww(payload, trace, originalMsg);
460
+ },
461
+
462
+ white(payload, trace, originalMsg) {
463
+ const { brightness } = resolveGroupBrightness(payload);
464
+ sendState({ state: "ON", brightness, color_mode: "white" });
465
+ fanOut({ state: "ON", brightness, color_mode: "white", transition: payload.transition }, trace, originalMsg);
466
+ rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness });
467
+ startDiskSaveTimer(() => {
468
+ rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness });
469
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:ON bright:${brightness}`);
470
+ });
471
+ },
472
+ },
473
+
474
+ OFF: {
475
+
476
+ onoff(payload, trace, originalMsg) {
477
+ sendState({ state: "OFF", color_mode: "onoff" });
478
+ fanOut({ state: "OFF", color_mode: "onoff" }, trace, originalMsg);
479
+ rememberRam({ "<<G_State>>": "OFF" });
480
+ startDiskSaveTimer(() => {
481
+ rememberDisk({ "<<GD_State>>": "OFF" });
482
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
483
+ });
484
+ },
485
+
486
+ brightness(payload, trace, originalMsg) {
487
+ sendState({ state: "OFF", color_mode: "brightness" });
488
+ fanOut({ state: "OFF", color_mode: "brightness", transition: payload.transition }, trace, originalMsg);
489
+ rememberRam({ "<<G_State>>": "OFF" });
490
+ startDiskSaveTimer(() => {
491
+ rememberDisk({ "<<GD_State>>": "OFF" });
492
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
493
+ });
494
+ },
495
+
496
+ rgb(payload, trace, originalMsg) {
497
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0 }, color_mode: "rgb" });
498
+ fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0 }, color_mode: "rgb", transition: payload.transition }, trace, originalMsg);
499
+ rememberRam({ "<<G_State>>": "OFF" });
500
+ startDiskSaveTimer(() => {
501
+ rememberDisk({ "<<GD_State>>": "OFF" });
502
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
503
+ });
504
+ },
505
+
506
+ rgbw(payload, trace, originalMsg) {
507
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw" });
508
+ fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw", transition: payload.transition }, trace, originalMsg);
509
+ rememberRam({ "<<G_State>>": "OFF" });
510
+ startDiskSaveTimer(() => {
511
+ rememberDisk({ "<<GD_State>>": "OFF" });
512
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
513
+ });
514
+ },
515
+
516
+ rgbww(payload, trace, originalMsg) {
517
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww" });
518
+ fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww", transition: payload.transition }, trace, originalMsg);
519
+ rememberRam({ "<<G_State>>": "OFF" });
520
+ startDiskSaveTimer(() => {
521
+ rememberDisk({ "<<GD_State>>": "OFF" });
522
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
523
+ });
524
+ },
525
+
526
+ color_temp(payload, trace, originalMsg) {
527
+ sendState({ state: "OFF", color_mode: "color_temp" });
528
+ fanOut({ state: "OFF", color_mode: "color_temp", transition: payload.transition }, trace, originalMsg);
529
+ rememberRam({ "<<G_State>>": "OFF" });
530
+ startDiskSaveTimer(() => {
531
+ rememberDisk({ "<<GD_State>>": "OFF" });
532
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
533
+ });
534
+ },
535
+
536
+ "rgb,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgb(payload, trace, originalMsg); },
537
+ "rgbw,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgbw(payload, trace, originalMsg); },
538
+ "rgbww,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgbww(payload, trace, originalMsg); },
539
+
540
+ white(payload, trace, originalMsg) {
541
+ sendState({ state: "OFF", color_mode: "white" });
542
+ fanOut({ state: "OFF", color_mode: "white", transition: payload.transition }, trace, originalMsg);
543
+ rememberRam({ "<<G_State>>": "OFF" });
544
+ startDiskSaveTimer(() => {
545
+ rememberDisk({ "<<GD_State>>": "OFF" });
546
+ console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
547
+ });
548
+ },
549
+ },
550
+ };
551
+
552
+ // ─────────────────────────────────────────────────────────────────────────────
553
+ //@ DISPATCH
554
+ // ─────────────────────────────────────────────────────────────────────────────
555
+
556
+ /**
557
+ * Look up and invoke the correct group handler.
558
+ * @param {string} state - "ON" or "OFF"
559
+ * @param {Object} payload - msg.payload
560
+ * @param {Object} trace - dmx_trace to attach to fan-out
561
+ * @param {Object} originalMsg - original incoming msg
562
+ */
563
+ function dispatch(state, payload, trace, originalMsg) {
564
+ console.log(`Group dispatch — group:"${CFG.groupName}" state:"${state}" colorMode:"${CFG.colorModes}"`);
565
+
566
+ const stateHandlers = GROUP_HANDLERS[state];
567
+ if (!stateHandlers) {
568
+ node.warn(
569
+ `Group "${CFG.groupName}" dispatch error — unknown state "${state}". ` +
570
+ `Must be "ON" or "OFF". Check incoming payload from HA.`
571
+ );
572
+ return;
573
+ }
574
+ const handler = stateHandlers[CFG.colorModes];
575
+ if (handler === undefined) {
576
+ node.warn(
577
+ `Group "${CFG.groupName}" dispatch error — unknown color mode "${CFG.colorModes}" ` +
578
+ `for state "${state}". Check <HA_supported_color_modes> env var matches ` +
579
+ `a supported value: onoff, brightness, rgb, rgbw, rgbww, color_temp, white, ` +
580
+ `rgb\,color_temp, rgbw\,color_temp, rgbww\,color_temp`
581
+ );
582
+ return;
583
+ }
584
+ handler(payload, trace, originalMsg);
585
+ }
586
+
587
+ // ─────────────────────────────────────────────────────────────────────────────
588
+ //@ EFFECTS — synced group effects
589
+ //# The Group Node forwards the effect name downstream so all children receive
590
+ //# the command at the same NR tick, keeping siblings in sync.
591
+ //# Individual fixture effects (sleep_transition, temperature_transition) are
592
+ //# intentionally excluded from the group effects list.
593
+ // ─────────────────────────────────────────────────────────────────────────────
594
+
595
+ /**
596
+ * Validate and forward an effect command to all children.
597
+ * @param {string} effectName
598
+ * @param {Object} payload
599
+ * @param {Object} trace
600
+ * @param {Object} originalMsg
601
+ */
602
+ function dispatchGroupEffect(effectName, payload, trace, originalMsg) {
603
+ if (!CFG.groupEffectsList.includes(effectName.trim())) {
604
+ node.warn(
605
+ `Group "${CFG.groupName}" — effect "${effectName}" is not in the group effects list. ` +
606
+ `Check <HA_group_effects_list> env var.`
607
+ );
608
+ return;
609
+ }
610
+ console.log(`Group effect forwarding: "${effectName}" group:"${CFG.groupName}"`);
611
+ fanOut({ ...payload, effect: effectName }, trace, originalMsg);
612
+ sendStatus("blue", "dot", `Group effect: ${effectName}`);
613
+ }
614
+
615
+ // ─────────────────────────────────────────────────────────────────────────────
616
+ //@ DEVICE REQUEST HANDLERS
617
+ // ─────────────────────────────────────────────────────────────────────────────
618
+
619
+ /** Write default values to RAM on startup if nothing is stored yet. */
620
+ function initDefaultMemory() {
621
+ const existing = flow.get("<<G_State>>", "memory") || flow.get("<<GD_State>>", "disk_values");
622
+ if (existing) {
623
+ console.log(`initDefaultMemory: existing group state found for "${CFG.groupName}", skipping defaults.`);
624
+ return;
625
+ }
626
+ rememberRam({
627
+ "<<G_State>>": CFG.defaultState,
628
+ "<<G_bright>>": 255,
629
+ "<<G_red>>": 255,
630
+ "<<G_green>>": 255,
631
+ "<<G_blue>>": 255,
632
+ "<<G_white>>": 255,
633
+ "<<G_warmWhite>>": 0,
634
+ "<<G_colorTemp>>": 300,
635
+ });
636
+ console.log(`initDefaultMemory: group defaults written for "${CFG.groupName}".`);
637
+ }
638
+
639
+ /** Clear all RAM and disk memory keys for this group on device remove. */
640
+ function clearAllMemory() {
641
+ const ramKeys = ["<<G_State>>", "<<G_bright>>", "<<G_red>>", "<<G_green>>", "<<G_blue>>", "<<G_white>>", "<<G_warmWhite>>", "<<G_colorTemp>>"];
642
+ const diskKeys = ["<<GD_State>>", "<<GD_bright>>", "<<GD_red>>", "<<GD_green>>", "<<GD_blue>>", "<<GD_white>>", "<<GD_warmWhite>>", "<<GD_colorTemp>>"];
643
+ ramKeys.forEach(k => flow.set(k, null, "memory"));
644
+ diskKeys.forEach(k => flow.set(k, null, "disk_values"));
645
+ console.log(`clearAllMemory: group memory cleared for "${CFG.groupName}".`);
646
+ }
647
+
648
+ /** Build the HA state recovery payload for this group's color mode. */
649
+ function buildRecoveryStatePayload() {
650
+ const state = recall("<<G_State>>", "<<GD_State>>", CFG.defaultState);
651
+ const brightness = recall("<<G_bright>>", "<<GD_bright>>", 255);
652
+ const red = recall("<<G_red>>", "<<GD_red>>", 255);
653
+ const green = recall("<<G_green>>", "<<GD_green>>", 255);
654
+ const blue = recall("<<G_blue>>", "<<GD_blue>>", 255);
655
+ const white = recall("<<G_white>>", "<<GD_white>>", 255);
656
+ const warmWhite = recall("<<G_warmWhite>>", "<<GD_warmWhite>>", 0);
657
+ const colorTemp = recall("<<G_colorTemp>>", "<<GD_colorTemp>>", 300);
658
+
659
+ console.log(`Group recovery — "${CFG.groupName}" state:${state} bright:${brightness}`);
660
+
661
+ const rgb = { r: red, g: green, b: blue };
662
+ const rgbw = { ...rgb, w: white };
663
+ const rgbww = { ...rgbw, ww: warmWhite };
664
+
665
+ const map = {
666
+ "onoff": { state, color_mode: "onoff" },
667
+ "brightness": { state, brightness, color_mode: "brightness" },
668
+ "rgb": { state, brightness, color: rgb, color_mode: "rgb" },
669
+ "rgbw": { state, brightness, color: rgbw, color_mode: "rgbw" },
670
+ "rgbww": { state, brightness, color: rgbww, color_mode: "rgbww" },
671
+ "color_temp": { state, brightness, color_temp: colorTemp, color_mode: "color_temp" },
672
+ "white": { state, brightness, color_mode: "white" },
673
+ "rgb,color_temp": { state, brightness, color: rgb, color_mode: "rgb" },
674
+ "rgbw,color_temp": { state, brightness, color: rgbw, color_mode: "rgbw" },
675
+ "rgbww,color_temp": { state, brightness, color: rgbww, color_mode: "rgbww" },
676
+ };
677
+
678
+ return map[CFG.colorModes] ?? null;
679
+ }
680
+
681
+ function handleDeviceAdd() {
682
+ initDefaultMemory();
683
+
684
+ const colorModesArray = CFG.colorModes.split(',');
685
+ const cmdTopic = `${groupTopic}/${CFG.commandTopic}`;
686
+ const cfgTopic = `${groupTopic}/${CFG.configTopic}`;
687
+ const { device: dev } = CFG;
688
+
689
+ const discoveryPayload = {
690
+ retain: CFG.retain,
691
+ qos: CFG.qos,
692
+ topic: cfgTopic,
693
+ payload: {
694
+ unique_id: `group(${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix})`,
695
+ schema: CFG.schema,
696
+ //^ object_id locks the HA entity_id to the plan ID: light.lg_992
697
+ object_id: `${CFG.groupUidPrefix}_${CFG.uid}${CFG.uidPostfix}`.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/__+/g, '_'),
698
+ name: CFG.groupName || `${dev.type} Group ${dev.situation} the ${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
699
+ cmd_t: cmdTopic,
700
+ stat_t: `${groupTopic}/${CFG.stateTopic}`,
701
+ optimistic: CFG.optimistic,
702
+ enabled_by_default: CFG.enabledByDefault,
703
+ icon: CFG.icon,
704
+ supported_color_modes: colorModesArray,
705
+ brightness: CFG.brightness,
706
+ transition: CFG.transitionsEnabled,
707
+ effect: CFG.effects,
708
+ effect_list: CFG.groupEffectsList,
709
+ flash_time_short: CFG.flashShort,
710
+ flash_time_long: CFG.flashLong,
711
+ min_mireds: CFG.minMireds,
712
+ max_mireds: CFG.maxMireds,
713
+ device: {
714
+ identifiers: `${CFG.component}-${CFG.groupUidPrefix}-${CFG.uid}`,
715
+ name: `(${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix}) - ${dev.type}/s Group ${dev.situation} the ${CFG.dmxZone} - ${dev.area} - ${dev.subLocation}`,
716
+ model: `${colorModesArray} ${dev.type}/s Group located ${dev.situation} the ${CFG.dmxZone}-${dev.area} - ${dev.subLocation}`,
717
+ model_id: `referenced on plan as: (${CFG.groupUidPrefix}-${CFG.uid}`,
718
+ suggested_area: `${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
719
+ hw_version: `This is a virtual fixture — no physical hardware. It exists as a DMX Group Node in a Node-RED flow, grouping multiple DMX fixture nodes under a single Home Assistant entity. Changes made here are distributed to all child fixture nodes in the group.`,
720
+ sw_version: `${CFG.nodeCode}: ${CFG.nodeSw}`,
721
+ manufacturer: "DeSwaggy — Discord: @deswaggy",
722
+ },
723
+ },
724
+ };
725
+
726
+ //# Output 1: HA MQTT discovery payload
727
+ //# Output 2: MQTT subscribe action → MQTT In node
728
+ //# Output 3: Node status
729
+ //# Output 5: AUX — forward device:add to child DMX/Group Nodes so they self-discover
730
+ node.send([
731
+ discoveryPayload,
732
+ { retain: CFG.retain, qos: CFG.qos, topic: cmdTopic, action: "subscribe" },
733
+ { status: { fill: "green", shape: "ring", text: `Group "${CFG.groupName}" discovery sent — awaiting HA` } },
734
+ null,
735
+ { device: "add" },
736
+ ]);
737
+ console.log(`Group device added: "${CFG.groupName}"`);
738
+
739
+ //# Wait briefly before pushing recovery state — gives HA time to register
740
+ //# the discovery before we report the last-known state.
741
+ //# Simple setTimeout is correct here — we are not writing to disk, just
742
+ //# reporting state to HA. The disk save timer is reserved for command handling.
743
+ setTimeout(() => {
744
+ const recoveryPayload = buildRecoveryStatePayload();
745
+ if (recoveryPayload) {
746
+ sendState(recoveryPayload);
747
+ sendStatus("yellow", "ring", `Group "${CFG.groupName}" ready — awaiting HA commands`);
748
+ console.log(`Group recovery state sent — "${CFG.groupName}" mode:${CFG.colorModes}`);
749
+ } else {
750
+ sendStatus("yellow", "ring", `Group "${CFG.groupName}" ready — awaiting HA commands`);
751
+ }
752
+ }, 2000);
753
+ }
754
+
755
+ function handleDeviceRemove() {
756
+ const existing = context.get('timer_diskSave');
757
+ if (existing) clearInterval(existing);
758
+ context.set('timer_diskSave', null);
759
+
760
+ clearAllMemory();
761
+
762
+ node.send([
763
+ { retain: CFG.retain, qos: CFG.qos, topic: `${groupTopic}/${CFG.configTopic}`, payload: "" },
764
+ { retain: CFG.retain, qos: CFG.qos, topic: `${groupTopic}/${CFG.commandTopic}`, action: "unsubscribe" },
765
+ { status: { fill: "red", shape: "ring", text: `Group "${CFG.groupName}" removed — memory cleared` } },
766
+ null,
767
+ { device: "remove" },
768
+ ]);
769
+ console.log(`Group device removed: "${CFG.groupName}"`);
770
+ }
771
+
772
+ // ─────────────────────────────────────────────────────────────────────────────
773
+ //!? ENTRY POINT
774
+ // ─────────────────────────────────────────────────────────────────────────────
775
+
776
+ //# Pass original message through on output 4 untouched
777
+ node.send([null, null, null, msg, null]);
778
+
779
+ const prevGroupState = recall("<<G_State>>", "<<GD_State>>", CFG.defaultState);
780
+ console.log(`Group:"${CFG.groupName}" prev state:${prevGroupState} colorMode:${CFG.colorModes}`);
781
+
782
+ //? ── AUX — payload received from a parent Group Node ─────────────────────────
783
+ if (msg.dmx_trace != null) {
784
+ console.log(
785
+ `AUX received — from:"${msg.dmx_trace.source}" ` +
786
+ `path:[${msg.dmx_trace.path.join(' → ')}] depth:${msg.dmx_trace.depth}`
787
+ );
788
+
789
+ //# Loop detection — check BEFORE building a new trace
790
+ if (isLoopDetected(msg.dmx_trace)) {
791
+ node.done();
792
+ return;
793
+ }
794
+
795
+ //# Build updated trace — this node adds itself to the ancestry path
796
+ const trace = buildTrace(msg.dmx_trace);
797
+
798
+ if (msg.payload?.effect && CFG.effects) {
799
+ dispatchGroupEffect(msg.payload.effect, msg.payload, trace, msg);
800
+ } else if (msg.payload?.state != null) {
801
+ const wantTransition = msg.payload.transition && CFG.transitionsEnabled;
802
+ console.log(`AUX dispatch — state:${msg.payload.state} transition:${wantTransition}`);
803
+ dispatch(msg.payload.state, msg.payload, trace, msg);
804
+ } else {
805
+ node.warn(
806
+ `Group "${CFG.groupName}" — AUX payload from "${msg.dmx_trace.source}" ` +
807
+ `had no valid state or effect — message dropped.`
808
+ );
809
+ }
810
+
811
+ //? ── DIRECT HA COMMAND — payload.state from MQTT in ─────────────────────────
812
+ } else if (msg.payload?.state != null) {
813
+ //# Guard: if payload is still a string, MQTT In node is not set to parse JSON
814
+ if (typeof msg.payload === 'string') {
815
+ node.warn(
816
+ `Group "${CFG.groupName}" — msg.payload is a string, not a parsed object. ` +
817
+ `Check the MQTT In node is configured with Output: "a parsed JSON object". ` +
818
+ `Raw payload: ${msg.payload}`
819
+ );
820
+ node.done();
821
+ return;
822
+ }
823
+
824
+ const { state, transition, effect } = msg.payload;
825
+
826
+ //# Origin node — build a fresh trace starting at depth 1
827
+ const trace = buildTrace(null);
828
+
829
+ if (effect && CFG.effects) {
830
+ console.log(`Group effect received: "${effect}" group:"${CFG.groupName}"`);
831
+ dispatchGroupEffect(effect, msg.payload, trace, msg);
832
+ } else {
833
+ const wantTransition = transition && CFG.transitionsEnabled;
834
+ console.log(`Direct HA command — state:${state} transition:${wantTransition} mode:${CFG.colorModes}`);
835
+ dispatch(state, msg.payload, trace, msg);
836
+ }
837
+
838
+ //? ── DEVICE REQUEST — add / remove / debug ───────────────────────────────────
839
+ } else if (msg.device != null && (msg.device?.request != null || typeof msg.device === "string")) {
840
+ const _devReq = typeof msg.device === "string" ? msg.device : msg.device.request;
841
+ console.log(`device.request: ${_devReq} group:"${CFG.groupName}"`);
842
+ switch (_devReq) {
843
+ case "add": handleDeviceAdd(); break;
844
+ case "remove": handleDeviceRemove(); break;
845
+ case "debug": console.log("DEBUG:", JSON.stringify(msg)); break;
846
+ default: node.warn(`Group "${CFG.groupName}" — unknown device.request: "${msg.device.request}"`);
847
+ }
848
+
849
+ //? ── UNKNOWN MESSAGE ─────────────────────────────────────────────────────────
850
+ } else {
851
+ node.warn(
852
+ `DMX Group Node "${CFG.groupName}" — unrecognised message. ` +
853
+ `Expected: msg.dmx_trace (AUX from parent Group Node), ` +
854
+ `msg.payload.state (direct HA command), or msg.device.request (add/remove). ` +
855
+ `Message received and dropped — unrecognised format. See node documentation.`
856
+ );
857
+ }
858
+
859
+ node.done();
860
+ return [null, null, null, msg, null];