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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-CXMfqWm7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cmi0595H.css">
<script type="module" crossorigin src="/assets/index-DxjZG9qS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D6X9qUts.css">
</head>
<body>
<div id="root"></div>
@@ -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 });
}