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

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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.8 — 2026-09-30
4
+
5
+ - Simplify Adaptive Lighting to an On/Off checkbox with collapsed advanced profile selection.
6
+ - Use the supported color-only `adaptive_lighting.apply` action with a fresh provider calculation, only after automatic OFF → ON on CT-capable lights. SW Light retains brightness, maximum, override protection and transitions.
7
+ - Explicit message colors and manual commands suppress adaptation; already-on brightness changes do not reapply it. Add `msg.adaptive_lighting` opt-in/out.
8
+ - Discover only unique exact `configuration.lights` matches. Missing, ambiguous, unavailable, running and RGB sleep profiles fall back with structured warnings. Non-CT lights retain normal ON.
9
+ - Require the HA profile main switch off for one-shot ownership. Preserve inactive legacy nodes; migrate former optional-apply opt-in.
10
+ - Document inspected upstream API/source, two-action timing limitations and the exact live verification sequence.
11
+ - Full syntax/editor/example check and 42 tests passed, including all 3 isolated published-provider integration tests. No live HA/device verification or publication.
12
+
13
+ ## 0.1.7 — local default threshold adjustment
14
+
15
+ - Sets the runtime/editor default manual override threshold to 98% (HA brightness 250 or above), retaining the 94% automatic maximum.
16
+ - Updates example flows and documentation. Existing saved thresholds remain unchanged.
17
+ - Verifies the new default boundary and release below threshold; preserves explicit 100% threshold coverage.
18
+ - No npm publication.
19
+
20
+ ## 0.1.6 — local HA state normalization fix
21
+
22
+ - Omits null/non-array observed HA color attributes instead of spreading them, preventing `a[k] is not iterable` during observation and commands.
23
+ - Keeps valid color arrays as independent copies; retains brightness and manual override behavior.
24
+ - Adds null/non-array normalization coverage and exercises all three override regressions with null HA color attributes.
25
+ - Full checks and isolated Node-RED/HA contrib integration: 33 tests passed. No npm publication; physical-device verification pending.
26
+
27
+ ## 0.1.5 — local override fix candidate
28
+
29
+ - Starts per-light HA observation after flow startup, before the first input.
30
+ - Retries temporary provider initialization failures without freezing the node; preserves the shared-client/readiness adapter.
31
+ - Fixes idle OFF nodes missing external brightness changes: observed 255 activates override, observed 204 releases the default threshold, and explicit manual OFF bypasses the guard.
32
+ - Adds real-adapter regressions for automatic 94% → external 100% → suppressed OFF, external 80% release, manual OFF, and entity isolation.
33
+ - Full suite including isolated Node-RED/HA contrib 0.80.3 integration: 32 tests passed. Physical-device verification of this candidate is pending. No npm publication.
34
+
3
35
  ## 0.1.4 — scoped distribution candidate
4
36
 
5
37
  - Changes npm identity to `@johanwistbacka/node-red-contrib-sw-light`.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # SW Light — v0.1.4
1
+ # SW Light — v0.1.8
2
2
 
3
3
  A reusable Node-RED node for one Home Assistant `light.*` entity. Select an
4
4
  existing HA server, select a light, deploy in a **development** environment,
@@ -14,8 +14,9 @@ and integration only. With a fixed Action, a normal timestamp Inject is enough.
14
14
 
15
15
  The permanent npm package name is `@johanwistbacka/node-red-contrib-sw-light`.
16
16
  The Node-RED type stays `sw-light` and its display name stays **SW Light**.
17
- The scoped package starts at version 0.1.4; version numbers are independent
18
- for each npm package name. No runtime/editor or HA integration behavior changes.
17
+ The scoped package started at version 0.1.4; version numbers are independent
18
+ for each npm package name. Version 0.1.5 fixes observation before the first command; 0.1.6 also tolerates
19
+ null/non-array observed HA color attributes.
19
20
 
20
21
  If upgrading from the old unscoped `node-red-contrib-sw-light`, export your
21
22
  flows first. Remove the old package from the Node-RED user directory before
@@ -25,7 +26,7 @@ or, with Node-RED stopped, run these commands in its user directory:
25
26
 
26
27
  ```sh
27
28
  npm uninstall node-red-contrib-sw-light
28
- npm install @johanwistbacka/node-red-contrib-sw-light@0.1.4
29
+ npm install @johanwistbacka/node-red-contrib-sw-light@0.1.8
29
30
  ```
30
31
 
31
32
  Restart Node-RED and reload the editor. Existing flow nodes retain type
@@ -51,7 +52,7 @@ are enabled. Test with a development light before relying on this node.
51
52
  After this version is published, install it in your Node-RED user directory:
52
53
 
53
54
  ```sh
54
- npm install @johanwistbacka/node-red-contrib-sw-light@0.1.4
55
+ npm install @johanwistbacka/node-red-contrib-sw-light@0.1.8
55
56
  ```
56
57
 
57
58
  After publication, the current development release is available under npm latest:
@@ -74,7 +75,7 @@ For edits, use npm's local directory installation/link or reinstall an archive
74
75
  created with `npm pack`. Restart the isolated Node-RED runtime after changes.
75
76
  There is no build step or additional runtime dependency.
76
77
 
77
- The current development release uses the normal SemVer version `0.1.4` and
78
+ The current development release uses the normal SemVer version `0.1.8` and
78
79
  npm tag `latest` for Node-RED compatibility. This version remains experimental;
79
80
  it is not a SemVer prerelease. Every installation/publication candidate needs a
80
81
  unique version. Use `npm version patch --no-git-tag-version` for the next normal
