mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
184 lines
5.5 KiB
JavaScript
184 lines
5.5 KiB
JavaScript
// Home Assistant Entity Helpers
|
|
// Purpose: Normalizes Home Assistant entity/button config and converts raw snapshots into stable UI state records.
|
|
// Scope: Provides pure helper logic for config loading, entity typing, and state-shape generation.
|
|
function inferType(entityId, explicitType) {
|
|
if (explicitType === 'light' || explicitType === 'switch') {
|
|
return explicitType;
|
|
}
|
|
const domain = String(entityId || '').split('.')[0];
|
|
if (domain === 'light') return 'light';
|
|
return 'switch';
|
|
}
|
|
|
|
function inferDomain(entityId) {
|
|
const domain = String(entityId || '').split('.')[0].trim().toLowerCase();
|
|
return domain || 'switch';
|
|
}
|
|
|
|
function normalizeConfigEntry(entry) {
|
|
if (!entry) return null;
|
|
const id = entry.id || entry.entityId || entry.entity_id;
|
|
if (!id) return null;
|
|
const type = inferType(id, entry.type);
|
|
const domain = inferDomain(id);
|
|
const name = entry.name || null;
|
|
return { id: String(id), name, type, domain };
|
|
}
|
|
|
|
function normalizeTriggerEntry(entry, index) {
|
|
if (!entry || typeof entry !== 'object') return null;
|
|
const action = String(entry.action || '').trim();
|
|
if (!action) return null;
|
|
const entityId = String(entry.entityId || entry.entity_id || '').trim();
|
|
if (!entityId) return null;
|
|
const stateEqualsRaw = entry.stateEquals ?? entry.state_equals;
|
|
const stateEquals =
|
|
stateEqualsRaw === null || stateEqualsRaw === undefined ? null : String(stateEqualsRaw).trim();
|
|
const runtimeKey = `${action}::entity::${entityId}::${stateEquals || '*'}::${index}`;
|
|
const cooldownMs = Number.isFinite(Number(entry.cooldownMs)) ? Math.max(0, Number(entry.cooldownMs)) : 0;
|
|
const allowedModes = Array.isArray(entry.allowedModes)
|
|
? entry.allowedModes.map((mode) => String(mode || '').trim().toLowerCase()).filter(Boolean)
|
|
: null;
|
|
return {
|
|
runtimeKey,
|
|
entityId,
|
|
action,
|
|
stateEquals,
|
|
payload: entry.payload && typeof entry.payload === 'object' ? entry.payload : {},
|
|
cooldownMs,
|
|
allowedModes,
|
|
};
|
|
}
|
|
|
|
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(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'));
|
|
|
|
if (!raw) {
|
|
return {
|
|
id: meta.id,
|
|
name,
|
|
type: meta.type,
|
|
state: 'unknown',
|
|
available: false,
|
|
lastChanged: null,
|
|
lastUpdated: null,
|
|
supportedColorModes,
|
|
colorMode: null,
|
|
colorHex: null,
|
|
rgbColor: null,
|
|
hsColor: null,
|
|
supportsColor,
|
|
};
|
|
}
|
|
|
|
const rawState = raw.state;
|
|
const unavailable = rawState === 'unavailable' || rawState === 'unknown';
|
|
const state = unavailable ? 'unavailable' : rawState === 'on' ? 'on' : 'off';
|
|
return {
|
|
id: meta.id,
|
|
name,
|
|
type: meta.type,
|
|
state,
|
|
available: !unavailable,
|
|
lastChanged: raw.last_changed || null,
|
|
lastUpdated: raw.last_updated || null,
|
|
supportedColorModes,
|
|
colorMode: attributes.color_mode || null,
|
|
colorHex,
|
|
rgbColor,
|
|
hsColor,
|
|
supportsColor,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
normalizeConfigEntry,
|
|
normalizeTriggerEntry,
|
|
buildState,
|
|
};
|