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,343 @@
|
|
|
1
|
+
<!-- ============================================================
|
|
2
|
+
ha-mqtt-pir — PIR Node Editor
|
|
3
|
+
Package: node-red-contrib-dmx-for-ha
|
|
4
|
+
Author: DeSwaggy — Discord: @deswaggy
|
|
5
|
+
============================================================ -->
|
|
6
|
+
|
|
7
|
+
<script type="text/javascript">
|
|
8
|
+
|
|
9
|
+
RED.nodes.registerType('ha-mqtt-pir', {
|
|
10
|
+
category: 'DMX for HA',
|
|
11
|
+
color: '#2980b9',
|
|
12
|
+
icon: 'font-awesome/fa-podcast',
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 0,
|
|
15
|
+
inputLabels: ['Input'],
|
|
16
|
+
paletteLabel: 'PIR',
|
|
17
|
+
|
|
18
|
+
defaults: {
|
|
19
|
+
name: { value: '' },
|
|
20
|
+
config: { value: '', type: 'ha-mqtt-config', required: true },
|
|
21
|
+
// Identity
|
|
22
|
+
uid: { value: '', required: true },
|
|
23
|
+
uidPostfix: { value: '' },
|
|
24
|
+
// Location
|
|
25
|
+
area: { value: 'Area 52' },
|
|
26
|
+
situation: { value: 'in' },
|
|
27
|
+
subLocation: { value: '' },
|
|
28
|
+
// PIR specifics
|
|
29
|
+
pirType: { value: 'Ceiling' },
|
|
30
|
+
pirPayload: { value: '', required: true },
|
|
31
|
+
subscribeTopic: { value: '' },
|
|
32
|
+
// Options
|
|
33
|
+
haIcon: { value: 'mdi:motion-sensor' },
|
|
34
|
+
cableColor: { value: 'Purple' },
|
|
35
|
+
holdTime: { value: '15' },
|
|
36
|
+
warmupTime: { value: '120' },
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
label: function () {
|
|
40
|
+
if (this.name) return this.name;
|
|
41
|
+
const id = this.uid || '?';
|
|
42
|
+
const postfix = this.uidPostfix || '';
|
|
43
|
+
return 'S-' + id + postfix;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
labelStyle: function () {
|
|
47
|
+
return this.name ? 'node_label_italic' : '';
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
oneditprepare: function () {
|
|
51
|
+
const node = this;
|
|
52
|
+
const areas = [
|
|
53
|
+
{ v: 'Area 52', l: 'TBC' },
|
|
54
|
+
{ v: 'Attic Roof', l: 'Attic Roof' },
|
|
55
|
+
{ v: 'Backyard', l: 'Backyard' },
|
|
56
|
+
{ v: 'Balcony', l: 'Balcony' },
|
|
57
|
+
{ v: 'Bathroom', l: 'Bathroom' },
|
|
58
|
+
{ v: 'Bedroom 1', l: 'Bedroom 1' },
|
|
59
|
+
{ v: 'Bedroom 2', l: 'Bedroom 2' },
|
|
60
|
+
{ v: 'Bedroom 3', l: 'Bedroom 3' },
|
|
61
|
+
{ v: 'Bedroom 4', l: 'Bedroom 4' },
|
|
62
|
+
{ v: 'Carport', l: 'Carport' },
|
|
63
|
+
{ v: 'Cellar', l: 'Cellar' },
|
|
64
|
+
{ v: 'Dining', l: 'Dining' },
|
|
65
|
+
{ v: 'Driveway', l: 'Driveway' },
|
|
66
|
+
{ v: 'Entry', l: 'Entry' },
|
|
67
|
+
{ v: 'Garage', l: 'Garage' },
|
|
68
|
+
{ v: 'Gym', l: 'Gym' },
|
|
69
|
+
{ v: 'Hallway', l: 'Hallway' },
|
|
70
|
+
{ v: 'Kitchen', l: 'Kitchen' },
|
|
71
|
+
{ v: 'Laundry', l: 'Laundry' },
|
|
72
|
+
{ v: 'Link Bridge (Upper)', l: 'Link Bridge Upper' },
|
|
73
|
+
{ v: 'Link Bridge (Lower)', l: 'Link Bridge Lower' },
|
|
74
|
+
{ v: 'Living', l: 'Living' },
|
|
75
|
+
{ v: 'Media', l: 'Media' },
|
|
76
|
+
{ v: 'Office', l: 'Office' },
|
|
77
|
+
{ v: 'Pantry', l: 'Pantry' },
|
|
78
|
+
{ v: 'Pass Over', l: 'Pass Over' },
|
|
79
|
+
{ v: 'Pass Under', l: 'Pass Under' },
|
|
80
|
+
{ v: 'Passageway', l: 'Passageway' },
|
|
81
|
+
{ v: 'Pool', l: 'Pool' },
|
|
82
|
+
{ v: 'Portico', l: 'Portico' },
|
|
83
|
+
{ v: 'PowderRoom', l: 'PowderRoom' },
|
|
84
|
+
{ v: 'Reading', l: 'Reading' },
|
|
85
|
+
{ v: 'Rumpus', l: 'Rumpus' },
|
|
86
|
+
{ v: 'Spa', l: 'Spa' },
|
|
87
|
+
{ v: 'Stairs', l: 'Stairs' },
|
|
88
|
+
{ v: 'Store', l: 'Store' },
|
|
89
|
+
{ v: 'SubFloor', l: 'SubFloor' },
|
|
90
|
+
{ v: 'Terrace', l: 'Terrace' },
|
|
91
|
+
];
|
|
92
|
+
const subAreas = [
|
|
93
|
+
{ v: '', l: 'None' },
|
|
94
|
+
{ v: 'Balcony', l: 'Balcony' },
|
|
95
|
+
{ v: 'Bed (North)', l: 'Bed North' },
|
|
96
|
+
{ v: 'Bed (South)', l: 'Bed South' },
|
|
97
|
+
{ v: 'Bed (East)', l: 'Bed East' },
|
|
98
|
+
{ v: 'Bed (West)', l: 'Bed West' },
|
|
99
|
+
{ v: 'Ceiling', l: 'Ceiling' },
|
|
100
|
+
{ v: 'Dress', l: 'Dress' },
|
|
101
|
+
{ v: 'Ensuite', l: 'Ensuite' },
|
|
102
|
+
{ v: 'Exterior', l: 'Exterior' },
|
|
103
|
+
{ v: 'Ground Floor', l: 'Ground Floor' },
|
|
104
|
+
{ v: '1st Floor', l: '1st Floor' },
|
|
105
|
+
{ v: 'Landing', l: 'Landing' },
|
|
106
|
+
{ v: 'Office', l: 'Office' },
|
|
107
|
+
{ v: 'Stairs', l: 'Stairs' },
|
|
108
|
+
{ v: 'WIR', l: 'WIR' },
|
|
109
|
+
{ v: '(North)', l: 'North' },
|
|
110
|
+
{ v: '(South)', l: 'South' },
|
|
111
|
+
{ v: '(East)', l: 'East' },
|
|
112
|
+
{ v: '(West)', l: 'West' },
|
|
113
|
+
{ v: 'Void', l: 'Void' },
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
const areaEl = $('#node-input-area');
|
|
117
|
+
areaEl.empty();
|
|
118
|
+
areas.forEach(function (a) {
|
|
119
|
+
areaEl.append($('<option>').val(a.v).text(a.l)
|
|
120
|
+
.prop('selected', a.v === node.area));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const subEl = $('#node-input-subLocation');
|
|
124
|
+
subEl.empty();
|
|
125
|
+
subAreas.forEach(function (s) {
|
|
126
|
+
subEl.append($('<option>').val(s.v).text(s.l)
|
|
127
|
+
.prop('selected', s.v === node.subLocation));
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
</script>
|
|
133
|
+
|
|
134
|
+
<script type="text/html" data-template-name="ha-mqtt-pir">
|
|
135
|
+
|
|
136
|
+
<div class="form-row">
|
|
137
|
+
<label for="node-input-name">
|
|
138
|
+
<i class="fa fa-tag"></i> Name
|
|
139
|
+
</label>
|
|
140
|
+
<input type="text" id="node-input-name"
|
|
141
|
+
placeholder="Optional — defaults to S-31 on canvas" />
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div class="form-row">
|
|
145
|
+
<label for="node-input-config">
|
|
146
|
+
<i class="fa fa-cog"></i> Config
|
|
147
|
+
</label>
|
|
148
|
+
<input type="text" id="node-input-config" />
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<hr/>
|
|
152
|
+
|
|
153
|
+
<!-- ── PIR (* Required) ──────────────────────────────────── -->
|
|
154
|
+
<div class="form-row">
|
|
155
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
156
|
+
<i class="fa fa-podcast"></i> PIR <span style="color:#e74c3c">* Required</span>
|
|
157
|
+
</label>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div class="form-row">
|
|
161
|
+
<label for="node-input-uid">
|
|
162
|
+
<i class="fa fa-tv"></i> Cable ID
|
|
163
|
+
</label>
|
|
164
|
+
<span style="margin-right:4px; font-weight:bold;">S -</span>
|
|
165
|
+
<input type="text" id="node-input-uid"
|
|
166
|
+
placeholder="31" style="width:70px" />
|
|
167
|
+
|
|
168
|
+
<select id="node-input-uidPostfix" style="width:65px">
|
|
169
|
+
<option value="">(none)</option>
|
|
170
|
+
<option value="-A">-A</option>
|
|
171
|
+
<option value="-B">-B</option>
|
|
172
|
+
<option value="-C">-C</option>
|
|
173
|
+
<option value="-D">-D</option>
|
|
174
|
+
</select>
|
|
175
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
176
|
+
Plan ID — Sensor group
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="form-row">
|
|
181
|
+
<label for="node-input-pirType">
|
|
182
|
+
<i class="fa fa-podcast"></i> PIR Type
|
|
183
|
+
</label>
|
|
184
|
+
<select id="node-input-pirType" style="width:55%">
|
|
185
|
+
<option value="Ceiling">Ceiling</option>
|
|
186
|
+
<option value="Cupboard">Cupboard</option>
|
|
187
|
+
<option value="Hidden">Hidden</option>
|
|
188
|
+
<option value="Wall">Wall</option>
|
|
189
|
+
<option value="External">External</option>
|
|
190
|
+
</select>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div class="form-row">
|
|
194
|
+
<label for="node-input-area">
|
|
195
|
+
<i class="fa fa-map-marker"></i> Area
|
|
196
|
+
</label>
|
|
197
|
+
<select id="node-input-area" style="width:55%"></select>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="form-row">
|
|
201
|
+
<label for="node-input-situation">
|
|
202
|
+
<i class="fa fa-compass"></i> Situation
|
|
203
|
+
</label>
|
|
204
|
+
<select id="node-input-situation" style="width:55%">
|
|
205
|
+
<option value="in">in</option>
|
|
206
|
+
<option value="above">above</option>
|
|
207
|
+
<option value="below">below</option>
|
|
208
|
+
<option value="outside">outside</option>
|
|
209
|
+
<option value="throughout">throughout</option>
|
|
210
|
+
<option value="under">under</option>
|
|
211
|
+
<option value="over">over</option>
|
|
212
|
+
</select>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div class="form-row">
|
|
216
|
+
<label for="node-input-subLocation">
|
|
217
|
+
<i class="fa fa-map-marker"></i> Sub-Area
|
|
218
|
+
</label>
|
|
219
|
+
<select id="node-input-subLocation" style="width:55%"></select>
|
|
220
|
+
</div>
|
|
221
|
+
|
|
222
|
+
<hr/>
|
|
223
|
+
|
|
224
|
+
<!-- ── CONTROLLER ────────────────────────────────────────── -->
|
|
225
|
+
<div class="form-row">
|
|
226
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
227
|
+
<i class="fa fa-exchange"></i> Controller
|
|
228
|
+
</label>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div class="form-row">
|
|
232
|
+
<label for="node-input-pirPayload">
|
|
233
|
+
<i class="fa fa-podcast"></i> Payload
|
|
234
|
+
</label>
|
|
235
|
+
<input type="text" id="node-input-pirPayload"
|
|
236
|
+
placeholder="e.g. 13-62" style="width:120px" />
|
|
237
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
238
|
+
panelId-GPIOpin
|
|
239
|
+
</span>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div class="form-row">
|
|
243
|
+
<label for="node-input-subscribeTopic">
|
|
244
|
+
<i class="fa fa-exchange"></i> Subscribe topic
|
|
245
|
+
</label>
|
|
246
|
+
<input type="text" id="node-input-subscribeTopic"
|
|
247
|
+
placeholder="e.g. MW3D/Master/PIR_Sensors"
|
|
248
|
+
style="width:55%" />
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<hr/>
|
|
252
|
+
|
|
253
|
+
<!-- ── OPTIONS ───────────────────────────────────────────── -->
|
|
254
|
+
<div class="form-row">
|
|
255
|
+
<label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
|
|
256
|
+
<i class="fa fa-sliders"></i> Options
|
|
257
|
+
</label>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<div class="form-row">
|
|
261
|
+
<label for="node-input-haIcon">
|
|
262
|
+
<i class="fa fa-image"></i> HA Icon
|
|
263
|
+
</label>
|
|
264
|
+
<input type="text" id="node-input-haIcon"
|
|
265
|
+
placeholder="mdi:motion-sensor" style="width:55%" />
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div class="form-row">
|
|
269
|
+
<label for="node-input-cableColor">
|
|
270
|
+
<i class="fa fa-circle"></i> Cable Colour
|
|
271
|
+
</label>
|
|
272
|
+
<select id="node-input-cableColor" style="width:120px">
|
|
273
|
+
<option value="Purple">Purple</option>
|
|
274
|
+
<option value="Blue">Blue</option>
|
|
275
|
+
<option value="Green">Green</option>
|
|
276
|
+
<option value="Red">Red</option>
|
|
277
|
+
<option value="Yellow">Yellow</option>
|
|
278
|
+
<option value="Orange">Orange</option>
|
|
279
|
+
<option value="White">White</option>
|
|
280
|
+
<option value="Grey">Grey</option>
|
|
281
|
+
<option value="Black">Black</option>
|
|
282
|
+
</select>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<div class="form-row">
|
|
286
|
+
<label for="node-input-holdTime">
|
|
287
|
+
<i class="fa fa-clock-o"></i> Hold time (s)
|
|
288
|
+
</label>
|
|
289
|
+
<input type="number" id="node-input-holdTime"
|
|
290
|
+
min="1" style="width:80px" />
|
|
291
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
292
|
+
HA auto-clears motion after this delay
|
|
293
|
+
</span>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<div class="form-row">
|
|
297
|
+
<label for="node-input-warmupTime">
|
|
298
|
+
<i class="fa fa-clock-o"></i> Warm-up (s)
|
|
299
|
+
</label>
|
|
300
|
+
<input type="number" id="node-input-warmupTime"
|
|
301
|
+
min="0" style="width:80px" />
|
|
302
|
+
<span style="margin-left:8px; color:#999; font-size:0.85em;">
|
|
303
|
+
Offline delay after boot before PIR activates
|
|
304
|
+
</span>
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
</script>
|
|
308
|
+
|
|
309
|
+
<script type="text/html" data-help-name="ha-mqtt-pir">
|
|
310
|
+
<p>
|
|
311
|
+
PIR motion sensor receiver. Listens for hardware controller MQTT
|
|
312
|
+
payloads and publishes to HA as a <code>binary_sensor</code>
|
|
313
|
+
with <code>device_class: motion</code>.
|
|
314
|
+
</p>
|
|
315
|
+
|
|
316
|
+
<h3>Setup</h3>
|
|
317
|
+
<ol>
|
|
318
|
+
<li>Select your <strong>Config</strong> node</li>
|
|
319
|
+
<li>Set <strong>Cable ID</strong> — matches electrical plan</li>
|
|
320
|
+
<li>Set <strong>Payload</strong> — the <code>{panelId}-{GPIOpin}</code> string your controller publishes</li>
|
|
321
|
+
<li>Set <strong>Subscribe topic</strong> — the MQTT topic your PIR controller publishes to</li>
|
|
322
|
+
<li>Deploy — motion sensor appears in HA, offline during warm-up</li>
|
|
323
|
+
</ol>
|
|
324
|
+
|
|
325
|
+
<h3>Warm-up sequence</h3>
|
|
326
|
+
<p>
|
|
327
|
+
On deploy the PIR goes <code>offline</code> in HA for the warm-up period.
|
|
328
|
+
HA ignores all triggers during this time — prevents spurious motion events
|
|
329
|
+
during controller reboot from triggering lights at 3am.
|
|
330
|
+
</p>
|
|
331
|
+
|
|
332
|
+
<h3>Payload format</h3>
|
|
333
|
+
<p>
|
|
334
|
+
The controller publishes a plain string — e.g. <code>13-62</code>
|
|
335
|
+
meaning panel 13, GPIO pin 62. This node filters for its own
|
|
336
|
+
payload and silently ignores all others on the shared topic.
|
|
337
|
+
</p>
|
|
338
|
+
|
|
339
|
+
<h3>HA entity created</h3>
|
|
340
|
+
<ul>
|
|
341
|
+
<li><code>binary_sensor.s_31</code> — motion sensor, auto-clears via hold time</li>
|
|
342
|
+
</ul>
|
|
343
|
+
</script>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// ha-mqtt-pir — PIR / Motion Sensor Node Runtime
|
|
3
|
+
// Package: node-red-contrib-dmx-for-ha
|
|
4
|
+
// Author: DeSwaggy — Discord: @deswaggy
|
|
5
|
+
// ============================================================
|
|
6
|
+
|
|
7
|
+
module.exports = function (RED) {
|
|
8
|
+
|
|
9
|
+
function HaMqttPirNode(config) {
|
|
10
|
+
RED.nodes.createNode(this, config);
|
|
11
|
+
const node = this;
|
|
12
|
+
|
|
13
|
+
// ── Config & broker ───────────────────────────────────────
|
|
14
|
+
const cfg = RED.nodes.getNode(config.config);
|
|
15
|
+
if (!cfg) { node.error('PIR: no config node selected'); return; }
|
|
16
|
+
|
|
17
|
+
const broker = RED.nodes.getNode(cfg.broker);
|
|
18
|
+
if (!broker) { node.error('PIR: no MQTT broker in config'); return; }
|
|
19
|
+
|
|
20
|
+
broker.register(node);
|
|
21
|
+
|
|
22
|
+
// ── Node settings ─────────────────────────────────────────
|
|
23
|
+
const S = {
|
|
24
|
+
uid: config.uid || '',
|
|
25
|
+
uidPostfix: config.uidPostfix || '',
|
|
26
|
+
pirType: config.pirType || 'Ceiling',
|
|
27
|
+
pirPayload: String(config.pirPayload || ''),
|
|
28
|
+
subscribeTopic: config.subscribeTopic || '',
|
|
29
|
+
area: config.area || '',
|
|
30
|
+
situation: config.situation || 'in',
|
|
31
|
+
subLocation: config.subLocation || '',
|
|
32
|
+
haIcon: config.haIcon || 'mdi:motion-sensor',
|
|
33
|
+
cableColor: config.cableColor || 'Purple',
|
|
34
|
+
holdTime: parseInt(config.holdTime) || 15,
|
|
35
|
+
warmupTime: parseInt(config.warmupTime) || 120,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const fixtureId = `S-${S.uid}${S.uidPostfix}`;
|
|
39
|
+
const objectId = `s_${S.uid}${S.uidPostfix}`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
40
|
+
const fixtureTopic = `${cfg.discoveryPrefix}/binary_sensor/${fixtureId}`;
|
|
41
|
+
const cmdTopic = `${fixtureTopic}/${cfg.commandTopic}`;
|
|
42
|
+
const cfgTopic = `${fixtureTopic}/${cfg.configTopic}`;
|
|
43
|
+
const statTopic = `${fixtureTopic}/${cfg.stateTopic}`;
|
|
44
|
+
const avtyTopic = `${fixtureTopic}/${cfg.availTopic}`;
|
|
45
|
+
|
|
46
|
+
// ── Helpers ───────────────────────────────────────────────
|
|
47
|
+
function pub(topic, payload, retain) {
|
|
48
|
+
broker.publish({
|
|
49
|
+
topic,
|
|
50
|
+
payload: typeof payload === 'object' ? JSON.stringify(payload) : String(payload),
|
|
51
|
+
qos: cfg.qos,
|
|
52
|
+
retain: retain !== undefined ? retain : cfg.retain,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setStatus(fill, shape, text) {
|
|
57
|
+
node.status({ fill, shape, text });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Timers ────────────────────────────────────────────────
|
|
61
|
+
let warmupTimer = null;
|
|
62
|
+
let lastTrigger = 0;
|
|
63
|
+
|
|
64
|
+
function cancelWarmup() {
|
|
65
|
+
if (warmupTimer) { clearTimeout(warmupTimer); warmupTimer = null; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function startWarmup() {
|
|
69
|
+
cancelWarmup();
|
|
70
|
+
pub(avtyTopic, 'offline', true);
|
|
71
|
+
setStatus('orange', 'ring', `${fixtureId} warming up — ${S.warmupTime}s`);
|
|
72
|
+
warmupTimer = setTimeout(() => {
|
|
73
|
+
warmupTimer = null;
|
|
74
|
+
pub(avtyTopic, 'online', true);
|
|
75
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting motion`);
|
|
76
|
+
node.log(`${fixtureId} warm-up complete`);
|
|
77
|
+
}, S.warmupTime * 1000);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── PIR trigger ───────────────────────────────────────────
|
|
81
|
+
function handleTrigger() {
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const holdMs = S.holdTime * 1000;
|
|
84
|
+
if (now - lastTrigger < holdMs) return; // debounce
|
|
85
|
+
lastTrigger = now;
|
|
86
|
+
pub(statTopic, 'ON', false);
|
|
87
|
+
setStatus('blue', 'dot', `${fixtureId} motion detected`);
|
|
88
|
+
node.log(`${fixtureId} motion detected`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Availability ──────────────────────────────────────────
|
|
92
|
+
function handleAvailability(payload) {
|
|
93
|
+
const status = String(payload || '').toLowerCase();
|
|
94
|
+
cancelWarmup();
|
|
95
|
+
if (status === 'offline') {
|
|
96
|
+
pub(avtyTopic, 'offline', true);
|
|
97
|
+
setStatus('red', 'ring', `${fixtureId} offline`);
|
|
98
|
+
} else {
|
|
99
|
+
pub(avtyTopic, 'online', true);
|
|
100
|
+
lastTrigger = 0;
|
|
101
|
+
setStatus('yellow', 'ring', `${fixtureId} ready — awaiting motion`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Device add ────────────────────────────────────────────
|
|
106
|
+
function handleDeviceAdd() {
|
|
107
|
+
const discovery = {
|
|
108
|
+
unique_id: fixtureId,
|
|
109
|
+
object_id: objectId,
|
|
110
|
+
name: `${S.pirType} PIR ${S.situation} the ${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
111
|
+
stat_t: statTopic,
|
|
112
|
+
avty_t: avtyTopic,
|
|
113
|
+
payload_available: 'online',
|
|
114
|
+
payload_not_available: 'offline',
|
|
115
|
+
off_delay: S.holdTime,
|
|
116
|
+
device_class: 'motion',
|
|
117
|
+
enabled_by_default: cfg.enabledDefault,
|
|
118
|
+
icon: S.haIcon,
|
|
119
|
+
device: {
|
|
120
|
+
identifiers: `binary_sensor-${fixtureId}`,
|
|
121
|
+
name: `(${fixtureId}) - ${S.pirType} PIR ${S.situation} the ${cfg.zone} - ${S.area} - ${S.subLocation}`,
|
|
122
|
+
model: `${S.pirType} PIR located ${S.situation} the ${cfg.zone} - ${S.area}`,
|
|
123
|
+
model_id: `referenced on plan as: (${fixtureId}`,
|
|
124
|
+
suggested_area: `${cfg.zone} ${S.area} ${S.subLocation}`,
|
|
125
|
+
hw_version: `${S.pirType} PIR — ${S.cableColor} Cat5 cable. Publishes "${S.pirPayload}" on topic: ${S.subscribeTopic}`,
|
|
126
|
+
serial_number: `(${fixtureId}) Payload: ${S.pirPayload}`,
|
|
127
|
+
sw_version: `ha-mqtt-pir: ${RED.version ? RED.version() : '0.1.0'}`,
|
|
128
|
+
manufacturer: 'DeSwaggy — Discord: @deswaggy',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
pub(cfgTopic, discovery, true);
|
|
133
|
+
|
|
134
|
+
// Subscribe to controller topic
|
|
135
|
+
broker.subscribe(S.subscribeTopic, cfg.qos, function (topic, payload) {
|
|
136
|
+
const str = payload.toString();
|
|
137
|
+
if (str === S.pirPayload) handleTrigger();
|
|
138
|
+
}, node.id);
|
|
139
|
+
|
|
140
|
+
setStatus('green', 'ring', `${fixtureId} discovery sent`);
|
|
141
|
+
node.log(`${fixtureId} device added`);
|
|
142
|
+
startWarmup();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Device remove ─────────────────────────────────────────
|
|
146
|
+
function handleDeviceRemove() {
|
|
147
|
+
cancelWarmup();
|
|
148
|
+
pub(avtyTopic, 'offline', true);
|
|
149
|
+
pub(cfgTopic, '', true);
|
|
150
|
+
broker.unsubscribe(S.subscribeTopic, node.id);
|
|
151
|
+
setStatus('red', 'ring', `${fixtureId} removed`);
|
|
152
|
+
node.log(`${fixtureId} device removed`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── NR input entry point ──────────────────────────────────
|
|
156
|
+
node.on('input', function (msg, send, done) {
|
|
157
|
+
const devReq = typeof msg.device === 'string'
|
|
158
|
+
? msg.device
|
|
159
|
+
: (msg.device && msg.device.request);
|
|
160
|
+
|
|
161
|
+
if (devReq) {
|
|
162
|
+
switch (devReq) {
|
|
163
|
+
case 'add': handleDeviceAdd(); break;
|
|
164
|
+
case 'remove': handleDeviceRemove(); break;
|
|
165
|
+
case 'avty': handleAvailability(msg.payload); break;
|
|
166
|
+
default: node.warn(`${fixtureId} — unknown device.request: "${devReq}"`);
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
node.warn(`${fixtureId} — unrecognised message received and dropped. See node documentation.`);
|
|
170
|
+
}
|
|
171
|
+
done();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ── Cleanup ───────────────────────────────────────────────
|
|
175
|
+
node.on('close', function (done) {
|
|
176
|
+
cancelWarmup();
|
|
177
|
+
broker.unsubscribe(S.subscribeTopic, node.id);
|
|
178
|
+
broker.deregister(node, done);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
RED.nodes.registerType('ha-mqtt-pir', HaMqttPirNode);
|
|
183
|
+
};
|