@@ -86,7 +87,7 @@ After publication, submit the package through the
86
87
  [Node-RED Flow Library](https://flows.nodered.org/add/node), or request a refresh
87
88
  if it is already listed.
88
89
 
89
- ## Temporary runtime diagnostics in 0.1.4
90
+ ## Temporary runtime diagnostics in 0.1.8
90
91
 
91
92
  This build logs `[SW Light debug]` at construction and on the first input,
92
93
  then when the relevant structure/readiness changes. It reports version,
@@ -96,7 +97,7 @@ It never logs server IDs, entity IDs, credentials, hosts, full objects or state
96
97
  snapshots. Provider initialization errors are reduced to a generic message.
97
98
 
98
99
  After installation, restart Node-RED and verify a log line containing
99
- `[SW Light debug] version=0.1.4`. Inject once and retain the prefixed lines,
100
+ `[SW Light debug] version=0.1.8`. Inject once and retain the prefixed lines,
100
101
  along with any error/stack. Those lines distinguish missing configuration,
101
102
  a missing provider client and a connected client with unexpected methods.
102
103
  The real installation problem remains unverified until this build is tested
@@ -119,11 +120,10 @@ there is no `require.cache` scan or search for alternate package copies.
119
120
  | On/off transition | 1s each | Sent only with light transition capability |
120
121
  | Manual override | Enabled | Pause automatic commands while override is active |
121
122
  | Detection | Brightness threshold + flag | Alternative: explicit flag only |
122
- | Threshold | 100% | Lamp brightness at/above this value activates override |
123
- | Color mode | Adaptive Lighting | No configured color sent during ordinary ON |
124
- | Adaptive discovery | Enabled | Unique membership match in switch attributes |
125
- | Adaptive apply on ON | Disabled | Optional color-only `adaptive_lighting.apply` |
126
- | Respect adaptive manual control | Enabled | Skip apply when light is manually controlled |
123
+ | Threshold | 98% | Lamp brightness at/above this value activates override |
124
+ | Color mode | Keep current | Normal behavior and fallback |
125
+ | Adaptive Lighting | Off | Current temperature once on automatic OFF → ON |
126
+ | Advanced profile | Automatic | Unique exact light membership; explicit selection if ambiguous |
127
127
 
128
128
  Require `minimum <= on brightness <= automatic maximum`. When threshold
129
129
  detection is enabled, automatic maximum must be below the threshold.
@@ -180,6 +180,7 @@ validated**, even those that lose precedence.
180
180
  | `rgbww_color`, `rgbw_color`, `rgb_color` | Arrays of 5, 4, 3 integer bytes 0–255 |
181
181
  | `hs_color`, `xy_color`, `color_name` | [0–360,0–100], [0–1,0–1], HA color name |
182
182
  | `transition` | 0–3600 seconds |
183
+ | `adaptive_lighting` | Boolean; overrides Adaptive Lighting for one command |
183
184
 
184
185
  The complete color precedence follows the table top to bottom. `color_name`
185
186
  is passed to HA for name resolution/validation, only on a color-capable lamp.
@@ -210,15 +211,21 @@ This hard guard requires brightness exactly **255/255**, so 94%, 95%, 99% and
210
211
  is disabled or set to explicit-only. Lowering the light below 255 allows
211
212
  automatic control again unless an additional configured override is active.
212
213
 
213
- Threshold detection observes HA state, not requested brightness. The configured
214
- threshold is converted to a byte; default 100% means exactly 255, not 254 rounded
215
- for display. With defaults, automation uses 1–94%, and observed 100% activates
214
+ Threshold detection observes HA state, not requested brightness. Each node starts
215
+ its state subscription after flow startup, before its first command, and retries
216
+ temporary provider initialization failures. This includes separate ON/OFF nodes
217
+ for the same physical light. The configured
218
+ threshold is converted to a byte; default 98% means brightness 250 or above.
219
+ With defaults, automation uses 1–94%, and observed brightness at/above this threshold activates
216
220
  override. It pauses **all automatic commands**, including ON, OFF, toggle and
217
221
  brightness zero, so an automatic ON cannot accidentally lower and release a
218
222
  manual setting. A manually lowered observed brightness releases the threshold
219
223
  flag. OFF releases all flags. Missing/unknown/unavailable state preserves flags
220
224
  and rejects commands.
221
225
 
226
+ Existing nodes retain their saved threshold. Change a saved 100% threshold to
227
+ 98% manually to use the new default behavior.
228
+
222
229
  An explicit latch is independent of the threshold. It persists until explicit
223
230
  release or observed OFF. A release flag clears both flags for its command;
224
231
  subsequent threshold observations can reactivate override. Flags are in memory,
@@ -228,49 +235,112 @@ detection. `source` is the v0.1 command-origin contract.
228
235
 
229
236
  ## Adaptive Lighting
230
237
 
231
- Ordinary ON in Adaptive mode sends brightness and supported transition, **no
232
- color or temperature**. Adaptive Lighting retains ownership of color. A message
233
- color explicitly overrides that behavior for one command, and suppresses optional
234
- apply even when the color is unsupported and filtered.
235
-
236
- Manual switch selection takes precedence. Discovery uses the light's exact
237
- membership in `attributes.configuration.lights` or `attributes.lights` on
238
- switch entities with adaptive-like attributes. It only selects one unique
239
- candidate; it does not infer names or configure HA. This is best-effort attribute
240
- discovery, not a registry-backed guarantee that a switch belongs to the integration.
241
- No actual installation was available for validating its attribute schema.
242
- Use the switch picker/manual entity ID when discovery fails or is ambiguous.
243
-
244
- Optional apply runs **after** successful turn-on, only for an enabled selected
245
- switch, without explicit message color. It calls `adaptive_lighting.apply` with
246
- `adapt_brightness: false`, `adapt_color: true`, `turn_on_lights: false`. It respects
247
- `manual_control` / `manual_control_color` lists by default and never clears them,
248
- turns on an adaptive switch or changes integration settings. No discovery is
249
- required when apply is disabled: the ordinary HA light action works on its own.
250
- An apply failure emits output 2 with `phase: "adaptive_apply"` and
251
- `light_service_completed: true`; the successful light result still emits.
252
-
253
- Adaptive Lighting may interpret explicit brightness commands as manual control
254
- depending on its own `take_over_control` and related settings. Its background
255
- brightness adaptation may also exceed this node's maximum; the ceiling only
256
- applies to **SW Light's outgoing commands**. Configure the integration's own
257
- brightness limits/ownership in your dev environment and test that combination.
258
- SW Light never repeatedly corrects brightness or fights its adaptation.
259
-
260
- ## Test the updated 0.1.4 in your HA Node-RED installation
238
+ The primary editor control is **Adaptive Lighting: On / Off** (off for new nodes).
239
+ Color mode independently defines normal/fallback color behavior; the default is
240
+ Keep current. Advanced profile selection is collapsed. Discovery selects only
241
+ one exact light-membership match in a main switch's `configuration.lights`.
242
+ No entity names or undocumented `lights` attributes are guessed. Configuration
243
+ attributes must be exposed in HA (`include_config_in_attributes`) for discovery;
244
+ otherwise select the main switch explicitly. Multiple matches never choose the
245
+ first profile. Group membership expansion is not performed.
246
+
247
+ Keep the selected Adaptive Lighting **main switch OFF** to use its calculation
248
+ without continuous control or ON interception. The supported `adaptive_lighting.apply`
249
+ action works with that switch off and calculates fresh values for every call.
250
+ SW Light never toggles that switch, changes profile settings, clears manual
251
+ control or starts a continuous adaptation loop. Choose a profile using
252
+ color-temperature sleep color; RGB sleep profiles are rejected with a warning.
253
+
254
+ Precedence for each command:
255
+
256
+ 1. Explicit message color/temperature wins, including unsupported color that
257
+ is omitted with an event. It always suppresses Adaptive Lighting.
258
+ 2. Otherwise, Adaptive Lighting runs only after a successful automatic OFF → ON
259
+ service, on a color-temperature-capable light.
260
+ 3. Otherwise, normal configured SW Light color behavior applies.
261
+ 4. Keep current/lamp default omit color and leave normal HA/device behavior.
262
+
263
+ `msg.adaptive_lighting` (boolean) overrides the checkbox for that command.
264
+ Brightness, automatic maximum, override protection and fade stay with SW Light.
265
+ An already-ON brightness command, manual command, OFF or zero-brightness command
266
+ never invokes Adaptive Lighting. Configured fixed color still follows normal
267
+ SW Light behavior on these commands. Legacy adaptive flows with optional apply
268
+ already enabled retain their opt-in; former inactive adaptive flows stay off.
269
+ Legacy discover/respect-manual controls are retired.
270
+
271
+ After `light.turn_on`, SW Light calls `adaptive_lighting.apply` for exactly one
272
+ light with `adapt_brightness: false`, `adapt_color: true`, `prefer_rgb_color: false`,
273
+ `turn_on_lights: true`, and the SW Light command transition (0 if unsupported).
274
+ The true flag avoids silently skipping color when HA has not yet observed the
275
+ successful ON. Adaptive Lighting clamps temperature to the light's range.
276
+ The two actions are not atomic: a brief initial/default color may be visible,
277
+ and an external OFF between them can race with the second action. Service
278
+ acknowledgement reports an accepted action, not physical temperature confirmation.
279
+
280
+ Missing/unavailable/ambiguous/running profiles, a missing apply action or its
281
+ failure emit output 2 `warning`, with `phase: "adaptive_apply"`, structured
282
+ `reason`, and `light_service_completed: true`. Normal ON and output 1 survive,
283
+ retaining the configured color fallback. Non-CT lights emit `unsupported` and
284
+ still turn on without temperature parameters. A running HA profile or other
285
+ external automation can still alter lights independently; turn those off for
286
+ this ownership model. SW Light cannot cancel their behavior.
287
+
288
+ Investigation: [upstream apply documentation](https://adaptive-lighting.nijho.lt/services/)
289
+ and [source inspected on 2026-09-30](https://github.com/basnijholt/adaptive-lighting/blob/283fec08ae7d34901d13c32e91fb001f92395fe7/custom_components/adaptive_lighting/switch.py).
290
+ The main switch exposes last calculated `color_temp_kelvin` when on, but null
291
+ when off; reading that cache cannot deliver the requested fresh calculation.
292
+ `apply` recalculates in `prepare_adaptation_data` even while off. No dedicated
293
+ read-only temperature calculation service is exposed. Calculations remain in
294
+ Adaptive Lighting, including sun/time and sleep settings.
295
+
296
+ ### Real-world verification
297
+
298
+ 1. Install the local 0.1.8 archive, restart Node-RED, and verify its startup
299
+ version. Use a test light with `color_temp` support. Connect both outputs to Debug.
300
+ 2. In HA, expose the profile's configuration attributes for automatic discovery
301
+ (or select it under Advanced). Keep its main switch off, use color-temperature
302
+ sleep color, and disable any other automatic controllers for this test light.
303
+ 3. In SW Light select Action On, brightness/max 94%, Keep current, Adaptive Lighting
304
+ On. Turn the light off in HA and wait until Node-RED observes OFF. Trigger ON.
305
+ 4. Verify `adaptive.applied: true` and brightness 239/255 (about 93.7%). After the
306
+ fade, inspect HA light state and physically check the light. To get a comparison
307
+ value while the profile is off, call HA's `adaptive_lighting.apply` yourself
308
+ with the same profile/light, brightness false, color true, RGB preference false,
309
+ turn-on true and transition 0; verify the light's temperature stays the same
310
+ (allow calculation changes around a time boundary and device rounding).
311
+ 5. Turn the light off and wait for OFF. Repeat at another time, or toggle the
312
+ profile's sleep-mode helper with a distinct configured sleep temperature.
313
+ Trigger ON; verify the new value rather than the previous temperature is used.
314
+ Restore the sleep helper after testing.
315
+ 6. While ON, send another automatic ON with `brightness_pct: 80`. Verify brightness
316
+ changes and `adaptive.applied: false`; with Keep current, temperature stays put.
317
+ 7. From OFF, send ON with `color_temp_kelvin: 3000`, then a supported explicit
318
+ color command. Verify each wins and no adaptive action runs. Try a manual ON
319
+ and verify the same suppression.
320
+ 8. Set brightness to 100% externally. Automatic OFF must remain blocked. Lower
321
+ below the configured threshold and verify OFF works again.
322
+ 9. Test missing/ambiguous profiles, the main switch ON, and a non-CT light. Confirm
323
+ normal ON plus the corresponding warning/unsupported event. Redeploy/reconnect
324
+ and verify fresh discovery, safe observation and no duplicate adaptive calls.
325
+
326
+ Actual Adaptive Lighting version, exposed configuration, sleep behavior, lamp
327
+ latency/rounding and the two-action race still require verification on your HA
328
+ installation. Automated tests mock that service boundary, not its algorithm.
329
+
330
+ ## Test the updated 0.1.8 in your HA Node-RED installation
261
331
 
262
332
  1. Save/export your current test flow. Transfer the rebuilt local
263
- `johanwistbacka-node-red-contrib-sw-light-0.1.4.tgz` to the Node-RED host. Check that HA contrib
333
+ `johanwistbacka-node-red-contrib-sw-light-0.1.8.tgz` to the Node-RED host. Check that HA contrib
264
334
  is 0.80.3; do not upgrade it for this task.
265
335
  2. In a terminal for that Node-RED instance, find **User directory** in its
266
336
  startup log. Change to that directory (not a global npm directory) and run
267
- `npm install ./johanwistbacka-node-red-contrib-sw-light-0.1.4.tgz --ignore-scripts --no-audit --no-fund`.
337
+ `npm install ./johanwistbacka-node-red-contrib-sw-light-0.1.8.tgz --ignore-scripts --no-audit --no-fund`.
268
338
  Use the add-on's supported local package installation mechanism if its terminal
269
339
  does not expose that user directory/npm. Restart Node-RED/the Node-RED add-on
270
340
  and reload the editor so the updated runtime and HTML are both loaded.
271
341
  3. Add a timestamp Inject directly into SW Light. Choose your **existing HA
272
342
  server**, one `light.*` entity, Action **On**, and the desired On brightness
273
- (94% by default). Keep optional Adaptive apply disabled for this basic test.
343
+ (94% by default). Keep Adaptive Lighting off for this basic test.
274
344
  Remove downstream HA Action nodes; leave outputs unconnected or use Debug.
275
345
  4. Deploy. SW Light constructs without resolving HA and initially shows
276
346
  `HA NOT READY`. With the light OFF, click Inject: the light should turn on at the
@@ -279,7 +349,7 @@ SW Light never repeatedly corrects brightness or fights its adaptation.
279
349
  5. Set the lamp to 100% manually in HA (verify attribute `brightness: 255`).
280
350
  Try On, Off and Toggle: no automatic light service should run, and the node
281
351
  should show `MANUAL · 100% · BLOCK ...`. Lower the light manually to 94%, 95%
282
- or 99%, then retry; automatic control should resume with default override settings.
352
+ then retry; automatic control should resume with default override settings.
283
353
  6. Select **From msg** and use Inject properties `action` (string) with `on`,
284
354
  `off` and `toggle`. Also test `auto_light.action` when top-level action is
285
355
  absent. A missing/invalid action should show an error, emit output 2, and
@@ -310,13 +380,14 @@ Output 1 follows successful light service acknowledgement:
310
380
  "service_data": { "brightness": 115, "transition": 1 },
311
381
  "ignored_conflicts": [], "state_confirmation": "last_observed",
312
382
  "observed_at": "2026-09-30T10:00:00Z",
313
- "adaptive": { "entity_id": null, "active": false, "manual_control": false, "state": null }
383
+ "adaptive": { "entity_id": null, "active": false, "state": null, "candidates": [], "applied": false, "reason": "not_requested" }
314
384
  }
315
385
  }
316
386
  ```
317
387
 
318
- `adaptive_lighting` means **configured mode**. `adaptive.active` means a resolved
319
- switch is ON. `adaptive.applied` is present when optional apply is evaluated.
388
+ `adaptive_lighting` means the command's effective enablement (message overrides node).
389
+ `adaptive.active` means a resolved profile is available with its main switch OFF.
390
+ `adaptive.applied` reports successful action acknowledgement, not measured Kelvin.
320
391
  The state is the **last observed HA snapshot**, possibly preceding the action
321
392
  or an unfinished transition. Service acknowledgement does not confirm a physical
322
393
  lamp change. No requested value is fabricated as observed state. The first input
@@ -357,7 +428,7 @@ Output 2 has a stable versioned event envelope:
357
428
  ```
358
429
 
359
430
  Types: `manual_override`, `override_released`, `unavailable`, `unsupported`,
360
- `error`. Details include `reason`, `parameters`, `message`, `phase` as applicable.
431
+ `warning`, `error`. Details include `reason`, `parameters`, `message`, `phase` as applicable.
361
432
  Unsupported parameters are omitted and the supported action still runs.
362
433
  Blocked commands emit only output 2. Invalid input and transport failures also
363
434
  call Node-RED `done(error)` for Catch-node handling. Connection/state events have
@@ -381,7 +452,7 @@ npm pack --dry-run
381
452
  Run these checks from the source checkout; test tooling is not included in the npm package.
382
453
  Tests use Node's built-in test runner without HA or npm dependencies. They cover
383
454
  numeric ranges, precedence, max/min, manual override, color translation,
384
- unsupported capabilities, adaptive discovery/manual control, runtime outputs,
455
+ unsupported capabilities, one-shot adaptive discovery/fallback, runtime outputs,
385
456
  async errors, reconnect readiness and listener cleanup. The source checkout retains a separate development integration report; it is
386
457
  excluded from the public npm package. Importable examples are in `examples/`; select your
387
458
  own HA server and light before deploying them in a dev instance.
@@ -395,14 +466,14 @@ own HA server and light before deploying them in a dev instance.
395
466
  - A v0.1 raw state subscription receives all state changes and filters locally;
396
467
  one is created per SW Light node. It is removed on close, and HA's websocket
397
468
  resubscription handles reconnects. Sharing subscriptions is a v0.2 improvement.
398
- - Adaptive attributes are not standardized. Existing manual-control detection
399
- and automatic brightness ownership require testing with the actual integration.
469
+ - Adaptive discovery requires exposed configuration and exact light membership.
470
+ Use Advanced selection when discovery is unavailable; verify the installed profile.
400
471
  - Keep current depends on HA/device behavior. Color gamut correction and XY to
401
472
  other color-space conversion are not implemented. ON/OFF lights remain usable
402
473
  without brightness/color capabilities.
403
474
 
404
475
  Before live use, test server selection/autocomplete, each lamp capability,
405
- external brightness changes, threshold/explicit release, adaptive takeover,
476
+ external brightness changes, threshold/explicit release, adaptive one-shot behavior,
406
477
  long transitions, deletion/unavailability, reconnect and repeated redeployment
407
478
  in your real editor with a development light. No live deployment, global config
408
479
  change, npm publication or GitHub release is part of this implementation.
@@ -139,7 +139,8 @@
139
139
  "sw-example-basic-events"
140
140
  ]
