mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
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:
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Vendored
+74
-24
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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-сущности — для умного выключателя с обычными светильниками. Ореол следует за выключателем (или за привязанными выше лампами)."
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user