node-red-contrib-dmx-for-ha 0.5.2 → 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 (66) 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-dmx-group.html +1 -1
  4. package/nodes/ha-mqtt-dmx-group.js +26 -0
  5. package/nodes/ha-mqtt-dmx.html +34 -3
  6. package/nodes/ha-mqtt-dmx.js +87 -14
  7. package/nodes/ha-mqtt-pir.html +30 -6
  8. package/nodes/ha-mqtt-pir.js +72 -9
  9. package/nodes/ha-mqtt-relay.html +15 -4
  10. package/nodes/ha-mqtt-relay.js +54 -4
  11. package/package.json +16 -9
  12. package/profiles/README.md +105 -0
  13. package/profiles/button/custom.js +17 -0
  14. package/profiles/button/mw3d_v1.js +37 -0
  15. package/profiles/button/mw3d_v2.js +36 -0
  16. package/profiles/dmx/custom.js +65 -0
  17. package/profiles/dmx/etherten_v1.js +26 -0
  18. package/profiles/dmx/etherten_v2.js +32 -0
  19. package/profiles/dmx/mqtt_json.js +27 -0
  20. package/profiles/index.js +45 -0
  21. package/profiles/pir/custom.js +15 -0
  22. package/profiles/pir/mw3d_v1.js +34 -0
  23. package/profiles/pir/mw3d_v2.js +38 -0
  24. package/profiles/relay/bedrock_v1.js +17 -0
  25. package/profiles/relay/custom.js +26 -0
  26. package/profiles/relay/generic_onoff.js +16 -0
  27. package/profiles/relay/mqtt_json.js +15 -0
  28. package/docs/config_node_spec.md +0 -236
  29. package/docs/dmx_node_env_reference.md +0 -341
  30. package/docs/master_todo.md +0 -428
  31. package/docs/node_contracts.md +0 -278
  32. package/docs/nr_subflow_gotchas.md +0 -258
  33. package/node-red-contrib-dmx-for-ha/LICENSE +0 -28
  34. package/node-red-contrib-dmx-for-ha/README.md +0 -587
  35. package/node-red-contrib-dmx-for-ha/docs/config_node_spec.md +0 -236
  36. package/node-red-contrib-dmx-for-ha/docs/dmx_node_env_reference.md +0 -341
  37. package/node-red-contrib-dmx-for-ha/docs/master_todo.md +0 -428
  38. package/node-red-contrib-dmx-for-ha/docs/node_contracts.md +0 -278
  39. package/node-red-contrib-dmx-for-ha/docs/nr_subflow_gotchas.md +0 -258
  40. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-button.html +0 -326
  41. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-button.js +0 -284
  42. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-config.html +0 -270
  43. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-config.js +0 -99
  44. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx-group.html +0 -387
  45. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx-group.js +0 -410
  46. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx.html +0 -618
  47. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-dmx.js +0 -808
  48. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-pir.html +0 -337
  49. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-pir.js +0 -306
  50. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-relay.html +0 -329
  51. package/node-red-contrib-dmx-for-ha/nodes/ha-mqtt-relay.js +0 -424
  52. package/node-red-contrib-dmx-for-ha/package.json +0 -39
  53. package/node-red-contrib-dmx-for-ha/subflow/README.md +0 -35
  54. package/node-red-contrib-dmx-for-ha/subflow/button_node_v5.0.3.js +0 -324
  55. package/node-red-contrib-dmx-for-ha/subflow/dmx_group_node_v0.3.8.js +0 -860
  56. package/node-red-contrib-dmx-for-ha/subflow/dmx_node_v0.5.9.js +0 -1994
  57. package/node-red-contrib-dmx-for-ha/subflow/pir_node_v1.0.3.js +0 -365
  58. package/node-red-contrib-dmx-for-ha/subflow/relay_node_v4.0.2.js +0 -553
  59. package/node-red-contrib-dmx-for-ha/subflow/subflow_definitions.json +0 -6154
  60. package/subflow/README.md +0 -35
  61. package/subflow/button_node_v5.0.3.js +0 -324
  62. package/subflow/dmx_group_node_v0.3.8.js +0 -860
  63. package/subflow/dmx_node_v0.5.9.js +0 -1994
  64. package/subflow/pir_node_v1.0.3.js +0 -365
  65. package/subflow/relay_node_v4.0.2.js +0 -553
  66. 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
  });
@@ -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 },
@@ -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.5.2
587
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.1
557
588
  </div>
558
589
 
559
590
  </script>
