node-red-contrib-dmx-for-ha 0.2.3 → 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>
@@ -5,6 +5,10 @@
5
5
  // ============================================================
6
6
 
7
7
  module.exports = function (RED) {
8
+ // Package version — read once at module load
9
+ const PKG_VERSION = require('path').join(__dirname, '../package.json');
10
+ const _pkgVer = (() => { try { return require(PKG_VERSION).version; } catch(e) { return '?'; } })();
11
+
8
12
 
9
13
  function HaMqttButtonNode(config) {
10
14
  RED.nodes.createNode(this, config);
@@ -79,12 +83,13 @@ module.exports = function (RED) {
79
83
  haIcon: config.haIcon || 'mdi:gesture-tap-button',
80
84
  ledColor: config.ledColor || 'Blue',
81
85
  holdTime: parseFloat(config.holdTime) || 0.5,
86
+ debugMode: config.debugMode === true,
82
87
  };
83
88
 
84
89
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
85
90
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
86
- const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
87
- 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');
88
93
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
89
94
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
90
95
  const uiBtnCfgTopic = `${uiBtnTopic}/${cfg.configTopic}`;
@@ -92,12 +97,14 @@ module.exports = function (RED) {
92
97
 
93
98
  // ── Helpers ───────────────────────────────────────────────
94
99
  function pub(topic, payload, retain) {
100
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
95
101
  broker.publish({
96
102
  topic,
97
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
103
+ payload: strPayload,
98
104
  qos: cfg.qos,
99
105
  retain: retain !== undefined ? retain : cfg.retain,
100
106
  });
107
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
101
108
  }
102
109
 
103
110
  function setStatus(fill, shape, text) {
@@ -117,20 +124,20 @@ module.exports = function (RED) {
117
124
  const bsDiscovery = {
118
125
  unique_id: fixtureId,
119
126
  object_id: objectId,
120
- 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)}`,
121
128
  stat_t: statTopic,
122
129
  off_delay: S.holdTime,
123
130
  enabled_by_default: true,
124
131
  icon: S.haIcon,
125
132
  device: {
126
133
  identifiers: `binary_sensor-${fixtureId}`,
127
- 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)}`,
128
135
  model: `Wall button located ${S.situation} the ${cfg.zone} - ${S.area}`,
129
136
  model_id: `referenced on plan as: (${fixtureId}`,
130
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
137
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
131
138
  hw_version: `Wall button — ${S.ledColor} LED. Publishes "${S.buttonPayload}" on topic: ${S.subscribeTopic}`,
132
139
  serial_number: `(${fixtureId}) Payload: ${S.buttonPayload}`,
133
- sw_version: 'ha-mqtt-button: 0.1.0',
140
+ sw_version: `ha-mqtt-button: ${_pkgVer}`,
134
141
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
135
142
  },
136
143
  };
@@ -139,7 +146,7 @@ module.exports = function (RED) {
139
146
  const uiDiscovery = {
140
147
  unique_id: `${fixtureId}-BTN`,
141
148
  object_id: `${objectId}_btn`,
142
- 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)}`,
143
150
  cmd_t: uiBtnCmdTopic,
144
151
  payload_press: 'PRESS',
145
152
  enabled_by_default: true,
@@ -202,6 +209,19 @@ module.exports = function (RED) {
202
209
  }
203
210
 
204
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
+
205
225
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
206
226
  RED.httpAdmin.post('/ha-mqtt-button/:id/toggle', RED.auth.needsPermission('ha-mqtt-button.write'), function(req, res) {
207
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>
@@ -8,6 +8,10 @@
8
8
  // ============================================================
9
9
 
10
10
  module.exports = function (RED) {
11
+ // Package version — read once at module load
12
+ const PKG_VERSION = require('path').join(__dirname, '../package.json');
13
+ const _pkgVer = (() => { try { return require(PKG_VERSION).version; } catch(e) { return '?'; } })();
14
+
11
15
 
12
16
  function HaMqttDmxGroupNode(config) {
13
17
  RED.nodes.createNode(this, config);
@@ -91,7 +95,7 @@ module.exports = function (RED) {
91
95
 
92
96
  const groupId = `LG-${S.uid}${S.uidPostfix}`;
93
97
  const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
94
- const groupTopic = `${cfg.discoveryPrefix}/light/${groupId}`;
98
+ const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
95
99
  const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
96
100
  const statTopic = `${groupTopic}/${cfg.stateTopic}`;
97
101
  const cmdTopic = `${groupTopic}/${cfg.commandTopic}`;
@@ -125,12 +129,14 @@ module.exports = function (RED) {
125
129
 
126
130
  // ── MQTT helpers ──────────────────────────────────────────
127
131
  function pub(topic, payload, retain) {
132
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
128
133
  broker.publish({
129
134
  topic,
130
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
135
+ payload: strPayload,
131
136
  qos: cfg.qos,
132
137
  retain: retain !== undefined ? retain : cfg.retain,
133
138
  });
139
+ if (S.debugMode) node.debug(`${groupId} → ${topic} ${strPayload.substring(0, 120)}`);
134
140
  }
135
141
 
136
142
  function pubState(payload) {
@@ -200,7 +206,7 @@ module.exports = function (RED) {
200
206
  }
201
207
 
202
208
  const discovery = {
203
- unique_id: `group(${groupId})`,
209
+ unique_id: groupId,
204
210
  schema: 'json',
205
211
  object_id: objectId,
206
212
  optimistic: false,
@@ -217,15 +223,15 @@ module.exports = function (RED) {
217
223
  max_mireds: 500,
218
224
  stat_t: statTopic,
219
225
  cmd_t: cmdTopic,
220
- 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)}`,
221
227
  device: {
222
228
  identifiers: `light-${groupId}`,
223
- 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)}`,
224
230
  model: `${S.colorMode} ${S.deviceType} Group located ${S.situation} the ${cfg.zone} - ${S.area}`,
225
231
  model_id: `referenced on plan as: (${groupId}`,
226
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
232
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
227
233
  hw_version: 'Virtual fixture — exists as a DMX Group Node in Node-RED',
228
- sw_version: 'ha-mqtt-dmx-group: 0.1.0',
234
+ sw_version: 'ha-mqtt-dmx-group: ' + _pkgVer,
229
235
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
230
236
  },
231
237
  };
@@ -323,6 +329,19 @@ module.exports = function (RED) {
323
329
  }
324
330
 
325
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
+
326
345
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
327
346
  RED.httpAdmin.post('/ha-mqtt-dmx-group/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx-group.write'), function(req, res) {
328
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>
@@ -5,6 +5,10 @@
5
5
  // ============================================================
6
6
 
7
7
  module.exports = function (RED) {
8
+ // Package version — read once at module load
9
+ const PKG_VERSION = require('path').join(__dirname, '../package.json');
10
+ const _pkgVer = (() => { try { return require(PKG_VERSION).version; } catch(e) { return '?'; } })();
11
+
8
12
 
9
13
  // ── Gamma correction table ────────────────────────────────────
10
14
  // Pre-computed CIE 1931 gamma 2.2 lookup — 0..255 → 0..255
@@ -104,6 +108,10 @@ module.exports = function (RED) {
104
108
  minOutput: parseInt(config.minOutput) || 1,
105
109
  brightBump: parseInt(config.brightBump) || 50,
106
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,
107
115
  flashShort: cfg.flashShort,
108
116
  flashLong: cfg.flashLong,
109
117
  diskDelay: cfg.diskDelay,
@@ -111,11 +119,11 @@ module.exports = function (RED) {
111
119
 
112
120
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
113
121
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
114
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
122
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
115
123
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
116
124
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
117
125
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
118
- const dmxTopic = `${cfg.siteId}/${cfg.zone}/dmx/${S.universe}`;
126
+ const dmxTopic = cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
119
127
 
120
128
  // ── Context helpers ───────────────────────────────────────
121
129
  // Check disk store available once on startup
@@ -158,26 +166,45 @@ module.exports = function (RED) {
158
166
  }
159
167
 
160
168
  function buildDmxPayload(channel, value) {
161
- 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;
162
171
  return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
163
172
  }
164
173
 
174
+ // Fix 2: Cache last sent value per channel — skip publish if unchanged
175
+ const _lastSent = {};
176
+
165
177
  function sendDmxChannels(channels) {
166
178
  channels.forEach(function ([ch, val]) {
167
179
  const payload = buildDmxPayload(ch, val);
168
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;
169
187
  broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
188
+ if (S.debugMode) node.debug(`${fixtureId} → ${dmxTopic} "${payload}"`);
170
189
  });
171
190
  }
172
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
+
173
198
  // ── MQTT helpers ──────────────────────────────────────────
174
199
  function pub(topic, payload, retain) {
200
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
175
201
  broker.publish({
176
202
  topic,
177
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
203
+ payload: strPayload,
178
204
  qos: cfg.qos,
179
205
  retain: retain !== undefined ? retain : cfg.retain,
180
206
  });
207
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
181
208
  }
182
209
 
183
210
  function pubState(payload) {
@@ -257,23 +284,40 @@ module.exports = function (RED) {
257
284
  // ── Transition ────────────────────────────────────────────
258
285
  function runTransition(fromChannels, toChannels, durationSecs) {
259
286
  stopEffect();
260
- const totalTicks = Math.round(durationSecs * S.ticksPerSec);
261
- 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);
262
300
  let tick = 0;
263
301
 
264
- effectTimer = setInterval(function () {
265
- tick++;
266
- const progress = Math.min(1, tick / totalTicks);
267
- const channels = fromChannels.map(function ([ch, from], i) {
268
- const to = toChannels[i] ? toChannels[i][1] : 0;
269
- return [ch, Math.round(from + (to - from) * progress)];
270
- });
271
- sendDmxChannels(channels);
272
- if (tick >= totalTicks) {
273
- stopEffect();
274
- sendDmxChannels(toChannels);
275
- }
276
- }, 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);
277
321
  }
278
322
 
279
323
  // ── State persistence ─────────────────────────────────────
@@ -305,7 +349,12 @@ module.exports = function (RED) {
305
349
 
306
350
  const toChannels = buildColorChannels(brightness, r, g, b, w, ww);
307
351
 
308
- 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) {
309
358
  const bump = sendInitialBump(toChannels);
310
359
  const prevBright = recall('brightness', 'brightness_disk', 0);
311
360
  const prevR = recall('red', 'red_disk', S.brightBump || r);
@@ -316,7 +365,7 @@ module.exports = function (RED) {
316
365
  const fromChannels = buildColorChannels(
317
366
  bump || prevBright, prevR, prevG, prevB, prevW, prevWW
318
367
  );
319
- runTransition(fromChannels, toChannels, payload.transition);
368
+ runTransition(fromChannels, toChannels, transitionDuration);
320
369
  } else {
321
370
  sendInitialBump(toChannels);
322
371
  sendDmxChannels(toChannels);
@@ -341,7 +390,7 @@ module.exports = function (RED) {
341
390
 
342
391
  if (S.transitions && payload && payload.transition && payload.transition > 0) {
343
392
  const fromChannels = buildColorChannels(brightness, r, g, b, w, ww);
344
- runTransition(fromChannels, toChannels, payload.transition);
393
+ runTransition(fromChannels, toChannels, transitionDuration);
345
394
  } else {
346
395
  sendDmxChannels(toChannels);
347
396
  }
@@ -377,7 +426,7 @@ module.exports = function (RED) {
377
426
  // Rainbow
378
427
  if (effectName === 'rainbow') {
379
428
  let deg = 0;
380
- startEffect('Rainbow', Math.round(1000 / S.ticksPerSec), () => {
429
+ startEffect('Rainbow', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
381
430
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
382
431
  const [r,g,b] = hsvToRgb(deg, 1, 1);
383
432
  sendDmxChannels(buildColorChannels(255, r, g, b, 0, 0));
@@ -387,7 +436,7 @@ module.exports = function (RED) {
387
436
  // Rainbow RGBW
388
437
  if (effectName === 'rainbow_rgbw') {
389
438
  let deg = 0;
390
- startEffect('Rainbow RGBW', Math.round(1000 / S.ticksPerSec), () => {
439
+ startEffect('Rainbow RGBW', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
391
440
  deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
392
441
  const [r,g,b] = hsvToRgb(deg, 1, 1);
393
442
  const w = Math.round(255 * Math.abs(Math.sin(deg * Math.PI / 180)));
@@ -415,7 +464,7 @@ module.exports = function (RED) {
415
464
  // Twinkle
416
465
  if (effectName === 'twinkle') {
417
466
  let dir = 1; let bright = 0;
418
- startEffect('Twinkle', Math.round(1000 / S.ticksPerSec), () => {
467
+ startEffect('Twinkle', Math.round(1000 / Math.max(1, S.ticksPerSec * S.rateLimit)), () => {
419
468
  bright = Math.max(0, Math.min(255, bright + dir * 8));
420
469
  if (bright >= 255 || bright <= 0) dir = -dir;
421
470
  sendDmxChannels(buildColorChannels(bright, 255, 255, 255, 255, 0));
@@ -458,7 +507,7 @@ module.exports = function (RED) {
458
507
  ] : [];
459
508
 
460
509
  const discovery = {
461
- unique_id: `${S.deviceType}(${fixtureId})`,
510
+ unique_id: fixtureId,
462
511
  schema: 'json',
463
512
  object_id: objectId,
464
513
  optimistic: false,
@@ -475,21 +524,22 @@ module.exports = function (RED) {
475
524
  max_mireds: 500,
476
525
  stat_t: statTopic,
477
526
  cmd_t: cmdTopic,
478
- 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)}`,
479
528
  device: {
480
529
  identifiers: `light-${fixtureId}`,
481
- name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
482
- 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}`,
483
532
  model_id: `referenced on plan as: (${fixtureId}`,
484
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
533
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
485
534
  hw_version: `DMX Controller in ${cfg.zone}. MQTT: ${dmxTopic}`,
486
535
  serial_number: fixtureId,
487
- sw_version: 'ha-mqtt-dmx: 0.1.0',
536
+ sw_version: 'ha-mqtt-dmx: ' + _pkgVer,
488
537
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
489
538
  },
490
539
  };
491
540
 
492
541
  pub(cfgTopic, discovery, true);
542
+ resetLastSent(); // Force full state re-publish after discovery
493
543
 
494
544
  broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
495
545
  let payload;
@@ -588,6 +638,19 @@ module.exports = function (RED) {
588
638
  }
589
639
 
590
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
+
591
654
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
592
655
  RED.httpAdmin.post('/ha-mqtt-dmx/:id/toggle', RED.auth.needsPermission('ha-mqtt-dmx.write'), function(req, res) {
593
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>
@@ -5,6 +5,10 @@
5
5
  // ============================================================
6
6
 
7
7
  module.exports = function (RED) {
8
+ // Package version — read once at module load
9
+ const PKG_VERSION = require('path').join(__dirname, '../package.json');
10
+ const _pkgVer = (() => { try { return require(PKG_VERSION).version; } catch(e) { return '?'; } })();
11
+
8
12
 
9
13
  function HaMqttPirNode(config) {
10
14
  RED.nodes.createNode(this, config);
@@ -80,11 +84,12 @@ module.exports = function (RED) {
80
84
  cableColor: config.cableColor || 'Purple',
81
85
  holdTime: parseInt(config.holdTime) || 15,
82
86
  warmupTime: parseInt(config.warmupTime) || 120,
87
+ debugMode: config.debugMode === true,
83
88
  };
84
89
 
85
90
  const fixtureId = `S-${S.uid}${S.uidPostfix}`;
86
91
  const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
87
- const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
92
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
88
93
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
89
94
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
90
95
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
@@ -92,12 +97,14 @@ module.exports = function (RED) {
92
97
 
93
98
  // ── Helpers ───────────────────────────────────────────────
94
99
  function pub(topic, payload, retain) {
100
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
95
101
  broker.publish({
96
102
  topic,
97
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
103
+ payload: strPayload,
98
104
  qos: cfg.qos,
99
105
  retain: retain !== undefined ? retain : cfg.retain,
100
106
  });
107
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
101
108
  }
102
109
 
103
110
  function setStatus(fill, shape, text) {
@@ -154,7 +161,7 @@ module.exports = function (RED) {
154
161
  const discovery = {
155
162
  unique_id: fixtureId,
156
163
  object_id: objectId,
157
- 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)}`,
158
165
  stat_t: statTopic,
159
166
  avty_t: avtyTopic,
160
167
  payload_available: 'online',
@@ -165,13 +172,13 @@ module.exports = function (RED) {
165
172
  icon: S.haIcon,
166
173
  device: {
167
174
  identifiers: `binary_sensor-${fixtureId}`,
168
- 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)}`,
169
176
  model: `${S.pirType} PIR located ${S.situation} the ${cfg.zone} - ${S.area}`,
170
177
  model_id: `referenced on plan as: (${fixtureId}`,
171
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
178
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
172
179
  hw_version: `${S.pirType} PIR — ${S.cableColor} Cat5 cable. Publishes "${S.pirPayload}" on topic: ${S.subscribeTopic}`,
173
180
  serial_number: `(${fixtureId}) Payload: ${S.pirPayload}`,
174
- sw_version: `ha-mqtt-pir: ${RED.version ? RED.version() : '0.1.0'}`,
181
+ sw_version: `ha-mqtt-pir: ${_pkgVer}`,
175
182
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
176
183
  },
177
184
  };
@@ -227,6 +234,19 @@ module.exports = function (RED) {
227
234
  }
228
235
 
229
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
+
230
250
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
231
251
  RED.httpAdmin.post('/ha-mqtt-pir/:id/toggle', RED.auth.needsPermission('ha-mqtt-pir.write'), function(req, res) {
232
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>
@@ -8,6 +8,10 @@
8
8
  // ============================================================
9
9
 
10
10
  module.exports = function (RED) {
11
+ // Package version — read once at module load
12
+ const PKG_VERSION = require('path').join(__dirname, '../package.json');
13
+ const _pkgVer = (() => { try { return require(PKG_VERSION).version; } catch(e) { return '?'; } })();
14
+
11
15
 
12
16
  function HaMqttRelayNode(config) {
13
17
  RED.nodes.createNode(this, config);
@@ -91,11 +95,11 @@ module.exports = function (RED) {
91
95
 
92
96
  const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
93
97
  const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
94
- const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
98
+ const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
95
99
  const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
96
100
  const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
97
101
  const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
98
- 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);
99
103
 
100
104
  // ── Context helpers ───────────────────────────────────────
101
105
  // Check disk store available once on startup
@@ -129,12 +133,14 @@ module.exports = function (RED) {
129
133
 
130
134
  // ── MQTT helpers ──────────────────────────────────────────
131
135
  function pub(topic, payload, retain) {
136
+ const strPayload = typeof payload === 'object' ? JSON.stringify(payload) : String(payload);
132
137
  broker.publish({
133
138
  topic,
134
- payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
139
+ payload: strPayload,
135
140
  qos: cfg.qos,
136
141
  retain: retain !== undefined ? retain : cfg.retain,
137
142
  });
143
+ if (S.debugMode) node.debug(`${fixtureId} → ${topic} ${strPayload.substring(0, 120)}`);
138
144
  }
139
145
 
140
146
  function pubRelay(value) {
@@ -228,10 +234,10 @@ module.exports = function (RED) {
228
234
  }
229
235
 
230
236
  const discovery = {
231
- unique_id: `${S.deviceType}(${fixtureId})`,
237
+ unique_id: fixtureId,
232
238
  schema: 'json',
233
239
  object_id: objectId,
234
- 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)}`,
235
241
  cmd_t: cmdTopic,
236
242
  stat_t: statTopic,
237
243
  optimistic: false,
@@ -245,13 +251,13 @@ module.exports = function (RED) {
245
251
  flash_time_long: S.flashLong,
246
252
  device: {
247
253
  identifiers: `light-${fixtureId}`,
248
- 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)}`,
249
255
  model: `${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
250
256
  model_id: `referenced on plan as: (${fixtureId}`,
251
- suggested_area: discoveryMode !== 'hidden' ? `${cfg.zone} ${S.area} ${S.subLocation}` : undefined,
257
+ suggested_area: discoveryMode !== 'hidden' ? buildLocation(cfg.zone, S.area, S.subLocation) : undefined,
252
258
  hw_version: `Relay Controller in ${cfg.zone}. Topic: ${relayTopic}`,
253
259
  serial_number: fixtureId,
254
- sw_version: 'ha-mqtt-relay: 0.1.0',
260
+ sw_version: 'ha-mqtt-relay: ' + _pkgVer,
255
261
  manufacturer: 'DeSwaggy — Discord: @deswaggy',
256
262
  },
257
263
  };
@@ -340,6 +346,19 @@ module.exports = function (RED) {
340
346
  }
341
347
 
342
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
+
343
362
  // ── HTTP endpoint for canvas toggle button ───────────────────────────────
344
363
  RED.httpAdmin.post('/ha-mqtt-relay/:id/toggle', RED.auth.needsPermission('ha-mqtt-relay.write'), function(req, res) {
345
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.3",
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",