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 +39 -0
- package/nodes/ha-mqtt-button.html +13 -0
- package/nodes/ha-mqtt-button.js +23 -7
- package/nodes/ha-mqtt-config.html +5 -4
- package/nodes/ha-mqtt-config.js +8 -0
- package/nodes/ha-mqtt-dmx-group.html +13 -0
- package/nodes/ha-mqtt-dmx-group.js +20 -5
- package/nodes/ha-mqtt-dmx.html +13 -0
- package/nodes/ha-mqtt-dmx.js +88 -29
- package/nodes/ha-mqtt-pir.html +13 -0
- package/nodes/ha-mqtt-pir.js +21 -5
- package/nodes/ha-mqtt-relay.html +13 -0
- package/nodes/ha-mqtt-relay.js +21 -6
- package/package.json +1 -1
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> </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 v0.2.2
|
|
280
293
|
</div>
|
package/nodes/ha-mqtt-button.js
CHANGED
|
@@ -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 =
|
|
91
|
-
const uiBtnTopic =
|
|
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:
|
|
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:
|
|
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
|
|
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' ?
|
|
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
|
|
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: ''
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
package/nodes/ha-mqtt-config.js
CHANGED
|
@@ -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> </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 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 =
|
|
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:
|
|
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
|
|
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
|
|
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' ?
|
|
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);
|
package/nodes/ha-mqtt-dmx.html
CHANGED
|
@@ -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> </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 v0.2.2
|
|
544
557
|
</div>
|
package/nodes/ha-mqtt-dmx.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
486
|
-
model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone}
|
|
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' ?
|
|
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);
|
package/nodes/ha-mqtt-pir.html
CHANGED
|
@@ -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> </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 v0.2.2
|
|
285
298
|
</div>
|
package/nodes/ha-mqtt-pir.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
|
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
|
|
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' ?
|
|
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);
|
package/nodes/ha-mqtt-relay.html
CHANGED
|
@@ -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> </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 v0.2.2
|
|
281
294
|
</div>
|
package/nodes/ha-mqtt-relay.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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' ?
|
|
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.
|
|
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",
|