node-red-contrib-dmx-for-ha 0.2.4 → 0.3.4

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 CHANGED
@@ -231,6 +231,45 @@ Guest Wing config → Zone: GuestWing → topics: home/GuestWing/dmx/1
231
231
 
232
232
  ---
233
233
 
234
+ ## Transition rate tuning
235
+
236
+ The config node has two global transition settings that apply to all DMX nodes in the zone:
237
+
238
+ ### Transition rate limit
239
+
240
+ Controls the tick rate multiplier for all transitions and effects:
241
+
242
+ ```
243
+ ticksPerSec (per node) × rateLimit (config) = effectiveTicks/sec
244
+ ```
245
+
246
+ | Rate Limit | Effective ticks/sec | MQTT msgs/sec (RGBW × 30 nodes) | Use case |
247
+ |---|---|---|---|
248
+ | 1.0 | 31 | ~3,700 | Small deployment, fast hardware |
249
+ | 0.5 | 15 | ~1,800 | Medium deployment, balanced |
250
+ | 0.25 | 7 | ~840 | Large deployment, light load |
251
+ | 0.1 | 3 | ~360 | Maximum scale, minimal load |
252
+
253
+ Reduce `transitionRateLimit` if you observe:
254
+ - MQTT broker lag or dropped messages
255
+ - NR event loop warnings
256
+ - DMX controller missing channel updates
257
+ - Sluggish HA response during scene changes
258
+
259
+ ### HA UI transition time
260
+
261
+ Fallback transition duration (seconds) used when HA sends a command with no
262
+ transition specified. Default 1 second.
263
+
264
+ ```
265
+ HA sends transition=3 → use 3 seconds
266
+ HA sends no transition → use haUiTime (default 1s)
267
+ ```
268
+
269
+ Set to `0` to make all un-timed commands instant.
270
+
271
+ ---
272
+
234
273
  ## Troubleshooting
235
274
 
236
275
  **Nodes don't appear in palette** — Restart Node-RED. Check log for errors.
@@ -47,6 +47,7 @@
47
47
  haIcon: { value: 'mdi:gesture-tap-button' },
48
48
  ledColor: { value: 'Blue' },
49
49
  holdTime: { value: '0.5' },
50
+ debugMode: { value: false },
50
51
  },
51
52
 
52
53
  label: function () {
@@ -275,6 +276,18 @@
275
276
  <span style="margin-left:8px; color:#999; font-size:0.85em;">HA auto-clears binary_sensor after this delay</span>
276
277
  </div>
277
278
 
279
+ <div class="form-row">
280
+ <label>&nbsp;</label>
281
+ <input type="checkbox" id="node-input-debugMode"
282
+ style="width:auto; margin-right:8px" />
283
+ <label for="node-input-debugMode" style="width:auto">
284
+ Debug output to NR debug tab
285
+ </label>
286
+ <div style="margin-left:106px; margin-top:4px; color:#e74c3c; font-size:0.8em;">
287
+ ⚠ Disable in production — logs every MQTT publish. Auto-disables after 12hrs.
288
+ </div>
289
+ </div>
290
+
278
291
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
279
292
  node-red-contrib-dmx-for-ha &nbsp;v0.2.2
280
293
  </div>
@@ -37,14 +37,32 @@ module.exports = function (RED) {
37
37
  }, 1500);
38
38
  }
39
39
 
40
- // Track if already discovered to prevent double-fire
40
+ // Track discovery state prevents double-fire and allows reconnect re-discovery
41
41
  let _discovered = false;
42
+ let _lastDiscoveryTime = 0;
43
+ const _DISCOVERY_COOLDOWN_MS = 5000; // 5 second cooldown between discoveries
44
+
42
45
  function _tryDiscover() {
43
46
  if (_discovered) return;
47
+ const now = Date.now();
48
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) return;
44
49
  _discovered = true;
50
+ _lastDiscoveryTime = now;
45
51
  autoDiscover();
46
52
  }
47
53
 
54
+ function _manualDiscover() {
55
+ // Manual trigger (canvas button or input msg) — bypass _discovered flag
56
+ // but still respect cooldown to prevent accidental rapid-fire
57
+ const now = Date.now();
58
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) {
59
+ node.warn('Discovery cooldown active — please wait 5 seconds between manual triggers');
60
+ return;
61
+ }
62
+ _lastDiscoveryTime = now;
63
+ handleDeviceAdd();
64
+ }
65
+
48
66
  // Auto-discover if broker already connected on deploy
49
67
  if (broker.connected) {
50
68
  _tryDiscover();
@@ -54,12 +72,30 @@ module.exports = function (RED) {
54
72
  _tryDiscover();
55
73
  });
56
74
  broker.on('close', function () {
75
+ _discovered = false; // Allow re-discovery on reconnect
57
76
  setStatus('red', 'ring', 'Broker disconnected');
58
77
  });
59
78
  broker.on('error', function (err) {
60
79
  node.warn('MQTT broker error: ' + (err.message || err));
61
80
  setStatus('red', 'dot', 'Broker error — check config');
62
81
  });
82
+ // ── Debug mode safeguards ─────────────────────────────────────────
83
+ if (S.debugMode) {
84
+ // Permanent canvas warning so debug mode is obvious
85
+ setStatus('red', 'dot', `ha-mqtt-button "${fixtureId}" ⚠ DEBUG MODE ON`);
86
+ node.warn(`[DEBUG] ha-mqtt-button "${fixtureId}" — debug mode is enabled. Disable in production.`);
87
+
88
+ // Auto-disable after 12 hours — safety net for forgotten debug sessions
89
+ setTimeout(function () {
90
+ if (S.debugMode) {
91
+ S.debugMode = false;
92
+ node.warn(`[DEBUG] ha-mqtt-button "${fixtureId}" — debug mode auto-disabled after 12 hours`);
93
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
94
+ }
95
+ }, 12 * 60 * 60 * 1000);
96
+ }
97
+
98
+
63
99
  // Fallback — if connect event already fired before listener registered
