node-red-contrib-dmx-for-ha 0.6.28 → 0.6.33

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 CHANGED
@@ -32,7 +32,7 @@ Node-RED acts as the **virtual lighting desk** — it owns the DMX channels, man
32
32
  ```
33
33
  Home Assistant Node-RED DMX Hardware
34
34
  ────────────── ──────── ────────────
35
- Automations ←──→ ha-mqtt-dmx ──→ EtherTen /
35
+ Automations ←──→ ha-mqtt-dmx ──→ EtherTen /
36
36
  Dashboards ha-mqtt-group DMX decoder /
37
37
  Scenes ha-mqtt-relay MQTT bridge
38
38
  Voice control ha-mqtt-button ←── Wall buttons
@@ -55,27 +55,17 @@ Most DMX implementations require either expensive proprietary hardware or comple
55
55
 
56
56
  - Full Home Assistant MQTT discovery — entities appear automatically in HA
57
57
  - RGBW, RGBWW, RGB, Colour Temperature, Brightness, and On/Off colour modes
58
- - Gamma-corrected DMX output
59
- - Transitions, effects, and group control
58
+ - Gamma-corrected DMX output with configurable floor and limiter
59
+ - Smooth transitions with configurable ON and OFF fade times
60
+ - Effects engine — strobe, rainbow, fire, flicker, twinkle, police and more
61
+ - Group control — virtual group entities fan commands to member fixtures
60
62
  - State persistence across reboots — survives power cycles and HA updates
61
63
  - Wall button and PIR motion sensor integration with remote commissioning support
62
64
  - 230V relay switching
63
65
  - DMX channel conflict detection — warns on duplicate channel assignments
64
- - Configurable DMX floor value — prevents low-value flicker on hardware that needs it
66
+ - Per-node DMX floor — prevents low-value flicker on hardware that needs it
65
67
  - Debug mode per node — 12hr auto-disable, safe to leave in production flows
66
68
 
67
- ## Licence
68
-
69
- This package is licensed under **[GPL v3](https://www.gnu.org/licenses/gpl-3.0.txt)**.
70
-
71
- You are free to use, modify, and distribute this package. Any derivative works must be distributed under the same GPL v3 licence. This package may not be taken proprietary, rebranded as closed source, or distributed without crediting the original author.
72
-
73
- This package was built from 6+ years of real-world professional installation experience. If you use it, improve it, or build on it — please contribute your changes back to the community.
74
-
75
- ---
76
-
77
- > **Note:** This is building automation DMX — fixtures, decoders, dimmers, relay switching. Not entertainment industry DMX (no movers, gobos, or fixture profiles). DMX is an open standard and this package works with any DMX controller that accepts MQTT payloads.
78
-
79
69
  ---
80
70
 
81
71
  ## Nodes included
@@ -84,7 +74,7 @@ This package was built from 6+ years of real-world professional installation exp
84
74
  |---|---|---|---|
85
75
  | `ha-mqtt-config` | *(config)* | — | Shared site config — broker, zone, HA settings |
86
76
  | `ha-mqtt-dmx` | DMX | Yellow | Single DMX fixture |
87
- | `ha-mqtt-dmx-group` | DMX Group | Gold | Virtual group — fans commands to child nodes |
77
+ | `ha-mqtt-dmx-group` | DMX Group | Gold | Virtual group — fans commands to member fixtures |
88
78
  | `ha-mqtt-relay` | Relay | Red | 230V relay switching |
89
79
  | `ha-mqtt-button` | Button | Green | Wall button receiver |
90
80
  | `ha-mqtt-pir` | PIR | Purple | Motion sensor receiver |
@@ -160,10 +150,13 @@ Context store : 'disk_values' [module=localfilesystem]
160
150
  1. Drag a **DMX** node onto the canvas and open its editor
161
151
  2. Click **Add new ha-mqtt-config...** next to the Config field
162
152
  3. Fill in:
163
- - **Config Label** — a friendly name e.g. "My House". Don't stress — this can be changed at any time.
153
+ - **Config Label** — a friendly name e.g. "My House"
164
154
  - **Site ID** — short slug for MQTT topics e.g. `home` (no spaces)
165
- - **Zone** — physical zone e.g. `Master`, `BnB`. Used in MQTT topics. This can be updated later if needed.
155
+ - **Zone** — physical zone e.g. `Master`, `BnB`. Used in MQTT topics.
166
156
  - **Broker** — select your existing MQTT broker config
157
+ - **DMX Floor** — minimum DMX value sent to controllers (default 3). Prevents flicker on decoders that are unstable at values 1–2. Set to 0 to disable.
158
+ - **Default ON transition** — fade time in seconds when HA sends no transition (default 1s)
159
+ - **Default OFF transition** — fade time in seconds when HA turns off with no transition (default 1s)
167
160
  4. Click **Add**
168
161
 
169
162
  ### 2. Add a DMX fixture
@@ -172,7 +165,7 @@ Fill in the node editor:
172
165
  1. **Fixture ID** — Prefix (`L`), Plan ID, optional channel letter e.g. `L` `992` `-A`
173
166
  2. **Colour Mode** — RGBW, RGB, CCT etc.
174
167
  3. **Device Type** — e.g. Downlight, Strip light
175
- 4. **Situation** — describes where the fixture is relative to its area. Options:
168
+ 4. **Situation** — describes where the fixture is relative to its area:
176
169
 
177
170
  | Situation | Example result |
178
171
  |---|---|
@@ -184,19 +177,90 @@ Fill in the node editor:
184
177
  | `outside` | Rail light **outside** Media Room |
185
178
  | `throughout` | Downlight **throughout** Living Area |
186
179
 
187
- > **Tip:** Don't default to `in` for everything — `outside`, `near` and `above` are useful for fixtures in corridors, at thresholds, and above architectural features. On a large installation this makes fixture names immediately meaningful without opening a plan.
188
-
189
180
  5. **Config** — select your config node
190
181
  6. **Area** and **Sub-Area** — location on the property
191
182
  7. **DMX Channels** — channel numbers matching your fixture's DMX start address
192
183
  8. **Controller** and **Universe** numbers
193
- 9. **Discovery Mode** — Enabled, Hidden, or Disabled
184
+ 9. **DMX Floor** — leave blank to use the config node default, or set a value to override for this fixture only
185
+ 10. **Discovery Mode** — Enabled, Hidden, or Disabled
194
186
 
195
187
  Deploy — the fixture appears in HA automatically.
196
188
 
197
- ### 3. Auto-discovery
189
+ ---
190
+
191
+ ## DMX Floor
198
192
 
199
- Nodes auto-discover when deployed if the MQTT broker is connected. No external inject nodes or SYSTEM node needed.
193
+ The DMX floor sets the **absolute minimum value** sent to the controller for any channel that is on. It prevents flickering on LED decoders that are unstable at values 1 or 2.
194
+
195
+ ```
196
+ value = 0 → sends 0 (OFF — always honoured)
197
+ 0 < value < floor → sends floor (snapped up)
198
+ value >= floor → sends value (unchanged)
199
+ ```
200
+
201
+ This is enforced at the output stage — no code path can bypass it.
202
+
203
+ **Config node** sets the site-wide default (e.g. 3). **DMX node** can override it per fixture — leave blank to inherit from config, or set a specific value for fixtures that can handle lower values without flicker.
204
+
205
+ ---
206
+
207
+ ## DMX Group Node
208
+
209
+ Appears as a single light entity in HA. Commands fan out to all member fixtures.
210
+
211
+ ### Two ways to define members
212
+
213
+ **Method A — Wires (v1, still supported):**
214
+ ```
215
+ [DMX Group LG-992]
216
+ │ Link output
217
+ ├──→ [DMX L-992-A]
218
+ ├──→ [DMX L-992-B]
219
+ └──→ [DMX L-992-C]
220
+ ```
221
+
222
+ **Method B — Member list (v2, recommended):**
223
+
224
+ Add fixture IDs directly in the group node editor. No wires needed. Commands are forwarded internally via the Node-RED message bus — no extra MQTT traffic.
225
+
226
+ ```
227
+ Members:
228
+ local L-992-A → forwarded via RED.nodes.getNode() internally
229
+ local L-992-B → forwarded via RED.nodes.getNode() internally
230
+ local P-149 → relay node, forwarded internally
231
+ remote MW3D/BnB/group/cmd → published via MQTT for cross-instance groups
232
+ ```
233
+
234
+ Both methods can be used together. If a fixture appears in both wires and the member list it will receive the command twice — avoid this.
235
+
236
+ ### Member types
237
+
238
+ | Type | Value | How it works |
239
+ |---|---|---|
240
+ | `local` | Fixture ID e.g. `L-992-A` | Resolved via fixture registry → `RED.nodes.getNode()` |
241
+ | `remote` | MQTT topic e.g. `MW3D/BnB/group/cmd` | Published via MQTT with loop-detection envelope |
242
+
243
+ ### Duplicate member protection
244
+
245
+ If the same fixture ID appears more than once in the member list, the **entire group command is blocked** and an orange warning is shown. The editor also prevents saving with duplicates.
246
+
247
+ ### Loop detection
248
+
249
+ The node tracks the cascade path and blocks circular references. Works across wired groups, member list groups, and cross-instance remote groups. Max depth defaults to 10.
250
+
251
+ ### Node status
252
+
253
+ | Colour | Meaning |
254
+ |---|---|
255
+ | Green dot | Healthy — all members resolved |
256
+ | Yellow ring | One or more members not found in registry |
257
+ | Orange ring | Duplicate member detected — group blocked |
258
+ | Red dot | Loop detected |
259
+ | Red ring | Broker disconnected |
260
+
261
+ ### Naming convention
262
+
263
+ `L-992-A`, `L-992-B`, `L-992-C` group naturally under `LG-992`. The `LG` prefix is fixed.
200
264
 
201
265
  ---
202
266
 
@@ -230,8 +294,6 @@ Typical workflow: Remove → update node settings → Deploy → node auto-disco
230
294
 
231
295
  All nodes subscribe to a system control topic for **bulk add/remove during commissioning**. No wiring needed — publish one MQTT message and all matching nodes respond instantly.
232
296
 
233
- This is one of the most useful commissioning features in the package. On large installations with hundreds of entities, the ability to wipe and rediscover a specific zone or type in one click saves hours of manual work.
234
-
235
297
  **Topic:** `{siteId}/system/control` (e.g. `MW3D/system/control`)
236
298
 
237
299
  **Payload:**
@@ -247,116 +309,25 @@ This is one of the most useful commissioning features in the package. On large i
247
309
  | `zone` | `all`, or any zone name e.g. `Master`, `BnB` | Matches config node Zone field — case insensitive |
248
310
  | `type` | `all`, `dmx`, `group`, `button`, `pir`, `relay` | Node type filter |
249
311
 
250
- **Type reference:**
251
-
252
- | Type | Node | Description |
253
- |---|---|---|
254
- | `dmx` | ha-mqtt-dmx | Individual DMX fixtures |
255
- | `group` | ha-mqtt-dmx-group | DMX group nodes |
256
- | `button` | ha-mqtt-button | Wall button inputs |
257
- | `pir` | ha-mqtt-pir | PIR / motion sensors |
258
- | `relay` | ha-mqtt-relay | Relay / power outputs |
259
- | `all` | all of the above | Everything in the zone |
260
-
261
- **Commissioning workflow — roll out one type at a time:**
262
- ```
263
- Day 1 — DMX lights:
264
- ADD Master DMX → all lights appear in HA
265
- Test each light
266
- REMOVE Master DMX → wipe and fix any wrong names/channels
267
- ADD Master DMX → rediscover clean
268
-
269
- Day 2 — Buttons:
270
- ADD Master Button → all buttons appear in HA
271
- Test each button
272
- etc.
273
- ```
274
-
275
- **How to send the command:**
276
-
277
- Option A — from anywhere on the network (MQTT Explorer, HA automation, script):
312
+ **Commissioning workflow:**
278
313
  ```
