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
This commit is contained in:
Paul Bottein
2026-09-10 15:46:28 +02:00
committed by GitHub
parent 13ef9712bc
commit b2bb3e9d9d
8 changed files with 847 additions and 361 deletions
@@ -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,
};
};
@@ -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`
<ha-target-picker-item-row
sub-entry
@@ -528,7 +432,7 @@ export class HaTargetPickerItemRow extends LitElement {
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.parentEntries=${rowEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
@@ -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
></ha-target-picker-item-row>
@@ -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<string>();
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 (
@@ -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<string, Level3Entries>;
}
@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<string, DeviceRegistryEntry[]> = {};
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<string, Level3Entries> = {};
const devices = this._buildDeviceEntries(
unassignedDevices.filter((device) => device.entry_type !== "service")
);
const services: Record<string, Level3Entries> = {};
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<string, Level3Entries> = {};
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<string, Level3Entries> {
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<string, Level3Entries> = {};
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<string, Level1Entries>,
device: DeviceRegistryEntry
): Record<string, Level3Entries> | undefined {
const [level1, level2] = this._deviceGroupPath(device);
const entry = entries[level1];
return level2 ? entry?.areas?.[level2]?.devices : entry?.devices;
}
private _deviceChain(
group: Record<string, Level3Entries> | 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<string, Level1Entries>,
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<string, Level3Entries>,
[key, ...rest]: string[]
): Record<string, Level3Entries> => {
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;
}
@@ -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<string, string[]>();
const searchableEntities: SearchableEntity[] = [];
const addDeviceEntity = (
bucket: Map<string, string[]>,
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<string, string[]>): 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<string, string[]>): DeviceNode[] => {
const nodes = new Map<string, DeviceNode>();
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) {
@@ -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`
<ha-combo-box-item
type="button"
class="branch depth-device device-item"
class="branch ${nested ? "depth-device-nested" : "depth-device"} device-item"
aria-expanded=${expanded}
data-node-key=${key}
@click=${this._toggleNode}
@@ -444,11 +448,24 @@ export class HuiSuggestionEntityTree extends LitElement {
</ha-combo-box-item>
${
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;
@@ -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>
): 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",
]);
});
});
@@ -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"],
@@ -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") },