@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/CHANGELOG.md +42 -0
- package/README.md +157 -60
- package/docs/labels.md +198 -0
- package/examples/basic.json +2 -1
- package/examples/manual-adaptive.json +3 -2
- package/lib/adaptive.js +33 -23
- package/lib/controller.js +98 -0
- package/lib/ha-adapter.js +51 -7
- package/lib/light.js +18 -11
- package/lib/targets.js +54 -0
- package/nodes/sw-light.html +61 -25
- package/nodes/sw-light.js +121 -89
- package/package.json +5 -2
package/lib/adaptive.js
CHANGED
|
@@ -2,38 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
const { fault } = require('./light');
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
return
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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 (
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (!
|
|
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:
|
|
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
|
|
71
|
+
if (stopped || ha !== client || !entityIds.has(event.data?.entity_id)) return;
|
|
64
72
|
const state = event.data.new_state;
|
|
65
|
-
rawStates.set(
|
|
66
|
-
try { observers.onState(state); } catch (error) { observers.onError(error); }
|
|
73
|
+
rawStates.set(event.data.entity_id, state ?? undefined);
|
|
74
|
+
try { observers.onState(state, event.data.entity_id); } catch (error) { observers.onError(error); }
|
|
67
75
|
}, { type: 'subscribe_events', event_type: 'state_changed' }, { resubscribe: true });
|
|
68
76
|
if (stopped || ha !== client) await unsubscribe();
|
|
69
77
|
else subscriptions.add(unsubscribe);
|
|
@@ -72,16 +80,24 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
|
|
|
72
80
|
}
|
|
73
81
|
function refresh() {
|
|
74
82
|
rawStates.clear();
|
|
83
|
+
invalidateRegistries();
|
|
75
84
|
if (!stopped && observers) {
|
|
76
85
|
observers.onConnection(connected());
|
|
77
86
|
if (!installing && !subscriptions.size && connected()) pendingSubscription = install();
|
|
78
87
|
}
|
|
79
88
|
}
|
|
89
|
+
function bindRegistries() {
|
|
90
|
+
if (!ha || !registryListener || registryBound || stopped) return;
|
|
91
|
+
registryBound = true;
|
|
92
|
+
for (const event of ['labels_updated', 'entity_registry_updated', 'devices_updated', 'areas_updated']) listen(event, invalidateRegistries);
|
|
93
|
+
}
|
|
80
94
|
function bind() {
|
|
81
|
-
|
|
95
|
+
bindRegistries();
|
|
96
|
+
if (!ha || !observers || stopped || connectionBound) return;
|
|
97
|
+
connectionBound = true;
|
|
82
98
|
for (const event of ['ha_client:ready', 'ha_client:states_loaded', 'ha_client:connected']) listen(event, refresh);
|
|
83
99
|
for (const event of ['ha_client:close', 'ha_client:connecting', 'ha_client:error']) {
|
|
84
|
-
listen(event, () => { rawStates.clear(); if (!stopped) observers.onConnection(false); });
|
|
100
|
+
listen(event, () => { rawStates.clear(); invalidateRegistries(); if (!stopped) observers.onConnection(false); });
|
|
85
101
|
}
|
|
86
102
|
refresh();
|
|
87
103
|
}
|
|
@@ -123,8 +139,36 @@ function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
|
|
|
123
139
|
if (!connected()) throw fault('HA NOT READY: disconnected or loading states', 'unavailable');
|
|
124
140
|
return ha.websocket.callService(domain, service, data, target);
|
|
125
141
|
},
|
|
142
|
+
onRegistryChange(callback) { registryListener = callback; bindRegistries(); },
|
|
143
|
+
async labels() {
|
|
144
|
+
if (!connected()) throw fault('HA NOT READY', 'unavailable');
|
|
145
|
+
return ha.websocket.send({ type: 'config/label_registry/list' });
|
|
146
|
+
},
|
|
147
|
+
async registries() {
|
|
148
|
+
if (!connected() || typeof ha.websocket.send !== 'function') throw fault('HA registries unavailable', 'target_resolution_failure');
|
|
149
|
+
if (registryCache && Date.now() - registryCache.time < 30000) return registryCache.data;
|
|
150
|
+
if (registryPending) return registryPending;
|
|
151
|
+
const generation = registryGeneration, client = ha;
|
|
152
|
+
const pending = Promise.all(['label', 'entity', 'device', 'area'].map(name =>
|
|
153
|
+
client.websocket.send({ type: `config/${name}_registry/list` }))).then(([labels, entities, devices, areas]) => {
|
|
154
|
+
if (stopped || ha !== client || generation !== registryGeneration) throw fault('HA registries changed during resolution; retry', 'target_resolution_failure');
|
|
155
|
+
const data = { labels, entities, devices, areas };
|
|
156
|
+
if (!Object.values(data).every(Array.isArray)) throw fault('Invalid HA registry response', 'target_resolution_failure');
|
|
157
|
+
registryCache = { time: Date.now(), data };
|
|
158
|
+
return data;
|
|
159
|
+
});
|
|
160
|
+
registryPending = pending;
|
|
161
|
+
try { return await pending; } finally { if (registryPending === pending) registryPending = undefined; }
|
|
162
|
+
},
|
|
163
|
+
async watch(ids, onState, onConnection, onError) {
|
|
164
|
+
entityIds = new Set(ids);
|
|
165
|
+
for (const id of rawStates.keys()) if (!entityIds.has(id)) rawStates.delete(id);
|
|
166
|
+
observers = { onState, onConnection, onError };
|
|
167
|
+
bind();
|
|
168
|
+
},
|
|
126
169
|
async subscribe(entityId, onState, onConnection, onError) {
|
|
127
|
-
|
|
170
|
+
entityIds = new Set(entityId ? [entityId] : []);
|
|
171
|
+
observers = { onState, onConnection, onError };
|
|
128
172
|
bind();
|
|
129
173
|
},
|
|
130
174
|
async close() {
|
package/lib/light.js
CHANGED
|
@@ -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:
|
|
7
|
-
overrideStrategy: 'threshold', colorMode: '
|
|
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
|
-
|
|
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,
|
|
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]
|
|
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 };
|
package/nodes/sw-light.html
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
return true;
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
+
function targetKind(node) {
|
|
17
|
+
const field = typeof $ === 'function' ? $('#node-input-targetType') : null;
|
|
18
|
+
return field?.length ? field.val() || node.targetType || 'entity' : node.targetType || 'entity';
|
|
19
|
+
}
|
|
16
20
|
RED.nodes.registerType('sw-light', {
|
|
17
21
|
category: 'home assistant', color: '#c7d7e8', icon: 'font-awesome/fa-lightbulb-o',
|
|
18
22
|
inputs: 1, outputs: 2, outputLabels: ['result / state', 'events / errors'],
|
|
@@ -20,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
|
-
|
|
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:
|
|
30
|
-
overrideStrategy: { value: 'threshold' }, colorMode: { value: '
|
|
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
|
-
|
|
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
|
-
//
|
|
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-
|
|
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="
|
|
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
|
|
116
|
-
<
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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.
|
|
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
|
|
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>
|