diff --git a/src/common/entity/context/get_entity_context.ts b/src/common/entity/context/get_entity_context.ts index 0cdab2b617..8244e42d90 100644 --- a/src/common/entity/context/get_entity_context.ts +++ b/src/common/entity/context/get_entity_context.ts @@ -18,9 +18,12 @@ interface EntityContext { export const getEntityContext = ( stateObj: HassEntity, - hass: HomeAssistant + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"] ): EntityContext => { - const entry = hass.entities[stateObj.entity_id] as + const entry = entities[stateObj.entity_id] as | EntityRegistryDisplayEntry | undefined; @@ -32,7 +35,7 @@ export const getEntityContext = ( floor: null, }; } - return getEntityEntryContext(entry, hass); + return getEntityEntryContext(entry, entities, devices, areas, floors); }; export const getEntityEntryContext = ( @@ -40,15 +43,18 @@ export const getEntityEntryContext = ( | EntityRegistryDisplayEntry | EntityRegistryEntry | ExtEntityRegistryEntry, - hass: HomeAssistant + entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"] ): EntityContext => { - const entity = hass.entities[entry.entity_id]; + const entity = entities[entry.entity_id]; const deviceId = entry?.device_id; - const device = deviceId ? hass.devices[deviceId] : undefined; + const device = deviceId ? devices[deviceId] : undefined; const areaId = entry?.area_id || device?.area_id; - const area = areaId ? hass.areas[areaId] : undefined; + const area = areaId ? areas[areaId] : undefined; const floorId = area?.floor_id; - const floor = floorId ? hass.floors[floorId] : undefined; + const floor = floorId ? floors[floorId] : undefined; return { entity: entity, diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index cef8cc1638..41901a8bae 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -60,7 +60,13 @@ export const generateEntityFilter = ( } } - const { area, floor, device, entity } = getEntityContext(stateObj, hass); + const { area, floor, device, entity } = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); if (entity && entity.hidden) { return false; diff --git a/src/common/translations/entity-state.ts b/src/common/translations/entity-state.ts index 69c4df603d..1102ebcc46 100644 --- a/src/common/translations/entity-state.ts +++ b/src/common/translations/entity-state.ts @@ -2,6 +2,11 @@ import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; import type { FrontendLocaleData } from "../../data/translation"; import type { HomeAssistant } from "../../types"; 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"; export type FormatEntityStateFunc = ( stateObj: HassEntity, @@ -17,16 +22,28 @@ export type FormatEntityAttributeNameFunc = ( attribute: string ) => string; +export type EntityNameToken = "entity" | "device" | "area" | "floor"; + +export type FormatEntityNameFunc = ( + stateObj: HassEntity, + tokens: EntityNameToken[], + separator?: string +) => string; + export const computeFormatFunctions = async ( localize: LocalizeFunc, locale: FrontendLocaleData, config: HassConfig, entities: HomeAssistant["entities"], + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + floors: HomeAssistant["floors"], sensorNumericDeviceClasses: string[] ): Promise<{ formatEntityState: FormatEntityStateFunc; formatEntityAttributeValue: FormatEntityAttributeValueFunc; formatEntityAttributeName: FormatEntityAttributeNameFunc; + formatEntityName: FormatEntityNameFunc; }> => { const { computeStateDisplay } = await import( "../entity/compute_state_display" @@ -57,5 +74,44 @@ export const computeFormatFunctions = async ( ), formatEntityAttributeName: (stateObj, attribute) => computeAttributeNameDisplay(localize, stateObj, entities, attribute), + formatEntityName: (stateObj, tokens, separator = " ") => { + const namesList: (string | undefined)[] = []; + + const { device, area, floor } = getEntityContext( + stateObj, + entities, + devices, + areas, + floors + ); + + for (const token of tokens) { + switch (token) { + 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(Boolean).join(separator); + }, }; }; diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index b73acf6b47..c547e280da 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -148,7 +148,13 @@ export class HaEntityPicker extends LitElement { `; } - const { area, device } = getEntityContext(stateObj, this.hass); + const { area, device } = getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const entityName = computeEntityName( stateObj, @@ -315,7 +321,13 @@ export class HaEntityPicker extends LitElement { items = entityIds.map((entityId) => { const stateObj = hass!.states[entityId]; - const { area, device } = getEntityContext(stateObj, hass); + const { area, device } = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); const friendlyName = computeStateName(stateObj); // Keep this for search const entityName = computeEntityName( diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index a0b2ad04bb..221fbc9a22 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -259,7 +259,13 @@ export class HaStatisticPicker extends LitElement { } const id = meta.statistic_id; - const { area, device } = getEntityContext(stateObj, hass); + const { area, device } = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); const friendlyName = computeStateName(stateObj); // Keep this for search const entityName = computeEntityName( @@ -341,7 +347,13 @@ export class HaStatisticPicker extends LitElement { const stateObj = this.hass.states[statisticId]; if (stateObj) { - const { area, device } = getEntityContext(stateObj, this.hass); + const { area, device } = getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const entityName = computeEntityName( stateObj, diff --git a/src/data/selector/format_selector_value.ts b/src/data/selector/format_selector_value.ts index f616dd487f..cca90596c3 100644 --- a/src/data/selector/format_selector_value.ts +++ b/src/data/selector/format_selector_value.ts @@ -79,7 +79,13 @@ export const formatSelectorValue = ( if (!stateObj) { return entityId; } - const { device } = getEntityContext(stateObj, hass); + const { device } = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); const deviceName = device ? computeDeviceName(device) : undefined; const entityName = computeEntityName( stateObj, diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index e54be9bde0..16104cf394 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -305,9 +305,21 @@ export class MoreInfoDialog extends LitElement { const showCloseIcon = isDefaultView || isSpecificInitialView; const context = stateObj - ? getEntityContext(stateObj, this.hass) + ? getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ) : this._entry - ? getEntityEntryContext(this._entry, this.hass) + ? getEntityEntryContext( + this._entry, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ) : undefined; const entityName = stateObj diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 87590448b1..fd7ed28b9f 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -631,7 +631,13 @@ export class QuickBar extends LitElement { .map((entityId) => { const stateObj = this.hass.states[entityId]; - const { area, device } = getEntityContext(stateObj, this.hass); + const { area, device } = getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const friendlyName = computeStateName(stateObj); // Keep this for search const entityName = computeEntityName( diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 9317c11f85..24864d8b8b 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -114,17 +114,22 @@ export const provideHass = ( formatEntityState, formatEntityAttributeName, formatEntityAttributeValue, + formatEntityName, } = await computeFormatFunctions( hass().localize, hass().locale, hass().config, hass().entities, + hass().devices, + hass().areas, + hass().floors, [] // numericDeviceClasses ); hass().updateHass({ formatEntityState, formatEntityAttributeName, formatEntityAttributeValue, + formatEntityName, }); } diff --git a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts index 5f1a9cf146..3205392ec5 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-sankey-card.ts @@ -408,7 +408,13 @@ class HuiEnergySankeyCard }; deviceNodes.forEach((deviceNode) => { const entity = this.hass.states[deviceNode.id]; - const { area, floor } = getEntityContext(entity, this.hass); + const { area, floor } = getEntityContext( + entity, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); if (area) { if (area.area_id in areas) { areas[area.area_id].value += deviceNode.value; 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 d1dfe579fa..7e0bd296ac 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 @@ -66,7 +66,13 @@ export class HuiEntityPickerTable extends LitElement { (entity) => { const stateObj = this.hass.states[entity]; - const { area, device } = getEntityContext(stateObj, this.hass); + const { area, device } = getEntityContext( + stateObj, + this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors + ); const entityName = computeEntityName( stateObj, diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 82fc5f3caf..386dda867a 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -33,6 +33,7 @@ import { fetchWithAuth } from "../util/fetch-with-auth"; import { getState } from "../util/ha-pref-storage"; import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api"; import type { HassBaseEl } from "./hass-base-mixin"; +import { computeStateName } from "../common/entity/compute_state_name"; export const connectionMixin = >( superClass: T @@ -210,6 +211,7 @@ export const connectionMixin = >( value != null ? value : (stateObj.attributes[attribute] ?? ""), ...getState(), ...this._pendingHass, + formatEntityName: (stateObj) => computeStateName(stateObj), }; this.hassConnected(); diff --git a/src/state/state-display-mixin.ts b/src/state/state-display-mixin.ts index 05cca8e8f7..660635a890 100644 --- a/src/state/state-display-mixin.ts +++ b/src/state/state-display-mixin.ts @@ -52,17 +52,22 @@ export default >(superClass: T) => { formatEntityState, formatEntityAttributeName, formatEntityAttributeValue, + formatEntityName, } = await computeFormatFunctions( this.hass.localize, this.hass.locale, this.hass.config, this.hass.entities, + this.hass.devices, + this.hass.areas, + this.hass.floors, sensorNumericDeviceClasses ); this._updateHass({ formatEntityState, formatEntityAttributeName, formatEntityAttributeValue, + formatEntityName, }); }; } diff --git a/src/types.ts b/src/types.ts index e0b1cbf4f4..5af3a066c1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ import type { HassServiceTarget, MessageBase, } from "home-assistant-js-websocket"; +import type { EntityNameToken } from "./common/translations/entity-state"; import type { LocalizeFunc } from "./common/translations/localize"; import type { AreaRegistryEntry } from "./data/area_registry"; import type { DeviceRegistryEntry } from "./data/device_registry"; @@ -285,6 +286,11 @@ export interface HomeAssistant { value?: any ): string; formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; + formatEntityName( + stateObj: HassEntity, + tokens: EntityNameToken[], + separator?: string + ): string; } export interface Route { diff --git a/test/common/entity/compute_entity_name.test.ts b/test/common/entity/compute_entity_name.test.ts index 82bc16fa44..7c6e041b80 100644 --- a/test/common/entity/compute_entity_name.test.ts +++ b/test/common/entity/compute_entity_name.test.ts @@ -6,33 +6,39 @@ import { } 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, + mockEntityEntry, + mockStateObj, +} from "./context/context-mock"; describe("computeEntityName", () => { it("returns state name if entity not in registry", () => { vi.spyOn(computeStateNameModule, "computeStateName").mockReturnValue( "Kitchen Light" ); - const stateObj = { + const stateObj = mockStateObj({ entity_id: "light.kitchen", attributes: { friendly_name: "Kitchen Light" }, state: "on", - }; + }); const hass = { entities: {}, devices: {}, - }; - expect( - computeEntityName(stateObj as any, hass.entities, hass.devices) - ).toBe("Kitchen Light"); + } as unknown as HomeAssistant; + expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe( + "Kitchen Light" + ); vi.restoreAllMocks(); }); it("returns entity entry name if present", () => { - const stateObj = { + const stateObj = mockStateObj({ entity_id: "light.kitchen", attributes: {}, state: "on", - }; + }); const hass = { entities: { "light.kitchen": { @@ -45,20 +51,21 @@ describe("computeEntityName", () => { states: { "light.kitchen": stateObj, }, - }; - expect( - computeEntityName(stateObj as any, hass.entities, hass.devices) - ).toBe("Ceiling Light"); + } as unknown as HomeAssistant; + expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe( + "Ceiling Light" + ); }); }); describe("computeEntityEntryName", () => { it("returns entry.name if no device", () => { - const entry = { entity_id: "light.kitchen", name: "Ceiling Light" }; + const entry = mockEntity({ + entity_id: "light.kitchen", + name: "Ceiling Light", + }); const hass = { devices: {}, states: {} }; - expect(computeEntityEntryName(entry as any, hass.devices)).toBe( - "Ceiling Light" - ); + expect(computeEntityEntryName(entry, hass.devices)).toBe("Ceiling Light"); }); it("returns device-stripped name if device present", () => { @@ -68,18 +75,16 @@ describe("computeEntityEntryName", () => { vi.spyOn(stripPrefixModule, "stripPrefixFromEntityName").mockImplementation( (name, prefix) => name.replace(prefix + " ", "") ); - const entry = { + const entry = mockEntity({ entity_id: "light.kitchen", name: "Kitchen Light", device_id: "dev1", - }; + }); const hass = { devices: { dev1: {} }, states: {}, - }; - expect(computeEntityEntryName(entry as any, hass.devices as any)).toBe( - "Light" - ); + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBe("Light"); vi.restoreAllMocks(); }); @@ -87,18 +92,16 @@ describe("computeEntityEntryName", () => { vi.spyOn(computeDeviceNameModule, "computeDeviceName").mockReturnValue( "Kitchen Light" ); - const entry = { + const entry = mockEntity({ entity_id: "light.kitchen", name: "Kitchen Light", device_id: "dev1", - }; + }); const hass = { devices: { dev1: {} }, states: {}, - }; - expect( - computeEntityEntryName(entry as any, hass.devices as any) - ).toBeUndefined(); + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined(); vi.restoreAllMocks(); }); @@ -106,32 +109,35 @@ describe("computeEntityEntryName", () => { vi.spyOn(computeStateNameModule, "computeStateName").mockReturnValue( "Fallback Name" ); - const entry = { entity_id: "light.kitchen" }; + const entry = mockEntity({ entity_id: "light.kitchen" }); const hass = { devices: {}, - }; - const stateObj = { entity_id: "light.kitchen" }; - expect( - computeEntityEntryName(entry as any, hass.devices, stateObj as any) - ).toBe("Fallback Name"); + } 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", () => { - const entry = { entity_id: "light.kitchen", original_name: "Old Name" }; + const entry = mockEntityEntry({ + entity_id: "light.kitchen", + original_name: "Old Name", + }); const hass = { devices: {}, states: {}, - }; - expect(computeEntityEntryName(entry as any, hass.devices)).toBe("Old Name"); + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name"); }); it("returns undefined if no name, original_name, or device", () => { - const entry = { entity_id: "light.kitchen" }; + const entry = mockEntity({ entity_id: "light.kitchen" }); const hass = { devices: {}, states: {}, - }; - expect(computeEntityEntryName(entry as any, hass.devices)).toBeUndefined(); + } as unknown as HomeAssistant; + expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined(); }); }); diff --git a/test/common/entity/context/context-mock.ts b/test/common/entity/context/context-mock.ts index 6fa8e5dce9..1f38f26c56 100644 --- a/test/common/entity/context/context-mock.ts +++ b/test/common/entity/context/context-mock.ts @@ -1,7 +1,10 @@ import type { HassEntity } from "home-assistant-js-websocket"; -import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity_registry"; -import type { DeviceRegistryEntry } from "../../../../src/data/device_registry"; import type { AreaRegistryEntry } from "../../../../src/data/area_registry"; +import type { DeviceRegistryEntry } from "../../../../src/data/device_registry"; +import type { + EntityRegistryDisplayEntry, + EntityRegistryEntry, +} from "../../../../src/data/entity_registry"; import type { FloorRegistryEntry } from "../../../../src/data/floor_registry"; export const mockStateObj = (partial: Partial): HassEntity => ({ @@ -26,6 +29,31 @@ export const mockEntity = ( ...partial, }); +export const mockEntityEntry = ( + partial: Partial +): EntityRegistryEntry => ({ + entity_id: "", + name: null, + icon: null, + platform: "", + config_entry_id: null, + config_subentry_id: null, + device_id: null, + area_id: null, + labels: [], + disabled_by: null, + hidden_by: null, + entity_category: null, + has_entity_name: false, + unique_id: "", + id: "", + options: null, + categories: {}, + created_at: 0, + modified_at: 0, + ...partial, +}); + export const mockDevice = ( partial: Partial ): DeviceRegistryEntry => ({ diff --git a/test/common/entity/context/get_entity_context.test.ts b/test/common/entity/context/get_entity_context.test.ts index 82a74b5e76..487981bde0 100644 --- a/test/common/entity/context/get_entity_context.test.ts +++ b/test/common/entity/context/get_entity_context.test.ts @@ -26,7 +26,13 @@ describe("getEntityContext", () => { floors: {}, } as unknown as HomeAssistant; - const result = getEntityContext(stateObj, hass); + const result = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); expect(result).toEqual({ entity, @@ -71,7 +77,13 @@ describe("getEntityContext", () => { }, } as unknown as HomeAssistant; - const result = getEntityContext(stateObj, hass); + const result = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); expect(result).toEqual({ entity, @@ -105,7 +117,13 @@ describe("getEntityContext", () => { }, } as unknown as HomeAssistant; - const result = getEntityContext(stateObj, hass); + const result = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); expect(result).toEqual({ entity, @@ -138,7 +156,13 @@ describe("getEntityContext", () => { floors: {}, } as unknown as HomeAssistant; - const result = getEntityContext(stateObj, hass); + const result = getEntityContext( + stateObj, + hass.entities, + hass.devices, + hass.areas, + hass.floors + ); expect(result).toEqual({ entity,