279
- Topic: MW3D/system/control
280
- Payload: {"cmd":"add","zone":"all","type":"all"}
314
+ ADD Master DMX → all lights appear in HA → test each light
315
+ REMOVE Master DMX → wipe and fix any wrong names/channels
316
+ ADD Master DMX → rediscover clean
281
317
  ```
282
318
 
283
- Option B — from Node-RED using the included System Control flow:
284
-
285
- Import `system_control_flow.json` into Node-RED. Connect the MQTT out node to your broker. You get a dedicated tab with one-click buttons for every zone and type combination:
286
-
287
- ```
288
- 🗑 REMOVE ALL ✅ ADD ALL
289
-
290
- 🗑 REMOVE Master DMX ✅ ADD Master DMX
291
- 🗑 REMOVE Master Group ✅ ADD Master Group
292
- 🗑 REMOVE Master Button ✅ ADD Master Button
293
- 🗑 REMOVE Master PIR ✅ ADD Master PIR
294
- 🗑 REMOVE Master Relay ✅ ADD Master Relay
295
-
296
- 🗑 REMOVE BnB DMX ✅ ADD BnB DMX
297
- ... etc.
298
- ```
299
-
300
- Option C — wire an inject node directly to any ha-mqtt node input:
301
- ```
302
- msg.device = "add" → add that node only
303
- msg.device = "remove" → remove that node only
304
- ```
305
-
306
- > **Tip:** The system control topic is zone and type filtered — sending `REMOVE BnB DMX` only affects BnB DMX fixtures. Master lights, buttons, relays are completely unaffected. This makes it safe to use on a live installation with clients present.
307
-
308
- ---
309
-
310
- ## DMX Group Node
311
-
312
- Appears as a single light entity in HA. Commands fan out to all nodes on its **Link** output.
313
-
314
- ```
315
- [DMX Group LG-992]
316
- │ Link
317
- ├──→ [DMX L-992-A]
318
- ├──→ [DMX L-992-B]
319
- └──→ [DMX L-992-C]
320
- ```
321
-
322
- Wire **Link** to child DMX or Relay node inputs. Supports nested groups.
323
-
324
- Naming: `L-992-A`, `L-992-B`, `L-992-C` group naturally under `LG-992`.
325
-
326
319
  ---
327
320
 
328
321
  ## Wall buttons
329
322
 
330
323
  Creates two HA entities per button:
331
324
 
332
- - **`binary_sensor.s_10_a`** — physical state. ON = pressed/held, OFF = released. Use this for automations and dashboard display.
325
+ - **`binary_sensor.s_10_a`** — physical state. ON = pressed/held, OFF = released.
333
326
  - **`button.s_10_a_btn`** — momentary trigger. Simulates a physical press from the HA UI.
334
327
 
335
- ### Why two entities?
336
-
337
- This package is designed for **professional installation** as well as home use. On large installations it is not practical to physically press every button every time you make a change — the building may be large, the button may be on the other side of the property, or you may be commissioning remotely.
338
-
339
- The `button` entity gives you a **virtual commissioning panel** — every physical wall button has a software mirror in HA that you can trigger from a laptop anywhere in the world without being on site.
340
-
341
- ### Important — HA button entity behaviour
342
-
343
- The `button` domain in HA is **intentionally stateless** — it will never light up or show an active state. This is a HA platform design decision, not a bug. The `button` entity triggers and immediately resets with no persistent state.
328
+ The `button` entity gives you a **virtual commissioning panel** — every physical wall button has a software mirror in HA that you can trigger from anywhere without being on site.
344
329
 
345
- For visual feedback always use the `binary_sensor` entity.
346
-
347
- ### Recommended dashboard setup
348
-
349
- Hide the `button` entity from the default dashboard to keep things clean — it remains fully accessible when needed:
350
-
351
- ```
352
- Settings → Devices → (button device) → button entity → ⋮ → Hide from dashboard
353
- ```
354
-
355
- This gives you:
356
- - **Dashboard** — clean, only `binary_sensor` shown
357
- - **Device page** — both entities visible, Press button accessible for commissioning
358
- - **Automations** — both entities fully available, nothing hidden from logic
359
- - **Remote commissioning** — navigate to device page, simulate press from anywhere
330
+ The `button` domain in HA is intentionally stateless — it will never show an active state. For visual feedback always use the `binary_sensor` entity.
360
331
 
361
332
  Letters `I` and `O` are never used as suffixes — they look too similar to `1` and `0`.
362
333
 
@@ -383,69 +354,22 @@ Entity IDs in HA are locked to the fixture ID and survive friendly name changes.
383
354
 
384
355
  ## Retain discovery — why the default is false
385
356
 
386
- If you search "MQTT discovery retain" you will find HA community posts and documentation recommending `retain=true` for discovery messages. **This advice is correct for ESP and battery-powered IoT devices — but not for Node-RED.**
387
-
388
- ### Why ESP devices need retain=true
389
-
390
- An ESP sensor runs independently of HA. When HA restarts or updates, the ESP device has no way of knowing — it thinks it already discovered itself and won't re-send the discovery payload. Without `retain=true`, the device disappears from HA permanently until the ESP reboots.
391
-
392
- ```
393
- ESP device Home Assistant
394
- ────────── ──────────────
395
- Sends discovery once → HA restarts
396
- Doesn't know HA restarted Retained message restores device ✓
397
- ```
398
-
399
- ### Why Node-RED is different
400
-
401
- Node-RED sits on the same server stack as HA. They are tightly coupled — if one goes down the other knows immediately via the websocket connection. When HA restarts, NR reconnects and re-discovers every device automatically. If NR goes down, HA *should* show nothing — because nothing is working.
402
-
403
- ```
404
- Node-RED + Home Assistant (same server):
405
- NR down → no devices in HA → client sees fault immediately ✓
406
- HA restart → NR reconnects → re-discovers everything ✓
407
- retain=true → ghost devices → client confused, support call ✗
408
- ```
409
-
410
- ### The config node setting
357
+ If you search "MQTT discovery retain" you will find HA community posts recommending `retain=true`. **This advice is correct for ESP and battery-powered IoT devices — but not for Node-RED.**
411
358
 
412
- The `Retain discovery` setting on the config node controls this behaviour:
413
-
414
- | Setting | Behaviour | Recommended for |
415
- |---|---|---|
416
- | `false` (default) | Devices disappear if NR stops | Professional installs — clear fault indication |
417
- | `true` | Devices persist if NR stops | Standalone IoT, voice assistant integrations where entity persistence matters |
418
-
419
- **Default is `false`** — if a client opens their HA dashboard and sees no lights, they know something is wrong with Node-RED. This is the correct behaviour for a professionally installed system where NR and HA live on the same hardware.
420
-
421
- ---
359
+ Node-RED sits on the same server stack as HA. When HA restarts, NR reconnects and re-discovers every device automatically. With `retain=true`, ghost entities persist in HA even when NR is down — clients see lights that don't work and can't tell why.
422
360
 
423
- ## MQTT topic formats
361
+ With `retain=false` (default): if NR stops, entities disappear. Clients see a fault immediately. This is the correct behaviour for a professionally installed system.
424
362
 
425
- | Node | Topic |
363
+ | Setting | Recommended for |
426
364
  |---|---|
427
- | DMX | `{siteId}/{zone}/dmx/{universe}` |
428
- | Relay | `{siteId}/{zone}/{controller}/relay/{relayNum}` |
429
- | HA discovery | `homeassistant/light/{fixtureId}/config` |
430
-
431
- DMX payload format: `"212255"` = channel 212, value 255 (3 digits each).
432
-
433
- ---
434
-
435
- ## Multi-zone deployments
436
-
437
- Create one `ha-mqtt-config` per zone. Each node selects its zone via the Config dropdown.
438
-
439
- ```
440
- Master Zone config → Zone: Master → topics: home/Master/dmx/1
441
- Guest Wing config → Zone: GuestWing → topics: home/GuestWing/dmx/1
442
- ```
365
+ | `false` (default) | Professional installs — clear fault indication |
366
+ | `true` | Standalone IoT where entity persistence matters more than fault clarity |
443
367
 
444
368
  ---
445
369
 
446
370
  ## Transition rate tuning
447
371
 
448
- The config node has two global transition settings that apply to all DMX nodes in the zone:
372
+ The config node has two global transition settings:
449
373
 
450
374
  ### Transition rate limit
451
375
 
@@ -455,151 +379,56 @@ Controls the tick rate multiplier for all transitions and effects:
455
379
  ticksPerSec (per node) × rateLimit (config) = effectiveTicks/sec
456
380
  ```
