@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,132 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
(function () {
|
|
3
|
+
const ranges = { brightnessPct: [1,100], autoMaxPct: [1,100], minPct: [1,100], overrideThreshold: [1,100], transitionOn: [0,3600], transitionOff: [0,3600], kelvin: [1000,40000], mired: [25,1000] };
|
|
4
|
+
function numeric(key) {
|
|
5
|
+
return function (value) {
|
|
6
|
+
const n = Number(value), range = ranges[key];
|
|
7
|
+
if (String(value).trim() === '' || !Number.isFinite(n) || n < range[0] || n > range[1]) return false;
|
|
8
|
+
if (key === 'kelvin' && !Number.isInteger(n)) return false;
|
|
9
|
+
if (['brightnessPct','autoMaxPct','minPct'].includes(key)) {
|
|
10
|
+
const read = k => $('#node-input-' + k).length ? Number($('#node-input-' + k).val()) : Number(this[k]);
|
|
11
|
+
if (!(read('minPct') <= read('brightnessPct') && read('brightnessPct') <= read('autoMaxPct'))) return false;
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
RED.nodes.registerType('sw-light', {
|
|
17
|
+
category: 'home assistant', color: '#c7d7e8', icon: 'font-awesome/fa-lightbulb-o',
|
|
18
|
+
inputs: 1, outputs: 2, outputLabels: ['result / state', 'events / errors'],
|
|
19
|
+
paletteLabel: 'SW Light', label: function () { return this.name || 'SW Light'; },
|
|
20
|
+
defaults: {
|
|
21
|
+
name: { value: '' }, server: { value: '', type: 'server', required: true },
|
|
22
|
+
action: { value: 'msg', validate: v => ['on','off','toggle','msg'].includes(v) },
|
|
23
|
+
entityId: { value: '', required: true, validate: v => /^light\.[a-z0-9_]+$/.test(v) },
|
|
24
|
+
brightnessPct: { value: 94, validate: numeric('brightnessPct') },
|
|
25
|
+
autoMaxPct: { value: 94, validate: numeric('autoMaxPct') },
|
|
26
|
+
minPct: { value: 1, validate: numeric('minPct') },
|
|
27
|
+
transitionOn: { value: 1, validate: numeric('transitionOn') },
|
|
28
|
+
transitionOff: { value: 1, validate: numeric('transitionOff') },
|
|
29
|
+
manualOverride: { value: true }, overrideThreshold: { value: 100, validate: numeric('overrideThreshold') },
|
|
30
|
+
overrideStrategy: { value: 'threshold' }, colorMode: { value: 'adaptive' },
|
|
31
|
+
kelvin: { value: 2700, validate: numeric('kelvin') }, mired: { value: 370, validate: numeric('mired') },
|
|
32
|
+
fixedColor: { value: '#ffffff', validate: v => /^#[0-9a-f]{6}$/i.test(v) },
|
|
33
|
+
adaptiveEntity: { value: '', validate: v => !v || /^switch\.[a-z0-9_]+$/.test(v) },
|
|
34
|
+
adaptiveDiscover: { value: true }, adaptiveApply: { value: false }, adaptiveRespectManual: { value: true }
|
|
35
|
+
},
|
|
36
|
+
oneditprepare: function () {
|
|
37
|
+
let states = {}, request, generation = 0;
|
|
38
|
+
const entity = $('#node-input-entityId'), adaptive = $('#node-input-adaptiveEntity');
|
|
39
|
+
function update() {
|
|
40
|
+
const mode = $('#node-input-colorMode').val();
|
|
41
|
+
$('.sw-color-row').hide();
|
|
42
|
+
$('.sw-color-' + mode).show();
|
|
43
|
+
$('.sw-override-row').toggle($('#node-input-manualOverride').prop('checked'));
|
|
44
|
+
$('.sw-threshold-row').toggle($('#node-input-manualOverride').prop('checked') && $('#node-input-overrideStrategy').val() === 'threshold');
|
|
45
|
+
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.');
|
|
47
|
+
// 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)));
|
|
50
|
+
}
|
|
51
|
+
function source(domain) {
|
|
52
|
+
return function (req, respond) {
|
|
53
|
+
const term = req.term.toLowerCase();
|
|
54
|
+
respond(Object.values(states).filter(s => s.entity_id.startsWith(domain + '.') &&
|
|
55
|
+
(s.entity_id.toLowerCase().includes(term) || String(s.attributes.friendly_name || '').toLowerCase().includes(term)))
|
|
56
|
+
.slice(0,100).map(s => ({ label: (s.attributes.friendly_name || s.entity_id) + ' (' + s.entity_id + ')', value: s.entity_id })));
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
entity.autocomplete({ minLength: 0, source: source('light'), select: function (_e, ui) { entity.val(ui.item.value); update(); } });
|
|
60
|
+
adaptive.autocomplete({ minLength: 0, source: source('switch') });
|
|
61
|
+
function load() {
|
|
62
|
+
generation++; const token = generation;
|
|
63
|
+
if (request) request.abort();
|
|
64
|
+
states = {}; update();
|
|
65
|
+
const server = $('#node-input-server').val();
|
|
66
|
+
if (!server || server === '_ADD_') return;
|
|
67
|
+
$('#sw-capabilities').text('Loading Home Assistant entities…');
|
|
68
|
+
// HA contrib's own editor endpoint. No auth tokens or static entity lists.
|
|
69
|
+
request = $.getJSON('homeassistant/states/' + encodeURIComponent(server))
|
|
70
|
+
.done(function (data) { if (token === generation) { states = data || {}; update(); } })
|
|
71
|
+
.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
|
+
}
|
|
73
|
+
$('#node-input-server').on('change.swLight', load);
|
|
74
|
+
$('#node-input-colorMode, #node-input-manualOverride, #node-input-overrideStrategy, #node-input-entityId').on('change.swLight', update);
|
|
75
|
+
entity.on('focus.swLight', function () { entity.autocomplete('search', entity.val()); });
|
|
76
|
+
adaptive.on('focus.swLight', function () { adaptive.autocomplete('search', adaptive.val()); });
|
|
77
|
+
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');
|
|
81
|
+
delete this._swCleanup;
|
|
82
|
+
};
|
|
83
|
+
load(); update();
|
|
84
|
+
},
|
|
85
|
+
oneditsave: function () { if (this._swCleanup) this._swCleanup(); },
|
|
86
|
+
oneditcancel: function () { if (this._swCleanup) this._swCleanup(); }
|
|
87
|
+
});
|
|
88
|
+
})();
|
|
89
|
+
</script>
|
|
90
|
+
|
|
91
|
+
<script type="text/html" data-template-name="sw-light">
|
|
92
|
+
<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
|
+
<div class="form-row"><label for="node-input-server"><i class="fa fa-server"></i> HA server</label><input id="node-input-server" type="text"></div>
|
|
94
|
+
<div class="form-row"><label for="node-input-entityId"><i class="fa fa-lightbulb-o"></i> Light</label><input id="node-input-entityId" type="text" placeholder="light.entity_id"></div>
|
|
95
|
+
<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
|
+
<div class="form-tips" id="sw-capabilities" style="margin-bottom:16px"></div>
|
|
97
|
+
<fieldset><legend>Automatic lighting</legend>
|
|
98
|
+
<div class="form-row"><label for="node-input-brightnessPct">On brightness %</label><input id="node-input-brightnessPct" type="number" min="1" max="100" step="0.1"></div>
|
|
99
|
+
<div class="form-row"><label for="node-input-autoMaxPct">Auto maximum %</label><input id="node-input-autoMaxPct" type="number" min="1" max="100" step="0.1"></div>
|
|
100
|
+
<div class="form-row"><label for="node-input-minPct">Minimum %</label><input id="node-input-minPct" type="number" min="1" max="100" step="0.1"></div>
|
|
101
|
+
<div class="form-row"><label for="node-input-transitionOn">On fade (s)</label><input id="node-input-transitionOn" type="number" min="0" max="3600" step="0.1"></div>
|
|
102
|
+
<div class="form-row"><label for="node-input-transitionOff">Off fade (s)</label><input id="node-input-transitionOff" type="number" min="0" max="3600" step="0.1"></div>
|
|
103
|
+
</fieldset>
|
|
104
|
+
<fieldset><legend>Manual override</legend>
|
|
105
|
+
<div class="form-row"><label for="node-input-manualOverride">Enable</label><input id="node-input-manualOverride" type="checkbox" style="width:auto"></div>
|
|
106
|
+
<div class="form-row sw-override-row"><label for="node-input-overrideStrategy">Detection</label><select id="node-input-overrideStrategy"><option value="threshold">Brightness threshold + explicit flag</option><option value="explicit">Explicit flag only</option></select></div>
|
|
107
|
+
<div class="form-row sw-threshold-row"><label for="node-input-overrideThreshold">Threshold %</label><input id="node-input-overrideThreshold" type="number" min="1" max="100" step="0.1"></div>
|
|
108
|
+
<p class="sw-override-row">Automatic commands pause during override. Manual commands use <code>msg.source = "manual"</code>.</p>
|
|
109
|
+
</fieldset>
|
|
110
|
+
<fieldset><legend>Color</legend>
|
|
111
|
+
<div class="form-row"><label for="node-input-colorMode">Mode</label><select id="node-input-colorMode"><option value="adaptive">Adaptive Lighting</option><option value="keep">Keep current</option><option value="kelvin">Fixed Kelvin</option><option value="mired">Fixed mired</option><option value="default">Lamp default</option><option value="color">Fixed color</option></select></div>
|
|
112
|
+
<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
|
+
<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
|
+
<div class="form-row sw-color-row sw-color-color"><label for="node-input-fixedColor">Color</label><input id="node-input-fixedColor" type="color"></div>
|
|
115
|
+
<div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveEntity">Adaptive switch</label><input id="node-input-adaptiveEntity" type="text" placeholder="Optional switch.entity_id"></div>
|
|
116
|
+
<div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveDiscover">Discover</label><input id="node-input-adaptiveDiscover" type="checkbox" style="width:auto"> From a unique light membership match</div>
|
|
117
|
+
<div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveApply">Apply on turn-on</label><input id="node-input-adaptiveApply" type="checkbox" style="width:auto"> Color only</div>
|
|
118
|
+
<div class="form-row sw-color-row sw-color-adaptive"><label for="node-input-adaptiveRespectManual">Respect manual</label><input id="node-input-adaptiveRespectManual" type="checkbox" style="width:auto"></div>
|
|
119
|
+
<p class="sw-color-row sw-color-adaptive">Ordinary turn-on sends no color. Adaptive Lighting owns color adaptation. Apply is optional and never clears its manual control.</p>
|
|
120
|
+
</fieldset>
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<script type="text/html" data-help-name="sw-light">
|
|
124
|
+
<p>Controls one Home Assistant light directly: Trigger → SW Light → Home Assistant. No downstream HA Action node is required. Select On, Off, Toggle, or From msg. Toggle resolves the current state to turn_on or turn_off.</p>
|
|
125
|
+
<h3>Inputs</h3>
|
|
126
|
+
<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
|
+
<h3>Outputs</h3><ol><li>Original message plus <code>msg.sw_light</code>: last observed HA state, action, service data, and override/adaptive information.</li><li>Original message plus <code>msg.sw_light_event</code>: version, type, entity_id, timestamp, and details.</li></ol>
|
|
128
|
+
<h3>Details</h3><p>Default on brightness/max 94%, minimum 1%, fade 1s. ON at 100% (brightness 255) always blocks automatic ON, OFF and Toggle. 94%, 95% and 99% do not trigger this guard. Optional threshold/explicit overrides can also pause automation. Lowering brightness below the threshold releases its flag; turning off releases all override flags. Manual commands bypass the automatic maximum. Explicit flags require explicit release or off.</p>
|
|
129
|
+
<p>Top-level message properties override configuration. Brightness precedence: percent, byte, percent step, byte step. Color precedence: Kelvin, mired, RGBWW, RGBW, RGB, HS, XY, name. Invalid values produce errors; unsupported parameters emit an event and are omitted.</p>
|
|
130
|
+
<p>Adaptive mode sends no configured color. An explicit message color takes precedence and suppresses optional adaptive apply for that command. Keep current and lamp default both omit configured color and leave HA/device behavior in control.</p>
|
|
131
|
+
<p>Requires HA contrib 0.80.3. See package README for full contracts, examples and compatibility limitations.</p>
|
|
132
|
+
</script>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const light = require('../lib/light');
|
|
4
|
+
const adaptive = require('../lib/adaptive');
|
|
5
|
+
const { createAdapter } = require('../lib/ha-adapter');
|
|
6
|
+
|
|
7
|
+
// Optional factory is for isolated transport/lifecycle tests, never flow config.
|
|
8
|
+
module.exports = function register(RED, adapterFactory = createAdapter) {
|
|
9
|
+
function SWLight(raw) {
|
|
10
|
+
RED.nodes.createNode(this, raw);
|
|
11
|
+
const node = this;
|
|
12
|
+
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);
|
|
17
|
+
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 } }]);
|
|
21
|
+
}
|
|
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);
|
|
27
|
+
}
|
|
28
|
+
function observe(state) {
|
|
29
|
+
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;
|
|
45
|
+
}
|
|
46
|
+
function connection(ready) {
|
|
47
|
+
if (closed) return;
|
|
48
|
+
if (ready) observe(adapter.state(c.entityId));
|
|
49
|
+
else {
|
|
50
|
+
node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
|
|
51
|
+
if (lastAvailability !== 'disconnected') event('unavailable', { reason: 'disconnected' });
|
|
52
|
+
lastAvailability = 'disconnected';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function startAdapter() {
|
|
56
|
+
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
|
+
});
|
|
61
|
+
connection(adapter.connected());
|
|
62
|
+
}
|
|
63
|
+
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.
|
|
68
|
+
node.status({ fill: 'red', shape: 'ring', text: 'HA NOT READY' });
|
|
69
|
+
} catch (error) {
|
|
70
|
+
node.status({ fill: 'red', shape: 'ring', text: 'CONFIG ERROR' });
|
|
71
|
+
node.error(error.message);
|
|
72
|
+
node.swLightError = error;
|
|
73
|
+
}
|
|
74
|
+
node.on('input', (msg, send, done) => {
|
|
75
|
+
send = send || node.send.bind(node);
|
|
76
|
+
done = done || (error => { if (error) node.error(error, msg); });
|
|
77
|
+
const run = async () => {
|
|
78
|
+
if (closed) { done(); return; }
|
|
79
|
+
let command, serviceCompleted = false;
|
|
80
|
+
try {
|
|
81
|
+
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.
|
|
84
|
+
if (!adapter) startAdapter();
|
|
85
|
+
if (adapter.resolve) await adapter.resolve();
|
|
86
|
+
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;
|
|
108
|
+
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); }
|
|
112
|
+
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();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
event(error.eventType || 'error', { message: error.message, light_service_completed: serviceCompleted }, msg, send);
|
|
125
|
+
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 });
|
|
127
|
+
done(error);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
// Serialize relative brightness and toggles; contain all asynchronous failures.
|
|
131
|
+
queue = queue.then(run).catch(error => { done(error); });
|
|
132
|
+
});
|
|
133
|
+
node.on('close', (_removed, done) => {
|
|
134
|
+
closed = true;
|
|
135
|
+
Promise.resolve().then(() => adapter?.close()).then(() => startup).catch(() => {}).finally(() => done());
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
RED.nodes.registerType('sw-light', SWLight);
|
|
139
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@johanwistbacka/node-red-contrib-sw-light",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Home Assistant light control with automatic brightness limits and manual override",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"node-red",
|
|
8
|
+
"home-assistant",
|
|
9
|
+
"light",
|
|
10
|
+
"adaptive-lighting"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18.2.0"
|
|
14
|
+
},
|
|
15
|
+
"node-red": {
|
|
16
|
+
"version": ">=3.1.1",
|
|
17
|
+
"nodes": {
|
|
18
|
+
"sw-light": "nodes/sw-light.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"node-red-contrib-home-assistant-websocket": "0.80.3"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"nodes/sw-light.js",
|
|
26
|
+
"nodes/sw-light.html",
|
|
27
|
+
"lib/light.js",
|
|
28
|
+
"lib/adaptive.js",
|
|
29
|
+
"lib/ha-adapter.js",
|
|
30
|
+
"examples/basic.json",
|
|
31
|
+
"examples/manual-adaptive.json",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"CHANGELOG.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "node --test",
|
|
38
|
+
"check": "node scripts/check.js"
|
|
39
|
+
},
|
|
40
|
+
"author": "Johan Wistbacka",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public",
|
|
43
|
+
"tag": "latest",
|
|
44
|
+
"registry": "https://registry.npmjs.org/"
|
|
45
|
+
}
|
|
46
|
+
}
|