diff --git a/src/common/entity/compute_entity_name.ts b/src/common/entity/compute_entity_name.ts index 846d7414a7..f395e01dd7 100644 --- a/src/common/entity/compute_entity_name.ts +++ b/src/common/entity/compute_entity_name.ts @@ -4,11 +4,14 @@ import type { EntityRegistryEntry, } from "../../data/entity/entity_registry"; import type { HomeAssistant } from "../../types"; +import { computeDeviceName } from "./compute_device_name"; import { computeStateName } from "./compute_state_name"; +import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name"; export const computeEntityName = ( stateObj: HassEntity, - entities: HomeAssistant["entities"] + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"] ): string | undefined => { const entry = entities[stateObj.entity_id] as | EntityRegistryDisplayEntry @@ -18,22 +21,49 @@ export const computeEntityName = ( // Fall back to state name if not in the entity registry (friendly name) return computeStateName(stateObj); } - return computeEntityEntryName(entry); + return computeEntityEntryName(entry, devices); }; export const computeEntityEntryName = ( - entry: EntityRegistryDisplayEntry | EntityRegistryEntry + entry: EntityRegistryDisplayEntry | EntityRegistryEntry, + devices: HomeAssistant["devices"], + fallbackStateObj?: HassEntity ): string | undefined => { - if (entry.name != null) { - return entry.name; + const name = + entry.name || + ("original_name" in entry && entry.original_name != null + ? String(entry.original_name) + : undefined); + + const device = entry.device_id ? devices[entry.device_id] : undefined; + + if (!device) { + if (name) { + return name; + } + if (fallbackStateObj) { + return computeStateName(fallbackStateObj); + } + return undefined; } - if ("original_name" in entry && entry.original_name != null) { - return String(entry.original_name); + + const deviceName = computeDeviceName(device); + + // If the device name is the same as the entity name, consider empty entity name + if (deviceName === name) { + return undefined; } - return undefined; + + // Remove the device name from the entity name if it starts with it + if (deviceName && name) { + return stripPrefixFromEntityName(name, deviceName) || name; + } + + return name; }; export const entityUseDeviceName = ( stateObj: HassEntity, - entities: HomeAssistant["entities"] -): boolean => !computeEntityName(stateObj, entities); + 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 index cda397a1cc..44717c2bfc 100644 --- a/src/common/entity/compute_entity_name_display.ts +++ b/src/common/entity/compute_entity_name_display.ts @@ -5,6 +5,7 @@ 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 { computeStateName } from "./compute_state_name"; import { getEntityContext } from "./context/get_entity_context"; const DEFAULT_SEPARATOR = " "; @@ -40,7 +41,12 @@ export const computeEntityNameDisplay = ( return name; } - let items = ensureArray(name ?? DEFAULT_ENTITY_NAME); + // If no name config is provided, fall back to the friendly name + if (!name) { + return computeStateName(stateObj); + } + + let items = ensureArray(name); const separator = options?.separator ?? DEFAULT_SEPARATOR; @@ -49,7 +55,7 @@ export const computeEntityNameDisplay = ( return items.map((item) => item.text).join(separator); } - const useDeviceName = entityUseDeviceName(stateObj, entities); + const useDeviceName = entityUseDeviceName(stateObj, entities, devices); // If entity uses device name, and device is not already included, replace it with device name if (useDeviceName) { @@ -95,7 +101,7 @@ export const computeEntityNameList = ( const names = name.map((item) => { switch (item.type) { case "entity": - return computeEntityName(stateObj, entities); + return computeEntityName(stateObj, entities, devices); case "device": return device ? computeDeviceName(device) : undefined; case "area": diff --git a/src/components/entity/ha-entity-name-picker.ts b/src/components/entity/ha-entity-name-picker.ts index e56e07ef4b..753ea0057d 100644 --- a/src/components/entity/ha-entity-name-picker.ts +++ b/src/components/entity/ha-entity-name-picker.ts @@ -7,10 +7,7 @@ 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 { - DEFAULT_ENTITY_NAME, - type EntityNameItem, -} from "../../common/entity/compute_entity_name_display"; +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"; @@ -333,13 +330,13 @@ export class HaEntityNamePicker extends LitElement { } return [{ type: "text", text: value } satisfies EntityNameItem]; } - return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME]; + return value ? ensureArray(value) : []; }); private _toValue = memoizeOne( (items: EntityNameItem[]): typeof this.value => { if (items.length === 0) { - return ""; + return undefined; } if (items.length === 1) { const item = items[0]; diff --git a/src/components/target-picker/ha-target-picker-item-row.ts b/src/components/target-picker/ha-target-picker-item-row.ts index da090a9b11..36bc2688d9 100644 --- a/src/components/target-picker/ha-target-picker-item-row.ts +++ b/src/components/target-picker/ha-target-picker-item-row.ts @@ -535,7 +535,7 @@ export class HaTargetPickerItemRow extends LitElement { const stateObject: HassEntity | undefined = this.hass.states[item]; const entityName = stateObject - ? computeEntityName(stateObject, this.hass.entities) + ? computeEntityName(stateObject, this.hass.entities, this.hass.devices) : item; const { area, device } = stateObject ? getEntityContext( diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index d83056f09b..3dfd0e54ae 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -536,9 +536,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) { : undefined; const entityName = stateObj - ? computeEntityName(stateObj, this.hass.entities) + ? computeEntityName(stateObj, this.hass.entities, this.hass.devices) : this._entry - ? computeEntityEntryName(this._entry) + ? computeEntityEntryName(this._entry, this.hass.devices) : entityId; const deviceName = context?.device diff --git a/src/dialogs/more-info/more-info-content.ts b/src/dialogs/more-info/more-info-content.ts index 34dd81bd1c..1ccd16b3a7 100644 --- a/src/dialogs/more-info/more-info-content.ts +++ b/src/dialogs/more-info/more-info-content.ts @@ -107,7 +107,11 @@ class MoreInfoContent extends LitElement { if (!stateObj) { return null; } - const entityName = computeEntityName(stateObj, hass.entities); + const entityName = computeEntityName( + stateObj, + hass.entities, + hass.devices + ); const { area } = getEntityContext( stateObj, hass.entities, diff --git a/src/panels/config/devices/device-detail/ha-device-entities-card.ts b/src/panels/config/devices/device-detail/ha-device-entities-card.ts index 0306de54fd..ce9d89335b 100644 --- a/src/panels/config/devices/device-detail/ha-device-entities-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-entities-card.ts @@ -18,7 +18,7 @@ import { computeSection, } from "../../../lovelace/common/generate-lovelace-config"; import { addEntitiesToLovelaceView } from "../../../lovelace/editor/add-entities-to-view"; -import type { EntityRegistryEntryWithDisplayName } from "../ha-config-device-page"; +import type { EntityRegistryStateEntry } from "../ha-config-device-page"; import { entityRowElement } from "../../../lovelace/entity-rows/entity-row-element-directive"; @customElement("ha-device-entities-card") @@ -30,7 +30,7 @@ export class HaDeviceEntitiesCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) - public entities!: EntityRegistryEntryWithDisplayName[]; + public entities!: EntityRegistryStateEntry[]; @property({ attribute: "show-hidden", type: Boolean }) public showHidden = false; @@ -115,10 +115,8 @@ export class HaDeviceEntitiesCard extends LitElement { this.showHidden = !this.showHidden; } - private _renderEntity( - entry: EntityRegistryEntryWithDisplayName - ): TemplateResult { - let name = entry.display_name || this.deviceName; + private _renderEntity(entry: EntityRegistryStateEntry): TemplateResult { + let name = entry.stateName || this.deviceName; if (entry.hidden_by) { name += ` (${this.hass.localize( "ui.panel.config.devices.entities.hidden" @@ -130,9 +128,9 @@ export class HaDeviceEntitiesCard extends LitElement { } private _renderUnavailableEntity( - entry: EntityRegistryEntryWithDisplayName + entry: EntityRegistryStateEntry ): TemplateResult { - const name = entry.display_name || this.deviceName; + const name = entry.stateName || this.deviceName; const icon = until(entryIcon(this.hass, entry)); diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index bd6ad01f01..47431d40f6 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -34,7 +34,6 @@ import "../../../components/entity/ha-battery-icon"; import "../../../components/ha-alert"; import "../../../components/ha-button"; import "../../../components/ha-dropdown"; -import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; import "../../../components/ha-dropdown-item"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-next"; @@ -66,6 +65,7 @@ import type { EntityRegistryEntry } from "../../../data/entity/entity_registry"; import { findBatteryChargingEntity, findBatteryEntity, + updateEntityRegistryEntry, } from "../../../data/entity/entity_registry"; import type { IntegrationManifest } from "../../../data/integration"; import { domainToName } from "../../../data/integration"; @@ -94,9 +94,10 @@ import { loadDeviceRegistryDetailDialog, showDeviceRegistryDetailDialog, } from "./device-registry-detail/show-dialog-device-registry-detail"; +import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown"; -export interface EntityRegistryEntryWithDisplayName extends EntityRegistryEntry { - display_name?: string | null; +export interface EntityRegistryStateEntry extends EntityRegistryEntry { + stateName?: string | null; } export interface DeviceAction { @@ -176,17 +177,17 @@ export class HaConfigDevicePage extends LitElement { ( deviceId: string, entities: EntityRegistryEntry[] - ): EntityRegistryEntry[] => + ): EntityRegistryStateEntry[] => entities .filter((entity) => entity.device_id === deviceId) .map((entity) => ({ ...entity, - display_name: computeEntityEntryName(entity), + stateName: this._computeEntityName(entity), })) .sort((ent1, ent2) => stringCompare( - ent1.display_name || "", - ent2.display_name || "", + ent1.stateName || `zzz${ent1.entity_id}`, + ent2.stateName || `zzz${ent2.entity_id}`, this.hass.locale.language ) ) @@ -216,7 +217,7 @@ export class HaConfigDevicePage extends LitElement { private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]); private _entityIds = memoizeOne( - (entries: EntityRegistryEntryWithDisplayName[]): string[] => + (entries: EntityRegistryStateEntry[]): string[] => entries.map((entry) => entry.entity_id) ); @@ -249,7 +250,7 @@ export class HaConfigDevicePage extends LitElement { | "assist" | "notify" | NonNullable, - EntityRegistryEntryWithDisplayName[] + EntityRegistryStateEntry[] >; for (const key of [ "assist", @@ -1270,6 +1271,14 @@ export class HaConfigDevicePage extends LitElement { } } + private _computeEntityName(entity: EntityRegistryEntry) { + const device = this.hass.devices[this.deviceId]; + return ( + computeEntityEntryName(entity, this.hass.devices) || + computeDeviceNameDisplay(device, this.hass) + ); + } + private _onImageLoad(ev) { ev.target.style.display = "inline-block"; } @@ -1362,6 +1371,8 @@ export class HaConfigDevicePage extends LitElement { showDeviceRegistryDetailDialog(this, { device, updateEntry: async (updates) => { + const oldDeviceName = device.name_by_user || device.name; + const newDeviceName = updates.name_by_user; const disabled = updates.disabled_by === "user" && device.disabled_by !== "user"; @@ -1433,7 +1444,43 @@ export class HaConfigDevicePage extends LitElement { ), text: err.message, }); + return; } + + if ( + !oldDeviceName || + !newDeviceName || + oldDeviceName === newDeviceName + ) { + return; + } + const entities = this._entities(this.deviceId, this._entityReg); + + const updateProms = entities.map((entity) => { + const name = entity.name || entity.stateName; + let newName: string | null | undefined; + + if (entity.has_entity_name && !entity.name) { + return undefined; + } + + if ( + entity.has_entity_name && + (entity.name === oldDeviceName || entity.name === newDeviceName) + ) { + // clear name if it matches the device name and it uses the device name (entity naming) + newName = null; + } else if (name && name.includes(oldDeviceName)) { + newName = name.replace(oldDeviceName, newDeviceName); + } else { + return undefined; + } + + return updateEntityRegistryEntry(this.hass!, entity.entity_id, { + name: newName, + }); + }); + await Promise.all(updateProms); }, }); }; diff --git a/src/panels/config/entities/dialogs/dialog-vacuum-segment-mapping.ts b/src/panels/config/entities/dialogs/dialog-vacuum-segment-mapping.ts index 324985a043..11384d6962 100644 --- a/src/panels/config/entities/dialogs/dialog-vacuum-segment-mapping.ts +++ b/src/panels/config/entities/dialogs/dialog-vacuum-segment-mapping.ts @@ -140,9 +140,9 @@ export class DialogVacuumSegmentMapping : undefined; const entityName = stateObj - ? computeEntityName(stateObj, this.hass.entities) + ? computeEntityName(stateObj, this.hass.entities, this.hass.devices) : this._entry - ? computeEntityEntryName(this._entry) + ? computeEntityEntryName(this._entry, this.hass.devices) : this._params.entityId; const deviceName = context?.device diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index dca650e8ed..c836f2f50d 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -164,7 +164,7 @@ export class EntitySettingsHelperTab extends LitElement { } private async _confirmDeleteItem(): Promise { - const name = computeEntityEntryName(this.entry); + const name = computeEntityEntryName(this.entry, this.hass.devices); const confirmationText = await getDeleteConfirmationText( this.hass, this.entry, diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index 13af1bd09f..314e7c9615 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -153,7 +153,7 @@ export class EntityRegistrySettingsEditor extends LitElement { @property({ attribute: false }) public helperConfigEntry?: ConfigEntry; - @state() private _name!: string | null; + @state() private _name!: string; @state() private _icon!: string; @@ -218,7 +218,7 @@ export class EntityRegistrySettingsEditor extends LitElement { return; } - this._name = this.entry.name; + this._name = this.entry.name || ""; this._icon = this.entry.icon || ""; this._deviceClass = this.entry.device_class || this.entry.original_device_class; @@ -388,27 +388,14 @@ export class EntityRegistrySettingsEditor extends LitElement { ${this.hideName ? nothing : html` - ${this._name !== null - ? html`
- -
` - : nothing} -
`} + >`} ${this.hideIcon ? nothing : html` @@ -1065,7 +1052,7 @@ export class EntityRegistrySettingsEditor extends LitElement { } const params: Partial = { - name: this._name?.trim() ?? null, + name: this._name.trim() || null, icon: this._icon.trim() || null, area_id: this._areaId || null, labels: this._labels || [], @@ -1331,11 +1318,6 @@ export class EntityRegistrySettingsEditor extends LitElement { this._name = ev.target.value; } - private _restoreName(): void { - fireEvent(this, "change"); - this._name = null; - } - private _iconChanged(ev: CustomEvent): void { fireEvent(this, "change"); this._icon = ev.detail.value; @@ -1610,13 +1592,9 @@ export class EntityRegistrySettingsEditor extends LitElement { } ha-textfield.entityId { --text-field-prefix-padding-right: 0; - } - ha-textfield.entityId, - ha-textfield.name { --textfield-icon-trailing-padding: 0; } - ha-textfield.entityId ha-icon-button, - ha-textfield.name ha-icon-button { + ha-textfield.entityId ha-icon-button { position: relative; right: calc(var(--ha-space-2) * -1); --ha-icon-button-size: 36px; diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index 5e25787baf..4631089218 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -213,7 +213,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { } private async _confirmDeleteEntry(): Promise { - let name = computeEntityEntryName(this.entry); + let name = computeEntityEntryName(this.entry, this.hass.devices); if (!name) { const { device } = getEntityEntryContext( this.entry, diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index d7cc6face6..396d3935b0 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -32,10 +32,7 @@ import { getDuplicatedDeviceNames, } from "../../../common/entity/compute_device_name"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { - computeEntityEntryName, - computeEntityName, -} from "../../../common/entity/compute_entity_name"; +import { computeEntityEntryName } from "../../../common/entity/compute_entity_name"; import { computeStateName } from "../../../common/entity/compute_state_name"; import { deleteEntity, @@ -693,9 +690,11 @@ export class HaConfigEntities extends LitElement { (lbl) => labelReg!.find((label) => label.label_id === lbl)! ); - const entityName = entity - ? computeEntityName(entity, this.hass.entities) - : computeEntityEntryName(entry as EntityRegistryEntry); + const entityName = computeEntityEntryName( + entry as EntityRegistryEntry, + this.hass.devices, + entity + ); const deviceName = device ? computeDeviceName(device) : undefined; const areaName = area ? computeAreaName(area) : undefined; diff --git a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts index 67eb74e7f8..ed1ea3aad3 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts @@ -4,7 +4,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { computeEntityEntryName } from "../../../../../common/entity/compute_entity_name"; +import { computeStateName } from "../../../../../common/entity/compute_state_name"; import { stringCompare } from "../../../../../common/string/compare"; import "../../../../../components/entity/state-badge"; import "../../../../../components/ha-area-picker"; @@ -12,13 +12,17 @@ import "../../../../../components/ha-card"; import "../../../../../components/ha-textfield"; import { updateDeviceRegistryEntry } from "../../../../../data/device/device_registry"; import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry"; -import { subscribeEntityRegistry } from "../../../../../data/entity/entity_registry"; +import { + getAutomaticEntityIds, + subscribeEntityRegistry, + updateEntityRegistryEntry, +} from "../../../../../data/entity/entity_registry"; import type { ZHADevice } from "../../../../../data/zha"; import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; -import type { EntityRegistryEntryWithDisplayName } from "../../../devices/ha-config-device-page"; +import type { EntityRegistryStateEntry } from "../../../devices/ha-config-device-page"; @customElement("zha-device-card") class ZHADeviceCard extends SubscribeMixin(LitElement) { @@ -34,17 +38,17 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) { ( deviceId: string, entities: EntityRegistryEntry[] - ): EntityRegistryEntryWithDisplayName[] => + ): EntityRegistryStateEntry[] => entities .filter((entity) => entity.device_id === deviceId) .map((entity) => ({ ...entity, - display_name: computeEntityEntryName(entity), + stateName: this._computeEntityName(entity), })) .sort((ent1, ent2) => stringCompare( - ent1.display_name || "", - ent2.display_name || "", + ent1.stateName || `zzz${ent1.entity_id}`, + ent2.stateName || `zzz${ent2.entity_id}`, this.hass.locale.language ) ) @@ -85,7 +89,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) { ? html` entity.entity_id) + ); + + const updateProms = entities.map((entity) => { + const name = entity.name; + const newEntityId = entityIdsMapping[entity.entity_id]; + let newName: string | null | undefined; + + if (entity.has_entity_name && !entity.name) { + newName = undefined; + } else if ( + entity.has_entity_name && + (entity.name === oldDeviceName || entity.name === newDeviceName) + ) { + // clear name if it matches the device name and it uses the device name (entity naming) + newName = null; + } else if (name && name.includes(oldDeviceName)) { + newName = name.replace(oldDeviceName, newDeviceName); + } + + if (newName !== undefined && !newEntityId) { + return undefined; + } + + return updateEntityRegistryEntry(this.hass!, entity.entity_id, { + name: newName, + new_entity_id: newEntityId || undefined, + }); + }); + await Promise.all(updateProms); } private _openMoreInfo(ev: MouseEvent): void { @@ -129,6 +174,13 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) { }); } + private _computeEntityName(entity: EntityRegistryEntry): string | null { + if (this.hass.states[entity.entity_id]) { + return computeStateName(this.hass.states[entity.entity_id]); + } + return entity.name; + } + private async _areaPicked(ev: CustomEvent) { const picker = ev.currentTarget as any; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts index 597e86ce0a..a64cbff91d 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/add-node/dialog-zwave_js-add-node.ts @@ -890,6 +890,8 @@ class DialogZWaveJSAddNode extends LitElement { this._step = "rename_device"; const nameChanged = this._device.name !== this._deviceOptions?.name; if (nameChanged || this._deviceOptions?.area) { + const oldDeviceName = this._device.name; + const newDeviceName = this._deviceOptions!.name; try { await updateDeviceRegistryEntry(this.hass, this._device.id, { name_by_user: this._deviceOptions!.name, @@ -897,6 +899,8 @@ class DialogZWaveJSAddNode extends LitElement { }); if (nameChanged) { + // rename entities + const entities = this._entities.filter( (entity) => entity.device_id === this._device!.id ); @@ -908,14 +912,33 @@ class DialogZWaveJSAddNode extends LitElement { await Promise.all( entities.map((entity) => { + const name = entity.name; + let newName: string | null | undefined; const newEntityId = entityIdsMapping[entity.entity_id]; - if (!newEntityId || newEntityId === entity.entity_id) { + if (entity.has_entity_name && !entity.name) { + newName = undefined; + } else if ( + entity.has_entity_name && + (entity.name === oldDeviceName || + entity.name === newDeviceName) + ) { + // clear name if it matches the device name and it uses the device name (entity naming) + newName = null; + } else if (name && name.includes(oldDeviceName)) { + newName = name.replace(oldDeviceName, newDeviceName); + } + + if ( + (newName === undefined && !newEntityId) || + newEntityId === entity.entity_id + ) { return undefined; } return updateEntityRegistryEntry(this.hass!, entity.entity_id, { - new_entity_id: newEntityId, + name: newName || name, + new_entity_id: newEntityId || undefined, }); }) ); diff --git a/src/panels/lovelace/components/hui-entity-editor.ts b/src/panels/lovelace/components/hui-entity-editor.ts index cd5713ede0..ea3885f232 100644 --- a/src/panels/lovelace/components/hui-entity-editor.ts +++ b/src/panels/lovelace/components/hui-entity-editor.ts @@ -42,7 +42,8 @@ export class HuiEntityEditor extends LitElement { const stateObj = this.hass.states[item.entity]; const useDeviceName = - stateObj && entityUseDeviceName(stateObj, this.hass.entities); + stateObj && + entityUseDeviceName(stateObj, this.hass.entities, this.hass.devices); const isRTL = computeRTL(this.hass); diff --git a/src/translations/en.json b/src/translations/en.json index 6c795e7a8e..b35867ad3e 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1764,7 +1764,6 @@ "faq": "documentation", "editor": { "name": "Name", - "restore_name": "Restore default name", "icon": "Icon", "icon_error": "Icons should be in the format 'prefix:iconname', e.g. 'mdi:home'", "default_code": "Default code", diff --git a/test/common/entity/compute_entity_name.test.ts b/test/common/entity/compute_entity_name.test.ts index abe29c7208..fb420f0daf 100644 --- a/test/common/entity/compute_entity_name.test.ts +++ b/test/common/entity/compute_entity_name.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; +import * as computeDeviceNameModule from "../../../src/common/entity/compute_device_name"; import { computeEntityEntryName, computeEntityName, } from "../../../src/common/entity/compute_entity_name"; import * as computeStateNameModule from "../../../src/common/entity/compute_state_name"; +import * as stripPrefixModule from "../../../src/common/entity/strip_prefix_from_entity_name"; import type { HomeAssistant } from "../../../src/types"; import { mockEntity, @@ -23,8 +25,11 @@ describe("computeEntityName", () => { }); const hass = { entities: {}, + devices: {}, } as unknown as HomeAssistant; - expect(computeEntityName(stateObj, hass.entities)).toBe("Kitchen Light"); + expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe( + "Kitchen Light" + ); vi.restoreAllMocks(); }); @@ -42,47 +47,77 @@ describe("computeEntityName", () => { labels: [], }, }, + devices: {}, states: { "light.kitchen": stateObj, }, } as unknown as HomeAssistant; - expect(computeEntityName(stateObj, hass.entities)).toBe("Ceiling Light"); + expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe( + "Ceiling Light" + ); }); }); describe("computeEntityEntryName", () => { - it("returns entry.name if present", () => { + it("returns entry.name if no device", () => { const entry = mockEntity({ entity_id: "light.kitchen", name: "Ceiling Light", }); - expect(computeEntityEntryName(entry)).toBe("Ceiling Light"); + const hass = { devices: {}, states: {} }; + expect(computeEntityEntryName(entry, hass.devices)).toBe("Ceiling Light"); }); - it("returns entity name as-is when device present", () => { + it("returns device-stripped name if device present", () => { + vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue( + "Kitchen" + ); + vi.spyOn(stripPrefixModule, "stripPrefixFromEntityName").mockImplementation( + (name, prefix) => name.replace(prefix + " ", "") + ); const entry = mockEntity({ entity_id: "light.kitchen", - name: "Light", + name: "Kitchen Light", device_id: "dev1", }); - expect(computeEntityEntryName(entry)).toBe("Light"); + const hass = { + devices: { dev1: {} }, + states: {}, + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBe("Light"); + vi.restoreAllMocks(); }); - it("returns undefined if entity has no name (uses device name)", () => { + it("returns undefined if device name equals entity name", () => { + vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue( + "Kitchen Light" + ); const entry = mockEntity({ entity_id: "light.kitchen", + name: "Kitchen Light", device_id: "dev1", }); - expect(computeEntityEntryName(entry)).toBeUndefined(); + const hass = { + devices: { dev1: {} }, + states: {}, + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined(); + vi.restoreAllMocks(); }); - it("returns empty string if name is empty (does not fallback to original_name)", () => { - const entry = mockEntityEntry({ - entity_id: "light.kitchen", - name: "", - original_name: "Old Name", - }); - expect(computeEntityEntryName(entry)).toBe(""); + it("falls back to state name if no name and no device", () => { + vi.spyOn(computeStateNameModule, "computeStateName").mockReturnValue( + "Fallback Name" + ); + const entry = mockEntity({ entity_id: "light.kitchen" }); + const hass = { + devices: {}, + } as unknown as HomeAssistant; + const stateObj = mockStateObj({ entity_id: "light.kitchen" }); + expect(computeEntityEntryName(entry, hass.devices, stateObj)).toBe( + "Fallback Name" + ); + vi.restoreAllMocks(); }); it("returns original_name if present", () => { @@ -90,15 +125,27 @@ describe("computeEntityEntryName", () => { entity_id: "light.kitchen", original_name: "Old Name", }); - expect(computeEntityEntryName(entry)).toBe("Old Name"); + const hass = { + devices: {}, + states: {}, + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name"); }); - it("returns undefined if no name or original_name", () => { + it("returns undefined if no name, original_name, or device", () => { const entry = mockEntity({ entity_id: "light.kitchen" }); - expect(computeEntityEntryName(entry)).toBeUndefined(); + const hass = { + devices: {}, + states: {}, + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined(); }); it("handles entities with numeric original_name (real bug from issue #25363)", () => { + vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue( + "Texas Instruments CC2652" + ); + const entry = { entity_id: "sensor.texas_instruments_cc2652_2", name: null, // null name @@ -106,21 +153,37 @@ describe("computeEntityEntryName", () => { device_id: "dev1", has_entity_name: true, }; + const hass = { + devices: { dev1: {} }, + states: {}, + }; // Should not throw an error and should return the stringified number - expect(() => computeEntityEntryName(entry as any)).not.toThrow(); - expect(computeEntityEntryName(entry as any)).toBe("2"); + expect(() => + computeEntityEntryName(entry as any, hass as any) + ).not.toThrow(); + expect(computeEntityEntryName(entry as any, hass as any)).toBe("2"); + vi.restoreAllMocks(); }); it("returns undefined when entity has device but no name or original_name", () => { + vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue( + "Kitchen Device" + ); + const entry = { entity_id: "sensor.kitchen_sensor", // No name property // No original_name property device_id: "dev1", }; + const hass = { + devices: { dev1: {} }, + states: {}, + }; // Should return undefined to maintain function contract - expect(computeEntityEntryName(entry as any)).toBeUndefined(); + expect(computeEntityEntryName(entry as any, hass as any)).toBeUndefined(); + vi.restoreAllMocks(); }); }); diff --git a/test/common/entity/compute_entity_name_display.test.ts b/test/common/entity/compute_entity_name_display.test.ts index 9aac451cf9..6e521e1d15 100644 --- a/test/common/entity/compute_entity_name_display.test.ts +++ b/test/common/entity/compute_entity_name_display.test.ts @@ -115,6 +115,7 @@ describe("computeEntityNameDisplay", () => { entities: { "light.kitchen": mockEntity({ entity_id: "light.kitchen", + name: "Kitchen Device", device_id: "dev1", }), }, @@ -140,12 +141,13 @@ describe("computeEntityNameDisplay", () => { expect(result).toBe("Kitchen Device"); }); - it("does not duplicate device name when entity uses device name and device is included", () => { + 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", }), }, @@ -168,8 +170,8 @@ describe("computeEntityNameDisplay", () => { hass.floors ); - // Entity has no name (uses device name), device is already included - // So we only get the device name once + // Since entity name equals device name, entity returns undefined + // So we only get the device name expect(result).toBe("Kitchen Device"); });