UNPKG

node-red-adv-lights

Version:

Niko Home Control 2, Advanced light translators for Tuya and Home Assistant (mode-aware, brightness carry-over)

227 lines (207 loc) 11.7 kB
module.exports = function(RED) { function TuyaAdvNode(config) { RED.nodes.createNode(this, config); const node = this; const statusQuiet = !!config.statusQuiet; const defaultScale = (config.scale || "pct").toLowerCase(); const keepBrightnessInColour = !!config.keepBrightnessInColour; 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(max, n)); } function pctTo1000(p){ return clamp(Math.round(+p * 10), 0, 1000); } // 0..100 -> 0..1000 function fromAnyToPct(x){ const v=+x; if(!isFinite(v)) return null; return v<=100?clamp(Math.round(v),0,100):clamp(Math.round(v/10),0,100); } 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 mapKelvinToTuyaTemp1000(k, inverted=false){ const MIN=2700, MAX=6500, K=clamp(+k,MIN,MAX); const t = Math.round(((K - MIN)/(MAX - MIN)) * 1000); return inverted ? (1000 - t) : t; } function colourDataJSON(h, s_any, v_any){ const H = clamp(+h, 0, 360); const S = clamp(s_any <= 100 ? Math.round(s_any * 10) : Math.round(s_any), 0, 1000); const V = clamp(v_any <= 100 ? Math.round(v_any * 10) : Math.round(v_any), 0, 1000); return JSON.stringify({ h:H, s:S, v:V }); } // Persistent state let last = node.context().get('tuyaLast') || { mode: null, // "white" | "colour" brightnessPct: 100, // latest brightness 0..100 colour: { h: 0, s1000: 1000, v1000: 1000 }, temperature1000: 500 }; node.on('input', function(msg, send, done) { try { const p = msg.payload || {}; const out = {}; // friendly payload (0..100 for brightness/temperature) let haveAny = false; let wantOn; let requestedMode; const scaleMode = (msg.scale || defaultScale) === "tuya" ? "tuya" : "pct"; // Switch if (p.switch !== undefined) { wantOn = !!p.switch; } else if (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 intent 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 input let bPct = null; if (p.brightness != null && p.brightness !== '') bPct = fromAnyToPct(p.brightness); if (bPct == null && p.Brightness != null && p.Brightness !== '') bPct = fromAnyToPct(p.Brightness); if (bPct != null) { out.brightness = bPct; haveAny = true; last.brightnessPct = bPct; if (wantOn === undefined) wantOn = (bPct > 0); } // TunableWhite "cwww(k,percent)" if (typeof p.TunableWhite === 'string') { const m = p.TunableWhite.match(/cwww\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*\)/i); if (m) { const kelvin = parseFloat(m[1]); const pctIn = fromAnyToPct(m[2]); const t1000 = mapKelvinToTuyaTemp1000(kelvin, !!msg.tuyaTempInverted); out.temperature = clamp(Math.round(t1000/10), 0, 100); haveAny = true; requestedMode = requestedMode || "white"; last.temperature1000 = t1000; if (pctIn != null) { out.brightness = pctIn; haveAny = true; last.brightnessPct = pctIn; } if (wantOn === undefined) wantOn = true; } } // Temperature direct if (p.temperature != null && p.temperature !== '') { const tPct = fromAnyToPct(p.temperature); if (tPct != null) { out.temperature = tPct; haveAny = true; requestedMode = requestedMode || "white"; last.temperature1000 = clamp(Math.round(tPct*10), 0, 1000); if (wantOn === undefined) wantOn = true; } } // Color / colour_data_v2 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=+m[1], s=+m[2], v=+m[3]; // s,v 0..100 out.colour_data_v2 = colourDataJSON(h, s, v); haveAny = true; requestedMode = "colour"; last.mode="colour"; last.colour = { h: clamp(h,0,360), s1000: clamp(Math.round(s*10),0,1000), v1000: clamp(Math.round(v*10),0,1000) }; last.brightnessPct = clamp(Math.round(v), 0, 100); if (wantOn === undefined) wantOn = v > 0; } else { m = p.Color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/i); if (m) { // convert RGB to HSV (quick) let r=+m[1]/255,g=+m[2]/255,b=+m[3]/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, v = max*100; out.colour_data_v2 = colourDataJSON(h, s, v); haveAny = true; requestedMode="colour"; last.mode="colour"; last.colour = { h: clamp(h,0,360), s1000: clamp(Math.round(s*10),0,1000), v1000: clamp(Math.round(v*10),0,1000) }; last.brightnessPct = clamp(Math.round(v), 0, 100); if (wantOn === undefined) wantOn = v > 0; } } } else if (p.colour_data_v2) { if (typeof p.colour_data_v2 === 'string') { try { const obj = JSON.parse(p.colour_data_v2); out.colour_data_v2 = colourDataJSON(obj.h, obj.s, obj.v); last.mode="colour"; last.colour = { h: clamp(+obj.h,0,360), s1000: clamp(obj.s<=100?Math.round(obj.s*10):Math.round(+obj.s),0,1000), v1000: clamp(obj.v<=100?Math.round(obj.v*10):Math.round(+obj.v),0,1000) }; last.brightnessPct = clamp(Math.round(obj.v<=100?obj.v:obj.v/10), 0, 100); } catch { out.colour_data_v2 = p.colour_data_v2; } haveAny = true; requestedMode="colour"; if (wantOn === undefined) wantOn = true; } else if (typeof p.colour_data_v2 === 'object') { out.colour_data_v2 = colourDataJSON(p.colour_data_v2.h, p.colour_data_v2.s, p.colour_data_v2.v); last.mode="colour"; haveAny = true; requestedMode="colour"; last.colour = { h: clamp(+p.colour_data_v2.h,0,360), s1000: clamp(p.colour_data_v2.s<=100?Math.round(p.colour_data_v2.s*10):Math.round(+p.colour_data_v2.s),0,1000), v1000: clamp(p.colour_data_v2.v<=100?Math.round(p.colour_data_v2.v*10):Math.round(+p.colour_data_v2.v),0,1000) }; last.brightnessPct = clamp(Math.round(p.colour_data_v2.v<=100?p.colour_data_v2.v:p.colour_data_v2.v/10), 0, 100); if (wantOn === undefined) wantOn = true; } } // Determine mode & carry brightness on mode switch const prevMode = last.mode; let mode = requestedMode || prevMode; const brightnessProvidedThisMsg = (bPct != null) || (typeof p.Color === 'string') || !!p.colour_data_v2 || typeof p.TunableWhite === 'string'; if (requestedMode && prevMode && requestedMode !== prevMode && !brightnessProvidedThisMsg) { if (requestedMode === "white") { out.brightness = last.brightnessPct; } else if (requestedMode === "colour") { const h = last.colour?.h ?? 0; const s1000 = last.colour?.s1000 ?? 1000; const v1000 = pctTo1000(last.brightnessPct); out.colour_data_v2 = JSON.stringify({ h, s: s1000, v: v1000 }); last.colour = { h, s1000, v1000 }; } haveAny = true; if (wantOn === undefined) wantOn = last.brightnessPct > 0; } // Brightness change + colour mode: update V and optionally strip brightness if (out.brightness != null && mode === "colour") { const h = last.colour?.h ?? 0; const s1000 = last.colour?.s1000 ?? 1000; const v1000 = pctTo1000(out.brightness); out.colour_data_v2 = JSON.stringify({ h, s: s1000, v: v1000 }); last.colour = { h, s1000, v1000 }; if (!keepBrightnessInColour) delete out.brightness; } // Final friendly fields if (wantOn !== undefined) { out.switch = !!wantOn; haveAny = true; } if (mode) out.mode = mode; // Persist if (out.mode) last.mode = out.mode; if (out.colour_data_v2) { try { const o=JSON.parse(out.colour_data_v2); last.colour = { h:o.h, s1000:o.s, v1000:o.v }; } catch{} } if (out.brightness != null) last.brightnessPct = out.brightness; if (out.temperature != null) last.temperature1000 = clamp(Math.round(out.temperature*10),0,1000); node.context().set('tuyaLast', last); // Output selection const format = (msg.format || "").toString().toLowerCase() || "payload"; if (!haveAny) { setStatus("no-op (nothing recognized)", false); send(msg); done(); return; } if (format === "commands") { const cmds = []; if (out.switch !== undefined) cmds.push({ code: "switch_led", value: !!out.switch }); if (out.mode) cmds.push({ code: "work_mode", value: out.mode }); const valB = out.brightness != null ? (scaleMode === "tuya" ? pctTo1000(out.brightness) : clamp(Math.round(out.brightness), 0, 100)) : null; const valT = out.temperature != null ? (scaleMode === "tuya" ? clamp(Math.round(out.temperature*10),0,1000) : clamp(Math.round(out.temperature), 0, 100)) : null; if (valB != null && out.mode !== "colour") cmds.push({ code: "bright_value_v2", value: valB }); if (out.mode === "white" && valT != null) cmds.push({ code: "temp_value_v2", value: valT }); if (out.mode === "colour" && out.colour_data_v2) cmds.push({ code: "colour_data_v2", value: out.colour_data_v2 }); delete msg.payload; msg.commands = cmds; setStatus(`commands ${cmds.length} codes (${scaleMode})` + (out.mode?` mode=${out.mode}`:"") + (out.switch!==undefined?` ${out.switch?'on':'off'}`:""), true); send(msg); done(); return; } // default: friendly payload delete msg.commands; msg.payload = out; setStatus(`payload (${scaleMode})` + (out.mode?` mode=${out.mode}`:"") + (out.switch!==undefined?` ${out.switch?'on':'off'}`:"") + (out.brightness!=null?` b=${out.brightness}`:"") + (out.mode==="white"&&out.temperature!=null?` t=${out.temperature}`:"") + (out.mode==="colour"&&out.colour_data_v2?` hsv`:""), true); send(msg); done(); } catch (err) { node.error(err.message || String(err), msg); setStatus("error", false); done(err); } }); } RED.nodes.registerType("tuya-adv", TuyaAdvNode); };