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