@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 +10 -0
- package/README.md +38 -12
- package/docs/labels.md +198 -0
- package/lib/controller.js +98 -0
- package/lib/ha-adapter.js +51 -7
- package/lib/light.js +4 -1
- package/lib/targets.js +54 -0
- package/nodes/sw-light.html +38 -15
- package/nodes/sw-light.js +109 -100
- package/package.json +5 -2
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.
|
|
1
|
+
# SW Light — v0.1.9
|
|
2
2
|
|
|
3
|
-
A reusable Node-RED node for
|
|
4
|
-
existing HA server, select a
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
71
|
+
if (stopped || ha !== client || !entityIds.has(event.data?.entity_id)) return;
|
|
64
72
|
const state = event.data.new_state;
|
|
65
|
-
rawStates.set(
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|
package/nodes/sw-light.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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-
|
|
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
|
|
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
|
|
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,
|
|
14
|
-
let queue = Promise.resolve(), startup = Promise.resolve(),
|
|
15
|
-
const
|
|
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)
|
|
19
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
75
|
+
function connection(value) {
|
|
47
76
|
if (closed) return;
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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
|
-
|
|
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('
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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:
|
|
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' ?
|
|
159
|
+
error.eventType === 'unavailable' ? 'HA NOT READY' : error.message });
|
|
149
160
|
done(error);
|
|
150
161
|
}
|
|
151
162
|
};
|
|
152
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|