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.
@@ -0,0 +1,553 @@
1
+ // 230V RELAY NODE — HOME ASSISTANT in NODE-RED
2
+ // Discord: @deswaggy | Version: 4.0.2
3
+ // Extensions: "BETTER COMMENTS", "TODO TREE"
4
+
5
+ //! BIG & BRIGHT
6
+ //@ Topic Headers
7
+ //# Description / Explanations
8
+ //$ Logic
9
+ //^ Debug Comments
10
+ //? Breaks / Headers
11
+
12
+ // Naming conventions:
13
+ // <env_var> = Sub-flow environmental (Edit subflow template)
14
+ // <<flow_var>> = RAM flow memory (flow.set / flow.get, "memory")
15
+ // <<D_flow_var>> = Disk flow memory (flow.set / flow.get, "disk_values")
16
+ // <<<flow_env>>> = Flow-level environmental (Tab → gear → flow envs)
17
+
18
+ // Context stores:
19
+ // memory = fast, lost on reboot → used during live operation
20
+ // disk_values = persistent → written after a debounce delay
21
+
22
+ // Role of this node:
23
+ // Receives ON/OFF commands from HA via MQTT, translates them to integer
24
+ // payloads (1/0) and publishes to the physical relay controller's MQTT topic.
25
+ // Reports state back to HA. Handles HA MQTT discovery (add/remove).
26
+ //
27
+ // This node has NO relationship to DMX. It is a completely separate control
28
+ // path for 230V relay switching hardware (relay controller boards, SSRs etc.).
29
+ // Do not reference DMX in this node's documentation or support material.
30
+ //
31
+ // Relay MQTT topic format: {siteId}/{zone}/{controllerNum}/{mqttTopic}/{relayNum}
32
+ // Example: MW3D/Master/1/relay/23
33
+ // Relay payload: 1 (ON) or 0 (OFF) — integer, not string, not JSON
34
+
35
+ //! IMPORTANT — NODE-RED FUNCTION NODE SETUP:
36
+ // This node requires EXACTLY 5 outputs configured in the NR function node settings.
37
+ // Output 1: HA MQTT (state topic + discovery config) → wire to MQTT Out node
38
+ // Output 2: MQTT subscribe / unsubscribe actions → wire to MQTT In node
39
+ // Output 3: Node status + debug → wire to Status / Debug node
40
+ // Output 5: Relay controller MQTT command (integer 1 or 0) → wire to MQTT Out node
41
+
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+ //!@ CONFIG — all env reads in one place
44
+ // ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ const CFG = {
47
+ //@ HA identity
48
+ discoveryPrefix: env.get("<HA_discovery_prefix>"),
49
+ siteId: env.get("<HA_site_unique_id>"),
50
+ component: env.get("<HA_component>"),
51
+ uidPrefix: env.get("<HA_unique_id_prefix>"),
52
+ uid: env.get("<HA_unique_id>"),
53
+ uidPostfix: env.get("<HA_unique_id_postfix>"),
54
+
55
+ //@ MQTT — HA side
56
+ retain: env.get("<HA_MQTT_retain_flag>"),
57
+ qos: env.get("<HA_MQTT_QOS>"),
58
+ configTopic: env.get("<HA_config_topic>"),
59
+ commandTopic: env.get("<HA_command_topic>"),
60
+ stateTopic: env.get("<HA_state_topic>"),
61
+
62
+ //@ HA features
63
+ schema: env.get("<HA_schema>"),
64
+ optimistic: env.get("<HA_optimistic>"),
65
+ enabledByDefault: env.get("<HA_enabled_by_default>"), //^ no trailing space — was a bug in v3.x
66
+ icon: env.get("<HA_icon>"),
67
+ //^ supported_color_modes always ["onoff"] — relay is binary, hardcoded not env-driven
68
+ //^ brightness always false — binary device
69
+
70
+ //@ Effects — relay-appropriate only
71
+ //^ flash_short: single ON pulse for flashShort seconds then OFF
72
+ //^ flash_long: single ON pulse for flashLong seconds then OFF
73
+ //^ strobe: rapid toggling — 500ms minimum interval enforced for mechanical relay safety
74
+ //^ SSRs (solid state relays) do not have this mechanical wear limitation
75
+ effects: env.get("<HA_effects>"),
76
+ flashShort: env.get("<HA_effects_flash_time_Short_(seconds)>"),
77
+ flashLong: env.get("<HA_effects_flash_time_Long_(seconds)>"),
78
+
79
+ //@ Relay controller — hardware side
80
+ //^ Intentionally separate MQTT settings from the HA side —
81
+ //^ relay controllers may require different QOS/retain to the HA broker.
82
+ relayZone: env.get("<CONTROLLER_zone>"),
83
+ relayControllerNumber: env.get("<CONTROLLER_number>"),
84
+ relayNumber: env.get("<CONTROLLER_relay_number>"),
85
+ relayMqttTopic: env.get("<CONTROLLER_mqtt_topic>") || "relay",
86
+ relayRetain: env.get("<RELAY_MQTT_retain_flag>"),
87
+ relayQos: env.get("<RELAY_MQTT_QOS>"),
88
+ controllerFw: env.get("<CONTROLLER_fw_v>"),
89
+
90
+ //@ Device metadata
91
+ device: {
92
+ type: env.get("<DEVICE_Type>"),
93
+ situation: env.get("<DEVICE_situation>"),
94
+ area: env.get("<DEVICE_area>"),
95
+ subLocation: env.get("<DEVICE_Sub_Location>"),
96
+ },
97
+
98
+ //@ Node metadata
99
+ nodeCode: env.get("<NODE_code>") || "RELAY NODE",
100
+ nodeSw: env.get("<NODE_sw_v>"),
101
+
102
+ //@ Persistence — same pattern as DMX Node
103
+ diskDelay: env.get("<CONTEXT_STORE_values_to_disk_delay_in_seconds>"),
104
+ defaultState: env.get("<FIXTURE_Default_State>") || "OFF",
105
+ };
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────
108
+ //@ TOPIC BUILDERS
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ /** HA MQTT base topic for this relay entity. */
112
+ const fixtureTopic = `${CFG.discoveryPrefix}/${CFG.component}/${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
113
+
114
+ /** Physical relay controller MQTT command topic.
115
+ * Format: {siteId}/{zone}/{controllerNumber}/{mqttTopic}/{relayNumber}
116
+ * Example: MW3D/Master/1/relay/23 */
117
+ const relayTopic = `${CFG.siteId}/${CFG.relayZone}/${CFG.relayControllerNumber}/${CFG.relayMqttTopic}/${CFG.relayNumber}`;
118
+
119
+ /** Short fixture ID for NR node status messages — quick visual feedback on canvas. */
120
+ const fixtureId = `${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
121
+
122
+ // ─────────────────────────────────────────────────────────────────────────────
123
+ //@ MEMORY HELPERS
124
+ //# SHARED PATTERN WITH DMX NODE — extract to shared utility on node package build
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ function recall(ramKey, diskKey, fallback) {
128
+ return flow.get(ramKey, "memory") || flow.get(diskKey, "disk_values") || fallback;
129
+ }
130
+
131
+ function rememberRam(values) {
132
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "memory"));
133
+ }
134
+
135
+ function rememberDisk(values) {
136
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "disk_values"));
137
+ }
138
+
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+ //@ MQTT SEND HELPERS
141
+ // ─────────────────────────────────────────────────────────────────────────────
142
+
143
+ /** Report relay state back to HA on output 1. */
144
+ function sendState(payloadObj) {
145
+ node.send([{
146
+ retain: CFG.retain,
147
+ qos: CFG.qos,
148
+ topic: `${fixtureTopic}/${CFG.stateTopic}`,
149
+ payload: payloadObj,
150
+ }, null, null, null, null]);
151
+ }
152
+
153
+ /** Send a Node-RED status + debug message on output 3. */
154
+ function sendStatus(fill, shape, text) {
155
+ node.send([null, null, {
156
+ debug: { node: fixtureId, message: text },
157
+ status: { fill, shape, text },
158
+ }, null, null]);
159
+ }
160
+
161
+ /**
162
+ * Send integer 1 or 0 to the physical relay controller on output 5.
163
+ * @param {0|1} value - 1 = ON, 0 = OFF
164
+ */
165
+ function sendRelayCommand(value) {
166
+ node.send([null, null, null, null, {
167
+ retain: CFG.relayRetain,
168
+ qos: CFG.relayQos,
169
+ topic: relayTopic,
170
+ payload: value, //# integer — relay controllers expect numeric not string/JSON
171
+ }]);
172
+ console.log(`${fixtureId} relay command — topic:${relayTopic} payload:${value}`);
173
+ }
174
+
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+ //@ DISK-SAVE TIMER
177
+ //# SHARED PATTERN WITH DMX NODE — extract to shared utility on node package build
178
+ // ─────────────────────────────────────────────────────────────────────────────
179
+
180
+ function startDiskSaveTimer(onComplete) {
181
+ const existing = context.get('timer_diskSave');
182
+ if (existing) {
183
+ clearInterval(existing);
184
+ context.set('timer_diskSave', null);
185
+ context.set('timer_diskSave_count', 0);
186
+ console.log(`${fixtureId} disk-save timer reset by new input.`);
187
+ }
188
+
189
+ const maxLoops = CFG.diskDelay;
190
+ let count = 0;
191
+ sendStatus("green", "ring", `${fixtureId} disk-save timer running`);
192
+
193
+ const timer = setInterval(() => {
194
+ count++;
195
+ if (count >= maxLoops) {
196
+ clearInterval(timer);
197
+ context.set('timer_diskSave', null);
198
+ context.set('timer_diskSave_count', 0);
199
+ onComplete();
200
+ sendStatus("yellow", "ring", `${fixtureId} ready — awaiting HA commands`);
201
+ } else {
202
+ context.set('timer_diskSave_count', count);
203
+ }
204
+ }, 1000);
205
+
206
+ context.set('timer_diskSave', timer);
207
+ }
208
+
209
+ // ─────────────────────────────────────────────────────────────────────────────
210
+ //@ SERIAL NUMBER BUILDER
211
+ //# Carries forward the uuid compile + clean logic from the original v3.x node.
212
+ //# Generates a sanitised unique identifier for the HA device serial_number field.
213
+ // ─────────────────────────────────────────────────────────────────────────────
214
+
215
+ /**
216
+ * Build a sanitised serial number from device attributes.
217
+ * Lowercase, underscores for spaces, no special characters.
218
+ * Example: "p_23_master_bedroom_1_ensuite_exhaust_fan"
219
+ */
220
+ function buildSerialNumber() {
221
+ const raw = [
222
+ CFG.uidPrefix,
223
+ String(CFG.uid) + String(CFG.uidPostfix),
224
+ CFG.relayZone,
225
+ CFG.device.area,
226
+ CFG.device.subLocation,
227
+ CFG.device.type,
228
+ ].join('_');
229
+
230
+ return raw
231
+ .toLowerCase()
232
+ .replace(/\s+/g, '_')
233
+ .replace(/[#()]/g, '')
234
+ .replace(/-/g, '_')
235
+ .replace(/__+/g, '_');
236
+ }
237
+
238
+ // ─────────────────────────────────────────────────────────────────────────────
239
+ //@ EFFECTS — relay-appropriate only
240
+ //# Binary device — effects are simple ON/OFF pulse or toggle patterns.
241
+ //# strobe has a 500ms minimum interval to protect mechanical relay contacts.
242
+ //# SSRs (solid state relays) do not have this mechanical wear limitation.
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ /** Cancel any currently running effect timer. */
246
+ function stopEffect() {
247
+ const t = context.get('timer_effect');
248
+ if (t) {
249
+ clearTimeout(t);
250
+ clearInterval(t);
251
+ context.set('timer_effect', null);
252
+ }
253
+ }
254
+
255
+ /** Restore relay to its last known RAM state after an effect ends. */
256
+ function restoreAfterEffect() {
257
+ const prevState = recall("<<RELAY_State>>", "<<D_RELAY_State>>", CFG.defaultState);
258
+ sendRelayCommand(prevState === "ON" ? 1 : 0);
259
+ sendState({ state: prevState, color_mode: "onoff" });
260
+ sendStatus("yellow", "ring", `${fixtureId} effect complete — awaiting HA commands`);
261
+ console.log(`${fixtureId} restored to state: ${prevState}`);
262
+ }
263
+
264
+ /**
265
+ * Single ON pulse for durationSecs then restores to previous state.
266
+ * Used for flash_short and flash_long.
267
+ */
268
+ function effectPulse(durationSecs, label) {
269
+ stopEffect();
270
+ sendStatus("blue", "dot", `${fixtureId} effect: ${label}`);
271
+ sendRelayCommand(1);
272
+ sendState({ state: "ON", color_mode: "onoff" });
273
+
274
+ const timer = setTimeout(() => {
275
+ context.set('timer_effect', null);
276
+ restoreAfterEffect();
277
+ }, durationSecs * 1000);
278
+
279
+ context.set('timer_effect', timer);
280
+ }
281
+
282
+ /**
283
+ * Rapid ON/OFF toggling.
284
+ * 500ms minimum interval enforced to protect mechanical relay contacts.
285
+ * @param {number} durationSecs - 0 = run indefinitely until next command
286
+ * @param {number} [intervalMs=500]
287
+ */
288
+ function effectStrobe(durationSecs, intervalMs = 500) {
289
+ stopEffect();
290
+ const safeInterval = Math.max(500, intervalMs);
291
+ sendStatus("blue", "dot", `${fixtureId} effect: strobe`);
292
+
293
+ const endTime = durationSecs > 0 ? Date.now() + durationSecs * 1000 : Infinity;
294
+ let isOn = false;
295
+
296
+ const timer = setInterval(() => {
297
+ isOn = !isOn;
298
+ sendRelayCommand(isOn ? 1 : 0);
299
+ if (Date.now() >= endTime) {
300
+ clearInterval(timer);
301
+ context.set('timer_effect', null);
302
+ restoreAfterEffect();
303
+ }
304
+ }, safeInterval);
305
+
306
+ context.set('timer_effect', timer);
307
+ }
308
+
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+ //@ EFFECT DISPATCH
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+
313
+ const EFFECT_MAP = {
314
+ "flash_short": () => effectPulse(CFG.flashShort, "flash short"),
315
+ "flash_long": () => effectPulse(CFG.flashLong, "flash long"),
316
+ "strobe": () => effectStrobe(0),
317
+ };
318
+
319
+ function runEffect(effectName) {
320
+ const fn = EFFECT_MAP[effectName];
321
+ if (fn) {
322
+ console.log(`${fixtureId} running effect: "${effectName}"`);
323
+ fn();
324
+ } else {
325
+ node.warn(`${fixtureId} — unknown effect: "${effectName}"`);
326
+ }
327
+ }
328
+
329
+ // ─────────────────────────────────────────────────────────────────────────────
330
+ //@ STATE HANDLERS
331
+ //# Relay is binary — ON or OFF only.
332
+ //# Each handler:
333
+ //# 1. Sends integer command to physical relay controller (output 5)
334
+ //# 2. Reports state back to HA (output 1)
335
+ //# 3. Saves to RAM, queues disk write
336
+ // ─────────────────────────────────────────────────────────────────────────────
337
+
338
+ const RELAY_HANDLERS = {
339
+
340
+ ON(payload) {
341
+ stopEffect();
342
+ sendRelayCommand(1);
343
+ sendState({ state: "ON", color_mode: "onoff" });
344
+ rememberRam({ "<<RELAY_State>>": "ON" });
345
+ startDiskSaveTimer(() => {
346
+ rememberDisk({ "<<D_RELAY_State>>": "ON" });
347
+ console.log(`${fixtureId} disk saved — state:ON`);
348
+ });
349
+ },
350
+
351
+ OFF(payload) {
352
+ stopEffect();
353
+ sendRelayCommand(0);
354
+ sendState({ state: "OFF", color_mode: "onoff" });
355
+ rememberRam({ "<<RELAY_State>>": "OFF" });
356
+ startDiskSaveTimer(() => {
357
+ rememberDisk({ "<<D_RELAY_State>>": "OFF" });
358
+ console.log(`${fixtureId} disk saved — state:OFF`);
359
+ });
360
+ },
361
+ };
362
+
363
+ function dispatch(state, payload) {
364
+ console.log(`${fixtureId} dispatch — state:"${state}"`);
365
+ const handler = RELAY_HANDLERS[state];
366
+ if (!handler) {
367
+ node.warn(
368
+ `${fixtureId} dispatch error — unknown state "${state}". ` +
369
+ `Must be "ON" or "OFF". Check incoming payload from HA.`
370
+ );
371
+ return;
372
+ }
373
+ handler(payload);
374
+ }
375
+
376
+ // ─────────────────────────────────────────────────────────────────────────────
377
+ //@ DEVICE REQUEST HANDLERS
378
+ // ─────────────────────────────────────────────────────────────────────────────
379
+
380
+ function initDefaultMemory() {
381
+ const existing = flow.get("<<RELAY_State>>", "memory")
382
+ || flow.get("<<D_RELAY_State>>", "disk_values");
383
+ if (existing) {
384
+ console.log(`${fixtureId} initDefaultMemory: existing state found in memory, skipping defaults.`);
385
+ return;
386
+ }
387
+ rememberRam({ "<<RELAY_State>>": CFG.defaultState });
388
+ console.log(`${fixtureId} initDefaultMemory: default state "${CFG.defaultState}" written to RAM.`);
389
+ }
390
+
391
+ function clearAllMemory() {
392
+ flow.set("<<RELAY_State>>", null, "memory");
393
+ flow.set("<<D_RELAY_State>>", null, "disk_values");
394
+ console.log(`${fixtureId} clearAllMemory: all relay memory cleared.`);
395
+ }
396
+
397
+ function buildRecoveryStatePayload() {
398
+ const state = recall("<<RELAY_State>>", "<<D_RELAY_State>>", CFG.defaultState);
399
+ console.log(`${fixtureId} recovery — state:${state}`);
400
+ return { state, color_mode: "onoff" };
401
+ }
402
+
403
+ function handleDeviceAdd() {
404
+ initDefaultMemory();
405
+
406
+ const cmdTopic = `${fixtureTopic}/${CFG.commandTopic}`;
407
+ const cfgTopic = `${fixtureTopic}/${CFG.configTopic}`;
408
+ const { device: dev } = CFG;
409
+
410
+ const discoveryPayload = {
411
+ retain: CFG.retain,
412
+ qos: CFG.qos,
413
+ topic: cfgTopic,
414
+ payload: {
415
+ unique_id: `${dev.type}(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix})`,
416
+ schema: CFG.schema,
417
+ //^ object_id locks the HA entity_id to the plan ID: light.p_23
418
+ object_id: `${CFG.uidPrefix}_${CFG.uid}${CFG.uidPostfix}`.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/__+/g, '_'),
419
+ name: `${dev.type} ${dev.situation} the ${CFG.relayZone} ${dev.area} ${dev.subLocation}`,
420
+ cmd_t: cmdTopic,
421
+ stat_t: `${fixtureTopic}/${CFG.stateTopic}`,
422
+ optimistic: CFG.optimistic,
423
+ enabled_by_default: CFG.enabledByDefault,
424
+ icon: CFG.icon,
425
+ supported_color_modes: ["onoff"],
426
+ brightness: false,
427
+ effect: CFG.effects,
428
+ effect_list: Object.keys(EFFECT_MAP),
429
+ flash_time_short: CFG.flashShort,
430
+ flash_time_long: CFG.flashLong,
431
+ device: {
432
+ identifiers: `${CFG.component}-${CFG.uidPrefix}-${CFG.uid}`,
433
+ name: `(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}) - ${dev.type}/s ${dev.situation} the ${CFG.relayZone} - ${dev.area} - ${dev.subLocation}`,
434
+ model: `${dev.type}/s located ${dev.situation} the ${CFG.relayZone}-${dev.area} - ${dev.subLocation}`,
435
+ model_id: `referenced on plan as: (${CFG.uidPrefix}-${CFG.uid}`,
436
+ suggested_area: `${CFG.relayZone} ${dev.area} ${dev.subLocation}`,
437
+ hw_version: `Relay Controller in ${CFG.relayZone} Server Rack, firmware: ${CFG.controllerFw}. ` +
438
+ `Publishes MQTT integer 1 (ON) or 0 (OFF) on topic: ${relayTopic}`,
439
+ serial_number: buildSerialNumber(),
440
+ sw_version: `${CFG.nodeCode}: ${CFG.nodeSw}`,
441
+ manufacturer: "DeSwaggy — Discord: @deswaggy",
442
+ },
443
+ },
444
+ };
445
+
446
+ node.send([
447
+ discoveryPayload,
448
+ { retain: CFG.retain, qos: CFG.qos, topic: cmdTopic, action: "subscribe" },
449
+ { status: { fill: "green", shape: "ring", text: `${fixtureId} discovery sent — awaiting HA` } },
450
+ null,
451
+ null,
452
+ ]);
453
+ console.log(`${fixtureId} device added.`);
454
+
455
+ setTimeout(() => {
456
+ const recoveryPayload = buildRecoveryStatePayload();
457
+ sendState(recoveryPayload);
458
+ //# Also re-assert the physical relay state so hardware matches HA after reboot
459
+ sendRelayCommand(recoveryPayload.state === "ON" ? 1 : 0);
460
+ sendStatus("yellow", "ring", `${fixtureId} ready — awaiting HA commands`);
461
+ console.log(`${fixtureId} recovery state sent — state:${recoveryPayload.state}`);
462
+ }, 2000);
463
+ }
464
+
465
+ function handleDeviceRemove() {
466
+ stopEffect();
467
+ const existing = context.get('timer_diskSave');
468
+ if (existing) clearInterval(existing);
469
+ context.set('timer_diskSave', null);
470
+
471
+ clearAllMemory();
472
+
473
+ node.send([
474
+ { retain: CFG.retain, qos: CFG.qos, topic: `${fixtureTopic}/${CFG.configTopic}`, payload: "" },
475
+ { retain: CFG.retain, qos: CFG.qos, topic: `${fixtureTopic}/${CFG.commandTopic}`, action: "unsubscribe" },
476
+ { status: { fill: "red", shape: "ring", text: `${fixtureId} discovery removed — memory cleared` } },
477
+ null,
478
+ null,
479
+ ]);
480
+ console.log(`${fixtureId} device removed.`);
481
+ }
482
+
483
+ // ─────────────────────────────────────────────────────────────────────────────
484
+ //!? ENTRY POINT
485
+ // ─────────────────────────────────────────────────────────────────────────────
486
+
487
+ //# Suppress passthrough for AUX messages — Group Node already passed it through
488
+ const isAuxMessage = msg.dmx_trace != null;
489
+ if (!isAuxMessage) {
490
+ node.send([null, null, null, msg, null]);
491
+ }
492
+
493
+ const prevState = recall("<<RELAY_State>>", "<<D_RELAY_State>>", CFG.defaultState);
494
+ console.log(`${fixtureId} prev state:${prevState} isAux:${isAuxMessage}`);
495
+
496
+ //? ── AUX — payload received from a parent Group Node ─────────────────────────
497
+ if (isAuxMessage) {
498
+ console.log(
499
+ `${fixtureId} AUX — from:"${msg.dmx_trace.source}" ` +
500
+ `path:[${msg.dmx_trace.path.join(' → ')}] depth:${msg.dmx_trace.depth}`
501
+ );
502
+ if (msg.payload?.effect && CFG.effects) {
503
+ runEffect(msg.payload.effect);
504
+ } else if (msg.payload?.state != null) {
505
+ if (typeof msg.payload === 'string') {
506
+ node.warn(`${fixtureId} AUX — payload is a string not parsed JSON. Check MQTT In node output setting.`);
507
+ node.done(); return;
508
+ }
509
+ dispatch(msg.payload.state, msg.payload);
510
+ } else {
511
+ node.warn(`${fixtureId} AUX from "${msg.dmx_trace.source}" — no valid state or effect.`);
512
+ }
513
+
514
+ //? ── DIRECT HA COMMAND — payload.state from MQTT In ─────────────────────────
515
+ } else if (msg.payload?.state != null) {
516
+ if (typeof msg.payload === 'string') {
517
+ node.warn(
518
+ `${fixtureId} — msg.payload is a string not a parsed object. ` +
519
+ `Check MQTT In node is configured with Output: "a parsed JSON object".`
520
+ );
521
+ node.done(); return;
522
+ }
523
+ const { state, effect } = msg.payload;
524
+ if (effect && CFG.effects) {
525
+ console.log(`${fixtureId} effect received: "${effect}"`);
526
+ runEffect(effect);
527
+ } else {
528
+ dispatch(state, msg.payload);
529
+ }
530
+
531
+ //? ── DEVICE REQUEST — add / remove / debug ───────────────────────────────────
532
+ } else if (msg.device != null && (typeof msg.device === "string" || msg.device?.request != null)) {
533
+ const _devReq = typeof msg.device === "string" ? msg.device : msg.device.request;
534
+ console.log(`${fixtureId} device.request: ${_devReq}`);
535
+ switch (_devReq) {
536
+ case "add": handleDeviceAdd(); break;
537
+ case "remove": handleDeviceRemove(); break;
538
+ case "debug": console.log(`${fixtureId} DEBUG:`, JSON.stringify(msg)); break;
539
+ default: node.warn(`${fixtureId} — unknown device.request: "${_devReq}"`);
540
+ }
541
+
542
+ //? ── UNKNOWN MESSAGE ─────────────────────────────────────────────────────────
543
+ } else {
544
+ node.warn(
545
+ `${fixtureId} — unrecognised message. ` +
546
+ `Expected: msg.dmx_trace (AUX from Group Node), ` +
547
+ `msg.payload.state (direct HA command), or msg.device.request (add/remove). ` +
548
+ `Message received and dropped — unrecognised format. See node documentation.`
549
+ );
550
+ }
551
+
552
+ node.done();
553
+ return [null, null, null, msg, null];