@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// #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;
|
||||
|
||||
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);
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -1763,20 +1763,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. После настройки,
|
||||
сохранения или отмены каталог возвращается с тем же поиском и фильтрами.
|
||||
|
||||

|
||||
|
||||

|
||||
@@ -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` |
|
||||
|
||||
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 327 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 134 KiB |
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"sourceFingerprint": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"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": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
|
||||
},
|
||||
"space-create": {
|
||||
"file": "03-space-create.png",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
|
||||
},
|
||||
"room-contour-close": {
|
||||
"file": "04-room-contour-close.png",
|
||||
@@ -47,8 +47,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -58,8 +58,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -91,8 +91,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -102,8 +102,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -113,8 +113,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "3a81ad744dfb6f19f9cfc7dd90c2b5e06f0a784356d5daf2d3a112f6ff3f6617",
|
||||
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
|
||||
"sourceSha256": "78c24f3a8aef0c2bad01bebc701a8127c43fb40f1a298df7b769375bc5b081fa",
|
||||
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 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;
|
||||
canOpenHa: 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 canOpenHa = kind === 'device' || kind === 'entity';
|
||||
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,
|
||||
canOpenHa,
|
||||
});
|
||||
}
|
||||
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)));
|
||||
}
|
||||
@@ -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,230 @@ 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 areaNames: Record<string, string> = {};
|
||||
for (const [id, area] of Object.entries<any>(this.hass?.areas || {})) areaNames[id] = area?.name || id;
|
||||
const spaceNames = Object.fromEntries(this._model.map((space) => [space.id, space.title]));
|
||||
const spaceByArea = Object.fromEntries(
|
||||
Object.entries(this._areaToSpace).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 +14402,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 +14938,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 +14956,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 +14997,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 +18347,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 +20942,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 +20961,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 +21735,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 +22291,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')))}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Устройство скрыто пользователем",
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||