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,860 @@
|
|
|
1
|
+
// DMX GROUP NODE — HOME ASSISTANT in NODE-RED
|
|
2
|
+
// Discord: @deswaggy | Version: 0.3.8
|
|
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
|
+
// Sits between the MQTT in node and one or more DMX fixture nodes (or child Group Nodes).
|
|
24
|
+
// Appears as a single HA light entity (e.g. "Bedroom 1", "Ceiling").
|
|
25
|
+
// On command: updates own HA state, then fans the payload downstream via output 2 (AUX)
|
|
26
|
+
// to all wired children — each child handles its own DMX/HA state independently.
|
|
27
|
+
// No DMX channels. No gamma correction. No fixture-level concerns.
|
|
28
|
+
//
|
|
29
|
+
// Star topology: MQTT in → Group Node → [DMX Node AUX | Group Node AUX] × N
|
|
30
|
+
// Recursive: Group Node → Group Node → Group Node → DMX Node (any depth)
|
|
31
|
+
//
|
|
32
|
+
//! IMPORTANT — NODE-RED FUNCTION NODE SETUP:
|
|
33
|
+
// This node requires EXACTLY 5 outputs configured in the NR function node settings.
|
|
34
|
+
// Output 1: HA MQTT (state topic + discovery config) → wire to MQTT Out node
|
|
35
|
+
// Output 2: MQTT subscribe / unsubscribe actions → wire to MQTT In node
|
|
36
|
+
// Output 3: Node status + debug → wire to Status / Debug node
|
|
37
|
+
// Output 5: AUX fan-out → all child DMX / Group Nodes → wire to children's input
|
|
38
|
+
// If outputs are misconfigured, state and fan-out messages will be silently dropped.
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
//!@ CONFIG — all env reads in one place
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const CFG = {
|
|
45
|
+
//@ HA identity
|
|
46
|
+
discoveryPrefix: env.get("<HA_discovery_prefix>"),
|
|
47
|
+
siteId: env.get("<HA_site_unique_id>"),
|
|
48
|
+
component: env.get("<HA_component>"),
|
|
49
|
+
uidPrefix: env.get("<HA_unique_id_prefix>"),
|
|
50
|
+
uid: env.get("<HA_unique_id>"),
|
|
51
|
+
uidPostfix: env.get("<HA_unique_id_postfix>"),
|
|
52
|
+
|
|
53
|
+
//@ Group UID prefix — used in MQTT topic and HA entity unique_id
|
|
54
|
+
//^ LG = Light Group (default), PG = Power Group
|
|
55
|
+
//^ This is intentionally separate from the DMX fixture node's <HA_unique_id_prefix>
|
|
56
|
+
//^ so group topics are always correctly prefixed without manual env var changes.
|
|
57
|
+
groupUidPrefix: env.get("<GROUP_uid_prefix>") || "LG",
|
|
58
|
+
|
|
59
|
+
//@ MQTT
|
|
60
|
+
retain: env.get("<HA_MQTT_retain_flag>"),
|
|
61
|
+
qos: env.get("<HA_MQTT_QOS>"),
|
|
62
|
+
configTopic: env.get("<HA_config_topic>"),
|
|
63
|
+
commandTopic: env.get("<HA_command_topic>"),
|
|
64
|
+
stateTopic: env.get("<HA_state_topic>"),
|
|
65
|
+
|
|
66
|
+
//@ HA features
|
|
67
|
+
schema: env.get("<HA_schema>"),
|
|
68
|
+
optimistic: env.get("<HA_optimistic>"),
|
|
69
|
+
enabledByDefault: env.get("<HA_enabled_by_default>"),
|
|
70
|
+
icon: env.get("<HA_icon>"),
|
|
71
|
+
colorModes: env.get("<HA_supported_color_modes>"),
|
|
72
|
+
brightness: env.get("<HA_brightness>"),
|
|
73
|
+
transitionsEnabled: env.get("<HA_Transitions_Enabled>") !== "false",
|
|
74
|
+
|
|
75
|
+
//@ Effects
|
|
76
|
+
//^ Group Node only supports synced group effects — forwarded downstream so all
|
|
77
|
+
//^ children receive the command simultaneously, keeping siblings in sync.
|
|
78
|
+
//^ Individual fixture effects (sleep_transition etc.) are intentionally excluded.
|
|
79
|
+
effects: env.get("<HA_effects>"),
|
|
80
|
+
//^ <HA_group_effects_list> — comma-separated list of synced group effects.
|
|
81
|
+
//^ Set as "str" type in the subflow env menu (comma-separated, no spaces).
|
|
82
|
+
//^ Intentionally excludes sleep_transition and temperature_transition —
|
|
83
|
+
//^ those are intimate per-fixture effects, not group-level concerns.
|
|
84
|
+
//^ Recommended list (same regardless of color mode — group syncs all children):
|
|
85
|
+
//# flash_short,flash_long,strobe,rainbow,rainbow_rgbw,fire,flicker,twinkle,
|
|
86
|
+
//# color_chase,scan,random,police,christmas,halloween,calaveras,party,fireworks
|
|
87
|
+
groupEffectsList: (env.get("<HA_group_effects_list>") ||
|
|
88
|
+
"flash_short,flash_long,strobe,rainbow,rainbow_rgbw,fire,flicker,twinkle," +
|
|
89
|
+
"color_chase,scan,random,police,christmas,halloween,calaveras," +
|
|
90
|
+
"party,fireworks").split(','),
|
|
91
|
+
flashShort: env.get("<HA_effects_flash_time_Short_(seconds)>"),
|
|
92
|
+
flashLong: env.get("<HA_effects_flash_time_Long_(seconds)>"),
|
|
93
|
+
|
|
94
|
+
//@ Color temperature range — advertised to HA in discovery payload
|
|
95
|
+
minMireds: env.get("<HA_ct_min_mireds>") || 153,
|
|
96
|
+
maxMireds: env.get("<HA_ct_max_mireds>") || 500,
|
|
97
|
+
|
|
98
|
+
//@ Transition — flow-level globals (<<<...>>>)
|
|
99
|
+
transitionRateLimit: env.get("<<<DMX_NODE_FLOW_Transition_Rate_Limit>>>") || 1,
|
|
100
|
+
transitionDefaultSecs: env.get("<<<DMX_NODE_FLOW_Transition_HA_UI_Time_(seconds)>>>") || 1,
|
|
101
|
+
|
|
102
|
+
//@ Group Node — identity
|
|
103
|
+
//^ Human-readable name used in dmx_trace for fault-finding and in HA device info.
|
|
104
|
+
//^ Always cast to string — <HA_unique_id> may be an integer (e.g. 992)
|
|
105
|
+
groupName: String(env.get("<GROUP_name>") || env.get("<HA_unique_id>") || ""),
|
|
106
|
+
|
|
107
|
+
//@ Group Node — loop detection
|
|
108
|
+
//^ Max AUX hop depth before dropping the message and warning.
|
|
109
|
+
//^ Protects against accidental circular wiring in the NR flow.
|
|
110
|
+
//^ Set to 0 to disable (not recommended in production).
|
|
111
|
+
//^ Default: 10
|
|
112
|
+
groupMaxDepth: (() => {
|
|
113
|
+
const raw = parseInt(env.get("<DMX_Group_Max_Depth>"));
|
|
114
|
+
return isNaN(raw) ? 10 : raw;
|
|
115
|
+
})(),
|
|
116
|
+
|
|
117
|
+
//@ DMX zone — used in discovery payload descriptions (matches DMX Node convention)
|
|
118
|
+
dmxZone: env.get("<CONTROLLER_zone>"),
|
|
119
|
+
|
|
120
|
+
//@ Device metadata
|
|
121
|
+
device: {
|
|
122
|
+
type: env.get("<DEVICE_Type>"),
|
|
123
|
+
situation: env.get("<DEVICE_situation>"),
|
|
124
|
+
area: env.get("<DEVICE_area>"),
|
|
125
|
+
subLocation: env.get("<DEVICE_Sub_Location>"),
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
//@ Node metadata
|
|
129
|
+
nodeCode: env.get("<NODE_code>") || "DMX GROUP NODE",
|
|
130
|
+
nodeSw: env.get("<NODE_sw_v>"),
|
|
131
|
+
|
|
132
|
+
//@ Persistence
|
|
133
|
+
diskDelay: env.get("<CONTEXT_STORE_values_to_disk_delay_in_seconds>"),
|
|
134
|
+
defaultState: env.get("<GROUP_default_state>") || "OFF",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
//@ TOPIC BUILDER
|
|
139
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** Base MQTT topic path for this group entity.
|
|
142
|
+
* Uses CFG.groupUidPrefix (default "LG") — separate from fixture <HA_unique_id_prefix>
|
|
143
|
+
* so a Group Node always produces e.g. "homeassistant/light/LG-992/cmd"
|
|
144
|
+
* without requiring the user to manually set the prefix per node. */
|
|
145
|
+
const groupTopic = `${CFG.discoveryPrefix}/${CFG.component}/${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
//@ MEMORY HELPERS
|
|
149
|
+
//# SHARED WITH DMX NODE — extract to shared utility module on node package build
|
|
150
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read a value from RAM → disk → fallback, in that priority order.
|
|
154
|
+
* @param {string} ramKey
|
|
155
|
+
* @param {string} diskKey
|
|
156
|
+
* @param {*} fallback
|
|
157
|
+
*/
|
|
158
|
+
function recall(ramKey, diskKey, fallback) {
|
|
159
|
+
return flow.get(ramKey, "memory") || flow.get(diskKey, "disk_values") || fallback;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Write a key/value map to RAM memory in one call. */
|
|
163
|
+
function rememberRam(values) {
|
|
164
|
+
Object.entries(values).forEach(([key, val]) => flow.set(key, val, "memory"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Write a key/value map to disk memory in one call. */
|
|
168
|
+
function rememberDisk(values) {
|
|
169
|
+
Object.entries(values).forEach(([key, val]) => flow.set(key, val, "disk_values"));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
//@ MQTT SEND HELPERS
|
|
174
|
+
//# SHARED WITH DMX NODE — extract to shared utility module on node package build
|
|
175
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/** Report group state back to HA on output 1. */
|
|
178
|
+
function sendState(payloadObj) {
|
|
179
|
+
node.send([{
|
|
180
|
+
retain: CFG.retain,
|
|
181
|
+
qos: CFG.qos,
|
|
182
|
+
topic: `${groupTopic}/${CFG.stateTopic}`,
|
|
183
|
+
payload: payloadObj,
|
|
184
|
+
}, null, null, null, null]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Send a Node-RED status + debug message on output 3. */
|
|
188
|
+
function sendStatus(fill, shape, text) {
|
|
189
|
+
node.send([null, null, {
|
|
190
|
+
debug: { node: `Group: ${CFG.groupName}`, message: text },
|
|
191
|
+
status: { fill, shape, text },
|
|
192
|
+
}, null, null]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
196
|
+
//@ DISK-SAVE TIMER
|
|
197
|
+
//# SHARED WITH DMX NODE — extract to shared utility module on node package build
|
|
198
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Debounced disk-write timer — resets on every new command.
|
|
202
|
+
* @param {Function} onComplete - called when the delay has elapsed
|
|
203
|
+
*/
|
|
204
|
+
function startDiskSaveTimer(onComplete) {
|
|
205
|
+
const existing = context.get('timer_diskSave');
|
|
206
|
+
if (existing) {
|
|
207
|
+
clearInterval(existing);
|
|
208
|
+
context.set('timer_diskSave', null);
|
|
209
|
+
context.set('timer_diskSave_count', 0);
|
|
210
|
+
console.log("Group disk-save timer reset by new input.");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const maxLoops = CFG.diskDelay;
|
|
214
|
+
let count = 0;
|
|
215
|
+
sendStatus("green", "ring", "Disk-save timer running");
|
|
216
|
+
|
|
217
|
+
const timer = setInterval(() => {
|
|
218
|
+
count++;
|
|
219
|
+
if (count >= maxLoops) {
|
|
220
|
+
clearInterval(timer);
|
|
221
|
+
context.set('timer_diskSave', null);
|
|
222
|
+
context.set('timer_diskSave_count', 0);
|
|
223
|
+
onComplete();
|
|
224
|
+
sendStatus("yellow", "ring", "Disk-save complete — awaiting next HA command");
|
|
225
|
+
} else {
|
|
226
|
+
context.set('timer_diskSave_count', count);
|
|
227
|
+
}
|
|
228
|
+
}, 1000);
|
|
229
|
+
|
|
230
|
+
context.set('timer_diskSave', timer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
//@ DMX TRACE — ancestry tracking and loop detection
|
|
235
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Build a dmx_trace object to attach to every forwarded payload.
|
|
239
|
+
* Appends this Group Node to the ancestry path.
|
|
240
|
+
*
|
|
241
|
+
* @param {Object|null} incomingTrace - dmx_trace from upstream, or null if this is the origin
|
|
242
|
+
* @returns {Object} dmx_trace for the forwarded msg
|
|
243
|
+
*
|
|
244
|
+
* Example output at depth 2:
|
|
245
|
+
* {
|
|
246
|
+
* source: "DMX Group Node: Ceiling",
|
|
247
|
+
* path: ["Bedroom 1"],
|
|
248
|
+
* depth: 2
|
|
249
|
+
* }
|
|
250
|
+
*/
|
|
251
|
+
function buildTrace(incomingTrace) {
|
|
252
|
+
const path = incomingTrace ? [...incomingTrace.path, incomingTrace.source] : [];
|
|
253
|
+
const depth = incomingTrace ? incomingTrace.depth + 1 : 1;
|
|
254
|
+
return {
|
|
255
|
+
source: `DMX Group Node: ${CFG.groupName}`,
|
|
256
|
+
path,
|
|
257
|
+
depth,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if the incoming trace exceeds the configured max depth.
|
|
263
|
+
* Warns, sets node status red, and returns true if the message should be dropped.
|
|
264
|
+
*
|
|
265
|
+
* @param {Object} trace - dmx_trace from incoming msg
|
|
266
|
+
* @returns {boolean} true = drop the message
|
|
267
|
+
*/
|
|
268
|
+
function isLoopDetected(trace) {
|
|
269
|
+
if (CFG.groupMaxDepth === 0) return false; //# 0 = disabled
|
|
270
|
+
if (trace.depth > CFG.groupMaxDepth) {
|
|
271
|
+
node.warn(
|
|
272
|
+
`DMX Group Node "${CFG.groupName}" — loop guard tripped! ` +
|
|
273
|
+
`Depth ${trace.depth} exceeds max ${CFG.groupMaxDepth}. ` +
|
|
274
|
+
`Payload dropped — check for circular wiring in your NR flow. ` +
|
|
275
|
+
`Ancestry path: [${trace.path.join(' → ')}]. ` +
|
|
276
|
+
`Adjust <DMX_Group_Max_Depth> to change the limit (0 = disabled).`
|
|
277
|
+
);
|
|
278
|
+
sendStatus("red", "ring", `Loop guard tripped — depth ${trace.depth} > max ${CFG.groupMaxDepth}`);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
285
|
+
//@ FAN-OUT — forward payload to all wired children via output 2
|
|
286
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Forward a command payload downstream to all wired children.
|
|
290
|
+
* Attaches dmx_trace so children know where the command came from
|
|
291
|
+
* and can detect loops if they are also Group Nodes.
|
|
292
|
+
*
|
|
293
|
+
* @param {Object} payload - HA command payload (state, brightness, color etc.)
|
|
294
|
+
* @param {Object} trace - dmx_trace to attach
|
|
295
|
+
* @param {Object} originalMsg - the original incoming msg (for _msgid passthrough)
|
|
296
|
+
*/
|
|
297
|
+
function fanOut(payload, trace, originalMsg) {
|
|
298
|
+
const forwardMsg = {
|
|
299
|
+
topic: `${groupTopic}/${CFG.commandTopic}`,
|
|
300
|
+
dmx_trace: trace,
|
|
301
|
+
payload,
|
|
302
|
+
qos: CFG.qos,
|
|
303
|
+
retain: CFG.retain,
|
|
304
|
+
_msgid: originalMsg._msgid,
|
|
305
|
+
};
|
|
306
|
+
//# Output 5 is the AUX fan-out output — wire this to children's input
|
|
307
|
+
node.send([null, null, null, null, forwardMsg]);
|
|
308
|
+
console.log(`Fan-out — group:"${CFG.groupName}" depth:${trace.depth} state:${payload.state || payload.effect || "effect"}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
312
|
+
//@ COLOR RESOLVERS
|
|
313
|
+
//# Group Node resolvers use G-prefixed memory keys to avoid collisions
|
|
314
|
+
//# with sibling DMX fixture nodes in the shared flow context store.
|
|
315
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
function resolveGroupOnoff() {
|
|
318
|
+
return { white: recall("<<G_white>>", "<<GD_white>>", 255) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function resolveGroupBrightness(payload) {
|
|
322
|
+
return { brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function resolveGroupRgb(payload) {
|
|
326
|
+
return {
|
|
327
|
+
brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
|
|
328
|
+
red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
|
|
329
|
+
green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
|
|
330
|
+
blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function resolveGroupRgbw(payload) {
|
|
335
|
+
return {
|
|
336
|
+
brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
|
|
337
|
+
red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
|
|
338
|
+
green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
|
|
339
|
+
blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
|
|
340
|
+
white: payload?.color?.w || recall("<<G_white>>", "<<GD_white>>", 255),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function resolveGroupRgbww(payload) {
|
|
345
|
+
return {
|
|
346
|
+
brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
|
|
347
|
+
red: payload?.color?.r || recall("<<G_red>>", "<<GD_red>>", 255),
|
|
348
|
+
green: payload?.color?.g || recall("<<G_green>>", "<<GD_green>>", 255),
|
|
349
|
+
blue: payload?.color?.b || recall("<<G_blue>>", "<<GD_blue>>", 255),
|
|
350
|
+
white: payload?.color?.w || recall("<<G_white>>", "<<GD_white>>", 255),
|
|
351
|
+
warmWhite: payload?.color?.ww || recall("<<G_warmWhite>>", "<<GD_warmWhite>>", 0),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveGroupColorTemp(payload) {
|
|
356
|
+
return {
|
|
357
|
+
brightness: payload?.brightness || recall("<<G_bright>>", "<<GD_bright>>", 255),
|
|
358
|
+
colorTemp: payload?.color_temp || recall("<<G_colorTemp>>", "<<GD_colorTemp>>", 300),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
+
//@ STATE HANDLERS
|
|
364
|
+
//# Each handler:
|
|
365
|
+
//# 1. Resolves current values (payload → RAM → disk → default)
|
|
366
|
+
//# 2. Reports own state to HA
|
|
367
|
+
//# 3. Fans payload out to all wired children via output 2
|
|
368
|
+
//# 4. Saves resolved values to RAM, queues disk write
|
|
369
|
+
//#
|
|
370
|
+
//# No DMX. No gamma. No channel maths. State + fan-out only.
|
|
371
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
const GROUP_HANDLERS = {
|
|
374
|
+
|
|
375
|
+
ON: {
|
|
376
|
+
|
|
377
|
+
onoff(payload, trace, originalMsg) {
|
|
378
|
+
const { white } = resolveGroupOnoff();
|
|
379
|
+
sendState({ state: "ON", color: { w: white }, color_mode: "onoff" });
|
|
380
|
+
fanOut({ state: "ON", color: { w: white }, color_mode: "onoff" }, trace, originalMsg);
|
|
381
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_white>>": white });
|
|
382
|
+
startDiskSaveTimer(() => {
|
|
383
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_white>>": white });
|
|
384
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:ON`);
|
|
385
|
+
});
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
brightness(payload, trace, originalMsg) {
|
|
389
|
+
const { brightness } = resolveGroupBrightness(payload);
|
|
390
|
+
sendState({ state: "ON", brightness, color_mode: "brightness" });
|
|
391
|
+
fanOut({ state: "ON", brightness, color_mode: "brightness", transition: payload.transition }, trace, originalMsg);
|
|
392
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness });
|
|
393
|
+
startDiskSaveTimer(() => {
|
|
394
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness });
|
|
395
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:ON bright:${brightness}`);
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
rgb(payload, trace, originalMsg) {
|
|
400
|
+
const { brightness, red, green, blue } = resolveGroupRgb(payload);
|
|
401
|
+
sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue }, color_mode: "rgb" });
|
|
402
|
+
fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue }, color_mode: "rgb", transition: payload.transition }, trace, originalMsg);
|
|
403
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue });
|
|
404
|
+
startDiskSaveTimer(() => {
|
|
405
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue });
|
|
406
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} bright:${brightness}`);
|
|
407
|
+
});
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
rgbw(payload, trace, originalMsg) {
|
|
411
|
+
const { brightness, red, green, blue, white } = resolveGroupRgbw(payload);
|
|
412
|
+
sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white }, color_mode: "rgbw" });
|
|
413
|
+
fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white }, color_mode: "rgbw", transition: payload.transition }, trace, originalMsg);
|
|
414
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue, "<<G_white>>": white });
|
|
415
|
+
startDiskSaveTimer(() => {
|
|
416
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue, "<<GD_white>>": white });
|
|
417
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} W:${white} bright:${brightness}`);
|
|
418
|
+
});
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
rgbww(payload, trace, originalMsg) {
|
|
422
|
+
const { brightness, red, green, blue, white, warmWhite } = resolveGroupRgbww(payload);
|
|
423
|
+
sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white, ww: warmWhite }, color_mode: "rgbww" });
|
|
424
|
+
fanOut({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white, ww: warmWhite }, color_mode: "rgbww", transition: payload.transition }, trace, originalMsg);
|
|
425
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_red>>": red, "<<G_green>>": green, "<<G_blue>>": blue, "<<G_white>>": white, "<<G_warmWhite>>": warmWhite });
|
|
426
|
+
startDiskSaveTimer(() => {
|
|
427
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_red>>": red, "<<GD_green>>": green, "<<GD_blue>>": blue, "<<GD_white>>": white, "<<GD_warmWhite>>": warmWhite });
|
|
428
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" R:${red} G:${green} B:${blue} W:${white} WW:${warmWhite} bright:${brightness}`);
|
|
429
|
+
});
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
color_temp(payload, trace, originalMsg) {
|
|
433
|
+
const { brightness, colorTemp } = resolveGroupColorTemp(payload);
|
|
434
|
+
sendState({ state: "ON", brightness, color_temp: colorTemp, color_mode: "color_temp" });
|
|
435
|
+
fanOut({ state: "ON", brightness, color_temp: colorTemp, color_mode: "color_temp", transition: payload.transition }, trace, originalMsg);
|
|
436
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness, "<<G_colorTemp>>": colorTemp });
|
|
437
|
+
startDiskSaveTimer(() => {
|
|
438
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness, "<<GD_colorTemp>>": colorTemp });
|
|
439
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" colorTemp:${colorTemp} bright:${brightness}`);
|
|
440
|
+
});
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
//# Combo modes — HA tells us which sub-mode is active via payload.color_mode
|
|
444
|
+
"rgb,color_temp"(payload, trace, originalMsg) {
|
|
445
|
+
payload.color_mode === "color_temp"
|
|
446
|
+
? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
|
|
447
|
+
: GROUP_HANDLERS.ON.rgb(payload, trace, originalMsg);
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
"rgbw,color_temp"(payload, trace, originalMsg) {
|
|
451
|
+
payload.color_mode === "color_temp"
|
|
452
|
+
? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
|
|
453
|
+
: GROUP_HANDLERS.ON.rgbw(payload, trace, originalMsg);
|
|
454
|
+
},
|
|
455
|
+
|
|
456
|
+
"rgbww,color_temp"(payload, trace, originalMsg) {
|
|
457
|
+
payload.color_mode === "color_temp"
|
|
458
|
+
? GROUP_HANDLERS.ON.color_temp(payload, trace, originalMsg)
|
|
459
|
+
: GROUP_HANDLERS.ON.rgbww(payload, trace, originalMsg);
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
white(payload, trace, originalMsg) {
|
|
463
|
+
const { brightness } = resolveGroupBrightness(payload);
|
|
464
|
+
sendState({ state: "ON", brightness, color_mode: "white" });
|
|
465
|
+
fanOut({ state: "ON", brightness, color_mode: "white", transition: payload.transition }, trace, originalMsg);
|
|
466
|
+
rememberRam({ "<<G_State>>": "ON", "<<G_bright>>": brightness });
|
|
467
|
+
startDiskSaveTimer(() => {
|
|
468
|
+
rememberDisk({ "<<GD_State>>": "ON", "<<GD_bright>>": brightness });
|
|
469
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:ON bright:${brightness}`);
|
|
470
|
+
});
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
OFF: {
|
|
475
|
+
|
|
476
|
+
onoff(payload, trace, originalMsg) {
|
|
477
|
+
sendState({ state: "OFF", color_mode: "onoff" });
|
|
478
|
+
fanOut({ state: "OFF", color_mode: "onoff" }, trace, originalMsg);
|
|
479
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
480
|
+
startDiskSaveTimer(() => {
|
|
481
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
482
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
483
|
+
});
|
|
484
|
+
},
|
|
485
|
+
|
|
486
|
+
brightness(payload, trace, originalMsg) {
|
|
487
|
+
sendState({ state: "OFF", color_mode: "brightness" });
|
|
488
|
+
fanOut({ state: "OFF", color_mode: "brightness", transition: payload.transition }, trace, originalMsg);
|
|
489
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
490
|
+
startDiskSaveTimer(() => {
|
|
491
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
492
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
493
|
+
});
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
rgb(payload, trace, originalMsg) {
|
|
497
|
+
sendState({ state: "OFF", color: { r: 0, g: 0, b: 0 }, color_mode: "rgb" });
|
|
498
|
+
fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0 }, color_mode: "rgb", transition: payload.transition }, trace, originalMsg);
|
|
499
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
500
|
+
startDiskSaveTimer(() => {
|
|
501
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
502
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
|
|
506
|
+
rgbw(payload, trace, originalMsg) {
|
|
507
|
+
sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw" });
|
|
508
|
+
fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw", transition: payload.transition }, trace, originalMsg);
|
|
509
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
510
|
+
startDiskSaveTimer(() => {
|
|
511
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
512
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
513
|
+
});
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
rgbww(payload, trace, originalMsg) {
|
|
517
|
+
sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww" });
|
|
518
|
+
fanOut({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww", transition: payload.transition }, trace, originalMsg);
|
|
519
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
520
|
+
startDiskSaveTimer(() => {
|
|
521
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
522
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
523
|
+
});
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
color_temp(payload, trace, originalMsg) {
|
|
527
|
+
sendState({ state: "OFF", color_mode: "color_temp" });
|
|
528
|
+
fanOut({ state: "OFF", color_mode: "color_temp", transition: payload.transition }, trace, originalMsg);
|
|
529
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
530
|
+
startDiskSaveTimer(() => {
|
|
531
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
532
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
533
|
+
});
|
|
534
|
+
},
|
|
535
|
+
|
|
536
|
+
"rgb,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgb(payload, trace, originalMsg); },
|
|
537
|
+
"rgbw,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgbw(payload, trace, originalMsg); },
|
|
538
|
+
"rgbww,color_temp"(payload, trace, originalMsg) { GROUP_HANDLERS.OFF.rgbww(payload, trace, originalMsg); },
|
|
539
|
+
|
|
540
|
+
white(payload, trace, originalMsg) {
|
|
541
|
+
sendState({ state: "OFF", color_mode: "white" });
|
|
542
|
+
fanOut({ state: "OFF", color_mode: "white", transition: payload.transition }, trace, originalMsg);
|
|
543
|
+
rememberRam({ "<<G_State>>": "OFF" });
|
|
544
|
+
startDiskSaveTimer(() => {
|
|
545
|
+
rememberDisk({ "<<GD_State>>": "OFF" });
|
|
546
|
+
console.log(`Disk saved — Group:"${CFG.groupName}" state:OFF`);
|
|
547
|
+
});
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
553
|
+
//@ DISPATCH
|
|
554
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Look up and invoke the correct group handler.
|
|
558
|
+
* @param {string} state - "ON" or "OFF"
|
|
559
|
+
* @param {Object} payload - msg.payload
|
|
560
|
+
* @param {Object} trace - dmx_trace to attach to fan-out
|
|
561
|
+
* @param {Object} originalMsg - original incoming msg
|
|
562
|
+
*/
|
|
563
|
+
function dispatch(state, payload, trace, originalMsg) {
|
|
564
|
+
console.log(`Group dispatch — group:"${CFG.groupName}" state:"${state}" colorMode:"${CFG.colorModes}"`);
|
|
565
|
+
|
|
566
|
+
const stateHandlers = GROUP_HANDLERS[state];
|
|
567
|
+
if (!stateHandlers) {
|
|
568
|
+
node.warn(
|
|
569
|
+
`Group "${CFG.groupName}" dispatch error — unknown state "${state}". ` +
|
|
570
|
+
`Must be "ON" or "OFF". Check incoming payload from HA.`
|
|
571
|
+
);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const handler = stateHandlers[CFG.colorModes];
|
|
575
|
+
if (handler === undefined) {
|
|
576
|
+
node.warn(
|
|
577
|
+
`Group "${CFG.groupName}" dispatch error — unknown color mode "${CFG.colorModes}" ` +
|
|
578
|
+
`for state "${state}". Check <HA_supported_color_modes> env var matches ` +
|
|
579
|
+
`a supported value: onoff, brightness, rgb, rgbw, rgbww, color_temp, white, ` +
|
|
580
|
+
`rgb\,color_temp, rgbw\,color_temp, rgbww\,color_temp`
|
|
581
|
+
);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
handler(payload, trace, originalMsg);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
588
|
+
//@ EFFECTS — synced group effects
|
|
589
|
+
//# The Group Node forwards the effect name downstream so all children receive
|
|
590
|
+
//# the command at the same NR tick, keeping siblings in sync.
|
|
591
|
+
//# Individual fixture effects (sleep_transition, temperature_transition) are
|
|
592
|
+
//# intentionally excluded from the group effects list.
|
|
593
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Validate and forward an effect command to all children.
|
|
597
|
+
* @param {string} effectName
|
|
598
|
+
* @param {Object} payload
|
|
599
|
+
* @param {Object} trace
|
|
600
|
+
* @param {Object} originalMsg
|
|
601
|
+
*/
|
|
602
|
+
function dispatchGroupEffect(effectName, payload, trace, originalMsg) {
|
|
603
|
+
if (!CFG.groupEffectsList.includes(effectName.trim())) {
|
|
604
|
+
node.warn(
|
|
605
|
+
`Group "${CFG.groupName}" — effect "${effectName}" is not in the group effects list. ` +
|
|
606
|
+
`Check <HA_group_effects_list> env var.`
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
console.log(`Group effect forwarding: "${effectName}" group:"${CFG.groupName}"`);
|
|
611
|
+
fanOut({ ...payload, effect: effectName }, trace, originalMsg);
|
|
612
|
+
sendStatus("blue", "dot", `Group effect: ${effectName}`);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
616
|
+
//@ DEVICE REQUEST HANDLERS
|
|
617
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
/** Write default values to RAM on startup if nothing is stored yet. */
|
|
620
|
+
function initDefaultMemory() {
|
|
621
|
+
const existing = flow.get("<<G_State>>", "memory") || flow.get("<<GD_State>>", "disk_values");
|
|
622
|
+
if (existing) {
|
|
623
|
+
console.log(`initDefaultMemory: existing group state found for "${CFG.groupName}", skipping defaults.`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
rememberRam({
|
|
627
|
+
"<<G_State>>": CFG.defaultState,
|
|
628
|
+
"<<G_bright>>": 255,
|
|
629
|
+
"<<G_red>>": 255,
|
|
630
|
+
"<<G_green>>": 255,
|
|
631
|
+
"<<G_blue>>": 255,
|
|
632
|
+
"<<G_white>>": 255,
|
|
633
|
+
"<<G_warmWhite>>": 0,
|
|
634
|
+
"<<G_colorTemp>>": 300,
|
|
635
|
+
});
|
|
636
|
+
console.log(`initDefaultMemory: group defaults written for "${CFG.groupName}".`);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Clear all RAM and disk memory keys for this group on device remove. */
|
|
640
|
+
function clearAllMemory() {
|
|
641
|
+
const ramKeys = ["<<G_State>>", "<<G_bright>>", "<<G_red>>", "<<G_green>>", "<<G_blue>>", "<<G_white>>", "<<G_warmWhite>>", "<<G_colorTemp>>"];
|
|
642
|
+
const diskKeys = ["<<GD_State>>", "<<GD_bright>>", "<<GD_red>>", "<<GD_green>>", "<<GD_blue>>", "<<GD_white>>", "<<GD_warmWhite>>", "<<GD_colorTemp>>"];
|
|
643
|
+
ramKeys.forEach(k => flow.set(k, null, "memory"));
|
|
644
|
+
diskKeys.forEach(k => flow.set(k, null, "disk_values"));
|
|
645
|
+
console.log(`clearAllMemory: group memory cleared for "${CFG.groupName}".`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Build the HA state recovery payload for this group's color mode. */
|
|
649
|
+
function buildRecoveryStatePayload() {
|
|
650
|
+
const state = recall("<<G_State>>", "<<GD_State>>", CFG.defaultState);
|
|
651
|
+
const brightness = recall("<<G_bright>>", "<<GD_bright>>", 255);
|
|
652
|
+
const red = recall("<<G_red>>", "<<GD_red>>", 255);
|
|
653
|
+
const green = recall("<<G_green>>", "<<GD_green>>", 255);
|
|
654
|
+
const blue = recall("<<G_blue>>", "<<GD_blue>>", 255);
|
|
655
|
+
const white = recall("<<G_white>>", "<<GD_white>>", 255);
|
|
656
|
+
const warmWhite = recall("<<G_warmWhite>>", "<<GD_warmWhite>>", 0);
|
|
657
|
+
const colorTemp = recall("<<G_colorTemp>>", "<<GD_colorTemp>>", 300);
|
|
658
|
+
|
|
659
|
+
console.log(`Group recovery — "${CFG.groupName}" state:${state} bright:${brightness}`);
|
|
660
|
+
|
|
661
|
+
const rgb = { r: red, g: green, b: blue };
|
|
662
|
+
const rgbw = { ...rgb, w: white };
|
|
663
|
+
const rgbww = { ...rgbw, ww: warmWhite };
|
|
664
|
+
|
|
665
|
+
const map = {
|
|
666
|
+
"onoff": { state, color_mode: "onoff" },
|
|
667
|
+
"brightness": { state, brightness, color_mode: "brightness" },
|
|
668
|
+
"rgb": { state, brightness, color: rgb, color_mode: "rgb" },
|
|
669
|
+
"rgbw": { state, brightness, color: rgbw, color_mode: "rgbw" },
|
|
670
|
+
"rgbww": { state, brightness, color: rgbww, color_mode: "rgbww" },
|
|
671
|
+
"color_temp": { state, brightness, color_temp: colorTemp, color_mode: "color_temp" },
|
|
672
|
+
"white": { state, brightness, color_mode: "white" },
|
|
673
|
+
"rgb,color_temp": { state, brightness, color: rgb, color_mode: "rgb" },
|
|
674
|
+
"rgbw,color_temp": { state, brightness, color: rgbw, color_mode: "rgbw" },
|
|
675
|
+
"rgbww,color_temp": { state, brightness, color: rgbww, color_mode: "rgbww" },
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
return map[CFG.colorModes] ?? null;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function handleDeviceAdd() {
|
|
682
|
+
initDefaultMemory();
|
|
683
|
+
|
|
684
|
+
const colorModesArray = CFG.colorModes.split(',');
|
|
685
|
+
const cmdTopic = `${groupTopic}/${CFG.commandTopic}`;
|
|
686
|
+
const cfgTopic = `${groupTopic}/${CFG.configTopic}`;
|
|
687
|
+
const { device: dev } = CFG;
|
|
688
|
+
|
|
689
|
+
const discoveryPayload = {
|
|
690
|
+
retain: CFG.retain,
|
|
691
|
+
qos: CFG.qos,
|
|
692
|
+
topic: cfgTopic,
|
|
693
|
+
payload: {
|
|
694
|
+
unique_id: `group(${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix})`,
|
|
695
|
+
schema: CFG.schema,
|
|
696
|
+
//^ object_id locks the HA entity_id to the plan ID: light.lg_992
|
|
697
|
+
object_id: `${CFG.groupUidPrefix}_${CFG.uid}${CFG.uidPostfix}`.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/__+/g, '_'),
|
|
698
|
+
name: CFG.groupName || `${dev.type} Group ${dev.situation} the ${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
|
|
699
|
+
cmd_t: cmdTopic,
|
|
700
|
+
stat_t: `${groupTopic}/${CFG.stateTopic}`,
|
|
701
|
+
optimistic: CFG.optimistic,
|
|
702
|
+
enabled_by_default: CFG.enabledByDefault,
|
|
703
|
+
icon: CFG.icon,
|
|
704
|
+
supported_color_modes: colorModesArray,
|
|
705
|
+
brightness: CFG.brightness,
|
|
706
|
+
transition: CFG.transitionsEnabled,
|
|
707
|
+
effect: CFG.effects,
|
|
708
|
+
effect_list: CFG.groupEffectsList,
|
|
709
|
+
flash_time_short: CFG.flashShort,
|
|
710
|
+
flash_time_long: CFG.flashLong,
|
|
711
|
+
min_mireds: CFG.minMireds,
|
|
712
|
+
max_mireds: CFG.maxMireds,
|
|
713
|
+
device: {
|
|
714
|
+
identifiers: `${CFG.component}-${CFG.groupUidPrefix}-${CFG.uid}`,
|
|
715
|
+
name: `(${CFG.groupUidPrefix}-${CFG.uid}${CFG.uidPostfix}) - ${dev.type}/s Group ${dev.situation} the ${CFG.dmxZone} - ${dev.area} - ${dev.subLocation}`,
|
|
716
|
+
model: `${colorModesArray} ${dev.type}/s Group located ${dev.situation} the ${CFG.dmxZone}-${dev.area} - ${dev.subLocation}`,
|
|
717
|
+
model_id: `referenced on plan as: (${CFG.groupUidPrefix}-${CFG.uid}`,
|
|
718
|
+
suggested_area: `${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
|
|
719
|
+
hw_version: `This is a virtual fixture — no physical hardware. It exists as a DMX Group Node in a Node-RED flow, grouping multiple DMX fixture nodes under a single Home Assistant entity. Changes made here are distributed to all child fixture nodes in the group.`,
|
|
720
|
+
sw_version: `${CFG.nodeCode}: ${CFG.nodeSw}`,
|
|
721
|
+
manufacturer: "DeSwaggy — Discord: @deswaggy",
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
//# Output 1: HA MQTT discovery payload
|
|
727
|
+
//# Output 2: MQTT subscribe action → MQTT In node
|
|
728
|
+
//# Output 3: Node status
|
|
729
|
+
//# Output 5: AUX — forward device:add to child DMX/Group Nodes so they self-discover
|
|
730
|
+
node.send([
|
|
731
|
+
discoveryPayload,
|
|
732
|
+
{ retain: CFG.retain, qos: CFG.qos, topic: cmdTopic, action: "subscribe" },
|
|
733
|
+
{ status: { fill: "green", shape: "ring", text: `Group "${CFG.groupName}" discovery sent — awaiting HA` } },
|
|
734
|
+
null,
|
|
735
|
+
{ device: "add" },
|
|
736
|
+
]);
|
|
737
|
+
console.log(`Group device added: "${CFG.groupName}"`);
|
|
738
|
+
|
|
739
|
+
//# Wait briefly before pushing recovery state — gives HA time to register
|
|
740
|
+
//# the discovery before we report the last-known state.
|
|
741
|
+
//# Simple setTimeout is correct here — we are not writing to disk, just
|
|
742
|
+
//# reporting state to HA. The disk save timer is reserved for command handling.
|
|
743
|
+
setTimeout(() => {
|
|
744
|
+
const recoveryPayload = buildRecoveryStatePayload();
|
|
745
|
+
if (recoveryPayload) {
|
|
746
|
+
sendState(recoveryPayload);
|
|
747
|
+
sendStatus("yellow", "ring", `Group "${CFG.groupName}" ready — awaiting HA commands`);
|
|
748
|
+
console.log(`Group recovery state sent — "${CFG.groupName}" mode:${CFG.colorModes}`);
|
|
749
|
+
} else {
|
|
750
|
+
sendStatus("yellow", "ring", `Group "${CFG.groupName}" ready — awaiting HA commands`);
|
|
751
|
+
}
|
|
752
|
+
}, 2000);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function handleDeviceRemove() {
|
|
756
|
+
const existing = context.get('timer_diskSave');
|
|
757
|
+
if (existing) clearInterval(existing);
|
|
758
|
+
context.set('timer_diskSave', null);
|
|
759
|
+
|
|
760
|
+
clearAllMemory();
|
|
761
|
+
|
|
762
|
+
node.send([
|
|
763
|
+
{ retain: CFG.retain, qos: CFG.qos, topic: `${groupTopic}/${CFG.configTopic}`, payload: "" },
|
|
764
|
+
{ retain: CFG.retain, qos: CFG.qos, topic: `${groupTopic}/${CFG.commandTopic}`, action: "unsubscribe" },
|
|
765
|
+
{ status: { fill: "red", shape: "ring", text: `Group "${CFG.groupName}" removed — memory cleared` } },
|
|
766
|
+
null,
|
|
767
|
+
{ device: "remove" },
|
|
768
|
+
]);
|
|
769
|
+
console.log(`Group device removed: "${CFG.groupName}"`);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
773
|
+
//!? ENTRY POINT
|
|
774
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
775
|
+
|
|
776
|
+
//# Pass original message through on output 4 untouched
|
|
777
|
+
node.send([null, null, null, msg, null]);
|
|
778
|
+
|
|
779
|
+
const prevGroupState = recall("<<G_State>>", "<<GD_State>>", CFG.defaultState);
|
|
780
|
+
console.log(`Group:"${CFG.groupName}" prev state:${prevGroupState} colorMode:${CFG.colorModes}`);
|
|
781
|
+
|
|
782
|
+
//? ── AUX — payload received from a parent Group Node ─────────────────────────
|
|
783
|
+
if (msg.dmx_trace != null) {
|
|
784
|
+
console.log(
|
|
785
|
+
`AUX received — from:"${msg.dmx_trace.source}" ` +
|
|
786
|
+
`path:[${msg.dmx_trace.path.join(' → ')}] depth:${msg.dmx_trace.depth}`
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
//# Loop detection — check BEFORE building a new trace
|
|
790
|
+
if (isLoopDetected(msg.dmx_trace)) {
|
|
791
|
+
node.done();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
//# Build updated trace — this node adds itself to the ancestry path
|
|
796
|
+
const trace = buildTrace(msg.dmx_trace);
|
|
797
|
+
|
|
798
|
+
if (msg.payload?.effect && CFG.effects) {
|
|
799
|
+
dispatchGroupEffect(msg.payload.effect, msg.payload, trace, msg);
|
|
800
|
+
} else if (msg.payload?.state != null) {
|
|
801
|
+
const wantTransition = msg.payload.transition && CFG.transitionsEnabled;
|
|
802
|
+
console.log(`AUX dispatch — state:${msg.payload.state} transition:${wantTransition}`);
|
|
803
|
+
dispatch(msg.payload.state, msg.payload, trace, msg);
|
|
804
|
+
} else {
|
|
805
|
+
node.warn(
|
|
806
|
+
`Group "${CFG.groupName}" — AUX payload from "${msg.dmx_trace.source}" ` +
|
|
807
|
+
`had no valid state or effect — message dropped.`
|
|
808
|
+
);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
//? ── DIRECT HA COMMAND — payload.state from MQTT in ─────────────────────────
|
|
812
|
+
} else if (msg.payload?.state != null) {
|
|
813
|
+
//# Guard: if payload is still a string, MQTT In node is not set to parse JSON
|
|
814
|
+
if (typeof msg.payload === 'string') {
|
|
815
|
+
node.warn(
|
|
816
|
+
`Group "${CFG.groupName}" — msg.payload is a string, not a parsed object. ` +
|
|
817
|
+
`Check the MQTT In node is configured with Output: "a parsed JSON object". ` +
|
|
818
|
+
`Raw payload: ${msg.payload}`
|
|
819
|
+
);
|
|
820
|
+
node.done();
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const { state, transition, effect } = msg.payload;
|
|
825
|
+
|
|
826
|
+
//# Origin node — build a fresh trace starting at depth 1
|
|
827
|
+
const trace = buildTrace(null);
|
|
828
|
+
|
|
829
|
+
if (effect && CFG.effects) {
|
|
830
|
+
console.log(`Group effect received: "${effect}" group:"${CFG.groupName}"`);
|
|
831
|
+
dispatchGroupEffect(effect, msg.payload, trace, msg);
|
|
832
|
+
} else {
|
|
833
|
+
const wantTransition = transition && CFG.transitionsEnabled;
|
|
834
|
+
console.log(`Direct HA command — state:${state} transition:${wantTransition} mode:${CFG.colorModes}`);
|
|
835
|
+
dispatch(state, msg.payload, trace, msg);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
//? ── DEVICE REQUEST — add / remove / debug ───────────────────────────────────
|
|
839
|
+
} else if (msg.device != null && (msg.device?.request != null || typeof msg.device === "string")) {
|
|
840
|
+
const _devReq = typeof msg.device === "string" ? msg.device : msg.device.request;
|
|
841
|
+
console.log(`device.request: ${_devReq} group:"${CFG.groupName}"`);
|
|
842
|
+
switch (_devReq) {
|
|
843
|
+
case "add": handleDeviceAdd(); break;
|
|
844
|
+
case "remove": handleDeviceRemove(); break;
|
|
845
|
+
case "debug": console.log("DEBUG:", JSON.stringify(msg)); break;
|
|
846
|
+
default: node.warn(`Group "${CFG.groupName}" — unknown device.request: "${msg.device.request}"`);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
//? ── UNKNOWN MESSAGE ─────────────────────────────────────────────────────────
|
|
850
|
+
} else {
|
|
851
|
+
node.warn(
|
|
852
|
+
`DMX Group Node "${CFG.groupName}" — unrecognised message. ` +
|
|
853
|
+
`Expected: msg.dmx_trace (AUX from parent Group Node), ` +
|
|
854
|
+
`msg.payload.state (direct HA command), or msg.device.request (add/remove). ` +
|
|
855
|
+
`Message received and dropped — unrecognised format. See node documentation.`
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
node.done();
|
|
860
|
+
return [null, null, null, msg, null];
|