@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/nodes/sw-light.js CHANGED
@@ -1,138 +1,170 @@
1
1
  'use strict';
2
-
3
2
  const light = require('../lib/light');
4
- const adaptive = require('../lib/adaptive');
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, detector, adapter, closed = false, lastAvailability;
14
- let queue = Promise.resolve(), startup = Promise.resolve();
15
- const protectedAtFullBrightness = state => state?.state === 'on' && state.attributes?.brightness === 255;
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) return;
19
- send([null, { ...msg, sw_light_event: { version: 1, type, entity_id: c?.entityId ?? raw.entityId,
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 overrideUpdate(fn, msg, send) {
23
- const before = detector.active;
24
- fn();
25
- if (before !== detector.active) event(detector.active ? 'manual_override' : 'override_released',
26
- { active: detector.active, strategy: c.overrideStrategy }, msg, send);
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 observe(state) {
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
- overrideUpdate(() => detector.observe(state));
31
- const availability = !state ? 'missing' : state.state;
32
- if (['missing', 'unknown', 'unavailable'].includes(availability)) {
33
- node.status({ fill: 'red', shape: 'ring', text: 'ENTITY UNAVAILABLE' });
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(ready) {
75
+ function connection(value) {
47
76
  if (closed) return;
48
- if (ready) observe(adapter.state(c.entityId));
49
- else {
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 (lastAvailability !== 'disconnected') event('unavailable', { reason: 'disconnected' });
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
- startup = adapter.subscribe(c.entityId, observe, connection,
58
- error => event('error', { message: error.message })).catch(error => {
59
- if (!closed) { node.status({ fill: 'red', shape: 'ring', text: 'subscription failed' }); event(error.eventType || 'error', { message: error.message }); }
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
- // A temporarily absent/uninitialized provider is retried on
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('Home Assistant is disconnected or loading states', 'unavailable');
88
- const state = adapter.state(c.entityId);
89
- if (!state || !['on', 'off'].includes(state.state)) throw light.fault(`Light is ${state?.state ?? 'missing'}`, 'unavailable');
90
- observe(state);
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
- let adaptiveResult;
110
- try { adaptiveResult = await adaptive.apply(adapter, c, command); }
111
- catch (error) { event(error.eventType || 'error', { message: error.message, phase: 'adaptive_apply', light_service_completed: true }, msg, send); }
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
- const current = adapter.state(c.entityId);
114
- observe(current);
115
- send([{ ...msg, sw_light: { ...light.normalized(current, overrideActive(current), c.colorMode === 'adaptive', command.service),
116
- entity_id: c.entityId, source: command.source, requested_action: command.action,
117
- service_data: command.data, ignored_conflicts: command.ignoredConflicts,
118
- state_confirmation: 'last_observed', adaptive: { ...adaptive.inspect(c, adapter.states()), ...adaptiveResult } } }, null]);
119
- const level = command.data.brightness === undefined ? '' : ` · ${Math.round(light.pct(command.data.brightness))}%`;
120
- node.status({ fill: command.source === 'manual' ? 'yellow' : command.turningOn ? 'green' : 'grey', shape: 'dot',
121
- text: `${command.source === 'manual' ? 'MANUAL' : 'AUTO'} · ${command.turningOn ? 'ON' : 'OFF'}${level}` });
122
- done();
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: serviceCompleted }, msg, send);
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' ? (error.message.startsWith('HA NOT READY:') || !adapter || !adapter.connected() ? 'HA NOT READY' : 'ENTITY UNAVAILABLE') : error.message });
159
+ error.eventType === 'unavailable' ? 'HA NOT READY' : error.message });
127
160
  done(error);
128
161
  }
129
162
  };
130
- // Serialize relative brightness and toggles; contain all asynchronous failures.
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.4",
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",