node-red-contrib-dmx-for-ha 0.5.2 → 0.6.2

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 (67) hide show
  1. package/nodes/ha-mqtt-button.html +28 -4
  2. package/nodes/ha-mqtt-button.js +58 -7
  3. package/nodes/ha-mqtt-config.html +3 -3
  4. package/nodes/ha-mqtt-dmx-group.html +1 -1
  5. package/nodes/ha-mqtt-dmx-group.js +26 -0
  6. package/nodes/ha-mqtt-dmx.html +34 -14
  7. package/nodes/ha-mqtt-dmx.js +97 -23
  8. package/nodes/ha-mqtt-pir.html +30 -6
  9. package/nodes/ha-mqtt-pir.js +72 -9
  10. package/nodes/ha-mqtt-relay.html +15 -4
  11. package/nodes/ha-mqtt-relay.js +54 -4
  12. package/package.json +16 -9
  13. package/profiles/README.md +105 -0
  14. package/profiles/button/custom.js +17 -0
  15. package/profiles/button/mw3d_v1.js +37 -0
  16. package/profiles/button/mw3d_v2.js +36 -0
  17. package/profiles/dmx/custom.js +65 -0
  18. package/profiles/dmx/etherten_v1.js +26 -0
  19. package/profiles/dmx/etherten_v2.js +32 -0
  20. package/profiles/dmx/mqtt_json.js +27 -0
  21. package/profiles/index.js +45 -0
  22. package/profiles/pir/custom.js +15 -0
  23. package/profiles/pir/mw3d_v1.js +34 -0
  24. package/profiles/pir/mw3d_v2.js +38 -0
  25. package/profiles/relay/bedrock_v1.js +17 -0
  26. package/profiles/relay/custom.js +26 -0
  27. package/profiles/relay/generic_onoff.js +16 -0
  28. package/profiles/relay/mqtt_json.js +15 -0
  29. package/docs/config_node_spec.md +0 -236
  30. package/docs/dmx_node_env_reference.md +0 -341
  31. package/docs/master_todo.md +0 -428
  32. package/docs/node_contracts.md +0 -278
  33. package/docs/nr_subflow_gotchas.md +0 -258
  34. package/node-red-contrib-dmx-for-ha/LICENSE +0 -28
  35. package/node-red-contrib-dmx-for-ha/README.md +0 -587
  36. package/node-red-contrib-dmx-for-ha/docs/config_node_spec.md +0 -236
  37. package/node-red-contrib-dmx-for-ha/docs/dmx_node_env_reference.md +0 -341
  38. package/node-red-contrib-dmx-for-ha/docs/master_todo.md +0 -428
  39. package/node-red-contrib-dmx-for-ha/docs/node_contracts.md +0 -278
  40. package/node-red-contrib-dmx-for-ha/docs/nr_subflow_gotchas.md +0 -258
  41. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-button.html +0 -326
  42. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-button.js +0 -284
  43. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-config.html +0 -270
  44. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-config.js +0 -99
  45. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx-group.html +0 -387
  46. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx-group.js +0 -410
  47. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx.html +0 -618
  48. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx.js +0 -808
  49. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-pir.html +0 -337
  50. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-pir.js +0 -306
  51. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-relay.html +0 -329
  52. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-relay.js +0 -424
  53. package/node-red-contrib-dmx-for-ha/package.json +0 -39
  54. package/node-red-contrib-dmx-for-ha/subflow/README.md +0 -35
  55. package/node-red-contrib-dmx-for-ha/subflow/button_node_v5.0.3.js +0 -324
  56. package/node-red-contrib-dmx-for-ha/subflow/dmx_group_node_v0.3.8.js +0 -860
  57. package/node-red-contrib-dmx-for-ha/subflow/dmx_node_v0.5.9.js +0 -1994
  58. package/node-red-contrib-dmx-for-ha/subflow/pir_node_v1.0.3.js +0 -365
  59. package/node-red-contrib-dmx-for-ha/subflow/relay_node_v4.0.2.js +0 -553
  60. package/node-red-contrib-dmx-for-ha/subflow/subflow_definitions.json +0 -6154
  61. package/subflow/README.md +0 -35
  62. package/subflow/button_node_v5.0.3.js +0 -324
  63. package/subflow/dmx_group_node_v0.3.8.js +0 -860
  64. package/subflow/dmx_node_v0.5.9.js +0 -1994
  65. package/subflow/pir_node_v1.0.3.js +0 -365
  66. package/subflow/relay_node_v4.0.2.js +0 -553
  67. package/subflow/subflow_definitions.json +0 -6154
