diff --git a/src/common/entity/compute_area_name.ts b/src/common/entity/compute_area_name.ts new file mode 100644 index 000000000000..eb5bf81b92b0 --- /dev/null +++ b/src/common/entity/compute_area_name.ts @@ -0,0 +1,4 @@ +import { AreaRegistryEntry } from "../../data/area_registry"; + +export const computeAreaName = (area: AreaRegistryEntry): string | undefined => + area.name?.trim(); diff --git a/src/common/entity/compute_device_name.ts b/src/common/entity/compute_device_name.ts new file mode 100644 index 000000000000..b184fe325474 --- /dev/null +++ b/src/common/entity/compute_device_name.ts @@ -0,0 +1,5 @@ +import { DeviceRegistryEntry } from "../../data/device_registry"; + +export const computeDeviceName = ( + device: DeviceRegistryEntry +): string | undefined => (device.name_by_user || device.name)?.trim(); diff --git a/src/common/entity/compute_entity_name.ts b/src/common/entity/compute_entity_name.ts new file mode 100644 index 000000000000..9de44644e58c --- /dev/null +++ b/src/common/entity/compute_entity_name.ts @@ -0,0 +1,107 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { EntityRegistryDisplayEntry } from "../../data/entity_registry"; +import { HomeAssistant } from "../../types"; +import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name"; +import { computeStateName } from "./compute_state_name"; +import { computeDeviceName } from "./compute_device_name"; +import { computeAreaName } from "./compute_area_name"; +import { computeFloorName } from "./compute_floor_name"; + +export const computeEntityFullName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"] +): string | undefined => { + const entry = entities[stateObj.entity_id] as + | EntityRegistryDisplayEntry + | undefined; + + const entityName = computeEntityName(stateObj, entities, devices); + + if (!entry?.has_entity_name) { + return entityName; + } + + const deviceName = computeEntityDeviceName(stateObj, entities, devices); + + if (!entityName || !deviceName || entityName === deviceName) { + return entityName || deviceName; + } + + return `${deviceName} ${entityName}`; +}; + +export const computeEntityName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"] +): string | undefined => { + const entry = entities[stateObj.entity_id] as + | EntityRegistryDisplayEntry + | undefined; + + const device = entry?.device_id ? devices[entry.device_id] : undefined; + + const name = (entry ? entry.name : computeStateName(stateObj))?.trim(); + + const deviceName = device ? computeDeviceName(device) : undefined; + + if (!name || !deviceName) { + return name || deviceName; + } + + if (name === deviceName) { + return name; + } + + return stripPrefixFromEntityName(name, deviceName.toLowerCase()) || name; +}; + +export const computeEntityDeviceName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"] +): string | undefined => { + const entry = entities[stateObj.entity_id] as + | EntityRegistryDisplayEntry + | undefined; + const device = entry?.device_id ? devices[entry.device_id] : undefined; + + return device ? computeDeviceName(device) : undefined; +}; + +export const computeEntityAreaName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"] +): string | undefined => { + const entry = entities[stateObj.entity_id] as + | EntityRegistryDisplayEntry + | undefined; + const device = entry?.device_id ? devices[entry?.device_id] : undefined; + + const areaId = entry?.area_id || device?.area_id; + const area = areaId ? areas[areaId] : undefined; + + return area ? computeAreaName(area) : undefined; +}; + +export const computeEntityFloorName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"] +): string | undefined => { + const entry = entities[stateObj.entity_id] as + | EntityRegistryDisplayEntry + | undefined; + const device = entry?.device_id ? devices[entry?.device_id] : undefined; + + const areaId = entry?.area_id || device?.area_id; + const area = areaId ? areas[areaId] : undefined; + const floor = area?.floor_id ? floors[area?.floor_id] : undefined; + + return floor ? computeFloorName(floor) : undefined; +}; diff --git a/src/common/entity/compute_floor_name.ts b/src/common/entity/compute_floor_name.ts new file mode 100644 index 000000000000..e6355cdb216d --- /dev/null +++ b/src/common/entity/compute_floor_name.ts @@ -0,0 +1,5 @@ +import { FloorRegistryEntry } from "../../data/floor_registry"; + +export const computeFloorName = ( + floor: FloorRegistryEntry +): string | undefined => floor.name?.trim(); diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 854406065bf0..18401b7c7ef9 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -1,29 +1,37 @@ -import "../ha-list-item"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { HassEntity } from "home-assistant-js-websocket"; import type { PropertyValues, TemplateResult } from "lit"; -import { html, LitElement } from "lit"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; -import { computeStateName } from "../../common/entity/compute_state_name"; +import { + computeEntityAreaName, + computeEntityDeviceName, + computeEntityFloorName, + computeEntityFullName, + computeEntityName, +} from "../../common/entity/compute_entity_name"; +import { caseInsensitiveStringCompare } from "../../common/string/compare"; import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; -import type { ValueChangedEvent, HomeAssistant } from "../../types"; +import { domainToName } from "../../data/integration"; +import type { HelperDomain } from "../../panels/config/helpers/const"; +import { isHelperDomain } from "../../panels/config/helpers/const"; +import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-combo-box"; import type { HaComboBox } from "../ha-combo-box"; import "../ha-icon-button"; +import "../ha-list-item"; import "../ha-svg-icon"; import "./state-badge"; -import { caseInsensitiveStringCompare } from "../../common/string/compare"; -import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail"; -import { domainToName } from "../../data/integration"; -import type { HelperDomain } from "../../panels/config/helpers/const"; -import { isHelperDomain } from "../../panels/config/helpers/const"; interface HassEntityWithCachedName extends HassEntity, ScorableTextItem { - friendly_name: string; + displayed_name: string; + entity_name?: string; + entity_context?: string; } export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; @@ -105,7 +113,7 @@ export class HaEntityPicker extends LitElement { @property({ type: Boolean }) public hideClearIcon = false; @property({ attribute: "item-label-path" }) public itemLabelPath = - "friendly_name"; + "displayed_name"; @state() private _opened = false; @@ -127,22 +135,36 @@ export class HaEntityPicker extends LitElement { private _rowRenderer: ComboBoxLitRenderer = ( item - ) => - html` + ) => html` + ${item.state - ? html`` - : ""} - ${item.friendly_name} - ${item.entity_id.startsWith(CREATE_ID) + ? html` + + ` + : nothing} + ${item.entity_name ?? item.displayed_name} + ${item.entity_context + ? html` +
+ ${item.entity_context} +
+ ` + : nothing} +
+ ${item.entity_id.startsWith(CREATE_ID) ? this.hass.localize("ui.components.entity.entity-picker.new_entity") - : item.entity_id} - `; + : item.entity_id} +
+
+ `; private _getStates = memoizeOne( ( @@ -165,8 +187,8 @@ export class HaEntityPicker extends LitElement { let entityIds = Object.keys(hass.states); const createItems = createDomains?.length - ? createDomains.map((domain) => { - const newFriendlyName = hass.localize( + ? createDomains.map((domain) => { + const displayedName = hass.localize( "ui.components.entity.entity-picker.create_helper", { domain: isHelperDomain(domain) @@ -183,11 +205,11 @@ export class HaEntityPicker extends LitElement { last_changed: "", last_updated: "", context: { id: "", user_id: null, parent_id: null }, - friendly_name: newFriendlyName, + displayed_name: displayedName, attributes: { icon: "mdi:plus", }, - strings: [domain, newFriendlyName], + strings: [domain, displayedName], }; }) : []; @@ -200,7 +222,7 @@ export class HaEntityPicker extends LitElement { last_changed: "", last_updated: "", context: { id: "", user_id: null, parent_id: null }, - friendly_name: this.hass!.localize( + displayed_name: this.hass!.localize( "ui.components.entity.entity-picker.no_entities" ), attributes: { @@ -240,18 +262,11 @@ export class HaEntityPicker extends LitElement { } states = entityIds - .map((key) => { - const friendly_name = computeStateName(hass!.states[key]) || key; - return { - ...hass!.states[key], - friendly_name, - strings: [key, friendly_name], - }; - }) + .map((key) => this._stateObjToRowItem(hass!.states[key], hass)) .sort((entityA, entityB) => caseInsensitiveStringCompare( - entityA.friendly_name, - entityB.friendly_name, + entityA.displayed_name, + entityB.displayed_name, this.hass.locale.language ) ); @@ -294,7 +309,7 @@ export class HaEntityPicker extends LitElement { last_changed: "", last_updated: "", context: { id: "", user_id: null, parent_id: null }, - friendly_name: this.hass!.localize( + displayed_name: this.hass!.localize( "ui.components.entity.entity-picker.no_match" ), attributes: { @@ -317,6 +332,61 @@ export class HaEntityPicker extends LitElement { } ); + private _stateObjToRowItem( + stateObj: HassEntity, + hass: HomeAssistant + ): HassEntityWithCachedName { + const areaName = computeEntityAreaName( + stateObj, + hass.entities, + hass.devices, + hass.areas + ); + const floorName = computeEntityFloorName( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + const deviceName = computeEntityDeviceName( + stateObj, + hass.entities, + hass.devices + ); + + const entityName = computeEntityName(stateObj, hass.entities, hass.devices); + + const displayedName = computeEntityFullName( + stateObj, + hass.entities, + hass.devices + ); + + // Do not include device name if it's the same as entity name + const entityContext = [ + entityName !== deviceName ? deviceName : undefined, + areaName, + floorName, + ] + .filter(Boolean) + .join(" ⸱ "); + + return { + ...stateObj, + displayed_name: displayedName ?? "", + strings: [ + stateObj.entity_id, + displayedName ?? "", + areaName ?? "", + deviceName ?? "", + floorName ?? "", + ].filter(Boolean), + entity_name: entityName, + entity_context: entityContext, + }; + } + protected shouldUpdate(changedProps: PropertyValues) { if ( changedProps.has("value") || diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 1af01d3575cd..2a2df47a49bd 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -26,6 +26,7 @@ export interface EntityRegistryDisplayEntry { translation_key?: string; platform?: string; display_precision?: number; + has_entity_name?: boolean; } export interface EntityRegistryDisplayEntryResponse { @@ -41,6 +42,7 @@ export interface EntityRegistryDisplayEntryResponse { tk?: string; hb?: boolean; dp?: number; + hn?: boolean; }[]; entity_categories: Record; } diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index fa9d5743f799..2d24a5def766 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -14,11 +14,17 @@ import type { PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { cache } from "lit/directives/cache"; +import { classMap } from "lit/directives/class-map"; import { dynamicElement } from "../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../common/dom/fire_event"; import { stopPropagation } from "../../common/dom/stop_propagation"; import { computeDomain } from "../../common/entity/compute_domain"; -import { computeStateName } from "../../common/entity/compute_state_name"; +import { + computeEntityAreaName, + computeEntityDeviceName, + computeEntityFloorName, + computeEntityName, +} from "../../common/entity/compute_entity_name"; import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event"; import { navigate } from "../../common/navigate"; import "../../components/ha-button-menu"; @@ -266,19 +272,55 @@ export class MoreInfoDialog extends LitElement { const stateObj = this.hass.states[entityId] as HassEntity | undefined; const domain = computeDomain(entityId); - const name = (stateObj && computeStateName(stateObj)) || entityId; const isAdmin = this.hass.user!.is_admin; const deviceId = this._getDeviceId(); - const title = this._childView?.viewTitle ?? name; - const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView; const isSpecificInitialView = this._initialView !== DEFAULT_VIEW && !this._childView; const showCloseIcon = isDefaultView || isSpecificInitialView; + const entityName = stateObj + ? computeEntityName(stateObj, this.hass.entities, this.hass.devices) + : entityId; + + const areaName = stateObj + ? computeEntityAreaName( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas + ) + : ""; + + const floorName = stateObj + ? computeEntityFloorName( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ) + : ""; + + const deviceName = stateObj + ? computeEntityDeviceName(stateObj, this.hass.entities, this.hass.devices) + : ""; + + const title = this._childView?.viewTitle || entityName || entityId; + + const subtitle = this._childView?.viewTitle + ? undefined + : [ + entityName !== deviceName ? deviceName : undefined, + areaName, + floorName, + ] // Do not include device name if it's the same as entity name + .filter(Boolean) + .join(" ⸱ "); + return html` `} - - ${title} + +

${title}

+ ${subtitle ? html`

${subtitle}

` : nothing}
${isDefaultView ? html` @@ -593,6 +641,33 @@ export class MoreInfoDialog extends LitElement { --mdc-dialog-max-width: 90vw; } } + + .title { + display: flex; + flex-direction: column; + } + + .title p { + margin: 0; + min-width: 0; + width: 100%; + text-overflow: ellipsis; + overflow: hidden; + } + + .title .primary { + color: var(--primary-text-color); + } + + .title .secondary { + color: var(--secondary-text-color); + font-size: 14px; + } + + .title.two-line .primary { + margin-top: -4px; + margin-bottom: -6px; + } `, ]; } diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 2a9803d8e8a4..28b129c523b0 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -246,6 +246,7 @@ export const connectionMixin = >( entity.ec !== undefined ? entityReg.entity_categories[entity.ec] : undefined, + has_entity_name: entity.hn, name: entity.en, icon: entity.ic, hidden: entity.hb,