457
381
 
458
- | Rate Limit | Effective ticks/sec | MQTT msgs/sec (RGBW × 30 nodes) | Use case |
459
- |---|---|---|---|
460
- | 1.0 | 31 | ~3,700 | Small deployment, fast hardware |
461
- | 0.5 | 15 | ~1,800 | Medium deployment, balanced |
462
- | 0.25 | 7 | ~840 | Large deployment, light load |
463
- | 0.1 | 3 | ~360 | Maximum scale, minimal load |
464
-
465
- Reduce `transitionRateLimit` if you observe:
466
- - MQTT broker lag or dropped messages
467
- - NR event loop warnings
468
- - DMX controller missing channel updates
469
- - Sluggish HA response during scene changes
382
+ | Rate Limit | Effective ticks/sec | Use case |
383
+ |---|---|---|
384
+ | 1.0 | 31 | Small deployment, fast hardware |
385
+ | 0.5 | 15 | Medium deployment, balanced |
386
+ | 0.25 | 7 | Large deployment, light load |
387
+ | 0.1 | 3 | Maximum scale, minimal load |
470
388
 
471
- ### HA UI transition time
389
+ Reduce `transitionRateLimit` if you see MQTT broker lag, NR event loop warnings, or sluggish HA response during scene changes.
472
390
 
473
- Fallback transition duration (seconds) used when HA sends a command with no
474
- transition specified. Default 1 second.
391
+ ### Default transition times
475
392
 
476
- ```
477
- HA sends transition=3 → use 3 seconds
478
- HA sends no transition use haUiTime (default 1s)
479
- ```
393
+ | Field | Default | Description |
394
+ |---|---|---|
395
+ | Default ON transition | 1s | Fade time when HA sends ON with no transition specified |
396
+ | Default OFF transition | 1s | Fade time when HA sends OFF with no transition specified |
480
397
 
481
- Set to `0` to make all un-timed commands instant.
398
+ Set to `0` for instant snap on/off.
482
399
 
483
400
  ---
484
401
 
485
402
  ## Context store — finding your files
486
403
 
487
- This is one of the most common questions with Node-RED on Home Assistant. The context store files are **not** where you expect them.
404
+ The NR add-on runs in its own Docker container. Context store files are **not** accessible via Samba.
488
405
 
489
- ### Where are the files?
490
-
491
- The NR add-on runs in its own Docker container with an isolated config directory. This is **separate** from the main HA config you see via Samba.
492
-
493
- | What you see | Actual path on host |
406
+ | Location | Path |
494
407
  |---|---|
495
- | Samba `\\{haip}\config\` | HA config — flows, automations etc. |
496
408
  | NR add-on config | `/addon_configs/a0d7b954_nodered/` |
497
409
  | Context store files | `/addon_configs/a0d7b954_nodered/nodeRED/context_stores/context/` |
498
410
 
499
- The NR add-on config directory is **not accessible via Samba** this trips everyone up.
500
-
501
- ### How to find your context files
502
-
503
- Use the **Studio Code Server** add-on (VS Code in HA):
504
-
505
- 1. Install Studio Code Server from the add-on store if not already installed
506
- 2. Open it: Settings → Add-ons → Studio Code Server → Open Web UI
507
- 3. Open a terminal: Terminal → New Terminal
508
- 4. Run:
509
-
510
- ```bash
511
- find / -name "*.json" -path "*context*" 2>/dev/null | grep -v proc
512
- ```
513
-
514
- You should see something like:
515
- ```
516
- /addon_configs/a0d7b954_nodered/nodeRED/context_stores/context/7e2467f26a0693a0/dd6582b967f0314a.json
517
- /addon_configs/a0d7b954_nodered/nodeRED/context_stores/context/7e2467f26a0693a0/75b10249fcbfd224.json
518
- ...
519
- ```
520
-
521
- One `.json` file per node, named by node ID.
522
-
523
- ### Reading a context file
524
-
525
- ```bash
526
- cat "/addon_configs/a0d7b954_nodered/nodeRED/context_stores/context/{flowId}/{nodeId}.json"
527
- ```
528
-
529
- A healthy file looks like:
530
- ```json
531
- {
532
- "state": "ON",
533
- "brightness": 255,
534
- "red": 255,
535
- "green": 255,
536
- "blue": 255,
537
- "white": 255,
538
- "warmWhite": 0
539
- }
540
- ```
541
-
542
- ### Important: NR adds a `/context/` subfolder
543
-
544
- When you set `dir` in your context store config, NR automatically appends `/context/` to it. So:
545
-
546
- ```javascript
547
- // You set:
548
- config: { dir: '/config/nodeRED/context_stores' }
549
-
550
- // NR actually writes to:
551
- // /config/nodeRED/context_stores/context/
552
- ```
411
+ Use **Studio Code Server** add-on to access these files. One `.json` file per node, named by node ID.
553
412
 
554
413
  ### Recommended config.js settings
555
414
 
556
415
  ```javascript
