feat v1.44.0: control-first device card + light-source flag (user feedback)

- device card opens with controllable entities: toggles inline (finger
  targets), cover/lock/climate hand off to HA more-info; metadata and
  manuals moved below; config/diagnostic entities filtered; locks still
  never toggle from a card
- marker.is_light: a smart switch driving dumb fixtures glows in the
  light-sources fill (its own entity or the bound controls) — no
  light-group helper needed
- backend schema; smoke_card_controls.mjs, smoke_glow extended; docs
  same-commit
This commit is contained in:
Matysh
2026-07-27 12:41:26 +03:00
parent a841d17543
commit e04ef2f2e6
16 changed files with 442 additions and 83 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ FILES_URL = "/houseplan_files/files"
CONTENT_URL = "/api/houseplan/content"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.43.3"
VERSION = "1.44.0"
DEFAULT_CONFIG: dict = {
"spaces": [],
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,5 +16,5 @@
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [],
"single_config_entry": true,
"version": "1.43.3"
"version": "1.44.0"
}
@@ -205,6 +205,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], None),
vol.Optional("glow_radius_cm"): vol.Any(vol.All(vol.Coerce(float), vol.Range(min=10, max=10000)), None),
vol.Optional("is_light"): vol.Any(bool, None),
vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None),
+44
View File
@@ -0,0 +1,44 @@
import { launch, checkAll, finish } 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;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
await c.updateComplete;
c._setMode('view'); await c.updateComplete;
// устройство с управляемой сущностью
const dev = c._devices.find((d) => d.entities?.some((e) => e.startsWith('light.') || e.startsWith('switch.')));
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
// 1) блок сущностей идёт ПЕРВЫМ, до модели/ссылок
const body = sr().querySelector('.dialog .body');
out.entListFirst = body.firstElementChild?.classList.contains('entlist')
|| body.querySelector('.entlist') === body.children[0];
const rows = [...sr().querySelectorAll('.entrow')];
out.rows = rows.length > 0;
// 2) у переключаемой сущности — кнопка с крупной зоной нажатия
const btn = sr().querySelector('.entbtn');
out.hasButton = !!btn;
const box = btn?.getBoundingClientRect();
out.tapTarget = box ? box.height >= 30 && box.width >= 60 : null;
// 3) кнопка реально переключает
const before = calls.length;
btn.click(); await c.updateComplete;
out.toggles = calls.length > before && calls.at(-1)[1] === 'toggle';
// 4) замок никогда не переключается из карточки
const n = calls.length;
c._cardToggle('lock.front_door');
out.lockNeverToggles = calls.length === n;
// 5) диагностические/конфиг-сущности не засоряют список
const ents = c._cardEntities(dev).map((e) => e.eid);
out.noConfigEntities = ents.every((e) => {
const cat = c.hass.entities[e]?.entity_category;
return cat !== 'config' && cat !== 'diagnostic';
});
c._infoCard = null; await c.updateComplete;
return out;
});
checkAll(res);
await finish(browser, res);
+16
View File
@@ -93,6 +93,22 @@ const res = await page.evaluate(async () => {
out.perSourceRadius = Math.abs(rOwn - c._cmToUnits(150)) < 0.5;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId) };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
// 6в) флаг «источник света»: умный выключатель с обычными светильниками
const swDev = c._devices.find((d) => d.space === spId && d.entities.some((e) => e.startsWith('switch.')));
if (swDev) {
const swEid = swDev.entities.find((e) => e.startsWith('switch.'));
c.hass = { ...c.hass, states: { ...c.hass.states, [swEid]: { ...c.hass.states[swEid], state: 'on' } } };
const spotsBefore = sr().querySelectorAll('.glowlayer circle').length;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== swDev.id),
{ id: swDev.id, binding: swDev.bindingKind + ':' + swDev.bindingRef, is_light: true },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlows = sr().querySelectorAll('.glowlayer circle').length === spotsBefore + 1;
c._serverCfg = { ...c._serverCfg, markers: (c._serverCfg.markers || []).filter((m) => m.id !== swDev.id) };
c._regSignature = ''; c._maybeRebuildDevices(); c._saveConfig(); c.requestUpdate(); await c.updateComplete;
out.switchGlowsOffByDefault = sr().querySelectorAll('.glowlayer circle').length === spotsBefore;
} else { out.switchGlows = 'no-switch'; out.switchGlowsOffByDefault = 'no-switch'; }
// 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
File diff suppressed because one or more lines are too long
+74 -24
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## v1.44.0 — 2026-07-27 (user feedback: control first)
- **The device card is now a control surface.** It opens with the device's
controllable entities: lights, switches and fans toggle straight from the
card with finger-sized buttons, covers/locks/climate open Home Assistant's
own more-info. Model, links and PDF manuals moved below — on a wall tablet
this card is for running the home, not for reading documentation (field
report). Config and diagnostic entities are not listed; locks still never
toggle from a card tap.
- **"This device is a light source"** — a new per-device flag. A smart switch
driving ordinary (dumb) fixtures now casts a glow in the "Light sources"
fill without inventing a light-group helper: the glow follows the switch, or
the lights bound under "Controls light sources" when they are set.
## v1.43.3 — 2026-07-27 (user feedback: discoverability and touch)
- **Room settings were unfindable.** The gear added in v1.42.0 lived inside the
+10
View File
@@ -30,6 +30,16 @@
ANY touch/pen pointer event, even if the browser claims `hover: hover`
(stylus, paired mouse, vendor skins) [auto: smoke_feedback_v2]
- [ ] Light-source flag (v1.44.0, user feedback): a smart SWITCH driving dumb
fixtures glows in the "Light sources" fill once "This device is a light
source" is ticked (its own entity or the lights bound under "Controls");
unticked devices without a light entity never glow [auto: smoke_glow]
- [ ] Device card controls (v1.44.0): the device card opens with its
controllable entities FIRST — toggles right there (≥30 px tap targets),
cover/lock/climate open HA more-info; model, links and manuals moved
below; config/diagnostic entities are not listed; locks never toggle from
the card [auto: smoke_card_controls]
## Environments matrix
Run the *core flows* (marked ★ below) in each environment at least once per minor release:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.43.3",
"version": "1.44.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+84 -6
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.43.3';
const CARD_VERSION = '1.44.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';
@@ -233,6 +233,7 @@ class HouseplanCard extends LitElement {
controls: string[]; // entities this icon toggles as a group
controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default
isLight: boolean; // force this marker to glow (dumb fixtures behind a switch)
model: string;
link: string;
description: string;
@@ -2429,6 +2430,7 @@ class HouseplanCard extends LitElement {
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
controls: [...(d.marker?.controls || [])],
controlsFilter: '',
isLight: d.marker?.is_light === true,
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
@@ -2448,7 +2450,8 @@ class HouseplanCard extends LitElement {
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', glowRadius: '', model: '',
tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', isLight: false,
glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false,
};
}
@@ -2642,6 +2645,7 @@ class HouseplanCard extends LitElement {
tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null,
// pdfs may be rewritten below when rebinding changes the marker id
is_light: dlg.isLight ? true : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
@@ -3133,9 +3137,15 @@ class HouseplanCard extends LitElement {
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string[] | null; r: number }[] = [];
for (const d of this._devices) {
if (d.space !== space.id) continue;
const lightEid = d.entities.find(
(e) => e.startsWith('light.') && this.hass.states[e]?.state === 'on',
);
// A light source is normally a device with a lit light.* entity. With the
// "is a light source" flag (field request: a smart SWITCH driving dumb
// fixtures) any lit entity counts — the switch itself, or the lights it
// controls when they are bound.
const forced = d.marker?.is_light === true;
const pool = forced
? [...(d.marker?.controls || []), ...d.entities]
: d.entities.filter((e) => e.startsWith('light.'));
const lightEid = pool.find((e) => this.hass.states[e]?.state === 'on');
if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue;
@@ -4485,6 +4495,39 @@ class HouseplanCard extends LitElement {
</div>`;
}
/** Entities of a device worth CONTROLLING or reading, in a sensible order. */
private _cardEntities(d: DevItem): { eid: string; kind: 'toggle' | 'value' | 'open' }[] {
const h = this.hass;
const out: { eid: string; kind: 'toggle' | 'value' | 'open' }[] = [];
const seen = new Set<string>();
const push = (eid: string) => {
if (!eid || seen.has(eid) || !h.states[eid]) return;
const reg = h.entities[eid];
if (reg?.entity_category === 'config' || reg?.entity_category === 'diagnostic') return;
seen.add(eid);
const dom = eid.split('.')[0];
if (['light', 'switch', 'fan', 'humidifier', 'siren', 'input_boolean'].includes(dom))
out.push({ eid, kind: 'toggle' });
else if (['cover', 'valve', 'lock', 'climate', 'media_player', 'vacuum', 'water_heater'].includes(dom))
out.push({ eid, kind: 'open' }); // needs the full more-info UI
else if (['sensor', 'binary_sensor', 'number', 'select'].includes(dom))
out.push({ eid, kind: 'value' });
};
for (const e of d.marker?.controls || []) push(e);
if (d.primary) push(d.primary);
for (const e of d.entities) push(e);
return out.slice(0, 12);
}
/** Toggle straight from the device card (safe domains only). */
private _cardToggle(eid: string): void {
const dom = eid.split('.')[0];
if (dom === 'lock' || dom === 'alarm_control_panel') return; // never from a card tap
this.hass
.callService('homeassistant', 'toggle', { entity_id: eid })
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
}
private _renderInfoCard(): TemplateResult {
const d = this._infoCard!;
const st = d.primary ? this.hass.states[d.primary] : undefined;
@@ -4494,8 +4537,38 @@ class HouseplanCard extends LitElement {
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
<div class="body">
${(() => {
// Field feedback: on a wall tablet this card is for CONTROLLING the
// home; model/links/manuals are reference material and belong below.
const ents = this._cardEntities(d);
if (!ents.length) return nothing;
return html`<div class="entlist">
${ents.map(({ eid, kind }) => {
const est = this.hass.states[eid];
const name = this.hass.entities[eid]?.name
|| est?.attributes?.friendly_name || eid;
const val = est ? this.hass.formatEntityState?.(est) ?? est.state : '';
const on = est?.state === 'on' || ['open', 'unlocked', 'playing', 'cleaning'].includes(est?.state);
return html`<div class="entrow ${on ? 'on' : ''}">
<ha-icon icon=${stateIcon(
iconFor(name, '', this._iconRules), eid.split('.')[0],
est?.attributes?.device_class, est?.state, false,
)}></ha-icon>
<span class="en">${name}</span>
${kind === 'toggle'
? html`<button class="entbtn ${on ? 'on' : ''}"
@click=${() => this._cardToggle(eid)}>${val}</button>`
: kind === 'open'
? html`<button class="entbtn"
@click=${() => { this._infoCard = null; this._openMoreInfo(eid); }}>${val}</button>`
: html`<span class="ev">${val}</span>`}
</div>`;
})}
</div>`;
})()}
${d.model ? html`<div class="inforow"><span class="k">${this._t('info.model')}</span><span>${d.model}</span></div>` : nothing}
${stateTxt ? html`<div class="inforow"><span class="k">${this._t('info.state')}</span><span>${stateTxt}</span></div>` : nothing}
${stateTxt && !this._cardEntities(d).length
? html`<div class="inforow"><span class="k">${this._t('info.state')}</span><span>${stateTxt}</span></div>` : nothing}
${safeUrl(d.link)
? html`<div class="inforow"><span class="k">${this._t('info.link')}</span>
<a href="${safeUrl(d.link)}" target="_blank" rel="noreferrer noopener">${d.link}</a></div>`
@@ -4663,6 +4736,11 @@ class HouseplanCard extends LitElement {
</div>`
: nothing}
<label class="srcrow" title=${this._t('marker.is_light_tip')}>
<input type="checkbox" .checked=${d.isLight}
@change=${(e: Event) => (this._markerDialog = { ...d, isLight: (e.target as HTMLInputElement).checked })} />
<span>${this._t('marker.is_light')}</span>
</label>
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
+3 -1
View File
@@ -325,5 +325,7 @@
"preview.room_name": "Living room",
"toast.cfg_reload_failed": "Could not reload the plan from the server: {err}",
"room.settings_short": "Room",
"room.unnamed": "Unnamed room"
"room.unnamed": "Unnamed room",
"marker.is_light": "This device is a light source",
"marker.is_light_tip": "Makes the icon glow in the “Light sources” fill even without a light entity — for a smart switch driving ordinary fixtures. The glow follows the switch (or the lights bound above)."
}
+3 -1
View File
@@ -325,5 +325,7 @@
"preview.room_name": "Гостиная",
"toast.cfg_reload_failed": "Не удалось перечитать план с сервера: {err}",
"room.settings_short": "Комната",
"room.unnamed": "Комната без имени"
"room.unnamed": "Комната без имени",
"marker.is_light": "Это устройство — источник света",
"marker.is_light_tip": "Даёт ореол в заливке «Свет по источникам» даже без light-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами)."
}
+36
View File
@@ -1215,6 +1215,42 @@ export const cardStyles = css`
.pdftag .x:hover {
color: #ff7a5c;
}
.entlist {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.entrow {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
background: var(--secondary-background-color, rgba(128, 128, 128, 0.12));
}
.entrow ha-icon { --mdc-icon-size: 20px; color: var(--hp-muted); }
.entrow.on ha-icon { color: var(--hp-accent); }
.entrow .en { flex: 1; font-size: 13px; }
.entrow .ev { font-size: 13px; color: var(--hp-muted); }
.entbtn {
min-width: 74px;
min-height: 32px;
padding: 4px 12px;
border: 1px solid var(--hp-muted);
border-radius: 999px;
background: transparent;
color: var(--hp-txt);
font: inherit;
font-size: 13px;
cursor: pointer;
}
.entbtn.on {
background: var(--hp-accent);
border-color: var(--hp-accent);
color: var(--text-primary-color, #fff);
font-weight: 600;
}
.inforow {
display: flex;
gap: 10px;
+6
View File
@@ -61,6 +61,12 @@ export interface Marker {
controls?: string[] | null;
/** Per-source glow radius in cm (glow fill); null = the global default. */
glow_radius_cm?: number | null;
/**
* Treat this marker as a light source in the glow fill even when it has no
* light.* entity (a smart switch driving dumb fixtures field request).
* null/undefined = auto: any light.* entity of the device.
*/
is_light?: boolean | null;
}
/** A door or window: plan geometry (normalized coords), optionally live via entities. */