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