better RGB light stuff, simpler on the frontend.

This commit is contained in:
legop3
2026-06-05 14:02:05 -04:00
parent 0fe060cd22
commit 41b59058bc
9 changed files with 180 additions and 99 deletions
@@ -50,14 +50,90 @@ function normalizeTriggerEntry(entry, index) {
};
}
function clampByte(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(0, Math.min(255, Math.round(numeric)));
}
function byteToHex(value) {
return clampByte(value).toString(16).padStart(2, '0');
}
function rgbToHex(rgb) {
if (!Array.isArray(rgb) || rgb.length < 3) return null;
return `#${byteToHex(rgb[0])}${byteToHex(rgb[1])}${byteToHex(rgb[2])}`;
}
function hsToRgb(hsColor) {
if (!Array.isArray(hsColor) || hsColor.length < 2) return null;
const rawHue = Number(hsColor[0]);
const rawSaturation = Number(hsColor[1]);
if (!Number.isFinite(rawHue) || !Number.isFinite(rawSaturation)) return null;
// Home Assistant's hs_color is hue in degrees plus saturation as 0-100.
// Brightness is not part of this attribute, so the UI color preview uses full
// value. When HA also provides rgb_color, rgb_color wins because it carries
// the already-resolved device color.
const hue = ((rawHue % 360) + 360) % 360;
const saturation = Math.max(0, Math.min(100, rawSaturation)) / 100;
const value = 1;
const chroma = value * saturation;
const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1));
const m = value - chroma;
let r = 0;
let g = 0;
let b = 0;
if (hue < 60) {
r = chroma;
g = x;
} else if (hue < 120) {
r = x;
g = chroma;
} else if (hue < 180) {
g = chroma;
b = x;
} else if (hue < 240) {
g = x;
b = chroma;
} else if (hue < 300) {
r = x;
b = chroma;
} else {
r = chroma;
b = x;
}
return [
clampByte((r + m) * 255),
clampByte((g + m) * 255),
clampByte((b + m) * 255),
];
}
function colorHexFromAttributes(attributes = {}) {
const rgbColor = Array.isArray(attributes.rgb_color) ? attributes.rgb_color : null;
if (rgbColor) {
return rgbToHex(rgbColor);
}
const hsColor = Array.isArray(attributes.hs_color) ? attributes.hs_color : null;
if (hsColor) {
return rgbToHex(hsToRgb(hsColor));
}
return null;
}
function buildState(meta, raw) {
if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id;
const attributes = raw?.attributes || {};
const supportedColorModes = Array.isArray(raw?.attributes?.supported_color_modes)
? raw.attributes.supported_color_modes.map((mode) => String(mode))
: [];
const rgbColor = Array.isArray(raw?.attributes?.rgb_color) ? raw.attributes.rgb_color : null;
const hsColor = Array.isArray(raw?.attributes?.hs_color) ? raw.attributes.hs_color : null;
const rgbColor = Array.isArray(attributes.rgb_color) ? attributes.rgb_color : null;
const hsColor = Array.isArray(attributes.hs_color) ? attributes.hs_color : null;
const colorHex = colorHexFromAttributes(attributes);
const supportsColor =
meta.type === 'light' &&
(rgbColor || hsColor || supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
@@ -73,6 +149,7 @@ function buildState(meta, raw) {
lastUpdated: null,
supportedColorModes,
colorMode: null,
colorHex: null,
rgbColor: null,
hsColor: null,
supportsColor,
@@ -91,7 +168,8 @@ function buildState(meta, raw) {
lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null,
supportedColorModes,
colorMode: raw?.attributes?.color_mode || null,
colorMode: attributes.color_mode || null,
colorHex,
rgbColor,
hsColor,
supportsColor,
@@ -67,7 +67,7 @@ function registerHomeAssistantHooks(deps) {
}
});
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
socket.on('homeAssistant:lightColor', async ({ entityId, colorHex, rgbColor } = {}, cb = () => {}) => {
if (!hasPermission()) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
@@ -76,7 +76,10 @@ function registerHomeAssistantHooks(deps) {
}
try {
if (!entityId) throw new Error('entityId required');
await setLightColor(entityId, rgbColor);
// New browser clients send colorHex because it is directly usable by
// React/CSS. rgbColor remains accepted for compatibility with any older
// client that still sends Home Assistant-style RGB arrays.
await setLightColor(entityId, colorHex ?? rgbColor);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
@@ -15,6 +15,68 @@ const {
} = require('./state');
const { normalizeConfigEntry, normalizeTriggerEntry, buildState } = require('./entityHelpers');
function arrayValuesEqual(left, right) {
if (left === right) return true;
if (!Array.isArray(left) || !Array.isArray(right)) return false;
if (left.length !== right.length) return false;
return left.every((value, index) => String(value) === String(right[index]));
}
function entityStateChanged(prev, next) {
if (!prev) return true;
// Home Assistant color changes often update attributes and last_updated
// without changing the entity's on/off state or last_changed timestamp. The
// web UI depends on those attribute updates for lamp tile colors, so the HA
// runtime must treat them as real sync-worthy changes.
return (
prev.state !== next.state ||
prev.available !== next.available ||
prev.lastChanged !== next.lastChanged ||
prev.lastUpdated !== next.lastUpdated ||
prev.colorMode !== next.colorMode ||
prev.colorHex !== next.colorHex ||
prev.supportsColor !== next.supportsColor ||
!arrayValuesEqual(prev.supportedColorModes, next.supportedColorModes) ||
!arrayValuesEqual(prev.rgbColor, next.rgbColor) ||
!arrayValuesEqual(prev.hsColor, next.hsColor)
);
}
function hexToRgbColor(hex) {
const raw = String(hex || '').trim();
const normalized = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(normalized)) return null;
const expanded =
normalized.length === 3
? normalized
.split('')
.map((char) => char + char)
.join('')
: normalized;
return [
Number.parseInt(expanded.slice(0, 2), 16),
Number.parseInt(expanded.slice(2, 4), 16),
Number.parseInt(expanded.slice(4, 6), 16),
];
}
function normalizeRgbColor(color) {
const rawColor = typeof color === 'string' ? hexToRgbColor(color) : color;
if (!Array.isArray(rawColor) || rawColor.length !== 3) {
throw new Error('rgbColor hex string or [r,g,b] array required');
}
// The browser now sends hex because it is the simplest React/CSS format, but
// accepting arrays keeps older clients and internal callers working. Home
// Assistant still wants rgb_color, so the final conversion happens here.
return rawColor.map((value) => {
const next = Number(value);
if (Number.isNaN(next)) return 0;
return Math.max(0, Math.min(255, Math.round(next)));
});
}
function createRuntimeEngine(deps) {
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
@@ -316,7 +378,7 @@ function createRuntimeEngine(deps) {
const raw = snapshot[id];
const next = buildState(meta, raw);
const prev = entityState.get(id);
if (!prev || prev.state !== next.state || prev.available !== next.available || prev.lastChanged !== next.lastChanged) {
if (entityStateChanged(prev, next)) {
entityState.set(id, next);
changed = true;
}
@@ -332,18 +394,13 @@ function createRuntimeEngine(deps) {
return setEntityState(entityId, nextState, { source });
}
async function setLightColor(entityId, rgbColor) {
async function setLightColor(entityId, color) {
if (!enabled) throw new Error('Home Assistant not configured');
const meta = entityConfig.get(entityId);
if (!meta || meta.type !== 'light') throw new Error('Home Assistant light required');
if (!runtime.connection) throw new Error('Home Assistant not connected');
if (!Array.isArray(rgbColor) || rgbColor.length !== 3) throw new Error('rgbColor required');
const normalized = rgbColor.map((value) => {
const next = Number(value);
if (Number.isNaN(next)) return 0;
return Math.max(0, Math.min(255, Math.round(next)));
});
const normalized = normalizeRgbColor(color);
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}