node-red-contrib-dmx-for-ha 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +282 -0
- package/docs/config_node_spec.md +236 -0
- package/docs/dmx_node_env_reference.md +341 -0
- package/docs/master_todo.md +428 -0
- package/docs/node_contracts.md +278 -0
- package/docs/nr_subflow_gotchas.md +258 -0
- package/nodes/ha-mqtt-button.html +326 -0
- package/nodes/ha-mqtt-button.js +158 -0
- package/nodes/ha-mqtt-config.html +233 -0
- package/nodes/ha-mqtt-config.js +81 -0
- package/nodes/ha-mqtt-dmx-group.html +392 -0
- package/nodes/ha-mqtt-dmx-group.js +265 -0
- package/nodes/ha-mqtt-dmx.html +547 -0
- package/nodes/ha-mqtt-dmx.js +537 -0
- package/nodes/ha-mqtt-pir.html +343 -0
- package/nodes/ha-mqtt-pir.js +183 -0
- package/nodes/ha-mqtt-relay.html +326 -0
- package/nodes/ha-mqtt-relay.js +289 -0
- package/package.json +39 -0
- package/subflow/README.md +35 -0
- package/subflow/button_node_v5.0.3.js +324 -0
- package/subflow/dmx_group_node_v0.3.8.js +860 -0
- package/subflow/dmx_node_v0.5.9.js +1994 -0
- package/subflow/pir_node_v1.0.3.js +365 -0
- package/subflow/relay_node_v4.0.2.js +553 -0
- package/subflow/subflow_definitions.json +6154 -0
|
@@ -0,0 +1,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];
|