Add name preset to tile card (#27065)

This commit is contained in:
Paul Bottein
2025-10-08 10:13:54 +02:00
committed by GitHub
parent a8f8d197f8
commit b87ffbd4f7
22 changed files with 1306 additions and 137 deletions

View File

@@ -61,3 +61,9 @@ export const computeEntityEntryName = (
return name; return name;
}; };
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);

View File

@@ -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;
};

View File

@@ -1,13 +1,12 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import {
computeEntityNameDisplay,
type EntityNameItem,
type EntityNameOptions,
} from "../entity/compute_entity_name_display";
import type { LocalizeFunc } from "./localize"; 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 = ( export type FormatEntityStateFunc = (
stateObj: HassEntity, stateObj: HassEntity,
@@ -27,8 +26,8 @@ export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = ( export type FormatEntityNameFunc = (
stateObj: HassEntity, stateObj: HassEntity,
type: EntityNameType | EntityNameType[], name: EntityNameItem | EntityNameItem[],
separator?: string options?: EntityNameOptions
) => string; ) => string;
export const computeFormatFunctions = async ( export const computeFormatFunctions = async (
@@ -75,45 +74,15 @@ export const computeFormatFunctions = async (
), ),
formatEntityAttributeName: (stateObj, attribute) => formatEntityAttributeName: (stateObj, attribute) =>
computeAttributeNameDisplay(localize, stateObj, entities, attribute), computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, type, separator = " ") => { formatEntityName: (stateObj, name, options) =>
const types = ensureArray(type); computeEntityNameDisplay(
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj, stateObj,
name,
entities, entities,
devices, devices,
areas, areas,
floors floors,
); options
),
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);
},
}; };
}; };

View File

@@ -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<EntityNameOption> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
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<string>();
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<EntityNameOption>((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`<label>${this.label}</label>` : nothing}
<div class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid =
item.type === "text" || validOptions.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div>
`;
}
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<boolean>) {
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<string>) {
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<EntityNameOption> = {
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<string>);
}
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<string>);
}
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): 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;
}
}

View File

@@ -6,6 +6,7 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain"; 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 { computeStateName } from "../../common/entity/compute_state_name";
import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
@@ -144,9 +145,14 @@ export class HaEntityPicker extends LitElement {
`; `;
} }
const entityName = this.hass.formatEntityName(stateObj, "entity"); const [entityName, deviceName, areaName] = computeEntityNameList(
const deviceName = this.hass.formatEntityName(stateObj, "device"); stateObj,
const areaName = this.hass.formatEntityName(stateObj, "area"); [{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass); 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<EntityComboBoxItem>((entityId) => { items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId]; const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search 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( const [entityName, deviceName, areaName] = computeEntityNameList(
this.hass.localize, stateObj,
computeDomain(entityId) [{ 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 primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)

View File

@@ -6,6 +6,7 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; 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 { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration"; 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[] = []; const output: StatisticComboBoxItem[] = [];
@@ -256,9 +257,15 @@ export class HaStatisticPicker extends LitElement {
const id = meta.statistic_id; const id = meta.statistic_id;
const friendlyName = computeStateName(stateObj); // Keep this for search const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = hass.formatEntityName(stateObj, "entity");
const deviceName = hass.formatEntityName(stateObj, "device"); const [entityName, deviceName, areaName] = computeEntityNameList(
const areaName = hass.formatEntityName(stateObj, "area"); stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id; const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
@@ -331,9 +338,14 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId]; const stateObj = this.hass.states[statisticId];
if (stateObj) { if (stateObj) {
const entityName = this.hass.formatEntityName(stateObj, "entity"); const [entityName, deviceName, areaName] = computeEntityNameList(
const deviceName = this.hass.formatEntityName(stateObj, "device"); stateObj,
const areaName = this.hass.formatEntityName(stateObj, "area"); [{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);

View File

@@ -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`
<ha-entity-name-picker
.hass=${this.hass}
.entityId=${this.selector.entity_name?.entity_id ||
this.context?.entity}
.value=${value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-entity-name-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-entity_name": HaSelectorEntityName;
}
}

View File

@@ -29,6 +29,7 @@ const LOAD_ELEMENTS = {
device: () => import("./ha-selector-device"), device: () => import("./ha-selector-device"),
duration: () => import("./ha-selector-duration"), duration: () => import("./ha-selector-duration"),
entity: () => import("./ha-selector-entity"), entity: () => import("./ha-selector-entity"),
entity_name: () => import("./ha-selector-entity-name"),
statistic: () => import("./ha-selector-statistic"), statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"), file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"), floor: () => import("./ha-selector-floor"),

View File

