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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
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>
@@ -83,12 +83,13 @@ module.exports = function (RED) {
83
83
  haIcon: config.haIcon || 'mdi:gesture-tap-button',
84
84
  ledColor: config.ledColor || 'Blue',
85
85
  holdTime: parseFloat(config.holdTime) || 0.5,
86
+ debugMode: config.debugMode === true,
86
87
  };
87
88
 
88
89
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
89
90
  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`;
91
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
92
+ const uiBtnTopic = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureId + '-BTN');
92
93
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
93
94
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
94
95
  const uiBtnCfgTopic = `${uiBtnTopic}/${cfg.configTopic}`;
@@ -96,12 +97,14 @@ module.exports = function (RED) {
96
97
 
97
98
  // ── Helpers ───────────────────────────────────────────────
98
99
  function pub(topic, payload, retain) {
100
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
99
101
  broker.publish({
100
102
  topic,
101
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
103
+ payload: strPayload,
102
104
  qos: cfg.qos,
103
105
  retain: retain !== undefined ? retain : cfg.retain,
104
106
  });
107
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
105
108
  }
106
109
 
107
110
  function setStatus(fill, shape, text) {
@@ -121,17 +124,17 @@ module.exports = function (RED) {
121
124
  const bsDiscovery = {
122
125
  unique_id: fixtureId,
123
126
  object_id: objectId,
124
- name: `${S.buttonPosition} button ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
127
+ name: `button ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
125
128
  stat_t: statTopic,
126
129
  off_delay: S.holdTime,
127
130
  enabled_by_default: true,
128
131
  icon: S.haIcon,
129
132
  device: {
130
133
  identifiers: `binary_sensor-${fixtureId}`,
131
- name: `(${fixtureId}) - Wall Button ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
134
+ name: `(${fixtureId}) - Wall Button ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
132
135
  model: `Wall button located ${S.situation} the ${cfg.zone} - ${S.area}`,
133
136
  model_id: `referenced on plan as: (${fixtureId}`,
134
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
137
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
135
138
  hw_version: `Wall button — ${S.ledColor} LED. Publishes "${S.buttonPayload}" on topic: ${S.subscribeTopic}`,
136
139
  serial_number: `(${fixtureId}) Payload: ${S.buttonPayload}`,
137
140
  sw_version: `ha-mqtt-button: ${_pkgVer}`,
@@ -143,7 +146,7 @@ module.exports = function (RED) {
143
146
  const uiDiscovery = {
144
147
  unique_id: `${fixtureId}-BTN`,
145
148
  object_id: `${objectId}_btn`,
146
- name: `${S.buttonPosition} (UI) ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
149
+ name: `${S.buttonPosition} (UI) ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
147
150
  cmd_t: uiBtnCmdTopic,
148
151
  payload_press: 'PRESS',
149
152
  enabled_by_default: true,
@@ -206,6 +209,19 @@ module.exports = function (RED) {
206
209
  }
207
210
 
208
211
 
212
+
213
+ // ── Location string helper ─────────────────────────────────────────────
214
+ function buildLocation(zone, area, subLoc) {
215
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
216
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
217
+ const areaClean = (area || '').trim();
218
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
219
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
220
+ return `${zone} ${areaClean}`.trim();
221
+ }
222
+ return `${zone} ${areaClean} - ${sub}`.trim();
223
+ }
224
+
209
225
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
210
226
  RED.httpAdmin.post('/ha-mqtt-button/:id/toggle', RED.auth.needsPermission('ha-mqtt-button.write'), function(req, res) {
211
227
  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
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>
@@ -95,7 +95,7 @@ module.exports = function (RED) {
95
95
 
96
96
  const groupId = `LG-${S.uid}${S.uidPostfix}`;
97
97
  const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
98
- const groupTopic = `${cfg.discoveryPrefix}/light/${groupId}`;
98
+ const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
99
99
  const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
100
100
  const statTopic = `${groupTopic}/${cfg.stateTopic}`;
101
101
  const cmdTopic = `${groupTopic}/${cfg.commandTopic}`;
@@ -129,12 +129,14 @@ module.exports = function (RED) {
129
129
 
130
130
  // ── MQTT helpers ──────────────────────────────────────────
131
131
  function pub(topic, payload, retain) {
132
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
132
133
  broker.publish({
133
134
  topic,
134
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
135
+ payload: strPayload,
135
136
  qos: cfg.qos,
136
137
  retain: retain !== undefined ? retain : cfg.retain,
137
138
  });
139
+ if (S.debugMode) node.debug(`${groupId} → ${topic} ${strPayload.substring(0, 120)}`);
138
140
  }
139
141
 
140
142
  function pubState(payload) {
@@ -221,13 +223,13 @@ module.exports = function (RED) {
221
223
  max_mireds: 500,
222
224
  stat_t: statTopic,
223
225
  cmd_t: cmdTopic,
224
- name: S.groupName || `${S.deviceType} Group ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
226
+ name: S.groupName || `${S.deviceType} Group ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
225
227
  device: {
226
228
  identifiers: `light-${groupId}`,
227
- name: `(${groupId}) - ${S.deviceType} Group ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
229
+ name: `(${groupId}) - ${S.deviceType} Group ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
228
230
  model: `${S.colorMode} ${S.deviceType} Group located ${S.situation} the ${cfg.zone} - ${S.area}`,
229
231
  model_id: `referenced on plan as: (${groupId}`,
230
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
232
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
231
233
  hw_version: 'Virtual fixture — exists as a DMX Group Node in Node-RED',
232
234
  sw_version: 'ha-mqtt-dmx-group: ' + _pkgVer,
233
235
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
@@ -327,6 +329,19 @@ module.exports = function (RED) {
327
329
  }
328
330
 
329
331
 
332
+
333
+ // ── Location string helper ─────────────────────────────────────────────
334
+ function buildLocation(zone, area, subLoc) {
335
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
336
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
337
+ const areaClean = (area || '').trim();
338
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
339
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
340
+ return `${zone} ${areaClean}`.trim();
341
+ }
342
+ return `${zone} ${areaClean} - ${sub}`.trim();
343
+ }
344
+
330
345
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
331
346
  RED.httpAdmin.post('/ha-mqtt-dmx-group/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx-group.write'), function(req, res) {
332
347
  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
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>
@@ -108,6 +108,10 @@ module.exports = function (RED) {
108
108
  minOutput: parseInt(config.minOutput) || 1,
109
109
  brightBump: parseInt(config.brightBump) || 50,
110
110
  ticksPerSec: parseInt(config.ticksPerSec) || 31,
111
+ debugMode: config.debugMode === true,
112
+ // Global transition controls from config node
113
+ rateLimit: cfg.transitionRateLimit || 1,
114
+ haUiTime: cfg.transitionHaUiTime || 1,
111
115
  flashShort: cfg.flashShort,
112
116
  flashLong: cfg.flashLong,
113
117
  diskDelay: cfg.diskDelay,
@@ -115,11 +119,11 @@ module.exports = function (RED) {
115
119
 
116
120
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
117
121
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
118
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
122
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
119
123
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
120
124
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
121
125
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
122
- const dmxTopic = `${cfg.siteId}/${cfg.zone}/dmx/${S.universe}`;
126
+ const dmxTopic = cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
123
127
 
124
128
  // ── Context helpers ───────────────────────────────────────
125
129
  // Check disk store available once on startup
@@ -162,26 +166,45 @@ module.exports = function (RED) {
162
166
  }
163
167
 
164
168
  function buildDmxPayload(channel, value) {
165
- if (channel == null || value == null) return null;
169
+ // Skip channels with no address configured (0 or null)
170
+ if (!channel || channel <= 0 || value == null) return null;
166
171
  return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
167
172
  }
168
173
 
174
+ // Fix 2: Cache last sent value per channel — skip publish if unchanged
175
+ const _lastSent = {};
176
+
169
177
  function sendDmxChannels(channels) {
170
178
  channels.forEach(function ([ch, val]) {
171
179
  const payload = buildDmxPayload(ch, val);
172
180
  if (payload === null) return;
181
+ // Skip if value unchanged since last publish
182
+ if (_lastSent[ch] === val) {
183
+ if (S.debugMode) node.debug(`${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
184
+ return;
185
+ }
186
+ _lastSent[ch] = val;
173
187
  broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
188
+ if (S.debugMode) node.debug(`${fixtureId} → ${dmxTopic} "${payload}"`);
174
189
  });
