node-red-contrib-dmx-for-ha 0.4.9 → 0.6.1

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.
Files changed (43) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +49 -0
  3. package/nodes/ha-mqtt-button.html +28 -4
  4. package/nodes/ha-mqtt-button.js +67 -9
  5. package/nodes/ha-mqtt-config.html +16 -2
  6. package/nodes/ha-mqtt-config.js +4 -2
  7. package/nodes/ha-mqtt-dmx-group.html +1 -1
  8. package/nodes/ha-mqtt-dmx-group.js +31 -1
  9. package/nodes/ha-mqtt-dmx.html +34 -3
  10. package/nodes/ha-mqtt-dmx.js +93 -15
  11. package/nodes/ha-mqtt-pir.html +30 -6
  12. package/nodes/ha-mqtt-pir.js +78 -11
  13. package/nodes/ha-mqtt-relay.html +15 -4
  14. package/nodes/ha-mqtt-relay.js +59 -5
  15. package/package.json +17 -10
  16. package/profiles/README.md +105 -0
  17. package/profiles/button/custom.js +17 -0
  18. package/profiles/button/mw3d_v1.js +37 -0
  19. package/profiles/button/mw3d_v2.js +36 -0
  20. package/profiles/dmx/custom.js +65 -0
  21. package/profiles/dmx/etherten_v1.js +26 -0
  22. package/profiles/dmx/etherten_v2.js +32 -0
  23. package/profiles/dmx/mqtt_json.js +27 -0
  24. package/profiles/index.js +45 -0
  25. package/profiles/pir/custom.js +15 -0
  26. package/profiles/pir/mw3d_v1.js +34 -0
  27. package/profiles/pir/mw3d_v2.js +38 -0
  28. package/profiles/relay/bedrock_v1.js +17 -0
  29. package/profiles/relay/custom.js +26 -0
  30. package/profiles/relay/generic_onoff.js +16 -0
  31. package/profiles/relay/mqtt_json.js +15 -0
  32. package/docs/config_node_spec.md +0 -236
  33. package/docs/dmx_node_env_reference.md +0 -341
  34. package/docs/master_todo.md +0 -428
  35. package/docs/node_contracts.md +0 -278
  36. package/docs/nr_subflow_gotchas.md +0 -258
  37. package/subflow/README.md +0 -35
  38. package/subflow/button_node_v5.0.3.js +0 -324
  39. package/subflow/dmx_group_node_v0.3.8.js +0 -860
  40. package/subflow/dmx_node_v0.5.9.js +0 -1994
  41. package/subflow/pir_node_v1.0.3.js +0 -365
  42. package/subflow/relay_node_v4.0.2.js +0 -553
  43. package/subflow/subflow_definitions.json +0 -6154
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ node-red-contrib-dmx-for-ha
5
+ Copyright (C) 2024-2026 DeSwaggy
6
+
7
+ This program is free software: you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation, either version 3 of the License, or
10
+ (at your option) any later version.
11
+
12
+ This program is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
19
+
20
+ ---
21
+
22
+ Full licence text: https://www.gnu.org/licenses/gpl-3.0.txt
23
+
24
+ ADDITIONAL NOTE:
25
+ This package was built from 6+ years of real-world professional
26
+ installation experience. If you use it, improve it, or build on it —
27
+ please contribute your changes back to the community. That's the spirit
28
+ of this licence and the spirit in which it was written.
package/README.md CHANGED
@@ -64,6 +64,16 @@ Most DMX implementations require either expensive proprietary hardware or comple
64
64
  - Configurable DMX floor value — prevents low-value flicker on hardware that needs it
65
65
  - Debug mode per node — 12hr auto-disable, safe to leave in production flows
66
66
 
