@johanwistbacka/node-red-contrib-sw-light 0.1.4 → 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/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 };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+ const light = require('./light');
3
+ const adaptive = require('./adaptive');
4
+
5
+ // One independent controller per entity, shared by every target type.
6
+ function createController(c, adapter, node, isClosed) {
7
+ const detector = new light.Override(c);
8
+ let lastAvailability;
9
+ const protectedAtFullBrightness = state => state?.state === 'on' && state.attributes?.brightness === 255;
10
+ const overrideActive = state => detector.active || protectedAtFullBrightness(state);
11
+ function event(type, detail = {}, msg = {}, send = node.send.bind(node)) {
12
+ if (isClosed()) return;
13
+ send([null, { ...msg, sw_light_event: { version: 1, type, entity_id: c.entityId,
14
+ timestamp: new Date().toISOString(), ...detail } }]);
15
+ }
16
+ function overrideUpdate(fn, msg, send) {
17
+ const before = detector.active;
18
+ fn();
19
+ if (before !== detector.active) event(detector.active ? 'manual_override' : 'override_released',
20
+ { active: detector.active, strategy: c.overrideStrategy }, msg, send);
21
+ }
22
+ function observe(state) {
23
+ if (isClosed()) return;
24
+ overrideUpdate(() => detector.observe(state));
25
+ const availability = !state ? 'missing' : state.state;
26
+ if (['missing', 'unknown', 'unavailable'].includes(availability)) {
27
+ node.status({ fill: 'red', shape: 'ring', text: 'ENTITY UNAVAILABLE' });
28
+ if (lastAvailability !== availability) event('unavailable', { reason: availability });
29
+ } else {
30
+ const active = overrideActive(state);
31
+ const info = light.normalized(state, active, c.adaptiveEnabled, 'state_changed');
32
+ let text = active ? 'MANUAL' : `AUTO · ${info.state.toUpperCase()}`;
33
+ if (info.state === 'on' && info.brightness_pct !== null) text += ` · ${info.brightness_pct}%`;
34
+ if (info.state === 'on' && info.color_temp_kelvin) text += ` · ${info.color_temp_kelvin}K`;
35
+ if (info.state === 'on' && c.adaptiveEnabled) text += ' · adaptive';
36
+ node.status({ fill: active ? 'yellow' : info.state === 'on' ? 'green' : 'grey', shape: 'dot', text });
37
+ }
38
+ lastAvailability = availability;
39
+ }
40
+
41
+ async function execute(msg, send) {
42
+ let command, serviceCompleted = false;
43
+ try {
44
+ const state = adapter.state(c.entityId);
45
+ if (!state || !['on', 'off'].includes(state.state)) throw light.fault(`Light is ${state?.state ?? 'missing'}`, 'unavailable');
46
+ observe(state);
47
+ const requestedOverride = Object.prototype.hasOwnProperty.call(msg, 'manual_override');
48
+ if (requestedOverride && (msg.source ?? 'automatic') !== 'manual') throw light.fault('manual_override requires source: manual');
49
+ if (requestedOverride && typeof msg.manual_override !== 'boolean') throw light.fault('manual_override must be boolean');
50
+ command = light.plan(c, msg, state, overrideActive(state));
51
+ if (requestedOverride) {
52
+ overrideUpdate(() => detector.set(msg.manual_override), msg, send);
53
+ command.blocked = false; // validated manual source
54
+ }
55
+ if (command.unsupported.length) event('unsupported', { parameters: command.unsupported, reason: 'lamp_capabilities_or_action' }, msg, send);
56
+ if (command.blocked) {
57
+ event('manual_override', { active: true, blocked: true, requested_action: command.action }, msg, send);
58
+ const percent = Number.isFinite(state.attributes?.brightness) ? Math.round(light.pct(state.attributes.brightness)) : '?';
59
+ node.status({ fill: 'yellow', shape: 'dot', text: `MANUAL · ${percent}% · BLOCK ${command.action.toUpperCase()}` });
60
+ return { ...light.normalized(state, overrideActive(state), command.adaptiveEnabled, command.service), entity_id: c.entityId, outcome: 'skipped', reason: 'manual_override', source: command.source, requested_action: command.action, state_confirmation: 'last_observed' };
61
+ }
62
+ await adapter.call('light', command.service, command.data, { entity_id: c.entityId });
63
+ serviceCompleted = true;
64
+ if (isClosed()) return { entity_id: c.entityId, outcome: 'skipped', reason: 'node_closed' };
65
+ let adaptiveResult;
66
+ try {
67
+ adaptiveResult = await adaptive.apply(adapter, c, command);
68
+ if (adaptiveResult.reason === 'unsupported_color_temperature') event('unsupported',
69
+ { phase: 'adaptive_apply', reason: adaptiveResult.reason, light_service_completed: true }, msg, send);
70
+ }
71
+ catch (error) {
72
+ adaptiveResult = { applied: false, reason: error.reason || 'apply_failed' };
73
+ event('warning', { message: error.message, reason: adaptiveResult.reason,
74
+ phase: 'adaptive_apply', light_service_completed: true }, msg, send);
75
+ }
76
+ if (isClosed()) return { entity_id: c.entityId, outcome: 'skipped', reason: 'node_closed' };
77
+ const current = adapter.state(c.entityId);
78
+ observe(current);
79
+ const result = { ...light.normalized(current, overrideActive(current), command.adaptiveEnabled, command.service),
80
+ entity_id: c.entityId, source: command.source, requested_action: command.action,
81
+ service_data: command.data, ignored_conflicts: command.ignoredConflicts,
82
+ state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect({ ...c, adaptiveEnabled: command.adaptiveEnabled }, adapter.states()), ...adaptiveResult } };
83
+
84
+ const level = command.data.brightness === undefined ? '' : ` · ${Math.round(light.pct(command.data.brightness))}%`;
85
+ node.status({ fill: command.source === 'manual' ? 'yellow' : command.turningOn ? 'green' : 'grey', shape: 'dot',
86
+ text: `${command.source === 'manual' ? 'MANUAL' : 'AUTO'} · ${command.turningOn ? 'ON' : 'OFF'}${level}` });
87
+ return { ...result, outcome: 'succeeded' };
88
+ } catch (error) {
89
+ event(error.eventType || 'error', { message: error.message, light_service_completed: serviceCompleted }, msg, send);
90
+ if (!isClosed()) node.status({ fill: 'red', shape: 'ring', text: error.eventType === 'config_error' ? 'CONFIG ERROR' :
91
+ error.eventType === 'unavailable' ? (error.message.startsWith('HA NOT READY:') || !adapter || !adapter.connected() ? 'HA NOT READY' : 'ENTITY UNAVAILABLE') : error.message });
92
+ return { ...light.normalized(adapter.state(c.entityId), overrideActive(adapter.state(c.entityId)), c.adaptiveEnabled), entity_id: c.entityId, outcome: 'failed', error: { type: error.eventType || 'error', message: error.message }, light_service_completed: serviceCompleted, cause: error };
93
+ }
94
+
95
+ }
96
+ return { observe, execute, info: () => { const state = adapter.state(c.entityId); return light.normalized(state, overrideActive(state), c.adaptiveEnabled); } };
97
+ }
98
+ module.exports = { createController };
package/lib/ha-adapter.js CHANGED
@@ -27,6 +27,13 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
27
27
  let ha, stopped = false, observers, installing = false;