175
190
  }
176
191
 
192
+ function resetLastSent() {
193
+ // Clear cache so next send forces a full re-publish
194
+ // Used on device:add to ensure controller gets current state
195
+ Object.keys(_lastSent).forEach(k => delete _lastSent[k]);
196
+ }
197
+
177
198
  // ── MQTT helpers ──────────────────────────────────────────
178
199
  function pub(topic, payload, retain) {
200
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
179
201
  broker.publish({
180
202
  topic,
181
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
203
+ payload: strPayload,
182
204
  qos: cfg.qos,
183
205
  retain: retain !== undefined ? retain : cfg.retain,
184
206
  });
207
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
185
208
  }
186
209
 
187
210
  function pubState(payload) {
@@ -261,23 +284,40 @@ module.exports = function (RED) {
261
284
  // ── Transition ────────────────────────────────────────────
262
285
  function runTransition(fromChannels, toChannels, durationSecs) {
263
286
  stopEffect();
264
- const totalTicks = Math.round(durationSecs * S.ticksPerSec);
265
- const intervalMs = Math.round(1000 / S.ticksPerSec);
287
+ // Apply global rate limit from config node
288
+ // rateLimit < 1 = slower transitions, rateLimit > 1 = faster
289
+ const effectiveTicks = Math.max(1, Math.round(S.ticksPerSec * S.rateLimit));
290
+
291
+ // Fix 4: Skip micro-transitions — snap to target if too short to animate
292
+ const minDuration = 2 / effectiveTicks;
293
+ if (durationSecs < minDuration) {
294
+ sendDmxChannels(toChannels);
295
+ return;
296
+ }
297
+
298
+ const totalTicks = Math.round(durationSecs * effectiveTicks);
299
+ const intervalMs = Math.round(1000 / effectiveTicks);
266
300
  let tick = 0;
267
301
 
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);
302
+ // Fix 3: Random jitter (0 to 1 interval) to stagger NR event loop
303
+ // Prevents thundering herd when many nodes start transitions simultaneously
304
+ const jitter = Math.random() * intervalMs;
305
+ // Fix 3: Jitter delay spreads load across event loop
306
+ setTimeout(function () {
307
+ effectTimer = setInterval(function () {
308
+ tick++;
309
+ const progress = Math.min(1, tick / totalTicks);
310
+ const channels = fromChannels.map(function ([ch, from], i) {
311
+ const to = toChannels[i] ? toChannels[i][1] : 0;
312
+ return [ch, Math.round(from + (to - from) * progress)];
313
+ });
314
+ sendDmxChannels(channels);
315
+ if (tick >= totalTicks) {
316
+ stopEffect();
317
+ sendDmxChannels(toChannels);
318
+ }
319
+ }, intervalMs);
320
+ }, jitter);
281
321
  }
282
322
 
283
323
  // ── State persistence ─────────────────────────────────────
@@ -309,7 +349,12 @@ module.exports = function (RED) {
309
349
 
310
350
  const toChannels = buildColorChannels(brightness, r, g, b, w, ww);
311
351
 
312
- if (S.transitions && payload.transition && payload.transition > 0) {
352
+ // Use HA UI time as fallback if HA sends no transition duration
353
+ const transitionDuration = (payload.transition && payload.transition > 0)
354
+ ? payload.transition
355
+ : (S.transitions ? S.haUiTime : 0);
356
+
357
+ if (S.transitions && transitionDuration > 0) {
313
358
  const bump = sendInitialBump(toChannels);
314
359
  const prevBright = recall('brightness', 'brightness_disk', 0);
315
360
  const prevR = recall('red', 'red_disk', S.brightBump || r);
@@ -320,7 +365,7 @@ module.exports = function (RED) {
320
365
  const fromChannels = buildColorChannels(
321
366
  bump || prevBright, prevR, prevG, prevB, prevW, prevWW
322
367
  );
323
- runTransition(fromChannels, toChannels, payload.transition);
368
+ runTransition(fromChannels, toChannels, transitionDuration);
324
369
  } else {
325
370
  sendInitialBump(toChannels);
326
371
  sendDmxChannels(toChannels);
@@ -345,7 +390,7 @@ module.exports = function (RED) {
345
390
 
346
391
  if (S.transitions && payload && payload.transition && payload.transition > 0) {
347
392
  const fromChannels = buildColorChannels(brightness, r, g, b, w, ww);
348
- runTransition(fromChannels, toChannels, payload.transition);
393
+ runTransition(fromChannels, toChannels, transitionDuration);
349
394
  } else {
350
395
  sendDmxChannels(toChannels);
351
396
  }
@@ -381,7 +426,7 @@ module.exports = function (RED) {
381
426
  // Rainbow
382
427
  if (effectName === 'rainbow') {
383
428
  let deg = 0;
384
- startEffect('Rainbow', Math.round(1000 / S.ticksPerSec), () => {
429
+ startEffect('Rainbow', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
385
430
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
386
431
  const [r,g,b] = hsvToRgb(deg, 1, 1);
387
432
  sendDmxChannels(buildColorChannels(255, r, g, b, 0, 0));
@@ -391,7 +436,7 @@ module.exports = function (RED) {
391
436
  // Rainbow RGBW
392
437
  if (effectName === 'rainbow_rgbw') {
393
438
  let deg = 0;
394
- startEffect('Rainbow RGBW', Math.round(1000 / S.ticksPerSec), () => {
439
+ startEffect('Rainbow RGBW', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
395
440
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
396
441
  const [r,g,b] = hsvToRgb(deg, 1, 1);
397
442
  const w = Math.round(255 * Math.abs(Math.sin(deg * Math.PI / 180)));
@@ -419,7 +464,7 @@ module.exports = function (RED) {
419
464
  // Twinkle
420
465
  if (effectName === 'twinkle') {
421
466
  let dir = 1; let bright = 0;
422
- startEffect('Twinkle', Math.round(1000 / S.ticksPerSec), () => {
467
+ startEffect('Twinkle', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
423
468
  bright = Math.max(0, Math.min(255, bright + dir * 8));
424
469
  if (bright >= 255 || bright <= 0) dir = -dir;
425
470
  sendDmxChannels(buildColorChannels(bright, 255, 255, 255, 255, 0));
@@ -479,13 +524,13 @@ module.exports = function (RED) {
479
524
  max_mireds: 500,
480
525
  stat_t: statTopic,
481
526
  cmd_t: cmdTopic,
482
- name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
527
+ name: `${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
483
528
  device: {
484
529
  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}`,
530
+ name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
531
+ model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone} ${S.area}`,
487
532
  model_id: `referenced on plan as: (${fixtureId}`,
488
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
533
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
489
534
  hw_version: `DMX Controller in ${cfg.zone}. MQTT: ${dmxTopic}`,
490
535
  serial_number: fixtureId,
491
536
  sw_version: 'ha-mqtt-dmx: ' + _pkgVer,
@@ -494,6 +539,7 @@ module.exports = function (RED) {
494
539
  };
495
540
 
496
541
  pub(cfgTopic, discovery, true);
542
+ resetLastSent(); // Force full state re-publish after discovery
497
543
 
498
544
  broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
499
545
  let payload;
@@ -592,6 +638,19 @@ module.exports = function (RED) {
592
638
  }
593
639
 
594
640
 
641
+
642
+ // ── Location string helper ─────────────────────────────────────────────
643
+ function buildLocation(zone, area, subLoc) {
644
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
645
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
646
+ const areaClean = (area || '').trim();
647
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
648
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
649
+ return `${zone} ${areaClean}`.trim();
650
+ }
651
+ return `${zone} ${areaClean} - ${sub}`.trim();
652
+ }
653
+
595
654
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
596
655
  RED.httpAdmin.post('/ha-mqtt-dmx/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx.write'), function(req, res) {
597
656
  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
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>
@@ -84,11 +84,12 @@ module.exports = function (RED) {
84
84
  cableColor: config.cableColor || 'Purple',
85
85
  holdTime: parseInt(config.holdTime) || 15,
86
86
  warmupTime: parseInt(config.warmupTime) || 120,
87
+ debugMode: config.debugMode === true,
87
88
  };
88
89
 
89
90
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
90
91
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
91
- const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
92
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
92
93
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
93
94
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
94
95
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
@@ -96,12 +97,14 @@ module.exports = function (RED) {
96
97
 
97
98
  // ── Helpers ───────────────────────────────────────────────
98
99
  function pub(topic, payload, retain) {
100
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
99
101
  broker.publish({
100
102
  topic,
101
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
103
+ payload: strPayload,
102
104
  qos: cfg.qos,
103
105
  retain: retain !== undefined ? retain : cfg.retain,
104
106
  });
107
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
105
108
  }
106
109
 
107
110
  function setStatus(fill, shape, text) {
@@ -158,7 +161,7 @@ module.exports = function (RED) {
158
161
  const discovery = {
159
162
  unique_id: fixtureId,
160
163
  object_id: objectId,
161
- name: `${S.pirType} PIR ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
164
+ name: `${S.pirType} PIR ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
162
165
  stat_t: statTopic,
163
166
  avty_t: avtyTopic,
164
167
  payload_available: 'online',
@@ -169,10 +172,10 @@ module.exports = function (RED) {
169
172
  icon: S.haIcon,
170
173
  device: {
171
174
  identifiers: `binary_sensor-${fixtureId}`,
172
- name: `(${fixtureId}) - ${S.pirType} PIR ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
175
+ name: `(${fixtureId}) - ${S.pirType} PIR ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
173
176
  model: `${S.pirType} PIR located ${S.situation} the ${cfg.zone} - ${S.area}`,
174
177
  model_id: `referenced on plan as: (${fixtureId}`,
175
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
178
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
176
179
  hw_version: `${S.pirType} PIR — ${S.cableColor} Cat5 cable. Publishes "${S.pirPayload}" on topic: ${S.subscribeTopic}`,
177
180
  serial_number: `(${fixtureId}) Payload: ${S.pirPayload}`,
178
181
  sw_version: `ha-mqtt-pir: ${_pkgVer}`,
@@ -231,6 +234,19 @@ module.exports = function (RED) {
231
234
  }
232
235
 
233
236
 
237
+
238
+ // ── Location string helper ─────────────────────────────────────────────
239
+ function buildLocation(zone, area, subLoc) {
240
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
241
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
242
+ const areaClean = (area || '').trim();
243
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
244
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
245
+ return `${zone} ${areaClean}`.trim();
246
+ }
247
+ return `${zone} ${areaClean} - ${sub}`.trim();
248
+ }
249
+
234
250
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
235
251
  RED.httpAdmin.post('/ha-mqtt-pir/:id/toggle', RED.auth.needsPermission('ha-mqtt-pir.write'), function(req, res) {
236
252
  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
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>
@@ -95,11 +95,11 @@ module.exports = function (RED) {
95
95
 
96
96
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
97
97
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
98
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
98
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
99
99
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
100
100
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
101
101
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
102
- const relayTopic = `${cfg.siteId}/${cfg.zone}/${S.controllerNum}/${S.mqttSegment}/${S.relayNum}`;
102
+ const relayTopic = cfg.buildTopic(cfg.siteId, cfg.zone, S.controllerNum, S.mqttSegment, S.relayNum);
103
103
 
104
104
  // ── Context helpers ───────────────────────────────────────
105
105
  // Check disk store available once on startup
@@ -133,12 +133,14 @@ module.exports = function (RED) {
133
133
 
134
134
  // ── MQTT helpers ──────────────────────────────────────────
135
135
  function pub(topic, payload, retain) {
136
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
136
137
  broker.publish({
137
138
  topic,
138
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
139
+ payload: strPayload,
139
140
  qos: cfg.qos,
140
141
  retain: retain !== undefined ? retain : cfg.retain,
141
142
  });
143
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
142
144
  }
143
145
 
144
146
  function pubRelay(value) {
@@ -235,7 +237,7 @@ module.exports = function (RED) {
235
237
  unique_id: fixtureId,
236
238
  schema: 'json',
237
239
  object_id: objectId,
238
- name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
240
+ name: `${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
239
241
  cmd_t: cmdTopic,
240
242
  stat_t: statTopic,
241
243
  optimistic: false,
@@ -249,10 +251,10 @@ module.exports = function (RED) {
249
251
  flash_time_long: S.flashLong,
250
252
  device: {
251
253
  identifiers: `light-${fixtureId}`,
252
- name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
254
+ name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${buildLocation(cfg.zone, S.area, S.subLocation)}`,
253
255
  model: `${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
254
256
  model_id: `referenced on plan as: (${fixtureId}`,
255
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
257
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
256
258
  hw_version: `Relay Controller in ${cfg.zone}. Topic: ${relayTopic}`,
257
259
  serial_number: fixtureId,
258
260
  sw_version: 'ha-mqtt-relay: ' + _pkgVer,
@@ -344,6 +346,19 @@ module.exports = function (RED) {
344
346
  }
345
347
 
346
348
 
349
+
350
+ // ── Location string helper ─────────────────────────────────────────────
351
+ function buildLocation(zone, area, subLoc) {
352
+ // Strip parentheses from sub-area values e.g. "(North)" → "North"
353
+ const sub = (subLoc || '').replace(/[()]/g, '').trim();
354
+ const areaClean = (area || '').trim();
355
+ // Skip sub-area if empty or same as area (prevents "Stairs - Stairs")
356
+ if (!sub || sub.toLowerCase() === areaClean.toLowerCase()) {
357
+ return `${zone} ${areaClean}`.trim();
358
+ }
359
+ return `${zone} ${areaClean} - ${sub}`.trim();
360
+ }
361
+
347
362
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
348
363
  RED.httpAdmin.post('/ha-mqtt-relay/:id/toggle', RED.auth.needsPermission('ha-mqtt-relay.write'), function(req, res) {
349
364
  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.0",
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",