@@ -124,7 +124,8 @@ 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,
@@ -224,18 +225,41 @@ module.exports = function (RED) {
224
225
  const _lastSent = {};
225
226
 
226
227
  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;
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}`);
234
245
  }
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
- });
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
+ }
239
263
  }
240
264
 
241
265
  function resetLastSent() {
@@ -561,14 +585,61 @@ module.exports = function (RED) {
561
585
 
562
586
 
563
587
 
564
- // ── 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 ───────────────────────────────────────
565
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
+ }
566
635
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
567
636
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
568
637
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
569
638
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
570
639
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
571
- 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);
572
643
 
573
644
  // ── DMX channel conflict detection ───────────────────────────────────
574
645
  function checkChannelConflicts() {
@@ -613,6 +684,7 @@ module.exports = function (RED) {
613
684
 
614
685
  // ── Device add ────────────────────────────────────────────
615
686
  function handleDeviceAdd() {
687
+ registerFixtureId();
616
688
  if (ctxGet('state') === undefined && ctxGet('state', 'disk_values') === undefined) {
617
689
  ctxSet('state', S.defaultState);
618
690
  }
@@ -703,6 +775,7 @@ module.exports = function (RED) {
703
775
 
704
776
  // ── Device remove ─────────────────────────────────────────
705
777
  function handleDeviceRemove() {
778
+ unregisterFixtureId();
706
779
  clearChannelRegistry();
707
780
  stopEffect();
708
781
  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>
@@ -100,8 +100,9 @@ module.exports = function (RED) {
100
100
  uid: config.uid || '',
101
101
  uidPostfix: config.uidPostfix || '',
102
102
  pirType: config.pirType || 'Ceiling',
103
- pirPayload: String(config.pirPayload || ''),
104
- subscribeTopic: config.subscribeTopic || '',
103
+ pirPayload: String(config.pirPayload || ''),
104
+ subscribeTopic: config.subscribeTopic || '',
105
+ controllerProfile: config.controllerProfile || 'mw3d_v1',
105
106
  area: config.area || '',
106
107
  situation: config.situation || 'in',
107
108
  subLocation: config.subLocation || '',
@@ -127,8 +128,50 @@ module.exports = function (RED) {
127
128
  }
128
129
 
129
130
 
130
- // ── Fixture identity and topics ───────────────────────────────────────
131
+
132
+ // ── Controller profile ────────────────────────────────────────────
133
+ let _pirProfile;
134
+ try {
135
+ const _profiles = require('../profiles/index');
136
+ _pirProfile = (_profiles.pir || []).find(p => p.id === S.controllerProfile)
137
+ || _profiles.pir[0];
138
+ } catch(e) {
139
+ _pirProfile = {
140
+ id: 'mw3d_v1',
141
+ buildTopic: () => null,
142
+ parsePayload: (raw, fid, exp) => raw === exp ? { match: true, state: 'on' } : null,
143
+ hasOffEvent: false
144
+ };
145
+ }
146
+ const pirProfile = _pirProfile;
147
+
148
+ // ── Fixture identity and topics ───────────────────────────────────────
131
149
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
150
+
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
+ }
132
175
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
133
176
  const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
134
177
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
@@ -183,6 +226,14 @@ module.exports = function (RED) {
183
226
  node.log(`${fixtureId} motion detected`);
184
227
  }
185
228
 
229
+ function handleClear() {
230
+ // Explicit motion clear from v2 firmware
231
+ lastTrigger = 0;
232
+ pub(statTopic, 'OFF', false);
233
+ setStatus('green', 'ring', `${fixtureId} motion cleared`);
234
+ node.log(`${fixtureId} motion cleared`);
235
+ }
236
+
186
237
  // ── Availability ──────────────────────────────────────────
187
238
  function handleAvailability(payload) {
188
239
  const status = String(payload || '').toLowerCase();
@@ -199,6 +250,7 @@ module.exports = function (RED) {
199
250
 
200
251
  // ── Device add ────────────────────────────────────────────
201
252
  function handleDeviceAdd() {
253
+ registerFixtureId();
202
254
  const discovery = {
203
255
  unique_id: fixtureId,
204
256
  object_id: objectId,
@@ -226,10 +278,20 @@ module.exports = function (RED) {
226
278
 
227
279
  pub(cfgTopic, discovery, cfg.retainDiscovery);
228
280
 
229
- // Subscribe to controller topic
230
- broker.subscribe(S.subscribeTopic, cfg.qos, function (topic, payload) {
231
- const str = payload.toString();
232
- if (str === S.pirPayload) handleTrigger();
281
+ // Subscribe to controller topic — topic and matching via profile
282
+ const _pirSubTopic = pirProfile.buildTopic ? pirProfile.buildTopic(cfg) : null;
283
+ const activePirTopic = _pirSubTopic || S.subscribeTopic;
284
+ S._activePirTopic = activePirTopic;
285
+
286
+ broker.subscribe(activePirTopic, cfg.qos, function (topic, payload) {
287
+ const result = pirProfile.parsePayload(
288
+ payload.toString(), fixtureId, S.pirPayload
289
+ );
290
+ if (result && result.match) {
291
+ if (result.state === 'on') handleTrigger();
292
+ // v2 firmware sends explicit off — handle motion clear
293
+ else if (result.state === 'off' && pirProfile.hasOffEvent) handleClear();
294
+ }
233
295
  }, node.id);
234
296
 
235
297
  setStatus('green', 'ring', `${fixtureId} discovery sent`);
@@ -239,10 +301,11 @@ module.exports = function (RED) {
239
301
 
240
302
  // ── Device remove ─────────────────────────────────────────
241
303
  function handleDeviceRemove() {
304
+ unregisterFixtureId();
242
305
  cancelWarmup();
243
306
  pub(avtyTopic, 'offline', true);
244
307
  pub(cfgTopic, '', true);
245
- broker.unsubscribe(S.subscribeTopic, node.id);
308
+ broker.unsubscribe(S._activePirTopic || S.subscribeTopic, node.id);
246
309
  setStatus('red', 'ring', `${fixtureId} removed`);
247
310
  node.log(`${fixtureId} device removed`);
248
311
  }
@@ -269,7 +332,7 @@ module.exports = function (RED) {
269
332
  // ── Cleanup ───────────────────────────────────────────────
270
333
  node.on('close', function (done) {
271
334
  cancelWarmup();
272
- broker.unsubscribe(S.subscribeTopic, node.id);
335
+ broker.unsubscribe(S._activePirTopic || S.subscribeTopic, node.id);
273
336
  broker.deregister(node, done);
274
337
  });
275
338
  }