@johanwistbacka/node-red-contrib-sw-light 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.9 — Label target development candidate
4
+
5
+ - Adds Entity/Label targets, stable Label ID autocomplete, and structured `msg.target` overrides; old Entity flows keep their default behavior.
6
+ - Resolves direct entity/device labels and HA's labeled-area relationships, filters non-light/hidden/disabled entries and deduplicates members. Area is not exposed as a target.
7
+ - Extracts the existing light-control path into one controller per entity, preserving independent override, capability filtering, automatic limits and Adaptive Lighting.
8
+ - Adds cached membership, registry/reconnect invalidation, idle refresh and reconciled state observation; close/redeploy cleans node-owned resources.
9
+ - Adds aggregate Label results/status, per-entity partial failures and empty/deleted target events.
10
+ - Documents the APIs, refresh/lifecycle contract and exact physical multi-light verification sequence in `docs/labels.md`.
11
+ - Local check and full tests include the published HA contrib 0.80.3 integration. Real HA/physical-device verification is pending. No npm publication or HA installation.
12
+
3
13
  ## 0.1.8 — 2026-09-30
4
14
 
5
15
  - Simplify Adaptive Lighting to an On/Off checkbox with collapsed advanced profile selection.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
- # SW Light — v0.1.8
1
+ # SW Light — v0.1.9
2
2
 
3
- A reusable Node-RED node for one Home Assistant `light.*` entity. Select an
4
- existing HA server, select a light, deploy in a **development** environment,
3
+ A reusable Node-RED node for a Home Assistant `light.*` Entity or Label. Select an
4
+ existing HA server, select a target, deploy in a **development** environment,
5
5
  and select Action **On**, **Off**, **Toggle**, or **From msg**. Display name:
6
6
  **SW Light**; flow type: `sw-light`.
7
7
 
@@ -10,6 +10,32 @@ selected light, checks manual override and calls the HA light service itself.
10
10
  No external HA Action node is required; the two outputs are for observation
11
11
  and integration only. With a fixed Action, a normal timestamp Inject is enough.
12
12
 
13
+ ## Label targets in 0.1.9
14
+
15
+ Choose **Target → Entity** (the default, including existing flows) or **Label**.
16
+ Label autocomplete displays the HA name and stores the stable Label ID. Current
17
+ members are resolved from entity, device and labeled-area registry relationships;
18
+ non-lights are excluded and duplicate light IDs are removed. Area is not a target
19
+ choice in this version.
20
+
21
+ Each resolved light reuses the same controller with independent override,
22
+ capabilities, brightness limits and Adaptive Lighting. One overridden/unavailable
23
+ member does not stop the others. Output 1 for Labels contains `target`,
24
+ `resolved_entity_ids`, per-light `results` and success/skipped/failure/manual counts;
25
+ output 2 identifies the actual entity for per-light events. Status aggregates
26
+ last observed states.
27
+
28
+ Membership refreshes on provider registry/reconnect events and a 30-second cache
29
+ expiry/idle reconciliation fallback. One filtered state subscription per node is
30
+ reconciled without per-light subscription leaks. Optional `msg.target =
31
+ {type: "label", id: "label_id"}` or `{type: "entity", id: "light.example"}` overrides
32
+ configuration for one input; that target remains observed until the next input.
33
+ Departed target members discard their controller's explicit latch.
34
+
35
+ See [Label architecture, API evidence and exact multi-light test sequence](docs/labels.md).
36
+ This is a local development candidate; no npm publication or HA installation is
37
+ part of its preparation. Physical Label behavior still needs the documented test.
38
+
13
39
  ## Package identity and migration
14
40
 
15
41
  The permanent npm package name is `@johanwistbacka/node-red-contrib-sw-light`.
@@ -26,7 +52,7 @@ or, with Node-RED stopped, run these commands in its user directory:
26
52
 
27
53
  ```sh
28
54
  npm uninstall node-red-contrib-sw-light
29
- npm install @johanwistbacka/node-red-contrib-sw-light@0.1.8
55
+ npm install @johanwistbacka/node-red-contrib-sw-light@0.1.9
30
56
  ```
31
57
 
32
58
  Restart Node-RED and reload the editor. Existing flow nodes retain type
@@ -52,7 +78,7 @@ are enabled. Test with a development light before relying on this node.
52
78
  After this version is published, install it in your Node-RED user directory:
53
79
 
54
80
  ```sh
55
- npm install @johanwistbacka/node-red-contrib-sw-light@0.1.8
81
+ npm install @johanwistbacka/node-red-contrib-sw-light@0.1.9
56
82
  ```
57
83
 
58
84
  After publication, the current development release is available under npm latest:
@@ -75,7 +101,7 @@ For edits, use npm's local directory installation/link or reinstall an archive
75
101
  created with `npm pack`. Restart the isolated Node-RED runtime after changes.
76
102
  There is no build step or additional runtime dependency.
77
103
 
78
- The current development release uses the normal SemVer version `0.1.8` and
104
+ The current development release uses the normal SemVer version `0.1.9` and
79
105
  npm tag `latest` for Node-RED compatibility. This version remains experimental;
80
106
  it is not a SemVer prerelease. Every installation/publication candidate needs a
81
107
  unique version. Use `npm version patch --no-git-tag-version` for the next normal