28
28
  const listeners = [], subscriptions = new Set(), rawStates = new Map();
29
29
  let pendingSubscription = Promise.resolve();
30
+ let registryBound = false, connectionBound = false;
31
+ let registryListener, registryCache, registryPending, registryGeneration = 0;
32
+ let entityIds = new Set();
33
+ function invalidateRegistries() {
34
+ registryGeneration++; registryCache = undefined;
35
+ if (!stopped) registryListener?.();
36
+ }
30
37
  const diagnostics = new Map();
31
38
  function report(server, client, phase) {
32
39
  const info = { phase, serverIdPresent: Boolean(serverId), serverResolved: Boolean(server),
@@ -48,11 +55,12 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
48
55
  }
49
56
  async function detach() {
50
57
  for (const [bus, event, fn] of listeners) bus.removeListener(event, fn);
51
- listeners.length = 0;
58
+ listeners.length = 0; registryBound = false; connectionBound = false;
52
59
  await pendingSubscription;
53
60
  await Promise.allSettled([...subscriptions].map(fn => Promise.resolve().then(fn)));
54
61
  subscriptions.clear();
55
62
  rawStates.clear();
63
+ invalidateRegistries();
56
64
  }
57
65
  async function install() {
58
66
  if (stopped || installing || subscriptions.size || !connected() || !observers) return;
@@ -60,10 +68,10 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
60
68
  const client = ha;
61
69
  try {
62
70
  const unsubscribe = await client.websocket.subscribeMessage(event => {
63
- if (stopped || ha !== client || event.data?.entity_id !== observers.entityId) return;
71
+ if (stopped || ha !== client || !entityIds.has(event.data?.entity_id)) return;
64
72
  const state = event.data.new_state;
65
- rawStates.set(observers.entityId, state ?? undefined);
66
- try { observers.onState(state); } catch (error) { observers.onError(error); }
73
+ rawStates.set(event.data.entity_id, state ?? undefined);
74
+ try { observers.onState(state, event.data.entity_id); } catch (error) { observers.onError(error); }
67
75
  }, { type: 'subscribe_events', event_type: 'state_changed' }, { resubscribe: true });
68
76
  if (stopped || ha !== client) await unsubscribe();
69
77
  else subscriptions.add(unsubscribe);
@@ -72,16 +80,24 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
72
80
  }
73
81
  function refresh() {
74
82
  rawStates.clear();
83
+ invalidateRegistries();
75
84
  if (!stopped && observers) {
76
85
  observers.onConnection(connected());
77
86
  if (!installing && !subscriptions.size && connected()) pendingSubscription = install();
78
87
  }
79
88
  }
89
+ function bindRegistries() {
90
+ if (!ha || !registryListener || registryBound || stopped) return;
91
+ registryBound = true;
92
+ for (const event of ['labels_updated', 'entity_registry_updated', 'devices_updated', 'areas_updated']) listen(event, invalidateRegistries);
93
+ }
80
94
  function bind() {
81
- if (!ha || !observers || stopped || listeners.length) return;
95
+ bindRegistries();
96
+ if (!ha || !observers || stopped || connectionBound) return;
97
+ connectionBound = true;
82
98
  for (const event of ['ha_client:ready', 'ha_client:states_loaded', 'ha_client:connected']) listen(event, refresh);
83
99
  for (const event of ['ha_client:close', 'ha_client:connecting', 'ha_client:error']) {
84
- listen(event, () => { rawStates.clear(); if (!stopped) observers.onConnection(false); });
100
+ listen(event, () => { rawStates.clear(); invalidateRegistries(); if (!stopped) observers.onConnection(false); });
85
101
  }
86
102
  refresh();
87
103
  }
@@ -123,8 +139,36 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
123
139
  if (!connected()) throw fault('HA NOT READY: disconnected or loading states', 'unavailable');
124
140
  return ha.websocket.callService(domain, service, data, target);
125
141
  },
142
+ onRegistryChange(callback) { registryListener = callback; bindRegistries(); },
143
+ async labels() {
144
+ if (!connected()) throw fault('HA NOT READY', 'unavailable');
145
+ return ha.websocket.send({ type: 'config/label_registry/list' });
146
+ },
147
+ async registries() {
148
+ if (!connected() || typeof ha.websocket.send !== 'function') throw fault('HA registries unavailable', 'target_resolution_failure');
149
+ if (registryCache && Date.now() - registryCache.time < 30000) return registryCache.data;
150
+ if (registryPending) return registryPending;
151
+ const generation = registryGeneration, client = ha;
152
+ const pending = Promise.all(['label', 'entity', 'device', 'area'].map(name =>
153
+ client.websocket.send({ type: `config/${name}_registry/list` }))).then(([labels, entities, devices, areas]) => {
154
+ if (stopped || ha !== client || generation !== registryGeneration) throw fault('HA registries changed during resolution; retry', 'target_resolution_failure');
155
+ const data = { labels, entities, devices, areas };
156
+ if (!Object.values(data).every(Array.isArray)) throw fault('Invalid HA registry response', 'target_resolution_failure');
157
+ registryCache = { time: Date.now(), data };
158
+ return data;
159
+ });
160
+ registryPending = pending;
161
+ try { return await pending; } finally { if (registryPending === pending) registryPending = undefined; }
162
+ },
163
+ async watch(ids, onState, onConnection, onError) {
164
+ entityIds = new Set(ids);
165
+ for (const id of rawStates.keys()) if (!entityIds.has(id)) rawStates.delete(id);
166
+ observers = { onState, onConnection, onError };
167
+ bind();
168
+ },
126
169
  async subscribe(entityId, onState, onConnection, onError) {
127
- observers = { entityId, onState, onConnection, onError };
170
+ entityIds = new Set(entityId ? [entityId] : []);
171
+ observers = { onState, onConnection, onError };
128
172
  bind();
129
173
  },
130
174
  async close() {
package/lib/light.js CHANGED
@@ -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
  }
@@ -54,7 +56,10 @@ function config(raw) {
54
56
  c.mired = number(c.mired, 'mired', 25, 1000);
55
57
  if (!/^#[0-9a-f]{6}$/i.test(c.fixedColor)) throw fault('Fixed color must be #rrggbb');
56
58
  if (c.adaptiveEntity && !/^switch\.[a-z0-9_]+$/.test(c.adaptiveEntity)) throw fault('Adaptive entity must be a switch entity');
57
- 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;
58
63
  return c;
59
64
  }
60
65
  function pct(brightness) { return brightness * 100 / 255; }
@@ -138,6 +143,7 @@ function resolveAction(c, msg) {
138
143
  function plan(c, msg, state, overrideActive) {
139
144
  const action = resolveAction(c, msg);
140
145
  const source = msg.source ?? 'automatic';
146
+ const adaptiveEnabled = own(msg, 'adaptive_lighting') ? boolean(msg.adaptive_lighting, 'adaptive_lighting') : c.adaptiveEnabled;
141
147
  if (!['automatic', 'manual'].includes(source)) throw fault('msg.source must be automatic or manual');
142
148
  const turningOn = action === 'on' || (action === 'toggle' && state.state === 'off');
143
149
  const cap = capabilities(state), data = {}, unsupported = [];
@@ -180,8 +186,9 @@ function plan(c, msg, state, overrideActive) {
180
186
  for (const key of Object.keys(data)) if (key !== 'transition') delete data[key];
181
187
  if (!own(msg, 'transition') && cap.transition) data.transition = c.transitionOff;
182
188
  }
183
- return { action, source, service: turningOn && !zero ? 'turn_on' : 'turn_off',
184
- turningOn: turningOn && !zero, data, unsupported,
189
+ return { action, source, adaptiveEnabled, service: turningOn && !zero ? 'turn_on' : 'turn_off',
190
+ turningOn: turningOn && !zero, offToOn: turningOn && !zero && state.state === 'off',
191
+ supportsTemperature: cap.modes.includes('color_temp'), data, unsupported,
185
192
  blocked: source === 'automatic' && overrideActive,
186
193
  explicitColor: colors.length > 0,
187
194
  ignoredConflicts: [...bKeys.slice(1), ...colors.slice(1).map(([key]) => key)] };
@@ -192,7 +199,7 @@ function normalized(state, override, adaptive, action) {
192
199
  brightness: a.brightness ?? null, brightness_pct: Number.isFinite(a.brightness) ? Math.round(pct(a.brightness) * 10) / 10 : null,
193
200
  color_mode: a.color_mode ?? null, color_temp_kelvin: a.color_temp_kelvin ?? (a.color_temp ? Math.round(1000000 / a.color_temp) : null),
194
201
  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]]])),
202
+ 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
203
  manual_override: override, adaptive_lighting: adaptive, action,
197
204
  observed_at: state?.last_updated ?? null };
