fix: deduplicate entity markers from parent devices

Issue: #226
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-20 21:35:23 +03:00
parent d7c654d4c3
commit f151e70a27
14 changed files with 960 additions and 423 deletions
File diff suppressed because one or more lines are too long
+120
View File
@@ -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));
File diff suppressed because one or more lines are too long
+136 -136
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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
+7
View File
@@ -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
- Текстовые маркеры устройств снова получают капсульную внешнюю обводку вместо
+10
View File
@@ -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.
+17
View File
@@ -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]
+10
View File
@@ -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;
+10
View File
@@ -573,6 +573,16 @@ House Plan читает реестры устройств, сущностей и
Одну и ту же привязку нельзя одновременно использовать в двух маркерах.
Если отдельная HA-сущность принадлежит устройству, после её размещения этот
канал принадлежит точному entity-marker. Автоматический родительский маркер,
если он ещё нужен, строится только из оставшихся активных, видимых в HA и не
размещённых отдельно сущностей; при пустом остатке он исчезает. Одни только
скрытые в HA siblings не удерживают auto-marker на плане. Чтобы одновременно
показать точную сущность и полное устройство, явно разместите и `entity:X`, и
`device:D` — два явных маркера считаются осознанной конфигурацией. После
удаления entity-marker сущность снова доступна автоматическому родителю:
binding tombstone не вырезает её из живого устройства HA.
### Редактор устройств
| Действие | Результат |
+11 -11
View File
@@ -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"
}
}
+30
View File
@@ -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
View File
@@ -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: [],
+270
View File
@@ -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'),