UNPKG

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
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); };