feat v1.36.2: per-source glow radius

- marker.glow_radius_cm overrides the global default per device (field
  in the marker dialog, HA units, placeholder = global default); pools
  and door sectors use the per-source radius
- backend schema; smoke_glow extended (11 checks); docs same-commit
This commit is contained in:
Matysh
2026-07-23 13:53:19 +03:00
parent df2ed0c3e1
commit 81ea5c0f3c
14 changed files with 113 additions and 26 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.36.1" VERSION = "1.36.2"
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.36.1" "version": "1.36.2"
} }
@@ -169,6 +169,7 @@ MARKER_SCHEMA = vol.Schema(
vol.Optional("description"): vol.Any(str, None), vol.Optional("description"): vol.Any(str, None),
vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None), vol.Optional("tap_action"): vol.Any("info", "more-info", "toggle", None),
vol.Optional("controls"): vol.Any([str], 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("room_id"): vol.Any(str, None), vol.Optional("room_id"): vol.Any(str, None),
vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None), vol.Optional("display"): vol.Any("badge", "ripple", "icon_ripple", None),
vol.Optional("ripple_color"): vol.Any(str, None), vol.Optional("ripple_color"): vol.Any(str, None),
+11
View File
@@ -73,6 +73,17 @@ const res = await page.evaluate(async () => {
c.requestUpdate(); await c.updateComplete; c.requestUpdate(); await c.updateComplete;
const clips2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path')]; const clips2 = [...sr().querySelectorAll('defs clipPath[id^="hp-glowclip"] path')];
out.entranceNoSector = clips2.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1); out.entranceNoSector = clips2.every((p) => ((p.getAttribute('d') || '').match(/M /g) || []).length === 1);
// 7а) персональный радиус источника перекрывает глобальный
const litMarkerId = litLight.id;
c._serverCfg = { ...c._serverCfg, markers: [
...(c._serverCfg.markers || []).filter((m) => m.id !== litMarkerId),
{ id: litMarkerId, binding: litLight.bindingKind === 'virtual' ? 'virtual' : litLight.bindingKind + ':' + litLight.bindingRef, glow_radius_cm: 150 },
] };
c._regSignature = ''; c._maybeRebuildDevices(); c.requestUpdate(); await c.updateComplete;
const rOwn = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
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;
// 7) радиус из настроек: 600 см против 300 см — вдвое больше // 7) радиус из настроек: 600 см против 300 см — вдвое больше
const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r')); const r600 = Number(sr().querySelector('.glowlayer circle')?.getAttribute('r'));
c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } }; c._serverCfg = { ...c._serverCfg, settings: { ...(c._serverCfg.settings || {}), glow_radius_cm: 300 } };
File diff suppressed because one or more lines are too long
+15 -5
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## v1.36.2 — 2026-07-23
- **Glow radius is now per source**: every device dialog gained a "Glow
radius" field (in your HA units; empty = the global default from general
settings, shown as the placeholder). A kettle's night light can glow half
a meter while the ceiling lamp floods the room. Door sectors use the same
per-source radius.
## v1.36.1 — 2026-07-23 ## v1.36.1 — 2026-07-23
- Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity - Fixed tap-toggle "doing nothing" on lamps whose individual `light.*` entity
is **hidden in the registry** (the usual setup when lamps are folded into a is **hidden in the registry** (the usual setup when lamps are folded into a
+3
View File
@@ -140,6 +140,9 @@ 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
- [ ] Per-source glow radius (v1.36.2): the device dialog has a "Glow radius"
field (HA units; empty = general-settings default shown as placeholder);
an override changes that source's pool and door sectors only [auto]
- [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in - [ ] Hidden-light primary (v1.36.1): a lamp whose light entity is HIDDEN in
the registry (folded into a light group) still toggles/reflects the lamp, the registry (folded into a light group) still toggles/reflects the lamp,
not its do-not-disturb switch or identify button; visible entities of the not its do-not-disturb switch or identify button; visible entities of the
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "houseplan-card", "name": "houseplan-card",
"version": "1.36.1", "version": "1.36.2",
"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",
+35 -6
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.36.1'; const CARD_VERSION = '1.36.2';
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';
@@ -168,6 +168,7 @@ class HouseplanCard extends LitElement {
tapAction: string; // '' = card default tapAction: string; // '' = card default
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
model: string; model: string;
link: string; link: string;
description: string; description: string;
@@ -2007,6 +2008,11 @@ class HouseplanCard extends LitElement {
tapAction: d.marker?.tap_action || '', tapAction: d.marker?.tap_action || '',
controls: [...(d.marker?.controls || [])], controls: [...(d.marker?.controls || [])],
controlsFilter: '', controlsFilter: '',
glowRadius: Number(d.marker?.glow_radius_cm) > 0
? String(this._imperial
? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10
: Math.round(Number(d.marker!.glow_radius_cm)) / 100)
: '',
model: d.model || '', model: d.model || '',
link: d.link || '', link: d.link || '',
description: d.description || '', description: d.description || '',
@@ -2020,7 +2026,7 @@ class HouseplanCard extends LitElement {
this._markerDialog = { this._markerDialog = {
name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '', name: '', binding: 'virtual', bindingFilter: '', icon: '', autoIcon: '',
display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0, display: 'badge', rippleColor: '', rippleSize: 3, size: 1, angle: 0,
tapAction: '', controls: [], controlsFilter: '', model: '', tapAction: '', controls: [], controlsFilter: '', glowRadius: '', model: '',
link: '', description: '', pdfs: [], room: '', busy: false, link: '', description: '', pdfs: [], room: '', busy: false,
}; };
} }
@@ -2215,6 +2221,11 @@ class HouseplanCard extends LitElement {
angle: dlg.angle ? dlg.angle : null, angle: dlg.angle ? dlg.angle : null,
tap_action: dlg.tapAction || null, tap_action: dlg.tapAction || null,
controls: dlg.controls.length ? dlg.controls : null, controls: dlg.controls.length ? dlg.controls : null,
glow_radius_cm: (() => {
const v = parseFloat(dlg.glowRadius);
if (!Number.isFinite(v) || v <= 0) return null;
return Math.round(this._imperial ? v * 30.48 : v * 100);
})(),
model: dlg.model.trim() || null, model: dlg.model.trim() || null,
link: dlg.link.trim() || null, link: dlg.link.trim() || null,
description: dlg.description.trim() || null, description: dlg.description.trim() || null,
@@ -2658,16 +2669,21 @@ class HouseplanCard extends LitElement {
return this.hass?.config?.unit_system?.length === 'mi'; return this.hass?.config?.unit_system?.length === 'mi';
} }
private get _glowRadiusPlaceholder(): string {
const cm = this._glowRadiusCm;
return this._imperial ? String(Math.round((cm / 30.48) * 10) / 10) : String(cm / 100);
}
/** Light pools of the current space: dark house, glowing sources. */ /** Light pools of the current space: dark house, glowing sources. */
private _renderGlowLayer(space: SpaceModel): TemplateResult { private _renderGlowLayer(space: SpaceModel): TemplateResult {
const colors = this._fillColors; const colors = this._fillColors;
const R = (this._glowRadiusCm / this._cellCm) * this._gridPitch; const defaultR = (this._glowRadiusCm / this._cellCm) * this._gridPitch;
const g = this._gridPitch; const g = this._gridPitch;
const polys = space.rooms const polys = space.rooms
.map((r) => ({ r, poly: roomPoly(r) })) .map((r) => ({ r, poly: roomPoly(r) }))
.filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly); .filter((x): x is { r: RoomCfg; poly: number[][] } => !!x.poly);
const doors = this._openingsR.filter((o) => o.type === 'door'); const doors = this._openingsR.filter((o) => o.type === 'door');
const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string | null }[] = []; const spots: { pos: { x: number; y: number }; c: string; alpha: number; clip: string | null; r: number }[] = [];
for (const d of this._devices) { for (const d of this._devices) {
if (d.space !== space.id) continue; if (d.space !== space.id) continue;
const lightEid = d.entities.find( const lightEid = d.entities.find(
@@ -2676,6 +2692,9 @@ class HouseplanCard extends LitElement {
if (!lightEid) continue; if (!lightEid) continue;
const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c); const glow = glowColorOf(this.hass.states[lightEid], colors.glow_light.c);
if (!glow) continue; if (!glow) continue;
// per-source radius (owner's decision v1.36.2): marker override, else global
const ownCm = Number(d.marker?.glow_radius_cm);
const R = Number.isFinite(ownCm) && ownCm > 0 ? (ownCm / this._cellCm) * this._gridPitch : defaultR;
const pos = this._pos(d); const pos = this._pos(d);
// innermost room under the source (islands win — reverse order) // innermost room under the source (islands win — reverse order)
const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r)); const home = [...polys].reverse().find((x) => this._pointInRoom([pos.x, pos.y], x.r));
@@ -2696,7 +2715,7 @@ class HouseplanCard extends LitElement {
} }
clip = shapes.join(' '); clip = shapes.join(' ');
} }
spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip }); spots.push({ pos, c: glow.c, alpha: colors.glow_light.a * glow.bri, clip, r: R });
} }
if (!spots.length) return svg`` as unknown as TemplateResult; if (!spots.length) return svg`` as unknown as TemplateResult;
return svg`<defs> return svg`<defs>
@@ -2709,7 +2728,7 @@ class HouseplanCard extends LitElement {
${sp.clip ? svg`<clipPath id="hp-glowclip-${i}"><path d="${sp.clip}"></path></clipPath>` : nothing}`)} ${sp.clip ? svg`<clipPath id="hp-glowclip-${i}"><path d="${sp.clip}"></path></clipPath>` : nothing}`)}
</defs> </defs>
<g class="glowlayer"> <g class="glowlayer">
${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${R}" ${spots.map((sp, i) => svg`<circle cx="${sp.pos.x}" cy="${sp.pos.y}" r="${sp.r}"
fill="url(#hp-glow-${i})" ${''} fill="url(#hp-glow-${i})" ${''}
clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)} clip-path=${sp.clip ? `url(#hp-glowclip-${i})` : nothing}></circle>`)}
</g>` as unknown as TemplateResult; </g>` as unknown as TemplateResult;
@@ -3829,6 +3848,16 @@ class HouseplanCard extends LitElement {
</div>` </div>`
: nothing} : nothing}
<label>${this._t('marker.glow_radius_label')}</label>
<div class="colorrow">
<input class="tempin" type="number" min="0.5" step="0.5"
placeholder=${this._glowRadiusPlaceholder}
.value=${d.glowRadius}
@input=${(e: Event) => (this._markerDialog = { ...d, glowRadius: (e.target as HTMLInputElement).value })} />
<span class="opl">${this._imperial ? this._t('gs.unit_ft') : this._t('gs.unit_m')}</span>
<span class="opl muted">${this._t('marker.glow_radius_hint')}</span>
</div>
<label>${this._t('marker.icon_label')}</label> <label>${this._t('marker.icon_label')}</label>
${customElements.get('ha-icon-picker') ${customElements.get('ha-icon-picker')
? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon} ? html`<ha-icon-picker .hass=${this.hass} .value=${d.icon}
+3 -1
View File
@@ -291,5 +291,7 @@
"marker.controls_label": "Controls light sources", "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_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…", "marker.controls_filter": "Search lights and switches…",
"info.controls": "Controls" "info.controls": "Controls",
"marker.glow_radius_label": "Glow radius (light-sources fill)",
"marker.glow_radius_hint": "empty = default from general settings"
} }
+3 -1
View File
@@ -291,5 +291,7 @@
"marker.controls_label": "Управляет источниками света", "marker.controls_label": "Управляет источниками света",
"marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.", "marker.controls_hint": "При действии «Переключить» клик разом переключает все привязанные источники (горит хоть один → выключить все). Значок отражает их состояние.",
"marker.controls_filter": "Поиск ламп и выключателей…", "marker.controls_filter": "Поиск ламп и выключателей…",
"info.controls": "Управляет" "info.controls": "Управляет",
"marker.glow_radius_label": "Радиус свечения (заливка «Свет по источникам»)",
"marker.glow_radius_hint": "пусто = по умолчанию из общих настроек"
} }
+2
View File
@@ -46,6 +46,8 @@ export interface Marker {
angle?: number | null; // icon rotation, degrees angle?: number | null; // icon rotation, degrees
/** Entities this icon toggles as a group (wall switch → its lights). */ /** Entities this icon toggles as a group (wall switch → its lights). */
controls?: string[] | null; controls?: string[] | null;
/** Per-source glow radius in cm (glow fill); null = the global default. */
glow_radius_cm?: number | null;
} }
/** A door or window: plan geometry (normalized coords), optionally live via entities. */ /** A door or window: plan geometry (normalized coords), optionally live via entities. */