557
416
  contextStorage: {
558
417
  memory: { module: 'memory' },
559
- disk_values: {
560
- module: 'localfilesystem',
561
- config: {
562
- dir: '/config/nodeRED/context_stores',
563
- flushInterval: 5 // Write to disk every 5 seconds (default is 30)
564
- }
565
- },
566
- disk_meta: {
567
- module: 'localfilesystem',
568
- config: {
418
+ disk_values: {
419
+ module: 'localfilesystem',
420
+ config: {
569
421
  dir: '/config/nodeRED/context_stores',
570
422
  flushInterval: 5
571
- }
423
+ }
572
424
  },
573
425
  default: { module: 'memory' },
574
426
  },
575
427
  ```
576
428
 
577
- **Important:** Use an **absolute path** starting with `/config/`. A relative path will silently resolve to the wrong location and nothing will be saved.
578
-
579
- `flushInterval: 5` means NR writes to disk every 5 seconds instead of the default 30. This reduces the window of data loss on unexpected shutdown — after turning a light on, wait 5 seconds before restarting.
429
+ Use an **absolute path** starting with `/config/`. A relative path will silently write to the wrong location.
580
430
 
581
- ### Verifying context store is working
582
-
583
- After saving a state and waiting 5+ seconds, check your files appeared:
584
-
585
- ```bash
586
- ls "/addon_configs/a0d7b954_nodered/nodeRED/context_stores/context/"
587
- ```
588
-
589
- If the directory is empty or doesn't exist — check your `config.js` path is absolute and restart NR.
590
-
591
- ### NR startup log confirmation
592
-
593
- On every startup NR logs which context stores loaded:
594
-
595
- ```
596
- Context store : 'memory' [module=memory]
597
- Context store : 'disk_values' [module=localfilesystem]
598
- Context store : 'disk_meta' [module=localfilesystem]
599
- Context store : 'default' [module=memory]
600
- ```
601
-
602
- If `disk_values` shows `[module=memory]` — your config.js change didn't take effect.
431
+ `flushInterval: 5` writes to disk every 5 seconds instead of the default 30 — reduces the window of data loss on unexpected shutdown.
603
432
 
604
433
  ---
605
434
 
@@ -617,44 +446,35 @@ If `disk_values` shows `[module=memory]` — your config.js change didn't take e
617
446
 
618
447
  **PIR stuck offline** — Warm-up timer running. Wait for configured warm-up period.
619
448
 
449
+ **Group member not found (yellow warning)** — Fixture ID not in registry. Check the fixture node is deployed and registered. Command still fires to all other members.
450
+
451
+ **Group command blocked (orange warning)** — Duplicate member in the list. Open the group node editor and remove the duplicate.
452
+
620
453
  ---
621
454
 
622
455
  ## Version history
623
456
 
624
457
  | Version | Changes |
625
458
  |---|---|
626
- | 0.4.4 | Recovery status shows actual state on canvas instead of "ready awaiting HA" |
627
- | 0.4.3 | device:remove no longer clears disk state state survives remove/add cycles |
628
- | 0.4.2 | Group node pubState includes color fixes color wheel jitter in HA |
459
+ | 0.6.31 | Group Node v2 member list (local + remote), internal routing via RED.nodes.getNode(), duplicate detection, loop protection cross-instance. Registry stores nodeId + nodeType. |
460
+ | 0.6.30 | Debug timer leak fixed S._debugTimer saved and cleared on node close (all 5 nodes) |
461
+ | 0.6.29 | Node label updated to match HA device format including subLocation |
462
+ | 0.6.28 | Per-node DMX floor override field — blank inherits config default |
463
+ | 0.6.27 | DMX floor enforced at sendDmxChannels level — absolute gate, covers all code paths |
464
+ | 0.6.26 | pubState recovery + restoreAfterEffect floor fix (reverted — wrong approach) |
465
+ | 0.6.25 | pubState handleON floor fix (reverted — wrong approach) |
466
+ | 0.6.24 | User published manually |
467
+ | 0.6.23 | colorValue>=3 bleed guard, ON/OFF transition UI fields (0-60s), brightness=0 hard zero restored |
468
+ | 0.6.22 | Transition floor in runTransition, default OFF transition config field |
469
+ | 0.6.21 | Reverted bad rgbw_color parsing block |
470
+ | 0.6.20 | colorValue>=3 guard added, ON transition UI field |
471
+ | 0.6.19 | unregisterFixtureId() on close (all 5 nodes), setMaxListeners(0), brightness=0 hard zero |
472
+ | 0.6.17 | Group node separated from DMX type, README updated |
473
+ | 0.4.4 | Recovery status shows actual state on canvas |
474
+ | 0.4.3 | device:remove no longer clears disk state |
475
+ | 0.4.2 | Group node pubState includes color |
629
476
  | 0.4.1 | Group node status reflects ON/OFF correctly |
630
- | 0.4.0 | Group recovery no longer forwards state to children — each node recovers independently |
631
- | 0.3.9 | Four fixes: group device:add forward removed, cooldown warn suppressed, group status permanent, disk flush on close |
632
- | 0.3.8 | Restored missing variable declarations wiped by debug block corruption |
633
- | 0.3.7 | Fix _debugId computed from S (not fixtureId) to avoid ReferenceError on startup |
634
- | 0.3.6 | DMX channel conflict detection, jitter timer tracked and cancellable |
635
- | 0.3.5 | Fix debug mode block initialisation order (S before debug) |
636
- | 0.3.4 | Debug hint text updated — notes 12hr auto-disable |
637
- | 0.3.3 | Debug mode canvas warning + 12hr auto-disable safety net |
638
- | 0.3.2 | Debug output to NR debug tab via node.warn with [DEBUG] prefix |
639
- | 0.3.1 | Double-fire fix — 5s cooldown + _discovered resets on broker close |
640
- | 0.3.0 | Debug mode toggle in Advanced section on all nodes |
641
- | 0.2.9 | _lastSent cache — skip redundant MQTT publishes (unchanged values) |
642
- | 0.2.8 | Transition jitter, micro-transition threshold, unconfigured ch skip |
643
- | 0.2.7 | transitionRateLimit + transitionHaUiTime wired from config node |
644
- | 0.2.6 | Zone optional — omitted from topic when blank |
645
- | 0.2.5 | buildLocation helper — strips parens, skips duplicates, uses " - " separator |
646
- | 0.2.4 | sw_version reads from package.json, unique_id = fixtureId only, MW3D removed |
647
- | 0.2.3 | Canvas label format: L-991-E · Downlight in Bedroom 1 |
648
- | 0.2.2 | Group Node status updates, broker feedback, version in editor panel |
649
- | 0.2.1 | Hidden mode — entity not auto-placed in dashboard |
650
- | 0.2.0 | Canvas button toggles remove/add, auto-discovery on deploy |
651
- | 0.1.9 | Correct disk store name, auto-discovery fallback, node ordering |
652
- | 0.1.8 | Section reordering all nodes, Config picker position |
653
- | 0.1.7 | Discovery Mode, broker already-connected fix |
654
- | 0.1.6 | Canvas button fix |
655
- | 0.1.5 | Context store error suppressed |
656
- | 0.1.2 | Original subflow colours restored |
657
- | 0.1.1 | UI fixes, postfix alphabet, Config Label, Discovery Mode |
477
+ | 0.4.0 | Group recovery no longer forwards state to children |
658
478
  | 0.1.0 | Initial release |
659
479
 
660
480
  ---
@@ -663,6 +483,14 @@ If `disk_values` shows `[module=memory]` — your config.js change didn't take e
663
483
 
664
484
  DeSwaggy — Discord: @deswaggy
665
485
 
666
- ## License
486
+ ## Licence
487
+
488
+ This package is licensed under **[GPL v3](https://www.gnu.org/licenses/gpl-3.0.txt)**.
489
+
490
+ You are free to use, modify, and distribute this package. Any derivative works must be distributed under the same GPL v3 licence. This package may not be taken proprietary, rebranded as closed source, or distributed without crediting the original author.
667
491
 
668
- Apache-2.0
492
+ This package was built from 6+ years of real-world professional installation experience. If you use it, improve it, or build on it — please contribute your changes back to the community.
493
+
494
+ ---
495
+
496
+ > **Note:** This is building automation DMX — fixtures, decoders, dimmers, relay switching. Not entertainment industry DMX (no movers, gobos, or fixture profiles). DMX is an open standard and this package works with any DMX controller that accepts MQTT payloads.
@@ -313,7 +313,7 @@
313
313
  </div>
314
314
 
315
315
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
316
- node-red-contrib-dmx-for-ha &nbsp;v0.6.28
316
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.33
317
317
  </div>
318
318
 
319
319
  </script>
@@ -137,7 +137,7 @@ module.exports = function (RED) {
137
137
  if (S.debugMode) {
138
138
  setStatus('red', 'dot', `ha-mqtt-button "${_debugId}" ⚠ DEBUG MODE ON`);
139
139
  node.warn(`[DEBUG] ha-mqtt-button "${_debugId}" — debug mode is enabled. Disable in production.`);
140
- setTimeout(function () {
140
+ S._debugTimer = setTimeout(function () {
141
141
  if (S.debugMode) {
142
142
  S.debugMode = false;
143
143
  node.warn(`[DEBUG] ha-mqtt-button "${_debugId}" — debug mode auto-disabled after 12 hours`);
@@ -171,11 +171,14 @@ module.exports = function (RED) {
171
171
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
172
172
  let reg = {};
173
173
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
174
- if (reg[fixtureId] && reg[fixtureId] !== node.id) {
175
- node.warn(`${fixtureId} DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
174
+ // Migration: handle old string format and new object format
175
+ const _existing = reg[fixtureId];
176
+ const _existingNodeId = _existing && typeof _existing === 'object' ? _existing.nodeId : _existing;
177
+ if (_existingNodeId && _existingNodeId !== node.id) {
178
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node $_{_existingNodeId}`);
176
179
  setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
177
180
  } else {
178
- reg[fixtureId] = node.id;
181
+ reg[fixtureId] = { nodeId: node.id, nodeType: 'ha-mqtt-button' };
179
182
  try { globalCtx.set(regKey, reg); } catch(e) {}
180
183
  }
181
184
  }
@@ -184,7 +187,9 @@ module.exports = function (RED) {
184
187
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
185
188
  let reg = {};
186
189
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
187
- if (reg[fixtureId] === node.id) {
190
+ const _unreg = reg[fixtureId];
191
+ const _unregNodeId = _unreg && typeof _unreg === 'object' ? _unreg.nodeId : _unreg;
192
+ if (_unregNodeId === node.id) {
188
193
  delete reg[fixtureId];
189
194
  try { globalCtx.set(regKey, reg); } catch(e) {}
190
195
  }
@@ -315,6 +320,7 @@ module.exports = function (RED) {
315
320
 
316
321
  // ── Cleanup ───────────────────────────────────────────────
317
322
  node.on('close', function (done) {
323
+ if (S._debugTimer) clearTimeout(S._debugTimer);
318
324
  broker.unsubscribe(S._activeBtnTopic || S.subscribeTopic, node.id);
319
325
  broker.unsubscribe(uiBtnCmdTopic, node.id);
320
326
  broker.deregister(node, done);
@@ -22,7 +22,7 @@
22
22
  availTopic: { value: 'avty' },
23
23
  enabledDefault: { value: 'true' },
24
24
  diskDelay: { value: '10' },
25
- transitionRateLimit: { value: '1' },
25
+ transitionRateLimit: { value: 1, validate: RED.validators.number() },
26
26
  transitionHaUiTime: { value: 1, validate: RED.validators.number() },
27
27
  offTransition: { value: 1, validate: RED.validators.number() },
28
28
  flashShort: { value: '1' },
@@ -228,6 +228,17 @@
228
228
  </span>
229
229
  </div>
230
230
 
231
+ <div class="form-row">
232
+ <label for="node-config-input-transitionRateLimit">
233
+ <i class="fa fa-tachometer"></i> Transition rate limit
234
+ </label>
235
+ <input type="number" id="node-config-input-transitionRateLimit"
236
+ min="0.1" max="1" step="0.05" style="width:70px" />
237
+ <span style="margin-left:8px; color:#999; font-size:0.85em;">
238
+ Multiplier for transition tick rate (0.1–1.0). Lower = fewer MQTT messages. Default: 1
239
+ </span>
240
+ </div>
241
+
231
242
  <div class="form-row">
232
243
  <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
233
244
  <i class="fa fa-sliders"></i> DMX Channel Limits
@@ -47,6 +47,8 @@
47
47
  showEffects: { value: true },
48
48
  transitions: { value: true },
49
49
  defaultState: { value: 'OFF' },
50
+ // Members
51
+ members: { value: [] },
50
52
  // Advanced
51
53
  maxDepth: { value: '10' },
52
54
  debugMode: { value: false },
@@ -55,17 +57,77 @@
55
57
  label: function () {
56
58
  if (this.name) return this.name;
57
59
  const id = `LG-${this.uid || '?'}${this.uidPostfix || ''}`;
58
- const type = this.deviceType || '';
59
- const sit = this.situation || 'in';
60
- const area = this.area || '';
61
- return `${id} · ${type} Group ${sit} ${area}`.trim();
60
+ const type = this.deviceType || '';
61
+ const sit = this.situation || 'in';
62
+ const area = this.area || '';
63
+ const sub = this.subLocation || '';
64
+ const loc = sub ? `${area} - ${sub}` : area;
65
+ return `(${id}) - ${type} Group ${sit} the ${loc}`.trim();
62
66
  },
63
67
 
64
68
  labelStyle: function () {
65
69
  return this.name ? 'node_label_italic' : '';
66
70
  },
67
71
 
72
+ oneditsave: function () {
73
+ const members = [];
74
+ const seen = {};
75
+ let hasDuplicate = false;
76
+ let duplicateVal = '';
77
+
78
+ $('#node-input-members-list').editableList('items').each(function () {
79
+ const type = $(this).find('select').val();
80
+ const value = $(this).find('input').val().trim();
81
+ if (!value) return;
82
+ const key = type + ':' + value;
83
+ if (seen[key]) { hasDuplicate = true; duplicateVal = value; }
84
+ seen[key] = true;
85
+ members.push({ type, value });
86
+ });
87
+
88
+ if (hasDuplicate) {
89
+ RED.notify(`Duplicate member "${duplicateVal}" — please remove the duplicate before saving.`, 'error');
90
+ return false;
91
+ }
92
+ this.members = members;
93
+ },
94
+
68
95
  oneditprepare: function () {
96
+ const node = this;
97
+
98
+ // ── Member list (editableList) ────────────────────────
99
+ $('#node-input-members-list').editableList({
100
+ addItem: function (container, i, data) {
101
+ data = data || { type: 'local', value: '' };
102
+ container.css({ display: 'flex', gap: '8px', alignItems: 'center', width: '100%' });
103
+
104
+ const typeSelect = $('<select style="width:90px; flex-shrink:0;">')
105
+ .append($('<option value="local">local</option>'))
106
+ .append($('<option value="remote">remote</option>'))
107
+ .val(data.type || 'local');
108
+
109
+ const valueInput = $('<input type="text" style="flex:1; min-width:0;">')
110
+ .attr('placeholder', data.type === 'remote' ? 'e.g. MW3D/BnB/group/cmd' : 'e.g. L-177-A')
111
+ .val(data.value || '');
112
+
113
+ typeSelect.on('change', function () {
114
+ const t = $(this).val();
115
+ valueInput.attr('placeholder', t === 'remote' ? 'e.g. MW3D/BnB/group/cmd' : 'e.g. L-177-A');
116
+ });
117
+
118
+ container.append(typeSelect).append(valueInput);
119
+ },
120
+ removable: true,
121
+ sortable: true,
122
+ });
123
+
124
+ // Populate existing members
125
+ const members = node.members || [];
126
+ members.forEach(function (m) {
127
+ $('#node-input-members-list').editableList('addItem', m);
128
+ });
129
+
130
+ // ── Area/sub-area lists ───────────────────────────────
69
131
  // Reuse area/sub-area lists from ha-mqtt-dmx if loaded,
70
132
  // otherwise define inline
71
133
  const areas = typeof HA_DMX_AREAS !== 'undefined' ? HA_DMX_AREAS : [
@@ -260,6 +322,22 @@
260
322
 
261
323
  <hr/>
262
324
 
325
+ <div class="form-row">
326
+ <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
327
+ <i class="fa fa-list"></i> Members
328
+ </label>
329
+ </div>
330
+
331
+ <div class="form-row">
332
+ <div style="margin-left:0; color:#999; font-size:0.85em; margin-bottom:8px;">
333
+ Add fixtures by ID (local) or MQTT topic (remote). Wires still work alongside this list.
334
+ Duplicate members will block the entire group command.
335
+ </div>
336
+ <ol id="node-input-members-list" style="width:100%;"></ol>
337
+ </div>
338
+
339
+ <hr/>
340
+
263
341
  <div class="form-row">
264
342
  <label style="width:100%; font-weight:bold; color:#999; font-size:0.85em; text-transform:uppercase; letter-spacing:0.05em;">
265
343
  <i class="fa fa-sliders"></i> Options
@@ -317,7 +395,7 @@
317
395
  </div>
318
396
 
319
397
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
320
- node-red-contrib-dmx-for-ha &nbsp;v0.6.28
398
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.33
321
399
  </div>
322
400
 
323
401
  </script>
@@ -328,9 +406,9 @@
328
406
  ============================================================ -->
329
407
  <script type="text/html" data-help-name="ha-mqtt-dmx-group">
330
408
  <p>
331
- Virtual DMX group. Appears as a single light entity in Home Assistant.
332
- Commands received from HA are forwarded via the <strong>Link</strong>
333
- output to all downstream DMX and Relay nodes.
409
+ Virtual DMX group node (v2). Appears as a single light entity in Home Assistant.
410
+ Commands are forwarded to member fixtures via the internal NR message bus (no extra MQTT traffic).
411
+ Wires and member list can be used together or independently.
334
412
  </p>
335
413
 
336
414
  <h3>Setup</h3>
@@ -338,10 +416,26 @@
338
416
  <li>Select your <strong>Config</strong> node</li>
339
417
  <li>Set a <strong>Group Name</strong> — shown as the HA entity friendly name</li>
340
418
  <li>Set <strong>Group ID</strong> — matches the plan ID (prefix LG is fixed)</li>
341
- <li>Wire the <strong>Link</strong> output to the input of each DMX or Relay node in the group</li>
419
+ <li>Add members to the <strong>Members</strong> list by fixture ID (local) or MQTT topic (remote)</li>
420
+ <li>Optionally wire the <strong>Link</strong> output to additional nodes</li>
342
421
  <li>Deploy — the group entity appears in HA</li>
343
422
  </ol>
344
423
 
424
+ <h3>Members list</h3>
425
+ <p>
426
+ <strong>Local</strong> members are resolved via the fixture ID registry (e.g. <code>L-177-A</code>, <code>P-149</code>).
427
+ Commands are forwarded internally via <code>RED.nodes.getNode()</code> — no MQTT round trip.
428
+ <strong>Remote</strong> members are MQTT topics for cross-instance groups (e.g. <code>MW3D/BnB/group/cmd</code>).
429
+ Remote commands include a <code>_dmxTrace</code> envelope for cross-instance loop detection.
430
+ </p>
431
+
432
+ <h3>Duplicate members</h3>
433
+ <p>
434
+ Duplicate entries in the member list will block the entire group command and show an orange warning.
435
+ The UI also prevents saving with duplicates. If a fixture ID is in both the member list and wired,
436
+ it will receive the command twice — avoid this by using one method only per fixture.
437
+ </p>
438
+
345
439
  <h3>Group ID</h3>
346
440
  <p>
347
441
  The <code>LG</code> prefix is fixed for all group nodes — it matches the
@@ -349,18 +443,12 @@
349
443
  and <code>L-992-C</code> form group <code>LG-992</code>.
350
444
  </p>
351
445
 
352
- <h3>Link output</h3>
353
- <p>
354
- Wire the Link output to one or more DMX or Relay node inputs.
355
- You can also wire it to another DMX Group Node input to create
356
- a nested group hierarchy.
357
- </p>
358
-
359
446
  <h3>Loop detection</h3>
360
447
  <p>
361
- The node tracks the cascade path in <code>msg.dmx_trace</code> and
362
- fires a warning if a loop is detected. Max group depth sets the
363
- maximum number of hops before a loop is assumed. Set to 0 to disable.
448
+ The node tracks the cascade path in <code>msg.dmx_trace</code> and <code>_dmxTrace</code>
449
+ and fires a warning if a loop is detected. Works across both wired and member list paths,
450
+ and across instances via the remote MQTT envelope. Max depth sets the maximum hops before
451
+ a loop is assumed. Set to 0 to disable.
364
452
  </p>
365
453
 
366
454
  <h3>Inputs</h3>
@@ -131,6 +131,7 @@ module.exports = function (RED) {
131
131
  transitions: config.transitions !== false,
132
132
  defaultState: config.defaultState || 'OFF',
133
133
  maxDepth: parseInt(config.maxDepth) || 10,
134
+ members: Array.isArray(config.members) ? config.members : [],
134
135
  flashShort: cfg.flashShort,
135
136
  flashLong: cfg.flashLong,
136
137
  diskDelay: cfg.diskDelay,
@@ -142,7 +143,7 @@ module.exports = function (RED) {
142
143
  if (S.debugMode) {
143
144
  setStatus('red', 'dot', `ha-mqtt-dmx-group "${_debugId}" ⚠ DEBUG MODE ON`);
144
145
  node.warn(`[DEBUG] ha-mqtt-dmx-group "${_debugId}" — debug mode is enabled. Disable in production.`);
145
- setTimeout(function () {
146
+ S._debugTimer = setTimeout(function () {
146
147
  if (S.debugMode) {
147
148
  S.debugMode = false;
148
149
  node.warn(`[DEBUG] ha-mqtt-dmx-group "${_debugId}" — debug mode auto-disabled after 12 hours`);
@@ -160,11 +161,14 @@ module.exports = function (RED) {
160
161
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
161
162
  let reg = {};
162
163
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
163
- if (reg[groupId] && reg[groupId] !== node.id) {
164
- node.warn(`${groupId} DUPLICATE FIXTURE ID — already registered by node ${reg[groupId]}`);
164
+ // Migration: handle old string format and new object format
165
+ const _existing = reg[groupId];
166
+ const _existingNodeId = _existing && typeof _existing === 'object' ? _existing.nodeId : _existing;
167
+ if (_existingNodeId && _existingNodeId !== node.id) {
168
+ node.warn(`${groupId} ⚠ DUPLICATE FIXTURE ID — already registered by node ${_existingNodeId}`);
165
169
  setStatus('red', 'dot', `DUPLICATE ID: ${groupId}`);
166
170
  } else {
167
- reg[groupId] = node.id;
171
+ reg[groupId] = { nodeId: node.id, nodeType: 'ha-mqtt-dmx-group' };
168
172
  try { globalCtx.set(regKey, reg); } catch(e) {}
169
173
  }
170
174
  }
@@ -173,7 +177,9 @@ module.exports = function (RED) {
173
177
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
174
178
  let reg = {};
175
179
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
176
- if (reg[groupId] === node.id) {
180
+ const _unreg = reg[groupId];
181
+ const _unregNodeId = _unreg && typeof _unreg === 'object' ? _unreg.nodeId : _unreg;
182
+ if (_unregNodeId === node.id) {
177
183
  delete reg[groupId];
178
184
  try { globalCtx.set(regKey, reg); } catch(e) {}
179
185
  }
@@ -250,7 +256,104 @@ module.exports = function (RED) {
250
256
  return { source: groupId, path: [groupId], depth: 1 };
251
257
  }
252
258
 
253
- // ── Forward to children via Link output ───────────────────
259
+ // ── Member list cache & resolution ─────────────────────
260
+ const _memberCache = {}; // fixtureId → { nodeId, nodeType }
261
+
262
+ function resolveMember(fixtureId) {
263
+ if (_memberCache[fixtureId]) return _memberCache[fixtureId];
264
+ const globalCtx = node.context().global;
265
+ const regKey = `dmx_fixture_ids_${cfg.siteId}`;
266
+ let reg = {};
267
+ try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
268
+ const entry = reg[fixtureId];
269
+ if (!entry) return null;
270
+ // Migration: handle old string format
271
+ const resolved = typeof entry === 'object'
272
+ ? entry
273
+ : { nodeId: entry, nodeType: 'unknown' };
274
+ _memberCache[fixtureId] = resolved;
275
+ return resolved;
276
+ }
277
+
278
+ function validateMembers() {
279
+ // Check for duplicate fixture IDs in member list
280
+ const seen = {};
281
+ for (const m of S.members) {
282
+ if (m.type !== 'local') continue;
283
+ if (seen[m.value]) return { ok: false, duplicate: m.value };
284
+ seen[m.value] = true;
285
+ }
286
+ // Check for duplicate remote topics
287
+ const seenTopics = {};
288
+ for (const m of S.members) {
289
+ if (m.type !== 'remote') continue;
290
+ if (seenTopics[m.value]) return { ok: false, duplicate: m.value };
291
+ seenTopics[m.value] = true;
292
+ }
293
+ return { ok: true };
294
+ }
295
+
296
+ function forwardToMemberList(payload, trace) {
297
+ if (S.members.length === 0) return;
298
+
299
+ // Validate — block entire group on duplicate
300
+ const validation = validateMembers();
301
+ if (!validation.ok) {
302
+ node.warn(`${groupId} — duplicate member "${validation.duplicate}" in list. Command blocked.`);
303
+ setStatus('orange', 'ring', `${groupId} DUPLICATE MEMBER: ${validation.duplicate}`);
304
+ return;
305
+ }
306
+
307
+ // Check for wire/list overlap
308
+ // We can't know wired node IDs here, but we can warn about any local member
309
+ // whose nodeId matches a node that received the message via wire
310
+ // This is handled by the DMX node itself receiving twice — we warn at group level
311
+ // by tracking which fixture IDs we're about to send to
312
+ const localIds = S.members
313
+ .filter(m => m.type === 'local')
314
+ .map(m => m.value);
315
+
316
+ let anyNotFound = false;
317
+
318
+ for (const member of S.members) {
319
+ if (member.type === 'local') {
320
+ const resolved = resolveMember(member.value);
321
+ if (!resolved) {
322
+ node.warn(`${groupId} — member "${member.value}" not found in registry, skipping`);
323
+ anyNotFound = true;
324
+ continue;
325
+ }
326
+ // Skip sensor-only node types
327
+ if (resolved.nodeType === 'ha-mqtt-button' || resolved.nodeType === 'ha-mqtt-pir') {
328
+ if (S.debugMode) node.warn(`[DEBUG] ${groupId} — skipping ${member.value} (${resolved.nodeType} is receive-only)`);
329
+ continue;
330
+ }
331
+ const targetNode = RED.nodes.getNode(resolved.nodeId);
332
+ if (!targetNode) {
333
+ node.warn(`${groupId} — member "${member.value}" node not found (${resolved.nodeId}), skipping`);
334
+ anyNotFound = true;
335
+ // Invalidate cache entry
336
+ delete _memberCache[member.value];
337
+ continue;
338
+ }
339
+ // Forward internally
340
+ if (S.debugMode) node.warn(`[DEBUG] ${groupId} → internal → ${member.value} (${resolved.nodeType})`);
341
+ targetNode.receive({ dmx_trace: trace, payload });
342
+
343
+ } else if (member.type === 'remote') {
344
+ // Forward via MQTT with trace embedded
345
+ const remotePayload = Object.assign({}, payload, { _dmxTrace: trace });
346
+ pub(member.value, remotePayload, false);
347
+ if (S.debugMode) node.warn(`[DEBUG] ${groupId} → remote → ${member.value}`);
348
+ }
349
+ }
350
+
351
+ if (anyNotFound) {
352
+ setStatus('yellow', 'ring', `${groupId} — some members not found`);
353
+ }
354
+ }
355
+
356
+ // ── Forward to children via Link output + member list ────
254
357
  function forwardToChildren(payload, incomingTrace) {
255
358
  if (isLoop(incomingTrace)) {
256
359
  node.warn(`${groupId} — loop detected! path:[${incomingTrace && incomingTrace.path.join(' → ')}]`);
@@ -258,7 +361,10 @@ module.exports = function (RED) {
258
361
  return;
259
362
  }
260
363
  const trace = buildTrace(incomingTrace);
364
+ // Wire output — backward compatible
261
365
  node.send([{ dmx_trace: trace, payload }]);
366
+ // Member list — internal + remote
367
+ forwardToMemberList(payload, trace);
262
368
  }
263
369
 
264
370
  // ── Effects ───────────────────────────────────────────────
@@ -417,6 +523,7 @@ module.exports = function (RED) {
417
523
 
418
524
  // ── Cleanup ───────────────────────────────────────────────
419
525
  node.on('close', function (done) {
526
+ if (S._debugTimer) clearTimeout(S._debugTimer);
420
527
  if (diskTimer) clearTimeout(diskTimer);
421
528
  broker.unsubscribe(cmdTopic, node.id);
422
529
  broker.deregister(node, done);
@@ -178,11 +178,13 @@
178
178
 
179
179
  label: function () {
180
180
  if (this.name) return this.name;
181
- const id = `${this.uidPrefix || 'L'}-${this.uid || '?'}${this.uidPostfix || ''}`;
182
- const type = this.deviceType || '';
183
- const sit = this.situation || 'in';
184
- const area = this.area || '';
185
- return `${id} · ${type} ${sit} ${area}`.trim();
181
+ const id = `${this.uidPrefix || 'L'}-${this.uid || '?'}${this.uidPostfix || ''}`;
182
+ const type = this.deviceType || '';
183
+ const sit = this.situation || 'in';
184
+ const area = this.area || '';
185
+ const sub = this.subLocation || '';
186
+ const loc = sub ? `${area} - ${sub}` : area;
187
+ return `(${id}) - ${type} ${sit} the ${loc}`.trim();
186
188
  },
187
189
 
188
190
  labelStyle: function () {
@@ -594,7 +596,7 @@
594
596
  </div>
595
597
 
596
598
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
597
- node-red-contrib-dmx-for-ha &nbsp;v0.6.28
599
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.33
598
600
  </div>
599
601
 
600
602
  </script>
@@ -172,7 +172,7 @@ module.exports = function (RED) {
172
172
  if (S.debugMode) {
173
173
  setStatus('red', 'dot', `ha-mqtt-dmx "${_debugId}" ⚠ DEBUG MODE ON`);
174
174
  node.warn(`[DEBUG] ha-mqtt-dmx "${_debugId}" — debug mode is enabled. Disable in production.`);
175
- setTimeout(function () {
175
+ S._debugTimer = setTimeout(function () {
176
176
  if (S.debugMode) {
177
177
  S.debugMode = false;
178
178
  node.warn(`[DEBUG] ha-mqtt-dmx "${_debugId}" — debug mode auto-disabled after 12 hours`);
@@ -653,11 +653,14 @@ module.exports = function (RED) {
653
653
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
654
654
  let reg = {};
655
655
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
656
- if (reg[fixtureId] && reg[fixtureId] !== node.id) {
657
- node.warn(`${fixtureId} DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
656
+ // Migration: handle old string format and new object format
657
+ const _existing = reg[fixtureId];
658
+ const _existingNodeId = _existing && typeof _existing === 'object' ? _existing.nodeId : _existing;
659
+ if (_existingNodeId && _existingNodeId !== node.id) {
660
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node $_{_existingNodeId}`);
658
661
  setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
659
662
  } else {
660
- reg[fixtureId] = node.id;
663
+ reg[fixtureId] = { nodeId: node.id, nodeType: 'ha-mqtt-dmx' };
661
664
  try { globalCtx.set(regKey, reg); } catch(e) {}
662
665
  }
663
666
  }
@@ -666,7 +669,9 @@ module.exports = function (RED) {
666
669
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
667
670
  let reg = {};
668
671
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
669
- if (reg[fixtureId] === node.id) {
672
+ const _unreg = reg[fixtureId];
673
+ const _unregNodeId = _unreg && typeof _unreg === 'object' ? _unreg.nodeId : _unreg;
674
+ if (_unregNodeId === node.id) {
670
675
  delete reg[fixtureId];
671
676
  try { globalCtx.set(regKey, reg); } catch(e) {}
672
677
  }
@@ -881,6 +886,7 @@ module.exports = function (RED) {
881
886
 
882
887
  // ── Cleanup ───────────────────────────────────────────────
883
888
  node.on('close', function (done) {
889
+ if (S._debugTimer) clearTimeout(S._debugTimer);
884
890
  clearChannelRegistry();
885
891
  stopEffect();
886
892
  if (diskTimer) clearTimeout(diskTimer);
@@ -318,7 +318,7 @@
318
318
  </div>
319
319
 
320
320
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
321
- node-red-contrib-dmx-for-ha &nbsp;v0.6.28
321
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.33
322
322
  </div>
323
323
 
324
324
  </script>
@@ -135,7 +135,7 @@ module.exports = function (RED) {
135
135
  if (S.debugMode) {
136
136
  setStatus('red', 'dot', `ha-mqtt-pir "${_debugId}" ⚠ DEBUG MODE ON`);
137
137
  node.warn(`[DEBUG] ha-mqtt-pir "${_debugId}" — debug mode is enabled. Disable in production.`);
138
- setTimeout(function () {
138
+ S._debugTimer = setTimeout(function () {
139
139
  if (S.debugMode) {
140
140
  S.debugMode = false;
141
141
  node.warn(`[DEBUG] ha-mqtt-pir "${_debugId}" — debug mode auto-disabled after 12 hours`);
@@ -171,11 +171,14 @@ module.exports = function (RED) {
171
171
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
172
172
  let reg = {};
173
173
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
174
- if (reg[fixtureId] && reg[fixtureId] !== node.id) {
175
- node.warn(`${fixtureId} DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
174
+ // Migration: handle old string format and new object format
175
+ const _existing = reg[fixtureId];
176
+ const _existingNodeId = _existing && typeof _existing === 'object' ? _existing.nodeId : _existing;
177
+ if (_existingNodeId && _existingNodeId !== node.id) {
178
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node $_{_existingNodeId}`);
176
179
  setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
177
180
  } else {
178
- reg[fixtureId] = node.id;
181
+ reg[fixtureId] = { nodeId: node.id, nodeType: 'ha-mqtt-pir' };
179
182
  try { globalCtx.set(regKey, reg); } catch(e) {}
180
183
  }
181
184
  }
@@ -184,7 +187,9 @@ module.exports = function (RED) {
184
187
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
185
188
  let reg = {};
186
189
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
187
- if (reg[fixtureId] === node.id) {
190
+ const _unreg = reg[fixtureId];
191
+ const _unregNodeId = _unreg && typeof _unreg === 'object' ? _unreg.nodeId : _unreg;
192
+ if (_unregNodeId === node.id) {
188
193
  delete reg[fixtureId];
189
194
  try { globalCtx.set(regKey, reg); } catch(e) {}
190
195
  }
@@ -349,6 +354,7 @@ module.exports = function (RED) {
349
354
 
350
355
  // ── Cleanup ───────────────────────────────────────────────
351
356
  node.on('close', function (done) {
357
+ if (S._debugTimer) clearTimeout(S._debugTimer);
352
358
  cancelWarmup();
353
359
  broker.unsubscribe(S._activePirTopic || S.subscribeTopic, node.id);
354
360
  broker.deregister(node, done);
@@ -301,7 +301,7 @@
301
301
  </div>
302
302
 
303
303
  <div style="margin-top:16px; padding-top:8px; border-top:1px solid #444; color:#666; font-size:0.8em; text-align:right;">
304
- node-red-contrib-dmx-for-ha &nbsp;v0.6.28
304
+ node-red-contrib-dmx-for-ha &nbsp;v0.6.33
305
305
  </div>
306
306
 
307
307
  </script>
@@ -159,7 +159,7 @@ module.exports = function (RED) {
159
159
  if (S.debugMode) {
160
160
  setStatus('red', 'dot', `ha-mqtt-relay "${_debugId}" ⚠ DEBUG MODE ON`);
161
161
  node.warn(`[DEBUG] ha-mqtt-relay "${_debugId}" — debug mode is enabled. Disable in production.`);
162
- setTimeout(function () {
162
+ S._debugTimer = setTimeout(function () {
163
163
  if (S.debugMode) {
164
164
  S.debugMode = false;
165
165
  node.warn(`[DEBUG] ha-mqtt-relay "${_debugId}" — debug mode auto-disabled after 12 hours`);
@@ -178,11 +178,14 @@ module.exports = function (RED) {
178
178
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
179
179
  let reg = {};
180
180
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
181
- if (reg[fixtureId] && reg[fixtureId] !== node.id) {
182
- node.warn(`${fixtureId} DUPLICATE FIXTURE ID — already registered by node ${reg[fixtureId]}`);
181
+ // Migration: handle old string format and new object format
182
+ const _existing = reg[fixtureId];
183
+ const _existingNodeId = _existing && typeof _existing === 'object' ? _existing.nodeId : _existing;
184
+ if (_existingNodeId && _existingNodeId !== node.id) {
185
+ node.warn(`${fixtureId} ⚠ DUPLICATE FIXTURE ID — already registered by node $_{_existingNodeId}`);
183
186
  setStatus('red', 'dot', `DUPLICATE ID: ${fixtureId}`);
184
187
  } else {
185
- reg[fixtureId] = node.id;
188
+ reg[fixtureId] = { nodeId: node.id, nodeType: 'ha-mqtt-relay' };
186
189
  try { globalCtx.set(regKey, reg); } catch(e) {}
187
190
  }
188
191
  }
@@ -191,7 +194,9 @@ module.exports = function (RED) {
191
194
  const regKey = `dmx_fixture_ids_${cfg.siteId}`;
192
195
  let reg = {};
193
196
  try { reg = globalCtx.get(regKey) || {}; } catch(e) {}
194
- if (reg[fixtureId] === node.id) {
197
+ const _unreg = reg[fixtureId];
198
+ const _unregNodeId = _unreg && typeof _unreg === 'object' ? _unreg.nodeId : _unreg;
199
+ if (_unregNodeId === node.id) {
195
200
  delete reg[fixtureId];
196
201
  try { globalCtx.set(regKey, reg); } catch(e) {}
197
202
  }
@@ -453,6 +458,7 @@ module.exports = function (RED) {
453
458
 
454
459
  // ── Cleanup ───────────────────────────────────────────────
455
460
  node.on('close', function (done) {
461
+ if (S._debugTimer) clearTimeout(S._debugTimer);
456
462
  stopEffect();
457
463
  if (diskTimer) clearTimeout(diskTimer);
458
464
  broker.unsubscribe(cmdTopic, node.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-dmx-for-ha",
3
- "version": "0.6.28",
3
+ "version": "0.6.33",
4
4
  "description": "DMX lighting control for Home Assistant via Node-RED and MQTT. Place a node, fill in the settings, deploy. Full HA device registry integration with RGBW/RGBWW/CCT/brightness colour modes, transitions, effects, and group control.",
5
5
  "keywords": [
6
6
  "node-red",