feat v1.39.0: pure light sources toggle on click by default

- resolveTapAction: no explicit action + domain light -> toggle (kettle
  et al. keep info via their non-light primary); explicit choice wins
- device dialog shows the effective default (defaultTap)
- unit tests updated (+1, 106); smoke_light_default_tap.mjs; docs
  same-commit
This commit is contained in:
Matysh
2026-07-23 17:28:38 +03:00
parent 9bfef453db
commit 0522413c48
12 changed files with 89 additions and 22 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_URL = "/houseplan_files/files"
FILES_DIR = "houseplan/files" FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only" CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.38.4" VERSION = "1.39.0"
DEFAULT_CONFIG: dict = { DEFAULT_CONFIG: dict = {
"spaces": [], "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", "issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
"requirements": [], "requirements": [],
"single_config_entry": true, "single_config_entry": true,
"version": "1.38.4" "version": "1.39.0"
} }
+34
View File
@@ -0,0 +1,34 @@
import { launch } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
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;
// лампа (primary light) без явного действия → клик = toggle
const lamp = c._devices.find((d) => d.primary?.startsWith('light.') && !d.tapAction);
out.hasLamp = !!lamp;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), lamp);
out.lampToggles = JSON.stringify(calls.at(-1)) === JSON.stringify(['homeassistant', 'toggle', lamp.primary]);
// устройство с не-light primary (сенсор) → клик = инфо
const sensorDev = c._devices.find((d) => d.primary?.startsWith('sensor.') && !d.tapAction);
const n = calls.length;
c._infoCard = null;
c._clickDevice(new MouseEvent('click'), sensorDev);
out.sensorInfo = calls.length === n && !!c._infoCard;
c._infoCard = null;
// явный info у лампы отключает дефолт
const cfgm = { id: lamp.id, binding: lamp.bindingKind + ':' + lamp.bindingRef, tap_action: 'info' };
c._serverCfg = { ...c._serverCfg, markers: [...(c._serverCfg.markers || []).filter((m) => m.id !== lamp.id), cfgm] };
c._regSignature = ''; c._maybeRebuildDevices(); await c.updateComplete;
const lamp2 = c._devices.find((d) => d.id === lamp.id);
const n2 = calls.length;
c._clickDevice(new MouseEvent('click'), lamp2);
out.explicitInfoWins = calls.length === n2 && !!c._infoCard;
return out;
});
console.log(JSON.stringify(res, null, 1));
await browser.close();
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## v1.39.0 — 2026-07-23 (lights toggle by default)
- Pure light sources — devices whose primary entity is a `light` (bulbs,
chandeliers, night lights, light groups) — now **toggle on click by
default**, right from auto-placement, no per-device setting needed.
Devices where light is a side function (a kettle's backlight: its primary
is a sensor) keep the Device-card default. An explicit per-device choice
always wins. The device dialog shows the effective default.
## v1.38.4 — 2026-07-23 ## v1.38.4 — 2026-07-23
- Plan editor: the DERIVED wall segments (the markup layer's solid lines) - Plan editor: the DERIVED wall segments (the markup layer's solid lines)
are now trimmed under open boundaries as well — v1.38.3 only trimmed the are now trimmed under open boundaries as well — v1.38.3 only trimmed the
+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] (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 - [ ] 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 display mode; clears on 'off'; unavailable never alarms [auto]; reduced-motion static
- [ ] Lights toggle by default (v1.39.0): a device whose PRIMARY entity is a
light (bulbs, chandeliers, night lights, light groups) toggles on click
out of the box — no per-device setting needed; the device dialog shows
"Toggle" as its effective default; devices where light is a side
function (kettle: primary = sensor) keep the Device-card default;
explicit per-device "Device card" wins over the default [auto]
- [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall - [ ] Derived walls cut too (v1.38.4): in the Plan editor the derived wall
segments (.seg) no longer run solid through an open stretch — only the segments (.seg) no longer run solid through an open stretch — only the
dash remains there [auto] dash remains there [auto]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.38.4", "version": "1.39.0",
"description": "Interactive house plan Lovelace card for Home Assistant", "description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+6 -4
View File
@@ -32,7 +32,7 @@ import './space-card';
import { cardStyles } from './styles'; import { cardStyles } from './styles';
import { langOf, t, type I18nKey } from './i18n'; import { langOf, t, type I18nKey } from './i18n';
const CARD_VERSION = '1.38.4'; const CARD_VERSION = '1.39.0';
const LS_KEY = 'houseplan_card_layout_v1'; 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_CFG = 'houseplan_card_cfg_v1'; // cache of the server config+layout for instant rendering
const LS_ZOOM = 'houseplan_card_zoom_v1'; const LS_ZOOM = 'houseplan_card_zoom_v1';
@@ -169,7 +169,8 @@ class HouseplanCard extends LitElement {
rippleSize: number; // in icon diameters rippleSize: number; // in icon diameters
size: number; // icon size multiplier size: number; // icon size multiplier
angle: number; // icon rotation, degrees angle: number; // icon rotation, degrees
tapAction: string; // '' = card default tapAction: string; // '' = the effective default (defaultTap)
defaultTap: 'info' | 'toggle';
controls: string[]; // entities this icon toggles as a group controls: string[]; // entities this icon toggles as a group
controlsFilter: string; controlsFilter: string;
glowRadius: string; // per-device glow radius in display units; '' = global default glowRadius: string; // per-device glow radius in display units; '' = global default
@@ -2151,6 +2152,7 @@ class HouseplanCard extends LitElement {
size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1, size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1,
angle: Number(d.marker?.angle) || 0, angle: Number(d.marker?.angle) || 0,
tapAction: d.marker?.tap_action || '', tapAction: d.marker?.tap_action || '',
defaultTap: d.primary?.split('.')[0] === 'light' ? 'toggle' : 'info',
controls: [...(d.marker?.controls || [])], controls: [...(d.marker?.controls || [])],
controlsFilter: '', controlsFilter: '',
glowRadius: Number(d.marker?.glow_radius_cm) > 0 glowRadius: Number(d.marker?.glow_radius_cm) > 0
@@ -2172,7 +2174,7 @@ class HouseplanCard extends LitElement {
name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false, name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false,
showEntities: false, bindingFilter: '', icon: '', autoIcon: '', showEntities: false, bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', controls: [], controlsFilter: '', glowRadius: '', model: '', tapAction: '', defaultTap: 'info', controls: [], controlsFilter: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
}; };
} }
@@ -4028,7 +4030,7 @@ class HouseplanCard extends LitElement {
<select class="areasel" <select class="areasel"
@change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}> @change=${(e: Event) => (this._markerDialog = { ...d, tapAction: (e.target as HTMLSelectElement).value })}>
${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map( ${[['info', 'tap.info'], ['more-info', 'tap.more_info'], ['toggle', 'tap.toggle']].map(
([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || 'info') === v}>${this._t(k as any)}</option>`, ([v, k]) => html`<option value=${v} ?selected=${(d.tapAction || d.defaultTap) === v}>${this._t(k as any)}</option>`,
)} )}
</select> </select>
+5 -1
View File
@@ -492,7 +492,11 @@ export function resolveTapAction(
cardDefault: string | null | undefined, cardDefault: string | null | undefined,
domain: string | null | undefined, domain: string | null | undefined,
): TapAction { ): TapAction {
const want = explicit || cardDefault || 'info'; // Pure light sources (the device's PRIMARY function is a lamp: bulbs,
// chandeliers, night lights, light groups) toggle by default — no explicit
// setting needed. Devices where light is a side function (a kettle's
// backlight) have a non-light primary and keep the info default.
const want = explicit || cardDefault || (domain === 'light' ? 'toggle' : 'info');
if (want === 'more-info') return 'more-info'; if (want === 'more-info') return 'more-info';
if (want !== 'toggle') return 'info'; if (want !== 'toggle') return 'info';
if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info'; if (!domain || TOGGLE_FORBIDDEN_DOMAINS.has(domain)) return 'info';
+15 -2
View File
@@ -175,8 +175,9 @@ test('icon rules: device_class fallback', () => {
assert.equal(iconFromDeviceClasses([]), null); assert.equal(iconFromDeviceClasses([]), null);
}); });
test('tap action: defaults to info', () => { test('tap action: defaults info everywhere except pure lights (v1.39.0)', () => {
assert.equal(resolveTapAction(undefined, undefined, 'light'), 'info'); assert.equal(resolveTapAction(undefined, undefined, 'light'), 'toggle'); // лампы кликабельны из коробки
assert.equal(resolveTapAction(undefined, undefined, 'switch'), 'info');
assert.equal(resolveTapAction(null, 'info', 'switch'), 'info'); assert.equal(resolveTapAction(null, 'info', 'switch'), 'info');
assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info'); assert.equal(resolveTapAction(null, 'more-info', 'sensor'), 'more-info');
}); });
@@ -721,3 +722,15 @@ test('outlineWithout: removes the cut stretch, keeps the rest', () => {
// без вырезов — весь периметр // без вырезов — весь периметр
assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6); assert.ok(Math.abs(outlineWithout(sq, []).reduce((a, s) => a + len(s), 0) - 40) < 1e-6);
}); });
test('resolveTapAction: pure lights toggle by default (v1.39.0)', () => {
assert.equal(resolveTapAction(null, null, 'light'), 'toggle');
assert.equal(resolveTapAction('', undefined, 'light'), 'toggle');
// явный выбор пользователя сильнее дефолта
assert.equal(resolveTapAction('info', null, 'light'), 'info');
// не-световые домены не трогаем
assert.equal(resolveTapAction(null, null, 'switch'), 'info');
assert.equal(resolveTapAction(null, null, 'sensor'), 'info');
// замок никогда
assert.equal(resolveTapAction('toggle', null, 'lock'), 'info');
});