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