mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
new home assistant light buttons yay
This commit is contained in:
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
@@ -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="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Ctz62ozz.js"></script>
|
<script type="module" crossorigin src="/assets/index-B5kEZ6--.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Ci1EF4ZU.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cj_nGPpn.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -134,6 +134,89 @@ function createRuntimeEngine(deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setEntityLockedOnWhite(entityId, options = {}) {
|
||||||
|
const meta = entityConfig.get(entityId);
|
||||||
|
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
||||||
|
|
||||||
|
// Lock-on is meant to make the real room visibly lit, so outlet-backed lamps
|
||||||
|
// still only need a plain turn_on command. Home Assistant exposes those as
|
||||||
|
// switch entities even though the user-facing object is a lamp.
|
||||||
|
if (!meta || meta.type !== 'light') {
|
||||||
|
return setEntityState(entityId, 'on', { source: `${source}:switch-on` });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Color-capable lights should be forced to white during the lock so the
|
||||||
|
// privacy/safety state is visually predictable instead of preserving the
|
||||||
|
// previous red/blue/etc. color. The white command also turns the light on.
|
||||||
|
await setLightWhite(entityId, haConfig?.whiteKelvin);
|
||||||
|
return { entityId, mode: 'white' };
|
||||||
|
} catch (err) {
|
||||||
|
// Some Home Assistant light integrations report themselves as lights but
|
||||||
|
// reject color temperature. Falling back to a plain turn_on preserves the
|
||||||
|
// most important part of the lock behavior: every lamp is still on.
|
||||||
|
logger.warn('Home Assistant lock white command failed; falling back to plain on', {
|
||||||
|
entityId,
|
||||||
|
error: err.message,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
await setEntityState(entityId, 'on', { source: `${source}:white-fallback-on` });
|
||||||
|
return { entityId, mode: 'fallback-on', whiteError: err.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAllControllableEntitiesLockedOnWhite(options = {}) {
|
||||||
|
const source = String(options?.source || 'homeAssistant:setAllControllableEntitiesLockedOnWhite');
|
||||||
|
const ids = getControllableEntityIds();
|
||||||
|
if (!ids.length) {
|
||||||
|
logger.warn('Home Assistant lock-on-white skipped; no controllable entities configured', {
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
desiredState: 'on',
|
||||||
|
source,
|
||||||
|
total: 0,
|
||||||
|
succeeded: [],
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Issuing Home Assistant lock-on-white update', {
|
||||||
|
source,
|
||||||
|
total: ids.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Each entity is settled independently because one flaky bulb should not
|
||||||
|
// prevent the rest of the room from entering the locked-on state.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
ids.map((id) => setEntityLockedOnWhite(id, { source: `${source}:bulk` })),
|
||||||
|
);
|
||||||
|
const failures = results
|
||||||
|
.map((result, index) => ({ result, entityId: ids[index] }))
|
||||||
|
.filter(({ result }) => result.status === 'rejected')
|
||||||
|
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
||||||
|
const succeeded = results
|
||||||
|
.map((result, index) => ({ result, entityId: ids[index] }))
|
||||||
|
.filter(({ result }) => result.status === 'fulfilled')
|
||||||
|
.map(({ entityId }) => entityId);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
logger.warn('Some Home Assistant lock-on-white updates failed', {
|
||||||
|
total: ids.length,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
desiredState: 'on',
|
||||||
|
source,
|
||||||
|
total: ids.length,
|
||||||
|
succeeded,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function triggerMatches(trigger, raw, runtimeState) {
|
function triggerMatches(trigger, raw, runtimeState) {
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const nextState = raw?.state ?? null;
|
const nextState = raw?.state ?? null;
|
||||||
@@ -293,9 +376,18 @@ function createRuntimeEngine(deps) {
|
|||||||
|
|
||||||
if (runtime.lightsLockState != null) {
|
if (runtime.lightsLockState != null) {
|
||||||
if ((changed || forceApply) && enabled) {
|
if ((changed || forceApply) && enabled) {
|
||||||
await setAllControllableEntitiesState(runtime.lightsLockState, {
|
const source = String(options?.source || 'homeAssistant:setLightsLockedOn');
|
||||||
source: String(options?.source || 'homeAssistant:setLightsLockedOn'),
|
if (runtime.lightsLockState === 'on') {
|
||||||
});
|
// The lock-on path is intentionally stronger than a normal bulk
|
||||||
|
// turn_on. It makes actual light entities white while still turning
|
||||||
|
// outlet-backed lamp switches on, matching the user's "light lock"
|
||||||
|
// mental model for the room.
|
||||||
|
await setAllControllableEntitiesLockedOnWhite({ source });
|
||||||
|
} else {
|
||||||
|
await setAllControllableEntitiesState(runtime.lightsLockState, {
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
evaluateLightAutomation();
|
evaluateLightAutomation();
|
||||||
@@ -337,6 +429,7 @@ function createRuntimeEngine(deps) {
|
|||||||
setLightColor,
|
setLightColor,
|
||||||
setLightWhite,
|
setLightWhite,
|
||||||
setAllControllableEntitiesState,
|
setAllControllableEntitiesState,
|
||||||
|
setAllControllableEntitiesLockedOnWhite,
|
||||||
setLightsLockedOn,
|
setLightsLockedOn,
|
||||||
toggleLightsLockedOn,
|
toggleLightsLockedOn,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
// Home Assistant Controls
|
// Home Assistant Controls
|
||||||
// Purpose: Defines the Home Assistant Controls module and the local helpers/components used in this file.
|
// Purpose: Defines the Home Assistant Controls module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } 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';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
|
const COLOR_SWATCHES = Object.freeze([
|
||||||
|
{ id: 'white', label: 'White', rgb: [255, 255, 255], action: 'white' },
|
||||||
|
{ id: 'red', label: 'Red', rgb: [255, 0, 0] },
|
||||||
|
{ id: 'green', label: 'Green', rgb: [0, 255, 0] },
|
||||||
|
{ id: 'cyan', label: 'Cyan', rgb: [0, 199, 190] },
|
||||||
|
{ id: 'blue', label: 'Blue', rgb: [0, 0, 255] },
|
||||||
|
{ id: 'purple', label: 'Purple', rgb: [191, 90, 242] },
|
||||||
|
]);
|
||||||
|
|
||||||
function StatusBadge({ label, tone = 'muted' }) {
|
function StatusBadge({ label, tone = 'muted' }) {
|
||||||
const styles =
|
const styles =
|
||||||
tone === 'success'
|
tone === 'success'
|
||||||
@@ -21,33 +30,16 @@ function StatusBadge({ label, tone = 'muted' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cx(...values) {
|
||||||
|
return values.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
function clampHue(value) {
|
function clampHue(value) {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
const wrapped = value % 360;
|
const wrapped = value % 360;
|
||||||
return wrapped < 0 ? wrapped + 360 : wrapped;
|
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) {
|
function hueToRgb(hue) {
|
||||||
const h = clampHue(hue);
|
const h = clampHue(hue);
|
||||||
const c = 1;
|
const c = 1;
|
||||||
@@ -77,18 +69,101 @@ function hueToRgb(hue) {
|
|||||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEntityHue(entity) {
|
function getEntityRgb(entity) {
|
||||||
if (!entity) return 0;
|
if (!entity) return null;
|
||||||
if (Array.isArray(entity.hsColor)) {
|
if (Array.isArray(entity.rgbColor) && entity.rgbColor.length >= 3) {
|
||||||
return clampHue(Number(entity.hsColor[0]));
|
return entity.rgbColor.slice(0, 3).map((value) => {
|
||||||
|
const next = Number(value);
|
||||||
|
return Number.isFinite(next) ? Math.max(0, Math.min(255, Math.round(next))) : 0;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (Array.isArray(entity.rgbColor)) {
|
if (Array.isArray(entity.hsColor) && entity.hsColor.length >= 1) {
|
||||||
return rgbToHue(entity.rgbColor);
|
return hueToRgb(entity.hsColor[0]);
|
||||||
}
|
}
|
||||||
return 0;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntityRow({ entity, connected, controlsLocked, onToggle, onSetColor, onSetWhite }) {
|
function rgbToCss(rgb) {
|
||||||
|
return `rgb(${rgb.join(',')})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rgbLuminance(rgb) {
|
||||||
|
const [r, g, b] = rgb.map((value) => Number(value) / 255);
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swatchMatchesEntity(entity, swatch) {
|
||||||
|
const current = getEntityRgb(entity);
|
||||||
|
if (!current || !Array.isArray(swatch.rgb)) return false;
|
||||||
|
|
||||||
|
// Exact Home Assistant color values vary by bulb and color space, so use a
|
||||||
|
// small distance threshold. This keeps the selected swatch useful without
|
||||||
|
// pretending bulbs report the same RGB values that the UI originally sent.
|
||||||
|
const distance = Math.sqrt(
|
||||||
|
current.reduce((sum, value, index) => sum + (value - swatch.rgb[index]) ** 2, 0),
|
||||||
|
);
|
||||||
|
return distance < 55;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTileStyle(entity, { unavailable, isOn, supportsColor }) {
|
||||||
|
if (unavailable) return undefined;
|
||||||
|
if (!isOn) return undefined;
|
||||||
|
const rgb = supportsColor ? getEntityRgb(entity) : null;
|
||||||
|
if (!rgb) return undefined;
|
||||||
|
|
||||||
|
// Colored lights use their actual current color as the tile fill. Switches
|
||||||
|
// and non-color lights deliberately stay on the neutral Tailwind classes
|
||||||
|
// because their real-world lamps do not expose a useful color value.
|
||||||
|
return {
|
||||||
|
backgroundColor: rgbToCss(rgb),
|
||||||
|
borderColor: 'rgba(255,255,255,0.38)',
|
||||||
|
color: rgbLuminance(rgb) > 0.62 ? '#111827' : '#ffffff',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function LampSwatch({ entity, swatch, disabled, onSetColor, onSetWhite }) {
|
||||||
|
const selected = entity.state === 'on' && swatchMatchesEntity(entity, swatch);
|
||||||
|
|
||||||
|
const handleClick = (event) => {
|
||||||
|
// Swatches live inside a tile that toggles the lamp when clicked. Stop the
|
||||||
|
// event here so choosing a color is a single, unambiguous action instead of
|
||||||
|
// setting color and then also toggling the lamp off.
|
||||||
|
event.stopPropagation();
|
||||||
|
if (disabled) return;
|
||||||
|
|
||||||
|
// White gets its own server action because Home Assistant white/temperature
|
||||||
|
// mode is not the same as sending pure RGB white on many smart bulbs.
|
||||||
|
if (swatch.action === 'white') {
|
||||||
|
onSetWhite(entity.id).catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSetColor(entity.id, swatch.rgb).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
// Keyboard activation on a swatch should follow the same isolation rule
|
||||||
|
// as pointer clicks: the swatch owns color selection, the tile owns
|
||||||
|
// lamp toggling.
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
title={`${swatch.label} ${entity.name || entity.id}`}
|
||||||
|
aria-label={`Set ${entity.name || entity.id} to ${swatch.label}`}
|
||||||
|
className={cx(
|
||||||
|
'h-5 w-6 shrink-0 rounded-sm border transition-transform disabled:cursor-not-allowed disabled:opacity-40',
|
||||||
|
selected ? 'border-white ring-1 ring-white/80' : 'border-white/35',
|
||||||
|
!disabled && 'hover:scale-110 hover:border-white',
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: rgbToCss(swatch.rgb) }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onSetWhite }) {
|
||||||
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 supportsColor = entity.type === 'light' && entity.supportsColor;
|
||||||
@@ -96,147 +171,73 @@ function EntityRow({ entity, connected, controlsLocked, onToggle, onSetColor, on
|
|||||||
const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off';
|
const statusLabel = unavailable ? 'Unavailable' : isOn ? 'On' : 'Off';
|
||||||
const disableToggle = controlsLocked || !connected || unavailable;
|
const disableToggle = controlsLocked || !connected || unavailable;
|
||||||
const disableColor = disableToggle || !supportsColor;
|
const disableColor = disableToggle || !supportsColor;
|
||||||
const [hue, setHue] = useState(() => getEntityHue(entity));
|
const tileStyle = buildTileStyle(entity, { unavailable, isOn, supportsColor });
|
||||||
const hueRef = useRef(hue);
|
const tileTone = unavailable
|
||||||
const draggingRef = useRef(false);
|
? 'border-slate-800 bg-slate-950 text-slate-500'
|
||||||
const toneStyles = unavailable
|
|
||||||
? 'border-slate-800 bg-slate-900 text-slate-400 cursor-not-allowed'
|
|
||||||
: controlsLocked
|
: controlsLocked
|
||||||
? 'border-slate-800 bg-slate-900 text-slate-300 cursor-not-allowed'
|
? 'border-amber-700/60 bg-amber-950/70 text-amber-100'
|
||||||
: isOn
|
: isOn
|
||||||
? 'border-emerald-700 bg-emerald-900 text-emerald-50 hover:bg-emerald-800'
|
? 'border-emerald-700/70 bg-emerald-900 text-white'
|
||||||
: 'border-rose-800 bg-rose-900 text-rose-50 hover:bg-rose-800';
|
: 'border-neutral-700 bg-neutral-950 text-slate-300';
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTileToggle = () => {
|
||||||
if (!supportsColor || draggingRef.current) return;
|
if (disableToggle) return;
|
||||||
const nextHue = getEntityHue(entity);
|
onToggle(entity.id);
|
||||||
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 = () => {
|
const handleTileKeyDown = (event) => {
|
||||||
if (disableColor || !onSetColor) return;
|
// The outer element is intentionally the interactive tile because HTML
|
||||||
onSetColor(entity.id, hueToRgb(hueRef.current));
|
// cannot validly nest the color swatch buttons inside another button. Giving
|
||||||
|
// the tile button-like keyboard behavior keeps the larger click target
|
||||||
|
// accessible while preserving real buttons for swatches.
|
||||||
|
if (disableToggle) return;
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleTileToggle();
|
||||||
};
|
};
|
||||||
|
|
||||||
const stopPropagation = (event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSetWhite = (event) => {
|
|
||||||
stopPropagation(event);
|
|
||||||
if (disableColor || !onSetWhite) return;
|
|
||||||
onSetWhite(entity.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayRgb = supportsColor ? hueToRgb(hueRef.current) : [255, 255, 255];
|
|
||||||
const displayColor = `rgb(${displayRgb.join(',')})`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
type="button"
|
role="button"
|
||||||
onClick={() => onToggle(entity.id)}
|
tabIndex={disableToggle ? -1 : 0}
|
||||||
disabled={disableToggle}
|
onClick={handleTileToggle}
|
||||||
className={`relative flex min-w-[12rem] flex-[1_1_12rem] 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`}
|
onKeyDown={handleTileKeyDown}
|
||||||
|
aria-disabled={disableToggle}
|
||||||
|
className={cx(
|
||||||
|
// The parent grid owns row and column sizing so the room-controls panel
|
||||||
|
// never sprawls wider than three lamps per row. The tile only keeps a
|
||||||
|
// small minimum width so names and swatches remain readable.
|
||||||
|
'flex min-w-[9.5rem] flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
|
||||||
|
disableToggle ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:border-white/60',
|
||||||
|
tileTone,
|
||||||
|
)}
|
||||||
|
style={tileStyle}
|
||||||
|
title={`${isOn ? 'Turn off' : 'Turn on'} ${entity.name || entity.id}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="grid min-h-5 grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5 text-left">
|
||||||
<div className="flex items-center gap-0.5 text-sm leading-normal">
|
<span className="min-w-0 truncate text-[0.78rem] font-semibold leading-none">
|
||||||
<span className="truncate font-semibold text-white">{entity.name || entity.id}</span>
|
{entity.name || entity.id}
|
||||||
{supportsColor && (
|
</span>
|
||||||
<>
|
<span className="flex items-center gap-0.5">
|
||||||
<StatusBadge label={statusLabel} tone={statusTone} />
|
<StatusBadge label={statusLabel} tone={statusTone} />
|
||||||
{!connected && <span className="text-xs text-amber-200">Offline</span>}
|
{!connected ? <span className="text-[0.65rem] font-semibold text-amber-200">Offline</span> : null}
|
||||||
</>
|
</span>
|
||||||
)}
|
|
||||||
</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>
|
||||||
{supportsColor && (
|
{supportsColor ? (
|
||||||
<div className="absolute right-1 top-1 flex items-center gap-0.5">
|
<div className="flex flex-wrap justify-center gap-0.5">
|
||||||
<span
|
{COLOR_SWATCHES.map((swatch) => (
|
||||||
role="button"
|
<LampSwatch
|
||||||
tabIndex={disableColor ? -1 : 0}
|
key={swatch.id}
|
||||||
onClick={handleSetWhite}
|
entity={entity}
|
||||||
onPointerDown={stopPropagation}
|
swatch={swatch}
|
||||||
onKeyDown={(event) => {
|
disabled={disableColor}
|
||||||
if (disableColor) return;
|
onSetColor={onSetColor}
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
onSetWhite={onSetWhite}
|
||||||
event.preventDefault();
|
/>
|
||||||
handleSetWhite(event);
|
))}
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Set white color temperature"
|
|
||||||
aria-label={`Set ${entity.name || entity.id} to white`}
|
|
||||||
aria-disabled={disableColor}
|
|
||||||
className={`inline-flex items-center justify-center rounded border border-slate-200 bg-white px-1 py-0.5 text-xs font-semibold leading-none text-slate-900 ${
|
|
||||||
disableColor
|
|
||||||
? 'cursor-not-allowed opacity-60'
|
|
||||||
: 'cursor-pointer hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
white
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="pointer-events-none h-2.5 w-2.5 rounded-full border border-white/60"
|
|
||||||
style={{ backgroundColor: displayColor }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
{!supportsColor && (
|
</div>
|
||||||
<div className="self-center text-xs font-semibold text-white/90">
|
|
||||||
{isOn ? 'Turn off' : 'Turn on'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,9 +299,9 @@ export default function HomeAssistantControls() {
|
|||||||
: 'Lights are locked on. Room controls are disabled.'}
|
: 'Lights are locked on. Room controls are disabled.'}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap gap-0.5">
|
<div className="grid grid-cols-1 gap-0.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{entities.map((entity) => (
|
{entities.map((entity) => (
|
||||||
<EntityRow
|
<LampTile
|
||||||
key={entity.id}
|
key={entity.id}
|
||||||
entity={entity}
|
entity={entity}
|
||||||
connected={connected}
|
connected={connected}
|
||||||
|
|||||||
@@ -152,74 +152,3 @@ 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user