Compare commits

..

6 Commits

Author SHA1 Message Date
Jan Layola
98c4e34a23 Sort installed addons by name in the ha-config-logs component (#27056) 2025-09-15 17:01:54 +02:00
karwosts
3d005c8316 Manual entry mode for media selector (#26753) 2025-09-15 16:48:03 +02:00
Paul Bottein
af31b5add3 Add formatEntityName helper on hass object. (#26057) 2025-09-15 14:18:52 +00:00
Aidan Timson
9d02a1d391 Fix calendar toggle group wrapping (#27049) 2025-09-15 14:12:37 +00:00
Petar Petrov
98e6f32fe8 Improve device section organization in energy Sankey card (#26978) 2025-09-15 15:57:52 +02:00
Aidan Timson
2726c6a849 Fix calendar toggle group button sizes (#27050)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-09-15 13:31:49 +00:00
37 changed files with 674 additions and 539 deletions

View File

@@ -10,9 +10,10 @@ import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
hass: HomeAssistant
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = hass.entities[stateObj.entity_id] as
const entry = entities[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
@@ -20,12 +21,13 @@ export const computeEntityName = (
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry, hass);
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
hass: HomeAssistant
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ||
@@ -33,15 +35,14 @@ export const computeEntityEntryName = (
? String(entry.original_name)
: undefined);
const device = entry.device_id ? hass.devices[entry.device_id] : undefined;
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
const stateObj = hass.states[entry.entity_id] as HassEntity | undefined;
if (stateObj) {
return computeStateName(stateObj);
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}

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,12 @@ 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";
import { ensureArray } from "../array/ensure-array";
export type FormatEntityStateFunc = (
stateObj: HassEntity,
@@ -17,16 +23,28 @@ export type FormatEntityAttributeNameFunc = (
attribute: string
) => string;
export type EntityNameType = "entity" | "device" | "area" | "floor";
export type FormatEntityNameFunc = (
stateObj: HassEntity,
type: EntityNameType | EntityNameType[],
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 +75,45 @@ export const computeFormatFunctions = async (
),
formatEntityAttributeName: (stateObj, attribute) =>
computeAttributeNameDisplay(localize, stateObj, entities, attribute),
formatEntityName: (stateObj, type, separator = " ") => {
const types = ensureArray(type);
const namesList: (string | undefined)[] = [];
const { device, area, floor } = getEntityContext(
stateObj,
entities,
devices,
areas,
floors
);
for (const t of types) {
switch (t) {
case "entity": {
namesList.push(computeEntityName(stateObj, entities, devices));
break;
}
case "device": {
if (device) {
namesList.push(computeDeviceName(device));
}
break;
}
case "area": {
if (area) {
namesList.push(computeAreaName(area));
}
break;
}
case "floor": {
if (floor) {
namesList.push(computeFloorName(floor));
}
break;
}
}
}
return namesList.filter((name) => name !== undefined).join(separator);
},
};
};

View File

@@ -5,12 +5,8 @@ import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
@@ -148,11 +144,9 @@ export class HaEntityPicker extends LitElement {
`;
}
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const isRTL = computeRTL(this.hass);
@@ -311,12 +305,10 @@ export class HaEntityPicker extends LitElement {
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const domainName = domainToName(
this.hass.localize,

View File

@@ -6,11 +6,7 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
@@ -259,12 +255,10 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = hass.formatEntityName(stateObj, "entity");
const deviceName = hass.formatEntityName(stateObj, "device");
const areaName = hass.formatEntityName(stateObj, "area");
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
@@ -337,11 +331,9 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const isRTL = computeRTL(this.hass);

View File

@@ -28,6 +28,9 @@ export class HaButtonToggleGroup extends LitElement {
@property({ reflect: true }) size: "small" | "medium" = "medium";
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
public nowrap = false;
@property() public variant:
| "brand"
| "neutral"
@@ -71,6 +74,10 @@ export class HaButtonToggleGroup extends LitElement {
:host {
--mdc-icon-size: var(--button-toggle-icon-size, 20px);
}
:host([no-wrap]) wa-button-group::part(base) {
flex-wrap: nowrap;
}
`;
}

View File

@@ -330,7 +330,13 @@ export class HaCodeEditor extends ReactiveElement {
private _renderInfo = (completion: Completion): CompletionInfo => {
const key = completion.label;
const context = getEntityContext(this.hass!.states[key], this.hass!);
const context = getEntityContext(
this.hass!.states[key],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");

View File

@@ -246,6 +246,8 @@ export class HaMediaSelector extends LitElement {
entityId: this._getActiveEntityId(),
navigateIds: this.value?.metadata?.navigateIds,
accept: this.selector.media?.accept,
defaultId: this.value?.media_content_id,
defaultType: this.value?.media_content_type,
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
fireEvent(this, "value-changed", {
value: {

View File

@@ -165,6 +165,8 @@ class DialogMediaPlayerBrowse extends LitElement {
.action=${this._action}
.preferredLayout=${this._preferredLayout}
.accept=${this._params.accept}
.defaultId=${this._params.defaultId}
.defaultType=${this._params.defaultType}
@close-dialog=${this.closeDialog}
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}

View File

@@ -0,0 +1,112 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-card";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import type { MediaPlayerItemId } from "./ha-media-player-browse";
export interface ManualMediaPickedEvent {
item: MediaPlayerItemId;
}
@customElement("ha-browse-media-manual")
class BrowseMediaManual extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public item!: MediaPlayerItemId;
private _schema = memoizeOne(
() =>
[
{
name: "media_content_id",
required: true,
selector: {
text: {},
},
},
{
name: "media_content_type",
required: false,
selector: {
text: {},
},
},
] as const
);
protected render() {
return html`
<ha-card>
<div class="card-content">
<ha-form
.hass=${this.hass}
.schema=${this._schema()}
.data=${this.item}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
</div>
<div class="card-actions">
<ha-button @click=${this._mediaPicked}>
${this.hass.localize("ui.common.submit")}
</ha-button>
</div>
</ha-card>
`;
}
private _valueChanged(ev: CustomEvent) {
const value = { ...ev.detail.value };
this.item = value;
}
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.media.${entry.name}`);
private _computeHelper = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.media.${entry.name}_detail`);
private _mediaPicked() {
fireEvent(this, "manual-media-picked", {
item: {
media_content_id: this.item.media_content_id || "",
media_content_type: this.item.media_content_type || "",
},
});
}
static override styles = css`
:host {
margin: 16px auto;
padding: 0 8px;
display: flex;
flex-direction: column;
max-width: 448px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-browse-media-manual": BrowseMediaManual;
}
interface HASSDomEvents {
"manual-media-picked": ManualMediaPickedEvent;
}
}

View File

@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js";
import { mdiArrowUpRight, mdiPlay, mdiPlus, mdiKeyboard } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -28,7 +28,11 @@ import {
BROWSER_PLAYER,
MediaClassBrowserSettings,
} from "../../data/media-player";
import { browseLocalMediaPlayer } from "../../data/media_source";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../resources/styles";
@@ -53,7 +57,9 @@ import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tooltip";
import "./ha-browse-media-tts";
import "./ha-browse-media-manual";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
declare global {
interface HASSDomEvents {
@@ -74,6 +80,18 @@ export interface MediaPlayerItemId {
media_content_type: string | undefined;
}
const MANUAL_ITEM: MediaPlayerItem = {
can_expand: true,
can_play: false,
can_search: false,
children_media_class: "",
media_class: "app",
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
iconPath: mdiKeyboard,
title: "Manual entry",
};
@customElement("ha-media-player-browse")
export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -91,6 +109,10 @@ export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public accept?: string[];
@property({ attribute: false }) public defaultId?: string;
@property({ attribute: false }) public defaultType?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -216,56 +238,69 @@ export class HaMediaPlayerBrowse extends LitElement {
}
}
// Fetch current
if (!currentProm) {
currentProm = this._fetchData(
this.entityId,
currentId.media_content_id,
currentId.media_content_type
if (
currentId.media_content_id &&
isManualMediaSourceContentId(currentId.media_content_id)
) {
this._currentItem = MANUAL_ITEM;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: this._currentItem,
});
} else {
if (!currentProm) {
currentProm = this._fetchData(
this.entityId,
currentId.media_content_id,
currentId.media_content_type
);
}
currentProm.then(
(item) => {
this._currentItem = item;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: item,
});
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
// able to resolve the new path. If that results in an error, browse the root.
const isNewEntityWithSamePath =
oldNavigateIds &&
changedProps.has("entityId") &&
navigateIds.length === oldNavigateIds.length &&
oldNavigateIds.every(
(oldItem, idx) =>
navigateIds[idx].media_content_id ===
oldItem.media_content_id &&
navigateIds[idx].media_content_type ===
oldItem.media_content_type
);
if (isNewEntityWithSamePath) {
fireEvent(this, "media-browsed", {
ids: [
{ media_content_id: undefined, media_content_type: undefined },
],
replace: true,
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
message: this.hass.localize(
`ui.components.media-browser.media_player_unavailable`
),
code: "entity_not_found",
});
} else {
this._setError(err);
}
}
);
}
currentProm.then(
(item) => {
this._currentItem = item;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: item,
});
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
// able to resolve the new path. If that results in an error, browse the root.
const isNewEntityWithSamePath =
oldNavigateIds &&
changedProps.has("entityId") &&
navigateIds.length === oldNavigateIds.length &&
oldNavigateIds.every(
(oldItem, idx) =>
navigateIds[idx].media_content_id === oldItem.media_content_id &&
navigateIds[idx].media_content_type === oldItem.media_content_type
);
if (isNewEntityWithSamePath) {
fireEvent(this, "media-browsed", {
ids: [
{ media_content_id: undefined, media_content_type: undefined },
],
replace: true,
});
} else if (
err.code === "entity_not_found" &&
this.entityId &&
isUnavailableState(this.hass.states[this.entityId]?.state)
) {
this._setError({
message: this.hass.localize(
`ui.components.media-browser.media_player_unavailable`
),
code: "entity_not_found",
});
} else {
this._setError(err);
}
}
);
// Fetch parent
if (!parentProm && parentId !== undefined) {
parentProm = this._fetchData(
@@ -479,111 +514,120 @@ export class HaMediaPlayerBrowse extends LitElement {
</ha-alert>
</div>
`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
: isManualMediaSourceContentId(currentItem.media_content_id)
? html`<ha-browse-media-manual
.item=${{
media_content_id: this.defaultId || "",
media_content_type: this.defaultType || "",
}}
.hass=${this.hass}
@manual-media-picked=${this._manualPicked}
></ha-browse-media-manual>`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
: !children.length && !currentItem.not_shown
? html`
<lit-virtualizer
scroller
.layout=${grid({
itemSize: {
width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
</div>
`
: ""}
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
`
: html`
<ha-list>
: this.preferredLayout === "grid" ||
(this.preferredLayout === "auto" &&
childrenMediaClass.layout === "grid")
? html`
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
.layout=${grid({
itemSize: {
width: "175px",
height:
childrenMediaClass.thumbnail_ratio ===
"portrait"
? "312px"
: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.renderItem=${this._renderListItem}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio ===
"portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
</div>
</div>
`
: ""}
</ha-list>
`
`
: html`
<ha-list>
<lit-virtualizer
scroller
.items=${children}
style=${styleMap({
height: `${children.length * 72 + 26}px`,
})}
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
? html`
<ha-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
>
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</ha-list-item>
`
: ""}
</ha-list>
`
}
</div>
</div>
@@ -617,8 +661,9 @@ export class HaMediaPlayerBrowse extends LitElement {
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
class=${child.iconPath ? "icon" : "folder"}
.path=${child.iconPath ||
MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class || child.media_class
: child.media_class
@@ -768,6 +813,14 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
private _manualPicked(ev: CustomEvent<ManualMediaPickedEvent>) {
ev.stopPropagation();
fireEvent(this, "media-picked", {
item: ev.detail.item as MediaPlayerItem,
navigateIds: this.navigateIds,
});
}
private _childClicked = async (ev: MouseEvent): Promise<void> => {
const target = ev.currentTarget as any;
const item: MediaPlayerItem = target.item;
@@ -791,9 +844,23 @@ export class HaMediaPlayerBrowse extends LitElement {
mediaContentId?: string,
mediaContentType?: string
): Promise<MediaPlayerItem> {
return entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
: browseLocalMediaPlayer(this.hass, mediaContentId);
const prom =
entityId && entityId !== BROWSER_PLAYER
? browseMediaPlayer(
this.hass,
entityId,
mediaContentId,
mediaContentType
)
: browseLocalMediaPlayer(this.hass, mediaContentId);
return prom.then((item) => {
if (!mediaContentId && this.action === "pick") {
item.children = item.children || [];
item.children.push(MANUAL_ITEM);
}
return item;
});
}
private _measureCard(): void {
@@ -1141,6 +1208,11 @@ export class HaMediaPlayerBrowse extends LitElement {
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
}
.child .icon {
color: #00a9f7; /* Match the png color from brands repo */
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
}
.child .play {
position: absolute;
transition: color 0.5s;

View File

@@ -12,6 +12,8 @@ export interface MediaPlayerBrowseDialogParams {
navigateIds?: MediaPlayerItemId[];
minimumNavigateLevel?: number;
accept?: string[];
defaultId?: string;
defaultType?: string;
}
export const showMediaBrowserDialog = (

View File

@@ -199,10 +199,12 @@ export interface MediaPlayerItem {
media_content_type: string;
media_content_id: string;
media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"];
children_media_class?: string;
children_media_class?: string | null;
can_play: boolean;
can_expand: boolean;
can_search: boolean;
thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[];
not_shown?: number;
}

View File

@@ -24,6 +24,11 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export const MANUAL_MEDIA_SOURCE_PREFIX = "__MANUAL_ENTRY__";
export const isManualMediaSourceContentId = (mediaContentId: string) =>
mediaContentId.startsWith(MANUAL_MEDIA_SOURCE_PREFIX);
export const isMediaSourceContentId = (mediaId: string) =>
mediaId.startsWith("media-source://");

View File

@@ -1,8 +1,5 @@
import { ensureArray } from "../../common/array/ensure-array";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { Selector } from "../selector";
@@ -79,10 +76,8 @@ export const formatSelectorValue = (
if (!stateObj) {
return entityId;
}
const { device } = getEntityContext(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const entityName = computeEntityName(stateObj, hass);
return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
const name = hass.formatEntityName(stateObj, ["device", "entity"], " ");
return name || entityId;
})
.join(", ");
}

View File

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

View File

@@ -21,15 +21,10 @@ import { componentsWithService } from "../../common/config/components_with_servi
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
@@ -635,12 +630,10 @@ export class QuickBar extends LitElement {
.map((entityId) => {
const stateObj = this.hass.states[entityId];
const { area, device } = getEntityContext(stateObj, this.hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]

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

@@ -161,6 +161,8 @@ export class HAFullCalendar extends LitElement {
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="small"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
`
@@ -195,6 +197,8 @@ export class HAFullCalendar extends LitElement {
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="small"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
</div>

View File

@@ -1,7 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-resizable-bottom-sheet";
import type { HaResizableBottomSheet } from "../../../components/ha-resizable-bottom-sheet";
import {
@@ -39,18 +37,9 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false;
@state() private _resizing = false;
@query("ha-resizable-bottom-sheet")
private _bottomSheetElement?: HaResizableBottomSheet;
private _resizeStartX = 0;
disconnectedCallback() {
super.disconnectedCallback();
this._unregisterResizeHandlers();
}
private _renderContent() {
// get config type
const type = this._getType();
@@ -165,16 +154,7 @@ export default class HaAutomationSidebar extends LitElement {
`;
}
return html`
<div
class="handle ${this._resizing ? "resizing" : ""}"
@mousedown=${this._handleMouseDown}
@touchstart=${this._handleMouseDown}
>
${this._resizing ? html`<div class="indicator"></div>` : nothing}
</div>
${this._renderContent()}
`;
return this._renderContent();
}
private _getType() {
@@ -227,67 +207,6 @@ export default class HaAutomationSidebar extends LitElement {
(this.config as ActionSidebarConfig)?.toggleYamlMode();
};
private _handleMouseDown = (ev: MouseEvent | TouchEvent) => {
// Prevent the browser from interpreting this as a scroll/PTR gesture.
ev.preventDefault();
this._startResizing(
(ev as TouchEvent).touches?.length
? (ev as TouchEvent).touches[0].clientX
: (ev as MouseEvent).clientX
);
};
private _startResizing(clientX: number) {
// register event listeners for drag handling
document.addEventListener("mousemove", this._handleMouseMove);
document.addEventListener("mouseup", this._endResizing);
document.addEventListener("touchmove", this._handleMouseMove, {
passive: false,
});
document.addEventListener("touchend", this._endResizing);
document.addEventListener("touchcancel", this._endResizing);
this._resizing = true;
this._resizeStartX = clientX;
}
private _handleMouseMove = (ev: MouseEvent | TouchEvent) => {
this._updateSize(
(ev as TouchEvent).touches?.length
? (ev as TouchEvent).touches[0].clientX
: (ev as MouseEvent).clientX
);
};
private _updateSize(clientX: number) {
let delta = this._resizeStartX - clientX;
if (computeRTL(this.hass)) {
delta = -delta;
}
requestAnimationFrame(() => {
fireEvent(this, "sidebar-resized", {
deltaInPx: delta,
});
});
}
private _endResizing = () => {
this._unregisterResizeHandlers();
this._resizing = false;
document.body.style.removeProperty("cursor");
fireEvent(this, "sidebar-resizing-stopped");
};
private _unregisterResizeHandlers() {
document.removeEventListener("mousemove", this._handleMouseMove);
document.removeEventListener("mouseup", this._endResizing);
document.removeEventListener("touchmove", this._handleMouseMove);
document.removeEventListener("touchend", this._endResizing);
document.removeEventListener("touchcancel", this._endResizing);
}
static styles = css`
:host {
z-index: 6;
@@ -308,28 +227,6 @@ export default class HaAutomationSidebar extends LitElement {
max-height: 100%;
}
}
.handle {
position: absolute;
margin-inline-start: -11px;
height: calc(100% - (2 * var(--ha-card-border-radius)));
width: 24px;
z-index: 7;
cursor: ew-resize;
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-card-border-radius) 0;
}
.handle.resizing {
cursor: grabbing;
}
.handle .indicator {
background-color: var(--primary-color);
height: 100%;
width: 4px;
border-radius: var(--ha-border-radius-pill);
}
`;
}
@@ -343,9 +240,5 @@ declare global {
"yaml-changed": {
value: unknown;
};
"sidebar-resized": {
deltaInPx: number;
};
"sidebar-resizing-stopped": undefined;
}
}

View File

@@ -22,7 +22,6 @@ import {
union,
} from "superstruct";
import { ensureArray } from "../../../common/array/ensure-array";
import { storage } from "../../../common/decorators/storage";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
@@ -102,13 +101,6 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _sidebarKey?: string;
@storage({
key: "automation-sidebar-width-percentage",
state: false,
subscribe: false,
})
private _sidebarWidth = 40;
@query("ha-automation-sidebar") private _sidebarElement?: HaAutomationSidebar;
@queryAll("ha-automation-action, ha-automation-condition")
@@ -118,17 +110,13 @@ export class HaManualAutomationEditor extends LitElement {
private _previousConfig?: ManualAutomationConfig;
private _prevSidebarWidthPx?: number;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this._handlePaste);
window.addEventListener("resize", this._resizeSidebarWidth);
}
public disconnectedCallback() {
window.removeEventListener("paste", this._handlePaste);
window.removeEventListener("resize", this._resizeSidebarWidth);
super.disconnectedCallback();
}
@@ -315,11 +303,9 @@ export class HaManualAutomationEditor extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.config=${this._sidebarConfig}
@value-changed=${this._sidebarConfigChanged}
.disabled=${this.disabled}
.sidebarKey=${this._sidebarKey}
@value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar}
></ha-automation-sidebar>
</div>
</div>
@@ -328,12 +314,6 @@ export class HaManualAutomationEditor extends LitElement {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._widthPxToVw(this._widthPercentageToPx(this._sidebarWidth))}vw`
);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
@@ -662,47 +642,6 @@ export class HaManualAutomationEditor extends LitElement {
}
}
private _resizeSidebarWidth = () => {
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._widthPxToVw(this._widthPercentageToPx(this._sidebarWidth))}vw`
);
};
private _widthPxToVw(px: number) {
return (px / window.innerWidth) * 100;
}
private _widthPercentageToPx(percentage: number) {
return (percentage / 100) * this.clientWidth;
}
private _resizeSidebar(ev) {
ev.stopPropagation();
const delta = ev.detail.deltaInPx as number;
// set initial resize width to add / reduce delta from it
if (!this._prevSidebarWidthPx) {
this._prevSidebarWidthPx = (this._sidebarWidth / 100) * this.clientWidth;
}
const widthPx = delta + this._prevSidebarWidthPx;
if (widthPx > this.clientWidth * 0.7 || widthPx < this.clientWidth * 0.3) {
return;
}
const widthVw = this._widthPxToVw(widthPx);
this.style.setProperty("--sidebar-dynamic-width", `${widthVw}vw`);
this._sidebarWidth = (widthPx / this.clientWidth) * 100;
}
private _stopResizeSidebar(ev) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,

View File

@@ -109,7 +109,7 @@ export const manualEditorStyles = css`
}
.has-sidebar {
--sidebar-width: max(var(--sidebar-dynamic-width), 375px);
--sidebar-width: min(35vw, 500px);
--sidebar-gap: 16px;
}

View File

@@ -172,7 +172,9 @@ export class HaDeviceEntitiesCard extends LitElement {
element.hass = this.hass;
const stateObj = this.hass.states[entry.entity_id];
let name = computeEntityName(stateObj, this.hass) || this.deviceName;
let name =
computeEntityName(stateObj, this.hass.entities, this.hass.devices) ||
this.deviceName;
if (entry.hidden_by) {
name += ` (${this.hass.localize(

View File

@@ -1238,7 +1238,7 @@ export class HaConfigDevicePage extends LitElement {
private _computeEntityName(entity: EntityRegistryEntry) {
const device = this.hass.devices[this.deviceId];
return (
computeEntityEntryName(entity, this.hass) ||
computeEntityEntryName(entity, this.hass.devices) ||
computeDeviceNameDisplay(device, this.hass)
);
}

View File

@@ -682,7 +682,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
this.hass
this.hass.devices,
entity
);
const deviceName = device ? computeDeviceName(device) : undefined;

View File

@@ -18,6 +18,7 @@ import type { HomeAssistant, Route } from "../../../types";
import "./error-log-card";
import "./system-log-card";
import type { SystemLogCard } from "./system-log-card";
import { stringCompare } from "../../../common/string/compare";
const logProviders: LogProvider[] = [
{
@@ -208,15 +209,17 @@ export class HaConfigLogs extends LitElement {
private async _getInstalledAddons() {
try {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._logProviders = [
...this._logProviders,
...addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
key: addon.slug,
name: addon.name,
})),
];
const sortedAddons = addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
key: addon.slug,
name: addon.name,
}))
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
this._logProviders = [...this._logProviders, ...sortedAddons];
} catch (_err) {
// Ignore, nothing the user can do anyway
}

View File

@@ -21,7 +21,6 @@ import {
string,
} from "superstruct";
import { ensureArray } from "../../../common/array/ensure-array";
import { storage } from "../../../common/decorators/storage";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
@@ -85,13 +84,6 @@ export class HaManualScriptEditor extends LitElement {
@state() private _sidebarKey?: string;
@storage({
key: "automation-sidebar-width-percentage",
state: false,
subscribe: false,
})
private _sidebarWidth = 40;
@query("ha-script-fields")
private _scriptFields?: HaScriptFields;
@@ -106,8 +98,6 @@ export class HaManualScriptEditor extends LitElement {
private _openFields = false;
private _prevSidebarWidthPx?: number;
public addFields() {
this._openFields = true;
fireEvent(this, "value-changed", {
@@ -262,10 +252,8 @@ export class HaManualScriptEditor extends LitElement {
.isWide=${this.isWide}
.hass=${this.hass}
.config=${this._sidebarConfig}
.disabled=${this.disabled}
@value-changed=${this._sidebarConfigChanged}
@sidebar-resized=${this._resizeSidebar}
@sidebar-resizing-stopped=${this._stopResizeSidebar}
.disabled=${this.disabled}
></ha-automation-sidebar>
</div>
</div>
@@ -274,12 +262,6 @@ export class HaManualScriptEditor extends LitElement {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._widthPxToVw(this._widthPercentageToPx(this._sidebarWidth))}vw`
);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
@@ -314,12 +296,10 @@ export class HaManualScriptEditor extends LitElement {
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this._handlePaste);
window.addEventListener("resize", this._resizeSidebarWidth);
}
public disconnectedCallback() {
window.removeEventListener("paste", this._handlePaste);
window.removeEventListener("resize", this._resizeSidebarWidth);
super.disconnectedCallback();
}
@@ -577,47 +557,6 @@ export class HaManualScriptEditor extends LitElement {
}
}
private _resizeSidebarWidth = () => {
this.style.setProperty(
"--sidebar-dynamic-width",
`${this._widthPxToVw(this._widthPercentageToPx(this._sidebarWidth))}vw`
);
};
private _widthPxToVw(px: number) {
return (px / window.innerWidth) * 100;
}
private _widthPercentageToPx(percentage: number) {
return (percentage / 100) * this.clientWidth;
}
private _resizeSidebar(ev) {
ev.stopPropagation();
const delta = ev.detail.deltaInPx as number;
// set initial resize width to add / reduce delta from it
if (!this._prevSidebarWidthPx) {
this._prevSidebarWidthPx = (this._sidebarWidth / 100) * this.clientWidth;
}
const widthPx = delta + this._prevSidebarWidthPx;
if (widthPx > this.clientWidth * 0.7 || widthPx < this.clientWidth * 0.3) {
return;
}
const widthVw = this._widthPxToVw(widthPx);
this.style.setProperty("--sidebar-dynamic-width", `${widthVw}vw`);
this._sidebarWidth = (widthPx / this.clientWidth) * 100;
}
private _stopResizeSidebar(ev) {
ev.stopPropagation();
this._prevSidebarWidthPx = undefined;
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,

View File

@@ -419,7 +419,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;
@@ -457,6 +463,9 @@ class HuiEnergySankeyCard
return { areas, floors };
}
/**
* Organizes device nodes into hierarchical sections based on parent-child relationships.
*/
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
@@ -465,20 +474,34 @@ class HuiEnergySankeyCard
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
if (parentIds.includes(deviceNode.id)) {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
// Top-level parents (have children but no parents themselves)
parentSection.push(deviceNode);
remainingLinks[deviceNode.id] = parentLinks[deviceNode.id];
} else {
childSection.push(deviceNode);
}
});
// Filter out links where parent is already in current parent section
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
// Recursively process child section with remaining links
return [
...this._getDeviceSections(remainingLinks, parentSection),
childSection,
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
// Base case: no more parent-child relationships to process
return [deviceNodes];
}

View File

@@ -5,11 +5,7 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/data-table/ha-data-table";
@@ -66,11 +62,9 @@ export class HuiEntityPickerTable extends LitElement {
(entity) => {
const stateObj = this.hass.states[entity];
const { area, device } = getEntityContext(stateObj, this.hass);
const entityName = computeEntityName(stateObj, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const entityName = this.hass.formatEntityName(stateObj, "entity");
const deviceName = this.hass.formatEntityName(stateObj, "device");
const areaName = this.hass.formatEntityName(stateObj, "area");
const name = [deviceName, entityName].filter(Boolean).join(" ");
const domain = computeDomain(entity);

View File

@@ -1,7 +1,6 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
@@ -177,7 +176,13 @@ export class HomeAreaViewStrategy extends ReactiveElement {
for (const entityId of otherEntities) {
const stateObj = hass.states[entityId];
if (!stateObj) continue;
const { device } = getEntityContext(stateObj, hass);
const { device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
if (!device) {
unassignedEntities.push(entityId);
continue;
@@ -268,8 +273,8 @@ export class HomeAreaViewStrategy extends ReactiveElement {
return {
...computeTileCard(e),
name:
computeEntityName(stateObj, hass) ||
(device ? computeDeviceName(device) : ""),
hass.formatEntityName(stateObj, "entity") ||
hass.formatEntityName(stateObj, "device"),
};
}),
],

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

@@ -8,7 +8,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) => {
class StateDisplayMixin extends superClass {
protected hassConnected() {
super.hassConnected();
this._updateStateDisplay();
this._updateFormatFunctions();
}
protected willUpdate(changedProps) {
@@ -25,13 +25,16 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) => {
this.hass.localize !== oldHass.localize ||
this.hass.locale !== oldHass.locale ||
this.hass.config !== oldHass.config ||
this.hass.entities !== oldHass.entities)
this.hass.entities !== oldHass.entities ||
this.hass.devices !== oldHass.devices ||
this.hass.areas !== oldHass.areas ||
this.hass.floors !== oldHass.floors)
) {
this._updateStateDisplay();
this._updateFormatFunctions();
}
}
private _updateStateDisplay = async () => {
private _updateFormatFunctions = async () => {
if (!this.hass || !this.hass.config) {
return;
}
@@ -52,17 +55,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 { EntityNameType } 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,
type: EntityNameType | EntityNameType[],
separator?: string
): string;
}
export interface Route {

View File

@@ -6,49 +6,53 @@ 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: {},
states: {
"light.kitchen": stateObj,
},
};
expect(computeEntityName(stateObj as any, hass as any)).toBe(
} 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": {
entity_id: "light.kitchen",
name: "Ceiling Light",
labels: [],
},
},
devices: {},
states: {
"light.kitchen": stateObj,
},
};
expect(computeEntityName(stateObj as any, hass as any)).toBe(
} as unknown as HomeAssistant;
expect(computeEntityName(stateObj, hass.entities, hass.devices)).toBe(
"Ceiling Light"
);
});
@@ -56,11 +60,12 @@ describe("computeEntityName", () => {
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 as any)).toBe(
"Ceiling Light"
);
expect(computeEntityEntryName(entry, hass.devices)).toBe("Ceiling Light");
});
it("returns device-stripped name if device present", () => {
@@ -70,16 +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 as any)).toBe("Light");
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBe("Light");
vi.restoreAllMocks();
});
@@ -87,16 +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 as any)).toBeUndefined();
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined();
vi.restoreAllMocks();
});
@@ -104,35 +109,36 @@ 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: {},
states: {
"light.kitchen": { entity_id: "light.kitchen" },
},
};
expect(computeEntityEntryName(entry as any, hass as any)).toBe(
} 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 as any)).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 as any)).toBeUndefined();
} as unknown as HomeAssistant;
expect(computeEntityEntryName(entry, hass.devices)).toBeUndefined();
});
it("handles entities with numeric original_name (real bug from issue #25363)", () => {

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,