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.
- package/README.md +282 -0
- package/docs/config_node_spec.md +236 -0
- package/docs/dmx_node_env_reference.md +341 -0
- package/docs/master_todo.md +428 -0
- package/docs/node_contracts.md +278 -0
- package/docs/nr_subflow_gotchas.md +258 -0
- package/nodes/ha-mqtt-button.html +326 -0
- package/nodes/ha-mqtt-button.js +158 -0
- package/nodes/ha-mqtt-config.html +233 -0
- package/nodes/ha-mqtt-config.js +81 -0
- package/nodes/ha-mqtt-dmx-group.html +392 -0
- package/nodes/ha-mqtt-dmx-group.js +265 -0
- package/nodes/ha-mqtt-dmx.html +547 -0
- package/nodes/ha-mqtt-dmx.js +537 -0
- package/nodes/ha-mqtt-pir.html +343 -0
- package/nodes/ha-mqtt-pir.js +183 -0
- package/nodes/ha-mqtt-relay.html +326 -0
- package/nodes/ha-mqtt-relay.js +289 -0
- package/package.json +39 -0
- package/subflow/README.md +35 -0
- package/subflow/button_node_v5.0.3.js +324 -0
- package/subflow/dmx_group_node_v0.3.8.js +860 -0
- package/subflow/dmx_node_v0.5.9.js +1994 -0
- package/subflow/pir_node_v1.0.3.js +365 -0
- package/subflow/relay_node_v4.0.2.js +553 -0
- package/subflow/subflow_definitions.json +6154 -0
|
@@ -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 <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
|
+
|
|
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> </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> </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
|
+
};
|