node-red-contrib-dmx-for-ha 0.4.9 → 0.6.1
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/LICENSE +28 -0
- package/README.md +49 -0
- package/nodes/ha-mqtt-button.html +28 -4
- package/nodes/ha-mqtt-button.js +67 -9
- package/nodes/ha-mqtt-config.html +16 -2
- package/nodes/ha-mqtt-config.js +4 -2
- package/nodes/ha-mqtt-dmx-group.html +1 -1
- package/nodes/ha-mqtt-dmx-group.js +31 -1
- package/nodes/ha-mqtt-dmx.html +34 -3
- package/nodes/ha-mqtt-dmx.js +93 -15
- package/nodes/ha-mqtt-pir.html +30 -6
- package/nodes/ha-mqtt-pir.js +78 -11
- package/nodes/ha-mqtt-relay.html +15 -4
- package/nodes/ha-mqtt-relay.js +59 -5
- package/package.json +17 -10
- package/profiles/README.md +105 -0
- package/profiles/button/custom.js +17 -0
- package/profiles/button/mw3d_v1.js +37 -0
- package/profiles/button/mw3d_v2.js +36 -0
- package/profiles/dmx/custom.js +65 -0
- package/profiles/dmx/etherten_v1.js +26 -0
- package/profiles/dmx/etherten_v2.js +32 -0
- package/profiles/dmx/mqtt_json.js +27 -0
- package/profiles/index.js +45 -0
- package/profiles/pir/custom.js +15 -0
- package/profiles/pir/mw3d_v1.js +34 -0
- package/profiles/pir/mw3d_v2.js +38 -0
- package/profiles/relay/bedrock_v1.js +17 -0
- package/profiles/relay/custom.js +26 -0
- package/profiles/relay/generic_onoff.js +16 -0
- package/profiles/relay/mqtt_json.js +15 -0
- package/docs/config_node_spec.md +0 -236
- package/docs/dmx_node_env_reference.md +0 -341
- package/docs/master_todo.md +0 -428
- package/docs/node_contracts.md +0 -278
- package/docs/nr_subflow_gotchas.md +0 -258
- package/subflow/README.md +0 -35
- package/subflow/button_node_v5.0.3.js +0 -324
- package/subflow/dmx_group_node_v0.3.8.js +0 -860
- package/subflow/dmx_node_v0.5.9.js +0 -1994
- package/subflow/pir_node_v1.0.3.js +0 -365
- package/subflow/relay_node_v4.0.2.js +0 -553
- package/subflow/subflow_definitions.json +0 -6154
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
GNU GENERAL PUBLIC LICENSE
|
|
2
|
+
Version 3, 29 June 2007
|
|
3
|
+
|
|
4
|
+
node-red-contrib-dmx-for-ha
|
|
5
|
+
Copyright (C) 2024-2026 DeSwaggy
|
|
6
|
+
|
|
7
|
+
This program is free software: you can redistribute it and/or modify
|
|
8
|
+
it under the terms of the GNU General Public License as published by
|
|
9
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
10
|
+
(at your option) any later version.
|
|
11
|
+
|
|
12
|
+
This program is distributed in the hope that it will be useful,
|
|
13
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
14
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
15
|
+
GNU General Public License for more details.
|
|
16
|
+
|
|
17
|
+
You should have received a copy of the GNU General Public License
|
|
18
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
Full licence text: https://www.gnu.org/licenses/gpl-3.0.txt
|
|
23
|
+
|
|
24
|
+
ADDITIONAL NOTE:
|
|
25
|
+
This package was built from 6+ years of real-world professional
|
|
26
|
+
installation experience. If you use it, improve it, or build on it —
|
|
27
|
+
please contribute your changes back to the community. That's the spirit
|
|
28
|
+
of this licence and the spirit in which it was written.
|
package/README.md
CHANGED
|
@@ -64,6 +64,16 @@ Most DMX implementations require either expensive proprietary hardware or comple
|
|
|
64
64
|
- Configurable DMX floor value — prevents low-value flicker on hardware that needs it
|
|
65
65
|
- Debug mode per node — 12hr auto-disable, safe to leave in production flows
|
|
66
66
|
|
|
67
|
+
## Licence
|
|
68
|
+
|
|
69
|
+
This package is licensed under **[GPL v3](https://www.gnu.org/licenses/gpl-3.0.txt)**.
|
|
70
|
+
|
|
71
|
+
You are free to use, modify, and distribute this package. Any derivative works must be distributed under the same GPL v3 licence. This package may not be taken proprietary, rebranded as closed source, or distributed without crediting the original author.
|
|
72
|
+
|
|
73
|
+
This package was built from 6+ years of real-world professional installation experience. If you use it, improve it, or build on it — please contribute your changes back to the community.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
67
77
|
> **Note:** This is building automation DMX — fixtures, decoders, dimmers, relay switching. Not entertainment industry DMX (no movers, gobos, or fixture profiles). DMX is an open standard and this package works with any DMX controller that accepts MQTT payloads.
|
|
68
78
|
|
|
69
79
|
---
|
|
@@ -290,6 +300,45 @@ Entity IDs in HA are locked to the fixture ID and survive friendly name changes.
|
|
|
290
300
|
|
|
291
301
|
---
|
|
292
302
|
|
|
303
|
+
## Retain discovery — why the default is false
|
|
304
|
+
|
|
305
|
+
If you search "MQTT discovery retain" you will find HA community posts and documentation recommending `retain=true` for discovery messages. **This advice is correct for ESP and battery-powered IoT devices — but not for Node-RED.**
|
|
306
|
+
|
|
307
|
+
### Why ESP devices need retain=true
|
|
308
|
+
|
|
309
|
+
An ESP sensor runs independently of HA. When HA restarts or updates, the ESP device has no way of knowing — it thinks it already discovered itself and won't re-send the discovery payload. Without `retain=true`, the device disappears from HA permanently until the ESP reboots.
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
ESP device Home Assistant
|
|
313
|
+
────────── ──────────────
|
|
314
|
+
Sends discovery once → HA restarts
|
|
315
|
+
Doesn't know HA restarted Retained message restores device ✓
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### Why Node-RED is different
|
|
319
|
+
|
|
320
|
+
Node-RED sits on the same server stack as HA. They are tightly coupled — if one goes down the other knows immediately via the websocket connection. When HA restarts, NR reconnects and re-discovers every device automatically. If NR goes down, HA *should* show nothing — because nothing is working.
|
|
321
|
+
|
|
322
|
+
```
|
|
323
|
+
Node-RED + Home Assistant (same server):
|
|
324
|
+
NR down → no devices in HA → client sees fault immediately ✓
|
|
325
|
+
HA restart → NR reconnects → re-discovers everything ✓
|
|
326
|
+
retain=true → ghost devices → client confused, support call ✗
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### The config node setting
|
|
330
|
+
|
|
331
|
+
The `Retain discovery` setting on the config node controls this behaviour:
|
|
332
|
+
|
|
333
|
+
| Setting | Behaviour | Recommended for |
|
|
334
|
+
|---|---|---|
|
|
335
|
+
| `false` (default) | Devices disappear if NR stops | Professional installs — clear fault indication |
|
|
336
|
+
| `true` | Devices persist if NR stops | Standalone IoT, voice assistant integrations where entity persistence matters |
|
|
337
|
+
|
|
338
|
+
**Default is `false`** — if a client opens their HA dashboard and sees no lights, they know something is wrong with Node-RED. This is the correct behaviour for a professionally installed system where NR and HA live on the same hardware.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
293
342
|
## MQTT topic formats
|
|
294
343
|
|
|
295
344
|
| Node | Topic |
|
|
@@ -41,8 +41,9 @@
|
|
|
41
41
|
subLocation: { value: '' },
|
|
42
42
|
// Button specifics
|
|
43
43
|
buttonPosition: { value: '' },
|
|
44
|
-
buttonPayload:
|
|
45
|
-
subscribeTopic:
|
|
44
|
+
buttonPayload: { value: '', required: true },
|
|
45
|
+
subscribeTopic: { value: '', required: true },
|
|
46
|
+
controllerProfile: { value: 'mw3d_v1' },
|
|
46
47
|
// Options
|
|
47
48
|
haIcon: { value: 'mdi:gesture-tap-button' },
|
|
48
49
|
ledColor: { value: 'Blue' },
|
|
@@ -239,12 +240,35 @@
|
|
|
239
240
|
</div>
|
|
240
241
|
|
|
241
242
|
<div class="form-row">
|
|
243
|
+
<label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
|
|
244
|
+
<select id="node-input-controllerProfile" style="width:65%" onchange="
|
|
245
|
+
var v = this.value;
|
|
246
|
+
document.getElementById('btn-payload-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
|
|
247
|
+
document.getElementById('btn-topic-row').style.display = (v === 'mw3d_v1' || v === 'custom') ? '' : 'none';
|
|
248
|
+
document.getElementById('btn-v2-note').style.display = (v === 'mw3d_v2') ? '' : 'none';
|
|
249
|
+
">
|
|
250
|
+
<option value="mw3d_v1">MW3D v1 — Legacy (panelId-GPIO payload)</option>
|
|
251
|
+
<option value="mw3d_v2">MW3D v2 — JSON (fixture ID payload)</option>
|
|
252
|
+
<option value="custom">Custom</option>
|
|
253
|
+
</select>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div class="form-row" id="btn-v2-note" style="display:none">
|
|
257
|
+
<label></label>
|
|
258
|
+
<span style="color:#999; font-size:0.85em;">
|
|
259
|
+
<i class="fa fa-info-circle"></i>
|
|
260
|
+
v2 firmware — topic and payload auto-configured from zone + fixture ID.
|
|
261
|
+
No manual payload or topic needed.
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<div class="form-row" id="btn-payload-row">
|
|
242
266
|
<label for="node-input-buttonPayload"><i class="fa fa-hand-pointer-o"></i> Payload</label>
|
|
243
267
|
<input type="text" id="node-input-buttonPayload" placeholder="Required — e.g. 10-54" style="width:120px" />
|
|
244
268
|
<span style="margin-left:8px; color:#999; font-size:0.85em;">panelId-GPIOpin</span>
|
|
245
269
|
</div>
|
|
246
270
|
|
|
247
|
-
<div class="form-row">
|
|
271
|
+
<div class="form-row" id="btn-topic-row">
|
|
248
272
|
<label for="node-input-subscribeTopic"><i class="fa fa-exchange"></i> Subscribe topic</label>
|
|
249
273
|
<input type="text" id="node-input-subscribeTopic" placeholder="Required — e.g. buttons or home/zone/buttons" style="width:55%" />
|
|
250
274
|
</div>
|
|
@@ -289,7 +313,7 @@
|
|
|
289
313
|
</div>
|
|
290
314
|
|
|
291
315
|
<div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
|
|
292
|
-
node-red-contrib-dmx-for-ha v0.
|
|
316
|
+
node-red-contrib-dmx-for-ha v0.6.1
|
|
293
317
|
</div>
|
|
294
318
|
|
|
295
319
|
</script>
|
package/nodes/ha-mqtt-button.js
CHANGED
|
@@ -28,6 +28,13 @@ module.exports = function (RED) {
|
|
|
28
28
|
|
|
29
29
|
function autoDiscover() {
|
|
30
30
|
if (discoveryMode === 'disabled') {
|
|
31
|
+
const fixtureIdD = `S-${config.uid}${config.uidPostfix || ''}`;
|
|
32
|
+
const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureIdD);
|
|
33
|
+
const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
|
|
34
|
+
broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
|
|
35
|
+
const uiBtnTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureIdD + '-BTN');
|
|
36
|
+
const uiBtnCfgTopicD = `${uiBtnTopicD}/${cfg.configTopic}`;
|
|
37
|
+
broker.publish({ topic: uiBtnCfgTopicD, payload: '', qos: cfg.qos, retain: true });
|
|
31
38
|
setStatus('grey', 'ring', 'Disabled — not discovered');
|
|
32
39
|
return;
|
|
33
40
|
}
|
|
@@ -99,8 +106,9 @@ module.exports = function (RED) {
|
|
|
99
106
|
situation: config.situation || 'in',
|
|
100
107
|
subLocation: config.subLocation || '',
|
|
101
108
|
buttonPosition: config.buttonPosition || 'Single',
|
|
102
|
-
buttonPayload:
|
|
103
|
-
subscribeTopic:
|
|
109
|
+
buttonPayload: String(config.buttonPayload || ''),
|
|
110
|
+
subscribeTopic: config.subscribeTopic || 'buttons',
|
|
111
|
+
controllerProfile: config.controllerProfile || 'mw3d_v1',
|
|
104
112
|
haIcon: config.haIcon || 'mdi:gesture-tap-button',
|
|
105
113
|
ledColor: config.ledColor || 'Blue',
|
|
106
114
|
holdTime: parseFloat(config.holdTime) || 0.5,
|
|
@@ -122,8 +130,49 @@ module.exports = function (RED) {
|
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
|
|
133
|
+
// ── Controller profile ────────────────────────────────────────────
|
|
134
|
+
let _btnProfile;
|
|
135
|
+
try {
|
|
136
|
+
const _profiles = require('../profiles/index');
|
|
137
|
+
_btnProfile = (_profiles.button || []).find(p => p.id === S.controllerProfile)
|
|
138
|
+
|| _profiles.button[0];
|
|
139
|
+
} catch(e) {
|
|
140
|
+
_btnProfile = {
|
|
141
|
+
id: 'mw3d_v1',
|
|
142
|
+
buildTopic: () => null,
|
|
143
|
+
parsePayload: (raw, fid, exp) => raw === exp ? { match: true, state: 'on' } : null,
|
|
144
|
+
hasOffEvent: false
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const btnProfile = _btnProfile;
|
|
148
|
+
|
|
125
149
|
// ── Fixture identity and topics ───────────────────────────────────────
|
|
126
150
|
const fixtureId = `S-${S.uid}${S.uidPostfix}`;
|
|
151
|
+
// ── Fixture ID duplicate detection ──────────────────────────────────
|
|
152
|
+
function registerFixtureId() {
|
|
153
|
+
const globalCtx = node.context().global;
|
|
154
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
155
|
+
let reg = {};
|
|
156
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
157
|
+
if (reg[fixtureId] && reg[fixtureId] !== node.id) {
|
|
158
|
+
node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
|
|
159
|
+
setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
|
|
160
|
+
} else {
|
|
161
|
+
reg[fixtureId] = node.id;
|
|
162
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function unregisterFixtureId() {
|
|
166
|
+
const globalCtx = node.context().global;
|
|
167
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
168
|
+
let reg = {};
|
|
169
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
170
|
+
if (reg[fixtureId] === node.id) {
|
|
171
|
+
delete reg[fixtureId];
|
|
172
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
128
177
|
const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'binary_sensor', fixtureId);
|
|
129
178
|
const uiBtnTopic = cfg.buildTopic(cfg.discoveryPrefix, 'button', fixtureId + '-BTN');
|
|
@@ -157,6 +206,7 @@ module.exports = function (RED) {
|
|
|
157
206
|
|
|
158
207
|
// ── Device add ────────────────────────────────────────────
|
|
159
208
|
function handleDeviceAdd() {
|
|
209
|
+
registerFixtureId();
|
|
160
210
|
// Binary sensor discovery
|
|
161
211
|
const bsDiscovery = {
|
|
162
212
|
unique_id: fixtureId,
|
|
@@ -192,11 +242,18 @@ module.exports = function (RED) {
|
|
|
192
242
|
};
|
|
193
243
|
|
|
194
244
|
pub(cfgTopic, bsDiscovery, true);
|
|
195
|
-
pub(uiBtnCfgTopic, uiDiscovery,
|
|
196
|
-
|
|
197
|
-
// Subscribe to controller topic
|
|
198
|
-
|
|
199
|
-
|
|
245
|
+
pub(uiBtnCfgTopic, uiDiscovery, cfg.retainDiscovery);
|
|
246
|
+
|
|
247
|
+
// Subscribe to controller topic — topic and matching via profile
|
|
248
|
+
const _btnSubTopic = btnProfile.buildTopic ? btnProfile.buildTopic(cfg) : null;
|
|
249
|
+
const activeBtnTopic = _btnSubTopic || S.subscribeTopic;
|
|
250
|
+
S._activeBtnTopic = activeBtnTopic;
|
|
251
|
+
|
|
252
|
+
broker.subscribe(activeBtnTopic, cfg.qos, function (topic, payload) {
|
|
253
|
+
const result = btnProfile.parsePayload(
|
|
254
|
+
String(payload), fixtureId, S.buttonPayload
|
|
255
|
+
);
|
|
256
|
+
if (result && result.match && result.state === 'on') handlePress('physical');
|
|
200
257
|
}, node.id);
|
|
201
258
|
|
|
202
259
|
// Subscribe to HA UI button cmd topic
|
|
@@ -211,9 +268,10 @@ module.exports = function (RED) {
|
|
|
211
268
|
|
|
212
269
|
// ── Device remove ─────────────────────────────────────────
|
|
213
270
|
function handleDeviceRemove() {
|
|
271
|
+
unregisterFixtureId();
|
|
214
272
|
pub(cfgTopic, '', true);
|
|
215
273
|
pub(uiBtnCfgTopic, '', true);
|
|
216
|
-
broker.unsubscribe(S.subscribeTopic, node.id);
|
|
274
|
+
broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
|
|
217
275
|
broker.unsubscribe(uiBtnCmdTopic, node.id);
|
|
218
276
|
setStatus('red', 'ring', `${fixtureId} removed`);
|
|
219
277
|
node.log(`${fixtureId} device removed`);
|
|
@@ -239,7 +297,7 @@ module.exports = function (RED) {
|
|
|
239
297
|
|
|
240
298
|
// ── Cleanup ───────────────────────────────────────────────
|
|
241
299
|
node.on('close', function (done) {
|
|
242
|
-
broker.unsubscribe(S.subscribeTopic, node.id);
|
|
300
|
+
broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
|
|
243
301
|
broker.unsubscribe(uiBtnCmdTopic, node.id);
|
|
244
302
|
broker.deregister(node, done);
|
|
245
303
|
});
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
zone: { value: '' },
|
|
14
14
|
broker: { value: '', type: 'mqtt-broker', required: true },
|
|
15
15
|
discoveryPrefix: { value: 'homeassistant' },
|
|
16
|
-
qos:
|
|
17
|
-
retain:
|
|
16
|
+
qos: { value: '0' },
|
|
17
|
+
retain: { value: 'false' },
|
|
18
|
+
retainDiscovery: { value: 'false' },
|
|
18
19
|
configTopic: { value: 'config' },
|
|
19
20
|
stateTopic: { value: 'state' },
|
|
20
21
|
commandTopic: { value: 'cmd' },
|
|
@@ -142,6 +143,19 @@
|
|
|
142
143
|
</select>
|
|
143
144
|
</div>
|
|
144
145
|
|
|
146
|
+
<div class="form-row">
|
|
147
|
+
<label for="node-config-input-retainDiscovery" style="width:auto">
|
|
148
|
+
<i class="fa fa-thumb-tack"></i> Retain discovery
|
|
149
|
+
</label>
|
|
150
|
+
<select id="node-config-input-retainDiscovery" style="width:80px">
|
|
151
|
+
<option value="false">false</option>
|
|
152
|
+
<option value="true">true</option>
|
|
153
|
+
</select>
|
|
154
|
+
<span style="color:#999; font-size:0.85em; margin-left:8px">
|
|
155
|
+
false = devices disappear from HA if NR stops (recommended)
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
145
159
|
<hr/>
|
|
146
160
|
|
|
147
161
|
<!-- DEFAULTS -->
|
package/nodes/ha-mqtt-config.js
CHANGED
|
@@ -48,8 +48,9 @@ module.exports = function (RED) {
|
|
|
48
48
|
this.availTopic = config.availTopic || 'avty';
|
|
49
49
|
|
|
50
50
|
// MQTT settings
|
|
51
|
-
this.qos
|
|
52
|
-
this.retain
|
|
51
|
+
this.qos = parseInt(config.qos) || 0;
|
|
52
|
+
this.retain = config.retain === 'true';
|
|
53
|
+
this.retainDiscovery = config.retainDiscovery === 'true';
|
|
53
54
|
|
|
54
55
|
// HA defaults
|
|
55
56
|
this.enabledDefault = config.enabledDefault !== 'false';
|
|
@@ -83,6 +84,7 @@ module.exports = function (RED) {
|
|
|
83
84
|
availTopic: this.availTopic,
|
|
84
85
|
qos: this.qos,
|
|
85
86
|
retain: this.retain,
|
|
87
|
+
retainDiscovery: this.retainDiscovery,
|
|
86
88
|
enabledDefault: this.enabledDefault,
|
|
87
89
|
diskDelay: this.diskDelay,
|
|
88
90
|
flashShort: this.flashShort,
|
|
@@ -317,7 +317,7 @@
|
|
|
317
317
|
</div>
|
|
318
318
|
|
|
319
319
|
<div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
|
|
320
|
-
node-red-contrib-dmx-for-ha v0.
|
|
320
|
+
node-red-contrib-dmx-for-ha v0.6.1
|
|
321
321
|
</div>
|
|
322
322
|
|
|
323
323
|
</script>
|
|
@@ -31,6 +31,10 @@ module.exports = function (RED) {
|
|
|
31
31
|
|
|
32
32
|
function autoDiscover() {
|
|
33
33
|
if (discoveryMode === 'disabled') {
|
|
34
|
+
const fixtureIdD = `LG-${config.uid}${config.uidPostfix || ''}`;
|
|
35
|
+
const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureIdD);
|
|
36
|
+
const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
|
|
37
|
+
broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
|
|
34
38
|
setStatus('grey', 'ring', 'Disabled — not discovered');
|
|
35
39
|
return;
|
|
36
40
|
}
|
|
@@ -132,6 +136,31 @@ module.exports = function (RED) {
|
|
|
132
136
|
|
|
133
137
|
// ── Group identity and topics ─────────────────────────────────────────
|
|
134
138
|
const groupId = `LG-${S.uid}${S.uidPostfix}`;
|
|
139
|
+
// ── Fixture ID duplicate detection ──────────────────────────────────
|
|
140
|
+
function registerFixtureId() {
|
|
141
|
+
const globalCtx = node.context().global;
|
|
142
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
143
|
+
let reg = {};
|
|
144
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
145
|
+
if (reg[groupId] && reg[groupId] !== node.id) {
|
|
146
|
+
node.warn(`${groupId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[groupId]}`);
|
|
147
|
+
setStatus('red', 'dot', `DUPLICATE ID: ${groupId}`);
|
|
148
|
+
} else {
|
|
149
|
+
reg[groupId] = node.id;
|
|
150
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function unregisterFixtureId() {
|
|
154
|
+
const globalCtx = node.context().global;
|
|
155
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
156
|
+
let reg = {};
|
|
157
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
158
|
+
if (reg[groupId] === node.id) {
|
|
159
|
+
delete reg[groupId];
|
|
160
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
135
164
|
const objectId = `lg_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
136
165
|
const groupTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', groupId);
|
|
137
166
|
const cfgTopic = `${groupTopic}/${cfg.configTopic}`;
|
|
@@ -248,6 +277,7 @@ module.exports = function (RED) {
|
|
|
248
277
|
|
|
249
278
|
// ── Device add ────────────────────────────────────────────
|
|
250
279
|
function handleDeviceAdd(incomingTrace) {
|
|
280
|
+
registerFixtureId();
|
|
251
281
|
if (!ctxGet('state') && !ctxGet('state', 'disk_values')) {
|
|
252
282
|
ctxSet('state', S.defaultState);
|
|
253
283
|
}
|
|
@@ -283,7 +313,7 @@ module.exports = function (RED) {
|
|
|
283
313
|
},
|
|
284
314
|
};
|
|
285
315
|
|
|
286
|
-
pub(cfgTopic, discovery,
|
|
316
|
+
pub(cfgTopic, discovery, cfg.retainDiscovery);
|
|
287
317
|
|
|
288
318
|
broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
|
|
289
319
|
let payload;
|
package/nodes/ha-mqtt-dmx.html
CHANGED
|
@@ -158,8 +158,9 @@
|
|
|
158
158
|
chWhite: { value: '' },
|
|
159
159
|
chWarmWhite: { value: '' },
|
|
160
160
|
// DMX controller
|
|
161
|
-
controllerNum:
|
|
162
|
-
universe:
|
|
161
|
+
controllerNum: { value: '1' },
|
|
162
|
+
universe: { value: '1' },
|
|
163
|
+
controllerProfile: { value: 'etherten_v1' },
|
|
163
164
|
// Options
|
|
164
165
|
haIcon: { value: 'mdi:lightbulb' },
|
|
165
166
|
showEffects: { value: true },
|
|
@@ -378,6 +379,36 @@
|
|
|
378
379
|
</label>
|
|
379
380
|
</div>
|
|
380
381
|
|
|
382
|
+
<div class="form-row">
|
|
383
|
+
<label for="node-input-controllerProfile"><i class="fa fa-microchip"></i> Profile</label>
|
|
384
|
+
<select id="node-input-controllerProfile" style="width:65%" onchange="
|
|
385
|
+
var v = this.value;
|
|
386
|
+
document.getElementById('dmx-v2-note').style.display = (v === 'etherten_v2') ? '' : 'none';
|
|
387
|
+
document.getElementById('dmx-custom-row').style.display = (v === 'custom') ? '' : 'none';
|
|
388
|
+
">
|
|
389
|
+
<option value="etherten_v1">EtherTen v1 — Legacy "212255" (one msg per channel)</option>
|
|
390
|
+
<option value="etherten_v2">EtherTen v2 — Batched [[212,255],...] (one msg per tick)</option>
|
|
391
|
+
<option value="mqtt_json">MQTT JSON — {"channel":212,"value":255}</option>
|
|
392
|
+
<option value="custom">Custom payload</option>
|
|
393
|
+
</select>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div class="form-row" id="dmx-v2-note" style="display:none">
|
|
397
|
+
<label></label>
|
|
398
|
+
<span style="color:#999; font-size:0.85em;">
|
|
399
|
+
<i class="fa fa-info-circle"></i>
|
|
400
|
+
v2 batches all channel changes into one MQTT message per tick.
|
|
401
|
+
Requires MW3D DMXController firmware v3.0+.
|
|
402
|
+
Significantly reduces MQTT traffic during effects and transitions.
|
|
403
|
+
</span>
|
|
404
|
+
</div>
|
|
405
|
+
|
|
406
|
+
<div class="form-row" id="dmx-custom-row" style="display:none">
|
|
407
|
+
<label for="node-input-customTopic"><i class="fa fa-exchange"></i> Custom topic</label>
|
|
408
|
+
<input type="text" id="node-input-customTopic"
|
|
409
|
+
placeholder="{siteId}/{zone}/dmx/{universe}" style="width:55%" />
|
|
410
|
+
</div>
|
|
411
|
+
|
|
381
412
|
<div class="form-row">
|
|
382
413
|
<label for="node-input-controllerNum">
|
|
383
414
|
<i class="fa fa-sort-numeric-asc"></i> Controller
|
|
@@ -553,7 +584,7 @@
|
|
|
553
584
|
</div>
|
|
554
585
|
|
|
555
586
|
<div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
|
|
556
|
-
node-red-contrib-dmx-for-ha v0.
|
|
587
|
+
node-red-contrib-dmx-for-ha v0.6.1
|
|
557
588
|
</div>
|
|
558
589
|
|
|
559
590
|
</script>
|
package/nodes/ha-mqtt-dmx.js
CHANGED
|
@@ -38,6 +38,11 @@ module.exports = function (RED) {
|
|
|
38
38
|
|
|
39
39
|
function autoDiscover() {
|
|
40
40
|
if (discoveryMode === 'disabled') {
|
|
41
|
+
// Clear any previously retained discovery message so HA removes the device
|
|
42
|
+
const fixtureIdD = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
|
|
43
|
+
const fixTopicD = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureIdD);
|
|
44
|
+
const cfgTopicD = `${fixTopicD}/${cfg.configTopic}`;
|
|
45
|
+
broker.publish({ topic: cfgTopicD, payload: '', qos: cfg.qos, retain: true });
|
|
41
46
|
setStatus('grey', 'ring', 'Disabled — not discovered');
|
|
42
47
|
return;
|
|
43
48
|
}
|
|
@@ -119,7 +124,8 @@ module.exports = function (RED) {
|
|
|
119
124
|
warmWhite:parseInt(config.chWarmWhite)|| 0,
|
|
120
125
|
},
|
|
121
126
|
controllerNum: config.controllerNum || '1',
|
|
122
|
-
universe:
|
|
127
|
+
universe: config.universe || '1',
|
|
128
|
+
controllerProfile: config.controllerProfile || 'etherten_v1',
|
|
123
129
|
haIcon: config.haIcon || 'mdi:lightbulb',
|
|
124
130
|
showEffects: config.showEffects !== false,
|
|
125
131
|
transitions: config.transitions !== false,
|
|
@@ -219,18 +225,41 @@ module.exports = function (RED) {
|
|
|
219
225
|
const _lastSent = {};
|
|
220
226
|
|
|
221
227
|
function sendDmxChannels(channels) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (
|
|
228
|
-
|
|
228
|
+
if (dmxProfile.batched) {
|
|
229
|
+
// ── Batched mode (etherten_v2) ─────────────────────────────
|
|
230
|
+
// Collect all changed channels → publish ONE message per tick
|
|
231
|
+
const batch = [];
|
|
232
|
+
channels.forEach(function ([ch, val]) {
|
|
233
|
+
if (!ch || ch <= 0 || val == null) return;
|
|
234
|
+
if (_lastSent[ch] === val) {
|
|
235
|
+
if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
_lastSent[ch] = val;
|
|
239
|
+
batch.push([ch, val]);
|
|
240
|
+
});
|
|
241
|
+
if (batch.length > 0) {
|
|
242
|
+
const payload = dmxProfile.buildPayload(null, null, batch);
|
|
243
|
+
broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
|
|
244
|
+
if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} batch[${batch.length}] ${payload}`);
|
|
229
245
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
246
|
+
} else {
|
|
247
|
+
// ── Single message per channel (etherten_v1 legacy) ────────
|
|
248
|
+
channels.forEach(function ([ch, val]) {
|
|
249
|
+
if (!ch || ch <= 0 || val == null) return;
|
|
250
|
+
if (_lastSent[ch] === val) {
|
|
251
|
+
if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → SKIP ch${ch}=${val} (unchanged)`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
_lastSent[ch] = val;
|
|
255
|
+
const payload = dmxProfile.buildPayload
|
|
256
|
+
? dmxProfile.buildPayload(ch, val, null)
|
|
257
|
+
: buildDmxPayload(ch, val);
|
|
258
|
+
if (payload === null) return;
|
|
259
|
+
broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
|
|
260
|
+
if (S.debugMode) node.warn(`[DEBUG] ${fixtureId} → ${dmxTopic} "${payload}"`);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
function resetLastSent() {
|
|
@@ -556,14 +585,61 @@ module.exports = function (RED) {
|
|
|
556
585
|
|
|
557
586
|
|
|
558
587
|
|
|
559
|
-
|
|
588
|
+
|
|
589
|
+
// ── DMX Controller profile ────────────────────────────────────────
|
|
590
|
+
let _dmxProfile;
|
|
591
|
+
try {
|
|
592
|
+
const _profiles = require('../profiles/index');
|
|
593
|
+
_dmxProfile = (_profiles.dmx || []).find(p => p.id === S.controllerProfile)
|
|
594
|
+
|| _profiles.dmx[0];
|
|
595
|
+
} catch(e) {
|
|
596
|
+
// Fallback to etherten_v1 if profiles not found
|
|
597
|
+
_dmxProfile = {
|
|
598
|
+
id: 'etherten_v1',
|
|
599
|
+
batched: false,
|
|
600
|
+
buildPayload: (ch, val) =>
|
|
601
|
+
String(ch).padStart(3,'0') + String(val).padStart(3,'0'),
|
|
602
|
+
buildTopic: (cfg, universe) =>
|
|
603
|
+
cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', universe)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
const dmxProfile = _dmxProfile;
|
|
607
|
+
|
|
608
|
+
// ── Fixture identity and topics ───────────────────────────────────────
|
|
560
609
|
const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
|
|
610
|
+
|
|
611
|
+
// ── Fixture ID duplicate detection ──────────────────────────────────
|
|
612
|
+
function registerFixtureId() {
|
|
613
|
+
const globalCtx = node.context().global;
|
|
614
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
615
|
+
let reg = {};
|
|
616
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
617
|
+
if (reg[fixtureId] && reg[fixtureId] !== node.id) {
|
|
618
|
+
node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
|
|
619
|
+
setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
|
|
620
|
+
} else {
|
|
621
|
+
reg[fixtureId] = node.id;
|
|
622
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function unregisterFixtureId() {
|
|
626
|
+
const globalCtx = node.context().global;
|
|
627
|
+
const regKey = `dmx_fixture_ids_${cfg.siteId}`;
|
|
628
|
+
let reg = {};
|
|
629
|
+
try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
|
|
630
|
+
if (reg[fixtureId] === node.id) {
|
|
631
|
+
delete reg[fixtureId];
|
|
632
|
+
try { globalCtx.set(regKey, reg); } catch(e) {}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
561
635
|
const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
562
636
|
const fixtureTopic = cfg.buildTopic(cfg.discoveryPrefix, 'light', fixtureId);
|
|
563
637
|
const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
|
|
564
638
|
const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
|
|
565
639
|
const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
|
|
566
|
-
const dmxTopic =
|
|
640
|
+
const dmxTopic = dmxProfile.buildTopic
|
|
641
|
+
? dmxProfile.buildTopic(cfg, S.universe)
|
|
642
|
+
: cfg.buildTopic(cfg.siteId, cfg.zone, 'dmx', S.universe);
|
|
567
643
|
|
|
568
644
|
// ── DMX channel conflict detection ───────────────────────────────────
|
|
569
645
|
function checkChannelConflicts() {
|
|
@@ -608,6 +684,7 @@ module.exports = function (RED) {
|
|
|
608
684
|
|
|
609
685
|
// ── Device add ────────────────────────────────────────────
|
|
610
686
|
function handleDeviceAdd() {
|
|
687
|
+
registerFixtureId();
|
|
611
688
|
if (ctxGet('state') === undefined && ctxGet('state', 'disk_values') === undefined) {
|
|
612
689
|
ctxSet('state', S.defaultState);
|
|
613
690
|
}
|
|
@@ -649,7 +726,7 @@ module.exports = function (RED) {
|
|
|
649
726
|
},
|
|
650
727
|
};
|
|
651
728
|
|
|
652
|
-
pub(cfgTopic, discovery,
|
|
729
|
+
pub(cfgTopic, discovery, cfg.retainDiscovery);
|
|
653
730
|
// Check for DMX channel conflicts before discovery
|
|
654
731
|
if (!checkChannelConflicts()) return;
|
|
655
732
|
resetLastSent(); // Force full state re-publish after discovery
|
|
@@ -698,6 +775,7 @@ module.exports = function (RED) {
|
|
|
698
775
|
|
|
699
776
|
// ── Device remove ─────────────────────────────────────────
|
|
700
777
|
function handleDeviceRemove() {
|
|
778
|
+
unregisterFixtureId();
|
|
701
779
|
clearChannelRegistry();
|
|
702
780
|
stopEffect();
|
|
703
781
|
if (diskTimer) {
|