198
205
  }
package/lib/targets.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+ const { fault } = require('./light');
3
+
4
+ function target(raw, override) {
5
+ const value = override === undefined ? { type: raw.targetType ?? 'entity', id: (raw.targetType ?? 'entity') === 'label' ? raw.labelId : raw.entityId } : override;
6
+ if (!value || typeof value !== 'object' || Array.isArray(value) || !['entity', 'label'].includes(value.type) ||
7
+ typeof value.id !== 'string' || !value.id.trim()) throw fault('Target requires type entity/label and a stable id', 'config_error');
8
+ if (value.type === 'entity' && !/^light\.[a-z0-9_]+$/.test(value.id)) throw fault('Select a light entity', 'config_error');
9
+ return { type: value.type, id: value.id };
10
+ }
11
+
12
+ // HA 2026.9 service target semantics. Area relationships are part of Label
13
+ // expansion; Area is deliberately not exposed as a configured target type.
14
+ function labelLights(id, { labels, entities, devices, areas }) {
15
+ if (![labels, entities, devices, areas].every(Array.isArray)) throw fault('HA registries unavailable', 'target_resolution_failure');
16
+ if (!labels.some(l => l.label_id === id)) throw fault(`Label ${id} was deleted or does not exist`, 'target_resolution_failure');
17
+ const marked = item => Array.isArray(item.labels) && item.labels.includes(id);
18
+ const deviceIds = new Set(devices.filter(marked).map(d => d.id));
19
+ const areaIds = new Set(areas.filter(marked).map(a => a.area_id));
20
+ const areaDevices = new Set(devices.filter(d => areaIds.has(d.area_id)).map(d => d.id));
21
+ return [...new Set(entities.filter(e => {
22
+ if (!/^light\.[a-z0-9_]+$/.test(e.entity_id) || e.hidden_by || e.disabled_by) return false;
23
+ if (marked(e)) return true;
24
+ if (e.entity_category) return false;
25
+ return deviceIds.has(e.device_id) || areaIds.has(e.area_id) || (!e.area_id && areaDevices.has(e.device_id));
26
+ }).map(e => e.entity_id))].sort();
27
+ }
28
+ async function resolveTarget(adapter, configured) {
29
+ if (configured.type === 'entity') return [configured.id];
30
+ return labelLights(configured.id, await adapter.registries());
31
+ }
32
+ function createTargetResolver(adapter) {
33
+ let snapshot;
34
+ const membership = new Map();
35
+ return async selected => {
36
+ if (selected.type === 'entity') return [selected.id];
37
+ const next = await adapter.registries();
38
+ if (next !== snapshot) { membership.clear(); snapshot = next; }
39
+ if (!membership.has(selected.id)) membership.set(selected.id, labelLights(selected.id, snapshot));
40
+ return [...membership.get(selected.id)];
41
+ };
42
+ }
43
+ function aggregateStatus(infos) {
44
+ const counts = { on: 0, manual: 0, off: 0, unavailable: 0 };
45
+ for (const info of infos) {
46
+ if (!['on', 'off'].includes(info.state)) counts.unavailable++;
47
+ else if (info.manual_override) counts.manual++;
48
+ else counts[info.state]++;
49
+ }
50
+ return { fill: counts.unavailable ? 'red' : counts.manual ? 'yellow' : counts.on ? 'green' : 'grey',
51
+ shape: counts.unavailable ? 'ring' : 'dot',
52
+ text: infos.length ? Object.entries(counts).filter(([,n]) => n).map(([key,n]) => `${n} ${key}`).join(' · ') : 'LABEL EMPTY' };
53
+ }
54
+ module.exports = { target, labelLights, resolveTarget, createTargetResolver, aggregateStatus };
@@ -13,6 +13,10 @@
13
13
  return true;
