mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 16:38:31 +00:00
feat v1.27.0: RGB light colors + red alarm pulse (issue #3)
- lightColorOf(): on+rgb_color tints icon/glow/ripple (explicit ripple color wins); brightness ignored by design; off/white/unavailable unchanged - isAlarmState(): leak/smoke/gas/CO/safety/tamper/problem sensors and sirens in 'on' pulse a red ring over any display mode; outages never alarm; prefers-reduced-motion honoured - +2 unit tests, smoke_rgb_alarm.mjs, TESTING.md rows
This commit is contained in:
+12
-5
@@ -17,7 +17,7 @@ import {
|
||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||
snapToWall, openingAmount,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon,
|
||||
stateIcon, lightColorOf, isAlarmState,
|
||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
@@ -32,7 +32,7 @@ import './space-card';
|
||||
import { cardStyles } from './styles';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.26.0';
|
||||
const CARD_VERSION = '1.27.0';
|
||||
const LS_KEY = 'houseplan_card_layout_v1';
|
||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||
@@ -2537,10 +2537,15 @@ class HouseplanCard extends LitElement {
|
||||
: null)
|
||||
: null;
|
||||
// live state variants of the auto icon (doors, locks, bulbs), like core HA
|
||||
const domain = d.primary ? d.primary.split('.')[0] : null;
|
||||
const icon = this._config?.live_states
|
||||
? stateIcon(d.icon, d.primary ? d.primary.split('.')[0] : null,
|
||||
primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
|
||||
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
|
||||
: d.icon;
|
||||
// RGB lights color the icon (and the ripple, unless a custom ripple color is set)
|
||||
const lightC = this._config?.live_states && domain === 'light' ? lightColorOf(primarySt) : null;
|
||||
// emergencies (leak/smoke/gas/CO/siren) pulse red regardless of display mode
|
||||
const alarm = this._config?.live_states
|
||||
&& isAlarmState(domain, primarySt?.attributes?.device_class, primarySt?.state);
|
||||
const active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
|
||||
const angle = Number(m?.angle) || 0;
|
||||
@@ -2550,9 +2555,11 @@ class HouseplanCard extends LitElement {
|
||||
if (ripple) {
|
||||
st.push(`--ripple-scale:${rScale}`);
|
||||
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
|
||||
else if (lightC) st.push(`--ripple-color:${lightC}`);
|
||||
}
|
||||
if (lightC) st.push(`--light-color:${lightC}`);
|
||||
return html`<div
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''}"
|
||||
class="dev ${cls} ${this._selId === d.id ? 'sel' : ''} ${d.virtual ? 'virtual' : ''} ${disp === 'ripple' ? 'noicon' : ''} ${valText != null ? 'valonly' : ''} ${lightC ? 'rgb' : ''} ${alarm ? 'alarm' : ''}"
|
||||
style="${st.join(';')}"
|
||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||
@mousemove=${(e: MouseEvent) =>
|
||||
|
||||
@@ -674,3 +674,37 @@ export function stateIcon(
|
||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||
return base;
|
||||
}
|
||||
|
||||
// ---------------- light color & alarm states ----------------
|
||||
|
||||
/**
|
||||
* The current color of a light entity as a CSS color, or null when it is off,
|
||||
* unavailable or reports no usable color. rgb_color is the source of truth
|
||||
* (HA normalizes hs/xy into it); brightness is deliberately ignored — a dim
|
||||
* red bulb should still read as red on the plan.
|
||||
*/
|
||||
export function lightColorOf(state: any): string | null {
|
||||
if (!state || state.state !== 'on') return null;
|
||||
const rgb = state.attributes?.rgb_color;
|
||||
if (Array.isArray(rgb) && rgb.length >= 3 && rgb.every((v: any) => Number.isFinite(v))) {
|
||||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Device classes whose active state is an emergency, not a status. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
/**
|
||||
* An alarm is firing: leak/smoke/gas/CO/safety binary sensors in `on`, or a
|
||||
* siren that is on. Unavailable/unknown never alarm (an outage is not a fire).
|
||||
*/
|
||||
export function isAlarmState(
|
||||
domain: string | null | undefined,
|
||||
deviceClass: string | null | undefined,
|
||||
state: string | null | undefined,
|
||||
): boolean {
|
||||
if (state !== 'on') return false;
|
||||
if (domain === 'siren') return true;
|
||||
return domain === 'binary_sensor' && !!deviceClass && ALARM_CLASSES.has(deviceClass);
|
||||
}
|
||||
|
||||
@@ -495,6 +495,31 @@ export const cardStyles = css`
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* RGB lights: the bulb takes the light's actual color */
|
||||
.dev.rgb ha-icon { color: var(--light-color); }
|
||||
.dev.rgb.on {
|
||||
box-shadow: 0 0 10px var(--light-color);
|
||||
border-color: var(--light-color);
|
||||
background: var(--hp-bg);
|
||||
color: var(--light-color);
|
||||
}
|
||||
/* alarms pulse red over everything */
|
||||
.dev.alarm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: calc(var(--icon-size, 2.5cqw) * -0.35);
|
||||
border: 3px solid #f25a4a;
|
||||
border-radius: 50%;
|
||||
animation: hp-alarm 1s ease-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes hp-alarm {
|
||||
0% { transform: scale(0.7); opacity: 1; }
|
||||
100% { transform: scale(1.25); opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dev.alarm::after { animation: none; opacity: 0.9; }
|
||||
}
|
||||
.devlayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user