67
+ ## Licence
68
+
69
+ This package is licensed under **[GPL v3](https://www.gnu.org/licenses/gpl-3.0.txt)**.
70
+
71
+ You are free to use, modify, and distribute this package. Any derivative works must be distributed under the same GPL v3 licence. This package may not be taken proprietary, rebranded as closed source, or distributed without crediting the original author.
72
+
73
+ This package was built from 6+ years of real-world professional installation experience. If you use it, improve it, or build on it — please contribute your changes back to the community.
74
+
75
+ ---
76
+
67
77
  > **Note:** This is building automation DMX — fixtures, decoders, dimmers, relay switching. Not entertainment industry DMX (no movers, gobos, or fixture profiles). DMX is an open standard and this package works with any DMX controller that accepts MQTT payloads.
68
78
 
69
79
  ---
@@ -290,6 +300,45 @@ Entity IDs in HA are locked to the fixture ID and survive friendly name changes.
290
300
 
291
301
  ---
292
302
 
303
+ ## Retain discovery — why the default is false
304
+
305
+ If you search "MQTT discovery retain" you will find HA community posts and documentation recommending `retain=true` for discovery messages. **This advice is correct for ESP and battery-powered IoT devices — but not for Node-RED.**
306
+
307
+ ### Why ESP devices need retain=true
308
+
309
+ An ESP sensor runs independently of HA. When HA restarts or updates, the ESP device has no way of knowing — it thinks it already discovered itself and won't re-send the discovery payload. Without `retain=true`, the device disappears from HA permanently until the ESP reboots.
310
+
311
+ ```
312
+ ESP device Home Assistant
313
+ ────────── ──────────────
314
+ Sends discovery once → HA restarts
315
+ Doesn't know HA restarted Retained message restores device ✓
316
+ ```
317
+
318
+ ### Why Node-RED is different
319
+
320
+ Node-RED sits on the same server stack as HA. They are tightly coupled — if one goes down the other knows immediately via the websocket connection. When HA restarts, NR reconnects and re-discovers every device automatically. If NR goes down, HA *should* show nothing — because nothing is working.
321
+
322
+ ```
323
+ Node-RED + Home Assistant (same server):
324
+ NR down → no devices in HA → client sees fault immediately ✓
325
+ HA restart → NR reconnects → re-discovers everything ✓
326
+ retain=true → ghost devices → client confused, support call ✗
327
+ ```
328
+
329
+ ### The config node setting
330
+
331
+ The `Retain discovery` setting on the config node controls this behaviour:
332
+
333
+ | Setting | Behaviour | Recommended for |
334
+ |---|---|---|
335
+ | `false` (default) | Devices disappear if NR stops | Professional installs — clear fault indication |
336
+ | `true` | Devices persist if NR stops | Standalone IoT, voice assistant integrations where entity persistence matters |
337
+
338
+ **Default is `false`** — if a client opens their HA dashboard and sees no lights, they know something is wrong with Node-RED. This is the correct behaviour for a professionally installed system where NR and HA live on the same hardware.
339
+
340
+ ---
341
+
293
342
  ## MQTT topic formats
294
343
 
295
344
  | Node | Topic |
@@ -41,8 +41,9 @@
41
41
  subLocation: { value: '' },
42
42
  // Button specifics
43
43
  buttonPosition: { value: '' },
44
- buttonPayload: { value: '', required: true },
45
- subscribeTopic: { value: '', required: true },
44
+ buttonPayload: { value: '', required: true },
45
+ subscribeTopic: { value: '', required: true },
46
+ controllerProfile: { value: 'mw3d_v1' },
46
47
  // Options
47
48
  haIcon: { value: 'mdi:gesture-tap-button' },
48
49
  ledColor: { value: 'Blue' },
@@ -239,12 +240,35 @@
239
240
  </div>
240
241
 
241
242
  <div class="form-row">
243
+ <label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
244
+ <select id="node-input-controllerProfile" style="width:65%" onchange="
245
+ var v = this.value;
246
+ document.getElementById('btn-payload-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
247
+ document.getElementById('btn-topic-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
248
+ document.getElementById('btn-v2-note').style.display = (v === 'mw3d_v2') ? '' : 'none';
249
+ ">
250
+ <option value="mw3d_v1">MW3D v1 — Legacy (panelId-GPIO payload)</option>
251
+ <option value="mw3d_v2">MW3D v2 — JSON (fixture ID payload)</option>
252
+ <option value="custom">Custom</option>
253
+ </select>
254
+ </div>
255
+
256
+ <div class="form-row" id="btn-v2-note" style="display:none">
257
+ <label></label>
258
+ <span style="color:#999; font-size:0.85em;">
259
+ <i class="fa fa-info-circle"></i>
260
+ v2 firmware — topic and payload auto-configured from zone + fixture ID.
261
+ No manual payload or topic needed.
262
+ </span>
263
+ </div>
264
+
265
+ <div class="form-row" id="btn-payload-row">
242
266
  <label for="node-input-buttonPayload"><i class="fa fa-hand-pointer-o"></i> Payload</label>
243
267
  <input type="text" id="node-input-buttonPayload" placeholder="Required — e.g. 10-54" style="width:120px" />
244
268
  <span style="margin-left:8px; color:#999; font-size:0.85em;">panelId-GPIOpin</span>
245
269
  </div>
246
270
 
247
- <div class="form-row">
271
+ <div class="form-row" id="btn-topic-row">
248
272
  <label for="node-input-subscribeTopic"><i class="fa fa-exchange"></i> Subscribe topic</label>
249
273
  <input type="text" id="node-input-subscribeTopic" placeholder="Required — e.g. buttons or home/zone/buttons" style="width:55%" />
250
274
  </div>
@@ -289,7 +313,7 @@
289
313
  </div>
290
314
 
291
315
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
292
- node-red-contrib-dmx-for-ha &nbsp;v0.4.8
316
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
293
317
  </div>
294
318
 
295
319
  </script>
@@ -28,6 +28,13 @@ module.exports = function (RED) {
28
28
 
29
29
  function autoDiscover() {
30
30
  if (discoveryMode === 'disabled') {
31
+ const fixtureIdD = `S-${config.uid}${config.uidPostfix || ''}`;
32
+ const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureIdD);
33
+ const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
34
+ broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
35
+ const uiBtnTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureIdD + '-BTN');
36
+ const uiBtnCfgTopicD = `${uiBtnTopicD}/${cfg.configTopic}`;
37
+ broker.publish({ topic: uiBtnCfgTopicD, payload: '', qos: cfg.qos, retain: true });
31
38
  setStatus('grey', 'ring', 'Disabled — not discovered');
32
39
  return;
33
40
  }
@@ -99,8 +106,9 @@ module.exports = function (RED) {
99
106
  situation: config.situation || 'in',
100
107
  subLocation: config.subLocation || '',
101
108
  buttonPosition: config.buttonPosition || 'Single',
102
- buttonPayload: String(config.buttonPayload || ''),
103
- subscribeTopic: config.subscribeTopic || 'buttons',
109
+ buttonPayload: String(config.buttonPayload || ''),
110
+ subscribeTopic: config.subscribeTopic || 'buttons',
111
+ controllerProfile: config.controllerProfile || 'mw3d_v1',
104
112
  haIcon: config.haIcon || 'mdi:gesture-tap-button',
105
113
  ledColor: config.ledColor || 'Blue',
106
114
  holdTime: parseFloat(config.holdTime) || 0.5,
@@ -122,8 +130,49 @@ module.exports = function (RED) {
122
130
  }
123
131
 
124
132
 
133
+ // ── Controller profile ────────────────────────────────────────────
134
+ let _btnProfile;
135
+ try {
136
+ const _profiles = require('../profiles/index');
137
+ _btnProfile = (_profiles.button || []).find(p => p.id === S.controllerProfile)
138
+ || _profiles.button[0];
139
+ } catch(e) {
140
+ _btnProfile = {
141
+ id: 'mw3d_v1',
142
+ buildTopic: () => null,
143
+ parsePayload: (raw, fid, exp) => raw === exp ? { match: true, state: 'on' } : null,
144
+ hasOffEvent: false
145
+ };
146
+ }
147
+ const btnProfile = _btnProfile;
148
+
125
149
  // ── Fixture identity and topics ───────────────────────────────────────
126
150
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
151
+ // ── Fixture ID duplicate detection ──────────────────────────────────
152
+ function registerFixtureId() {
153
+ const globalCtx = node.context().global;
154
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
155
+ let reg = {};
156
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
157
+ if (reg[fixtureId] && reg[fixtureId] !== node.id) {
158
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
159
+ setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
160
+ } else {
161
+ reg[fixtureId] = node.id;
162
+ try { globalCtx.set(regKey, reg); } catch(e) {}
163
+ }
164
+ }
165
+ function unregisterFixtureId() {
166
+ const globalCtx = node.context().global;
167
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
168
+ let reg = {};
169
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
170
+ if (reg[fixtureId] === node.id) {
171
+ delete reg[fixtureId];
172
+ try { globalCtx.set(regKey, reg); } catch(e) {}
173
+ }
174
+ }
175
+
127
176
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
128
177
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
129
178
  const uiBtnTopic = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureId + '-BTN');
@@ -157,6 +206,7 @@ module.exports = function (RED) {
157
206
 
158
207
  // ── Device add ────────────────────────────────────────────
159
208
  function handleDeviceAdd() {
209
+ registerFixtureId();
160
210
  // Binary sensor discovery
161
211
  const bsDiscovery = {
162
212
  unique_id: fixtureId,
@@ -192,11 +242,18 @@ module.exports = function (RED) {
192
242
  };
193
243
 
194
244
  pub(cfgTopic, bsDiscovery, true);
195
- pub(uiBtnCfgTopic, uiDiscovery, true);
196
-
197
- // Subscribe to controller topic (physical presses)
198
- broker.subscribe(S.subscribeTopic, cfg.qos, function (topic, payload) {
199
- if (String(payload) === S.buttonPayload) handlePress('physical');
245
+ pub(uiBtnCfgTopic, uiDiscovery, cfg.retainDiscovery);
246
+
247
+ // Subscribe to controller topic topic and matching via profile
248
+ const _btnSubTopic = btnProfile.buildTopic ? btnProfile.buildTopic(cfg) : null;
249
+ const activeBtnTopic = _btnSubTopic || S.subscribeTopic;
250
+ S._activeBtnTopic = activeBtnTopic;
251
+
252
+ broker.subscribe(activeBtnTopic, cfg.qos, function (topic, payload) {
253
+ const result = btnProfile.parsePayload(
254
+ String(payload), fixtureId, S.buttonPayload
255
+ );
256
+ if (result && result.match && result.state === 'on') handlePress('physical');
200
257
  }, node.id);
201
258
 
202
259
  // Subscribe to HA UI button cmd topic
@@ -211,9 +268,10 @@ module.exports = function (RED) {
211
268
 
212
269
  // ── Device remove ─────────────────────────────────────────
213
270
  function handleDeviceRemove() {
271
+ unregisterFixtureId();
214
272
  pub(cfgTopic, '', true);
215
273
  pub(uiBtnCfgTopic, '', true);
216
- broker.unsubscribe(S.subscribeTopic, node.id);
274
+ broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
217
275
  broker.unsubscribe(uiBtnCmdTopic, node.id);
218
276
  setStatus('red', 'ring', `${fixtureId} removed`);
219
277
  node.log(`${fixtureId} device removed`);
@@ -239,7 +297,7 @@ module.exports = function (RED) {
239
297
 
240
298
  // ── Cleanup ───────────────────────────────────────────────
241
299
  node.on('close', function (done) {
242
- broker.unsubscribe(S.subscribeTopic, node.id);
300
+ broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
243
301
  broker.unsubscribe(uiBtnCmdTopic, node.id);
244
302
  broker.deregister(node, done);
245
303
  });
@@ -13,8 +13,9 @@
13
13
  zone: { value: '' },
14
14
  broker: { value: '', type: 'mqtt-broker', required: true },
15
15
  discoveryPrefix: { value: 'homeassistant' },
16
- qos: { value: '0' },
17
- retain: { value: 'false' },
16
+ qos: { value: '0' },
17
+ retain: { value: 'false' },
18
+ retainDiscovery: { value: 'false' },
18
19
  configTopic: { value: 'config' },
19
20
  stateTopic: { value: 'state' },
20
21
  commandTopic: { value: 'cmd' },
@@ -142,6 +143,19 @@
142
143
  </select>
143
144
  </div>
144
145
 
146
+ <div class="form-row">
147
+ <label for="node-config-input-retainDiscovery" style="width:auto">
148
+ <i class="fa fa-thumb-tack"></i> Retain discovery
149
+ </label>
150
+ <select id="node-config-input-retainDiscovery" style="width:80px">
151
+ <option value="false">false</option>
152
+ <option value="true">true</option>
153
+ </select>
154
+ <span style="color:#999; font-size:0.85em; margin-left:8px">
155
+ false = devices disappear from HA if NR stops (recommended)
156
+ </span>
157
+ </div>
158
+
145
159
  <hr/>
146
160
 
147
161
  <!-- DEFAULTS -->
@@ -48,8 +48,9 @@ module.exports = function (RED) {
48
48
  this.availTopic = config.availTopic || 'avty';
49
49
 
50
50
  // MQTT settings
51
- this.qos = parseInt(config.qos) || 0;
52
- this.retain = config.retain === 'true';
51
+ this.qos = parseInt(config.qos) || 0;
52
+ this.retain = config.retain === 'true';
53
+ this.retainDiscovery = config.retainDiscovery === 'true';
53
54
 
54
55
  // HA defaults
55
56
  this.enabledDefault = config.enabledDefault !== 'false';
@@ -83,6 +84,7 @@ module.exports = function (RED) {
83
84
  availTopic: this.availTopic,
84
85
  qos: this.qos,
85
86
  retain: this.retain,
87
+ retainDiscovery: this.retainDiscovery,
86
88
  enabledDefault: this.enabledDefault,
87
89
  diskDelay: this.diskDelay,
88
90
  flashShort: this.flashShort,
@@ -317,7 +317,7 @@
317
317
  </div>
318
318
 
319
319
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
320
- node-red-contrib-dmx-for-ha &nbsp;v0.4.8
320
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
321
321
  </div>
322
322
 
323
323
  </script>
@@ -31,6 +31,10 @@ module.exports = function (RED) {
31
31
 
32
32
  function autoDiscover() {
33
33
  if (discoveryMode === 'disabled') {
34
+ const fixtureIdD = `LG-${config.uid}${config.uidPostfix || ''}`;
35
+ const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureIdD);
36
+ const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
37
+ broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
34
38
  setStatus('grey', 'ring', 'Disabled — not discovered');
35
39
  return;
36
40
  }
@@ -132,6 +136,31 @@ module.exports = function (RED) {
132
136
 
133
137
  // ── Group identity and topics ─────────────────────────────────────────
134
138
  const groupId = `LG-${S.uid}${S.uidPostfix}`;
139
+ // ── Fixture ID duplicate detection ──────────────────────────────────
140
+ function registerFixtureId() {
141
+ const globalCtx = node.context().global;
142
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
143
+ let reg = {};
144
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
145
+ if (reg[groupId] && reg[groupId] !== node.id) {
146
+ node.warn(`${groupId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[groupId]}`);
147
+ setStatus('red', 'dot', `DUPLICATE ID: ${groupId}`);
148
+ } else {
149
+ reg[groupId] = node.id;
150
+ try { globalCtx.set(regKey, reg); } catch(e) {}
151
+ }
152
+ }
153
+ function unregisterFixtureId() {
154
+ const globalCtx = node.context().global;
155
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
156
+ let reg = {};
157
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
158
+ if (reg[groupId] === node.id) {
159
+ delete reg[groupId];
160
+ try { globalCtx.set(regKey, reg); } catch(e) {}
161
+ }
162
+ }
163
+
135
164
  const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
136
165
  const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
137
166
  const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
@@ -248,6 +277,7 @@ module.exports = function (RED) {
248
277
 
249
278
  // ── Device add ────────────────────────────────────────────
250
279
  function handleDeviceAdd(incomingTrace) {
280
+ registerFixtureId();
251
281
  if (!ctxGet('state') && !ctxGet('state', 'disk_values')) {
252
282
  ctxSet('state', S.defaultState);
253
283
  }
@@ -283,7 +313,7 @@ module.exports = function (RED) {
283
313
  },
284
314
  };
285
315
 
286
- pub(cfgTopic, discovery, true);
316
+ pub(cfgTopic, discovery, cfg.retainDiscovery);
287
317
 
288
318
  broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
289
319
  let payload;
@@ -158,8 +158,9 @@
158
158
  chWhite: { value: '' },
159
159
  chWarmWhite: { value: '' },
160
160
  // DMX controller
161
- controllerNum: { value: '1' },
162
- universe: { value: '1' },
161
+ controllerNum: { value: '1' },
162
+ universe: { value: '1' },
163
+ controllerProfile: { value: 'etherten_v1' },
163
164
  // Options
164
165
  haIcon: { value: 'mdi:lightbulb' },
165
166
  showEffects: { value: true },
@@ -378,6 +379,36 @@
378
379
  </label>
379
380
  </div>
380
381
 
382
+ <div class="form-row">
383
+ <label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
384
+ <select id="node-input-controllerProfile" style="width:65%" onchange="
385
+ var v = this.value;
386
+ document.getElementById('dmx-v2-note').style.display = (v === 'etherten_v2') ? '' : 'none';
387
+ document.getElementById('dmx-custom-row').style.display = (v === 'custom') ? '' : 'none';
388
+ ">
389
+ <option value="etherten_v1">EtherTen v1 — Legacy "212255" (one msg per channel)</option>
390
+ <option value="etherten_v2">EtherTen v2 — Batched [[212,255],...] (one msg per tick)</option>
391
+ <option value="mqtt_json">MQTT JSON — {"channel":212,"value":255}</option>
392
+ <option value="custom">Custom payload</option>
393
+ </select>
394
+ </div>
395
+
396
+ <div class="form-row" id="dmx-v2-note" style="display:none">
397
+ <label></label>
398
+ <span style="color:#999; font-size:0.85em;">
399
+ <i class="fa fa-info-circle"></i>
400
+ v2 batches all channel changes into one MQTT message per tick.
401
+ Requires MW3D DMXController firmware v3.0+.
402
+ Significantly reduces MQTT traffic during effects and transitions.
403
+ </span>
404
+ </div>
405
+
406
+ <div class="form-row" id="dmx-custom-row" style="display:none">
407
+ <label for="node-input-customTopic"><i class="fa fa-exchange"></i> Custom topic</label>
408
+ <input type="text" id="node-input-customTopic"
409
+ placeholder="{siteId}/{zone}/dmx/{universe}" style="width:55%" />
410
+ </div>
411
+
381
412
  <div class="form-row">
382
413
  <label for="node-input-controllerNum">
383
414
  <i class="fa fa-sort-numeric-asc"></i> Controller
@@ -553,7 +584,7 @@
553
584
  </div>
554
585
 
555
586
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
556
- node-red-contrib-dmx-for-ha &nbsp;v0.4.8
587
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
557
588
  </div>
558
589
 
559
590
  </script>
@@ -38,6 +38,11 @@ module.exports = function (RED) {
38
38
 
39
39
  function autoDiscover() {
40
40
  if (discoveryMode === 'disabled') {
41
+ // Clear any previously retained discovery message so HA removes the device
42
+ const fixtureIdD = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
43
+ const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureIdD);
44
+ const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
45
+ broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
41
46
  setStatus('grey', 'ring', 'Disabled — not discovered');
42
47
  return;
43
48
  }
@@ -119,7 +124,8 @@ module.exports = function (RED) {
119
124
  warmWhite:parseInt(config.chWarmWhite)|| 0,
120
125
  },
121
126
  controllerNum: config.controllerNum || '1',
122
- universe: config.universe || '1',
127
+ universe: config.universe || '1',
128
+ controllerProfile: config.controllerProfile || 'etherten_v1',
123
129
  haIcon: config.haIcon || 'mdi:lightbulb',
124
130
  showEffects: config.showEffects !== false,
125
131
  transitions: config.transitions !== false,
@@ -219,18 +225,41 @@ module.exports = function (RED) {
219
225
  const _lastSent = {};
220
226
 
221
227
  function sendDmxChannels(channels) {
222
- channels.forEach(function ([ch, val]) {
223
- const payload = buildDmxPayload(ch, val);
224
- if (payload === null) return;
225
- // Skip if value unchanged since last publish
226
- if (_lastSent[ch] === val) {
227
- if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} SKIP ch${ch}=${val} (unchanged)`);
228
- return;
228
+ if (dmxProfile.batched) {
229
+ // ── Batched mode (etherten_v2) ─────────────────────────────
230
+ // Collect all changed channels → publish ONE message per tick
231
+ const batch = [];
232
+ channels.forEach(function ([ch, val]) {
233
+ if (!ch || ch <= 0 || val == null) return;
234
+ if (_lastSent[ch] === val) {
235
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
236
+ return;
237
+ }
238
+ _lastSent[ch] = val;
239
+ batch.push([ch, val]);
240
+ });
241
+ if (batch.length > 0) {
242
+ const payload = dmxProfile.buildPayload(null, null, batch);
243
+ broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
244
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} batch[${batch.length}] ${payload}`);
229
245
  }
230
- _lastSent[ch] = val;
231
- broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
232
- if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
233
- });
246
+ } else {
247
+ // ── Single message per channel (etherten_v1 legacy) ────────
248
+ channels.forEach(function ([ch, val]) {
249
+ if (!ch || ch <= 0 || val == null) return;
250
+ if (_lastSent[ch] === val) {
251
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
252
+ return;
253
+ }
254
+ _lastSent[ch] = val;
255
+ const payload = dmxProfile.buildPayload
256
+ ? dmxProfile.buildPayload(ch, val, null)
257
+ : buildDmxPayload(ch, val);
258
+ if (payload === null) return;
259
+ broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
260
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
261
+ });
262
+ }
234
263
  }
235
264
 
236
265
  function resetLastSent() {
@@ -556,14 +585,61 @@ module.exports = function (RED) {
556
585
 
557
586
 
558
587
 
559
- // ── Fixture identity and topics ───────────────────────────────────────
588
+
589
+ // ── DMX Controller profile ────────────────────────────────────────
590
+ let _dmxProfile;
591
+ try {
592
+ const _profiles = require('../profiles/index');
593
+ _dmxProfile = (_profiles.dmx || []).find(p => p.id === S.controllerProfile)
594
+ || _profiles.dmx[0];
595
+ } catch(e) {
596
+ // Fallback to etherten_v1 if profiles not found
597
+ _dmxProfile = {
598
+ id: 'etherten_v1',
599
+ batched: false,
600
+ buildPayload: (ch, val) =>
601
+ String(ch).padStart(3,'0') + String(val).padStart(3,'0'),
602
+ buildTopic: (cfg, universe) =>
603
+ cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', universe)
604
+ };
605
+ }
606
+ const dmxProfile = _dmxProfile;
607
+
608
+ // ── Fixture identity and topics ───────────────────────────────────────
560
609
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
610
+
611
+ // ── Fixture ID duplicate detection ──────────────────────────────────
612
+ function registerFixtureId() {
613
+ const globalCtx = node.context().global;
614
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
615
+ let reg = {};
616
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
617
+ if (reg[fixtureId] && reg[fixtureId] !== node.id) {
618
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
619
+ setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
620
+ } else {
621
+ reg[fixtureId] = node.id;
622
+ try { globalCtx.set(regKey, reg); } catch(e) {}
623
+ }
624
+ }
625
+ function unregisterFixtureId() {
626
+ const globalCtx = node.context().global;
627
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
628
+ let reg = {};
629
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
630
+ if (reg[fixtureId] === node.id) {
631
+ delete reg[fixtureId];
632
+ try { globalCtx.set(regKey, reg); } catch(e) {}
633
+ }
634
+ }
561
635
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
562
636
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
563
637
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
564
638
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
565
639
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
566
- const dmxTopic = cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
640
+ const dmxTopic = dmxProfile.buildTopic
641
+ ? dmxProfile.buildTopic(cfg, S.universe)
642
+ : cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
567
643
 
568
644
  // ── DMX channel conflict detection ───────────────────────────────────
569
645
  function checkChannelConflicts() {
@@ -608,6 +684,7 @@ module.exports = function (RED) {
608
684
 
609
685
  // ── Device add ────────────────────────────────────────────
610
686
  function handleDeviceAdd() {
687
+ registerFixtureId();
611
688
  if (ctxGet('state') === undefined && ctxGet('state', 'disk_values') === undefined) {
612
689
  ctxSet('state', S.defaultState);
613
690
  }
@@ -649,7 +726,7 @@ module.exports = function (RED) {
649
726
  },
650
727
  };
651
728
 
652
- pub(cfgTopic, discovery, true);
729
+ pub(cfgTopic, discovery, cfg.retainDiscovery);
653
730
  // Check for DMX channel conflicts before discovery
654
731
  if (!checkChannelConflicts()) return;
655
732
  resetLastSent(); // Force full state re-publish after discovery
@@ -698,6 +775,7 @@ module.exports = function (RED) {
698
775
 
699
776
  // ── Device remove ─────────────────────────────────────────
700
777
  function handleDeviceRemove() {
778
+ unregisterFixtureId();
701
779
  clearChannelRegistry();
702
780
  stopEffect();
703
781
  if (diskTimer) {