14
14
  };
15
15
  }
16
+ function targetKind(node) {
17
+ const field = typeof $ === 'function' ? $('#node-input-targetType') : null;
18
+ return field?.length ? field.val() || node.targetType || 'entity' : node.targetType || 'entity';
19
+ }
16
20
  RED.nodes.registerType('sw-light', {
17
21
  category: 'home assistant', color: '#c7d7e8', icon: 'font-awesome/fa-lightbulb-o',
18
22
  inputs: 1, outputs: 2, outputLabels: ['result / state', 'events / errors'],
@@ -20,64 +24,92 @@
20
24
  defaults: {
21
25
  name: { value: '' }, server: { value: '', type: 'server', required: true },
22
26
  action: { value: 'msg', validate: v => ['on','off','toggle','msg'].includes(v) },
23
- entityId: { value: '', required: true, validate: v => /^light\.[a-z0-9_]+$/.test(v) },
27
+ targetType: { value: 'entity', validate: v => ['entity','label'].includes(v) },
28
+ entityId: { value: '', validate: function (v) { return targetKind(this) === 'label' || /^light\.[a-z0-9_]+$/.test(v); } },
29
+ labelId: { value: '', validate: function (v) { return targetKind(this) !== 'label' || (typeof v === 'string' && v.trim().length > 0); } },
24
30
  brightnessPct: { value: 94, validate: numeric('brightnessPct') },
25
31
  autoMaxPct: { value: 94, validate: numeric('autoMaxPct') },
26
32
  minPct: { value: 1, validate: numeric('minPct') },
27
33
  transitionOn: { value: 1, validate: numeric('transitionOn') },
28
34
  transitionOff: { value: 1, validate: numeric('transitionOff') },
29
- manualOverride: { value: true }, overrideThreshold: { value: 100, validate: numeric('overrideThreshold') },
30
- overrideStrategy: { value: 'threshold' }, colorMode: { value: 'adaptive' },
35
+ manualOverride: { value: true }, overrideThreshold: { value: 98, validate: numeric('overrideThreshold') },
36
+ overrideStrategy: { value: 'threshold' }, colorMode: { value: 'keep' },
37
+ adaptiveEnabled: { value: false },
31
38
  kelvin: { value: 2700, validate: numeric('kelvin') }, mired: { value: 370, validate: numeric('mired') },
32
39
  fixedColor: { value: '#ffffff', validate: v => /^#[0-9a-f]{6}$/i.test(v) },
33
40
  adaptiveEntity: { value: '', validate: v => !v || /^switch\.[a-z0-9_]+$/.test(v) },
34
- adaptiveDiscover: { value: true }, adaptiveApply: { value: false }, adaptiveRespectManual: { value: true }
35
41
  },
36
42
  oneditprepare: function () {
37
- let states = {}, request, generation = 0;
43
+ if (this.colorMode === 'adaptive') {
44
+ $('#node-input-colorMode').val('keep');
45
+ $('#node-input-adaptiveEnabled').prop('checked', this.adaptiveApply === true);
46
+ }
47
+ let states = {}, labels = [], request, labelRequest, generation = 0;
48
+ $('#node-input-targetType').val(this.targetType || 'entity');
49
+ const label = $('#node-input-labelId');
38
50
  const entity = $('#node-input-entityId'), adaptive = $('#node-input-adaptiveEntity');
51
+ function modesForLight() { return states[entity.val()]?.attributes?.supported_color_modes || []; }
39
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.');
40
56
  const mode = $('#node-input-colorMode').val();
41
57
  $('.sw-color-row').hide();
42
58
  $('.sw-color-' + mode).show();
59
+ $('.sw-adaptive-row').toggle($('#node-input-adaptiveEnabled').prop('checked'));
60
+ const profiles = Object.values(states).filter(s => s.entity_id.startsWith('switch.') &&
61
+ Array.isArray(s.attributes?.configuration?.lights) && s.attributes.configuration.lights.includes(entity.val()));
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') ?
63
+ (profiles.length === 1 ? 'Profile found. Keep its main switch off for one-shot use.' :
64
+ 'No unique profile found. Select a profile under Advanced.') : 'This light has no known color-temperature support. Normal ON still works.');
43
65
  $('.sw-override-row').toggle($('#node-input-manualOverride').prop('checked'));
44
66
  $('.sw-threshold-row').toggle($('#node-input-manualOverride').prop('checked') && $('#node-input-overrideStrategy').val() === 'threshold');
45
67
  const s = states[entity.val()], modes = s && s.attributes && s.attributes.supported_color_modes;
46
- $('#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.');
47
69
  // Disabled options reflect known capabilities, without discarding saved settings.
48
- $('#node-input-colorMode option[value="kelvin"], #node-input-colorMode option[value="mired"]').prop('disabled', !!modes && !modes.includes('color_temp'));
49
- $('#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)));
50
72
  }
51
73
  function source(domain) {
52
74
  return function (req, respond) {
53
75
  const term = req.term.toLowerCase();
54
76
  respond(Object.values(states).filter(s => s.entity_id.startsWith(domain + '.') &&
77
+ (domain !== 'switch' || s.attributes?.configuration) &&
55
78
  (s.entity_id.toLowerCase().includes(term) || String(s.attributes.friendly_name || '').toLowerCase().includes(term)))
56
79
  .slice(0,100).map(s => ({ label: (s.attributes.friendly_name || s.entity_id) + ' (' + s.entity_id + ')', value: s.entity_id })));
57
80
  };
58
81
  }
59
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()); });
60
89
  adaptive.autocomplete({ minLength: 0, source: source('switch') });
