color light support in HA panel

This commit is contained in:
legop3
2026-01-27 19:29:06 -05:00
parent 7dbb657dcd
commit eea1774549
9 changed files with 424 additions and 135 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
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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D0OyPG8n.js"></script> <script type="module" crossorigin src="/assets/index-U-JdJMLL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVqWOsms.css"> <link rel="stylesheet" crossorigin href="/assets/index-C0HFz2ll.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -81,6 +81,16 @@ function loadEntityConfig() {
function buildState(meta, raw) { function buildState(meta, raw) {
if (!meta) return null; if (!meta) return null;
const name = meta.name || raw?.attributes?.friendly_name || meta.id; const name = meta.name || raw?.attributes?.friendly_name || meta.id;
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 supportsColor =
meta.type === 'light' &&
(rgbColor ||
hsColor ||
supportedColorModes.some((mode) => mode === 'hs' || mode === 'rgb' || mode === 'xy'));
if (!raw) { if (!raw) {
return { return {
id: meta.id, id: meta.id,
@@ -90,6 +100,11 @@ function buildState(meta, raw) {
available: false, available: false,
lastChanged: null, lastChanged: null,
lastUpdated: null, lastUpdated: null,
supportedColorModes,
colorMode: null,
rgbColor: null,
hsColor: null,
supportsColor,
}; };
} }
const rawState = raw.state; const rawState = raw.state;
@@ -103,6 +118,11 @@ function buildState(meta, raw) {
available: !unavailable, available: !unavailable,
lastChanged: raw.last_changed || null, lastChanged: raw.last_changed || null,
lastUpdated: raw.last_updated || null, lastUpdated: raw.last_updated || null,
supportedColorModes,
colorMode: raw?.attributes?.color_mode || null,
rgbColor,
hsColor,
supportsColor,
}; };
} }
@@ -219,6 +239,29 @@ async function toggleEntity(entityId) {
return setEntityState(entityId, nextState); return setEntityState(entityId, nextState);
} }
async function setLightColor(entityId, rgbColor) {
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 (!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)));
});
await callService(connection, 'light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
function getState() { function getState() {
const entities = Array.from(entityConfig.values()).map( const entities = Array.from(entityConfig.values()).map(
(meta) => entityState.get(meta.id) || buildState(meta, null), (meta) => entityState.get(meta.id) || buildState(meta, null),
@@ -265,11 +308,30 @@ io.on('connection', (socket) => {
cb({ error: err.message }); cb({ error: err.message });
} }
}); });
socket.on('homeAssistant:lightColor', async ({ entityId, rgbColor } = {}, cb = () => {}) => {
const mode = getMode();
if (
(mode === 'admin' && isAdmin(socket) !== true) ||
(mode === 'lockdown' && isLockdownAdmin(socket) !== true)
) {
return cb({ error: 'Insufficient permissions to control Home Assistant' });
}
try {
if (!entityId) throw new Error('entityId required');
await setLightColor(entityId, rgbColor);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
}); });
module.exports = { module.exports = {
getState, getState,
toggleEntity, toggleEntity,
setEntityState, setEntityState,
setLightColor,
homeAssistantEvents: events, homeAssistantEvents: events,
}; };
+166 -13
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useControlSystem } from '../controls/index.js'; import { useControlSystem } from '../controls/index.js';
import { formatKeyLabel } from '../controls/keymapUtils.js'; import { formatKeyLabel } from '../controls/keymapUtils.js';
@@ -11,42 +11,194 @@ function StatusBadge({ label, tone = 'muted' }) {
? 'bg-amber-900 text-amber-100' ? 'bg-amber-900 text-amber-100'
: 'bg-slate-800 text-slate-200'; : 'bg-slate-800 text-slate-200';
return ( return (
<span className={`rounded px-1 py-0.5 text-xs font-semibold leading-tight ${styles}`}> <span className={`rounded px-1 py-0.5 text-xs font-semibold leading-none ${styles}`}>
{label} {label}
</span> </span>
); );
} }
function EntityRow({ entity, connected, onToggle }) { function clampHue(value) {
if (!Number.isFinite(value)) return 0;
const wrapped = value % 360;
return wrapped < 0 ? wrapped + 360 : wrapped;
}
function rgbToHue(rgb) {
if (!Array.isArray(rgb) || rgb.length < 3) return 0;
const [rRaw, gRaw, bRaw] = rgb;
const r = Math.max(0, Math.min(255, Number(rRaw))) / 255;
const g = Math.max(0, Math.min(255, Number(gRaw))) / 255;
const b = Math.max(0, Math.min(255, Number(bRaw))) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const delta = max - min;
if (delta === 0) return 0;
let hue = 0;
if (max === r) {
hue = ((g - b) / delta) % 6;
} else if (max === g) {
hue = (b - r) / delta + 2;
} else {
hue = (r - g) / delta + 4;
}
return clampHue(Math.round(hue * 60));
}
function hueToRgb(hue) {
const h = clampHue(hue);
const c = 1;
const x = 1 - Math.abs(((h / 60) % 2) - 1);
let r = 0;
let g = 0;
let b = 0;
if (h < 60) {
r = c;
g = x;
} else if (h < 120) {
r = x;
g = c;
} else if (h < 180) {
g = c;
b = x;
} else if (h < 240) {
g = x;
b = c;
} else if (h < 300) {
r = x;
b = c;
} else {
r = c;
b = x;
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
function getEntityHue(entity) {
if (!entity) return 0;
if (Array.isArray(entity.hsColor)) {
return clampHue(Number(entity.hsColor[0]));
}
if (Array.isArray(entity.rgbColor)) {
return rgbToHue(entity.rgbColor);
}
return 0;
}
function EntityRow({ entity, connected, onToggle, onSetColor }) {
const unavailable = entity.state === 'unavailable' || !entity.available; const unavailable = entity.state === 'unavailable' || !entity.available;
const isOn = entity.state === 'on'; const isOn = entity.state === 'on';
const supportsColor = entity.type === 'light' && entity.supportsColor;
const statusTone = unavailable ? 'warn' : isOn ? 'success' : 'muted'; const statusTone = unavailable ? 'warn' : isOn ? 'success' : 'muted';
const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off'; const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off';
const disableToggle = !connected || unavailable; const disableToggle = !connected || unavailable;
const disableColor = disableToggle || !supportsColor;
const [hue, setHue] = useState(() => getEntityHue(entity));
const hueRef = useRef(hue);
const draggingRef = useRef(false);
const toneStyles = unavailable const toneStyles = unavailable
? 'border-slate-800 bg-slate-900 text-slate-400 cursor-not-allowed' ? 'border-slate-800 bg-slate-900 text-slate-400 cursor-not-allowed'
: isOn : isOn
? 'border-emerald-700 bg-emerald-900/80 text-emerald-50 hover:bg-emerald-800' ? 'border-emerald-700 bg-emerald-900/80 text-emerald-50 hover:bg-emerald-800'
: 'border-rose-800 bg-rose-900/80 text-rose-50 hover:bg-rose-800'; : 'border-rose-800 bg-rose-900/80 text-rose-50 hover:bg-rose-800';
useEffect(() => {
if (!supportsColor || draggingRef.current) return;
const nextHue = getEntityHue(entity);
hueRef.current = nextHue;
setHue(nextHue);
}, [entity.rgbColor, entity.hsColor, supportsColor]);
const handleHueChange = (event) => {
const nextHue = clampHue(Number(event.target.value));
hueRef.current = nextHue;
setHue(nextHue);
};
const commitHue = () => {
if (disableColor || !onSetColor) return;
onSetColor(entity.id, hueToRgb(hueRef.current));
};
const stopPropagation = (event) => {
event.stopPropagation();
};
const displayRgb = supportsColor ? hueToRgb(hueRef.current) : [255, 255, 255];
const displayColor = `rgb(${displayRgb.join(',')})`;
return ( return (
<button <button
type="button" type="button"
onClick={() => onToggle(entity.id)} onClick={() => onToggle(entity.id)}
disabled={disableToggle} disabled={disableToggle}
className={`flex min-w-[10rem] flex-1 items-center justify-between gap-0.5 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`} className={`relative flex min-w-[10rem] flex-1 items-start justify-between gap-0.5 rounded px-1 py-0.5 text-left transition-colors ${toneStyles} disabled:opacity-60 disabled:hover:bg-inherit`}
> >
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-0.5 text-sm"> <div className="flex items-center gap-0.5 text-sm leading-normal">
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span> <span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
{/* <StatusBadge label={entity.type === 'light' ? 'Light' : 'Switch'} /> */} {supportsColor && (
</div> <>
<div className="flex items-center gap-0.5 text-xs text-slate-400"> <StatusBadge label={statusLabel} tone={statusTone} />
<StatusBadge label={statusLabel} tone={statusTone} /> {!connected && <span className="text-xs text-amber-200">Offline</span>}
{!connected && <span className="text-amber-200"> · Offline</span>} </>
)}
</div> </div>
{supportsColor ? (
<div
className="-mt-0.5 w-full"
onClick={stopPropagation}
onPointerDown={(event) => {
stopPropagation(event);
draggingRef.current = true;
}}
onPointerUp={(event) => {
stopPropagation(event);
draggingRef.current = false;
commitHue();
}}
onPointerCancel={(event) => {
stopPropagation(event);
draggingRef.current = false;
commitHue();
}}
onKeyUp={(event) => {
stopPropagation(event);
commitHue();
}}
onBlur={commitHue}
>
<div className="relative w-full">
<input
type="range"
min="0"
max="360"
step="1"
value={hue}
onChange={handleHueChange}
disabled={disableColor}
aria-label={`Set color for ${entity.name || entity.id}`}
className="ha-hue-slider w-full"
/>
</div>
</div>
) : (
<div className="flex items-center gap-0.5 text-xs text-slate-400">
<StatusBadge label={statusLabel} tone={statusTone} />
{!connected && <span className="text-amber-200"> · Offline</span>}
</div>
)}
</div> </div>
<div className="text-xs font-semibold text-white/90">{isOn ? 'Turn off' : 'Turn on'}</div> {supportsColor && (
<span
className="pointer-events-none absolute right-1 top-1 h-2.5 w-2.5 rounded-full border border-white/60"
style={{ backgroundColor: displayColor }}
/>
)}
{!supportsColor && (
<div className="self-center text-xs font-semibold text-white/90">
{isOn ? 'Turn off' : 'Turn on'}
</div>
)}
</button> </button>
); );
} }
@@ -55,7 +207,7 @@ export default function HomeAssistantControls() {
const { const {
state: { keymap }, state: { keymap },
} = useControlSystem(); } = useControlSystem();
const { session, homeAssistantToggle } = useSession(); const { session, homeAssistantToggle, homeAssistantSetLightColor } = useSession();
const ha = session?.homeAssistant; const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]); const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]); const onKeyLabel = formatKeyLabel(keymap?.homeAssistantOn?.[0]);
@@ -107,6 +259,7 @@ export default function HomeAssistantControls() {
entity={entity} entity={entity}
connected={connected} connected={connected}
onToggle={homeAssistantToggle} onToggle={homeAssistantToggle}
onSetColor={homeAssistantSetLightColor}
/> />
))} ))}
</div> </div>
+3
View File
@@ -15,6 +15,7 @@ const SessionContext = createContext({
subscribeAll: async () => {}, subscribeAll: async () => {},
homeAssistantToggle: async () => {}, homeAssistantToggle: async () => {},
homeAssistantSetState: async () => {}, homeAssistantSetState: async () => {},
homeAssistantSetLightColor: async () => {},
setNickname: async () => {}, setNickname: async () => {},
triggerReplay: async () => {}, triggerReplay: async () => {},
setCommunityGoal: async () => {}, setCommunityGoal: async () => {},
@@ -109,6 +110,8 @@ export function SessionProvider({ children }) {
homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }), homeAssistantToggle: (entityId) => emitWithAck('homeAssistant:toggle', { entityId }),
homeAssistantSetState: (entityId, state) => homeAssistantSetState: (entityId, state) =>
emitWithAck('homeAssistant:setState', { entityId, state }), emitWithAck('homeAssistant:setState', { entityId, state }),
homeAssistantSetLightColor: (entityId, rgbColor) =>
emitWithAck('homeAssistant:lightColor', { entityId, rgbColor }),
setNickname: (nickname) => emitWithAck('nickname:set', { nickname }), setNickname: (nickname) => emitWithAck('nickname:set', { nickname }),
triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }), triggerReplay: (sources = []) => emitWithAck('replay:trigger', { sources }),
setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }), setCommunityGoal: (text) => emitWithAck('communityGoal:set', { text }),
+71
View File
@@ -137,3 +137,74 @@ body {
-webkit-touch-callout: none; -webkit-touch-callout: none;
} }
} }
.ha-hue-slider {
appearance: none;
width: 100%;
height: 0.45rem;
border-radius: 9999px;
background: linear-gradient(
90deg,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
cursor: pointer;
}
.ha-hue-slider:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.ha-hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: #ffffff;
}
.ha-hue-slider::-webkit-slider-runnable-track {
height: 0.45rem;
border-radius: 9999px;
background: linear-gradient(
90deg,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
}
.ha-hue-slider::-moz-range-thumb {
width: 0.75rem;
height: 0.75rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.7);
background: #ffffff;
}
.ha-hue-slider::-moz-range-track {
height: 0.45rem;
border-radius: 9999px;
background: linear-gradient(
90deg,
#ff0000,
#ffff00,
#00ff00,
#00ffff,
#0000ff,
#ff00ff,
#ff0000
);
}