Compare commits

...
Author SHA1 Message Date
claude[bot] 0ba651a558 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:17:50 +00:00
Matyshandclaude[bot] 8572d43c91 fix: align device catalog with plan rooms
Issue: #29
User-Visible: yes
2026-08-27 23:05:45 +00:00
claude[bot] 2f6c79c4aa docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] 4b3b71e73b feat: add device lifecycle catalog
Issue: #29
User-Visible: yes
2026-08-27 23:05:45 +00:00
claude[bot] ef1320b6b7 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] ba434693be docs: preserve hidden device ghost mode
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
claude[bot] ce75899ec5 docs: review document for #29
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
Matyshandclaude[bot] fcf30073e9 docs: update device inbox lifecycle spec
Issue: #29
User-Visible: no
2026-08-27 23:05:45 +00:00
40 changed files with 5674 additions and 2717 deletions
File diff suppressed because one or more lines are too long
+20
View File
@@ -1265,6 +1265,26 @@ export async function prepareGoldenScenario(page, scenario) {
|| dialog.querySelector('.btn.on')) {
throw new Error('golden Optimize preflight failure dialog is incomplete');
}
} else if (scenario.dialog === 'device-inbox') {
card._setMode('devices');
await card.updateComplete;
await settleMode(card);
card._openDeviceInbox();
if (scenario.deviceInboxTab) {
card._deviceInbox = { ...card._deviceInbox, tab: scenario.deviceInboxTab };
}
await card.updateComplete;
await frame();
const dialog = card.renderRoot.querySelector('hp-dialog.device-inbox-dialog');
const tabs = dialog?.querySelectorAll('.device-inbox-tabs [role="tab"]');
const rows = dialog?.querySelectorAll('.device-inbox-row');
if (!dialog || tabs?.length !== 4 || !rows?.length) {
throw new Error(`golden device lifecycle catalog is incomplete: ${scenario.id}`);
}
const body = dialog.querySelector('.device-inbox');
if (body && body.scrollWidth > body.clientWidth + 1) {
throw new Error(`golden device lifecycle catalog overflows horizontally: ${scenario.id}`);
}
} else if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
+10
View File
@@ -370,6 +370,16 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-devices-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'devices',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'device-inbox-desktop-en-light', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-inbox', language: 'en', theme: 'light',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'device-inbox-desktop-ru-dark', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-inbox', language: 'ru', theme: 'dark',
viewport: { width: 1180, height: 900 }, ...page },
{ id: 'device-inbox-narrow-ru-dark', fixture: 'visual', space: 'golden-lighting',
dialog: 'device-inbox', deviceInboxTab: 'hidden',
markerOverrides: [{ id: 'golden-light-two', binding: 'device:golden-light-two', hidden: true }],
language: 'ru', theme: 'dark', viewport: { width: 390, height: 900 }, ...page },
{ id: 'geometry-decor-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'decor',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
// HP-UX-11 visual contract: every adaptive width is captured in both
+108
View File
@@ -0,0 +1,108 @@
// #29: one lifecycle catalog replaces the separate Add / hidden-device paths.
// This smoke exercises the real Lit dialog and verifies that browsing it is
// read-only while nested marker flows return to the same catalog state.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 980, height: 820 });
const out = await page.evaluate(async () => {
const c = window.__card;
const root = () => c.renderRoot || c.shadowRoot;
const writes = [];
const originalCallWS = c.hass.callWS;
c.hass = { ...c.hass, callWS: async (message) => {
if (/^houseplan\/(config|layout)\/(set|update|delete)$/.test(message.type)) {
writes.push(message.type);
}
return originalCallWS(message);
} };
c._setMode('devices');
await c.updateComplete;
const toolbar = [...root().querySelectorAll('.editbar .btn')];
const labels = toolbar.map((node) => node.textContent.trim());
const result = {
oneCatalogEntryPoint: labels.some((label) => /Devices/i.test(label))
&& !labels.some((label) => /Hidden and disabled|^Add$/i.test(label)),
};
const before = JSON.stringify({
config: c._serverCfg, layout: c._layout, cfgRev: c._cfgRev, layoutRev: c._layoutRev,
});
c._openDeviceInbox();
await c.updateComplete;
const dialog = root().querySelector('hp-dialog');
const dialogSurface = dialog?.renderRoot?.querySelector('.surface');
const tabs = [...root().querySelectorAll('.device-inbox-tabs [role="tab"]')];
result.dialogHasFourTabs = !!dialog && tabs.length === 4;
result.desktopCatalogIsWide = !!dialogSurface && dialogSurface.getBoundingClientRect().width > 800;
result.hasOverflowMenu = !!root().querySelector('.device-inbox-menu');
result.onPlanHasRows = root().querySelectorAll('.device-inbox-row[data-category="on_plan"]').length > 0;
result.noHorizontalOverflow = !!dialog && dialog.scrollWidth <= dialog.clientWidth + 1;
// The catalog speaks in the plan's room names, not a stale HA Area name.
const initialRoomRow = c._deviceInboxRows()
.find((item) => item.areaId && c._areaToSpace[item.areaId]);
const roomTarget = initialRoomRow ? c._areaToSpace[initialRoomRow.areaId] : null;
const sourceRoom = roomTarget ? c._serverCfg.spaces.find((space) => space.id === roomTarget.space)
?.rooms?.find((room) => room.id === roomTarget.room.id) : null;
result.hasCatalogRowInPlanRoom = !!roomTarget && !!initialRoomRow && !!sourceRoom;
if (roomTarget && initialRoomRow && sourceRoom) {
const originalRoomName = sourceRoom?.name;
const planRoomName = 'Renamed room on plan';
sourceRoom.name = planRoomName;
c._cfgEpoch++;
c._deviceInboxMemo = null;
await c.updateComplete;
const roomRow = c._deviceInboxRows().find((item) => item.binding === initialRoomRow.binding);
result.planRoomNameWins = roomRow?.areaName === planRoomName;
result.planRoomNameIsSearchable = !!roomRow?.searchText.includes(planRoomName.toLowerCase());
sourceRoom.name = originalRoomName;
c._cfgEpoch++;
c._deviceInboxMemo = null;
} else {
result.planRoomNameWins = false;
result.planRoomNameIsSearchable = false;
}
const search = root().querySelector('.device-inbox-search');
search.value = 'Ceiling light';
search.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await c.updateComplete;
result.searchUsesFullSnapshot = root().querySelectorAll('.device-inbox-row').length === 1
&& root().querySelector('.device-inbox-row')?.textContent.includes('Ceiling light');
// Arrow-key tab navigation is part of the keyboard contract.
const tablist = root().querySelector('.device-inbox-tabs');
tablist.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
await c.updateComplete;
result.arrowChangesTab = c._deviceInbox?.tab === 'available';
// Restore a non-default context, open the real marker dialog, then cancel.
c._deviceInbox = { ...c._deviceInbox, tab: 'on_plan', search: 'lamp', onlyNew: false };
await c.updateComplete;
const row = c._deviceInboxRows().find((item) => item.category === 'on_plan' && item.canEdit);
c._openInboxMarker(row);
await c.updateComplete;
result.nestedDialogOpened = !!c._markerDialog && !c._deviceInbox;
c._closeMarkerDialog();
await c.updateComplete;
result.nestedCancelReturnsContext = c._deviceInbox?.tab === 'on_plan'
&& c._deviceInbox?.search === 'lamp' && c._deviceInbox?.anchor === row.key;
// Find is navigation only: it closes the dialog and selects a marker briefly.
c._deviceInbox = { ...c._deviceInbox, search: '' };
const findable = c._deviceInboxRows().find((item) => item.category === 'on_plan' && item.canFind);
c._findInboxDevice(findable);
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
await c.updateComplete;
result.findClosesAndSelects = !c._deviceInbox && c._selId === findable.deviceId;
const after = JSON.stringify({
config: c._serverCfg, layout: c._layout, cfgRev: c._cfgRev, layoutRev: c._layoutRev,
});
result.browsingIsReadOnly = before === after && writes.length === 0;
return result;
});
checkAll(out);
await finish(browser, out);
+1 -1
View File
@@ -156,7 +156,7 @@ const res = await page.evaluate(async () => {
tabs()[1].click(); await c.updateComplete;
out.directSwitch = c._mode === 'devices';
out.devBar = !!sr().querySelector('.editbar.devbar');
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 2; // devices catalog + icon rules (#29)
const swapChrome = sr().querySelector('.editorchrome');
const swapInner = swapChrome.querySelector('.editorchrome-inner');
out.editorSwapAnimatesHeight = c._modeTransitionBusy
+7 -3
View File
@@ -34,10 +34,14 @@ Object.assign(out, await page.evaluate(async () => {
o.stillBuilt = !!built && built.hidden === true;
o.countExcludesHidden = (sr().querySelector('.count')?.textContent || '').includes(String(before - 1));
// в просмотре тумблера нет эффекта — призраки только в редакторе устройств
c._showHidden = true; c.requestUpdate(); await c.updateComplete;
// Ghost-режим теперь включается из единого каталога. В View каталога нет,
// а призраки по-прежнему существуют только в Device editor.
o.noGhostsInView = !sr().querySelector('.dev.ghost');
c._setMode('devices'); c.requestUpdate(); await c.updateComplete;
c._setMode('devices'); c._openDeviceInbox(); await c.updateComplete;
const ghostToggle = [...sr().querySelectorAll('.device-inbox-filters input[type="checkbox"]')].at(-1);
o.ghostToggleMovedIntoCatalog = !!ghostToggle;
ghostToggle?.click(); await c.updateComplete;
c._deviceInbox = null; await c.updateComplete;
o.ghostInEditor = !!sr().querySelector('.dev.ghost');
// призрак — конфигурация, не статус: ни жёлтого, ни unavail, ни тревоги
const g = sr().querySelector('.dev.ghost');
+1476 -1251
View File
File diff suppressed because one or more lines are too long
+10 -4
View File
@@ -29,6 +29,7 @@ houseplan-card/
│ ├─ floating-surface-controller.ts # shared Popover/fallback portal DOM lifecycle
│ ├─ editor-secondary.ts # context tray model, groups, focus/dismiss lifecycle and stable template
│ ├─ editor-secondary.styles.ts # styles owned by the context tray/submenu surface
│ ├─ device-inbox.ts # pure exact-binding lifecycle catalog + shared Add eligibility
│ ├─ space-model-selection.ts # active-or-first and exact optional space selectors
│ ├─ render/opening-tunnels.ts # immutable SVG projection of resolved tunnel geometry/fills
│ ├─ editor.ts # GUI config editor (ha-form + selectors)
@@ -314,19 +315,24 @@ user-space shadows include the factor in their structural cache inputs.
name?, icon?, model?, link?, description?, pdfs:[{name,url}]}`. A hybrid: auto-discovered HA devices
appear on their own; a marker with `binding=device:<id>` overrides them (metadata/rebinding/hiding),
`entity:<eid>` — for groups/helpers, `virtual` — a manual icon without HA. The marker id = device_id /
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker excludes already-placed
references and duplicates by name|area. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
`lg_<eid>` / `v_<rand>` (preserves the position in the layout). The binding picker and
the Device editor lifecycle catalog use the same pure `bindingCandidates()`
eligibility helper; filtering/paging happens only after the full candidate
snapshot, so large registries cannot hide later exact entities. The catalog's
`buildDeviceInbox()` projection combines runtime devices, markers, tombstones,
HA binding statuses and `new_device_ids` without owning persistence or Lit
state. Manual files: transactional HTTP upload into `<config>/houseplan/files/<id>/`
(staging `up_*` folders promoted on save), served via signed
`/api/houseplan/content/files/…` urls.
`removed:true` is a binding tombstone, not a renderable marker. It claims an
HA binding against automatic discovery while intentionally exposing that same
binding to the Add picker. A device tombstone excludes all data of that device;
binding to the catalog's re-add flow. A device tombstone excludes all data of that device;
an entity tombstone excludes the standalone entity binding but does not mutate
the same entity out of a still-live parent device. A live exact `entity:X`
marker is the one narrow override: it may coexist with a `device:D` tombstone,
restoring X while the parent claim continues to suppress D and every sibling
without its own live exact marker. The Add picker exposes active children of a
without its own live exact marker. The catalog exposes active children of a
device tombstone only behind **Show entities**, so that combination is reachable
without weakening ordinary runtime deletion. Runtime-filtered references such
as `controls` and live text remain persisted and become active again after
+5
View File
@@ -9,6 +9,11 @@
rooms with no interior left. Length is measured along the whole wall, so a
short filler segment compensating a thickness step stays legal
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
- The Device editor now has one searchable lifecycle catalog for devices on
the plan, available bindings, hidden markers and bindings available to add
again. It explains Home Assistant status separately, can find markers on the
plan using the room names visible there, and keeps hide/show/add/re-add flows in one place
([#29](https://github.com/Matysh/houseplan-card/issues/29)).
## v1.68.1 — 2026-08-27
+5
View File
@@ -15,6 +15,11 @@
комнаты без внутреннего просвета. Длина меряется по всей стене, поэтому
короткий доборный участок, компенсирующий перепад толщин, остаётся законным
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
- В редакторе устройств появился единый каталог с поиском: устройства
на плане, доступные привязки, скрытые маркеры и привязки для
повторного добавления. Статус Home Assistant объясняется отдельно, а
поиск использует видимые на плане имена комнат; скрытие, показ и повторное добавление собраны в одном
месте ([#29](https://github.com/Matysh/houseplan-card/issues/29)).
## v1.68.1 — 2026-08-27
+12 -6
View File
@@ -73,7 +73,7 @@ the old behaviour until an editing client materialises it.
## Behaviour
| State | Renders | Hidden/disabled tool | Room/light/climate data | Add picker |
| State | Renders | Devices catalog | Room/light/climate data | Add/re-add action |
|---|---:|---:|---:|---:|
| visible marker/device | yes | — | yes | no duplicate |
| `hidden: true` | no | ghost | LQI/climate yes, visible light no | no duplicate |
@@ -85,10 +85,15 @@ the old behaviour until an editing client materialises it.
except the device editor with "Show hidden devices" on — there they render
ghosted (translucent, dashed) and clicking opens the dialog, where the
bottom-left "Show" action restores it after saving.
- "Hidden and disabled" (formerly "Show all") is LOCAL, ephemeral state of
the current tab. A disabled ghost is grey and explicitly labelled; it cannot
be dragged or shown until the binding is activated in HA. Its dialog still
permits metadata edits, Open in HA and Delete.
- The Device editor exposes one lifecycle catalog built by the pure
`device-inbox.ts` projection. Exact bindings stay in one user-intent category
(`on_plan`, `available`, `hidden`, `readd`); HA disabled/orphaned/unverified
is an independent operational status and never silently moves a row.
- **Show hidden on plan** in that catalog is LOCAL, ephemeral state of the
current editor session. A disabled ghost is grey and explicitly labelled;
it cannot be dragged or shown until the binding is activated in HA. Its
dialog still permits metadata edits, Open in HA and Delete. Opening,
searching, filtering and Find are read-only.
- Room LQI counts hidden devices (owner's decision).
- Hidden devices are NOT content for the CONTENT FRAME (docs/CANVAS.md §4,
audit DEV-2C947-01). The frame is presentation: an object the plan does not
@@ -102,7 +107,8 @@ the old behaviour until an editing client materialises it.
- Light fill and glow do NOT count hidden devices — an invisible device casts
no visible light (owner's decision). Room climate is registry-wide and
unaffected, as before.
- The bottom-left "Hide" / "Show" action appears in the dialog of every
- Hide/Show is also available from the lifecycle catalog; the bottom-left
"Hide" / "Show" action remains in the dialog of every
existing device kind, virtual included; changing it is applied by "Save".
- "Delete" appears beside Hide/Show for every existing marker. It asks for
confirmation and commits immediately. HA device/entity markers leave only
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.68.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — owner-approved emergency patch release for space creation and deletion (#324) |
| Current local cycle | v1.68.1 is the current stable release. Active branch `issue/267-device-presentation-table` is a refactor-only cycle: the marker face now has one pure ordered policy plus a canonical executable decision matrix, stable internal trace and mutation evidence. User-visible behaviour, config and renderer DOM/CSS remain unchanged. |
| Current local cycle | v1.68.1 is the current stable release. Active branch `issue/29-device-inbox-lifecycle` replaces the Device editor's separate Add/hidden paths with one searchable lifecycle catalog. The catalog is a read-only projection of existing markers, tombstones and HA registry state; no persisted model or discovery semantics change. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. 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`. Release bodies are short and bilingual (Russian first); every bullet links its GitHub issue (#NN) so the #328 rules stay machine-checkable. A STABLE body aggregates the changelog since the PREVIOUS STABLE release (never since the last beta): features/fixes described across the line's beta changelogs must appear, while bugs that were introduced and fixed strictly inside the beta line (never shipped in any stable) are excluded — draft with `npm run release:notes -- <tag>`, curate by hand, then `npm run release:notes -- <tag> --verify` must pass. `Мелкие исправления и улучшения` / `Small fixes and improvements` is allowed only when the range really contains user-visible work not itemised in the body; a single-issue hotfix ships without it (the verifier enforces this). Every body ends with separate links to the Russian and English changelogs. Open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `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) |
+7 -3
View File
@@ -1707,20 +1707,24 @@ separately promised workflows:
for confirmation, Cancel changes nothing, Confirm closes the dialog
immediately [manual]
- [ ] Delete an auto device: no icon and no Show-hidden ghost; it does not
return on rebuild/reload, but its binding is offered by Add. Re-add it:
return on rebuild/reload, but its binding is offered in **Available again**. Re-add it:
one marker only, fresh centred/grid position, no tombstone
[auto: smoke_hidden_flag; unit + manual]
- [ ] Delete `device:D`, enable Show entities in Add and restore only child
- [ ] Delete `device:D`, enable Show entities in **Devices → Available** and restore only child
`entity:X`: X receives one live marker and a fresh position; the parent
tombstone remains, so D and sibling Y do not return or contribute to
aggregates. Delete/re-add X is idempotent; explicitly re-adding D later
leaves the intentional D + X pair from #226
[auto: smoke_binding_picker; unit: devices.test.mjs; mutation x4]
- [ ] Delete an entity marker and a virtual marker: the entity is offered by
Add (with Show entities when applicable); the virtual marker is gone and
the catalog (with Show entities when applicable); the virtual marker is gone and
can be recreated manually. The exact deleted entity remains offered even
if HA marks its registry entry hidden. Other virtual markers survive both
Save and Delete [auto: smoke_hidden_flag; unit + manual]
- [ ] The Device editor has one **Devices** entry point. Its four lifecycle
tabs, counts, search, keyboard arrows and narrow layout work; browsing,
Find and a nested Edit/Cancel round-trip write neither config nor layout
[auto: smoke_device_inbox; unit: device-inbox.test; golden: device-inbox-*].
- [ ] Deleted device contributes to none of LQI, climate average, explicit room
temp/humidity, resolved lights, Light fill, Glow, room stats or another
marker's controls. Hidden device keeps the documented hidden semantics
+20 -6
View File
@@ -478,20 +478,34 @@ to automatic parent discovery; its binding tombstone does not remove registry
data from the live HA device.
After deleting a complete HA device, you can restore only one of its entities:
open **Add**, enable **Show entities**, and select that entity. House Plan
open **Devices → Available**, enable **Show entities**, and select that entity. House Plan
returns the selected marker with a fresh position while the complete device
and its other entities remain deleted. The complete device stays available in
Add if you later decide to restore it explicitly as well.
**Available again** if you later decide to restore it explicitly as well.
### Device editor
- drag a marker to save its server-side position;
- click it to edit name, binding, room, tap action and presentation;
- **Add** creates a virtual marker or picks a binding manually;
- **Hidden and disabled** reveals user-hidden and HA-disabled records only in
the editor;
- **Devices** opens one searchable lifecycle catalog. Its **On plan**,
**Available**, **Hidden** and **Available again** tabs explain where every
exact HA binding is and offer the next valid action;
- **Add virtual device** lives at the top of that catalog. Enable **Show
entities** in **Available** to place an individual entity;
- **Show hidden on plan** is a local catalog switch. It reveals user-hidden
and HA-disabled records as service ghosts only until you leave the Device
editor; it never changes the saved Hidden flag;
- **Icon rules** edits the first-match regular-expression list.
An automatically discovered marker is already **On plan** even before it has
saved marker settings. The **New** badge is independent and remains until the
marker settings are opened. **Find on plan** centres and briefly selects the
marker without changing config or acknowledging that badge. Hide and Show are
reversible; Delete leaves an exact binding tombstone and moves an active HA
binding to **Available again**. A disabled or missing binding keeps its saved
category and receives a separate Home Assistant status instead of silently
moving to another tab.
The dialog shows binding provenance, exact next tap result, skipped targets and
a live presentation preview. A saved missing source is shown as missing rather
than silently replaced.
@@ -913,7 +927,7 @@ per space. The configuration package is limited to 2 MB.
### Devices or values are missing
- confirm the room's HA-area binding and the device/entity registry area;
- open Device → Hidden and disabled;
- open Device → **Devices** and inspect **Hidden** / **Available again**;
- verify the selected source still exists and is available;
- remember that a virtual marker has no active state;
- inspect the dialog's exact target and skipped-target explanation.
+23 -9
View File
@@ -735,10 +735,10 @@ House Plan читает реестры устройств, сущностей и
binding tombstone не вырезает её из живого устройства HA.
После удаления целого HA-устройства можно вернуть только одну его сущность:
откройте **Добавить**, включите **Показывать сущности** и выберите нужную
откройте **Устройства → Доступны**, включите **Показывать сущности** и выберите нужную
сущность. House Plan вернёт выбранный маркер на свежую позицию, а всё устройство
и остальные его сущности останутся удалёнными. Само устройство продолжит
предлагаться в **Добавить**, если позднее вы решите явно вернуть и его.
предлагаться в **Доступны снова**, если позднее вы решите явно вернуть и его.
### Редактор устройств
@@ -746,10 +746,22 @@ binding tombstone не вырезает её из живого устройст
|---|---|
| Перетащить маркер | Меняет его общую серверную позицию; центр всегда оказывается на узле сетки |
| Клик по маркеру | Открывает диалог настройки |
| **Добавить** | Создаёт виртуальный маркер или вручную выбирает HA-привязку |
| **Скрытые и деактивированные** | Показывает пользовательски скрытые и отключённые в HA устройства служебными маркерами только в этой вкладке |
| **Устройства** | Открывает единый каталог с поиском и вкладками «На плане», «Доступны», «Скрытые» и «Доступны снова»; у каждой привязки показаны причина, статус HA и допустимое следующее действие |
| **Добавить виртуальное устройство** | Находится вверху каталога и создаёт маркер без HA-привязки |
| **Показывать скрытые на плане** | Локальный переключатель каталога: до выхода из редактора показывает скрытые и деактивированные маркеры служебными призраками, не меняя сохранённый флаг «Скрыть» |
| **Правила иконок** | Открывает приоритетный список регулярных выражений для «имя + модель» |
Автоматически найденный маркер уже относится к **На плане**, даже если для него
ещё нет сохранённой записи marker. Значок **Новое** — отдельный признак: поиск и
кнопка **Найти на плане** его не снимают, а открытие настроек снимает. Поиск работает по имени,
модели, интеграции, комнате, пространству и exact binding. **Найти на плане** переключает пространство
при необходимости, центрирует маркер и кратко выделяет его, не записывая config или layout.
Скрытие обратимо, а удаление оставляет tombstone точной привязки и переносит подтверждённо
существующее устройство в **Доступны снова**. Деактивация или исчезновение привязки в Home Assistant не
меняет выбранную пользователем вкладку: к ней добавляется отдельный статус HA. После настройки,
сохранения или отмены каталог возвращается с тем же поиском и фильтрами.
![Редактор устройства: источник привязки и ожидаемое действие](images/06-device-editor.png)
![Живой предпросмотр выбранного отображения устройства](images/06-device-display-preview.png)
@@ -811,11 +823,11 @@ binding tombstone не вырезает её из живого устройст
экспорт или импорт плана.
Удаление и скрытие — разные операции. Скрытый маркер можно показать через
**Скрытые и деактивированные**, и он продолжает участвовать в предусмотренных для скрытых
**Устройства → Скрытые**, и он продолжает участвовать в предусмотренных для скрытых
устройств данных. Удалённый маркер там не показывается, не участвует в LQI,
температуре, влажности, свете, Glow, карточке комнаты, управлении, текстовых
переменных и внешних controls других маркеров. Его привязка снова появляется в
списке **Добавить**. Контактный датчик или замок, явно выбранный в свойствах
списке **Доступны снова**. Контактный датчик или замок, явно выбранный в свойствах
проёма, остаётся отдельной рабочей связью и не восстанавливает удалённый маркер.
Повторное добавление создаёт маркеру новую позицию и снова оживляет сохранённые
текстовые переменные и controls, но не дублирует и не переписывает связь проёма.
@@ -826,7 +838,7 @@ binding tombstone не вырезает её из живого устройст
Скрытый маркер:
- не рисуется в просмотре;
- в редакторе виден только при включённом **Скрытые и деактивированные**;
- в редакторе виден только при включённом в каталоге **Показывать скрытые на плане**;
- не показывает цвет состояния, число, температуру, влажность и LQI;
- не создаёт световое пятно и не считается источником для заливки «Свет»;
- может продолжать участвовать в среднем LQI комнаты, потому что оно считается по реестру устройств зоны.
@@ -839,7 +851,9 @@ binding tombstone не вырезает её из живого устройст
переменные и проёмы, не выполняет действия; у пылесоса также нет puck и следа.
Конфигурация, позиция, вложения и серверная история при этом не удаляются.
В редакторе **Скрытые и деактивированные** показывает серый marker с причиной.
В каталоге **Устройства** такой marker остаётся в своей lifecycle-вкладке и
получает статус «Отключено в Home Assistant». **Показывать скрытые на плане**
показывает его серым служебным призраком с причиной.
Его можно открыть, изменить описание, удалить с плана или перейти в настройки
HA. «Показать» заблокировано до активации. После активации того же ID устройство
возвращается со старыми настройками и позицией; ранее установленное пользователем
@@ -1709,7 +1723,7 @@ cycle: 0
| Симптом | Причина/действие |
|---|---|
| Устройство не на плане | Проверьте HA-зону комнаты; откройте **Скрытые и деактивированные**; если marker серый — активируйте устройство/сущность в HA; при необходимости добавьте маркер вручную |
| Устройство не на плане | Проверьте HA-зону комнаты; откройте каталог **Устройства** и вкладки **Скрытые** / **Доступны снова**; если показан статус деактивации — активируйте устройство/сущность в HA; при необходимости добавьте маркер вручную |
| Устройство помечено деактивированным | Откройте его серый marker и нажмите «Открыть в HA». Если показано ограничение реестра, проверьте права текущего HA-пользователя или войдите администратором |
| Новая сущность не предлагается | В диалоге привязки включите **Показывать сущности** |
| Нет температуры climate | В маркере включите использование `current_temperature` |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 134 KiB

+21 -21
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"sourceFingerprint": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "827a5f0cd6f1c122a63a51d83569a2dbd87b70b3753266a3eb0a9f2986b1b911"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
"sourceSha256": "6d094396ebd90fad934bace295f8eee0e6dafda27d8f38dd31beac954ff93880",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
}
+281
View File
@@ -0,0 +1,281 @@
# CODE-REVIEW-29-r1
- Issue: #29 «[HP-UX-02] inbox и жизненный цикл устройств»
- Этап: code (PROCESS.md §2.7)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (первый реальный код-ревью:
предыдущая попытка была остановлена до чтения кода из-за конфликта ребейза —
см. комментарий issue от 2026-08-27T22:42:46Z — поэтому дельта-режим §2.9 не
применяется, разбор ниже полный)
- Проверено на SHA `905d4847a3797a8830eb6c5c509351f142193b73` (ветка
`issue/29-device-inbox-lifecycle`, перебазирована на `origin/dev@2c20f2d`)
- ТЗ: `docs/specs/029-device-inbox-lifecycle.md`, зелёный spec-review r2
(`docs/reviews/SPEC-REVIEW-29-r2.md`)
## Скоуп изменения
Один коммит `905d4847` (Issue #29, User-Visible: yes, оба CHANGELOG в этом же
коммите):
- новый чистый модуль `src/device-inbox.ts` (281 строка): `bindingCandidates`
(общий eligibility-helper, вынесенный из `_bindingCandidates`),
`buildDeviceInbox` (резолвер строк каталога), `filterDeviceInbox`;
- `src/houseplan-card.ts`: диалог каталога устройств (`_renderDeviceInbox` и
~15 приватных методов), замена кнопок «Добавить»/«Скрытые и деактивированные»
на «Устройства», перенос ghost-toggle внутрь каталога;
- `src/styles/dialogs.styles.ts` (+135), `src/hp-dialog.ts` (параметризована
ширина wide-диалога);
- `src/i18n/en.json` / `ru.json` (+45 ключей `device_inbox.*`, обновлён
`marker.hide_tip`);
- `test/device-inbox.test.mjs` (154 строки, чистый модуль);
- `demo/smoke_device_inbox.mjs` (новый), `demo/smoke_hidden_flag.mjs`
(адаптирован под перенос ghost-toggle);
- `demo/golden/{harness,matrix}.mjs` — 3 новых golden-сценария
(`device-inbox-desktop-en-light/ru-dark/narrow-ru-dark`);
- `scripts/mutation-gate.mjs`, `tsconfig.test.json` — точечные правки под
перенос кода в `device-inbox.ts`;
- документация: `docs/ARCHITECTURE.md`, `docs/FILTERING.md`, `docs/TESTING.md`,
`docs/USER-GUIDE.md`/`.ru.md`, `docs/STATUS.md`, `docs/CHANGELOG.md`/`.ru.md`,
golden-скриншоты и `screenshots.json` (docs-check зелёный, см. ниже).
## Как проверялось
Гейты, реально прогнанные мной на дереве SHA `905d4847`:
| Команда | Результат |
|---|---|
| `npx tsc --noEmit` | зелёный, без вывода |
| `npm test` | `# tests 1400 / pass 1399 / fail 0 / skipped 1` |
| `npm run build` | собран `dist/houseplan-card.js` за 10.8s |
| `md5sum dist/… custom_components/…` | идентичны после чистой пересборки (bundle:sync подтверждён) |
| `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| `npm run bundle:sync` | пересобрал и синхронизировал все три копии, включая недостающую `demo/srv/assets/…` |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 75 прямых совпадений, много «зарегистрированных связей», 41 слабая связь (полный вывод ниже) |
| `node demo/smoke_device_inbox.mjs` | `OK`, все 12 проверок true |
| `node demo/smoke_binding_picker.mjs` | `OK`, все 24 проверки true |
| `node demo/smoke_hidden_flag.mjs` | `OK`, все 27 проверок true |
| `node scripts/mutation-gate.mjs --id=device-tombstone-blocks-child-picker` | `поймано 1 из 1` — тест умеет падать |
| `npm run inventory` | Node unit 1400, backend pure 198, HA-harness 143, browser smokes 194 (совпадает с числами автора после ребейза) |
| `node demo/smoke_editor_tabs.mjs` | **FAILED** — `devBarBtns: expected true, got false` (находка H1 ниже) |
| `gh run view` на точный SHA `905d4847` | CI уже прогнан и **red**: job «Смоки в браузере (шард 2 из 3)» упал именно на `smoke_editor_tabs` с той же ошибкой; job «Golden» упал на 3 `missing-baseline` для новых сценариев (ожидаемо, см. «Что не проверялось») |
`smoke-select` — «прямое совпадение» (75) в основном ложноположительное: почти
все совпадения идут по родовым символам `_markerDialog`/`_openMarkerDialog`/
`_maybeRebuildDevices`/`_infoCard`/`_config`/`_spaceDialog`/`_zoom`, которые
задеты только потому, что `this._markerDialog = null` заменено на
`this._closeMarkerDialog()` в нескольких местах кода, не относящихся к
каталогу. Прочитал `_closeMarkerDialog()` (houseplan-card.ts:14173-14186): вне
пути возврата из каталога (`_deviceInboxReturn` не установлен) он делает ровно
то же самое, `this._markerDialog = null`, — поведение для всех смоков вне темы
каталога не меняется. Прогонять все 75 не стал; прогнал три прямо тематических
(`smoke_device_inbox`, `smoke_binding_picker`, `smoke_hidden_flag` — все зелёные
выше) и, поскольку инструмент относит `smoke_editor_tabs.mjs` только к «слабой
связи» (общее имя `_saveConfig`), посмотрел его отдельно из-за прецедента
#234 («по названию не про тему — на деле регрессия») — и он оказался красным
(находка H1). Остальные слабые связи не прогонял: все они делят по одному
общему символу (`_saveConfig`/`_applyView`/`_zoom`/`_model` и т.п.) с
изменёнными строками, не относящимися по смыслу к каталогу устройств.
Прочитан полный diff `src/houseplan-card.ts` (687 строк) построчно и полностью
модуль `src/device-inbox.ts`; сверено с ТЗ по каждому AC1–AC11 (детали в
следующем разделе), с `docs/SCOPE.md` (J4/J6, admin persona, desktop-first) и
с `docs/USER-GUIDE.ru.md` (терминология «На плане/Доступны/Скрытые/Доступны
снова», «Показывать скрытые на плане» и т.д. совпадает 1:1 с UI-строками).
## Находки
### H1 — `demo/smoke_editor_tabs.mjs` сломан этим диффом (High, в скоупе)
`demo/smoke_editor_tabs.mjs:159` — существующая, не тронутая этим PR строка:
```js
out.devBarBtns = sr().querySelectorAll('.editbar.devbar .btn:not(.barclose)').length === 3; // add/show-all/rules (v1.33.2: Reset removed)
```
ТЗ §10.1 сознательно заменяет две кнопки («Добавить», «Скрытые и
деактивированные») на одну («Устройства»), оставляя «Правила иконок» —
итоговый devbar имеет 2 кнопки, не 3. Ассерт не обновлён.
Воспроизведение — не гипотеза, дважды подтверждено:
1. Локально: `node demo/smoke_editor_tabs.mjs` → `FAILED (1): - devBarBtns:
expected true, got false`.
2. В CI на точном SHA задачи: run `33123777345` (`Проверка (CI)`, SHA
`905d4847…`), job «Смоки в браузере (шард 2 из 3)»
(https://github.com/Matysh/houseplan-card/actions/runs/33123777345/job/98697171028)
уже красный с той же строкой: `FAIL smoke_editor_tabs` /
`devBarBtns: expected true, got false`.
Это ровно тот класс дефекта, о котором предупреждает прецедент #234: смок
называется «editor_tabs» и не намекает на устройства, `smoke-select` относит
его только к слабой связи по общему имени `_saveConfig` — но по факту это
прямая регрессия темы задачи. `smoke` — один из обязательных gate-джобов
(`AGENTS.md`: `docs, provenance, process-gate, hacs, hassfest, frontend,
smoke, golden, performance_smoke, backend`), и он объективно красный на SHA
задачи прямо сейчас, а не гипотетически. Фикс тривиален и целиком в скоупе
задачи: обновить ожидаемое число (и комментарий `add/show-all/rules`) до 2
кнопок/`devices/rules`; отдельный issue не заводится (решение владельца
2026-08-19, #202).
### M2 — «комната» в каталоге — это имя HA-зоны, а не название комнаты плана (Medium, в скоупе)
`src/houseplan-card.ts`, `_deviceInboxRows()`:
```js
const areaNames: Record<string, string> = {};
for (const [id, area] of Object.entries<any>(this.hass?.areas || {})) areaNames[id] = area?.name || id;
```
Это единственный источник поля `areaName`/поисковой строки для «комнаты» в
каталоге (`src/device-inbox.ts` строит `searchText` и мета-строку строго из
переданных `areaNames`). Значение берётся из **имени HA-зоны** (`hass.areas`),
а не из **имени комнаты плана** (`RoomCfg.name`), которое пользователь
свободно переименовывает независимо от HA (единственное место записи —
`room.name = this._nameSel.trim() || room.name` в диалоге комнаты,
houseplan-card.ts:19386, без всякой синхронизации с зоной HA) и которое
показывается везде на самом плане (подпись комнаты на канвасе —
`${r.name}` — houseplan-card.ts:19054; sub-label бинд-пикера —
`(sp.title || sp.id) + ' · ' + r.name`, houseplan-card.ts:14571/14576).
Сам каталог явно различает эти два понятия: причина `no_bound_room`
переведена как «Зона HA не связана с комнатой плана» — то есть «зона» и
«комната» осознанно не одно и то же. При этом ТЗ §10.2 требует показывать в
строке «пространство и комнату», а `docs/USER-GUIDE.ru.md` (этот же коммит)
прямо обещает: «Поиск работает по имени, модели, интеграции, комнате,
пространству и exact binding» — то есть комнатой пользователю обещан
объект, названный так, как он называется на плане, а не как называется зона
Home Assistant.
Воспроизводимый сценарий: пользователь переименовал в редакторе плана
комнату «Kitchen» (имя зоны HA) в «Кухня-столовая» (`room.name`). На плане и
в бинд-пикере отображается «Кухня-столовая». В каталоге устройств та же
строка покажет и будет искаться по «Kitchen» — расхождение с тем, что видно
на самом плане, прямо в задаче, продукт которой — «объяснить, почему
устройство там, где оно есть» (§2 «После» ТЗ). Это не гипотетический边ge
case: `room.name` независимо редактируется в стандартном рабочем процессе, не
только в экзотике.
Исправление в скоупе: строить `areaNames` (или отдельный `roomNames`) из
`this._areaToSpace[areaId]?.room?.name`, а не из `this.hass?.areas`; на
`hass.areas` можно откатываться только для `available`-кандидатов без
привязанной комнаты (`no_bound_room`), где комнаты действительно ещё нет.
### L3 — `DeviceInboxRow.canOpenHa` — мёртвое поле (Low)
`src/device-inbox.ts:245`: `const canOpenHa = kind === 'device' || kind ===
'entity';` — тривиально всегда `true` (у `kind` только два значения), и само
поле нигде не читается: рендер каталога (`houseplan-card.ts`) использует
отдельный, независимый метод `this._bindingHasHaPage(row.binding)` для того
же решения. Не влияет на поведение — предлагаю удалить поле как неиспользуемое,
либо, если оно задумано для другого потребителя, использовать per отчёту.
Не блокирует; можно оставить с пометкой в этом документе (снято ревьюером).
## Проверено чтением и признано корректным
- **AC1** (единая точка входа): подтверждено кодом и `smoke_device_inbox`
(`oneCatalogEntryPoint`), плюс `smoke_editor_tabs` фактически доказывает
замену кнопок (см. H1 — сам факт правильный, сломан только счётчик).
- **AC2** (детерминированная классификация): приоритет `removed → hidden →
on_plan → available` в `buildDeviceInbox` (device-inbox.ts:219-224) читается
ровно как в §7.2 ТЗ; unit-тест «full lifecycle matrix» покрывает auto
no-marker, manual/automatic hidden, ha_disabled, orphaned, unverified,
removed active/missing, parent-tombstone+live-child, synthetic light group,
candidate без комнаты — все проходят зелёным.
- **AC3** (auto/new): `isNew: !!runtime && newDeviceIds.has(runtime.id)`,
подтверждён unit-тестами (`device:auto`/`device:d3`) и не снимается Find
(`_findInboxDevice` не трогает `_newIds`), снимается только через
`_openMarkerDialog` → `_ackNewDevice` (существующий контракт, не менялся).
- **AC4** (lifecycle/HA-status независимы): проверено unit-тестом
«HA status overlays lifecycle…» — `hidden:true+ha_disabled` остаётся в
`hidden`, `canShow=false`, `canFind` включается только вместе с
`showHiddenOnPlan`; `limited registry` (`unverified`) не создаёт ложный
disabled/orphaned — статус остаётся `unverified` дословно.
- **AC5** (exact binding/re-add): device-parent tombstone и live entity child
дают разные строки (`readd`/`on_plan`) в unit-тесте №1; логика материализации
Re-add переиспользует немодифицированный `_saveMarker`/`deletePlanMarkerRecords`
(контракт #262), только точка входа `_closeMarkerDialog` заменяет
`this._markerDialog = null`. Подтверждено `smoke_binding_picker` (24/24) и
mutation-gate (`device-tombstone-blocks-child-picker` — поймано 1/1).
- **AC6** (действия): Find/Edit/Hide/Show/Add/Hide-from-list/Re-add сверены
построчно с таблицей §10.3; `_setInboxHidden` использует существующий
`marker.hidden`, не создаёт tombstone, требует `status.kind==='active'`,
что совпадает с `canHide`/`canShow` в резолвере. Ghost-toggle перенесён и
проверен `smoke_hidden_flag` (`ghostToggleMovedIntoCatalog`, `ghostInEditor`
и весь остальной набор — 27/27).
- **AC7** (read-only): открытие/поиск/смена вкладки/Show more/Find не
вызывают `_saveConfig`/websocket-запись — проверено чтением всех
обработчиков (только присвоения `this._deviceInbox = {...}`) и
подтверждено `smoke_device_inbox.browsingIsReadOnly` (перехват `callWS`,
сравнение `config/layout/cfgRev/layoutRev` до/после — 0 записей).
- **AC8** (возврат/refresh): `_deviceInboxReturn` восстанавливает
tab/search/filter и `anchor` после Cancel/Save, `_deviceInboxMemo`
инвалидируется по `cfgRev/cfgEpoch/regSignature/newSyncKey` — подтверждено
`smoke_device_inbox.nestedCancelReturnsContext`.
- **AC9** (поиск/большие реестры): `bindingCandidates` не режет список до
фильтрации (только глобальная сортировка), пагинация (`limit`) применяется
строго после `filterDeviceInbox` — подтверждено unit-тестом на 260 сущностях
(находит `entity_259`, за пределами старого cap=200) и
`smoke_device_inbox.searchUsesFullSnapshot`.
- **AC10** (accessibility/responsive): `role=tablist`/`aria-selected`,
`aria-live=polite`, стрелочная навигация (`_deviceInboxTabKey`,
подтверждена `smoke_device_inbox.arrowChangesTab`), нет горизонтального
скролла на десктопе (`noHorizontalOverflow`) и в golden narrow-сценарии
(`device-inbox-narrow-ru-dark`, ширина 390px, harness проверяет
`scrollWidth <= clientWidth+1`). Полный ручной keyboard-проход (Tab по всем
действиям, focus-restore при закрытии) не выполнялся — см. «Что не
проверялось».
- **AC11** (compatibility): `npm test` зелёный целиком (1399/1400, 1
пропущен), включая незатронутые regression-наборы `devices.test.mjs`,
`ha-binding-status.test.mjs`; miграция конфигурации не добавлена
(`filter_seeded`/`show_all` логика не изменена по существу — см. отдельно
ниже).
- Три golden-сценария (`device-inbox-desktop-en-light/ru-dark`,
`device-inbox-narrow-ru-dark`) добавлены в матрицу и harness-препарация
проверяет 4 вкладки + отсутствие горизонтального переполнения.
- Трейлеры коммита корректны: один коммит, `Issue: #29`, `User-Visible: yes`,
оба CHANGELOG в этом же коммите.
- Отдельно проверил переход `_showAll`/`_showHidden`/legacy `settings.show_all`
(комментарий автора в коде: «Legacy configs still honour it through
buildDevices until they are seeded»): для конфигов до `filter_seeded` окно,
где чекбокс «Показывать скрытые на плане» не мог бы выключить унаследованный
`show_all:true`, закрывается автоматически при первом
`_maybeRebuildDevices()` редактирующего клиента (сидер удаляет
`settings.show_all`, houseplan-card.ts:3693) — это существующий,
немодифицированный этой задачей механизм; не завожу как находку.
## Чего не проверял и почему
- **Полный набор `demo/smoke_*.mjs` (194 файла)** — не прогонял целиком;
прогнал 4 тематических (`device_inbox`, `binding_picker`, `hidden_flag`,
`editor_tabs` — последний отдельно из-за прецедента #234) плюс сверил
вывод `smoke-select`. Полный набор — обязанность pre-release гейта
(PROCESS.md §8) и одновременно уже прогнан в CI (см. H1) — красный сейчас
именно на `editor_tabs`, других красных смоков в этом прогоне CI не было.
- **`npm run golden:verify` / приёмка baseline** — не прогонял. Три новых
golden-ID (`device-inbox-*`) не имеют базовых кадров; CI на этом SHA уже
показал `missing-baseline` для всех трёх (job «Golden», run 33123777345) —
это ожидаемое, задокументированное поведение (PROCESS.md: baseline
принимается только `npm run golden:accept -- --reviewed` на полном Linux
CI-артефакте, не в рамках код-ревью), а не находка.
- **`python -m pytest tests_backend`** — diff не трогает
`custom_components/**/*.py`; backend-код не менялся.
- **Performance-профили** — АС/риски (§17 ТЗ) не called out perf budget,
`smoke-select` не выделил performance-чувствительные пути; unit-тест на
260 записях покрывает синтетический large-registry сценарий, отдельный
benchmark не запускал.
- **Ручной keyboard/screen-reader проход** — полагался на чтение разметки
(roles/aria-атрибуты) и golden/smoke автоматику; полного ручного прохода
Tab-порядка по всем действиям строки не делал.
- **71 из 75 «прямых совпадений» `smoke-select`** — не прогонял; обоснование
см. в разделе «Как проверялось» (общие символы `_markerDialog` и т.п.,
поведение вне пути возврата из каталога не меняется — проверено чтением
`_closeMarkerDialog`).
## Вывод
Один подтверждённый High (сломанный существующий смок, воспроизведён и
локально, и в реальном CI-прогоне на SHA задачи) и один Medium в скоупе
(источник имени «комната» в каталоге — HA-зона вместо имени комнаты плана,
расходится с остальным UI и с обещанием user-guide). Оба чинятся в этой же
задаче без нового issue. Функциональное ядро (резолвер, read-only контракт,
поиск, re-add/exact-binding семантика) проверено тестами, которые умеют
падать (mutation-gate), и не вызывает возражений.
+194
View File
@@ -0,0 +1,194 @@
# CODE-REVIEW-29-r2
- Issue: #29 «[HP-UX-02] inbox и жизненный цикл устройств»
- Этап: code (PROCESS.md §2.7)
- Заход: r2 · блокирующих циклов израсходовано 1 из 4
- Проверено на SHA `8572d43c9192ace04b8e7d305d5a3589eee80071` (HEAD, ветка
`issue/29-device-inbox-lifecycle`, приведена конвейером к `origin/dev@2b1964f9`)
- Предыдущий раунд: `docs/reviews/CODE-REVIEW-29-r1.md`, жёлтый, проверен на
SHA `905d4847a3797a8830eb6c5c509351f142193b73` (эквивалент текущего
`4b3b71e73bc369d04f57ef000a30465571932bf1` — см. «Скоуп разбора» ниже, это
тот же коммит после ребейза, содержимое подтверждено идентичным)
## Скоуп разбора: почему разбор полный, а не по дельте
Заголовок задачи говорит: между r1 и этим раундом ветка приведена конвейером
к `dev` — «поверх легло 1 коммит(ов) dev, `11c0cbd4 -> 8572d43c`». Это ровно
условие §7.2 «после ребейза это другой код» — по умолчанию разбор полный.
Я не поверил этому на слово и проверил, что ребейз действительно ничего не
подмешал:
- коммит `4b3b71e7` («feat: add device lifecycle catalog») — это тот самый
коммит, что рецензировался в r1 под SHA `905d4847` (сообщение коммита то
же самое, дифф-статистика совпадает построчно: `src/device-inbox.ts` — ровно
281 строка, как в тексте r1, `src/houseplan-card.ts` — 536 вставок против
536 в свежем `git show --stat`); сам SHA `905d4847` в дереве больше не
существует именно потому, что рецензируемая история была переиграна поверх
нового `dev`-коммита — это и есть смена родителя, о которой предупреждает
§7.2, а не смысловое расхождение;
- единственный коммит `dev`, который лёг поверх (`2b1964f9`, «ci: бандл
собирается один раз…») — это перестройка CI-джобов, не трогает ни один
файл в `src/**`, `docs/specs/**`, `demo/**`; пересечения с темой задачи нет;
- `npx tsc --noEmit` / `npm test` (1409 pass / 0 fail / 1 skip) / `npm run
build` + сверка трёх копий бандла — зелёные на текущем HEAD, то есть
переигранный код компилируется и проходит тот же набор тестов, что и до
ребейза.
Итог: контент коммита, который рецензировал r1, не изменился — изменился
только его родитель. Поэтому этот раунд не повторяет построчный разбор всего
`src/houseplan-card.ts`/`src/device-inbox.ts` заново (это было бы тем самым
бесполезным полным прогоном ради нуля новой информации, о котором
предупреждает §2.9), а: (а) независимо перепроверяет, что ребейз не подменил
код тихо (сделано выше), (б) разбирает новый коммит `8572d43c` («fix: align
device catalog with plan rooms») целиком — это и есть фактическая дельта
поведения, отвечающая на находки r1, (в) заново прогоняет весь набор дешёвых
гейтов §8 на итоговом HEAD, а не только на дельте, (г) перепроверяет каждый
AC, довод которого лежит в изменённых строках (AC2, AC6, AC9 — источник
имени комнаты; AC1 — счётчик кнопок devbar), остальные наследует из r1 с
независимым подтверждением по гейтам (раздел «Унаследовано» ниже).
## Скоуп изменения (коммит `8572d43c`, единственный новый относительно r1)
Один коммит, `Issue: #29`, `User-Visible: yes`, оба CHANGELOG в этом же
коммите:
- `src/houseplan-card.ts` (`_deviceInboxRows()`): `areaNames` теперь строится
из `this._areaToSpace[id]?.room?.name` (имя комнаты плана), а не из
`this.hass?.areas[id].name` (имя зоны HA); второй проход по `areaMap`
гарантирует это и для комнат, чья зона отсутствует в текущем (возможно
урезанном) `hass.areas`-снапшоте;
- `src/device-inbox.ts`: удалено мёртвое поле `DeviceInboxRow.canOpenHa`
(не читалось нигде, рендер использует независимый `_bindingHasHaPage`);
- `demo/smoke_editor_tabs.mjs`: ожидаемое число кнопок devbar исправлено
с 3 на 2 (комментарий обновлён на `#29`);
- `demo/smoke_device_inbox.mjs`: добавлены 3 проверки — строка каталога
берёт имя из комнаты плана, а не из зоны HA, переименование комнаты на
плане отражается в каталоге и в поисковой строке немедленно;
- скриншот `docs/images/06-device-editor.png` и `screenshots.json`
пересобраны (следствие смены отображаемого текста, docs-check зелёный).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | зелёный, без вывода |
| Юниты | `npm test` | `# tests 1410 / pass 1409 / fail 0 / skipped 1` |
| Сборка | `npm run build` | собран `dist/houseplan-card.js` за 13.4s |
| Три копии бандла | `npm run bundle:sync` + `md5sum dist/… custom_components/…` | идентичны; `git status` после сборки чист — закоммиченные копии уже актуальны |
| Документация | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| Выборка смоков по дельте | `node scripts/smoke-select.mjs --base 4b3b71e7 --head HEAD` | «Прямое совпадение (1): `demo/smoke_device_inbox.mjs` ← `_areaToSpace`» — единственный релевантный смок, других слабых связей нет |
| Тематический смок (H1) | `node demo/smoke_editor_tabs.mjs` | `OK` |
| Тематический смок (M2) | `node demo/smoke_device_inbox.mjs` | `OK`, включая новые `hasCatalogRowInPlanRoom`/`planRoomNameWins`/`planRoomNameIsSearchable` |
| Тематический смок (не тронут этим коммитом, перепроверен как в r1) | `node demo/smoke_hidden_flag.mjs` | `OK`, 27/27 |
| Тематический смок (не тронут этим коммитом, перепроверен как в r1) | `node demo/smoke_binding_picker.mjs` | `OK`, 24/24 |
| Single-source-numbers | `node --test test/single-source-numbers.test.mjs` | 3/3 pass (не входит в скоуп изменения — в каталоге нет дублирующихся чисел, badge-счётчиков нет) |
| Единый источник чисел (продуктовое) | — | этот диф не добавляет и не меняет ни одной видимой пользователю величины (счётчика/площади/etc.) — есть только текстовое имя комнаты; проверять нечего |
| Инварианты модели | не прогонялись | diff не трогает рёбра комнат, `layout`, `marker.space`, `open_spans`, толщину стен — geometry не затронута (grep по diff подтверждает отсутствие этих символов) |
| CI на точном SHA задачи | `gh run view 33125028109` (run триггернут этим же коммитом `8572d43c`) | «Фронтенд» ✓, «Перф-смок» ✓, все 3 шарда браузерных смоков ✓ (включая шард 2, где в r1 падал `smoke_editor_tabs` — теперь зелёный), «Golden» ✗ — `missing-baseline` для `device-inbox-desktop-en-light/ru-dark`, `device-inbox-narrow-ru-dark` (лог job подтверждает: `missing-baseline device-inbox-desktop-en-light` и т.д.) — то же самое ожидаемое состояние, что и в r1, не новая находка |
Полный `node scripts/smoke-select.mjs --base origin/dev --head HEAD` уже
выполнялся и разобран в r1 (75 прямых совпадений, обоснование см. там); для
этого раунда достаточно delta-режима (`--base 4b3b71e7`), так как только он
отвечает на вопрос «что изменилось между r1 и r2» — полная выборка от `dev`
не даёт новой информации, потому что нерассмотренная r1 часть дифа не
менялась.
Прочитан полный diff коммита `8572d43c` (все 10 файлов) построчно.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **H1** (High, в скоупе) — `demo/smoke_editor_tabs.mjs` ожидал 3 кнопки в devbar (`add/show-all/rules`), а ТЗ §10.1 оставляет 2 («Устройства», «Правила иконок») — существующий смок был красным на SHA задачи, в т.ч. в реальном CI | Ассерт исправлен на `=== 2`, комментарий переписан на `devices catalog + icon rules (#29)`; фактическая разметка `_renderDevicesBar()` (houseplan-card.ts:20950-20970) рендерит ровно 2 `.btn:not(.barclose)` (кнопка каталога + кнопка правил, `_editorToolbarGroups` в демо-конфиге пуст) | `demo/smoke_editor_tabs.mjs:159`; локальный прогон `OK`; CI run `33125028109`, job «Смоки в браузере (шард 2 из 3)» — зелёный (был красным на SHA r1) |
| **M2** (Medium, в скоупе) — источник имени «комната» в каталоге — `hass.areas[id].name` (имя зоны HA), а не `RoomCfg.name` (имя комнаты плана); расходится с остальным UI и с текстом `USER-GUIDE.ru.md` («поиск… по комнате») | `areaNames` теперь строится в первую очередь из `this._areaToSpace[id]?.room?.name`; второй проход по `areaMap` дополнительно покрывает комнаты, чья HA-зона отсутствует в (возможно урезанном) `hass.areas`-снапшоте текущего пользователя (комментарий в коде это явно объясняет) | `src/houseplan-card.ts:14100-14110`; новый смок-сценарий переименовывает `room.name` на живом конфиге и проверяет, что строка каталога и `searchText` немедленно отражают новое имя (`demo/smoke_device_inbox.mjs`, `planRoomNameWins`/`planRoomNameIsSearchable` — оба `true`) |
| **L3** (Low, снято автором) — `DeviceInboxRow.canOpenHa` — мёртвое поле, нигде не читается | Поле удалено из интерфейса и построения строки | `src/device-inbox.ts` — оба места (`interface`, `push`) отсутствуют; `grep -rn canOpenHa` по репозиторию не находит использований (только упоминание в тексте r1) |
Все три находки r1 закрыты по существу (проверено чтением кода и
исполнением тестов/смоков, а не заявлением автора).
## Проверено (полный охват AC, с указанием источника доказательства)
Диф `8572d43c` не трогает резолвер жизненного цикла, read-only контракт,
пагинацию, accessibility-разметку и compatibility-слой — они не менялись со
времени r1, где были разобраны построчно и подтверждены тестами/mutation-gate.
Ниже — не слепое доверие, а повторное подтверждение тем же набором
тестов/гейтов на итоговом HEAD (см. таблицу гейтов выше: `npm test`
1409/1410, все тематические смоки зелёные на `8572d43c`), с отдельной пометкой,
где что-то реально поменялось в этом раунде.
- **AC1** (единая точка входа, счётчик кнопок): изменилось в этом раунде —
подтверждено чтением разметки `_renderDevicesBar` и `smoke_editor_tabs` (см.
«Закрытие r1 → H1»).
- **AC2** (детерминированная классификация): не менялось; наследуется из r1
(unit-тест «full lifecycle matrix», `npm test` зелёный).
- **AC3** (auto/new): не менялось; наследуется из r1.
- **AC4** (lifecycle/HA-status независимы): не менялось; наследуется из r1.
- **AC5** (exact binding/re-add): не менялось; наследуется из r1
(`smoke_binding_picker` 24/24 перепрогнан на HEAD, зелёный).
- **AC6** (действия строки, включая «комнату» в мета-строке): источник имени
комнаты изменился в этом раунде — закрывает M2, подтверждено новым смок-
сценарием (`planRoomNameWins`/`planRoomNameIsSearchable`).
- **AC7** (read-only): не менялось; наследуется из r1
(`smoke_device_inbox.browsingIsReadOnly` перепрогнан, `true`).
- **AC8** (возврат/refresh): не менялось; наследуется из r1
(`nestedCancelReturnsContext` перепрогнан, `true`).
- **AC9** (поиск/большие реестры): поисковая строка теперь включает имя
комнаты плана, а не зоны HA — это и есть предмет M2; unit-тест на 260
сущностях (не менялся, не завязан на имя комнаты) и
`searchUsesFullSnapshot` перепрогнаны, зелёные.
- **AC10** (accessibility/responsive): не менялось; наследуется из r1
(`arrowChangesTab`, `noHorizontalOverflow` перепрогнаны, `true`).
- **AC11** (compatibility): `npm test` перепрогнан целиком на HEAD, 1409/1410
зелёных (то же соотношение, что и в r1, плюс 10 новых утверждений в смоках).
- Трейлеры всех 7 коммитов диапазона `origin/dev..HEAD`: `Issue: #29` везде;
`User-Visible: yes` у `8572d43c` и `4b3b71e7` (единственные, меняющие
видимое поведение) — в обоих оба CHANGELOG правились в том же коммите
(проверено `git show --stat` и содержимым диффов).
## Унаследовано из r1 (без повторного построчного чтения)
Документ: `docs/reviews/CODE-REVIEW-29-r1.md`, SHA `905d4847` (= текущий
`4b3b71e7` после ребейза, содержимое идентично — проверено дифф-статистикой
и повторным прогоном тестов, см. «Скоуп разбора» выше). Принято без
повторного посимвольного чтения (но с независимым перепрогоном тестов на
HEAD):
- построчный разбор `src/houseplan-card.ts` (диалог каталога, ~15 приватных
методов рендера/действий строк) и полного модуля `src/device-inbox.ts` —
логика приоритета статусов, `bindingCandidates`, пагинация;
- сверка терминологии с `docs/USER-GUIDE.ru.md` и `docs/SCOPE.md` (J4/J6,
admin persona, desktop-first) — не менялась в этом раунде за пределами
правки M2, которая саму терминологию подтверждает, а не опровергает;
- разбор 75 «прямых совпадений» `smoke-select` от `origin/dev` — обоснование
(общие символы `_markerDialog`/`_saveConfig` и т.п., не относящиеся к теме)
не пересматривалось, так как ни один из этих участков кода не тронут
коммитом `8572d43c`;
- вывод о переходе `_showAll`/legacy `settings.show_all` — не менялся.
## Чего не проверял и почему
- **Полный набор `demo/smoke_*.mjs` (194 файла, `ls demo/smoke_*.mjs | wc -l`)**
— не прогонял целиком локально; CI на точном SHA `8572d43c`
(run `33125028109`) уже прогнал все 3 шарда браузерных смоков полностью и
все зелёные — это закрывает вопрос лучше, чем повторный локальный прогон.
- **`npm run golden:verify` / приёмка baseline** — не прогонял; три новых
golden-ID по-прежнему без базовых кадров (`missing-baseline` в CI job
«Golden», run `33125028109`, лог подтверждён) — то же ожидаемое состояние,
что и в r1, приёмка baseline — не обязанность код-ревью (PROCESS.md).
- **`python -m pytest tests_backend`** — diff не трогает
`custom_components/**/*.py`.
- **Инварианты модели (`npm run invariants`)** — diff не трогает геометрию
(нет изменений рёбер комнат, `layout`, `marker.space`, `open_spans`, записей
толщины стен) — проверено grep'ом по диффу, гейт не применим.
- **Performance-профили** — не затронуты, не названы в AC/§17 ТЗ.
- **Ручной keyboard/screen-reader проход** — не менялся этим коммитом,
унаследовано из r1 (роли/aria не тронуты).
## Вывод
Оба блокирующих/скоуповых замечания r1 (H1 — сломанный существующий смок,
M2 — комната каталога называлась по зоне HA, а не по имени комнаты плана)
закрыты по существу: чтением кода, перепрогоном тестов/смоков и реальным CI
на точном SHA задачи (шард с падением H1 теперь зелёный). L3 снято автором
чисто. Новых находок в коммите `8572d43c` не обнаружено. Рекомендация:
зелёный вердикт.
+191
View File
@@ -0,0 +1,191 @@
# SPEC-REVIEW-29-r1
- Issue: https://github.com/Matysh/houseplan-card/issues/29
- Этап: spec (PROCESS.md §2.4)
- Заход: r1 · блокирующих циклов израсходовано 0 из 4
- ТЗ: `docs/specs/029-device-inbox-lifecycle.md`
- SHA материала ревью: `a6ce1ae7e534fa4b41fcc0ab93e5ed95a693c4c1` (ветка `issue/29-device-inbox-lifecycle`)
- Трек: обычный (не `small`), лимит циклов ревью ТЗ — 4.
Это первый заход ревью по этой задаче — предыдущего вердикта в issue нет,
раздел «Унаследовано» не применяется, разбор полный.
## Скоуп разбора
Проверено:
1. `docs/SCOPE.md` — соответствие job'ам J4/J6, отсутствие выхода за
продуктовую рамку (out-of-scope список, standing rules).
2. `AGENTS.md` и `PROCESS.md` §2.4, §7.1 — формальные требования к ТЗ и
формат вердикта.
3. Тело issue #29 и все комментарии, включая повторную актуализацию анализа
от 2026-08-28 и решение владельца по Q1.
4. `docs/USER-GUIDE.ru.md` (терминология «Скрытые и деактивированные»,
«Редактор устройств», раздел про скрытие/удаление) и `docs/FILTERING.md`
(канонический контракт hidden/removed/tombstone/entity-ownership).
5. Исходный код: `src/houseplan-card.ts` (`_bindingCandidates`,
`new_device_ids`, `_maybeRebuildDevices`, `.slice(0, 200)` дважды),
`src/ha-binding-status.ts` (`HaBindingStatus.kind`), `src/types.ts`
(`marker.hidden`, `marker.removed`), `src/i18n/ru.json` и `en.json`.
6. Существующие тесты, упомянутые в AC11/§16: `test/devices.test.mjs`,
`test/ha-binding-status.test.mjs`, `test/device-presentation*.test.mjs`,
`demo/smoke_hidden_flag.mjs`, `demo/smoke_binding_picker.mjs` —
существуют, содержимое `smoke_hidden_flag.mjs` прочитано целиком.
7. `docs/CONFIG-COMPATIBILITY.md` — беглая проверка на отсутствие
противоречий заявлению «нет новых persisted-полей».
Ревью — на этапе ТЗ: продуктового кода нет, автотесты не запускались (их
ещё не существует), гейты §8 к этому этапу не относятся.
## Метод проверки
Каждое фактическое утверждение ТЗ о текущем поведении продукта
(«До», §3, §7, §8, §9, §10.1) сверено с кодом или каноническим документом, а
не принято на слово автора. Отдельно проверено, есть ли в тексте
утверждение о поведении, которого нет ни в одном документе и которое не
помечено как предположение (раздел 21 ТЗ).
## Находки
### M1 (Medium, в скоупе) — исчезновение призрачного показа скрытых/HA-disabled маркеров на плане не названо как продуктовое решение
**Файл:** `docs/specs/029-device-inbox-lifecycle.md`, §10.1 (строки 216–222).
Сегодня редактор устройств может показать скрытые и HA-disabled маркеры
прямо на плане, в их реальной позиции, призраками — режим переключается
кнопкой «Скрытые и деактивированные» (`docs/FILTERING.md` строки 84–91,
`docs/USER-GUIDE.ru.md:178` — «доступны скрытые маркеры», перетаскиваются;
подтверждено в `demo/smoke_hidden_flag.mjs`, где ghost рендерится в
`_setMode('devices')` с `_showHidden = true` и клик по нему открывает
диалог). Это единственный способ увидеть, ГДЕ на плане сидит скрытое или
деактивированное устройство, не отменяя его скрытость.
ТЗ прямо убирает этот режим: «Скрытые markers больше не рисуются поверх
плана постоянным локальным режимом: доступ к ним даёт каталог» (§10.1).
Но каталог (§10.2–10.4) не даёт эквивалента: `Find` явно доступен «только
если marker реально отрисовывается» (§10.4), а для строк категории
«Скрытые» и для «На плане, временно не отображается из-за HA status»
primary/secondary действия — это «Показать»/«Настроить», не позиция на
плане. Значит, чтобы увидеть, где стоит скрытый маркер, администратору
придётся сначала его показать (что меняет конфиг), посмотреть, и при
необходимости скрыть обратно — вместо непосредственного просмотра.
Это видимое пользователю изменение объёма функциональности («какая
персона что видит и делает» — ровно тот класс вопросов, который согласно
PROCESS.md §7.1 задаётся владельцу или явно фиксируется как принятое
предположение в §21). В тексте ТЗ оно подано как самоочевидный
технический побочный эффект объединения кнопок, а не как решение,
которое можно оспорить: обоснование «устраняет состояние панели,
неочевидное после возврата в редактор» — это плюс нового дизайна, но оно
не адресует потерю прямого просмотра позиции.
**Не High**, потому что: обходной путь существует (Показать → посмотреть/
перетащить → Скрыть), это не потеря данных и не поломка AC — просто
непроверенное продуктовое допущение, которое дёшево закрыть на этом этапе.
**Как закрыть в этом же цикле (на выбор автора):** либо явно вынести это в
блок §21 как предположение, которое ревьюер/владелец может оспорить, с
описанием обходного пути; либо задать это владельцу одним пакетным
вопросом с предложенным дефолтом (например: «Find для скрытой/disabled
строки временно подсвечивает позицию на плане не снимая hidden» как
альтернативный дизайн); либо сознательно сохранить упрощённый вариант, но
явно назвать компромисс и обходной путь в §2 «До/После» и в §18 «Риски».
### M2 (Medium, в скоупе) — устаревающая строка i18n не включена в план обновления
**Файлы:** `src/i18n/ru.json:673`, `src/i18n/en.json:673` (`marker.hide_tip`).
Текущий текст подсказки при скрытии маркера дословно ссылается на кнопку,
которую это ТЗ удаляет: RU — «Вернуть его можно через кнопку «Скрытые и
деактивированные» в редакторе устройств»; EN — `Restore it through "Hidden
and disabled" in the device editor`. §10.1 заменяет обе кнопки («Добавить»
и «Скрытые и деактивированные») одной кнопкой «Устройства» (`devbar.add`,
`devbar.show_all` перестают существовать в UI в текущем виде).
Раздел 14 (i18n) перечисляет только **новые** ключи и не содержит пункта
«обновить существующие ключи, ссылающиеся на удаляемые элементы
интерфейса». Если реализовать ТЗ как написано, `marker.hide_tip` останется
нетронутым и после релиза будет указывать пользователю нажать
несуществующую кнопку — конкретный, проверяемый дефект, а не гипотетический.
**Как закрыть:** добавить в §14 явный пункт «`marker.hide_tip` (en+ru)
обновляется, чтобы указывать на новую точку входа «Устройства»» (и
проверить, нет ли других строк с той же ссылкой — быстрый `grep` по
`show_all`/«Скрытые и деактивированные» в `src/i18n/*.json` показывает,
что это единственная пара строк такого рода, кроме самих `devbar.*`
/`title.show_all`, которые и так меняются по §10.1).
Обе находки Medium, в скоупе задачи (правки в самом файле ТЗ) — по
PROCESS.md §2.4/§2.7 они не создают отдельный issue (решение владельца
2026-08-19, #202) и возвращают ТЗ автору с жёлтым вердиктом.
## Что проверено и признано корректным
- **Формальные разделы §7.1** — сценарий, «что человек увидит до/после»,
проблема, скоуп/не-скоуп, контракт поведения, UX, модель данных и
миграция, i18n, AC1…AC11 с указанием способа доказательства, план
автотестов, риски, откат, release-артефакты — все присутствуют.
- **AC1–AC11** однозначны и у каждого назван способ доказательства
(unit/smoke/golden/review); ни один не описывает недоказуемое поведение.
- **Q1 владельца** (классификация автообнаруженного видимого устройства
без marker) корректно отражена в §7.2 (категория `on_plan`) и в AC3 —
соответствует решению владельца в комментарии issue.
- **Заявления о текущем поведении, использованные как база для «До»,
подтверждены кодом и канонoм**, а не додуманы:
- `_bindingCandidates` в `src/houseplan-card.ts:14032` — существующий
eligibility-код, который ТЗ предлагает извлечь в общий helper (§12);
не выдумка.
- `HaBindingStatus.kind` (`active`/`ha_disabled`/`orphaned`/`unverified`)
в `src/ha-binding-status.ts:14-17` — точное совпадение с таблицей §7.3.
- `marker.hidden`/`marker.removed` в `src/types.ts:118,121` — существуют,
семантика совпадает с `docs/FILTERING.md`.
- Жёсткий кап на 200 элементов в существующих списках кандидатов
(`src/houseplan-card.ts:14106`, `19114` — `.slice(0, 200)`) —
подтверждает описанную в §8.1/AC9 проблему, которую каталог обязан не
унаследовать.
- `duplicate_name_area` действительно устарела: `docs/FILTERING.md:145`
прямо говорит «Duplicate names are still numbered», не скрываются.
- `settings.new_device_ids` фильтруется от уже скрытых id при seed
(`src/houseplan-card.ts:3679–3684`) — подтверждает §8.2 «Первично
отфильтрованный скрытый кандидат не получает badge, как и сегодня».
- Терминология «Добавить», «Скрытые и деактивированные», «Правила
иконок» — точное совпадение с `src/i18n/ru.json:455-457` и
`docs/USER-GUIDE.ru.md`.
- **Технические предположения (§21)** промаркированы явно и корректно
отделены от продуктовых решений — кроме пробела, описанного в M1.
- **Не входит (§6)** корректно исключает #44 (discovery-настройки), #126
(смена area), #109 (multi-channel), bulk-операции, историю/графики,
touch-паритет — всё по `docs/SCOPE.md` и `docs/TOUCH-SUPPORT.md`, без
расширения скоупа.
- **Откат (§19)** реалистичен: нет новых persisted-полей и версии модели,
откат — вернуть старые кнопки; совместимость с созданными через «Скрыть
из списка» обычными `hidden:true` маркерами сохраняется.
- Названные тестовые артефакты существуют: `test/devices.test.mjs`,
`test/ha-binding-status.test.mjs`, `test/device-presentation.test.mjs`,
`test/device-presentation-policy.test.mjs`, `demo/smoke_hidden_flag.mjs`,
`demo/smoke_binding_picker.mjs`, `scripts/smoke-select.mjs` — ни один не
является выдумкой.
## Чего не проверял
- Не запускал никакие гейты (`typecheck`/`test`/`build`/смоки) — на этапе
ТЗ продуктового кода нет, это не применимо (§8 относится к
код-ревью).
- Не проверял `docs/ARCHITECTURE.md` и `docs/CANVAS.md` целиком постранично
— только точечно то, что касается заявленных в ТЗ архитектурных решений
(pure resolver, eligibility helper); полный аудит этих документов не
требуется для ревью ТЗ.
- Не оценивал производительность реализации (её ещё нет); проверил только,
что заявленная асимптотика (§12, §17) не противоречит существующему коду
(`.slice(0, 200)` факт капа подтверждён).
- Не проверял golden/скриншот-инфраструктуру предметно — она релевантна
коду, а не ТЗ.
## Вердикт
Полностью выполненные формальные требования ТЗ (§7.1) не отменяют жёлтый
вердикт: две находки Medium в скоупе (M1, M2) — реальные пробелы, оставить
их «в тексте ревью» без правки запрещено §12 PROCESS.md. High-находок нет.
**Вердикт: жёлтый.** ТЗ возвращается автору на правку M1 и M2; после
правки — новый заход ревью по дельте (PROCESS.md §2.10).
+163
View File
@@ -0,0 +1,163 @@
# SPEC-REVIEW-29-r2
- Issue: https://github.com/Matysh/houseplan-card/issues/29
- Этап: spec (PROCESS.md §2.4)
- Заход: r2 · блокирующих циклов израсходовано 1 из 4
- ТЗ: `docs/specs/029-device-inbox-lifecycle.md`
- SHA предыдущего вердикта (r1): `a6ce1ae7e534fa4b41fcc0ab93e5ed95a693c4c1`
- SHA материала этого раунда: `375b402d940a0ea30f23becef95f9755212ecbd9`
(ветка `issue/29-device-inbox-lifecycle`, коммит «docs: preserve hidden
device ghost mode»)
- Трек: обычный, лимит циклов ревью ТЗ — 4.
## Скоуп разбора (по дельте, PROCESS.md §2.10)
Предыдущий вердикт (r1, документ `docs/reviews/SPEC-REVIEW-29-r1.md`) —
жёлтый, две находки Medium в скоупе (M1, M2), High нет. Автор ответил
комментарием в issue со ссылкой на коммит `375b402b` (фактический SHA —
`375b402d`, см. выше) и заявил закрытие обеих находок.
Дельта — `git diff a6ce1ae7e534fa4b41fcc0ab93e5ed95a693c4c1..HEAD` по файлу
ТЗ: 36 добавленных / 11 удалённых строк, все — в §10.1, §10.2, §10.3, §10.4,
§14, AC1, AC4, AC6, §18 (риски). Дельта локальна: один документ, правки
адресуют ровно M1 и M2, новая подсистема не затронута, поведенческий
контракт вне уже согласованных Q1/AC не меняется, объём дельты не
сопоставим с объёмом исходного ТЗ (548 строк). Условия «разбор остаётся
полным» (ребейз на ушедший вперёд `dev`, смена контракта, новая подсистема,
сопоставимый объём) не выполнены — сокращаю объём разбора до дельты и её
последствий для AC1, AC4, AC6, §14, §18.
Продуктового кода на этом этапе нет, гейты §8 к ревью ТЗ не относятся (как
и в r1).
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **M1** (Medium, в скоупе) — §10.1 убирал существующий режим просмотра скрытых/HA-disabled маркеров призраками на плане, не давая эквивалента в каталоге (`Find` работал только для реально отрисованного marker) | Автор не выбрал ни один из трёх предложенных путей закрытия буквально, а снял противоречие в корне: существующий локальный ghost-режим **сохранён** целиком (позиция, выбор, настройка, drag), его переключатель просто переносится с панели внутрь каталога как новый switch «Показывать скрытые на плане», доступный во всех вкладках. `Find` для скрытых/HA-disabled строк теперь доступен при включённом ghost-режиме (было — недоступен вовсе) | `docs/specs/029-device-inbox-lifecycle.md` §10.1 (новый абзац «Существующий локальный режим… сохраняется»), §10.2 п.6 (новый switch), §10.3 (столбец «Дополнительные действия» для трёх категорий — «Найти призрак при включённом ghost-режиме»), §10.4 (переписанное условие доступности `Find`), AC1 («Локальный ghost-toggle доступен внутри каталога, а его активность видна на кнопке «Устройства»»), AC4 (переписан), AC6 (добавлен абзац с явным описанием эквивалентности старому поведению), §18 (новая строка риска с защитой) |
| **M2** (Medium, в скоупе) — `marker.hide_tip` (ru/en) ссылался на удаляемую кнопку «Скрытые и деактивированные» / «Hidden and disabled», §14 не включал обновление существующих строк | §14 получил новый абзац: `marker.hide_tip` обновляется на обеих языках, чтобы вести в каталог «Устройства», плюс явное требование поиска прочих ссылок на старые названия кнопок перед реализацией | `docs/specs/029-device-inbox-lifecycle.md` §14, новый абзац сразу после списка тестируемых строк i18n |
Обе находки закрыты по существу, а не декларативно: текст, который был
предметом претензии (удаление режима просмотра позиции; необновлённая
подсказка), в текущей редакции отсутствует, проверено чтением файла
целиком, а не поиском одной фразы.
## Проверка дельты
- **AC1** (golden + smoke): формулировка «активность видна на кнопке
«Устройства»» — проверяемое поведение (класс/атрибут кнопки для smoke,
визуальное состояние для golden), метод доказательства не изменился и
остаётся адекватным.
- **AC4** (unit): новая формулировка «Show недоступен, а Find доступен
только для призрака при включённом ghost-режиме» согласована с §10.3/§10.4
той же дельты — противоречий нет.
- **AC6** (smoke): добавленный абзац описывает переключатель ghost-режима
как воспроизводящий текущее поведение 1:1 (позиция/выбор/настройка/drag,
без изменения `marker.hidden`, сброс при выходе из редактора) — это то же
утверждение, что и в §10.1, не новое обязательство.
- **§14** (i18n): новый пункт обязывает обновить `marker.hide_tip` в обоих
языках и найти прочие ссылки на старые названия кнопок. Точечно
перепроверено по коду (не только по тексту ТЗ): `src/i18n/ru.json:673` и
`src/i18n/en.json:673` — единственная пара строк с буквальной ссылкой на
удаляемую кнопку (тот же результат, что и в r1); `title.show_all` и
`devbar.show_all` и так входят в объём замены по §10.1 и отдельного
упоминания не требуют.
- **§18** (риски): новая строка «Перенос ghost-toggle делает активный режим
незаметным после закрытия каталога» ссылается на AC6 как на защиту, но
видимое active-state кнопки описано в AC1, а не в AC6 — см. находку L1
ниже.
Остальные AC (AC2, AC3, AC5, AC7–AC11) дельтой не задеты текстуально и не
зависят по доказательству от изменённых разделов — наследую вывод r1 без
повторной проверки (см. «Унаследовано»).
## Находки
### L1 (Low) — риск в §18 ссылается не на тот AC
**Файл:** `docs/specs/029-device-inbox-lifecycle.md`, §18, строка риска
«Перенос ghost-toggle делает активный режим незаметным после закрытия
каталога».
Защита названа как «Активное состояние кнопки «Устройства» и сброс при
выходе из редактора; AC6». Однако видимое active-state кнопки «Устройства»
описано в AC1 («его активность видна на кнопке «Устройства»»), а не в AC6
(AC6 — про воспроизведение самого ghost-поведения: позиция/выбор/drag).
Сброс режима при выходе из редактора действительно упомянут в AC6 —
ссылка верна только для половины защиты.
Не создаёт двусмысленности AC и не блокирует реализацию: оба AC
(AC1 и AC6) существуют, проверяемы и в сумме покрывают заявленную защиту —
перепутана только адресация в таблице рисков.
**Решение ревьюера:** снимается без правки текста ТЗ. Основание —
PROCESS.md §2.4 «Low либо правится, либо снимается решением ревьюера с
записью»: неверная перекрёстная ссылка в информационной таблице рисков не
меняет ни один AC, не вводит в заблуждение исполнителя относительно того,
*что* защищает риск (только относительно того, *под каким номером* это
искать), и правка не стоит очередного цикла ревью на light-независимой
задаче с уже потраченным одним из четырёх циклов.
## Унаследовано из r1
Документ: `docs/reviews/SPEC-REVIEW-29-r1.md`, SHA `a6ce1ae7e534fa4b41fcc0ab93e5ed95a693c4c1`.
Принято без повторной проверки в r2, поскольку дельта их не касается:
- Соответствие `docs/SCOPE.md` (job J4/J6, персона Home admin, отсутствие
выхода за продуктовую рамку, `Не входит §6`).
- Формальная полнота §7.1 (все обязательные разделы присутствуют).
- AC1–AC11 однозначны, у каждого назван способ доказательства (кроме
точечных правок AC1/AC4/AC6, разобранных выше в этом раунде).
- Корректность отражения решения владельца по Q1 в §7.2 и AC3.
- Проверка фактических утверждений о текущем поведении кодом и каноном:
`_bindingCandidates`, `HaBindingStatus.kind`, `marker.hidden`/`marker.removed`,
`.slice(0, 200)` кап, `duplicate_name_area` устарела, фильтрация
`new_device_ids` от уже скрытых id, терминология «Добавить»/«Правила
иконок».
- §21 «Принятые технические предположения» — маркировка предположений
корректна и отделена от продуктовых решений (кроме пробела M1, который
в r2 закрыт не через §21, а прямым сохранением поведения — что делает
сам вопрос неактуальным: решать стало нечего, поведение не меняется).
- Существование тестовых артефактов: `test/devices.test.mjs`,
`test/ha-binding-status.test.mjs`, `test/device-presentation*.test.mjs`,
`demo/smoke_hidden_flag.mjs`, `demo/smoke_binding_picker.mjs`,
`scripts/smoke-select.mjs`.
- Откат (§19) реалистичен, обратно совместим.
## Что проверено и признано корректным (в этом раунде)
- M1 и M2 закрыты по существу — см. таблицу выше.
- Новый текст §10.1–§10.4 и AC1/AC4/AC6 внутренне непротиворечив: описание
условия доступности `Find` (§10.4) совпадает с формулировками в таблице
действий (§10.3) и в AC4.
- Новая формулировка `marker.hide_tip` (замена ссылки на кнопку) не создаёт
нового расхождения с `src/i18n/*.json` — единственная затронутая пара
строк подтверждена прямым чтением файлов.
- Старый (ошибочный) текст §10.1 «Скрытые markers больше не рисуются…» и
«устраняет состояние панели» полностью удалён из документа — проверено
`grep` по всему файлу, совпадений нет.
- Терминология не расходится с `docs/USER-GUIDE.ru.md`/`FILTERING.md`:
старое «Скрытые и деактивированные» (панель) и новое «Показывать скрытые
на плане» (switch внутри каталога) — разные элементы UI с разной
областью действия, противоречия нет; обновление USER-GUIDE уже стоит в
§20 Release-артефакты.
## Чего не проверял
- Не повторял полную сверку §7.1/AC1–AC11 с кодом — она выполнена в r1 и
дельта её не аннулирует (см. «Унаследовано»).
- Не запускал никакие гейты (`typecheck`/`test`/`build`/смоки/`check-docs`) —
на этапе ТЗ продуктового кода нет, диф не затрагивает `src/**`, гейты §8
относятся к код-ревью.
- Не проверял `docs/ARCHITECTURE.md`/`docs/CANVAS.md` — дельта их не
касается.
- Не оценивал заново перформанс (§17 дельтой не затронут).
## Вердикт
High-находок нет. Обе Medium-находки r1 закрыты по существу и подтверждены
чтением текущего файла, а не заявлением автора. Единственная новая находка
этого раунда — Low, снята решением ревьюера с записью, без возврата на
правку.
**Вердикт: зелёный.** ТЗ переходит в «Готово к разработке».
+541 -47
View File
@@ -1,65 +1,559 @@
# ТЗ #29 — Inbox и объяснимый жизненный цикл устройств
# ТЗ #29 — единый каталог и жизненный цикл устройств
- Issue: https://github.com/Matysh/houseplan-card/issues/29
- Приоритет: P1
- Статус ТЗ: draft, требуется UX-утверждение
- Связано: #44 переносит advanced-фильтры в этот интерфейс
- Тип: feature
- Трек: обычный
- Связано: #44 (настройки discovery), #109 (поиск каналов устройства),
#126 (смена HA area), #262 (возврат exact entity после удаления device)
## Цель
## 1. Сценарий
Заменить разрозненные «Добавить»/«Показать скрытые» одним read-only при
открытии каталогом, который объясняет состояние каждой HA-привязки и даёт
явные действия жизненного цикла.
**Персона:** администратор дома из `docs/SCOPE.md`.
## Единая классификация
**Поверхность:** полный `houseplan-card`, desktop-first редактор устройств.
Pure resolver получает registry snapshot, live states, markers и product
filters и возвращает одну запись на canonical binding:
**Момент:** пользователь добавил, скрыл или удалил устройства, Home Assistant
обнаружил новые или деактивировал существующие, и теперь нужно понять, какие
объекты представлены на плане и что с каждым из них можно сделать.
| Раздел | Условие | Основные действия |
## 2. Что человек увидит до и после
**До:** в панели отдельно находятся «Добавить» и «Скрытые и
деактивированные»; они позволяют выполнить операцию, но не дают единой картины
и не объясняют, почему конкретного устройства нет на плане.
**После:** одна кнопка «Устройства» открывает каталог всех относящихся к плану
HA-привязок с поиском, понятной категорией, причиной текущего состояния и
доступным следующим действием; автоматически появившееся устройство уже
считается находящимся на плане и отдельно помечается как новое.
## 3. Проблема
Текущая реализация уже поддерживает несколько устойчивых состояний:
- автоматически отображаемое устройство без сохранённого `marker`;
- явный видимый marker;
- пользовательски или автоматически скрытый `hidden:true` marker;
- временно неактивную HA-привязку (`ha_disabled`, `orphaned`, `unverified`);
- удалённую exact binding с `removed:true`, которую можно добавить заново;
- отдельную `entity:X`, возвращённую из-под tombstone родителя `device:D`.
Эти состояния обслуживаются разными UI-путями и не образуют объяснимого
каталога. Пользователь вынужден помнить разницу между скрытием, удалением,
деактивацией в HA, фильтрацией и автоматическим появлением. В результате
одинаковое отсутствие значка визуально выглядит как несколько разных причин,
а поиск уже размещённого устройства возможен только глазами на плане.
Старое ТЗ от 9 августа дополнительно считало любую binding без marker «новой».
Это расходится с текущим zero-config поведением: автообнаруженное устройство в
привязанной HA area уже рисуется без marker.
## 4. Цель и метрики результата
Создать одну read-only при открытии проекцию lifecycle, которая использует
существующие данные и действия, а не вводит второй источник истины.
Результат считается достигнутым, если:
1. для каждой включённой в каталог exact binding существует ровно одна базовая
категория;
2. каталог отличает пользовательское намерение от временного статуса HA;
3. открытие, поиск, смена вкладки и просмотр причины не меняют config, layout и
их revisions;
4. все существующие операции — найти, настроить, добавить, показать и добавить
заново — доступны из одной поверхности;
5. текущая автоматическая раскладка, tombstones, exact entity ownership и
фильтрация не меняют семантику.
## 5. Скоуп
Входит:
- единый каталог в редакторе устройств;
- pure resolver строк каталога;
- категории, статусы, причины, счётчики и поиск;
- действия над одной строкой;
- возврат из существующего диалога устройства обратно в каталог;
- локальное выделение найденного marker на плане;
- desktop, узкая responsive-компоновка без горизонтального скролла, keyboard
navigation;
- RU/EN i18n, unit/smoke/golden, пользовательская и каноническая документация.
## 6. Не входит
- изменение правил автоматического discovery, группировки света и списка
исключённых интеграций — это #44;
- управление HA registry: активация, деактивация, переименование или смена area;
- автоматический перенос marker при смене HA area — это #126;
- исправление отсутствующих каналов конкретного multi-channel устройства — #109;
- bulk hide/show/delete/add;
- история устройств, графики и статистика;
- добавление виртуальных markers в lifecycle-каталог: они не имеют HA binding и
остаются редактируемыми кликом на плане;
- гарантированная полнофункциональность редактора на touch по
`docs/TOUCH-SUPPORT.md`.
## 7. Термины и две независимые оси
### 7.1 Exact binding
Ключ строки — точная привязка `device:<device_id>` либо
`entity:<entity_id>`. `device:D` и `entity:X`, принадлежащая D, являются разными
ключами и могут осознанно сосуществовать. Семейство HA device не является
единицей дедупликации.
### 7.2 Базовая категория — пользовательское намерение
| Категория | Условие | Смысл |
|---|---|---|
| Новые | активная допустимая binding без marker/tombstone | Добавить, скрыть |
| На плане | live marker, `hidden !== true` | Найти, редактировать |
| Скрытые | live marker `hidden:true` либо seed-кандидат filter | Показать/добавить, причина |
| Доступные снова | tombstone `removed:true`, binding снова существует | Добавить заново |
| `on_plan` — «На плане» | Binding представлена runtime-устройством на плане либо живым marker с `hidden !== true` | Объект уже принадлежит плану. Отсутствие сохранённого marker не делает автоустройство «не добавленным» |
| `available` — «Доступны» | Активная допустимая binding не представлена runtime-устройством, живым marker или tombstone | Её можно добавить впервые |
| `hidden` — «Скрытые» | Живой marker имеет `hidden:true` | Объект сохранён, но пользовательское намерение — не показывать его |
| `readd` — «Доступны снова» | Exact binding имеет `removed:true` и её существование сейчас подтверждено | Объект был удалён с плана и может быть создан заново |
HA-disabled binding остаётся в своём lifecycle-разделе, но получает статус
«Отключено в Home Assistant» и недоступное действие показа согласно текущему
контракту disabled devices. Orphaned сохранённый marker остаётся «На плане» или
«Скрытые» с предупреждением, а не превращается в «Новый».
Приоритет категорий для одного exact key:
## Причины
`removed → hidden → on_plan → available`.
Причина — stable enum, локализованный в UI: `manual_hidden`, `ha_disabled`,
`service_entry`, `excluded_integration`, `excluded_domain`, `grouped_light`,
`represented_by_parent`, `duplicate_name_area`, `removed`, `orphaned`,
`limited_registry`. Regex/id могут быть в раскрываемой диагностике, но не в
основной фразе.
Живой exact marker перекрывает tombstone той же exact binding. Tombstone
родителя `device:D` не перекрывает живую `entity:X`, и наоборот, согласно #262.
## UX
### 7.3 Операционный статус — состояние Home Assistant
- Кнопка редактора устройств открывает wide `hp-dialog`/side sheet с поиском,
tabs/filters и счётчиками.
- Просмотр, поиск и раскрытие причины ничего не пишут в config.
- «Найти» переключает пространство, закрывает inbox и мягко выделяет marker.
- «Добавить» открывает существующий device dialog с preselected binding.
- «Скрыть» материализует `hidden:true`, но не `removed:true`.
- «Добавить заново» заменяет tombstone одним live marker и не наследует старую
позицию, файлы или trail, уже удалённые подтверждённым Delete.
- Все mutation actions получают Undo/confirmation согласно их текущей
семантике; bulk actions в v1 не входят.
Статус накладывается на категорию и не переносит строку между вкладками:
## Инварианты и конкуренция
| Статус | Источник | Поведение |
|---|---|---|
| `active` | `HaBindingStatus.kind === 'active'` либо положительное доказательство для нового кандидата | Доступны обычные действия категории |
| `ha_disabled` | Авторитетный registry подтверждает деактивацию | Строка помечена «Отключено в Home Assistant»; показать, добавить и выполнить Find нельзя |
| `orphaned` | Авторитетный registry подтверждает отсутствие сохранённой binding | Строка сохраняет свою базовую категорию, получает предупреждение и безопасные действия Edit/Delete |
| `unverified` | Registry недоступен или ограничен | Нельзя делать отрицательный вывод; строка не объявляется удалённой или деактивированной |
Canonical binding уникальна. Повторный save и конфликт revision не создают
дубликат. Registry refresh обновляет список с сохранением tab/search, но не
закрывает редактируемый dialog. Limited registry не выводит ложный «удалён».
Пример: сохранённый видимый marker, который позже деактивировали в HA, остаётся
в «На плане», но строка сообщает, что сейчас он временно не показывается.
`hidden:true` + HA-disabled остаётся в «Скрытые» и показывает обе причины.
## Проверки и приёмка
## 8. Состав каталога
- матрица resolver по marker/hidden/removed/disabled/orphaned/filter;
- два клиента и revision conflict;
- re-add device/entity tombstones, virtual marker вне inbox;
- поиск, keyboard navigation, narrow layout и ru/en golden;
- любой кандидат находится ровно в одном разделе и имеет понятную причину;
- открытие inbox не меняет config/layout/revisions.
Pure resolver строит детерминированный snapshot из:
- runtime `_devices`;
- `config.markers`;
- `settings.new_device_ids`;
- авторитетного или ограниченного HA registry snapshot;
- live states и текущей проекции доступности;
- действующих product filters и synthetic light groups;
- связей HA area → пространство/комната.
Кандидаты собираются из того же источника и по тем же eligibility-правилам,
что текущий Add picker. Нельзя поддерживать отдельную, постепенно расходящуюся
копию правил.
Виртуальные markers исключаются. Tombstone без положительного доказательства,
что exact binding снова существует, не показывается как «Доступно снова».
Limited registry никогда не превращает неизвестность в `ha_disabled`,
`orphaned` или `readd`.
### 8.1 Entity-уровень
- Уже размещённые `entity:*` markers всегда присутствуют в каталоге.
- В категории «Доступны» individual child entities показываются после включения
фильтра **«Показывать сущности»**, как в текущем Add dialog.
- Поиск выполняется по полному индексу и может найти exact entity независимо от
её позиции после первых 200 элементов; hard cap до фильтрации запрещён.
- Явные `device:D` и `entity:X` не скрывают друг друга из каталога. Residual
automatic parent продолжает строиться по текущему контракту `buildDevices`.
### 8.2 Признак «Новое»
`settings.new_device_ids` остаётся единственным серверным источником этого
признака.
- Автоматически появившееся и уже видимое устройство находится в «На плане» и
получает badge/фильтр **«Новое»**.
- Открытие каталога и действие Find не подтверждают новизну.
- Открытие существующего диалога настройки через Edit подтверждает новизну по
текущему контракту.
- Первично отфильтрованный скрытый кандидат не получает badge, как и сегодня.
## 9. Причины и объяснения
Строка может иметь одну основную lifecycle-причину и дополнительные статусы.
В UI используются локализованные сообщения, не внутренние regex или raw id.
Stable reason enum:
- `visible_auto` — найдено автоматически и уже находится на плане;
- `visible_explicit` — добавлено или настроено явно;
- `manual_hidden` — скрыто пользователем;
- `automatic_hidden` — скрыто автоматически, точная историческая причина не
доказуема;
- `service_entry`;
- `excluded_integration`;
- `excluded_domain`;
- `grouped_light` / `represented_by_parent`;
- `removed`;
- `no_bound_room` — доступно, но HA area не связана с комнатой плана;
- `ha_disabled_device`, `ha_disabled_entity`, `ha_disabled_all_entities`;
- `orphaned_device`, `orphaned_entity`;
- `registry_unavailable`.
`duplicate_name_area` удаляется из старого ТЗ: совпадающие имена сейчас
нумеруются, а не скрываются.
Если точную причину старого seed-marker нельзя доказать по текущему registry и
правилам, показывается честное **«Скрыто автоматически»**. Новое поле с
исторической причиной в config не добавляется.
Raw binding и диагностические детали допускаются в раскрываемой строке
«Технические сведения» и копируются отдельно; они не заменяют пользовательское
объяснение.
## 10. UX
### 10.1 Панель редактора
- Кнопки **«Добавить»** и **«Скрытые и деактивированные»** заменяются одной
кнопкой **«Устройства»** с иконкой списка/устройств.
- **«Правила иконок»** остаётся отдельной кнопкой.
- Существующий локальный режим показа скрытых и HA-disabled markers призраками
на плане сохраняется, включая их реальную позицию, выбор, настройку и
перетаскивание. Его переключатель переносится с основной панели внутрь
каталога; это не persisted-настройка и не меняет `marker.hidden`.
- После закрытия каталога выбранный локальный режим действует до выхода из
редактора устройств, как сегодня. При повторном входе в редактор он выключен;
кнопка **«Устройства»** показывает активное состояние, чтобы режим не был
скрыт от пользователя.
### 10.2 Диалог
Используется wide `hp-dialog` с заголовком **«Устройства на плане»**.
Верхняя область:
1. поле поиска с initial focus;
2. действие **«Добавить виртуальное устройство»**;
3. вкладки/фильтры со счётчиками:
- **На плане**;
- **Доступны**;
- **Скрытые**;
- **Доступны снова**;
4. дополнительный фильтр **«Новые»** для «На плане»;
5. **«Показывать сущности»** для «Доступны»;
6. локальный switch **«Показывать скрытые на плане»**. Он доступен во всех
вкладках, включает существующий ghost-render скрытых и HA-disabled markers и
ничего не сохраняет в config/layout сам по себе.
Строка содержит:
- текущую иконку;
- пользовательское имя;
- тип «Устройство»/«Сущность»;
- пространство и комнату, если известны;
- интеграцию/модель, если известны;
- badge «Новое» и operational status;
- одну короткую причину;
- primary action и меню дополнительных действий.
Поиск регистронезависим и работает по имени, модели, интеграции, названию
пространства/комнаты, `entity_id` и exact binding. Сначала фильтруется полный
snapshot, затем применяется progressive rendering. Первые 100 строк выводятся
сразу, остальные — кнопкой «Показать ещё» порциями по 100; счётчики всегда
относятся к полному результату.
Пустое состояние каждой вкладки объясняет, что сюда попадает и какое действие
может изменить результат.
### 10.3 Действия строк
| Категория/статус | Primary | Дополнительные действия |
|---|---|---|
| На плане, реально отображается | **Найти на плане** | Настроить, Скрыть |
| На плане, временно не отображается из-за HA status | **Настроить** | Найти призрак при включённом ghost-режиме; Открыть в HA; Удалить через существующий dialog |
| Скрытые, active | **Показать** | Найти призрак при включённом ghost-режиме; Настроить |
| Скрытые, HA-disabled | **Настроить** | Найти призрак при включённом ghost-режиме; Открыть в HA; «Показать» disabled с объяснением |
| Доступны | **Добавить** | Скрыть из списка |
| Доступны снова | **Добавить заново** | — |
Hide/Show — обратимые одиночные действия без подтверждения, как текущий флаг
скрытия. Delete остаётся только в существующем диалоге и сохраняет текущее
подтверждение. Add/Re-add не сохраняют объект немедленно: они открывают
существующий device dialog с preselected exact binding; Cancel ничего не пишет.
«Скрыть из списка» материализует обычный `hidden:true` exact marker и переносит
строку в «Скрытые». Это не tombstone и всегда обратимо.
### 10.4 Find и возврат из настройки
Find:
- доступен для обычного marker, если тот реально отрисовывается, и для
hidden/HA-disabled marker, если включён локальный ghost-режим; в противном
случае действие disabled и объясняет, что сначала надо включить
**«Показывать скрытые на плане»**;
- переключает пространство при необходимости;
- закрывает каталог;
- перемещает viewport так, чтобы marker оказался внутри безопасной центральной
области, не меняя zoom без необходимости;
- выделяет marker существующим selection style и дополнительным конечным
акцентом не дольше 1,5 секунды;
- не открывает карточку, не выполняет tap action и не подтверждает badge «Новое».
Add/Edit/Show через существующий device dialog временно заменяют каталог.
Cancel или Save возвращают пользователя в тот же tab/search/filter и к той же
логической строке; snapshot пересчитывается. После удаления открывается
обновлённая категория «Доступны снова», если binding подтверждена HA.
Registry/config refresh при открытом каталоге обновляет строки и счётчики, но
не сбрасывает tab/search/filter. Открытый device dialog не закрывается из-за
refresh; результат применяется после возврата.
## 11. Модель данных, сохранение и конкуренция
Новых persisted-полей и версии модели нет.
- Категория, статус, reason и состояние UI вычисляются в памяти.
- Tab, search, entity/new filters, progressive limit, scroll anchor и
`returnToInbox` — локальное состояние текущего экземпляра карточки и не
переживают reload.
- Hide/Show используют существующий `marker.hidden`.
- Delete/Re-add используют существующий `marker.removed` и текущую очистку
layout/files/trails.
- New badge использует `settings.new_device_ids` без смены формата.
- Любая mutation проходит существующий config revision/expected-rev путь.
Конфликт не создаёт дубликат: карточка перечитывает snapshot, сохраняет
tab/search и показывает локализованный toast.
- Открытие каталога, Find, поиск, смена вкладки, раскрытие причины и Show more не
вызывают `_saveConfig`, layout update или acknowledgement.
Существующий `filter_seeded` остаётся compatibility-механизмом. Если старый
config материализуется при входе редактирующего клиента, это отдельная
идемпотентная миграция текущего продукта; каталог не инициирует и не маскирует
её как пользовательское действие.
## 12. Архитектурный контракт
Новый pure-модуль (рабочее имя `src/device-inbox.ts`) владеет:
- `DeviceInboxRow`, category/status/reason enums;
- сборкой и дедупликацией exact bindings;
- capability flags действий;
- поисковым индексом и стабильной сортировкой.
Он не импортирует Lit, не пишет config и не выполняет service calls.
Eligibility новых bindings обязана использовать общий helper текущего Add
picker. В ходе реализации общая логика извлекается из
`houseplan-card.ts::_bindingCandidates`; две независимые реализации запрещены.
Стабильная сортировка внутри вкладки:
1. `new`;
2. blocking operational status;
3. localized display name;
4. exact binding как tie-breaker.
Runtime complexity: O(devices + entities + markers) на snapshot плюс O(rows)
на поиск. Нельзя для каждой строки повторно обходить все registry entities.
Результат memoize по config epoch, registry revision, device roster signature,
new ids и UI entity filter.
## 13. Доступность и responsive
- `hp-dialog` сохраняет focus trap и restore focus.
- Search получает initial focus; Escape закрывает dialog.
- Tabs имеют `role=tablist`, выбранная вкладка — `aria-selected`; стрелки
переключают вкладки.
- Строка и каждое действие достижимы Tab; icon-only действия имеют aria-label.
- Изменение результатов поиска объявляется ненавязчивым `aria-live=polite`.
- На узкой ширине метаданные переносятся под имя, действия не создают
горизонтальный скролл.
- View/киоск не меняются. Редактор на touch остаётся best effort; каталог не
должен ломать pinch/pan View и не добавляет gestures на сам план.
## 14. i18n
Новые ключи создаются одновременно в `src/i18n/en.json` и
`src/i18n/ru.json` для:
- кнопки и заголовка каталога;
- четырёх вкладок, счётчиков и empty states;
- search, Show entities, New, Show more;
- Add virtual, Find, Edit, Hide, Show, Hide from list, Add, Re-add;
- всех reason/status из §9;
- unavailable action hints, refresh/conflict toast и accessibility labels.
Существующий `marker.hide_tip` в обоих языках обновляется: вместо удаляемой
кнопки **«Скрытые и деактивированные»** он направляет в каталог
**«Устройства»**. Перед реализацией выполняется поиск остальных ссылок на старые
названия кнопок; пользовательские строки не должны вести к отсутствующему UI.
Имена integration/model/binding не переводятся. Формулировки интерфейса
синхронизируются с RU/EN user guide.
## 15. Критерии приёмки
### AC1 — единая точка входа (golden + smoke)
В Device editor вместо двух кнопок Add/Show hidden отображается одна кнопка
«Устройства»; она открывает каталог с четырьмя категориями, поиском и
счётчиками. Локальный ghost-toggle доступен внутри каталога, а его активность
видна на кнопке «Устройства». «Правила иконок» остаётся доступной.
### AC2 — детерминированная классификация (unit)
Матрица покрывает auto no-marker, explicit visible, manual/automatic hidden,
HA-disabled, orphaned, unverified, removed active, removed missing,
device-parent tombstone + live entity child, synthetic light group и candidate
без bound room. Каждая включённая exact binding находится ровно в одной базовой
категории.
### AC3 — принятое правило auto/new (unit + smoke)
Автообнаруженное уже видимое устройство без marker находится в «На плане» и
получает «Новое», если его runtime id есть в `new_device_ids`. Открытие каталога
и Find badge не снимают; Edit снимает по текущему контракту.
### AC4 — lifecycle и HA status независимы (unit)
`hidden:true + ha_disabled` остаётся в «Скрытые» с двумя объяснениями;
`hidden:false + ha_disabled` остаётся в «На плане»: Show недоступен, а Find
доступен только для призрака при включённом ghost-режиме.
Limited registry не создаёт ложный disabled/orphaned/readd.
### AC5 — exact binding и повторное добавление (unit + smoke)
`device:D` tombstone и живой `entity:X` образуют две корректные строки.
Re-add exact binding заменяет только её tombstone, не создаёт дубликат, не
возвращает старую позицию/файлы/trail и сохраняет соседний parent/child lifecycle.
### AC6 — действия используют существующие транзакции (smoke)
Find, Edit, Hide, Show, Add, Hide from list и Re-add дают результат §10.3.
Cancel из Add/Re-add не меняет данные; Delete остаётся подтверждаемым действием
существующего dialog.
Переключатель ghost-режима внутри каталога воспроизводит текущий локальный показ
hidden и HA-disabled markers: после закрытия каталога их можно найти, выбрать,
настроить и перетащить без изменения `marker.hidden`; выход из редактора
сбрасывает режим.
### AC7 — read-only открытие (unit + smoke)
Snapshot config, layout и обе revisions до/после открытия, поиска, tab switch,
reason expand, Show more и Find идентичны. Ни один из этих путей не вызывает
config/layout websocket write.
### AC8 — возврат и live refresh (smoke)
После Edit/Save/Cancel каталог возвращается с прежними tab/search/filter и
логическим scroll anchor. Registry/config refresh пересчитывает строку без
закрытия открытого device dialog и без дубликатов.
### AC9 — поиск и большие реестры (unit + smoke)
Поиск находит строку по каждому полю §10.2, включая entity за пределом первых
200 исходных записей. Progressive rendering не меняет total counts и не
приводит к вложенному полному обходу registry на каждую строку.
### AC10 — accessibility/responsive (smoke + golden + review)
Диалог работает с клавиатуры, восстанавливает focus, не имеет горизонтального
скролла на narrow fixture и корректен в RU/EN и light/dark. Touch View и kiosk
не получают новых интерактивных слоёв.
### AC11 — compatibility (unit + existing regressions)
Текущие тесты `devices`, `ha-binding-status`, `device-presentation`,
`binding_picker` и `hidden_flag` сохраняют смысл. Старые markers/settings читаются
без миграции, а каталог не меняет auto-placement, filtering, light aggregation,
LQI/climate или opening references.
## 16. План автотестов
- `test/device-inbox.test.mjs` — pure resolver, reason/capability/search/sort,
large registry и exact-binding matrix;
- `test/devices.test.mjs`, `test/ha-binding-status.test.mjs` — соседние
регрессии lifecycle;
- `demo/smoke_device_inbox.mjs` — реальный dialog, read-only assertions,
row actions, return state, late registry refresh;
- адаптация `demo/smoke_binding_picker.mjs` и `demo/smoke_hidden_flag.mjs` без
потери существующих проверок: `smoke_hidden_flag` включает ghost-режим через
каталог и по-прежнему проверяет позицию, клик/настройку и drag скрытого marker;
- golden: desktop RU/EN, light/dark; narrow RU с длинными причинами;
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` определяет
дополнительный набор перед код-ревью.
Минимальные локальные гейты реализации:
```text
npx tsc --noEmit
npm test
npm run bundle:sync
node scripts/check-docs.mjs
node scripts/smoke-select.mjs --base origin/dev --head HEAD
node demo/smoke_device_inbox.mjs
node demo/smoke_binding_picker.mjs
node demo/smoke_hidden_flag.mjs
npm run golden:verify
```
## 17. Производительность
- полный индекс может включать тысячи entity, но DOM получает не более 100
строк за одну порцию;
- поиск и counts считаются по полному memoized snapshot;
- registry refresh инвалидирует snapshot один раз на revision;
- новый код не добавляет рендеров или observers в View/киоск;
- performance benchmark обязателен только если `smoke-select` либо ревью
выявят затронутый бюджет; synthetic unit на большой registry остаётся
обязательным.
## 18. Риски
| Риск | Защита |
|---|---|
| Auto no-marker ошибочно показан как не добавленный | Effective runtime presence важнее наличия marker; AC3 |
| Device/entity parent-child дедуплицируются слишком широко | Exact binding key и fixture #262; AC5 |
| Limited registry создаёт ложные причины | Только positive evidence; AC4 |
| Catalog и Add picker расходятся | Один общий eligibility helper |
| Открытие UI материализует данные | Явный no-write contract и websocket assertions; AC7 |
| Историческая причина скрытия выдумывается | Generic `automatic_hidden` fallback |
| Большой registry блокирует dialog | O(N) snapshot + progressive rendering |
| Возврат из nested flow теряет контекст | Локальный `returnToInbox` snapshot; AC8 |
| Перенос ghost-toggle делает активный режим незаметным после закрытия каталога | Активное состояние кнопки «Устройства» и сброс при выходе из редактора; AC6 |
| #29 поглощает redesign фильтров | #44 явно вне скоупа |
## 19. Откат
Изменение не мигрирует config и не создаёт новых persisted-полей. Откат
возвращает две прежние кнопки и Add picker; markers, tombstones, hidden flags,
layout и attachments остаются совместимыми. Созданный через «Скрыть из списка»
`hidden:true` marker уже поддерживается старым UI и может быть показан обычным
способом.
## 20. Release-артефакты
- `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` со ссылкой на #29;
- раздел Device editor в `docs/USER-GUIDE.md` и
`docs/USER-GUIDE.ru.md`;
- `docs/FILTERING.md` — каталог как единая presentation surface, без изменения
lifecycle semantics;
- `docs/ARCHITECTURE.md` — pure resolver и shared eligibility helper;
- актуальные golden baselines и публичный screenshot редактора устройств, если
документационный capture затронут;
- backend, security и model migration release notes: **не требуются**.
## 21. Принятые технические предположения — ревьюер может менять свободно
1. Pure resolver живёт в `src/device-inbox.ts`, а не внутри Lit-компонента.
2. Existing Add eligibility извлекается в общий helper; имя модуля не является
продуктовым контрактом.
3. Tab/search/filter/scroll state хранится только в экземпляре карточки.
4. Progressive page = 100 строк; конкретное число можно скорректировать по
golden/performance без изменения пользовательской семантики.
5. Точная историческая причина seed не сохраняется; при отсутствии доказательства
используется `automatic_hidden`.
6. #44 расширит тот же dialog advanced-настройками позже, но #29 не ждёт #44 и
не меняет discovery settings.
+4 -6
View File
@@ -156,14 +156,12 @@ export const MUTANTS = [
{
id: 'device-tombstone-blocks-child-picker',
guard: 'node demo/smoke_binding_picker.mjs',
because: 'a device tombstone must expose an active child in Add when Show entities is on; '
because: 'a device tombstone must expose an active child in the catalog/Add flow when Show entities is on; '
+ 'restoring only the exact device reproduces the user-visible dead end from #262',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (isRemovedPlanEntity(h, eid, removed)\n'
+ ' && !removedBindings.has(v) && !childOfRemovedDevice) continue;',
replace: ' if (isRemovedPlanEntity(h, eid, removed)\n'
+ ' && !removedBindings.has(v)) continue;',
file: 'src/device-inbox.ts',
find: ' if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;',
replace: ' if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;',
}],
},
{
+278
View File
@@ -0,0 +1,278 @@
/**
* Pure device-catalog projection for #29.
*
* The module deliberately owns neither Lit state nor persistence. The card
* supplies one registry snapshot and executes the capabilities exposed here
* through its existing marker/config transactions.
*/
import type { HaBindingStatus } from './ha-binding-status';
import { isRemovedPlanEntity, removedPlanBindings } from './devices';
import type { DevItem, Marker } from './types';
export type DeviceInboxCategory = 'on_plan' | 'available' | 'hidden' | 'readd';
export type DeviceInboxReason =
| 'visible_auto' | 'visible_explicit'
| 'manual_hidden' | 'automatic_hidden'
| 'service_entry' | 'excluded_integration' | 'excluded_domain'
| 'grouped_light' | 'represented_by_parent'
| 'removed' | 'available' | 'no_bound_room';
export interface BindingCandidate {
value: string;
label: string;
sub: string;
kind: 'device' | 'entity';
ref: string;
areaId: string;
model: string;
parentDeviceId?: string;
}
export interface BindingCandidateLabels {
device: string;
z2mGroup: string;
group: string;
helper: string;
entity: string;
}
export interface BindingCandidateInput {
hass: any;
devices: readonly DevItem[];
markers: readonly Marker[];
showEntities: boolean;
currentBinding?: string;
currentDeviceId?: string;
labels: BindingCandidateLabels;
}
const HELPER_PLATFORMS = new Set([
'group', 'template', 'derivative', 'min_max', 'threshold', 'integration',
'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule',
]);
/** One shared Add/catalog eligibility implementation. Filtering and paging
* happen after this function so an exact entity can never disappear behind a
* pre-filter hard cap. */
export function bindingCandidates(input: BindingCandidateInput): BindingCandidate[] {
const { hass: h, devices, markers, showEntities, currentBinding, currentDeviceId, labels } = input;
const removed = removedPlanBindings(markers);
const removedBindings = new Set(markers.filter((m) => m.removed).map((m) => m.binding));
const taken = new Set<string>();
for (const dev of devices) {
if (dev.id === currentDeviceId) continue;
if (dev.bindingKind === 'device' && dev.bindingRef) taken.add(`device:${dev.bindingRef}`);
if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add(`entity:${dev.bindingRef}`);
}
const shownKeys = new Set<string>();
for (const dev of devices) {
if (dev.bindingKind === 'device' && dev.name) shownKeys.add(`${dev.name.trim()}|${dev.area || ''}`);
}
const list: BindingCandidate[] = [];
for (const dev of Object.values<any>(h?.devices || {})) {
if (!dev || dev.entry_type === 'service') continue;
const value = `device:${dev.id}`;
if (taken.has(value)) continue;
const name = String(dev.name_by_user || dev.name || dev.id).trim();
if (value !== currentBinding && !removedBindings.has(value)
&& shownKeys.has(`${name}|${dev.area_id || ''}`)) continue;
list.push({
value, label: name,
sub: (dev.model || labels.device) + (dev.model === 'Group' ? labels.z2mGroup : ''),
kind: 'device', ref: dev.id, areaId: dev.area_id || '', model: dev.model || '',
});
}
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
const value = `entity:${eid}`;
if (taken.has(value)) continue;
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;
const isHelper = HELPER_PLATFORMS.has(reg?.platform);
const isGroup = reg?.platform === 'group';
if (!isHelper && !isGroup) continue;
if (reg?.hidden && !removedBindings.has(value)) continue;
const state = h?.states?.[eid];
list.push({
value,
label: reg?.name || state?.attributes?.friendly_name || eid,
sub: `${eid.split('.')[0]} · ${isGroup ? labels.group : labels.helper}`,
kind: 'entity', ref: eid,
areaId: reg?.area_id || (reg?.device_id && h?.devices?.[reg.device_id]?.area_id) || '',
model: '', parentDeviceId: reg?.device_id || undefined,
});
}
if (showEntities) {
const seen = new Set(list.map((item) => item.value));
for (const [eid, reg] of Object.entries<any>(h?.entities || {})) {
const value = `entity:${eid}`;
if (taken.has(value) || seen.has(value) || (reg?.hidden && !removedBindings.has(value))) continue;
const childOfRemovedDevice = !!reg?.device_id && removed.devices.has(reg.device_id);
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;
const state = h?.states?.[eid];
const parent = reg?.device_id ? h?.devices?.[reg.device_id] : null;
const parentName = parent ? String(parent.name_by_user || parent.name || '') : '';
list.push({
value,
label: reg?.name || state?.attributes?.friendly_name || eid,
sub: `${eid.split('.')[0]} · ${labels.entity}${parentName ? ` · ${parentName}` : ''}`,
kind: 'entity', ref: eid,
areaId: reg?.area_id || parent?.area_id || '', model: '',
parentDeviceId: reg?.device_id || undefined,
});
}
}
return list.sort((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value));
}
export interface DeviceInboxRow {
key: string;
binding: string;
category: DeviceInboxCategory;
status: HaBindingStatus;
reason: DeviceInboxReason;
deviceId?: string;
markerId?: string;
name: string;
icon: string;
model: string;
integration: string;
areaId: string;
areaName: string;
spaceId: string;
spaceName: string;
kind: 'device' | 'entity';
isNew: boolean;
searchText: string;
canFind: boolean;
canEdit: boolean;
canHide: boolean;
canShow: boolean;
canAdd: boolean;
}
export interface DeviceInboxInput {
devices: readonly DevItem[];
markers: readonly Marker[];
candidates: readonly BindingCandidate[];
statuses: ReadonlyMap<string, HaBindingStatus>;
newDeviceIds: ReadonlySet<string>;
showHiddenOnPlan: boolean;
areaNames?: Readonly<Record<string, string>>;
spaceNames?: Readonly<Record<string, string>>;
spaceByArea?: Readonly<Record<string, string>>;
integrationByBinding?: Readonly<Record<string, string>>;
/** Best-effort explanation of an automatically hidden/represented binding. */
reasonByBinding?: Readonly<Record<string, DeviceInboxReason>>;
}
const ACTIVE_STATUS: HaBindingStatus = {
kind: 'active', enabledEntityIds: [], allEntityIds: [],
};
function markerBindingKind(binding: string): 'device' | 'entity' {
return binding.startsWith('entity:') ? 'entity' : 'device';
}
function autoHiddenMarker(marker: Marker): boolean {
const split = marker.binding.indexOf(':');
const ref = split >= 0 ? marker.binding.slice(split + 1) : '';
const meaningful = Object.keys(marker).filter((key) => !['id', 'binding', 'hidden'].includes(key));
return marker.id === `h${ref}` && meaningful.length === 0;
}
/** Build one deterministic row for every exact binding in the catalog. */
export function buildDeviceInbox(input: DeviceInboxInput): DeviceInboxRow[] {
const {
devices, markers, candidates, statuses, newDeviceIds, showHiddenOnPlan,
areaNames = {}, spaceNames = {}, spaceByArea = {}, integrationByBinding = {},
reasonByBinding = {},
} = input;
const runtimeByBinding = new Map<string, DevItem>();
for (const device of devices) {
if (device.virtual || !device.bindingKind || device.bindingKind === 'virtual' || !device.bindingRef) continue;
runtimeByBinding.set(`${device.bindingKind}:${device.bindingRef}`, device);
}
const liveByBinding = new Map<string, Marker>();
const removedByBinding = new Map<string, Marker>();
for (const marker of markers) {
if (!marker?.binding || marker.binding === 'virtual') continue;
if (marker.removed) removedByBinding.set(marker.binding, marker);
else liveByBinding.set(marker.binding, marker);
}
const candidateByBinding = new Map(candidates.map((item) => [item.value, item]));
const keys = new Set<string>([
...runtimeByBinding.keys(), ...liveByBinding.keys(), ...candidateByBinding.keys(),
...[...removedByBinding.keys()].filter((key) => candidateByBinding.has(key)),
]);
const rows: DeviceInboxRow[] = [];
for (const binding of keys) {
const runtime = runtimeByBinding.get(binding);
const live = liveByBinding.get(binding);
const removed = live ? undefined : removedByBinding.get(binding);
const candidate = candidateByBinding.get(binding);
let category: DeviceInboxCategory;
if (removed && candidate) category = 'readd';
else if (live?.hidden === true) category = 'hidden';
else if (runtime || live) category = 'on_plan';
else if (candidate) category = 'available';
else continue;
const status = runtime?.bindingStatus || statuses.get(binding) || ACTIVE_STATUS;
const marker = live || removed;
const kind = markerBindingKind(binding);
const areaId = runtime?.area || candidate?.areaId || marker?.area || '';
const spaceId = runtime?.space || marker?.space || spaceByArea[areaId] || '';
const name = runtime?.name || marker?.name || candidate?.label || binding;
const model = runtime?.model || marker?.model || candidate?.model || '';
const representedByParent = !!candidate?.parentDeviceId
&& runtimeByBinding.has(`device:${candidate.parentDeviceId}`);
const reason: DeviceInboxReason = category === 'readd' ? 'removed'
: category === 'hidden' ? (live && autoHiddenMarker(live)
? reasonByBinding[binding] || 'automatic_hidden' : 'manual_hidden')
: category === 'on_plan' ? (live ? 'visible_explicit' : 'visible_auto')
: reasonByBinding[binding] || (representedByParent ? 'represented_by_parent'
: areaId && spaceId ? 'available' : 'no_bound_room');
const isRendered = !!runtime && (!runtime.hidden || showHiddenOnPlan);
const active = status.kind === 'active';
const canFind = isRendered && (active
|| (status.kind === 'ha_disabled' && showHiddenOnPlan));
const searchText = [
name, model, integrationByBinding[binding], areaNames[areaId], spaceNames[spaceId],
binding, candidate?.sub,
].filter(Boolean).join(' ').toLocaleLowerCase();
rows.push({
key: binding, binding, category, status, reason,
deviceId: runtime?.id,
markerId: marker?.id,
name, icon: runtime?.icon || marker?.icon || (kind === 'entity' ? 'mdi:code-braces' : 'mdi:devices'),
model, integration: integrationByBinding[binding] || '',
areaId, areaName: areaNames[areaId] || '',
spaceId, spaceName: spaceNames[spaceId] || '',
kind, isNew: !!runtime && newDeviceIds.has(runtime.id), searchText,
canFind,
canEdit: !!runtime || !!live,
canHide: category === 'on_plan' && active,
canShow: category === 'hidden' && active,
canAdd: (category === 'available' || category === 'readd') && active,
});
}
const statusRank = (row: DeviceInboxRow): number => row.status.kind === 'active' ? 1 : 0;
return rows.sort((a, b) => a.category.localeCompare(b.category)
|| Number(b.isNew) - Number(a.isNew)
|| statusRank(a) - statusRank(b)
|| a.name.localeCompare(b.name) || a.binding.localeCompare(b.binding));
}
export function filterDeviceInbox(
rows: readonly DeviceInboxRow[], category: DeviceInboxCategory, query: string, onlyNew = false,
): DeviceInboxRow[] {
const needle = query.trim().toLocaleLowerCase();
return rows.filter((row) => row.category === category
&& (!onlyNew || row.isNew)
&& (!needle || row.searchText.includes(needle)));
}
+441 -104
View File
@@ -126,6 +126,10 @@ import {
resolveIcon,
type AreaClimate,
} from './devices';
import {
bindingCandidates, buildDeviceInbox, filterDeviceInbox,
type DeviceInboxCategory, type DeviceInboxReason, type DeviceInboxRow,
} from './device-inbox';
import {
formatToggleConfirmation, formatToggleIntent, projectedTapAction, resolveToggleIntent,
sameToggleOperationTargets, toggleCoverEntity, toggleIntentName, toggleOperation,
@@ -781,6 +785,17 @@ type RenderOpening = OpeningCfg & {
orphanReason?: PartitionOpeningOrphanReason;
};
interface DeviceInboxDialogState {
tab: DeviceInboxCategory;
search: string;
showEntities: boolean;
onlyNew: boolean;
limit: number;
/** Logical row restored after a nested marker dialog closes. */
anchor?: string;
busy?: string;
}
type FixedFloorState = FixedFloorSelection | { kind: 'pending'; value: unknown };
class HouseplanCard extends LitElement {
@@ -1874,6 +1889,9 @@ class HouseplanCard extends LitElement {
private _infoCard: DevItem | null = null;
/** Native HA more-info last opened by this card, for disabled mid-dialog cleanup. */
private _nativeMoreInfoEntity: string | null = null;
private _deviceInbox: DeviceInboxDialogState | null = null;
private _deviceInboxReturn: DeviceInboxDialogState | null = null;
private _deviceInboxMemo: { key: string; rows: DeviceInboxRow[] } | null = null;
private _markerDialog: {
devId?: string; // the icon being edited (if any)
/**
@@ -2298,6 +2316,7 @@ class HouseplanCard extends LitElement {
_roomLabelScale: { state: true },
_spaceDialog: { state: true },
_infoCard: { state: true },
_deviceInbox: { state: true },
_rulesDialog: { state: true },
_settingsDialog: { state: true },
_alignDialog: { state: true },
@@ -2545,7 +2564,8 @@ class HouseplanCard extends LitElement {
if (this._backupImportDialog) { this._backupImportDialog = null; return; }
if (this._backupExportDialog) { this._backupExportDialog = null; return; }
if (this._settingsDialog) { this._settingsDialog = null; return; }
if (this._markerDialog) { this._markerDialog = null; return; }
if (this._markerDialog) { this._closeMarkerDialog(); return; }
if (this._deviceInbox) { this._deviceInbox = null; return; }
if (this._openingDialog) { this._openingDialog = null; return; }
if (this._physicalDialog) { this._physicalDialog = null; return; }
if (this._backdropDialog) { this._backdropDialog = null; return; }
@@ -3600,6 +3620,8 @@ class HouseplanCard extends LitElement {
this._hoverRoom = null;
this._openingInfo = null;
this._closeInfoCard();
this._deviceInbox = null;
this._deviceInboxReturn = null;
this._markerDialog = null;
this._physicalDialog = null;
this._backdropDialog = null;
@@ -3633,22 +3655,7 @@ class HouseplanCard extends LitElement {
private _showHidden = false;
private get _showAll(): boolean {
return this._settings.filter_seeded ? this._showHidden : !!this._settings.show_all;
}
private _toggleShowAll(): void {
if (!this._serverCfg) return;
if (this._settings.filter_seeded) {
this._showHidden = !this._showHidden;
this.requestUpdate();
return;
}
// legacy config: the old shared behaviour until an editor materialises it
this._serverCfg = { ...this._serverCfg, settings: { ...this._serverCfg.settings, show_all: !this._settings.show_all } };
this._regSignature = '';
this._maybeRebuildDevices();
this._saveConfig();
this.requestUpdate();
return this._showHidden || (!this._settings.filter_seeded && !!this._settings.show_all);
}
/**
@@ -6468,6 +6475,8 @@ class HouseplanCard extends LitElement {
this._backupImportDialog = null;
this._backupExportDialog = null;
this._settingsDialog = null;
this._deviceInbox = null;
this._deviceInboxReturn = null;
this._markerDialog = null;
this._openingDialog = null;
this._physicalDialog = null;
@@ -6590,6 +6599,12 @@ class HouseplanCard extends LitElement {
if (previousMode === 'plan' && this._activeDraftId)
this._resumeDraftBySpace[this._space] = this._activeDraftId;
this._mode = mode;
if (previousMode === 'devices' && mode !== 'devices') {
this._showHidden = false;
this._deviceInbox = null;
this._deviceInboxReturn = null;
this._deviceInboxMemo = null;
}
this._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode;
if (mode === 'view') {
if (previousMode !== 'view' && !retargeting) {
@@ -11245,7 +11260,7 @@ class HouseplanCard extends LitElement {
return !!(this._tapConfirm || this._vacCalConfirm || this._roomDialog || this._mergeDialog
|| this._openingDialog || this._physicalDialog || this._openingInfo
|| this._decorTextDialog || this._decorShapeDialog || this._backdropDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog
|| this._decorEraseConfirm || this._spaceDialog || this._markerDialog || this._deviceInbox
|| this._infoCard || this._rulesDialog || this._settingsDialog
|| this._alignDialog || this._importDialog || this._kioskDialog
|| this._backupExportDialog || this._backupImportDialog
@@ -14024,6 +14039,239 @@ class HouseplanCard extends LitElement {
// ================= DEVICE EDITOR (markers) =================
private _openDeviceInbox(): void {
this._deviceInboxReturn = null;
this._deviceInbox = this._deviceInbox || {
tab: 'on_plan', search: '', showEntities: false, onlyNew: false, limit: 100,
};
}
private _closeMarkerDialog(): void {
this._markerDialog = null;
if (this._deviceInboxReturn) {
const restored = { ...this._deviceInboxReturn };
this._deviceInbox = restored;
this._deviceInboxReturn = null;
if (restored.anchor) {
void this.updateComplete.then(() => requestAnimationFrame(() => {
const selector = `.device-inbox-row[data-binding="${CSS.escape(restored.anchor!)}"]`;
this.renderRoot.querySelector<HTMLElement>(selector)?.scrollIntoView({ block: 'nearest' });
}));
}
}
}
private _deviceInboxCandidates(showEntities: boolean) {
return bindingCandidates({
hass: this._planHass,
devices: this._devices,
markers: this._markers,
showEntities,
labels: {
device: this._t('marker.sub_device'),
z2mGroup: this._t('marker.sub_z2m_group'),
group: this._t('marker.sub_group'),
helper: this._t('marker.sub_helper'),
entity: this._t('marker.sub_entity'),
},
});
}
private _deviceInboxRows(): DeviceInboxRow[] {
const dialog = this._deviceInbox || this._deviceInboxReturn;
const showEntities = !!dialog?.showEntities;
const key = [
this._haRegistry.revision, this._cfgRev, this._cfgEpoch, this._regSignature,
this._newSyncKey, showEntities ? 1 : 0, this._showAll ? 1 : 0,
langOf(this.hass, this._config?.language),
].join('|');
if (this._deviceInboxMemo?.key === key) return this._deviceInboxMemo.rows;
const candidates = this._deviceInboxCandidates(showEntities);
const bindings = new Set<string>(candidates.map((item) => item.value));
for (const marker of this._markers) if (marker.binding && marker.binding !== 'virtual') bindings.add(marker.binding);
for (const device of this._devices) {
if (device.bindingKind && device.bindingKind !== 'virtual' && device.bindingRef) {
bindings.add(`${device.bindingKind}:${device.bindingRef}`);
}
}
const statuses = new Map<string, HaBindingStatus>();
for (const binding of bindings) statuses.set(binding, this._bindingStatus(binding));
const areaMap = this._areaToSpace;
const areaNames: Record<string, string> = {};
for (const [id, area] of Object.entries<any>(this.hass?.areas || {})) {
areaNames[id] = areaMap[id]?.room?.name || area?.name || id;
}
// A room may keep a valid HA area reference even when the current user has
// only a limited area-registry snapshot. Its plan-visible name is still
// authoritative for the catalog and search.
for (const [id, target] of Object.entries(areaMap)) {
areaNames[id] = target.room.name || areaNames[id] || id;
}
const spaceNames = Object.fromEntries(this._model.map((space) => [space.id, space.title]));
const spaceByArea = Object.fromEntries(
Object.entries(areaMap).map(([area, value]) => [area, value.space]),
);
const integrationByBinding: Record<string, string> = {};
const devicePlatforms = new Map<string, Set<string>>();
for (const [entityId, entity] of Object.entries<any>(this._fullRegistryHass.entities || {})) {
const platform = String(entity?.platform || '').trim();
if (platform) integrationByBinding[`entity:${entityId}`] = platform;
if (platform && entity?.device_id) {
const set = devicePlatforms.get(entity.device_id) || new Set<string>();
set.add(platform);
devicePlatforms.set(entity.device_id, set);
}
}
for (const [deviceId, platforms] of devicePlatforms) {
integrationByBinding[`device:${deviceId}`] = [...platforms].sort().join(', ');
}
const reasonByBinding: Record<string, DeviceInboxReason> = {};
for (const [deviceId, device] of Object.entries<any>(this._fullRegistryHass.devices || {})) {
const binding = `device:${deviceId}`;
const platforms = devicePlatforms.get(deviceId) || new Set<string>();
const identifierDomain = Array.isArray(device?.identifiers?.[0])
? String(device.identifiers[0][0] || '') : '';
const excluded = [identifierDomain, ...platforms].some((domain) => this._excluded.has(domain));
if (device?.entry_type === 'service') reasonByBinding[binding] = 'service_entry';
else if (excluded) reasonByBinding[binding] = 'excluded_integration';
else if (device?.model === 'Group') reasonByBinding[binding] = 'grouped_light';
else if (/scene/i.test(device?.model || '')) reasonByBinding[binding] = 'excluded_domain';
else if (/bridge/i.test(`${device?.model || ''}${device?.name || ''}`)
|| (identifierDomain === 'myheat' && device?.via_device_id)) {
reasonByBinding[binding] = 'represented_by_parent';
}
}
const rows = buildDeviceInbox({
devices: this._devices,
markers: this._markers,
candidates,
statuses,
newDeviceIds: this._newIds,
showHiddenOnPlan: this._showAll,
areaNames, spaceNames, spaceByArea, integrationByBinding, reasonByBinding,
});
this._deviceInboxMemo = { key, rows };
return rows;
}
private _deviceForInboxRow(row: DeviceInboxRow): DevItem | null {
const runtime = row.deviceId ? this._devices.find((item) => item.id === row.deviceId) : null;
if (runtime) return runtime;
const marker = row.markerId ? this._markers.find((item) => item.id === row.markerId) : null;
if (!marker || marker.removed) return null;
return {
id: marker.id,
name: row.name,
model: row.model,
area: row.areaId,
space: row.spaceId || this._space,
hidden: marker.hidden === true || row.status.kind === 'ha_disabled',
userHidden: marker.hidden === true,
bindingStatus: row.status,
icon: row.icon,
entities: row.status.kind === 'active' ? row.status.enabledEntityIds : [],
allEntities: row.status.allEntityIds,
primary: row.status.kind === 'active' ? row.status.enabledEntityIds[0] : undefined,
marker,
bindingKind: row.kind,
bindingRef: row.binding.slice(row.binding.indexOf(':') + 1),
pdfs: marker.pdfs || [],
};
}
private _openInboxMarker(row: DeviceInboxRow, add = false): void {
const snapshot = this._deviceInbox;
if (!snapshot) return;
this._deviceInboxReturn = { ...snapshot, anchor: row.key };
this._deviceInbox = null;
if (!add) {
const device = this._deviceForInboxRow(row);
if (device) this._openMarkerDialog(device);
else this._closeMarkerDialog();
return;
}
this._openMarkerDialog();
if (!this._markerDialog) {
this._closeMarkerDialog();
return;
}
this._markerDialog = {
...this._markerDialog,
bindingMode: 'ha', binding: row.binding, bindingOpen: false,
showEntities: row.kind === 'entity', name: '',
};
}
private async _setInboxHidden(row: DeviceInboxRow, hidden: boolean): Promise<void> {
const dialog = this._deviceInbox;
const cfg = this._serverCfg;
if (!dialog || !cfg || dialog.busy || row.status.kind !== 'active') return;
const previous = cfg.markers || [];
const live = previous.find((marker) => !marker.removed && marker.binding === row.binding);
if (!hidden && !live) return;
const id = live?.id || markerIdForBinding(
row.binding, row.markerId, () => `m_${Date.now().toString(36)}`,
);
const next: Marker = live
? { ...live, hidden }
: { id, binding: row.binding, hidden: true };
cfg.markers = [
...previous.filter((marker) => marker.id !== id
&& (marker.binding !== row.binding || marker.removed === true)),
next,
];
this._deviceInbox = { ...dialog, busy: row.key, anchor: row.key };
try {
await this._saveConfigNow();
this._regSignature = '';
this._deviceInboxMemo = null;
this._maybeRebuildDevices();
if (this._deviceInbox) this._deviceInbox = { ...this._deviceInbox, busy: undefined };
this._showToast(this._t('device_inbox.saved' as any));
} catch (error: any) {
if (this._serverCfg === cfg) cfg.markers = previous;
if (this._deviceInbox) this._deviceInbox = { ...this._deviceInbox, busy: undefined };
this._showToast(this._t('toast.error', { err: this._errText(error) }));
}
}
private _findInboxDevice(row: DeviceInboxRow): void {
if (!row.canFind) return;
const device = this._deviceForInboxRow(row);
if (!device) return;
this._deviceInbox = null;
if (device.space && device.space !== this._space) {
if (!this._commitSpace(device.space)) return;
this._restoreZoom();
}
const focus = () => {
const current = this._devices.find((item) => item.id === device.id) || device;
const point = this._pos(current);
this._applyView(this._zoom, point.x, point.y);
this._selId = current.id;
this.requestUpdate();
window.setTimeout(() => {
if (this._selId === current.id) {
this._selId = null;
this.requestUpdate();
}
}, 1500);
};
requestAnimationFrame(() => requestAnimationFrame(focus));
}
private _deviceInboxTabKey(event: KeyboardEvent): void {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
const dialog = this._deviceInbox;
if (!dialog) return;
const tabs: DeviceInboxCategory[] = ['on_plan', 'available', 'hidden', 'readd'];
const offset = event.key === 'ArrowRight' ? 1 : -1;
const index = (tabs.indexOf(dialog.tab) + offset + tabs.length) % tabs.length;
this._deviceInbox = { ...dialog, tab: tabs[index], limit: 100, onlyNew: false };
event.preventDefault();
}
private _openMarkerDialog(d?: DevItem): void {
// A global-camera snapshot belongs to one explicit expansion only; never
// carry it across device-dialog sessions.
@@ -14163,74 +14411,21 @@ class HouseplanCard extends LitElement {
/** Binding candidates: HA devices + group/helper entities, minus the ones already placed. */
private _bindingCandidates(): { value: string; label: string; sub: string }[] {
const h = this._planHass;
const removed = removedPlanBindings(this._markers);
const removedBindings = new Set(
this._markers.filter((m) => m.removed).map((m) => m.binding),
);
const taken = new Set<string>();
for (const dev of this._devices) {
if (dev.id === this._markerDialog?.devId) continue;
if (dev.bindingKind === 'device' && dev.bindingRef) taken.add('device:' + dev.bindingRef);
if (dev.bindingKind === 'entity' && dev.bindingRef) taken.add('entity:' + dev.bindingRef);
}
// dedup as on the plan: hide devices with the same “name|area” as already shown ones (Tuya duplicates)
const shownKeys = new Set<string>();
for (const dev of this._devices) {
if (dev.bindingKind === 'device' && dev.name) shownKeys.add(dev.name.trim() + '|' + (dev.area || ''));
}
const list: { value: string; label: string; sub: string }[] = [];
// devices (incl. Z2M groups with model=Group)
for (const dev of Object.values<any>(h.devices)) {
if (dev.entry_type === 'service') continue;
const v = 'device:' + dev.id;
if (taken.has(v)) continue;
const name = (dev.name_by_user || dev.name || dev.id).trim();
if (v !== this._markerDialog?.binding && !removedBindings.has(v)
&& shownKeys.has(name + '|' + (dev.area_id || ''))) continue;
list.push({ value: v, label: name, sub: (dev.model || this._t('marker.sub_device')) + (dev.model === 'Group' ? this._t('marker.sub_z2m_group') : '') });
}
// group/helper entities without a physical device of their own
const helperPlatforms = new Set([
'group', 'template', 'derivative', 'min_max', 'threshold', 'integration',
'statistics', 'trend', 'utility_meter', 'tod', 'switch_as_x', 'schedule',
]);
for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v)) continue;
if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(v)) continue;
const isHelper = helperPlatforms.has(reg.platform);
const isGroupEntity = reg.platform === 'group';
if (!isHelper && !isGroupEntity) continue;
if (reg.hidden && !removedBindings.has(v)) continue;
const st = h.states[eid];
list.push({
value: v,
label: reg.name || st?.attributes?.friendly_name || eid,
sub: eid.split('.')[0] + ' · ' + (reg.platform === 'group' ? this._t('marker.sub_group') : this._t('marker.sub_helper')),
});
}
// Individual entities of devices — behind the "show entities" checkbox
// (groups/helpers above are ALWAYS listed: they are standalone objects).
if (this._markerDialog?.showEntities) {
const seen = new Set(list.map((o) => o.value));
for (const [eid, reg] of Object.entries<any>(h.entities)) {
const v = 'entity:' + eid;
if (taken.has(v) || seen.has(v) || (reg.hidden && !removedBindings.has(v))) continue;
// A deleted device remains offered as a whole, but the person may also
// restore just one of its children. The runtime override begins only
// after that exact entity marker is saved; this picker-only exception
// is what makes the intentional transition possible (#262).
const childOfRemovedDevice = !!reg.device_id && removed.devices.has(reg.device_id);
if (isRemovedPlanEntity(h, eid, removed)
&& !removedBindings.has(v) && !childOfRemovedDevice) continue;
const stt = h.states[eid];
const label = reg.name || stt?.attributes?.friendly_name || eid;
const dev = reg.device_id ? h.devices[reg.device_id] : null;
const devName = dev ? (dev.name_by_user || dev.name || '') : '';
list.push({ value: v, label, sub: eid.split('.')[0] + ' · ' + this._t('marker.sub_entity') + (devName ? ' · ' + devName : '') });
}
}
const list = bindingCandidates({
hass: this._planHass,
devices: this._devices,
markers: this._markers,
showEntities: !!this._markerDialog?.showEntities,
currentBinding: this._markerDialog?.binding,
currentDeviceId: this._markerDialog?.devId,
labels: {
device: this._t('marker.sub_device'),
z2mGroup: this._t('marker.sub_z2m_group'),
group: this._t('marker.sub_group'),
helper: this._t('marker.sub_helper'),
entity: this._t('marker.sub_entity'),
},
});
const f = (this._markerDialog?.bindingFilter || '').toLowerCase().trim();
const filtered = f
? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f))
@@ -14752,7 +14947,7 @@ class HouseplanCard extends LitElement {
.callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc })
.catch(() => undefined); // leftovers are harmless; broken links are not
}
this._markerDialog = null;
this._closeMarkerDialog();
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.marker_saved'));
@@ -14770,18 +14965,21 @@ class HouseplanCard extends LitElement {
const dlg = this._markerDialog;
if (!dlg || dlg.busy || !dlg.devId) return;
const d = dlg.devId ? this._devices.find((x) => x.id === dlg.devId) : null;
if (!d) return;
const persisted = this._markers.find((marker) => marker.id === dlg.devId);
if (!d && !persisted) return;
const label = dlg.name || this._t('device.fallback');
if (!confirm(this._t('confirm.remove_marker', { name: label }))) return;
const cfg = this._serverCfg!;
cfg.markers = cfg.markers || [];
const previousMarkers = cfg.markers;
const binding = d.bindingKind === 'virtual'
? 'virtual'
: d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : '';
const targetId = d?.id || persisted!.id;
const binding = d
? d.bindingKind === 'virtual' ? 'virtual'
: d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : ''
: persisted!.binding;
if (!binding) return;
const deletion = deletePlanMarkerRecords(
cfg.markers, d.id, binding, d.bindingKind === 'virtual',
cfg.markers, targetId, binding, binding === 'virtual',
);
cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds);
const cleanupIds = deletion.cleanupIds;
@@ -14808,10 +15006,13 @@ class HouseplanCard extends LitElement {
await this.hass.callWS({ type: 'houseplan/trail/delete', marker_id: id })
.catch(() => undefined);
}
this._markerDialog = null;
if (this._infoCard?.id === d.id) this._closeInfoCard();
if (this._selId === d.id) this._selId = null;
if (this._drag?.id === d.id) this._drag = null;
if (this._deviceInboxReturn) {
this._deviceInboxReturn = { ...this._deviceInboxReturn, tab: 'readd', anchor: binding };
}
this._closeMarkerDialog();
if (this._infoCard?.id === targetId) this._closeInfoCard();
if (this._selId === targetId) this._selId = null;
if (this._drag?.id === targetId) this._drag = null;
this._regSignature = '';
this._maybeRebuildDevices();
this._showToast(this._t('toast.marker_removed'));
@@ -18155,6 +18356,7 @@ class HouseplanCard extends LitElement {
${this._backdropDialog ? this._renderBackdropDialog() : nothing}
${this._decorEraseConfirm ? this._renderDecorEraseConfirm() : nothing}
${this._spaceDialog ? this._renderSpaceDialog() : nothing}
${this._deviceInbox ? this._renderDeviceInbox() : nothing}
${this._markerDialog ? this._renderMarkerDialog() : nothing}
${this._vacCalConfirm ? html`<hp-dialog .hass=${this.hass}
.title=${this._t('vac.residual_title')} icon="mdi:map-marker-alert-outline"
@@ -20749,12 +20951,9 @@ class HouseplanCard extends LitElement {
return html`<div class="editbar devbar">
<div class="editbar-tools" tabindex="-1" ?inert=${this._modeTransitionBusy}>
<ha-icon icon="mdi:tune-variant" class="warn"></ha-icon>
<button class="btn" @click=${() => this._openMarkerDialog()} title=${this._t('title.add_device')}>
<ha-icon icon="mdi:plus-box-outline"></ha-icon>${this._t('devbar.add')}
</button>
<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._toggleShowAll}
title=${this._t('title.show_all')}>
<ha-icon icon="${this._showAll ? 'mdi:eye' : 'mdi:eye-off-outline'}"></ha-icon>${this._t('devbar.show_all')}
<button class="btn ${this._showAll ? 'on' : ''}" @click=${this._openDeviceInbox}
title=${this._t('device_inbox.title' as any)}>
<ha-icon icon="mdi:devices"></ha-icon>${this._t('device_inbox.button' as any)}
</button>
<button class="btn" @click=${this._openRulesDialog} title=${this._t('title.icon_rules')}>
<ha-icon icon="mdi:shape-plus-outline"></ha-icon>${this._t('devbar.rules')}
@@ -20771,6 +20970,144 @@ class HouseplanCard extends LitElement {
</div>`;
}
private _renderDeviceInbox(): TemplateResult {
const dialog = this._deviceInbox!;
const rows = this._deviceInboxRows();
const counts = Object.fromEntries((['on_plan', 'available', 'hidden', 'readd'] as const)
.map((category) => [category, rows.filter((row) => row.category === category).length]));
const filtered = filterDeviceInbox(
rows, dialog.tab, dialog.search, dialog.tab === 'on_plan' && dialog.onlyNew,
);
const visible = filtered.slice(0, dialog.limit);
const tabLabel = (tab: DeviceInboxCategory) => this._t(`device_inbox.tab_${tab}` as any);
const emptyKey = `device_inbox.empty_${dialog.tab}` as any;
const openVirtual = () => {
this._deviceInboxReturn = { ...dialog };
this._deviceInbox = null;
this._openMarkerDialog();
if (!this._markerDialog) this._closeMarkerDialog();
};
return html`<hp-dialog class="device-inbox-dialog" .hass=${this.hass}
.title=${this._t('device_inbox.title' as any)} icon="mdi:devices" wide
@hp-close=${() => (this._deviceInbox = null)}>
<div class="device-inbox" ?inert=${!!dialog.busy}>
<div class="device-inbox-head">
<input class="device-inbox-search" type="search" autofocus
placeholder=${this._t('device_inbox.search' as any)} .value=${dialog.search}
@input=${(event: Event) => (this._deviceInbox = {
...dialog, search: (event.target as HTMLInputElement).value, limit: 100,
})} />
<button type="button" class="btn" @click=${openVirtual}>
<ha-icon icon="mdi:map-marker-plus-outline"></ha-icon>
${this._t('device_inbox.add_virtual' as any)}
</button>
</div>
<div class="device-inbox-tabs" role="tablist" @keydown=${this._deviceInboxTabKey}>
${(['on_plan', 'available', 'hidden', 'readd'] as DeviceInboxCategory[]).map((tab) => html`
<button type="button" role="tab" aria-selected=${dialog.tab === tab ? 'true' : 'false'}
class=${dialog.tab === tab ? 'on' : ''}
@click=${() => (this._deviceInbox = { ...dialog, tab, limit: 100, onlyNew: false })}>
${tabLabel(tab)} <span>${counts[tab]}</span>
</button>`)}
</div>
<div class="device-inbox-filters">
${dialog.tab === 'on_plan' ? html`<label>
<input type="checkbox" .checked=${dialog.onlyNew}
@change=${(event: Event) => (this._deviceInbox = {
...dialog, onlyNew: (event.target as HTMLInputElement).checked, limit: 100,
})} />${this._t('device_inbox.only_new' as any)}
</label>` : nothing}
${dialog.tab === 'available' ? html`<label>
<input type="checkbox" .checked=${dialog.showEntities}
@change=${(event: Event) => {
this._deviceInboxMemo = null;
this._deviceInbox = {
...dialog, showEntities: (event.target as HTMLInputElement).checked, limit: 100,
};
}} />${this._t('device_inbox.show_entities' as any)}
</label>` : nothing}
<label>
<input type="checkbox" .checked=${this._showAll}
@change=${(event: Event) => {
this._showHidden = (event.target as HTMLInputElement).checked;
this._deviceInboxMemo = null;
this.requestUpdate();
}} />${this._t('device_inbox.show_hidden' as any)}
</label>
</div>
<div class="device-inbox-results" aria-live="polite">
${visible.length ? visible.map((row) => {
const primary = row.category === 'on_plan'
? row.canFind ? html`<button type="button" class="btn" @click=${() => this._findInboxDevice(row)}>
<ha-icon icon="mdi:crosshairs-gps"></ha-icon>${this._t('device_inbox.find' as any)}</button>`
: html`<button type="button" class="btn" @click=${() => this._openInboxMarker(row)}
?disabled=${!row.canEdit}>${this._t('device_inbox.edit' as any)}</button>`
: row.category === 'hidden'
? html`<button type="button" class="btn" @click=${() => this._setInboxHidden(row, false)}
title=${row.canShow ? '' : this._t('device_inbox.show_disabled' as any)}
?disabled=${!row.canShow}>${this._t('device_inbox.show' as any)}</button>`
: html`<button type="button" class="btn" @click=${() => this._openInboxMarker(row, true)}
?disabled=${!row.canAdd}>${this._t(row.category === 'readd'
? 'device_inbox.readd' as any : 'device_inbox.add' as any)}</button>`;
const status = row.status.kind === 'active' ? ''
: this._t(`device_inbox.status_${row.status.kind}` as any);
return html`<article class="device-inbox-row" data-binding=${row.binding}
data-category=${row.category} data-status=${row.status.kind}>
<ha-icon class="device-inbox-icon" .icon=${row.icon}></ha-icon>
<div class="device-inbox-copy">
<div class="device-inbox-name"><b>${row.name}</b>
${row.isNew ? html`<span class="device-inbox-new">${this._t('device_inbox.new' as any)}</span>` : nothing}
</div>
<div class="device-inbox-meta">
${[row.model, row.integration, row.spaceName, row.areaName].filter(Boolean).join(' · ')}
</div>
<div class="device-inbox-reason">
${this._t(`device_inbox.reason_${row.reason}` as any)}
${status ? html`<span class="device-inbox-status">${status}</span>` : nothing}
</div>
<code>${row.binding}</code>
</div>
<div class="device-inbox-actions">
${primary}
${row.canEdit || row.canHide || row.category === 'available'
|| row.category === 'hidden' || this._bindingHasHaPage(row.binding) ? html`
<details class="device-inbox-menu">
<summary class="btn ghost" aria-label=${this._t('device_inbox.more_actions' as any)}
title=${this._t('device_inbox.more_actions' as any)}>
<ha-icon icon="mdi:dots-vertical"></ha-icon>
</summary>
<div class="device-inbox-menu-items">
${row.canEdit && !(row.category === 'on_plan' && !row.canFind)
? html`<button type="button" class="btn ghost" @click=${() => this._openInboxMarker(row)}>
${this._t('device_inbox.edit' as any)}</button>` : nothing}
${row.canHide ? html`<button type="button" class="btn ghost"
@click=${() => this._setInboxHidden(row, true)}>${this._t('device_inbox.hide' as any)}</button>` : nothing}
${row.category === 'available' ? html`<button type="button" class="btn ghost"
@click=${() => this._setInboxHidden(row, true)}>${this._t('device_inbox.hide_available' as any)}</button>` : nothing}
${row.category === 'hidden' ? html`<button type="button" class="btn ghost"
title=${row.canFind ? '' : this._t('device_inbox.find_hidden_hint' as any)}
?disabled=${!row.canFind} @click=${() => this._findInboxDevice(row)}>
<ha-icon icon="mdi:crosshairs-gps"></ha-icon>${this._t('device_inbox.find' as any)}</button>` : nothing}
${this._bindingHasHaPage(row.binding) ? html`<button type="button" class="btn ghost"
@click=${() => this._openBindingInHa(row.binding)}>${this._t('btn.open_in_ha')}</button>` : nothing}
</div>
</details>` : nothing}
</div>
</article>`;
}) : html`<div class="device-inbox-empty">${this._t(emptyKey)}</div>`}
</div>
${filtered.length > visible.length ? html`<button type="button" class="btn device-inbox-more"
@click=${() => (this._deviceInbox = { ...dialog, limit: dialog.limit + 100 })}>
${this._t('device_inbox.show_more' as any)} (${filtered.length - visible.length})
</button>` : nothing}
</div>
<div slot="footer" class="row">
<button type="button" class="btn ghost" @click=${() => (this._deviceInbox = null)}>
${this._t('btn.close')}</button>
</div>
</hp-dialog>`;
}
/** Entities of a device worth CONTROLLING or reading, in a sensible order. */
private _cardEntities(d: DevItem): { eid: string; kind: 'toggle' | 'value' | 'open' }[] {
const h = this._planHass;
@@ -21407,7 +21744,7 @@ class HouseplanCard extends LitElement {
})();
return html`<hp-dialog .hass=${this.hass}
.title=${d.devId ? this._t('info.device_header') : this._t('marker.new_device')}
icon="mdi:shape-plus" wide @hp-close=${() => (this._markerDialog = null)}>
icon="mdi:shape-plus" wide @hp-close=${this._closeMarkerDialog}>
<div class="body">
${bindingStatus?.kind === 'ha_disabled'
? html`<div class="habindingbanner" role="status">
@@ -21963,7 +22300,7 @@ class HouseplanCard extends LitElement {
</div>
<div class="markersaveactions">
<button class="btn ghost" ?disabled=${d.busy}
@click=${() => (this._markerDialog = null)}>${this._t('btn.cancel')}</button>
@click=${this._closeMarkerDialog}>${this._t('btn.cancel')}</button>
<button class="btn on" @click=${this._saveMarker}
?disabled=${d.busy || (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual'
|| (!d.devId && bindingStatus?.kind !== 'active')))}
+1 -1
View File
@@ -155,7 +155,7 @@ export class HpDialog extends LitElement {
}
:host([wide]) .surface {
width: min(500px, 94vw);
width: min(var(--hp-dialog-wide-width, 500px), 94vw);
}
.header {
+44 -1
View File
@@ -456,6 +456,49 @@
"devbar.add": "Add",
"devbar.show_all": "Hidden and disabled",
"devbar.rules": "Icon rules",
"device_inbox.button": "Devices",
"device_inbox.title": "Devices on the plan",
"device_inbox.search": "Search devices, entities and integrations…",
"device_inbox.add_virtual": "Add virtual device",
"device_inbox.tab_on_plan": "On plan",
"device_inbox.tab_available": "Available",
"device_inbox.tab_hidden": "Hidden",
"device_inbox.tab_readd": "Available again",
"device_inbox.only_new": "New only",
"device_inbox.show_entities": "Show entities",
"device_inbox.show_hidden": "Show hidden on plan",
"device_inbox.new": "New",
"device_inbox.find": "Find on plan",
"device_inbox.edit": "Edit",
"device_inbox.hide": "Hide",
"device_inbox.show": "Show",
"device_inbox.add": "Add",
"device_inbox.readd": "Add again",
"device_inbox.hide_available": "Hide from list",
"device_inbox.show_more": "Show more",
"device_inbox.more_actions": "More actions",
"device_inbox.find_hidden_hint": "Enable “Show hidden on plan” first",
"device_inbox.show_disabled": "Activate this binding in Home Assistant before showing it",
"device_inbox.empty_on_plan": "No matching devices are on the plan yet.",
"device_inbox.empty_available": "No devices are available to add.",
"device_inbox.empty_hidden": "There are no hidden devices.",
"device_inbox.empty_readd": "No removed devices are available to add again.",
"device_inbox.reason_visible_auto": "Discovered automatically",
"device_inbox.reason_visible_explicit": "Added explicitly",
"device_inbox.reason_manual_hidden": "Hidden by user",
"device_inbox.reason_automatic_hidden": "Hidden automatically",
"device_inbox.reason_service_entry": "Service-only registry entry",
"device_inbox.reason_excluded_integration": "Integration excluded by device filters",
"device_inbox.reason_excluded_domain": "Non-spatial entity type",
"device_inbox.reason_grouped_light": "Represented by a room light group",
"device_inbox.reason_represented_by_parent": "Represented by its parent device",
"device_inbox.reason_removed": "Previously removed from the plan",
"device_inbox.reason_available": "Available to add to the plan",
"device_inbox.reason_no_bound_room": "HA area is not linked to a plan room",
"device_inbox.status_ha_disabled": "Disabled in Home Assistant",
"device_inbox.status_orphaned": "Binding is no longer present in Home Assistant",
"device_inbox.status_unverified": "Home Assistant registry is temporarily unavailable",
"device_inbox.saved": "Device list updated",
"space.roomcard_section": "Room card shows:",
"space.label_temp": "Temperature",
"space.label_hum": "Humidity",
@@ -676,7 +719,7 @@
"toast.plans_list_failed": "Could not list the stored plans: {err}",
"toast.plan_delete_failed": "Could not delete the plan: {err}",
"marker.hide": "Hide",
"marker.hide_tip": "The device will disappear from the plan after saving but will still count toward the room signal. Restore it through \"Hidden and disabled\" in the device editor.",
"marker.hide_tip": "The device will disappear from the plan after saving but will still count toward the room signal. Restore it through the \"Devices\" catalog in the device editor.",
"marker.show": "Show",
"marker.show_tip": "The device will appear on the plan again after saving.",
"marker.hidden_ghost": "Device hidden by the user",
+44 -1
View File
@@ -456,6 +456,49 @@
"devbar.add": "Добавить",
"devbar.show_all": "Скрытые и деактивированные",
"devbar.rules": "Правила иконок",
"device_inbox.button": "Устройства",
"device_inbox.title": "Устройства на плане",
"device_inbox.search": "Поиск по устройствам, сущностям и интеграциям…",
"device_inbox.add_virtual": "Добавить виртуальное устройство",
"device_inbox.tab_on_plan": "На плане",
"device_inbox.tab_available": "Доступны",
"device_inbox.tab_hidden": "Скрытые",
"device_inbox.tab_readd": "Доступны снова",
"device_inbox.only_new": "Только новые",
"device_inbox.show_entities": "Показывать сущности",
"device_inbox.show_hidden": "Показывать скрытые на плане",
"device_inbox.new": "Новое",
"device_inbox.find": "Найти на плане",
"device_inbox.edit": "Настроить",
"device_inbox.hide": "Скрыть",
"device_inbox.show": "Показать",
"device_inbox.add": "Добавить",
"device_inbox.readd": "Добавить заново",
"device_inbox.hide_available": "Скрыть из списка",
"device_inbox.show_more": "Показать ещё",
"device_inbox.more_actions": "Другие действия",
"device_inbox.find_hidden_hint": "Сначала включите «Показывать скрытые на плане»",
"device_inbox.show_disabled": "Перед показом активируйте эту привязку в Home Assistant",
"device_inbox.empty_on_plan": "На плане пока нет подходящих устройств.",
"device_inbox.empty_available": "Нет доступных устройств для добавления.",
"device_inbox.empty_hidden": "Скрытых устройств нет.",
"device_inbox.empty_readd": "Нет удалённых устройств, доступных для повторного добавления.",
"device_inbox.reason_visible_auto": "Найдено автоматически",
"device_inbox.reason_visible_explicit": "Добавлено явно",
"device_inbox.reason_manual_hidden": "Скрыто пользователем",
"device_inbox.reason_automatic_hidden": "Скрыто автоматически",
"device_inbox.reason_service_entry": "Служебная запись реестра",
"device_inbox.reason_excluded_integration": "Интеграция исключена фильтрами устройств",
"device_inbox.reason_excluded_domain": "Непространственный тип сущности",
"device_inbox.reason_grouped_light": "Представлено группой света комнаты",
"device_inbox.reason_represented_by_parent": "Представлено родительским устройством",
"device_inbox.reason_removed": "Ранее удалено с плана",
"device_inbox.reason_available": "Можно добавить на план",
"device_inbox.reason_no_bound_room": "Зона HA не связана с комнатой плана",
"device_inbox.status_ha_disabled": "Отключено в Home Assistant",
"device_inbox.status_orphaned": "Привязка больше не найдена в Home Assistant",
"device_inbox.status_unverified": "Реестр Home Assistant временно недоступен",
"device_inbox.saved": "Список устройств обновлён",
"space.roomcard_section": "В карточке комнаты:",
"space.label_temp": "Температура",
"space.label_hum": "Влажность",
@@ -676,7 +719,7 @@
"toast.plans_list_failed": "Не удалось получить список планов: {err}",
"toast.plan_delete_failed": "Не удалось удалить план: {err}",
"marker.hide": "Скрыть",
"marker.hide_tip": "Устройство исчезнет с плана после сохранения, но продолжит участвовать в расчёте сигнала комнаты. Вернуть его можно через кнопку «Скрытые и деактивированные» в редакторе устройств.",
"marker.hide_tip": "Устройство исчезнет с плана после сохранения, но продолжит участвовать в расчёте сигнала комнаты. Вернуть его можно через каталог «Устройства» в редакторе устройств.",
"marker.show": "Показать",
"marker.show_tip": "Устройство снова появится на плане после сохранения.",
"marker.hidden_ghost": "Устройство скрыто пользователем",
+135
View File
@@ -1198,4 +1198,139 @@ export const dialogsStyles = css`
}
.markeractions:empty { display: none; }
.markersaveactions { margin-left: auto; }
.device-inbox {
display: flex;
flex-direction: column;
gap: var(--sp-4);
padding: var(--sp-5) var(--sp-6);
min-width: 0;
}
.device-inbox-dialog { --hp-dialog-wide-width: 920px; }
.device-inbox-head {
display: grid;
grid-template-columns: minmax(180px, 1fr) auto;
align-items: center;
gap: var(--sp-4);
}
.device-inbox-search {
width: 100%;
min-width: 0;
box-sizing: border-box;
border: 1px solid var(--hp-line);
border-radius: var(--rad-m);
background: transparent;
color: var(--hp-txt);
font: inherit;
padding: 11px 14px;
}
.device-inbox-tabs {
display: flex;
gap: var(--sp-2);
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: var(--sp-1);
}
.device-inbox-tabs button {
flex: 0 0 auto;
border: 1px solid var(--hp-line);
border-radius: 999px;
background: transparent;
color: var(--hp-txt);
font: inherit;
padding: 8px 12px;
cursor: pointer;
}
.device-inbox-tabs button.on {
border-color: var(--hp-accent);
background: color-mix(in srgb, var(--hp-accent) 18%, transparent);
}
.device-inbox-tabs button span { color: var(--hp-muted); margin-inline-start: 4px; }
.device-inbox-filters {
display: flex;
flex-wrap: wrap;
gap: var(--sp-4) var(--sp-6);
color: var(--hp-muted);
}
.device-inbox-filters label {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
cursor: pointer;
}
.device-inbox-results { display: grid; gap: var(--sp-3); min-width: 0; }
.device-inbox-row {
display: grid;
grid-template-columns: 42px minmax(180px, 1fr) minmax(180px, auto);
align-items: center;
gap: var(--sp-4);
min-width: 0;
border: 1px solid var(--hp-line);
border-radius: var(--rad-m);
padding: var(--sp-4);
background: color-mix(in srgb, var(--hp-txt) 3%, transparent);
}
.device-inbox-icon { --mdc-icon-size: 28px; color: var(--hp-txt); justify-self: center; }
.device-inbox-copy { min-width: 0; }
.device-inbox-name { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2); }
.device-inbox-new {
border-radius: 999px;
background: var(--hp-accent);
color: var(--text-primary-color, #fff);
font-size: var(--fs-s);
padding: 2px 7px;
}
.device-inbox-meta,
.device-inbox-reason,
.device-inbox-copy code {
display: block;
color: var(--hp-muted);
font-size: var(--fs-s);
overflow-wrap: anywhere;
white-space: normal;
}
.device-inbox-status { color: var(--error-color, #db4437); margin-inline-start: var(--sp-2); }
.device-inbox-actions {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
}
.device-inbox-actions .btn { min-height: 36px; padding: 7px 10px; }
.device-inbox-menu { position: relative; }
.device-inbox-menu summary { list-style: none; cursor: pointer; }
.device-inbox-menu summary::-webkit-details-marker { display: none; }
.device-inbox-menu-items {
position: absolute;
z-index: 2;
inset-inline-end: 0;
top: calc(100% + var(--sp-1));
display: grid;
gap: var(--sp-1);
min-width: 180px;
padding: var(--sp-2);
border: 1px solid var(--hp-line);
border-radius: var(--rad-m);
background: var(--hp-panel, var(--card-background-color, #fff));
box-shadow: 0 8px 24px rgba(0, 0, 0, .22);
}
.device-inbox-menu-items .btn { justify-content: flex-start; width: 100%; }
.device-inbox-empty { color: var(--hp-muted); text-align: center; padding: var(--sp-8); }
.device-inbox-more { align-self: center; }
@media (max-width: 680px) {
.device-inbox { padding: var(--sp-4); }
.device-inbox-head { grid-template-columns: minmax(0, 1fr); }
.device-inbox-head .btn { justify-self: stretch; }
.device-inbox-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow-x: visible;
}
.device-inbox-tabs button {
min-width: 0;
overflow-wrap: anywhere;
}
.device-inbox-row { grid-template-columns: 36px minmax(0, 1fr); }
.device-inbox-actions { grid-column: 1 / -1; justify-content: flex-start; }
}
`;
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
bindingCandidates, buildDeviceInbox, filterDeviceInbox,
} from '../test-build/device-inbox.js';
const active = { kind: 'active', enabledEntityIds: [], allEntityIds: [] };
const labels = {
device: 'device', z2mGroup: ' group', group: 'group', helper: 'helper', entity: 'entity',
};
function dev(id, binding, over = {}) {
const [bindingKind, bindingRef] = binding.split(':');
return {
id, name: id, model: '', area: 'living', space: 'f1', icon: 'mdi:lamp',
entities: [], allEntities: [], bindingKind, bindingRef, bindingStatus: active, ...over,
};
}
test('catalog classifies exact bindings without merging a removed parent and live child', () => {
const markers = [
{ id: 'parent', binding: 'device:d1', removed: true, hidden: true },
{ id: 'child', binding: 'entity:sensor.child', hidden: false },
{ id: 'hidden', binding: 'device:d2', hidden: true },
];
const devices = [
dev('child', 'entity:sensor.child', { marker: markers[1] }),
dev('hidden', 'device:d2', { marker: markers[2], hidden: true, userHidden: true }),
dev('auto', 'device:d3'),
];
const candidates = [
{ value: 'device:d1', label: 'Parent', sub: 'device', kind: 'device', ref: 'd1', areaId: '', model: '' },
{ value: 'device:d4', label: 'Available', sub: 'device', kind: 'device', ref: 'd4', areaId: '', model: '' },
];
const rows = buildDeviceInbox({
devices, markers, candidates,
statuses: new Map(candidates.map((candidate) => [candidate.value, active])),
newDeviceIds: new Set(['auto']), showHiddenOnPlan: false,
});
assert.equal(rows.find((row) => row.binding === 'device:d1').category, 'readd');
assert.equal(rows.find((row) => row.binding === 'entity:sensor.child').category, 'on_plan');
assert.equal(rows.find((row) => row.binding === 'device:d2').category, 'hidden');
assert.equal(rows.find((row) => row.binding === 'device:d2').canFind, false);
assert.equal(rows.find((row) => row.binding === 'device:d3').isNew, true);
assert.equal(rows.find((row) => row.binding === 'device:d4').category, 'available');
});
test('HA status overlays lifecycle and ghost mode only enables Find', () => {
const marker = { id: 'd1', binding: 'device:d1', hidden: true };
const disabled = {
kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: ['switch.d1'],
};
const device = dev('d1', 'device:d1', {
marker, hidden: true, userHidden: true, bindingStatus: disabled,
});
const base = {
devices: [device], markers: [marker], candidates: [],
statuses: new Map([['device:d1', disabled]]), newDeviceIds: new Set(),
};
const hidden = buildDeviceInbox({ ...base, showHiddenOnPlan: false })[0];
const ghost = buildDeviceInbox({ ...base, showHiddenOnPlan: true })[0];
assert.equal(hidden.category, 'hidden');
assert.equal(hidden.status.kind, 'ha_disabled');
assert.equal(hidden.canShow, false);
assert.equal(hidden.canFind, false);
assert.equal(ghost.canFind, true);
assert.equal(ghost.canShow, false);
});
test('shared eligibility searches the full entity list beyond the former 200-row cap', () => {
const entities = {};
const states = {};
for (let index = 0; index < 260; index++) {
const id = `sensor.entity_${String(index).padStart(3, '0')}`;
entities[id] = { entity_id: id, platform: 'demo', device_id: `d${index}` };
states[id] = { state: String(index), attributes: { friendly_name: `Entity ${index}` } };
}
const candidates = bindingCandidates({
hass: { devices: {}, entities, states }, devices: [], markers: [], showEntities: true, labels,
});
assert.equal(candidates.length, 260);
const rows = buildDeviceInbox({
devices: [], markers: [], candidates,
statuses: new Map(candidates.map((candidate) => [candidate.value, active])),
newDeviceIds: new Set(), showHiddenOnPlan: false,
});
const found = filterDeviceInbox(rows, 'available', 'entity_259');
assert.equal(found.length, 1);
assert.equal(found[0].binding, 'entity:sensor.entity_259');
});
test('opening, searching and filtering are pure', () => {
const marker = { id: 'd1', binding: 'device:d1', hidden: false };
const input = {
devices: [dev('d1', 'device:d1', { marker })], markers: [marker], candidates: [],
statuses: new Map([['device:d1', active]]), newDeviceIds: new Set(), showHiddenOnPlan: false,
};
const before = JSON.stringify({ markers: input.markers, devices: input.devices });
const rows = buildDeviceInbox(input);
filterDeviceInbox(rows, 'on_plan', 'd1');
assert.equal(JSON.stringify({ markers: input.markers, devices: input.devices }), before);
});
test('full lifecycle matrix keeps intent category separate from HA status and reasons', () => {
const disabled = { kind: 'ha_disabled', reason: 'device', enabledEntityIds: [], allEntityIds: [] };
const orphaned = { kind: 'orphaned', reason: 'entity', enabledEntityIds: [], allEntityIds: [] };
const unverified = { kind: 'unverified', enabledEntityIds: [], allEntityIds: [] };
const markers = [
{ id: 'hfiltered', binding: 'device:filtered', hidden: true },
{ id: 'hidden-disabled', binding: 'device:hidden-disabled', hidden: true, name: 'Hidden disabled' },
{ id: 'orphan', binding: 'entity:sensor.orphan', hidden: false },
{ id: 'unknown', binding: 'entity:sensor.unknown', hidden: false },
{ id: 'missing-tombstone', binding: 'device:gone', removed: true, hidden: true },
];
const devices = [
dev('auto', 'device:auto'),
dev('group', 'entity:light.room_group', { model: 'Light group' }),
dev('filtered', 'device:filtered', { marker: markers[0], hidden: true, userHidden: true }),
dev('hidden-disabled', 'device:hidden-disabled', {
marker: markers[1], hidden: true, userHidden: true, bindingStatus: disabled,
}),
];
const candidates = [
{ value: 'device:available', label: 'Available', sub: 'device', kind: 'device', ref: 'available', areaId: 'living', model: '' },
{ value: 'device:no-room', label: 'No room', sub: 'device', kind: 'device', ref: 'no-room', areaId: '', model: '' },
{ value: 'entity:sensor.child', label: 'Child', sub: 'entity', kind: 'entity', ref: 'sensor.child', areaId: 'living', model: '', parentDeviceId: 'auto' },
];
const statuses = new Map([
...candidates.map((candidate) => [candidate.value, active]),
['device:hidden-disabled', disabled], ['entity:sensor.orphan', orphaned],
['entity:sensor.unknown', unverified],
]);
const rows = buildDeviceInbox({
devices, markers, candidates, statuses, newDeviceIds: new Set(['auto']),
showHiddenOnPlan: false, spaceByArea: { living: 'f1' },
reasonByBinding: { 'device:filtered': 'excluded_integration' },
});
const row = (binding) => rows.find((item) => item.binding === binding);
assert.equal(row('device:auto').category, 'on_plan');
assert.equal(row('device:auto').reason, 'visible_auto');
assert.equal(row('device:auto').isNew, true);
assert.equal(row('entity:light.room_group').category, 'on_plan');
assert.equal(row('device:filtered').category, 'hidden');
assert.equal(row('device:filtered').reason, 'excluded_integration');
assert.equal(row('device:hidden-disabled').category, 'hidden');
assert.equal(row('device:hidden-disabled').status.kind, 'ha_disabled');
assert.equal(row('entity:sensor.orphan').category, 'on_plan');
assert.equal(row('entity:sensor.orphan').status.kind, 'orphaned');
assert.equal(row('entity:sensor.unknown').status.kind, 'unverified');
assert.equal(row('device:available').category, 'available');
assert.equal(row('device:no-room').reason, 'no_bound_room');
assert.equal(row('entity:sensor.child').reason, 'represented_by_parent');
assert.equal(row('device:gone'), undefined);
});
+1 -1
View File
@@ -18,7 +18,7 @@
"src/light-visibility.ts",
"src/resize.ts", "src/resize-labels.ts", "src/resize-controller.ts", "src/wall-record-preservation.ts",
"src/rules.ts",
"src/devices.ts",
"src/devices.ts", "src/device-inbox.ts",
"src/virtual-light-state.ts",
"src/types.ts",
"src/space-geometry.ts", "src/junction-limits.ts",