mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better RGB light stuff, simpler on the frontend.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user