64
100
  setTimeout(function () {
65
101
  _tryDiscover();
@@ -83,12 +119,13 @@ module.exports = function (RED) {
83
119
  haIcon: config.haIcon || 'mdi:gesture-tap-button',
84
120
  ledColor: config.ledColor || 'Blue',
85
121
  holdTime: parseFloat(config.holdTime) || 0.5,
122
+ debugMode: config.debugMode === true,
86
123
  };
87
124
 
88
125
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
89
126
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
90
- const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
91
- const uiBtnTopic = `${cfg.discoveryPrefix}/button/${fixtureId}-BTN`;
127
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
128
+ const uiBtnTopic = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureId + '-BTN');
92
129
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
93
130
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
94
131
  const uiBtnCfgTopic = `${uiBtnTopic}/${cfg.configTopic}`;
@@ -96,12 +133,14 @@ module.exports = function (RED) {
96
133
 
97
134
  // ── Helpers ───────────────────────────────────────────────
98
135
  function pub(topic, payload, retain) {
136
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
99
137
  broker.publish({
100
138
  topic,
101
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
139
+ payload: strPayload,
102
140
  qos: cfg.qos,
103
141
  retain: retain !== undefined ? retain : cfg.retain,
104
142
  });
143
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
105
144
  }
106
145
 
107
146
  function setStatus(fill, shape, text) {
@@ -121,17 +160,17 @@ module.exports = function (RED) {
121
160
  const bsDiscovery = {
122
161
  unique_id: fixtureId,
123
162
  object_id: objectId,
124
- name: `${S.buttonPosition} button ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
163
+ name: `button ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
125
164
  stat_t: statTopic,
126
165
  off_delay: S.holdTime,
127
166
  enabled_by_default: true,
128
167
  icon: S.haIcon,
129
168
  device: {
130
169
  identifiers: `binary_sensor-${fixtureId}`,
131
- name: `(${fixtureId}) - Wall Button ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
170
+ name: `(${fixtureId}) - Wall Button ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
132
171
  model: `Wall button located ${S.situation} the ${cfg.zone} - ${S.area}`,
133
172
  model_id: `referenced on plan as: (${fixtureId}`,
134
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
173
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
135
174
  hw_version: `Wall button — ${S.ledColor} LED. Publishes "${S.buttonPayload}" on topic: ${S.subscribeTopic}`,
136
175
  serial_number: `(${fixtureId}) Payload: ${S.buttonPayload}`,
137
176
  sw_version: `ha-mqtt-button: ${_pkgVer}`,
@@ -143,7 +182,7 @@ module.exports = function (RED) {
143
182
  const uiDiscovery = {
144
183
  unique_id: `${fixtureId}-BTN`,
145
184
  object_id: `${objectId}_btn`,
146
- name: `${S.buttonPosition} (UI) ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
185
+ name: `${S.buttonPosition} (UI) ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
147
186
  cmd_t: uiBtnCmdTopic,
148
187
  payload_press: 'PRESS',
149
188
  enabled_by_default: true,
@@ -187,7 +226,7 @@ module.exports = function (RED) {
187
226
 
188
227
  if (devReq) {
189
228
  switch (devReq) {
190
- case 'add': handleDeviceAdd(); break;
229
+ case 'add': _manualDiscover(); break;
191
230
  case 'remove': handleDeviceRemove(); break;
192
231
  default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
193
232
  }
@@ -206,6 +245,19 @@ module.exports = function (RED) {
206
245
  }
207
246
 
208
247
 
248
+
249
+ // ── Location string helper ─────────────────────────────────────────────
250
+ function buildLocation(zone, area, subLoc) {
251
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
252
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
253
+ const areaClean = (area || '').trim();
254
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
255
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
256
+ return `${zone} ${areaClean}`.trim();
257
+ }
258
+ return `${zone} ${areaClean} - ${sub}`.trim();
259
+ }
260
+
209
261
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
210
262
  RED.httpAdmin.post('/ha-mqtt-button/:id/toggle', RED.auth.needsPermission('ha-mqtt-button.write'), function(req, res) {
211
263
  const n = RED.nodes.getNode(req.params.id);
@@ -10,7 +10,7 @@
10
10
  defaults: {
11
11
  name: { value: '', required: true },
12
12
  siteId: { value: '', required: true },
13
- zone: { value: '', required: true },
13
+ zone: { value: '' },
14
14
  broker: { value: '', type: 'mqtt-broker', required: true },
15
15
  discoveryPrefix: { value: 'homeassistant' },
16
16
  qos: { value: '0' },
@@ -80,11 +80,12 @@
80
80
  <i class="fa fa-location-arrow"></i> Zone
81
81
  </label>
82
82
  <input type="text" id="node-config-input-zone"
83
- placeholder="e.g. Master, BnB, GuestWing"
83
+ placeholder="Optional — e.g. Master, BnB, GarageApt"
84
84
  style="width:60%" />
85
85
  <div style="margin-left:106px; margin-top:4px; color:#999; font-size:0.85em;">
86
- Physical zone name used in your MQTT topics — e.g. <code>Master</code>, <code>BnB</code>, <code>GuestWing</code>.
87
- Keep it short with no spaces. This can be updated later if needed.
86
+ <strong>Optional</strong> leave blank for a single-zone home. Topics become <code>{siteId}/dmx/1</code>.<br/>
87
+ Use for multiple buildings or areas e.g. <code>Master</code>, <code>BnB</code>, <code>GarageApt</code>.
88
+ This can be updated later if needed.
88
89
  </div>
89
90
  </div>
90
91
 
@@ -61,6 +61,14 @@ module.exports = function (RED) {
61
61
  this.transitionRateLimit = parseFloat(config.transitionRateLimit) || 1;
62
62
  this.transitionHaUiTime = parseFloat(config.transitionHaUiTime) || 1;
63
63
 
64
+ // ── Topic builder — omits empty zone segment ─────────────────────────
65
+ this.buildTopic = function() {
66
+ const segments = Array.from(arguments).filter(function(s) {
67
+ return s !== null && s !== undefined && String(s).trim().length > 0;
68
+ });
69
+ return segments.join('/');
70
+ };
71
+
64
72
  // Expose a convenience settings object for child nodes
65
73
  this.settings = {
66
74
  siteId: this.siteId,
@@ -49,6 +49,7 @@
49
49
  defaultState: { value: 'OFF' },
50
50
  // Advanced
51
51
  maxDepth: { value: '10' },
52
+ debugMode: { value: false },
52
53
  },
53
54
 
54
55
  label: function () {
@@ -303,6 +304,18 @@
303
304
  <span style="margin-left:8px; color:#999; font-size:0.85em;">0 = disable loop detection</span>
304
305
  </div>
305
306
 
307
+ <div class="form-row">
308
+ <label>&nbsp;</label>
309
+ <input type="checkbox" id="node-input-debugMode"
310
+ style="width:auto; margin-right:8px" />
311
+ <label for="node-input-debugMode" style="width:auto">
312
+ Debug output to NR debug tab
313
+ </label>
314
+ <div style="margin-left:106px; margin-top:4px; color:#e74c3c; font-size:0.8em;">
315
+ ⚠ Disable in production — logs every MQTT publish. Auto-disables after 12hrs.
316
+ </div>
317
+ </div>
318
+
306
319
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
307
320
  node-red-contrib-dmx-for-ha &nbsp;v0.2.2
308
321
  </div>
@@ -40,14 +40,32 @@ module.exports = function (RED) {
40
40
  }, 1500);
41
41
  }
42
42
 
43
- // Track if already discovered to prevent double-fire
43
+ // Track discovery state prevents double-fire and allows reconnect re-discovery
44
44
  let _discovered = false;
45
+ let _lastDiscoveryTime = 0;
46
+ const _DISCOVERY_COOLDOWN_MS = 5000; // 5 second cooldown between discoveries
47
+
45
48
  function _tryDiscover() {
46
49
  if (_discovered) return;
50
+ const now = Date.now();
51
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) return;
47
52
  _discovered = true;
53
+ _lastDiscoveryTime = now;
48
54
  autoDiscover();
49
55
  }
50
56
 
57
+ function _manualDiscover() {
58
+ // Manual trigger (canvas button or input msg) — bypass _discovered flag
59
+ // but still respect cooldown to prevent accidental rapid-fire
60
+ const now = Date.now();
61
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) {
62
+ node.warn('Discovery cooldown active — please wait 5 seconds between manual triggers');
63
+ return;
64
+ }
65
+ _lastDiscoveryTime = now;
66
+ handleDeviceAdd();
67
+ }
68
+
51
69
  // Auto-discover if broker already connected on deploy
52
70
  if (broker.connected) {
53
71
  _tryDiscover();
@@ -57,12 +75,30 @@ module.exports = function (RED) {
57
75
  _tryDiscover();
58
76
  });
59
77
  broker.on('close', function () {
78
+ _discovered = false; // Allow re-discovery on reconnect
60
79
  setStatus('red', 'ring', 'Broker disconnected');
61
80
  });
62
81
  broker.on('error', function (err) {
63
82
  node.warn('MQTT broker error: ' + (err.message || err));
64
83
  setStatus('red', 'dot', 'Broker error — check config');
65
84
  });
85
+ // ── Debug mode safeguards ─────────────────────────────────────────
86
+ if (S.debugMode) {
87
+ // Permanent canvas warning so debug mode is obvious
88
+ setStatus('red', 'dot', `ha-mqtt-dmx-group "${groupId}" ⚠ DEBUG MODE ON`);
89
+ node.warn(`[DEBUG] ha-mqtt-dmx-group "${groupId}" — debug mode is enabled. Disable in production.`);
90
+
91
+ // Auto-disable after 12 hours — safety net for forgotten debug sessions
92
+ setTimeout(function () {
93
+ if (S.debugMode) {
94
+ S.debugMode = false;
95
+ node.warn(`[DEBUG] ha-mqtt-dmx-group "${groupId}" — debug mode auto-disabled after 12 hours`);
96
+ setStatus('yellow', 'ring', `${groupId} ready — awaiting HA`);
97
+ }
98
+ }, 12 * 60 * 60 * 1000);
99
+ }
100
+
101
+
66
102
  // Fallback — if connect event already fired before listener registered
67
103
  setTimeout(function () {
68
104
  _tryDiscover();
@@ -95,7 +131,7 @@ module.exports = function (RED) {
95
131
 
96
132
  const groupId = `LG-${S.uid}${S.uidPostfix}`;
97
133
  const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
98
- const groupTopic = `${cfg.discoveryPrefix}/light/${groupId}`;
134
+ const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
99
135
  const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
100
136
  const statTopic = `${groupTopic}/${cfg.stateTopic}`;
101
137
  const cmdTopic = `${groupTopic}/${cfg.commandTopic}`;
@@ -129,12 +165,14 @@ module.exports = function (RED) {
129
165
 
130
166
  // ── MQTT helpers ──────────────────────────────────────────
131
167
  function pub(topic, payload, retain) {
168
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
132
169
  broker.publish({
133
170
  topic,
134
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
171
+ payload: strPayload,
135
172
  qos: cfg.qos,
136
173
  retain: retain !== undefined ? retain : cfg.retain,
137
174
  });
175
+ if (S.debugMode) node.warn(`[DEBUG] ${groupId} → ${topic} ${strPayload.substring(0, 120)}`);
138
176
  }
139
177
 
140
178
  function pubState(payload) {
@@ -221,13 +259,13 @@ module.exports = function (RED) {
221
259
  max_mireds: 500,
222
260
  stat_t: statTopic,
223
261
  cmd_t: cmdTopic,
224
- name: S.groupName || `${S.deviceType} Group ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
262
+ name: S.groupName || `${S.deviceType} Group ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
225
263
  device: {
226
264
  identifiers: `light-${groupId}`,
227
- name: `(${groupId}) - ${S.deviceType} Group ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
265
+ name: `(${groupId}) - ${S.deviceType} Group ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
228
266
  model: `${S.colorMode} ${S.deviceType} Group located ${S.situation} the ${cfg.zone} - ${S.area}`,
229
267
  model_id: `referenced on plan as: (${groupId}`,
230
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
268
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
231
269
  hw_version: 'Virtual fixture — exists as a DMX Group Node in Node-RED',
232
270
  sw_version: 'ha-mqtt-dmx-group: ' + _pkgVer,
233
271
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
@@ -301,7 +339,7 @@ module.exports = function (RED) {
301
339
 
302
340
  if (devReq) {
303
341
  switch (devReq) {
304
- case 'add': handleDeviceAdd(null); break;
342
+ case 'add': _manualDiscover(); break;
305
343
  case 'remove': handleDeviceRemove(null); break;
306
344
  default: node.warn(`${groupId} — unknown device.request: "${devReq}"`);
307
345
  }
@@ -327,6 +365,19 @@ module.exports = function (RED) {
327
365
  }
328
366
 
329
367
 
368
+
369
+ // ── Location string helper ─────────────────────────────────────────────
370
+ function buildLocation(zone, area, subLoc) {
371
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
372
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
373
+ const areaClean = (area || '').trim();
374
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
375
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
376
+ return `${zone} ${areaClean}`.trim();
377
+ }
378
+ return `${zone} ${areaClean} - ${sub}`.trim();
379
+ }
380
+
330
381
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
331
382
  RED.httpAdmin.post('/ha-mqtt-dmx-group/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx-group.write'), function(req, res) {
332
383
  const n = RED.nodes.getNode(req.params.id);
@@ -171,6 +171,7 @@
171
171
  minOutput: { value: '1' },
172
172
  brightBump: { value: '50' },
173
173
  ticksPerSec: { value: '31' },
174
+ debugMode: { value: false },
174
175
  },
175
176
 
176
177
  label: function () {
@@ -539,6 +540,18 @@
539
540
  <span style="margin-left:8px; color:#999; font-size:0.85em;">Default 31</span>
540
541
  </div>
541
542
 
543
+ <div class="form-row">
544
+ <label>&nbsp;</label>
545
+ <input type="checkbox" id="node-input-debugMode"
546
+ style="width:auto; margin-right:8px" />
547
+ <label for="node-input-debugMode" style="width:auto">
548
+ Debug output to NR debug tab
549
+ </label>
550
+ <div style="margin-left:106px; margin-top:4px; color:#e74c3c; font-size:0.8em;">
551
+ ⚠ Disable in production — logs every MQTT publish. Auto-disables after 12hrs.
552
+ </div>
553
+ </div>
554
+
542
555
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
543
556
  node-red-contrib-dmx-for-ha &nbsp;v0.2.2
544
557
  </div>
@@ -47,14 +47,32 @@ module.exports = function (RED) {
47
47
  }, 1500);
48
48
  }
49
49
 
50
- // Track if already discovered to prevent double-fire
50
+ // Track discovery state prevents double-fire and allows reconnect re-discovery
51
51
  let _discovered = false;
52
+ let _lastDiscoveryTime = 0;
53
+ const _DISCOVERY_COOLDOWN_MS = 5000; // 5 second cooldown between discoveries
54
+
52
55
  function _tryDiscover() {
53
56
  if (_discovered) return;
57
+ const now = Date.now();
58
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) return;
54
59
  _discovered = true;
60
+ _lastDiscoveryTime = now;
55
61
  autoDiscover();
56
62
  }
57
63
 
64
+ function _manualDiscover() {
65
+ // Manual trigger (canvas button or input msg) — bypass _discovered flag
66
+ // but still respect cooldown to prevent accidental rapid-fire
67
+ const now = Date.now();
68
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) {
69
+ node.warn('Discovery cooldown active — please wait 5 seconds between manual triggers');
70
+ return;
71
+ }
72
+ _lastDiscoveryTime = now;
73
+ handleDeviceAdd();
74
+ }
75
+
58
76
  // Auto-discover if broker already connected on deploy
59
77
  if (broker.connected) {
60
78
  _tryDiscover();
@@ -64,12 +82,30 @@ module.exports = function (RED) {
64
82
  _tryDiscover();
65
83
  });
66
84
  broker.on('close', function () {
85
+ _discovered = false; // Allow re-discovery on reconnect
67
86
  setStatus('red', 'ring', 'Broker disconnected');
68
87
  });
69
88
  broker.on('error', function (err) {
70
89
  node.warn('MQTT broker error: ' + (err.message || err));
71
90
  setStatus('red', 'dot', 'Broker error — check config');
72
91
  });
92
+ // ── Debug mode safeguards ─────────────────────────────────────────
93
+ if (S.debugMode) {
94
+ // Permanent canvas warning so debug mode is obvious
95
+ setStatus('red', 'dot', `ha-mqtt-dmx "${fixtureId}" ⚠ DEBUG MODE ON`);
96
+ node.warn(`[DEBUG] ha-mqtt-dmx "${fixtureId}" — debug mode is enabled. Disable in production.`);
97
+
98
+ // Auto-disable after 12 hours — safety net for forgotten debug sessions
99
+ setTimeout(function () {
100
+ if (S.debugMode) {
101
+ S.debugMode = false;
102
+ node.warn(`[DEBUG] ha-mqtt-dmx "${fixtureId}" — debug mode auto-disabled after 12 hours`);
103
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
104
+ }
105
+ }, 12 * 60 * 60 * 1000);
106
+ }
107
+
108
+
73
109
  // Fallback — if connect event already fired before listener registered
74
110
  setTimeout(function () {
75
111
  _tryDiscover();
@@ -108,6 +144,10 @@ module.exports = function (RED) {
108
144
  minOutput: parseInt(config.minOutput) || 1,
109
145
  brightBump: parseInt(config.brightBump) || 50,
110
146
  ticksPerSec: parseInt(config.ticksPerSec) || 31,
147
+ debugMode: config.debugMode === true,
148
+ // Global transition controls from config node
149
+ rateLimit: cfg.transitionRateLimit || 1,
150
+ haUiTime: cfg.transitionHaUiTime || 1,
111
151
  flashShort: cfg.flashShort,
112
152
  flashLong: cfg.flashLong,
113
153
  diskDelay: cfg.diskDelay,
@@ -115,11 +155,11 @@ module.exports = function (RED) {
115
155
 
116
156
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
117
157
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
118
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
158
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
119
159
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
120
160
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
121
161
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
122
- const dmxTopic = `${cfg.siteId}/${cfg.zone}/dmx/${S.universe}`;
162
+ const dmxTopic = cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
123
163
 
124
164
  // ── Context helpers ───────────────────────────────────────
125
165
  // Check disk store available once on startup
@@ -162,26 +202,45 @@ module.exports = function (RED) {
162
202
  }
163
203
 
164
204
  function buildDmxPayload(channel, value) {
165
- if (channel == null || value == null) return null;
205
+ // Skip channels with no address configured (0 or null)
206
+ if (!channel || channel <= 0 || value == null) return null;
166
207
  return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
167
208
  }
168
209
 
210
+ // Fix 2: Cache last sent value per channel — skip publish if unchanged
211
+ const _lastSent = {};
212
+
169
213
  function sendDmxChannels(channels) {
170
214
  channels.forEach(function ([ch, val]) {
171
215
  const payload = buildDmxPayload(ch, val);
172
216
  if (payload === null) return;
217
+ // Skip if value unchanged since last publish
218
+ if (_lastSent[ch] === val) {
219
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
220
+ return;
221
+ }
222
+ _lastSent[ch] = val;
173
223
  broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
224
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
174
225
  });
175
226
  }
176
227
 
228
+ function resetLastSent() {
229
+ // Clear cache so next send forces a full re-publish
230
+ // Used on device:add to ensure controller gets current state
231
+ Object.keys(_lastSent).forEach(k => delete _lastSent[k]);
232
+ }
233
+
177
234
  // ── MQTT helpers ──────────────────────────────────────────
178
235
  function pub(topic, payload, retain) {
236
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
179
237
  broker.publish({
180
238
  topic,
181
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
239
+ payload: strPayload,
182
240
  qos: cfg.qos,
183
241
  retain: retain !== undefined ? retain : cfg.retain,
184
242
  });
243
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
185
244
  }
186
245
 
187
246
  function pubState(payload) {
@@ -261,23 +320,40 @@ module.exports = function (RED) {
261
320
  // ── Transition ────────────────────────────────────────────
262
321
  function runTransition(fromChannels, toChannels, durationSecs) {
263
322
  stopEffect();
264
- const totalTicks = Math.round(durationSecs * S.ticksPerSec);
265
- const intervalMs = Math.round(1000 / S.ticksPerSec);
323
+ // Apply global rate limit from config node
324
+ // rateLimit < 1 = slower transitions, rateLimit > 1 = faster
325
+ const effectiveTicks = Math.max(1, Math.round(S.ticksPerSec * S.rateLimit));
326
+
327
+ // Fix 4: Skip micro-transitions — snap to target if too short to animate
328
+ const minDuration = 2 / effectiveTicks;
329
+ if (durationSecs < minDuration) {
330
+ sendDmxChannels(toChannels);
331
+ return;
332
+ }
333
+
334
+ const totalTicks = Math.round(durationSecs * effectiveTicks);
335
+ const intervalMs = Math.round(1000 / effectiveTicks);
266
336
  let tick = 0;
267
337
 
268
- effectTimer = setInterval(function () {
269
- tick++;
270
- const progress = Math.min(1, tick / totalTicks);
271
- const channels = fromChannels.map(function ([ch, from], i) {
272
- const to = toChannels[i] ? toChannels[i][1] : 0;
273
- return [ch, Math.round(from + (to - from) * progress)];
274
- });
275
- sendDmxChannels(channels);
276
- if (tick >= totalTicks) {
277
- stopEffect();
278
- sendDmxChannels(toChannels);
279
- }
280
- }, intervalMs);
338
+ // Fix 3: Random jitter (0 to 1 interval) to stagger NR event loop
339
+ // Prevents thundering herd when many nodes start transitions simultaneously
340
+ const jitter = Math.random() * intervalMs;
341
+ // Fix 3: Jitter delay spreads load across event loop
342
+ setTimeout(function () {
343
+ effectTimer = setInterval(function () {
344
+ tick++;
345
+ const progress = Math.min(1, tick / totalTicks);
346
+ const channels = fromChannels.map(function ([ch, from], i) {
347
+ const to = toChannels[i] ? toChannels[i][1] : 0;
348
+ return [ch, Math.round(from + (to - from) * progress)];
349
+ });
350
+ sendDmxChannels(channels);
351
+ if (tick >= totalTicks) {
352
+ stopEffect();
353
+ sendDmxChannels(toChannels);
354
+ }
355
+ }, intervalMs);
356
+ }, jitter);
281
357
  }
282
358
 
283
359
  // ── State persistence ─────────────────────────────────────
@@ -309,7 +385,12 @@ module.exports = function (RED) {
309
385
 
310
386
  const toChannels = buildColorChannels(brightness, r, g, b, w, ww);
311
387
 
312
- if (S.transitions && payload.transition && payload.transition > 0) {
388
+ // Use HA UI time as fallback if HA sends no transition duration
389
+ const transitionDuration = (payload.transition && payload.transition > 0)
390
+ ? payload.transition
391
+ : (S.transitions ? S.haUiTime : 0);
392
+
393
+ if (S.transitions && transitionDuration > 0) {
313
394
  const bump = sendInitialBump(toChannels);
314
395
  const prevBright = recall('brightness', 'brightness_disk', 0);
315
396
  const prevR = recall('red', 'red_disk', S.brightBump || r);
@@ -320,7 +401,7 @@ module.exports = function (RED) {
320
401
  const fromChannels = buildColorChannels(
321
402
  bump || prevBright, prevR, prevG, prevB, prevW, prevWW
322
403
  );
323
- runTransition(fromChannels, toChannels, payload.transition);
404
+ runTransition(fromChannels, toChannels, transitionDuration);
324
405
  } else {
325
406
  sendInitialBump(toChannels);
326
407
  sendDmxChannels(toChannels);
@@ -345,7 +426,7 @@ module.exports = function (RED) {
345
426
 
346
427
  if (S.transitions && payload && payload.transition && payload.transition > 0) {
347
428
  const fromChannels = buildColorChannels(brightness, r, g, b, w, ww);
348
- runTransition(fromChannels, toChannels, payload.transition);
429
+ runTransition(fromChannels, toChannels, transitionDuration);
349
430
  } else {
350
431
  sendDmxChannels(toChannels);
351
432
  }
@@ -381,7 +462,7 @@ module.exports = function (RED) {
381
462
  // Rainbow
382
463
  if (effectName === 'rainbow') {
383
464
  let deg = 0;
384
- startEffect('Rainbow', Math.round(1000 / S.ticksPerSec), () => {
465
+ startEffect('Rainbow', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
385
466
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
386
467
  const [r,g,b] = hsvToRgb(deg, 1, 1);
387
468
  sendDmxChannels(buildColorChannels(255, r, g, b, 0, 0));
@@ -391,7 +472,7 @@ module.exports = function (RED) {
391
472
  // Rainbow RGBW
392
473
  if (effectName === 'rainbow_rgbw') {
393
474
  let deg = 0;
394
- startEffect('Rainbow RGBW', Math.round(1000 / S.ticksPerSec), () => {
475
+ startEffect('Rainbow RGBW', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
395
476
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
396
477
  const [r,g,b] = hsvToRgb(deg, 1, 1);
397
478
  const w = Math.round(255 * Math.abs(Math.sin(deg * Math.PI / 180)));
@@ -419,7 +500,7 @@ module.exports = function (RED) {
419
500
  // Twinkle
420
501
  if (effectName === 'twinkle') {
421
502
  let dir = 1; let bright = 0;
422
- startEffect('Twinkle', Math.round(1000 / S.ticksPerSec), () => {
503
+ startEffect('Twinkle', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
423
504
  bright = Math.max(0, Math.min(255, bright + dir * 8));
424
505
  if (bright >= 255 || bright <= 0) dir = -dir;
425
506
  sendDmxChannels(buildColorChannels(bright, 255, 255, 255, 255, 0));
@@ -479,13 +560,13 @@ module.exports = function (RED) {
479
560
  max_mireds: 500,
480
561
  stat_t: statTopic,
481
562
  cmd_t: cmdTopic,
482
- name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
563
+ name: `${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
483
564
  device: {
484
565
  identifiers: `light-${fixtureId}`,
485
- name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
486
- model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
566
+ name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
567
+ model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone} ${S.area}`,
487
568
  model_id: `referenced on plan as: (${fixtureId}`,
488
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
569
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
489
570
  hw_version: `DMX Controller in ${cfg.zone}. MQTT: ${dmxTopic}`,
490
571
  serial_number: fixtureId,
491
572
  sw_version: 'ha-mqtt-dmx: ' + _pkgVer,
@@ -494,6 +575,7 @@ module.exports = function (RED) {
494
575
  };
495
576
 
496
577
  pub(cfgTopic, discovery, true);
578
+ resetLastSent(); // Force full state re-publish after discovery
497
579
 
498
580
  broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
499
581
  let payload;
@@ -571,7 +653,7 @@ module.exports = function (RED) {
571
653
 
572
654
  if (devReq) {
573
655
  switch (devReq) {
574
- case 'add': handleDeviceAdd(); break;
656
+ case 'add': _manualDiscover(); break;
575
657
  case 'remove': handleDeviceRemove(); break;
576
658
  default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
577
659
  }
@@ -592,6 +674,19 @@ module.exports = function (RED) {
592
674
  }
593
675
 
594
676
 
677
+
678
+ // ── Location string helper ─────────────────────────────────────────────
679
+ function buildLocation(zone, area, subLoc) {
680
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
681
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
682
+ const areaClean = (area || '').trim();
683
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
684
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
685
+ return `${zone} ${areaClean}`.trim();
686
+ }
687
+ return `${zone} ${areaClean} - ${sub}`.trim();
688
+ }
689
+
595
690
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
596
691
  RED.httpAdmin.post('/ha-mqtt-dmx/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx.write'), function(req, res) {
597
692
  const n = RED.nodes.getNode(req.params.id);
@@ -47,6 +47,7 @@
47
47
  haIcon: { value: 'mdi:motion-sensor' },
48
48
  cableColor: { value: 'Purple' },
49
49
  holdTime: { value: '15' },
50
+ debugMode: { value: false },
50
51
  warmupTime: { value: '120' },
51
52
  },
52
53
 
@@ -280,6 +281,18 @@
280
281
  <span style="margin-left:8px; color:#999; font-size:0.85em;">Offline delay after boot before PIR activates</span>
281
282
  </div>
282
283
 
284
+ <div class="form-row">
285
+ <label>&nbsp;</label>
286
+ <input type="checkbox" id="node-input-debugMode"
287
+ style="width:auto; margin-right:8px" />
288
+ <label for="node-input-debugMode" style="width:auto">
289
+ Debug output to NR debug tab
290
+ </label>
291
+ <div style="margin-left:106px; margin-top:4px; color:#e74c3c; font-size:0.8em;">
292
+ ⚠ Disable in production — logs every MQTT publish. Auto-disables after 12hrs.
293
+ </div>
294
+ </div>
295
+
283
296
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
284
297
  node-red-contrib-dmx-for-ha &nbsp;v0.2.2
285
298
  </div>
@@ -37,14 +37,32 @@ module.exports = function (RED) {
37
37
  }, 1500);
38
38
  }
39
39
 
40
- // Track if already discovered to prevent double-fire
40
+ // Track discovery state prevents double-fire and allows reconnect re-discovery
41
41
  let _discovered = false;
42
+ let _lastDiscoveryTime = 0;
43
+ const _DISCOVERY_COOLDOWN_MS = 5000; // 5 second cooldown between discoveries
44
+
42
45
  function _tryDiscover() {
43
46
  if (_discovered) return;
47
+ const now = Date.now();
48
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) return;
44
49
  _discovered = true;
50
+ _lastDiscoveryTime = now;
45
51
  autoDiscover();
46
52
  }
47
53
 
54
+ function _manualDiscover() {
55
+ // Manual trigger (canvas button or input msg) — bypass _discovered flag
56
+ // but still respect cooldown to prevent accidental rapid-fire
57
+ const now = Date.now();
58
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) {
59
+ node.warn('Discovery cooldown active — please wait 5 seconds between manual triggers');
60
+ return;
61
+ }
62
+ _lastDiscoveryTime = now;
63
+ handleDeviceAdd();
64
+ }
65
+
48
66
  // Auto-discover if broker already connected on deploy
49
67
  if (broker.connected) {
50
68
  _tryDiscover();
@@ -54,12 +72,30 @@ module.exports = function (RED) {
54
72
  _tryDiscover();
55
73
  });
56
74
  broker.on('close', function () {
75
+ _discovered = false; // Allow re-discovery on reconnect
57
76
  setStatus('red', 'ring', 'Broker disconnected');
58
77
  });
59
78
  broker.on('error', function (err) {
60
79
  node.warn('MQTT broker error: ' + (err.message || err));
61
80
  setStatus('red', 'dot', 'Broker error — check config');
62
81
  });
82
+ // ── Debug mode safeguards ─────────────────────────────────────────
83
+ if (S.debugMode) {
84
+ // Permanent canvas warning so debug mode is obvious
85
+ setStatus('red', 'dot', `ha-mqtt-pir "${fixtureId}" ⚠ DEBUG MODE ON`);
86
+ node.warn(`[DEBUG] ha-mqtt-pir "${fixtureId}" — debug mode is enabled. Disable in production.`);
87
+
88
+ // Auto-disable after 12 hours — safety net for forgotten debug sessions
89
+ setTimeout(function () {
90
+ if (S.debugMode) {
91
+ S.debugMode = false;
92
+ node.warn(`[DEBUG] ha-mqtt-pir "${fixtureId}" — debug mode auto-disabled after 12 hours`);
93
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
94
+ }
95
+ }, 12 * 60 * 60 * 1000);
96
+ }
97
+
98
+
63
99
  // Fallback — if connect event already fired before listener registered
64
100
  setTimeout(function () {
65
101
  _tryDiscover();
@@ -84,11 +120,12 @@ module.exports = function (RED) {
84
120
  cableColor: config.cableColor || 'Purple',
85
121
  holdTime: parseInt(config.holdTime) || 15,
86
122
  warmupTime: parseInt(config.warmupTime) || 120,
123
+ debugMode: config.debugMode === true,
87
124
  };
88
125
 
89
126
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
90
127
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
91
- const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
128
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
92
129
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
93
130
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
94
131
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
@@ -96,12 +133,14 @@ module.exports = function (RED) {
96
133
 
97
134
  // ── Helpers ───────────────────────────────────────────────
98
135
  function pub(topic, payload, retain) {
136
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
99
137
  broker.publish({
100
138
  topic,
101
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
139
+ payload: strPayload,
102
140
  qos: cfg.qos,
103
141
  retain: retain !== undefined ? retain : cfg.retain,
104
142
  });
143
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
105
144
  }
106
145
 
107
146
  function setStatus(fill, shape, text) {
@@ -158,7 +197,7 @@ module.exports = function (RED) {
158
197
  const discovery = {
159
198
  unique_id: fixtureId,
160
199
  object_id: objectId,
161
- name: `${S.pirType} PIR ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
200
+ name: `${S.pirType} PIR ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
162
201
  stat_t: statTopic,
163
202
  avty_t: avtyTopic,
164
203
  payload_available: 'online',
@@ -169,10 +208,10 @@ module.exports = function (RED) {
169
208
  icon: S.haIcon,
170
209
  device: {
171
210
  identifiers: `binary_sensor-${fixtureId}`,
172
- name: `(${fixtureId}) - ${S.pirType} PIR ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
211
+ name: `(${fixtureId}) - ${S.pirType} PIR ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
173
212
  model: `${S.pirType} PIR located ${S.situation} the ${cfg.zone} - ${S.area}`,
174
213
  model_id: `referenced on plan as: (${fixtureId}`,
175
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
214
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
176
215
  hw_version: `${S.pirType} PIR — ${S.cableColor} Cat5 cable. Publishes "${S.pirPayload}" on topic: ${S.subscribeTopic}`,
177
216
  serial_number: `(${fixtureId}) Payload: ${S.pirPayload}`,
178
217
  sw_version: `ha-mqtt-pir: ${_pkgVer}`,
@@ -231,6 +270,19 @@ module.exports = function (RED) {
231
270
  }
232
271
 
233
272
 
273
+
274
+ // ── Location string helper ─────────────────────────────────────────────
275
+ function buildLocation(zone, area, subLoc) {
276
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
277
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
278
+ const areaClean = (area || '').trim();
279
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
280
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
281
+ return `${zone} ${areaClean}`.trim();
282
+ }
283
+ return `${zone} ${areaClean} - ${sub}`.trim();
284
+ }
285
+
234
286
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
235
287
  RED.httpAdmin.post('/ha-mqtt-pir/:id/toggle', RED.auth.needsPermission('ha-mqtt-pir.write'), function(req, res) {
236
288
  const n = RED.nodes.getNode(req.params.id);
@@ -48,6 +48,7 @@
48
48
  // Options
49
49
  haIcon: { value: 'mdi:toggle-switch' },
50
50
  showEffects: { value: false },
51
+ debugMode: { value: false },
51
52
  defaultState: { value: 'OFF' },
52
53
  },
53
54
 
@@ -276,6 +277,18 @@
276
277
  <label for="node-input-showEffects" style="width:auto">Show effects in HA</label>
277
278
  </div>
278
279
 
280
+ <div class="form-row">
281
+ <label>&nbsp;</label>
282
+ <input type="checkbox" id="node-input-debugMode"
283
+ style="width:auto; margin-right:8px" />
284
+ <label for="node-input-debugMode" style="width:auto">
285
+ Debug output to NR debug tab
286
+ </label>
287
+ <div style="margin-left:106px; margin-top:4px; color:#e74c3c; font-size:0.8em;">
288
+ ⚠ Disable in production — logs every MQTT publish. Auto-disables after 12hrs.
289
+ </div>
290
+ </div>
291
+
279
292
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
280
293
  node-red-contrib-dmx-for-ha &nbsp;v0.2.2
281
294
  </div>
@@ -40,14 +40,32 @@ module.exports = function (RED) {
40
40
  }, 1500);
41
41
  }
42
42
 
43
- // Track if already discovered to prevent double-fire
43
+ // Track discovery state prevents double-fire and allows reconnect re-discovery
44
44
  let _discovered = false;
45
+ let _lastDiscoveryTime = 0;
46
+ const _DISCOVERY_COOLDOWN_MS = 5000; // 5 second cooldown between discoveries
47
+
45
48
  function _tryDiscover() {
46
49
  if (_discovered) return;
50
+ const now = Date.now();
51
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) return;
47
52
  _discovered = true;
53
+ _lastDiscoveryTime = now;
48
54
  autoDiscover();
49
55
  }
50
56
 
57
+ function _manualDiscover() {
58
+ // Manual trigger (canvas button or input msg) — bypass _discovered flag
59
+ // but still respect cooldown to prevent accidental rapid-fire
60
+ const now = Date.now();
61
+ if (now - _lastDiscoveryTime < _DISCOVERY_COOLDOWN_MS) {
62
+ node.warn('Discovery cooldown active — please wait 5 seconds between manual triggers');
63
+ return;
64
+ }
65
+ _lastDiscoveryTime = now;
66
+ handleDeviceAdd();
67
+ }
68
+
51
69
  // Auto-discover if broker already connected on deploy
52
70
  if (broker.connected) {
53
71
  _tryDiscover();
@@ -57,12 +75,30 @@ module.exports = function (RED) {
57
75
  _tryDiscover();
58
76
  });
59
77
  broker.on('close', function () {
78
+ _discovered = false; // Allow re-discovery on reconnect
60
79
  setStatus('red', 'ring', 'Broker disconnected');
61
80
  });
62
81
  broker.on('error', function (err) {
63
82
  node.warn('MQTT broker error: ' + (err.message || err));
64
83
  setStatus('red', 'dot', 'Broker error — check config');
65
84
  });
85
+ // ── Debug mode safeguards ─────────────────────────────────────────
86
+ if (S.debugMode) {
87
+ // Permanent canvas warning so debug mode is obvious
88
+ setStatus('red', 'dot', `ha-mqtt-relay "${fixtureId}" ⚠ DEBUG MODE ON`);
89
+ node.warn(`[DEBUG] ha-mqtt-relay "${fixtureId}" — debug mode is enabled. Disable in production.`);
90
+
91
+ // Auto-disable after 12 hours — safety net for forgotten debug sessions
92
+ setTimeout(function () {
93
+ if (S.debugMode) {
94
+ S.debugMode = false;
95
+ node.warn(`[DEBUG] ha-mqtt-relay "${fixtureId}" — debug mode auto-disabled after 12 hours`);
96
+ setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
97
+ }
98
+ }, 12 * 60 * 60 * 1000);
99
+ }
100
+
101
+
66
102
  // Fallback — if connect event already fired before listener registered
67
103
  setTimeout(function () {
68
104
  _tryDiscover();
@@ -95,11 +131,11 @@ module.exports = function (RED) {
95
131
 
96
132
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
97
133
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
98
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
134
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
99
135
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
100
136
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
101
137
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
102
- const relayTopic = `${cfg.siteId}/${cfg.zone}/${S.controllerNum}/${S.mqttSegment}/${S.relayNum}`;
138
+ const relayTopic = cfg.buildTopic(cfg.siteId, cfg.zone, S.controllerNum, S.mqttSegment, S.relayNum);
103
139
 
104
140
  // ── Context helpers ───────────────────────────────────────
105
141
  // Check disk store available once on startup
@@ -133,12 +169,14 @@ module.exports = function (RED) {
133
169
 
134
170
  // ── MQTT helpers ──────────────────────────────────────────
135
171
  function pub(topic, payload, retain) {
172
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
136
173
  broker.publish({
137
174
  topic,
138
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
175
+ payload: strPayload,
139
176
  qos: cfg.qos,
140
177
  retain: retain !== undefined ? retain : cfg.retain,
141
178
  });
179
+ if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
142
180
  }
143
181
 
144
182
  function pubRelay(value) {
@@ -235,7 +273,7 @@ module.exports = function (RED) {
235
273
  unique_id: fixtureId,
236
274
  schema: 'json',
237
275
  object_id: objectId,
238
- name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
276
+ name: `${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
239
277
  cmd_t: cmdTopic,
240
278
  stat_t: statTopic,
241
279
  optimistic: false,
@@ -249,10 +287,10 @@ module.exports = function (RED) {
249
287
  flash_time_long: S.flashLong,
250
288
  device: {
251
289
  identifiers: `light-${fixtureId}`,
252
- name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
290
+ name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
253
291
  model: `${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
254
292
  model_id: `referenced on plan as: (${fixtureId}`,
255
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
293
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
256
294
  hw_version: `Relay Controller in ${cfg.zone}. Topic: ${relayTopic}`,
257
295
  serial_number: fixtureId,
258
296
  sw_version: 'ha-mqtt-relay: ' + _pkgVer,
@@ -323,7 +361,7 @@ module.exports = function (RED) {
323
361
 
324
362
  if (devReq) {
325
363
  switch (devReq) {
326
- case 'add': handleDeviceAdd(); break;
364
+ case 'add': _manualDiscover(); break;
327
365
  case 'remove': handleDeviceRemove(); break;
328
366
  default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
329
367
  }
@@ -344,6 +382,19 @@ module.exports = function (RED) {
344
382
  }
345
383
 
346
384
 
385
+
386
+ // ── Location string helper ─────────────────────────────────────────────
387
+ function buildLocation(zone, area, subLoc) {
388
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
389
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
390
+ const areaClean = (area || '').trim();
391
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
392
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
393
+ return `${zone} ${areaClean}`.trim();
394
+ }
395
+ return `${zone} ${areaClean} - ${sub}`.trim();
396
+ }
397
+
347
398
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
348
399
  RED.httpAdmin.post('/ha-mqtt-relay/:id/toggle', RED.auth.needsPermission('ha-mqtt-relay.write'), function(req, res) {
349
400
  const n = RED.nodes.getNode(req.params.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-dmx-for-ha",
3
- "version": "0.2.4",
3
+ "version": "0.3.4",
4
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
5
  "keywords": [
6
6
  "node-red",