node-red-contrib-dmx-for-ha 0.1.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 +282 -0
- package/docs/config_node_spec.md +236 -0
- package/docs/dmx_node_env_reference.md +341 -0
- package/docs/master_todo.md +428 -0
- package/docs/node_contracts.md +278 -0
- package/docs/nr_subflow_gotchas.md +258 -0
- package/nodes/ha-mqtt-button.html +326 -0
- package/nodes/ha-mqtt-button.js +158 -0
- package/nodes/ha-mqtt-config.html +233 -0
- package/nodes/ha-mqtt-config.js +81 -0
- package/nodes/ha-mqtt-dmx-group.html +392 -0
- package/nodes/ha-mqtt-dmx-group.js +265 -0
- package/nodes/ha-mqtt-dmx.html +547 -0
- package/nodes/ha-mqtt-dmx.js +537 -0
- package/nodes/ha-mqtt-pir.html +343 -0
- package/nodes/ha-mqtt-pir.js +183 -0
- package/nodes/ha-mqtt-relay.html +326 -0
- package/nodes/ha-mqtt-relay.js +289 -0
- package/package.json +39 -0
- package/subflow/README.md +35 -0
- package/subflow/button_node_v5.0.3.js +324 -0
- package/subflow/dmx_group_node_v0.3.8.js +860 -0
- package/subflow/dmx_node_v0.5.9.js +1994 -0
- package/subflow/pir_node_v1.0.3.js +365 -0
- package/subflow/relay_node_v4.0.2.js +553 -0
- package/subflow/subflow_definitions.json +6154 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// ha-mqtt-dmx — DMX Fixture Node Runtime
|
|
3
|
+
// Package: node-red-contrib-dmx-for-ha
|
|
4
|
+
// Author: DeSwaggy — Discord: @deswaggy
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
module.exports = function (RED) {
|
|
8
|
+
|
|
9
|
+
// ── Gamma correction table ────────────────────────────────────
|
|
10
|
+
// Pre-computed CIE 1931 gamma 2.2 lookup — 0..255 → 0..255
|
|
11
|
+
const GAMMA_TABLE = (function () {
|
|
12
|
+
const t = new Array(256);
|
|
13
|
+
for (let i = 0; i < 256; i++) {
|
|
14
|
+
t[i] = Math.round(Math.pow(i / 255, 2.2) * 255);
|
|
15
|
+
}
|
|
16
|
+
return t;
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
function HaMqttDmxNode(config) {
|
|
20
|
+
RED.nodes.createNode(this, config);
|
|
21
|
+
const node = this;
|
|
22
|
+
|
|
23
|
+
// ── Config & broker ───────────────────────────────────────
|
|
24
|
+
const cfg = RED.nodes.getNode(config.config);
|
|
25
|
+
if (!cfg) { node.error('DMX: no config node selected'); return; }
|
|
26
|
+
|
|
27
|
+
const broker = RED.nodes.getNode(cfg.broker);
|
|
28
|
+
if (!broker) { node.error('DMX: no MQTT broker in config'); return; }
|
|
29
|
+
|
|
30
|
+
broker.register(node);
|
|
31
|
+
|
|
32
|
+
// ── Node settings ─────────────────────────────────────────
|
|
33
|
+
const S = {
|
|
34
|
+
uidPrefix: config.uidPrefix || 'L',
|
|
35
|
+
uid: config.uid || '',
|
|
36
|
+
uidPostfix: config.uidPostfix || '',
|
|
37
|
+
deviceType: config.deviceType || 'Downlight',
|
|
38
|
+
colorMode: config.colorMode || 'rgbw',
|
|
39
|
+
area: config.area || '',
|
|
40
|
+
situation: config.situation || 'in',
|
|
41
|
+
subLocation: config.subLocation || '',
|
|
42
|
+
ch: {
|
|
43
|
+
red: parseInt(config.chRed) || 0,
|
|
44
|
+
green: parseInt(config.chGreen) || 0,
|
|
45
|
+
blue: parseInt(config.chBlue) || 0,
|
|
46
|
+
white: parseInt(config.chWhite) || 0,
|
|
47
|
+
warmWhite:parseInt(config.chWarmWhite)|| 0,
|
|
48
|
+
},
|
|
49
|
+
controllerNum: config.controllerNum || '1',
|
|
50
|
+
universe: config.universe || '1',
|
|
51
|
+
haIcon: config.haIcon || 'mdi:lightbulb',
|
|
52
|
+
showEffects: config.showEffects !== false,
|
|
53
|
+
transitions: config.transitions !== false,
|
|
54
|
+
groupSync: config.groupSync === true,
|
|
55
|
+
defaultState: config.defaultState || 'OFF',
|
|
56
|
+
dmxLimiter: parseInt(config.dmxLimiter) || 255,
|
|
57
|
+
minOutput: parseInt(config.minOutput) || 1,
|
|
58
|
+
brightBump: parseInt(config.brightBump) || 50,
|
|
59
|
+
ticksPerSec: parseInt(config.ticksPerSec) || 31,
|
|
60
|
+
flashShort: cfg.flashShort,
|
|
61
|
+
flashLong: cfg.flashLong,
|
|
62
|
+
diskDelay: cfg.diskDelay,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const fixtureId = `${S.uidPrefix}-${S.uid}${S.uidPostfix}`;
|
|
66
|
+
const objectId = `${S.uidPrefix}_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
67
|
+
const fixtureTopic = `${cfg.discoveryPrefix}/light/${fixtureId}`;
|
|
68
|
+
const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
|
|
69
|
+
const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
|
|
70
|
+
const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
|
|
71
|
+
const dmxTopic = `${cfg.siteId}/${cfg.zone}/dmx/${S.universe}`;
|
|
72
|
+
|
|
73
|
+
// ── Context helpers ───────────────────────────────────────
|
|
74
|
+
function ctxGet(key, store) {
|
|
75
|
+
try { return node.context().get(key, store); }
|
|
76
|
+
catch(e) { return node.context().get(key); }
|
|
77
|
+
}
|
|
78
|
+
function ctxSet(key, val, store) {
|
|
79
|
+
try { node.context().set(key, val, store); }
|
|
80
|
+
catch(e) { node.context().set(key, val); }
|
|
81
|
+
}
|
|
82
|
+
function recall(ramKey, diskKey, fallback) {
|
|
83
|
+
const v = ctxGet(ramKey) ?? ctxGet(ramKey, 'disk');
|
|
84
|
+
return v !== null && v !== undefined ? v : fallback;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Disk save timer ───────────────────────────────────────
|
|
88
|
+
let diskTimer = null;
|
|
89
|
+
function startDiskSave(onComplete) {
|
|
90
|
+
if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
|
|
91
|
+
diskTimer = setTimeout(() => { diskTimer = null; onComplete(); }, S.diskDelay * 1000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── DMX helpers ───────────────────────────────────────────
|
|
95
|
+
function scaleToDmx(colorValue, brightness) {
|
|
96
|
+
brightness = brightness !== undefined ? brightness : 255;
|
|
97
|
+
const limited = Math.round((colorValue / 255) * (brightness / 255) * S.dmxLimiter);
|
|
98
|
+
const gamma = GAMMA_TABLE[Math.max(0, Math.min(255, limited))];
|
|
99
|
+
// Min output floor — only apply when both inputs are non-zero (intentionally on)
|
|
100
|
+
if (gamma === 0 && colorValue > 0 && brightness > 0 && S.minOutput > 0) {
|
|
101
|
+
return S.minOutput;
|
|
102
|
+
}
|
|
103
|
+
return gamma;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildDmxPayload(channel, value) {
|
|
107
|
+
if (channel == null || value == null) return null;
|
|
108
|
+
return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sendDmxChannels(channels) {
|
|
112
|
+
channels.forEach(function ([ch, val]) {
|
|
113
|
+
const payload = buildDmxPayload(ch, val);
|
|
114
|
+
if (payload === null) return;
|
|
115
|
+
broker.publish({ topic: dmxTopic, payload, qos: cfg.qos, retain: false });
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── MQTT helpers ──────────────────────────────────────────
|
|
120
|
+
function pub(topic, payload, retain) {
|
|
121
|
+
broker.publish({
|
|
122
|
+
topic,
|
|
123
|
+
payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
|
|
124
|
+
qos: cfg.qos,
|
|
125
|
+
retain: retain !== undefined ? retain : cfg.retain,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pubState(payload) {
|
|
130
|
+
pub(statTopic, JSON.stringify(payload), false);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function setStatus(fill, shape, text) {
|
|
134
|
+
node.status({ fill, shape, text });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Effects ───────────────────────────────────────────────
|
|
138
|
+
let effectTimer = null;
|
|
139
|
+
let preEffectState = null;
|
|
140
|
+
|
|
141
|
+
function stopEffect() {
|
|
142
|
+
if (effectTimer) {
|
|
143
|
+
clearInterval(effectTimer); clearTimeout(effectTimer);
|
|
144
|
+
effectTimer = null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function savePreEffectState() {
|
|
149
|
+
preEffectState = {
|
|
150
|
+
state: ctxGet('state'),
|
|
151
|
+
brightness: ctxGet('brightness'),
|
|
152
|
+
red: ctxGet('red'),
|
|
153
|
+
green: ctxGet('green'),
|
|
154
|
+
blue: ctxGet('blue'),
|
|
155
|
+
white: ctxGet('white'),
|
|
156
|
+
warmWhite: ctxGet('warmWhite'),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function restoreAfterEffect() {
|
|
161
|
+
if (!preEffectState) return;
|
|
162
|
+
const p = preEffectState;
|
|
163
|
+
preEffectState = null;
|
|
164
|
+
const channels = buildColorChannels(
|
|
165
|
+
p.brightness || 255, p.red || 255, p.green || 255,
|
|
166
|
+
p.blue || 255, p.white || 255, p.warmWhite || 0
|
|
167
|
+
);
|
|
168
|
+
sendDmxChannels(channels);
|
|
169
|
+
pubState({ state: p.state || 'OFF', color_mode: S.colorMode });
|
|
170
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function startEffect(label, intervalMs, tickFn) {
|
|
174
|
+
stopEffect();
|
|
175
|
+
savePreEffectState();
|
|
176
|
+
setStatus('blue', 'dot', `${fixtureId} effect: ${label}`);
|
|
177
|
+
effectTimer = setInterval(tickFn, intervalMs);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Color channel builder ─────────────────────────────────
|
|
181
|
+
function buildColorChannels(brightness, r, g, b, w, ww) {
|
|
182
|
+
const mode = S.colorMode;
|
|
183
|
+
if (mode === 'rgbw') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)],[S.ch.white,scaleToDmx(w,brightness)]];
|
|
184
|
+
if (mode === 'rgbww') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)],[S.ch.white,scaleToDmx(w,brightness)],[S.ch.warmWhite,scaleToDmx(ww,brightness)]];
|
|
185
|
+
if (mode === 'rgb') return [[S.ch.red,scaleToDmx(r,brightness)],[S.ch.green,scaleToDmx(g,brightness)],[S.ch.blue,scaleToDmx(b,brightness)]];
|
|
186
|
+
if (mode === 'color_temp') return [[S.ch.white,scaleToDmx(w,brightness)],[S.ch.warmWhite,scaleToDmx(ww,brightness)]];
|
|
187
|
+
if (mode === 'brightness') return [[S.ch.white,scaleToDmx(255,brightness)]];
|
|
188
|
+
if (mode === 'onoff') return [[S.ch.white,scaleToDmx(255,brightness)]];
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Initial bump ──────────────────────────────────────────
|
|
193
|
+
function sendInitialBump(targetChannels) {
|
|
194
|
+
const prevState = recall('state', 'state_disk', S.defaultState);
|
|
195
|
+
if (prevState !== 'OFF' || !S.brightBump || S.brightBump <= 0) return 0;
|
|
196
|
+
const bumpDmx = GAMMA_TABLE[Math.min(255, S.brightBump)];
|
|
197
|
+
targetChannels.forEach(function ([ch, to]) {
|
|
198
|
+
if (to > 0) sendDmxChannels([[ch, Math.min(bumpDmx, to)]]);
|
|
199
|
+
});
|
|
200
|
+
return bumpDmx;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── Transition ────────────────────────────────────────────
|
|
204
|
+
function runTransition(fromChannels, toChannels, durationSecs) {
|
|
205
|
+
stopEffect();
|
|
206
|
+
const totalTicks = Math.round(durationSecs * S.ticksPerSec);
|
|
207
|
+
const intervalMs = Math.round(1000 / S.ticksPerSec);
|
|
208
|
+
let tick = 0;
|
|
209
|
+
|
|
210
|
+
effectTimer = setInterval(function () {
|
|
211
|
+
tick++;
|
|
212
|
+
const progress = Math.min(1, tick / totalTicks);
|
|
213
|
+
const channels = fromChannels.map(function ([ch, from], i) {
|
|
214
|
+
const to = toChannels[i] ? toChannels[i][1] : 0;
|
|
215
|
+
return [ch, Math.round(from + (to - from) * progress)];
|
|
216
|
+
});
|
|
217
|
+
sendDmxChannels(channels);
|
|
218
|
+
if (tick >= totalTicks) {
|
|
219
|
+
stopEffect();
|
|
220
|
+
sendDmxChannels(toChannels);
|
|
221
|
+
}
|
|
222
|
+
}, intervalMs);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── State persistence ─────────────────────────────────────
|
|
226
|
+
function saveState(state, brightness, r, g, b, w, ww) {
|
|
227
|
+
ctxSet('state', state);
|
|
228
|
+
ctxSet('brightness', brightness);
|
|
229
|
+
ctxSet('red', r); ctxSet('green', g); ctxSet('blue', b);
|
|
230
|
+
ctxSet('white', w); ctxSet('warmWhite', ww);
|
|
231
|
+
startDiskSave(function () {
|
|
232
|
+
ctxSet('state', state, 'disk');
|
|
233
|
+
ctxSet('brightness', brightness, 'disk');
|
|
234
|
+
ctxSet('red', r, 'disk'); ctxSet('green', g, 'disk');
|
|
235
|
+
ctxSet('blue', b, 'disk'); ctxSet('white', w, 'disk');
|
|
236
|
+
ctxSet('warmWhite', ww, 'disk');
|
|
237
|
+
node.log(`${fixtureId} disk saved — state:${state}`);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ── ON handler ────────────────────────────────────────────
|
|
242
|
+
function handleON(payload) {
|
|
243
|
+
stopEffect();
|
|
244
|
+
const brightness = payload.brightness !== undefined ? payload.brightness : (recall('brightness','brightness_disk',255));
|
|
245
|
+
const color = payload.color || {};
|
|
246
|
+
const r = color.r !== undefined ? color.r : recall('red', 'red_disk', 255);
|
|
247
|
+
const g = color.g !== undefined ? color.g : recall('green', 'green_disk', 255);
|
|
248
|
+
const b = color.b !== undefined ? color.b : recall('blue', 'blue_disk', 255);
|
|
249
|
+
const w = color.w !== undefined ? color.w : recall('white', 'white_disk', 255);
|
|
250
|
+
const ww = color.ww!== undefined ? color.ww: recall('warmWhite','warmWhite_disk', 0);
|
|
251
|
+
|
|
252
|
+
const toChannels = buildColorChannels(brightness, r, g, b, w, ww);
|
|
253
|
+
|
|
254
|
+
if (S.transitions && payload.transition && payload.transition > 0) {
|
|
255
|
+
const bump = sendInitialBump(toChannels);
|
|
256
|
+
const prevBright = recall('brightness', 'brightness_disk', 0);
|
|
257
|
+
const prevR = recall('red', 'red_disk', S.brightBump || r);
|
|
258
|
+
const prevG = recall('green', 'green_disk', S.brightBump || g);
|
|
259
|
+
const prevB = recall('blue', 'blue_disk', S.brightBump || b);
|
|
260
|
+
const prevW = recall('white', 'white_disk', S.brightBump || w);
|
|
261
|
+
const prevWW = recall('warmWhite','warmWhite_disk', 0);
|
|
262
|
+
const fromChannels = buildColorChannels(
|
|
263
|
+
bump || prevBright, prevR, prevG, prevB, prevW, prevWW
|
|
264
|
+
);
|
|
265
|
+
runTransition(fromChannels, toChannels, payload.transition);
|
|
266
|
+
} else {
|
|
267
|
+
sendInitialBump(toChannels);
|
|
268
|
+
sendDmxChannels(toChannels);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
saveState('ON', brightness, r, g, b, w, ww);
|
|
272
|
+
pubState({ state: 'ON', color_mode: S.colorMode, brightness, color: { r, g, b, w, ww } });
|
|
273
|
+
setStatus('green', 'dot', `${fixtureId} ON bright:${brightness}`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── OFF handler ───────────────────────────────────────────
|
|
277
|
+
function handleOFF(payload) {
|
|
278
|
+
stopEffect();
|
|
279
|
+
const brightness = recall('brightness', 'brightness_disk', 255);
|
|
280
|
+
const r = recall('red', 'red_disk', 255);
|
|
281
|
+
const g = recall('green', 'green_disk', 255);
|
|
282
|
+
const b = recall('blue', 'blue_disk', 255);
|
|
283
|
+
const w = recall('white', 'white_disk', 255);
|
|
284
|
+
const ww = recall('warmWhite', 'warmWhite_disk', 0);
|
|
285
|
+
|
|
286
|
+
const toChannels = buildColorChannels(0, r, g, b, w, ww);
|
|
287
|
+
|
|
288
|
+
if (S.transitions && payload && payload.transition && payload.transition > 0) {
|
|
289
|
+
const fromChannels = buildColorChannels(brightness, r, g, b, w, ww);
|
|
290
|
+
runTransition(fromChannels, toChannels, payload.transition);
|
|
291
|
+
} else {
|
|
292
|
+
sendDmxChannels(toChannels);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
saveState('OFF', brightness, r, g, b, w, ww);
|
|
296
|
+
pubState({ state: 'OFF', color_mode: S.colorMode });
|
|
297
|
+
setStatus('grey', 'ring', `${fixtureId} OFF`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── Effect dispatch ───────────────────────────────────────
|
|
301
|
+
function runEffect(effectName) {
|
|
302
|
+
if (effectName === 'none') {
|
|
303
|
+
stopEffect();
|
|
304
|
+
restoreAfterEffect();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Flash effects
|
|
308
|
+
if (effectName === 'flash_short' || effectName === 'flash_long') {
|
|
309
|
+
const dur = effectName === 'flash_short' ? S.flashShort : S.flashLong;
|
|
310
|
+
stopEffect(); savePreEffectState();
|
|
311
|
+
const allOn = buildColorChannels(255,255,255,255,255,255);
|
|
312
|
+
const allOff = buildColorChannels(0,0,0,0,0,0);
|
|
313
|
+
sendDmxChannels(allOn);
|
|
314
|
+
effectTimer = setTimeout(() => { effectTimer = null; sendDmxChannels(allOff); restoreAfterEffect(); }, dur * 1000);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Strobe
|
|
318
|
+
if (effectName === 'strobe') {
|
|
319
|
+
let on = false;
|
|
320
|
+
startEffect('strobe', 100, () => { on = !on; sendDmxChannels(buildColorChannels(on ? 255 : 0, 255,255,255,255,255)); });
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
// Rainbow
|
|
324
|
+
if (effectName === 'rainbow') {
|
|
325
|
+
let deg = 0;
|
|
326
|
+
startEffect('Rainbow', Math.round(1000 / S.ticksPerSec), () => {
|
|
327
|
+
deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
|
|
328
|
+
const [r,g,b] = hsvToRgb(deg, 1, 1);
|
|
329
|
+
sendDmxChannels(buildColorChannels(255, r, g, b, 0, 0));
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
// Rainbow RGBW
|
|
334
|
+
if (effectName === 'rainbow_rgbw') {
|
|
335
|
+
let deg = 0;
|
|
336
|
+
startEffect('Rainbow RGBW', Math.round(1000 / S.ticksPerSec), () => {
|
|
337
|
+
deg = (deg + 360 / (S.ticksPerSec * 3)) % 360;
|
|
338
|
+
const [r,g,b] = hsvToRgb(deg, 1, 1);
|
|
339
|
+
const w = Math.round(255 * Math.abs(Math.sin(deg * Math.PI / 180)));
|
|
340
|
+
sendDmxChannels(buildColorChannels(255, r, g, b, w, 0));
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Fire
|
|
345
|
+
if (effectName === 'fire') {
|
|
346
|
+
startEffect('Fire', 80, () => {
|
|
347
|
+
const r = 200 + Math.floor(Math.random() * 55);
|
|
348
|
+
const g = Math.floor(Math.random() * 80);
|
|
349
|
+
sendDmxChannels(buildColorChannels(255, r, g, 0, 0, 0));
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Flicker
|
|
354
|
+
if (effectName === 'flicker') {
|
|
355
|
+
startEffect('Flicker', 120, () => {
|
|
356
|
+
const bright = 180 + Math.floor(Math.random() * 75);
|
|
357
|
+
sendDmxChannels(buildColorChannels(bright, 255, 180, 50, 200, 0));
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
// Twinkle
|
|
362
|
+
if (effectName === 'twinkle') {
|
|
363
|
+
let dir = 1; let bright = 0;
|
|
364
|
+
startEffect('Twinkle', Math.round(1000 / S.ticksPerSec), () => {
|
|
365
|
+
bright = Math.max(0, Math.min(255, bright + dir * 8));
|
|
366
|
+
if (bright >= 255 || bright <= 0) dir = -dir;
|
|
367
|
+
sendDmxChannels(buildColorChannels(bright, 255, 255, 255, 255, 0));
|
|
368
|
+
});
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
// Police
|
|
372
|
+
if (effectName === 'police') {
|
|
373
|
+
let tick = 0;
|
|
374
|
+
startEffect('Police', 150, () => {
|
|
375
|
+
tick++;
|
|
376
|
+
const isRed = (tick % 4) < 2;
|
|
377
|
+
sendDmxChannels(buildColorChannels(255, isRed ? 255 : 0, 0, isRed ? 0 : 255, 0, 0));
|
|
378
|
+
});
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
node.warn(`${fixtureId} — unknown effect: "${effectName}"`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── HSV helper ────────────────────────────────────────────
|
|
385
|
+
function hsvToRgb(h, s, v) {
|
|
386
|
+
const i = Math.floor(h / 60) % 6;
|
|
387
|
+
const f = h / 60 - Math.floor(h / 60);
|
|
388
|
+
const p = Math.round(v * (1 - s) * 255);
|
|
389
|
+
const q = Math.round(v * (1 - f * s) * 255);
|
|
390
|
+
const t = Math.round(v * (1 - (1 - f) * s) * 255);
|
|
391
|
+
const vv = Math.round(v * 255);
|
|
392
|
+
return [[vv,t,p],[q,vv,p],[p,vv,t],[p,q,vv],[t,p,vv],[vv,p,q]][i];
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Device add ────────────────────────────────────────────
|
|
396
|
+
function handleDeviceAdd() {
|
|
397
|
+
if (ctxGet('state') === undefined && ctxGet('state', 'disk') === undefined) {
|
|
398
|
+
ctxSet('state', S.defaultState);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const effectList = S.showEffects ? [
|
|
402
|
+
'none','flash_short','flash_long','strobe','rainbow','rainbow_rgbw',
|
|
403
|
+
'fire','flicker','twinkle','police',
|
|
404
|
+
] : [];
|
|
405
|
+
|
|
406
|
+
const discovery = {
|
|
407
|
+
unique_id: `${S.deviceType}(${fixtureId})`,
|
|
408
|
+
schema: 'json',
|
|
409
|
+
object_id: objectId,
|
|
410
|
+
optimistic: false,
|
|
411
|
+
enabled_by_default: cfg.enabledDefault,
|
|
412
|
+
icon: S.haIcon,
|
|
413
|
+
supported_color_modes: [S.colorMode],
|
|
414
|
+
brightness: true,
|
|
415
|
+
brightness_scale: 255,
|
|
416
|
+
effect: S.showEffects,
|
|
417
|
+
effect_list: effectList,
|
|
418
|
+
flash_time_short: S.flashShort,
|
|
419
|
+
flash_time_long: S.flashLong,
|
|
420
|
+
min_mireds: 153,
|
|
421
|
+
max_mireds: 500,
|
|
422
|
+
stat_t: statTopic,
|
|
423
|
+
cmd_t: cmdTopic,
|
|
424
|
+
name: `${S.deviceType} ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
425
|
+
device: {
|
|
426
|
+
identifiers: `light-${fixtureId}`,
|
|
427
|
+
name: `(${fixtureId}) - ${S.deviceType} ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
|
|
428
|
+
model: `${S.colorMode} ${S.deviceType} located ${S.situation} the ${cfg.zone} - ${S.area}`,
|
|
429
|
+
model_id: `referenced on plan as: (${fixtureId}`,
|
|
430
|
+
suggested_area: `${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
431
|
+
hw_version: `DMX Controller in ${cfg.zone}. MQTT: ${dmxTopic}`,
|
|
432
|
+
serial_number: fixtureId,
|
|
433
|
+
sw_version: 'ha-mqtt-dmx: 0.1.0',
|
|
434
|
+
manufacturer: 'DeSwaggy — Discord: @deswaggy',
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
pub(cfgTopic, discovery, true);
|
|
439
|
+
|
|
440
|
+
broker.subscribe(cmdTopic, cfg.qos, function (topic, rawPayload) {
|
|
441
|
+
let payload;
|
|
442
|
+
try { payload = JSON.parse(rawPayload.toString()); }
|
|
443
|
+
catch(e) { node.warn(`${fixtureId} — failed to parse HA command`); return; }
|
|
444
|
+
|
|
445
|
+
if (payload.effect) {
|
|
446
|
+
runEffect(payload.effect);
|
|
447
|
+
} else if (payload.state === 'ON') {
|
|
448
|
+
handleON(payload);
|
|
449
|
+
} else if (payload.state === 'OFF') {
|
|
450
|
+
handleOFF(payload);
|
|
451
|
+
}
|
|
452
|
+
}, node.id);
|
|
453
|
+
|
|
454
|
+
setStatus('green', 'ring', `${fixtureId} discovery sent`);
|
|
455
|
+
node.log(`${fixtureId} device added`);
|
|
456
|
+
|
|
457
|
+
// Recovery state
|
|
458
|
+
setTimeout(function () {
|
|
459
|
+
const state = recall('state', 'state_disk', S.defaultState);
|
|
460
|
+
const brightness = recall('brightness', 'brightness_disk', 255);
|
|
461
|
+
const r = recall('red', 'red_disk', 255);
|
|
462
|
+
const g = recall('green', 'green_disk', 255);
|
|
463
|
+
const b = recall('blue', 'blue_disk', 255);
|
|
464
|
+
const w = recall('white', 'white_disk', 255);
|
|
465
|
+
const ww = recall('warmWhite', 'warmWhite_disk', 0);
|
|
466
|
+
|
|
467
|
+
if (state === 'ON') {
|
|
468
|
+
sendDmxChannels(buildColorChannels(brightness, r, g, b, w, ww));
|
|
469
|
+
} else {
|
|
470
|
+
sendDmxChannels(buildColorChannels(0, r, g, b, w, ww));
|
|
471
|
+
}
|
|
472
|
+
pubState({ state, color_mode: S.colorMode, brightness, color: { r, g, b, w, ww } });
|
|
473
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting HA`);
|
|
474
|
+
node.log(`${fixtureId} recovery — state:${state}`);
|
|
475
|
+
}, 2000);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Device remove ─────────────────────────────────────────
|
|
479
|
+
function handleDeviceRemove() {
|
|
480
|
+
stopEffect();
|
|
481
|
+
if (diskTimer) { clearTimeout(diskTimer); diskTimer = null; }
|
|
482
|
+
['state','brightness','red','green','blue','white','warmWhite'].forEach(function (k) {
|
|
483
|
+
ctxSet(k, null); ctxSet(k, null, 'disk');
|
|
484
|
+
});
|
|
485
|
+
pub(cfgTopic, '', true);
|
|
486
|
+
broker.unsubscribe(cmdTopic, node.id);
|
|
487
|
+
setStatus('red', 'ring', `${fixtureId} removed`);
|
|
488
|
+
node.log(`${fixtureId} device removed`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── AUX from Group Node ───────────────────────────────────
|
|
492
|
+
function handleAux(msg) {
|
|
493
|
+
if (!msg.payload) return;
|
|
494
|
+
// groupSync=false only blocks effect sync — state commands always pass through
|
|
495
|
+
if (msg.payload.effect) {
|
|
496
|
+
if (S.groupSync) runEffect(msg.payload.effect);
|
|
497
|
+
// else: fixture ignores group effects and keeps its own
|
|
498
|
+
} else if (msg.payload.state === 'ON') {
|
|
499
|
+
handleON(msg.payload);
|
|
500
|
+
} else if (msg.payload.state === 'OFF') {
|
|
501
|
+
handleOFF(msg.payload);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ── NR input entry point ──────────────────────────────────
|
|
506
|
+
node.on('input', function (msg, send, done) {
|
|
507
|
+
if (msg.dmx_trace != null) {
|
|
508
|
+
handleAux(msg);
|
|
509
|
+
} else {
|
|
510
|
+
const devReq = typeof msg.device === 'string'
|
|
511
|
+
? msg.device
|
|
512
|
+
: (msg.device && msg.device.request);
|
|
513
|
+
|
|
514
|
+
if (devReq) {
|
|
515
|
+
switch (devReq) {
|
|
516
|
+
case 'add': handleDeviceAdd(); break;
|
|
517
|
+
case 'remove': handleDeviceRemove(); break;
|
|
518
|
+
default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
node.warn(`${fixtureId} — unrecognised message received and dropped. See node documentation.`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
done();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ── Cleanup ───────────────────────────────────────────────
|
|
528
|
+
node.on('close', function (done) {
|
|
529
|
+
stopEffect();
|
|
530
|
+
if (diskTimer) clearTimeout(diskTimer);
|
|
531
|
+
broker.unsubscribe(cmdTopic, node.id);
|
|
532
|
+
broker.deregister(node, done);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
RED.nodes.registerType('ha-mqtt-dmx', HaMqttDmxNode);
|
|
537
|
+
};
|