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:
Matysh
2026-07-23 13:10:58 +03:00
parent 7eaf513c9e
commit d7a1b344e4
17 changed files with 421 additions and 71 deletions
+79 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Управляет"
}
+13
View File
@@ -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']);
+20
View File
@@ -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;
+2
View File
@@ -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. */