mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-28 16:04:00 +01:00
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:
@@ -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 (
|
||||
|
||||
+237
-186
@@ -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") },
|
||||
|
||||
Reference in New Issue
Block a user