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 +39 -0
- package/nodes/ha-mqtt-button.html +13 -0
- package/nodes/ha-mqtt-button.js +28 -8
- 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 +26 -7
- package/nodes/ha-mqtt-dmx.html +13 -0
- package/nodes/ha-mqtt-dmx.js +94 -31
- package/nodes/ha-mqtt-pir.html +13 -0
- package/nodes/ha-mqtt-pir.js +26 -6
- package/nodes/ha-mqtt-relay.html +13 -0
- package/nodes/ha-mqtt-relay.js +27 -8
- 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
|
@@ -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 =
|
|
87
|
-
const uiBtnTopic =
|
|
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:
|
|
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:
|
|
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
|
|
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' ?
|
|
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:
|
|
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
|
|
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: ''
|
|
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>
|
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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' ?
|
|
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:
|
|
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);
|
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
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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
|
|
482
|
-
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}`,
|
|
483
532
|
model_id: `referenced on plan as: (${fixtureId}`,
|
|
484
|
-
suggested_area: discoveryMode !== 'hidden' ?
|
|
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:
|
|
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);
|
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
|
@@ -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 =
|
|
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:
|
|
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
|
|
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
|
|
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' ?
|
|
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: ${
|
|
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);
|
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
|
@@ -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 =
|
|
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 =
|
|
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:
|
|
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:
|
|
237
|
+
unique_id: fixtureId,
|
|
232
238
|
schema: 'json',
|
|
233
239
|
object_id: objectId,
|
|
234
|
-
name: `${S.deviceType} ${S.situation} the ${cfg.zone
|
|
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
|
|
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' ?
|
|
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:
|
|
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.
|
|
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",
|