@@ -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.5.2
316
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
293
317
  </div>
294
318
 
295
319
  </script>
@@ -106,8 +106,9 @@ module.exports = function (RED) {
106
106
  situation: config.situation || 'in',
107
107
  subLocation: config.subLocation || '',
108
108
  buttonPosition: config.buttonPosition || 'Single',
109
- buttonPayload: String(config.buttonPayload || ''),
110
- subscribeTopic: config.subscribeTopic || 'buttons',
109
+ buttonPayload: String(config.buttonPayload || ''),
110
+ subscribeTopic: config.subscribeTopic || 'buttons',
111
+ controllerProfile: config.controllerProfile || 'mw3d_v1',
111
112
  haIcon: config.haIcon || 'mdi:gesture-tap-button',
112
113
  ledColor: config.ledColor || 'Blue',
113
114
  holdTime: parseFloat(config.holdTime) || 0.5,
@@ -129,8 +130,49 @@ module.exports = function (RED) {
129
130
  }
130
131
 
131
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
+
132
149
  // ── Fixture identity and topics ───────────────────────────────────────
133
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
+
134
176
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
135
177
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
136
178
  const uiBtnTopic = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureId + '-BTN');
@@ -164,6 +206,7 @@ module.exports = function (RED) {
164
206
 
165
207
  // ── Device add ────────────────────────────────────────────
166
208
  function handleDeviceAdd() {
209
+ registerFixtureId();
167
210
  // Binary sensor discovery
168
211
  const bsDiscovery = {
169
212
  unique_id: fixtureId,
@@ -201,9 +244,16 @@ module.exports = function (RED) {
201
244
  pub(cfgTopic, bsDiscovery, true);
202
245
  pub(uiBtnCfgTopic, uiDiscovery, cfg.retainDiscovery);
203
246
 
204
- // Subscribe to controller topic (physical presses)
205
- broker.subscribe(S.subscribeTopic, cfg.qos, function (topic, payload) {
206
- if (String(payload) === S.buttonPayload) handlePress('physical');
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');
207
257
  }, node.id);
208
258
 
209
259
  // Subscribe to HA UI button cmd topic
@@ -218,9 +268,10 @@ module.exports = function (RED) {
218
268
 
219
269
  // ── Device remove ─────────────────────────────────────────
220
270
  function handleDeviceRemove() {
271
+ unregisterFixtureId();
221
272
  pub(cfgTopic, '', true);
222
273
  pub(uiBtnCfgTopic, '', true);
223
- broker.unsubscribe(S.subscribeTopic, node.id);
274
+ broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
224
275
  broker.unsubscribe(uiBtnCmdTopic, node.id);
225
276
  setStatus('red', 'ring', `${fixtureId} removed`);
226
277
  node.log(`${fixtureId} device removed`);
@@ -246,7 +297,7 @@ module.exports = function (RED) {
246
297
 
247
298
  // ── Cleanup ───────────────────────────────────────────────
248
299
  node.on('close', function (done) {
249
- broker.unsubscribe(S.subscribeTopic, node.id);
300
+ broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
250
301
  broker.unsubscribe(uiBtnCmdTopic, node.id);
251
302
  broker.deregister(node, done);
252
303
  });
@@ -212,12 +212,12 @@
212
212
  </div>
213
213
  <div class="form-row">
214
214
  <label for="node-config-input-dmxFloor">
215
- <i class="fa fa-arrow-down"></i> DMX Floor
215
+ <i class="fa fa-arrow-up"></i> DMX Floor
216
216
  </label>
217
217
  <input type="number" id="node-config-input-dmxFloor"
218
- min="0" max="20" step="1" style="width:70px" />
218
+ min="0" max="50" step="1" style="width:70px" />
219
219
  <span style="margin-left:8px; color:#999; font-size:0.85em;">
220
- Min DMX value sent below this snaps up (0=OFF always OFF). Default: 3
220
+ Minimum DMX value sent when light is ON. Values below floor snap up to floor. 0 = disabled. Default: 3
221
221
  </span>
222
222
  </div>
223
223
  </script>
@@ -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.5.2
320
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
321
321
  </div>
322
322
 
323
323
  </script>
@@ -136,6 +136,31 @@ module.exports = function (RED) {
136
136
 
137
137
  // ── Group identity and topics ─────────────────────────────────────────
138
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
+
139
164
  const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
140
165
  const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
141
166
  const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
@@ -252,6 +277,7 @@ module.exports = function (RED) {
252
277
 
253
278
  // ── Device add ────────────────────────────────────────────
254
279
  function handleDeviceAdd(incomingTrace) {
280
+ registerFixtureId();
255
281
  if (!ctxGet('state') && !ctxGet('state', 'disk_values')) {
256
282
  ctxSet('state', S.defaultState);
257
283
  }
@@ -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 },
@@ -168,7 +169,6 @@
168
169
  defaultState: { value: 'OFF' },
169
170
  // Advanced
170
171
  dmxLimiter: { value: '255' },
171
- minOutput: { value: '1' },
172
172
  brightBump: { value: '50' },
173
173
  ticksPerSec: { value: '31' },
174
174
  debugMode: { value: false },
@@ -378,6 +378,36 @@
378
378
  </label>
379
379
  </div>
380
380
 
381
+ <div class="form-row">
382
+ <label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
383
+ <select id="node-input-controllerProfile" style="width:65%" onchange="
384
+ var v = this.value;
385
+ document.getElementById('dmx-v2-note').style.display = (v === 'etherten_v2') ? '' : 'none';
386
+ document.getElementById('dmx-custom-row').style.display = (v === 'custom') ? '' : 'none';
387
+ ">
388
+ <option value="etherten_v1">EtherTen v1 — Legacy "212255" (one msg per channel)</option>
389
+ <option value="etherten_v2">EtherTen v2 — Batched [[212,255],...] (one msg per tick)</option>
390
+ <option value="mqtt_json">MQTT JSON — {"channel":212,"value":255}</option>
391
+ <option value="custom">Custom payload</option>
392
+ </select>
393
+ </div>
394
+
395
+ <div class="form-row" id="dmx-v2-note" style="display:none">
396
+ <label></label>
397
+ <span style="color:#999; font-size:0.85em;">
398
+ <i class="fa fa-info-circle"></i>
399
+ v2 batches all channel changes into one MQTT message per tick.
400
+ Requires MW3D DMXController firmware v3.0+.
401
+ Significantly reduces MQTT traffic during effects and transitions.
402
+ </span>
403
+ </div>
404
+
405
+ <div class="form-row" id="dmx-custom-row" style="display:none">
406
+ <label for="node-input-customTopic"><i class="fa fa-exchange"></i> Custom topic</label>
407
+ <input type="text" id="node-input-customTopic"
408
+ placeholder="{siteId}/{zone}/dmx/{universe}" style="width:55%" />
409
+ </div>
410
+
381
411
  <div class="form-row">
382
412
  <label for="node-input-controllerNum">
383
413
  <i class="fa fa-sort-numeric-asc"></i> Controller
@@ -512,16 +542,6 @@
512
542
  min="0" max="255" style="width:70px" />
513
543
  <span style="margin-left:8px; color:#999; font-size:0.85em;">Caps all DMX channel values (0–255)</span>
514
544
  </div>
515
-
516
- <div class="form-row">
517
- <label for="node-input-minOutput">
518
- <i class="fa fa-sort-numeric-asc"></i> Min output when ON
519
- </label>
520
- <input type="number" id="node-input-minOutput"
521
- min="0" max="255" style="width:70px" />
522
- <span style="margin-left:8px; color:#999; font-size:0.85em;">0 = pure gamma</span>
523
- </div>
524
-
525
545
  <div class="form-row">
526
546
  <label for="node-input-brightBump">
527
547
  <i class="fa fa-sort-numeric-asc"></i> Brightness bump
@@ -553,7 +573,7 @@
553
573
  </div>
554
574
 
555
575
  <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.5.2
576
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
557
577
  </div>
558
578
 
559
579
  </script>
@@ -124,14 +124,15 @@ module.exports = function (RED) {
124
124
  warmWhite:parseInt(config.chWarmWhite)|| 0,
125
125
  },
126
126
  controllerNum: config.controllerNum || '1',
127
- universe: config.universe || '1',
127
+ universe: config.universe || '1',
128
+ controllerProfile: config.controllerProfile || 'etherten_v1',
128
129
  haIcon: config.haIcon || 'mdi:lightbulb',
129
130
  showEffects: config.showEffects !== false,
130
131
  transitions: config.transitions !== false,
131
132
  groupSync: config.groupSync === true,
132
133
  defaultState: config.defaultState || 'OFF',
133
134
  dmxLimiter: parseInt(config.dmxLimiter) || 255,
134
- minOutput: parseInt(config.minOutput) || 1,
135
+ // minOutput removed v0.6.2 — use dmxFloor instead
135
136
  brightBump: parseInt(config.brightBump) || 50,
136
137
  ticksPerSec: parseInt(config.ticksPerSec) || 31,
137
138
  debugMode: config.debugMode === true,
@@ -204,13 +205,10 @@ module.exports = function (RED) {
204
205
  brightness = brightness !== undefined ? brightness : 255;
205
206
  const limited = Math.round((colorValue / 255) * (brightness / 255) * S.dmxLimiter);
206
207
  const gamma = GAMMA_TABLE[Math.max(0, Math.min(255, limited))];
207
- // Min output floor — only apply when both inputs are non-zero (intentionally on)
208
- if (gamma === 0 && colorValue > 0 && brightness > 0 && S.minOutput > 0) {
209
- return S.minOutput;
210
- }
211
- // DMX floor — snap up if above 0 but below hardware stable threshold
212
- if (gamma > 0 && gamma < S.dmxFloor) return S.dmxFloor;
213
- // Note: ceiling is handled by S.dmxLimiter in scaleToDmx above
208
+ // DMX floor — if source is ON but output is below floor, snap up
209
+ // Prevents flickering at low DMX values that decoders can't handle cleanly
210
+ // Exception: if colorValue is 0, light is intentionally OFF — send 0
211
+ if (colorValue > 0 && gamma < S.dmxFloor) return S.dmxFloor;
214
212
  return gamma;
215
213
  }
216
214
 
@@ -224,18 +222,41 @@ module.exports = function (RED) {
224
222
  const _lastSent = {};
225
223
 
226
224
  function sendDmxChannels(channels) {
227
- channels.forEach(function ([ch, val]) {
228
- const payload = buildDmxPayload(ch, val);
229
- if (payload === null) return;
230
- // Skip if value unchanged since last publish
231
- if (_lastSent[ch] === val) {
232
- if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} SKIP ch${ch}=${val} (unchanged)`);
233
- return;
225
+ if (dmxProfile.batched) {
226
+ // ── Batched mode (etherten_v2) ─────────────────────────────
227
+ // Collect all changed channels → publish ONE message per tick
228
+ const batch = [];
229
+ channels.forEach(function ([ch, val]) {
230
+ if (!ch || ch <= 0 || val == null) return;
231
+ if (_lastSent[ch] === val) {
232
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
233
+ return;
234
+ }
235
+ _lastSent[ch] = val;
236
+ batch.push([ch, val]);
237
+ });
238
+ if (batch.length > 0) {
239
+ const payload = dmxProfile.buildPayload(null, null, batch);
240
+ broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
241
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} batch[${batch.length}] ${payload}`);
234
242
  }
235
- _lastSent[ch] = val;
236
- broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
237
- if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
238
- });
243
+ } else {
244
+ // ── Single message per channel (etherten_v1 legacy) ────────
245
+ channels.forEach(function ([ch, val]) {
246
+ if (!ch || ch <= 0 || val == null) return;
247
+ if (_lastSent[ch] === val) {
248
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
249
+ return;
250
+ }
251
+ _lastSent[ch] = val;
252
+ const payload = dmxProfile.buildPayload
253
+ ? dmxProfile.buildPayload(ch, val, null)
254
+ : buildDmxPayload(ch, val);
255
+ if (payload === null) return;
256
+ broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
257
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
258
+ });
259
+ }
239
260
  }
240
261
 
241
262
  function resetLastSent() {
@@ -373,7 +394,11 @@ module.exports = function (RED) {
373
394
  const progress = Math.min(1, tick / totalTicks);
374
395
  const channels = fromChannels.map(function ([ch, from], i) {
375
396
  const to = toChannels[i] ? toChannels[i][1] : 0;
376
- return [ch, Math.round(from + (to - from) * progress)];
397
+ const raw = Math.round(from + (to - from) * progress);
398
+ // Apply floor during transition — never send flicker values
399
+ // Exception: if target is 0, allow 0 (light intentionally off)
400
+ const floored = (raw > 0 && raw < S.dmxFloor) ? S.dmxFloor : raw;
401
+ return [ch, floored];
377
402
  });
378
403
  sendDmxChannels(channels);
379
404
  if (tick >= totalTicks) {
@@ -561,14 +586,61 @@ module.exports = function (RED) {
561
586
 
562
587
 
563
588
 
564
- // ── Fixture identity and topics ───────────────────────────────────────
589
+
590
+ // ── DMX Controller profile ────────────────────────────────────────
591
+ let _dmxProfile;
592
+ try {
593
+ const _profiles = require('../profiles/index');
594
+ _dmxProfile = (_profiles.dmx || []).find(p => p.id === S.controllerProfile)
595
+ || _profiles.dmx[0];
596
+ } catch(e) {
597
+ // Fallback to etherten_v1 if profiles not found
598
+ _dmxProfile = {
599
+ id: 'etherten_v1',
600
+ batched: false,
601
+ buildPayload: (ch, val) =>
602
+ String(ch).padStart(3,'0') + String(val).padStart(3,'0'),
603
+ buildTopic: (cfg, universe) =>
604
+ cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', universe)
605
+ };
606
+ }
607
+ const dmxProfile = _dmxProfile;
608
+
609
+ // ── Fixture identity and topics ───────────────────────────────────────
565
610
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
611
+
612
+ // ── Fixture ID duplicate detection ──────────────────────────────────
613
+ function registerFixtureId() {
614
+ const globalCtx = node.context().global;
615
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
616
+ let reg = {};
617
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
618
+ if (reg[fixtureId] && reg[fixtureId] !== node.id) {
619
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
620
+ setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
621
+ } else {
622
+ reg[fixtureId] = node.id;
623
+ try { globalCtx.set(regKey, reg); } catch(e) {}
624
+ }
625
+ }
626
+ function unregisterFixtureId() {
627
+ const globalCtx = node.context().global;
628
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
629
+ let reg = {};
630
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
631
+ if (reg[fixtureId] === node.id) {
632
+ delete reg[fixtureId];
633
+ try { globalCtx.set(regKey, reg); } catch(e) {}
634
+ }
635
+ }
566
636
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
567
637
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
568
638
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
569
639
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
570
640
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
571
- const dmxTopic = cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
641
+ const dmxTopic = dmxProfile.buildTopic
642
+ ? dmxProfile.buildTopic(cfg, S.universe)
643
+ : cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
572
644
 
573
645
  // ── DMX channel conflict detection ───────────────────────────────────
574
646
  function checkChannelConflicts() {
@@ -613,6 +685,7 @@ module.exports = function (RED) {
613
685
 
614
686
  // ── Device add ────────────────────────────────────────────
615
687
  function handleDeviceAdd() {
688
+ registerFixtureId();
616
689
  if (ctxGet('state') === undefined && ctxGet('state', 'disk_values') === undefined) {
617
690
  ctxSet('state', S.defaultState);
618
691
  }
@@ -703,6 +776,7 @@ module.exports = function (RED) {
703
776
 
704
777
  // ── Device remove ─────────────────────────────────────────
705
778
  function handleDeviceRemove() {
779
+ unregisterFixtureId();
706
780
  clearChannelRegistry();
707
781
  stopEffect();
708
782
  if (diskTimer) {
@@ -41,8 +41,9 @@
41
41
  subLocation: { value: '' },
42
42
  // PIR specifics
43
43
  pirType: { value: 'Ceiling' },
44
- pirPayload: { value: '', required: true },
45
- subscribeTopic: { value: '', required: true },
44
+ pirPayload: { value: '', required: true },
45
+ subscribeTopic: { value: '', required: true },
46
+ controllerProfile: { value: 'mw3d_v1' },
46
47
  // Options
47
48
  haIcon: { value: 'mdi:motion-sensor' },
48
49
  cableColor: { value: 'Purple' },
@@ -237,14 +238,37 @@
237
238
  </div>
238
239
 
239
240
  <div class="form-row">
241
+ <label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
242
+ <select id="node-input-controllerProfile" style="width:65%" onchange="
243
+ var v = this.value;
244
+ document.getElementById('pir-payload-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
245
+ document.getElementById('pir-topic-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
246
+ document.getElementById('pir-v2-note').style.display = (v === 'mw3d_v2') ? '' : 'none';
247
+ ">
248
+ <option value="mw3d_v1">MW3D v1 — Legacy (panelId-GPIO, motion only)</option>
249
+ <option value="mw3d_v2">MW3D v2 — JSON (fixture ID + clear event)</option>
250
+ <option value="custom">Custom</option>
251
+ </select>
252
+ </div>
253
+
254
+ <div class="form-row" id="pir-v2-note" style="display:none">
255
+ <label></label>
256
+ <span style="color:#999; font-size:0.85em;">
257
+ <i class="fa fa-info-circle"></i>
258
+ v2 firmware — topic and payload auto-configured. Includes motion clear event
259
+ (hold timer optional). No manual payload or topic needed.
260
+ </span>
261
+ </div>
262
+
263
+ <div class="form-row" id="pir-payload-row">
240
264
  <label for="node-input-pirPayload"><i class="fa fa-podcast"></i> Payload</label>
241
- <input type="text" id="node-input-pirPayload" placeholder="Required — e.g. home/Master/PIR_Sensors" style="width:120px" />
265
+ <input type="text" id="node-input-pirPayload" placeholder="Required — e.g. 21-62" style="width:120px" />
242
266
  <span style="margin-left:8px; color:#999; font-size:0.85em;">panelId-GPIOpin</span>
243
267
  </div>
244
268
 
245
- <div class="form-row">
269
+ <div class="form-row" id="pir-topic-row">
246
270
  <label for="node-input-subscribeTopic"><i class="fa fa-exchange"></i> Subscribe topic</label>
247
- <input type="text" id="node-input-subscribeTopic" placeholder="Required — e.g. home/Master/PIR_Sensors" style="width:55%" />
271
+ <input type="text" id="node-input-subscribeTopic" placeholder="Required — e.g. MW3D/Master/PIR_Sensors" style="width:55%" />
248
272
  </div>
249
273
 
250
274
  <hr/>
@@ -294,7 +318,7 @@
294
318
  </div>
295
319
 
296
320
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
297
- node-red-contrib-dmx-for-ha &nbsp;v0.5.2
321
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
298
322
  </div>
299
323
 
300
324
  </script>