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,326 @@
|
|
|
1
|
+
<!-- ============================================================
|
|
2
|
+
ha-mqtt-relay — Relay 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-relay', {
|
|
10
|
+
category: 'DMX for HA',
|
|
11
|
+
color: '#e67e22',
|
|
12
|
+
icon: 'font-awesome/fa-toggle-on',
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 0,
|
|
15
|
+
inputLabels: ['Input'],
|
|
16
|
+
paletteLabel: 'Relay',
|
|
17
|
+
|
|
18
|
+
defaults: {
|
|
19
|
+
name: { value: '' },
|
|
20
|
+
config: { value: '', type: 'ha-mqtt-config', required: true },
|
|
21
|
+
// Fixture identity
|
|
22
|
+
uidPrefix: { value: 'P', required: true },
|
|
23
|
+
uid: { value: '', required: true },
|
|
24
|
+
uidPostfix: { value: '' },
|
|
25
|
+
deviceType: { value: '' },
|
|
26
|
+
// Location
|
|
27
|
+
area: { value: 'Area 52' },
|
|
28
|
+
situation: { value: 'in' },
|
|
29
|
+
subLocation: { value: '' },
|
|
30
|
+
// Controller
|
|
31
|
+
controllerNum: { value: '1', required: true },
|
|
32
|
+
relayNum: { value: '', required: true },
|
|
33
|
+
mqttSegment: { value: 'relay' },
|
|
34
|
+
// Options
|
|
35
|
+
haIcon: { value: 'mdi:toggle-switch' },
|
|
36
|
+
showEffects: { value: false },
|
|
37
|
+
defaultState: { value: 'OFF' },
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
label: function () {
|
|
41
|
+
if (this.name) return this.name;
|
|
42
|
+
const prefix = this.uidPrefix || 'P';
|
|
43
|
+
const id = this.uid || '?';
|
|
44
|
+
const postfix = this.uidPostfix || '';
|
|
45
|
+
return prefix + '-' + id + postfix;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
labelStyle: function () {
|
|
49
|
+
return this.name ? 'node_label_italic' : '';
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
oneditprepare: function () {
|
|
53
|
+
const node = this;
|
|
54
|
+
const areas = [
|
|
55
|
+
{ v: 'Area 52', l: 'TBC' },
|
|
56
|
+
{ v: 'Attic Roof', l: 'Attic Roof' },
|
|
57
|
+
{ v: 'Backyard', l: 'Backyard' },
|
|
58
|
+
{ v: 'Balcony', l: 'Balcony' },
|
|
59
|
+
{ v: 'Bathroom', l: 'Bathroom' },
|
|
60
|
+
{ v: 'Bedroom 1', l: 'Bedroom 1' },
|
|
61
|
+
{ v: 'Bedroom 2', l: 'Bedroom 2' },
|
|
62
|
+
{ v: 'Bedroom 3', l: 'Bedroom 3' },
|
|
63
|
+
{ v: 'Bedroom 4', l: 'Bedroom 4' },
|
|
64
|
+
{ v: 'Carport', l: 'Carport' },
|
|
65
|
+
{ v: 'Cellar', l: 'Cellar' },
|
|
66
|
+
{ v: 'Dining', l: 'Dining' },
|
|
67
|
+
{ v: 'Driveway', l: 'Driveway' },
|
|
68
|
+
{ v: 'Entry', l: 'Entry' },
|
|
69
|
+
{ v: 'Garage', l: 'Garage' },
|
|
70
|
+
{ v: 'Gym', l: 'Gym' },
|
|
71
|
+
{ v: 'Hallway', l: 'Hallway' },
|
|
72
|
+
{ v: 'Kitchen', l: 'Kitchen' },
|
|
73
|
+
{ v: 'Laundry', l: 'Laundry' },
|
|
74
|
+
{ v: 'Link Bridge (Upper)', l: 'Link Bridge Upper' },
|
|
75
|
+
{ v: 'Link Bridge (Lower)', l: 'Link Bridge Lower' },
|
|
76
|
+
{ v: 'Living', l: 'Living' },
|
|
77
|
+
{ v: 'Media', l: 'Media' },
|
|
78
|
+
{ v: 'Office', l: 'Office' },
|
|
79
|
+
{ v: 'Pantry', l: 'Pantry' },
|
|
80
|
+
{ v: 'Pass Over', l: 'Pass Over' },
|
|
81
|
+
{ v: 'Pass Under', l: 'Pass Under' },
|
|
82
|
+
{ v: 'Passageway', l: 'Passageway' },
|
|
83
|
+
{ v: 'Pool', l: 'Pool' },
|
|
84
|
+
{ v: 'Portico', l: 'Portico' },
|
|
85
|
+
{ v: 'PowderRoom', l: 'PowderRoom' },
|
|
86
|
+
{ v: 'Reading', l: 'Reading' },
|
|
87
|
+
{ v: 'Rumpus', l: 'Rumpus' },
|
|
88
|
+
{ v: 'Spa', l: 'Spa' },
|
|
89
|
+
{ v: 'Stairs', l: 'Stairs' },
|
|
90
|
+
{ v: 'Store', l: 'Store' },
|
|
91
|
+
{ v: 'SubFloor', l: 'SubFloor' },
|
|
92
|
+
{ v: 'Terrace', l: 'Terrace' },
|
|
93
|
+
];
|
|
94
|
+
const subAreas = [
|
|
95
|
+
{ v: '', l: 'None' },
|
|
96
|
+
{ v: 'Balcony', l: 'Balcony' },
|
|
97
|
+
{ v: 'Bed (North)', l: 'Bed North' },
|
|
98
|
+
{ v: 'Bed (South)', l: 'Bed South' },
|
|
99
|
+
{ v: 'Bed (East)', l: 'Bed East' },
|
|
100
|
+
{ v: 'Bed (West)', l: 'Bed West' },
|
|
101
|
+
{ v: 'Ceiling', l: 'Ceiling' },
|
|
102
|
+
{ v: 'Dress', l: 'Dress' },
|
|
103
|
+
{ v: 'Ensuite', l: 'Ensuite' },
|
|
104
|
+
{ v: 'Exterior', l: 'Exterior' },
|
|
105
|
+
{ v: 'Ground Floor', l: 'Ground Floor' },
|
|
106
|
+
{ v: '1st Floor', l: '1st Floor' },
|
|
107
|
+
{ v: 'Landing', l: 'Landing' },
|
|
108
|
+
{ v: 'Office', l: 'Office' },
|
|
109
|
+
{ v: 'Stairs', l: 'Stairs' },
|
|
110
|
+
{ v: 'WIR', l: 'WIR' },
|
|
111
|
+
{ v: '(North)', l: 'North' },
|
|
112
|
+
{ v: '(South)', l: 'South' },
|
|
113
|
+
{ v: '(East)', l: 'East' },
|
|
114
|
+
{ v: '(West)', l: 'West' },
|
|
115
|
+
{ v: 'Void', l: 'Void' },
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
const areaEl = $('#node-input-area');
|
|
119
|
+
areaEl.empty();
|
|
120
|
+
areas.forEach(function (a) {
|
|
121
|
+
areaEl.append($('<option>').val(a.v).text(a.l)
|
|
122
|
+
.prop('selected', a.v === node.area));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const subEl = $('#node-input-subLocation');
|
|
126
|
+
subEl.empty();
|
|
127
|
+
subAreas.forEach(function (s) {
|
|
128
|
+
subEl.append($('<option>').val(s.v).text(s.l)
|
|
129
|
+
.prop('selected', s.v === node.subLocation));
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<script type="text/html" data-template-name="ha-mqtt-relay">
|
|
137
|
+
|
|
138
|
+
<div class="form-row">
|
|
139
|
+
<label for="node-input-name">
|
|
140
|
+
<i class="fa fa-tag"></i> Name
|
|
141
|
+
</label>
|
|
142
|
+
<input type="text" id="node-input-name"
|
|
143
|
+
placeholder="Optional — defaults to fixture ID e.g. P-51" />
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div class="form-row">
|
|
147
|
+
<label for="node-input-config">
|
|
148
|
+
<i class="fa fa-cog"></i> Config
|
|
149
|
+
</label>
|
|
150
|
+
<input type="text" id="node-input-config" />
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<hr/>
|
|
154
|
+
|
|
155
|
+
<!-- ── RELAY (* Required) ────────────────────────────────── -->
|
|
156
|
+
<div class="form-row">
|
|
157
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
158
|
+
<i class="fa fa-toggle-on"></i> Relay <span style="color:#e74c3c">* Required</span>
|
|
159
|
+
</label>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="form-row">
|
|
163
|
+
<label for="node-input-deviceType">
|
|
164
|
+
<i class="fa fa-lightbulb-o"></i> Device Type
|
|
165
|
+
</label>
|
|
166
|
+
<input type="text" id="node-input-deviceType"
|
|
167
|
+
placeholder="e.g. Exhaust Fan, Pump, Socket"
|
|
168
|
+
style="width:55%" />
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div class="form-row">
|
|
172
|
+
<label for="node-input-uidPrefix">
|
|
173
|
+
<i class="fa fa-tv"></i> Fixture ID
|
|
174
|
+
</label>
|
|
175
|
+
<select id="node-input-uidPrefix" style="width:55px">
|
|
176
|
+
<option value="P">P</option>
|
|
177
|
+
<option value="L">L</option>
|
|
178
|
+
</select>
|
|
179
|
+
-
|
|
180
|
+
<input type="text" id="node-input-uid"
|
|
181
|
+
placeholder="51" style="width:70px" />
|
|
182
|
+
|
|
183
|
+
<select id="node-input-uidPostfix" style="width:65px">
|
|
184
|
+
<option value="">(none)</option>
|
|
185
|
+
<option value="-A">-A</option>
|
|
186
|
+
<option value="-B">-B</option>
|
|
187
|
+
<option value="-C">-C</option>
|
|
188
|
+
<option value="-D">-D</option>
|
|
189
|
+
</select>
|
|
190
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
191
|
+
Prefix — Plan ID — Channel
|
|
192
|
+
</span>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="form-row">
|
|
196
|
+
<label for="node-input-area">
|
|
197
|
+
<i class="fa fa-map-marker"></i> Area
|
|
198
|
+
</label>
|
|
199
|
+
<select id="node-input-area" style="width:55%"></select>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
<div class="form-row">
|
|
203
|
+
<label for="node-input-situation">
|
|
204
|
+
<i class="fa fa-compass"></i> Situation
|
|
205
|
+
</label>
|
|
206
|
+
<select id="node-input-situation" style="width:55%">
|
|
207
|
+
<option value="in">in</option>
|
|
208
|
+
<option value="above">above</option>
|
|
209
|
+
<option value="below">below</option>
|
|
210
|
+
<option value="outside">outside</option>
|
|
211
|
+
<option value="throughout">throughout</option>
|
|
212
|
+
<option value="under">under</option>
|
|
213
|
+
<option value="over">over</option>
|
|
214
|
+
</select>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div class="form-row">
|
|
218
|
+
<label for="node-input-subLocation">
|
|
219
|
+
<i class="fa fa-map-marker"></i> Sub-Area
|
|
220
|
+
</label>
|
|
221
|
+
<select id="node-input-subLocation" style="width:55%"></select>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<hr/>
|
|
225
|
+
|
|
226
|
+
<!-- ── CONTROLLER ────────────────────────────────────────── -->
|
|
227
|
+
<div class="form-row">
|
|
228
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
229
|
+
<i class="fa fa-sort-numeric-asc"></i> Controller
|
|
230
|
+
</label>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div class="form-row">
|
|
234
|
+
<label for="node-input-controllerNum">
|
|
235
|
+
<i class="fa fa-sort-numeric-asc"></i> Controller
|
|
236
|
+
</label>
|
|
237
|
+
<input type="number" id="node-input-controllerNum"
|
|
238
|
+
min="1" style="width:70px" />
|
|
239
|
+
|
|
240
|
+
<label for="node-input-relayNum" style="width:auto">
|
|
241
|
+
Relay number
|
|
242
|
+
</label>
|
|
243
|
+
<input type="number" id="node-input-relayNum"
|
|
244
|
+
min="1" style="width:70px" />
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div class="form-row">
|
|
248
|
+
<label for="node-input-mqttSegment">
|
|
249
|
+
<i class="fa fa-exchange"></i> MQTT segment
|
|
250
|
+
</label>
|
|
251
|
+
<input type="text" id="node-input-mqttSegment"
|
|
252
|
+
style="width:120px" placeholder="relay" />
|
|
253
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
254
|
+
Topic: {siteId}/{zone}/{controller}/<strong>relay</strong>/{relayNum}
|
|
255
|
+
</span>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<hr/>
|
|
259
|
+
|
|
260
|
+
<!-- ── OPTIONS ───────────────────────────────────────────── -->
|
|
261
|
+
<div class="form-row">
|
|
262
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
263
|
+
<i class="fa fa-sliders"></i> Options
|
|
264
|
+
</label>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div class="form-row">
|
|
268
|
+
<label for="node-input-haIcon">
|
|
269
|
+
<i class="fa fa-image"></i> HA Icon
|
|
270
|
+
</label>
|
|
271
|
+
<input type="text" id="node-input-haIcon"
|
|
272
|
+
placeholder="mdi:toggle-switch" style="width:55%" />
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<div class="form-row">
|
|
276
|
+
<label for="node-input-defaultState">
|
|
277
|
+
<i class="fa fa-toggle-off"></i> Default state
|
|
278
|
+
</label>
|
|
279
|
+
<select id="node-input-defaultState" style="width:100px">
|
|
280
|
+
<option value="OFF">OFF</option>
|
|
281
|
+
<option value="ON">ON</option>
|
|
282
|
+
</select>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div class="form-row">
|
|
286
|
+
<label> </label>
|
|
287
|
+
<input type="checkbox" id="node-input-showEffects"
|
|
288
|
+
style="width:auto; margin-right:8px" />
|
|
289
|
+
<label for="node-input-showEffects" style="width:auto">
|
|
290
|
+
Show effects in HA
|
|
291
|
+
</label>
|
|
292
|
+
</div>
|
|
293
|
+
|
|
294
|
+
</script>
|
|
295
|
+
|
|
296
|
+
<script type="text/html" data-help-name="ha-mqtt-relay">
|
|
297
|
+
<p>
|
|
298
|
+
Controls a 230V relay via Home Assistant and MQTT.
|
|
299
|
+
Publishes integer <code>1</code> (ON) or <code>0</code> (OFF) to
|
|
300
|
+
the relay controller. Not related to DMX — entirely separate control path.
|
|
301
|
+
</p>
|
|
302
|
+
|
|
303
|
+
<h3>Setup</h3>
|
|
304
|
+
<ol>
|
|
305
|
+
<li>Select your <strong>Config</strong> node</li>
|
|
306
|
+
<li>Set <strong>Fixture ID</strong> — Prefix (P for power), Plan ID, optional channel</li>
|
|
307
|
+
<li>Set <strong>Controller</strong> and <strong>Relay number</strong></li>
|
|
308
|
+
<li>Deploy — relay entity appears in HA</li>
|
|
309
|
+
</ol>
|
|
310
|
+
|
|
311
|
+
<h3>Relay payload</h3>
|
|
312
|
+
<p>
|
|
313
|
+
Publishes integer <code>1</code> or <code>0</code> — not a string,
|
|
314
|
+
not JSON. Most relay controller firmware expects a numeric payload.
|
|
315
|
+
</p>
|
|
316
|
+
|
|
317
|
+
<h3>MQTT topic</h3>
|
|
318
|
+
<pre>{siteId}/{zone}/{controller}/relay/{relayNum}</pre>
|
|
319
|
+
|
|
320
|
+
<h3>Effects</h3>
|
|
321
|
+
<p>
|
|
322
|
+
Flash short, flash long, and strobe are available.
|
|
323
|
+
Strobe enforces a 500ms minimum interval to protect
|
|
324
|
+
mechanical relay contacts. SSRs do not have this limitation.
|
|
325
|
+
</p>
|
|
326
|
+
</script>
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// ha-mqtt-relay — 230V Relay Node Runtime
|
|
3
|
+
// Package: node-red-contrib-dmx-for-ha
|
|
4
|
+
// Author: DeSwaggy — Discord: @deswaggy
|
|
5
|
+
//
|
|
6
|
+
// NOT related to DMX. Entirely separate control path.
|
|
7
|
+
// Publishes integer 1 (ON) or 0 (OFF) to relay controller.
|
|
8
|
+
// ============================================================
|
|
9
|
+
|
|
10
|
+
module.exports = function (RED) {
|
|
11
|
+
|
|
12
|
+
function HaMqttRelayNode(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('Relay: no config node selected'); return; }
|
|
19
|
+
|
|
20
|
+
const broker = RED.nodes.getNode(cfg.broker);
|
|
21
|
+
if (!broker) { node.error('Relay: no MQTT broker in config'); return; }
|
|
22
|
+
|
|
23
|
+
broker.register(node);
|
|
24
|
+
|
|
25
|
+
// ── Node settings ─────────────────────────────────────────
|
|
26
|
+
const S = {
|
|
27
|
+
uidPrefix: config.uidPrefix || 'P',
|
|
28
|
+
uid: config.uid || '',
|
|
29
|
+
uidPostfix: config.uidPostfix || '',
|
|
30
|
+
deviceType: config.deviceType || '',
|
|
31
|
+
area: config.area || '',
|
|
32
|
+
situation: config.situation || 'in',
|
|
33
|
+
subLocation: config.subLocation || '',
|
|
34
|
+
controllerNum: config.controllerNum || '1',
|
|
35
|
+
relayNum: config.relayNum || '',
|
|
36
|
+
mqttSegment: config.mqttSegment || 'relay',
|
|
37
|
+
haIcon: config.haIcon || 'mdi:toggle-switch',
|
|
38
|
+
showEffects: config.showEffects !== false,
|
|
39
|
+
defaultState: config.defaultState || 'OFF',
|
|
40
|
+
flashShort: cfg.flashShort,
|
|
41
|
+
flashLong: cfg.flashLong,
|
|
42
|
+
diskDelay: cfg.diskDelay,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
|
|
46
|
+
const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
47
|
+
const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
|
|
48
|
+
const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
|
|
49
|
+
const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
|
|
50
|
+
const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
|
|
51
|
+
const relayTopic = `${cfg.siteId}/${cfg.zone}/${S.controllerNum}/${S.mqttSegment}/${S.relayNum}`;
|
|
52
|
+
|
|
53
|
+
// ── Context helpers ───────────────────────────────────────
|
|
54
|
+
function ctxGet(key, store) {
|
|
55
|
+
try { return node.context().get(key, store); }
|
|
56
|
+
catch(e) { return node.context().get(key); }
|
|
57
|
+
}
|
|
58
|
+
function ctxSet(key, val, store) {
|
|
59
|
+
try { node.context().set(key, val, store); }
|
|
60
|
+
catch(e) { node.context().set(key, val); }
|
|
61
|
+
}
|
|
62
|
+
function recall(ramKey, diskKey, fallback) {
|
|
63
|
+
return ctxGet(ramKey) || ctxGet(diskKey, 'disk') || fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Disk save timer ───────────────────────────────────────
|
|
67
|
+
let diskTimer = null;
|
|
68
|
+
function startDiskSave(onComplete) {
|
|
69
|
+
if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
|
|
70
|
+
diskTimer = setTimeout(() => {
|
|
71
|
+
diskTimer = null;
|
|
72
|
+
onComplete();
|
|
73
|
+
}, S.diskDelay * 1000);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── MQTT helpers ──────────────────────────────────────────
|
|
77
|
+
function pub(topic, payload, retain) {
|
|
78
|
+
broker.publish({
|
|
79
|
+
topic,
|
|
80
|
+
payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
|
|
81
|
+
qos: cfg.qos,
|
|
82
|
+
retain: retain !== undefined ? retain : cfg.retain,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pubRelay(value) {
|
|
87
|
+
// Relay controller expects integer payload
|
|
88
|
+
broker.publish({ topic: relayTopic, payload: String(value), qos: cfg.qos, retain: false });
|
|
89
|
+
node.log(`${fixtureId} relay — topic:${relayTopic} payload:${value}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pubState(state) {
|
|
93
|
+
pub(statTopic, JSON.stringify({ state, color_mode: 'onoff' }), false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function setStatus(fill, shape, text) {
|
|
97
|
+
node.status({ fill, shape, text });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Effects ───────────────────────────────────────────────
|
|
101
|
+
let effectTimer = null;
|
|
102
|
+
|
|
103
|
+
function stopEffect() {
|
|
104
|
+
if (effectTimer) {
|
|
105
|
+
clearTimeout(effectTimer);
|
|
106
|
+
clearInterval(effectTimer);
|
|
107
|
+
effectTimer = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function restoreAfterEffect() {
|
|
112
|
+
const state = recall('state', 'state_disk', S.defaultState);
|
|
113
|
+
pubRelay(state === 'ON' ? 1 : 0);
|
|
114
|
+
pubState(state);
|
|
115
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function effectPulse(durationSecs) {
|
|
119
|
+
stopEffect();
|
|
120
|
+
pubRelay(1); pubState('ON');
|
|
121
|
+
setStatus('blue', 'dot', `${fixtureId} flash`);
|
|
122
|
+
effectTimer = setTimeout(() => {
|
|
123
|
+
effectTimer = null;
|
|
124
|
+
restoreAfterEffect();
|
|
125
|
+
}, durationSecs * 1000);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function effectStrobe() {
|
|
129
|
+
stopEffect();
|
|
130
|
+
setStatus('blue', 'dot', `${fixtureId} strobe`);
|
|
131
|
+
let on = false;
|
|
132
|
+
effectTimer = setInterval(() => {
|
|
133
|
+
on = !on;
|
|
134
|
+
pubRelay(on ? 1 : 0);
|
|
135
|
+
}, 500); // 500ms minimum for mechanical relay safety
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const EFFECTS = {
|
|
139
|
+
flash_short: () => effectPulse(S.flashShort),
|
|
140
|
+
flash_long: () => effectPulse(S.flashLong),
|
|
141
|
+
strobe: () => effectStrobe(),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// ── State handlers ────────────────────────────────────────
|
|
145
|
+
function handleON() {
|
|
146
|
+
stopEffect();
|
|
147
|
+
pubRelay(1);
|
|
148
|
+
pubState('ON');
|
|
149
|
+
ctxSet('state', 'ON');
|
|
150
|
+
startDiskSave(() => {
|
|
151
|
+
ctxSet('state', 'ON', 'disk');
|
|
152
|
+
node.log(`${fixtureId} disk saved — state:ON`);
|
|
153
|
+
});
|
|
154
|
+
setStatus('green', 'dot', `${fixtureId} ON`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleOFF() {
|
|
158
|
+
stopEffect();
|
|
159
|
+
pubRelay(0);
|
|
160
|
+
pubState('OFF');
|
|
161
|
+
ctxSet('state', 'OFF');
|
|
162
|
+
startDiskSave(() => {
|
|
163
|
+
ctxSet('state', 'OFF', 'disk');
|
|
164
|
+
node.log(`${fixtureId} disk saved — state:OFF`);
|
|
165
|
+
});
|
|
166
|
+
setStatus('grey', 'ring', `${fixtureId} OFF`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Device add ────────────────────────────────────────────
|
|
170
|
+
function handleDeviceAdd() {
|
|
171
|
+
// Init default memory if first run
|
|
172
|
+
if (!ctxGet('state') && !ctxGet('state', 'disk')) {
|
|
173
|
+
ctxSet('state', S.defaultState);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const discovery = {
|
|
177
|
+
unique_id: `${S.deviceType}(${fixtureId})`,
|
|
178
|
+
schema: 'json',
|
|
179
|
+
object_id: objectId,
|
|
180
|
+
name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
181
|
+
cmd_t: cmdTopic,
|
|
182
|
+
stat_t: statTopic,
|
|
183
|
+
optimistic: false,
|
|
184
|
+
enabled_by_default: cfg.enabledDefault,
|
|
185
|
+
icon: S.haIcon,
|
|
186
|
+
supported_color_modes: ['onoff'],
|
|
187
|
+
brightness: false,
|
|
188
|
+
effect: S.showEffects,
|
|
189
|
+
effect_list: S.showEffects ? Object.keys(EFFECTS) : [],
|
|
190
|
+
flash_time_short: S.flashShort,
|
|
191
|
+
flash_time_long: S.flashLong,
|
|
192
|
+
device: {
|
|
193
|
+
identifiers: `light-${fixtureId}`,
|
|
194
|
+
name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
|
|
195
|
+
model: `${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
|
|
196
|
+
model_id: `referenced on plan as: (${fixtureId}`,
|
|
197
|
+
suggested_area: `${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
198
|
+
hw_version: `Relay Controller in ${cfg.zone}. Topic: ${relayTopic}`,
|
|
199
|
+
serial_number: fixtureId,
|
|
200
|
+
sw_version: 'ha-mqtt-relay: 0.1.0',
|
|
201
|
+
manufacturer: 'DeSwaggy — Discord: @deswaggy',
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
pub(cfgTopic, discovery, true);
|
|
206
|
+
|
|
207
|
+
broker.subscribe(cmdTopic, cfg.qos, function (topic, payload) {
|
|
208
|
+
let msg;
|
|
209
|
+
try { msg = JSON.parse(payload.toString()); }
|
|
210
|
+
catch(e) { node.warn(`${fixtureId} — failed to parse HA command`); return; }
|
|
211
|
+
|
|
212
|
+
if (msg.effect && S.showEffects && EFFECTS[msg.effect]) {
|
|
213
|
+
EFFECTS[msg.effect]();
|
|
214
|
+
} else if (msg.state === 'ON') {
|
|
215
|
+
handleON();
|
|
216
|
+
} else if (msg.state === 'OFF') {
|
|
217
|
+
handleOFF();
|
|
218
|
+
}
|
|
219
|
+
}, node.id);
|
|
220
|
+
|
|
221
|
+
setStatus('green', 'ring', `${fixtureId} discovery sent`);
|
|
222
|
+
node.log(`${fixtureId} device added`);
|
|
223
|
+
|
|
224
|
+
// Recovery — re-assert state after deploy
|
|
225
|
+
setTimeout(() => {
|
|
226
|
+
const state = recall('state', 'state_disk', S.defaultState);
|
|
227
|
+
pubRelay(state === 'ON' ? 1 : 0);
|
|
228
|
+
pubState(state);
|
|
229
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
|
|
230
|
+
node.log(`${fixtureId} recovery — state:${state}`);
|
|
231
|
+
}, 2000);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Device remove ─────────────────────────────────────────
|
|
235
|
+
function handleDeviceRemove() {
|
|
236
|
+
stopEffect();
|
|
237
|
+
if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
|
|
238
|
+
ctxSet('state', null); ctxSet('state', null, 'disk');
|
|
239
|
+
pub(cfgTopic, '', true);
|
|
240
|
+
broker.unsubscribe(cmdTopic, node.id);
|
|
241
|
+
setStatus('red', 'ring', `${fixtureId} removed`);
|
|
242
|
+
node.log(`${fixtureId} device removed`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── AUX cascade from Group Node ───────────────────────────
|
|
246
|
+
function handleAux(msg) {
|
|
247
|
+
if (!msg.payload) return;
|
|
248
|
+
if (msg.payload.effect && S.showEffects && EFFECTS[msg.payload.effect]) {
|
|
249
|
+
EFFECTS[msg.payload.effect]();
|
|
250
|
+
} else if (msg.payload.state === 'ON') {
|
|
251
|
+
handleON();
|
|
252
|
+
} else if (msg.payload.state === 'OFF') {
|
|
253
|
+
handleOFF();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── NR input entry point ──────────────────────────────────
|
|
258
|
+
node.on('input', function (msg, send, done) {
|
|
259
|
+
if (msg.dmx_trace != null) {
|
|
260
|
+
handleAux(msg);
|
|
261
|
+
} else {
|
|
262
|
+
const devReq = typeof msg.device === 'string'
|
|
263
|
+
? msg.device
|
|
264
|
+
: (msg.device && msg.device.request);
|
|
265
|
+
|
|
266
|
+
if (devReq) {
|
|
267
|
+
switch (devReq) {
|
|
268
|
+
case 'add': handleDeviceAdd(); break;
|
|
269
|
+
case 'remove': handleDeviceRemove(); break;
|
|
270
|
+
default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
node.warn(`${fixtureId} — unrecognised message received and dropped. See node documentation.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
done();
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// ── Cleanup ───────────────────────────────────────────────
|
|
280
|
+
node.on('close', function (done) {
|
|
281
|
+
stopEffect();
|
|
282
|
+
if (diskTimer) clearTimeout(diskTimer);
|
|
283
|
+
broker.unsubscribe(cmdTopic, node.id);
|
|
284
|
+
broker.deregister(node, done);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
RED.nodes.registerType('ha-mqtt-relay', HaMqttRelayNode);
|
|
289
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "node-red-contrib-dmx-for-ha",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DMX lighting control for Home Assistant via Node-RED and MQTT. Place a node, fill in the settings, deploy. Full HA device registry integration with RGBW/RGBWW/CCT/brightness colour modes, transitions, effects, and group control.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"dmx",
|
|
8
|
+
"dmx512",
|
|
9
|
+
"home-assistant",
|
|
10
|
+
"mqtt",
|
|
11
|
+
"mqtt-discovery",
|
|
12
|
+
"lighting",
|
|
13
|
+
"building-automation",
|
|
14
|
+
"rgb",
|
|
15
|
+
"rgbw",
|
|
16
|
+
"color-temp",
|
|
17
|
+
"relay",
|
|
18
|
+
"pir",
|
|
19
|
+
"motion-sensor",
|
|
20
|
+
"button",
|
|
21
|
+
"smart-home"
|
|
22
|
+
],
|
|
23
|
+
"author": "DeSwaggy",
|
|
24
|
+
"license": "Apache-2.0",
|
|
25
|
+
"node-red": {
|
|
26
|
+
"version": ">=3.0.0",
|
|
27
|
+
"nodes": {
|
|
28
|
+
"ha-mqtt-config": "nodes/ha-mqtt-config.js",
|
|
29
|
+
"ha-mqtt-dmx": "nodes/ha-mqtt-dmx.js",
|
|
30
|
+
"ha-mqtt-dmx-group": "nodes/ha-mqtt-dmx-group.js",
|
|
31
|
+
"ha-mqtt-relay": "nodes/ha-mqtt-relay.js",
|
|
32
|
+
"ha-mqtt-button": "nodes/ha-mqtt-button.js",
|
|
33
|
+
"ha-mqtt-pir": "nodes/ha-mqtt-pir.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
}
|
|
39
|
+
}
|