mirror of
https://github.com/Matysh/houseplan-card
synced 2026-07-31 08:28:31 +00:00
feat v1.36.0: marker controls — wall switches that really switch
- marker.controls[]: bound light.*/switch.* entities; explicit per-marker tap_action=toggle flips them with HA-group semantics in one call (controlsAction + isControllable pure helpers, +2 unit tests, 100) - icon state and RGB tint mirror the targets (stateless remotes and virtual dumb-switch markers finally show something); info card lists targets with states; locks filtered everywhere - chips+search UI in the marker dialog; backend schema; smoke_controls (9 checks); TESTING/CHANGELOG same-commit
This commit is contained in:
+79
-6
@@ -17,7 +17,7 @@ import {
|
||||
pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, islandsOf,
|
||||
snapToWall, openingAmount,
|
||||
averageLqi, fitView, declump, safeUrl, resolveTapAction, floorsOf, type FloorInfo,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind,
|
||||
stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices, glowColorOf, doorSector, hasRoomBehind, controlsAction, isControllable,
|
||||
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.35.0';
|
||||
const CARD_VERSION = '1.36.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';
|
||||
@@ -166,6 +166,8 @@ class HouseplanCard extends LitElement {
|
||||
size: number; // icon size multiplier
|
||||
angle: number; // icon rotation, degrees
|
||||
tapAction: string; // '' = card default
|
||||
controls: string[]; // entities this icon toggles as a group
|
||||
controlsFilter: string;
|
||||
model: string;
|
||||
link: string;
|
||||
description: string;
|
||||
@@ -770,6 +772,11 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _stateClass(d: DevItem): string {
|
||||
if (!this._config?.live_states) return '';
|
||||
// an icon with controlled targets mirrors THEM, not its own entity
|
||||
// (stateless remotes and virtual wall switches have nothing else to show)
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
if (controls.length)
|
||||
return controls.some((e) => this.hass.states[e]?.state === 'on') ? 'on' : '';
|
||||
const p = d.primary ? this.hass.states[d.primary] : undefined;
|
||||
if (!p) return '';
|
||||
if (p.state === 'unavailable') return 'unavail';
|
||||
@@ -821,6 +828,17 @@ class HouseplanCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
const domain = d.primary ? d.primary.split('.')[0] : null;
|
||||
// a switch with bound targets: the EXPLICIT per-marker toggle flips them
|
||||
// all with HA-group semantics (any on -> all off). Owner's decision:
|
||||
// controls never fire on the card-wide default action.
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
if (d.tapAction === 'toggle' && controls.length) {
|
||||
const act = controlsAction(controls.map((e) => this.hass.states[e]?.state));
|
||||
this.hass
|
||||
.callService('homeassistant', act, { entity_id: controls })
|
||||
.catch((e: any) => this._showToast(this._t('toast.error', { err: this._errText(e) })));
|
||||
return;
|
||||
}
|
||||
const action = resolveTapAction(d.tapAction, this._config?.tap_action, domain);
|
||||
if (action === 'toggle' && d.primary) {
|
||||
this.hass
|
||||
@@ -1987,6 +2005,8 @@ class HouseplanCard extends LitElement {
|
||||
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
|
||||
angle: Number(d.marker?.angle) || 0,
|
||||
tapAction: d.marker?.tap_action || '',
|
||||
controls: [...(d.marker?.controls || [])],
|
||||
controlsFilter: '',
|
||||
model: d.model || '',
|
||||
link: d.link || '',
|
||||
description: d.description || '',
|
||||
@@ -2000,7 +2020,7 @@ class HouseplanCard extends LitElement {
|
||||
this._markerDialog = {
|
||||
name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '',
|
||||
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
|
||||
tapAction: '', model: '',
|
||||
tapAction: '', controls: [], controlsFilter: '', model: '',
|
||||
link: '', description: '', pdfs: [], room: '', busy: false,
|
||||
};
|
||||
}
|
||||
@@ -2194,6 +2214,7 @@ class HouseplanCard extends LitElement {
|
||||
size: dlg.size !== 1 ? dlg.size : null,
|
||||
angle: dlg.angle ? dlg.angle : null,
|
||||
tap_action: dlg.tapAction || null,
|
||||
controls: dlg.controls.length ? dlg.controls : null,
|
||||
model: dlg.model.trim() || null,
|
||||
link: dlg.link.trim() || null,
|
||||
description: dlg.description.trim() || null,
|
||||
@@ -3095,8 +3116,14 @@ class HouseplanCard extends LitElement {
|
||||
const icon = this._config?.live_states
|
||||
? 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;
|
||||
// RGB lights color the icon (and the ripple, unless a custom ripple color is set);
|
||||
// an icon with controlled targets takes the color of its first lit RGB target
|
||||
const ctrl = (m?.controls || []).filter(isControllable);
|
||||
const lightC = this._config?.live_states
|
||||
? ctrl.length
|
||||
? ctrl.map((e) => lightColorOf(this.hass.states[e])).find((v) => v) || null
|
||||
: domain === 'light' ? lightColorOf(primarySt) : null
|
||||
: 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);
|
||||
@@ -3655,6 +3682,7 @@ class HouseplanCard extends LitElement {
|
||||
const d = this._infoCard!;
|
||||
const st = d.primary ? this.hass.states[d.primary] : undefined;
|
||||
const stateTxt = st ? this.hass.formatEntityState?.(st) ?? st.state : null;
|
||||
const controls = (d.marker?.controls || []).filter(isControllable);
|
||||
return html`<div class="menuwrap dialogwrap" @click=${() => (this._infoCard = null)}>
|
||||
<div class="dialog" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<div class="hd"><ha-icon icon="${d.icon}"></ha-icon>${d.name}</div>
|
||||
@@ -3673,7 +3701,19 @@ class HouseplanCard extends LitElement {
|
||||
<ha-icon icon="mdi:file-pdf-box"></ha-icon>${p.name}</a>`,
|
||||
)}</span></div>`
|
||||
: nothing}
|
||||
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length)
|
||||
${controls.length
|
||||
? html`<div class="inforow"><span class="k">${this._t('info.controls')}</span>
|
||||
<span class="ctrlstates">
|
||||
${controls.map((eid) => {
|
||||
const cs = this.hass.states[eid];
|
||||
const on = cs?.state === 'on';
|
||||
return html`<span class="ctrlstate ${on ? 'on' : ''}">
|
||||
<ha-icon icon=${on ? 'mdi:lightbulb-on' : 'mdi:lightbulb-outline'}></ha-icon>
|
||||
${cs?.attributes?.friendly_name || eid}</span>`;
|
||||
})}
|
||||
</span></div>`
|
||||
: nothing}
|
||||
${!d.model && !stateTxt && !d.link && !d.description && !(d.pdfs && d.pdfs.length) && !controls.length
|
||||
? html`<div class="infodesc muted">${this._t('info.none')}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -3756,6 +3796,39 @@ class HouseplanCard extends LitElement {
|
||||
)}
|
||||
</select>
|
||||
|
||||
<label>${this._t('marker.controls_label')}</label>
|
||||
<div class="rhint">${this._t('marker.controls_hint')}</div>
|
||||
${d.controls.length
|
||||
? html`<div class="ctrlchips">
|
||||
${d.controls.map((eid) => html`<span class="ctrlchip">
|
||||
${this.hass.states[eid]?.attributes?.friendly_name || eid}
|
||||
<ha-icon icon="mdi:close" @click=${() =>
|
||||
(this._markerDialog = { ...d, controls: d.controls.filter((x) => x !== eid) })}></ha-icon>
|
||||
</span>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
<input class="namein" type="text" placeholder=${this._t('marker.controls_filter')}
|
||||
.value=${d.controlsFilter}
|
||||
@input=${(e: Event) => (this._markerDialog = { ...d, controlsFilter: (e.target as HTMLInputElement).value })} />
|
||||
${d.controlsFilter.trim()
|
||||
? html`<div class="ctrllist">
|
||||
${Object.keys(this.hass.states)
|
||||
.filter((eid) => isControllable(eid) && !d.controls.includes(eid))
|
||||
.filter((eid) => {
|
||||
const q = d.controlsFilter.trim().toLowerCase();
|
||||
const name = String(this.hass.states[eid]?.attributes?.friendly_name || '');
|
||||
return eid.toLowerCase().includes(q) || name.toLowerCase().includes(q);
|
||||
})
|
||||
.slice(0, 8)
|
||||
.map((eid) => html`<button class="ctrlopt"
|
||||
@click=${() => (this._markerDialog = { ...d, controls: [...d.controls, eid], controlsFilter: '' })}>
|
||||
<ha-icon icon=${eid.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch'}></ha-icon>
|
||||
${this.hass.states[eid]?.attributes?.friendly_name || eid}
|
||||
<span class="sub">${eid}</span>
|
||||
</button>`)}
|
||||
</div>`
|
||||
: nothing}
|
||||
|
||||
<label>${this._t('marker.icon_label')}</label>
|
||||
${customElements.get('ha-icon-picker')
|
||||
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
|
||||
|
||||
+5
-1
@@ -287,5 +287,9 @@
|
||||
"gs.glow_light": "Default light color / intensity",
|
||||
"gs.glow_radius": "Glow radius",
|
||||
"gs.unit_m": "m",
|
||||
"gs.unit_ft": "ft"
|
||||
"gs.unit_ft": "ft",
|
||||
"marker.controls_label": "Controls light sources",
|
||||
"marker.controls_hint": "With tap action “Toggle”, a click flips all bound lights at once (any on → all off). The icon mirrors their state.",
|
||||
"marker.controls_filter": "Search lights and switches…",
|
||||
"info.controls": "Controls"
|
||||
}
|
||||
|
||||
+5
-1
@@ -287,5 +287,9 @@
|
||||
"gs.glow_light": "Цвет света по умолчанию / интенсивность",
|
||||
"gs.glow_radius": "Радиус свечения",
|
||||
"gs.unit_m": "м",
|
||||
"gs.unit_ft": "фут"
|
||||
"gs.unit_ft": "фут",
|
||||
"marker.controls_label": "Управляет источниками света",
|
||||
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
|
||||
"marker.controls_filter": "Поиск ламп и выключателей…",
|
||||
"info.controls": "Управляет"
|
||||
}
|
||||
|
||||
@@ -838,6 +838,19 @@ export function hasRoomBehind(
|
||||
return polys.some((poly) => pointStrictlyInside(p, poly, 1e-9));
|
||||
}
|
||||
|
||||
/**
|
||||
* Group toggle for a switch's controlled entities, HA-group semantics:
|
||||
* any target on -> turn everything off; all off -> turn everything on.
|
||||
*/
|
||||
export function controlsAction(states: (string | undefined)[]): 'turn_on' | 'turn_off' {
|
||||
return states.some((st) => st === 'on') ? 'turn_off' : 'turn_on';
|
||||
}
|
||||
|
||||
/** Only lights and plain switches may be group-controlled from the plan. */
|
||||
export function isControllable(entityId: string): boolean {
|
||||
return entityId.startsWith('light.') || entityId.startsWith('switch.');
|
||||
}
|
||||
|
||||
/** Device classes whose active state is an emergency, not a status. */
|
||||
const ALARM_CLASSES = new Set(['smoke', 'gas', 'carbon_monoxide', 'moisture', 'safety', 'tamper', 'problem']);
|
||||
|
||||
|
||||
@@ -396,6 +396,26 @@ export const cardStyles = css`
|
||||
display: inline-flex;
|
||||
}
|
||||
.roomlabel .rlm.lit { opacity: 1; }
|
||||
.ctrlchips { display: flex; flex-wrap: wrap; gap: 5px; margin: 4px 0; }
|
||||
.ctrlchip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--hp-accent); color: var(--text-primary-color, #fff);
|
||||
border-radius: 12px; padding: 3px 8px; font-size: 12px;
|
||||
}
|
||||
.ctrlchip ha-icon { --mdc-icon-size: 14px; cursor: pointer; }
|
||||
.ctrllist { display: flex; flex-direction: column; gap: 2px; margin-top: 4px; }
|
||||
.ctrlopt {
|
||||
display: flex; align-items: center; gap: 7px; text-align: left;
|
||||
border: 0; background: transparent; color: var(--hp-txt);
|
||||
padding: 5px 7px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px;
|
||||
}
|
||||
.ctrlopt:hover { background: var(--secondary-background-color, rgba(128,128,128,0.15)); }
|
||||
.ctrlopt .sub { color: var(--hp-muted); font-size: 11px; margin-left: auto; }
|
||||
.ctrlopt ha-icon { --mdc-icon-size: 16px; }
|
||||
.ctrlstates { display: flex; flex-direction: column; gap: 3px; }
|
||||
.ctrlstate { display: inline-flex; align-items: center; gap: 5px; color: var(--hp-muted); }
|
||||
.ctrlstate.on { color: var(--hp-txt); }
|
||||
.ctrlstate ha-icon { --mdc-icon-size: 15px; }
|
||||
.iconauto {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface Marker {
|
||||
ripple_size?: number | null; // max ring diameter, in icon diameters (default 3)
|
||||
size?: number | null; // icon size multiplier (default 1)
|
||||
angle?: number | null; // icon rotation, degrees
|
||||
/** Entities this icon toggles as a group (wall switch → its lights). */
|
||||
controls?: string[] | null;
|
||||
}
|
||||
|
||||
/** A door or window: plan geometry (normalized coords), optionally live via entities. */
|
||||
|
||||
Reference in New Issue
Block a user