From b2bb3e9d9d4a9156885803b341bbacb08baf1014 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 10 Sep 2026 15:46:28 +0200 Subject: [PATCH] Nest child devices under their parent in device trees (#54063) * Nest child devices under their parent in the entity tree * Resolve device entries through shared helpers in the automation target browser * Nest child devices under their parent when browsing automation targets * Nest child devices under their parent in target details * Skip child device rows already nested under their parent in area targets * Fix expanding the automation target tree to an entity attached to an area * Skip labeled child device rows already nested under a labeled parent * Extract target sub-row computation and test child device nesting * Use the inherited area when hiding filtered child devices * Keep a parent device as a group row when a child device matches * Keep child devices at the top level when their parent is disabled * Drop disabled-parent special cases, core disables children with their parent --- .../target-picker/compute-target-sub-rows.ts | 181 ++++++++ .../ha-target-picker-item-row.ts | 198 +++----- .../ha-automation-add-from-target.ts | 423 ++++++++++-------- .../editor/card-editor/entity-tree-builder.ts | 110 +++-- .../card-editor/hui-suggestion-entity-tree.ts | 37 +- .../compute-target-sub-rows.test.ts | 156 +++++++ .../ha-target-picker-item-row.test.ts | 24 + .../card-editor/entity-tree-builder.test.ts | 79 ++++ 8 files changed, 847 insertions(+), 361 deletions(-) create mode 100644 src/components/target-picker/compute-target-sub-rows.ts create mode 100644 test/components/target-picker/compute-target-sub-rows.test.ts diff --git a/src/components/target-picker/compute-target-sub-rows.ts b/src/components/target-picker/compute-target-sub-rows.ts new file mode 100644 index 0000000000..df3d4b8273 --- /dev/null +++ b/src/components/target-picker/compute-target-sub-rows.ts @@ -0,0 +1,181 @@ +import { getDeviceAreaId } from "../../common/entity/context/get_device_context"; +import type { + ExtractFromTargetResultReferenced, + TargetType, +} from "../../data/target"; +import type { HomeAssistant } from "../../types"; + +export interface TargetSubRows { + nextType: TargetType; + rows: string[]; + rowEntries?: ExtractFromTargetResultReferenced[]; + deviceRows: string[]; + deviceRowEntries?: ExtractFromTargetResultReferenced[]; + entityRows: string[]; +} + +const emptyEntries = (): ExtractFromTargetResultReferenced => ({ + referenced_areas: [], + referenced_devices: [], + referenced_entities: [], +}); + +export const computeTargetSubRows = ( + type: TargetType, + itemId: string, + entries: ExtractFromTargetResultReferenced, + entityRegistry: HomeAssistant["entities"], + devices: HomeAssistant["devices"] +): TargetSubRows => { + let nextType: TargetType = + type === "floor" ? "area" : type === "area" ? "device" : "entity"; + + if (type === "label") { + if (entries.referenced_areas.length) { + nextType = "area"; + } else if (entries.referenced_devices.length) { + nextType = "device"; + } + } + + const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id; + const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id; + // An entity belongs to a device row when it is on that device or on one of + // its child devices, so the row can nest the children below it. + const belongsTo = (entityId: string, deviceId: string) => { + const entityDevice = deviceOf(entityId); + return ( + entityDevice === deviceId || + (!!entityDevice && parentOf(entityDevice) === deviceId) + ); + }; + const entitiesOf = (deviceId: string) => + entries.referenced_entities.filter((entityId) => + belongsTo(entityId, deviceId) + ); + const rootsOf = (deviceIds: string[]) => + deviceIds.filter((deviceId) => { + const parentId = parentOf(deviceId); + return !parentId || !deviceIds.includes(parentId); + }); + + const childDevices = + type === "device" + ? [ + ...new Set( + entries.referenced_entities + .map(deviceOf) + .filter( + (deviceId): deviceId is string => + !!deviceId && + deviceId !== itemId && + parentOf(deviceId) === itemId + ) + ), + ] + : []; + + const rows = + nextType === "area" + ? entries.referenced_areas + : nextType === "device" && type !== "label" + ? rootsOf(entries.referenced_devices) + : type === "device" + ? entries.referenced_entities.filter( + (entityId) => !childDevices.includes(deviceOf(entityId) || "") + ) + : type !== "label" + ? entries.referenced_entities + : []; + + const devicesInAreas: string[] = []; + + const rowEntries = + nextType === "entity" + ? undefined + : rows.map((rowItem) => { + const nextEntries = emptyEntries(); + + if (nextType === "area") { + const areaDevices = entries.referenced_devices.filter( + (deviceId) => { + const device = devices[deviceId]; + return ( + !!device && + getDeviceAreaId(device, devices) === rowItem && + entries.referenced_entities.some((entityId) => + belongsTo(entityId, deviceId) + ) + ); + } + ); + + devicesInAreas.push(...areaDevices); + + nextEntries.referenced_devices = rootsOf(areaDevices); + nextEntries.referenced_entities = + entries.referenced_entities.filter((entityId) => { + const entity = entityRegistry[entityId]; + if (!entity) { + return false; + } + return ( + entity.area_id === rowItem || + !entity.device_id || + areaDevices.includes(entity.device_id) + ); + }); + + return nextEntries; + } + + nextEntries.referenced_entities = entitiesOf(rowItem); + + return nextEntries; + }); + + const entityRows = + type === "label" + ? entries.referenced_entities.filter((entityId) => { + const entity = entityRegistry[entityId]; + if (!entity) { + return false; + } + return ( + entity.labels.includes(itemId) && + !entries.referenced_devices.includes(entity.device_id || "") + ); + }) + : nextType === "device" + ? entries.referenced_entities.filter( + (entityId) => entityRegistry[entityId]?.area_id === itemId + ) + : []; + + const deviceRows = + type === "label" + ? rootsOf( + entries.referenced_devices.filter( + (deviceId) => + !devicesInAreas.includes(deviceId) && + devices[deviceId]?.labels.includes(itemId) + ) + ) + : childDevices; + + const deviceRowEntries = deviceRows.length + ? deviceRows.map((deviceId) => ({ + ...emptyEntries(), + referenced_entities: entitiesOf(deviceId), + })) + : undefined; + + return { + nextType, + rows, + rowEntries, + deviceRows, + deviceRowEntries, + entityRows, + }; +}; diff --git a/src/components/target-picker/ha-target-picker-item-row.ts b/src/components/target-picker/ha-target-picker-item-row.ts index d620eaf53e..10d9170bf4 100644 --- a/src/components/target-picker/ha-target-picker-item-row.ts +++ b/src/components/target-picker/ha-target-picker-item-row.ts @@ -29,7 +29,10 @@ import { } from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; import { computeEntityName } from "../../common/entity/compute_entity_name"; -import { getDeviceArea } from "../../common/entity/context/get_device_context"; +import { + getDeviceArea, + getDeviceAreaId, +} from "../../common/entity/context/get_device_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { computeRTL } from "../../common/util/compute_rtl"; import type { AreaRegistryEntry } from "../../data/area/area_registry"; @@ -63,6 +66,7 @@ import "../ha-state-icon"; import "../ha-svg-icon"; import "../item/ha-list-item-base"; import "../item/ha-list-item-button"; +import { computeTargetSubRows } from "./compute-target-sub-rows"; import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details"; @customElement("ha-target-picker-item-row") @@ -401,125 +405,25 @@ export class HaTargetPickerItemRow extends LitElement { return this._renderEmptyEntries(); } - let nextType: TargetType = - this.type === "floor" - ? "area" - : this.type === "area" - ? "device" - : "entity"; - - if (this.type === "label") { - if (entries?.referenced_areas.length) { - nextType = "area"; - } else if (entries?.referenced_devices.length) { - nextType = "device"; - } - } - - const rows1 = - (nextType === "area" - ? entries?.referenced_areas - : nextType === "device" && this.type !== "label" - ? entries?.referenced_devices - : this.type !== "label" - ? entries?.referenced_entities - : []) || []; - - const devicesInAreas = [] as string[]; - - const rows1Entries = - nextType === "entity" - ? undefined - : rows1.map((rowItem) => { - const nextEntries = { - referenced_areas: [] as string[], - referenced_devices: [] as string[], - referenced_entities: [] as string[], - }; - - if (nextType === "area") { - nextEntries.referenced_devices = - entries?.referenced_devices.filter( - (device_id) => - this.hass.devices?.[device_id]?.area_id === rowItem && - entries?.referenced_entities.some( - (entity_id) => - this.hass.entities?.[entity_id]?.device_id === device_id - ) - ) || ([] as string[]); - - devicesInAreas.push(...nextEntries.referenced_devices); - - nextEntries.referenced_entities = - entries?.referenced_entities.filter((entity_id) => { - const entity = this.hass.entities[entity_id]; - if (!entity) { - return false; - } - return ( - entity.area_id === rowItem || - !entity.device_id || - nextEntries.referenced_devices.includes(entity.device_id) - ); - }) || ([] as string[]); - - return nextEntries; - } - - nextEntries.referenced_entities = - entries?.referenced_entities.filter( - (entity_id) => - this.hass.entities?.[entity_id]?.device_id === rowItem - ) || ([] as string[]); - - return nextEntries; - }); - - const entityRows = - this.type === "label" && entries - ? entries.referenced_entities.filter((entity_id) => { - const entity = this.hass.entities[entity_id]; - if (!entity) { - return false; - } - return ( - entity.labels.includes(this.itemId) && - !entries.referenced_devices.includes(entity.device_id || "") - ); - }) - : nextType === "device" && entries - ? entries.referenced_entities.filter( - (entity_id) => - this.hass.entities[entity_id]?.area_id === this.itemId - ) - : []; - - const deviceRows = - this.type === "label" && entries - ? entries.referenced_devices.filter( - (device_id) => - !devicesInAreas.includes(device_id) && - this.hass.devices[device_id]?.labels.includes(this.itemId) - ) - : []; - - const deviceRowsEntries = - deviceRows.length === 0 - ? undefined - : deviceRows.map((device_id) => ({ - referenced_areas: [] as string[], - referenced_devices: [] as string[], - referenced_entities: - entries?.referenced_entities.filter( - (entity_id) => - this.hass.entities?.[entity_id]?.device_id === device_id - ) || ([] as string[]), - })); + const { + nextType, + rows, + rowEntries, + deviceRows, + deviceRowEntries, + entityRows, + } = computeTargetSubRows( + this.type, + this.itemId, + entries, + this.hass.entities, + this.hass.devices + ); const nextSubLevel = this.subLevel + 1; return html` - ${rows1.map( + ${rows.map( (itemId, index) => html` @@ -543,7 +447,7 @@ export class HaTargetPickerItemRow extends LitElement { .hass=${this.hass} type="device" .itemId=${itemId} - .parentEntries=${deviceRowsEntries?.[index]} + .parentEntries=${deviceRowEntries?.[index]} .hideContext=${this.hideContext || this.type !== "label"} expand > @@ -624,32 +528,53 @@ export class HaTargetPickerItemRow extends LitElement { let referencedDevices = entries.referenced_devices; const hiddenDeviceIds: string[] = []; + // Parents kept only so a matching child device can nest under them; + // their own entities stay filtered out like a hidden device's. + const groupOnlyDeviceIds = new Set(); if ( this.type === "floor" || this.type === "area" || this.type === "label" ) { + const matchingDeviceIds = new Set( + referencedDevices.filter((device_id) => { + const device = this.hass.devices[device_id]; + return ( + !!device && + !hiddenAreaIds.includes( + getDeviceAreaId(device, this.hass.devices) || "" + ) && + deviceMeetsFilter( + device, + this.hass.entities, + this.deviceFilter, + this.includeDomains, + this.includeDeviceClasses, + this.hass.states, + this.entityFilter, + !this.primaryEntitiesOnly + ) + ); + }) + ); + const parentsOfMatching = new Set( + [...matchingDeviceIds].map( + (device_id) => this.hass.devices[device_id].parent_device_id + ) + ); referencedDevices = referencedDevices.filter((device_id) => { - const device = this.hass.devices[device_id]; - if (!device) { + // Absent from the registry is not a filter decision: drop the id + // without marking it hidden, like the area filtering above. + if (!this.hass.devices[device_id]) { return false; } - if ( - !hiddenAreaIds.includes(device.area_id || "") && - deviceMeetsFilter( - device, - this.hass.entities, - this.deviceFilter, - this.includeDomains, - this.includeDeviceClasses, - this.hass.states, - this.entityFilter, - !this.primaryEntitiesOnly - ) - ) { + if (matchingDeviceIds.has(device_id)) { + return true; + } + if (parentsOfMatching.has(device_id)) { + groupOnlyDeviceIds.add(device_id); return true; } - hiddenDeviceIds.push(device_id); return false; }); @@ -663,7 +588,10 @@ export class HaTargetPickerItemRow extends LitElement { if (!entity) { return false; } - if (hiddenDeviceIds.includes(entity.device_id || "")) { + if ( + hiddenDeviceIds.includes(entity.device_id || "") || + groupOnlyDeviceIds.has(entity.device_id || "") + ) { return false; } if ( diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts index d640445fd9..a1f465aea6 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -20,6 +20,7 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import { computeAreaName } from "../../../../common/entity/compute_area_name"; import { computeDeviceName } from "../../../../common/entity/compute_device_name"; import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display"; +import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context"; import { stringCompare } from "../../../../common/string/compare"; import "../../../../components/ha-floor-icon"; import "../../../../components/ha-icon"; @@ -30,10 +31,7 @@ import "../../../../components/ha-svg-icon"; import "../../../../components/item/ha-list-item-button"; import "../../../../components/item/ha-row-item"; import "../../../../components/list/ha-list-base"; -import { - getAreaDeviceLookup, - getAreaEntityLookup, -} from "../../../../data/area/area_registry"; +import { getAreaEntityLookup } from "../../../../data/area/area_registry"; import { getAreasNestedInFloors, type AreaFloorValue, @@ -51,7 +49,10 @@ import { registriesContext, statesContext, } from "../../../../data/context"; -import { getDeviceEntityLookup } from "../../../../data/device/device_registry"; +import { + getDeviceEntityLookup, + type DeviceRegistryEntry, +} from "../../../../data/device/device_registry"; import { domainToName, type DomainManifestLookup, @@ -83,6 +84,7 @@ interface Level2Entries { interface Level3Entries { open: boolean; entities: string[]; + devices?: Record; } @customElement("ha-automation-add-from-target") @@ -281,23 +283,19 @@ export default class HaAutomationAddFromTarget extends LitElement { } if (valueId && valueType === "device") { - const areaId = this._registries.devices[valueId]?.area_id; - if (areaId) { - const floorId = this._registries.areas[areaId]?.floor_id || ""; - const { entities } = - entries[`floor${TARGET_SEPARATOR}${floorId}`].areas![ - `area${TARGET_SEPARATOR}${areaId}` - ].devices![valueId]; - - return entities.length ? this._renderEntities(entities) : nothing; + const entry = this._getDeviceEntry(entries, valueId); + if (!entry) { + return nothing; } - - const device = this._registries.devices[valueId]; - const isService = device.entry_type === "service"; - const { entities } = - entries[`${isService ? "service" : "area"}${TARGET_SEPARATOR}`] - .devices![valueId]; - return entities.length ? this._renderEntities(entities) : nothing; + const numberOfDevices = Object.keys(entry.devices ?? {}).length; + return html` + ${numberOfDevices ? this._renderDevices(entry.devices!) : nothing} + ${ + entry.entities.length + ? this._renderEntities(entry.entities) + : nothing + } + `; } if (valueType === "device" || valueType === "helper") { @@ -680,7 +678,8 @@ export default class HaAutomationAddFromTarget extends LitElement { stringCompare(deviceNameA, deviceNameB, this.hass.locale.language) ) .map(([deviceId, deviceName, domain]) => { - const { open, entities } = devices[deviceId]; + const { open, entities, devices: children } = devices[deviceId]; + const numberOfChildren = Object.keys(children ?? {}).length; return this._renderItem( deviceName || deviceId, @@ -688,10 +687,15 @@ export default class HaAutomationAddFromTarget extends LitElement { false, this._getSelectedTargetId(this.value) === `device${TARGET_SEPARATOR}${deviceId}`, - !open && !!entities.length, + !open && !!(entities.length || numberOfChildren), open, domain ? this._renderDomainIcon(domain) : undefined, - open ? this._renderEntities(entities) : undefined + open + ? html` + ${numberOfChildren ? this._renderDevices(children!) : nothing} + ${this._renderEntities(entities)} + ` + : undefined ); }); @@ -899,8 +903,16 @@ export default class HaAutomationAddFromTarget extends LitElement { // #region memoized data helpers private _getAreaDeviceLookupMemoized = memoizeOne( - (devices: HomeAssistant["devices"]) => - getAreaDeviceLookup(Object.values(devices)) + (devices: HomeAssistant["devices"]) => { + const lookup: Record = {}; + for (const device of Object.values(devices)) { + const areaId = getDeviceAreaId(device, devices); + if (areaId) { + (lookup[areaId] ??= []).push(device); + } + } + return lookup; + } ); private _getAreaEntityLookupMemoized = memoizeOne( @@ -974,32 +986,18 @@ export default class HaAutomationAddFromTarget extends LitElement { private _loadUnassignedDevices() { const unassignedDevices = Object.values(this._registries.devices).filter( - (device) => !device.area_id + (device) => + !device.disabled_by && + !getDeviceAreaId(device, this._registries.devices) ); - const devices: Record = {}; + const devices = this._buildDeviceEntries( + unassignedDevices.filter((device) => device.entry_type !== "service") + ); - const services: Record = {}; - - unassignedDevices.forEach(({ id: deviceId, entry_type }) => { - const device = this._registries.devices[deviceId]; - if (!device || device.disabled_by) { - return; - } - const deviceEntry = { - open: false, - entities: - this._getDeviceEntityLookupMemoized(this._registries.entities)[ - deviceId - ]?.map((entity) => entity.entity_id) || [], - }; - if (entry_type === "service") { - services[deviceId] = deviceEntry; - return; - } - - devices[deviceId] = deviceEntry; - }); + const services = this._buildDeviceEntries( + unassignedDevices.filter((device) => device.entry_type === "service") + ); if (Object.keys(devices).length) { this._entries = { @@ -1079,31 +1077,21 @@ export default class HaAutomationAddFromTarget extends LitElement { private _loadArea(area: FloorComboBoxItem) { const [, id] = area.id.split(TARGET_SEPARATOR, 2); - const referenced_devices = - this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || []; + const referenced_devices = ( + this._getAreaDeviceLookupMemoized(this._registries.devices)[id] || [] + ).filter((device) => !device.disabled_by); const referenced_entities = this._getAreaEntityLookupMemoized(this._registries.entities)[id] || []; - const devices: Record = {}; - - referenced_devices.forEach(({ id: deviceId }) => { - const device = this._registries.devices[deviceId]; - if (!device || device.disabled_by) { - return; - } - devices[deviceId] = { - open: false, - entities: - this._getDeviceEntityLookupMemoized(this._registries.entities)[ - deviceId - ]?.map((entity) => entity.entity_id) || [], - }; - }); + const devices = this._buildDeviceEntries(referenced_devices); + const areaDeviceIds = new Set( + referenced_devices.map((device) => device.id) + ); const entities: string[] = []; referenced_entities.forEach((entity) => { - if (!entity.device_id || !devices[entity.device_id]) { + if (!entity.device_id || !areaDeviceIds.has(entity.device_id)) { entities.push(entity.entity_id); } }); @@ -1115,6 +1103,160 @@ export default class HaAutomationAddFromTarget extends LitElement { }; } + private _buildDeviceEntries( + devices: DeviceRegistryEntry[] + ): Record { + const deviceEntityLookup = this._getDeviceEntityLookupMemoized( + this._registries.entities + ); + const entryFor = (deviceId: string): Level3Entries => ({ + open: false, + entities: + deviceEntityLookup[deviceId]?.map((entity) => entity.entity_id) || [], + }); + + const deviceIds = new Set(devices.map((device) => device.id)); + const nested = (device: DeviceRegistryEntry) => + !!device.parent_device_id && deviceIds.has(device.parent_device_id); + + const roots = devices.filter((device) => !nested(device)); + const children = devices.filter(nested); + + const entries: Record = {}; + for (const device of roots) { + entries[device.id] = entryFor(device.id); + } + for (const device of children) { + (entries[device.parent_device_id!].devices ??= {})[device.id] = entryFor( + device.id + ); + } + return entries; + } + + private _deviceGroupPath( + device: DeviceRegistryEntry + ): [level1: string, level2?: string] { + const areaId = getDeviceAreaId(device, this._registries.devices); + if (areaId) { + return [ + `floor${TARGET_SEPARATOR}${this._registries.areas[areaId]?.floor_id || ""}`, + `area${TARGET_SEPARATOR}${areaId}`, + ]; + } + return [ + `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`, + ]; + } + + private _deviceGroup( + entries: Record, + device: DeviceRegistryEntry + ): Record | undefined { + const [level1, level2] = this._deviceGroupPath(device); + const entry = entries[level1]; + return level2 ? entry?.areas?.[level2]?.devices : entry?.devices; + } + + private _deviceChain( + group: Record | undefined, + device: DeviceRegistryEntry + ): string[] { + const parentId = device.parent_device_id; + return parentId && group?.[parentId]?.devices?.[device.id] + ? [parentId, device.id] + : [device.id]; + } + + private _getDeviceEntry( + entries: Record, + deviceId: string + ): Level3Entries | undefined { + const device = this._registries.devices[deviceId]; + if (!device) { + return undefined; + } + const group = this._deviceGroup(entries, device); + let level = group; + let entry: Level3Entries | undefined; + for (const key of this._deviceChain(group, device)) { + entry = level?.[key]; + level = entry?.devices; + } + return entry; + } + + private _setDeviceOpen( + deviceId: string, + deviceOpen: boolean | undefined, + openAncestors: boolean + ) { + const device = this._registries.devices[deviceId]; + if (!device) { + return; + } + const [level1, level2] = this._deviceGroupPath(device); + const group = this._deviceGroup(this._entries, device); + if (!group) { + return; + } + const chain = this._deviceChain(group, device); + + const updateGroup = ( + devices: Record, + [key, ...rest]: string[] + ): Record => { + const entry = devices[key]; + if (!entry) { + return devices; + } + const isTarget = !rest.length; + return { + ...devices, + [key]: { + ...entry, + open: isTarget + ? (deviceOpen ?? entry.open) + : openAncestors || entry.open, + devices: + isTarget || !entry.devices + ? entry.devices + : updateGroup(entry.devices, rest), + }, + }; + }; + + const level1Entry = this._entries[level1]; + if (!level2) { + this._entries = { + ...this._entries, + [level1]: { + ...level1Entry, + open: openAncestors || level1Entry.open, + devices: updateGroup(group, chain), + }, + }; + return; + } + + const level2Entry = level1Entry.areas![level2]; + this._entries = { + ...this._entries, + [level1]: { + ...level1Entry, + open: openAncestors || level1Entry.open, + areas: { + ...level1Entry.areas, + [level2]: { + ...level2Entry, + open: openAncestors || level2Entry.open, + devices: updateGroup(group, chain), + }, + }, + }, + }; + } + private _expandTreeToItem(type: string, id: string) { if (type === "floor" || type === "label") { return; @@ -1122,39 +1264,26 @@ export default class HaAutomationAddFromTarget extends LitElement { if (type === "entity") { const deviceId = this._registries.entities[id]?.device_id; - const device = deviceId ? this._registries.devices[deviceId] : undefined; - const deviceAreaId = (deviceId && device?.area_id) || undefined; + if (deviceId && this._registries.devices[deviceId]) { + this._setDeviceOpen(deviceId, true, true); + return; + } - if (!deviceAreaId) { - let floor: string; - let area: string; - const entity = this._registries.entities[id]; + const entity = this._registries.entities[id]; - if (!deviceId && entity.area_id) { - floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`; - area = `area${TARGET_SEPARATOR}${entity.area_id}`; - } else if (!deviceId) { - const domain = id.split(".", 1)[0]; - const isHelper = - this.manifests![domain]?.integration_type === "helper"; - - floor = isHelper - ? `helper${TARGET_SEPARATOR}` - : `device${TARGET_SEPARATOR}`; - area = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`; - } else { - floor = `${device!.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`; - area = deviceId; - } + if (entity?.area_id) { + const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[entity.area_id]?.floor_id || ""}`; + const area = `area${TARGET_SEPARATOR}${entity.area_id}`; + const floorEntry = this._entries[floor]; this._entries = { ...this._entries, [floor]: { - ...this._entries[floor], + ...floorEntry, open: true, - devices: { - ...this._entries[floor].devices!, + areas: { + ...floorEntry.areas, [area]: { - ...this._entries[floor].devices![area], + ...floorEntry.areas![area], open: true, }, }, @@ -1163,26 +1292,23 @@ export default class HaAutomationAddFromTarget extends LitElement { return; } - const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`; - const area = `area${TARGET_SEPARATOR}${deviceAreaId}`; - + const domain = id.split(".", 1)[0]; + const isHelper = this.manifests![domain]?.integration_type === "helper"; + const group = isHelper + ? `helper${TARGET_SEPARATOR}` + : `device${TARGET_SEPARATOR}`; + const domainGroup = `${isHelper ? "helper_" : "entity_"}${domain}${TARGET_SEPARATOR}`; + const groupEntry = this._entries[group]; this._entries = { ...this._entries, - [floor]: { - ...this._entries[floor], + [group]: { + ...groupEntry, open: true, - areas: { - ...this._entries[floor].areas!, - [area]: { - ...this._entries[floor].areas![area], + devices: { + ...groupEntry.devices, + [domainGroup]: { + ...groupEntry.devices![domainGroup], open: true, - devices: { - ...this._entries[floor].areas![area].devices, - [deviceId!]: { - ...this._entries[floor].areas![area].devices![deviceId!], - open: true, - }, - }, }, }, }, @@ -1191,38 +1317,7 @@ export default class HaAutomationAddFromTarget extends LitElement { } if (type === "device") { - const deviceAreaId = this._registries.devices[id]?.area_id; - - if (!deviceAreaId) { - const device = this._registries.devices[id]; - const floor = `${device.entry_type === "service" ? "service" : "area"}${TARGET_SEPARATOR}`; - this._entries = { - ...this._entries, - [floor]: { - ...this._entries[floor], - open: true, - }, - }; - return; - } - - const floor = `floor${TARGET_SEPARATOR}${this._registries.areas[deviceAreaId]?.floor_id || ""}`; - const area = `area${TARGET_SEPARATOR}${deviceAreaId}`; - - this._entries = { - ...this._entries, - [floor]: { - ...this._entries[floor], - open: true, - areas: { - ...this._entries[floor].areas!, - [area]: { - ...this._entries[floor].areas![area], - open: true, - }, - }, - }, - }; + this._setDeviceOpen(id, undefined, true); return; } @@ -1348,51 +1443,7 @@ export default class HaAutomationAddFromTarget extends LitElement { } if (type === "device" && id) { - const areaId = this._registries.devices[id]?.area_id; - if (areaId) { - const areaTargetId = `area${TARGET_SEPARATOR}${this._registries.devices[id]?.area_id ?? ""}`; - const floorId = `floor${TARGET_SEPARATOR}${(areaId && this._registries.areas[areaId]?.floor_id) || ""}`; - - this._entries = { - ...this._entries, - [floorId]: { - ...this._entries[floorId], - areas: { - ...this._entries[floorId].areas, - [areaTargetId]: { - ...this._entries[floorId].areas![areaTargetId], - devices: { - ...this._entries[floorId].areas![areaTargetId].devices, - [id]: { - ...this._entries[floorId].areas![areaTargetId].devices[id], - open, - }, - }, - }, - }, - }, - }; - return; - } - - const deviceType = - this._registries.devices[id]?.entry_type === "service" - ? "service" - : "area"; - const floorId = `${deviceType}${TARGET_SEPARATOR}`; - this._entries = { - ...this._entries, - [floorId]: { - ...this._entries[floorId], - devices: { - ...this._entries[floorId].devices, - [id]: { - ...this._entries[floorId].devices![id], - open, - }, - }, - }, - }; + this._setDeviceOpen(id, open, false); return; } diff --git a/src/panels/lovelace/editor/card-editor/entity-tree-builder.ts b/src/panels/lovelace/editor/card-editor/entity-tree-builder.ts index 6d730c79de..f93a77b46c 100644 --- a/src/panels/lovelace/editor/card-editor/entity-tree-builder.ts +++ b/src/panels/lovelace/editor/card-editor/entity-tree-builder.ts @@ -12,9 +12,11 @@ import { computeDeviceName } from "../../../../common/entity/compute_device_name import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeEntityName } from "../../../../common/entity/compute_entity_name"; import { computeStateName } from "../../../../common/entity/compute_state_name"; +import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context"; import { getEntityContext } from "../../../../common/entity/context/get_entity_context"; import { stringCompare } from "../../../../common/string/compare"; import { entityComboBoxKeys } from "../../../../data/entity/entity_picker"; +import type { DeviceRegistryEntry } from "../../../../data/device/device_registry"; import { domainToName } from "../../../../data/integration"; import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm"; import type { HomeAssistant } from "../../../../types"; @@ -24,6 +26,7 @@ export interface DeviceNode { id: string; name: string; entityIds: string[]; + children: DeviceNode[]; } export interface AreaNode { @@ -132,6 +135,30 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree { const unassignedEntityByDomain = new Map(); const searchableEntities: SearchableEntity[] = []; + const addDeviceEntity = ( + bucket: Map, + device: DeviceRegistryEntry, + entityId: string, + areaId: string | undefined + ) => { + const list = bucket.get(device.id) ?? []; + list.push(entityId); + bucket.set(device.id, list); + // A child device nests under its parent when both land in the same + // bucket, so the parent needs a row even without entities of its own. + const parent = device.parent_device_id + ? deviceReg[device.parent_device_id] + : undefined; + if ( + parent && + getDeviceAreaId(parent, deviceReg) === areaId && + (parent.entry_type === "service") === (device.entry_type === "service") && + !bucket.has(parent.id) + ) { + bucket.set(parent.id, []); + } + }; + for (const entityId of Object.keys(states)) { const stateObj = states[entityId]; if (!stateObj) continue; @@ -182,9 +209,7 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree { const target = isService ? unassignedServiceEntities : unassignedDeviceEntities; - const list = target.get(device.id) ?? []; - list.push(entityId); - target.set(device.id, list); + addDeviceEntity(target, device, entityId, undefined); } else if (isHelperDomain(domain)) { const list = unassignedHelperByDomain.get(domain) ?? []; list.push(entityId); @@ -200,9 +225,7 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree { const groupUnderDevice = device && !entry?.area_id; if (groupUnderDevice) { const byDevice = areaDeviceEntities.get(areaId) ?? new Map(); - const list = byDevice.get(device!.id) ?? []; - list.push(entityId); - byDevice.set(device!.id, list); + addDeviceEntity(byDevice, device!, entityId, areaId); areaDeviceEntities.set(areaId, byDevice); } else { const list = areaDirectEntities.get(areaId) ?? []; @@ -217,17 +240,35 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree { return stringCompare(an, bn, language); }; - const buildDeviceNodes = (source: Map): DeviceNode[] => - [...source.entries()] - .map(([id, ids]) => { - const device = deviceReg[id]; - return { - id, - name: (device ? computeDeviceName(device) : undefined) ?? id, - entityIds: ids.sort(sortByName), - }; - }) - .sort((a, b) => stringCompare(a.name, b.name, language)); + const sortDeviceNodes = (nodes: DeviceNode[]) => { + nodes.sort((a, b) => stringCompare(a.name, b.name, language)); + nodes.forEach((node) => sortDeviceNodes(node.children)); + }; + + const buildDeviceNodes = (source: Map): DeviceNode[] => { + const nodes = new Map(); + for (const [id, ids] of source) { + const device = deviceReg[id]; + nodes.set(id, { + id, + name: (device ? computeDeviceName(device) : undefined) ?? id, + entityIds: ids.sort(sortByName), + children: [], + }); + } + const roots: DeviceNode[] = []; + for (const node of nodes.values()) { + const parentId = deviceReg[node.id]?.parent_device_id; + const parent = parentId ? nodes.get(parentId) : undefined; + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + sortDeviceNodes(roots); + return roots; + }; const buildAreaNode = (areaId: string): AreaNode | undefined => { const area = areaReg[areaId]; @@ -325,17 +366,28 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree { }; } +const devicePathToEntity = ( + devices: DeviceNode[], + parentKey: string, + entityId: string +): string[] | undefined => { + for (const device of devices) { + const key = deviceKey(parentKey, device.id); + if (device.entityIds.includes(entityId)) return [key]; + const nested = devicePathToEntity(device.children, key, entityId); + if (nested) return [key, ...nested]; + } + return undefined; +}; + export function pathToEntity(tree: EntityTree, entityId: string): string[] { for (const floor of tree.floors) { const fKey = floorKey(floor.id); for (const area of floor.areas) { const aKey = areaKey(fKey, area.id); if (area.directEntityIds.includes(entityId)) return [fKey, aKey]; - for (const device of area.devices) { - if (device.entityIds.includes(entityId)) { - return [fKey, aKey, deviceKey(aKey, device.id)]; - } - } + const devicePath = devicePathToEntity(area.devices, aKey, entityId); + if (devicePath) return [fKey, aKey, ...devicePath]; } } @@ -345,21 +397,15 @@ export function pathToEntity(tree: EntityTree, entityId: string): string[] { if (area.directEntityIds.includes(entityId)) { return [otherAreasFloor, aKey]; } - for (const device of area.devices) { - if (device.entityIds.includes(entityId)) { - return [otherAreasFloor, aKey, deviceKey(aKey, device.id)]; - } - } + const devicePath = devicePathToEntity(area.devices, aKey, entityId); + if (devicePath) return [otherAreasFloor, aKey, ...devicePath]; } for (const section of tree.unassignedSections) { const sKey = unassignedKey(section.id); if (section.devices) { - for (const device of section.devices) { - if (device.entityIds.includes(entityId)) { - return [sKey, deviceKey(sKey, device.id)]; - } - } + const devicePath = devicePathToEntity(section.devices, sKey, entityId); + if (devicePath) return [sKey, ...devicePath]; } if (section.domains) { for (const group of section.domains) { diff --git a/src/panels/lovelace/editor/card-editor/hui-suggestion-entity-tree.ts b/src/panels/lovelace/editor/card-editor/hui-suggestion-entity-tree.ts index 33bf1ae532..6e50237b52 100644 --- a/src/panels/lovelace/editor/card-editor/hui-suggestion-entity-tree.ts +++ b/src/panels/lovelace/editor/card-editor/hui-suggestion-entity-tree.ts @@ -416,14 +416,18 @@ export class HuiSuggestionEntityTree extends LitElement { `; } - private _renderDevice(device: DeviceNode, parentKey: string): TemplateResult { + private _renderDevice( + device: DeviceNode, + parentKey: string, + nested = false + ): TemplateResult { const key = deviceKey(parentKey, device.id); const expanded = this._isExpanded(key); const domain = this._deviceDomain(device.id); return html` ${ expanded - ? repeat( - device.entityIds, - (id: string) => id, - (id: string) => this._renderEntity(id, "depth-entity-device") - ) + ? html` + ${repeat( + device.children, + (child: DeviceNode) => child.id, + (child: DeviceNode) => this._renderDevice(child, key, true) + )} + ${repeat( + device.entityIds, + (id: string) => id, + (id: string) => + this._renderEntity( + id, + nested + ? "depth-entity-device-nested" + : "depth-entity-device" + ) + )} + ` : nothing } `; @@ -650,9 +667,13 @@ export class HuiSuggestionEntityTree extends LitElement { ha-combo-box-item.depth-entity-area { --md-list-item-leading-space: var(--ha-space-12); } - ha-combo-box-item.depth-entity-device { + ha-combo-box-item.depth-entity-device, + ha-combo-box-item.depth-device-nested { --md-list-item-leading-space: var(--ha-space-16); } + ha-combo-box-item.depth-entity-device-nested { + --md-list-item-leading-space: var(--ha-space-20); + } .leading { display: flex; align-items: center; diff --git a/test/components/target-picker/compute-target-sub-rows.test.ts b/test/components/target-picker/compute-target-sub-rows.test.ts new file mode 100644 index 0000000000..28b2d781c5 --- /dev/null +++ b/test/components/target-picker/compute-target-sub-rows.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { computeTargetSubRows } from "../../../src/components/target-picker/compute-target-sub-rows"; +import type { ExtractFromTargetResultReferenced } from "../../../src/data/target"; +import { + mockDevice, + mockEntity, +} from "../../common/entity/context/context-mock"; + +const entries = ( + referenced: Partial +): ExtractFromTargetResultReferenced => ({ + referenced_areas: [], + referenced_devices: [], + referenced_entities: [], + ...referenced, +}); + +// A power strip in the garage with two outlets. The outlets have no area of +// their own and inherit the garage from the strip. +const entities = { + "sensor.strip_power": mockEntity({ + entity_id: "sensor.strip_power", + device_id: "strip", + }), + "sensor.outlet_1_power": mockEntity({ + entity_id: "sensor.outlet_1_power", + device_id: "outlet_1", + }), + "sensor.outlet_2_power": mockEntity({ + entity_id: "sensor.outlet_2_power", + device_id: "outlet_2", + }), +}; +const devices = { + strip: mockDevice({ id: "strip", area_id: "garage" }), + outlet_1: mockDevice({ id: "outlet_1", parent_device_id: "strip" }), + outlet_2: mockDevice({ id: "outlet_2", parent_device_id: "strip" }), +}; +const allDevices = Object.keys(devices); +const allEntities = Object.keys(entities); + +describe("computeTargetSubRows", () => { + it("nests child devices under a device target and keeps its own entities as rows", () => { + const subRows = computeTargetSubRows( + "device", + "strip", + entries({ + referenced_devices: allDevices, + referenced_entities: allEntities, + }), + entities, + devices + ); + + expect(subRows.nextType).toBe("entity"); + expect(subRows.rows).toEqual(["sensor.strip_power"]); + expect(subRows.deviceRows).toEqual(["outlet_1", "outlet_2"]); + expect(subRows.deviceRowEntries?.map((e) => e.referenced_entities)).toEqual( + [["sensor.outlet_1_power"], ["sensor.outlet_2_power"]] + ); + }); + + it("lists a child device only under its parent for an area target", () => { + const subRows = computeTargetSubRows( + "area", + "garage", + entries({ + referenced_devices: allDevices, + referenced_entities: allEntities, + }), + entities, + devices + ); + + expect(subRows.nextType).toBe("device"); + expect(subRows.rows).toEqual(["strip"]); + expect(subRows.rowEntries?.[0].referenced_entities).toEqual(allEntities); + expect(subRows.deviceRows).toEqual([]); + expect(subRows.entityRows).toEqual([]); + }); + + it("puts a child device in its parent's area when browsing from a floor", () => { + const subRows = computeTargetSubRows( + "floor", + "ground", + entries({ + referenced_areas: ["garage"], + referenced_devices: allDevices, + referenced_entities: allEntities, + }), + entities, + devices + ); + + expect(subRows.nextType).toBe("area"); + expect(subRows.rows).toEqual(["garage"]); + expect(subRows.rowEntries?.[0].referenced_devices).toEqual(["strip"]); + expect(subRows.rowEntries?.[0].referenced_entities).toEqual(allEntities); + }); + + it("does not duplicate a labeled child device under its labeled parent", () => { + const labeled = { + strip: mockDevice({ id: "strip", labels: ["power"] }), + outlet_1: mockDevice({ + id: "outlet_1", + parent_device_id: "strip", + labels: ["power"], + }), + }; + const subRows = computeTargetSubRows( + "label", + "power", + entries({ + referenced_devices: ["strip", "outlet_1"], + referenced_entities: ["sensor.strip_power", "sensor.outlet_1_power"], + }), + entities, + labeled + ); + + expect(subRows.nextType).toBe("device"); + expect(subRows.rows).toEqual([]); + expect(subRows.deviceRows).toEqual(["strip"]); + expect(subRows.deviceRowEntries?.[0].referenced_entities).toEqual([ + "sensor.strip_power", + "sensor.outlet_1_power", + ]); + expect(subRows.entityRows).toEqual([]); + }); + + it("keeps a labeled child device at the top level when its parent is not labeled", () => { + const labeled = { + strip: mockDevice({ id: "strip" }), + outlet_1: mockDevice({ + id: "outlet_1", + parent_device_id: "strip", + labels: ["power"], + }), + }; + const subRows = computeTargetSubRows( + "label", + "power", + entries({ + referenced_devices: ["outlet_1"], + referenced_entities: ["sensor.outlet_1_power"], + }), + entities, + labeled + ); + + expect(subRows.deviceRows).toEqual(["outlet_1"]); + expect(subRows.deviceRowEntries?.[0].referenced_entities).toEqual([ + "sensor.outlet_1_power", + ]); + }); +}); diff --git a/test/components/target-picker/ha-target-picker-item-row.test.ts b/test/components/target-picker/ha-target-picker-item-row.test.ts index f3d066b17c..07510dcebf 100644 --- a/test/components/target-picker/ha-target-picker-item-row.test.ts +++ b/test/components/target-picker/ha-target-picker-item-row.test.ts @@ -174,6 +174,30 @@ describe("ha-target-picker-item-row target extraction", () => { expect(entries!.referenced_entities).toEqual(["light.on_dev1"]); }); + it("keeps a parent device without entities when a child device matches", async () => { + const entries = await extractedBy( + extractResult({ + referenced_devices: ["strip", "outlet_1"], + referenced_entities: ["sensor.outlet_1_power"], + }), + { + entities: { + "sensor.outlet_1_power": mkEntity("sensor.outlet_1_power", { + device_id: "outlet_1", + }), + }, + devices: { + strip: mkDevice("strip", { area_id: "area_1" }), + outlet_1: mkDevice("outlet_1", { parent_device_id: "strip" }), + }, + } + ); + + expect(entries).toBeDefined(); + expect(entries!.referenced_devices).toEqual(["strip", "outlet_1"]); + expect(entries!.referenced_entities).toEqual(["sensor.outlet_1_power"]); + }); + it("does not mutate the extracted target result", async () => { const result = extractResult({ referenced_areas: ["area_missing"], diff --git a/test/panels/lovelace/editor/card-editor/entity-tree-builder.test.ts b/test/panels/lovelace/editor/card-editor/entity-tree-builder.test.ts index 9e5ad1761e..a4fa7dcc05 100644 --- a/test/panels/lovelace/editor/card-editor/entity-tree-builder.test.ts +++ b/test/panels/lovelace/editor/card-editor/entity-tree-builder.test.ts @@ -179,6 +179,64 @@ describe("buildEntityTree", () => { ]); }); + it("nests a child device under its parent, even when the parent has no entities", () => { + const hass = makeHass({ + states: { + "switch.outlet_1": state("switch.outlet_1"), + "switch.outlet_2": state("switch.outlet_2"), + }, + entities: { + "switch.outlet_1": entity({ device_id: "outlet_1" }), + "switch.outlet_2": entity({ device_id: "outlet_2" }), + }, + devices: { + strip: device("strip", { name: "Power strip", area_id: "office" }), + outlet_1: device("outlet_1", { + name: "Outlet 1", + parent_device_id: "strip", + }), + outlet_2: device("outlet_2", { + name: "Outlet 2", + parent_device_id: "strip", + }), + }, + areas: { office: area("office") }, + }); + + const tree = buildTree(hass); + const office = tree.otherAreas[0]; + expect(office.devices.map((d) => d.id)).toEqual(["strip"]); + expect(office.devices[0].entityIds).toEqual([]); + expect(office.devices[0].children.map((d) => d.id)).toEqual([ + "outlet_1", + "outlet_2", + ]); + expect(office.devices[0].children[0].entityIds).toEqual([ + "switch.outlet_1", + ]); + }); + + it("keeps a child device at the top level when its parent is in another area", () => { + const hass = makeHass({ + states: { "switch.outlet_1": state("switch.outlet_1") }, + entities: { "switch.outlet_1": entity({ device_id: "outlet_1" }) }, + devices: { + strip: device("strip", { area_id: "office" }), + outlet_1: device("outlet_1", { + area_id: "kitchen", + parent_device_id: "strip", + }), + }, + areas: { office: area("office"), kitchen: area("kitchen") }, + }); + + const tree = buildTree(hass); + const kitchen = tree.otherAreas.find((a) => a.id === "kitchen")!; + expect(kitchen.devices.map((d) => d.id)).toEqual(["outlet_1"]); + expect(kitchen.devices[0].children).toEqual([]); + expect(tree.otherAreas.find((a) => a.id === "office")).toBeUndefined(); + }); + it("treats entities with their own area_id as direct area entities (not under device)", () => { const hass = makeHass({ states: { "sensor.temp": state("sensor.temp") }, @@ -353,6 +411,27 @@ describe("pathToEntity", () => { ]); }); + it("includes the parent device key for an entity on a child device", () => { + const hass = makeHass({ + states: { "switch.outlet_1": state("switch.outlet_1") }, + entities: { "switch.outlet_1": entity({ device_id: "outlet_1" }) }, + devices: { + strip: device("strip", { area_id: "office" }), + outlet_1: device("outlet_1", { parent_device_id: "strip" }), + }, + areas: { office: area("office") }, + }); + const tree = buildTree(hass); + const aKey = areaKey(floorKey(OTHER_AREAS_ID), "office"); + const stripKey = deviceKey(aKey, "strip"); + expect(pathToEntity(tree, "switch.outlet_1")).toEqual([ + floorKey(OTHER_AREAS_ID), + aKey, + stripKey, + deviceKey(stripKey, "outlet_1"), + ]); + }); + it("returns the unassigned section + domain path for orphan entities", () => { const hass = makeHass({ states: { "light.unowned": state("light.unowned") },