mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
v1.60.2-beta.2: harden device state and editor interactions
This commit is contained in:
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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') || [])];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Vendored
+112
-87
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -120,8 +120,8 @@
|
||||
- Промах далеко от стены → тост — Стенд: кликнуть в центр комнаты.
|
||||
- Esc/Ctrl+Z снимает точку, Reset чистит — Стенд: во время рисования.
|
||||
- Замыкание (≥4 точек) → диалог комнаты — Стенд: нарисовать комнату во «Дворе».
|
||||
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — список пуст: создать комнату «без зоны».
|
||||
- «Без зоны» требует имя, `area: null` — Стенд: там же.
|
||||
- В диалоге только свободные зоны; выбор зоны подставляет имя — Стенд: все 6 зон заняты — в списке остаётся «— без зоны —».
|
||||
- При «— без зоны —» обычная кнопка «Сохранить» активна после ввода имени и создаёт комнату с `area: null`; отдельной кнопки «Без зоны» нет — Стенд: там же.
|
||||
- Cancel диалога возвращает контур — Стенд: там же.
|
||||
- Сохранение с зоной раскладывает устройства зоны — Стенд: не проверяется как есть (у demo-сущностей нет device registry — автораскладка по зонам не работает, маркеры на стенде явные); полноценно — локально/на даче.
|
||||
- Erase удаляет линию, удаление комнаты — полигон — Стенд: инструментом «ластик линий» в тулбаре (клик по своей линии).
|
||||
|
||||
@@ -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` | Короткое событие при открытии | Закрыто/открыто для известных классов | Внутренняя карточка |
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user