@@ -18,6 +18,7 @@ import type {
EntityRegistryEntry, EntityRegistryEntry,
} from "./entity_registry"; } from "./entity_registry";
import type { EntitySources } from "./entity_sources"; import type { EntitySources } from "./entity_sources";
import type { EntityNameItem } from "../common/entity/compute_entity_name_display";
export type Selector = export type Selector =
| ActionSelector | ActionSelector
@@ -41,6 +42,7 @@ export type Selector =
| LegacyDeviceSelector | LegacyDeviceSelector
| DurationSelector | DurationSelector
| EntitySelector | EntitySelector
| EntityNameSelector
| LegacyEntitySelector | LegacyEntitySelector
| FileSelector | FileSelector
| IconSelector | IconSelector
@@ -499,6 +501,13 @@ export interface UiStateContentSelector {
} | null; } | null;
} }
export interface EntityNameSelector {
entity_name: {
entity_id?: string;
default_name?: EntityNameItem | EntityNameItem[] | string;
} | null;
}
export const expandLabelTarget = ( export const expandLabelTarget = (
hass: HomeAssistant, hass: HomeAssistant,
labelId: string, labelId: string,

View File

@@ -76,7 +76,10 @@ export const formatSelectorValue = (
if (!stateObj) { if (!stateObj) {
return entityId; return entityId;
} }
const name = hass.formatEntityName(stateObj, ["device", "entity"], " "); const name = hass.formatEntityName(stateObj, [
{ type: "device" },
{ type: "entity" },
]);
return name || entityId; return name || entityId;
}) })
.join(", "); .join(", ");

View File

