node-red-adv-lights
Version:
Niko Home Control 2, Advanced light translators for Tuya and Home Assistant (mode-aware, brightness carry-over)
235 lines (217 loc) • 10.6 kB
JavaScript
module.exports = function(RED) {
function HaAdvNode(config) {
RED.nodes.createNode(this, config);
const node = this;
const statusQuiet = !!config.statusQuiet;
const minKelvinCfg = Number.isFinite(+config.minKelvin) ? +config.minKelvin : 2700;
const maxKelvinCfg = Number.isFinite(+config.maxKelvin) ? +config.maxKelvin : 6500;
const ctUnitCfg = (config.ctUnit || "kelvin").toLowerCase() === "mireds" ? "mireds" : "kelvin";
function setStatus(text, ok=true){
if (statusQuiet) return;
node.status({ fill: ok?'green':'yellow', shape: ok?'dot':'ring', text });
}
// Helpers
function clamp(n, min, max){ return Math.max(min, Math.min(n, max)); }
function fromAnyToPct(x){ const v=+x; if(!isFinite(v)) return null; return v<=100?Math.max(0,Math.min(100,Math.round(v))):Math.max(0,Math.min(100,Math.round(v/10))); }
function hsvToRgb(h, s, v){
h = ((+h % 360) + 360) % 360;
s = Math.max(0,Math.min(1,(+s)/100));
v = Math.max(0,Math.min(1,(+v)/100));
const c=v*s, x=c*(1-Math.abs(((h/60)%2)-1)), m=v-c;
let rp=0,gp=0,bp=0;
if (h<60){rp=c;gp=x;bp=0;}
else if (h<120){rp=x;gp=c;bp=0;}
else if (h<180){rp=0;gp=c;bp=x;}
else if (h<240){rp=0;gp=x;bp=c;}
else if (h<300){rp=x;gp=0;bp=c;}
else {rp=c;gp=0;bp=x;}
const to255=t=>Math.max(0,Math.min(255,Math.round((t+m)*255)));
return [to255(rp),to255(gp),to255(bp)];
}
function rgbToHsv(r,g,b){
r/=255; g/=255; b/=255;
const max=Math.max(r,g,b),min=Math.min(r,g,b),d=max-min;
let h=0;
if (d) {
switch(max){
case r:h=60*(((g-b)/d)%6);break;
case g:h=60*(((b-r)/d)+2);break;
case b:h=60*(((r-g)/d)+4);break;
}
}
if (h<0) h+=360;
const s = max===0?0:(d/max)*100;
const v = max*100;
return [Math.round(h),Math.round(s),Math.round(v)];
}
function normalizeMode(s){
if (!s) return undefined;
const v=String(s).trim().toLowerCase();
if (v==="color"||v==="colour"||v==="rgb") return "colour";
if (v==="tunablewhite"||v==="white"||v==="ct"||v==="cwww") return "white";
return undefined;
}
function pctToKelvin(pct, minK, maxK){
const p = Math.max(0,Math.min(1,(+pct)/100));
return Math.round(minK + p*(maxK - minK));
}
function kelvinToMireds(k){ const K=Math.max(1,+k); return Math.round(1e6 / K); }
// Persistent state
let state = node.context().get('haLightState') || {
mode: null,
brightnessPct: 100,
kelvin: 4000,
hsv: { h:0, s:100, v:100 }
};
node.on('input', function(msg, send, done) {
try {
const p = msg.payload || {};
const out = {};
let haveAny = false;
let wantOn;
let requestedMode;
const minK = Number.isFinite(+msg.minKelvin)?+msg.minKelvin:minKelvinCfg;
const maxK = Number.isFinite(+msg.maxKelvin)?+msg.maxKelvin:maxKelvinCfg;
const useMireds = ((msg.ctUnit||ctUnitCfg).toString().toLowerCase()==="mireds");
// switch
if (p.switch !== undefined) {
wantOn = !!p.switch;
} else if (p.Status !== undefined && p.Status !== null) {
const s = (typeof p.Status === 'string') ? p.Status.trim().toLowerCase() : p.Status;
if (s==='on'||s==='true'||s==='1'||s===1||s===true) wantOn = true;
if (s==='off'||s==='false'||s==='0'||s===0||s===false) wantOn = false;
}
// mode
if (p.ColorMode != null) { const m=normalizeMode(p.ColorMode); if (m){ requestedMode=m; haveAny=true; } }
if (p.mode != null) { const m=normalizeMode(p.mode); if (m){ requestedMode=m; haveAny=true; } }
// brightness
let bPct = null;
if (p.brightness !== undefined && p.brightness !== null && p.brightness !== '') bPct = fromAnyToPct(p.brightness);
if (bPct == null && p.Brightness !== undefined && p.Brightness !== null && p.Brightness !== '') bPct = fromAnyToPct(p.Brightness);
if (bPct != null) { out.brightness_pct = bPct; haveAny=true; state.brightnessPct=bPct; if (wantOn===undefined) wantOn = (bPct>0); }
// TunableWhite
if (typeof p.TunableWhite === 'string') {
const m = p.TunableWhite.match(/cwww\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*\)/i);
if (m) {
const kelvin = Math.max(minK, Math.min(maxK, parseFloat(m[1])));
const pctIn = fromAnyToPct(m[2]);
if (useMireds) out.color_temp = kelvinToMireds(kelvin);
else out.color_temp_kelvin = kelvin;
state.kelvin = kelvin;
requestedMode = requestedMode || "white";
haveAny = true;
if (pctIn != null) { out.brightness_pct=pctIn; state.brightnessPct=pctIn; haveAny=true; }
if (wantOn === undefined) wantOn = true;
}
}
// temperature direct (0..100 or kelvin)
if (p.temperature !== undefined && p.temperature !== null && p.temperature !== '') {
const v = +p.temperature;
if (isFinite(v)) {
let kelvin;
if (v <= 100) kelvin = pctToKelvin(v, minK, maxK);
else kelvin = Math.max(minK, Math.min(maxK, Math.round(v)));
if (useMireds) out.color_temp = kelvinToMireds(kelvin);
else out.color_temp_kelvin = kelvin;
state.kelvin = kelvin;
requestedMode = requestedMode || "white";
haveAny = true;
if (wantOn === undefined) wantOn = true;
}
}
// Color
let colorProvided = false;
if (typeof p.Color === 'string') {
let m = p.Color.match(/hsv\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)\s*\)/i);
if (m) {
const H = Math.max(0,Math.min(360,+m[1])), S = Math.max(0,Math.min(100,+m[2])), V = Math.max(0,Math.min(100,+m[3]));
out.hs_color = [H, S];
if (bPct == null) { out.brightness_pct = Math.max(0,Math.min(100,Math.round(V))); state.brightnessPct = out.brightness_pct; }
state.hsv = { h:H, s:S, v:(bPct != null ? bPct : V) };
requestedMode = "colour"; colorProvided = true; haveAny = true; if (wantOn===undefined) wantOn = true;
} else {
m = p.Color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i);
if (m) {
const r = Math.max(0,Math.min(255,parseInt(m[1],10)));
const g = Math.max(0,Math.min(255,parseInt(m[2],10)));
const b = Math.max(0,Math.min(255,parseInt(m[3],10)));
out.rgb_color = [r,g,b];
const [h,s,v] = rgbToHsv(r,g,b);
if (bPct == null) { out.brightness_pct = Math.max(0,Math.min(100,Math.round(v))); state.brightnessPct = out.brightness_pct; }
state.hsv = { h, s, v:(bPct != null ? bPct : v) };
requestedMode = "colour"; colorProvided = true; haveAny=true; if (wantOn===undefined) wantOn = true;
}
}
}
// Determine final mode & carry brightness
const prevMode = state.mode;
let mode = requestedMode || prevMode;
const brightnessProvidedThisMsg =
(bPct != null) || colorProvided || (typeof p.TunableWhite === 'string') ||
(p.temperature !== undefined && p.temperature !== null && p.temperature !== '');
if (requestedMode && prevMode && requestedMode !== prevMode && !brightnessProvidedThisMsg) {
out.brightness_pct = state.brightnessPct;
haveAny = true;
if (wantOn === undefined) wantOn = state.brightnessPct > 0;
}
// Clean payload (avoid mixing CT & colour)
if (mode === "white") {
delete out.hs_color; delete out.rgb_color;
if (!('color_temp_kelvin' in out) && !('color_temp' in out)) {
const k = Math.max(minK, Math.min(maxK, state.kelvin));
if (useMireds) out.color_temp = kelvinToMireds(k);
else out.color_temp_kelvin = k;
haveAny = true;
}
} else if (mode === "colour") {
delete out.color_temp_kelvin; delete out.color_temp;
if (!('hs_color' in out) && !('rgb_color' in out)) {
out.hs_color = [ state.hsv.h, state.hsv.s ];
if (bPct == null && !('brightness_pct' in out)) {
out.brightness_pct = state.brightnessPct; haveAny = true;
}
haveAny = true;
}
}
if (wantOn === undefined) wantOn = haveAny ? true : undefined;
if (wantOn !== undefined) msg.wantOn = !!wantOn;
else msg.wantOn = msg.wantOn ?? true;
// Persist
if (mode) state.mode = mode;
if (out.color_temp_kelvin) state.kelvin = Math.max(minK, Math.min(maxK, out.color_temp_kelvin));
if (out.color_temp) state.kelvin = Math.max(minK, Math.min(maxK, Math.round(1e6 / out.color_temp)));
if (Array.isArray(out.hs_color)) state.hsv = { h: out.hs_color[0], s: out.hs_color[1], v: state.brightnessPct };
if (Array.isArray(out.rgb_color)) {
const [h,s,v] = rgbToHsv(out.rgb_color[0], out.rgb_color[1], out.rgb_color[2]);
state.hsv = { h, s, v: state.brightnessPct };
}
if (out.brightness_pct != null) state.brightnessPct = Math.max(0,Math.min(100,Math.round(out.brightness_pct)));
node.context().set('haLightState', state);
// Build dual outputs
let msgOn = null, msgOff = null;
if (msg.wantOn === false) {
// OFF: second output only
msgOff = RED.util.cloneMessage(msg);
msgOff.payload = {};
setStatus('off', true);
} else {
// ON (or implicit ON because we had meaningful payload)
msgOn = RED.util.cloneMessage(msg);
msgOn.payload = out;
setStatus(['on', (mode?`mode=${mode}`:''), (out.brightness_pct!=null?`b=${out.brightness_pct}`:''),
(out.color_temp_kelvin!=null?`${out.color_temp_kelvin}K`:''),
(out.color_temp!=null?`${out.color_temp}mireds`:''),
(out.hs_color?`hs=[${out.hs_color[0]},${out.hs_color[1]}]`:''),
(out.rgb_color?`rgb=[${out.rgb_color.join(',')}]`:'')].filter(Boolean).join(' '), true);
}
send([msgOn, msgOff]);
done();
} catch (err) {
node.error(err.message || String(err), msg);
setStatus("error", false);
done(err);
}
});
}
RED.nodes.registerType("ha-adv", HaAdvNode);
};