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,1994 @@
1
+ // TRANSITIONS — HA MQTT DMX NODE — HOME ASSISTANT in NODE-RED
2
+ // Discord: @deswaggy | Version: 0.5.9
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
+ // ─────────────────────────────────────────────────────────────────────────────
23
+ //!@ CONFIG — all env reads in one place
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+
26
+ const CFG = {
27
+ //@ HA identity
28
+ discoveryPrefix: env.get("<HA_discovery_prefix>"),
29
+ siteId: env.get("<HA_site_unique_id>"),
30
+ component: env.get("<HA_component>"),
31
+ uidPrefix: env.get("<HA_unique_id_prefix>"),
32
+ uid: env.get("<HA_unique_id>"),
33
+ uidPostfix: env.get("<HA_unique_id_postfix>"),
34
+
35
+ //@ MQTT
36
+ retain: env.get("<HA_MQTT_retain_flag>"),
37
+ qos: env.get("<HA_MQTT_QOS>"),
38
+ configTopic: env.get("<HA_config_topic>"),
39
+ commandTopic: env.get("<HA_command_topic>"),
40
+ stateTopic: env.get("<HA_state_topic>"),
41
+
42
+ //@ HA features
43
+ schema: env.get("<HA_schema>"),
44
+ optimistic: env.get("<HA_optimistic>"),
45
+ enabledByDefault: env.get("<HA_enabled_by_default>"),
46
+ icon: env.get("<HA_icon>"),
47
+ colorModes: env.get("<HA_supported_color_modes>"), //^ string, split only when needed
48
+ brightness: env.get("<HA_brightness>"),
49
+ dmxLimiter: env.get("<HA_brightness_scale_and_dmx_limiter>")
50
+ || node.error("No DMX Brightness Scale — check Node Menu"),
51
+ //^ Minimum DMX output value when the fixture state is ON.
52
+ //^ Prevents the gamma curve collapsing low brightness values to zero,
53
+ //^ which would cause the fixture to appear off while the HA slider is above 0%.
54
+ //^ Default: 1 — guarantees something visible at any brightness above 0%.
55
+ //^ Set to 0 to disable (pure gamma — recommended for lighting designers who
56
+ //^ want mathematically correct perceptual response and understand the behaviour).
57
+ //^ Note: this floor is only applied when state is ON. OFF always sends true 0.
58
+ dmxMinOutput: (() => {
59
+ const raw = parseInt(env.get("<DMX_min_output>"));
60
+ return isNaN(raw) ? 1 : raw;
61
+ })(),
62
+ runTransitions: env.get("<HA_run_Transitions>"),
63
+ transitionsEnabled: env.get("<HA_Transitions_Enabled>") ?? true,
64
+ effects: env.get("<HA_effects>"),
65
+ //^ <HA_effects_list> — comma-separated list of effects to advertise to HA.
66
+ //^ Set this in the subflow env menu as a "str" type (comma-separated, no spaces).
67
+ //^ Recommended values per color mode:
68
+ //#
69
+ //^ onoff:
70
+ //# flash_short,flash_long,strobe
71
+ //#
72
+ //^ brightness / white:
73
+ //# flash_short,flash_long,strobe,flicker,relaxing,sleep_transition
74
+ //#
75
+ //^ rgb:
76
+ //# flash_short,flash_long,strobe,rainbow,fire,flicker,twinkle,
77
+ //# color_chase,scan,random,police,christmas,halloween,calaveras,
78
+ //# party,fireworks,temperature_transition,sleep_transition
79
+ //#
80
+ //^ rgbw:
81
+ //# flash_short,flash_long,strobe,rainbow,rainbow_rgbw,fire,flicker,twinkle,
82
+ //# color_chase,scan,random,police,christmas,halloween,calaveras,
83
+ //# party,fireworks,temperature_transition,sleep_transition
84
+ //# (rainbow_rgbw modulates the white channel for a fuller colour spectrum)
85
+ //#
86
+ //^ rgbww:
87
+ //# same as rgbw
88
+ //#
89
+ //^ color_temp:
90
+ //# flash_short,flash_long,strobe,relaxing,sleep_transition,temperature_transition
91
+ //#
92
+ effectsList: env.get("<HA_effects_list>").split(','),
93
+ flashShort: env.get("<HA_effects_flash_time_Short_(seconds)>"),
94
+ flashLong: env.get("<HA_effects_flash_time_Long_(seconds)>"),
95
+ //^ ADVANCED — LIGHT EFFECTS
96
+ //^ effectsGroupControlled: true = effects only accepted via AUX from a parent Group Node (synced siblings)
97
+ //^ false = this fixture handles effects directly from HA payload (independent)
98
+ //^ Default: true — synced effects are almost always correct in a grouped deployment.
99
+ //^ Set to false only for standalone fixtures with no Group Node parent.
100
+ effectsGroupControlled: env.get("<DMX_Effects_Group_Controlled>") !== "false",
101
+
102
+ //@ DMX
103
+ dmxZone: env.get("<CONTROLLER_zone>"),
104
+ dmxChannel: "dmx",
105
+ dmxUniverse: env.get("<DMX_universe>"),
106
+ dmxControllerFw: env.get("<CONTROLLER_fw_v>"),
107
+
108
+ //@ Channels
109
+ ch: {
110
+ red: env.get("<DMX_ch_red>"),
111
+ green: env.get("<DMX_ch_green>"),
112
+ blue: env.get("<DMX_ch_blue>"),
113
+ white: env.get("<DMX_ch_white>"),
114
+ warmWhite: env.get("<DMX_ch_warm_white>"), //^ used by rgbww and color_temp warm channel
115
+ colorTemp: env.get("<DMX_ch_color_temp>"), //^ dedicated CT channel if fixture has one
116
+ },
117
+
118
+ //@ Default ON values
119
+ defaultOn: {
120
+ //^ Fallback to 255 if env vars not set — prevents undefined reaching buildDmxPayload
121
+ brightness: env.get("<DMX_default_on_v_brightness>") ?? 255,
122
+ red: env.get("<DMX_default_on_v_red>") ?? 255,
123
+ green: env.get("<DMX_default_on_v_green>") ?? 255,
124
+ blue: env.get("<DMX_default_on_v_blue>") ?? 255,
125
+ white: env.get("<DMX_default_on_v_white>") ?? 255,
126
+ warmWhite: env.get("<DMX_default_on_v_warm_white>") ?? 0,
127
+ colorTemp: env.get("<DMX_default_on_v_color_temp>") ?? 128, //^ midpoint in mireds range
128
+ //^ Initial brightness bump sent instantly before a transition begins when coming from OFF.
129
+ //^ Gives the room immediate usable light even during a slow fade-in (0 = disabled).
130
+ initialBump: env.get("<DMX_Brightness_Initial_on_value>") || 50,
131
+ },
132
+
133
+ //@ Default OFF values
134
+ defaultOff: {
135
+ red: env.get("<DMX_default_off_v_red>") ?? 0,
136
+ green: env.get("<DMX_default_off_v_green>") ?? 0,
137
+ blue: env.get("<DMX_default_off_v_blue>") ?? 0,
138
+ white: env.get("<DMX_off_v_white>") ?? 0,
139
+ warmWhite: 0,
140
+ },
141
+
142
+ //@ Color temperature range (mireds) — set to match your fixture's spec
143
+ //^ HA sends mireds; we map linearly: minMireds → full cold white, maxMireds → full warm white
144
+ minMireds: env.get("<DMX_ct_min_mireds>") || 153, //^ ~6500K cool
145
+ maxMireds: env.get("<DMX_ct_max_mireds>") || 500, //^ ~2000K warm
146
+
147
+ //@ Transition — flow-level globals (<<<...>>>)
148
+ //^ Rate multiplier: 1 = normal, 2 = double speed, 0.5 = half speed
149
+ transitionRateLimit: env.get("<<<DMX_NODE_FLOW_Transition_Rate_Limit>>>") || 1,
150
+ //^ Default transition duration used when HA UI doesn't specify one (seconds)
151
+ transitionDefaultSecs: env.get("<<<DMX_NODE_FLOW_Transition_HA_UI_Time_(seconds)>>>") || 1,
152
+ //^ Steps per second for transition interpolation — node-level subflow env only.
153
+ //^ 31 ticks/s is a safe default (roughly matches DMX refresh at low-to-mid fixture counts).
154
+ transitionTicksPerSec: env.get("<DMX_transition_ticks_ps>") || 31,
155
+
156
+ //@ Device metadata
157
+ device: {
158
+ type: env.get("<DEVICE_Type>"),
159
+ situation: env.get("<DEVICE_situation>"),
160
+ area: env.get("<DEVICE_area>"),
161
+ subLocation: env.get("<DEVICE_Sub_Location>"),
162
+ },
163
+
164
+ //@ Node metadata
165
+ nodeCode: env.get("<NODE_code>"),
166
+ nodeSw: env.get("<NODE_sw_v>"),
167
+
168
+ //@ Persistence
169
+ diskDelay: env.get("<CONTEXT_STORE_values_to_disk_delay_in_seconds>"),
170
+ defaultState: env.get("<FIXTURE_Default_State>"),
171
+ };
172
+
173
+ // ─────────────────────────────────────────────────────────────────────────────
174
+ //@ TOPIC BUILDERS
175
+ // ─────────────────────────────────────────────────────────────────────────────
176
+
177
+ /** The base topic path for this fixture. */
178
+ const fixtureTopic = `${CFG.discoveryPrefix}/${CFG.component}/${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
179
+
180
+ /** The DMX controller MQTT topic. */
181
+ const dmxTopic = `${CFG.siteId}/${CFG.dmxZone}/${CFG.dmxChannel}/${CFG.dmxUniverse}`;
182
+
183
+ /** Short fixture identifier used in NR node status messages for quick visual feedback.
184
+ * e.g. "L-991" — lets you instantly see which fixture a status belongs to on the canvas. */
185
+ const fixtureId = `${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`;
186
+
187
+ // ─────────────────────────────────────────────────────────────────────────────
188
+ //@ MEMORY HELPERS
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+
191
+ /**
192
+ * Read a value from RAM → disk → env fallback, in that priority order.
193
+ * @param {string} ramKey - flow memory key (e.g. "<<bright>>")
194
+ * @param {string} diskKey - disk memory key (e.g. "<<D_bright>>")
195
+ * @param {*} fallback - env default value
196
+ */
197
+ function recall(ramKey, diskKey, fallback) {
198
+ return flow.get(ramKey, "memory") || flow.get(diskKey, "disk_values") || fallback;
199
+ }
200
+
201
+ /**
202
+ * Write a key/value map to RAM memory in one call.
203
+ * @param {Object} values - e.g. { "<<bright>>": 200, "<<white>>": 255 }
204
+ */
205
+ function rememberRam(values) {
206
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "memory"));
207
+ }
208
+
209
+ /**
210
+ * Write a key/value map to disk memory in one call.
211
+ * @param {Object} values - e.g. { "<<D_bright>>": 200 }
212
+ */
213
+ function rememberDisk(values) {
214
+ Object.entries(values).forEach(([key, val]) => flow.set(key, val, "disk_values"));
215
+ }
216
+
217
+ // ─────────────────────────────────────────────────────────────────────────────
218
+ //@ DMX HELPERS
219
+ // ─────────────────────────────────────────────────────────────────────────────
220
+
221
+ //$ GAMMA CORRECTION
222
+ //# Human vision is logarithmic, not linear. A raw DMX value of 128 (50%) looks
223
+ //# roughly 73% as bright as 255, not 50%. Applying a gamma curve redistributes
224
+ //# the 256 steps so equal DMX increments produce equal perceived brightness steps.
225
+ //#
226
+ //# GAMMA 2.2 = standard display/sRGB gamma — good baseline for most LED fixtures.
227
+ //# Tune upward (2.5–2.8) if dim transitions still feel uneven on your hardware:
228
+ //# - Fade a fixture slowly from full to zero in a dark room.
229
+ //# - If the bottom quarter barely moves, increase GAMMA.
230
+ //# - If the top quarter jumps too fast, decrease GAMMA.
231
+ //#
232
+ //# The table is computed once at startup (256 entries) so Math.pow is never
233
+ //# called during a live transition — important when 200–300 subflows are ticking.
234
+ const GAMMA = 2.2;
235
+ const GAMMA_TABLE = Object.freeze(
236
+ Array.from({ length: 256 }, (_, i) => {
237
+ if (i === 0) return 0; //# i=0 must always be true zero
238
+ const scaled = Math.pow(i / 255, GAMMA) * CFG.dmxLimiter;
239
+ //# Clamp sub-0.5 results to 0 — prevents gamma rounding pushing 0 to 1,
240
+ //# which caused the HA colour picker white channel to never reach true off.
241
+ return scaled < 0.5 ? 0 : Math.round(scaled);
242
+ })
243
+ );
244
+
245
+ /**
246
+ * Scale a 0–255 color value with brightness, then apply gamma correction
247
+ * via the pre-computed lookup table. Returns an integer 0–dmxLimiter.
248
+ *
249
+ * When the input is non-zero, the result is floored to CFG.dmxMinOutput to
250
+ * prevent the gamma curve collapsing visible brightness levels to DMX zero.
251
+ * This ensures the fixture always produces something perceptible when the HA
252
+ * slider is above 0%, regardless of gamma compression at the low end.
253
+ *
254
+ * Set <DMX_min_output> = 0 to disable the floor for pure gamma behaviour.
255
+ *
256
+ * @param {number} colorValue - raw 0–255 color channel value
257
+ * @param {number} [brightness=255] - 0–255 brightness multiplier
258
+ */
259
+ function scaleToDmx(colorValue, brightness = 255) {
260
+ const linear = Math.round(colorValue * brightness / 255);
261
+ const gamma = GAMMA_TABLE[Math.min(255, Math.max(0, linear))];
262
+ //# Only apply the floor when the input is genuinely non-zero.
263
+ //# A zero input means the channel is intentionally off — floor must not apply.
264
+ if (gamma === 0 && linear > 0 && CFG.dmxMinOutput > 0) {
265
+ return CFG.dmxMinOutput;
266
+ }
267
+ return gamma;
268
+ }
269
+
270
+ /**
271
+ * Build the "CCCVVV" string payload expected by the DMX controller.
272
+ * @param {number} channel
273
+ * @param {number} value
274
+ */
275
+ function buildDmxPayload(channel, value) {
276
+ //# Guard: if channel or value is undefined/null, skip rather than send garbage
277
+ if (channel == null || value == null) {
278
+ node.warn(`buildDmxPayload — skipped: channel=${channel} value=${value}`);
279
+ return null;
280
+ }
281
+ return String(channel).padStart(3, '0') + String(value).padStart(3, '0');
282
+ }
283
+
284
+ /**
285
+ * Send one or more DMX channel updates in a single node.send call.
286
+ * @param {Array<[number, number]>} channels - array of [channel, scaledValue] pairs
287
+ */
288
+ function sendDmxChannels(channels) {
289
+ channels.forEach(([ch, val]) => {
290
+ const payload = buildDmxPayload(ch, val);
291
+ if (payload === null) return; //# skip undefined channels
292
+ node.send([{
293
+ retain: CFG.retain,
294
+ qos: CFG.qos,
295
+ topic: dmxTopic,
296
+ payload: payload,
297
+ }, null, null, null, null, null, null]);
298
+ });
299
+ }
300
+
301
+ // ─────────────────────────────────────────────────────────────────────────────
302
+ //@ MQTT SEND HELPERS
303
+ // ─────────────────────────────────────────────────────────────────────────────
304
+
305
+ /** Report fixture state back to HA on output 1. */
306
+ function sendState(payloadObj) {
307
+ node.send([{
308
+ retain: CFG.retain,
309
+ qos: CFG.qos,
310
+ topic: `${fixtureTopic}/${CFG.stateTopic}`,
311
+ payload: payloadObj,
312
+ }, null, null, null, null, null, null]);
313
+ }
314
+
315
+ /** Send a Node-RED status message on output 3. */
316
+ function sendStatus(fill, shape, text) {
317
+ node.send([null, null, {
318
+ debug: { timers: text },
319
+ status: { fill, shape, text },
320
+ }, null, null, null, null]);
321
+ }
322
+
323
+ // ─────────────────────────────────────────────────────────────────────────────
324
+ //@ DISK-SAVE TIMER
325
+ // ─────────────────────────────────────────────────────────────────────────────
326
+
327
+ /**
328
+ * Debounced disk-write timer.
329
+ * Resets any existing countdown and starts a fresh one.
330
+ * Calls `onComplete` once the delay has elapsed.
331
+ * @param {Function} onComplete - called when the timer expires
332
+ */
333
+ function startDiskSaveTimer(onComplete) {
334
+ const existing = context.get('timer_diskSave');
335
+ if (existing) {
336
+ clearInterval(existing);
337
+ context.set('timer_diskSave', null);
338
+ context.set('timer_diskSave_count', 0);
339
+ console.log("Disk-save timer reset by new input.");
340
+ }
341
+
342
+ const maxLoops = CFG.diskDelay;
343
+ let count = 0;
344
+ sendStatus("green", "ring", `${fixtureId} disk-save timer running`);
345
+ console.log(`Disk-save timer started — ${maxLoops}s delay`);
346
+
347
+ const timer = setInterval(() => {
348
+ count++;
349
+ if (count >= maxLoops) {
350
+ clearInterval(timer);
351
+ context.set('timer_diskSave', null);
352
+ context.set('timer_diskSave_count', 0);
353
+ onComplete();
354
+ sendStatus("yellow", "ring", `${fixtureId} disk-save complete — awaiting next HA command`);
355
+ } else {
356
+ context.set('timer_diskSave_count', count);
357
+ }
358
+ }, 1000);
359
+
360
+ context.set('timer_diskSave', timer);
361
+ }
362
+
363
+ // ─────────────────────────────────────────────────────────────────────────────
364
+ //@ COLOR CHANNEL RESOLVERS
365
+ // ─────────────────────────────────────────────────────────────────────────────
366
+ //# Each resolver reads: incoming payload → RAM → disk → env default
367
+ //# Returns a plain object with the resolved color values for the mode.
368
+
369
+ function resolveWhite() {
370
+ return {
371
+ white: recall("<<white>>", "<<D_white>>", CFG.defaultOn.white),
372
+ };
373
+ }
374
+
375
+ function resolveBrightness(payload) {
376
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
377
+ return {
378
+ brightness: payload?.brightness || prev,
379
+ white: recall("<<white>>", "<<D_white>>", CFG.defaultOn.white),
380
+ };
381
+ }
382
+
383
+ function resolveRgb(payload) {
384
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
385
+ return {
386
+ brightness: payload?.brightness || prev,
387
+ red: payload?.color?.r || recall("<<red>>", "<<D_red>>", CFG.defaultOn.red),
388
+ green: payload?.color?.g || recall("<<green>>", "<<D_green>>", CFG.defaultOn.green),
389
+ blue: payload?.color?.b || recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue),
390
+ };
391
+ }
392
+
393
+ function resolveRgbw(payload) {
394
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
395
+ return {
396
+ brightness: payload?.brightness || prev,
397
+ red: payload?.color?.r || recall("<<red>>", "<<D_red>>", CFG.defaultOn.red),
398
+ green: payload?.color?.g || recall("<<green>>", "<<D_green>>", CFG.defaultOn.green),
399
+ blue: payload?.color?.b || recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue),
400
+ white: payload?.color?.w || recall("<<white>>", "<<D_white>>", CFG.defaultOn.white),
401
+ };
402
+ }
403
+
404
+ function resolveRgbww(payload) {
405
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
406
+ return {
407
+ brightness: payload?.brightness || prev,
408
+ red: payload?.color?.r || recall("<<red>>", "<<D_red>>", CFG.defaultOn.red),
409
+ green: payload?.color?.g || recall("<<green>>", "<<D_green>>", CFG.defaultOn.green),
410
+ blue: payload?.color?.b || recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue),
411
+ white: payload?.color?.w || recall("<<white>>", "<<D_white>>", CFG.defaultOn.white),
412
+ warmWhite: payload?.color?.ww || recall("<<warmWhite>>", "<<D_warmWhite>>", CFG.defaultOn.warmWhite),
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Convert a mireds value to cold/warm white levels (0–255 each).
418
+ * minMireds (cool) → coldWhite=255, warmWhite=0
419
+ * maxMireds (warm) → coldWhite=0, warmWhite=255
420
+ *
421
+ * A square-root curve is applied to the crossfade ratio so the perceptual
422
+ * shift from cool to warm feels linear rather than clustering at the extremes.
423
+ * The resulting cold/warm values are then run through scaleToDmx (gamma) when
424
+ * sent to the controller, so colour temperature transitions are doubly correct.
425
+ */
426
+ function miredsToWhiteSplit(mireds) {
427
+ const clamped = Math.max(CFG.minMireds, Math.min(CFG.maxMireds, mireds));
428
+ const linearRatio = (clamped - CFG.minMireds) / (CFG.maxMireds - CFG.minMireds);
429
+ const warmRatio = Math.sqrt(linearRatio); //# perceptual correction
430
+ return {
431
+ coldWhite: Math.round(255 * (1 - warmRatio)),
432
+ warmWhite: Math.round(255 * warmRatio),
433
+ };
434
+ }
435
+
436
+ function resolveColorTemp(payload) {
437
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
438
+ const prevCt = recall("<<colorTemp>>", "<<D_colorTemp>>", CFG.defaultOn.colorTemp);
439
+ return {
440
+ brightness: payload?.brightness || prev,
441
+ colorTemp: payload?.color_temp || prevCt, //^ HA sends mireds in color_temp field
442
+ };
443
+ }
444
+
445
+ function resolveWhiteMode(payload) {
446
+ const prev = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
447
+ return {
448
+ brightness: payload?.brightness || prev,
449
+ white: recall("<<white>>", "<<D_white>>", CFG.defaultOn.white),
450
+ };
451
+ }
452
+
453
+
454
+ // ─────────────────────────────────────────────────────────────────────────────
455
+ //@ TRANSITION ENGINE
456
+ // ─────────────────────────────────────────────────────────────────────────────
457
+ //# Generic linear interpolation stepper used by all transition handlers.
458
+ //# fromChannels and toChannels are arrays of { ch, val } in the same order.
459
+ //# Each tick sends one DMX update per channel at the interpolated value,
460
+ //# respecting the global rate multiplier from CFG.transitionRateLimit.
461
+
462
+ /**
463
+ * Run a linear DMX transition across one or more channels.
464
+ * @param {Array<{ch:number, from:number, to:number}>} channels - channels to interpolate
465
+ * @param {number} durationSecs - total transition time in seconds
466
+ * @param {Function} onComplete - called with final channel values when done
467
+ */
468
+ function runTransition(channels, durationSecs, onComplete) {
469
+ //# Cancel any active transition before starting a new one
470
+ const existingTransition = context.get('timer_transition');
471
+ if (existingTransition) {
472
+ clearInterval(existingTransition);
473
+ context.set('timer_transition', null);
474
+ console.log("Transition timer cancelled by new command.");
475
+ }
476
+
477
+ //# transitionTicksPerSec — how often this node's DMX value advances per second (node-level env)
478
+ //# transitionRateLimit — global speed multiplier across ALL subflow nodes (flow-level env)
479
+ //#
480
+ //# rateLimit=0.1 → potato/limp mode (very slow, minimal network load)
481
+ //# rateLimit=0.5 → half speed
482
+ //# rateLimit=1 → normal speed (default, no change to ticksPerSec)
483
+ //# rateLimit=2 → double speed (overclocked)
484
+ //#
485
+ //# Lower = slower across every node. Higher = faster across every node.
486
+ //# Use it to manage total network/controller load when many fixtures
487
+ //# are transitioning simultaneously without touching individual node settings.
488
+ //#
489
+ //# Example: ticksPerSec=3, rateLimit=2, duration=2s
490
+ //# → effectiveTicks = 3 × 2 = 6 ticks/s (node running at double speed)
491
+ //# → intervalMs = 1000 / 6 ≈ 167ms (one DMX update every ~167ms)
492
+ //# → totalTicks = 2 × 6 = 12 (12 unique DMX steps over the run)
493
+ //#
494
+ //# Example: ticksPerSec=3, rateLimit=0.5, duration=2s
495
+ //# → effectiveTicks = 3 × 0.5 = 1.5 ticks/s
496
+ //# → intervalMs = 1000 / 1.5 ≈ 667ms
497
+ //# → totalTicks = 2 × 1.5 = 3
498
+
499
+ const rateLimit = Math.max(0.1, CFG.transitionRateLimit); //# floor at 0.1 — prevents division by zero (limp mode minimum)
500
+ const effectiveTicks = CFG.transitionTicksPerSec * rateLimit; //# actual steps/s this node runs at
501
+ const intervalMs = Math.round(1000 / effectiveTicks); //# ms between each DMX send
502
+ const totalTicks = Math.max(1, Math.round(durationSecs * effectiveTicks));//# total DMX steps over the duration
503
+ let tick = 0;
504
+
505
+ console.log(
506
+ `Transition start — duration:${durationSecs}s ` +
507
+ `ticksPerSec:${CFG.transitionTicksPerSec} rateLimit:${rateLimit} ` +
508
+ `effectiveTicks:${effectiveTicks.toFixed(2)} intervalMs:${intervalMs} totalTicks:${totalTicks}`
509
+ );
510
+ sendStatus("blue", "ring", `${fixtureId} transition running — ${durationSecs}s`);
511
+
512
+ const timer = setInterval(() => {
513
+ tick++;
514
+ const progress = Math.min(tick / totalTicks, 1);
515
+ const currentValues = channels.map(({ ch, from, to }) => ({
516
+ ch,
517
+ val: Math.round(from + (to - from) * progress),
518
+ }));
519
+ sendDmxChannels(currentValues.map(({ ch, val }) => [ch, val]));
520
+
521
+ if (tick >= totalTicks) {
522
+ clearInterval(timer);
523
+ context.set('timer_transition', null);
524
+ sendStatus("yellow", "ring", `${fixtureId} transition complete — awaiting HA command`);
525
+ console.log("Transition complete.");
526
+ onComplete(currentValues);
527
+ }
528
+ }, intervalMs);
529
+
530
+ context.set('timer_transition', timer);
531
+ }
532
+
533
+ // ─────────────────────────────────────────────────────────────────────────────
534
+ //@ STATE HANDLERS
535
+ // ─────────────────────────────────────────────────────────────────────────────
536
+ //# Each handler: resolves colors → sends DMX → reports state to HA → saves to RAM → queues disk save.
537
+ //# Keyed by color mode, looked up via COLOR_MODE_HANDLERS[state][colorMode].
538
+
539
+ const COLOR_MODE_HANDLERS = {
540
+
541
+ ON: {
542
+
543
+ onoff(payload) {
544
+ const { white } = resolveWhite();
545
+ sendDmxChannels([[CFG.ch.white, scaleToDmx(white)]]);
546
+ sendState({ state: "ON", color: { w: white }, color_mode: "onoff" });
547
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<white>>": white });
548
+ startDiskSaveTimer(() => {
549
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_white>>": white });
550
+ console.log(`Disk saved — state:ON white:${white}`);
551
+ });
552
+ },
553
+
554
+ brightness(payload) {
555
+ const { brightness, white } = resolveBrightness(payload);
556
+ const whiteDmx = scaleToDmx(white, brightness);
557
+ sendDmxChannels([[CFG.ch.white, whiteDmx]]);
558
+ sendState({ state: "ON", brightness: whiteDmx, color: { w: white }, color_mode: "brightness" });
559
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<white>>": white, "<<bright>>": brightness });
560
+ startDiskSaveTimer(() => {
561
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_white>>": white, "<<D_bright>>": brightness });
562
+ console.log(`Disk saved — state:ON white:${white} bright:${brightness}`);
563
+ });
564
+ },
565
+
566
+ rgb(payload) {
567
+ const { brightness, red, green, blue } = resolveRgb(payload);
568
+ sendDmxChannels([
569
+ [CFG.ch.red, scaleToDmx(red, brightness)],
570
+ [CFG.ch.green, scaleToDmx(green, brightness)],
571
+ [CFG.ch.blue, scaleToDmx(blue, brightness)],
572
+ ]);
573
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue }, color_mode: "rgb" });
574
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": brightness, "<<red>>": red, "<<green>>": green, "<<blue>>": blue });
575
+ startDiskSaveTimer(() => {
576
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": brightness, "<<D_red>>": red, "<<D_green>>": green, "<<D_blue>>": blue });
577
+ console.log(`Disk saved — R:${red} G:${green} B:${blue} bright:${brightness}`);
578
+ });
579
+ },
580
+
581
+ rgbw(payload) {
582
+ const { brightness, red, green, blue, white } = resolveRgbw(payload);
583
+ const redDmx = scaleToDmx(red, brightness);
584
+ const greenDmx = scaleToDmx(green, brightness);
585
+ const blueDmx = scaleToDmx(blue, brightness);
586
+ const whiteDmx = scaleToDmx(white, brightness);
587
+ sendDmxChannels([
588
+ [CFG.ch.red, redDmx],
589
+ [CFG.ch.green, greenDmx],
590
+ [CFG.ch.blue, blueDmx],
591
+ [CFG.ch.white, whiteDmx],
592
+ ]);
593
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white }, color_mode: "rgbw" });
594
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": brightness, "<<red>>": red, "<<green>>": green, "<<blue>>": blue, "<<white>>": white });
595
+ startDiskSaveTimer(() => {
596
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": brightness, "<<D_red>>": red, "<<D_green>>": green, "<<D_blue>>": blue, "<<D_white>>": white });
597
+ console.log(`Disk saved — R:${red} G:${green} B:${blue} W:${white} bright:${brightness}`);
598
+ });
599
+ },
600
+
601
+ rgbww(payload) {
602
+ const { brightness, red, green, blue, white, warmWhite } = resolveRgbww(payload);
603
+ const redDmx = scaleToDmx(red, brightness);
604
+ const greenDmx = scaleToDmx(green, brightness);
605
+ const blueDmx = scaleToDmx(blue, brightness);
606
+ const whiteDmx = scaleToDmx(white, brightness);
607
+ const warmWhiteDmx = scaleToDmx(warmWhite, brightness);
608
+ sendDmxChannels([
609
+ [CFG.ch.red, redDmx],
610
+ [CFG.ch.green, greenDmx],
611
+ [CFG.ch.blue, blueDmx],
612
+ [CFG.ch.white, whiteDmx],
613
+ [CFG.ch.warmWhite, warmWhiteDmx],
614
+ ]);
615
+ sendState({ state: "ON", brightness, color: { r: red, g: green, b: blue, w: white, ww: warmWhite }, color_mode: "rgbww" });
616
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": brightness, "<<red>>": red, "<<green>>": green, "<<blue>>": blue, "<<white>>": white, "<<warmWhite>>": warmWhite });
617
+ startDiskSaveTimer(() => {
618
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": brightness, "<<D_red>>": red, "<<D_green>>": green, "<<D_blue>>": blue, "<<D_white>>": white, "<<D_warmWhite>>": warmWhite });
619
+ console.log(`Disk saved — R:${red} G:${green} B:${blue} W:${white} WW:${warmWhite} bright:${brightness}`);
620
+ });
621
+ },
622
+
623
+ color_temp(payload) {
624
+ //# Map mireds → cold/warm white split, then scale by brightness
625
+ const { brightness, colorTemp } = resolveColorTemp(payload);
626
+ const { coldWhite, warmWhite } = miredsToWhiteSplit(colorTemp);
627
+ const coldDmx = scaleToDmx(coldWhite, brightness);
628
+ const warmDmx = scaleToDmx(warmWhite, brightness);
629
+ sendDmxChannels([
630
+ [CFG.ch.white, coldDmx],
631
+ [CFG.ch.warmWhite, warmDmx],
632
+ ]);
633
+ sendState({ state: "ON", brightness, color_temp: colorTemp, color_mode: "color_temp" });
634
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": brightness, "<<colorTemp>>": colorTemp });
635
+ startDiskSaveTimer(() => {
636
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": brightness, "<<D_colorTemp>>": colorTemp });
637
+ console.log(`Disk saved — state:ON colorTemp:${colorTemp} bright:${brightness}`);
638
+ });
639
+ },
640
+
641
+ "rgb,color_temp"(payload) {
642
+ //# HA uses color_mode field to tell us which sub-mode is active this command
643
+ if (payload.color_mode === "color_temp") {
644
+ COLOR_MODE_HANDLERS.ON.color_temp(payload);
645
+ } else {
646
+ COLOR_MODE_HANDLERS.ON.rgb(payload);
647
+ }
648
+ },
649
+
650
+ "rgbw,color_temp"(payload) {
651
+ if (payload.color_mode === "color_temp") {
652
+ COLOR_MODE_HANDLERS.ON.color_temp(payload);
653
+ } else {
654
+ COLOR_MODE_HANDLERS.ON.rgbw(payload);
655
+ }
656
+ },
657
+
658
+ "rgbww,color_temp"(payload) {
659
+ if (payload.color_mode === "color_temp") {
660
+ COLOR_MODE_HANDLERS.ON.color_temp(payload);
661
+ } else {
662
+ COLOR_MODE_HANDLERS.ON.rgbww(payload);
663
+ }
664
+ },
665
+
666
+ white(payload) {
667
+ //# "white" mode — brightness only, drives the white channel
668
+ const { brightness, white } = resolveWhiteMode(payload);
669
+ const whiteDmx = scaleToDmx(white, brightness);
670
+ sendDmxChannels([[CFG.ch.white, whiteDmx]]);
671
+ sendState({ state: "ON", brightness: whiteDmx, color_mode: "white" });
672
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": brightness, "<<white>>": white });
673
+ startDiskSaveTimer(() => {
674
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": brightness, "<<D_white>>": white });
675
+ console.log(`Disk saved — state:ON white:${white} bright:${brightness}`);
676
+ });
677
+ },
678
+ },
679
+
680
+ OFF: {
681
+
682
+ onoff(payload) {
683
+ const white = CFG.defaultOff.white;
684
+ sendDmxChannels([[CFG.ch.white, scaleToDmx(white)]]);
685
+ sendState({ state: "OFF", color: { w: white }, color_mode: "onoff" });
686
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF", "<<white>>": white });
687
+ startDiskSaveTimer(() => {
688
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
689
+ console.log("Disk saved — state:OFF");
690
+ });
691
+ },
692
+
693
+ brightness(payload) {
694
+ const white = CFG.defaultOff.white;
695
+ sendDmxChannels([[CFG.ch.white, scaleToDmx(white)]]);
696
+ sendState({ state: "OFF", color: { w: white }, color_mode: "brightness" });
697
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
698
+ startDiskSaveTimer(() => {
699
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
700
+ console.log("Disk saved — state:OFF");
701
+ });
702
+ },
703
+
704
+ rgb(payload) {
705
+ const { red, green, blue } = CFG.defaultOff;
706
+ const redDmx = scaleToDmx(red);
707
+ const greenDmx = scaleToDmx(green);
708
+ const blueDmx = scaleToDmx(blue);
709
+ sendDmxChannels([
710
+ [CFG.ch.red, redDmx],
711
+ [CFG.ch.green, greenDmx],
712
+ [CFG.ch.blue, blueDmx],
713
+ ]);
714
+ sendState({ state: "OFF", color: { r: red, g: green, b: blue }, color_mode: "rgb" });
715
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
716
+ startDiskSaveTimer(() => {
717
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
718
+ console.log("Disk saved — state:OFF");
719
+ });
720
+ },
721
+
722
+ rgbw(payload) {
723
+ sendDmxChannels([
724
+ [CFG.ch.red, 0],
725
+ [CFG.ch.green, 0],
726
+ [CFG.ch.blue, 0],
727
+ [CFG.ch.white, 0],
728
+ ]);
729
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw" });
730
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
731
+ startDiskSaveTimer(() => {
732
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
733
+ console.log("Disk saved — state:OFF");
734
+ });
735
+ },
736
+
737
+ rgbww(payload) {
738
+ sendDmxChannels([
739
+ [CFG.ch.red, 0],
740
+ [CFG.ch.green, 0],
741
+ [CFG.ch.blue, 0],
742
+ [CFG.ch.white, 0],
743
+ [CFG.ch.warmWhite, 0],
744
+ ]);
745
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww" });
746
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
747
+ startDiskSaveTimer(() => {
748
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
749
+ console.log("Disk saved — state:OFF");
750
+ });
751
+ },
752
+
753
+ color_temp(payload) {
754
+ sendDmxChannels([
755
+ [CFG.ch.white, 0],
756
+ [CFG.ch.warmWhite, 0],
757
+ ]);
758
+ sendState({ state: "OFF", color_mode: "color_temp" });
759
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
760
+ startDiskSaveTimer(() => {
761
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
762
+ console.log("Disk saved — state:OFF");
763
+ });
764
+ },
765
+
766
+ "rgb,color_temp"(payload) {
767
+ COLOR_MODE_HANDLERS.OFF.rgb(payload);
768
+ sendDmxChannels([[CFG.ch.white, 0], [CFG.ch.warmWhite, 0]]);
769
+ },
770
+
771
+ "rgbw,color_temp"(payload) {
772
+ COLOR_MODE_HANDLERS.OFF.rgbw(payload);
773
+ sendDmxChannels([[CFG.ch.warmWhite, 0]]);
774
+ },
775
+
776
+ "rgbww,color_temp"(payload) {
777
+ COLOR_MODE_HANDLERS.OFF.rgbww(payload);
778
+ },
779
+
780
+ white(payload) {
781
+ sendDmxChannels([[CFG.ch.white, 0]]);
782
+ sendState({ state: "OFF", color_mode: "white" });
783
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
784
+ startDiskSaveTimer(() => {
785
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" });
786
+ console.log("Disk saved — state:OFF");
787
+ });
788
+ },
789
+ },
790
+ };
791
+
792
+ // ─────────────────────────────────────────────────────────────────────────────
793
+ //@ TRANSITION HANDLERS
794
+ // ─────────────────────────────────────────────────────────────────────────────
795
+ //# onoff has no meaningful transition — falls through to instant handler.
796
+ //# All other modes interpolate from last-known values to target values
797
+ //# using runTransition(), then save state to RAM/disk on completion.
798
+
799
+ const TRANSITION_HANDLERS = {
800
+
801
+ ON: {
802
+
803
+ onoff(payload) {
804
+ COLOR_MODE_HANDLERS.ON.onoff(payload);
805
+ },
806
+
807
+ brightness(payload) {
808
+ const { brightness: targetBright, white } = resolveBrightness(payload);
809
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
810
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
811
+ const channels = [{ ch: CFG.ch.white, to: scaleToDmx(white, targetBright) }];
812
+ const bumpFrom = sendInitialBump(channels);
813
+
814
+ console.log(`Transition ON/brightness — from:${prevBright} to:${targetBright} ${durationSec}s bump:${bumpFrom}`);
815
+
816
+ runTransition([
817
+ { ch: CFG.ch.white, from: bumpFrom || scaleToDmx(white, prevBright), to: scaleToDmx(white, targetBright) },
818
+ ], durationSec, () => {
819
+ sendState({ state: "ON", brightness: scaleToDmx(white, targetBright), color: { w: white }, color_mode: "brightness" });
820
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<white>>": white, "<<bright>>": targetBright });
821
+ startDiskSaveTimer(() => {
822
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_white>>": white, "<<D_bright>>": targetBright });
823
+ console.log(`Disk saved — state:ON white:${white} bright:${targetBright}`);
824
+ });
825
+ });
826
+ },
827
+
828
+ rgb(payload) {
829
+ const { brightness: targetBright, red: toR, green: toG, blue: toB } = resolveRgb(payload);
830
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
831
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
832
+ const toChannels = [
833
+ { ch: CFG.ch.red, to: scaleToDmx(toR, targetBright) },
834
+ { ch: CFG.ch.green, to: scaleToDmx(toG, targetBright) },
835
+ { ch: CFG.ch.blue, to: scaleToDmx(toB, targetBright) },
836
+ ];
837
+ const bumpFrom = sendInitialBump(toChannels);
838
+ const fromR = bumpFrom || scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
839
+ const fromG = bumpFrom || scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
840
+ const fromB = bumpFrom || scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
841
+
842
+ runTransition([
843
+ { ch: CFG.ch.red, from: fromR, to: scaleToDmx(toR, targetBright) },
844
+ { ch: CFG.ch.green, from: fromG, to: scaleToDmx(toG, targetBright) },
845
+ { ch: CFG.ch.blue, from: fromB, to: scaleToDmx(toB, targetBright) },
846
+ ], durationSec, () => {
847
+ sendState({ state: "ON", brightness: targetBright, color: { r: toR, g: toG, b: toB }, color_mode: "rgb" });
848
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": targetBright, "<<red>>": toR, "<<green>>": toG, "<<blue>>": toB });
849
+ startDiskSaveTimer(() => {
850
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": targetBright, "<<D_red>>": toR, "<<D_green>>": toG, "<<D_blue>>": toB });
851
+ });
852
+ });
853
+ },
854
+
855
+ rgbw(payload) {
856
+ const { brightness: targetBright, red: toR, green: toG, blue: toB, white: toW } = resolveRgbw(payload);
857
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
858
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
859
+ const toChannels = [
860
+ { ch: CFG.ch.red, to: scaleToDmx(toR, targetBright) },
861
+ { ch: CFG.ch.green, to: scaleToDmx(toG, targetBright) },
862
+ { ch: CFG.ch.blue, to: scaleToDmx(toB, targetBright) },
863
+ { ch: CFG.ch.white, to: scaleToDmx(toW, targetBright) },
864
+ ];
865
+ const bumpFrom = sendInitialBump(toChannels);
866
+ const fromR = bumpFrom || scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
867
+ const fromG = bumpFrom || scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
868
+ const fromB = bumpFrom || scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
869
+ const fromW = bumpFrom || scaleToDmx(recall("<<white>>", "<<D_white>>", CFG.defaultOn.white), prevBright);
870
+
871
+ runTransition([
872
+ { ch: CFG.ch.red, from: fromR, to: scaleToDmx(toR, targetBright) },
873
+ { ch: CFG.ch.green, from: fromG, to: scaleToDmx(toG, targetBright) },
874
+ { ch: CFG.ch.blue, from: fromB, to: scaleToDmx(toB, targetBright) },
875
+ { ch: CFG.ch.white, from: fromW, to: scaleToDmx(toW, targetBright) },
876
+ ], durationSec, () => {
877
+ sendState({ state: "ON", brightness: targetBright, color: { r: toR, g: toG, b: toB, w: toW }, color_mode: "rgbw" });
878
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": targetBright, "<<red>>": toR, "<<green>>": toG, "<<blue>>": toB, "<<white>>": toW });
879
+ startDiskSaveTimer(() => {
880
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": targetBright, "<<D_red>>": toR, "<<D_green>>": toG, "<<D_blue>>": toB, "<<D_white>>": toW });
881
+ });
882
+ });
883
+ },
884
+
885
+ rgbww(payload) {
886
+ const { brightness: targetBright, red: toR, green: toG, blue: toB, white: toW, warmWhite: toWW } = resolveRgbww(payload);
887
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
888
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
889
+ const toChannels = [
890
+ { ch: CFG.ch.red, to: scaleToDmx(toR, targetBright) },
891
+ { ch: CFG.ch.green, to: scaleToDmx(toG, targetBright) },
892
+ { ch: CFG.ch.blue, to: scaleToDmx(toB, targetBright) },
893
+ { ch: CFG.ch.white, to: scaleToDmx(toW, targetBright) },
894
+ { ch: CFG.ch.warmWhite, to: scaleToDmx(toWW, targetBright) },
895
+ ];
896
+ const bumpFrom = sendInitialBump(toChannels);
897
+ const fromR = bumpFrom || scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
898
+ const fromG = bumpFrom || scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
899
+ const fromB = bumpFrom || scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
900
+ const fromW = bumpFrom || scaleToDmx(recall("<<white>>", "<<D_white>>", CFG.defaultOn.white), prevBright);
901
+ const fromWW = bumpFrom || scaleToDmx(recall("<<warmWhite>>", "<<D_warmWhite>>", CFG.defaultOn.warmWhite), prevBright);
902
+
903
+ runTransition([
904
+ { ch: CFG.ch.red, from: fromR, to: scaleToDmx(toR, targetBright) },
905
+ { ch: CFG.ch.green, from: fromG, to: scaleToDmx(toG, targetBright) },
906
+ { ch: CFG.ch.blue, from: fromB, to: scaleToDmx(toB, targetBright) },
907
+ { ch: CFG.ch.white, from: fromW, to: scaleToDmx(toW, targetBright) },
908
+ { ch: CFG.ch.warmWhite, from: fromWW, to: scaleToDmx(toWW, targetBright) },
909
+ ], durationSec, () => {
910
+ sendState({ state: "ON", brightness: targetBright, color: { r: toR, g: toG, b: toB, w: toW, ww: toWW }, color_mode: "rgbww" });
911
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": targetBright, "<<red>>": toR, "<<green>>": toG, "<<blue>>": toB, "<<white>>": toW, "<<warmWhite>>": toWW });
912
+ startDiskSaveTimer(() => {
913
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": targetBright, "<<D_red>>": toR, "<<D_green>>": toG, "<<D_blue>>": toB, "<<D_white>>": toW, "<<D_warmWhite>>": toWW });
914
+ });
915
+ });
916
+ },
917
+
918
+ color_temp(payload) {
919
+ const { brightness: targetBright, colorTemp: targetCt } = resolveColorTemp(payload);
920
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
921
+ const prevCt = recall("<<colorTemp>>", "<<D_colorTemp>>", CFG.defaultOn.colorTemp);
922
+ const { coldWhite: fromCold, warmWhite: fromWarm } = miredsToWhiteSplit(prevCt);
923
+ const { coldWhite: toCold, warmWhite: toWarm } = miredsToWhiteSplit(targetCt);
924
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
925
+ const toChannels = [
926
+ { ch: CFG.ch.white, to: scaleToDmx(toCold, targetBright) },
927
+ { ch: CFG.ch.warmWhite, to: scaleToDmx(toWarm, targetBright) },
928
+ ];
929
+ const bumpFrom = sendInitialBump(toChannels);
930
+
931
+ runTransition([
932
+ { ch: CFG.ch.white, from: bumpFrom || scaleToDmx(fromCold, prevBright), to: scaleToDmx(toCold, targetBright) },
933
+ { ch: CFG.ch.warmWhite, from: bumpFrom || scaleToDmx(fromWarm, prevBright), to: scaleToDmx(toWarm, targetBright) },
934
+ ], durationSec, () => {
935
+ sendState({ state: "ON", brightness: targetBright, color_temp: targetCt, color_mode: "color_temp" });
936
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": targetBright, "<<colorTemp>>": targetCt });
937
+ startDiskSaveTimer(() => {
938
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": targetBright, "<<D_colorTemp>>": targetCt });
939
+ });
940
+ });
941
+ },
942
+
943
+ "rgb,color_temp"(payload) {
944
+ if (payload.color_mode === "color_temp") {
945
+ TRANSITION_HANDLERS.ON.color_temp(payload);
946
+ } else {
947
+ TRANSITION_HANDLERS.ON.rgb(payload);
948
+ }
949
+ },
950
+
951
+ "rgbw,color_temp"(payload) {
952
+ if (payload.color_mode === "color_temp") {
953
+ TRANSITION_HANDLERS.ON.color_temp(payload);
954
+ } else {
955
+ TRANSITION_HANDLERS.ON.rgbw(payload);
956
+ }
957
+ },
958
+
959
+ "rgbww,color_temp"(payload) {
960
+ if (payload.color_mode === "color_temp") {
961
+ TRANSITION_HANDLERS.ON.color_temp(payload);
962
+ } else {
963
+ TRANSITION_HANDLERS.ON.rgbww(payload);
964
+ }
965
+ },
966
+
967
+ white(payload) {
968
+ const { brightness: targetBright, white } = resolveWhiteMode(payload);
969
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
970
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
971
+ const toChannels = [{ ch: CFG.ch.white, to: scaleToDmx(white, targetBright) }];
972
+ const bumpFrom = sendInitialBump(toChannels);
973
+
974
+ runTransition([
975
+ { ch: CFG.ch.white, from: bumpFrom || scaleToDmx(white, prevBright), to: scaleToDmx(white, targetBright) },
976
+ ], durationSec, () => {
977
+ sendState({ state: "ON", brightness: scaleToDmx(white, targetBright), color_mode: "white" });
978
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "ON", "<<bright>>": targetBright, "<<white>>": white });
979
+ startDiskSaveTimer(() => {
980
+ rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "ON", "<<D_bright>>": targetBright, "<<D_white>>": white });
981
+ });
982
+ });
983
+ },
984
+ },
985
+
986
+ OFF: {
987
+
988
+ onoff(payload) {
989
+ COLOR_MODE_HANDLERS.OFF.onoff(payload);
990
+ },
991
+
992
+ //# Fade to zero from current brightness
993
+ brightness(payload) {
994
+ const white = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
995
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
996
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
997
+
998
+ runTransition([
999
+ { ch: CFG.ch.white, from: scaleToDmx(white, prevBright), to: 0 },
1000
+ ], durationSec, () => {
1001
+ sendState({ state: "OFF", color_mode: "brightness" });
1002
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1003
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1004
+ });
1005
+ },
1006
+
1007
+ rgb(payload) {
1008
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1009
+ const fromR = scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
1010
+ const fromG = scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
1011
+ const fromB = scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
1012
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
1013
+
1014
+ runTransition([
1015
+ { ch: CFG.ch.red, from: fromR, to: 0 },
1016
+ { ch: CFG.ch.green, from: fromG, to: 0 },
1017
+ { ch: CFG.ch.blue, from: fromB, to: 0 },
1018
+ ], durationSec, () => {
1019
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0 }, color_mode: "rgb" });
1020
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1021
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1022
+ });
1023
+ },
1024
+
1025
+ rgbw(payload) {
1026
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1027
+ const fromR = scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
1028
+ const fromG = scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
1029
+ const fromB = scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
1030
+ const fromW = scaleToDmx(recall("<<white>>", "<<D_white>>", CFG.defaultOn.white), prevBright);
1031
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
1032
+
1033
+ runTransition([
1034
+ { ch: CFG.ch.red, from: fromR, to: 0 },
1035
+ { ch: CFG.ch.green, from: fromG, to: 0 },
1036
+ { ch: CFG.ch.blue, from: fromB, to: 0 },
1037
+ { ch: CFG.ch.white, from: fromW, to: 0 },
1038
+ ], durationSec, () => {
1039
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0 }, color_mode: "rgbw" });
1040
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1041
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1042
+ });
1043
+ },
1044
+
1045
+ rgbww(payload) {
1046
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1047
+ const fromR = scaleToDmx(recall("<<red>>", "<<D_red>>", CFG.defaultOn.red), prevBright);
1048
+ const fromG = scaleToDmx(recall("<<green>>", "<<D_green>>", CFG.defaultOn.green), prevBright);
1049
+ const fromB = scaleToDmx(recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue), prevBright);
1050
+ const fromW = scaleToDmx(recall("<<white>>", "<<D_white>>", CFG.defaultOn.white), prevBright);
1051
+ const fromWW = scaleToDmx(recall("<<warmWhite>>", "<<D_warmWhite>>", CFG.defaultOn.warmWhite), prevBright);
1052
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
1053
+
1054
+ runTransition([
1055
+ { ch: CFG.ch.red, from: fromR, to: 0 },
1056
+ { ch: CFG.ch.green, from: fromG, to: 0 },
1057
+ { ch: CFG.ch.blue, from: fromB, to: 0 },
1058
+ { ch: CFG.ch.white, from: fromW, to: 0 },
1059
+ { ch: CFG.ch.warmWhite, from: fromWW, to: 0 },
1060
+ ], durationSec, () => {
1061
+ sendState({ state: "OFF", color: { r: 0, g: 0, b: 0, w: 0, ww: 0 }, color_mode: "rgbww" });
1062
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1063
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1064
+ });
1065
+ },
1066
+
1067
+ color_temp(payload) {
1068
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1069
+ const prevCt = recall("<<colorTemp>>", "<<D_colorTemp>>", CFG.defaultOn.colorTemp);
1070
+ const { coldWhite, warmWhite } = miredsToWhiteSplit(prevCt);
1071
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
1072
+
1073
+ runTransition([
1074
+ { ch: CFG.ch.white, from: scaleToDmx(coldWhite, prevBright), to: 0 },
1075
+ { ch: CFG.ch.warmWhite, from: scaleToDmx(warmWhite, prevBright), to: 0 },
1076
+ ], durationSec, () => {
1077
+ sendState({ state: "OFF", color_mode: "color_temp" });
1078
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1079
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1080
+ });
1081
+ },
1082
+
1083
+ "rgb,color_temp"(payload) {
1084
+ TRANSITION_HANDLERS.OFF.rgb(payload);
1085
+ },
1086
+
1087
+ "rgbw,color_temp"(payload) {
1088
+ TRANSITION_HANDLERS.OFF.rgbw(payload);
1089
+ },
1090
+
1091
+ "rgbww,color_temp"(payload) {
1092
+ TRANSITION_HANDLERS.OFF.rgbww(payload);
1093
+ },
1094
+
1095
+ white(payload) {
1096
+ const white = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
1097
+ const prevBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1098
+ const durationSec = payload.transition || CFG.transitionDefaultSecs;
1099
+
1100
+ runTransition([
1101
+ { ch: CFG.ch.white, from: scaleToDmx(white, prevBright), to: 0 },
1102
+ ], durationSec, () => {
1103
+ sendState({ state: "OFF", color_mode: "white" });
1104
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1105
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1106
+ });
1107
+ },
1108
+ },
1109
+ };
1110
+
1111
+ // ─────────────────────────────────────────────────────────────────────────────
1112
+ //@ EFFECTS
1113
+ // ─────────────────────────────────────────────────────────────────────────────
1114
+ //# All effects share the same lifecycle:
1115
+ //# 1. stopEffect() — cancel any running effect timer
1116
+ //# 2. run the effect loop via setInterval stored in context('timer_effect')
1117
+ //# 3. restoreAfterEffect() — on natural end, send fixture back to last known state
1118
+ //#
1119
+ //# Effects run indefinitely (durationSecs = 0) until the next HA command cancels them.
1120
+ //# A non-zero durationSecs makes the effect self-terminate.
1121
+ //#
1122
+ //# Color helpers used throughout:
1123
+ //# hsvToRgb(h, s, v) — h:0–360, s/v:0–1, returns [r,g,b] 0–255
1124
+ //# sendRgb(r,g,b,bright) — scales through gamma and sends R/G/B channels
1125
+ //# sendWhites(cold,warm,bright) — scales through gamma and sends white channels
1126
+
1127
+ /** Cancel any currently running effect timer. */
1128
+ function stopEffect() {
1129
+ const t = context.get('timer_effect');
1130
+ if (t) {
1131
+ clearInterval(t);
1132
+ context.set('timer_effect', null);
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Restore fixture to its last known RAM state after an effect ends naturally.
1138
+ * Reads current RAM values and re-sends the appropriate DMX channels.
1139
+ */
1140
+ function restoreAfterEffect() {
1141
+ const state = recall("<<FIXTURE_RAM_Memory_State>>", "<<FIXTURE_Disk_Memory_State>>", CFG.defaultState);
1142
+ const bright = state === "ON" ? recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness) : 0;
1143
+ const r = recall("<<red>>", "<<D_red>>", CFG.defaultOn.red);
1144
+ const g = recall("<<green>>", "<<D_green>>", CFG.defaultOn.green);
1145
+ const b = recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue);
1146
+ const w = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
1147
+ const ww = recall("<<warmWhite>>","<<D_warmWhite>>", CFG.defaultOn.warmWhite);
1148
+ sendDmxChannels([
1149
+ [CFG.ch.red, scaleToDmx(r, bright)],
1150
+ [CFG.ch.green, scaleToDmx(g, bright)],
1151
+ [CFG.ch.blue, scaleToDmx(b, bright)],
1152
+ [CFG.ch.white, scaleToDmx(w, bright)],
1153
+ [CFG.ch.warmWhite, scaleToDmx(ww, bright)],
1154
+ ]);
1155
+ sendStatus("yellow", "ring", `${fixtureId} effect complete — awaiting HA command`);
1156
+ }
1157
+
1158
+ /**
1159
+ * Convert HSV to RGB.
1160
+ * @param {number} h - hue 0–360
1161
+ * @param {number} s - saturation 0–1
1162
+ * @param {number} v - value 0–1
1163
+ * @returns {[number, number, number]} [r, g, b] each 0–255
1164
+ */
1165
+ function hsvToRgb(h, s, v) {
1166
+ const i = Math.floor(h / 60) % 6;
1167
+ const f = (h / 60) - Math.floor(h / 60);
1168
+ const p = Math.round(255 * v * (1 - s));
1169
+ const q = Math.round(255 * v * (1 - f * s));
1170
+ const t = Math.round(255 * v * (1 - (1 - f) * s));
1171
+ const V = Math.round(255 * v);
1172
+ return [[V,t,p],[q,V,p],[p,V,t],[p,q,V],[t,p,V],[V,p,q]][i];
1173
+ }
1174
+
1175
+ /** Send R/G/B channels scaled through brightness and gamma. */
1176
+ function sendRgb(r, g, b, bright) {
1177
+ sendDmxChannels([
1178
+ [CFG.ch.red, scaleToDmx(r, bright)],
1179
+ [CFG.ch.green, scaleToDmx(g, bright)],
1180
+ [CFG.ch.blue, scaleToDmx(b, bright)],
1181
+ ]);
1182
+ }
1183
+
1184
+ /** Send cold/warm white channels scaled through brightness and gamma. */
1185
+ function sendWhites(cold, warm, bright) {
1186
+ sendDmxChannels([
1187
+ [CFG.ch.white, scaleToDmx(cold, bright)],
1188
+ [CFG.ch.warmWhite, scaleToDmx(warm, bright)],
1189
+ ]);
1190
+ }
1191
+
1192
+ /**
1193
+ * Send an instant brightness bump on all active channels before a transition begins.
1194
+ * Only fires when the fixture is currently OFF (prevBright === 0 or state is OFF).
1195
+ * This gives the room immediate usable light even during a slow fade-in.
1196
+ *
1197
+ * @param {Array<{ch:number, to:number}>} channels - the transition target channels
1198
+ * @returns {number} the bump DMX value used as the new `from` for the transition,
1199
+ * or 0 if the fixture was already ON (no bump needed).
1200
+ */
1201
+ function sendInitialBump(channels) {
1202
+ const prevState = recall("<<FIXTURE_RAM_Memory_State>>", "<<FIXTURE_Disk_Memory_State>>", CFG.defaultState);
1203
+
1204
+ //# Only bump when coming from OFF — brightness=0 while ON is not a real-world state
1205
+ if (prevState !== "OFF") return 0;
1206
+
1207
+ const bumpValue = CFG.defaultOn.initialBump;
1208
+ if (!bumpValue || bumpValue <= 0) return 0; //# disabled — env set to 0
1209
+
1210
+ //# Scale bump proportionally to each channel's target so colour balance is preserved
1211
+ const bumpDmx = scaleToDmx(bumpValue);
1212
+ channels.forEach(({ ch, to }) => {
1213
+ if (to > 0) {
1214
+ //# Bump proportional to how bright this channel will end up
1215
+ const channelBump = Math.min(bumpDmx, to);
1216
+ sendDmxChannels([[ch, channelBump]]);
1217
+ }
1218
+ });
1219
+
1220
+ console.log(`Initial bump sent — bumpValue:${bumpValue} bumpDmx:${bumpDmx}`);
1221
+ return bumpDmx;
1222
+ }
1223
+
1224
+ /** Start an effect interval, storing the timer in context. */
1225
+ function startEffect(label, intervalMs, tickFn, durationSecs = 0) {
1226
+ stopEffect();
1227
+ sendStatus("blue", "dot", `${fixtureId} effect: ${label}`);
1228
+ const endTime = durationSecs > 0 ? Date.now() + durationSecs * 1000 : Infinity;
1229
+ const timer = setInterval(() => {
1230
+ tickFn();
1231
+ if (Date.now() >= endTime) {
1232
+ stopEffect();
1233
+ restoreAfterEffect();
1234
+ }
1235
+ }, intervalMs);
1236
+ context.set('timer_effect', timer);
1237
+ }
1238
+
1239
+ // ─────────────────────────────────────────────────────────────────────────────
1240
+ //@ EFFECT IMPLEMENTATIONS
1241
+ // ─────────────────────────────────────────────────────────────────────────────
1242
+
1243
+ /**
1244
+ * FLASH — toggle on/off at 2 Hz.
1245
+ * Works on any color mode; uses last-known white/RGB values.
1246
+ */
1247
+ function effectFlash(durationSecs) {
1248
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1249
+ const w = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
1250
+ const onW = scaleToDmx(w, bright);
1251
+ const r = recall("<<red>>", "<<D_red>>", CFG.defaultOn.red);
1252
+ const g = recall("<<green>>", "<<D_green>>", CFG.defaultOn.green);
1253
+ const b = recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue);
1254
+ let isOn = false;
1255
+ startEffect("Flash", 500, () => {
1256
+ isOn = !isOn;
1257
+ const v = isOn ? bright : 0;
1258
+ sendDmxChannels([
1259
+ [CFG.ch.white, isOn ? onW : 0],
1260
+ [CFG.ch.red, scaleToDmx(r, v)],
1261
+ [CFG.ch.green, scaleToDmx(g, v)],
1262
+ [CFG.ch.blue, scaleToDmx(b, v)],
1263
+ ]);
1264
+ }, durationSecs);
1265
+ }
1266
+
1267
+ /**
1268
+ * STROBE — rapid on/off, up to ~20 Hz.
1269
+ * Harsher than flash; useful for DJ/stage looks.
1270
+ * Rate is configurable 1–20 Hz via strobeHz param (default 10).
1271
+ */
1272
+ function effectStrobe(durationSecs, strobeHz = 10) {
1273
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1274
+ const w = scaleToDmx(recall("<<white>>", "<<D_white>>", CFG.defaultOn.white), bright);
1275
+ const r = recall("<<red>>", "<<D_red>>", CFG.defaultOn.red);
1276
+ const g = recall("<<green>>", "<<D_green>>", CFG.defaultOn.green);
1277
+ const b = recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue);
1278
+ const hz = Math.min(20, Math.max(1, strobeHz));
1279
+ let isOn = false;
1280
+ startEffect("Strobe", Math.round(1000 / (hz * 2)), () => {
1281
+ isOn = !isOn;
1282
+ const v = isOn ? bright : 0;
1283
+ sendDmxChannels([
1284
+ [CFG.ch.white, isOn ? w : 0],
1285
+ [CFG.ch.red, scaleToDmx(r, v)],
1286
+ [CFG.ch.green, scaleToDmx(g, v)],
1287
+ [CFG.ch.blue, scaleToDmx(b, v)],
1288
+ ]);
1289
+ }, durationSecs);
1290
+ }
1291
+
1292
+ /**
1293
+ * RAINBOW — full HSV hue cycle. Full cycle ~3 seconds.
1294
+ * RGB fixtures only.
1295
+ */
1296
+ function effectRainbow(durationSecs) {
1297
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1298
+ const stepDeg = 360 / (CFG.transitionTicksPerSec * 3);
1299
+ let hue = 0;
1300
+ startEffect("Rainbow", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1301
+ const [r, g, b] = hsvToRgb(hue, 1, 1);
1302
+ sendRgb(r, g, b, bright);
1303
+ hue = (hue + stepDeg) % 360;
1304
+ }, durationSecs);
1305
+ }
1306
+
1307
+ /**
1308
+ * RAINBOW RGBW — full hue cycle with white channel modulation for RGBW fixtures.
1309
+ * White is driven inversely to the dominant RGB channel so it fills the gaps
1310
+ * between primaries with a warm ambient lift, giving a fuller colour spectrum
1311
+ * than RGB rainbow alone. White = 0 at pure primaries, peaks softly between them.
1312
+ */
1313
+ function effectRainbowRgbw(durationSecs) {
1314
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1315
+ const stepDeg = 360 / (CFG.transitionTicksPerSec * 3);
1316
+ let hue = 0;
1317
+ startEffect("Rainbow RGBW", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1318
+ const [r, g, b] = hsvToRgb(hue, 1, 1);
1319
+ //# White inversely proportional to the dominant channel — warm glow between primaries
1320
+ const dominant = Math.max(r, g, b);
1321
+ const white = Math.round(60 * (1 - dominant / 255));
1322
+ sendRgb(r, g, b, bright);
1323
+ sendDmxChannels([[CFG.ch.white, scaleToDmx(white, bright)]]);
1324
+ hue = (hue + stepDeg) % 360;
1325
+ }, durationSecs);
1326
+ }
1327
+
1328
+ /**
1329
+ * FIRE — warm flickering between deep orange and bright yellow-white.
1330
+ * Uses white + warm white channels if available, falls back to RGB.
1331
+ * Random intensity bursts with a warm bias.
1332
+ */
1333
+ function effectFire(durationSecs) {
1334
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1335
+ startEffect("Fire", 60, () => {
1336
+ //# Flicker: base warm glow + random burst of heat
1337
+ const flicker = 0.6 + Math.random() * 0.4; //# 60–100% intensity
1338
+ const heat = 0.7 + Math.random() * 0.3; //# warm bias 70–100%
1339
+ const r = Math.round(255 * flicker);
1340
+ const g = Math.round(80 * flicker * (1 - heat * 0.4));
1341
+ const b = 0;
1342
+ sendRgb(r, g, b, bright);
1343
+ //# Also drive warm white if the fixture has it
1344
+ sendWhites(Math.round(80 * flicker * (1 - heat)), Math.round(255 * flicker * heat), bright);
1345
+ }, durationSecs);
1346
+ }
1347
+
1348
+ /**
1349
+ * FLICKER — soft candlelight. Gentler and slower than fire.
1350
+ * Stays in the warm amber zone with subtle random dips.
1351
+ */
1352
+ function effectFlicker(durationSecs) {
1353
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1354
+ let phase = 0;
1355
+ startEffect("Flicker", 80, () => {
1356
+ //# Slow sine wave base with a small random offset for organic feel
1357
+ phase += 0.15 + Math.random() * 0.05;
1358
+ const sineBase = 0.75 + 0.15 * Math.sin(phase);
1359
+ const noise = (Math.random() - 0.5) * 0.12;
1360
+ const intensity = Math.min(1, Math.max(0.4, sineBase + noise));
1361
+ const r = Math.round(255 * intensity);
1362
+ const g = Math.round(100 * intensity * 0.7);
1363
+ const b = 0;
1364
+ sendRgb(r, g, b, bright);
1365
+ sendWhites(Math.round(40 * intensity), Math.round(220 * intensity), bright);
1366
+ }, durationSecs);
1367
+ }
1368
+
1369
+ /**
1370
+ * TWINKLE — random channels randomly spike to full brightness briefly then fade.
1371
+ * Simulates a starfield or twinkling fairy lights.
1372
+ * Works best on RGB or RGBW fixtures.
1373
+ */
1374
+ function effectTwinkle(durationSecs) {
1375
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1376
+ const CHANNELS = [CFG.ch.red, CFG.ch.green, CFG.ch.blue, CFG.ch.white];
1377
+ //# Each channel has an independent brightness level that decays each tick
1378
+ const levels = [0, 0, 0, 0];
1379
+ startEffect("Twinkle", 60, () => {
1380
+ //# Randomly trigger a sparkle on one channel
1381
+ if (Math.random() < 0.3) {
1382
+ const idx = Math.floor(Math.random() * CHANNELS.length);
1383
+ levels[idx] = 255;
1384
+ }
1385
+ //# Decay all channels
1386
+ for (let i = 0; i < levels.length; i++) {
1387
+ levels[i] = Math.max(0, levels[i] - 20 - Math.round(Math.random() * 20));
1388
+ sendDmxChannels([[CHANNELS[i], scaleToDmx(levels[i], bright)]]);
1389
+ }
1390
+ }, durationSecs);
1391
+ }
1392
+
1393
+ /**
1394
+ * COLOR CHASE — one colour at a time, cycling R → G → B → W.
1395
+ * Each colour holds for ~1 second then crossfades to the next.
1396
+ */
1397
+ function effectColorChase(durationSecs) {
1398
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1399
+ const COLORS = [
1400
+ [255, 0, 0, 0], //# Red
1401
+ [0, 255, 0, 0], //# Green
1402
+ [0, 0, 255, 0], //# Blue
1403
+ [0, 0, 0, 255], //# White
1404
+ ];
1405
+ let colorIdx = 0;
1406
+ let step = 0;
1407
+ const stepsPerColor = CFG.transitionTicksPerSec; //# ~1 second per colour
1408
+ startEffect("Color Chase", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1409
+ const from = COLORS[colorIdx % COLORS.length];
1410
+ const to = COLORS[(colorIdx + 1) % COLORS.length];
1411
+ const t = step / stepsPerColor;
1412
+ const cur = from.map((f, i) => Math.round(f + (to[i] - f) * t));
1413
+ sendDmxChannels([
1414
+ [CFG.ch.red, scaleToDmx(cur[0], bright)],
1415
+ [CFG.ch.green, scaleToDmx(cur[1], bright)],
1416
+ [CFG.ch.blue, scaleToDmx(cur[2], bright)],
1417
+ [CFG.ch.white, scaleToDmx(cur[3], bright)],
1418
+ ]);
1419
+ step++;
1420
+ if (step > stepsPerColor) { step = 0; colorIdx++; }
1421
+ }, durationSecs);
1422
+ }
1423
+
1424
+ /**
1425
+ * SCAN — brightness ramps up on one channel then another, like a moving spotlight.
1426
+ * Cycles R → G → B → W, each doing a full 0→255→0 sweep.
1427
+ */
1428
+ function effectScan(durationSecs) {
1429
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1430
+ const CHANNELS = [CFG.ch.red, CFG.ch.green, CFG.ch.blue, CFG.ch.white];
1431
+ let chIdx = 0;
1432
+ let phase = 0; //# 0 → 2π = one sweep
1433
+ const phaseStep = Math.PI / (CFG.transitionTicksPerSec * 1.5);
1434
+ startEffect("Scan", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1435
+ const val = Math.round(255 * Math.sin(phase));
1436
+ //# Zero all channels then set the active one
1437
+ CHANNELS.forEach(ch => sendDmxChannels([[ch, 0]]));
1438
+ sendDmxChannels([[CHANNELS[chIdx], scaleToDmx(val, bright)]]);
1439
+ phase += phaseStep;
1440
+ if (phase >= Math.PI) { phase = 0; chIdx = (chIdx + 1) % CHANNELS.length; }
1441
+ }, durationSecs);
1442
+ }
1443
+
1444
+ /**
1445
+ * RANDOM — each tick picks a random fully-saturated hue.
1446
+ * Chaotic, best at lower tick rates.
1447
+ */
1448
+ function effectRandom(durationSecs) {
1449
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1450
+ startEffect("Random", 200, () => {
1451
+ const [r, g, b] = hsvToRgb(Math.random() * 360, 1, 1);
1452
+ sendRgb(r, g, b, bright);
1453
+ }, durationSecs);
1454
+ }
1455
+
1456
+ /**
1457
+ * POLICE — alternating red/blue strobe at ~2 Hz per side.
1458
+ * Each side flashes twice before switching (like real emergency lights).
1459
+ */
1460
+ function effectPolice(durationSecs) {
1461
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1462
+ let phase = 0; //# 0–7: 0–3 = red side, 4–7 = blue side
1463
+ startEffect("Police", 120, () => {
1464
+ const isRed = phase < 4;
1465
+ const isFlashOn = phase % 2 === 0;
1466
+ const r = isRed && isFlashOn ? 255 : 0;
1467
+ const b = !isRed && isFlashOn ? 255 : 0;
1468
+ sendRgb(r, 0, b, bright);
1469
+ phase = (phase + 1) % 8;
1470
+ }, durationSecs);
1471
+ }
1472
+
1473
+ /**
1474
+ * CHRISTMAS — alternating slow red/green pulses with a brief white sparkle.
1475
+ */
1476
+ function effectChristmas(durationSecs) {
1477
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1478
+ let tick = 0;
1479
+ const PERIOD = CFG.transitionTicksPerSec * 2; //# 2 second full cycle
1480
+ startEffect("Christmas", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1481
+ const t = (tick % PERIOD) / PERIOD; //# 0→1 per cycle
1482
+ //# First half: fade red in/out; second half: fade green in/out
1483
+ const r = t < 0.5 ? Math.round(255 * Math.sin(t * Math.PI * 2)) : 0;
1484
+ const g = t >= 0.5 ? Math.round(255 * Math.sin((t - 0.5) * Math.PI * 2)) : 0;
1485
+ //# Brief white sparkle near the crossover points
1486
+ const w = (t > 0.45 && t < 0.55) ? Math.round(180 * Math.sin((t - 0.45) * Math.PI / 0.1)) : 0;
1487
+ sendDmxChannels([
1488
+ [CFG.ch.red, scaleToDmx(r, bright)],
1489
+ [CFG.ch.green, scaleToDmx(g, bright)],
1490
+ [CFG.ch.blue, 0],
1491
+ [CFG.ch.white, scaleToDmx(w, bright)],
1492
+ ]);
1493
+ tick++;
1494
+ }, durationSecs);
1495
+ }
1496
+
1497
+ /**
1498
+ * HALLOWEEN — slow alternating orange/purple pulses.
1499
+ */
1500
+ function effectHalloween(durationSecs) {
1501
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1502
+ let tick = 0;
1503
+ const PERIOD = CFG.transitionTicksPerSec * 3;
1504
+ startEffect("Halloween", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1505
+ const t = (tick % PERIOD) / PERIOD;
1506
+ //# Orange (255, 80, 0) ↔ Purple (128, 0, 255)
1507
+ const blend = 0.5 + 0.5 * Math.sin(t * Math.PI * 2);
1508
+ const r = Math.round(255 * blend + 128 * (1 - blend));
1509
+ const g = Math.round(80 * blend);
1510
+ const b = Math.round(255 * (1 - blend));
1511
+ sendRgb(r, g, b, bright);
1512
+ tick++;
1513
+ }, durationSecs);
1514
+ }
1515
+
1516
+ /**
1517
+ * CALAVERAS (Day of the Dead) — cycling magenta → cyan → yellow pulses.
1518
+ */
1519
+ function effectCalaveras(durationSecs) {
1520
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1521
+ const PALETTE = [
1522
+ [255, 0, 255], //# Magenta
1523
+ [0, 255, 255], //# Cyan
1524
+ [255, 255, 0], //# Yellow
1525
+ [255, 100, 200], //# Hot pink
1526
+ ];
1527
+ let idx = 0;
1528
+ let step = 0;
1529
+ const stepsPerColor = CFG.transitionTicksPerSec * 2;
1530
+ startEffect("Calaveras", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1531
+ const from = PALETTE[idx % PALETTE.length];
1532
+ const to = PALETTE[(idx + 1) % PALETTE.length];
1533
+ const t = step / stepsPerColor;
1534
+ const [r, g, b] = from.map((f, i) => Math.round(f + (to[i] - f) * t));
1535
+ sendRgb(r, g, b, bright);
1536
+ step++;
1537
+ if (step > stepsPerColor) { step = 0; idx++; }
1538
+ }, durationSecs);
1539
+ }
1540
+
1541
+ /**
1542
+ * PARTY — rapid random hue pops, high energy.
1543
+ */
1544
+ function effectParty(durationSecs) {
1545
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1546
+ startEffect("Party", 80, () => {
1547
+ const [r, g, b] = hsvToRgb(Math.random() * 360, 1, 1);
1548
+ sendRgb(r, g, b, bright);
1549
+ }, durationSecs);
1550
+ }
1551
+
1552
+ /**
1553
+ * RELAXING — very slow warm white breathing. A single breath cycle ~8 seconds.
1554
+ */
1555
+ function effectRelaxing(durationSecs) {
1556
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1557
+ let phase = 0;
1558
+ const phaseStep = (2 * Math.PI) / (CFG.transitionTicksPerSec * 8); //# 8s per breath
1559
+ startEffect("Relaxing", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1560
+ //# Sine wave from 20% to 100% brightness — never fully off
1561
+ const intensity = 0.2 + 0.8 * (0.5 + 0.5 * Math.sin(phase));
1562
+ const warm = Math.round(255 * intensity);
1563
+ const cold = Math.round(80 * intensity);
1564
+ sendWhites(cold, warm, bright);
1565
+ //# Soft warm RGB glow alongside
1566
+ sendRgb(Math.round(220 * intensity), Math.round(120 * intensity), Math.round(40 * intensity), bright);
1567
+ phase += phaseStep;
1568
+ }, durationSecs);
1569
+ }
1570
+
1571
+ /**
1572
+ * TEMPERATURE TRANSITION — slow drift from cool white to warm white and back.
1573
+ * Full cycle ~60 seconds by default. Great for morning/evening ambience.
1574
+ */
1575
+ function effectTemperatureTransition(durationSecs) {
1576
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1577
+ let phase = 0;
1578
+ const phaseStep = (2 * Math.PI) / (CFG.transitionTicksPerSec * 60);
1579
+ startEffect("Temp Transition", Math.round(1000 / CFG.transitionTicksPerSec), () => {
1580
+ //# 0 = full cool, 1 = full warm; sine wave between them
1581
+ const warmRatio = 0.5 + 0.5 * Math.sin(phase);
1582
+ const cold = Math.round(255 * (1 - warmRatio));
1583
+ const warm = Math.round(255 * warmRatio);
1584
+ sendWhites(cold, warm, bright);
1585
+ phase += phaseStep;
1586
+ }, durationSecs);
1587
+ }
1588
+
1589
+ /**
1590
+ * SLEEP TRANSITION — slowly dims from current brightness to zero over durationSecs.
1591
+ * Defaults to 30 minutes if no duration given. Does not loop — ends once dark.
1592
+ */
1593
+ function effectSleepTransition(durationSecs = 1800) {
1594
+ const startBright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1595
+ const w = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
1596
+ const ww = recall("<<warmWhite>>", "<<D_warmWhite>>", CFG.defaultOn.warmWhite);
1597
+ const r = recall("<<red>>", "<<D_red>>", CFG.defaultOn.red);
1598
+ const g = recall("<<green>>", "<<D_green>>", CFG.defaultOn.green);
1599
+ const b = recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue);
1600
+ const totalTicks = durationSecs * CFG.transitionTicksPerSec;
1601
+ let tick = 0;
1602
+ //# Force a fixed duration — sleep transition should always self-terminate
1603
+ stopEffect();
1604
+ sendStatus("blue", "dot", `Sleep transition — ${Math.round(durationSecs / 60)} min`);
1605
+ const timer = setInterval(() => {
1606
+ tick++;
1607
+ const progress = Math.min(1, tick / totalTicks);
1608
+ //# Ease out: square root gives a gentle initial fade then holds longer at low end
1609
+ const bright = Math.round(startBright * (1 - Math.sqrt(progress)));
1610
+ sendDmxChannels([
1611
+ [CFG.ch.white, scaleToDmx(w, bright)],
1612
+ [CFG.ch.warmWhite, scaleToDmx(ww, bright)],
1613
+ [CFG.ch.red, scaleToDmx(r, bright)],
1614
+ [CFG.ch.green, scaleToDmx(g, bright)],
1615
+ [CFG.ch.blue, scaleToDmx(b, bright)],
1616
+ ]);
1617
+ if (tick >= totalTicks) {
1618
+ clearInterval(timer);
1619
+ context.set('timer_effect', null);
1620
+ rememberRam({ "<<FIXTURE_RAM_Memory_State>>": "OFF" });
1621
+ startDiskSaveTimer(() => { rememberDisk({ "<<FIXTURE_Disk_Memory_State>>": "OFF" }); });
1622
+ sendStatus("yellow", "ring", "Sleep transition complete — lights off");
1623
+ }
1624
+ }, Math.round(1000 / CFG.transitionTicksPerSec));
1625
+ context.set('timer_effect', timer);
1626
+ }
1627
+
1628
+ /**
1629
+ * FIREWORKS — random bright colour bursts that decay quickly, on a dark background.
1630
+ * Multiple simultaneous "shells" each with an independent decay.
1631
+ */
1632
+ function effectFireworks(durationSecs) {
1633
+ const bright = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1634
+ const SHELLS = 3;
1635
+ //# Each shell: { h: hue, decay: 0–1 }
1636
+ const shells = Array.from({ length: SHELLS }, () => ({ h: Math.random() * 360, level: 0 }));
1637
+ startEffect("Fireworks", 40, () => {
1638
+ //# Randomly launch a new shell
1639
+ if (Math.random() < 0.05) {
1640
+ const s = shells[Math.floor(Math.random() * SHELLS)];
1641
+ s.h = Math.random() * 360;
1642
+ s.level = 1;
1643
+ }
1644
+ //# Mix all shells and decay
1645
+ let rSum = 0, gSum = 0, bSum = 0;
1646
+ shells.forEach(s => {
1647
+ if (s.level > 0) {
1648
+ const [r, g, b] = hsvToRgb(s.h, 1, s.level);
1649
+ rSum += r; gSum += g; bSum += b;
1650
+ s.level = Math.max(0, s.level - 0.04 - Math.random() * 0.03);
1651
+ }
1652
+ });
1653
+ sendRgb(Math.min(255, rSum), Math.min(255, gSum), Math.min(255, bSum), bright);
1654
+ }, durationSecs);
1655
+ }
1656
+
1657
+ // ─────────────────────────────────────────────────────────────────────────────
1658
+ //@ EFFECT DISPATCH
1659
+ // ─────────────────────────────────────────────────────────────────────────────
1660
+ //# Maps the HA effect name string (from payload.effect / CFG.effectsList)
1661
+ //# to the implementation function. Add new effects here and to CFG.effectsList.
1662
+
1663
+ const EFFECT_MAP = {
1664
+ //# "none" — stop any running effect and restore last known state gracefully
1665
+ "none": () => { stopEffect(); restoreAfterEffect(); },
1666
+ "flash_short": () => effectFlash(CFG.flashShort),
1667
+ "flash_long": () => effectFlash(CFG.flashLong),
1668
+ "strobe": () => effectStrobe(0),
1669
+ "rainbow": () => effectRainbow(0),
1670
+ "rainbow_rgbw": () => effectRainbowRgbw(0),
1671
+ "fire": () => effectFire(0),
1672
+ "flicker": () => effectFlicker(0),
1673
+ "twinkle": () => effectTwinkle(0),
1674
+ "color_chase": () => effectColorChase(0),
1675
+ "scan": () => effectScan(0),
1676
+ "random": () => effectRandom(0),
1677
+ "police": () => effectPolice(0),
1678
+ "christmas": () => effectChristmas(0),
1679
+ "halloween": () => effectHalloween(0),
1680
+ "calaveras": () => effectCalaveras(0),
1681
+ "party": () => effectParty(0),
1682
+ "relaxing": () => effectRelaxing(0),
1683
+ "temperature_transition": () => effectTemperatureTransition(0),
1684
+ "sleep_transition": () => effectSleepTransition(1800),
1685
+ "fireworks": () => effectFireworks(0),
1686
+ };
1687
+
1688
+ /**
1689
+ * Run an effect by name.
1690
+ * @param {string} effectName - must match a key in EFFECT_MAP / CFG.effectsList
1691
+ */
1692
+ function runEffect(effectName) {
1693
+ const fn = EFFECT_MAP[effectName];
1694
+ if (fn) {
1695
+ console.log(`Running effect: "${effectName}"`);
1696
+ fn();
1697
+ } else {
1698
+ node.warn(`Unknown effect: "${effectName}"`);
1699
+ }
1700
+ }
1701
+
1702
+ // ─────────────────────────────────────────────────────────────────────────────
1703
+ //@ DISPATCH
1704
+ // ─────────────────────────────────────────────────────────────────────────────
1705
+
1706
+ /**
1707
+ * Look up and invoke the right handler for a given state + color mode.
1708
+ * @param {Object} handlerMap - COLOR_MODE_HANDLERS or TRANSITION_HANDLERS
1709
+ * @param {string} state - "ON" or "OFF"
1710
+ * @param {Object} payload - msg.payload
1711
+ */
1712
+ function dispatch(handlerMap, state, payload) {
1713
+ const stateHandlers = handlerMap[state];
1714
+ if (!stateHandlers) {
1715
+ node.warn(`dispatch: unknown state "${state}" — must be ON or OFF`);
1716
+ return;
1717
+ }
1718
+ const handler = stateHandlers[CFG.colorModes];
1719
+ if (handler === undefined) {
1720
+ node.warn(`dispatch: unknown color mode "${CFG.colorModes}" for state "${state}"`);
1721
+ return;
1722
+ }
1723
+ handler(payload);
1724
+ }
1725
+
1726
+ // ─────────────────────────────────────────────────────────────────────────────
1727
+ //@ DEVICE REQUEST HANDLERS
1728
+ // ─────────────────────────────────────────────────────────────────────────────
1729
+
1730
+ /**
1731
+ * Write default/recovered values into RAM on startup so recall() always finds something.
1732
+ * Called immediately on device add before any HA command arrives.
1733
+ */
1734
+ function initDefaultMemory() {
1735
+ const existingState = flow.get("<<FIXTURE_RAM_Memory_State>>", "memory")
1736
+ || flow.get("<<FIXTURE_Disk_Memory_State>>", "disk_values");
1737
+ if (existingState) {
1738
+ console.log("initDefaultMemory: existing state found in memory, skipping defaults.");
1739
+ return;
1740
+ }
1741
+ rememberRam({
1742
+ "<<FIXTURE_RAM_Memory_State>>": CFG.defaultState,
1743
+ "<<bright>>": CFG.defaultOn.brightness,
1744
+ "<<red>>": CFG.defaultOn.red,
1745
+ "<<green>>": CFG.defaultOn.green,
1746
+ "<<blue>>": CFG.defaultOn.blue,
1747
+ "<<white>>": CFG.defaultOn.white,
1748
+ "<<warmWhite>>": CFG.defaultOn.warmWhite,
1749
+ "<<colorTemp>>": CFG.defaultOn.colorTemp,
1750
+ });
1751
+ console.log("initDefaultMemory: defaults written to RAM.");
1752
+ }
1753
+
1754
+ /**
1755
+ * Clear all RAM and disk memory keys for this fixture.
1756
+ * Called on device remove.
1757
+ */
1758
+ function clearAllMemory() {
1759
+ const ramKeys = ["<<FIXTURE_RAM_Memory_State>>", "<<bright>>", "<<red>>", "<<green>>", "<<blue>>", "<<white>>", "<<warmWhite>>", "<<colorTemp>>"];
1760
+ const diskKeys = ["<<FIXTURE_Disk_Memory_State>>", "<<D_bright>>", "<<D_red>>", "<<D_green>>", "<<D_blue>>", "<<D_white>>", "<<D_warmWhite>>", "<<D_colorTemp>>"];
1761
+ ramKeys.forEach(k => flow.set(k, null, "memory"));
1762
+ diskKeys.forEach(k => flow.set(k, null, "disk_values"));
1763
+ console.log("clearAllMemory: all fixture memory cleared.");
1764
+ }
1765
+
1766
+ /**
1767
+ * Build the HA state recovery payload for the current color mode.
1768
+ * Sends the last known state back to HA after a reboot/rediscovery
1769
+ * so the dashboard never shows "unknown".
1770
+ */
1771
+ function buildRecoveryStatePayload() {
1772
+ const state = recall("<<FIXTURE_RAM_Memory_State>>", "<<FIXTURE_Disk_Memory_State>>", CFG.defaultState);
1773
+ const brightness = recall("<<bright>>", "<<D_bright>>", CFG.defaultOn.brightness);
1774
+ const red = recall("<<red>>", "<<D_red>>", CFG.defaultOn.red);
1775
+ const green = recall("<<green>>", "<<D_green>>", CFG.defaultOn.green);
1776
+ const blue = recall("<<blue>>", "<<D_blue>>", CFG.defaultOn.blue);
1777
+ const white = recall("<<white>>", "<<D_white>>", CFG.defaultOn.white);
1778
+ const warmWhite = recall("<<warmWhite>>", "<<D_warmWhite>>", CFG.defaultOn.warmWhite);
1779
+ const colorTemp = recall("<<colorTemp>>", "<<D_colorTemp>>", CFG.defaultOn.colorTemp);
1780
+
1781
+ console.log(`Recovery — state:${state} bright:${brightness} R:${red} G:${green} B:${blue} W:${white} WW:${warmWhite} CT:${colorTemp}`);
1782
+
1783
+ const rgb = { r: red, g: green, b: blue };
1784
+ const rgbw = { ...rgb, w: white };
1785
+ const rgbww = { ...rgbw, ww: warmWhite };
1786
+
1787
+ const map = {
1788
+ "onoff": { state, brightness, color: rgb, color_mode: "onoff" },
1789
+ "brightness": { state, brightness, color: rgb, color_mode: "brightness" },
1790
+ "rgb": { state, brightness, color: rgb, color_mode: "rgb" },
1791
+ "rgbw": { state, brightness, color: rgbw, color_mode: "rgbw" },
1792
+ "rgbww": { state, brightness, color: rgbww, color_mode: "rgbww" },
1793
+ "color_temp": { state, brightness, color_temp: colorTemp, color_mode: "color_temp" },
1794
+ "white": { state, brightness, color_mode: "white" },
1795
+ "rgb,color_temp": { state, brightness, color: rgb, color_mode: "rgb" },
1796
+ "rgbw,color_temp": { state, brightness, color: rgbw, color_mode: "rgbw" },
1797
+ "rgbww,color_temp": { state, brightness, color: rgbww, color_mode: "rgbww" },
1798
+ };
1799
+
1800
+ return map[CFG.colorModes] ?? null;
1801
+ }
1802
+
1803
+ function handleDeviceAdd() {
1804
+ initDefaultMemory();
1805
+
1806
+ const colorModesArray = CFG.colorModes.split(',');
1807
+ const cmdTopic = `${fixtureTopic}/${CFG.commandTopic}`;
1808
+ const statTopic = `${fixtureTopic}/${CFG.stateTopic}`;
1809
+ const cfgTopic = `${fixtureTopic}/${CFG.configTopic}`;
1810
+ const { device: dev } = CFG;
1811
+
1812
+ const discoveryPayload = {
1813
+ retain: CFG.retain,
1814
+ qos: CFG.qos,
1815
+ topic: cfgTopic,
1816
+ payload: {
1817
+ unique_id: `${dev.type}(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix})`,
1818
+ schema: CFG.schema,
1819
+ //^ object_id locks the HA entity_id to the plan ID: light.l_991 or light.l_176_a
1820
+ //^ Survives user renaming the friendly name in the HA frontend.
1821
+ //^ WARNING: changing this on a deployed node changes the entity_id in HA —
1822
+ //^ update any automations referencing the old entity_id after re-discovery.
1823
+ object_id: `${CFG.uidPrefix}_${CFG.uid}${CFG.uidPostfix}`.toLowerCase().replace(/[^a-z0-9]/g, '_').replace(/__+/g, '_'),
1824
+ name: `${dev.type} ${dev.situation} the ${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
1825
+ cmd_t: cmdTopic,
1826
+ stat_t: statTopic,
1827
+ optimistic: CFG.optimistic,
1828
+ enabled_by_default: CFG.enabledByDefault,
1829
+ icon: CFG.icon,
1830
+ supported_color_modes: colorModesArray,
1831
+ brightness: CFG.brightness,
1832
+ brightness_scale: CFG.dmxLimiter,
1833
+ transition: CFG.transitionsEnabled,
1834
+ effect: CFG.effects,
1835
+ effect_list: CFG.effectsList,
1836
+ flash_time_short: CFG.flashShort,
1837
+ flash_time_long: CFG.flashLong,
1838
+ min_mireds: CFG.minMireds,
1839
+ max_mireds: CFG.maxMireds,
1840
+ device: {
1841
+ identifiers: `${CFG.component}-${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`,
1842
+ name: `(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}) - ${dev.type}/s ${dev.situation} the ${CFG.dmxZone} - ${dev.area} - ${dev.subLocation}`,
1843
+ model: `${colorModesArray} ${dev.type}/s located ${dev.situation} the ${CFG.dmxZone}-${dev.area} - ${dev.subLocation}`,
1844
+ model_id: `referenced on plan as: (${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}`,
1845
+ suggested_area: `${CFG.dmxZone} ${dev.area} ${dev.subLocation}`,
1846
+ hw_version: `DMX Controller in ${CFG.dmxZone} Server Rack, firmware: ${CFG.dmxControllerFw}. MQTT: ${dmxTopic}/#`,
1847
+ serial_number: `(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix}) See ⓘ MQTT Info below.`,
1848
+ sw_version: `${CFG.nodeCode}: ${CFG.nodeSw}`,
1849
+ manufacturer: "DeSwaggy — Discord: @deswaggy",
1850
+ },
1851
+ },
1852
+ };
1853
+
1854
+ node.send([
1855
+ discoveryPayload,
1856
+ { retain: CFG.retain, qos: CFG.qos, topic: cmdTopic, action: "subscribe" },
1857
+ { status: { fill: "green", shape: "ring", text: `${fixtureId} discovery sent — awaiting HA` } },
1858
+ null, null, null,
1859
+ ]);
1860
+ console.log(`Device added: ${dev.type}(${CFG.uidPrefix}-${CFG.uid}${CFG.uidPostfix})`);
1861
+
1862
+ //# Wait briefly before pushing recovery state — gives HA time to register
1863
+ //# the discovery before we report the last-known state.
1864
+ //# Simple setTimeout is correct here — not writing to disk, just reporting
1865
+ //# state to HA. The disk save timer is reserved for command handling only.
1866
+ setTimeout(() => {
1867
+ const recoveryPayload = buildRecoveryStatePayload();
1868
+ if (recoveryPayload) {
1869
+ sendState(recoveryPayload);
1870
+ sendStatus("yellow", "ring", `${fixtureId} ready — awaiting HA commands`);
1871
+ console.log(`Recovery state sent for mode: ${CFG.colorModes}`);
1872
+ } else {
1873
+ sendStatus("yellow", "ring", `${fixtureId} ready — awaiting HA commands`);
1874
+ }
1875
+ }, 2000);
1876
+ }
1877
+
1878
+ function handleDeviceRemove() {
1879
+ //# Cancel any running timers before removing
1880
+ [context.get('timer_transition'), context.get('timer_effect'), context.get('timer_diskSave')].forEach(t => {
1881
+ if (t) clearInterval(t);
1882
+ });
1883
+ context.set('timer_transition', null);
1884
+ context.set('timer_effect', null);
1885
+ context.set('timer_diskSave', null);
1886
+
1887
+ clearAllMemory();
1888
+
1889
+ const cfgTopic = `${fixtureTopic}/${CFG.configTopic}`;
1890
+ const cmdTopic = `${fixtureTopic}/${CFG.commandTopic}`;
1891
+
1892
+ node.send([
1893
+ { retain: CFG.retain, qos: CFG.qos, topic: cfgTopic, payload: "" },
1894
+ { retain: CFG.retain, qos: CFG.qos, topic: cmdTopic, action: "unsubscribe" },
1895
+ { status: { fill: "red", shape: "ring", text: `${fixtureId} discovery removed — memory cleared` } },
1896
+ null, null, null,
1897
+ ]);
1898
+ console.log(`Device removed. Unsubscribed: ${cmdTopic}`);
1899
+ }
1900
+
1901
+ // ─────────────────────────────────────────────────────────────────────────────
1902
+ //!? ENTRY POINT
1903
+ // ─────────────────────────────────────────────────────────────────────────────
1904
+
1905
+ //# Pass the original message through on output 4 — suppressed for AUX messages
1906
+ //# since the parent Group Node already passed it through, avoiding duplicates.
1907
+ const isAuxMessage = msg.dmx_trace != null;
1908
+ if (!isAuxMessage) {
1909
+ node.send([null, null, null, msg, null, null, null]);
1910
+ }
1911
+
1912
+ const prevState = recall("<<FIXTURE_RAM_Memory_State>>", "<<FIXTURE_Disk_Memory_State>>", CFG.defaultState);
1913
+ console.log(`prev state: ${prevState} colorMode: ${CFG.colorModes} isAux: ${isAuxMessage}`);
1914
+
1915
+ //? ── AUX — payload from parent Group Node ───────────────────────────────────
1916
+ if (isAuxMessage) {
1917
+ const trace = msg.dmx_trace;
1918
+ console.log(
1919
+ `AUX received — source:${trace.source} ` +
1920
+ `depth:${trace.depth} path:${trace.path.join(' → ')}`
1921
+ );
1922
+
1923
+ const { state, transition, effect } = msg.payload;
1924
+
1925
+ if (effect && CFG.effects) {
1926
+ if (CFG.effectsGroupControlled) {
1927
+ //# Group-controlled mode — accept effect from parent Group Node (synced with siblings)
1928
+ console.log(`AUX group-controlled effect: "${effect}"`);
1929
+ runEffect(effect);
1930
+ } else {
1931
+ //# Independent mode — this fixture ignores group effect commands
1932
+ console.log(`AUX effect "${effect}" ignored — fixture controls its own effects (<DMX_Effects_Group_Controlled>=false)`);
1933
+ }
1934
+ } else {
1935
+ //# Non-effect AUX command — stop any running effect first
1936
+ stopEffect();
1937
+ //# Route through normal dispatch — identical to a direct HA command
1938
+ const wantTransition = transition && CFG.transitionsEnabled;
1939
+ const handlerMap = wantTransition ? TRANSITION_HANDLERS : COLOR_MODE_HANDLERS;
1940
+ console.log(`AUX ${wantTransition ? "TRANSITION" : "NO TRANSITION"} — state:${state} colorMode:${CFG.colorModes}`);
1941
+ dispatch(handlerMap, state, msg.payload);
1942
+ }
1943
+
1944
+ //? ── DIRECT HA COMMAND ────────────────────────────────────────────────────────
1945
+ } else if (msg.payload?.state != null) {
1946
+ const { state, transition, effect } = msg.payload;
1947
+
1948
+ if (effect && CFG.effects) {
1949
+ if (CFG.effectsGroupControlled) {
1950
+ //# Group-controlled mode — ignore direct HA effect commands, only AUX accepted
1951
+ node.warn(
1952
+ `DMX Node — effect "${effect}" ignored. ` +
1953
+ `<DMX_Effects_Group_Controlled>=true means effects must come via a Group Node ` +
1954
+ `to keep siblings in sync. Set to false to allow direct effect control.`
1955
+ );
1956
+ } else {
1957
+ //# Independent mode — accept direct HA effect commands
1958
+ console.log(`Direct HA effect: "${effect}"`);
1959
+ runEffect(effect);
1960
+ }
1961
+ } else {
1962
+ //# Non-effect command — stop any running effect first so it doesn't fight the new state
1963
+ stopEffect();
1964
+ //# If transitions are disabled for this fixture always use the instant handler
1965
+ const wantTransition = transition && CFG.transitionsEnabled;
1966
+ const handlerMap = wantTransition ? TRANSITION_HANDLERS : COLOR_MODE_HANDLERS;
1967
+ console.log(`${wantTransition ? "TRANSITION" : "NO TRANSITION"} — state:${state} colorMode:${CFG.colorModes} transitionsEnabled:${CFG.transitionsEnabled}`);
1968
+ dispatch(handlerMap, state, msg.payload);
1969
+ }
1970
+
1971
+ //? ── DEVICE REQUEST — add / remove / debug ───────────────────────────────────
1972
+ } else if (msg.device != null && (msg.device?.request != null || typeof msg.device === "string")) {
1973
+ const _devReq = typeof msg.device === "string" ? msg.device : msg.device.request;
1974
+ console.log(`device.request: ${_devReq}`);
1975
+ switch (_devReq) {
1976
+ case "add": handleDeviceAdd(); break;
1977
+ case "remove": handleDeviceRemove(); break;
1978
+ case "debug": console.log("DEBUG:", JSON.stringify(msg)); break;
1979
+ default: node.warn(`Unknown device.request: ${msg.device.request}`);
1980
+ }
1981
+
1982
+ //? ── UNKNOWN MESSAGE ──────────────────────────────────────────────────────────
1983
+ } else {
1984
+ node.warn(
1985
+ "DMX Node — unrecognised message received. " +
1986
+ "Expected: msg.dmx_trace (AUX from Group Node), " +
1987
+ "msg.payload.state (direct HA command), " +
1988
+ "or msg.device.request (add/remove/debug). " +
1989
+ "Message received and dropped — unrecognised format. See node documentation."
1990
+ );
1991
+ }
1992
+
1993
+ node.done();
1994
+ return [null, null, null, msg, null, null];