61
90
  function load() {
62
91
  generation++; const token = generation;
63
- if (request) request.abort();
64
- states = {}; update();
92
+ if (request) request.abort(); if (labelRequest) labelRequest.abort();
93
+ states = {}; labels = []; update();
65
94
  const server = $('#node-input-server').val();
66
95
  if (!server || server === '_ADD_') return;
67
96
  $('#sw-capabilities').text('Loading Home Assistant entities…');
68
- // HA contrib's own editor endpoint. No auth tokens or static entity lists.
97
+ // Label route uses the shared adapter; Entity route belongs to HA contrib.
98
+ labelRequest = $.getJSON('sw-light/labels/' + encodeURIComponent(server))
99
+ .done(function (data) { if (token === generation) { labels = Array.isArray(data) ? data : []; update(); } })
100
+ .fail(function (_xhr, status) { if (status !== 'abort' && token === generation) $('#sw-label-name').text('Server must be deployed and connected. A stable Label ID can also be entered directly.'); });
69
101
  request = $.getJSON('homeassistant/states/' + encodeURIComponent(server))
70
102
  .done(function (data) { if (token === generation) { states = data || {}; update(); } })
71
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.'); });
72
104
  }
73
105
  $('#node-input-server').on('change.swLight', load);
74
- $('#node-input-colorMode, #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);
75
107
  entity.on('focus.swLight', function () { entity.autocomplete('search', entity.val()); });
76
108
  adaptive.on('focus.swLight', function () { adaptive.autocomplete('search', adaptive.val()); });
77
109
  this._swCleanup = function () {
78
- 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');
80
- 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');
81
113
  delete this._swCleanup;
82
114
  };
83
115
  load(); update();
@@ -91,7 +123,9 @@
91
123
  <script type="text/html" data-template-name="sw-light">
92
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>
93
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>
94
- <div class="form-row"><label for="node-input-entityId"><i class="fa fa-lightbulb-o"></i> Light</label><input id="node-input-entityId" type="text" placeholder="light.entity_id"></div>
126
+ <div class="form-row"><label for="node-input-targetType">Target</label><select id="node-input-targetType"><option value="entity">Entity</option><option value="label">Label</option></select></div>
127
+ <div class="form-row sw-label-target"><label for="node-input-labelId">Label</label><input id="node-input-labelId" type="text" placeholder="Stable Label ID"><p id="sw-label-name"></p></div>
128
+ <div class="form-row sw-entity-target"><label for="node-input-entityId"><i class="fa fa-lightbulb-o"></i> Light</label><input id="node-input-entityId" type="text" placeholder="light.entity_id"></div>
95
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>
96
130
  <div class="form-tips" id="sw-capabilities" style="margin-bottom:16px"></div>
97
131
  <fieldset><legend>Automatic lighting</legend>
@@ -108,25 +142,27 @@
108
142
  <p class="sw-override-row">Automatic commands pause during override. Manual commands use <code>msg.source = "manual"</code>.</p>
109
143
  </fieldset>
110
144
  <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>
145
+ <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
146
  <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
147
  <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
148
  <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>
149
+ <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>
150
+ <p class="sw-adaptive-row" id="sw-adaptive-info"></p>
151
+ <details class="sw-adaptive-row"><summary>Advanced: profile selection</summary>
152
+ <div class="form-row"><label for="node-input-adaptiveEntity">Profile</label><input id="node-input-adaptiveEntity" type="text" placeholder="Automatic (unique light membership)"></div>
153
+ <p>Select only when automatic discovery is unavailable or ambiguous. The profile's main switch must be off.</p>
154
+ </details>
120
155
  </fieldset>
121
156
  </script>
122
157
 
123
158
  <script type="text/html" data-help-name="sw-light">
124
- <p>Controls one Home Assistant light directly: Trigger → SW Light → Home Assistant. No downstream HA Action node is required. Select On, Off, Toggle, or From msg. Toggle resolves the current state to turn_on or turn_off.</p>
159
+ <p>Controls a Home Assistant Entity or Label directly: Trigger → SW Light → Home Assistant. No downstream HA Action node is required. Select On, Off, Toggle, or From msg. Toggle resolves the current state to turn_on or turn_off.</p>
160
+ <p>Entity is the default for existing flows. Label stores its stable ID and resolves current light members, including labeled devices and labeled area relationships. Each light retains its own override and capabilities. Label output 1 contains target, resolved_entity_ids, results and counts; status summarizes observed states. A failed member does not stop others. <code>msg.target = {type: "label", id: "label_id"}</code> or <code>{type: "entity", id: "light.example"}</code> overrides the configured target for one input. The latest target is observed until the next input.</p>
125
161
  <h3>Inputs</h3>
126
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>
127
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>
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>
164
+ <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
165
  <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>
166
+ <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
167
  <p>Requires HA contrib 0.80.3. See package README for full contracts, examples and compatibility limitations.</p>
132
168
  </script>