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
+1 -1
View File
@@ -11,7 +11,7 @@ PLANS_DIR = "houseplan/plans" # relative to the HA configuration directory
FILES_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.35.0"
VERSION = "1.36.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.35.0"
"version": "1.36.0"
}
@@ -168,6 +168,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("link"): vol.Any(str, None),
vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], 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),
+61
View File
@@ -0,0 +1,61 @@
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;
const calls = [];
c.hass = { ...c.hass, callService: (d, s, data) => { calls.push([d, s, data.entity_id]); return Promise.resolve(); } };
// два подопытных light-entity
const lights = Object.keys(c.hass.states).filter((e) => e.startsWith('light.')).slice(0, 2);
out.twoLights = lights.length === 2;
const setSt = async (m) => {
const st = { ...c.hass.states };
for (const [e, v] of Object.entries(m)) st[e] = { ...st[e], state: v };
c.hass = { ...c.hass, states: st }; await c.updateComplete;
};
// виртуальный маркер-выключатель с controls и tap_action=toggle
c._setMode('devices'); await c.updateComplete;
c._openMarkerDialog();
const room = c._spaceModel().rooms.find((r) => r.id && r.area);
c._markerDialog = { ...c._markerDialog, name: 'Выключатель', binding: 'virtual',
room: c._space + '#' + room.area, tapAction: 'toggle', controls: lights, icon: 'mdi:light-switch' };
await c._saveMarker(); await c.updateComplete;
const dev = c._devices.find((d) => d.name === 'Выключатель');
out.markerSaved = !!dev && JSON.stringify(dev.marker.controls) === JSON.stringify(lights);
c._setMode('view'); await c.updateComplete;
// 1) все выключены → клик включает все
await setSt({ [lights[0]]: 'off', [lights[1]]: 'off' });
const dev2 = () => c._devices.find((d) => d.name === 'Выключатель');
c._clickDevice(new MouseEvent('click'), dev2());
out.turnOnAll = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_on', lights]);
// 2) частично включено → клик выключает все
await setSt({ [lights[0]]: 'on', [lights[1]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
out.partialTurnsOff = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'turn_off', lights]);
// 3) значок отражает цели: горит одна → класс on
const el = [...sr().querySelectorAll('.dev')].find((e) => e.title === 'Выключатель' || e.textContent.includes(''));
out.stateOn = !!c._stateClass(dev2()).includes('on');
await setSt({ [lights[0]]: 'off' });
out.stateOff = c._stateClass(dev2()) === '';
// 4) без tap_action=toggle клик НЕ переключает (инфо)
const cfg = c._serverCfg.markers.find((m) => m.name === 'Выключатель');
cfg.tap_action = 'info'; c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const n = calls.length;
c._clickDevice(new MouseEvent('click'), dev2());
out.infoNoToggle = calls.length === n && !!c._infoCard;
// 5) инфо-карточка показывает цели
await c.updateComplete;
out.infoListsTargets = sr().querySelectorAll('.ctrlstate').length === 2;
c._infoCard = null; await c.updateComplete;
// 6) замок в controls отфильтрован при клике (защита)
cfg.tap_action = 'toggle'; cfg.controls = ['lock.front_door', lights[0]];
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
await setSt({ [lights[0]]: 'off' });
c._clickDevice(new MouseEvent('click'), dev2());
const last = calls.at(-1);
out.lockFiltered = JSON.stringify(last) === JSON.stringify(['homeassistant', 'turn_on', [lights[0]]]);
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+66 -20
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -1,5 +1,16 @@
# Changelog
## v1.36.0 — 2026-07-23 (wall switches that really switch)
- Markers gained **"Controls light sources"**: bind any set of `light.*` /
`switch.*` entities to an icon. With tap action **Toggle**, a click flips
them all with HA-group semantics — any on → all off, all off → all on — in
one service call. Covers stateless remotes, one-switch-many-lights and
dumb wall switches (place a virtual marker; no HA entity needed).
- The icon mirrors its targets: on when any target is on, tinted by the first
lit RGB light. The info card lists every target with its state. Controls
fire only on the explicit per-marker Toggle (owner's decision); locks and
other domains can never be group-controlled.
## v1.35.0 — 2026-07-23 (glow fill: dark house, glowing lamps)
- New fill mode **"Light sources"**: the whole house is painted with a single
configurable darkness color, and every lit lamp casts a radial pool of light
+6
View File
@@ -140,6 +140,12 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
(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
- [ ] Marker controls (v1.36.0): a marker with "Controls light sources" and
tap action Toggle flips all bound lights/switches at once (any on → all
off, all off → all on, one service call); the icon and its RGB tint
mirror the targets, not the marker's own entity; without explicit Toggle
the click opens info as usual; the info card lists targets with states;
locks/other domains are filtered out of controls [auto]
- [ ] Glow fill (v1.35.0): fill mode "Light sources" — every room painted with
one uniform darkness color; lit lamps glow with a radial gradient
(rgb_color → color temp → default color; brightness scales opacity),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.35.0",
"version": "1.36.0",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+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. */
+17
View File
@@ -5,6 +5,7 @@ import {
fitView, declump, safeUrl, resolveTapAction, floorsOf, subst, spaceDisplayOf, roomFillColor,
splitRoomPath, polyContainsPoly, islandsOf,
kelvinToRgb, glowColorOf, doorSector, hasRoomBehind,
controlsAction, isControllable,
segmentCm, formatLength, roomEdges, roomPoly, pointOnBoundary, pointStrictlyInside, roomsOverlap,
mergeRooms, splitRoom, polygonArea, closestPointOnBoundary, isActiveState, snapToWall, openingAmount, fillColorsOf, lerpColor, roomFillStyle, stateIcon, lightColorOf, isAlarmState, parseRoomRef, diffNewDevices,
} from '../test-build/logic.js';
@@ -649,3 +650,19 @@ test('hasRoomBehind: neighbour room yes, street no', () => {
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [], 1));
assert.ok(!hasRoomBehind([10, 0], 90, [5, 0], [[[30, 30], [40, 30], [40, 40], [30, 40]]], 1));
});
test('controlsAction: HA-group semantics', () => {
assert.equal(controlsAction(['off', 'off']), 'turn_on');
assert.equal(controlsAction(['off', 'on']), 'turn_off');
assert.equal(controlsAction(['on', 'on']), 'turn_off');
assert.equal(controlsAction([undefined, 'off']), 'turn_on');
assert.equal(controlsAction([]), 'turn_on');
});
test('isControllable: lights and switches only', () => {
assert.ok(isControllable('light.kitchen'));
assert.ok(isControllable('switch.pump'));
assert.ok(!isControllable('lock.front'));
assert.ok(!isControllable('cover.gate'));
assert.ok(!isControllable('alarm_control_panel.home'));
});