@@ -87,7 +113,7 @@ After publication, submit the package through the
87
113
  [Node-RED Flow Library](https://flows.nodered.org/add/node), or request a refresh
88
114
  if it is already listed.
89
115
 
90
- ## Temporary runtime diagnostics in 0.1.8
116
+ ## Temporary runtime diagnostics in 0.1.9
91
117
 
92
118
  This build logs `[SW Light debug]` at construction and on the first input,
93
119
  then when the relevant structure/readiness changes. It reports version,
@@ -97,7 +123,7 @@ It never logs server IDs, entity IDs, credentials, hosts, full objects or state
97
123
  snapshots. Provider initialization errors are reduced to a generic message.
98
124
 
99
125
  After installation, restart Node-RED and verify a log line containing
100
- `[SW Light debug] version=0.1.8`. Inject once and retain the prefixed lines,
126
+ `[SW Light debug] version=0.1.9`. Inject once and retain the prefixed lines,
101
127
  along with any error/stack. Those lines distinguish missing configuration,
102
128
  a missing provider client and a connected client with unexpected methods.
103
129
  The real installation problem remains unverified until this build is tested
@@ -295,7 +321,7 @@ Adaptive Lighting, including sun/time and sleep settings.
295
321
 
296
322
  ### Real-world verification
297
323
 
298
- 1. Install the local 0.1.8 archive, restart Node-RED, and verify its startup
324
+ 1. Install the local 0.1.9 archive, restart Node-RED, and verify its startup
299
325
  version. Use a test light with `color_temp` support. Connect both outputs to Debug.
300
326
  2. In HA, expose the profile's configuration attributes for automatic discovery
301
327
  (or select it under Advanced). Keep its main switch off, use color-temperature
@@ -327,14 +353,14 @@ Actual Adaptive Lighting version, exposed configuration, sleep behavior, lamp
327
353
  latency/rounding and the two-action race still require verification on your HA
328
354
  installation. Automated tests mock that service boundary, not its algorithm.
329
355
 
330
- ## Test the updated 0.1.8 in your HA Node-RED installation
356
+ ## Test the updated 0.1.9 in your HA Node-RED installation
331
357
 
332
358
  1. Save/export your current test flow. Transfer the rebuilt local
333
- `johanwistbacka-node-red-contrib-sw-light-0.1.8.tgz` to the Node-RED host. Check that HA contrib
359
+ `johanwistbacka-node-red-contrib-sw-light-0.1.9.tgz` to the Node-RED host. Check that HA contrib
334
360
  is 0.80.3; do not upgrade it for this task.
335
361
  2. In a terminal for that Node-RED instance, find **User directory** in its
336
362
  startup log. Change to that directory (not a global npm directory) and run
337
- `npm install ./johanwistbacka-node-red-contrib-sw-light-0.1.8.tgz --ignore-scripts --no-audit --no-fund`.
363
+ `npm install ./johanwistbacka-node-red-contrib-sw-light-0.1.9.tgz --ignore-scripts --no-audit --no-fund`.
338
364
  Use the add-on's supported local package installation mechanism if its terminal
339
365
  does not expose that user directory/npm. Restart Node-RED/the Node-RED add-on
340
366
  and reload the editor so the updated runtime and HTML are both loaded.
package/docs/labels.md ADDED
@@ -0,0 +1,198 @@
1
+ # Label targets — 0.1.9 development candidate
2
+
3
+ ## Investigated APIs and membership
4
+
5
+ The installed/published HA contrib **0.80.3** artifact was inspected, including
6
+ `Websocket.send`, `subscribeMessage`, registry collections, registry event names,
7
+ private editor selector and HTTP routes. It already owns registry subscriptions
8
+ but does not expose a public custom-node Label selector or Label HTTP endpoint.
9
+ SW Light continues to share its authenticated client through the existing adapter.
10
+ No separate connection, credentials or provider shutdown are introduced.
11
+
12
+ HA official WebSocket commands used:
13
+
14
+ - `config/label_registry/list`: `label_id`, human-readable `name`.
15
+ - `config/entity_registry/list`: `entity_id`, `labels`, `device_id`, `area_id`,
16
+ `hidden_by`, `disabled_by`, `entity_category`.
17
+ - `config/device_registry/list`: `id`, `labels`, `area_id`.
18
+ - `config/area_registry/list`: `area_id`, `labels`.
19
+
20
+ Membership follows HA 2026.9 service target semantics: directly labeled entities,
21
+ entities of directly labeled devices, and entities reached through labeled areas.
22
+ An entity's explicit area takes precedence over its device's area. A label on a
23
+ parent device is not inherited through `via_device_id`. Hidden entries are excluded;
24
+ indirect device/area expansion excludes config/diagnostic entities, whereas a
25
+ directly labeled entity may have a category. SW Light additionally excludes disabled
26
+ entries and non-`light.*` IDs, and deduplicates the final list. These are registry
27
+ entities; SW Light does not expand a light group's members or custom integration
28
+ relationships. A `light` group can therefore coexist with its physical members;
29
+ avoid putting both in the test Label if independent physical control is required.
30
+
31
+ Primary sources:
32
+
33
+ - [HA Labels](https://www.home-assistant.io/docs/organizing/labels/)
34
+ - [HA 2026.9.0 target resolution](https://github.com/home-assistant/core/blob/2026.9.0/homeassistant/helpers/target.py)
35
+ - [HA WebSocket API](https://developers.home-assistant.io/docs/api/websocket/)
36
+ - [HA contrib 0.80.3](https://www.npmjs.com/package/node-red-contrib-home-assistant-websocket/v/0.80.3)
37
+
38
+ Area relationships are necessary for Label semantics. **Area is not an exposed
39
+ target type**, and no Area/Room configuration, occupancy, scene or mode logic is added.
40
+
41
+ ## Architecture and editor
42
+
43
+ `targets.js` validates `{type,id}`, resolves registry membership and builds aggregate
44
+ status. `controller.js` owns the existing execution path, one Override detector per
45
+ entity, command planning, capability filtering and Adaptive Lighting. `sw-light.js`
46
+ coordinates controllers and output. `ha-adapter.js` alone knows provider internals.
47
+ Adding a future Area branch to target validation/resolution will reuse these same
48
+ controllers; it does not require another light-control implementation.
49
+
50
+ Target choices are **Entity** (default) and **Label**. Missing `targetType` in old
51
+ flows means Entity; `entityId` and old outputs/status remain compatible. Label uses
52
+ `labelId`, a stable HA ID. Autocomplete displays `name (label_id)` and a separate
53
+ name helper; it stores only the ID. Changing server aborts requests and ignores
54
+ obsolete responses. Cancel/save destroys autocompletes and handlers.
55
+
56
+ Entity autocomplete still uses HA contrib's `homeassistant/states` route. Label
57
+ uses `/sw-light/labels/:serverId`, protected by Node-RED `server.read`, fetching
58
+ `config/label_registry/list` through the shared adapter. A deployed connected server
59
+ is required for suggestions; entering a known stable Label ID remains possible.
60
+
61
+ ## Refresh, subscriptions and overrides
62
+
63
+ A node caches an immutable registry snapshot for **30 seconds**. Resolved lists are
64
+ cached per snapshot and Label ID, so repeated brightness inputs do not rescan all
65
+ registries. Provider events `labels_updated`, `entity_registry_updated`,
66
+ `devices_updated`, `areas_updated` invalidate the snapshot and debounce reconciliation
67
+ by 100 ms. The provider itself throttles registry updates. Ready/connect/state-loaded
68
+ and disconnect events also invalidate it. A 30-second timer reconciles idle nodes,
69
+ providing a fallback if an update event is missed; commands refresh expired snapshots.
70
+ An invalidated in-flight snapshot is rejected rather than installed as fresh data.
71
+
72
+ There is **one raw state_changed subscription per node**, with a reconciled Set of
73
+ member IDs, rather than a subscription for every light. Departed IDs stop updating
74
+ that node; added IDs get their own controller. Remaining controllers retain their
75
+ explicit/threshold flags. Removed controllers are discarded; rejoining lights start
76
+ with fresh flags and observe their actual state. Registry resolution failure clears
77
+ the watched membership and reports TARGET UNAVAILABLE; existing controllers are
78
+ retained until successful reconciliation. No stale members receive a command.
79
+
80
+ The shared WebSocket library handles reconnect resubscription. Closing/redeploying
81
+ removes this node's listeners, subscription, timers and controllers; it leaves the
82
+ provider's listeners and connection intact. Close during an in-flight service prevents
83
+ later light/adaptive calls and output from that node.
84
+
85
+ `msg.target = {type:"label", id:"cozy"}` or
86
+ `msg.target = {type:"entity", id:"light.example"}` overrides configuration for that
87
+ input. Its members remain observed until the next input; an input without `target`
88
+ restores configured targeting. Reconciliation discards controllers that leave the
89
+ active target, including their explicit latch. Use a stable target when retaining
90
+ explicit latches across commands. Unknown types, including Area, fail validation.
91
+
92
+ Each light retains independent state, automatic maximum, capability filtering,
93
+ override flags and Adaptive eligibility. With max 94%, threshold 98%, window 94%,
94
+ table 100% and floor 70%, automatic OFF controls window/floor and skips table only.
95
+ Lowering table below threshold releases table only. Explicit latches still require
96
+ explicit release or observed OFF; manual-source commands keep their existing bypass.
97
+ The brightness-255 guard remains independent of the configured detector.
98
+
99
+ ON/OFF-only lights receive no brightness/color parameters. RGB, CT, brightness-only
100
+ and transition support are inspected per actual entity. Unsupported parameters emit
101
+ entity-specific events and are omitted. A member's range/transport/availability error
102
+ does not prevent later members from running.
103
+
104
+ Adaptive Lighting behavior remains unchanged: eligible automatic observed OFF → ON
105
+ first executes the light action, then applies one-shot CT adaptation for that light
106
+ only. Profile discovery uses that light's exact configuration membership. Unsupported
107
+ members turn on normally; missing/running/ambiguous profiles or failed apply emit
108
+ warnings while the successful light action stays successful. No solar algorithm is
109
+ copied or shared between members.
110
+
111
+ ## Outputs and status
112
+
113
+ Entity output 1 retains the prior normalized result; blocked Entity commands still
114
+ emit their event without a result. Label output 1 retains the incoming message and
115
+ adds `sw_light` with `version:1`, `target:{type,id}`, `resolved_entity_ids`, `results`
116
+ and `counts:{succeeded,skipped,failed,manual}`. Each result identifies `entity_id`,
117
+ observed state/override and its outcome. Successful results include service data and
118
+ Adaptive details; skipped results explain manual override; failed results contain
119
+ error type/message and whether the light action completed. Adaptive warnings alone
120
+ are not counted as light-action failure.
121
+
122
+ Success means service completion, **not confirmed physical state**. Output and Label
123
+ status use last observed HA states. The aggregate can show `2 on · 1 manual · 2 off`
124
+ or `4 on · 1 unavailable`; manual members are counted separately from automatic ON.
125
+ Empty Labels emit `target_empty`, an empty aggregate and LABEL EMPTY. Deleted Labels
126
+ or missing registries emit `target_resolution_failure`, TARGET UNAVAILABLE and Catch
127
+ error handling, without controlling old members. Per-member partial failure returns
128
+ an aggregate and entity-specific output 2 events without failing the whole input.
129
+
130
+ ## Exact real-world verification sequence
131
+
132
+ This sequence is pending; do it after separately authorizing/installing the local
133
+ candidate in your development HA/Node-RED environment.
134
+
135
+ 1. Export the test flow and note the currently installed version. Restart Node-RED
136
+ after installing 0.1.9, reload the editor and verify the startup `version=0.1.9`.
137
+ Keep one old Entity node in the flow and confirm it operates without reconfiguration.
138
+ 2. Create one HA Label named **SW Light test**, record its generated stable ID, and
139
+ use physical test lights (not a group entity): a dimmable CT light A, an RGB or
140
+ brightness-only light B, and an ON/OFF light C where available. Add A directly,
141
+ B through its device, and C directly. Also add one sensor. If A's device is shared
142
+ with other lights, labeling it deliberately includes those primary lights too.
143
+ 3. Select Target **Label**, choose SW Light test by name, save/reopen and verify the
144
+ stable ID remains selected. Configure From msg, brightness/max 94%, threshold 98%,
145
+ 1-second fades. Connect both outputs to complete-message Debug nodes.
146
+ 4. Start all members OFF. Inject `{action:"on"}`. Confirm all physical members turn
147
+ on; A/B receive at most 94%, C receives plain ON. Check IDs, independent service
148
+ data, success counts and subsequent observed aggregate status.
149
+ 5. Set A to approximately 94%, B to 100% manually in HA, and leave C ON. Wait for
150
+ output 2's B `manual_override` event. Inject `{action:"off"}`. A and C must turn
151
+ OFF while B remains ON; aggregate must show B skipped/manual and two successes.
152
+ Optionally use three dimmable lights with values 94/100/70 to match the exact example.
153
+ 6. Lower B to 70% through HA and wait for its `override_released`. Inject automatic
154
+ ON and OFF; B now follows both. Raise A to 100%, leave B below threshold, repeat
155
+ OFF and confirm only A is protected. Inject `{action:"off",source:"manual"}` and
156
+ confirm the manually overridden member also turns OFF.
157
+ 7. Start A/B/C OFF. Enable Adaptive Lighting on SW Light and keep the relevant
158
+ profile main switch OFF, CT sleep color configured, configuration attributes
159
+ enabled. Inject ON. Confirm A receives its own one-shot CT apply while B/C turn on
160
+ normally. Verify no Adaptive brightness change, correct per-light profiles and
161
+ warnings for unsupported members. Already-ON brightness changes must not reapply.
162
+ 8. Add a fourth physical light D to the same Label while the node is idle. Wait for
163
+ reconciliation (normally provider event delay + 100 ms; fallback about 30 seconds)
164
+ and confirm D appears in status/next result. Remove C; changing C in HA must no
165
+ longer produce events for this node. No flow edit should be necessary.
166
+ 9. Label A's device as well as A directly; confirm A appears once. Where convenient,
167
+ label a test area with the same Label and verify the documented area precedence
168
+ and extra primary lights. Remove that area assignment afterward.
169
+ 10. Make B unavailable through the test device/integration's normal controls. Inject
170
+ automatic ON/OFF. Available members must still succeed; output identifies B as
171
+ failed/unavailable. Restore B and verify recovery.
172
+ 11. Disconnect/reconnect the HA connection in the development environment, then send
173
+ a fresh command after ready. Verify refreshed membership, external override
174
+ observation and no duplicate events. Redeploy only the SW Light node, then remove
175
+ it; confirm removed nodes no longer emit events/status or light actions.
176
+ 12. Remove all light assignments while retaining the Label/sensor: expect LABEL EMPTY
177
+ and no calls. Delete the Label: expect target_resolution_failure and no old calls.
178
+ Recreate a Label and select its actual new ID; do not assume the name restores identity.
179
+
180
+ Still requires real HA verification: actual registry permissions/schema on the
181
+ installed HA version, rendered autocomplete/save/reopen, physical service/state timing,
182
+ live membership update latency, real network reconnect/redeploy and Adaptive profile
183
+ behavior. Local tests simulate transport and never connect to or install into HA.
184
+
185
+ ## Changed files and local checks
186
+
187
+ Runtime/editor: `nodes/sw-light.js`, `nodes/sw-light.html`, `lib/light.js`,
188
+ `lib/ha-adapter.js`, new `lib/controller.js`, new `lib/targets.js`.
189
+ Package/documentation: `package.json`, `README.md`, `CHANGELOG.md`,
190
+ `docs/integration.md`, new `docs/labels.md`.
191
+ Tests: `test/published-integration.test.js`, new `test/targets.test.js`,
192
+ `test/label-runtime.test.js`, `test/label-adapter.test.js`, `test/label-editor.test.js`.
193
+ Existing Adaptive Lighting implementation and example flows are unchanged.
194
+
195
+ `npm run check` passed. Full suite with the isolated 0.80.3 installation enabled:
196
+ **65 tests passed, zero failures/skips**. Version is prepared through
197
+ `npm version 0.1.9 --no-git-tag-version`. No Git repository exists in this workspace;
198
+ no commit/tag/release/publication/HA deployment is part of this candidate.
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ const light = require('./light');
3
+ const adaptive = require('./adaptive');
4
+
5
+ // One independent controller per entity, shared by every target type.
6
+ function createController(c, adapter, node, isClosed) {
7
+ const detector = new light.Override(c);
8
+ let lastAvailability;
9
+ const protectedAtFullBrightness = state => state?.state === 'on' && state.attributes?.brightness === 255;
10
+ const overrideActive = state => detector.active || protectedAtFullBrightness(state);
11
+ function event(type, detail = {}, msg = {}, send = node.send.bind(node)) {
12
+ if (isClosed()) return;
13
+ send([null, { ...msg, sw_light_event: { version: 1, type, entity_id: c.entityId,
14
+ timestamp: new Date().toISOString(), ...detail } }]);
15
+ }
16
+ function overrideUpdate(fn, msg, send) {
17
+ const before = detector.active;
18
+ fn();
19
+ if (before !== detector.active) event(detector.active ? 'manual_override' : 'override_released',
20
+ { active: detector.active, strategy: c.overrideStrategy }, msg, send);
21
+ }
22
+ function observe(state) {
23
+ if (isClosed()) return;
24
+ overrideUpdate(() => detector.observe(state));
25
+ const availability = !state ? 'missing' : state.state;
26
+ if (['missing', 'unknown', 'unavailable'].includes(availability)) {
27
+ node.status({ fill: 'red', shape: 'ring', text: 'ENTITY UNAVAILABLE' });
28
+ if (lastAvailability !== availability) event('unavailable', { reason: availability });
29
+ } else {
30
+ const active = overrideActive(state);
31
+ const info = light.normalized(state, active, c.adaptiveEnabled, 'state_changed');
32
+ let text = active ? 'MANUAL' : `AUTO · ${info.state.toUpperCase()}`;
33
+ if (info.state === 'on' && info.brightness_pct !== null) text += ` · ${info.brightness_pct}%`;
34
+ if (info.state === 'on' && info.color_temp_kelvin) text += ` · ${info.color_temp_kelvin}K`;
35
+ if (info.state === 'on' && c.adaptiveEnabled) text += ' · adaptive';
36
+ node.status({ fill: active ? 'yellow' : info.state === 'on' ? 'green' : 'grey', shape: 'dot', text });
37
+ }
38
+ lastAvailability = availability;
39
+ }
40
+
41
+ async function execute(msg, send) {
42
+ let command, serviceCompleted = false;
43
+ try {
44
+ const state = adapter.state(c.entityId);
45
+ if (!state || !['on', 'off'].includes(state.state)) throw light.fault(`Light is ${state?.state ?? 'missing'}`, 'unavailable');
46
+ observe(state);
47
+ const requestedOverride = Object.prototype.hasOwnProperty.call(msg, 'manual_override');
48
+ if (requestedOverride && (msg.source ?? 'automatic') !== 'manual') throw light.fault('manual_override requires source: manual');
49
+ if (requestedOverride && typeof msg.manual_override !== 'boolean') throw light.fault('manual_override must be boolean');
50
+ command = light.plan(c, msg, state, overrideActive(state));
51
+ if (requestedOverride) {
52
+ overrideUpdate(() => detector.set(msg.manual_override), msg, send);
53
+ command.blocked = false; // validated manual source
54
+ }
55
+ if (command.unsupported.length) event('unsupported', { parameters: command.unsupported, reason: 'lamp_capabilities_or_action' }, msg, send);
56
+ if (command.blocked) {
57
+ event('manual_override', { active: true, blocked: true, requested_action: command.action }, msg, send);
58
+ const percent = Number.isFinite(state.attributes?.brightness) ? Math.round(light.pct(state.attributes.brightness)) : '?';
59
+ node.status({ fill: 'yellow', shape: 'dot', text: `MANUAL · ${percent}% · BLOCK ${command.action.toUpperCase()}` });
60
+ return { ...light.normalized(state, overrideActive(state), command.adaptiveEnabled, command.service), entity_id: c.entityId, outcome: 'skipped', reason: 'manual_override', source: command.source, requested_action: command.action, state_confirmation: 'last_observed' };
61
+ }
62
+ await adapter.call('light', command.service, command.data, { entity_id: c.entityId });
63
+ serviceCompleted = true;
64
+ if (isClosed()) return { entity_id: c.entityId, outcome: 'skipped', reason: 'node_closed' };
65
+ let adaptiveResult;
66
+ try {
67
+ adaptiveResult = await adaptive.apply(adapter, c, command);
68
+ if (adaptiveResult.reason === 'unsupported_color_temperature') event('unsupported',
69
+ { phase: 'adaptive_apply', reason: adaptiveResult.reason, light_service_completed: true }, msg, send);
70
+ }
71
+ catch (error) {
72
+ adaptiveResult = { applied: false, reason: error.reason || 'apply_failed' };
73
+ event('warning', { message: error.message, reason: adaptiveResult.reason,
74
+ phase: 'adaptive_apply', light_service_completed: true }, msg, send);
75
+ }
76
+ if (isClosed()) return { entity_id: c.entityId, outcome: 'skipped', reason: 'node_closed' };
77
+ const current = adapter.state(c.entityId);
78
+ observe(current);
79
+ const result = { ...light.normalized(current, overrideActive(current), command.adaptiveEnabled, command.service),
80
+ entity_id: c.entityId, source: command.source, requested_action: command.action,
81
+ service_data: command.data, ignored_conflicts: command.ignoredConflicts,
82
+ state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect({ ...c, adaptiveEnabled: command.adaptiveEnabled }, adapter.states()), ...adaptiveResult } };
83
+
84
+ const level = command.data.brightness === undefined ? '' : ` · ${Math.round(light.pct(command.data.brightness))}%`;
85
+ node.status({ fill: command.source === 'manual' ? 'yellow' : command.turningOn ? 'green' : 'grey', shape: 'dot',
86
+ text: `${command.source === 'manual' ? 'MANUAL' : 'AUTO'} · ${command.turningOn ? 'ON' : 'OFF'}${level}` });
87
+ return { ...result, outcome: 'succeeded' };
88
+ } catch (error) {
89
+ event(error.eventType || 'error', { message: error.message, light_service_completed: serviceCompleted }, msg, send);
90
+ if (!isClosed()) node.status({ fill: 'red', shape: 'ring', text: error.eventType === 'config_error' ? 'CONFIG ERROR' :
91
+ error.eventType === 'unavailable' ? (error.message.startsWith('HA NOT READY:') || !adapter || !adapter.connected() ? 'HA NOT READY' : 'ENTITY UNAVAILABLE') : error.message });
92
+ return { ...light.normalized(adapter.state(c.entityId), overrideActive(adapter.state(c.entityId)), c.adaptiveEnabled), entity_id: c.entityId, outcome: 'failed', error: { type: error.eventType || 'error', message: error.message }, light_service_completed: serviceCompleted, cause: error };
93
+ }
94
+
95
+ }
96
+ return { observe, execute, info: () => { const state = adapter.state(c.entityId); return light.normalized(state, overrideActive(state), c.adaptiveEnabled); } };
97
+ }
98
+ module.exports = { createController };
package/lib/ha-adapter.js CHANGED
@@ -27,6 +27,13 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
27
27
  let ha, stopped = false, observers, installing = false;
28
28
  const listeners = [], subscriptions = new Set(), rawStates = new Map();
29
29
  let pendingSubscription = Promise.resolve();
30
+ let registryBound = false, connectionBound = false;
31
+ let registryListener, registryCache, registryPending, registryGeneration = 0;
32
+ let entityIds = new Set();
33
+ function invalidateRegistries() {
34
+ registryGeneration++; registryCache = undefined;
35
+ if (!stopped) registryListener?.();
36
+ }
30
37
  const diagnostics = new Map();
31
38
  function report(server, client, phase) {
32
39
  const info = { phase, serverIdPresent: Boolean(serverId), serverResolved: Boolean(server),
@@ -48,11 +55,12 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
48
55
  }
49
56
  async function detach() {
50
57
  for (const [bus, event, fn] of listeners) bus.removeListener(event, fn);
51
- listeners.length = 0;
58
+ listeners.length = 0; registryBound = false; connectionBound = false;
52
59
  await pendingSubscription;
53
60
  await Promise.allSettled([...subscriptions].map(fn => Promise.resolve().then(fn)));
54
61
  subscriptions.clear();
55
62
  rawStates.clear();
63
+ invalidateRegistries();
56
64
  }
57
65
  async function install() {
58
66
  if (stopped || installing || subscriptions.size || !connected() || !observers) return;
@@ -60,10 +68,10 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
60
68
  const client = ha;
61
69
  try {
62
70
  const unsubscribe = await client.websocket.subscribeMessage(event => {
63
- if (stopped || ha !== client || event.data?.entity_id !== observers.entityId) return;
71
+ if (stopped || ha !== client || !entityIds.has(event.data?.entity_id)) return;
64
72
  const state = event.data.new_state;
65
- rawStates.set(observers.entityId, state ?? undefined);
66
- try { observers.onState(state); } catch (error) { observers.onError(error); }
73
+ rawStates.set(event.data.entity_id, state ?? undefined);
74
+ try { observers.onState(state, event.data.entity_id); } catch (error) { observers.onError(error); }
67
75
  }, { type: 'subscribe_events', event_type: 'state_changed' }, { resubscribe: true });
68
76
  if (stopped || ha !== client) await unsubscribe();
69
77
  else subscriptions.add(unsubscribe);
@@ -72,16 +80,24 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
72
80
  }
73
81
  function refresh() {
74
82
  rawStates.clear();
83
+ invalidateRegistries();
75
84
  if (!stopped && observers) {
76
85
  observers.onConnection(connected());
77
86
  if (!installing && !subscriptions.size && connected()) pendingSubscription = install();
78
87
  }
79
88
  }
89
+ function bindRegistries() {
90
+ if (!ha || !registryListener || registryBound || stopped) return;
91
+ registryBound = true;
92
+ for (const event of ['labels_updated', 'entity_registry_updated', 'devices_updated', 'areas_updated']) listen(event, invalidateRegistries);
93
+ }
80
94
  function bind() {
81
- if (!ha || !observers || stopped || listeners.length) return;
95
+ bindRegistries();
96
+ if (!ha || !observers || stopped || connectionBound) return;
97
+ connectionBound = true;
82
98
  for (const event of ['ha_client:ready', 'ha_client:states_loaded', 'ha_client:connected']) listen(event, refresh);
83
99
  for (const event of ['ha_client:close', 'ha_client:connecting', 'ha_client:error']) {
84
- listen(event, () => { rawStates.clear(); if (!stopped) observers.onConnection(false); });
100
+ listen(event, () => { rawStates.clear(); invalidateRegistries(); if (!stopped) observers.onConnection(false); });
85
101
  }
86
102
  refresh();
87
103
  }
@@ -123,8 +139,36 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
123
139
  if (!connected()) throw fault('HA NOT READY: disconnected or loading states', 'unavailable');
124
140
  return ha.websocket.callService(domain, service, data, target);
125
141
  },
142
+ onRegistryChange(callback) { registryListener = callback; bindRegistries(); },
143
+ async labels() {
144
+ if (!connected()) throw fault('HA NOT READY', 'unavailable');
145
+ return ha.websocket.send({ type: 'config/label_registry/list' });
146
+ },
147
+ async registries() {
148
+ if (!connected() || typeof ha.websocket.send !== 'function') throw fault('HA registries unavailable', 'target_resolution_failure');
149
+ if (registryCache && Date.now() - registryCache.time < 30000) return registryCache.data;
150
+ if (registryPending) return registryPending;
151
+ const generation = registryGeneration, client = ha;
152
+ const pending = Promise.all(['label', 'entity', 'device', 'area'].map(name =>
153
+ client.websocket.send({ type: `config/${name}_registry/list` }))).then(([labels, entities, devices, areas]) => {
154
+ if (stopped || ha !== client || generation !== registryGeneration) throw fault('HA registries changed during resolution; retry', 'target_resolution_failure');
155
+ const data = { labels, entities, devices, areas };
156
+ if (!Object.values(data).every(Array.isArray)) throw fault('Invalid HA registry response', 'target_resolution_failure');
157
+ registryCache = { time: Date.now(), data };
158
+ return data;
159
+ });
160
+ registryPending = pending;
161
+ try { return await pending; } finally { if (registryPending === pending) registryPending = undefined; }
162
+ },
163
+ async watch(ids, onState, onConnection, onError) {
164
+ entityIds = new Set(ids);
165
+ for (const id of rawStates.keys()) if (!entityIds.has(id)) rawStates.delete(id);
166
+ observers = { onState, onConnection, onError };
167
+ bind();
168
+ },
126
169
  async subscribe(entityId, onState, onConnection, onError) {
127
- observers = { entityId, onState, onConnection, onError };
170
+ entityIds = new Set(entityId ? [entityId] : []);
171
+ observers = { onState, onConnection, onError };
128
172
  bind();
129
173
  },
130
174
  async close() {
package/lib/light.js CHANGED
@@ -56,7 +56,10 @@ function config(raw) {
56
56
  c.mired = number(c.mired, 'mired', 25, 1000);
57
57
  if (!/^#[0-9a-f]{6}$/i.test(c.fixedColor)) throw fault('Fixed color must be #rrggbb');
58
58
  if (c.adaptiveEntity && !/^switch\.[a-z0-9_]+$/.test(c.adaptiveEntity)) throw fault('Adaptive entity must be a switch entity');
59
- if (!/^light\.[a-z0-9_]+$/.test(c.entityId || '')) throw fault('Select a light entity');
59
+ if ((c.targetType ?? 'entity') === 'entity' && !/^light\.[a-z0-9_]+$/.test(c.entityId || '')) throw fault('Select a light entity');
60
+ const configured = require('./targets').target(c);
61
+ c.targetType = configured.type;
62
+ if (configured.type === 'label') c.labelId = configured.id;
60
63
  return c;
61
64
  }
62
65
  function pct(brightness) { return brightness * 100 / 255; }
package/lib/targets.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ const { fault } = require('./light');
3
+
4
+ function target(raw, override) {
5
+ const value = override === undefined ? { type: raw.targetType ?? 'entity', id: (raw.targetType ?? 'entity') === 'label' ? raw.labelId : raw.entityId } : override;
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !['entity', 'label'].includes(value.type) ||
7
+ typeof value.id !== 'string' || !value.id.trim()) throw fault('Target requires type entity/label and a stable id', 'config_error');
8
+ if (value.type === 'entity' && !/^light\.[a-z0-9_]+$/.test(value.id)) throw fault('Select a light entity', 'config_error');
9
+ return { type: value.type, id: value.id };
10
+ }
11
+
12
+ // HA 2026.9 service target semantics. Area relationships are part of Label
13
+ // expansion; Area is deliberately not exposed as a configured target type.
14
+ function labelLights(id, { labels, entities, devices, areas }) {
15
+ if (![labels, entities, devices, areas].every(Array.isArray)) throw fault('HA registries unavailable', 'target_resolution_failure');
16
+ if (!labels.some(l => l.label_id === id)) throw fault(`Label ${id} was deleted or does not exist`, 'target_resolution_failure');
17
+ const marked = item => Array.isArray(item.labels) && item.labels.includes(id);
18
+ const deviceIds = new Set(devices.filter(marked).map(d => d.id));
19
+ const areaIds = new Set(areas.filter(marked).map(a => a.area_id));
20
+ const areaDevices = new Set(devices.filter(d => areaIds.has(d.area_id)).map(d => d.id));
21
+ return [...new Set(entities.filter(e => {
22
+ if (!/^light\.[a-z0-9_]+$/.test(e.entity_id) || e.hidden_by || e.disabled_by) return false;
23
+ if (marked(e)) return true;
24
+ if (e.entity_category) return false;
25
+ return deviceIds.has(e.device_id) || areaIds.has(e.area_id) || (!e.area_id && areaDevices.has(e.device_id));
26
+ }).map(e => e.entity_id))].sort();
27
+ }
28
+ async function resolveTarget(adapter, configured) {
29
+ if (configured.type === 'entity') return [configured.id];
30
+ return labelLights(configured.id, await adapter.registries());
31
+ }
32
+ function createTargetResolver(adapter) {
33
+ let snapshot;
34
+ const membership = new Map();
35
+ return async selected => {
36
+ if (selected.type === 'entity') return [selected.id];
37
+ const next = await adapter.registries();
38
+ if (next !== snapshot) { membership.clear(); snapshot = next; }
39
+ if (!membership.has(selected.id)) membership.set(selected.id, labelLights(selected.id, snapshot));
40
+ return [...membership.get(selected.id)];
41
+ };
42
+ }
43
+ function aggregateStatus(infos) {
44
+ const counts = { on: 0, manual: 0, off: 0, unavailable: 0 };
45
+ for (const info of infos) {
46
+ if (!['on', 'off'].includes(info.state)) counts.unavailable++;
47
+ else if (info.manual_override) counts.manual++;
48
+ else counts[info.state]++;
49
+ }
50
+ return { fill: counts.unavailable ? 'red' : counts.manual ? 'yellow' : counts.on ? 'green' : 'grey',
51
+ shape: counts.unavailable ? 'ring' : 'dot',
52
+ text: infos.length ? Object.entries(counts).filter(([,n]) => n).map(([key,n]) => `${n} ${key}`).join(' · ') : 'LABEL EMPTY' };
53
+ }
54
+ module.exports = { target, labelLights, resolveTarget, createTargetResolver, aggregateStatus };
@@ -13,6 +13,10 @@
13
13
  return true;
14
14
  };
15
15
  }
16
+ function targetKind(node) {
17
+ const field = typeof $ === 'function' ? $('#node-input-targetType') : null;
18
+ return field?.length ? field.val() || node.targetType || 'entity' : node.targetType || 'entity';
19
+ }
16
20
  RED.nodes.registerType('sw-light', {
17
21
  category: 'home assistant', color: '#c7d7e8', icon: 'font-awesome/fa-lightbulb-o',
18
22
  inputs: 1, outputs: 2, outputLabels: ['result / state', 'events / errors'],
@@ -20,7 +24,9 @@
20
24
  defaults: {
21
25
  name: { value: '' }, server: { value: '', type: 'server', required: true },
22
26
  action: { value: 'msg', validate: v => ['on','off','toggle','msg'].includes(v) },
23
- entityId: { value: '', required: true, validate: v => /^light\.[a-z0-9_]+$/.test(v) },
27
+ targetType: { value: 'entity', validate: v => ['entity','label'].includes(v) },
28
+ entityId: { value: '', validate: function (v) { return targetKind(this) === 'label' || /^light\.[a-z0-9_]+$/.test(v); } },
29
+ labelId: { value: '', validate: function (v) { return targetKind(this) !== 'label' || (typeof v === 'string' && v.trim().length > 0); } },
24
30
  brightnessPct: { value: 94, validate: numeric('brightnessPct') },
25
31
  autoMaxPct: { value: 94, validate: numeric('autoMaxPct') },
26
32
  minPct: { value: 1, validate: numeric('minPct') },
@@ -38,26 +44,31 @@
38
44
  $('#node-input-colorMode').val('keep');
39
45
  $('#node-input-adaptiveEnabled').prop('checked', this.adaptiveApply === true);
40
46
  }
41
- let states = {}, request, generation = 0;
47
+ let states = {}, labels = [], request, labelRequest, generation = 0;
48
+ $('#node-input-targetType').val(this.targetType || 'entity');
49
+ const label = $('#node-input-labelId');
42
50
  const entity = $('#node-input-entityId'), adaptive = $('#node-input-adaptiveEntity');
43
51
  function modesForLight() { return states[entity.val()]?.attributes?.supported_color_modes || []; }
44
52
  function update() {
53
+ const isLabel = $('#node-input-targetType').val() === 'label';
54
+ $('.sw-entity-target').toggle(!isLabel); $('.sw-label-target').toggle(isLabel);
55
+ $('#sw-label-name').text(labels.find(l => l.label_id === label.val())?.name || 'Select a Label or enter its stable ID.');
45
56
  const mode = $('#node-input-colorMode').val();
46
57
  $('.sw-color-row').hide();
47
58
  $('.sw-color-' + mode).show();
48
59
  $('.sw-adaptive-row').toggle($('#node-input-adaptiveEnabled').prop('checked'));
49
60
  const profiles = Object.values(states).filter(s => s.entity_id.startsWith('switch.') &&
50
61
  Array.isArray(s.attributes?.configuration?.lights) && s.attributes.configuration.lights.includes(entity.val()));
51
- $('#sw-adaptive-info').text(modesForLight().includes('color_temp') ?
62
+ $('#sw-adaptive-info').text(isLabel ? 'A unique profile is discovered per light. Advanced selects one shared fallback; keep its main switch off.' : modesForLight().includes('color_temp') ?
52
63
  (profiles.length === 1 ? 'Profile found. Keep its main switch off for one-shot use.' :
53
64
  'No unique profile found. Select a profile under Advanced.') : 'This light has no known color-temperature support. Normal ON still works.');
54
65
  $('.sw-override-row').toggle($('#node-input-manualOverride').prop('checked'));
55
66
  $('.sw-threshold-row').toggle($('#node-input-manualOverride').prop('checked') && $('#node-input-overrideStrategy').val() === 'threshold');
56
67
  const s = states[entity.val()], modes = s && s.attributes && s.attributes.supported_color_modes;
57
- $('#sw-capabilities').text(modes ? 'Supported: ' + modes.join(', ') : 'Select a deployed server and light to read capabilities.');
68
+ $('#sw-capabilities').text(isLabel ? 'Capabilities and Adaptive Lighting profiles are evaluated per resolved light.' : modes ? 'Supported: ' + modes.join(', ') : 'Select a deployed server and light to read capabilities.');
58
69
  // Disabled options reflect known capabilities, without discarding saved settings.
59
- $('#node-input-colorMode option[value="kelvin"], #node-input-colorMode option[value="mired"]').prop('disabled', !!modes && !modes.includes('color_temp'));
60
- $('#node-input-colorMode option[value="color"]').prop('disabled', !!modes && !modes.some(m => ['hs','xy','rgb','rgbw','rgbww'].includes(m)));
70
+ $('#node-input-colorMode option[value="kelvin"], #node-input-colorMode option[value="mired"]').prop('disabled', !isLabel && !!modes && !modes.includes('color_temp'));
71
+ $('#node-input-colorMode option[value="color"]').prop('disabled', !isLabel && !!modes && !modes.some(m => ['hs','xy','rgb','rgbw','rgbww'].includes(m)));
61
72
  }
62
73
  function source(domain) {
63
74
  return function (req, respond) {
@@ -69,27 +80,36 @@
69
80
  };
70
81
  }
71
82
  entity.autocomplete({ minLength: 0, source: source('light'), select: function (_e, ui) { entity.val(ui.item.value); update(); } });
83
+ label.autocomplete({ minLength: 0, source: function (req, respond) {
84
+ const term = req.term.toLowerCase();
85
+ respond(labels.filter(l => l.label_id.toLowerCase().includes(term) || l.name.toLowerCase().includes(term))
86
+ .map(l => ({ label: l.name + ' (' + l.label_id + ')', value: l.label_id })));
87
+ }, select: function (_e, ui) { label.val(ui.item.value); update(); } });
88
+ label.on('focus.swLight', function () { label.autocomplete('search', label.val()); });
72
89
  adaptive.autocomplete({ minLength: 0, source: source('switch') });
73
90
  function load() {
74
91
  generation++; const token = generation;
75
- if (request) request.abort();
76
- states = {}; update();
92
+ if (request) request.abort(); if (labelRequest) labelRequest.abort();
93
+ states = {}; labels = []; update();
77
94
  const server = $('#node-input-server').val();
78
95
  if (!server || server === '_ADD_') return;
79
96
  $('#sw-capabilities').text('Loading Home Assistant entities…');
80
- // HA contrib's own editor endpoint. No auth tokens or static entity lists.
97
+ // Label route uses the shared adapter; Entity route belongs to HA contrib.
98
+ labelRequest = $.getJSON('sw-light/labels/' + encodeURIComponent(server))
99
+ .done(function (data) { if (token === generation) { labels = Array.isArray(data) ? data : []; update(); } })
100
+ .fail(function (_xhr, status) { if (status !== 'abort' && token === generation) $('#sw-label-name').text('Server must be deployed and connected. A stable Label ID can also be entered directly.'); });
81
101
  request = $.getJSON('homeassistant/states/' + encodeURIComponent(server))
82
102
  .done(function (data) { if (token === generation) { states = data || {}; update(); } })
83
103
  .fail(function (_xhr, status) { if (status !== 'abort' && token === generation) $('#sw-capabilities').text('Server must be deployed and connected. Entity IDs can also be entered directly.'); });
84
104
  }
85
105
  $('#node-input-server').on('change.swLight', load);
86
- $('#node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId').on('change.swLight', update);
106
+ $('#node-input-targetType, #node-input-labelId, #node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId').on('change.swLight', update);
87
107
  entity.on('focus.swLight', function () { entity.autocomplete('search', entity.val()); });
88
108
  adaptive.on('focus.swLight', function () { adaptive.autocomplete('search', adaptive.val()); });
89
109
  this._swCleanup = function () {
90
- generation++; if (request) request.abort();
91
- $('#node-input-server, #node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId, #node-input-adaptiveEntity').off('.swLight');
92
- entity.autocomplete('destroy'); adaptive.autocomplete('destroy');
110
+ generation++; if (request) request.abort(); if (labelRequest) labelRequest.abort();
111
+ $('#node-input-server, #node-input-targetType, #node-input-labelId, #node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId, #node-input-adaptiveEntity').off('.swLight');
112
+ label.autocomplete('destroy'); label.off('.swLight'); entity.autocomplete('destroy'); adaptive.autocomplete('destroy');
93
113
  delete this._swCleanup;
94
114
  };
95
115
  load(); update();
@@ -103,7 +123,9 @@
103
123
  <script type="text/html" data-template-name="sw-light">
104
124
  <div class="form-row"><label for="node-input-name"><i class="fa fa-tag"></i> Name</label><input id="node-input-name" type="text" placeholder="SW Light"></div>
105
125
  <div class="form-row"><label for="node-input-server"><i class="fa fa-server"></i> HA server</label><input id="node-input-server" type="text"></div>
106
- <div class="form-row"><label for="node-input-entityId"><i class="fa fa-lightbulb-o"></i> Light</label><input id="node-input-entityId" type="text" placeholder="light.entity_id"></div>
126
+ <div class="form-row"><label for="node-input-targetType">Target</label><select id="node-input-targetType"><option value="entity">Entity</option><option value="label">Label</option></select></div>
127
+ <div class="form-row sw-label-target"><label for="node-input-labelId">Label</label><input id="node-input-labelId" type="text" placeholder="Stable Label ID"><p id="sw-label-name"></p></div>
128
+ <div class="form-row sw-entity-target"><label for="node-input-entityId"><i class="fa fa-lightbulb-o"></i> Light</label><input id="node-input-entityId" type="text" placeholder="light.entity_id"></div>
107
129
  <div class="form-row"><label for="node-input-action">Action</label><select id="node-input-action"><option value="on">On</option><option value="off">Off</option><option value="toggle">Toggle</option><option value="msg">From msg</option></select></div>
108
130
  <div class="form-tips" id="sw-capabilities" style="margin-bottom:16px"></div>
109
131
  <fieldset><legend>Automatic lighting</legend>
@@ -134,7 +156,8 @@
134
156
  </script>
135
157
 
136
158
  <script type="text/html" data-help-name="sw-light">
137
- <p>Controls one Home Assistant light directly: Trigger → SW Light → Home Assistant. No downstream HA Action node is required. Select On, Off, Toggle, or From msg. Toggle resolves the current state to turn_on or turn_off.</p>
159
+ <p>Controls a Home Assistant Entity or Label directly: Trigger → SW Light → Home Assistant. No downstream HA Action node is required. Select On, Off, Toggle, or From msg. Toggle resolves the current state to turn_on or turn_off.</p>
160
+ <p>Entity is the default for existing flows. Label stores its stable ID and resolves current light members, including labeled devices and labeled area relationships. Each light retains its own override and capabilities. Label output 1 contains target, resolved_entity_ids, results and counts; status summarizes observed states. A failed member does not stop others. <code>msg.target = {type: "label", id: "label_id"}</code> or <code>{type: "entity", id: "light.example"}</code> overrides the configured target for one input. The latest target is observed until the next input.</p>
138
161
  <h3>Inputs</h3>
139
162
  <dl class="message-properties"><dt>action <span class="property-type">string</span></dt><dd>Required with From msg: <code>on</code>, <code>off</code>, or <code>toggle</code>. Falls back to <code>msg.auto_light.action</code> when msg.action is absent. Fixed Action settings ignore message actions.</dd><dt class="optional">source <span class="property-type">string</span></dt><dd><code>automatic</code> (default) or <code>manual</code>.</dd><dt class="optional">brightness_pct <span class="property-type">number</span></dt><dd>0–100. Automatic brightness is capped at the configured maximum.</dd><dt class="optional">transition <span class="property-type">number</span></dt><dd>Fade seconds, 0–3600, if supported.</dd><dt class="optional">manual_override <span class="property-type">boolean</span></dt><dd>Latch/release override with <code>source: "manual"</code>.</dd></dl>
140
163
  <h3>Outputs</h3><ol><li>Original message plus <code>msg.sw_light</code>: last observed HA state, action, service data, and override/adaptive information.</li><li>Original message plus <code>msg.sw_light_event</code>: version, type, entity_id, timestamp, and details.</li></ol>
package/nodes/sw-light.js CHANGED
@@ -1,70 +1,108 @@
1
1
  'use strict';
2
-
3
2
  const light = require('../lib/light');
4
- const adaptive = require('../lib/adaptive');
3
+ const { createController } = require('../lib/controller');
4
+ const { target, createTargetResolver, aggregateStatus } = require('../lib/targets');
5
5
  const { createAdapter } = require('../lib/ha-adapter');
6
6
 
7
- // Optional factory is for isolated transport/lifecycle tests, never flow config.
8
7
  module.exports = function register(RED, adapterFactory = createAdapter) {
8
+ // HA contrib 0.80.3's Label selector is bundled privately and its HTTP API
9
+ // has no labels endpoint. Reuse its authenticated shared transport here.
10
+ if (RED.httpAdmin && RED.auth) RED.httpAdmin.get('/sw-light/labels/:serverId', RED.auth.needsPermission('server.read'), async (req, res) => {
11
+ let handle;
12
+ try {
13
+ handle = adapterFactory(RED, req.params.serverId);
14
+ await handle.resolve();
15
+ res.json(await handle.labels());
16
+ } catch (error) { res.status(503).json({ error: error.message }); }
17
+ finally { await handle?.close(); }
18
+ });
9
19
  function SWLight(raw) {
10
20
  RED.nodes.createNode(this, raw);
11
21
  const node = this;
12
22
  node.log?.(`[SW Light debug] version=${require('../package.json').version} constructor=created serverIdPresent=${Boolean(raw.server)}`);
13
- let c, detector, adapter, closed = false, lastAvailability;
14
- let queue = Promise.resolve(), startup = Promise.resolve(), observerTimer;
15
- const protectedAtFullBrightness = state => state?.state === 'on' && state.attributes?.brightness === 255;
16
- const overrideActive = state => detector.active || protectedAtFullBrightness(state);
23
+ let c, configured, adapter, resolver, closed = false, ready = false, observerTimer, refreshTimer, registryTimer;
24
+ let queue = Promise.resolve(), startup = Promise.resolve(), activeIds = [];
25
+ const controllers = new Map();
17
26
  function event(type, detail = {}, msg = {}, send = node.send.bind(node)) {
18
- if (closed) return;
19
- send([null, { ...msg, sw_light_event: { version: 1, type, entity_id: c?.entityId ?? raw.entityId,
20
- timestamp: new Date().toISOString(), ...detail } }]);
27
+ if (!closed) send([null, { ...msg, sw_light_event: { version: 1, type,
28
+ entity_id: configured?.type === 'entity' ? configured.id : undefined,
29
+ target: configured, timestamp: new Date().toISOString(), ...detail } }]);
30
+ }
31
+ function status() {
32
+ if (!closed && ready && configured?.type === 'label') node.status(aggregateStatus(activeIds.map(id => controllers.get(id).info())));
21
33
  }
22
- function overrideUpdate(fn, msg, send) {
23
- const before = detector.active;
24
- fn();
25
- if (before !== detector.active) event(detector.active ? 'manual_override' : 'override_released',
26
- { active: detector.active, strategy: c.overrideStrategy }, msg, send);
34
+ function controller(id) {
35
+ if (!controllers.has(id)) controllers.set(id, createController({ ...c, entityId: id }, adapter, {
36
+ send: node.send.bind(node), status: value => { if (!closed && configured.type === 'entity') node.status(value); }
37
+ }, () => closed));
38
+ return controllers.get(id);
27
39
  }
28
- function observe(state) {
40
+ async function reconcile(selected = configured) {
41
+ if (closed) return [];
42
+ const ids = await resolver(selected);
43
+ if (closed) return [];
44
+ // Runtime overrides become the observed target until the next input.
45
+ configured = selected;
46
+ activeIds = ids;
47
+ for (const id of ids) controller(id);
48
+ for (const id of controllers.keys()) if (!ids.includes(id)) controllers.delete(id);
49
+ const observe = (state, id) => { if (!closed) { controllers.get(id || selected.id)?.observe(state); status(); } };
50
+ if (adapter.watch) await adapter.watch(ids, observe, connection, error => event('error', { message: error.message }));
51
+ else if (ids.length === 1) await adapter.subscribe(ids[0], state => observe(state, ids[0]), connection, error => event('error', { message: error.message }));
52
+ for (const id of ids) controller(id).observe(adapter.state(id));
53
+ status();
54
+ return ids;
55
+ }
56
+ function scheduleRefresh() {
57
+ if (closed || configured?.type !== 'label') return;
58
+ clearTimeout(refreshTimer);
59
+ refreshTimer = setTimeout(() => {
60
+ queue = queue.then(async () => {
61
+ if (closed || !adapter.connected()) return;
62
+ try { await reconcile(); }
63
+ catch (error) { resolutionError(error); }
64
+ }).catch(error => resolutionError(error));
65
+ }, 100);
66
+ refreshTimer.unref?.();
67
+ }
68
+ function resolutionError(error, msg, send, selected = configured) {
29
69
  if (closed) return;
30
- overrideUpdate(() => detector.observe(state));
31
- const availability = !state ? 'missing' : state.state;
32
- if (['missing', 'unknown', 'unavailable'].includes(availability)) {
33
- node.status({ fill: 'red', shape: 'ring', text: 'ENTITY UNAVAILABLE' });
34
- if (lastAvailability !== availability) event('unavailable', { reason: availability });
35
- } else {
36
- const active = overrideActive(state);
37
- const info = light.normalized(state, active, c.adaptiveEnabled, 'state_changed');
38
- let text = active ? 'MANUAL' : `AUTO · ${info.state.toUpperCase()}`;
39
- if (info.state === 'on' && info.brightness_pct !== null) text += ` · ${info.brightness_pct}%`;
40
- if (info.state === 'on' && info.color_temp_kelvin) text += ` · ${info.color_temp_kelvin}K`;
41
- if (info.state === 'on' && c.adaptiveEnabled) text += ' · adaptive';
42
- node.status({ fill: active ? 'yellow' : info.state === 'on' ? 'green' : 'grey', shape: 'dot', text });
43
- }
44
- lastAvailability = availability;
70
+ node.status({ fill: 'red', shape: 'ring', text: 'TARGET UNAVAILABLE' });
71
+ activeIds = [];
72
+ adapter?.watch?.([], () => {}, connection, failure => event('error', { message: failure.message }));
73
+ event('target_resolution_failure', { message: error.message, target: selected }, msg, send);
45
74
  }
46
- function connection(ready) {
75
+ function connection(value) {
47
76
  if (closed) return;
48
- if (ready) observe(adapter.state(c.entityId));
49
- else {
77
+ const wasReady = ready;
78
+ ready = value;
79
+ if (ready) {
80
+ if (configured.type === 'entity') controllers.get(configured.id)?.observe(adapter.state(configured.id));
81
+ else if (!wasReady) scheduleRefresh();
82
+ } else {
50
83
  node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
51
- if (lastAvailability !== 'disconnected') event('unavailable', { reason: 'disconnected' });
52
- lastAvailability = 'disconnected';
84
+ if (wasReady) event('unavailable', { reason: 'disconnected' });
53
85
  }
54
86
  }
55
87
  function startAdapter() {
56
88
  adapter = adapterFactory(RED, c.server, undefined, detail => node.log?.(`[SW Light debug] ${detail}`));
57
- startup = adapter.subscribe(c.entityId, observe, connection,
58
- error => event('error', { message: error.message })).catch(error => {
59
- if (!closed) { node.status({ fill: 'red', shape: 'ring', text: 'subscription failed' }); event(error.eventType || 'error', { message: error.message }); }
60
- });
89
+ resolver = createTargetResolver(adapter);
90
+ if (configured.type === 'label') adapter.onRegistryChange?.(scheduleRefresh);
91
+ registryTimer = setInterval(scheduleRefresh, 30000); registryTimer.unref?.();
92
+ // Bind connection notifications even while Label registries are unavailable.
93
+ startup = adapter.subscribe(configured.type === 'entity' ? configured.id : '',
94
+ state => { controllers.get(configured.id)?.observe(state); }, connection,
95
+ error => event('error', { message: error.message }));
61
96
  connection(adapter.connected());
62
97
  }
63
98
  function startObserving() {
64
99
  if (closed || node.swLightError) return;
65
- startup = Promise.resolve().then(async () => {
100
+ startup = queue = queue.then(async () => {
101
+ if (closed) return;
66
102
  if (!adapter) startAdapter();
67
103
  if (adapter.resolve) await adapter.resolve();
104
+ if (!adapter.connected()) throw light.fault('HA NOT READY', 'unavailable');
105
+ await reconcile();
68
106
  }).catch(error => {
69
107
  if (closed) return;
70
108
  event(error.eventType || 'error', { message: error.message, phase: 'state_subscription' });
@@ -73,89 +111,60 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
73
111
  });
74
112
  }
75
113
  try {
76
- c = light.config(raw);
77
- detector = new light.Override(c);
78
- // Defer provider resolution until after Flow.start. Observe even
79
- // before this node's first command; retry temporary startup failures.
114
+ c = light.config(raw); configured = target(c);
80
115
  node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
81
- observerTimer = setTimeout(startObserving, 0);
82
- observerTimer.unref?.();
116
+ observerTimer = setTimeout(startObserving, 0); observerTimer.unref?.();
83
117
  } catch (error) {
84
118
  node.status({ fill: 'red', shape: 'ring', text: 'CONFIG ERROR' });
85
- node.error(error.message);
86
- node.swLightError = error;
119
+ node.error(error.message); node.swLightError = error;
87
120
  }
88
121
  node.on('input', (msg, send, done) => {
89
122
  send = send || node.send.bind(node);
90
123
  done = done || (error => { if (error) node.error(error, msg); });
91
124
  const run = async () => {
92
125
  if (closed) { done(); return; }
93
- let command, serviceCompleted = false;
94
126
  try {
95
127
  if (node.swLightError) throw node.swLightError;
96
- // A temporarily absent/uninitialized provider is retried on
97
- // the next input, rather than freezing the constructor error.
128
+ const selected = target(c, msg.target);
98
129
  if (!adapter) startAdapter();
130
+ if (selected.type === 'label') adapter.onRegistryChange?.(scheduleRefresh);
99
131
  if (adapter.resolve) await adapter.resolve();
100
132
  if (closed) { done(); return; }
101
- if (!adapter.connected()) throw light.fault('Home Assistant is disconnected or loading states', 'unavailable');
102
- const state = adapter.state(c.entityId);
103
- if (!state || !['on', 'off'].includes(state.state)) throw light.fault(`Light is ${state?.state ?? 'missing'}`, 'unavailable');
104
- observe(state);
105
- const requestedOverride = Object.prototype.hasOwnProperty.call(msg, 'manual_override');
106
- if (requestedOverride && (msg.source ?? 'automatic') !== 'manual') throw light.fault('manual_override requires source: manual');
107
- if (requestedOverride && typeof msg.manual_override !== 'boolean') throw light.fault('manual_override must be boolean');
108
- command = light.plan(c, msg, state, overrideActive(state));
109
- if (requestedOverride) {
110
- overrideUpdate(() => detector.set(msg.manual_override), msg, send);
111
- command.blocked = false; // validated manual source
112
- }
113
- if (command.unsupported.length) event('unsupported', { parameters: command.unsupported, reason: 'lamp_capabilities_or_action' }, msg, send);
114
- if (command.blocked) {
115
- event('manual_override', { active: true, blocked: true, requested_action: command.action }, msg, send);
116
- const percent = Number.isFinite(state.attributes?.brightness) ? Math.round(light.pct(state.attributes.brightness)) : '?';
117
- node.status({ fill: 'yellow', shape: 'dot', text: `MANUAL · ${percent}% · BLOCK ${command.action.toUpperCase()}` });
118
- done(); return;
119
- }
120
- await adapter.call('light', command.service, command.data, { entity_id: c.entityId });
121
- serviceCompleted = true;
133
+ if (!adapter.connected()) throw light.fault('HA NOT READY: disconnected or loading states', 'unavailable');
134
+ let ids;
135
+ try { ids = await reconcile(selected); }
136
+ catch (error) { resolutionError(error, msg, send, selected); done(error); return; }
122
137
  if (closed) { done(); return; }
123
- let adaptiveResult;
124
- try {
125
- adaptiveResult = await adaptive.apply(adapter, c, command);
126
- if (adaptiveResult.reason === 'unsupported_color_temperature') event('unsupported',
127
- { phase: 'adaptive_apply', reason: adaptiveResult.reason, light_service_completed: true }, msg, send);
128
- }
129
- catch (error) {
130
- adaptiveResult = { applied: false, reason: error.reason || 'apply_failed' };
131
- event('warning', { message: error.message, reason: adaptiveResult.reason,
132
- phase: 'adaptive_apply', light_service_completed: true }, msg, send);
138
+ if (!ids.length) event('target_empty', { target: selected, resolved_entity_ids: [] }, msg, send);
139
+ const results = [];
140
+ for (const id of ids) {
141
+ if (closed) break;
142
+ results.push(await controller(id).execute(msg, send));
133
143
  }
134
144
  if (closed) { done(); return; }
135
- const current = adapter.state(c.entityId);
136
- observe(current);
137
- send([{ ...msg, sw_light: { ...light.normalized(current, overrideActive(current), command.adaptiveEnabled, command.service),
138
- entity_id: c.entityId, source: command.source, requested_action: command.action,
139
- service_data: command.data, ignored_conflicts: command.ignoredConflicts,
140
- state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect({ ...c, adaptiveEnabled: command.adaptiveEnabled }, adapter.states()), ...adaptiveResult } } }, null]);
141
- const level = command.data.brightness === undefined ? '' : ` · ${Math.round(light.pct(command.data.brightness))}%`;
142
- node.status({ fill: command.source === 'manual' ? 'yellow' : command.turningOn ? 'green' : 'grey', shape: 'dot',
143
- text: `${command.source === 'manual' ? 'MANUAL' : 'AUTO'} · ${command.turningOn ? 'ON' : 'OFF'}${level}` });
144
- done();
145
+ if (selected.type === 'entity') {
146
+ const { outcome, cause, ...result } = results[0];
147
+ if (outcome === 'succeeded') send([{ ...msg, sw_light: result }, null]);
148
+ done(cause); return;
149
+ }
150
+ const normalized = results.map(({ cause, ...result }) => result);
151
+ const counts = { succeeded: 0, skipped: 0, failed: 0, manual: 0 };
152
+ for (const result of results) { counts[result.outcome]++; if (result.manual_override) counts.manual++; }
153
+ send([{ ...msg, sw_light: { version: 1, target: selected, resolved_entity_ids: ids,
154
+ results: normalized, counts, state_confirmation: 'last_observed' } }, null]);
155
+ status(); done();
145
156
  } catch (error) {
146
- event(error.eventType || 'error', { message: error.message, light_service_completed: serviceCompleted }, msg, send);
157
+ event(error.eventType || 'error', { message: error.message, light_service_completed: false }, msg, send);
147
158
  if (!closed) node.status({ fill: 'red', shape: 'ring', text: error.eventType === 'config_error' ? 'CONFIG ERROR' :
148
- error.eventType === 'unavailable' ? (error.message.startsWith('HA NOT READY:') || !adapter || !adapter.connected() ? 'HA NOT READY' : 'ENTITY UNAVAILABLE') : error.message });
159
+ error.eventType === 'unavailable' ? 'HA NOT READY' : error.message });
149
160
  done(error);
150
161
  }
151
162
  };
152
- // Serialize relative brightness and toggles; contain all asynchronous failures.
153
- queue = queue.then(run).catch(error => { done(error); });
163
+ queue = queue.then(run).catch(error => done(error));
154
164
  });
155
165
  node.on('close', (_removed, done) => {
156
- closed = true;
157
- clearTimeout(observerTimer);
158
- Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => done());
166
+ closed = true; clearTimeout(observerTimer); clearTimeout(refreshTimer); clearInterval(registryTimer);
167
+ Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => { controllers.clear(); done(); });
159
168
  });
160
169
  }
161
170
  RED.nodes.registerType('sw-light', SWLight);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johanwistbacka/node-red-contrib-sw-light",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Home Assistant light control with automatic brightness limits and manual override",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -24,6 +24,8 @@
24
24
  "files": [
25
25
  "nodes/sw-light.js",
26
26
  "nodes/sw-light.html",
27
+ "lib/controller.js",
28
+ "lib/targets.js",
27
29
  "lib/light.js",
28
30
  "lib/adaptive.js",
29
31
  "lib/ha-adapter.js",
@@ -31,7 +33,8 @@
31
33
  "examples/manual-adaptive.json",
32
34
  "README.md",
33
35
  "LICENSE",
34
- "CHANGELOG.md"
36
+ "CHANGELOG.md",
37
+ "docs/labels.md"
35
38
  ],
36
39
  "scripts": {
37
40
  "test": "node --test",