@@ -23,8 +23,14 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../common/entity/compute_entity_name"; import {
import { getEntityEntryContext } from "../../common/entity/context/get_entity_context"; 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 { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate"; import { navigate } from "../../common/navigate";
import "../../components/ha-button-menu"; import "../../components/ha-button-menu";
@@ -321,28 +327,34 @@ export class MoreInfoDialog extends LitElement {
(isDefaultView && this._parentEntityIds.length === 0) || (isDefaultView && this._parentEntityIds.length === 0) ||
isSpecificInitialView; isSpecificInitialView;
let entityName: string | undefined; const context = stateObj
let deviceName: string | undefined; ? getEntityContext(
let areaName: string | undefined; 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) { const entityName = stateObj
entityName = this.hass.formatEntityName(stateObj, "entity"); ? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
deviceName = this.hass.formatEntityName(stateObj, "device"); : this._entry
areaName = this.hass.formatEntityName(stateObj, "area"); ? computeEntityEntryName(this._entry, this.hass.devices)
} else if (this._entry) { : entityId;
const { device, area } = getEntityEntryContext(
this._entry, const deviceName = context?.device
this.hass.entities, ? computeDeviceName(context.device)
this.hass.devices, : undefined;
this.hass.areas, const areaName = context?.area ? computeAreaName(context.area) : undefined;
this.hass.floors
);
entityName = computeEntityEntryName(this._entry, this.hass.devices);
deviceName = device ? computeDeviceName(device) : undefined;
areaName = area ? computeAreaName(area) : undefined;
} else {
entityName = entityId;
}
const breadcrumb = [areaName, deviceName, entityName].filter( const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v) (v): v is string => Boolean(v)

View File

@@ -23,6 +23,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { entityUseDeviceName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name"; import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { navigate } from "../../common/navigate"; 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 type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import "../../components/ha-button";
import "../../components/ha-icon-button"; import "../../components/ha-icon-button";
import "../../components/ha-label"; import "../../components/ha-label";
import "../../components/ha-button";
import "../../components/ha-list"; import "../../components/ha-list";
import "../../components/ha-md-list-item"; import "../../components/ha-md-list-item";
import "../../components/ha-spinner"; import "../../components/ha-spinner";
@@ -631,14 +632,29 @@ export class QuickBar extends LitElement {
const stateObj = this.hass.states[entityId]; const stateObj = this.hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search 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 useDeviceName = entityUseDeviceName(
const secondary = [areaName, entityName ? deviceName : undefined] stateObj,
.filter(Boolean) this.hass.entities,
.join(isRTL ? " ◂ " : " ▸ "); 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( const translatedDomain = domainToName(
this.hass.localize, this.hass.localize,

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket"; 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 { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; 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 { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { DOMAINS_TOGGLE } from "../../../common/const"; import { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain"; 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 { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color"; import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-card"; import "../../../components/ha-card";
@@ -47,6 +47,11 @@ export const getEntityDefaultTileIconAction = (entityId: string) => {
return supportsIconAction ? "toggle" : "none"; return supportsIconAction ? "toggle" : "none";
}; };
export const DEFAULT_NAME = [
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
@customElement("hui-tile-card") @customElement("hui-tile-card")
export class HuiTileCard extends LitElement implements LovelaceCard { export class HuiTileCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> { public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -255,7 +260,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const contentClasses = { vertical: Boolean(this._config.vertical) }; 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 active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color); const color = this._computeStateColor(stateObj, this._config.color);
const domain = computeDomain(stateObj.entity_id); const domain = computeDomain(stateObj.entity_id);
@@ -267,7 +278,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj} .stateObj=${stateObj}
.hass=${this.hass} .hass=${this.hass}
.content=${this._config.state_content} .content=${this._config.state_content}
.name=${this._config.name} .name=${nameDisplay}
> >
</state-display> </state-display>
`; `;
@@ -326,7 +337,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
${renderTileBadge(stateObj, this.hass)} ${renderTileBadge(stateObj, this.hass)}
</ha-tile-icon> </ha-tile-icon>
<ha-tile-info id="info"> <ha-tile-info id="info">
<span slot="primary" class="primary">${name}</span> <span slot="primary" class="primary">${nameDisplay}</span>
${stateDisplay ${stateDisplay
? html`<span slot="secondary">${stateDisplay}</span>` ? html`<span slot="secondary">${stateDisplay}</span>`
: nothing} : nothing}

View File

@@ -1,8 +1,11 @@
import type { HassServiceTarget } from "home-assistant-js-websocket"; 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 { HaDurationData } from "../../../components/ha-duration-input";
import type { EnergySourceByType } from "../../../data/energy";
import type { ActionConfig } from "../../../data/lovelace/config/action"; import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { Statistic, StatisticType } from "../../../data/recorder"; import type { Statistic, StatisticType } from "../../../data/recorder";
import type { TimeFormat } from "../../../data/translation";
import type { ForecastType } from "../../../data/weather"; import type { ForecastType } from "../../../data/weather";
import type { import type {
FullCalendarView, FullCalendarView,
@@ -25,9 +28,7 @@ import type {
} from "../entity-rows/types"; } from "../entity-rows/types";
import type { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceHeaderFooterConfig } from "../header-footer/types";
import type { LovelaceHeadingBadgeConfig } from "../heading-badges/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 { HomeSummary } from "../strategies/home/helpers/home-summaries";
import type { EnergySourceByType } from "../../../data/energy";
export type AlarmPanelCardConfigState = export type AlarmPanelCardConfigState =
| "arm_away" | "arm_away"
@@ -568,7 +569,7 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
export interface TileCardConfig extends LovelaceCardConfig { export interface TileCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
name?: string; name?: string | EntityNameItem | EntityNameItem[];
hide_state?: boolean; hide_state?: boolean;
state_content?: string | string[]; state_content?: string | string[];
icon?: string; icon?: string;

View File

@@ -3,6 +3,8 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../common/dom/fire_event"; 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 "../../../components/entity/ha-entity-picker";
import type { import type {
HaEntityPicker, HaEntityPicker,
@@ -12,11 +14,10 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-sortable"; import "../../../components/ha-sortable";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import type { EntityConfig } from "../entity-rows/types"; import type { EntityConfig } from "../entity-rows/types";
import { computeRTL } from "../../../common/util/compute_rtl";
@customElement("hui-entity-editor") @customElement("hui-entity-editor")
export class HuiEntityEditor extends LitElement { export class HuiEntityEditor extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entities?: EntityConfig[]; @property({ attribute: false }) public entities?: EntityConfig[];
@@ -38,20 +39,32 @@ export class HuiEntityEditor extends LitElement {
} }
private _renderItem(item: EntityConfig, index: number) { private _renderItem(item: EntityConfig, index: number) {
const stateObj = this.hass!.states[item.entity]; const stateObj = this.hass.states[item.entity];
const entityName = const useDeviceName = entityUseDeviceName(
stateObj && this.hass!.formatEntityName(stateObj, "entity"); stateObj,
const deviceName = this.hass.entities,
stateObj && this.hass!.formatEntityName(stateObj, "device"); this.hass.devices
const areaName = stateObj && this.hass!.formatEntityName(stateObj, "area"); );
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 isRTL = computeRTL(this.hass);
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) const primary = item.name || name || item.entity;
.join(isRTL ? " ◂ " : " ▸ ");
const secondary = this.hass.formatEntityName(
stateObj,
useDeviceName
? [{ type: "area" }]
: [{ type: "area" }, { type: "device" }],
{
separator: isRTL ? " ◂ " : " ▸ ",
}
);
return html` return html`
<ha-md-list-item class="item"> <ha-md-list-item class="item">
@@ -67,14 +80,14 @@ export class HuiEntityEditor extends LitElement {
slot="end" slot="end"
.item=${item} .item=${item}
.index=${index} .index=${index}
.label=${this.hass!.localize("ui.common.edit")} .label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil} .path=${mdiPencil}
@click=${this._editItem} @click=${this._editItem}
></ha-icon-button> ></ha-icon-button>
<ha-icon-button <ha-icon-button
slot="end" slot="end"
.index=${index} .index=${index}
.label=${this.hass!.localize("ui.common.delete")} .label=${this.hass.localize("ui.common.delete")}
.path=${mdiClose} .path=${mdiClose}
@click=${this._deleteItem} @click=${this._deleteItem}
></ha-icon-button> ></ha-icon-button>
@@ -109,9 +122,9 @@ export class HuiEntityEditor extends LitElement {
return html` return html`
<h3> <h3>
${this.label || ${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") +
")"} ")"}
</h3> </h3>
${this.canEdit ${this.canEdit

View File

@@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../../../common/translations/localize"; import type { LocalizeFunc } from "../../../../common/translations/localize";
import { computeRTL } from "../../../../common/util/compute_rtl"; import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/data-table/ha-data-table"; import "../../../../components/data-table/ha-data-table";
@@ -62,9 +63,14 @@ export class HuiEntityPickerTable extends LitElement {
(entity) => { (entity) => {
const stateObj = this.hass.states[entity]; const stateObj = this.hass.states[entity];
const entityName = this.hass.formatEntityName(stateObj, "entity"); const [entityName, deviceName, areaName] = computeEntityNameList(
const deviceName = this.hass.formatEntityName(stateObj, "device"); stateObj,
const areaName = this.hass.formatEntityName(stateObj, "area"); [{ 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 name = [deviceName, entityName].filter(Boolean).join(" ");
const domain = computeDomain(entity); const domain = computeDomain(entity);

View File

@@ -30,11 +30,15 @@ import type {
LovelaceCardFeatureConfig, LovelaceCardFeatureConfig,
LovelaceCardFeatureContext, LovelaceCardFeatureContext,
} from "../../card-features/types"; } 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 { TileCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types"; import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style"; import { configElementStyle } from "./config-elements-style";
import { getSupportedFeaturesType } from "./hui-card-features-editor"; import { getSupportedFeaturesType } from "./hui-card-features-editor";
@@ -43,7 +47,7 @@ const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
entity: optional(string()), entity: optional(string()),
name: optional(string()), name: optional(entityNameStruct),
icon: optional(string()), icon: optional(string()),
color: optional(string()), color: optional(string()),
show_entity_picture: optional(boolean()), show_entity_picture: optional(boolean()),
@@ -97,11 +101,19 @@ export class HuiTileCardEditor
type: "expandable", type: "expandable",
iconPath: mdiTextShort, iconPath: mdiTextShort,
schema: [ schema: [
{
name: "name",
selector: {
entity_name: {
default_name: DEFAULT_NAME,
},
},
context: { entity: "entity" },
},
{ {
name: "", name: "",
type: "grid", type: "grid",
schema: [ schema: [
{ name: "name", selector: { text: {} } },
{ {
name: "icon", name: "icon",
selector: { selector: {

View File

@@ -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),
]);

View File

@@ -270,15 +270,12 @@ export class HomeAreaViewStrategy extends ReactiveElement {
})), })),
], ],
} satisfies HeadingCardConfig, } satisfies HeadingCardConfig,
...entities.map((e) => { ...entities.map((e) => ({
const stateObj = hass.states[e]; ...computeTileCard(e),
return { name: {
...computeTileCard(e), type: "entity",
name: },
hass.formatEntityName(stateObj, "entity") || })),
hass.formatEntityName(stateObj, "device"),
};
}),
], ],
}); });
} }

View File

@@ -657,6 +657,18 @@
"placeholder": "Select an entity", "placeholder": "Select an entity",
"create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper." "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": { "entity-attribute-picker": {
"attribute": "Attribute", "attribute": "Attribute",
"show_attributes": "Show attributes" "show_attributes": "Show attributes"

View File

@@ -9,7 +9,10 @@ import type {
HassServiceTarget, HassServiceTarget,
MessageBase, MessageBase,
} from "home-assistant-js-websocket"; } 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 { LocalizeFunc } from "./common/translations/localize";
import type { AreaRegistryEntry } from "./data/area_registry"; import type { AreaRegistryEntry } from "./data/area_registry";
import type { DeviceRegistryEntry } from "./data/device_registry"; import type { DeviceRegistryEntry } from "./data/device_registry";
@@ -288,8 +291,8 @@ export interface HomeAssistant {
formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; formatEntityAttributeName(stateObj: HassEntity, attribute: string): string;
formatEntityName( formatEntityName(
stateObj: HassEntity, stateObj: HassEntity,
type: EntityNameType | EntityNameType[], type: EntityNameItem | EntityNameItem[],
separator?: string separator?: EntityNameOptions
): string; ): string;
} }

View File

@@ -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]);
});
});