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:
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
|
|||||||
FILES_URL = "/houseplan_files/files"
|
FILES_URL = "/houseplan_files/files"
|
||||||
FILES_DIR = "houseplan/files"
|
FILES_DIR = "houseplan/files"
|
||||||
CONF_ADMIN_ONLY = "admin_only"
|
CONF_ADMIN_ONLY = "admin_only"
|
||||||
VERSION = "1.26.0"
|
VERSION = "1.27.0"
|
||||||
|
|
||||||
DEFAULT_CONFIG: dict = {
|
DEFAULT_CONFIG: dict = {
|
||||||
"spaces": [],
|
"spaces": [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
|||||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"version": "1.26.0"
|
"version": "1.27.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { launch } from './serve.mjs';
|
||||||
|
const { page, browser } = await launch();
|
||||||
|
const res = await page.evaluate(async () => {
|
||||||
|
const out = {};
|
||||||
|
const c = window.__card;
|
||||||
|
const sr = () => c.shadowRoot || c.renderRoot;
|
||||||
|
// RGB-лампа: ceiling on + rgb_color
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||||
|
'light.ceiling': { entity_id: 'light.ceiling', state: 'on', attributes: { friendly_name: 'Ceiling light', rgb_color: [255, 0, 128] } } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
const rgbDev = sr().querySelector('.dev.rgb');
|
||||||
|
out.rgbClass = !!rgbDev;
|
||||||
|
out.rgbVar = rgbDev?.getAttribute('style')?.includes('--light-color:rgb(255, 0, 128)');
|
||||||
|
// тревога: датчик протечки on
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||||
|
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'on', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
out.alarmCount = sr().querySelectorAll('.dev.alarm').length;
|
||||||
|
// выключили — тревога ушла
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||||
|
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'off', attributes: { friendly_name: 'Leak', device_class: 'moisture' } } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
out.alarmCleared = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||||
|
// unavailable не тревожит
|
||||||
|
c.hass = { ...c.hass, states: { ...c.hass.states,
|
||||||
|
'binary_sensor.sink_leak': { entity_id: 'binary_sensor.sink_leak', state: 'unavailable', attributes: { device_class: 'moisture' } } } };
|
||||||
|
await c.updateComplete;
|
||||||
|
out.outageSafe = sr().querySelectorAll('.dev.alarm').length === 0;
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
console.log(JSON.stringify(res, null, 1));
|
||||||
|
await browser.close();
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+41
-16
File diff suppressed because one or more lines are too long
@@ -1,5 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.27.0 — 2026-07-21 (RGB light colors + alarm pulse; issue #3)
|
||||||
|
- **RGB lights show their actual color**: an "on" light with a color tints its
|
||||||
|
bulb icon, glow and — unless a custom ripple color is set — its presence ripple.
|
||||||
|
Brightness is deliberately ignored (a dim red bulb still reads red); off,
|
||||||
|
white-only and unavailable lights look as before. (Pure `lightColorOf`.)
|
||||||
|
- **Emergencies pulse red**: leak / smoke / gas / CO / safety / tamper / problem
|
||||||
|
binary sensors and sirens in `on` get a red pulsing ring over any display mode.
|
||||||
|
`unavailable`/`unknown` never alarm — an outage is not a fire. Honours
|
||||||
|
`prefers-reduced-motion`. (Pure `isAlarmState`.)
|
||||||
|
|
||||||
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
|
## v1.26.0 — 2026-07-21 (state-reflecting icons + value display; issue #3)
|
||||||
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
|
- **Auto icons now reflect live state**, like core HA: door/window/garage sensors
|
||||||
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
|
swap open↔closed variants, locks show locked/unlocked, a bulb lights up as
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
| Item | State |
|
| Item | State |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Version | **v1.26.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
| Version | **v1.27.0** everywhere (manifest, const.py, package.json, CARD_VERSION) |
|
||||||
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
| GitHub | https://github.com/Matysh/houseplan-card — branch `main`, releases v1.9.3…**v1.23.1** (latest published 2026-07-17, bundle auto-attached by release.yml) |
|
||||||
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
| CI | `.github/workflows/validate.yml` (hacs + hassfest + frontend + backend) — **fully green** since v1.11.1; `release.yml` auto-attaches the card bundle (needs `permissions: contents: write`, fixed) |
|
||||||
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
| HACS | Works as custom repository (id 1290210112 on the home instance). **Inclusion PR: https://github.com/hacs/default/pull/9004** (queue ≈2 months as of 2026-07). Lesson: #8995 was auto-closed by hacs-bot — the PR body MUST be their exact template with every checkbox ticked and all 3 links (release, HACS action run, hassfest run); a custom body gets closed without discussion |
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
|
|||||||
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
lock locked↔unlocked, bulb on; custom icons and unavailable states never morph [auto]
|
||||||
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
- [ ] display "Value instead of an icon": the marker shows the measurement (°/%/unit)
|
||||||
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
as its body, small badges hidden; non-numeric fallback keeps the icon [auto]
|
||||||
|
- [ ] RGB lights (v1.27.0): an on light with a color tints its icon/glow and the ripple
|
||||||
|
(explicit ripple color still wins); off/white lights unchanged [auto]
|
||||||
|
- [ ] Alarm pulse (v1.27.0): leak/smoke/gas/CO/siren in 'on' pulse a red ring over any
|
||||||
|
display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
|
||||||
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
- [ ] No devices at all in HA (fresh instance) → plan renders, "0 dev.", no console errors [auto]
|
||||||
|
|
||||||
## Device dialog (markers) ★
|
## Device dialog (markers) ★
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "houseplan-card",
|
"name": "houseplan-card",
|
||||||
"version": "1.26.0",
|
"version": "1.27.0",
|
||||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+12
-5
@@ -17,7 +17,7 @@ import {
|
|||||||
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
pointOnBoundary, mergeRooms, splitRoom, polygonArea, closestPointOnBoundary,
|
||||||
snapToWall, openingAmount,
|
snapToWall, openingAmount,
|
||||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||||
stateIcon,
|
stateIcon, lightColorOf, isAlarmState,
|
||||||
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
spaceDisplayOf, roomFillStyle, fillColorsOf, DEFAULT_FILL_COLORS, type FillColors,
|
||||||
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
isActiveState, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY,
|
||||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||||
@@ -32,7 +32,7 @@ import './space-card';
|
|||||||
import { cardStyles } from './styles';
|
import { cardStyles } from './styles';
|
||||||
import { langOf, t, type I18nKey } from './i18n';
|
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_KEY = 'houseplan_card_layout_v1';
|
||||||
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
const LS_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
|
||||||
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
const LS_ZOOM = 'houseplan_card_zoom_v1';
|
||||||
@@ -2537,10 +2537,15 @@ class HouseplanCard extends LitElement {
|
|||||||
: null)
|
: null)
|
||||||
: null;
|
: null;
|
||||||
// live state variants of the auto icon (doors, locks, bulbs), like core HA
|
// 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
|
const icon = this._config?.live_states
|
||||||
? stateIcon(d.icon, d.primary ? d.primary.split('.')[0] : null,
|
? stateIcon(d.icon, domain, primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
|
||||||
primarySt?.attributes?.device_class, primarySt?.state, !!m?.icon)
|
|
||||||
: d.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 active = ripple && !!d.primary && isActiveState(this.hass.states[d.primary]?.state);
|
||||||
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
|
const scale = Number(m?.size) > 0 ? Number(m!.size) : 1;
|
||||||
const angle = Number(m?.angle) || 0;
|
const angle = Number(m?.angle) || 0;
|
||||||
@@ -2550,9 +2555,11 @@ class HouseplanCard extends LitElement {
|
|||||||
if (ripple) {
|
if (ripple) {
|
||||||
st.push(`--ripple-scale:${rScale}`);
|
st.push(`--ripple-scale:${rScale}`);
|
||||||
if (m?.ripple_color) st.push(`--ripple-color:${m.ripple_color}`);
|
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
|
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(';')}"
|
style="${st.join(';')}"
|
||||||
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
@click=${(e: MouseEvent) => this._clickDevice(e, d)}
|
||||||
@mousemove=${(e: MouseEvent) =>
|
@mousemove=${(e: MouseEvent) =>
|
||||||
|
|||||||
@@ -674,3 +674,37 @@ export function stateIcon(
|
|||||||
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
if (domain === 'light' && base === 'mdi:lightbulb') return state === 'on' ? 'mdi:lightbulb-on' : base;
|
||||||
return 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;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
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 {
|
.devlayer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
+18
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
lqiColor, snapToGrid, segKey, samePoint, pointInPolygon, markerIdForBinding, averageLqi,
|
||||||
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
|
||||||
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
|
||||||
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon,
|
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState,
|
||||||
} from '../test-build/logic.js';
|
} from '../test-build/logic.js';
|
||||||
import {
|
import {
|
||||||
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
iconFor, compileIconRules, isValidPattern, iconFromDeviceClasses,
|
||||||
@@ -496,3 +496,20 @@ test('stateIcon: doors/locks/bulbs reflect state; custom icons and outages never
|
|||||||
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
|
assert.equal(stateIcon('mdi:custom', 'lock', undefined, 'unlocked', true), 'mdi:custom'); // user icon wins
|
||||||
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
|
assert.equal(stateIcon('mdi:cctv', 'camera', undefined, 'recording', false), 'mdi:cctv'); // unknown pair
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lightColorOf: rgb of an on light; off/unavailable/no-color → null', () => {
|
||||||
|
assert.equal(lightColorOf({ state: 'on', attributes: { rgb_color: [255, 20, 40] } }), 'rgb(255, 20, 40)');
|
||||||
|
assert.equal(lightColorOf({ state: 'off', attributes: { rgb_color: [255, 20, 40] } }), null);
|
||||||
|
assert.equal(lightColorOf({ state: 'on', attributes: {} }), null);
|
||||||
|
assert.equal(lightColorOf({ state: 'unavailable', attributes: { rgb_color: [1, 2, 3] } }), null);
|
||||||
|
assert.equal(lightColorOf(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isAlarmState: leak/smoke/gas/siren fire; doors and outages do not', () => {
|
||||||
|
assert.ok(isAlarmState('binary_sensor', 'moisture', 'on'));
|
||||||
|
assert.ok(isAlarmState('binary_sensor', 'smoke', 'on'));
|
||||||
|
assert.ok(isAlarmState('siren', undefined, 'on'));
|
||||||
|
assert.ok(!isAlarmState('binary_sensor', 'door', 'on'));
|
||||||
|
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'off'));
|
||||||
|
assert.ok(!isAlarmState('binary_sensor', 'smoke', 'unavailable'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user