141
141
  ],
142
- "action": "msg"
142
+ "action": "msg",
143
+ "overrideThreshold": 98
143
144
  },
144
145
  {
145
146
  "id": "sw-example-basic-result",
@@ -11,7 +11,7 @@
11
11
  "type": "comment",
12
12
  "z": "sw-example-manual-adaptive",
13
13
  "name": "Select HA server + light in SW Light before deploy",
14
- "info": "Adaptive apply is off by default. Select your Adaptive Lighting switch and enable optional color-only apply if desired.",
14
+ "info": "Adaptive Lighting is off by default. Enable the checkbox for automatic OFF to ON; keep the HA profile main switch off. Advanced selection is only needed when discovery is ambiguous or unavailable.",
15
15
  "x": 350,
16
16
  "y": 60,
17
17
  "wires": []
@@ -193,7 +193,8 @@
193
193
  "sw-example-manual-adaptive-events"
194
194
  ]
195
195
  ],
196
- "action": "msg"
196
+ "action": "msg",
197
+ "overrideThreshold": 98
197
198
  },
198
199
  {
199
200
  "id": "sw-example-manual-adaptive-result",
package/lib/adaptive.js CHANGED
@@ -2,38 +2,48 @@
2
2
 
3
3
  const { fault } = require('./light');
4
4
 
5
- function lights(state) {
6
- const a = state?.attributes || {};
7
- const list = a.configuration?.lights ?? a.lights;
8
- return Array.isArray(list) ? list : [];
5
+ // Main Adaptive Lighting switches expose configuration.lights in upstream
6
+ // extra_state_attributes. Auxiliary switches do not. Never infer entity names.
7
+ function candidates(c, states) {
8
+ return Object.values(states).filter(s => s.entity_id?.startsWith('switch.') &&
9
+ Array.isArray(s.attributes?.configuration?.lights) &&
10
+ s.attributes.configuration.lights.includes(c.entityId)).map(s => s.entity_id);
9
11
  }
10
12
  function select(c, states) {
11
13
  if (c.adaptiveEntity) return c.adaptiveEntity;
12
- if (!c.adaptiveDiscover) return null;
13
- const candidates = Object.values(states).filter(s => s.entity_id?.startsWith('switch.') &&
14
- lights(s).includes(c.entityId) &&
15
- (s.attributes?.configuration || Array.isArray(s.attributes?.manual_control)));
16
- return candidates.length === 1 ? candidates[0].entity_id : null;
14
+ const matches = candidates(c, states);
15
+ return matches.length === 1 ? matches[0] : null;
17
16
  }
18
17
  function inspect(c, states) {
19
- const entityId = select(c, states), state = entityId ? states[entityId] : null;
20
- const a = state?.attributes || {};
21
- // Conservatively respect either color-specific or aggregate manual control.
22
- const manual = [a.manual_control, a.manual_control_color].some(list => Array.isArray(list) && list.includes(c.entityId));
23
- return { entity_id: entityId, active: c.colorMode === 'adaptive' && state?.state === 'on',
24
- manual_control: manual, state: state?.state ?? null };
18
+ const matches = candidates(c, states), entityId = select(c, states);
19
+ const state = entityId ? states[entityId] : null;
20
+ return { entity_id: entityId, active: c.adaptiveEnabled && state?.state === 'off',
21
+ state: state?.state ?? null, candidates: matches };
25
22
  }
26
23
  async function apply(adapter, c, command) {
27
- if (c.colorMode !== 'adaptive' || !c.adaptiveApply || !command.turningOn || command.explicitColor) return { applied: false };
24
+ if (!command.adaptiveEnabled || !command.offToOn || command.source !== 'automatic' || command.explicitColor) {
25
+ return { applied: false, reason: 'not_requested' };
26
+ }
27
+ if (!command.supportsTemperature) return { applied: false, reason: 'unsupported_color_temperature' };
28
28
  const info = inspect(c, adapter.states());
29
- if (!info.entity_id) throw fault('Select an Adaptive Lighting switch; discovery found no unique match', 'unsupported');
30
- if (!info.active) return { applied: false, reason: 'switch_not_on' };
31
- if (c.adaptiveRespectManual && info.manual_control) return { applied: false, reason: 'manual_control' };
32
- if (!adapter.services()?.adaptive_lighting?.apply) throw fault('Adaptive Lighting apply service is unavailable', 'unsupported');
29
+ const fail = (reason, message) => {
30
+ const error = fault(message, 'warning'); error.reason = reason; throw error;
31
+ };
32
+ if (!info.entity_id) fail(info.candidates.length > 1 ? 'ambiguous_configuration' : 'configuration_missing',
33
+ 'Adaptive Lighting: no unique profile for this light; select a profile under Advanced.');
34
+ if (!['on', 'off'].includes(info.state)) fail('profile_unavailable', 'Adaptive Lighting profile is unavailable.');
35
+ // A running profile can intercept ON and continuously adapt brightness/color.
36
+ // One-shot apply also works with the main switch off, without installing listeners.
37
+ if (info.state !== 'off') fail('profile_running', 'Turn off the Adaptive Lighting main switch to use it as a one-shot profile.');
38
+ const configuration = adapter.states()[info.entity_id]?.attributes?.configuration;
39
+ if (configuration?.sleep_rgb_or_color_temp === 'rgb_color') fail('profile_uses_rgb',
40
+ 'Adaptive Lighting profile uses RGB sleep color; choose a color-temperature sleep profile.');
41
+ if (!adapter.services()?.adaptive_lighting?.apply) fail('service_unavailable', 'Adaptive Lighting apply action is unavailable.');
33
42
  await adapter.call('adaptive_lighting', 'apply', {
34
43
  entity_id: info.entity_id, lights: [c.entityId], adapt_brightness: false,
35
- adapt_color: true, turn_on_lights: false
44
+ adapt_color: true, prefer_rgb_color: false, turn_on_lights: true,
45
+ transition: command.data.transition ?? 0
36
46
  });
37
- return { applied: true };
47
+ return { applied: true, entity_id: info.entity_id };
38
48
  }
39
- module.exports = { select, inspect, apply };
49
+ module.exports = { candidates, select, inspect, apply };
package/lib/light.js CHANGED
@@ -3,10 +3,9 @@
3
3
  const DEFAULTS = Object.freeze({
4
4
  action: 'msg',
5
5
  brightnessPct: 94, autoMaxPct: 94, minPct: 1, transitionOn: 1,
6
- transitionOff: 1, manualOverride: true, overrideThreshold: 100,
7
- overrideStrategy: 'threshold', colorMode: 'adaptive', kelvin: 2700,
8
- mired: 370, fixedColor: '#ffffff', adaptiveEntity: '',
9
- adaptiveDiscover: true, adaptiveApply: false, adaptiveRespectManual: true
6
+ transitionOff: 1, manualOverride: true, overrideThreshold: 98,
7
+ overrideStrategy: 'threshold', adaptiveEnabled: false, colorMode: 'keep', kelvin: 2700,
8
+ mired: 370, fixedColor: '#ffffff', adaptiveEntity: ''
10
9
  });
11
10
  const COLOR_KEYS = ['color_temp_kelvin', 'color_temp', 'rgbww_color', 'rgbw_color',
12
11
  'rgb_color', 'hs_color', 'xy_color', 'color_name'];
@@ -34,6 +33,11 @@ function boolean(value, name) {
34
33
  }
35
34
  function config(raw) {
36
35
  const c = { ...DEFAULTS, ...raw };
36
+ if (own(raw, 'adaptiveApply')) boolean(raw.adaptiveApply, 'adaptiveApply');
37
+ // Preserve opt-in from legacy flows; inactive legacy adaptive mode stays off.
38
+ c.adaptiveEnabled = own(raw, 'adaptiveEnabled') ? boolean(raw.adaptiveEnabled, 'adaptiveEnabled') :
39
+ raw.colorMode === 'adaptive' && boolean(raw.adaptiveApply ?? false, 'adaptiveApply');
40
+ if (c.colorMode === 'adaptive') c.colorMode = 'keep';
37
41
  if (!['on', 'off', 'toggle', 'msg'].includes(c.action)) throw fault('Invalid configured action');
38
42
  for (const key of ['brightnessPct', 'autoMaxPct', 'minPct', 'overrideThreshold']) {
39
43
  c[key] = number(c[key], key, key === 'minPct' || key === 'overrideThreshold' ? 1 : 0, 100);
@@ -42,9 +46,7 @@ function config(raw) {
42
46
  throw fault('Require minPct <= brightnessPct <= autoMaxPct');
43
47
  }
44
48
  for (const key of ['transitionOn', 'transitionOff']) c[key] = number(c[key], key, 0, 3600);
45
- for (const key of ['manualOverride', 'adaptiveDiscover', 'adaptiveApply', 'adaptiveRespectManual']) {
46
- c[key] = boolean(c[key], key);
47
- }
49
+ c.manualOverride = boolean(c.manualOverride, 'manualOverride');
48
50
  if (c.manualOverride && c.overrideStrategy === 'threshold' && c.autoMaxPct >= c.overrideThreshold) {
49
51
  throw fault('Automatic maximum must be below the manual override threshold');
50
52
  }
@@ -138,6 +140,7 @@ function resolveAction(c, msg) {
138
140
  function plan(c, msg, state, overrideActive) {
139
141
  const action = resolveAction(c, msg);
140
142
  const source = msg.source ?? 'automatic';
143
+ const adaptiveEnabled = own(msg, 'adaptive_lighting') ? boolean(msg.adaptive_lighting, 'adaptive_lighting') : c.adaptiveEnabled;
141
144
  if (!['automatic', 'manual'].includes(source)) throw fault('msg.source must be automatic or manual');
142
145
  const turningOn = action === 'on' || (action === 'toggle' && state.state === 'off');
143
146
  const cap = capabilities(state), data = {}, unsupported = [];
@@ -180,8 +183,9 @@ function plan(c, msg, state, overrideActive) {
180
183
  for (const key of Object.keys(data)) if (key !== 'transition') delete data[key];
181
184
  if (!own(msg, 'transition') && cap.transition) data.transition = c.transitionOff;
182
185
  }
183
- return { action, source, service: turningOn && !zero ? 'turn_on' : 'turn_off',
184
- turningOn: turningOn && !zero, data, unsupported,
186
+ return { action, source, adaptiveEnabled, service: turningOn && !zero ? 'turn_on' : 'turn_off',
187
+ turningOn: turningOn && !zero, offToOn: turningOn && !zero && state.state === 'off',
188
+ supportsTemperature: cap.modes.includes('color_temp'), data, unsupported,
185
189
  blocked: source === 'automatic' && overrideActive,
186
190
  explicitColor: colors.length > 0,
187
191
  ignoredConflicts: [...bKeys.slice(1), ...colors.slice(1).map(([key]) => key)] };
@@ -192,7 +196,7 @@ function normalized(state, override, adaptive, action) {
192
196
  brightness: a.brightness ?? null, brightness_pct: Number.isFinite(a.brightness) ? Math.round(pct(a.brightness) * 10) / 10 : null,
193
197
  color_mode: a.color_mode ?? null, color_temp_kelvin: a.color_temp_kelvin ?? (a.color_temp ? Math.round(1000000 / a.color_temp) : null),
194
198
  color_temp: a.color_temp ?? null,
195
- color: Object.fromEntries(['rgb_color','rgbw_color','rgbww_color','hs_color','xy_color'].filter(k => a[k] !== undefined).map(k => [k, [...a[k]]])),
199
+ color: Object.fromEntries(['rgb_color','rgbw_color','rgbww_color','hs_color','xy_color'].filter(k => Array.isArray(a[k])).map(k => [k, [...a[k]]])),
196
200
  manual_override: override, adaptive_lighting: adaptive, action,
197
201
  observed_at: state?.last_updated ?? null };
198
202
  }
@@ -26,20 +26,31 @@
26
26
  minPct: { value: 1, validate: numeric('minPct') },
27
27
  transitionOn: { value: 1, validate: numeric('transitionOn') },
28
28
  transitionOff: { value: 1, validate: numeric('transitionOff') },
29
- manualOverride: { value: true }, overrideThreshold: { value: 100, validate: numeric('overrideThreshold') },
30
- overrideStrategy: { value: 'threshold' }, colorMode: { value: 'adaptive' },
29
+ manualOverride: { value: true }, overrideThreshold: { value: 98, validate: numeric('overrideThreshold') },
30
+ overrideStrategy: { value: 'threshold' }, colorMode: { value: 'keep' },
31
+ adaptiveEnabled: { value: false },
31
32
  kelvin: { value: 2700, validate: numeric('kelvin') }, mired: { value: 370, validate: numeric('mired') },
32
33
  fixedColor: { value: '#ffffff', validate: v => /^#[0-9a-f]{6}$/i.test(v) },
33
34
  adaptiveEntity: { value: '', validate: v => !v || /^switch\.[a-z0-9_]+$/.test(v) },
34
- adaptiveDiscover: { value: true }, adaptiveApply: { value: false }, adaptiveRespectManual: { value: true }
35
35
  },
36
36
  oneditprepare: function () {
37
+ if (this.colorMode === 'adaptive') {
38
+ $('#node-input-colorMode').val('keep');
39
+ $('#node-input-adaptiveEnabled').prop('checked', this.adaptiveApply === true);
40
+ }
37
41
  let states = {}, request, generation = 0;
38
42
  const entity = $('#node-input-entityId'), adaptive = $('#node-input-adaptiveEntity');
43
+ function modesForLight() { return states[entity.val()]?.attributes?.supported_color_modes || []; }
39
44
  function update() {
40
45
  const mode = $('#node-input-colorMode').val();
41
46
  $('.sw-color-row').hide();
42
47
  $('.sw-color-' + mode).show();
48
+ $('.sw-adaptive-row').toggle($('#node-input-adaptiveEnabled').prop('checked'));
49
+ const profiles = Object.values(states).filter(s => s.entity_id.startsWith('switch.') &&
50
+ Array.isArray(s.attributes?.configuration?.lights) && s.attributes.configuration.lights.includes(entity.val()));
51
+ $('#sw-adaptive-info').text(modesForLight().includes('color_temp') ?
52
+ (profiles.length === 1 ? 'Profile found. Keep its main switch off for one-shot use.' :
53
+ 'No unique profile found. Select a profile under Advanced.') : 'This light has no known color-temperature support. Normal ON still works.');
43
54
  $('.sw-override-row').toggle($('#node-input-manualOverride').prop('checked'));
44
55
  $('.sw-threshold-row').toggle($('#node-input-manualOverride').prop('checked') && $('#node-input-overrideStrategy').val() === 'threshold');
45
56
  const s = states[entity.val()], modes = s && s.attributes && s.attributes.supported_color_modes;
@@ -52,6 +63,7 @@
52
63
  return function (req, respond) {
53
64
  const term = req.term.toLowerCase();
54
65
  respond(Object.values(states).filter(s => s.entity_id.startsWith(domain + '.') &&
66
+ (domain !== 'switch' || s.attributes?.configuration) &&
55
67
  (s.entity_id.toLowerCase().includes(term) || String(s.attributes.friendly_name || '').toLowerCase().includes(term)))
56
68
  .slice(0,100).map(s => ({ label: (s.attributes.friendly_name || s.entity_id) + ' (' + s.entity_id + ')', value: s.entity_id })));
57
69
  };
@@ -71,12 +83,12 @@
71
83
  .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.'); });
72
84
  }
73
85
  $('#node-input-server').on('change.swLight', load);
74
- $('#node-input-colorMode, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId').on('change.swLight', update);
86
+ $('#node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId').on('change.swLight', update);
75
87
  entity.on('focus.swLight', function () { entity.autocomplete('search', entity.val()); });
76
88
  adaptive.on('focus.swLight', function () { adaptive.autocomplete('search', adaptive.val()); });
77
89
  this._swCleanup = function () {
78
90
  generation++; if (request) request.abort();
79
- $('#node-input-server, #node-input-colorMode, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId, #node-input-adaptiveEntity').off('.swLight');
91
+ $('#node-input-server, #node-input-colorMode, #node-input-adaptiveEnabled, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId, #node-input-adaptiveEntity').off('.swLight');
80
92
  entity.autocomplete('destroy'); adaptive.autocomplete('destroy');
81
93
  delete this._swCleanup;
82
94
  };
@@ -108,15 +120,16 @@
108
120
  <p class="sw-override-row">Automatic commands pause during override. Manual commands use <code>msg.source = "manual"</code>.</p>
109
121
  </fieldset>
110
122
  <fieldset><legend>Color</legend>
111
- <div class="form-row"><label for="node-input-colorMode">Mode</label><select id="node-input-colorMode"><option value="adaptive">Adaptive Lighting</option><option value="keep">Keep current</option><option value="kelvin">Fixed Kelvin</option><option value="mired">Fixed mired</option><option value="default">Lamp default</option><option value="color">Fixed color</option></select></div>
123
+ <div class="form-row"><label for="node-input-colorMode">Mode</label><select id="node-input-colorMode"><option value="keep">Keep current</option><option value="kelvin">Fixed Kelvin</option><option value="mired">Fixed mired</option><option value="default">Lamp default</option><option value="color">Fixed color</option></select></div>
112
124
  <div class="form-row sw-color-row sw-color-kelvin"><label for="node-input-kelvin">Kelvin</label><input id="node-input-kelvin" type="number" min="1000" max="40000"></div>
113
125
  <div class="form-row sw-color-row sw-color-mired"><label for="node-input-mired">Mired</label><input id="node-input-mired" type="number" min="25" max="1000" step="0.1"></div>
114
126
  <div class="form-row sw-color-row sw-color-color"><label for="node-input-fixedColor">Color</label><input id="node-input-fixedColor" type="color"></div>
115
- <div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveEntity">Adaptive switch</label><input id="node-input-adaptiveEntity" type="text" placeholder="Optional switch.entity_id"></div>
116
- <div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveDiscover">Discover</label><input id="node-input-adaptiveDiscover" type="checkbox" style="width:auto"> From a unique light membership match</div>
117
- <div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveApply">Apply on turn-on</label><input id="node-input-adaptiveApply" type="checkbox" style="width:auto"> Color only</div>
118
- <div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveRespectManual">Respect manual</label><input id="node-input-adaptiveRespectManual" type="checkbox" style="width:auto"></div>
119
- <p class="sw-color-row sw-color-adaptive">Ordinary turn-on sends no color. Adaptive Lighting owns color adaptation. Apply is optional and never clears its manual control.</p>
127
+ <div class="form-row"><label for="node-input-adaptiveEnabled">Adaptive Lighting</label><input id="node-input-adaptiveEnabled" type="checkbox" style="width:auto"> On / Off</div>
128
+ <p class="sw-adaptive-row" id="sw-adaptive-info"></p>
129
+ <details class="sw-adaptive-row"><summary>Advanced: profile selection</summary>
130
+ <div class="form-row"><label for="node-input-adaptiveEntity">Profile</label><input id="node-input-adaptiveEntity" type="text" placeholder="Automatic (unique light membership)"></div>
131
+ <p>Select only when automatic discovery is unavailable or ambiguous. The profile's main switch must be off.</p>
132
+ </details>
120
133
  </fieldset>
121
134
  </script>
122
135
 
@@ -125,8 +138,8 @@
125
138
  <h3>Inputs</h3>
126
139
  <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>
127
140
  <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>
128
- <h3>Details</h3><p>Default on brightness/max 94%, minimum 1%, fade 1s. ON at 100% (brightness 255) always blocks automatic ON, OFF and Toggle. 94%, 95% and 99% do not trigger this guard. Optional threshold/explicit overrides can also pause automation. Lowering brightness below the threshold releases its flag; turning off releases all override flags. Manual commands bypass the automatic maximum. Explicit flags require explicit release or off.</p>
141
+ <h3>Details</h3><p>Default on brightness/max 94%, minimum 1%, fade 1s. ON at 100% (brightness 255) always blocks automatic ON, OFF and Toggle. 94%, 95% and 99% do not trigger this guard. Threshold detection defaults to 98% (brightness 250 or above) and pauses automatic commands; explicit overrides can also pause automation. Lowering brightness below the threshold releases its flag; turning off releases all override flags. Manual commands bypass the automatic maximum. Explicit flags require explicit release or off.</p>
129
142
  <p>Top-level message properties override configuration. Brightness precedence: percent, byte, percent step, byte step. Color precedence: Kelvin, mired, RGBWW, RGBW, RGB, HS, XY, name. Invalid values produce errors; unsupported parameters emit an event and are omitted.</p>
130
- <p>Adaptive mode sends no configured color. An explicit message color takes precedence and suppresses optional adaptive apply for that command. Keep current and lamp default both omit configured color and leave HA/device behavior in control.</p>
143
+ <p>Adaptive Lighting applies current color temperature once after automatic OFF → ON, with SW Light brightness and fade. Explicit message color wins; already-on brightness changes and manual commands never apply it. A unique profile is discovered automatically; Advanced selects a fallback. Keep its main switch off to prevent continuous control. Use a profile with color-temperature sleep color; RGB sleep profiles fall back with a warning. <code>msg.adaptive_lighting</code> overrides the checkbox for one command. Missing, ambiguous or running profiles emit a warning and retain normal configured color behavior. Keep current and lamp default both omit configured color and leave HA/device behavior in control.</p>
131
144
  <p>Requires HA contrib 0.80.3. See package README for full contracts, examples and compatibility limitations.</p>
132
145
  </script>
package/nodes/sw-light.js CHANGED
@@ -11,7 +11,7 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
11
11
  const node = this;
12
12
  node.log?.(`[SW Light debug] version=${require('../package.json').version} constructor=created serverIdPresent=${Boolean(raw.server)}`);
13
13
  let c, detector, adapter, closed = false, lastAvailability;
14
- let queue = Promise.resolve(), startup = Promise.resolve();
14
+ let queue = Promise.resolve(), startup = Promise.resolve(), observerTimer;
15
15
  const protectedAtFullBrightness = state => state?.state === 'on' && state.attributes?.brightness === 255;
16
16
  const overrideActive = state => detector.active || protectedAtFullBrightness(state);
17
17
  function event(type, detail = {}, msg = {}, send = node.send.bind(node)) {
@@ -34,11 +34,11 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
34
34
  if (lastAvailability !== availability) event('unavailable', { reason: availability });
35
35
  } else {
36
36
  const active = overrideActive(state);
37
- const info = light.normalized(state, active, c.colorMode === 'adaptive', 'state_changed');
37
+ const info = light.normalized(state, active, c.adaptiveEnabled, 'state_changed');
38
38
  let text = active ? 'MANUAL' : `AUTO · ${info.state.toUpperCase()}`;
39
39
  if (info.state === 'on' && info.brightness_pct !== null) text += ` · ${info.brightness_pct}%`;
40
40
  if (info.state === 'on' && info.color_temp_kelvin) text += ` · ${info.color_temp_kelvin}K`;
41
- if (info.state === 'on' && c.colorMode === 'adaptive') text += ' · adaptive';
41
+ if (info.state === 'on' && c.adaptiveEnabled) text += ' · adaptive';
42
42
  node.status({ fill: active ? 'yellow' : info.state === 'on' ? 'green' : 'grey', shape: 'dot', text });
43
43
  }
44
44
  lastAvailability = availability;
@@ -60,12 +60,26 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
60
60
  });
61
61
  connection(adapter.connected());
62
62
  }
63
+ function startObserving() {
64
+ if (closed || node.swLightError) return;
65
+ startup = Promise.resolve().then(async () => {
66
+ if (!adapter) startAdapter();
67
+ if (adapter.resolve) await adapter.resolve();
68
+ }).catch(error => {
69
+ if (closed) return;
70
+ event(error.eventType || 'error', { message: error.message, phase: 'state_subscription' });
71
+ observerTimer = setTimeout(startObserving, 1000);
72
+ observerTimer.unref?.();
73
+ });
74
+ }
63
75
  try {
64
76
  c = light.config(raw);
65
77
  detector = new light.Override(c);
66
- // No HA module/client resolution during Flow.start. Startup order
67
- // cannot permanently disable this node; first input resolves HA.
78
+ // Defer provider resolution until after Flow.start. Observe even
79
+ // before this node's first command; retry temporary startup failures.
68
80
  node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
81
+ observerTimer = setTimeout(startObserving, 0);
82
+ observerTimer.unref?.();
69
83
  } catch (error) {
70
84
  node.status({ fill: 'red', shape: 'ring', text: 'CONFIG ERROR' });
71
85
  node.error(error.message);
@@ -107,15 +121,23 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
107
121
  serviceCompleted = true;
108
122
  if (closed) { done(); return; }
109
123
  let adaptiveResult;
110
- try { adaptiveResult = await adaptive.apply(adapter, c, command); }
111
- catch (error) { event(error.eventType || 'error', { message: error.message, phase: 'adaptive_apply', light_service_completed: true }, msg, send); }
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);
133
+ }
112
134
  if (closed) { done(); return; }
113
135
  const current = adapter.state(c.entityId);
114
136
  observe(current);
115
- send([{ ...msg, sw_light: { ...light.normalized(current, overrideActive(current), c.colorMode === 'adaptive', command.service),
137
+ send([{ ...msg, sw_light: { ...light.normalized(current, overrideActive(current), command.adaptiveEnabled, command.service),
116
138
  entity_id: c.entityId, source: command.source, requested_action: command.action,
117
139
  service_data: command.data, ignored_conflicts: command.ignoredConflicts,
118
- state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect(c, adapter.states()), ...adaptiveResult } } }, null]);
140
+ state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect({ ...c, adaptiveEnabled: command.adaptiveEnabled }, adapter.states()), ...adaptiveResult } } }, null]);
119
141
  const level = command.data.brightness === undefined ? '' : ` · ${Math.round(light.pct(command.data.brightness))}%`;
120
142
  node.status({ fill: command.source === 'manual' ? 'yellow' : command.turningOn ? 'green' : 'grey', shape: 'dot',
121
143
  text: `${command.source === 'manual' ? 'MANUAL' : 'AUTO'} · ${command.turningOn ? 'ON' : 'OFF'}${level}` });
@@ -132,6 +154,7 @@ module.exports = function register(RED, adapterFactory = createAdapter) {
132
154
  });
133
155
  node.on('close', (_removed, done) => {
134
156
  closed = true;
157
+ clearTimeout(observerTimer);
135
158
  Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => done());
136
159
  });
137
160
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johanwistbacka/node-red-contrib-sw-light",
3
- "version": "0.1.4",
3
+ "version": "0.1.8",
4
4
  "description": "Home Assistant light control with automatic brightness limits and manual override",
5
5
  "license": "MIT",
6
6
  "keywords": [