Add formatEntityName function

This commit is contained in:
Paul Bottein 2025-07-03 18:38:54 +02:00
parent 337b890abe
commit 7da48d8fd8
No known key found for this signature in database
17 changed files with 269 additions and 65 deletions

View File

@ -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,

View File

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

View File

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

View File

@ -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<EntityComboBoxItem>((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(

View File

@ -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,

View File

@ -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,

View File

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

View File

@ -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(

View File

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

View File

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

View File

@ -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,

View File

@ -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 = <T extends Constructor<HassBaseEl>>(
superClass: T
@ -210,6 +211,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
value != null ? value : (stateObj.attributes[attribute] ?? ""),
...getState(),
...this._pendingHass,
formatEntityName: (stateObj) => computeStateName(stateObj),
};
this.hassConnected();

View File

@ -52,17 +52,22 @@ export default <T extends Constructor<HassBaseEl>>(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,
});
};
}

View File

@ -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 {

View File

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

View File

@ -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>): HassEntity => ({
@ -26,6 +29,31 @@ export const mockEntity = (
...partial,
});
export const mockEntityEntry = (
partial: Partial<EntityRegistryEntry>
): 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>
): DeviceRegistryEntry => ({

View File

@ -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,