diff --git a/src/common/entity/compute_entity_name.ts b/src/common/entity/compute_entity_name.ts index 55701cec87..172c45dad3 100644 --- a/src/common/entity/compute_entity_name.ts +++ b/src/common/entity/compute_entity_name.ts @@ -61,3 +61,9 @@ export const computeEntityEntryName = ( return name; }; + +export const entityUseDeviceName = ( + stateObj: HassEntity, + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"] +): boolean => !computeEntityName(stateObj, entities, devices); diff --git a/src/common/entity/compute_entity_name_display.ts b/src/common/entity/compute_entity_name_display.ts new file mode 100644 index 0000000000..3e243557a3 --- /dev/null +++ b/src/common/entity/compute_entity_name_display.ts @@ -0,0 +1,104 @@ +import type { HassEntity } from "home-assistant-js-websocket"; +import type { HomeAssistant } from "../../types"; +import { ensureArray } from "../array/ensure-array"; +import { computeAreaName } from "./compute_area_name"; +import { computeDeviceName } from "./compute_device_name"; +import { computeEntityName, entityUseDeviceName } from "./compute_entity_name"; +import { computeFloorName } from "./compute_floor_name"; +import { getEntityContext } from "./context/get_entity_context"; + +const DEFAULT_SEPARATOR = " "; + +export type EntityNameItem = + | { + type: "entity" | "device" | "area" | "floor"; + } + | { + type: "text"; + text: string; + }; + +export interface EntityNameOptions { + separator?: string; +} + +export const computeEntityNameDisplay = ( + stateObj: HassEntity, + name: EntityNameItem | EntityNameItem[], + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"], + options?: EntityNameOptions +) => { + let items = ensureArray(name); + + const separator = options?.separator ?? DEFAULT_SEPARATOR; + + // If all items are text, just join them + if (items.every((n) => n.type === "text")) { + return items.map((item) => item.text).join(separator); + } + + const useDeviceName = entityUseDeviceName(stateObj, entities, devices); + + // If entity uses device name, and device is not already included, replace it with device name + if (useDeviceName) { + const hasDevice = items.some((n) => n.type === "device"); + if (!hasDevice) { + items = items.map((n) => (n.type === "entity" ? { type: "device" } : n)); + } + } + + const names = computeEntityNameList( + stateObj, + items, + entities, + devices, + areas, + floors + ); + + // If after processing there is only one name, return that + if (names.length === 1) { + return names[0] || ""; + } + + return names.filter((n) => n).join(separator); +}; + +export const computeEntityNameList = ( + stateObj: HassEntity, + name: EntityNameItem[], + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"] +): (string | undefined)[] => { + const { device, area, floor } = getEntityContext( + stateObj, + entities, + devices, + areas, + floors + ); + + const names = name.map((item) => { + switch (item.type) { + case "entity": + return computeEntityName(stateObj, entities, devices); + case "device": + return device ? computeDeviceName(device) : undefined; + case "area": + return area ? computeAreaName(area) : undefined; + case "floor": + return floor ? computeFloorName(floor) : undefined; + case "text": + return item.text; + default: + return ""; + } + }); + + return names; +}; diff --git a/src/common/translations/entity-state.ts b/src/common/translations/entity-state.ts index ed52228229..7561a03286 100644 --- a/src/common/translations/entity-state.ts +++ b/src/common/translations/entity-state.ts @@ -1,13 +1,12 @@ import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; import type { FrontendLocaleData } from "../../data/translation"; import type { HomeAssistant } from "../../types"; +import { + computeEntityNameDisplay, + type EntityNameItem, + type EntityNameOptions, +} from "../entity/compute_entity_name_display"; import type { LocalizeFunc } from "./localize"; -import { computeEntityName } from "../entity/compute_entity_name"; -import { computeDeviceName } from "../entity/compute_device_name"; -import { getEntityContext } from "../entity/context/get_entity_context"; -import { computeAreaName } from "../entity/compute_area_name"; -import { computeFloorName } from "../entity/compute_floor_name"; -import { ensureArray } from "../array/ensure-array"; export type FormatEntityStateFunc = ( stateObj: HassEntity, @@ -27,8 +26,8 @@ export type EntityNameType = "entity" | "device" | "area" | "floor"; export type FormatEntityNameFunc = ( stateObj: HassEntity, - type: EntityNameType | EntityNameType[], - separator?: string + name: EntityNameItem | EntityNameItem[], + options?: EntityNameOptions ) => string; export const computeFormatFunctions = async ( @@ -75,45 +74,15 @@ export const computeFormatFunctions = async ( ), formatEntityAttributeName: (stateObj, attribute) => computeAttributeNameDisplay(localize, stateObj, entities, attribute), - formatEntityName: (stateObj, type, separator = " ") => { - const types = ensureArray(type); - const namesList: (string | undefined)[] = []; - - const { device, area, floor } = getEntityContext( + formatEntityName: (stateObj, name, options) => + computeEntityNameDisplay( stateObj, + name, entities, devices, areas, - floors - ); - - for (const t of types) { - switch (t) { - case "entity": { - namesList.push(computeEntityName(stateObj, entities, devices)); - break; - } - case "device": { - if (device) { - namesList.push(computeDeviceName(device)); - } - break; - } - case "area": { - if (area) { - namesList.push(computeAreaName(area)); - } - break; - } - case "floor": { - if (floor) { - namesList.push(computeFloorName(floor)); - } - break; - } - } - } - return namesList.filter((name) => name !== undefined).join(separator); - }, + floors, + options + ), }; }; diff --git a/src/components/entity/ha-entity-name-picker.ts b/src/components/entity/ha-entity-name-picker.ts new file mode 100644 index 0000000000..e7046435c3 --- /dev/null +++ b/src/components/entity/ha-entity-name-picker.ts @@ -0,0 +1,493 @@ +import "@material/mwc-menu/mwc-menu-surface"; +import { mdiDrag, mdiPlus } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import type { IFuseOptions } from "fuse.js"; +import Fuse from "fuse.js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; +import { fireEvent } from "../../common/dom/fire_event"; +import { stopPropagation } from "../../common/dom/stop_propagation"; +import type { EntityNameItem } from "../../common/entity/compute_entity_name_display"; +import { getEntityContext } from "../../common/entity/context/get_entity_context"; +import type { EntityNameType } from "../../common/translations/entity-state"; +import type { LocalizeKeys } from "../../common/translations/localize"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; +import "../chips/ha-assist-chip"; +import "../chips/ha-chip-set"; +import "../chips/ha-input-chip"; +import "../ha-combo-box"; +import type { HaComboBox } from "../ha-combo-box"; +import "../ha-sortable"; + +interface EntityNameOption { + primary: string; + secondary?: string; + value: string; +} + +const rowRenderer: ComboBoxLitRenderer = (item) => html` + + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + +`; + +const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]); + +const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]); + +@customElement("ha-entity-name-picker") +export class HaEntityNamePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public entityId?: string; + + @property({ attribute: false }) public value?: + | string + | EntityNameItem + | EntityNameItem[]; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @query(".container", true) private _container?: HTMLDivElement; + + @query("ha-combo-box", true) private _comboBox!: HaComboBox; + + @state() private _opened = false; + + private _editIndex?: number; + + private _validOptions = memoizeOne((entityId?: string) => { + const options = new Set(); + if (!entityId) { + return options; + } + + const stateObj = this.hass.states[entityId]; + + if (!stateObj) { + return options; + } + + options.add("entity"); + + const context = getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); + + if (context.device) options.add("device"); + if (context.area) options.add("area"); + if (context.floor) options.add("floor"); + return options; + }); + + private _getOptions = memoizeOne((entityId?: string) => { + if (!entityId) { + return []; + } + + const options = this._validOptions(entityId); + + const items = ( + ["entity", "device", "area", "floor"] as const + ).map((name) => { + const stateObj = this.hass.states[entityId]; + const isValid = options.has(name); + const primary = this.hass.localize( + `ui.components.entity.entity-name-picker.types.${name}` + ); + const secondary = + stateObj && isValid + ? this.hass.formatEntityName(stateObj, { type: name }) + : this.hass.localize( + `ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys + ) || "-"; + + return { + primary, + secondary, + value: name, + }; + }); + + return items; + }); + + private _formatItem = (item: EntityNameItem) => { + if (item.type === "text") { + return `"${item.text}"`; + } + if (KNOWN_TYPES.has(item.type)) { + return this.hass.localize( + `ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}` + ); + } + return item.type; + }; + + protected render() { + const value = this._value; + const options = this._getOptions(this.entityId); + const validOptions = this._validOptions(this.entityId); + + return html` + ${this.label ? html`` : nothing} +
+ + + ${repeat( + this._value, + (item) => item, + (item: EntityNameItem, idx) => { + const label = this._formatItem(item); + const isValid = + item.type === "text" || validOptions.has(item.type); + return html` + + + ${label} + + `; + } + )} + ${this.disabled + ? nothing + : html` + + + + `} + + + + + + + +
+ `; + } + + private _onClosed(ev) { + ev.stopPropagation(); + this._opened = false; + this._editIndex = undefined; + } + + private async _onOpened(ev) { + if (!this._opened) { + return; + } + ev.stopPropagation(); + this._opened = true; + await this._comboBox?.focus(); + await this._comboBox?.open(); + } + + private async _addItem(ev) { + ev.stopPropagation(); + this._opened = true; + } + + private async _editItem(ev) { + ev.stopPropagation(); + const idx = parseInt(ev.currentTarget.dataset.idx, 10); + this._editIndex = idx; + this._opened = true; + } + + private get _value(): EntityNameItem[] { + return this._toItems(this.value); + } + + private _toItems = memoizeOne((value?: typeof this.value) => { + if (typeof value === "string") { + return [{ type: "text", text: value } as const]; + } + return value ? ensureArray(value) : []; + }); + + private _toValue = memoizeOne( + (items: EntityNameItem[]): typeof this.value => { + if (items.length === 0) { + return []; + } + if (items.length === 1) { + const item = items[0]; + return item.type === "text" ? item.text : item; + } + return items; + } + ); + + private _openedChanged(ev: ValueChangedEvent) { + const open = ev.detail.value; + if (open) { + const options = this._comboBox.items || []; + + const initialItem = + this._editIndex != null ? this._value[this._editIndex] : undefined; + + const initialValue = initialItem + ? initialItem.type === "text" + ? initialItem.text + : initialItem.type + : ""; + + const filteredItems = this._filterSelectedOptions(options, initialValue); + + this._comboBox.filteredItems = filteredItems; + this._comboBox.setInputValue(initialValue); + } else { + this._opened = false; + } + } + + private _filterSelectedOptions = ( + options: EntityNameOption[], + current?: string + ) => { + const value = this._value; + + const types = value.map((item) => item.type) as string[]; + + const filteredOptions = options.filter( + (option) => + !UNIQUE_TYPES.has(option.value) || + !types.includes(option.value) || + option.value === current + ); + return filteredOptions; + }; + + private _filterChanged(ev: ValueChangedEvent) { + const input = ev.detail.value; + const filter = input?.toLowerCase() || ""; + const options = this._comboBox.items || []; + + const currentItem = + this._editIndex != null ? this._value[this._editIndex] : undefined; + + const currentValue = currentItem + ? currentItem.type === "text" + ? currentItem.text + : currentItem.type + : ""; + + this._comboBox.filteredItems = this._filterSelectedOptions( + options, + currentValue + ); + + if (!filter) { + return; + } + + const fuseOptions: IFuseOptions = { + keys: ["primary", "secondary", "value"], + isCaseSensitive: false, + minMatchCharLength: Math.min(filter.length, 2), + threshold: 0.2, + ignoreDiacritics: true, + }; + + const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions); + const filteredItems = fuse.search(filter).map((result) => result.item); + + this._comboBox.filteredItems = filteredItems; + } + + private async _moveItem(ev: CustomEvent) { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + const value = this._value; + const newValue = value.concat(); + const element = newValue.splice(oldIndex, 1)[0]; + newValue.splice(newIndex, 0, element); + this._setValue(newValue); + await this.updateComplete; + this._filterChanged({ detail: { value: "" } } as ValueChangedEvent); + } + + private async _removeItem(ev) { + ev.stopPropagation(); + const value = [...this._value]; + const idx = parseInt(ev.target.dataset.idx, 10); + value.splice(idx, 1); + this._setValue(value); + await this.updateComplete; + this._filterChanged({ detail: { value: "" } } as ValueChangedEvent); + } + + private _comboBoxValueChanged(ev: ValueChangedEvent): void { + ev.stopPropagation(); + const value = ev.detail.value; + + if (this.disabled || value === "") { + return; + } + + const item: EntityNameItem = KNOWN_TYPES.has(value as any) + ? { type: value as EntityNameType } + : { type: "text", text: value }; + + const newValue = [...this._value]; + + if (this._editIndex != null) { + newValue[this._editIndex] = item; + } else { + newValue.push(item); + } + + this._setValue(newValue); + } + + private _setValue(value: EntityNameItem[]) { + const newValue = this._toValue(value); + this.value = newValue; + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + static styles = css` + :host { + position: relative; + width: 100%; + } + + .container { + position: relative; + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-radius: var(--ha-border-radius-sm); + border-end-end-radius: var(--ha-border-radius-square); + border-end-start-radius: var(--ha-border-radius-square); + } + .container:after { + display: block; + content: ""; + position: absolute; + pointer-events: none; + bottom: 0; + left: 0; + right: 0; + height: 1px; + width: 100%; + background-color: var( + --mdc-text-field-idle-line-color, + rgba(0, 0, 0, 0.42) + ); + transform: + height 180ms ease-in-out, + background-color 180ms ease-in-out; + } + :host([disabled]) .container:after { + background-color: var( + --mdc-text-field-disabled-line-color, + rgba(0, 0, 0, 0.42) + ); + } + .container:focus-within:after { + height: 2px; + background-color: var(--mdc-theme-primary); + } + + label { + display: block; + margin: 0 0 var(--ha-space-2); + } + + .add { + order: 1; + } + + mwc-menu-surface { + --mdc-menu-min-width: 100%; + } + + ha-chip-set { + padding: var(--ha-space-2) var(--ha-space-2); + } + + .invalid { + text-decoration: line-through; + } + + .sortable-fallback { + display: none; + opacity: 0; + } + + .sortable-ghost { + opacity: 0.4; + } + + .sortable-drag { + cursor: grabbing; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-entity-name-picker": HaEntityNamePicker; + } +} diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 77956ea297..bf12d37c6f 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -6,6 +6,7 @@ import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; +import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; import { computeStateName } from "../../common/entity/compute_state_name"; import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { computeRTL } from "../../common/util/compute_rtl"; @@ -144,9 +145,14 @@ export class HaEntityPicker extends LitElement { `; } - const entityName = this.hass.formatEntityName(stateObj, "entity"); - const deviceName = this.hass.formatEntityName(stateObj, "device"); - const areaName = this.hass.formatEntityName(stateObj, "area"); + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const isRTL = computeRTL(this.hass); @@ -300,21 +306,24 @@ export class HaEntityPicker extends LitElement { ); } - const isRTL = computeRTL(this.hass); + const isRTL = computeRTL(hass); items = entityIds.map((entityId) => { - const stateObj = hass!.states[entityId]; + const stateObj = hass.states[entityId]; const friendlyName = computeStateName(stateObj); // Keep this for search - const entityName = this.hass.formatEntityName(stateObj, "entity"); - const deviceName = this.hass.formatEntityName(stateObj, "device"); - const areaName = this.hass.formatEntityName(stateObj, "area"); - const domainName = domainToName( - this.hass.localize, - computeDomain(entityId) + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors ); + const domainName = domainToName(hass.localize, computeDomain(entityId)); + const primary = entityName || deviceName || entityId; const secondary = [areaName, entityName ? deviceName : undefined] .filter(Boolean) diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index 668a030011..e437837dd8 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -6,6 +6,7 @@ import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; +import { computeEntityNameList } from "../../common/entity/compute_entity_name_display"; import { computeStateName } from "../../common/entity/compute_state_name"; import { computeRTL } from "../../common/util/compute_rtl"; import { domainToName } from "../../data/integration"; @@ -199,7 +200,7 @@ export class HaStatisticPicker extends LitElement { }); } - const isRTL = computeRTL(this.hass); + const isRTL = computeRTL(hass); const output: StatisticComboBoxItem[] = []; @@ -256,9 +257,15 @@ export class HaStatisticPicker extends LitElement { const id = meta.statistic_id; const friendlyName = computeStateName(stateObj); // Keep this for search - const entityName = hass.formatEntityName(stateObj, "entity"); - const deviceName = hass.formatEntityName(stateObj, "device"); - const areaName = hass.formatEntityName(stateObj, "area"); + + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); const primary = entityName || deviceName || id; const secondary = [areaName, entityName ? deviceName : undefined] @@ -331,9 +338,14 @@ export class HaStatisticPicker extends LitElement { const stateObj = this.hass.states[statisticId]; if (stateObj) { - const entityName = this.hass.formatEntityName(stateObj, "entity"); - const deviceName = this.hass.formatEntityName(stateObj, "device"); - const areaName = this.hass.formatEntityName(stateObj, "area"); + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const isRTL = computeRTL(this.hass); diff --git a/src/components/ha-selector/ha-selector-entity-name.ts b/src/components/ha-selector/ha-selector-entity-name.ts new file mode 100644 index 0000000000..bf105e7c32 --- /dev/null +++ b/src/components/ha-selector/ha-selector-entity-name.ts @@ -0,0 +1,50 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { EntityNameSelector } from "../../data/selector"; +import { SubscribeMixin } from "../../mixins/subscribe-mixin"; +import type { HomeAssistant } from "../../types"; +import "../entity/ha-entity-name-picker"; + +@customElement("ha-selector-entity_name") +export class HaSelectorEntityName extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public selector!: EntityNameSelector; + + @property() public value?: string | string[]; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + @property({ attribute: false }) public context?: { + entity?: string; + }; + + protected render() { + const value = this.value ?? this.selector.entity_name?.default_name; + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-entity_name": HaSelectorEntityName; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 47c2bdd8dd..cb686caef0 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -29,6 +29,7 @@ const LOAD_ELEMENTS = { device: () => import("./ha-selector-device"), duration: () => import("./ha-selector-duration"), entity: () => import("./ha-selector-entity"), + entity_name: () => import("./ha-selector-entity-name"), statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), floor: () => import("./ha-selector-floor"), diff --git a/src/data/selector.ts b/src/data/selector.ts index 00ff98e321..7276355c6f 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -18,6 +18,7 @@ import type { EntityRegistryEntry, } from "./entity_registry"; import type { EntitySources } from "./entity_sources"; +import type { EntityNameItem } from "../common/entity/compute_entity_name_display"; export type Selector = | ActionSelector @@ -41,6 +42,7 @@ export type Selector = | LegacyDeviceSelector | DurationSelector | EntitySelector + | EntityNameSelector | LegacyEntitySelector | FileSelector | IconSelector @@ -499,6 +501,13 @@ export interface UiStateContentSelector { } | null; } +export interface EntityNameSelector { + entity_name: { + entity_id?: string; + default_name?: EntityNameItem | EntityNameItem[] | string; + } | null; +} + export const expandLabelTarget = ( hass: HomeAssistant, labelId: string, diff --git a/src/data/selector/format_selector_value.ts b/src/data/selector/format_selector_value.ts index 7a85abd099..5ca0f7a7a1 100644 --- a/src/data/selector/format_selector_value.ts +++ b/src/data/selector/format_selector_value.ts @@ -76,7 +76,10 @@ export const formatSelectorValue = ( if (!stateObj) { return entityId; } - const name = hass.formatEntityName(stateObj, ["device", "entity"], " "); + const name = hass.formatEntityName(stateObj, [ + { type: "device" }, + { type: "entity" }, + ]); return name || entityId; }) .join(", "); diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index dc2a6a58d9..80b446ef16 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -23,8 +23,14 @@ import { stopPropagation } from "../../common/dom/stop_propagation"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; -import { computeEntityEntryName } from "../../common/entity/compute_entity_name"; -import { getEntityEntryContext } from "../../common/entity/context/get_entity_context"; +import { + computeEntityEntryName, + computeEntityName, +} from "../../common/entity/compute_entity_name"; +import { + getEntityContext, + getEntityEntryContext, +} from "../../common/entity/context/get_entity_context"; import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event"; import { navigate } from "../../common/navigate"; import "../../components/ha-button-menu"; @@ -321,28 +327,34 @@ export class MoreInfoDialog extends LitElement { (isDefaultView && this._parentEntityIds.length === 0) || isSpecificInitialView; - let entityName: string | undefined; - let deviceName: string | undefined; - let areaName: string | undefined; + const context = stateObj + ? getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ) + : this._entry + ? getEntityEntryContext( + this._entry, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ) + : undefined; - if (stateObj) { - entityName = this.hass.formatEntityName(stateObj, "entity"); - deviceName = this.hass.formatEntityName(stateObj, "device"); - areaName = this.hass.formatEntityName(stateObj, "area"); - } else if (this._entry) { - const { device, area } = getEntityEntryContext( - this._entry, - this.hass.entities, - this.hass.devices, - this.hass.areas, - this.hass.floors - ); - entityName = computeEntityEntryName(this._entry, this.hass.devices); - deviceName = device ? computeDeviceName(device) : undefined; - areaName = area ? computeAreaName(area) : undefined; - } else { - entityName = entityId; - } + const entityName = stateObj + ? computeEntityName(stateObj, this.hass.entities, this.hass.devices) + : this._entry + ? computeEntityEntryName(this._entry, this.hass.devices) + : entityId; + + const deviceName = context?.device + ? computeDeviceName(context.device) + : undefined; + const areaName = context?.area ? computeAreaName(context.area) : undefined; const breadcrumb = [areaName, deviceName, entityName].filter( (v): v is string => Boolean(v) diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index f5cf1f0cf0..14ec12738e 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -23,6 +23,7 @@ import { fireEvent } from "../../common/dom/fire_event"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; +import { entityUseDeviceName } from "../../common/entity/compute_entity_name"; import { computeStateName } from "../../common/entity/compute_state_name"; import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { navigate } from "../../common/navigate"; @@ -30,9 +31,9 @@ import { caseInsensitiveStringCompare } from "../../common/string/compare"; import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; import { computeRTL } from "../../common/util/compute_rtl"; import { debounce } from "../../common/util/debounce"; +import "../../components/ha-button"; import "../../components/ha-icon-button"; import "../../components/ha-label"; -import "../../components/ha-button"; import "../../components/ha-list"; import "../../components/ha-md-list-item"; import "../../components/ha-spinner"; @@ -631,14 +632,29 @@ export class QuickBar extends LitElement { const stateObj = this.hass.states[entityId]; const friendlyName = computeStateName(stateObj); // Keep this for search - const entityName = this.hass.formatEntityName(stateObj, "entity"); - const deviceName = this.hass.formatEntityName(stateObj, "device"); - const areaName = this.hass.formatEntityName(stateObj, "area"); - const primary = entityName || deviceName || entityId; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); + const useDeviceName = entityUseDeviceName( + stateObj, + this.hass.entities, + this.hass.devices + ); + + const name = this.hass.formatEntityName( + stateObj, + useDeviceName ? { type: "device" } : { type: "entity" } + ); + + const primary = name || entityId; + + const secondary = this.hass.formatEntityName( + stateObj, + useDeviceName + ? [{ type: "area" }] + : [{ type: "area" }, { type: "device" }], + { + separator: isRTL ? " ◂ " : " ▸ ", + } + ); const translatedDomain = domainToName( this.hass.localize, diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 91a588ce41..41b1b7079e 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -1,5 +1,5 @@ import type { HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; @@ -9,7 +9,7 @@ import { computeCssColor } from "../../../common/color/compute-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { DOMAINS_TOGGLE } from "../../../common/const"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { computeStateName } from "../../../common/entity/compute_state_name"; +import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display"; import { stateActive } from "../../../common/entity/state_active"; import { stateColorCss } from "../../../common/entity/state_color"; import "../../../components/ha-card"; @@ -47,6 +47,11 @@ export const getEntityDefaultTileIconAction = (entityId: string) => { return supportsIconAction ? "toggle" : "none"; }; +export const DEFAULT_NAME = [ + { type: "device" }, + { type: "entity" }, +] satisfies EntityNameItem[]; + @customElement("hui-tile-card") export class HuiTileCard extends LitElement implements LovelaceCard { public static async getConfigElement(): Promise { @@ -255,7 +260,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard { const contentClasses = { vertical: Boolean(this._config.vertical) }; - const name = this._config.name || computeStateName(stateObj); + const nameConfig = this._config.name; + + const nameDisplay = + typeof nameConfig === "string" + ? nameConfig + : this.hass.formatEntityName(stateObj, nameConfig || DEFAULT_NAME); + const active = stateActive(stateObj); const color = this._computeStateColor(stateObj, this._config.color); const domain = computeDomain(stateObj.entity_id); @@ -267,7 +278,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { .stateObj=${stateObj} .hass=${this.hass} .content=${this._config.state_content} - .name=${this._config.name} + .name=${nameDisplay} > `; @@ -326,7 +337,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { ${renderTileBadge(stateObj, this.hass)} - ${name} + ${nameDisplay} ${stateDisplay ? html`${stateDisplay}` : nothing} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 2d67e29c3f..ed66b62037 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -1,8 +1,11 @@ import type { HassServiceTarget } from "home-assistant-js-websocket"; +import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display"; import type { HaDurationData } from "../../../components/ha-duration-input"; +import type { EnergySourceByType } from "../../../data/energy"; import type { ActionConfig } from "../../../data/lovelace/config/action"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { Statistic, StatisticType } from "../../../data/recorder"; +import type { TimeFormat } from "../../../data/translation"; import type { ForecastType } from "../../../data/weather"; import type { FullCalendarView, @@ -25,9 +28,7 @@ import type { } from "../entity-rows/types"; import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceHeadingBadgeConfig } from "../heading-badges/types"; -import type { TimeFormat } from "../../../data/translation"; import type { HomeSummary } from "../strategies/home/helpers/home-summaries"; -import type { EnergySourceByType } from "../../../data/energy"; export type AlarmPanelCardConfigState = | "arm_away" @@ -568,7 +569,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig { export interface TileCardConfig extends LovelaceCardConfig { entity: string; - name?: string; + name?: string | EntityNameItem | EntityNameItem[]; hide_state?: boolean; state_content?: string | string[]; icon?: string; diff --git a/src/panels/lovelace/components/hui-entity-editor.ts b/src/panels/lovelace/components/hui-entity-editor.ts index 02f51ac2fe..66d48fa863 100644 --- a/src/panels/lovelace/components/hui-entity-editor.ts +++ b/src/panels/lovelace/components/hui-entity-editor.ts @@ -3,6 +3,8 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; import { fireEvent } from "../../../common/dom/fire_event"; +import { entityUseDeviceName } from "../../../common/entity/compute_entity_name"; +import { computeRTL } from "../../../common/util/compute_rtl"; import "../../../components/entity/ha-entity-picker"; import type { HaEntityPicker, @@ -12,11 +14,10 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-sortable"; import type { HomeAssistant } from "../../../types"; import type { EntityConfig } from "../entity-rows/types"; -import { computeRTL } from "../../../common/util/compute_rtl"; @customElement("hui-entity-editor") export class HuiEntityEditor extends LitElement { - @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public entities?: EntityConfig[]; @@ -38,20 +39,32 @@ export class HuiEntityEditor extends LitElement { } private _renderItem(item: EntityConfig, index: number) { - const stateObj = this.hass!.states[item.entity]; + const stateObj = this.hass.states[item.entity]; - const entityName = - stateObj && this.hass!.formatEntityName(stateObj, "entity"); - const deviceName = - stateObj && this.hass!.formatEntityName(stateObj, "device"); - const areaName = stateObj && this.hass!.formatEntityName(stateObj, "area"); + const useDeviceName = entityUseDeviceName( + stateObj, + this.hass.entities, + this.hass.devices + ); - const isRTL = computeRTL(this.hass!); + const name = this.hass.formatEntityName( + stateObj, + useDeviceName ? { type: "device" } : { type: "entity" } + ); - const primary = item.name || entityName || deviceName || item.entity; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); + const isRTL = computeRTL(this.hass); + + const primary = item.name || name || item.entity; + + const secondary = this.hass.formatEntityName( + stateObj, + useDeviceName + ? [{ type: "area" }] + : [{ type: "area" }, { type: "device" }], + { + separator: isRTL ? " ◂ " : " ▸ ", + } + ); return html` @@ -67,14 +80,14 @@ export class HuiEntityEditor extends LitElement { slot="end" .item=${item} .index=${index} - .label=${this.hass!.localize("ui.common.edit")} + .label=${this.hass.localize("ui.common.edit")} .path=${mdiPencil} @click=${this._editItem} > @@ -109,9 +122,9 @@ export class HuiEntityEditor extends LitElement { return html`

${this.label || - this.hass!.localize("ui.panel.lovelace.editor.card.generic.entities") + + this.hass.localize("ui.panel.lovelace.editor.card.generic.entities") + " (" + - this.hass!.localize("ui.panel.lovelace.editor.card.config.required") + + this.hass.localize("ui.panel.lovelace.editor.card.config.required") + ")"}

${this.canEdit diff --git a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts index f99e370548..847a2885f0 100644 --- a/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts +++ b/src/panels/lovelace/editor/card-editor/hui-entity-picker-table.ts @@ -6,6 +6,7 @@ import memoizeOne from "memoize-one"; import type { HASSDomEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event"; import { computeDomain } from "../../../../common/entity/compute_domain"; +import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display"; import type { LocalizeFunc } from "../../../../common/translations/localize"; import { computeRTL } from "../../../../common/util/compute_rtl"; import "../../../../components/data-table/ha-data-table"; @@ -62,9 +63,14 @@ export class HuiEntityPickerTable extends LitElement { (entity) => { const stateObj = this.hass.states[entity]; - const entityName = this.hass.formatEntityName(stateObj, "entity"); - const deviceName = this.hass.formatEntityName(stateObj, "device"); - const areaName = this.hass.formatEntityName(stateObj, "area"); + const [entityName, deviceName, areaName] = computeEntityNameList( + stateObj, + [{ type: "entity" }, { type: "device" }, { type: "area" }], + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const name = [deviceName, entityName].filter(Boolean).join(" "); const domain = computeDomain(entity); diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts index 4fdb699e3a..11f7a8f288 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts @@ -30,11 +30,15 @@ import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, } from "../../card-features/types"; -import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card"; +import { + DEFAULT_NAME, + getEntityDefaultTileIconAction, +} from "../../cards/hui-tile-card"; import type { TileCardConfig } from "../../cards/types"; import type { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { entityNameStruct } from "../structs/entity-name-struct"; import type { EditDetailElementEvent, EditSubElementEvent } from "../types"; import { configElementStyle } from "./config-elements-style"; import { getSupportedFeaturesType } from "./hui-card-features-editor"; @@ -43,7 +47,7 @@ const cardConfigStruct = assign( baseLovelaceCardConfig, object({ entity: optional(string()), - name: optional(string()), + name: optional(entityNameStruct), icon: optional(string()), color: optional(string()), show_entity_picture: optional(boolean()), @@ -97,11 +101,19 @@ export class HuiTileCardEditor type: "expandable", iconPath: mdiTextShort, schema: [ + { + name: "name", + selector: { + entity_name: { + default_name: DEFAULT_NAME, + }, + }, + context: { entity: "entity" }, + }, { name: "", type: "grid", schema: [ - { name: "name", selector: { text: {} } }, { name: "icon", selector: { diff --git a/src/panels/lovelace/editor/structs/entity-name-struct.ts b/src/panels/lovelace/editor/structs/entity-name-struct.ts new file mode 100644 index 0000000000..6ce77632a0 --- /dev/null +++ b/src/panels/lovelace/editor/structs/entity-name-struct.ts @@ -0,0 +1,22 @@ +import { array, literal, object, string, union } from "superstruct"; + +const entityNameItemStruct = union([ + object({ + type: literal("text"), + text: string(), + }), + object({ + type: union([ + literal("entity"), + literal("device"), + literal("area"), + literal("floor"), + ]), + }), + string(), +]); + +export const entityNameStruct = union([ + entityNameItemStruct, + array(entityNameItemStruct), +]); diff --git a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts index 7e1eb39dca..54064dc756 100644 --- a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts @@ -270,15 +270,12 @@ export class HomeAreaViewStrategy extends ReactiveElement { })), ], } satisfies HeadingCardConfig, - ...entities.map((e) => { - const stateObj = hass.states[e]; - return { - ...computeTileCard(e), - name: - hass.formatEntityName(stateObj, "entity") || - hass.formatEntityName(stateObj, "device"), - }; - }), + ...entities.map((e) => ({ + ...computeTileCard(e), + name: { + type: "entity", + }, + })), ], }); } diff --git a/src/translations/en.json b/src/translations/en.json index 302c3efe04..8babfe385d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -657,6 +657,18 @@ "placeholder": "Select an entity", "create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper." }, + "entity-name-picker": { + "types": { + "floor": "Floor", + "area": "Area", + "device": "Device", + "entity": "Entity", + "area_missing": "No area assigned", + "floor_missing": "No floor assigned", + "device_missing": "No device associated" + }, + "add": "Add" + }, "entity-attribute-picker": { "attribute": "Attribute", "show_attributes": "Show attributes" diff --git a/src/types.ts b/src/types.ts index ed7c5cc59f..c5286a2658 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,7 +9,10 @@ import type { HassServiceTarget, MessageBase, } from "home-assistant-js-websocket"; -import type { EntityNameType } from "./common/translations/entity-state"; +import type { + EntityNameItem, + EntityNameOptions, +} from "./common/entity/compute_entity_name_display"; import type { LocalizeFunc } from "./common/translations/localize"; import type { AreaRegistryEntry } from "./data/area_registry"; import type { DeviceRegistryEntry } from "./data/device_registry"; @@ -288,8 +291,8 @@ export interface HomeAssistant { formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; formatEntityName( stateObj: HassEntity, - type: EntityNameType | EntityNameType[], - separator?: string + type: EntityNameItem | EntityNameItem[], + separator?: EntityNameOptions ): string; } diff --git a/test/common/entity/compute_entity_name_display.test.ts b/test/common/entity/compute_entity_name_display.test.ts new file mode 100644 index 0000000000..11d22b7eda --- /dev/null +++ b/test/common/entity/compute_entity_name_display.test.ts @@ -0,0 +1,408 @@ +import { describe, expect, it } from "vitest"; +import { + computeEntityNameDisplay, + computeEntityNameList, +} from "../../../src/common/entity/compute_entity_name_display"; +import type { HomeAssistant } from "../../../src/types"; +import { + mockArea, + mockDevice, + mockEntity, + mockFloor, + mockStateObj, +} from "./context/context-mock"; + +describe("computeEntityNameDisplay", () => { + it("returns text when all items are text", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: {}, + devices: {}, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Hello World"); + }); + + it("uses custom separator for text items", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: {}, + devices: {}, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [ + { type: "text", text: "Hello" }, + { type: "text", text: "World" }, + ], + hass.entities, + hass.devices, + hass.areas, + hass.floors, + { separator: " - " } + ); + + expect(result).toBe("Hello - World"); + }); + + it("returns entity name", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Kitchen Light", + }), + }, + devices: {}, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + { type: "entity" }, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Kitchen Light"); + }); + + it("replaces entity with device name when entity uses device name", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Kitchen Device", + device_id: "dev1", + }), + }, + devices: { + dev1: mockDevice({ + id: "dev1", + name: "Kitchen Device", + }), + }, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + { type: "entity" }, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Kitchen Device"); + }); + + it("does not replace entity with device when device is already included", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Kitchen Device", + device_id: "dev1", + }), + }, + devices: { + dev1: mockDevice({ + id: "dev1", + name: "Kitchen Device", + }), + }, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [{ type: "entity" }, { type: "device" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + // Since entity name equals device name, entity returns undefined + // So we only get the device name + expect(result).toBe("Kitchen Device"); + }); + + it("returns combined entity and area names", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Ceiling Light", + area_id: "kitchen", + }), + }, + devices: {}, + areas: { + kitchen: mockArea({ + area_id: "kitchen", + name: "Kitchen", + }), + }, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [{ type: "area" }, { type: "entity" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Kitchen Ceiling Light"); + }); + + it("returns combined device and area names", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + device_id: "dev1", + }), + }, + devices: { + dev1: mockDevice({ + id: "dev1", + name: "Smart Light", + area_id: "kitchen", + }), + }, + areas: { + kitchen: mockArea({ + area_id: "kitchen", + name: "Kitchen", + }), + }, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [{ type: "area" }, { type: "device" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Kitchen Smart Light"); + }); + + it("returns floor name", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + area_id: "kitchen", + }), + }, + devices: {}, + areas: { + kitchen: mockArea({ + area_id: "kitchen", + name: "Kitchen", + floor_id: "first", + }), + }, + floors: { + first: mockFloor({ + floor_id: "first", + name: "First Floor", + }), + }, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + { type: "floor" }, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("First Floor"); + }); + + it("filters out undefined names when combining", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + }), + }, + devices: {}, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [{ type: "area" }, { type: "entity" }, { type: "floor" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + // Area and floor don't exist, so only entity name is included + expect(result).toBe("Light"); + }); + + it("mixes text with entity types", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + area_id: "kitchen", + }), + }, + devices: {}, + areas: { + kitchen: mockArea({ + area_id: "kitchen", + name: "Kitchen", + }), + }, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameDisplay( + stateObj, + [{ type: "area" }, { type: "text", text: "-" }, { type: "entity" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toBe("Kitchen - Light"); + }); +}); + +describe("computeEntityNameList", () => { + it("returns list of names for each item type", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + device_id: "dev1", + area_id: "kitchen", + }), + }, + devices: { + dev1: mockDevice({ + id: "dev1", + name: "Smart Device", + area_id: "kitchen", + }), + }, + areas: { + kitchen: mockArea({ + area_id: "kitchen", + name: "Kitchen", + floor_id: "first", + }), + }, + floors: { + first: mockFloor({ + floor_id: "first", + name: "First Floor", + }), + }, + } as unknown as HomeAssistant; + + const result = computeEntityNameList( + stateObj, + [ + { type: "floor" }, + { type: "area" }, + { type: "device" }, + { type: "entity" }, + { type: "text", text: "Custom" }, + ], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toEqual([ + "First Floor", + "Kitchen", + "Smart Device", + "Light", + "Custom", + ]); + }); + + it("returns undefined for missing context items", () => { + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + const hass = { + entities: { + "light.kitchen": mockEntity({ + entity_id: "light.kitchen", + name: "Light", + }), + }, + devices: {}, + areas: {}, + floors: {}, + } as unknown as HomeAssistant; + + const result = computeEntityNameList( + stateObj, + [{ type: "device" }, { type: "area" }, { type: "floor" }], + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); + + expect(result).toEqual([undefined, undefined, undefined]); + }); +});