Compare commits

..
Author SHA1 Message Date
Aidan Timson 6f3436c61f Show config entry load errors with retry on devices page 2026-09-25 14:52:18 +01:00
Aidan Timson 5ea505ec41 Handle devices page load failures 2026-09-25 14:52:18 +01:00
Aidan Timson 7302b7e709 Show loading labels for pending integration groups 2026-09-25 14:52:18 +01:00
Aidan Timson 716c4e464a Preserve table cell semantics while loading 2026-09-25 14:52:18 +01:00
Aidan Timson 78a64d4ab2 Leave Bluetooth advertisement loading for the follow-up 2026-09-25 14:52:18 +01:00
Aidan Timson ff5df1bfc8 Show loading state in more config data tables 2026-09-25 14:52:18 +01:00
Aidan Timson 03c2ed1f46 Simplify devices metadata placeholders 2026-09-25 14:52:18 +01:00
Aidan Timson e964058614 Inline data table skeleton rows 2026-09-25 14:52:18 +01:00
Aidan Timson da7e8e7a24 Announce data table loading to screen readers 2026-09-25 14:52:18 +01:00
Aidan Timson 66d985fb27 Use skeleton placeholders for devices metadata 2026-09-25 14:52:18 +01:00
Aidan Timson 98395405f3 Show skeleton rows while data tables load 2026-09-25 14:52:18 +01:00
Aidan TimsonandCopilot Autofix powered by AI 465f4a8227 Add shared skeleton components (#54325)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-25 14:52:14 +01:00
Paulus SchoutsenandClaude 87763fb59d Load MDI icon chunks from the static path (#54347)
ha-icon fetched the MDI chunks from a fixed /static/mdi/ path. Use
__STATIC_PATH__ instead, like the translation and locale data fetches.
The value is /static/ in all current builds, so nothing changes today.
Builds that serve static files from another location, such as an embed
on another site, can now load icons.


Claude-Session: https://claude.ai/code/session_01HqnRKRicxMCBK3EuxYXimM

Co-authored-by: Claude <[email protected]>
2026-09-25 07:08:25 +02:00
Aidan TimsonandCopilot Autofix powered by AI f4bb4e242d Improve data table loading (#54322)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-09-24 18:24:20 +01:00
Aidan Timson eb91411cfe Fix bottom padding in device and area add to dialogs (#54337) 2026-09-24 18:05:37 +03:00
Aidan Timson 1767632811 Fix unstyled border router rows in Thread panel (#54340)
Import list item components in Thread panel
2026-09-24 18:04:19 +03:00
Aidan Timson 7c5dd1841b Stop agents asking for tests on visual components (#54343) 2026-09-24 15:58:22 +01:00
Petar PetrovandMindFreeze d8effd44b2 Wrap refresh token supporting text on narrow screens (#54338)
Co-authored-by: MindFreeze <[email protected]>
2026-09-24 11:38:09 +01:00
35 changed files with 667 additions and 332 deletions
+2 -1
View File
@@ -118,7 +118,8 @@ For user-facing changes, establish the existing design context as part of fronte
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
- Keep style-only comments secondary unless they affect maintainability or user experience.
- Prefer small, direct fixes over large refactors during review follow-up.
+4 -4
View File
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
## When To Add Tests
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
- Do not try to cover every scenario, especially for behaviour that changes often.
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
## Dev Servers
+114 -33
View File
@@ -32,8 +32,11 @@ import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import "../animation/ha-fade-in";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../skeleton/ha-skeleton-icon";
import "../skeleton/ha-skeleton-text";
import "../ha-svg-icon";
import "../input/ha-input-search";
import { filterData, sortData } from "./sort-filter";
@@ -111,6 +114,25 @@ export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
// Default row height, used to fill the viewport with skeleton rows.
const ROW_HEIGHT = 52;
const cellClasses = (column: DataTableColumnData) => ({
"mdc-data-table__cell--flex": column.type === "flex",
"mdc-data-table__cell--numeric": column.type === "numeric",
"mdc-data-table__cell--icon": column.type === "icon",
"mdc-data-table__cell--icon-button": column.type === "icon-button",
"mdc-data-table__cell--overflow-menu": column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
forceLTR: Boolean(column.forceLTR),
});
const cellStyles = (column: DataTableColumnData) => ({
minWidth: column.minWidth,
maxWidth: column.maxWidth,
flex: column.flex || 1,
});
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@state()
@@ -123,6 +145,8 @@ export class HaDataTable extends LitElement {
@property({ type: Array }) public data: DataTableRowData[] = [];
@property({ type: Boolean }) public loading = false;
@property({ type: Boolean }) public selectable = false;
@property({ type: Boolean }) public clickable = false;
@@ -165,6 +189,9 @@ export class HaDataTable extends LitElement {
@state() private _filteredData?: DataTableRowData[];
// Row count of the data that _filteredData was computed from
@state() private _filteredDataSourceLength = 0;
@state() private _headerHeight = 0;
@query("slot[name='header']") private _header!: HTMLSlotElement;
@@ -515,18 +542,79 @@ export class HaDataTable extends LitElement {
</div>
${
!this._filteredData?.length
? html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div
class="mdc-data-table__cell grows center"
role="cell"
>
${
!this._filteredData
? this._i18n?.localize?.("ui.common.loading") ||
? this.loading ||
!this._filteredData ||
(this.data.length && !this._filteredDataSourceLength)
? html`
<div class="mdc-data-table__content" role="row">
<ha-fade-in .duration=${300} easing="ease-in">
<div role="cell">
<div
role="progressbar"
aria-label=${
this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
}
>
${Array.from(
{
length: this.autoHeight
? 1
: Math.ceil(window.innerHeight / ROW_HEIGHT),
},
() => html`
<div class="mdc-data-table__row">
${
this.selectable
? html`<div
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
></div>`
: nothing
}
${Object.entries(columns).map(
([key, column]) =>
(this.narrow &&
!column.main &&
!column.showNarrow) ||
!this._isColumnVisible(key, column)
? nothing
: html`
<div
class="mdc-data-table__cell ${classMap(
cellClasses(column)
)}"
style=${styleMap(cellStyles(column))}
>
${
column.type === "icon"
? html`<ha-skeleton-icon></ha-skeleton-icon>`
: column.type ===
"icon-button" ||
column.type ===
"overflow-menu"
? nothing
: html`<ha-skeleton-text></ha-skeleton-text>`
}
</div>
`
)}
</div>
`
)}
</div>
</div>
</ha-fade-in>
</div>
`
: html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div
class="mdc-data-table__cell grows center"
role="cell"
>
${
this.data.length
? this._i18n?.localize?.(
"ui.components.data-table.no_match_filter"
) || "No rows matching current filters"
@@ -535,11 +623,11 @@ export class HaDataTable extends LitElement {
"ui.components.data-table.no-data"
) ||
"No data"
}
}
</div>
</div>
</div>
</div>
`
`
: html`
<lit-virtualizer
scroller
@@ -633,22 +721,8 @@ export class HaDataTable extends LitElement {
@mouseover=${this._setTitle}
@focus=${this._setTitle}
role=${column.main ? "rowheader" : "cell"}
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--flex": column.type === "flex",
"mdc-data-table__cell--numeric": column.type === "numeric",
"mdc-data-table__cell--icon": column.type === "icon",
"mdc-data-table__cell--icon-button":
column.type === "icon-button",
"mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
forceLTR: Boolean(column.forceLTR),
})}"
style=${styleMap({
minWidth: column.minWidth,
maxWidth: column.maxWidth,
flex: column.flex || 1,
})}
class="mdc-data-table__cell ${classMap(cellClasses(column))}"
style=${styleMap(cellStyles(column))}
>
${
column.template
@@ -721,10 +795,11 @@ export class HaDataTable extends LitElement {
!this._lastUpdate ||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
let filteredData = this.data;
const sourceData = this.data;
let filteredData = sourceData;
if (this._filter) {
filteredData = await this._memFilterData(
this.data,
sourceData,
this._sortColumns,
this._filter.trim()
);
@@ -760,8 +835,13 @@ export class HaDataTable extends LitElement {
return;
}
if (startTime < this._lastUpdate) {
return;
}
this._lastUpdate = startTime;
this._filteredData = data;
this._filteredDataSourceLength = sourceData.length;
}
private _groupData = memoizeOne(
@@ -1317,7 +1397,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell--icon:first-child ha-svg-icon,
.mdc-data-table__cell--icon:first-child ha-state-icon,
.mdc-data-table__cell--icon:first-child ha-domain-icon,
.mdc-data-table__cell--icon:first-child ha-service-icon {
.mdc-data-table__cell--icon:first-child ha-service-icon,
.mdc-data-table__cell--icon:first-child ha-skeleton-icon {
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
+2 -2
View File
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
return;
}
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
response.json()
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
(response) => response.json()
);
chunks[chunk] = iconPromise;
this._setPath(iconPromise, iconName, requestedIcon);
@@ -0,0 +1,34 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { HaSkeleton } from "./ha-skeleton";
/**
* Placeholder for an icon. Follows `ha-svg-icon`'s `--mdc-icon-size`
* (24px by default), including inherited size overrides.
*/
@customElement("ha-skeleton-icon")
export class HaSkeletonIcon extends HaSkeleton {
static get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
vertical-align: middle;
flex: none;
width: var(--mdc-icon-size, 24px);
height: var(--mdc-icon-size, 24px);
min-height: 0;
--ha-skeleton-border-radius: var(--ha-border-radius-circle);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-icon": HaSkeletonIcon;
}
}
@@ -0,0 +1,36 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { HaSkeleton } from "./ha-skeleton";
/**
* Placeholder for a line of text, matching the tile secondary text skeleton.
* Its height follows the surrounding font size. Set `width` in CSS to fit the
* expected text.
*
* @cssprop --ha-skeleton-text-width - The width of the placeholder. Defaults to `140px`.
*/
@customElement("ha-skeleton-text")
export class HaSkeletonText extends HaSkeleton {
static get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
vertical-align: middle;
width: var(--ha-skeleton-text-width, 140px);
max-width: 100%;
height: 1em;
min-height: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton-text": HaSkeletonText;
}
}
+56
View File
@@ -0,0 +1,56 @@
import WaSkeleton from "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
/**
* Placeholder shown while content loads. Sized by its container unless a
* more specific variant such as `ha-skeleton-text` or `ha-skeleton-icon` is used.
*
* @cssprop --ha-skeleton-color - The fill color. Defaults to `var(--ha-color-fill-neutral-normal-resting)`.
* @cssprop --ha-skeleton-sheen-color - The sheen color when `effect="sheen"`. Defaults to `var(--ha-color-fill-neutral-loud-resting)`.
* @cssprop --ha-skeleton-border-radius - The corner radius. Defaults to `var(--ha-border-radius-sm)`.
*/
@customElement("ha-skeleton")
export class HaSkeleton extends WaSkeleton {
override effect: WaSkeleton["effect"] = "pulse";
static get styles(): CSSResultGroup {
return [
WaSkeleton.styles,
css`
:host {
--color: var(
--ha-skeleton-color,
var(--ha-color-fill-neutral-normal-resting)
);
--sheen-color: var(
--ha-skeleton-sheen-color,
var(--ha-color-fill-neutral-loud-resting)
);
--wa-border-radius-pill: var(
--ha-skeleton-border-radius,
var(--ha-border-radius-sm)
);
}
@media (forced-colors: active) {
:host {
--color: GrayText;
}
}
@media (prefers-reduced-motion: reduce) {
:host([effect="pulse"]) .indicator,
:host([effect="sheen"]) .indicator {
animation: none;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-skeleton": HaSkeleton;
}
}
+2 -10
View File
@@ -1,4 +1,4 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import "../skeleton/ha-skeleton-text";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -49,7 +49,7 @@ export class HaTileInfo extends LitElement {
${
this.secondaryLoading
? html`<div class="secondary">
<wa-skeleton class="placeholder" effect="pulse"></wa-skeleton>
<ha-skeleton-text></ha-skeleton-text>
</div>`
: html`<slot name="secondary" class="secondary">
<span>${this.secondary}</span>
@@ -150,14 +150,6 @@ export class HaTileInfo extends LitElement {
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
}
.placeholder {
width: 140px;
max-width: 100%;
height: var(--tile-info-secondary-font-size);
--wa-border-radius-pill: var(--ha-border-radius-sm);
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}
@@ -1,4 +1,5 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import "../../../../components/skeleton/ha-skeleton";
import "../../../../components/skeleton/ha-skeleton-text";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
@@ -112,14 +113,14 @@ export class HaMoreInfoUpdateBackup extends LitElement {
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
><ha-skeleton-text></ha-skeleton-text
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
><ha-skeleton></ha-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
@@ -307,6 +308,7 @@ export class HaMoreInfoUpdateBackup extends LitElement {
width: 48px;
height: 24px;
display: block;
--ha-skeleton-border-radius: var(--ha-border-radius-pill);
}
`;
}
+8 -1
View File
@@ -154,6 +154,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
*/
@property({ type: Boolean }) public empty = false;
/**
* Show a loading state instead of the empty message until data is ready.
* @type {Boolean}
*/
@property({ type: Boolean }) public loading = false;
@property({ attribute: false }) public route!: Route;
/**
@@ -492,7 +498,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: nothing
}
${
this.empty
this.empty && !this.loading
? html`<div class="center">
<slot name="empty">${this.noDataText}</slot>
</div>`
@@ -514,6 +520,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}
.loading=${this.loading}
.noDataText=${this.noDataText}
.filter=${this.filter}
.selectable=${this._selectMode}
+8 -14
View File
@@ -1,35 +1,29 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import type { TemplateResult } from "lit";
import { css, html } from "lit";
import "../components/skeleton/ha-skeleton-text";
/** Placeholder shown in place of a text while onboarding translations load. */
export const renderSkeleton = (variant: string): TemplateResult =>
html`<wa-skeleton effect="sheen" class="skeleton ${variant}"></wa-skeleton>`;
html`<ha-skeleton-text class="skeleton ${variant}"></ha-skeleton-text>`;
export const skeletonStyles = css`
.skeleton {
height: 1em;
vertical-align: middle;
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
}
.skeleton.title {
width: 200px;
--ha-skeleton-text-width: 200px;
}
.skeleton.line {
width: 100%;
--ha-skeleton-text-width: 100%;
}
.skeleton.headline {
width: 40%;
--ha-skeleton-text-width: 40%;
margin-bottom: var(--ha-space-1);
}
.skeleton.chip {
width: 80px;
--ha-skeleton-text-width: 80px;
}
.skeleton.button {
width: 120px;
--ha-skeleton-text-width: 120px;
}
.skeleton.label {
width: 80px;
--ha-skeleton-text-width: 80px;
}
`;
@@ -37,6 +37,8 @@ export class HaConfigApplicationCredentials extends LitElement {
@state() public _applicationCredentials: ApplicationCredential[] = [];
@state() private _loading = true;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@@ -154,6 +156,7 @@ export class HaConfigApplicationCredentials extends LitElement {
back-path="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._getApplicationCredentials(
this._applicationCredentials,
this.hass.localize
@@ -278,7 +281,13 @@ export class HaConfigApplicationCredentials extends LitElement {
}
private async _fetchApplicationCredentials() {
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
try {
this._applicationCredentials = await fetchApplicationCredentials(
this.hass
);
} finally {
this._loading = false;
}
}
private _addApplicationCredential() {
@@ -204,7 +204,7 @@ class DialogAreaAddTo extends LitElement {
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
--dialog-content-padding: 0 0 var(--ha-space-6);
}
`,
];
@@ -222,7 +222,7 @@ export class DialogDeviceAddTo extends LitElement {
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
--dialog-content-padding: 0 0 var(--ha-space-6);
}
`,
];
@@ -15,6 +15,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
@@ -42,6 +43,8 @@ import type {
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/skeleton/ha-skeleton-icon";
import "../../../components/skeleton/ha-skeleton-text";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -115,13 +118,15 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public entries!: ConfigEntry[];
@property({ attribute: false }) public entries?: ConfigEntry[];
@property({ attribute: false }) public entriesFailed = false;
@state() private _subEntries?: SubEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entities: EntityRegistryEntry[] = [];
entities?: EntityRegistryEntry[];
@property({ attribute: false }) public manifests!: IntegrationManifest[];
@@ -238,13 +243,13 @@ export class HaConfigDeviceDashboard extends LitElement {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
}
if (changedProps.has("_selected")) {
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
const device = this.hass.devices[d];
const entries = device.config_entries;
return entries.some(
(entryId) =>
this.entries.find((e) => e.entry_id === entryId)
this.entries?.find((e) => e.entry_id === entryId)
?.supports_remove_device
);
});
@@ -307,6 +312,10 @@ export class HaConfigDeviceDashboard extends LitElement {
};
}
private _reloadConfigEntries() {
fireEvent(this, "reload-config-entries");
}
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
@@ -317,8 +326,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _devicesAndFilterDomains = memoizeOne(
(
devices: HomeAssistant["devices"],
entries: ConfigEntry[],
entities: EntityRegistryEntry[],
entries: ConfigEntry[] | undefined,
entriesFailed: boolean,
entities: EntityRegistryEntry[] = [],
areas: HomeAssistant["areas"],
manifests: IntegrationManifest[],
filters: DataTableFilters,
@@ -346,7 +356,8 @@ export class HaConfigDeviceDashboard extends LitElement {
}
const entryLookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
for (const entry of entries ?? []) {
entryLookup[entry.entry_id] = entry;
}
@@ -371,7 +382,7 @@ export class HaConfigDeviceDashboard extends LitElement {
)
);
const configEntries = entries.filter(
const configEntries = (entries ?? []).filter(
(entry) =>
entry.entry_id &&
(filter.value as string[]).includes(entry.entry_id)
@@ -412,7 +423,7 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
const entryIds = entries
const entryIds = (entries ?? [])
.filter((entry) =>
(filter.value as string[]).includes(entry.domain)
)
@@ -531,16 +542,21 @@ export class HaConfigDeviceDashboard extends LitElement {
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
area: areaName,
floor: floorName,
integration: deviceEntries.length
? deviceEntries
.map(
(entry) =>
localize(`component.${entry.domain}.title`) || entry.domain
)
.join(", ")
: this.hass.localize(
"ui.panel.config.devices.data_table.no_integration"
),
integration: !entries
? localize("ui.common.loading")
: entriesFailed
? `<${localize("ui.panel.config.devices.data_table.unknown")}>`
: deviceEntries.length
? deviceEntries
.map(
(entry) =>
localize(`component.${entry.domain}.title`) ||
entry.domain
)
.join(", ")
: this.hass.localize(
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
@@ -599,21 +615,23 @@ export class HaConfigDeviceDashboard extends LitElement {
moveable: false,
showNarrow: true,
template: (device) =>
device.domains.length
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: device.domains[0],
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: "",
!this.entries
? html`<ha-skeleton-icon></ha-skeleton-icon>`
: device.domains.length
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: device.domains[0],
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: "",
},
name: {
title: localize("ui.panel.config.devices.data_table.device"),
@@ -642,7 +660,9 @@ export class HaConfigDeviceDashboard extends LitElement {
.labels=${device.label_entries}
></ha-data-table-labels>
`
: nothing
: device.labels.length && !this._labels
? html`<ha-skeleton-text></ha-skeleton-text>`
: nothing
}
`,
},
@@ -654,6 +674,10 @@ export class HaConfigDeviceDashboard extends LitElement {
filterable: true,
groupable: true,
minWidth: "120px",
template: (device) =>
!this.entries
? html`<ha-skeleton-text></ha-skeleton-text>`
: device.integration,
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
@@ -697,6 +721,9 @@ export class HaConfigDeviceDashboard extends LitElement {
minWidth: "101px",
valueColumn: "battery_level",
template: (device) => {
if (!this.entities) {
return html`<ha-skeleton-text></ha-skeleton-text>`;
}
const batteryEntityPair = device.battery_entity;
const battery =
batteryEntityPair && batteryEntityPair[0]
@@ -831,6 +858,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const { devicesOutput } = this._devicesAndFilterDomains(
this.hass.devices,
this.entries,
this.entriesFailed,
this.entities,
this.hass.areas,
this.manifests,
@@ -895,6 +923,22 @@ export class HaConfigDeviceDashboard extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.devices.add_device")}
</ha-button>
${
this.entriesFailed
? html`<ha-alert slot="top-header" alert-type="error">
${this.hass.localize(
"ui.panel.config.devices.config_entries_load_failed"
)}
<ha-button
slot="action"
appearance="plain"
@click=${this._reloadConfigEntries}
>
${this.hass.localize("ui.panel.config.devices.retry")}
</ha-button>
</ha-alert>`
: nothing
}
${
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry?.value.length
@@ -903,10 +947,13 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.filtering_by_config_entry"
)}
${
this.entries?.find(
(entry) =>
entry.entry_id === this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]
!this.entries
? html`<ha-skeleton-text></ha-skeleton-text>`
: this.entries.find(
(entry) =>
entry.entry_id ===
this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]
}${
this._filters.config_entry.value.length === 1 &&
Array.isArray(this._filters.sub_entry?.value) &&
@@ -1118,6 +1165,7 @@ export class HaConfigDeviceDashboard extends LitElement {
this._devicesAndFilterDomains(
this.hass.devices,
this.entries,
this.entriesFailed,
this.entities,
this.hass.areas,
this.manifests,
@@ -1357,6 +1405,13 @@ ${rejected
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
}
ha-alert[slot="top-header"] {
display: block;
margin: var(--ha-space-2) var(--ha-space-4);
}
ha-alert ha-skeleton-text {
--ha-skeleton-text-width: 100px;
}
ha-dropdown::part(menu),
ha-dropdown::part(submenu) {
--auto-size-available-width: calc(50vw - var(--ha-space-4));
+42 -7
View File
@@ -31,13 +31,21 @@ class HaConfigDevices extends HassRouterPage {
},
};
@state() private _configEntries: ConfigEntry[] = [];
@state() private _configEntries?: ConfigEntry[];
@state() private _configEntriesFailed = false;
@state() private _manifests: IntegrationManifest[] = [];
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadData();
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this.addEventListener("reload-config-entries", () =>
this._loadConfigEntries()
);
this._loadData();
}
}
protected updatePageEl(pageEl) {
@@ -45,9 +53,12 @@ class HaConfigDevices extends HassRouterPage {
if (this._currentPage === "device") {
pageEl.deviceId = this.routeTail.path.substr(1);
pageEl.entries = this._configEntries ?? [];
} else {
pageEl.entries = this._configEntries;
pageEl.entriesFailed = this._configEntriesFailed;
}
pageEl.entries = this._configEntries;
pageEl.manifests = this._manifests;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
@@ -55,12 +66,36 @@ class HaConfigDevices extends HassRouterPage {
}
private async _loadData() {
this._configEntries = await getConfigEntries(this.hass);
this._manifests = await fetchIntegrationManifests(this.hass);
await Promise.all([
this._loadConfigEntries(),
fetchIntegrationManifests(this.hass)
.then((manifests) => {
this._manifests = manifests;
})
.catch(() => {
// The pages remain usable without integration manifests.
}),
]);
}
private async _loadConfigEntries() {
this._configEntriesFailed = false;
this._configEntries = undefined;
try {
this._configEntries = await getConfigEntries(this.hass);
} catch {
this._configEntriesFailed = true;
this._configEntries = [];
}
}
}
declare global {
interface HASSDomEvents {
"reload-config-entries": undefined;
}
interface HTMLElementTagNameMap {
"ha-config-devices": HaConfigDevices;
}
@@ -38,6 +38,8 @@ class HaConfigHardwareAll extends LitElement {
@state() private _error?: string;
@state() private _loading = true;
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
name: {
@@ -98,6 +100,7 @@ class HaConfigHardwareAll extends LitElement {
.tabs=${hardwareTabs(this.hass)}
clickable
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._hardware ? this._data(this._hardware) : []}
.noDataText=${
this._error ||
@@ -113,6 +116,8 @@ class HaConfigHardwareAll extends LitElement {
this._hardware = await fetchHassioHardwareInfo(this.hass);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
} finally {
this._loading = false;
}
}
@@ -18,6 +18,9 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../../components/ha-dropdown";
import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-list-item";
import "../../../../../components/ha-svg-icon";
import { getSignedPath } from "../../../../../data/auth";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../../../data/diagnostics";
import type { OTBRInfo, OTBRInfoDict } from "../../../../../data/otbr";
@@ -45,6 +45,8 @@ export class ZHAGroupsDashboard extends LitElement {
@state() private _groups: ZHAGroup[] = [];
@state() private _loading = true;
private _firstUpdatedCalled = false;
public connectedCallback(): void {
@@ -114,6 +116,7 @@ export class ZHAGroupsDashboard extends LitElement {
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._formattedGroups(this._groups)}
@row-click=${this._handleRowClicked}
clickable
@@ -128,7 +131,11 @@ export class ZHAGroupsDashboard extends LitElement {
}
private async _fetchGroups() {
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
try {
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
} finally {
this._loading = false;
}
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
@@ -30,6 +30,8 @@ class ZWaveJSProvisioned extends LitElement {
@state() private _provisioningEntries: ZwaveJSProvisioningEntry[] = [];
@state() private _loading = true;
@state() private _nodeIdToDevice: Record<number, DeviceRegistryEntry> = {};
protected render() {
@@ -50,6 +52,7 @@ class ZWaveJSProvisioned extends LitElement {
this.configEntryId
}"
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._getData(this._provisioningEntries, this._nodeIdToDevice)}
>
</hass-tabs-subpage-data-table>
@@ -176,10 +179,14 @@ class ZWaveJSProvisioned extends LitElement {
}
private async _fetchProvisioningEntries() {
this._provisioningEntries = await fetchZwaveProvisioningEntries(
this.hass!,
this.configEntryId
);
try {
this._provisioningEntries = await fetchZwaveProvisioningEntries(
this.hass!,
this.configEntryId
);
} finally {
this._loading = false;
}
}
private _unprovision = async (ev) => {
+8 -1
View File
@@ -108,6 +108,8 @@ export class HaConfigLabels extends LitElement {
@state() private _labels: LabelRegistryEntry[] = [];
@state() private _loading = true;
@state()
@storage({
storage: "sessionStorage",
@@ -258,6 +260,7 @@ export class HaConfigLabels extends LitElement {
.tabs=${configSections.areas}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${this._data(this._labels)}
.loading=${this._loading}
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
has-fab
.initialSorting=${this._activeSorting}
@@ -321,7 +324,11 @@ export class HaConfigLabels extends LitElement {
}
private async _fetchLabels() {
this._labels = await fetchLabelRegistry(this.hass.connection);
try {
this._labels = await fetchLabelRegistry(this.hass.connection);
} finally {
this._loading = false;
}
}
private _addLabel() {
@@ -1,4 +1,4 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import "../../../components/skeleton/ha-skeleton-text";
import { consume, type ContextType } from "@lit/context";
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -188,8 +188,10 @@ class DialogSystemLogDetail extends LitElement {
integration &&
this._manifest === undefined &&
reportTarget !== "frontend"
? html`<ha-alert alert-type="info">
<wa-skeleton effect="sheen"></wa-skeleton>
? html`<ha-alert
alert-type=${this.isCustomIntegration ? "warning" : "info"}
>
<ha-skeleton-text></ha-skeleton-text>
</ha-alert>`
: html`<ha-alert
alert-type=${this.isCustomIntegration ? "warning" : "info"}
@@ -421,10 +423,24 @@ class DialogSystemLogDetail extends LitElement {
ha-alert p + p {
margin-block-start: var(--ha-space-2);
}
wa-skeleton {
height: 1em;
--color: var(--ha-color-fill-neutral-normal-resting);
--sheen-color: var(--ha-color-fill-neutral-loud-resting);
ha-skeleton-text {
--ha-skeleton-text-width: 320px;
}
@supports (color: color-mix(in srgb, black, transparent)) {
ha-alert[alert-type="info"] ha-skeleton-text {
--ha-skeleton-color: color-mix(
in srgb,
var(--info-color) 24%,
transparent
);
}
ha-alert[alert-type="warning"] ha-skeleton-text {
--ha-skeleton-color: color-mix(
in srgb,
var(--warning-color) 24%,
transparent
);
}
}
.contents {
outline: none;
@@ -101,6 +101,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
@state() private _dashboards: LovelaceDashboard[] = [];
@state() private _loading = true;
@state()
@storage({
storage: "sessionStorage",
@@ -409,6 +411,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._dashboards,
this.hass.localize
)}
.loading=${this._loading}
.data=${this._getItems(
this._dashboards,
defaultPanel,
@@ -476,7 +479,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private async _getDashboards() {
this._dashboards = await fetchDashboards(this.hass);
try {
this._dashboards = await fetchDashboards(this.hass);
} finally {
this._loading = false;
}
}
private _handleRowClicked(ev: CustomEvent) {
+8 -1
View File
@@ -58,6 +58,8 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
@state() private _tags: Tag[] = [];
@state() private _loading = true;
private get _canWriteTags() {
return this.hass.auth.external?.config.canWriteTag;
}
@@ -195,6 +197,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
.route=${this.route}
.tabs=${configSections.tags}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._data(this._tags)}
.noDataText=${this.hass.localize("ui.panel.config.tag.no_tags")}
.filter=${this._filter}
@@ -266,7 +269,11 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
}
private async _fetchTags() {
this._tags = await fetchTags(this.hass);
try {
this._tags = await fetchTags(this.hass);
} finally {
this._loading = false;
}
}
private _openWrite(tag: Tag) {
@@ -10,7 +10,7 @@ import { clearStatistics, getStatisticLabel } from "../../../../data/recorder";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { DialogStatisticsFixParams } from "./show-dialog-statistics-fix";
@customElement("dialog-statistics-fix")
@@ -13,7 +13,14 @@ import {
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { HassEntity } from "home-assistant-js-websocket";
import { consume, type ContextType } from "@lit/context";
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
import {
css,
type CSSResultGroup,
html,
LitElement,
nothing,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
@@ -67,7 +74,7 @@ import { getAreaTableColumn } from "../../common/data-table-columns";
import { KeyboardShortcutMixin } from "../../../../mixins/keyboard-shortcut-mixin";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistantRegistries } from "../../../../types";
import { showConfirmationDialog } from "../../../lovelace/custom-card-helpers";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { fixStatisticsIssue } from "./fix-statistics";
import { showStatisticsAdjustSumDialog } from "./show-dialog-statistics-adjust-sum";
@@ -108,6 +115,8 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
@state() private _data: StatisticData[] = [] as StatisticsMetaData[];
@state() private _loading = true;
@state() private filter = "";
@state() private _selected: string[] = [];
@@ -146,8 +155,12 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
@query("ha-input-search") private _searchInput!: HaInputSearch;
protected firstUpdated() {
this._validateStatistics();
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._validateStatistics();
}
}
private _displayData = memoizeOne(
@@ -554,6 +567,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
}
<ha-data-table
.narrow=${this.narrow}
.loading=${this._loading}
.columns=${columns}
.data=${this._displayData(
this._data,
@@ -722,38 +736,42 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
}
private async _validateStatistics() {
const [statisticIds, issues] = await Promise.all([
getStatisticIds(this._api),
validateStatistics(this._api),
]);
try {
const [statisticIds, issues] = await Promise.all([
getStatisticIds(this._api),
validateStatistics(this._api),
]);
updateStatisticsIssues(this._api);
updateStatisticsIssues(this._api);
const statsIds = new Set();
const statsIds = new Set();
this._data = statisticIds.map((statistic) => {
statsIds.add(statistic.statistic_id);
return {
...statistic,
state: this._states[statistic.statistic_id],
issues: issues[statistic.statistic_id],
};
});
this._data = statisticIds.map((statistic) => {
statsIds.add(statistic.statistic_id);
return {
...statistic,
state: this._states[statistic.statistic_id],
issues: issues[statistic.statistic_id],
};
});
Object.keys(issues).forEach((statisticId) => {
if (!statsIds.has(statisticId)) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
source: "",
state: this._states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_sum: false,
unit_class: null,
});
}
});
Object.keys(issues).forEach((statisticId) => {
if (!statsIds.has(statisticId)) {
this._data.push({
statistic_id: statisticId,
statistics_unit_of_measurement: "",
source: "",
state: this._states[statisticId],
issues: issues[statisticId],
mean_type: StatisticMeanType.NONE,
has_sum: false,
unit_class: null,
});
}
});
} finally {
this._loading = false;
}
}
private _clearSelected = async () => {
+8 -1
View File
@@ -40,6 +40,8 @@ export class HaConfigUsers extends LitElement {
@state() private _users: User[] = [];
@state() private _loading = true;
@storage({ key: "users-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@@ -179,6 +181,7 @@ export class HaConfigUsers extends LitElement {
back-path="/config"
.tabs=${configSections.persons}
.columns=${this._columns(this.narrow, this.hass.localize)}
.loading=${this._loading}
.data=${this._userData(this._users, this.hass.localize)}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@@ -212,7 +215,11 @@ export class HaConfigUsers extends LitElement {
);
private async _fetchUsers() {
this._users = await fetchUsers(this.hass);
try {
this._users = await fetchUsers(this.hass);
} finally {
this._loading = false;
}
this._users.forEach((user) => {
if (user.is_owner) {
@@ -1,45 +0,0 @@
import { round } from "../../../../common/number/round";
/**
* In periods where part of the sources' energy did not reach the home (grid
* charging a battery, a battery exporting), per-source attribution is
* ambiguous if multiple sources have data in the same period. Values that
* round to 0 Wh are float noise from subtracting sums.
* Rewrites single-source periods in place on `bySource`, and returns a
* combined used map for multi-source periods. Returns undefined when no
* combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsed(
bySource: Record<string, Record<number, number>>,
notUsed: Record<number, number>,
used: Record<number, number>
): Record<number, number> | undefined {
const combined: Record<number, number> = {};
for (const [start, notUsedInPeriod] of Object.entries(notUsed)) {
if (!round(notUsedInPeriod, 3)) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(bySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
bySource[source][start] = used[start];
} else {
Object.values(bySource).forEach((stats) => {
delete stats[start];
});
if (round(used[start], 3)) {
combined[start] = used[start];
}
}
}
return Object.keys(combined).length > 0 ? combined : undefined;
}
@@ -0,0 +1,39 @@
/**
* When battery is charging from grid, per-source import attribution is
* ambiguous if multiple grid sources have data in the same period.
* Rewrites single-source periods in place on `fromGridBySource`, and returns
* a combined used-grid map for multi-source periods. Returns undefined when
* no combined series is needed so the chart does not add an empty legend item.
*/
export function buildCombinedUsedGrid(
fromGridBySource: Record<string, Record<number, number>>,
gridToBattery: Record<number, number>,
usedGrid: Record<number, number>
): Record<number, number> | undefined {
const used_grid: Record<number, number> = {};
for (const [start, grid_to_battery] of Object.entries(gridToBattery)) {
if (!grid_to_battery) {
continue;
}
let noOfSources = 0;
let source: string | undefined;
for (const [key, stats] of Object.entries(fromGridBySource)) {
if (stats[start]) {
source = key;
noOfSources++;
}
if (noOfSources > 1) {
break;
}
}
if (noOfSources === 1 && source) {
fromGridBySource[source][start] = usedGrid[start];
} else {
Object.values(fromGridBySource).forEach((stats) => {
delete stats[start];
});
used_grid[start] = usedGrid[start];
}
}
return Object.keys(used_grid).length > 0 ? used_grid : undefined;
}
@@ -44,7 +44,7 @@ import {
} from "./common/energy-chart-options";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
import { buildCombinedUsed } from "./energy-usage-graph-combined-used";
import { buildCombinedUsedGrid } from "./energy-usage-graph-used-grid";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -52,7 +52,6 @@ const colorPropertyMap = {
from_grid: "--energy-grid-consumption-color",
used_grid: "--energy-grid-consumption-color",
used_solar: "--energy-solar-color",
from_battery: "--energy-battery-out-color",
used_battery: "--energy-battery-out-color",
};
@@ -60,7 +59,6 @@ const stackOrder = {
to_battery: 1,
to_grid: 2,
used_solar: 3,
from_battery: 4,
used_battery: 4,
from_grid: 5,
used_grid: 5,
@@ -299,12 +297,10 @@ export class HuiEnergyUsageGraphCard
to_grid: Record<string, string>;
from_grid: Record<string, string>;
to_battery: Record<string, string>;
from_battery: Record<string, string>;
} = {
to_grid: {},
from_grid: {},
to_battery: {},
from_battery: {},
};
// Grid sources can be import-only or export-only; assign color indices by
@@ -339,10 +335,6 @@ export class HuiEnergyUsageGraphCard
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_charged",
{ name: source.name }
);
statLabels.from_battery[source.stat_energy_from] = this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_sources_table.named_battery_discharged",
{ name: source.name }
);
}
continue;
}
@@ -551,7 +543,6 @@ export class HuiEnergyUsageGraphCard
to_grid: Record<string, string>;
from_grid: Record<string, string>;
to_battery: Record<string, string>;
from_battery: Record<string, string>;
},
trackY: (v: number) => void,
compare = false
@@ -562,16 +553,13 @@ export class HuiEnergyUsageGraphCard
to_grid?: Record<string, Record<number, number>>;
to_battery?: Record<string, Record<number, number>>;
from_grid?: Record<string, Record<number, number>>;
from_battery?: Record<string, Record<number, number>>;
used_grid?: Record<string, Record<number, number>>;
used_solar?: Record<string, Record<number, number>>;
used_battery?: Record<string, Record<number, number>>;
} = {};
Object.entries(statIdsByCat).forEach(([key, statIds]) => {
if (
!["to_grid", "from_grid", "to_battery", "from_battery"].includes(key)
) {
if (!["to_grid", "from_grid", "to_battery"].includes(key)) {
return;
}
const sets: Record<string, Record<number, number>> = {};
@@ -596,34 +584,21 @@ export class HuiEnergyUsageGraphCard
combinedData[key] = sets;
});
// Only add the solar consumption series when solar is configured,
// otherwise the legend shows an empty solar entry for grid-only setups.
// Combined used_grid and used_battery are fallbacks for periods that
// can't be split per source; skip them when they have no points.
// Only add solar/battery consumption series when such a source is
// actually configured, otherwise the legend shows empty solar/battery
// entries for grid-only setups. Combined used_grid is a fallback for
// multi-source battery charging; skip it when it has no points.
if (statIdsByCat.solar) {
combinedData.used_solar = { used_solar: consumptionData.used_solar };
}
if (combinedData.from_battery) {
// Discharge that did not reach the home
const batteryNotUsed: Record<number, number> = {};
for (const start of summedData.timestamps) {
batteryNotUsed[start] =
(summedData.from_battery?.[start] ?? 0) -
consumptionData.used_battery[start];
}
const used_battery = buildCombinedUsed(
combinedData.from_battery,
batteryNotUsed,
consumptionData.used_battery
);
if (used_battery) {
combinedData.used_battery = { used_battery };
}
if (statIdsByCat.from_battery) {
combinedData.used_battery = {
used_battery: consumptionData.used_battery,
};
}
if (combinedData.from_grid && summedData.to_battery) {
const used_grid = buildCombinedUsed(
const used_grid = buildCombinedUsedGrid(
combinedData.from_grid,
consumptionData.grid_to_battery,
consumptionData.used_grid
@@ -340,6 +340,9 @@ class HaRefreshTokens extends LitElement {
ha-list-item-base {
--ha-row-item-padding-inline: 0;
}
[slot="supporting-text"] {
white-space: normal;
}
ha-icon-button {
color: var(--primary-text-color);
}
+5 -1
View File
@@ -1,3 +1,7 @@
export const loadVirtualizer = async () => {
await import("@lit-labs/virtualizer");
// The default flow layout is otherwise only fetched once there are items.
await Promise.all([
import("@lit-labs/virtualizer"),
import("@lit-labs/virtualizer/layouts/flow.js"),
]);
};
+2
View File
@@ -6948,6 +6948,8 @@
"caption": "Devices",
"description": "Manage configured devices",
"filtering_by_config_entry": "[%key:ui::panel::config::entities::picker::filtering_by_config_entry%]",
"config_entries_load_failed": "Unable to load integrations for your devices.",
"retry": "[%key:ui::panel::app::retry%]",
"device_info": "{type} info",
"edit_settings": "Edit settings",
"restore_entity_ids": "Recreate entity IDs",
@@ -1,84 +0,0 @@
/**
* Protects the energy usage graph from adding an empty combined Grid or
* Battery legend item, while still combining sources when multiple grid
* imports share a battery-charging period or multiple batteries share an
* exporting period.
*/
import { assert, describe, it } from "vitest";
import { buildCombinedUsed } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-combined-used";
const t = 1_700_000_000_000;
describe("buildCombinedUsed", () => {
it("does not add a combined series for a single grid source charging a battery", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
});
it("combines overlapping grid sources and removes per-source points", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 6 },
"sensor.grid_import_b": { [t]: 4 },
};
const result = buildCombinedUsed(fromGridBySource, { [t]: 3 }, { [t]: 7 });
assert.deepEqual(result, { [t]: 7 });
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
});
it("does not add a combined series when battery is present but not charging from grid", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsed(fromGridBySource, {}, { [t]: 10 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
});
it("does not add a combined series when the home used none of the energy", () => {
const fromBatteryBySource = {
"sensor.battery_a_out": { [t]: 2 },
"sensor.battery_b_out": { [t]: 1 },
};
// All discharge exported; the model leaves 2.8e-17 as "used"
const result = buildCombinedUsed(
fromBatteryBySource,
{ [t]: 3 },
{ [t]: 2.7755575615628914e-17 }
);
assert.isUndefined(result);
assert.isUndefined(fromBatteryBySource["sensor.battery_a_out"][t]);
assert.isUndefined(fromBatteryBySource["sensor.battery_b_out"][t]);
});
it("treats float noise in the unused energy as zero", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 0.06 },
"sensor.grid_import_b": { [t]: 0.04 },
};
// 0.06 + 0.04 + 0.4 solar - 0.4 to battery leaves 2.8e-17 "grid to battery"
const result = buildCombinedUsed(
fromGridBySource,
{ [t]: 2.7755575615628914e-17 },
{ [t]: 0.1 }
);
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import_a"][t], 0.06);
assert.equal(fromGridBySource["sensor.grid_import_b"][t], 0.04);
});
});
@@ -0,0 +1,55 @@
/**
* Protects the energy usage graph from adding an empty combined Grid
* legend item for single-source + battery setups, while still combining
* sources when multiple grid imports share a battery-charging period.
*/
import { assert, describe, it } from "vitest";
import { buildCombinedUsedGrid } from "../../../../../src/panels/lovelace/cards/energy/energy-usage-graph-used-grid";
const t = 1_700_000_000_000;
describe("buildCombinedUsedGrid", () => {
it("does not add a combined series for a single grid source charging a battery", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 7);
});
it("combines overlapping grid sources and removes per-source points", () => {
const fromGridBySource = {
"sensor.grid_import_a": { [t]: 6 },
"sensor.grid_import_b": { [t]: 4 },
};
const result = buildCombinedUsedGrid(
fromGridBySource,
{ [t]: 3 },
{ [t]: 7 }
);
assert.deepEqual(result, { [t]: 7 });
assert.isUndefined(fromGridBySource["sensor.grid_import_a"][t]);
assert.isUndefined(fromGridBySource["sensor.grid_import_b"][t]);
});
it("does not add a combined series when battery is present but not charging from grid", () => {
const fromGridBySource = {
"sensor.grid_import": { [t]: 10 },
};
const result = buildCombinedUsedGrid(fromGridBySource, {}, { [t]: 10 });
assert.isUndefined(result);
assert.equal(fromGridBySource["sensor.grid_import"][t], 10);
});
});