@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/nodes/sw-light.js
CHANGED
|
@@ -1,138 +1,170 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
|
|
3
2
|
const light = require('../lib/light');
|
|
4
|
-
const
|
|
3
|
+
const { createController } = require('../lib/controller');
|
|
4
|
+
const { target, createTargetResolver, aggregateStatus } = require('../lib/targets');
|
|
5
5
|
const { createAdapter } = require('../lib/ha-adapter');
|
|
6
6
|
|
|
7
|
-
// Optional factory is for isolated transport/lifecycle tests, never flow config.
|
|
8
7
|
module.exports = function register(RED, adapterFactory = createAdapter) {
|
|
8
|
+
// HA contrib 0.80.3's Label selector is bundled privately and its HTTP API
|
|
9
|
+
// has no labels endpoint. Reuse its authenticated shared transport here.
|
|
10
|
+
if (RED.httpAdmin && RED.auth) RED.httpAdmin.get('/sw-light/labels/:serverId', RED.auth.needsPermission('server.read'), async (req, res) => {
|
|
11
|
+
let handle;
|
|
12
|
+
try {
|
|
13
|
+
handle = adapterFactory(RED, req.params.serverId);
|
|
14
|
+
await handle.resolve();
|
|
15
|
+
res.json(await handle.labels());
|
|
16
|
+
} catch (error) { res.status(503).json({ error: error.message }); }
|
|
17
|
+
finally { await handle?.close(); }
|
|
18
|
+
});
|
|
9
19
|
function SWLight(raw) {
|
|
10
20
|
RED.nodes.createNode(this, raw);
|
|
11
21
|
const node = this;
|
|
12
22
|
node.log?.(`[SW Light debug] version=${require('../package.json').version} constructor=created serverIdPresent=${Boolean(raw.server)}`);
|
|
13
|
-
let c,
|
|
14
|
-
let queue = Promise.resolve(), startup = Promise.resolve();
|
|
15
|
-
const
|
|
16
|
-
const overrideActive = state => detector.active || protectedAtFullBrightness(state);
|
|
23
|
+
let c, configured, adapter, resolver, closed = false, ready = false, observerTimer, refreshTimer, registryTimer;
|
|
24
|
+
let queue = Promise.resolve(), startup = Promise.resolve(), activeIds = [];
|
|
25
|
+
const controllers = new Map();
|
|
17
26
|
function event(type, detail = {}, msg = {}, send = node.send.bind(node)) {
|
|
18
|
-
if (closed)
|
|
19
|
-
|
|
20
|
-
timestamp: new Date().toISOString(), ...detail } }]);
|
|
27
|
+
if (!closed) send([null, { ...msg, sw_light_event: { version: 1, type,
|
|
28
|
+
entity_id: configured?.type === 'entity' ? configured.id : undefined,
|
|
29
|
+
target: configured, timestamp: new Date().toISOString(), ...detail } }]);
|
|
30
|
+
}
|
|
31
|
+
function status() {
|
|
32
|
+
if (!closed && ready && configured?.type === 'label') node.status(aggregateStatus(activeIds.map(id => controllers.get(id).info())));
|
|
21
33
|
}
|
|
22
|
-
function
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
function controller(id) {
|
|
35
|
+
if (!controllers.has(id)) controllers.set(id, createController({ ...c, entityId: id }, adapter, {
|
|
36
|
+
send: node.send.bind(node), status: value => { if (!closed && configured.type === 'entity') node.status(value); }
|
|
37
|
+
}, () => closed));
|
|
38
|
+
return controllers.get(id);
|
|
27
39
|
}
|
|
28
|
-
function
|
|
40
|
+
async function reconcile(selected = configured) {
|
|
41
|
+
if (closed) return [];
|
|
42
|
+
const ids = await resolver(selected);
|
|
43
|
+
if (closed) return [];
|
|
44
|
+
// Runtime overrides become the observed target until the next input.
|
|
45
|
+
configured = selected;
|
|
46
|
+
activeIds = ids;
|
|
47
|
+
for (const id of ids) controller(id);
|
|
48
|
+
for (const id of controllers.keys()) if (!ids.includes(id)) controllers.delete(id);
|
|
49
|
+
const observe = (state, id) => { if (!closed) { controllers.get(id || selected.id)?.observe(state); status(); } };
|
|
50
|
+
if (adapter.watch) await adapter.watch(ids, observe, connection, error => event('error', { message: error.message }));
|
|
51
|
+
else if (ids.length === 1) await adapter.subscribe(ids[0], state => observe(state, ids[0]), connection, error => event('error', { message: error.message }));
|
|
52
|
+
for (const id of ids) controller(id).observe(adapter.state(id));
|
|
53
|
+
status();
|
|
54
|
+
return ids;
|
|
55
|
+
}
|
|
56
|
+
function scheduleRefresh() {
|
|
57
|
+
if (closed || configured?.type !== 'label') return;
|
|
58
|
+
clearTimeout(refreshTimer);
|
|
59
|
+
refreshTimer = setTimeout(() => {
|
|
60
|
+
queue = queue.then(async () => {
|
|
61
|
+
if (closed || !adapter.connected()) return;
|
|
62
|
+
try { await reconcile(); }
|
|
63
|
+
catch (error) { resolutionError(error); }
|
|
64
|
+
}).catch(error => resolutionError(error));
|
|
65
|
+
}, 100);
|
|
66
|
+
refreshTimer.unref?.();
|
|
67
|
+
}
|
|
68
|
+
function resolutionError(error, msg, send, selected = configured) {
|
|
29
69
|
if (closed) return;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (lastAvailability !== availability) event('unavailable', { reason: availability });
|
|
35
|
-
} else {
|
|
36
|
-
const active = overrideActive(state);
|
|
37
|
-
const info = light.normalized(state, active, c.colorMode === 'adaptive', 'state_changed');
|
|
38
|
-
let text = active ? 'MANUAL' : `AUTO · ${info.state.toUpperCase()}`;
|
|
39
|
-
if (info.state === 'on' && info.brightness_pct !== null) text += ` · ${info.brightness_pct}%`;
|
|
40
|
-
if (info.state === 'on' && info.color_temp_kelvin) text += ` · ${info.color_temp_kelvin}K`;
|
|
41
|
-
if (info.state === 'on' && c.colorMode === 'adaptive') text += ' · adaptive';
|
|
42
|
-
node.status({ fill: active ? 'yellow' : info.state === 'on' ? 'green' : 'grey', shape: 'dot', text });
|
|
43
|
-
}
|
|
44
|
-
lastAvailability = availability;
|
|
70
|
+
node.status({ fill: 'red', shape: 'ring', text: 'TARGET UNAVAILABLE' });
|
|
71
|
+
activeIds = [];
|
|
72
|
+
adapter?.watch?.([], () => {}, connection, failure => event('error', { message: failure.message }));
|
|
73
|
+
event('target_resolution_failure', { message: error.message, target: selected }, msg, send);
|
|
45
74
|
}
|
|
46
|
-
function connection(
|
|
75
|
+
function connection(value) {
|
|
47
76
|
if (closed) return;
|
|
48
|
-
|
|
49
|
-
|
|
77
|
+
const wasReady = ready;
|
|
78
|
+
ready = value;
|
|
79
|
+
if (ready) {
|
|
80
|
+
if (configured.type === 'entity') controllers.get(configured.id)?.observe(adapter.state(configured.id));
|
|
81
|
+
else if (!wasReady) scheduleRefresh();
|
|
82
|
+
} else {
|
|
50
83
|
node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
|
|
51
|
-
if (
|
|
52
|
-
lastAvailability = 'disconnected';
|
|
84
|
+
if (wasReady) event('unavailable', { reason: 'disconnected' });
|
|
53
85
|
}
|
|
54
86
|
}
|
|
55
87
|
function startAdapter() {
|
|
56
88
|
adapter = adapterFactory(RED, c.server, undefined, detail => node.log?.(`[SW Light debug] ${detail}`));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
resolver = createTargetResolver(adapter);
|
|
90
|
+
if (configured.type === 'label') adapter.onRegistryChange?.(scheduleRefresh);
|
|
91
|
+
registryTimer = setInterval(scheduleRefresh, 30000); registryTimer.unref?.();
|
|
92
|
+
// Bind connection notifications even while Label registries are unavailable.
|
|
93
|
+
startup = adapter.subscribe(configured.type === 'entity' ? configured.id : '',
|
|
94
|
+
state => { controllers.get(configured.id)?.observe(state); }, connection,
|
|
95
|
+
error => event('error', { message: error.message }));
|
|
61
96
|
connection(adapter.connected());
|
|
62
97
|
}
|
|
98
|
+
function startObserving() {
|
|
99
|
+
if (closed || node.swLightError) return;
|
|
100
|
+
startup = queue = queue.then(async () => {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
if (!adapter) startAdapter();
|
|
103
|
+
if (adapter.resolve) await adapter.resolve();
|
|
104
|
+
if (!adapter.connected()) throw light.fault('HA NOT READY', 'unavailable');
|
|
105
|
+
await reconcile();
|
|
106
|
+
}).catch(error => {
|
|
107
|
+
if (closed) return;
|
|
108
|
+
event(error.eventType || 'error', { message: error.message, phase: 'state_subscription' });
|
|
109
|
+
observerTimer = setTimeout(startObserving, 1000);
|
|
110
|
+
observerTimer.unref?.();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
63
113
|
try {
|
|
64
|
-
c = light.config(raw);
|
|
65
|
-
detector = new light.Override(c);
|
|
66
|
-
// No HA module/client resolution during Flow.start. Startup order
|
|
67
|
-
// cannot permanently disable this node; first input resolves HA.
|
|
114
|
+
c = light.config(raw); configured = target(c);
|
|
68
115
|
node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
|
|
116
|
+
observerTimer = setTimeout(startObserving, 0); observerTimer.unref?.();
|
|
69
117
|
} catch (error) {
|
|
70
118
|
node.status({ fill: 'red', shape: 'ring', text: 'CONFIG ERROR' });
|
|
71
|
-
node.error(error.message);
|
|
72
|
-
node.swLightError = error;
|
|
119
|
+
node.error(error.message); node.swLightError = error;
|
|
73
120
|
}
|
|
74
121
|
node.on('input', (msg, send, done) => {
|
|
75
122
|
send = send || node.send.bind(node);
|
|
76
123
|
done = done || (error => { if (error) node.error(error, msg); });
|
|
77
124
|
const run = async () => {
|
|
78
125
|
if (closed) { done(); return; }
|
|
79
|
-
let command, serviceCompleted = false;
|
|
80
126
|
try {
|
|
81
127
|
if (node.swLightError) throw node.swLightError;
|
|
82
|
-
|
|
83
|
-
// the next input, rather than freezing the constructor error.
|
|
128
|
+
const selected = target(c, msg.target);
|
|
84
129
|
if (!adapter) startAdapter();
|
|
130
|
+
if (selected.type === 'label') adapter.onRegistryChange?.(scheduleRefresh);
|
|
85
131
|
if (adapter.resolve) await adapter.resolve();
|
|
86
132
|
if (closed) { done(); return; }
|
|
87
|
-
if (!adapter.connected()) throw light.fault('
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const requestedOverride = Object.prototype.hasOwnProperty.call(msg, 'manual_override');
|
|
92
|
-
if (requestedOverride && (msg.source ?? 'automatic') !== 'manual') throw light.fault('manual_override requires source: manual');
|
|
93
|
-
if (requestedOverride && typeof msg.manual_override !== 'boolean') throw light.fault('manual_override must be boolean');
|
|
94
|
-
command = light.plan(c, msg, state, overrideActive(state));
|
|
95
|
-
if (requestedOverride) {
|
|
96
|
-
overrideUpdate(() => detector.set(msg.manual_override), msg, send);
|
|
97
|
-
command.blocked = false; // validated manual source
|
|
98
|
-
}
|
|
99
|
-
if (command.unsupported.length) event('unsupported', { parameters: command.unsupported, reason: 'lamp_capabilities_or_action' }, msg, send);
|
|
100
|
-
if (command.blocked) {
|
|
101
|
-
event('manual_override', { active: true, blocked: true, requested_action: command.action }, msg, send);
|
|
102
|
-
const percent = Number.isFinite(state.attributes?.brightness) ? Math.round(light.pct(state.attributes.brightness)) : '?';
|
|
103
|
-
node.status({ fill: 'yellow', shape: 'dot', text: `MANUAL · ${percent}% · BLOCK ${command.action.toUpperCase()}` });
|
|
104
|
-
done(); return;
|
|
105
|
-
}
|
|
106
|
-
await adapter.call('light', command.service, command.data, { entity_id: c.entityId });
|
|
107
|
-
serviceCompleted = true;
|
|
133
|
+
if (!adapter.connected()) throw light.fault('HA NOT READY: disconnected or loading states', 'unavailable');
|
|
134
|
+
let ids;
|
|
135
|
+
try { ids = await reconcile(selected); }
|
|
136
|
+
catch (error) { resolutionError(error, msg, send, selected); done(error); return; }
|
|
108
137
|
if (closed) { done(); return; }
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
138
|
+
if (!ids.length) event('target_empty', { target: selected, resolved_entity_ids: [] }, msg, send);
|
|
139
|
+
const results = [];
|
|
140
|
+
for (const id of ids) {
|
|
141
|
+
if (closed) break;
|
|
142
|
+
results.push(await controller(id).execute(msg, send));
|
|
143
|
+
}
|
|
112
144
|
if (closed) { done(); return; }
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
145
|
+
if (selected.type === 'entity') {
|
|
146
|
+
const { outcome, cause, ...result } = results[0];
|
|
147
|
+
if (outcome === 'succeeded') send([{ ...msg, sw_light: result }, null]);
|
|
148
|
+
done(cause); return;
|
|
149
|
+
}
|
|
150
|
+
const normalized = results.map(({ cause, ...result }) => result);
|
|
151
|
+
const counts = { succeeded: 0, skipped: 0, failed: 0, manual: 0 };
|
|
152
|
+
for (const result of results) { counts[result.outcome]++; if (result.manual_override) counts.manual++; }
|
|
153
|
+
send([{ ...msg, sw_light: { version: 1, target: selected, resolved_entity_ids: ids,
|
|
154
|
+
results: normalized, counts, state_confirmation: 'last_observed' } }, null]);
|
|
155
|
+
status(); done();
|
|
123
156
|
} catch (error) {
|
|
124
|
-
event(error.eventType || 'error', { message: error.message, light_service_completed:
|
|
157
|
+
event(error.eventType || 'error', { message: error.message, light_service_completed: false }, msg, send);
|
|
125
158
|
if (!closed) node.status({ fill: 'red', shape: 'ring', text: error.eventType === 'config_error' ? 'CONFIG ERROR' :
|
|
126
|
-
error.eventType === 'unavailable' ?
|
|
159
|
+
error.eventType === 'unavailable' ? 'HA NOT READY' : error.message });
|
|
127
160
|
done(error);
|
|
128
161
|
}
|
|
129
162
|
};
|
|
130
|
-
|
|
131
|
-
queue = queue.then(run).catch(error => { done(error); });
|
|
163
|
+
queue = queue.then(run).catch(error => done(error));
|
|
132
164
|
});
|
|
133
165
|
node.on('close', (_removed, done) => {
|
|
134
|
-
closed = true;
|
|
135
|
-
Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => done());
|
|
166
|
+
closed = true; clearTimeout(observerTimer); clearTimeout(refreshTimer); clearInterval(registryTimer);
|
|
167
|
+
Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => { controllers.clear(); done(); });
|
|
136
168
|
});
|
|
137
169
|
}
|
|
138
170
|
RED.nodes.registerType('sw-light', SWLight);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johanwistbacka/node-red-contrib-sw-light",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Home Assistant light control with automatic brightness limits and manual override",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"nodes/sw-light.js",
|
|
26
26
|
"nodes/sw-light.html",
|
|
27
|
+
"lib/controller.js",
|
|
28
|
+
"lib/targets.js",
|
|
27
29
|
"lib/light.js",
|
|
28
30
|
"lib/adaptive.js",
|
|
29
31
|
"lib/ha-adapter.js",
|
|
@@ -31,7 +33,8 @@
|
|
|
31
33
|
"examples/manual-adaptive.json",
|
|
32
34
|
"README.md",
|
|
33
35
|
"LICENSE",
|
|
34
|
-
"CHANGELOG.md"
|
|
36
|
+
"CHANGELOG.md",
|
|
37
|
+
"docs/labels.md"
|
|
35
38
|
],
|
|
36
39
|
"scripts": {
|
|
37
40
|
"test": "node --test",
|