@johanwistbacka/node-red-contrib-sw-light 0.1.4
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 +47 -0
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/examples/basic.json +174 -0
- package/examples/manual-adaptive.json +228 -0
- package/lib/adaptive.js +39 -0
- package/lib/ha-adapter.js +138 -0
- package/lib/light.js +200 -0
- package/nodes/sw-light.html +132 -0
- package/nodes/sw-light.js +139 -0
- package/package.json +46 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "sw-example-manual-adaptive",
|
|
4
|
+
"type": "tab",
|
|
5
|
+
"label": "SW Light \u2014 manual and adaptive",
|
|
6
|
+
"disabled": false,
|
|
7
|
+
"info": "Development example. Select your own Home Assistant server and light before deployment. No HA credentials are included."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "sw-example-manual-adaptive-note",
|
|
11
|
+
"type": "comment",
|
|
12
|
+
"z": "sw-example-manual-adaptive",
|
|
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.",
|
|
15
|
+
"x": 350,
|
|
16
|
+
"y": 60,
|
|
17
|
+
"wires": []
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "sw-example-manual-adaptive-inject-0",
|
|
21
|
+
"type": "inject",
|
|
22
|
+
"z": "sw-example-manual-adaptive",
|
|
23
|
+
"name": "Manual 100%",
|
|
24
|
+
"props": [
|
|
25
|
+
{
|
|
26
|
+
"p": "action",
|
|
27
|
+
"v": "on",
|
|
28
|
+
"vt": "str"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"p": "source",
|
|
32
|
+
"v": "manual",
|
|
33
|
+
"vt": "str"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"p": "brightness_pct",
|
|
37
|
+
"v": "100",
|
|
38
|
+
"vt": "num"
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"repeat": "",
|
|
42
|
+
"crontab": "",
|
|
43
|
+
"once": false,
|
|
44
|
+
"onceDelay": 0.1,
|
|
45
|
+
"x": 180,
|
|
46
|
+
"y": 120,
|
|
47
|
+
"wires": [
|
|
48
|
+
[
|
|
49
|
+
"sw-example-manual-adaptive-light"
|
|
50
|
+
]
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "sw-example-manual-adaptive-inject-1",
|
|
55
|
+
"type": "inject",
|
|
56
|
+
"z": "sw-example-manual-adaptive",
|
|
57
|
+
"name": "Automatic OFF (protected)",
|
|
58
|
+
"props": [
|
|
59
|
+
{
|
|
60
|
+
"p": "action",
|
|
61
|
+
"v": "off",
|
|
62
|
+
"vt": "str"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"repeat": "",
|
|
66
|
+
"crontab": "",
|
|
67
|
+
"once": false,
|
|
68
|
+
"onceDelay": 0.1,
|
|
69
|
+
"x": 180,
|
|
70
|
+
"y": 180,
|
|
71
|
+
"wires": [
|
|
72
|
+
[
|
|
73
|
+
"sw-example-manual-adaptive-light"
|
|
74
|
+
]
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "sw-example-manual-adaptive-inject-2",
|
|
79
|
+
"type": "inject",
|
|
80
|
+
"z": "sw-example-manual-adaptive",
|
|
81
|
+
"name": "Manual 50%",
|
|
82
|
+
"props": [
|
|
83
|
+
{
|
|
84
|
+
"p": "action",
|
|
85
|
+
"v": "on",
|
|
86
|
+
"vt": "str"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"p": "source",
|
|
90
|
+
"v": "manual",
|
|
91
|
+
"vt": "str"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"p": "brightness_pct",
|
|
95
|
+
"v": "50",
|
|
96
|
+
"vt": "num"
|
|
97
|
+
}
|
|
98
|
+
],
|
|
99
|
+
"repeat": "",
|
|
100
|
+
"crontab": "",
|
|
101
|
+
"once": false,
|
|
102
|
+
"onceDelay": 0.1,
|
|
103
|
+
"x": 180,
|
|
104
|
+
"y": 240,
|
|
105
|
+
"wires": [
|
|
106
|
+
[
|
|
107
|
+
"sw-example-manual-adaptive-light"
|
|
108
|
+
]
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"id": "sw-example-manual-adaptive-inject-3",
|
|
113
|
+
"type": "inject",
|
|
114
|
+
"z": "sw-example-manual-adaptive",
|
|
115
|
+
"name": "Explicit latch",
|
|
116
|
+
"props": [
|
|
117
|
+
{
|
|
118
|
+
"p": "action",
|
|
119
|
+
"v": "on",
|
|
120
|
+
"vt": "str"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"p": "source",
|
|
124
|
+
"v": "manual",
|
|
125
|
+
"vt": "str"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"p": "manual_override",
|
|
129
|
+
"v": "true",
|
|
130
|
+
"vt": "bool"
|
|
131
|
+
}
|
|
132
|
+
],
|
|
133
|
+
"repeat": "",
|
|
134
|
+
"crontab": "",
|
|
135
|
+
"once": false,
|
|
136
|
+
"onceDelay": 0.1,
|
|
137
|
+
"x": 180,
|
|
138
|
+
"y": 300,
|
|
139
|
+
"wires": [
|
|
140
|
+
[
|
|
141
|
+
"sw-example-manual-adaptive-light"
|
|
142
|
+
]
|
|
143
|
+
]
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"id": "sw-example-manual-adaptive-inject-4",
|
|
147
|
+
"type": "inject",
|
|
148
|
+
"z": "sw-example-manual-adaptive",
|
|
149
|
+
"name": "Release latch",
|
|
150
|
+
"props": [
|
|
151
|
+
{
|
|
152
|
+
"p": "action",
|
|
153
|
+
"v": "on",
|
|
154
|
+
"vt": "str"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"p": "source",
|
|
158
|
+
"v": "manual",
|
|
159
|
+
"vt": "str"
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"p": "manual_override",
|
|
163
|
+
"v": "false",
|
|
164
|
+
"vt": "bool"
|
|
165
|
+
}
|
|
166
|
+
],
|
|
167
|
+
"repeat": "",
|
|
168
|
+
"crontab": "",
|
|
169
|
+
"once": false,
|
|
170
|
+
"onceDelay": 0.1,
|
|
171
|
+
"x": 180,
|
|
172
|
+
"y": 360,
|
|
173
|
+
"wires": [
|
|
174
|
+
[
|
|
175
|
+
"sw-example-manual-adaptive-light"
|
|
176
|
+
]
|
|
177
|
+
]
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
"id": "sw-example-manual-adaptive-light",
|
|
181
|
+
"type": "sw-light",
|
|
182
|
+
"z": "sw-example-manual-adaptive",
|
|
183
|
+
"name": "SW Light",
|
|
184
|
+
"server": "",
|
|
185
|
+
"entityId": "",
|
|
186
|
+
"x": 460,
|
|
187
|
+
"y": 210,
|
|
188
|
+
"wires": [
|
|
189
|
+
[
|
|
190
|
+
"sw-example-manual-adaptive-result"
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"sw-example-manual-adaptive-events"
|
|
194
|
+
]
|
|
195
|
+
],
|
|
196
|
+
"action": "msg"
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
"id": "sw-example-manual-adaptive-result",
|
|
200
|
+
"type": "debug",
|
|
201
|
+
"z": "sw-example-manual-adaptive",
|
|
202
|
+
"name": "result",
|
|
203
|
+
"active": true,
|
|
204
|
+
"tosidebar": true,
|
|
205
|
+
"console": false,
|
|
206
|
+
"tostatus": false,
|
|
207
|
+
"complete": "true",
|
|
208
|
+
"targetType": "full",
|
|
209
|
+
"x": 710,
|
|
210
|
+
"y": 180,
|
|
211
|
+
"wires": []
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
"id": "sw-example-manual-adaptive-events",
|
|
215
|
+
"type": "debug",
|
|
216
|
+
"z": "sw-example-manual-adaptive",
|
|
217
|
+
"name": "events",
|
|
218
|
+
"active": true,
|
|
219
|
+
"tosidebar": true,
|
|
220
|
+
"console": false,
|
|
221
|
+
"tostatus": false,
|
|
222
|
+
"complete": "true",
|
|
223
|
+
"targetType": "full",
|
|
224
|
+
"x": 710,
|
|
225
|
+
"y": 260,
|
|
226
|
+
"wires": []
|
|
227
|
+
}
|
|
228
|
+
]
|
package/lib/adaptive.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fault } = require('./light');
|
|
4
|
+
|
|
5
|
+
function lights(state) {
|
|
6
|
+
const a = state?.attributes || {};
|
|
7
|
+
const list = a.configuration?.lights ?? a.lights;
|
|
8
|
+
return Array.isArray(list) ? list : [];
|
|
9
|
+
}
|
|
10
|
+
function select(c, states) {
|
|
11
|
+
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;
|
|
17
|
+
}
|
|
18
|
+
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 };
|
|
25
|
+
}
|
|
26
|
+
async function apply(adapter, c, command) {
|
|
27
|
+
if (c.colorMode !== 'adaptive' || !c.adaptiveApply || !command.turningOn || command.explicitColor) return { applied: false };
|
|
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');
|
|
33
|
+
await adapter.call('adaptive_lighting', 'apply', {
|
|
34
|
+
entity_id: info.entity_id, lights: [c.entityId], adapt_brightness: false,
|
|
35
|
+
adapt_color: true, turn_on_lights: false
|
|
36
|
+
});
|
|
37
|
+
return { applied: true };
|
|
38
|
+
}
|
|
39
|
+
module.exports = { select, inspect, apply };
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createRequire } = require('node:module');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { fault } = require('./light');
|
|
6
|
+
const PACKAGE = 'node-red-contrib-home-assistant-websocket';
|
|
7
|
+
|
|
8
|
+
// This is the ONLY module allowed to depend on HA contrib's internal APIs.
|
|
9
|
+
// Inspect the published package before adding another version to this allowlist.
|
|
10
|
+
function loadIntegration(RED, diagnostic = () => {}) {
|
|
11
|
+
// Use one standard Node module resolution anchored at the runtime userDir.
|
|
12
|
+
// No cache scanning, alternate installation search or checkout fallback.
|
|
13
|
+
if (!RED.settings?.userDir) throw fault('Node-RED user directory is not available', 'unsupported');
|
|
14
|
+
let packagePath;
|
|
15
|
+
try { packagePath = createRequire(path.join(RED.settings.userDir, 'package.json')).resolve(`${PACKAGE}/package.json`); }
|
|
16
|
+
catch (_) { throw fault(`Install ${PACKAGE}@0.80.3 in the Node-RED user directory`, 'unsupported'); }
|
|
17
|
+
const req = createRequire(packagePath), metadata = req(packagePath);
|
|
18
|
+
if (metadata.version !== '0.80.3') throw fault(`Unsupported HA contrib ${metadata.version}; adapter verified against 0.80.3`, 'unsupported');
|
|
19
|
+
diagnostic(`providerVersion=${metadata.version} resolution=runtime-userDir`);
|
|
20
|
+
const integration = req('./dist/homeAssistant/index.js');
|
|
21
|
+
if (typeof integration.getHomeAssistant !== 'function') throw fault('HA contrib client API changed', 'unsupported');
|
|
22
|
+
return integration;
|
|
23
|
+
}
|
|
24
|
+
function createAdapter(RED, serverId, integration, diagnostic = () => {}) {
|
|
25
|
+
integration = integration || loadIntegration(RED, diagnostic);
|
|
26
|
+
// Construct a handle, not a ready client. Resolve the provider on each input.
|
|
27
|
+
let ha, stopped = false, observers, installing = false;
|
|
28
|
+
const listeners = [], subscriptions = new Set(), rawStates = new Map();
|
|
29
|
+
let pendingSubscription = Promise.resolve();
|
|
30
|
+
const diagnostics = new Map();
|
|
31
|
+
function report(server, client, phase) {
|
|
32
|
+
const info = { phase, serverIdPresent: Boolean(serverId), serverResolved: Boolean(server),
|
|
33
|
+
serverTypeIsServer: server?.type === 'server', serverConstructor: server?.constructor?.name || 'absent',
|
|
34
|
+
configPresent: Boolean(server?.config), configNodeClientPresent: Boolean(server?.homeAssistant),
|
|
35
|
+
configNodeGetState: typeof server?.getState === 'function', configNodeCallService: typeof server?.callService === 'function',
|
|
36
|
+
providerAccessor: typeof integration.getHomeAssistant === 'function', clientPresent: Boolean(client),
|
|
37
|
+
websocketPresent: Boolean(client?.websocket), getState: typeof client?.websocket?.getState === 'function',
|
|
38
|
+
callService: typeof client?.websocket?.callService === 'function', subscribeMessage: typeof client?.websocket?.subscribeMessage === 'function',
|
|
39
|
+
eventBus: typeof client?.eventBus?.on === 'function', connected: Boolean(client?.isConnected),
|
|
40
|
+
statesLoaded: Boolean(client?.websocket?.isStatesLoaded) };
|
|
41
|
+
const line = JSON.stringify(info);
|
|
42
|
+
if (line !== diagnostics.get(phase)) { diagnostics.set(phase, line); diagnostic(line); }
|
|
43
|
+
}
|
|
44
|
+
function connected() { return Boolean(!stopped && ha?.isConnected && ha.websocket.isStatesLoaded); }
|
|
45
|
+
function listen(event, callback) {
|
|
46
|
+
ha.eventBus.on(event, callback);
|
|
47
|
+
listeners.push([ha.eventBus, event, callback]);
|
|
48
|
+
}
|
|
49
|
+
async function detach() {
|
|
50
|
+
for (const [bus, event, fn] of listeners) bus.removeListener(event, fn);
|
|
51
|
+
listeners.length = 0;
|
|
52
|
+
await pendingSubscription;
|
|
53
|
+
await Promise.allSettled([...subscriptions].map(fn => Promise.resolve().then(fn)));
|
|
54
|
+
subscriptions.clear();
|
|
55
|
+
rawStates.clear();
|
|
56
|
+
}
|
|
57
|
+
async function install() {
|
|
58
|
+
if (stopped || installing || subscriptions.size || !connected() || !observers) return;
|
|
59
|
+
installing = true;
|
|
60
|
+
const client = ha;
|
|
61
|
+
try {
|
|
62
|
+
const unsubscribe = await client.websocket.subscribeMessage(event => {
|
|
63
|
+
if (stopped || ha !== client || event.data?.entity_id !== observers.entityId) return;
|
|
64
|
+
const state = event.data.new_state;
|
|
65
|
+
rawStates.set(observers.entityId, state ?? undefined);
|
|
66
|
+
try { observers.onState(state); } catch (error) { observers.onError(error); }
|
|
67
|
+
}, { type: 'subscribe_events', event_type: 'state_changed' }, { resubscribe: true });
|
|
68
|
+
if (stopped || ha !== client) await unsubscribe();
|
|
69
|
+
else subscriptions.add(unsubscribe);
|
|
70
|
+
} catch (error) { if (!stopped) observers.onError(error); }
|
|
71
|
+
finally { installing = false; }
|
|
72
|
+
}
|
|
73
|
+
function refresh() {
|
|
74
|
+
rawStates.clear();
|
|
75
|
+
if (!stopped && observers) {
|
|
76
|
+
observers.onConnection(connected());
|
|
77
|
+
if (!installing && !subscriptions.size && connected()) pendingSubscription = install();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function bind() {
|
|
81
|
+
if (!ha || !observers || stopped || listeners.length) return;
|
|
82
|
+
for (const event of ['ha_client:ready', 'ha_client:states_loaded', 'ha_client:connected']) listen(event, refresh);
|
|
83
|
+
for (const event of ['ha_client:close', 'ha_client:connecting', 'ha_client:error']) {
|
|
84
|
+
listen(event, () => { rawStates.clear(); if (!stopped) observers.onConnection(false); });
|
|
85
|
+
}
|
|
86
|
+
refresh();
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
connected,
|
|
90
|
+
async resolve() {
|
|
91
|
+
if (stopped) throw fault('HA NOT READY: node closed', 'unavailable');
|
|
92
|
+
const server = RED.nodes.getNode(serverId);
|
|
93
|
+
report(server, undefined, 'server-resolution');
|
|
94
|
+
if (!server || server.type !== 'server') throw fault('CONFIG ERROR: selected HA server is missing or invalid', 'config_error');
|
|
95
|
+
if (!server.config) throw fault('HA NOT READY: server config is still initializing', 'unavailable');
|
|
96
|
+
if (server.config.addon === false && typeof integration.hasCredentials === 'function' &&
|
|
97
|
+
!integration.hasCredentials(server.credentials || {})) {
|
|
98
|
+
throw fault('CONFIG ERROR: HA server credentials are not configured', 'config_error');
|
|
99
|
+
}
|
|
100
|
+
// Same accessor as provider Action/Current State. The provider owns
|
|
101
|
+
// initialization/reuse and authentication; no independent client.
|
|
102
|
+
let next;
|
|
103
|
+
try { next = integration.getHomeAssistant(server); }
|
|
104
|
+
catch (_) { report(server, undefined, 'provider-accessor-threw'); throw fault('HA NOT READY: provider could not supply its client', 'unavailable'); }
|
|
105
|
+
report(server, next, 'provider-client');
|
|
106
|
+
if (!next || typeof next.websocket?.getState !== 'function' ||
|
|
107
|
+
typeof next.websocket?.subscribeMessage !== 'function' || !next.eventBus) {
|
|
108
|
+
throw fault('HA NOT READY: Home Assistant server client is not initialized', 'unavailable');
|
|
109
|
+
}
|
|
110
|
+
if (next !== ha) {
|
|
111
|
+
await detach();
|
|
112
|
+
if (stopped) throw fault('HA NOT READY: node closed', 'unavailable');
|
|
113
|
+
ha = next;
|
|
114
|
+
bind();
|
|
115
|
+
} else if (observers && !installing && !subscriptions.size && connected()) {
|
|
116
|
+
pendingSubscription = install();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
state: id => rawStates.has(id) ? structuredClone(rawStates.get(id)) : ha?.websocket.getState(id),
|
|
120
|
+
states: () => ha?.websocket.getStates() || {},
|
|
121
|
+
services: () => ha?.websocket.getServices() || {},
|
|
122
|
+
async call(domain, service, data, target) {
|
|
123
|
+
if (!connected()) throw fault('HA NOT READY: disconnected or loading states', 'unavailable');
|
|
124
|
+
return ha.websocket.callService(domain, service, data, target);
|
|
125
|
+
},
|
|
126
|
+
async subscribe(entityId, onState, onConnection, onError) {
|
|
127
|
+
observers = { entityId, onState, onConnection, onError };
|
|
128
|
+
bind();
|
|
129
|
+
},
|
|
130
|
+
async close() {
|
|
131
|
+
stopped = true;
|
|
132
|
+
await detach();
|
|
133
|
+
ha = undefined;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { loadIntegration, createAdapter };
|
package/lib/light.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULTS = Object.freeze({
|
|
4
|
+
action: 'msg',
|
|
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
|
|
10
|
+
});
|
|
11
|
+
const COLOR_KEYS = ['color_temp_kelvin', 'color_temp', 'rgbww_color', 'rgbw_color',
|
|
12
|
+
'rgb_color', 'hs_color', 'xy_color', 'color_name'];
|
|
13
|
+
const BRIGHTNESS_KEYS = ['brightness_pct', 'brightness', 'brightness_step_pct', 'brightness_step'];
|
|
14
|
+
const own = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
|
|
15
|
+
|
|
16
|
+
function fault(message, type = 'error') {
|
|
17
|
+
const error = new Error(message);
|
|
18
|
+
error.eventType = type;
|
|
19
|
+
return error;
|
|
20
|
+
}
|
|
21
|
+
function number(value, name, min, max, integer = false) {
|
|
22
|
+
if ((typeof value !== 'number' && typeof value !== 'string') ||
|
|
23
|
+
(typeof value === 'string' && !value.trim())) throw fault(`${name} must be numeric`);
|
|
24
|
+
const n = Number(value);
|
|
25
|
+
if (!Number.isFinite(n) || n < min || n > max || (integer && !Number.isInteger(n))) {
|
|
26
|
+
throw fault(`${name} must be ${integer ? 'an integer ' : ''}between ${min} and ${max}`);
|
|
27
|
+
}
|
|
28
|
+
return n;
|
|
29
|
+
}
|
|
30
|
+
function boolean(value, name) {
|
|
31
|
+
if (value === true || value === 'true') return true;
|
|
32
|
+
if (value === false || value === 'false') return false;
|
|
33
|
+
throw fault(`${name} must be boolean`);
|
|
34
|
+
}
|
|
35
|
+
function config(raw) {
|
|
36
|
+
const c = { ...DEFAULTS, ...raw };
|
|
37
|
+
if (!['on', 'off', 'toggle', 'msg'].includes(c.action)) throw fault('Invalid configured action');
|
|
38
|
+
for (const key of ['brightnessPct', 'autoMaxPct', 'minPct', 'overrideThreshold']) {
|
|
39
|
+
c[key] = number(c[key], key, key === 'minPct' || key === 'overrideThreshold' ? 1 : 0, 100);
|
|
40
|
+
}
|
|
41
|
+
if (c.minPct > c.autoMaxPct || c.brightnessPct < c.minPct || c.brightnessPct > c.autoMaxPct) {
|
|
42
|
+
throw fault('Require minPct <= brightnessPct <= autoMaxPct');
|
|
43
|
+
}
|
|
44
|
+
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
|
+
}
|
|
48
|
+
if (c.manualOverride && c.overrideStrategy === 'threshold' && c.autoMaxPct >= c.overrideThreshold) {
|
|
49
|
+
throw fault('Automatic maximum must be below the manual override threshold');
|
|
50
|
+
}
|
|
51
|
+
if (!['threshold', 'explicit'].includes(c.overrideStrategy)) throw fault('Invalid override strategy');
|
|
52
|
+
if (!['adaptive', 'keep', 'kelvin', 'mired', 'default', 'color'].includes(c.colorMode)) throw fault('Invalid color mode');
|
|
53
|
+
c.kelvin = number(c.kelvin, 'kelvin', 1000, 40000, true);
|
|
54
|
+
c.mired = number(c.mired, 'mired', 25, 1000);
|
|
55
|
+
if (!/^#[0-9a-f]{6}$/i.test(c.fixedColor)) throw fault('Fixed color must be #rrggbb');
|
|
56
|
+
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');
|
|
58
|
+
return c;
|
|
59
|
+
}
|
|
60
|
+
function pct(brightness) { return brightness * 100 / 255; }
|
|
61
|
+
function byte(percent) { return Math.round(percent * 255 / 100); }
|
|
62
|
+
function capabilities(state) {
|
|
63
|
+
const a = state?.attributes || {};
|
|
64
|
+
const modes = Array.isArray(a.supported_color_modes) ? a.supported_color_modes : [];
|
|
65
|
+
return { modes, brightness: modes.some(m => ['brightness','color_temp','hs','xy','rgb','rgbw','rgbww','white'].includes(m)),
|
|
66
|
+
transition: Boolean((Number(a.supported_features) || 0) & 32) };
|
|
67
|
+
}
|
|
68
|
+
function vector(value, key, limits) {
|
|
69
|
+
if (!Array.isArray(value) || value.length !== limits.length) throw fault(`${key} requires ${limits.length} values`);
|
|
70
|
+
return value.map((n, i) => number(n, `${key}[${i}]`, 0, limits[i], limits[i] === 255));
|
|
71
|
+
}
|
|
72
|
+
function rgbToHs(rgb) {
|
|
73
|
+
const [r, g, b] = rgb.map(v => v / 255), max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
|
|
74
|
+
let h = 0;
|
|
75
|
+
if (d) h = max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
76
|
+
return [h * 60, max ? d / max * 100 : 0];
|
|
77
|
+
}
|
|
78
|
+
function hsToRgb([h, s]) {
|
|
79
|
+
s /= 100;
|
|
80
|
+
const f = n => { const k = (n + h / 60) % 6; return Math.round(255 * (1 - s * Math.max(0, Math.min(k, 4 - k, 1)))); };
|
|
81
|
+
return [f(5), f(3), f(1)];
|
|
82
|
+
}
|
|
83
|
+
function rgbToXy(rgb) {
|
|
84
|
+
const [r, g, b] = rgb.map(v => { v /= 255; return v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92; });
|
|
85
|
+
const x = r * 0.664511 + g * 0.154324 + b * 0.162028;
|
|
86
|
+
const y = r * 0.283881 + g * 0.668433 + b * 0.047685;
|
|
87
|
+
const z = r * 0.000088 + g * 0.072310 + b * 0.986039;
|
|
88
|
+
return x + y + z ? [x / (x + y + z), y / (x + y + z)] : [0, 0];
|
|
89
|
+
}
|
|
90
|
+
function normalizeColor(key, value) {
|
|
91
|
+
if (key === 'color_temp_kelvin') return { kind: 'kelvin', value: number(value, key, 1000, 40000, true) };
|
|
92
|
+
if (key === 'color_temp') return { kind: 'kelvin', value: Math.round(1000000 / number(value, key, 25, 1000)) };
|
|
93
|
+
const lengths = { rgb_color: [255,255,255], rgbw_color: [255,255,255,255], rgbww_color: [255,255,255,255,255], hs_color: [360,100], xy_color: [1,1] };
|
|
94
|
+
if (lengths[key]) return { kind: key, value: vector(value, key, lengths[key]) };
|
|
95
|
+
if (typeof value !== 'string' || !/^[a-zA-Z][a-zA-Z ]{0,39}$/.test(value)) throw fault('Invalid color_name');
|
|
96
|
+
return { kind: key, value };
|
|
97
|
+
}
|
|
98
|
+
function colorPayload(color, state) {
|
|
99
|
+
const modes = capabilities(state).modes, a = state.attributes || {};
|
|
100
|
+
const { kind, value } = color;
|
|
101
|
+
if (kind === 'kelvin') {
|
|
102
|
+
if (!modes.includes('color_temp')) return null;
|
|
103
|
+
const min = a.min_color_temp_kelvin ?? (a.max_mireds ? Math.ceil(1000000 / a.max_mireds) : 1000);
|
|
104
|
+
const max = a.max_color_temp_kelvin ?? (a.min_mireds ? Math.floor(1000000 / a.min_mireds) : 40000);
|
|
105
|
+
if (value < min || value > max) throw fault(`Color temperature outside lamp range ${min}–${max} K`);
|
|
106
|
+
return { color_temp_kelvin: value };
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'color_name') return modes.some(m => ['hs','xy','rgb','rgbw','rgbww'].includes(m)) ? { color_name: value } : null;
|
|
109
|
+
if (modes.includes(kind.replace('_color', ''))) return { [kind]: value };
|
|
110
|
+
// Preserve white channels: never silently discard RGBW/RGBWW channels.
|
|
111
|
+
if (!['rgb_color', 'hs_color'].includes(kind)) return null;
|
|
112
|
+
const rgb = kind === 'rgb_color' ? value : hsToRgb(value);
|
|
113
|
+
if (modes.includes('rgb')) return { rgb_color: rgb };
|
|
114
|
+
if (modes.includes('hs')) return { hs_color: rgbToHs(rgb) };
|
|
115
|
+
if (modes.includes('xy')) return { xy_color: rgbToXy(rgb) };
|
|
116
|
+
if (modes.includes('rgbw')) return { rgbw_color: [...rgb, 0] };
|
|
117
|
+
if (modes.includes('rgbww')) return { rgbww_color: [...rgb, 0, 0] };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
class Override {
|
|
121
|
+
constructor(c) { this.c = c; this.threshold = false; this.explicit = false; }
|
|
122
|
+
get active() { return this.c.manualOverride && (this.threshold || this.explicit); }
|
|
123
|
+
observe(state) {
|
|
124
|
+
if (!this.c.manualOverride) return;
|
|
125
|
+
if (state?.state === 'off') { this.threshold = false; this.explicit = false; }
|
|
126
|
+
else if (state?.state === 'on' && this.c.overrideStrategy === 'threshold' && Number.isFinite(state.attributes?.brightness)) {
|
|
127
|
+
// Compare bytes to avoid treating 254/255 as 100%.
|
|
128
|
+
this.threshold = state.attributes.brightness >= byte(this.c.overrideThreshold);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
set(value) { this.explicit = boolean(value, 'manual_override'); if (!this.explicit) this.threshold = false; }
|
|
132
|
+
}
|
|
133
|
+
function resolveAction(c, msg) {
|
|
134
|
+
const action = c.action === 'msg' ? (own(msg, 'action') ? msg.action : msg.auto_light?.action) : c.action;
|
|
135
|
+
if (!['on', 'off', 'toggle'].includes(action)) throw fault('Action must be on, off or toggle; From msg requires msg.action (or msg.auto_light.action)');
|
|
136
|
+
return action;
|
|
137
|
+
}
|
|
138
|
+
function plan(c, msg, state, overrideActive) {
|
|
139
|
+
const action = resolveAction(c, msg);
|
|
140
|
+
const source = msg.source ?? 'automatic';
|
|
141
|
+
if (!['automatic', 'manual'].includes(source)) throw fault('msg.source must be automatic or manual');
|
|
142
|
+
const turningOn = action === 'on' || (action === 'toggle' && state.state === 'off');
|
|
143
|
+
const cap = capabilities(state), data = {}, unsupported = [];
|
|
144
|
+
const bKeys = BRIGHTNESS_KEYS.filter(k => own(msg, k));
|
|
145
|
+
// Validate even lower-precedence properties, so bad input never disappears silently.
|
|
146
|
+
const validated = {};
|
|
147
|
+
for (const key of bKeys) validated[key] = number(msg[key], key, key.includes('step') ? (key.endsWith('pct') ? -100 : -255) : 0, key.endsWith('pct') ? 100 : 255, !key.endsWith('pct'));
|
|
148
|
+
const colors = COLOR_KEYS.filter(k => own(msg, k)).map(k => [k, normalizeColor(k, msg[k])]);
|
|
149
|
+
const transition = own(msg, 'transition') ? number(msg.transition, 'transition', 0, 3600) : turningOn ? c.transitionOn : c.transitionOff;
|
|
150
|
+
if (cap.transition) data.transition = transition;
|
|
151
|
+
else if (own(msg, 'transition')) unsupported.push('transition');
|
|
152
|
+
let zero = false;
|
|
153
|
+
if (turningOn) {
|
|
154
|
+
let level = c.brightnessPct;
|
|
155
|
+
if (bKeys.length) {
|
|
156
|
+
const key = bKeys[0], value = validated[key];
|
|
157
|
+
if (key.includes('step')) {
|
|
158
|
+
const current = state.state === 'off' ? 0 : state.attributes?.brightness;
|
|
159
|
+
if (!Number.isFinite(current)) throw fault('Brightness steps require a known brightness');
|
|
160
|
+
level = pct(current) + (key.endsWith('pct') ? value : pct(value));
|
|
161
|
+
level = Math.max(0, Math.min(100, level));
|
|
162
|
+
} else level = key.endsWith('pct') ? value : pct(value);
|
|
163
|
+
}
|
|
164
|
+
zero = level === 0 && cap.brightness;
|
|
165
|
+
if (cap.brightness && !zero) {
|
|
166
|
+
level = Math.max(c.minPct, Math.min(source === 'automatic' ? c.autoMaxPct : 100, level));
|
|
167
|
+
data.brightness = Math.min(byte(level), source === 'automatic' ? Math.floor(c.autoMaxPct * 255 / 100) : 255);
|
|
168
|
+
} else if (bKeys.length && !cap.brightness) unsupported.push(...bKeys);
|
|
169
|
+
let color = colors[0]?.[1];
|
|
170
|
+
if (!color && c.colorMode === 'kelvin') color = normalizeColor('color_temp_kelvin', c.kelvin);
|
|
171
|
+
if (!color && c.colorMode === 'mired') color = normalizeColor('color_temp', c.mired);
|
|
172
|
+
if (!color && c.colorMode === 'color') color = normalizeColor('rgb_color', c.fixedColor.match(/[0-9a-f]{2}/gi).map(v => parseInt(v, 16)));
|
|
173
|
+
if (color && !zero) {
|
|
174
|
+
const payload = colorPayload(color, state);
|
|
175
|
+
if (payload) Object.assign(data, payload);
|
|
176
|
+
else unsupported.push(colors[0]?.[0] || c.colorMode);
|
|
177
|
+
}
|
|
178
|
+
} else unsupported.push(...bKeys, ...colors.map(([key]) => key));
|
|
179
|
+
if (zero) {
|
|
180
|
+
for (const key of Object.keys(data)) if (key !== 'transition') delete data[key];
|
|
181
|
+
if (!own(msg, 'transition') && cap.transition) data.transition = c.transitionOff;
|
|
182
|
+
}
|
|
183
|
+
return { action, source, service: turningOn && !zero ? 'turn_on' : 'turn_off',
|
|
184
|
+
turningOn: turningOn && !zero, data, unsupported,
|
|
185
|
+
blocked: source === 'automatic' && overrideActive,
|
|
186
|
+
explicitColor: colors.length > 0,
|
|
187
|
+
ignoredConflicts: [...bKeys.slice(1), ...colors.slice(1).map(([key]) => key)] };
|
|
188
|
+
}
|
|
189
|
+
function normalized(state, override, adaptive, action) {
|
|
190
|
+
const a = state?.attributes || {};
|
|
191
|
+
return { entity_id: state?.entity_id, state: state?.state ?? 'missing',
|
|
192
|
+
brightness: a.brightness ?? null, brightness_pct: Number.isFinite(a.brightness) ? Math.round(pct(a.brightness) * 10) / 10 : null,
|
|
193
|
+
color_mode: a.color_mode ?? null, color_temp_kelvin: a.color_temp_kelvin ?? (a.color_temp ? Math.round(1000000 / a.color_temp) : null),
|
|
194
|
+
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]]])),
|
|
196
|
+
manual_override: override, adaptive_lighting: adaptive, action,
|
|
197
|
+
observed_at: state?.last_updated ?? null };
|
|
198
|
+
}
|
|
199
|
+
module.exports = { DEFAULTS, COLOR_KEYS, BRIGHTNESS_KEYS, config, number, fault, pct, byte, capabilities, resolveAction,
|
|
200
|
+
normalizeColor, colorPayload, Override, plan, normalized };
|