mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
fix: deduplicate entity markers from parent devices
Issue: #226 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
||||
// #226: an entity marker owns its HA channel, while an automatic parent may
|
||||
// render only the visible unclaimed residual. Proves full/touch-kiosk DOM,
|
||||
// exact tap target, editor preview and the static space card from one fixture.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 390, height: 760 }, 1);
|
||||
const out = await page.evaluate(async () => {
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const paint = async () => {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
|
||||
const marker = {
|
||||
id: 'switch-as-x-light',
|
||||
binding: 'entity:light.ceiling',
|
||||
space: 'f1',
|
||||
area: 'living_room',
|
||||
tap_action: 'toggle',
|
||||
};
|
||||
card._serverCfg = {
|
||||
...card._serverCfg,
|
||||
markers: [
|
||||
...(card._serverCfg.markers || []).filter((item) =>
|
||||
item.binding !== 'device:d_light1' && item.binding !== 'entity:light.ceiling'),
|
||||
marker,
|
||||
],
|
||||
};
|
||||
card._layout = {
|
||||
...card._layout,
|
||||
[marker.id]: { s: 'f1', x: 0.22, y: 0.22 },
|
||||
};
|
||||
card._config = { ...card._config, kiosk: true };
|
||||
card._cfgEpoch++;
|
||||
card._regSignature = '';
|
||||
card._visibleDeviceSnapshot = null;
|
||||
card._candidateDeviceSnapshot = null;
|
||||
card._maybeRebuildDevices();
|
||||
card._setMode('view');
|
||||
await paint();
|
||||
|
||||
const exact = card._devices.find((item) => item.id === marker.id);
|
||||
const configBeforeAction = JSON.stringify(card._serverCfg);
|
||||
const calls = [];
|
||||
const originalCallService = card.hass.callService.bind(card.hass);
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callService: async (domain, service, data) => {
|
||||
calls.push({ domain, service, data });
|
||||
return originalCallService(domain, service, data);
|
||||
},
|
||||
};
|
||||
root().querySelector(`.dev[data-id="${marker.id}"]`)?.click();
|
||||
await wait(180);
|
||||
await paint();
|
||||
|
||||
const planFacts = {
|
||||
exactOnlyInProjection: !!exact
|
||||
&& !card._devices.some((item) => item.id === 'd_light1')
|
||||
&& card._devices.filter((item) => item.entities.includes('light.ceiling')).length === 1,
|
||||
exactOnlyInTouchKioskDom: innerWidth === 390
|
||||
&& !!root().querySelector(`.dev[data-id="${marker.id}"]`)
|
||||
&& !root().querySelector('.dev[data-id="d_light1"]'),
|
||||
exactToggleTarget: calls.length === 1
|
||||
&& calls[0].domain === 'light'
|
||||
&& calls[0].service === 'turn_off'
|
||||
&& calls[0].data?.entity_id === 'light.ceiling',
|
||||
actionDoesNotRewriteConfig: JSON.stringify(card._serverCfg) === configBeforeAction,
|
||||
};
|
||||
|
||||
card._setMode('devices');
|
||||
card._openMarkerDialog(card._devices.find((item) => item.id === marker.id));
|
||||
await paint();
|
||||
const preview = root().querySelector('hp-device-preview');
|
||||
await preview?.updateComplete;
|
||||
const previewFacts = {
|
||||
editorKeepsExactBinding: card._markerDialog?.binding === 'entity:light.ceiling',
|
||||
editorPreviewHasOneFace: preview?.renderRoot?.querySelectorAll('.dev').length === 1,
|
||||
};
|
||||
card._markerDialog = null;
|
||||
card._setMode('view');
|
||||
await paint();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: 'f1' });
|
||||
const baseCallWS = card.hass.callWS.bind(card.hass);
|
||||
staticCard.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') {
|
||||
return { config: card._serverCfg, rev: 226, can_write: false };
|
||||
}
|
||||
if (message.type === 'houseplan/layout/get') {
|
||||
return { layout: card._layout, rev: 226 };
|
||||
}
|
||||
return baseCallWS(message);
|
||||
},
|
||||
};
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector(`.dev[data-id="${marker.id}"]`)
|
||||
&& Date.now() - started < 6000) {
|
||||
await wait(60);
|
||||
}
|
||||
await staticCard.updateComplete;
|
||||
const staticFacts = {
|
||||
staticCardHasExactEntity: !!staticCard.renderRoot?.querySelector(
|
||||
`.dev[data-id="${marker.id}"]`,
|
||||
),
|
||||
staticCardHasNoParent: !staticCard.renderRoot?.querySelector('.dev[data-id="d_light1"]'),
|
||||
};
|
||||
|
||||
return { ...planFacts, ...previewFacts, ...staticFacts };
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
+136
-136
File diff suppressed because one or more lines are too long
Vendored
+136
-136
File diff suppressed because one or more lines are too long
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Placing an individual Home Assistant entity no longer leaves a duplicate
|
||||
automatic marker of its complete parent device. The automatic marker now
|
||||
contains only the remaining active visible entities and disappears when
|
||||
none remain; two explicitly placed entity/device markers still coexist
|
||||
([#226](https://github.com/Matysh/houseplan-card/issues/226)).
|
||||
|
||||
## v1.66.0 — 2026-08-20
|
||||
|
||||
- Text device markers once again use a capsule-shaped outer outline instead of
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Размещение отдельной сущности Home Assistant больше не оставляет рядом
|
||||
дублирующий автоматический маркер всего родительского устройства. В
|
||||
auto-marker теперь входят только оставшиеся активные видимые сущности, а при
|
||||
пустом остатке он исчезает; две явно размещённые привязки entity/device
|
||||
по-прежнему могут сосуществовать
|
||||
([#226](https://github.com/Matysh/houseplan-card/issues/226)).
|
||||
|
||||
## v1.66.0 — 2026-08-20
|
||||
|
||||
- Текстовые маркеры устройств снова получают капсульную внешнюю обводку вместо
|
||||
|
||||
@@ -26,6 +26,12 @@ as the SEEDER of initial hidden flags.
|
||||
automatic discovery cannot immediately recreate the deleted device. The
|
||||
same binding remains available in Add; saving it again replaces the
|
||||
tombstone and starts with a fresh position.
|
||||
- A live `entity:X` marker owns X inside its automatic parent `device:D`. A
|
||||
residual auto-device contains only active, HA-visible siblings not owned by
|
||||
other entity markers and disappears when that set is empty. A user-hidden
|
||||
live marker still owns X; an entity tombstone does not. An explicit
|
||||
`device:D` remains complete and may intentionally coexist with explicit
|
||||
entity markers.
|
||||
- No marker — never evaluated by the seeder yet, or a plain physical device.
|
||||
- `bindingStatus: ha_disabled` is runtime-only. It is derived from Home
|
||||
Assistant's device/entity registries and is never written into a marker or
|
||||
@@ -51,6 +57,10 @@ are never revisited. It fires on:
|
||||
3. a new device appearing in a bound area — non-physical ones are hidden
|
||||
silently (no red dot); physical ones keep the red-dot flow.
|
||||
|
||||
Entity-marker ownership uses the same residual projection here as in the
|
||||
renderer. The seeder never turns an automatic parent with an empty or
|
||||
HA-hidden-only residual into a persistent hidden `device:D` stub.
|
||||
|
||||
Until a config is seeded (`filter_seeded` absent), `buildDevices` applies the
|
||||
LEGACY runtime filter, so a read-only client on an old config sees exactly
|
||||
the old behaviour until an editing client materialises it.
|
||||
|
||||
@@ -676,6 +676,23 @@ separately promised workflows:
|
||||
## Devices on the plan ★
|
||||
|
||||
- [ ] Auto devices appear only in rooms bound to their area [manual]
|
||||
- [ ] **Entity/parent ownership (#226):** placing `entity:X` removes X from its
|
||||
automatic parent. A visible unclaimed sibling keeps one residual parent;
|
||||
an empty or HA-hidden-only residual removes it. State, primary/action,
|
||||
`allEntities`, light/Glow and LQI cannot see X twice
|
||||
[auto: unit `devices.test.mjs`; browser `smoke_entity_parent_dedup.mjs`].
|
||||
- [ ] Two explicit markers `entity:X` + `device:D` coexist and the device stays
|
||||
complete. A user-hidden live entity marker still owns X, while an entity
|
||||
tombstone returns X to the parent; disabled entity ownership follows the
|
||||
known full-registry relation [auto: unit `devices.test.mjs`].
|
||||
- [ ] The #94 curtain boundary is deliberate: untouched hidden `cover.*` stays
|
||||
cover-first, but after placing the only visible auxiliary switch the
|
||||
hidden-only automatic remainder disappears. An explicit `device:D`
|
||||
restores the complete curtain beside that entity marker
|
||||
[auto: unit `devices.test.mjs`].
|
||||
- [ ] Renderer and seeder cannot drift back to exact-binding-only ownership
|
||||
[mutation: `entity-marker-kept-in-parent-device`,
|
||||
`entity-marker-parent-seeded`].
|
||||
- [ ] Filtering hides bridges/groups/scenes/excluded integrations; 👁 "show all" reveals [manual]
|
||||
- [ ] Duplicate "name|area" numbered ("Lamp", "Lamp 2") [manual]
|
||||
- [ ] Light groups fold their single lamps; `group_lights=false` unfolds [manual]
|
||||
|
||||
@@ -338,6 +338,16 @@ Newly discovered devices get a red dot until first opened in Device.
|
||||
|
||||
The same binding cannot be used by two markers.
|
||||
|
||||
When an exact HA entity belongs to a device, placing that entity gives its
|
||||
channel to the entity marker. The automatic parent marker, if needed, contains
|
||||
only the remaining active, HA-visible and unplaced entities; it disappears
|
||||
when that residual is empty. HA-hidden siblings alone do not keep an automatic
|
||||
parent on the plan. To show both the exact entity and the complete device,
|
||||
place `entity:X` and `device:D` explicitly — two explicit markers are treated
|
||||
as an intentional configuration. Deleting an entity marker returns the entity
|
||||
to automatic parent discovery; its binding tombstone does not remove registry
|
||||
data from the live HA device.
|
||||
|
||||
### Device editor
|
||||
|
||||
- drag a marker to save its server-side position;
|
||||
|
||||
@@ -573,6 +573,16 @@ House Plan читает реестры устройств, сущностей и
|
||||
|
||||
Одну и ту же привязку нельзя одновременно использовать в двух маркерах.
|
||||
|
||||
Если отдельная HA-сущность принадлежит устройству, после её размещения этот
|
||||
канал принадлежит точному entity-marker. Автоматический родительский маркер,
|
||||
если он ещё нужен, строится только из оставшихся активных, видимых в HA и не
|
||||
размещённых отдельно сущностей; при пустом остатке он исчезает. Одни только
|
||||
скрытые в HA siblings не удерживают auto-marker на плане. Чтобы одновременно
|
||||
показать точную сущность и полное устройство, явно разместите и `entity:X`, и
|
||||
`device:D` — два явных маркера считаются осознанной конфигурацией. После
|
||||
удаления entity-marker сущность снова доступна автоматическому родителю:
|
||||
binding tombstone не вырезает её из живого устройства HA.
|
||||
|
||||
### Редактор устройств
|
||||
|
||||
| Действие | Результат |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"sourceFingerprint": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceFingerprint": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "c53db2e5c642a5549c13f3c93a5b359fed69bdb2621bf71a243a877ffcb95e6b"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -46,7 +46,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "2f13ea645af306eee9cc7c52699c928377b72f412b08c1a3db6beb848ace1b51"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -57,7 +57,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -101,7 +101,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "d2baf22d884daf235af39c2010d42f5fdefc4004ae30a45a6a4ce33115c52c8f",
|
||||
"sourceSha256": "264380083a9a18bfc2305a6577368068d6839fabe82fe1a1f440a62867b4e589",
|
||||
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,36 @@ export const MUTANTS = [
|
||||
replace: " if (false && input.type === 'passage') {",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entity-marker-kept-in-parent-device',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="entity marker owns its entity|partial auto parent" '
|
||||
+ 'test/devices.test.mjs',
|
||||
because: 'an explicitly placed entity must be removed from the residual automatic parent '
|
||||
+ 'so state, action and light projection cannot render the same HA channel twice',
|
||||
patches: [{
|
||||
file: 'src/devices.ts',
|
||||
find: ' !owned.has(entityId) && !hass?.entities?.[entityId]?.hidden),',
|
||||
replace: ' !hass?.entities?.[entityId]?.hidden),',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'entity-marker-parent-seeded',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="seedHiddenBindings: entity ownership" '
|
||||
+ 'test/devices.test.mjs',
|
||||
because: 'the first-run seeder must share residual ownership with buildDevices or it can '
|
||||
+ 'materialise the removed automatic duplicate as a permanent hidden device marker',
|
||||
patches: [{
|
||||
file: 'src/devices.ts',
|
||||
find: ' const entsBy = entitiesByDevice(h);\n'
|
||||
+ ' const ownership = entityMarkerOwnership(markers, fullHass);\n'
|
||||
+ ' const marked = new Set(markers.map((m) => m.binding));',
|
||||
replace: ' const entsBy = entitiesByDevice(h);\n'
|
||||
+ ' const ownership = entityMarkerOwnership([], fullHass);\n'
|
||||
+ ' const marked = new Set(markers.map((m) => m.binding));',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'manual-room-device-area-fallback',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
+61
-4
@@ -58,6 +58,53 @@ function allEntitiesByDevice(hass: any): Record<string, string[]> {
|
||||
return map;
|
||||
}
|
||||
|
||||
interface EntityMarkerOwnership {
|
||||
byDevice: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live entity markers own their exact entity inside an auto-discovered parent.
|
||||
* Tombstones deliberately do not: removing a standalone binding must return
|
||||
* that entity to the still-live HA device (docs/FILTERING.md).
|
||||
*/
|
||||
function entityMarkerOwnership(markers: readonly Marker[], fullHass: any): EntityMarkerOwnership {
|
||||
const mutableByDevice = new Map<string, Set<string>>();
|
||||
for (const marker of markers) {
|
||||
if (marker?.removed === true) continue;
|
||||
const binding = marker?.binding || '';
|
||||
if (!binding.startsWith('entity:')) continue;
|
||||
const entityId = binding.slice('entity:'.length);
|
||||
if (!entityId) continue;
|
||||
const deviceId = fullHass?.entities?.[entityId]?.device_id;
|
||||
if (!deviceId) continue;
|
||||
const owned = mutableByDevice.get(deviceId) || new Set<string>();
|
||||
owned.add(entityId);
|
||||
mutableByDevice.set(deviceId, owned);
|
||||
}
|
||||
return { byDevice: mutableByDevice };
|
||||
}
|
||||
|
||||
/**
|
||||
* An untouched automatic device keeps its complete functional resolver,
|
||||
* including integration-hidden cover entities (#94). Once one of its entities
|
||||
* is placed explicitly, the residual auto marker contains only active,
|
||||
* HA-visible, unclaimed siblings.
|
||||
*/
|
||||
function residualAutoDeviceEntities(
|
||||
hass: any,
|
||||
deviceId: string,
|
||||
entityIds: readonly string[],
|
||||
ownership: EntityMarkerOwnership,
|
||||
): { partial: boolean; entityIds: string[] } {
|
||||
const owned = ownership.byDevice.get(deviceId);
|
||||
if (!owned?.size) return { partial: false, entityIds: [...entityIds] };
|
||||
return {
|
||||
partial: true,
|
||||
entityIds: entityIds.filter((entityId) =>
|
||||
!owned.has(entityId) && !hass?.entities?.[entityId]?.hidden),
|
||||
};
|
||||
}
|
||||
|
||||
export function domainOfDevice(hass: any, dev: any, entIds: string[]): string {
|
||||
if (dev.identifiers?.[0]?.[0]) return dev.identifiers[0][0];
|
||||
for (const eid of entIds) {
|
||||
@@ -957,6 +1004,7 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
|
||||
const baseHass = ctx.hass;
|
||||
const registry = ctx.registry || haRegistrySnapshot(baseHass);
|
||||
const h = activeRegistryHass(baseHass, registry);
|
||||
const fullHass = fullRegistryHass(baseHass, registry);
|
||||
const { areaToSpace, markers, settings, excluded, iconRules } = ctx;
|
||||
const groupLights = settings.group_lights !== false;
|
||||
const removed = removedPlanBindings(markers);
|
||||
@@ -964,6 +1012,7 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
|
||||
.filter((g) => !isRemovedPlanEntity(h, g.eid, removed));
|
||||
const groupedAreas = new Set(groups.map((g) => g.area));
|
||||
const entsBy = entitiesByDevice(h);
|
||||
const ownership = entityMarkerOwnership(markers, fullHass);
|
||||
const marked = new Set(markers.map((m) => m.binding));
|
||||
const out: string[] = [];
|
||||
for (const dev of Object.values<any>(h.devices)) {
|
||||
@@ -974,7 +1023,9 @@ export function seedHiddenBindings(ctx: Omit<BuildCtx, 'showAll' | 'loc'>): stri
|
||||
if (resolveHaBindingStatus(baseHass, 'device:' + dev.id, registry).kind !== 'active') continue;
|
||||
// An entity tombstone suppresses that standalone binding, not the same
|
||||
// entity as data belonging to a still-live parent device.
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership);
|
||||
if (residual.partial && !residual.entityIds.length) continue;
|
||||
const entIds = residual.entityIds;
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
let nonPhysical =
|
||||
excluded.has(dom)
|
||||
@@ -1027,6 +1078,7 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
const groupedAreas = new Set(groups.map((g) => g.area));
|
||||
const entsBy = entitiesByDevice(h);
|
||||
const allEntsBy = allEntitiesByDevice(fullHass);
|
||||
const ownership = entityMarkerOwnership(markers, fullHass);
|
||||
const claimed = new Set<string>();
|
||||
for (const m of markers) {
|
||||
const [kind, ref] = m.binding.split(':');
|
||||
@@ -1046,7 +1098,12 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
if (bindingStatus.kind !== 'active') continue;
|
||||
const marker = markerFor('device', dev.id);
|
||||
if (marker && marker.hidden && !settings.filter_seeded) continue; // legacy: dropped entirely
|
||||
const entIds = entsBy[dev.id] || [];
|
||||
const residual = residualAutoDeviceEntities(h, dev.id, entsBy[dev.id] || [], ownership);
|
||||
if (residual.partial && !residual.entityIds.length) continue;
|
||||
const entIds = residual.entityIds;
|
||||
const itemBindingStatus = residual.partial
|
||||
? { kind: 'active' as const, enabledEntityIds: entIds, allEntityIds: entIds }
|
||||
: bindingStatus;
|
||||
const dom = domainOfDevice(h, dev, entIds);
|
||||
// LEGACY runtime filter: only while the config is not yet materialised
|
||||
// (docs/FILTERING.md). A seeded config hides by explicit marker flags.
|
||||
@@ -1074,8 +1131,8 @@ export function buildDevices(ctx: BuildCtx): DevItem[] {
|
||||
space: areaToSpace[area],
|
||||
icon,
|
||||
entities: entIds,
|
||||
allEntities: bindingStatus.allEntityIds,
|
||||
bindingStatus,
|
||||
allEntities: itemBindingStatus.allEntityIds,
|
||||
bindingStatus: itemBindingStatus,
|
||||
bindingKind: 'device',
|
||||
bindingRef: dev.id,
|
||||
pdfs: [],
|
||||
|
||||
@@ -119,6 +119,223 @@ test('buildDevices: claimed device is replaced by its marker (metadata applied)'
|
||||
assert.equal(it.bindingKind, 'device');
|
||||
});
|
||||
|
||||
test('buildDevices: entity marker owns its entity and removes an empty auto parent', () => {
|
||||
const h = mkHass({
|
||||
devices: { combo: dev('combo', 'Combo sensor', 'Sensor', 'living') },
|
||||
entities: {
|
||||
'sensor.combo_temp': {
|
||||
entity_id: 'sensor.combo_temp', device_id: 'combo', platform: 'demo',
|
||||
},
|
||||
},
|
||||
states: {
|
||||
'sensor.combo_temp': {
|
||||
state: '22', attributes: { device_class: 'temperature', friendly_name: 'Combo temperature' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = buildDevices(baseCtx(h, {
|
||||
markers: [{ id: 'temp-marker', binding: 'entity:sensor.combo_temp' }],
|
||||
}));
|
||||
assert.deepEqual(result.map((item) => item.id), ['temp-marker']);
|
||||
assert.equal(result[0].bindingKind, 'entity');
|
||||
});
|
||||
|
||||
test('buildDevices: partial auto parent contains only visible unclaimed siblings', () => {
|
||||
const h = mkHass({
|
||||
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
|
||||
entities: {
|
||||
'light.combo_main': { entity_id: 'light.combo_main', device_id: 'combo', platform: 'demo' },
|
||||
'switch.combo_aux': { entity_id: 'switch.combo_aux', device_id: 'combo', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'light.combo_main': { state: 'on', attributes: { friendly_name: 'Main light' } },
|
||||
'switch.combo_aux': { state: 'off', attributes: { friendly_name: 'Aux relay' } },
|
||||
},
|
||||
});
|
||||
const result = buildDevices(baseCtx(h, {
|
||||
settings: { group_lights: false },
|
||||
markers: [{ id: 'main-marker', binding: 'entity:light.combo_main' }],
|
||||
}));
|
||||
const exact = result.find((item) => item.id === 'main-marker');
|
||||
const parent = result.find((item) => item.id === 'combo');
|
||||
assert.ok(exact);
|
||||
assert.ok(parent);
|
||||
assert.deepEqual(parent.entities, ['switch.combo_aux']);
|
||||
assert.deepEqual(parent.allEntities, ['switch.combo_aux']);
|
||||
assert.deepEqual(parent.bindingStatus, {
|
||||
kind: 'active', enabledEntityIds: ['switch.combo_aux'], allEntityIds: ['switch.combo_aux'],
|
||||
});
|
||||
assert.equal(parent.primary, 'switch.combo_aux');
|
||||
assert.deepEqual(
|
||||
resolvedLightSources(h, result).map((source) => source.eid).sort(),
|
||||
['light.combo_main'],
|
||||
'the claimed light contributes once and never leaks through the parent',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildDevices: several entity markers split one device without an auto duplicate', () => {
|
||||
const h = mkHass({
|
||||
devices: { climate_box: dev('climate_box', 'Climate box', 'Sensor', 'living') },
|
||||
entities: {
|
||||
'sensor.box_temp': { entity_id: 'sensor.box_temp', device_id: 'climate_box', platform: 'demo' },
|
||||
'sensor.box_hum': { entity_id: 'sensor.box_hum', device_id: 'climate_box', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'sensor.box_temp': { state: '21', attributes: { device_class: 'temperature' } },
|
||||
'sensor.box_hum': { state: '50', attributes: { device_class: 'humidity' } },
|
||||
},
|
||||
});
|
||||
const result = buildDevices(baseCtx(h, { markers: [
|
||||
{ id: 'temp', binding: 'entity:sensor.box_temp' },
|
||||
{ id: 'hum', binding: 'entity:sensor.box_hum' },
|
||||
] }));
|
||||
assert.deepEqual(result.map((item) => item.id), ['temp', 'hum']);
|
||||
assert.ok(!result.some((item) => item.bindingRef === 'climate_box'));
|
||||
});
|
||||
|
||||
test('buildDevices: explicit device and child entity markers coexist intentionally', () => {
|
||||
const h = mkHass({
|
||||
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
|
||||
entities: {
|
||||
'switch.combo': { entity_id: 'switch.combo', device_id: 'combo', platform: 'demo' },
|
||||
'sensor.combo_power': { entity_id: 'sensor.combo_power', device_id: 'combo', platform: 'demo' },
|
||||
},
|
||||
states: {
|
||||
'switch.combo': { state: 'on', attributes: {} },
|
||||
'sensor.combo_power': { state: '20', attributes: { device_class: 'power' } },
|
||||
},
|
||||
});
|
||||
const result = buildDevices(baseCtx(h, { markers: [
|
||||
{ id: 'device-marker', binding: 'device:combo' },
|
||||
{ id: 'power-marker', binding: 'entity:sensor.combo_power' },
|
||||
] }));
|
||||
const explicitDevice = result.find((item) => item.id === 'device-marker');
|
||||
const exact = result.find((item) => item.id === 'power-marker');
|
||||
assert.equal(result.length, 2);
|
||||
assert.ok(explicitDevice && exact);
|
||||
assert.deepEqual(explicitDevice.entities.sort(), ['sensor.combo_power', 'switch.combo']);
|
||||
assert.deepEqual(explicitDevice.allEntities.sort(), ['sensor.combo_power', 'switch.combo']);
|
||||
});
|
||||
|
||||
test('buildDevices: hidden entity marker still owns its entity inside the parent', () => {
|
||||
const h = mkHass({
|
||||
devices: { combo: dev('combo', 'Combo relay', 'Relay', 'living') },
|
||||
entities: {
|
||||
'switch.combo': { entity_id: 'switch.combo', device_id: 'combo', platform: 'demo' },
|
||||
},
|
||||
states: { 'switch.combo': { state: 'on', attributes: {} } },
|
||||
});
|
||||
const result = buildDevices(baseCtx(h, {
|
||||
settings: { filter_seeded: true },
|
||||
markers: [{ id: 'hidden-switch', binding: 'entity:switch.combo', hidden: true }],
|
||||
}));
|
||||
assert.equal(result.length, 1);
|
||||
assert.equal(result[0].id, 'hidden-switch');
|
||||
assert.equal(result[0].hidden, true);
|
||||
assert.ok(!result.some((item) => item.id === 'combo'));
|
||||
});
|
||||
|
||||
test('buildDevices: hidden-only curtain residual disappears but explicit device stays cover-first', () => {
|
||||
const h = mkHass({
|
||||
devices: { curtain: dev('curtain', 'Living curtain', 'Roller shade E1', 'living') },
|
||||
entities: {
|
||||
'cover.curtain': {
|
||||
entity_id: 'cover.curtain', device_id: 'curtain', platform: 'demo', hidden: true,
|
||||
},
|
||||
'switch.curtain_reverse_direction': {
|
||||
entity_id: 'switch.curtain_reverse_direction', device_id: 'curtain', platform: 'demo',
|
||||
},
|
||||
},
|
||||
states: {
|
||||
'cover.curtain': { state: 'opening', attributes: { device_class: 'curtain' } },
|
||||
'switch.curtain_reverse_direction': { state: 'off', attributes: {} },
|
||||
},
|
||||
});
|
||||
|
||||
const untouched = buildDevices(baseCtx(h));
|
||||
assert.equal(untouched.length, 1);
|
||||
assert.equal(untouched[0].primary, 'cover.curtain', 'untouched #94 resolver remains cover-first');
|
||||
|
||||
const marker = { id: 'reverse', binding: 'entity:switch.curtain_reverse_direction' };
|
||||
const split = buildDevices(baseCtx(h, { markers: [marker] }));
|
||||
assert.deepEqual(split.map((item) => item.id), ['reverse']);
|
||||
|
||||
const explicit = buildDevices(baseCtx(h, { markers: [
|
||||
marker, { id: 'curtain-device', binding: 'device:curtain' },
|
||||
] }));
|
||||
assert.equal(explicit.length, 2);
|
||||
assert.equal(explicit.find((item) => item.id === 'curtain-device').primary, 'cover.curtain');
|
||||
});
|
||||
|
||||
test('buildDevices: disabled entity marker owns its known parent relation', () => {
|
||||
const devices = { combo: dev('combo', 'Combo relay', 'Relay', 'living') };
|
||||
const entities = {
|
||||
'switch.combo_disabled': {
|
||||
entity_id: 'switch.combo_disabled', device_id: 'combo', platform: 'demo', disabled_by: 'user',
|
||||
},
|
||||
'switch.combo_live': {
|
||||
entity_id: 'switch.combo_live', device_id: 'combo', platform: 'demo', disabled_by: null,
|
||||
},
|
||||
};
|
||||
const h = mkHass({
|
||||
devices,
|
||||
entities,
|
||||
states: {
|
||||
'switch.combo_disabled': { state: 'on', attributes: {} },
|
||||
'switch.combo_live': { state: 'off', attributes: {} },
|
||||
},
|
||||
});
|
||||
const registry = {
|
||||
revision: 1, authoritative: true, access: 'full', devices, entities, lastSuccess: 1,
|
||||
};
|
||||
const result = buildDevices(baseCtx(h, {
|
||||
registry,
|
||||
settings: { filter_seeded: true },
|
||||
markers: [{ id: 'disabled-marker', binding: 'entity:switch.combo_disabled' }],
|
||||
}));
|
||||
const parent = result.find((item) => item.id === 'combo');
|
||||
const ghost = result.find((item) => item.id === 'disabled-marker');
|
||||
assert.deepEqual(parent.entities, ['switch.combo_live']);
|
||||
assert.deepEqual(parent.allEntities, ['switch.combo_live']);
|
||||
assert.equal(ghost.bindingStatus.kind, 'ha_disabled');
|
||||
assert.equal(ghost.hidden, true);
|
||||
});
|
||||
|
||||
test('buildDevices: registry hidden sibling changes rebuild the residual without config writes', () => {
|
||||
const devices = { combo: dev('combo', 'Combo relay', 'Relay', 'living') };
|
||||
const visibleEntities = {
|
||||
'switch.combo_claimed': {
|
||||
entity_id: 'switch.combo_claimed', device_id: 'combo', platform: 'demo', disabled_by: null,
|
||||
},
|
||||
'switch.combo_sibling': {
|
||||
entity_id: 'switch.combo_sibling', device_id: 'combo', platform: 'demo', disabled_by: null,
|
||||
},
|
||||
};
|
||||
const h = mkHass({
|
||||
devices,
|
||||
entities: visibleEntities,
|
||||
states: {
|
||||
'switch.combo_claimed': { state: 'on', attributes: {} },
|
||||
'switch.combo_sibling': { state: 'off', attributes: {} },
|
||||
},
|
||||
});
|
||||
const marker = { id: 'claimed', binding: 'entity:switch.combo_claimed' };
|
||||
const snapshot = (revision, hidden) => ({
|
||||
revision, authoritative: true, access: 'full', devices,
|
||||
entities: {
|
||||
...visibleEntities,
|
||||
'switch.combo_sibling': { ...visibleEntities['switch.combo_sibling'], hidden },
|
||||
},
|
||||
lastSuccess: revision,
|
||||
});
|
||||
const hidden = buildDevices(baseCtx(h, { registry: snapshot(1, true), markers: [marker] }));
|
||||
const visible = buildDevices(baseCtx(h, { registry: snapshot(2, false), markers: [marker] }));
|
||||
assert.deepEqual(hidden.map((item) => item.id), ['claimed']);
|
||||
assert.deepEqual(visible.map((item) => item.id), ['combo', 'claimed']);
|
||||
assert.deepEqual(visible.find((item) => item.id === 'combo').entities, ['switch.combo_sibling']);
|
||||
assert.deepEqual(marker, { id: 'claimed', binding: 'entity:switch.combo_claimed' });
|
||||
});
|
||||
|
||||
test('buildDevices: manual room without area overrides registry area for device binding', () => {
|
||||
const h = mkHass({ devices: { washer: dev('washer', 'Washer', 'WM-1', 'living') } });
|
||||
const [item] = buildDevices(baseCtx(h, {
|
||||
@@ -1572,6 +1789,59 @@ test('seedHiddenBindings: non-physical devices in bound areas, unmarked only', (
|
||||
assert.deepEqual(seedHiddenBindings(marked), [], 'marked and deleted devices are never revisited');
|
||||
});
|
||||
|
||||
test('seedHiddenBindings: entity ownership uses the same visible residual as buildDevices', () => {
|
||||
const devices = { bridge: dev('bridge', 'Z2M Bridge', 'Bridge', 'living') };
|
||||
const states = {
|
||||
'sensor.bridge_claimed': { state: 'ok', attributes: {} },
|
||||
'sensor.bridge_sibling': { state: 'ok', attributes: {} },
|
||||
};
|
||||
const makeCtx = (entities, markers) => ({
|
||||
hass: mkHass({ devices, entities, states }),
|
||||
areaToSpace: { living: 'f1' }, markers, settings: {},
|
||||
excluded: new Set(['hacs']), firstSpaceId: 'f1',
|
||||
});
|
||||
const claimed = {
|
||||
entity_id: 'sensor.bridge_claimed', device_id: 'bridge', platform: 'demo',
|
||||
};
|
||||
const marker = { id: 'claimed', binding: 'entity:sensor.bridge_claimed' };
|
||||
|
||||
assert.deepEqual(
|
||||
seedHiddenBindings(makeCtx({ 'sensor.bridge_claimed': claimed }, [marker])),
|
||||
[],
|
||||
'an empty residual must not become a hidden parent stub',
|
||||
);
|
||||
|
||||
const visibleSibling = {
|
||||
entity_id: 'sensor.bridge_sibling', device_id: 'bridge', platform: 'demo',
|
||||
};
|
||||
assert.deepEqual(
|
||||
seedHiddenBindings(makeCtx({
|
||||
'sensor.bridge_claimed': claimed,
|
||||
'sensor.bridge_sibling': visibleSibling,
|
||||
}, [marker])),
|
||||
['device:bridge'],
|
||||
'a visible unclaimed sibling keeps one residual parent',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
seedHiddenBindings(makeCtx({
|
||||
'sensor.bridge_claimed': claimed,
|
||||
'sensor.bridge_sibling': { ...visibleSibling, hidden: true },
|
||||
}, [marker])),
|
||||
[],
|
||||
'a hidden-only sibling does not keep the residual parent',
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
seedHiddenBindings(makeCtx(
|
||||
{ 'sensor.bridge_claimed': claimed },
|
||||
[{ ...marker, removed: true, hidden: true }],
|
||||
)),
|
||||
['device:bridge'],
|
||||
'an entity tombstone does not own the entity inside its live parent',
|
||||
);
|
||||
});
|
||||
|
||||
test('seeded config: hidden is a flag, not an absence', () => {
|
||||
const h = mkHass({ devices: {
|
||||
lamp: dev('lamp', 'Lamp', 'Bulb', 'living'),
|
||||
|
||||
Reference in New Issue
Block a user