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") },