v1.60.2-beta.2: harden device state and editor interactions
Validate / hassfest (push) Failing after 19s
Validate / hacs (push) Failing after 20s
Validate / frontend (push) Successful in 3m2s
Validate / backend (push) Failing after 9m17s
Validate / smoke (push) Failing after 14m1s

This commit is contained in:
Matysh
2026-08-08 00:12:47 +03:00
parent 5e1315f61d
commit f1537b2108
26 changed files with 627 additions and 320 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
SCHEDULED_GRACE_S = 30 * 24 * 3600
FILES_DIR = "houseplan/files"
CONF_ADMIN_ONLY = "admin_only"
VERSION = "1.60.2-beta.1"
VERSION = "1.60.2-beta.2"
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.60.2-beta.1"
"version": "1.60.2-beta.2"
}
+1
View File
@@ -15,6 +15,7 @@ const res = await page.evaluate(async () => {
out.hasDev = !!dev;
c._infoCard = dev; await c.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
out.infoUsesWideShell = sr().querySelector('hp-dialog')?.hasAttribute('wide') === true;
const footer = sr().querySelector('.infofooter');
const footerBox = footer?.getBoundingClientRect();
const footerButtons = [...(footer?.querySelectorAll('button') || [])];
+25
View File
@@ -206,6 +206,31 @@ const res = await page.evaluate(async () => {
out.objectDialogSavesStyle = editedProbe?.color === '#123456'
&& editedProbe?.width_cm === 6.5 && editedProbe?.width === undefined
&& c._decorShapeDialog === null;
// Erase gets a constant screen-space target around hairlines. Exercise the
// browser's actual SVG hit-test 6 px away from a sub-pixel painted stroke;
// dispatching directly to the proxy would only prove that its listener exists.
c._replaceDecor('dcprobe', { width_cm: 0.1, width: undefined });
c._decorTool = 'erase'; await c.updateComplete;
const paintedProbe = sr().querySelector('.decorlayer line.dshape:not(.derasehit)');
const eraseHit = sr().querySelector('.decorlayer line.derasehit');
const matrix = paintedProbe?.getScreenCTM();
let picked = null;
if (paintedProbe && eraseHit && matrix) {
const p1 = new DOMPoint(+paintedProbe.getAttribute('x1'), +paintedProbe.getAttribute('y1')).matrixTransform(matrix);
const p2 = new DOMPoint(+paintedProbe.getAttribute('x2'), +paintedProbe.getAttribute('y2')).matrixTransform(matrix);
const dx = p2.x - p1.x, dy = p2.y - p1.y, len = Math.hypot(dx, dy) || 1;
picked = sr().elementFromPoint((p1.x + p2.x) / 2 - (dy / len) * 6,
(p1.y + p2.y) / 2 + (dx / len) * 6);
}
out.eraseHairlineHasWideHitTarget = picked === eraseHit
&& getComputedStyle(eraseHit).strokeWidth === '16px'
&& getComputedStyle(eraseHit).pointerEvents === 'stroke';
picked?.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, composed: true, cancelable: true, pointerId: 45, button: 0, isPrimary: true,
}));
await c.updateComplete;
out.eraseWideHitOpensConfirmation = c._decorEraseConfirm?.id === 'dcprobe';
c._decorEraseConfirm = null;
c._decorSel = null;
c._curSpaceCfg.decor = decorBefore; // сцена как была до пробы
c._decorTool = 'select'; await c.updateComplete;
+11
View File
@@ -96,6 +96,8 @@ const res = await page.evaluate(async () => {
// the same gesture must refuse a draft with fewer than two existing edges.
const savedPath = c._path;
const savedRoomDialog = c._roomDialog;
const savedNameSel = c._nameSel;
const savedAreaSel = c._areaSel;
const ctrlClick = () => {
let prevented = false;
c._markupClick({
@@ -121,8 +123,17 @@ const res = await page.evaluate(async () => {
&& roomDialog.hasAttribute('wide');
out.roomDialogHasNoHorizontalScroll = !!roomBody
&& roomBody.scrollWidth <= roomBody.clientWidth + 1;
c._nameSel = 'No-area room';
c._areaSel = '';
c.requestUpdate();
await c.updateComplete;
const noAreaSave = sr().querySelector('hp-dialog.roomdialog .room-save');
out.noAreaUsesRegularSave = !!noAreaSave && !noAreaSave.disabled
&& sr().querySelectorAll('hp-dialog.roomdialog .room-save').length === 1;
c._path = savedPath;
c._roomDialog = savedRoomDialog;
c._nameSel = savedNameSel;
c._areaSel = savedAreaSel;
return out;
});
// значения зафиксированы прогоном на v1.43.1 и сверены с кодом (audit T1)
+1 -1
View File
@@ -61,7 +61,7 @@ out.cancelWhole = (await S()).rooms.length===5; // 4 базовых + rg
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.28,0.28));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.0625));
await page.evaluate((p)=>window.__card._splitClick(p), await R(0.25,0.5));
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._saveRoomNoArea();});
await page.evaluate(()=>{const c=window.__card; c._nameSel='Cabinet'; c._areaSel=''; c._saveRoom();});
s = await S();
out.splitRooms = s.rooms.length===6; // 4 базовых + rg разрезанная надвое
out.bigKeepsLiving = s.rooms.some(r=>r.id==='r1' && r.area==='living_room');
File diff suppressed because one or more lines are too long
+112 -87
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -478,12 +478,18 @@ hash falls back to the default.
- **Resolved device state** (2026-08-06): HA provides states per entity, not
one state per device. `resolvedDeviceStateEntities` therefore starts from
uncategorised registry entities, resolves one functional role (whole-device
domains, then semantic binary signals, then switches), and aggregates
domains, then semantic binary signals, then one representative switch), and aggregates
passive readings as the final fallback. `_visualSamples` consumes the full
result; `primaryEntity` only selects its first member where a single action
target is required. Integration option switches can no longer make an
otherwise healthy device working or unavailable merely by list order. For
`climate`, a recognized explicit `hvac_action` is authoritative: idle remains
A switch-only device never aggregates sibling option switches: integrations
which fail to categorise night mode, voice enhancement or child lock cannot
paint the whole marker as working. When generic HA metadata identifies a
dedicated Power switch in such a composite controller, `on` is a neutral
powered lifecycle and `off` reuses the faded unavailable presentation; a
lone relay keeps normal working-state yellow. For `climate`, a recognized
explicit `hvac_action` is authoritative: idle remains
neutral and heating/cooling/preheating/defrosting are working. Unknown vendor
pseudo-actions are ignored; when no recognized action exists, a current
non-off state advertised by `hvac_modes` (or a standard HA HVAC mode) is the
+17
View File
@@ -2,6 +2,23 @@
## Unreleased
## v1.60.2-beta.2 — 2026-08-08
- Composite switch-only devices now use one representative lifecycle entity
instead of treating every uncategorised feature toggle as whole-device
activity. A dedicated Power switch is selected from generic Home Assistant
metadata: powered-on appliances stay neutral, while off/unavailable ones use
the existing faded presentation. Standalone relays retain their normal
yellow working state.
- Device information uses the wide responsive dialog shell and a wrapping
footer which keeps Edit, Open in HA and Close aligned on narrow screens.
- New Room has one save path: choosing “No area” in the area list and entering
a name enables the regular Save action and stores `area: null`; the duplicate
“No area” footer button has been removed.
- Erase in the Background editor gives thin lines and outlined shapes an
invisible 16 px screen-space target without changing their appearance. The
target remains usable at every zoom level.
## v1.60.2-beta.1 — 2026-08-07
- The final review follow-up makes geometry cache invalidation root-safe and
+18
View File
@@ -8,6 +8,24 @@
## В разработке
## v1.60.2-beta.2 — 2026-08-08
- Составные устройства, представленные только набором switch-сущностей, теперь
используют один репрезентативный источник состояния вместо трактовки каждого
служебного переключателя как работы всего устройства. Отдельная сущность
Power определяется по общим метаданным Home Assistant: включённое устройство
остаётся нейтральным, а выключенное или недоступное отображается приглушённым.
Одиночные реле по-прежнему подсвечиваются жёлтым при работе.
- Информационная карточка устройства переведена на широкий адаптивный диалог;
кнопки «Редактировать», «Открыть в HA» и «Закрыть» корректно располагаются и
переносятся на узких экранах.
- В диалоге «Новая комната» оставлен единый сценарий сохранения: выбор
«— без зоны —» и введённое имя активируют обычную кнопку «Сохранить», комната
записывается с `area: null`. Дублирующая кнопка «Без зоны» удалена.
- Инструмент «Стереть» в редакторе подложки получил невидимую экранную область
попадания 16 px для тонких линий и контуров. Внешний вид объектов не меняется,
а удобная ширина сохраняется при любом масштабе.
## v1.60.2-beta.1 — 2026-08-07
- Финальные правки по ревью сделали инвалидацию геометрических кэшей
+7 -1
View File
@@ -128,7 +128,13 @@ source precedence is (`_visualSamples` / `_actEntity`):
separately de-duplicate and keep only currently controllable entities.
3. otherwise the device's **resolved state role**
(`resolvedDeviceStateEntities`): functional device domains first, then
semantic binary signals, then switches, then passive readings together.
semantic binary signals, then one representative switch, then passive
readings together. A switch-only device does not aggregate sibling feature
toggles into its working state; this covers integrations which expose power,
modes and options as uncategorised peer switches. If HA metadata identifies
a dedicated Power entity in that composite controller, Power=on is neutral
and Power=off uses the existing faded unavailable style. A lone relay is
unchanged and remains yellow while on.
`primaryEntity` is only the first entity of this same set for actions which
require one target; it no longer defines marker availability by itself.
+3 -3
View File
@@ -11,15 +11,15 @@
> (versions, publication, infrastructure), DEVELOPMENT.md for new gotchas,
> ARCHITECTURE.md for design changes, ROADMAP.md when plans move.
## Snapshot (2026-08-07)
## Snapshot (2026-08-08)
| Item | State |
|---|---|
| Version | **v1.60.2-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release with persistent unfinished outlines, independent partitions/columns and the complete follow-up review hardening. The tag points at `dev`; `main` remains on v1.60.0 |
| Version | **v1.60.2-beta.2** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release with corrected composite-device lifecycle state, responsive device information, a single no-area room save path and a wider Erase hit target. The tag points at `dev`; `main` remains on v1.60.0 |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
| CI | v1.60.2-beta.1 uses the targeted local pre-release gate; exact commands belong in the release handoff rather than this snapshot. The exact-SHA Ubuntu Validate remains mandatory and may run the broader CI matrix automatically: `release.yml` withholds the asset until every matching run finishes green. Stable releases additionally require the complete local frontend/backend/smoke gate before tagging |
| CI | v1.60.2-beta.2 uses the targeted local pre-release gate; exact commands belong in the release handoff rather than this snapshot. The exact-SHA Ubuntu Validate remains mandatory and may run the broader CI matrix automatically: `release.yml` withholds the asset until every matching run finishes green. Stable releases additionally require the complete local frontend/backend/smoke gate before tagging |
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
+2 -2
View File
@@ -120,8 +120,8 @@
- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты.
- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования.
- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Дворе».
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — список пуст: создать комнату «без зоны».
- «Без зоны» требует имя, `area: null` — Стенд: там же.
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — в списке остаётся «— без зоны —».
- При «— без зоны —» обычная кнопка «Сохранить» активна после ввода имени и создаёт комнату с `area: null`; отдельной кнопки «Без зоны» нет — Стенд: там же.
- Cancel диалога возвращает контур — Стенд: там же.
- Сохранение с зоной раскладывает устройства зоны — Стенд: не проверяется как есть (у demo-сущностей нет device registry — автораскладка по зонам не работает, маркеры на стенде явные); полноценно — локально/на даче.
- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии).
+2 -1
View File
@@ -236,7 +236,7 @@ title: План дома
5. Замкните контур кликом по первой точке или `Ctrl` + кликом в любом месте.
Быстрое замыкание доступно после двух нарисованных граней и не сработает,
если замыкающая стена создаёт самопересечение.
6. В диалоге задайте название и выберите свободную HA-зону либо сохраните без зоны.
6. В диалоге задайте название, выберите свободную HA-зону либо оставьте в списке «— без зоны —», затем нажмите «Сохранить».
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
@@ -528,6 +528,7 @@ House Plan читает реестры устройств, сущностей и
|---|---|---|---|---|
| `light.*` | Жёлтый, пока `on` | Работа, пока `on` | Стандартная лампочка off/on | Toggle |
| `switch.*`, `fan.*`, `humidifier.*` | Жёлтый, пока `on` | Работа, пока `on` | Обычно нет | Внутренняя карточка, если Toggle не выбран явно |
| Составное switch-only устройство с отдельной сущностью Power | Нейтральное при Power=`on`; приглушённое при Power=`off`/`unknown`/`unavailable` | Нет: соседние переключатели режимов и функций не считаются работой всего устройства | Обычно нет | Внутренняя карточка |
| Motion/vibration/sound binary sensor | Нейтральный | Событие около 3.3 с при переходе off→on | Нет | Внутренняя карточка |
| Occupancy/presence binary sensor | Нейтральный | Пока присутствие активно | Нет | Внутренняя карточка |
| Door/window/garage contact | Оранжевый, пока `on` | Короткое событие при открытии | Закрыто/открыто для известных классов | Внутренняя карточка |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "houseplan-card",
"version": "1.60.2-beta.1",
"version": "1.60.2-beta.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "houseplan-card",
"version": "1.60.2-beta.1",
"version": "1.60.2-beta.2",
"license": "MIT",
"dependencies": {
"lit": "^3.1.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "houseplan-card",
"version": "1.60.2-beta.1",
"version": "1.60.2-beta.2",
"description": "Interactive house plan Lovelace card for Home Assistant",
"license": "MIT",
"type": "module",
+49
View File
@@ -84,6 +84,26 @@ const unavailable = (state: string): boolean =>
const lower = (v: unknown): string => String(v ?? '').trim().toLowerCase();
/**
* A dedicated whole-device power switch, as opposed to a relay whose `on`
* state is the useful work itself. This uses generic HA registry/state
* metadata only; no integration, model or device-name exception is involved.
*/
export function isDevicePowerSwitch(hass: any, eid: string): boolean {
if (!eid.startsWith('switch.')) return false;
const reg = hass?.entities?.[eid] || {};
const st = hass?.states?.[eid];
const objectId = eid.slice('switch.'.length).toLowerCase();
if (/(?:^|_)(?:main_)?power$/.test(objectId)) return true;
const exact = [reg.translation_key, reg.original_name, reg.name]
.map(lower)
.some((value) => ['power', 'main power', 'power switch', 'питание'].includes(value));
if (exact) return true;
const friendly = lower(st?.attributes?.friendly_name);
return /(?:^|[\s._-])(?:main[\s._-]+)?power$/.test(friendly)
|| /(?:^|[\s._-])питание$/.test(friendly);
}
/** Best recognised actual-work attribute exposed by integrations. Vendor
* mode/options such as `current_operation: eco` are not action signals and
* must not suppress a climate entity's enabled-mode fallback. */
@@ -216,6 +236,35 @@ export function entityVisualSample(hass: any, eid: string): EntityVisualSample {
return base;
}
/**
* Classify a resolved device role with the small amount of topology that an
* entity alone cannot provide. A switch-only controller with several peer
* switches and a dedicated Power entity is a powered appliance: its feature
* toggles do not define device activity, Power=on is neutral, and Power=off
* reuses the existing unavailable/faded presentation. A lone relay remains a
* normal working switch.
*/
export function entityVisualSamplesForDevice(
hass: any,
resolvedEids: readonly string[],
allEids: readonly string[],
): EntityVisualSample[] {
const samples = resolvedEids.map((eid) => entityVisualSample(hass, eid));
const uncategorisedSwitches = allEids.filter((eid) =>
eid.startsWith('switch.') && !hass?.entities?.[eid]?.entity_category,
);
const lifecycle = resolvedEids.length === 1
&& uncategorisedSwitches.length > 1
&& isDevicePowerSwitch(hass, resolvedEids[0]);
if (!lifecycle) return samples;
return samples.map((sample) => {
if (sample.availability === 'unavailable') return sample;
return sample.state === 'off'
? { ...sample, availability: 'unavailable', status: 'neutral', activity: 'none', edge: 'none' }
: { ...sample, status: 'neutral', activity: 'none', edge: 'none' };
});
}
/** Combine the entities that jointly describe one marker. */
export function combineVisualSamples(samples: EntityVisualSample[]): DeviceVisualState {
if (!samples.length) return { availability: 'available', status: 'neutral', activity: 'none' };
+15 -3
View File
@@ -4,7 +4,7 @@
*/
import { iconFor, iconFromDeviceClasses, DOMAIN_PRIORITY, FALLBACK_ICON, type CompiledIconRule, EXCLUDED_DOMAINS } from './rules';
import { averageLqi, isControllable } from './logic';
import { isSemanticBinaryEntity } from './device-visual';
import { isDevicePowerSwitch, isSemanticBinaryEntity } from './device-visual';
import type { DevItem, Marker, ServerConfig } from './types';
/** Build context: a slice of hass + config resolution. */
@@ -85,7 +85,8 @@ const visibleFirst = (items: DeviceEntityCandidate[]): DeviceEntityCandidate[] =
* 1) uncategorised HA entities (all entities only as a fallback);
* 2) a whole-device state domain, if present;
* 3) semantic binary signals (presence/contact/motion/safety/running);
* 4) switches only when the device has no stronger state-bearing role;
* 4) one representative switch when the device has no stronger
* state-bearing role;
* 5) passive entities together, so one unavailable sensor does not make the
* whole marker unavailable while another reading is alive.
*/
@@ -106,7 +107,18 @@ export function resolvedDeviceStateEntities(hass: any, entIds: readonly string[]
if (semanticBinary.length) return visibleFirst(semanticBinary).map((item) => item.eid);
const switches = pool.filter((item) => item.eid.startsWith('switch.'));
if (switches.length) return visibleFirst(switches).map((item) => item.eid);
if (switches.length) {
// A switch-only integration commonly exposes one power relay plus a set
// of feature toggles (night mode, voice enhancement, child lock, etc.).
// HA gives us no device-level state and some third-party integrations do
// not mark those feature entities as `entity_category: config`. Combining
// every switch would therefore mean "any option enabled = device working".
// Prefer a dedicated HA Power entity when metadata identifies one, then
// use the same single representative that primaryEntity/actions use.
// Entity-bound markers remain exact because their list has one member.
const ordered = visibleFirst(switches);
return [(ordered.find((item) => isDevicePowerSwitch(hass, item.eid)) || ordered[0]).eid];
}
// Passive/fallback entities are all useful for aggregate availability. Keep
// the historical domain order only as a stable ordering for primaryEntity.
+36 -32
View File
@@ -104,7 +104,7 @@ import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import { langOf, t, type I18nKey } from './i18n';
import { CommandStack } from './command-stack';
import {
combineVisualSamples, edgeActivity, entityVisualSample,
combineVisualSamples, edgeActivity, entityVisualSample, entityVisualSamplesForDevice,
type DeviceActivity, type DeviceVisualState, type EntityVisualSample,
} from './device-visual';
import type { DecorShape, DecorStyle } from './editors/decor/types';
@@ -116,7 +116,7 @@ import {
type DecorBox, type SnapGeometry,
} from './editors/decor/geometry';
const CARD_VERSION = '1.60.2-beta.1';
const CARD_VERSION = '1.60.2-beta.2';
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
const DECOR_TEXT_CM_MAX = 2000;
const CELL_CM_MIN = 0.1;
@@ -2732,22 +2732,24 @@ class HouseplanCard extends LitElement {
* functional source.
*/
private _visualSamples(d: DevItem): EntityVisualSample[] {
const ids: string[] = [];
let samples: EntityVisualSample[] = [];
const cover = this._coverIndicator(d);
const lights = resolvedLightSources(this.hass, [d]);
if (cover) ids.push(cover);
else if (lights.length) ids.push(...lights.map((source) => source.eid));
if (cover) samples.push(entityVisualSample(this.hass, cover));
else if (lights.length) {
samples.push(...lights.map((source) => entityVisualSample(this.hass, source.eid)));
}
else {
const stateEntities = resolvedDeviceStateEntities(this.hass, d.entities);
if (stateEntities.length) ids.push(...stateEntities);
else if (d.primary) ids.push(d.primary);
const ids = stateEntities.length ? stateEntities : d.primary ? [d.primary] : [];
samples = entityVisualSamplesForDevice(this.hass, ids, d.entities);
}
// Safety wins independently of the presentation source.
for (const eid of d.entities || []) {
const sample = entityVisualSample(this.hass, eid);
if (sample.status === 'alarm' && !ids.includes(eid)) ids.push(eid);
if (sample.status === 'alarm' && !samples.some((item) => item.eid === eid)) samples.push(sample);
}
return ids.map((eid) => entityVisualSample(this.hass, eid));
return samples;
}
/** One semantic result feeds the plate and every non-critical activity effect. */
@@ -6694,6 +6696,7 @@ class HouseplanCard extends LitElement {
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const editing = this._mode === 'decor';
const erasing = editing && this._decorTool === 'erase';
const shapes = this._decorList.map((sh) => {
const cls = 'dshape' + (editing && this._decorSel === sh.id ? ' dsel' : '');
const style = this._decorResolvedStyle(sh);
@@ -6707,7 +6710,10 @@ class HouseplanCard extends LitElement {
return svg`<line class="${cls}" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
stroke="${style.color}" stroke-opacity="${style.opacity}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round"
@pointerdown=${down} @dblclick=${dbl}></line>`;
@pointerdown=${down} @dblclick=${dbl}></line>
${erasing ? svg`<line class="dshape derasehit" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
x1="${sh.x1 * W}" y1="${sh.y1 * H}" x2="${sh.x2 * W}" y2="${sh.y2 * H}"
@pointerdown=${down}></line>` : nothing}`;
if (sh.kind === 'rect') {
const cx = (sh.x + sh.w / 2) * W, cy = (sh.y + sh.h / 2) * H;
const ang = normalizeAngle(sh.angle);
@@ -6716,7 +6722,10 @@ class HouseplanCard extends LitElement {
stroke="${style.color}" stroke-opacity="${style.opacity}" stroke-width="${strokeWidth}"
fill="${style.fill ? style.fillColor : 'none'}" fill-opacity="${style.fill ? style.fillOpacity : 0}"
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing}
@pointerdown=${down} @dblclick=${dbl}></rect>`;
@pointerdown=${down} @dblclick=${dbl}></rect>
${erasing ? svg`<rect class="dshape derasehit" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
x="${sh.x * W}" y="${sh.y * H}" width="${sh.w * W}" height="${sh.h * H}"
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing} @pointerdown=${down}></rect>` : nothing}`;
}
if (sh.kind === 'ellipse') {
const cx = (sh.x + sh.w / 2) * W, cy = (sh.y + sh.h / 2) * H;
@@ -6726,7 +6735,10 @@ class HouseplanCard extends LitElement {
rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}" stroke="${style.color}" stroke-opacity="${style.opacity}" stroke-width="${strokeWidth}"
fill="${style.fill ? style.fillColor : 'none'}" fill-opacity="${style.fill ? style.fillOpacity : 0}"
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing}
@pointerdown=${down} @dblclick=${dbl}></ellipse>`;
@pointerdown=${down} @dblclick=${dbl}></ellipse>
${erasing ? svg`<ellipse class="dshape derasehit" data-hp="decor" data-id="${sh.id}" data-kind="${sh.kind}"
cx="${cx}" cy="${cy}" rx="${(sh.w / 2) * W}" ry="${(sh.h / 2) * H}"
transform=${ang ? `rotate(${ang} ${cx} ${cy})` : nothing} @pointerdown=${down}></ellipse>` : nothing}`;
}
if (sh.kind === 'furniture') {
// One path per piece, generated at the shape's REAL size, so the
@@ -6744,7 +6756,10 @@ class HouseplanCard extends LitElement {
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${d}" transform=${tr}
stroke="${style.color}" stroke-opacity="${style.opacity}" stroke-width="${strokeWidth}" fill="none"
stroke-linecap="round" stroke-linejoin="round"
@pointerdown=${down} @dblclick=${dbl}></path>`;
@pointerdown=${down} @dblclick=${dbl}></path>
${erasing ? svg`<path class="dshape derasehit" data-hp="decor" data-id="${sh.id}"
data-kind="${sh.kind}" data-symbol="${sh.symbol}" d="${d}" transform=${tr}
@pointerdown=${down}></path>` : nothing}`;
}
if (sh.kind === 'text') {
// The label is painted from the LIVE value on every render — the same
@@ -8131,16 +8146,10 @@ class HouseplanCard extends LitElement {
return this._opMeasure || this._openingPreview?.measure || null;
}
/** Save a room with a mandatory binding to an HA area. */
/** Save a room with an optional HA-area binding.
* An area supplies the fallback name; a room without one needs a name. */
private _saveRoom(): void {
if (!this._areaSel) return;
this._commitRoom();
}
/** Save a decorative room without an area (only a name is required). */
private _saveRoomNoArea(): void {
if (!this._nameSel.trim()) return;
this._areaSel = '';
if (!this._areaSel && !this._nameSel.trim()) return;
this._commitRoom();
}
@@ -12780,7 +12789,7 @@ class HouseplanCard extends LitElement {
const st = d.primary ? this.hass.states[d.primary] : undefined;
const stateTxt = st ? hassValue(this.hass, d.primary)?.text ?? st.state : null;
const controls = (d.controls ?? d.marker?.controls ?? []).filter(isControllable);
return html`<hp-dialog .hass=${this.hass} .title=${d.name} .icon=${d.icon}
return html`<hp-dialog .hass=${this.hass} .title=${d.name} .icon=${d.icon} wide
dismiss-on-scrim @hp-close=${() => (this._infoCard = null)}>
<div class="body">
${(() => {
@@ -12852,8 +12861,7 @@ class HouseplanCard extends LitElement {
<ha-icon icon="mdi:open-in-new"></ha-icon>${this._t('btn.open_in_ha')}
</button>`
: nothing}
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._infoCard = null)}>${this._t('btn.close')}</button>
<button class="btn ghost infofooter-close" @click=${() => (this._infoCard = null)}>${this._t('btn.close')}</button>
</div>
</hp-dialog>`;
}
@@ -13468,6 +13476,7 @@ class HouseplanCard extends LitElement {
private _renderRoomDialog(): TemplateResult {
const edit = !!this._roomEditId;
const canSaveNew = !!this._areaSel || !!this._nameSel.trim();
// the free-areas list must include the edited room's CURRENT area
const areas = [...this._freeAreas];
if (edit && this._areaSel && !areas.some((a) => a.area_id === this._areaSel)) {
@@ -13532,15 +13541,10 @@ class HouseplanCard extends LitElement {
? html`<button class="btn on" @click=${() => this._saveRoomEdit()} ?disabled=${!this._nameSel.trim()}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`
: html`<button class="btn ghost" @click=${this._saveRoomNoArea} ?disabled=${!this._nameSel.trim()}
title=${this._t('title.no_area_room')}>
${this._t('btn.no_area')}
</button>
${!this._pendingSplit ? html`<button class="btn ghost" @click=${this._keepClosedAsPartitions}>
: html`${!this._pendingSplit ? html`<button class="btn ghost" @click=${this._keepClosedAsPartitions}>
<ha-icon icon="mdi:wall"></ha-icon>${this._t('btn.keep_as_walls')}
</button>` : nothing}
<button class="btn on" @click=${this._saveRoom} ?disabled=${!this._areaSel}
title=${!this._areaSel ? this._t('title.choose_area') : ''}>
<button class="btn on room-save" @click=${this._saveRoom} ?disabled=${!canSaveNew}>
<ha-icon icon="mdi:check"></ha-icon>${this._t('btn.save')}
</button>`}
</div>
-3
View File
@@ -51,7 +51,6 @@
"btn.attach": "Attach…",
"btn.upload": "Upload…",
"btn.replace": "Replace…",
"btn.no_area": "No area",
"title.zoom_in": "Zoom in",
"title.zoom_out": "Zoom out",
"title.zoom_fit": "Fit all",
@@ -65,8 +64,6 @@
"title.markup_split": "Split a room: click the room, then two points on its walls",
"title.markup_delroom": "Delete room: click inside a room and confirm",
"title.markup_closewall": "Physical wall: click a dashed virtual stretch",
"title.no_area_room": "Decorative room without an HA area (e.g. a hallway)",
"title.choose_area": "Select a Home Assistant area",
"title.need_plan": "Upload a floor-plan image",
"markup.add": "Room outline",
"markup.merge": "Merge",
-3
View File
@@ -51,7 +51,6 @@
"btn.attach": "Прикрепить…",
"btn.upload": "Загрузить…",
"btn.replace": "Заменить…",
"btn.no_area": "Без зоны",
"title.zoom_in": "Приблизить",
"title.zoom_out": "Отдалить",
"title.zoom_fit": "Вписать всё",
@@ -65,8 +64,6 @@
"title.markup_split": "Разделить комнату: клик по комнате, затем две точки на её стенах",
"title.markup_delroom": "Удалить комнату: кликните внутри комнаты и подтвердите удаление",
"title.markup_closewall": "Физическая стена: кликните по пунктирному виртуальному участку",
"title.no_area_room": "Декоративная комната без привязки к зоне (например, холл)",
"title.choose_area": "Выберите зону Home Assistant",
"title.need_plan": "Загрузите подложку (план этажа)",
"markup.add": "Контур комнаты",
"markup.merge": "Объединить",
+22 -3
View File
@@ -992,6 +992,21 @@ export const cardStyles = css`
.stage.mode-decor.dtool-text .decorlayer .dshape.dtext {
cursor: text;
}
/* Erasing a hairline must not require pixel-perfect aim. The duplicate
geometry is invisible and exists only while Erase is active. A
non-scaling stroke keeps the target comfortably wide at every zoom. */
.decorlayer .derasehit {
fill: none;
stroke: transparent;
stroke-width: 16px;
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.stage.mode-decor.dtool-erase .decorlayer .derasehit {
pointer-events: stroke;
}
.decorlayer .dsel {
filter: drop-shadow(0 0 3px var(--hp-accent));
}
@@ -2515,17 +2530,21 @@ export const cardStyles = css`
.editorchrome-inner.nav-enter,
.editorchrome-inner.nav-exit { animation: none; }
}
/* Device info can have Edit + Open in HA + Close. A small HA dialog is
narrower than those three Russian-labelled actions; without wrapping,
HA's end-aligned footer pushed the first button outside the surface. */
/* Device info can have Edit + Open in HA + Close. It uses a wide dialog;
wrapping remains as a phone fallback, but without a flex spacer (which
used to strand Edit alone on the first line). */
hp-dialog .row.infofooter {
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: var(--sp-3);
}
hp-dialog .row.infofooter .btn {
flex-shrink: 0;
}
hp-dialog .row.infofooter .infofooter-close {
margin-left: auto;
}
@media (max-width: 480px) {
hp-dialog .row.infofooter {
padding: var(--sp-4) var(--sp-5);
+30
View File
@@ -4,6 +4,7 @@ import {
combineVisualSamples,
edgeActivity,
entityVisualSample,
entityVisualSamplesForDevice,
} from '../test-build/device-visual.js';
const hass = (states) => ({
@@ -78,6 +79,35 @@ test('media players express power without treating playback as work', () => {
);
});
test('a composite Power switch is a neutral lifecycle, while a lone relay still works', () => {
const h = {
...hass({
'switch.soundbar_power': { state: 'off', attributes: { friendly_name: 'Soundbar Power' } },
'switch.soundbar_pure_voice': { state: 'on' },
'switch.relay': { state: 'on' },
}),
entities: {
'switch.soundbar_power': {},
'switch.soundbar_pure_voice': {},
'switch.relay': {},
},
};
const soundbarIds = ['switch.soundbar_power', 'switch.soundbar_pure_voice'];
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, ['switch.soundbar_power'], soundbarIds)),
{ availability: 'unavailable', status: 'neutral', activity: 'none' },
);
h.states['switch.soundbar_power'].state = 'on';
assert.deepEqual(
combineVisualSamples(entityVisualSamplesForDevice(h, ['switch.soundbar_power'], soundbarIds)),
{ availability: 'available', status: 'neutral', activity: 'none' },
);
assert.equal(
combineVisualSamples(entityVisualSamplesForDevice(h, ['switch.relay'], ['switch.relay'])).status,
'working',
);
});
test('off media sources share unavailable styling without hiding an active peer', () => {
const off = entityVisualSample(hass({ 'media_player.one': { state: 'off' } }), 'media_player.one');
const on = entityVisualSample(hass({ 'media_player.two': { state: 'on' } }), 'media_player.two');
+39
View File
@@ -346,6 +346,45 @@ test('media role shields a marker from auxiliary light and switch entities', ()
);
});
test('switch-only device uses one representative instead of aggregating feature toggles', () => {
const hass = mkHass({
entities: {
'switch.soundbar_power': { entity_id: 'switch.soundbar_power' },
'switch.soundbar_smart_mode': { entity_id: 'switch.soundbar_smart_mode' },
'switch.soundbar_pure_voice': { entity_id: 'switch.soundbar_pure_voice' },
},
states: {
'switch.soundbar_power': { state: 'off', attributes: {} },
'switch.soundbar_smart_mode': { state: 'off', attributes: {} },
'switch.soundbar_pure_voice': { state: 'on', attributes: {} },
},
});
const entities = [
'switch.soundbar_power',
'switch.soundbar_smart_mode',
'switch.soundbar_pure_voice',
];
assert.deepEqual(resolvedDeviceStateEntities(hass, entities), ['switch.soundbar_power']);
assert.equal(primaryEntity(hass, entities, 'mdi:soundbar'), 'switch.soundbar_power');
});
test('switch-only resolver prefers a dedicated Power entity over registry order', () => {
const hass = mkHass({
entities: {
'switch.receiver_night_mode': { entity_id: 'switch.receiver_night_mode' },
'switch.receiver_power': { entity_id: 'switch.receiver_power' },
},
states: {
'switch.receiver_night_mode': { state: 'on', attributes: {} },
'switch.receiver_power': { state: 'off', attributes: { friendly_name: 'Receiver Power' } },
},
});
assert.deepEqual(
resolvedDeviceStateEntities(hass, ['switch.receiver_night_mode', 'switch.receiver_power']),
['switch.receiver_power'],
);
});
test('resolvedDeviceStateEntities: passive readings aggregate availability instead of picking the first', () => {
const hass = mkHass({
entities: {