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,392 @@
1
+ <!-- ============================================================
2
+ ha-mqtt-dmx-group — DMX Group Node Editor
3
+ Package: node-red-contrib-dmx-for-ha
4
+ Author: DeSwaggy — Discord: @deswaggy
5
+ ============================================================ -->
6
+
7
+ <script type="text/javascript">
8
+
9
+ RED.nodes.registerType('ha-mqtt-dmx-group', {
10
+ category: 'DMX for HA',
11
+ color: '#9b3fd4',
12
+ icon: 'font-awesome/fa-object-group',
13
+ inputs: 1,
14
+ outputs: 1,
15
+ outputLabels: ['Link'],
16
+ inputLabels: ['Input'],
17
+ paletteLabel: 'DMX Group',
18
+
19
+ defaults: {
20
+ name: { value: '' },
21
+ config: { value: '', type: 'ha-mqtt-config', required: true },
22
+ // Group identity
23
+ groupName: { value: '' },
24
+ uid: { value: '', required: true },
25
+ uidPostfix: { value: '' },
26
+ deviceType: { value: 'Downlight' },
27
+ colorMode: { value: 'rgbw' },
28
+ // Location
29
+ area: { value: 'Area 52' },
30
+ situation: { value: 'in' },
31
+ subLocation: { value: '' },
32
+ // Options
33
+ haIcon: { value: 'mdi:lightbulb-group' },
34
+ showEffects: { value: true },
35
+ transitions: { value: true },
36
+ defaultState: { value: 'OFF' },
37
+ // Advanced
38
+ maxDepth: { value: '10' },
39
+ },
40
+
41
+ label: function () {
42
+ if (this.name) return this.name;
43
+ const id = this.uid || '?';
44
+ const postfix = this.uidPostfix || '';
45
+ return 'LG-' + id + postfix;
46
+ },
47
+
48
+ labelStyle: function () {
49
+ return this.name ? 'node_label_italic' : '';
50
+ },
51
+
52
+ oneditprepare: function () {
53
+ // Reuse area/sub-area lists from ha-mqtt-dmx if loaded,
54
+ // otherwise define inline
55
+ const areas = typeof HA_DMX_AREAS !== 'undefined' ? HA_DMX_AREAS : [
56
+ { v: 'Area 52', l: 'TBC' },
57
+ { v: 'Attic Roof', l: 'Attic Roof' },
58
+ { v: 'Backyard', l: 'Backyard' },
59
+ { v: 'Balcony', l: 'Balcony' },
60
+ { v: 'Bathroom', l: 'Bathroom' },
61
+ { v: 'Bedroom 1', l: 'Bedroom 1' },
62
+ { v: 'Bedroom 2', l: 'Bedroom 2' },
63
+ { v: 'Bedroom 3', l: 'Bedroom 3' },
64
+ { v: 'Bedroom 4', l: 'Bedroom 4' },
65
+ { v: 'Carport', l: 'Carport' },
66
+ { v: 'Cellar', l: 'Cellar' },
67
+ { v: 'Dining', l: 'Dining' },
68
+ { v: 'Driveway', l: 'Driveway' },
69
+ { v: 'Entry', l: 'Entry' },
70
+ { v: 'Garage', l: 'Garage' },
71
+ { v: 'Gym', l: 'Gym' },
72
+ { v: 'Hallway', l: 'Hallway' },
73
+ { v: 'Kitchen', l: 'Kitchen' },
74
+ { v: 'Laundry', l: 'Laundry' },
75
+ { v: 'Link Bridge (Upper)', l: 'Link Bridge Upper' },
76
+ { v: 'Link Bridge (Lower)', l: 'Link Bridge Lower' },
77
+ { v: 'Living', l: 'Living' },
78
+ { v: 'Media', l: 'Media' },
79
+ { v: 'Office', l: 'Office' },
80
+ { v: 'Pantry', l: 'Pantry' },
81
+ { v: 'Pass Over', l: 'Pass Over' },
82
+ { v: 'Pass Under', l: 'Pass Under' },
83
+ { v: 'Passageway', l: 'Passageway' },
84
+ { v: 'Pool', l: 'Pool' },
85
+ { v: 'Portico', l: 'Portico' },
86
+ { v: 'PowderRoom', l: 'PowderRoom' },
87
+ { v: 'Reading', l: 'Reading' },
88
+ { v: 'Rumpus', l: 'Rumpus' },
89
+ { v: 'Spa', l: 'Spa' },
90
+ { v: 'Stairs', l: 'Stairs' },
91
+ { v: 'Store', l: 'Store' },
92
+ { v: 'SubFloor', l: 'SubFloor' },
93
+ { v: 'Terrace', l: 'Terrace' },
94
+ ];
95
+
96
+ const subAreas = typeof HA_DMX_SUB_AREAS !== 'undefined' ? HA_DMX_SUB_AREAS : [
97
+ { v: '', l: 'None' },
98
+ { v: 'Balcony', l: 'Balcony' },
99
+ { v: 'Bed (North)', l: 'Bed North' },
100
+ { v: 'Bed (South)', l: 'Bed South' },
101
+ { v: 'Bed (East)', l: 'Bed East' },
102
+ { v: 'Bed (West)', l: 'Bed West' },
103
+ { v: 'Ceiling', l: 'Ceiling' },
104
+ { v: 'Dress', l: 'Dress' },
105
+ { v: 'Ensuite', l: 'Ensuite' },
106
+ { v: 'Exterior', l: 'Exterior' },
107
+ { v: 'Ground Floor', l: 'Ground Floor' },
108
+ { v: '1st Floor', l: '1st Floor' },
109
+ { v: 'Landing', l: 'Landing' },
110
+ { v: 'Office', l: 'Office' },
111
+ { v: 'Stairs', l: 'Stairs' },
112
+ { v: 'WIR', l: 'WIR' },
113
+ { v: '(North)', l: 'North' },
114
+ { v: '(South)', l: 'South' },
115
+ { v: '(East)', l: 'East' },
116
+ { v: '(West)', l: 'West' },
117
+ { v: 'Void', l: 'Void' },
118
+ ];
119
+
120
+ // Populate dropdowns
121
+ const node = this;
122
+
123
+ const areaEl = $('#node-input-area');
124
+ areaEl.empty();
125
+ areas.forEach(function (a) {
126
+ areaEl.append($('<option>').val(a.v).text(a.l)
127
+ .prop('selected', a.v === node.area));
128
+ });
129
+
130
+ const subEl = $('#node-input-subLocation');
131
+ subEl.empty();
132
+ subAreas.forEach(function (s) {
133
+ subEl.append($('<option>').val(s.v).text(s.l)
134
+ .prop('selected', s.v === node.subLocation));
135
+ });
136
+ },
137
+ });
138
+
139
+ </script>
140
+
141
+ <!-- ============================================================
142
+ Editor Panel Template
143
+ ============================================================ -->
144
+ <script type="text/html" data-template-name="ha-mqtt-dmx-group">
145
+
146
+ <!-- NAME -->
147
+ <div class="form-row">
148
+ <label for="node-input-name">
149
+ <i class="fa fa-tag"></i> Name
150
+ </label>
151
+ <input type="text" id="node-input-name"
152
+ placeholder="Optional — defaults to LG-992 on canvas" />
153
+ </div>
154
+
155
+ <!-- CONFIG -->
156
+ <div class="form-row">
157
+ <label for="node-input-config">
158
+ <i class="fa fa-cog"></i> Config
159
+ </label>
160
+ <input type="text" id="node-input-config" />
161
+ </div>
162
+
163
+ <hr/>
164
+
165
+ <!-- ── GROUP (* Required) ────────────────────────────────── -->
166
+ <div class="form-row">
167
+ <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
168
+ <i class="fa fa-object-group"></i> Group &nbsp;<span style="color:#e74c3c">* Required</span>
169
+ </label>
170
+ </div>
171
+
172
+ <!-- Group Name -->
173
+ <div class="form-row">
174
+ <label for="node-input-groupName">
175
+ <i class="fa fa-object-group"></i> Group Name
176
+ </label>
177
+ <input type="text" id="node-input-groupName"
178
+ placeholder="e.g. Bedroom 1 Downlights"
179
+ style="width:55%" />
180
+ <div style="margin-left:106px; margin-top:4px; color:#999; font-size:0.85em;">
181
+ Shown as the HA entity friendly name
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Group ID -->
186
+ <div class="form-row">
187
+ <label for="node-input-uid">
188
+ <i class="fa fa-tv"></i> Group ID
189
+ </label>
190
+ <span style="margin-right:4px; font-weight:bold;">LG -</span>
191
+ <input type="text" id="node-input-uid"
192
+ placeholder="992" style="width:70px"
193
+ title="Cable/plan ID number — prefix LG is fixed for groups" />
194
+ &nbsp;
195
+ <select id="node-input-uidPostfix" style="width:65px">
196
+ <option value="">(none)</option>
197
+ <option value="-A">-A</option>
198
+ <option value="-B">-B</option>
199
+ <option value="-C">-C</option>
200
+ <option value="-D">-D</option>
201
+ </select>
202
+ <span style="margin-left:8px; color:#999; font-size:0.85em;">
203
+ Plan ID — Channel (if applicable)
204
+ </span>
205
+ </div>
206
+
207
+ <!-- Device Type -->
208
+ <div class="form-row">
209
+ <label for="node-input-deviceType">
210
+ <i class="fa fa-lightbulb-o"></i> Device Type
211
+ </label>
212
+ <input type="text" id="node-input-deviceType"
213
+ placeholder="e.g. Downlight, Strip light, Panel"
214
+ style="width:55%" />
215
+ </div>
216
+
217
+ <!-- Colour Mode -->
218
+ <div class="form-row">
219
+ <label for="node-input-colorMode">
220
+ <i class="fa fa-sliders"></i> Colour Mode
221
+ </label>
222
+ <select id="node-input-colorMode" style="width:55%">
223
+ <option value="rgbw">RGBW</option>
224
+ <option value="rgbww">RGBWW</option>
225
+ <option value="rgb">RGB</option>
226
+ <option value="color_temp">Colour Temperature (CCT)</option>
227
+ <option value="brightness">Brightness only</option>
228
+ <option value="onoff">On/Off only</option>
229
+ </select>
230
+ </div>
231
+
232
+ <!-- Area -->
233
+ <div class="form-row">
234
+ <label for="node-input-area">
235
+ <i class="fa fa-map-marker"></i> Area
236
+ </label>
237
+ <select id="node-input-area" style="width:55%"></select>
238
+ </div>
239
+
240
+ <!-- Situation -->
241
+ <div class="form-row">
242
+ <label for="node-input-situation">
243
+ <i class="fa fa-compass"></i> Situation
244
+ </label>
245
+ <select id="node-input-situation" style="width:55%">
246
+ <option value="in">in</option>
247
+ <option value="above">above</option>
248
+ <option value="below">below</option>
249
+ <option value="outside">outside</option>
250
+ <option value="throughout">throughout</option>
251
+ <option value="under">under</option>
252
+ <option value="over">over</option>
253
+ </select>
254
+ </div>
255
+
256
+ <!-- Sub-Area -->
257
+ <div class="form-row">
258
+ <label for="node-input-subLocation">
259
+ <i class="fa fa-map-marker"></i> Sub-Area
260
+ </label>
261
+ <select id="node-input-subLocation" style="width:55%"></select>
262
+ </div>
263
+
264
+ <hr/>
265
+
266
+ <!-- ── OPTIONS ───────────────────────────────────────────── -->
267
+ <div class="form-row">
268
+ <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
269
+ <i class="fa fa-sliders"></i> Options
270
+ </label>
271
+ </div>
272
+
273
+ <div class="form-row">
274
+ <label for="node-input-haIcon">
275
+ <i class="fa fa-image"></i> HA Icon
276
+ </label>
277
+ <input type="text" id="node-input-haIcon"
278
+ placeholder="mdi:lightbulb-group" style="width:55%" />
279
+ </div>
280
+
281
+ <div class="form-row">
282
+ <label for="node-input-defaultState">
283
+ <i class="fa fa-toggle-off"></i> Default state
284
+ </label>
285
+ <select id="node-input-defaultState" style="width:100px">
286
+ <option value="OFF">OFF</option>
287
+ <option value="ON">ON</option>
288
+ </select>
289
+ </div>
290
+
291
+ <div class="form-row">
292
+ <label>&nbsp;</label>
293
+ <input type="checkbox" id="node-input-showEffects"
294
+ style="width:auto; margin-right:8px" />
295
+ <label for="node-input-showEffects" style="width:auto">
296
+ Show effects in HA
297
+ </label>
298
+ </div>
299
+
300
+ <div class="form-row">
301
+ <label>&nbsp;</label>
302
+ <input type="checkbox" id="node-input-transitions"
303
+ style="width:auto; margin-right:8px" />
304
+ <label for="node-input-transitions" style="width:auto">
305
+ Enable transitions
306
+ </label>
307
+ </div>
308
+
309
+ <hr/>
310
+
311
+ <!-- ── ADVANCED ──────────────────────────────────────────── -->
312
+ <div class="form-row">
313
+ <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
314
+ <i class="fa fa-wrench"></i> Advanced
315
+ </label>
316
+ </div>
317
+
318
+ <div class="form-row">
319
+ <label for="node-input-maxDepth">
320
+ <i class="fa fa-sort-numeric-asc"></i> Max group depth
321
+ </label>
322
+ <input type="number" id="node-input-maxDepth"
323
+ min="0" max="20" style="width:70px" />
324
+ <span style="margin-left:8px; color:#999; font-size:0.85em;">
325
+ 0 = disable loop detection
326
+ </span>
327
+ </div>
328
+
329
+ </script>
330
+
331
+ <!-- ============================================================
332
+ Help Panel
333
+ ============================================================ -->
334
+ <script type="text/html" data-help-name="ha-mqtt-dmx-group">
335
+ <p>
336
+ Virtual DMX group. Appears as a single light entity in Home Assistant.
337
+ Commands received from HA are forwarded via the <strong>Link</strong>
338
+ output to all downstream DMX and Relay nodes.
339
+ </p>
340
+
341
+ <h3>Setup</h3>
342
+ <ol>
343
+ <li>Select your <strong>Config</strong> node</li>
344
+ <li>Set a <strong>Group Name</strong> — shown as the HA entity friendly name</li>
345
+ <li>Set <strong>Group ID</strong> — matches the plan ID (prefix LG is fixed)</li>
346
+ <li>Wire the <strong>Link</strong> output to the input of each DMX or Relay node in the group</li>
347
+ <li>Deploy — the group entity appears in HA</li>
348
+ </ol>
349
+
350
+ <h3>Group ID</h3>
351
+ <p>
352
+ The <code>LG</code> prefix is fixed for all group nodes — it matches the
353
+ electrical plan convention where <code>L-992-A</code>, <code>L-992-B</code>
354
+ and <code>L-992-C</code> form group <code>LG-992</code>.
355
+ </p>
356
+
357
+ <h3>Link output</h3>
358
+ <p>
359
+ Wire the Link output to one or more DMX or Relay node inputs.
360
+ You can also wire it to another DMX Group Node input to create
361
+ a nested group hierarchy.
362
+ </p>
363
+
364
+ <h3>Loop detection</h3>
365
+ <p>
366
+ The node tracks the cascade path in <code>msg.dmx_trace</code> and
367
+ fires a warning if a loop is detected. Max group depth sets the
368
+ maximum number of hops before a loop is assumed. Set to 0 to disable.
369
+ </p>
370
+
371
+ <h3>Inputs</h3>
372
+ <dl class="message-properties">
373
+ <dt>device:add <span class="property-type">msg.device = "add"</span></dt>
374
+ <dd>Run MQTT discovery and forward device:add to child nodes via Link.</dd>
375
+ <dt>device:remove <span class="property-type">msg.device = "remove"</span></dt>
376
+ <dd>Remove entity from HA and forward device:remove to child nodes.</dd>
377
+ <dt>HA command <span class="property-type">msg.payload.state</span></dt>
378
+ <dd>Handled internally via MQTT — forwarded to children via Link.</dd>
379
+ <dt>Cascade <span class="property-type">msg.dmx_trace</span></dt>
380
+ <dd>Command from an upstream Group Node — forwarded deeper into hierarchy.</dd>
381
+ </dl>
382
+
383
+ <h3>Outputs</h3>
384
+ <dl class="message-properties">
385
+ <dt>Link <span class="property-type">object</span></dt>
386
+ <dd>
387
+ Forwards commands to child nodes. Message includes
388
+ <code>msg.dmx_trace</code> (source, path, depth) and
389
+ the original <code>msg.payload</code> unchanged.
390
+ </dd>
391
+ </dl>
392
+ </script>
@@ -0,0 +1,265 @@
1
+ // ============================================================
2
+ // ha-mqtt-dmx-group — DMX Group Node Runtime
3
+ // Package: node-red-contrib-dmx-for-ha
4
+ // Author: DeSwaggy — Discord: @deswaggy
5
+ //
6
+ // Virtual group node. Receives HA commands and forwards
7
+ // them to child DMX/Relay nodes via the Link output port.
8
+ // ============================================================
9
+
10
+ module.exports = function (RED) {
11
+
12
+ function HaMqttDmxGroupNode(config) {
13
+ RED.nodes.createNode(this, config);
14
+ const node = this;
15
+
16
+ // ── Config & broker ───────────────────────────────────────
17
+ const cfg = RED.nodes.getNode(config.config);
18
+ if (!cfg) { node.error('DMX Group: no config node selected'); return; }
19
+
20
+ const broker = RED.nodes.getNode(cfg.broker);
21
+ if (!broker) { node.error('DMX Group: no MQTT broker in config'); return; }
22
+
23
+ broker.register(node);
24
+
25
+ // ── Node settings ─────────────────────────────────────────
26
+ const S = {
27
+ groupName: config.groupName || '',
28
+ uid: config.uid || '',
29
+ uidPostfix: config.uidPostfix || '',
30
+ deviceType: config.deviceType || 'Downlight',
31
+ colorMode: config.colorMode || 'rgbw',
32
+ area: config.area || '',
33
+ situation: config.situation || 'in',
34
+ subLocation: config.subLocation || '',
35
+ haIcon: config.haIcon || 'mdi:lightbulb-group',
36
+ showEffects: config.showEffects !== false,
37
+ transitions: config.transitions !== false,
38
+ defaultState: config.defaultState || 'OFF',
39
+ maxDepth: parseInt(config.maxDepth) || 10,
40
+ flashShort: cfg.flashShort,
41
+ flashLong: cfg.flashLong,
42
+ diskDelay: cfg.diskDelay,
43
+ };
44
+
45
+ const groupId = `LG-${S.uid}${S.uidPostfix}`;
46
+ const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
47
+ const groupTopic = `${cfg.discoveryPrefix}/light/${groupId}`;
48
+ const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
49
+ const statTopic = `${groupTopic}/${cfg.stateTopic}`;
50
+ const cmdTopic = `${groupTopic}/${cfg.commandTopic}`;
51
+
52
+ // ── Context helpers ───────────────────────────────────────
53
+ function ctxGet(key, store) {
54
+ try { return node.context().get(key, store); }
55
+ catch(e) { return node.context().get(key); }
56
+ }
57
+ function ctxSet(key, val, store) {
58
+ try { node.context().set(key, val, store); }
59
+ catch(e) { node.context().set(key, val); }
60
+ }
61
+ function recall(ramKey, diskKey, fallback) {
62
+ return ctxGet(ramKey) || ctxGet(diskKey, 'disk') || fallback;
63
+ }
64
+
65
+ // ── Disk save timer ───────────────────────────────────────
66
+ let diskTimer = null;
67
+ function startDiskSave(onComplete) {
68
+ if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
69
+ diskTimer = setTimeout(() => { diskTimer = null; onComplete(); }, S.diskDelay * 1000);
70
+ }
71
+
72
+ // ── MQTT helpers ──────────────────────────────────────────
73
+ function pub(topic, payload, retain) {
74
+ broker.publish({
75
+ topic,
76
+ payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
77
+ qos: cfg.qos,
78
+ retain: retain !== undefined ? retain : cfg.retain,
79
+ });
80
+ }
81
+
82
+ function pubState(payload) {
83
+ pub(statTopic, JSON.stringify(payload), false);
84
+ }
85
+
86
+ function setStatus(fill, shape, text) {
87
+ node.status({ fill, shape, text });
88
+ }
89
+
90
+ // ── Loop detection ────────────────────────────────────────
91
+ function isLoop(trace) {
92
+ if (!trace || !trace.path) return false;
93
+ if (S.maxDepth > 0 && trace.depth >= S.maxDepth) return true;
94
+ return trace.path.includes(groupId);
95
+ }
96
+
97
+ function buildTrace(incomingTrace) {
98
+ if (incomingTrace) {
99
+ return {
100
+ source: groupId,
101
+ path: [...incomingTrace.path, groupId],
102
+ depth: incomingTrace.depth + 1,
103
+ };
104
+ }
105
+ return { source: groupId, path: [groupId], depth: 1 };
106
+ }
107
+
108
+ // ── Forward to children via Link output ───────────────────
109
+ function forwardToChildren(payload, incomingTrace) {
110
+ if (isLoop(incomingTrace)) {
111
+ node.warn(`${groupId} — loop detected! path:[${incomingTrace && incomingTrace.path.join(' → ')}]`);
112
+ setStatus('red', 'dot', `${groupId} LOOP DETECTED`);
113
+ return;
114
+ }
115
+ const trace = buildTrace(incomingTrace);
116
+ node.send([{ dmx_trace: trace, payload }]);
117
+ }
118
+
119
+ // ── Effects ───────────────────────────────────────────────
120
+ const EFFECT_LIST = [
121
+ 'none', 'flash_short', 'flash_long', 'strobe',
122
+ 'rainbow', 'rainbow_rgbw', 'fire', 'flicker',
123
+ 'twinkle', 'color_chase', 'scan', 'random',
124
+ 'police', 'christmas', 'halloween', 'calaveras',
125
+ 'party', 'fireworks',
126
+ ];
127
+
128
+ // ── Group state save ──────────────────────────────────────
129
+ function saveGroupState(payload) {
130
+ ctxSet('state', payload.state);
131
+ ctxSet('brightness', payload.brightness);
132
+ ctxSet('color', payload.color);
133
+ ctxSet('transition', payload.transition);
134
+ startDiskSave(() => {
135
+ ctxSet('state', payload.state, 'disk');
136
+ ctxSet('brightness', payload.brightness, 'disk');
137
+ ctxSet('color', payload.color, 'disk');
138
+ node.log(`${groupId} disk saved — state:${payload.state}`);
139
+ });
140
+ }
141
+
142
+ // ── Device add ────────────────────────────────────────────
143
+ function handleDeviceAdd(incomingTrace) {
144
+ if (!ctxGet('state') && !ctxGet('state', 'disk')) {
145
+ ctxSet('state', S.defaultState);
146
+ }
147
+
148
+ const discovery = {
149
+ unique_id: `group(${groupId})`,
150
+ schema: 'json',
151
+ object_id: objectId,
152
+ optimistic: false,
153
+ enabled_by_default: cfg.enabledDefault,
154
+ icon: S.haIcon,
155
+ supported_color_modes: [S.colorMode],
156
+ brightness: true,
157
+ transition: S.transitions,
158
+ effect: S.showEffects,
159
+ effect_list: S.showEffects ? EFFECT_LIST : [],
160
+ flash_time_short: S.flashShort,
161
+ flash_time_long: S.flashLong,
162
+ min_mireds: 153,
163
+ max_mireds: 500,
164
+ stat_t: statTopic,
165
+ cmd_t: cmdTopic,
166
+ name: S.groupName || `${S.deviceType} Group ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
167
+ device: {
168
+ identifiers: `light-${groupId}`,
169
+ name: `(${groupId}) - ${S.deviceType} Group ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
170
+ model: `${S.colorMode} ${S.deviceType} Group located ${S.situation} the ${cfg.zone} - ${S.area}`,
171
+ model_id: `referenced on plan as: (${groupId}`,
172
+ suggested_area: `${cfg.zone} ${S.area} ${S.subLocation}`,
173
+ hw_version: 'Virtual fixture — exists as a DMX Group Node in Node-RED',
174
+ sw_version: 'ha-mqtt-dmx-group: 0.1.0',
175
+ manufacturer: 'DeSwaggy — Discord: @deswaggy',
176
+ },
177
+ };
178
+
179
+ pub(cfgTopic, discovery, true);
180
+
181
+ broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
182
+ let payload;
183
+ try { payload = JSON.parse(rawPayload.toString()); }
184
+ catch(e) { node.warn(`${groupId} — failed to parse HA command`); return; }
185
+
186
+ saveGroupState(payload);
187
+ pubState({ state: payload.state, color_mode: S.colorMode, brightness: payload.brightness });
188
+ forwardToChildren(payload, null);
189
+ }, node.id);
190
+
191
+ setStatus('green', 'ring', `${groupId} discovery sent`);
192
+ node.log(`Group device added: "${S.groupName || groupId}"`);
193
+
194
+ // Children receive device:add directly from the SYSTEM node
195
+ // No forwarding needed via Link
196
+
197
+ // Recovery
198
+ setTimeout(() => {
199
+ const state = recall('state', 'state_disk', S.defaultState);
200
+ const brightness = recall('brightness', 'brightness_disk', 255);
201
+ const color = recall('color', 'color_disk', null);
202
+ pubState({ state, color_mode: S.colorMode, brightness, color });
203
+ forwardToChildren({ state, brightness, color }, null);
204
+ setStatus('yellow', 'ring', `${groupId} ready`);
205
+ node.log(`${groupId} recovery — state:${state}`);
206
+ }, 2000);
207
+ }
208
+
209
+ // ── Device remove ─────────────────────────────────────────
210
+ function handleDeviceRemove(incomingTrace) {
211
+ if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
212
+ ctxSet('state', null); ctxSet('state', null, 'disk');
213
+ pub(cfgTopic, '', true);
214
+ broker.unsubscribe(cmdTopic, node.id);
215
+ forwardToChildren({ device: 'remove' }, incomingTrace);
216
+ setStatus('red', 'ring', `${groupId} removed`);
217
+ node.log(`${groupId} device removed`);
218
+ }
219
+
220
+ // ── NR input entry point ──────────────────────────────────
221
+ node.on('input', function (msg, send, done) {
222
+ // AUX cascade from upstream Group Node
223
+ if (msg.dmx_trace != null) {
224
+ if (isLoop(msg.dmx_trace)) {
225
+ node.warn(`${groupId} — loop detected`);
226
+ done(); return;
227
+ }
228
+ // Forward payload deeper
229
+ if (msg.payload) {
230
+ saveGroupState(msg.payload);
231
+ pubState({ state: msg.payload.state, color_mode: S.colorMode, brightness: msg.payload.brightness });
232
+ forwardToChildren(msg.payload, msg.dmx_trace);
233
+ }
234
+ } else {
235
+ const devReq = typeof msg.device === 'string'
236
+ ? msg.device
237
+ : (msg.device && msg.device.request);
238
+
239
+ if (devReq) {
240
+ switch (devReq) {
241
+ case 'add': handleDeviceAdd(null); break;
242
+ case 'remove': handleDeviceRemove(null); break;
243
+ default: node.warn(`${groupId} — unknown device.request: "${devReq}"`);
244
+ }
245
+ } else if (msg.payload && msg.payload.state != null) {
246
+ saveGroupState(msg.payload);
247
+ pubState({ state: msg.payload.state, color_mode: S.colorMode, brightness: msg.payload.brightness });
248
+ forwardToChildren(msg.payload, null);
249
+ } else {
250
+ node.warn(`${groupId} — unrecognised message received and dropped. See node documentation.`);
251
+ }
252
+ }
253
+ done();
254
+ });
255
+
256
+ // ── Cleanup ───────────────────────────────────────────────
257
+ node.on('close', function (done) {
258
+ if (diskTimer) clearTimeout(diskTimer);
259
+ broker.unsubscribe(cmdTopic, node.id);
260
+ broker.deregister(node, done);
261
+ });
262
+ }
263
+
264
+ RED.nodes.registerType('ha-mqtt-dmx-group', HaMqttDmxGroupNode);
265
+ };