From ac56c6df9a3a4aee7ef8cc29100c08252c3e7a88 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Nov 2025 16:11:20 +0100 Subject: [PATCH 01/99] Bumped version to 20251126.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 66a7cb314d..4d8aba79f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251029.0" +version = "20251126.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From 690fd5a061a96899bfe17880795f6873a892b1cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Pereira?= Date: Thu, 27 Nov 2025 08:03:23 +0100 Subject: [PATCH 02/99] Fix hide sidebar tooltip on touchend events (#28042) * fix: hide sidebar tooltip on touchend events * Add a comment recommended by Copilot * Clear timeouts id in disconnectedCallback --- src/components/ha-sidebar.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 108a9450cf..e73074c59c 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -197,6 +197,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { private _mouseLeaveTimeout?: number; + private _touchendTimeout?: number; + private _tooltipHideTimeout?: number; private _recentKeydownActiveUntil = 0; @@ -237,6 +239,18 @@ class HaSidebar extends SubscribeMixin(LitElement) { ]; } + public disconnectedCallback() { + super.disconnectedCallback(); + // clear timeouts + clearTimeout(this._mouseLeaveTimeout); + clearTimeout(this._tooltipHideTimeout); + clearTimeout(this._touchendTimeout); + // set undefined values + this._mouseLeaveTimeout = undefined; + this._tooltipHideTimeout = undefined; + this._touchendTimeout = undefined; + } + protected render() { if (!this.hass) { return nothing; @@ -406,6 +420,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { class="ha-scrollbar" @focusin=${this._listboxFocusIn} @focusout=${this._listboxFocusOut} + @touchend=${this._listboxTouchend} @scroll=${this._listboxScroll} @keydown=${this._listboxKeydown} > @@ -620,6 +635,14 @@ class HaSidebar extends SubscribeMixin(LitElement) { this._hideTooltip(); } + private _listboxTouchend() { + clearTimeout(this._touchendTimeout); + this._touchendTimeout = window.setTimeout(() => { + // Allow 1 second for users to read the tooltip on touch devices + this._hideTooltip(); + }, 1000); + } + @eventOptions({ passive: true, }) From bcae64df883bd0e9c8eea108205988f25266e7fd Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 14:35:36 +0100 Subject: [PATCH 03/99] Use hui-root for panel energy (#28149) * Use hui-root for panel energy * Review feedback * Set empty prefs --- src/panels/energy/ha-panel-energy.ts | 297 ++++++++++++--------------- src/panels/lovelace/hui-root.ts | 114 +++++----- 2 files changed, 187 insertions(+), 224 deletions(-) diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index 1f6772cdb6..3c7b84a741 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -26,16 +26,24 @@ import type { LovelaceConfig } from "../../data/lovelace/config/types"; import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; import type { StatisticValue } from "../../data/recorder"; import { haStyle } from "../../resources/styles"; -import type { HomeAssistant } from "../../types"; +import type { HomeAssistant, PanelInfo } from "../../types"; import { fileDownload } from "../../util/file_download"; import "../lovelace/components/hui-energy-period-selector"; +import "../lovelace/hui-root"; import type { Lovelace } from "../lovelace/types"; import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view-container"; export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard"; +const EMPTY_PREFERENCES: EnergyPreferences = { + energy_sources: [], + device_consumption: [], + device_consumption_water: [], +}; + const OVERVIEW_VIEW = { + path: "overview", strategy: { type: "energy-overview", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, @@ -43,8 +51,8 @@ const OVERVIEW_VIEW = { } as LovelaceViewConfig; const ELECTRICITY_VIEW = { - back_path: "/energy", path: "electricity", + back_path: "/energy", strategy: { type: "energy-electricity", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, @@ -72,54 +80,96 @@ class PanelEnergy extends LitElement { @property({ type: Boolean, reflect: true }) public narrow = false; + @property({ attribute: false }) public panel?: PanelInfo; + @state() private _lovelace?: Lovelace; @state() private _searchParms = new URLSearchParams(window.location.search); - @state() private _error?: string; - @property({ attribute: false }) public route?: { path: string; prefix: string; }; @state() - private _config?: LovelaceConfig; + private _prefs?: EnergyPreferences; - get _viewPath(): string | undefined { - const viewPath: string | undefined = this.route!.path.split("/")[1]; - return viewPath ? decodeURI(viewPath) : undefined; - } + @state() + private _error?: string; - public connectedCallback() { - super.connectedCallback(); - this._loadLovelaceConfig(); - } - - public async willUpdate(changedProps: PropertyValues) { + public willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + // Initial setup if (!this.hasUpdated) { this.hass.loadFragmentTranslation("lovelace"); + this._loadConfig(); + return; } + if (!changedProps.has("hass")) { return; } + const oldHass = changedProps.get("hass") as this["hass"]; - if (oldHass?.locale !== this.hass.locale) { + if (oldHass && oldHass.localize !== this.hass.localize) { this._setLovelace(); - } else if (oldHass && oldHass.localize !== this.hass.localize) { - this._reloadView(); } } - private async _loadLovelaceConfig() { + private _fetchEnergyPrefs = async (): Promise< + EnergyPreferences | undefined + > => { + const collection = getEnergyDataCollection(this.hass, { + key: DEFAULT_ENERGY_COLLECTION_KEY, + }); try { - this._config = undefined; - this._config = await this._generateLovelaceConfig(); - } catch (err) { - this._error = (err as Error).message; + await collection.refresh(); + } catch (err: any) { + if (err.code === "not_found") { + return undefined; + } + throw err; } + return collection.prefs; + }; - this._setLovelace(); + private async _loadConfig() { + try { + this._error = undefined; + const prefs = await this._fetchEnergyPrefs(); + this._prefs = prefs || EMPTY_PREFERENCES; + } catch (err) { + // eslint-disable-next-line no-console + console.error("Failed to load prefs:", err); + this._prefs = EMPTY_PREFERENCES; + this._error = (err as Error).message || "Unknown error"; + } + await this._setLovelace(); + + // Navigate to first view if not there yet + const firstPath = this._lovelace!.config?.views?.[0]?.path; + const viewPath: string | undefined = this.route!.path.split("/")[1]; + if (viewPath !== firstPath) { + navigate(`${this.route!.prefix}/${firstPath}`); + } + } + + private async _setLovelace() { + const config = await this._generateLovelaceConfig(); + + this._lovelace = { + config: config, + rawConfig: config, + editMode: false, + urlPath: "energy", + mode: "generated", + locale: this.hass.locale, + enableFullEditMode: () => undefined, + saveConfig: async () => undefined, + deleteConfig: async () => undefined, + setEditMode: () => undefined, + showToast: () => undefined, + }; } private _back(ev) { @@ -128,7 +178,17 @@ class PanelEnergy extends LitElement { } protected render() { - if (!this._config && !this._error) { + if (this._error) { + return html` +
+ + An error occurred loading energy preferences: ${this._error} + +
+ `; + } + + if (!this._prefs) { // Still loading return html`
@@ -136,20 +196,31 @@ class PanelEnergy extends LitElement {
`; } - const isSingleView = this._config?.views.length === 1; - const viewPath = this._viewPath; - const viewIndex = this._config - ? Math.max( - this._config.views.findIndex((view) => view.path === viewPath), - 0 - ) - : 0; - const showBack = - this._searchParms.has("historyBack") || (!isSingleView && viewIndex > 0); + + if (!this._lovelace) { + return nothing; + } + + const viewPath: string | undefined = this.route!.path.split("/")[1]; + + const views = this._lovelace.config?.views || []; + const viewIndex = Math.max( + views.findIndex((view) => view.path === viewPath), + 0 + ); + + const showBack = this._searchParms.has("historyBack") || viewIndex > 0; return html` -
-
+ +
${showBack ? html` ${this.hass.user?.is_admin - ? html` - - ${this.hass!.localize("ui.panel.energy.configure")} - ` + ? html` + + + + ${this.hass!.localize("ui.panel.energy.configure")} + + ` : nothing}
-
- - - ${this._error - ? html`
- - An error occurred while fetching your energy preferences: - ${this._error} - -
` - : this._lovelace - ? html`` - : nothing} -
+ `; } - private _fetchEnergyPrefs = async (): Promise< - EnergyPreferences | undefined - > => { - const collection = getEnergyDataCollection(this.hass, { - key: DEFAULT_ENERGY_COLLECTION_KEY, - }); - try { - await collection.refresh(); - } catch (err: any) { - if (err.code === "not_found") { - return undefined; - } - throw err; - } - return collection.prefs; - }; - private async _generateLovelaceConfig(): Promise { - const prefs = await this._fetchEnergyPrefs(); if ( - !prefs || - (prefs.device_consumption.length === 0 && - prefs.energy_sources.length === 0) + !this._prefs || + (this._prefs.device_consumption.length === 0 && + this._prefs.energy_sources.length === 0) ) { await import("./cards/energy-setup-wizard-card"); return { @@ -249,7 +284,7 @@ class PanelEnergy extends LitElement { }; } - const isElectricityOnly = prefs.energy_sources.every((source) => + const isElectricityOnly = this._prefs.energy_sources.every((source) => ["grid", "solar", "battery"].includes(source.type) ); if (isElectricityOnly) { @@ -259,8 +294,8 @@ class PanelEnergy extends LitElement { } const hasWater = - prefs.energy_sources.some((source) => source.type === "water") || - prefs.device_consumption_water?.length > 0; + this._prefs.energy_sources.some((source) => source.type === "water") || + this._prefs.device_consumption_water?.length > 0; const views: LovelaceViewConfig[] = [OVERVIEW_VIEW, ELECTRICITY_VIEW]; if (hasWater) { @@ -269,25 +304,6 @@ class PanelEnergy extends LitElement { return { views }; } - private _setLovelace() { - if (!this._config) { - return; - } - this._lovelace = { - config: this._config, - rawConfig: this._config, - editMode: false, - urlPath: "energy", - mode: "generated", - locale: this.hass.locale, - enableFullEditMode: () => undefined, - saveConfig: async () => undefined, - deleteConfig: async () => undefined, - setEditMode: () => undefined, - showToast: () => undefined, - }; - } - private _navigateConfig(ev) { ev.stopPropagation(); navigate("/config/energy?historyBack=1"); @@ -593,8 +609,8 @@ class PanelEnergy extends LitElement { fileDownload(url, "energy.csv"); } - private _reloadView() { - this._loadLovelaceConfig(); + private _reloadConfig() { + this._loadConfig(); } static get styles(): CSSResultGroup { @@ -620,45 +636,6 @@ class PanelEnergy extends LitElement { -webkit-user-select: none; -moz-user-select: none; } - .header { - background-color: var(--app-header-background-color); - color: var(--app-header-text-color, white); - border-bottom: var(--app-header-border-bottom, none); - position: fixed; - top: 0; - width: calc( - var(--mdc-top-app-bar-width, 100%) - var( - --safe-area-inset-right, - 0px - ) - ); - padding-top: var(--safe-area-inset-top); - z-index: 4; - transition: box-shadow 200ms linear; - display: flex; - flex-direction: row; - -webkit-backdrop-filter: var(--app-header-backdrop-filter, none); - backdrop-filter: var(--app-header-backdrop-filter, none); - padding-top: var(--safe-area-inset-top); - padding-right: var(--safe-area-inset-right); - } - :host([narrow]) .header { - width: calc( - var(--mdc-top-app-bar-width, 100%) - var( - --safe-area-inset-left, - 0px - ) - var(--safe-area-inset-right, 0px) - ); - padding-left: var(--safe-area-inset-left); - } - :host([scrolled]) .header { - box-shadow: var( - --mdc-top-app-bar-fixed-box-shadow, - 0px 2px 4px -1px rgba(0, 0, 0, 0.2), - 0px 4px 5px 0px rgba(0, 0, 0, 0.14), - 0px 1px 10px 0px rgba(0, 0, 0, 0.12) - ); - } .toolbar { height: var(--header-height); display: flex; @@ -677,24 +654,6 @@ class PanelEnergy extends LitElement { line-height: var(--ha-line-height-normal); flex-grow: 1; } - hui-view-container { - position: relative; - display: flex; - min-height: 100vh; - box-sizing: border-box; - padding-top: calc(var(--header-height) + var(--safe-area-inset-top)); - padding-right: var(--safe-area-inset-right); - padding-inline-end: var(--safe-area-inset-right); - padding-bottom: var(--safe-area-inset-bottom); - } - :host([narrow]) hui-view-container { - padding-left: var(--safe-area-inset-left); - padding-inline-start: var(--safe-area-inset-left); - } - hui-view { - flex: 1 1 100%; - max-width: 100%; - } .centered { width: 100%; height: 100%; diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index e7386f363d..cb29cb204d 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -127,7 +127,7 @@ interface UndoStackItem { @customElement("hui-root") class HUIRoot extends LitElement { - @property({ attribute: false }) public panel?: PanelInfo; + @property({ attribute: false }) public panel?: PanelInfo; @property({ attribute: false }) public hass!: HomeAssistant; @@ -543,68 +543,72 @@ class HUIRoot extends LitElement { })} >
-
+ +
+ ${this._editMode + ? html` +
+ ${dashboardTitle || + this.hass!.localize("ui.panel.lovelace.editor.header")} + +
+
${this._renderActionItems()}
+ ` + : html` + ${isSubview + ? html` + + ` + : html` + + `} + ${isSubview + ? html` +
${curViewConfig.title}
+ ` + : hasTabViews + ? tabs + : html` +
+ ${views[0]?.title ?? dashboardTitle} +
+ `} +
${this._renderActionItems()}
+ `} +
${this._editMode ? html` -
- ${dashboardTitle || - this.hass!.localize("ui.panel.lovelace.editor.header")} +
+ ${tabs}
-
${this._renderActionItems()}
` - : html` - ${isSubview - ? html` - - ` - : html` - - `} - ${isSubview - ? html`
${curViewConfig.title}
` - : hasTabViews - ? tabs - : html` -
- ${views[0]?.title ?? dashboardTitle} -
- `} -
${this._renderActionItems()}
- `} -
- ${this._editMode - ? html` -
- ${tabs} - -
- ` - : nothing} + : nothing} +
Date: Wed, 26 Nov 2025 18:37:18 +0200 Subject: [PATCH 04/99] Replace gauges with energy usage graph in energy overview (#28150) --- .../energy-overview-view-strategy.ts | 44 ++----------------- 1 file changed, 3 insertions(+), 41 deletions(-) diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index 3ed415fb84..698c25c6fb 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -6,7 +6,6 @@ import type { HomeAssistant } from "../../../types"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; -import type { LovelaceCardConfig } from "../../../data/lovelace/config/card"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; const sourceHasCost = (source: Record): boolean => @@ -52,10 +51,6 @@ export class EnergyViewStrategy extends ReactiveElement { source.type === "grid" && (source.flow_from?.length || source.flow_to?.length) ) as GridSourceTypeEnergyPreference; - const hasReturn = hasGrid && hasGrid.flow_to.length > 0; - const hasSolar = prefs.energy_sources.some( - (source) => source.type === "solar" - ); const hasGas = prefs.energy_sources.some((source) => source.type === "gas"); const hasBattery = prefs.energy_sources.some( (source) => source.type === "battery" @@ -143,43 +138,10 @@ export class EnergyViewStrategy extends ReactiveElement { modes: ["bar"], }); } else if (hasGrid) { - const gauges: LovelaceCardConfig[] = []; - // Only include if we have a grid source & return. - if (hasReturn) { - gauges.push({ - type: "energy-grid-neutrality-gauge", - view_layout: { position: "sidebar" }, - collection_key: collectionKey, - }); - } - - gauges.push({ - type: "energy-carbon-consumed-gauge", - view_layout: { position: "sidebar" }, - collection_key: collectionKey, - }); - - // Only include if we have a solar source. - if (hasSolar) { - if (hasReturn) { - gauges.push({ - type: "energy-solar-consumed-gauge", - view_layout: { position: "sidebar" }, - collection_key: collectionKey, - }); - } - gauges.push({ - type: "energy-self-sufficiency-gauge", - view_layout: { position: "sidebar" }, - collection_key: collectionKey, - }); - } - electricitySection.cards!.push({ - type: "grid", - columns: 2, - square: false, - cards: gauges, + title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"), + type: "energy-usage-graph", + collection_key: collectionKey, }); } From 39752f0e3fc419881f468d5c37e426ea137fb351 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 27 Nov 2025 08:55:23 +0100 Subject: [PATCH 05/99] Don't show more info for untracked consumption (#28151) --- .../cards/energy/hui-energy-devices-graph-card.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts index 10581ab97c..f6523d8e44 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts @@ -551,9 +551,12 @@ export class HuiEnergyDevicesGraphCard e.detail.seriesType === "pie" && e.detail.event?.target?.type === "tspan" // label ) { - fireEvent(this, "hass-more-info", { - entityId: (e.detail.data as any).id as string, - }); + const id = (e.detail.data as any).id as string; + if (id !== "untracked") { + fireEvent(this, "hass-more-info", { + entityId: id, + }); + } } } From 468139229c689e433cf0242259f3964d2dfb514b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 27 Nov 2025 13:19:42 +0200 Subject: [PATCH 06/99] Handle grouping by floor and area in power sankey card (#28162) --- .../energy/strategies/energy-electricity-view-strategy.ts | 8 +++++--- .../energy/strategies/energy-overview-view-strategy.ts | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/panels/energy/strategies/energy-electricity-view-strategy.ts b/src/panels/energy/strategies/energy-electricity-view-strategy.ts index 382841df3b..9f5bc131f9 100644 --- a/src/panels/energy/strategies/energy-electricity-view-strategy.ts +++ b/src/panels/energy/strategies/energy-electricity-view-strategy.ts @@ -55,6 +55,9 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { const hasPowerDevices = prefs.device_consumption.find( (device) => device.stat_rate ); + const showFloorsNAreas = !prefs.device_consumption.some( + (d) => d.included_in_stat + ); view.cards!.push({ type: "energy-compare", @@ -67,6 +70,8 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { title: hass.localize("ui.panel.energy.cards.power_sankey_title"), type: "power-sankey", collection_key: collectionKey, + group_by_floor: showFloorsNAreas, + group_by_area: showFloorsNAreas, grid_options: { columns: 24, }, @@ -156,9 +161,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { // Only include if we have at least 1 device in the config. if (prefs.device_consumption.length) { - const showFloorsNAreas = !prefs.device_consumption.some( - (d) => d.included_in_stat - ); view.cards!.push({ title: hass.localize("ui.panel.energy.cards.energy_sankey_title"), type: "energy-sankey", diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index 698c25c6fb..3933067e3b 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -81,10 +81,15 @@ export class EnergyViewStrategy extends ReactiveElement { cards: [], }; if (hasPowerSources && hasPowerDevices) { + const showFloorsNAreas = !prefs.device_consumption.some( + (d) => d.included_in_stat + ); overviewSection.cards!.push({ title: hass.localize("ui.panel.energy.cards.power_sankey_title"), type: "power-sankey", collection_key: collectionKey, + group_by_floor: showFloorsNAreas, + group_by_area: showFloorsNAreas, grid_options: { columns: 24, }, From aa7670cb5946a2216a1bd1afd8073af057690918 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 27 Nov 2025 13:20:06 +0200 Subject: [PATCH 07/99] Disable axis pointer on the energy devices bar chart to fix refresh issues on touch devices (#28163) --- .../lovelace/cards/energy/hui-energy-devices-graph-card.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts index f6523d8e44..9d505ef861 100644 --- a/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-energy-devices-graph-card.ts @@ -217,6 +217,9 @@ export class HuiEnergyDevicesGraphCard show: true, type: "value", name: "kWh", + axisPointer: { + show: false, + }, }; options.yAxis = { show: true, From fd1240f3357aee78b8956e849903d4ad75c4ee1d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 27 Nov 2025 13:21:55 +0200 Subject: [PATCH 08/99] Refactor power sankey hierarchy to handle devices with not power sensor (#28164) --- .../cards/energy/hui-power-sankey-card.ts | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts index d6e04e1c96..8190c357c6 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts @@ -23,6 +23,9 @@ const DEFAULT_CONFIG: Partial = { group_by_area: true, }; +// Minimum power threshold in kW to display a device node +const MIN_POWER_THRESHOLD = 0.01; + interface PowerData { solar: number; from_grid: number; @@ -251,23 +254,75 @@ class HuiPowerSankeyCard let untrackedConsumption = homeNode.value; const deviceNodes: Node[] = []; const parentLinks: Record = {}; + + // Build a map of device relationships for hierarchy resolution + // Key: stat_consumption (energy), Value: { stat_rate, included_in_stat } + const deviceMap = new Map< + string, + { stat_rate?: string; included_in_stat?: string } + >(); + prefs.device_consumption.forEach((device) => { + deviceMap.set(device.stat_consumption, { + stat_rate: device.stat_rate, + included_in_stat: device.included_in_stat, + }); + }); + + // Set of stat_rate entities that will be rendered as nodes + const renderedStatRates = new Set(); + prefs.device_consumption.forEach((device) => { + if (device.stat_rate) { + const value = this._getCurrentPower(device.stat_rate); + if (value >= MIN_POWER_THRESHOLD) { + renderedStatRates.add(device.stat_rate); + } + } + }); + + // Find the effective parent for power hierarchy + // Walks up the chain to find an ancestor with stat_rate that will be rendered + const findEffectiveParent = ( + includedInStat: string | undefined + ): string | undefined => { + let currentParent = includedInStat; + while (currentParent) { + const parentDevice = deviceMap.get(currentParent); + if (!parentDevice) { + return undefined; + } + // If this parent has a stat_rate and will be rendered, use it + if ( + parentDevice.stat_rate && + renderedStatRates.has(parentDevice.stat_rate) + ) { + return parentDevice.stat_rate; + } + // Otherwise, continue up the chain + currentParent = parentDevice.included_in_stat; + } + return undefined; + }; + prefs.device_consumption.forEach((device, idx) => { if (!device.stat_rate) { return; } const value = this._getCurrentPower(device.stat_rate); - if (value < 0.01) { + if (value < MIN_POWER_THRESHOLD) { return; } + // Find the effective parent (may be different from direct parent if parent has no stat_rate) + const effectiveParent = findEffectiveParent(device.included_in_stat); + const node = { id: device.stat_rate, label: device.name || this._getEntityLabel(device.stat_rate), value, color: getGraphColorByIndex(idx, computedStyle), index: 4, - parent: device.included_in_stat, + parent: effectiveParent, }; if (node.parent) { parentLinks[node.id] = node.parent; From 427e46201caded61f1166c2c469a3a970389cd1a Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 12:18:21 +0100 Subject: [PATCH 09/99] Fix add condition default tab and blank styles (#28166) --- .../config/automation/add-automation-element-dialog.ts | 5 +---- .../add-automation-element/ha-automation-add-items.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 51899a6ee6..4f7586b42b 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -294,10 +294,7 @@ class DialogAddAutomationElement feature.domain === "automation" && feature.preview_feature === "new_triggers_conditions" )?.enabled ?? false; - this._tab = - this._newTriggersAndConditions && this._params?.type !== "condition" - ? "targets" - : "groups"; + this._tab = this._newTriggersAndConditions ? "targets" : "groups"; } ); diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts index 27d170a7f7..7e31cb32c6 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts @@ -273,7 +273,7 @@ export class HaAutomationAddItems extends LitElement { align-items: center; color: var(--ha-color-text-secondary); padding: var(--ha-space-0); - margin: var(--ha-space-3) var(--ha-space-4) + margin: var(--ha-space-0) var(--ha-space-4) max(var(--safe-area-inset-bottom), var(--ha-space-3)); line-height: var(--ha-line-height-expanded); justify-content: center; From 670057e8e6bd948e515a661dae86a609c7fa9699 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 11:42:36 +0100 Subject: [PATCH 10/99] Restore sidebar view when clicking back (#28167) --- src/panels/lovelace/views/hui-sections-view.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index 7c24baac8f..ab40ce42c4 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -123,6 +123,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { "section-visibility-changed", this._sectionVisibilityChanged ); + this._showSidebar = Boolean(window.history.state?.sidebar); } disconnectedCallback(): void { @@ -428,6 +429,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement { this._showSidebar = !this._showSidebar; + // Add sidebar state to history + window.history.replaceState( + { ...window.history.state, sidebar: this._showSidebar }, + "" + ); + // Restore scroll position after view updates this.updateComplete.then(() => { const scrollY = this._showSidebar From 221aefd76469eda31e32f4bb783853d8769da564 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:28:18 +0100 Subject: [PATCH 11/99] Fix automation add TCA autofocus (#28168) Fix automation add tca autofocus --- src/components/ha-wa-dialog.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/ha-wa-dialog.ts b/src/components/ha-wa-dialog.ts index b9fa674849..0203047995 100644 --- a/src/components/ha-wa-dialog.ts +++ b/src/components/ha-wa-dialog.ts @@ -1,3 +1,5 @@ +import "@home-assistant/webawesome/dist/components/dialog/dialog"; +import { mdiClose } from "@mdi/js"; import { css, html, LitElement } from "lit"; import { customElement, @@ -7,8 +9,6 @@ import { state, } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; -import { mdiClose } from "@mdi/js"; -import "@home-assistant/webawesome/dist/components/dialog/dialog"; import { fireEvent } from "../common/dom/fire_event"; import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant } from "../types"; @@ -172,7 +172,9 @@ export class HaWaDialog extends LitElement { await this.updateComplete; - (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); + requestAnimationFrame(() => { + (this.querySelector("[autofocus]") as HTMLElement | null)?.focus(); + }); }; private _handleAfterShow = () => { From 6706d5904d948cdb68b5d7a76ef478c827ef9a0b Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 12:19:15 +0100 Subject: [PATCH 12/99] Fix box shadow for sidebar tabs (#28170) --- src/components/ha-control-select.ts | 2 +- src/panels/lovelace/views/hui-sections-view.ts | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts index da38ed5eb9..40220e179b 100644 --- a/src/components/ha-control-select.ts +++ b/src/components/ha-control-select.ts @@ -202,6 +202,7 @@ export class HaControlSelect extends LitElement { color: var(--primary-text-color); user-select: none; -webkit-tap-highlight-color: transparent; + border-radius: var(--control-select-border-radius); } :host([vertical]) { width: var(--control-select-thickness); @@ -211,7 +212,6 @@ export class HaControlSelect extends LitElement { position: relative; height: 100%; width: 100%; - border-radius: var(--control-select-border-radius); transform: translateZ(0); display: flex; flex-direction: row; diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index ab40ce42c4..c412f392f7 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -525,25 +525,24 @@ export class SectionsView extends LitElement implements LovelaceViewElement { .mobile-tabs { position: fixed; - bottom: calc(var(--ha-space-4) + env(safe-area-inset-bottom)); + bottom: calc(var(--ha-space-3) + env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); padding: 0; z-index: 1; - filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15)) - drop-shadow(0 4px 16px rgba(0, 0, 0, 0.1)); } .mobile-tabs ha-control-select { width: max-content; min-width: 280px; max-width: 90%; - --control-select-thickness: 56px; - --control-select-border-radius: var(--ha-border-radius-6xl); + --control-select-thickness: var(--ha-space-14); + --control-select-border-radius: var(--ha-border-radius-pill); --control-select-background: var(--card-background-color); --control-select-background-opacity: 1; --control-select-color: var(--primary-color); --control-select-padding: 6px; + box-shadow: rgba(0, 0, 0, 0.3) 0px 4px 10px 0px; } ha-sortable { From df1914cb7a804a6687ecb8c867d19634425a198a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 12:18:48 +0100 Subject: [PATCH 13/99] Fix disabled dashboard picker when no custom dashboard (#28172) --- src/panels/profile/ha-pick-dashboard-row.ts | 102 +++++++++++--------- 1 file changed, 54 insertions(+), 48 deletions(-) diff --git a/src/panels/profile/ha-pick-dashboard-row.ts b/src/panels/profile/ha-pick-dashboard-row.ts index 4df5e24e38..a4b725a6aa 100644 --- a/src/panels/profile/ha-pick-dashboard-row.ts +++ b/src/panels/profile/ha-pick-dashboard-row.ts @@ -1,14 +1,17 @@ +import { mdiViewDashboard } from "@mdi/js"; import type { PropertyValues, TemplateResult } from "lit"; import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import "../../components/ha-divider"; +import "../../components/ha-icon"; import "../../components/ha-list-item"; import "../../components/ha-select"; import "../../components/ha-settings-row"; +import "../../components/ha-svg-icon"; import { saveFrontendUserData } from "../../data/frontend"; import type { LovelaceDashboard } from "../../data/lovelace/dashboard"; import { fetchDashboards } from "../../data/lovelace/dashboard"; -import { getPanelTitle } from "../../data/panel"; +import { getPanelIcon, getPanelTitle } from "../../data/panel"; import type { HomeAssistant, PanelInfo } from "../../types"; import { PANEL_DASHBOARDS } from "../config/lovelace/dashboards/ha-config-lovelace-dashboards"; @@ -37,54 +40,57 @@ class HaPickDashboardRow extends LitElement { ${this.hass.localize("ui.panel.profile.dashboard.description")} - ${this._dashboards - ? html` - - ${this.hass.localize("ui.panel.profile.dashboard.system")} + + + ${this.hass.localize("ui.panel.profile.dashboard.system")} + + + + + ${this.hass.localize("ui.panel.profile.dashboard.lovelace")} + + ${PANEL_DASHBOARDS.map((panel) => { + const panelInfo = this.hass.panels[panel] as PanelInfo | undefined; + if (!panelInfo) { + return nothing; + } + return html` + + + ${getPanelTitle(this.hass, panelInfo)} - - - ${this.hass.localize("ui.panel.profile.dashboard.lovelace")} - - ${PANEL_DASHBOARDS.map((panel) => { - const panelInfo = this.hass.panels[panel] as - | PanelInfo - | undefined; - if (!panelInfo) { - return nothing; - } - return html` - - ${getPanelTitle(this.hass, panelInfo)} - - `; - })} - - ${this._dashboards.map((dashboard) => { - if (!this.hass.user!.is_admin && dashboard.require_admin) { - return ""; - } - return html` - - ${dashboard.title} - - `; - })} - ` - : html``} + `; + })} + ${this._dashboards?.length + ? html` + + ${this._dashboards.map((dashboard) => { + if (!this.hass.user!.is_admin && dashboard.require_admin) { + return ""; + } + return html` + + + ${dashboard.title} + + `; + })} + ` + : nothing} + `; } From 7aee2b7cb725fa88b45d5689b0432704dd5eeb97 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 11:42:55 +0100 Subject: [PATCH 14/99] Fix labs back button (#28174) --- src/panels/config/labs/ha-config-labs.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/labs/ha-config-labs.ts b/src/panels/config/labs/ha-config-labs.ts index df35e99801..a34442b900 100644 --- a/src/panels/config/labs/ha-config-labs.ts +++ b/src/panels/config/labs/ha-config-labs.ts @@ -94,7 +94,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) { ${sortedFeatures.length From b6b2d03a80f46bd97b8e2efe504e40d4e2ebc162 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Nov 2025 16:43:36 +0100 Subject: [PATCH 15/99] Always store token when using develop and serve (#28179) --- src/common/auth/token_storage.ts | 7 ++++++- test/common/auth/token_storage/askWrite.test.ts | 8 +++++++- test/common/auth/token_storage/saveTokens.test.ts | 3 +++ test/common/auth/token_storage/token_storage.test.ts | 3 +++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/common/auth/token_storage.ts b/src/common/auth/token_storage.ts index 3610f6b089..c2d168eb96 100644 --- a/src/common/auth/token_storage.ts +++ b/src/common/auth/token_storage.ts @@ -1,5 +1,6 @@ import type { AuthData } from "home-assistant-js-websocket"; import { extractSearchParam } from "../url/search-params"; +import { hassUrl } from "../../data/auth"; declare global { interface Window { @@ -30,7 +31,11 @@ export function askWrite() { export function saveTokens(tokens: AuthData | null) { tokenCache.tokens = tokens; - if (!tokenCache.writeEnabled && extractSearchParam("storeToken") === "true") { + if ( + !tokenCache.writeEnabled && + (extractSearchParam("storeToken") === "true" || + hassUrl !== `${location.protocol}//${location.host}`) + ) { tokenCache.writeEnabled = true; } diff --git a/test/common/auth/token_storage/askWrite.test.ts b/test/common/auth/token_storage/askWrite.test.ts index 2357d23edd..1dcd61376f 100644 --- a/test/common/auth/token_storage/askWrite.test.ts +++ b/test/common/auth/token_storage/askWrite.test.ts @@ -1,8 +1,14 @@ -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; let askWrite; +const HASS_URL = `${location.protocol}//${location.host}`; + describe("token_storage.askWrite", () => { + beforeEach(() => { + vi.stubGlobal("__HASS_URL__", HASS_URL); + }); + afterEach(() => { vi.resetModules(); }); diff --git a/test/common/auth/token_storage/saveTokens.test.ts b/test/common/auth/token_storage/saveTokens.test.ts index b98d310cf3..0222f50974 100644 --- a/test/common/auth/token_storage/saveTokens.test.ts +++ b/test/common/auth/token_storage/saveTokens.test.ts @@ -4,9 +4,12 @@ import { FallbackStorage } from "../../../test_helper/local-storage-fallback"; let saveTokens; +const HASS_URL = `${location.protocol}//${location.host}`; + describe("token_storage.saveTokens", () => { beforeEach(() => { window.localStorage = new FallbackStorage(); + vi.stubGlobal("__HASS_URL__", HASS_URL); }); afterEach(() => { diff --git a/test/common/auth/token_storage/token_storage.test.ts b/test/common/auth/token_storage/token_storage.test.ts index 42ed3ead23..18cb5ecf9e 100644 --- a/test/common/auth/token_storage/token_storage.test.ts +++ b/test/common/auth/token_storage/token_storage.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, test, vi, afterEach, beforeEach } from "vitest"; import type { AuthData } from "home-assistant-js-websocket"; import { FallbackStorage } from "../../../test_helper/local-storage-fallback"; +const HASS_URL = `${location.protocol}//${location.host}`; + describe("token_storage", () => { beforeEach(() => { vi.stubGlobal( @@ -11,6 +13,7 @@ describe("token_storage", () => { writeEnabled: undefined, }) ); + vi.stubGlobal("__HASS_URL__", HASS_URL); window.localStorage = new FallbackStorage(); }); From acd51814497e8c1ed85b3b310149df85357668c8 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 27 Nov 2025 15:41:46 +0200 Subject: [PATCH 16/99] Fix sankey chart resizing (#28180) --- src/components/chart/ha-sankey-chart.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chart/ha-sankey-chart.ts b/src/components/chart/ha-sankey-chart.ts index 36283f9009..70b5023c30 100644 --- a/src/components/chart/ha-sankey-chart.ts +++ b/src/components/chart/ha-sankey-chart.ts @@ -279,6 +279,7 @@ export class HaSankeyChart extends LitElement { :host { display: block; flex: 1; + max-width: 100%; background: var(--ha-card-background, var(--card-background-color)); } ha-chart-base { From b27b7210fd17a83e34b58fe6f5a4ebd21734740e Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:44:50 +0100 Subject: [PATCH 17/99] Show hidden entities in target tree (#28181) * Show hidden entities in target tree * Fix types --- src/data/area_registry.ts | 10 ++-------- src/data/device_registry.ts | 10 ++-------- .../config/automation/add-automation-element-dialog.ts | 4 ++-- .../ha-automation-add-from-target.ts | 10 +++++++--- src/translations/en.json | 1 + 5 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 8161e8a64e..915a26c005 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -75,17 +75,11 @@ export const reorderAreaRegistryEntries = ( }); export const getAreaEntityLookup = ( - entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], - filterHidden = false + entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[] ): AreaEntityLookup => { const areaEntityLookup: AreaEntityLookup = {}; for (const entity of entities) { - if ( - !entity.area_id || - (filterHidden && - ((entity as EntityRegistryDisplayEntry).hidden || - (entity as EntityRegistryEntry).hidden_by)) - ) { + if (!entity.area_id) { continue; } if (!(entity.area_id in areaEntityLookup)) { diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index b342832be2..861983bb4c 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -111,17 +111,11 @@ export const sortDeviceRegistryByName = ( ); export const getDeviceEntityLookup = ( - entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[], - filterHidden = false + entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[] ): DeviceEntityLookup => { const deviceEntityLookup: DeviceEntityLookup = {}; for (const entity of entities) { - if ( - !entity.device_id || - (filterHidden && - ((entity as EntityRegistryDisplayEntry).hidden || - (entity as EntityRegistryEntry).hidden_by)) - ) { + if (!entity.device_id) { continue; } if (!(entity.device_id in deviceEntityLookup)) { diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 4f7586b42b..26d2c0d40e 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -1365,12 +1365,12 @@ class DialogAddAutomationElement private _getAreaEntityLookupMemoized = memoizeOne( (entities: HomeAssistant["entities"]) => - getAreaEntityLookup(Object.values(entities), true) + getAreaEntityLookup(Object.values(entities)) ); private _getDeviceEntityLookupMemoized = memoizeOne( (entities: HomeAssistant["entities"]) => - getDeviceEntityLookup(Object.values(entities), true) + getDeviceEntityLookup(Object.values(entities)) ); private _extractTypeAndIdFromTarget = memoizeOne( diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts index 9b4f00b89b..8cfd1ea397 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -708,7 +708,11 @@ export default class HaAutomationAddFromTarget extends LitElement { this.floors ); - const label = entityName || deviceName || entityId; + let label = entityName || deviceName || entityId; + + if (this.entities[entityId]?.hidden) { + label += ` (${this.localize("ui.panel.config.automation.editor.entity_hidden")})`; + } return [entityId, label, stateObj] as [string, string, HassEntity]; }) @@ -837,12 +841,12 @@ export default class HaAutomationAddFromTarget extends LitElement { private _getAreaEntityLookupMemoized = memoizeOne( (entities: HomeAssistant["entities"]) => - getAreaEntityLookup(Object.values(entities), true) + getAreaEntityLookup(Object.values(entities)) ); private _getDeviceEntityLookupMemoized = memoizeOne( (entities: HomeAssistant["entities"]) => - getDeviceEntityLookup(Object.values(entities), true) + getDeviceEntityLookup(Object.values(entities)) ); private _getSelectedTargetId = memoizeOne( diff --git a/src/translations/en.json b/src/translations/en.json index 35546d1dd7..04ed305d92 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4045,6 +4045,7 @@ "other_areas": "Other areas", "services": "Services", "helpers": "Helpers", + "entity_hidden": "[%key:ui::panel::config::devices::entities::hidden%]", "triggers": { "name": "Triggers", "header": "When", From 4a5e1f9f3fe6a8c3cbd2a51d483bee4bb1936cc9 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:42:18 +0100 Subject: [PATCH 18/99] "Add TCA" dialog desktop height to 800px (#28182) --- src/panels/config/automation/add-automation-element-dialog.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 26d2c0d40e..0ea2f9cf35 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -1910,7 +1910,7 @@ class DialogAddAutomationElement ha-wa-dialog { --dialog-content-padding: var(--ha-space-0); --ha-dialog-min-height: min( - 648px, + 800px, calc( 100vh - max( var(--safe-area-inset-bottom), @@ -1919,7 +1919,7 @@ class DialogAddAutomationElement ) ); --ha-dialog-min-height: min( - 648px, + 800px, calc( 100dvh - max( var(--safe-area-inset-bottom), From 225ccf1d2f4c3fedae5e52a478c5849434fda328 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:57:42 +0100 Subject: [PATCH 19/99] Fix lab automations icons and sidebar width (#28184) Co-authored-by: Petar Petrov --- src/components/ha-icon.ts | 1 + .../ha-automation-add-items.ts | 12 ++++++++---- .../types/ha-automation-condition-platform.ts | 4 ++++ .../trigger/types/ha-automation-trigger-platform.ts | 4 ++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/ha-icon.ts b/src/components/ha-icon.ts index 5016e38c6a..37e32cbc17 100644 --- a/src/components/ha-icon.ts +++ b/src/components/ha-icon.ts @@ -186,6 +186,7 @@ export class HaIcon extends LitElement { static styles = css` :host { + display: flex; fill: currentcolor; } `; diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts index 7e31cb32c6..51657ff86c 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts @@ -306,7 +306,7 @@ export class HaAutomationAddItems extends LitElement { .items .item-headline { display: flex; align-items: center; - gap: var(--ha-space-1); + gap: var(--ha-space-2); min-height: var(--ha-space-9); flex-wrap: wrap; } @@ -366,12 +366,16 @@ export class HaAutomationAddItems extends LitElement { } .selected-target state-badge { - --mdc-icon-size: 20px; + --mdc-icon-size: 24px; } .selected-target state-badge, - .selected-target ha-domain-icon { + .selected-target ha-floor-icon { + display: flex; + height: 32px; width: 24px; - height: 24px; + align-items: center; + } + .selected-target ha-domain-icon { filter: grayscale(100%); } `; diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts b/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts index 9a9f442e73..27ca52003b 100644 --- a/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts +++ b/src/panels/config/automation/condition/types/ha-automation-condition-platform.ts @@ -393,6 +393,10 @@ export class HaPlatformCondition extends LitElement { } static styles = css` + :host { + display: block; + margin: 0px calc(-1 * var(--ha-space-4)); + } ha-settings-row { padding: 0 var(--ha-space-4); } diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-platform.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-platform.ts index 12c0f0611f..4952793a5a 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-platform.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-platform.ts @@ -429,6 +429,10 @@ export class HaPlatformTrigger extends LitElement { } static styles = css` + :host { + display: block; + margin: 0px calc(-1 * var(--ha-space-4)); + } ha-settings-row { padding: 0 var(--ha-space-4); } From a1d7e270ff1ba87cd008546a144a65db9cfa7e02 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 16:32:23 +0100 Subject: [PATCH 20/99] Add hint to reorder areas and floors (#28189) --- .../config/areas/ha-config-areas-dashboard.ts | 6 +++++- src/panels/home/dialogs/dialog-edit-home.ts | 16 ++++++++++++++++ src/translations/en.json | 4 +++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index 79fdc1c70c..5f146ee9ea 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -84,6 +84,8 @@ export class HaConfigAreasDashboard extends LitElement { @property({ attribute: false }) public route!: Route; + private _searchParms = new URLSearchParams(window.location.search); + @state() private _hierarchy?: AreasFloorHierarchy; private _blockHierarchyUpdate = false; @@ -167,7 +169,9 @@ export class HaConfigAreasDashboard extends LitElement { .hass=${this.hass} .narrow=${this.narrow} .isWide=${this.isWide} - back-path="/config" + .backPath=${this._searchParms.has("historyBack") + ? undefined + : "/config"} .tabs=${configSections.areas} .route=${this.route} has-fab diff --git a/src/panels/home/dialogs/dialog-edit-home.ts b/src/panels/home/dialogs/dialog-edit-home.ts index a93b8cb728..43a6ead67d 100644 --- a/src/panels/home/dialogs/dialog-edit-home.ts +++ b/src/panels/home/dialogs/dialog-edit-home.ts @@ -2,6 +2,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/entity/ha-entities-picker"; +import "../../../components/ha-alert"; import "../../../components/ha-button"; import "../../../components/ha-dialog-footer"; import "../../../components/ha-wa-dialog"; @@ -78,6 +79,16 @@ export class DialogEditHome @value-changed=${this._favoriteEntitiesChanged} > + + ${this.hass.localize("ui.panel.home.editor.areas_hint", { + areas_page: html`${this.hass.localize("ui.panel.home.editor.areas_page")}`, + })} + + Date: Thu, 27 Nov 2025 17:44:56 +0200 Subject: [PATCH 21/99] Fix water sankey calculation to include total supply from sources (#28191) --- .../cards/water/hui-water-sankey-card.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/panels/lovelace/cards/water/hui-water-sankey-card.ts b/src/panels/lovelace/cards/water/hui-water-sankey-card.ts index 61bccb87ae..79718cfd16 100644 --- a/src/panels/lovelace/cards/water/hui-water-sankey-card.ts +++ b/src/panels/lovelace/cards/water/hui-water-sankey-card.ts @@ -98,17 +98,32 @@ class HuiWaterSankeyCard const nodes: Node[] = []; const links: Link[] = []; - // Calculate total water consumption from all devices - let totalWaterConsumption = 0; - prefs.device_consumption_water.forEach((device) => { + // Calculate total water consumption from all sources or devices + const totalDownstreamConsumption = prefs.device_consumption_water.reduce( + (total, device) => { + const value = + device.stat_consumption in this._data!.stats + ? calculateStatisticSumGrowth( + this._data!.stats[device.stat_consumption] + ) || 0 + : 0; + return total + value; + }, + 0 + ); + const totalSourceSupply = waterSources.reduce((total, source) => { const value = - device.stat_consumption in this._data!.stats + source.stat_energy_from in this._data!.stats ? calculateStatisticSumGrowth( - this._data!.stats[device.stat_consumption] + this._data!.stats[source.stat_energy_from] ) || 0 : 0; - totalWaterConsumption += value; - }); + return total + value; + }, 0); + const totalWaterConsumption = Math.max( + totalDownstreamConsumption, + totalSourceSupply + ); // Create home/consumption node const homeNode: Node = { From a00e944a354ede029ecdcb6ca00e310692eff6d6 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:03:27 +0100 Subject: [PATCH 22/99] Add TCA by target sort like item collections (#28192) --- .../add-automation-element-dialog.ts | 79 +++++++++++++++++-- 1 file changed, 71 insertions(+), 8 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 0ea2f9cf35..393c7969ec 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -1350,6 +1350,61 @@ class DialogAddAutomationElement this._labelRegistry?.find(({ label_id }) => label_id === labelId) ); + private _getDomainType(domain: string) { + return ENTITY_DOMAINS_MAIN.has(domain) || + (this._manifests?.[domain].integration_type === "entity" && + !ENTITY_DOMAINS_OTHER.has(domain)) + ? "dynamicGroups" + : this._manifests?.[domain].integration_type === "helper" + ? "helpers" + : "other"; + } + + private _sortDomainsByCollection( + type: AddAutomationElementDialogParams["type"], + entries: [ + string, + { title: string; items: AddAutomationElementListItem[] }, + ][] + ): { title: string; items: AddAutomationElementListItem[] }[] { + const order: string[] = []; + + TYPES[type].collections.forEach((collection) => { + order.push(...Object.keys(collection.groups)); + }); + + return entries + .sort((a, b) => { + const domainA = a[0]; + const domainB = b[0]; + + if (order.includes(domainA) && order.includes(domainB)) { + return order.indexOf(domainA) - order.indexOf(domainB); + } + + let typeA = domainA; + let typeB = domainB; + + if (!order.includes(domainA)) { + typeA = this._getDomainType(domainA); + } + + if (!order.includes(domainB)) { + typeB = this._getDomainType(domainB); + } + + if (typeA === typeB) { + return stringCompare( + a[1].title, + b[1].title, + this.hass.locale.language + ); + } + return order.indexOf(typeA) - order.indexOf(typeB); + }) + .map((entry) => entry[1]); + } + // #endregion data // #region data memoize @@ -1435,8 +1490,9 @@ class DialogAddAutomationElement ); }); - return Object.values(items).sort((a, b) => - stringCompare(a.title, b.title, this.hass.locale.language) + return this._sortDomainsByCollection( + this._params!.type, + Object.entries(items) ); } @@ -1545,8 +1601,9 @@ class DialogAddAutomationElement ); }); - return Object.values(items).sort((a, b) => - stringCompare(a.title, b.title, this.hass.locale.language) + return this._sortDomainsByCollection( + this._params!.type, + Object.entries(items) ); } @@ -1577,8 +1634,9 @@ class DialogAddAutomationElement ); }); - return Object.values(items).sort((a, b) => - stringCompare(a.title, b.title, this.hass.locale.language) + return this._sortDomainsByCollection( + this._params!.type, + Object.entries(items) ); } @@ -1675,14 +1733,19 @@ class DialogAddAutomationElement } if (this._params!.type === "action") { - const items = await getServicesForTarget( + const items: string[] = await getServicesForTarget( this.hass.callWS, this._selectedTarget ); + const filteredItems = items.filter( + // homeassistant services are too generic to be applied on the selected target + (service) => !service.startsWith("homeassistant.") + ); + this._targetItems = this._getDomainGroupedActionListItems( this.hass.localize, - items + filteredItems ); } } catch (err) { From 38b7bd18bbcb24faddb48b0b8202999df895b0db Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 27 Nov 2025 17:06:57 +0100 Subject: [PATCH 23/99] Bumped version to 20251127.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4d8aba79f4..45ced7d59b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251126.0" +version = "20251127.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From 407d68250a43a182e4b925293308fc6bbf909329 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 27 Nov 2025 21:01:30 +0000 Subject: [PATCH 24/99] Fix ha-wa-dialog fullscreen and make alerts not fullscreen (#28175) --- src/components/ha-wa-dialog.ts | 73 +++++++++++++++++------------ src/dialogs/generic/dialog-box.ts | 16 ++++++- src/resources/theme/main.globals.ts | 25 +++++++--- 3 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/components/ha-wa-dialog.ts b/src/components/ha-wa-dialog.ts index 0203047995..a571acc38b 100644 --- a/src/components/ha-wa-dialog.ts +++ b/src/components/ha-wa-dialog.ts @@ -53,6 +53,7 @@ export type DialogWidth = "small" | "medium" | "large" | "full"; * @cssprop --dialog-surface-margin-top - Top margin for the dialog surface. * * @attr {boolean} open - Controls the dialog open state. + * @attr {("alert"|"standard")} type - Dialog type. Defaults to "standard". * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. * @attr {string} header-title - Header title text. If not set, the headerTitle slot is used. @@ -84,6 +85,9 @@ export class HaWaDialog extends LitElement { @property({ type: Boolean, reflect: true }) public open = false; + @property({ reflect: true }) + public type: "alert" | "standard" = "standard"; + @property({ type: String, reflect: true, attribute: "width" }) public width: DialogWidth = "medium"; @@ -200,18 +204,7 @@ export class HaWaDialog extends LitElement { haStyleScrollbar, css` wa-dialog { - --full-width: var( - --ha-dialog-width-full, - min( - 95vw, - calc( - 100vw - var(--safe-area-inset-left, var(--ha-space-0)) - var( - --safe-area-inset-right, - var(--ha-space-0) - ) - ) - ) - ); + --full-width: var(--ha-dialog-width-full, min(95vw, var(--safe-width))); --width: min(var(--ha-dialog-width-md, 580px), var(--full-width)); --spacing: var(--dialog-content-padding, var(--ha-space-6)); --show-duration: var(--ha-dialog-show-duration, 200ms); @@ -228,8 +221,7 @@ export class HaWaDialog extends LitElement { --ha-dialog-border-radius, var(--ha-border-radius-3xl) ); - max-width: var(--ha-dialog-max-width, 100vw); - max-width: var(--ha-dialog-max-width, 100svw); + max-width: var(--ha-dialog-max-width, var(--safe-width)); } :host([width="small"]) wa-dialog { @@ -249,34 +241,57 @@ export class HaWaDialog extends LitElement { max-width: var(--width, var(--full-width)); max-height: var( --ha-dialog-max-height, - calc(100% - var(--ha-space-20)) + calc(var(--safe-height) - var(--ha-space-20)) ); min-height: var(--ha-dialog-min-height); position: var(--dialog-surface-position, relative); margin-top: var(--dialog-surface-margin-top, auto); + /* Used to offset the dialog from the safe areas when space is limited */ + transform: translate( + calc( + var(--safe-area-offset-left, var(--ha-space-0)) - var( + --safe-area-offset-right, + var(--ha-space-0) + ) + ), + calc( + var(--safe-area-offset-top, var(--ha-space-0)) - var( + --safe-area-offset-bottom, + var(--ha-space-0) + ) + ) + ); display: flex; flex-direction: column; overflow: hidden; } @media all and (max-width: 450px), all and (max-height: 500px) { - :host { + :host([type="standard"]) { --ha-dialog-border-radius: var(--ha-space-0); - } - wa-dialog { - --full-width: var(--ha-dialog-width-full, 100vw); - } + wa-dialog { + /* Make the container fill the whole screen width and not the safe width */ + --full-width: var(--ha-dialog-width-full, 100vw); + --width: var(--full-width); + } - wa-dialog::part(dialog) { - min-height: var(--ha-dialog-min-height, 100vh); - min-height: var(--ha-dialog-min-height, 100svh); - max-height: var(--ha-dialog-max-height, 100vh); - max-height: var(--ha-dialog-max-height, 100svh); - padding-top: var(--safe-area-inset-top, var(--ha-space-0)); - padding-bottom: var(--safe-area-inset-bottom, var(--ha-space-0)); - padding-left: var(--safe-area-inset-left, var(--ha-space-0)); - padding-right: var(--safe-area-inset-right, var(--ha-space-0)); + wa-dialog::part(dialog) { + /* Make the dialog fill the whole screen height and not the safe height */ + min-height: var(--ha-dialog-min-height, 100vh); + min-height: var(--ha-dialog-min-height, 100dvh); + max-height: var(--ha-dialog-max-height, 100vh); + max-height: var(--ha-dialog-max-height, 100dvh); + margin-top: 0; + margin-bottom: 0; + /* Use safe area as padding instead of the container size */ + padding-top: var(--safe-area-inset-top); + padding-bottom: var(--safe-area-inset-bottom); + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); + /* Reset the transform to center the dialog */ + transform: none; + } } } diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 281c4c5876..a49ca83e83 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -1,6 +1,7 @@ import { mdiAlertOutline, mdiClose } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-button"; @@ -64,6 +65,7 @@ class DialogBox extends LitElement { ` : nothing} - + ${this._params.warning ? html` Date: Thu, 27 Nov 2025 18:46:56 +0100 Subject: [PATCH 25/99] Fix safe area for sidebar section views in Android (#28194) --- src/panels/lovelace/views/hui-sections-view.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index c412f392f7..588ee41b7f 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -514,8 +514,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { .wrapper.narrow hui-view-sidebar { grid-column: 1 / -1; padding-bottom: calc( - var(--ha-space-4) + 56px + var(--ha-space-4) + - env(safe-area-inset-bottom) + var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom) ); } @@ -525,7 +524,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { .mobile-tabs { position: fixed; - bottom: calc(var(--ha-space-3) + env(safe-area-inset-bottom)); + bottom: calc(var(--ha-space-3) + var(--safe-area-inset-bottom)); left: 50%; transform: translateX(-50%); padding: 0; @@ -566,8 +565,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { .wrapper.narrow.has-sidebar .content { padding-bottom: calc( - var(--ha-space-4) + 56px + var(--ha-space-4) + - env(safe-area-inset-bottom) + var(--ha-space-14) + var(--ha-space-3) + var(--safe-area-inset-bottom) ); } From 21509191fa19ba401b7efde6804666be01d667d0 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 27 Nov 2025 22:54:44 +0100 Subject: [PATCH 26/99] Fix ha icon size (#28201) --- src/components/ha-icon.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ha-icon.ts b/src/components/ha-icon.ts index 37e32cbc17..5016e38c6a 100644 --- a/src/components/ha-icon.ts +++ b/src/components/ha-icon.ts @@ -186,7 +186,6 @@ export class HaIcon extends LitElement { static styles = css` :host { - display: flex; fill: currentcolor; } `; From add22cf2e9f8b90173089af25b067d55756ecb0b Mon Sep 17 00:00:00 2001 From: Silas Krause Date: Fri, 28 Nov 2025 07:28:57 +0100 Subject: [PATCH 27/99] Fix markdown styles regression (#28202) * Render markdown table in wrapper. * Fix markdown styles * Fix formatting --- src/components/ha-markdown.ts | 14 ++++++++------ src/resources/markdown-worker.ts | 12 ++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index a1e7b886b7..404e24b6b9 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -88,8 +88,7 @@ export class HaMarkdown extends LitElement { } ol, ul { - list-style-position: inside; - padding-inline-start: 0; + padding-inline-start: 1rem; } li { &:has(input[type="checkbox"]) { @@ -140,16 +139,19 @@ export class HaMarkdown extends LitElement { margin: var(--ha-space-4) 0; } table { - border-collapse: collapse; - display: block; - overflow-x: auto; + border-collapse: var(--markdown-table-border-collapse, collapse); + } + div:has(> table) { + overflow: auto; } th { text-align: start; } td, th { - border: 1px solid var(--markdown-table-border-color, transparent); + border-width: var(--markdown-table-border-width, 1px); + border-style: var(--markdown-table-border-style, solid); + border-color: var(--markdown-table-border-color, var(--divider-color)); padding: 0.25em 0.5em; } blockquote { diff --git a/src/resources/markdown-worker.ts b/src/resources/markdown-worker.ts index d415c2391f..dfade11123 100644 --- a/src/resources/markdown-worker.ts +++ b/src/resources/markdown-worker.ts @@ -55,6 +55,18 @@ const renderMarkdown = async ( marked.setOptions(markedOptions); + marked.use({ + renderer: { + table(...args) { + const defaultRenderer = new marked.Renderer(); + // Wrap the table with block element because the property 'overflow' + // cannot be applied to elements of display type 'table'. + // https://www.w3.org/TR/css-overflow-3/#overflow-control + return `
${defaultRenderer.table.apply(this, args)}
`; + }, + }, + }); + const tokens = marked.lexer(content); return tokens.map((token) => filterXSS(marked.parser([token]), { From 5b1719fc6ec51e9ac6660680b42dc9a1a4b6aa95 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sat, 29 Nov 2025 03:35:04 -0800 Subject: [PATCH 28/99] Add missing helper to language selector (#28218) --- src/components/ha-language-picker.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts index dc129dbf4c..1f3f824be5 100644 --- a/src/components/ha-language-picker.ts +++ b/src/components/ha-language-picker.ts @@ -73,6 +73,8 @@ export class HaLanguagePicker extends LitElement { @property({ type: Boolean }) public required = false; + @property() public helper?: string; + @property({ attribute: "native-name", type: Boolean }) public nativeName = false; @@ -135,6 +137,7 @@ export class HaLanguagePicker extends LitElement { .value=${value} .valueRenderer=${this._valueRenderer} .disabled=${this.disabled} + .helper=${this.helper} .getItems=${this._getItems} @value-changed=${this._changed} hide-clear-icon From d5b66315e232a7c81089f0bd4b99e214800e13fe Mon Sep 17 00:00:00 2001 From: Silas Krause Date: Sun, 30 Nov 2025 14:21:39 +0100 Subject: [PATCH 29/99] Fix markdown rendering for cached html (#28229) * Render markdown table in wrapper. * Fix markdown styles * Fix formatting * fix rendering for cache --- src/components/ha-markdown-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ha-markdown-element.ts b/src/components/ha-markdown-element.ts index a2f30f7ff1..ec89e5ff21 100644 --- a/src/components/ha-markdown-element.ts +++ b/src/components/ha-markdown-element.ts @@ -71,7 +71,7 @@ class HaMarkdownElement extends ReactiveElement { if (!this.innerHTML && this.cache) { const key = this._computeCacheKey(); if (markdownCache.has(key)) { - render(markdownCache.get(key)!, this.renderRoot); + render(h(unsafeHTML(markdownCache.get(key))), this.renderRoot); this._resize(); } } From ecd563406e792d7bfadb2b5c1d6991209e920d4a Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 1 Dec 2025 15:16:11 +0200 Subject: [PATCH 30/99] Add power view and restructure energy dashboard layout (#28240) --- src/panels/energy/ha-panel-energy.ts | 57 +++++++-- .../energy-overview-view-strategy.ts | 109 +++++++++++------- ...ew-strategy.ts => energy-view-strategy.ts} | 35 +----- .../energy/strategies/power-view-strategy.ts | 76 ++++++++++++ ...iew-strategy.ts => water-view-strategy.ts} | 27 ++--- .../energy/hui-power-sources-graph-card.ts | 6 +- .../components/hui-energy-period-selector.ts | 23 ++-- .../lovelace/strategies/get-strategy.ts | 7 +- src/resources/echarts/echarts.ts | 8 ++ src/translations/en.json | 5 +- 10 files changed, 234 insertions(+), 119 deletions(-) rename src/panels/energy/strategies/{energy-electricity-view-strategy.ts => energy-view-strategy.ts} (81%) create mode 100644 src/panels/energy/strategies/power-view-strategy.ts rename src/panels/energy/strategies/{energy-water-view-strategy.ts => water-view-strategy.ts} (81%) diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index 3c7b84a741..e5eb939f34 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -23,7 +23,10 @@ import { getSummedData, } from "../../data/energy"; import type { LovelaceConfig } from "../../data/lovelace/config/types"; -import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; +import { + isStrategyView, + type LovelaceViewConfig, +} from "../../data/lovelace/config/view"; import type { StatisticValue } from "../../data/recorder"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, PanelInfo } from "../../types"; @@ -33,6 +36,7 @@ import "../lovelace/hui-root"; import type { Lovelace } from "../lovelace/types"; import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view-container"; +import type { LocalizeKeys } from "../../common/translations/localize"; export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard"; @@ -47,15 +51,17 @@ const OVERVIEW_VIEW = { strategy: { type: "energy-overview", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, + allow_compare: false, }, } as LovelaceViewConfig; -const ELECTRICITY_VIEW = { +const ENERGY_VIEW = { path: "electricity", back_path: "/energy", strategy: { - type: "energy-electricity", + type: "energy", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, + allow_compare: true, }, } as LovelaceViewConfig; @@ -63,8 +69,19 @@ const WATER_VIEW = { back_path: "/energy", path: "water", strategy: { - type: "energy-water", + type: "water", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, + allow_compare: true, + }, +} as LovelaceViewConfig; + +const POWER_VIEW = { + back_path: "/energy", + path: "power", + strategy: { + type: "power", + collection_key: DEFAULT_ENERGY_COLLECTION_KEY, + allow_compare: false, }, } as LovelaceViewConfig; @@ -208,6 +225,7 @@ class PanelEnergy extends LitElement { views.findIndex((view) => view.path === viewPath), 0 ); + const view = views[viewIndex]; const showBack = this._searchParms.has("historyBack") || viewIndex > 0; @@ -237,13 +255,16 @@ class PanelEnergy extends LitElement { `} ${!this.narrow ? html`
- ${this.hass.localize("panel.energy")} + ${this.hass.localize( + `ui.panel.energy.title.${viewPath}` as LocalizeKeys + ) || this.hass.localize("panel.energy")}
` : nothing} ${this.hass.user?.is_admin ? html` @@ -284,23 +305,35 @@ class PanelEnergy extends LitElement { }; } - const isElectricityOnly = this._prefs.energy_sources.every((source) => + const hasEnergy = this._prefs.energy_sources.some((source) => ["grid", "solar", "battery"].includes(source.type) ); - if (isElectricityOnly) { - return { - views: [ELECTRICITY_VIEW], - }; - } + + const hasPower = + this._prefs.energy_sources.some( + (source) => + (source.type === "solar" && source.stat_rate) || + (source.type === "battery" && source.stat_rate) || + (source.type === "grid" && source.power?.length) + ) || this._prefs.device_consumption.some((device) => device.stat_rate); const hasWater = this._prefs.energy_sources.some((source) => source.type === "water") || this._prefs.device_consumption_water?.length > 0; - const views: LovelaceViewConfig[] = [OVERVIEW_VIEW, ELECTRICITY_VIEW]; + const views: LovelaceViewConfig[] = []; + if (hasEnergy) { + views.push(ENERGY_VIEW); + } + if (hasPower) { + views.push(POWER_VIEW); + } if (hasWater) { views.push(WATER_VIEW); } + if (views.length > 1) { + views.unshift(OVERVIEW_VIEW); + } return { views }; } diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index 3933067e3b..dae99d461b 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -55,9 +55,10 @@ export class EnergyViewStrategy extends ReactiveElement { const hasBattery = prefs.energy_sources.some( (source) => source.type === "battery" ); - const hasWater = prefs.energy_sources.some( + const hasWaterSources = prefs.energy_sources.some( (source) => source.type === "water" ); + const hasWaterDevices = prefs.device_consumption_water?.length; const hasPowerSources = prefs.energy_sources.find( (source) => (source.type === "solar" && source.stat_rate) || @@ -80,21 +81,6 @@ export class EnergyViewStrategy extends ReactiveElement { column_span: 24, cards: [], }; - if (hasPowerSources && hasPowerDevices) { - const showFloorsNAreas = !prefs.device_consumption.some( - (d) => d.included_in_stat - ); - overviewSection.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sankey_title"), - type: "power-sankey", - collection_key: collectionKey, - group_by_floor: showFloorsNAreas, - group_by_area: showFloorsNAreas, - grid_options: { - columns: 24, - }, - }); - } // Only include if we have a grid or battery. if (hasGrid || hasBattery) { overviewSection.cards!.push({ @@ -112,12 +98,50 @@ export class EnergyViewStrategy extends ReactiveElement { } view.sections!.push(overviewSection); - const electricitySection: LovelaceSectionConfig = { + const powerSection: LovelaceSectionConfig = { type: "grid", cards: [ { type: "heading", - heading: hass.localize("ui.panel.energy.overview.electricity"), + heading: hass.localize("ui.panel.energy.title.power"), + tap_action: { + action: "navigate", + navigation_path: "/energy/power", + }, + }, + ], + }; + if (hasPowerDevices) { + const showFloorsNAreas = !prefs.device_consumption.some( + (d) => d.included_in_stat + ); + powerSection.cards!.push({ + title: hass.localize("ui.panel.energy.cards.power_sankey_title"), + type: "power-sankey", + collection_key: collectionKey, + group_by_floor: showFloorsNAreas, + group_by_area: showFloorsNAreas, + grid_options: { + columns: 24, + }, + }); + powerSection.column_span = 24; + view.sections!.push(powerSection); + } else if (hasPowerSources) { + powerSection.cards!.push({ + title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), + type: "power-sources-graph", + collection_key: collectionKey, + }); + view.sections!.push(powerSection); + } + + const energySection: LovelaceSectionConfig = { + type: "grid", + cards: [ + { + type: "heading", + heading: hass.localize("ui.panel.energy.title.energy"), tap_action: { action: "navigate", navigation_path: "/energy/electricity", @@ -125,15 +149,16 @@ export class EnergyViewStrategy extends ReactiveElement { }, ], }; - - if (hasPowerSources) { - electricitySection.cards!.push({ - type: "power-sources-graph", - collection_key: collectionKey, + view.sections!.push(energySection); + if (hasGrid || hasBattery) { + energySection.cards!.push({ + title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"), + type: "energy-usage-graph", + collection_key: "energy_dashboard", }); } if (prefs!.device_consumption.length > 3) { - electricitySection.cards!.push({ + energySection.cards!.push({ title: hass.localize( "ui.panel.energy.cards.energy_top_consumers_title" ), @@ -142,23 +167,15 @@ export class EnergyViewStrategy extends ReactiveElement { max_devices: 3, modes: ["bar"], }); - } else if (hasGrid) { - electricitySection.cards!.push({ - title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"), - type: "energy-usage-graph", - collection_key: collectionKey, - }); } - view.sections!.push(electricitySection); - if (hasGas) { view.sections!.push({ type: "grid", cards: [ { type: "heading", - heading: hass.localize("ui.panel.energy.overview.gas"), + heading: hass.localize("ui.panel.energy.title.gas"), }, { title: hass.localize( @@ -171,25 +188,33 @@ export class EnergyViewStrategy extends ReactiveElement { }); } - if (hasWater) { + if (hasWaterSources || hasWaterDevices) { view.sections!.push({ type: "grid", cards: [ { type: "heading", - heading: hass.localize("ui.panel.energy.overview.water"), + heading: hass.localize("ui.panel.energy.title.water"), tap_action: { action: "navigate", navigation_path: "/energy/water", }, }, - { - title: hass.localize( - "ui.panel.energy.cards.energy_water_graph_title" - ), - type: "energy-water-graph", - collection_key: collectionKey, - }, + hasWaterSources + ? { + title: hass.localize( + "ui.panel.energy.cards.energy_water_graph_title" + ), + type: "energy-water-graph", + collection_key: collectionKey, + } + : { + title: hass.localize( + "ui.panel.energy.cards.water_sankey_title" + ), + type: "water-sankey", + collection_key: collectionKey, + }, ], }); } diff --git a/src/panels/energy/strategies/energy-electricity-view-strategy.ts b/src/panels/energy/strategies/energy-view-strategy.ts similarity index 81% rename from src/panels/energy/strategies/energy-electricity-view-strategy.ts rename to src/panels/energy/strategies/energy-view-strategy.ts index 9f5bc131f9..e3e4cd002f 100644 --- a/src/panels/energy/strategies/energy-electricity-view-strategy.ts +++ b/src/panels/energy/strategies/energy-view-strategy.ts @@ -7,8 +7,8 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; -@customElement("energy-electricity-view-strategy") -export class EnergyElectricityViewStrategy extends ReactiveElement { +@customElement("energy-view-strategy") +export class EnergyViewStrategy extends ReactiveElement { static async generate( _config: LovelaceStrategyConfig, hass: HomeAssistant @@ -46,15 +46,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { const hasBattery = prefs.energy_sources.some( (source) => source.type === "battery" ); - const hasPowerSources = prefs.energy_sources.find( - (source) => - (source.type === "solar" && source.stat_rate) || - (source.type === "battery" && source.stat_rate) || - (source.type === "grid" && source.power?.length) - ); - const hasPowerDevices = prefs.device_consumption.find( - (device) => device.stat_rate - ); const showFloorsNAreas = !prefs.device_consumption.some( (d) => d.included_in_stat ); @@ -64,26 +55,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { collection_key: "energy_dashboard", }); - if (hasPowerSources) { - if (hasPowerDevices) { - view.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sankey_title"), - type: "power-sankey", - collection_key: collectionKey, - group_by_floor: showFloorsNAreas, - group_by_area: showFloorsNAreas, - grid_options: { - columns: 24, - }, - }); - } - view.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), - type: "power-sources-graph", - collection_key: collectionKey, - }); - } - // Only include if we have a grid or battery. if (hasGrid || hasBattery) { view.cards!.push({ @@ -190,6 +161,6 @@ export class EnergyElectricityViewStrategy extends ReactiveElement { declare global { interface HTMLElementTagNameMap { - "energy-electricity-view-strategy": EnergyElectricityViewStrategy; + "energy-view-strategy": EnergyViewStrategy; } } diff --git a/src/panels/energy/strategies/power-view-strategy.ts b/src/panels/energy/strategies/power-view-strategy.ts new file mode 100644 index 0000000000..d8623acbfd --- /dev/null +++ b/src/panels/energy/strategies/power-view-strategy.ts @@ -0,0 +1,76 @@ +import { ReactiveElement } from "lit"; +import { customElement } from "lit/decorators"; +import { getEnergyDataCollection } from "../../../data/energy"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; +import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; +import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; + +@customElement("power-view-strategy") +export class PowerViewStrategy extends ReactiveElement { + static async generate( + _config: LovelaceStrategyConfig, + hass: HomeAssistant + ): Promise { + const view: LovelaceViewConfig = { cards: [] }; + + const collectionKey = + _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; + + const energyCollection = getEnergyDataCollection(hass, { + key: collectionKey, + }); + const prefs = energyCollection.prefs; + + const hasPowerSources = prefs?.energy_sources.some( + (source) => + (source.type === "solar" && source.stat_rate) || + (source.type === "battery" && source.stat_rate) || + (source.type === "grid" && source.power?.length) + ); + const hasPowerDevices = prefs?.device_consumption.some( + (device) => device.stat_rate + ); + + // No power sources configured + if (!prefs || (!hasPowerSources && !hasPowerDevices)) { + return view; + } + + view.type = "sidebar"; + + view.cards!.push({ + type: "energy-compare", + collection_key: collectionKey, + }); + + if (hasPowerDevices) { + const showFloorsNAreas = !prefs.device_consumption.some( + (d) => d.included_in_stat + ); + view.cards!.push({ + title: hass.localize("ui.panel.energy.cards.power_sankey_title"), + type: "power-sankey", + collection_key: collectionKey, + group_by_floor: showFloorsNAreas, + group_by_area: showFloorsNAreas, + }); + } + + if (hasPowerSources) { + view.cards!.push({ + title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), + type: "power-sources-graph", + collection_key: collectionKey, + }); + } + + return view; + } +} + +declare global { + interface HTMLElementTagNameMap { + "power-view-strategy": PowerViewStrategy; + } +} diff --git a/src/panels/energy/strategies/energy-water-view-strategy.ts b/src/panels/energy/strategies/water-view-strategy.ts similarity index 81% rename from src/panels/energy/strategies/energy-water-view-strategy.ts rename to src/panels/energy/strategies/water-view-strategy.ts index 5828bf2704..97a932d59e 100644 --- a/src/panels/energy/strategies/energy-water-view-strategy.ts +++ b/src/panels/energy/strategies/water-view-strategy.ts @@ -6,8 +6,8 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; -@customElement("energy-water-view-strategy") -export class EnergyWaterViewStrategy extends ReactiveElement { +@customElement("water-view-strategy") +export class WaterViewStrategy extends ReactiveElement { static async generate( _config: LovelaceStrategyConfig, hass: HomeAssistant @@ -22,27 +22,24 @@ export class EnergyWaterViewStrategy extends ReactiveElement { }); const prefs = energyCollection.prefs; + const hasWaterSources = prefs?.energy_sources.some( + (source) => source.type === "water" + ); + const hasWaterDevices = prefs?.device_consumption_water?.length; + // No water sources available - if ( - !prefs || - (!prefs.device_consumption_water?.length && - !prefs.energy_sources.some((source) => source.type === "water")) - ) { + if (!prefs || (!hasWaterDevices && !hasWaterSources)) { return view; } view.type = "sidebar"; - const hasWater = prefs.energy_sources.some( - (source) => source.type === "water" - ); - view.cards!.push({ type: "energy-compare", collection_key: collectionKey, }); - if (hasWater) { + if (hasWaterSources) { view.cards!.push({ title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"), type: "energy-water-graph", @@ -50,7 +47,7 @@ export class EnergyWaterViewStrategy extends ReactiveElement { }); } - if (hasWater) { + if (hasWaterSources) { view.cards!.push({ title: hass.localize( "ui.panel.energy.cards.energy_sources_table_title" @@ -62,7 +59,7 @@ export class EnergyWaterViewStrategy extends ReactiveElement { } // Only include if we have at least 1 water device in the config. - if (prefs.device_consumption_water?.length) { + if (hasWaterDevices) { const showFloorsNAreas = !prefs.device_consumption_water.some( (d) => d.included_in_stat ); @@ -81,6 +78,6 @@ export class EnergyWaterViewStrategy extends ReactiveElement { declare global { interface HTMLElementTagNameMap { - "energy-water-view-strategy": EnergyWaterViewStrategy; + "water-view-strategy": WaterViewStrategy; } } diff --git a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts index 9f889e27ca..2894c4c226 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts @@ -6,7 +6,7 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; import type { LineSeriesOption } from "echarts/charts"; -import { graphic } from "echarts"; +import { LinearGradient } from "../../../../resources/echarts/echarts"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import type { EnergyData } from "../../../../data/energy"; @@ -213,7 +213,7 @@ export class HuiPowerSourcesGraphCard color: colorHex, stack: "positive", areaStyle: { - color: new graphic.LinearGradient(0, 0, 0, 1, [ + color: new LinearGradient(0, 0, 0, 1, [ { offset: 0, color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, @@ -235,7 +235,7 @@ export class HuiPowerSourcesGraphCard color: colorHex, stack: "negative", areaStyle: { - color: new graphic.LinearGradient(0, 1, 0, 0, [ + color: new LinearGradient(0, 1, 0, 0, [ { offset: 0, color: `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, 0.75)`, diff --git a/src/panels/lovelace/components/hui-energy-period-selector.ts b/src/panels/lovelace/components/hui-energy-period-selector.ts index 20e5031d8e..a1b3b551bd 100644 --- a/src/panels/lovelace/components/hui-energy-period-selector.ts +++ b/src/panels/lovelace/components/hui-energy-period-selector.ts @@ -66,6 +66,9 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { @property({ type: Boolean, reflect: true }) public narrow?; + @property({ type: Boolean, attribute: "allow-compare" }) public allowCompare = + true; + @state() _startDate?: Date; @state() _endDate?: Date; @@ -222,15 +225,17 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) { .label=${this.hass.localize("ui.common.menu")} .path=${mdiDotsVertical} > - - ${this.hass.localize( - "ui.panel.lovelace.components.energy_period_selector.compare" - )} - + ${this.allowCompare + ? html` + ${this.hass.localize( + "ui.panel.lovelace.components.energy_period_selector.compare" + )} + ` + : nothing}
diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index 790eb2b069..7e476e80ae 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -40,10 +40,9 @@ const STRATEGIES: Record> = { import("./original-states/original-states-view-strategy"), "energy-overview": () => import("../../energy/strategies/energy-overview-view-strategy"), - "energy-electricity": () => - import("../../energy/strategies/energy-electricity-view-strategy"), - "energy-water": () => - import("../../energy/strategies/energy-water-view-strategy"), + energy: () => import("../../energy/strategies/energy-view-strategy"), + water: () => import("../../energy/strategies/water-view-strategy"), + power: () => import("../../energy/strategies/power-view-strategy"), map: () => import("./map/map-view-strategy"), iframe: () => import("./iframe/iframe-view-strategy"), area: () => import("./areas/area-view-strategy"), diff --git a/src/resources/echarts/echarts.ts b/src/resources/echarts/echarts.ts index cd0720352a..57bab911e3 100644 --- a/src/resources/echarts/echarts.ts +++ b/src/resources/echarts/echarts.ts @@ -23,6 +23,12 @@ import { LabelLayout, UniversalTransition } from "echarts/features"; // Note that including the CanvasRenderer or SVGRenderer is a required step import { CanvasRenderer } from "echarts/renderers"; +// Import graphic utilities from zrender for use in charts +// This avoids importing from the full "echarts" package which has a separate registry +// zrender is a direct dependency of echarts and always available +// eslint-disable-next-line import/no-extraneous-dependencies +import LinearGradient from "zrender/lib/graphic/LinearGradient"; + import type { // The series option types are defined with the SeriesOption suffix BarSeriesOption, @@ -75,4 +81,6 @@ echarts.use([ ToolboxComponent, ]); +export { LinearGradient }; + export default echarts; diff --git a/src/translations/en.json b/src/translations/en.json index bd75f75803..d6cdb9f21a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -9567,8 +9567,9 @@ } }, "energy": { - "overview": { - "electricity": "Electricity", + "title": { + "energy": "Energy", + "power": "Power", "gas": "Gas", "water": "Water" }, From b60c5467fcf39d98b418e55a6711d335ffe6df68 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 30 Nov 2025 07:00:14 -0800 Subject: [PATCH 31/99] Add water devices to energy data download (#28242) --- src/panels/energy/ha-panel-energy.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index e5eb939f34..de1451c86b 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -357,6 +357,7 @@ class PanelEnergy extends LitElement { const energy_sources = energyData.prefs.energy_sources; const device_consumption = energyData.prefs.device_consumption; + const device_consumption_water = energyData.prefs.device_consumption_water; const stats = energyData.state.stats; const timeSet = new Set(); @@ -542,6 +543,20 @@ class PanelEnergy extends LitElement { printCategory("device_consumption", devices, electricUnit); + if (device_consumption_water) { + const waterDevices: string[] = []; + device_consumption_water.forEach((source) => { + source = source as DeviceConsumptionEnergyPreference; + waterDevices.push(source.stat_consumption); + }); + + printCategory( + "device_consumption_water", + waterDevices, + energyData.state.waterUnit + ); + } + const { summedData, compareSummedData: _ } = getSummedData( energyData.state ); From 6a2fac6a9e9ff7646e2c8475962cbb4c7e7a1660 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 1 Dec 2025 13:07:45 +0200 Subject: [PATCH 32/99] Fix refresh in energy panel subviews (#28252) --- src/panels/energy/ha-panel-energy.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index de1451c86b..746bc748e3 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -163,11 +163,15 @@ class PanelEnergy extends LitElement { } await this._setLovelace(); - // Navigate to first view if not there yet - const firstPath = this._lovelace!.config?.views?.[0]?.path; + // Check if current path is valid, navigate to first view if not + const views = this._lovelace!.config?.views || []; + const validPaths = views.map((view) => view.path); const viewPath: string | undefined = this.route!.path.split("/")[1]; - if (viewPath !== firstPath) { - navigate(`${this.route!.prefix}/${firstPath}`); + if (!viewPath || !validPaths.includes(viewPath)) { + navigate(`${this.route!.prefix}/${validPaths[0]}`); + } else { + // Force hui-root to re-process the route by creating a new route object + this.route = { ...this.route! }; } } From febcbf6242e31d1cedadb4f06ccc29588167a6de Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 1 Dec 2025 11:08:25 +0000 Subject: [PATCH 33/99] Make labs toolbar icon use default color (#28255) --- src/panels/config/labs/ha-config-labs.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/panels/config/labs/ha-config-labs.ts b/src/panels/config/labs/ha-config-labs.ts index a34442b900..985d6ed21a 100644 --- a/src/panels/config/labs/ha-config-labs.ts +++ b/src/panels/config/labs/ha-config-labs.ts @@ -385,6 +385,10 @@ class HaConfigLabs extends SubscribeMixin(LitElement) { display: block; } + a[slot="toolbar-icon"] { + color: var(--sidebar-icon-color); + } + .content { max-width: 800px; margin: 0 auto; From 61e865d3a6675c4bdbef8df25a6fc964769583e2 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 1 Dec 2025 11:03:38 +0000 Subject: [PATCH 34/99] Fix 1px padding for subpage titles (#28256) --- src/layouts/hass-subpage.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/layouts/hass-subpage.ts b/src/layouts/hass-subpage.ts index 5defc16322..bbd592c5f7 100644 --- a/src/layouts/hass-subpage.ts +++ b/src/layouts/hass-subpage.ts @@ -143,7 +143,6 @@ class HassSubpage extends LitElement { -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; - padding-bottom: 1px; } .content { From 6138aa5489e79dc6d90efca81ad31259ffc9e233 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Mon, 1 Dec 2025 12:04:15 +0100 Subject: [PATCH 35/99] Fix ha-bottom-sheet closed event (#28257) --- src/components/ha-bottom-sheet.ts | 3 ++- src/components/ha-generic-picker.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/ha-bottom-sheet.ts b/src/components/ha-bottom-sheet.ts index a22d575bbc..62d0ca7b69 100644 --- a/src/components/ha-bottom-sheet.ts +++ b/src/components/ha-bottom-sheet.ts @@ -21,7 +21,8 @@ export class HaBottomSheet extends LitElement { private _isDragging = false; - private _handleAfterHide() { + private _handleAfterHide(afterHideEvent: Event) { + afterHideEvent.stopPropagation(); this.open = false; const ev = new Event("closed", { bubbles: true, diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index a160b31ead..6f955cb7e2 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -248,7 +248,7 @@ export class HaGenericPicker extends LitElement { }); }; - private _hidePicker(ev) { + private _hidePicker(ev: Event) { ev.stopPropagation(); if (this._newValue) { fireEvent(this, "value-changed", { value: this._newValue }); From 6fb71e12c8d8bd1e2cee4fc3b32ef63bcd005625 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 1 Dec 2025 13:19:19 +0100 Subject: [PATCH 36/99] Use name instead of description_configured for triggers and conditions (#28260) --- src/data/automation_i18n.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/data/automation_i18n.ts b/src/data/automation_i18n.ts index da42eebbbb..0e6ac522cd 100644 --- a/src/data/automation_i18n.ts +++ b/src/data/automation_i18n.ts @@ -144,9 +144,7 @@ const tryDescribeTrigger = ( const type = getTriggerObjectId(trigger.trigger); return ( - hass.localize( - `component.${domain}.triggers.${type}.description_configured` - ) || + hass.localize(`component.${domain}.triggers.${type}.name`) || hass.localize( `ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label` ) || @@ -919,9 +917,7 @@ const tryDescribeCondition = ( const type = getConditionObjectId(condition.condition); return ( - hass.localize( - `component.${domain}.conditions.${type}.description_configured` - ) || + hass.localize(`component.${domain}.conditions.${type}.name`) || hass.localize( `ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label` ) || From 3e924e0cde85462953a0e2c5f0f9d08eef359d05 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 1 Dec 2025 12:26:41 +0000 Subject: [PATCH 37/99] Add missing key for labs to show in quick bar (#28261) --- src/translations/en.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/translations/en.json b/src/translations/en.json index d6cdb9f21a..6e321cbd4b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1392,7 +1392,8 @@ "addon_dashboard": "Add-on dashboard", "addon_store": "Add-on store", "addon_info": "{addon} info", - "shortcuts": "[%key:ui::panel::config::info::shortcuts%]" + "shortcuts": "[%key:ui::panel::config::info::shortcuts%]", + "labs": "[%key:ui::panel::config::labs::caption%]" } }, "filter_placeholder": "Search entities", From ecdf37490258d5d477361784c183cc523855fb8f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 1 Dec 2025 15:43:20 +0200 Subject: [PATCH 38/99] Reduce the duration of init animation for charts to 500ms (#28262) Reduce the duration of init animation for charts --- src/components/chart/ha-chart-base.ts | 1 + src/components/chart/ha-sankey-chart.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index ff8362dc5d..555ed1ad23 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -593,6 +593,7 @@ export class HaChartBase extends LitElement { } const options = { animation: !this._reducedMotion, + animationDuration: 500, darkMode: this._themes.darkMode ?? false, aria: { show: true }, dataZoom: this._getDataZoomConfig(), diff --git a/src/components/chart/ha-sankey-chart.ts b/src/components/chart/ha-sankey-chart.ts index 70b5023c30..10ede23c15 100644 --- a/src/components/chart/ha-sankey-chart.ts +++ b/src/components/chart/ha-sankey-chart.ts @@ -167,6 +167,7 @@ export class HaSankeyChart extends LitElement { curveness: 0.5, }, layoutIterations: 0, + animationDuration: 500, label: { formatter: (params) => data.nodes.find((node) => node.id === (params.data as Node).id) From 4c3156f290ebb2e7c902ec79c38445d386a66ed9 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:01:06 +0100 Subject: [PATCH 39/99] Respect system area sort in automation target tree (#28263) --- .../add-automation-element/ha-automation-add-from-target.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts index 8cfd1ea397..5808779391 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -553,9 +553,6 @@ export default class HaAutomationAddFromTarget extends LitElement { area.icon, ] as [string, string, string | undefined, string | undefined]; }) - .sort(([, nameA], [, nameB]) => - stringCompare(nameA, nameB, this.hass.locale.language) - ) .map(([areaTargetId, areaName, floorId, areaIcon]) => { const { open, devices, entities } = this._entries[`floor${TARGET_SEPARATOR}${floorId || ""}`].areas![ From df0fb423edabc88eca982aa21f4fa20bd3eb7e85 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:20:50 +0100 Subject: [PATCH 40/99] Include background in light, climate and security views (#28264) * Include background * Remove background key * Add imports --- src/panels/climate/ha-panel-climate.ts | 2 ++ src/panels/light/ha-panel-light.ts | 2 ++ src/panels/security/ha-panel-security.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/src/panels/climate/ha-panel-climate.ts b/src/panels/climate/ha-panel-climate.ts index 0540da1ebd..e5da650aa0 100644 --- a/src/panels/climate/ha-panel-climate.ts +++ b/src/panels/climate/ha-panel-climate.ts @@ -13,6 +13,7 @@ import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strateg import type { Lovelace } from "../lovelace/types"; import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view-container"; +import "../lovelace/views/hui-view-background"; const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = { strategy: { @@ -115,6 +116,7 @@ class PanelClimate extends LitElement { this._lovelace ? html` + + + Date: Mon, 1 Dec 2025 14:05:36 +0100 Subject: [PATCH 41/99] Fix automation trigger ha icon (#28265) --- src/components/ha-trigger-icon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ha-trigger-icon.ts b/src/components/ha-trigger-icon.ts index 0e2394427b..ae2174f4f0 100644 --- a/src/components/ha-trigger-icon.ts +++ b/src/components/ha-trigger-icon.ts @@ -6,7 +6,6 @@ import { mdiDevices, mdiFormatListBulleted, mdiGestureDoubleTap, - mdiHomeAssistant, mdiMapMarker, mdiMapMarkerRadius, mdiMessageAlert, @@ -23,6 +22,7 @@ import { customElement, property } from "lit/decorators"; import { until } from "lit/directives/until"; import { computeDomain } from "../common/entity/compute_domain"; import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons"; +import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import type { HomeAssistant } from "../types"; import "./ha-icon"; import "./ha-svg-icon"; From f812e7e9fbb796e4d022a48ace32e58eb307c8d9 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 1 Dec 2025 13:51:31 +0000 Subject: [PATCH 42/99] Match more-info-update backup preferences (#28266) --- .../dialog-labs-preview-feature-enable.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/panels/config/labs/dialog-labs-preview-feature-enable.ts b/src/panels/config/labs/dialog-labs-preview-feature-enable.ts index 2a4037578e..80d7e9c339 100644 --- a/src/panels/config/labs/dialog-labs-preview-feature-enable.ts +++ b/src/panels/config/labs/dialog-labs-preview-feature-enable.ts @@ -1,5 +1,6 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { relativeTime } from "../../../common/datetime/relative_time"; import { fireEvent } from "../../../common/dom/fire_event"; import "../../../components/ha-button"; @@ -11,6 +12,7 @@ import type { HaSwitch } from "../../../components/ha-switch"; import "../../../components/ha-switch"; import type { BackupConfig } from "../../../data/backup"; import { fetchBackupConfig } from "../../../data/backup"; +import { getSupervisorUpdateConfig } from "../../../data/supervisor/update"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import type { HomeAssistant } from "../../../types"; import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable"; @@ -35,7 +37,10 @@ export class DialogLabsPreviewFeatureEnable ): Promise { this._params = params; this._createBackup = false; - await this._fetchBackupConfig(); + this._fetchBackupConfig(); + if (isComponentLoaded(this.hass, "hassio")) { + this._fetchUpdateBackupConfig(); + } } public closeDialog(): boolean { @@ -54,15 +59,21 @@ export class DialogLabsPreviewFeatureEnable try { const { config } = await fetchBackupConfig(this.hass); this._backupConfig = config; + } catch (err) { + // Ignore error, user will get manual backup option + // eslint-disable-next-line no-console + console.error(err); + } + } - // Default to enabled if automatic backups are configured, disabled otherwise - this._createBackup = - config.automatic_backups_configured && - !!config.create_backup.password && - config.create_backup.agent_ids.length > 0; - } catch { - // User will get manual backup option if fetch fails - this._createBackup = false; + private async _fetchUpdateBackupConfig() { + try { + const config = await getSupervisorUpdateConfig(this.hass); + this._createBackup = config.core_backup_before_update; + } catch (err) { + // Ignore error, user can still toggle the switch manually + // eslint-disable-next-line no-console + console.error(err); } } From 885f9333d2bf489b078b55d045e90635d741f5e1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 1 Dec 2025 15:43:57 +0100 Subject: [PATCH 43/99] Add helper for floor level (#28268) * Add helper for floor level * Update src/translations/en.json Co-authored-by: Petar Petrov --------- Co-authored-by: Petar Petrov --- src/panels/config/areas/dialog-floor-registry-detail.ts | 4 ++++ src/translations/en.json | 1 + 2 files changed, 5 insertions(+) diff --git a/src/panels/config/areas/dialog-floor-registry-detail.ts b/src/panels/config/areas/dialog-floor-registry-detail.ts index bafd9bf73c..b464f07ead 100644 --- a/src/panels/config/areas/dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/dialog-floor-registry-detail.ts @@ -144,6 +144,10 @@ class DialogFloorDetail extends LitElement { "ui.panel.config.floors.editor.level" )} type="number" + .helper=${this.hass.localize( + "ui.panel.config.floors.editor.level_helper" + )} + helperPersistent > Date: Mon, 1 Dec 2025 15:47:09 +0100 Subject: [PATCH 44/99] Clean reference to floor compare (#28269) Fix floor compare --- src/data/floor_registry.ts | 25 ---- .../areas/helpers/areas-strategy-helper.ts | 8 +- test/data/floor_registry.test.ts | 116 ------------------ 3 files changed, 6 insertions(+), 143 deletions(-) delete mode 100644 test/data/floor_registry.test.ts diff --git a/src/data/floor_registry.ts b/src/data/floor_registry.ts index a281223bb0..65cf336856 100644 --- a/src/data/floor_registry.ts +++ b/src/data/floor_registry.ts @@ -1,4 +1,3 @@ -import { stringCompare } from "../common/string/compare"; import type { HomeAssistant } from "../types"; import type { AreaRegistryEntry } from "./area_registry"; import type { RegistryEntry } from "./registry"; @@ -75,27 +74,3 @@ export const getFloorAreaLookup = ( } return floorAreaLookup; }; - -export const floorCompare = - (entries?: HomeAssistant["floors"], order?: string[]) => - (a: string, b: string) => { - const indexA = order ? order.indexOf(a) : -1; - const indexB = order ? order.indexOf(b) : -1; - if (indexA === -1 && indexB === -1) { - const floorA = entries?.[a]; - const floorB = entries?.[b]; - if (floorA && floorB && floorA.level !== floorB.level) { - return (floorB.level ?? -9999) - (floorA.level ?? -9999); - } - const nameA = floorA?.name ?? a; - const nameB = floorB?.name ?? b; - return stringCompare(nameA, nameB); - } - if (indexA === -1) { - return 1; - } - if (indexB === -1) { - return -1; - } - return indexA - indexB; - }; diff --git a/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts index fadae61534..111df47497 100644 --- a/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts +++ b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts @@ -7,7 +7,6 @@ import { orderCompare } from "../../../../../common/string/compare"; import type { AreaRegistryEntry } from "../../../../../data/area_registry"; import { areaCompare } from "../../../../../data/area_registry"; import type { FloorRegistryEntry } from "../../../../../data/floor_registry"; -import { floorCompare } from "../../../../../data/floor_registry"; import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card"; import type { HomeAssistant } from "../../../../../types"; import { supportsAlarmModesCardFeature } from "../../../card-features/hui-alarm-modes-card-feature"; @@ -302,7 +301,12 @@ export const getFloors = ( floorsOrder?: string[] ): FloorRegistryEntry[] => { const floors = Object.values(entries); - const compare = floorCompare(entries, floorsOrder); + + if (!floorsOrder) { + return floors; + } + + const compare = orderCompare(floorsOrder); return floors.sort((floorA, floorB) => compare(floorA.floor_id, floorB.floor_id) diff --git a/test/data/floor_registry.test.ts b/test/data/floor_registry.test.ts deleted file mode 100644 index abb14e89b0..0000000000 --- a/test/data/floor_registry.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { floorCompare } from "../../src/data/floor_registry"; -import type { FloorRegistryEntry } from "../../src/data/floor_registry"; - -describe("floorCompare", () => { - describe("floorCompare()", () => { - it("sorts by floor ID alphabetically", () => { - const floors = ["basement", "attic", "ground"]; - - expect(floors.sort(floorCompare())).toEqual([ - "attic", - "basement", - "ground", - ]); - }); - - it("handles numeric strings in natural order", () => { - const floors = ["floor10", "floor2", "floor1"]; - - expect(floors.sort(floorCompare())).toEqual([ - "floor1", - "floor2", - "floor10", - ]); - }); - }); - - describe("floorCompare(entries)", () => { - it("sorts by level descending (highest to lowest), then by name", () => { - const entries = { - floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry, - floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry, - floor3: { name: "Basement", level: -1 } as FloorRegistryEntry, - }; - const floors = ["floor1", "floor2", "floor3"]; - - expect(floors.sort(floorCompare(entries))).toEqual([ - "floor2", - "floor1", - "floor3", - ]); - }); - - it("treats null level as -9999, placing it at the end", () => { - const entries = { - floor1: { name: "Ground Floor", level: 0 } as FloorRegistryEntry, - floor2: { name: "First Floor", level: 1 } as FloorRegistryEntry, - floor3: { name: "Unassigned", level: null } as FloorRegistryEntry, - }; - const floors = ["floor2", "floor3", "floor1"]; - - expect(floors.sort(floorCompare(entries))).toEqual([ - "floor2", - "floor1", - "floor3", - ]); - }); - - it("sorts by name when levels are equal", () => { - const entries = { - floor1: { name: "Suite B", level: 1 } as FloorRegistryEntry, - floor2: { name: "Suite A", level: 1 } as FloorRegistryEntry, - }; - const floors = ["floor1", "floor2"]; - - expect(floors.sort(floorCompare(entries))).toEqual(["floor2", "floor1"]); - }); - - it("falls back to floor ID when entry not found", () => { - const entries = { - floor1: { name: "Ground Floor" } as FloorRegistryEntry, - }; - const floors = ["xyz", "floor1", "abc"]; - - expect(floors.sort(floorCompare(entries))).toEqual([ - "abc", - "floor1", - "xyz", - ]); - }); - }); - - describe("floorCompare(entries, order)", () => { - it("follows order array", () => { - const entries = { - basement: { name: "Basement" } as FloorRegistryEntry, - ground: { name: "Ground Floor" } as FloorRegistryEntry, - first: { name: "First Floor" } as FloorRegistryEntry, - }; - const order = ["first", "ground", "basement"]; - const floors = ["basement", "first", "ground"]; - - expect(floors.sort(floorCompare(entries, order))).toEqual([ - "first", - "ground", - "basement", - ]); - }); - - it("places items not in order array at the end, sorted by name", () => { - const entries = { - floor1: { name: "First Floor" } as FloorRegistryEntry, - floor2: { name: "Ground Floor" } as FloorRegistryEntry, - floor3: { name: "Basement" } as FloorRegistryEntry, - }; - const order = ["floor1"]; - const floors = ["floor3", "floor2", "floor1"]; - - expect(floors.sort(floorCompare(entries, order))).toEqual([ - "floor1", - "floor3", - "floor2", - ]); - }); - }); -}); From a40512e0b500e8d93f16691772a128fc7b67aa0e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 1 Dec 2025 16:35:54 +0100 Subject: [PATCH 45/99] Bumped version to 20251201.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 45ced7d59b..5616be2745 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251127.0" +version = "20251201.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From b9836073b75d5ea080cb390a9c72040d504fd88c Mon Sep 17 00:00:00 2001 From: eringerli <48175951+eringerli@users.noreply.github.com> Date: Mon, 1 Dec 2025 07:25:47 +0100 Subject: [PATCH 46/99] fix stacking of multiple power sources (#28243) --- .../lovelace/cards/energy/hui-power-sources-graph-card.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts index 2894c4c226..524e246106 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts @@ -323,9 +323,9 @@ export class HuiPowerSourcesGraphCard const negative: [number, number][] = []; Object.entries(data).forEach(([x, y]) => { const ts = Number(x); - const meanY = y.reduce((a, b) => a + b, 0) / y.length; - positive.push([ts, Math.max(0, meanY)]); - negative.push([ts, Math.min(0, meanY)]); + const sumY = y.reduce((a, b) => a + b, 0); + positive.push([ts, Math.max(0, sumY)]); + negative.push([ts, Math.min(0, sumY)]); }); return { positive, negative }; } From b73f50e86485c96cb3892d6741ddb36a9766e576 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Dec 2025 15:12:36 +0100 Subject: [PATCH 47/99] Add dialog to reorder areas and floors (#28272) --- .../config/areas/dialog-areas-floors-order.ts | 494 ++++++++++++++++++ .../config/areas/ha-config-areas-dashboard.ts | 218 +++----- .../areas/show-dialog-areas-floors-order.ts | 17 + src/translations/en.json | 10 +- 4 files changed, 606 insertions(+), 133 deletions(-) create mode 100644 src/panels/config/areas/dialog-areas-floors-order.ts create mode 100644 src/panels/config/areas/show-dialog-areas-floors-order.ts diff --git a/src/panels/config/areas/dialog-areas-floors-order.ts b/src/panels/config/areas/dialog-areas-floors-order.ts new file mode 100644 index 0000000000..d8d1a5d288 --- /dev/null +++ b/src/panels/config/areas/dialog-areas-floors-order.ts @@ -0,0 +1,494 @@ +import { mdiClose, mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js"; +import type { CSSResultGroup } from "lit"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { + type AreasFloorHierarchy, + getAreasFloorHierarchy, + getAreasOrder, + getFloorOrder, +} from "../../../common/areas/areas-floor-hierarchy"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-button"; +import "../../../components/ha-dialog-header"; +import "../../../components/ha-floor-icon"; +import "../../../components/ha-icon"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../components/ha-md-dialog"; +import "../../../components/ha-md-list"; +import "../../../components/ha-md-list-item"; +import "../../../components/ha-sortable"; +import "../../../components/ha-svg-icon"; +import type { AreaRegistryEntry } from "../../../data/area_registry"; +import { + reorderAreaRegistryEntries, + updateAreaRegistryEntry, +} from "../../../data/area_registry"; +import { reorderFloorRegistryEntries } from "../../../data/floor_registry"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import { showToast } from "../../../util/toast"; +import type { AreasFloorsOrderDialogParams } from "./show-dialog-areas-floors-order"; + +const UNASSIGNED_FLOOR = "__unassigned__"; + +interface FloorChange { + areaId: string; + floorId: string | null; +} + +@customElement("dialog-areas-floors-order") +class DialogAreasFloorsOrder extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + + @state() private _hierarchy?: AreasFloorHierarchy; + + @state() private _saving = false; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public async showDialog( + _params: AreasFloorsOrderDialogParams + ): Promise { + this._open = true; + this._computeHierarchy(); + } + + private _computeHierarchy(): void { + this._hierarchy = getAreasFloorHierarchy( + Object.values(this.hass.floors), + Object.values(this.hass.areas) + ); + } + + public closeDialog(): void { + this._dialog?.close(); + } + + private _dialogClosed(): void { + this._open = false; + this._hierarchy = undefined; + this._saving = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._open || !this._hierarchy) { + return nothing; + } + + const dialogTitle = this.hass.localize( + "ui.panel.config.areas.dialog.reorder_title" + ); + + return html` + + + + ${dialogTitle} + +
+ +
+ ${repeat( + this._hierarchy.floors, + (floor) => floor.id, + (floor) => this._renderFloor(floor) + )} +
+
+ ${this._renderUnassignedAreas()} +
+
+ + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+
+ `; + } + + private _renderFloor(floor: { id: string; areas: string[] }) { + const floorEntry = this.hass.floors[floor.id]; + if (!floorEntry) { + return nothing; + } + + return html` +
+
+ + ${floorEntry.name} + +
+ + + ${floor.areas.length > 0 + ? floor.areas.map((areaId) => this._renderArea(areaId)) + : html`

+ ${this.hass.localize( + "ui.panel.config.areas.dialog.empty_floor" + )} +

`} +
+
+
+ `; + } + + private _renderUnassignedAreas() { + const hasFloors = this._hierarchy!.floors.length > 0; + + return html` +
+ ${hasFloors + ? html`
+ + ${this.hass.localize( + "ui.panel.config.areas.dialog.unassigned_areas" + )} + +
` + : nothing} + + + ${this._hierarchy!.areas.length > 0 + ? this._hierarchy!.areas.map((areaId) => this._renderArea(areaId)) + : html`

+ ${this.hass.localize( + "ui.panel.config.areas.dialog.empty_unassigned" + )} +

`} +
+
+
+ `; + } + + private _renderArea(areaId: string) { + const area = this.hass.areas[areaId]; + if (!area) { + return nothing; + } + + return html` + + ${area.icon + ? html`` + : html``} + ${area.name} + + + `; + } + + private _floorMoved(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._hierarchy) { + return; + } + + const { oldIndex, newIndex } = ev.detail; + const newFloors = [...this._hierarchy.floors]; + const [movedFloor] = newFloors.splice(oldIndex, 1); + newFloors.splice(newIndex, 0, movedFloor); + + this._hierarchy = { + ...this._hierarchy, + floors: newFloors, + }; + } + + private _areaMoved(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._hierarchy) { + return; + } + + const { floor } = ev.currentTarget as HTMLElement & { floor: string }; + const { oldIndex, newIndex } = ev.detail; + const floorId = floor === UNASSIGNED_FLOOR ? null : floor; + + if (floorId === null) { + // Reorder unassigned areas + const newAreas = [...this._hierarchy.areas]; + const [movedArea] = newAreas.splice(oldIndex, 1); + newAreas.splice(newIndex, 0, movedArea); + + this._hierarchy = { + ...this._hierarchy, + areas: newAreas, + }; + } else { + // Reorder areas within a floor + this._hierarchy = { + ...this._hierarchy, + floors: this._hierarchy.floors.map((f) => { + if (f.id === floorId) { + const newAreas = [...f.areas]; + const [movedArea] = newAreas.splice(oldIndex, 1); + newAreas.splice(newIndex, 0, movedArea); + return { ...f, areas: newAreas }; + } + return f; + }), + }; + } + } + + private _areaAdded(ev: CustomEvent): void { + ev.stopPropagation(); + if (!this._hierarchy) { + return; + } + + const { floor } = ev.currentTarget as HTMLElement & { floor: string }; + const { data: area, index } = ev.detail as { + data: AreaRegistryEntry; + index: number; + }; + + const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor; + + // Update hierarchy + const newUnassignedAreas = this._hierarchy.areas.filter( + (id) => id !== area.area_id + ); + if (newFloorId === null) { + // Add to unassigned at the specified index + newUnassignedAreas.splice(index, 0, area.area_id); + } + + this._hierarchy = { + ...this._hierarchy, + floors: this._hierarchy.floors.map((f) => { + if (f.id === newFloorId) { + // Add to new floor at the specified index + const newAreas = [...f.areas]; + newAreas.splice(index, 0, area.area_id); + return { ...f, areas: newAreas }; + } + // Remove from old floor + return { + ...f, + areas: f.areas.filter((id) => id !== area.area_id), + }; + }), + areas: newUnassignedAreas, + }; + } + + private _computeFloorChanges(): FloorChange[] { + if (!this._hierarchy) { + return []; + } + + const changes: FloorChange[] = []; + + // Check areas assigned to floors + for (const floor of this._hierarchy.floors) { + for (const areaId of floor.areas) { + const originalFloorId = this.hass.areas[areaId]?.floor_id ?? null; + if (floor.id !== originalFloorId) { + changes.push({ areaId, floorId: floor.id }); + } + } + } + + // Check unassigned areas + for (const areaId of this._hierarchy.areas) { + const originalFloorId = this.hass.areas[areaId]?.floor_id ?? null; + if (originalFloorId !== null) { + changes.push({ areaId, floorId: null }); + } + } + + return changes; + } + + private async _save(): Promise { + if (!this._hierarchy || this._saving) { + return; + } + + this._saving = true; + + try { + const areaOrder = getAreasOrder(this._hierarchy); + const floorOrder = getFloorOrder(this._hierarchy); + + // Update floor assignments for areas that changed floors + const floorChanges = this._computeFloorChanges(); + const floorChangePromises = floorChanges.map(({ areaId, floorId }) => + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: floorId, + }) + ); + + await Promise.all(floorChangePromises); + + // Reorder areas and floors + await reorderAreaRegistryEntries(this.hass, areaOrder); + await reorderFloorRegistryEntries(this.hass, floorOrder); + + this.closeDialog(); + } catch (err: any) { + showToast(this, { + message: + err.message || + this.hass.localize("ui.panel.config.areas.dialog.reorder_failed"), + }); + this._saving = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-md-dialog { + min-width: 600px; + max-height: 90%; + --dialog-content-padding: 8px 24px; + } + + @media all and (max-width: 600px), all and (max-height: 500px) { + ha-md-dialog { + --md-dialog-container-shape: 0; + min-width: 100%; + min-height: 100%; + } + } + + .floors { + display: flex; + flex-direction: column; + gap: 16px; + } + + .floor { + border: 1px solid var(--divider-color); + border-radius: var( + --ha-card-border-radius, + var(--ha-border-radius-lg) + ); + overflow: hidden; + } + + .floor.unassigned { + border-style: dashed; + margin-top: 16px; + } + + .floor-header { + display: flex; + align-items: center; + padding: 12px 16px; + background-color: var(--secondary-background-color); + gap: 12px; + } + + .floor-name { + flex: 1; + font-weight: var(--ha-font-weight-medium); + } + + .floor-handle { + cursor: grab; + color: var(--secondary-text-color); + } + + ha-md-list { + padding: 0; + --md-list-item-leading-space: 16px; + --md-list-item-trailing-space: 16px; + display: flex; + flex-direction: column; + } + + ha-md-list-item { + --md-list-item-one-line-container-height: 48px; + --md-list-item-container-shape: 0; + } + + ha-md-list-item.sortable-ghost { + border-radius: calc( + var(--ha-card-border-radius, var(--ha-border-radius-lg)) - 1px + ); + box-shadow: inset 0 0 0 2px var(--primary-color); + } + + .area-handle { + cursor: grab; + color: var(--secondary-text-color); + } + + .empty { + text-align: center; + color: var(--secondary-text-color); + font-style: italic; + margin: 0; + padding: 12px 16px; + order: 1; + } + + ha-md-list:has(ha-md-list-item) .empty { + display: none; + } + + .content { + padding-top: 16px; + padding-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-areas-floors-order": DialogAreasFloorsOrder; + } +} diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index 5f146ee9ea..b10b9adc81 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -2,10 +2,10 @@ import type { ActionDetail } from "@material/mwc-list"; import { mdiDelete, mdiDotsVertical, - mdiDragHorizontalVariant, mdiHelpCircle, mdiPencil, mdiPlus, + mdiSort, } from "@mdi/js"; import { css, @@ -21,7 +21,6 @@ import memoizeOne from "memoize-one"; import { getAreasFloorHierarchy, getAreasOrder, - getFloorOrder, type AreasFloorHierarchy, } from "../../../common/areas/areas-floor-hierarchy"; import { formatListWithAnds } from "../../../common/string/format-list"; @@ -42,7 +41,6 @@ import type { FloorRegistryEntry } from "../../../data/floor_registry"; import { createFloorRegistryEntry, deleteFloorRegistryEntry, - reorderFloorRegistryEntries, updateFloorRegistryEntry, } from "../../../data/floor_registry"; import { @@ -58,6 +56,7 @@ import { loadAreaRegistryDetailDialog, showAreaRegistryDetailDialog, } from "./show-dialog-area-registry-detail"; +import { showAreasFloorsOrderDialog } from "./show-dialog-areas-floors-order"; import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail"; const UNASSIGNED_FLOOR = "__unassigned__"; @@ -176,94 +175,90 @@ export class HaConfigAreasDashboard extends LitElement { .route=${this.route} has-fab > - + + + + + ${this.hass.localize("ui.panel.config.areas.picker.reorder")} + + + + ${this.hass.localize("ui.common.help")} + +
- -
- ${this._hierarchy.floors.map(({ areas, id }) => { - const floor = this.hass.floors[id]; - if (!floor) { - return nothing; - } - return html` -
-
-

- - ${floor.name} -

-
- - + ${this._hierarchy.floors.map(({ areas, id }) => { + const floor = this.hass.floors[id]; + if (!floor) { + return nothing; + } + return html` +
+
+

+ + ${floor.name} +

+
+ + + ${this.hass.localize( + "ui.panel.config.areas.picker.floor.edit_floor" + )} - - ${this.hass.localize( - "ui.panel.config.areas.picker.floor.edit_floor" - )} - ${this.hass.localize( - "ui.panel.config.areas.picker.floor.delete_floor" - )} - -
+ ${this.hass.localize( + "ui.panel.config.areas.picker.floor.delete_floor" + )} +
- -
- ${areas.map((areaId) => { - const area = this.hass.areas[areaId]; - if (!area) { - return nothing; - } - const stats = areasStats.get(area.area_id); - return this._renderArea(area, stats); - })} -
-
- `; - })} -
- + +
+ ${areas.map((areaId) => { + const area = this.hass.areas[areaId]; + if (!area) { + return nothing; + } + const stats = areasStats.get(area.area_id); + return this._renderArea(area, stats); + })} +
+
+
+ `; + })} +
${this._hierarchy.areas.length ? html` @@ -395,51 +390,6 @@ export class HaConfigAreasDashboard extends LitElement { }); } - private async _floorMoved(ev) { - ev.stopPropagation(); - if (!this.hass || !this._hierarchy) { - return; - } - const { oldIndex, newIndex } = ev.detail; - - const reorderFloors = ( - floors: AreasFloorHierarchy["floors"], - oldIdx: number, - newIdx: number - ) => { - const newFloors = [...floors]; - const [movedFloor] = newFloors.splice(oldIdx, 1); - newFloors.splice(newIdx, 0, movedFloor); - return newFloors; - }; - - // Optimistically update UI - this._hierarchy = { - ...this._hierarchy, - floors: reorderFloors(this._hierarchy.floors, oldIndex, newIndex), - }; - - const areaOrder = getAreasOrder(this._hierarchy); - const floorOrder = getFloorOrder(this._hierarchy); - - // Block hierarchy updates for 500ms to avoid flickering - // because of multiple async updates - this._blockHierarchyUpdateFor(500); - - try { - await reorderAreaRegistryEntries(this.hass, areaOrder); - await reorderFloorRegistryEntries(this.hass, floorOrder); - } catch { - showToast(this, { - message: this.hass.localize( - "ui.panel.config.areas.picker.floor_reorder_failed" - ), - }); - // Revert on error - this._computeHierarchy(); - } - } - private async _areaMoved(ev) { ev.stopPropagation(); if (!this.hass || !this._hierarchy) { @@ -602,6 +552,10 @@ export class HaConfigAreasDashboard extends LitElement { this._openAreaDialog(); } + private _showReorderDialog() { + showAreasFloorsOrderDialog(this, {}); + } + private _showHelp() { showAlertDialog(this, { title: this.hass.localize("ui.panel.config.areas.caption"), diff --git a/src/panels/config/areas/show-dialog-areas-floors-order.ts b/src/panels/config/areas/show-dialog-areas-floors-order.ts new file mode 100644 index 0000000000..22bb9c82fb --- /dev/null +++ b/src/panels/config/areas/show-dialog-areas-floors-order.ts @@ -0,0 +1,17 @@ +import { fireEvent } from "../../../common/dom/fire_event"; + +export interface AreasFloorsOrderDialogParams {} + +export const loadAreasFloorsOrderDialog = () => + import("./dialog-areas-floors-order"); + +export const showAreasFloorsOrderDialog = ( + element: HTMLElement, + params: AreasFloorsOrderDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-areas-floors-order", + dialogImport: loadAreasFloorsOrderDialog, + dialogParams: params, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index e1470cc651..67c86dea89 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2474,7 +2474,15 @@ }, "area_reorder_failed": "Failed to reorder areas", "area_move_failed": "Failed to move area", - "floor_reorder_failed": "Failed to reorder floors" + "floor_reorder_failed": "Failed to reorder floors", + "reorder": "Reorder floors and areas" + }, + "dialog": { + "reorder_title": "Reorder floors and areas", + "unassigned_areas": "Unassigned areas", + "reorder_failed": "Failed to save order", + "empty_floor": "No areas on this floor", + "empty_unassigned": "All your areas are assigned to floors" }, "editor": { "create_area": "Create area", From ee5c54030ab42570e93b3a47213f77c6a02cf191 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:08:36 -0800 Subject: [PATCH 48/99] Safer lookup of description_placeholders when service is invalid (#28273) --- src/components/ha-service-control.ts | 2 +- src/panels/developer-tools/action/developer-tools-action.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 652a645b39..f816d41fd3 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -467,7 +467,7 @@ export class HaServiceControl extends LitElement { const descriptionPlaceholders = domain && serviceName - ? this.hass.services[domain][serviceName].description_placeholders + ? this.hass.services[domain]?.[serviceName]?.description_placeholders : undefined; const description = diff --git a/src/panels/developer-tools/action/developer-tools-action.ts b/src/panels/developer-tools/action/developer-tools-action.ts index 12e4240bfe..3f23b0e0d4 100644 --- a/src/panels/developer-tools/action/developer-tools-action.ts +++ b/src/panels/developer-tools/action/developer-tools-action.ts @@ -137,7 +137,7 @@ class HaPanelDevAction extends LitElement { const descriptionPlaceholders = domain && serviceName - ? this.hass.services[domain][serviceName].description_placeholders + ? this.hass.services[domain]?.[serviceName]?.description_placeholders : undefined; return html` From 480122f40a67031b435511cecb5ef31122791f34 Mon Sep 17 00:00:00 2001 From: Silas Krause Date: Tue, 2 Dec 2025 07:07:45 +0100 Subject: [PATCH 49/99] Revert custom markdown styles (#28277) --- src/components/ha-assist-chat.ts | 1 + src/components/ha-markdown.ts | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 22f8213bf5..1d47313e56 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -659,6 +659,7 @@ export class HaAssistChat extends LitElement { --markdown-table-border-color: var(--divider-color); --markdown-code-background-color: var(--primary-background-color); --markdown-code-text-color: var(--primary-text-color); + --markdown-list-indent: 1rem; &:not(:has(ha-markdown-element)) { min-height: 1lh; min-width: 1lh; diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 404e24b6b9..f5627ef118 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -71,13 +71,11 @@ export class HaMarkdown extends LitElement { color: var(--markdown-link-color, var(--primary-color)); } img { - background-color: rgba(10, 10, 10, 0.15); + background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); max-width: 100%; - min-height: 2lh; height: auto; width: auto; - text-indent: 4px; transition: height 0.2s ease-in-out; } p:first-child > img:first-child { @@ -86,9 +84,9 @@ export class HaMarkdown extends LitElement { p:first-child > img:last-child { vertical-align: top; } - ol, - ul { - padding-inline-start: 1rem; + :host > ul, + :host > ol { + padding-inline-start: var(--markdown-list-indent, revert); } li { &:has(input[type="checkbox"]) { From 5375665dc68f1b4a9a9021fc86457c23905ec159 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 2 Dec 2025 14:47:15 +0200 Subject: [PATCH 50/99] Fix index value for grid return in power sankey card (#28281) --- src/panels/lovelace/cards/energy/hui-power-sankey-card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts index 8190c357c6..8cb03b84fb 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts @@ -235,7 +235,7 @@ class HuiPowerSankeyCard color: computedStyle .getPropertyValue("--energy-grid-return-color") .trim(), - index: 2, + index: 1, }); if (powerData.battery_to_grid > 0) { links.push({ From 2232db9c0f24622f4e7d0c5fecd67a8c4a35307b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 2 Dec 2025 17:01:17 +0200 Subject: [PATCH 51/99] Update Energy dashboard layout (#28283) --- src/panels/energy/ha-panel-energy.ts | 161 ++++++------------ .../energy-overview-view-strategy.ts | 139 ++++++--------- .../energy/strategies/energy-view-strategy.ts | 20 ++- .../energy/strategies/gas-view-strategy.ts | 70 ++++++++ .../energy/strategies/power-view-strategy.ts | 36 ++-- .../energy/strategies/water-view-strategy.ts | 20 ++- .../energy/hui-power-sources-graph-card.ts | 2 +- src/panels/lovelace/cards/types.ts | 1 + src/panels/lovelace/hui-root.ts | 27 +++ .../lovelace/strategies/get-strategy.ts | 1 + src/translations/en.json | 7 +- 11 files changed, 252 insertions(+), 232 deletions(-) create mode 100644 src/panels/energy/strategies/gas-view-strategy.ts diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index 746bc748e3..317a87aae6 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -1,11 +1,10 @@ -import { mdiDownload, mdiPencil } from "@mdi/js"; +import { mdiDownload } from "@mdi/js"; import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { goBack, navigate } from "../../common/navigate"; +import { navigate } from "../../common/navigate"; import "../../components/ha-alert"; import "../../components/ha-icon-button-arrow-prev"; -import "../../components/ha-list-item"; import "../../components/ha-menu-button"; import "../../components/ha-top-app-bar-fixed"; import type { @@ -23,16 +22,14 @@ import { getSummedData, } from "../../data/energy"; import type { LovelaceConfig } from "../../data/lovelace/config/types"; -import { - isStrategyView, - type LovelaceViewConfig, -} from "../../data/lovelace/config/view"; +import type { LovelaceViewConfig } from "../../data/lovelace/config/view"; import type { StatisticValue } from "../../data/recorder"; import { haStyle } from "../../resources/styles"; import type { HomeAssistant, PanelInfo } from "../../types"; import { fileDownload } from "../../util/file_download"; import "../lovelace/components/hui-energy-period-selector"; import "../lovelace/hui-root"; +import type { ExtraActionItem } from "../lovelace/hui-root"; import type { Lovelace } from "../lovelace/types"; import "../lovelace/views/hui-view"; import "../lovelace/views/hui-view-container"; @@ -51,37 +48,38 @@ const OVERVIEW_VIEW = { strategy: { type: "energy-overview", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, - allow_compare: false, }, } as LovelaceViewConfig; const ENERGY_VIEW = { path: "electricity", - back_path: "/energy", strategy: { type: "energy", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, - allow_compare: true, }, } as LovelaceViewConfig; const WATER_VIEW = { - back_path: "/energy", path: "water", strategy: { type: "water", collection_key: DEFAULT_ENERGY_COLLECTION_KEY, - allow_compare: true, + }, +} as LovelaceViewConfig; + +const GAS_VIEW = { + path: "gas", + strategy: { + type: "gas", + collection_key: DEFAULT_ENERGY_COLLECTION_KEY, }, } as LovelaceViewConfig; const POWER_VIEW = { - back_path: "/energy", - path: "power", + path: "now", strategy: { type: "power", - collection_key: DEFAULT_ENERGY_COLLECTION_KEY, - allow_compare: false, + collection_key: "energy_dashboard_now", }, } as LovelaceViewConfig; @@ -101,8 +99,6 @@ class PanelEnergy extends LitElement { @state() private _lovelace?: Lovelace; - @state() private _searchParms = new URLSearchParams(window.location.search); - @property({ attribute: false }) public route?: { path: string; prefix: string; @@ -114,6 +110,16 @@ class PanelEnergy extends LitElement { @state() private _error?: string; + private get _extraActionItems(): ExtraActionItem[] { + return [ + { + icon: mdiDownload, + labelKey: "ui.panel.energy.download_data", + action: this._dumpCSV, + }, + ]; + } + public willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); // Initial setup @@ -188,16 +194,11 @@ class PanelEnergy extends LitElement { enableFullEditMode: () => undefined, saveConfig: async () => undefined, deleteConfig: async () => undefined, - setEditMode: () => undefined, + setEditMode: () => this._navigateConfig(), showToast: () => undefined, }; } - private _back(ev) { - ev.stopPropagation(); - goBack(); - } - protected render() { if (this._error) { return html` @@ -222,17 +223,6 @@ class PanelEnergy extends LitElement { return nothing; } - const viewPath: string | undefined = this.route!.path.split("/")[1]; - - const views = this._lovelace.config?.views || []; - const viewIndex = Math.max( - views.findIndex((view) => view.path === viewPath), - 0 - ); - const view = views[viewIndex]; - - const showBack = this._searchParms.has("historyBack") || viewIndex > 0; - return html` -
- ${showBack - ? html` - - ` - : html` - - `} - ${!this.narrow - ? html`
- ${this.hass.localize( - `ui.panel.energy.title.${viewPath}` as LocalizeKeys - ) || this.hass.localize("panel.energy")} -
` - : nothing} - - - ${this.hass.user?.is_admin - ? html` - - - - ${this.hass!.localize("ui.panel.energy.configure")} - - ` - : nothing} - - - ${this.hass!.localize("ui.panel.energy.download_data")} - - -
-
+ > `; } @@ -325,29 +264,44 @@ class PanelEnergy extends LitElement { this._prefs.energy_sources.some((source) => source.type === "water") || this._prefs.device_consumption_water?.length > 0; + const hasGas = this._prefs.energy_sources.some( + (source) => source.type === "gas" + ); + const views: LovelaceViewConfig[] = []; if (hasEnergy) { views.push(ENERGY_VIEW); } - if (hasPower) { - views.push(POWER_VIEW); + if (hasGas) { + views.push(GAS_VIEW); } if (hasWater) { views.push(WATER_VIEW); } + if (hasPower) { + views.push(POWER_VIEW); + } if (views.length > 1) { views.unshift(OVERVIEW_VIEW); } - return { views }; + return { + views: views.map((view) => ({ + ...view, + title: + view.title || + this.hass.localize( + `ui.panel.energy.title.${view.path}` as LocalizeKeys + ), + })), + }; } - private _navigateConfig(ev) { - ev.stopPropagation(); + private _navigateConfig(ev?: Event) { + ev?.stopPropagation(); navigate("/config/energy?historyBack=1"); } - private async _dumpCSV(ev) { - ev.stopPropagation(); + private _dumpCSV = async () => { const energyData = getEnergyDataCollection(this.hass, { key: "energy_dashboard", }); @@ -659,7 +613,7 @@ class PanelEnergy extends LitElement { }); const url = window.URL.createObjectURL(blob); fileDownload(url, "energy.csv"); - } + }; private _reloadConfig() { this._loadConfig(); @@ -669,21 +623,8 @@ class PanelEnergy extends LitElement { return [ haStyle, css` - :host hui-energy-period-selector { - flex-grow: 1; - padding-left: 32px; - padding-inline-start: 32px; - padding-inline-end: initial; - --disabled-text-color: rgba(var(--rgb-text-primary-color), 0.5); - direction: var(--direction); - --date-range-picker-max-height: calc(100vh - 80px); - } - :host([narrow]) hui-energy-period-selector { - padding-left: 0px; - padding-inline-start: 0px; - padding-inline-end: initial; - } :host { + --ha-view-sections-column-max-width: 100%; -ms-user-select: none; -webkit-user-select: none; -moz-user-select: none; diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index dae99d461b..3cc68629eb 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -17,7 +17,7 @@ const sourceHasCost = (source: Record): boolean => ); @customElement("energy-overview-view-strategy") -export class EnergyViewStrategy extends ReactiveElement { +export class EnergyOverviewViewStrategy extends ReactiveElement { static async generate( _config: LovelaceStrategyConfig, hass: HomeAssistant @@ -55,6 +55,9 @@ export class EnergyViewStrategy extends ReactiveElement { const hasBattery = prefs.energy_sources.some( (source) => source.type === "battery" ); + const hasSolar = prefs.energy_sources.some( + (source) => source.type === "solar" + ); const hasWaterSources = prefs.energy_sources.some( (source) => source.type === "water" ); @@ -65,9 +68,6 @@ export class EnergyViewStrategy extends ReactiveElement { (source.type === "battery" && source.stat_rate) || (source.type === "grid" && source.power?.length) ); - const hasPowerDevices = prefs.device_consumption.find( - (device) => device.stat_rate - ); const hasCost = prefs.energy_sources.some( (source) => sourceHasCost(source) || @@ -78,94 +78,67 @@ export class EnergyViewStrategy extends ReactiveElement { const overviewSection: LovelaceSectionConfig = { type: "grid", - column_span: 24, - cards: [], + cards: [ + { + type: "energy-date-selection", + collection_key: collectionKey, + allow_compare: false, + }, + ], }; - // Only include if we have a grid or battery. - if (hasGrid || hasBattery) { + if (hasGrid || hasBattery || hasSolar) { overviewSection.cards!.push({ title: hass.localize("ui.panel.energy.cards.energy_distribution_title"), type: "energy-distribution", collection_key: collectionKey, }); } - if (hasCost) { - overviewSection.cards!.push({ - type: "energy-sources-table", - collection_key: collectionKey, - show_only_totals: true, - }); - } view.sections!.push(overviewSection); - const powerSection: LovelaceSectionConfig = { - type: "grid", - cards: [ - { - type: "heading", - heading: hass.localize("ui.panel.energy.title.power"), - tap_action: { - action: "navigate", - navigation_path: "/energy/power", + if (hasCost) { + view.sections!.push({ + type: "grid", + cards: [ + { + title: hass.localize( + "ui.panel.energy.cards.energy_sources_table_title" + ), + type: "energy-sources-table", + collection_key: collectionKey, + show_only_totals: true, }, - }, - ], - }; - if (hasPowerDevices) { - const showFloorsNAreas = !prefs.device_consumption.some( - (d) => d.included_in_stat - ); - powerSection.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sankey_title"), - type: "power-sankey", - collection_key: collectionKey, - group_by_floor: showFloorsNAreas, - group_by_area: showFloorsNAreas, - grid_options: { - columns: 24, - }, + ], }); - powerSection.column_span = 24; - view.sections!.push(powerSection); - } else if (hasPowerSources) { - powerSection.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), - type: "power-sources-graph", - collection_key: collectionKey, - }); - view.sections!.push(powerSection); } - const energySection: LovelaceSectionConfig = { - type: "grid", - cards: [ - { - type: "heading", - heading: hass.localize("ui.panel.energy.title.energy"), - tap_action: { - action: "navigate", - navigation_path: "/energy/electricity", + if (hasPowerSources) { + view.sections!.push({ + type: "grid", + cards: [ + { + title: hass.localize( + "ui.panel.energy.cards.power_sources_graph_title" + ), + type: "power-sources-graph", + collection_key: collectionKey, + show_legend: false, }, - }, - ], - }; - view.sections!.push(energySection); - if (hasGrid || hasBattery) { - energySection.cards!.push({ - title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"), - type: "energy-usage-graph", - collection_key: "energy_dashboard", + ], }); } - if (prefs!.device_consumption.length > 3) { - energySection.cards!.push({ - title: hass.localize( - "ui.panel.energy.cards.energy_top_consumers_title" - ), - type: "energy-devices-graph", - collection_key: collectionKey, - max_devices: 3, - modes: ["bar"], + + if (hasGrid || hasBattery) { + view.sections!.push({ + type: "grid", + cards: [ + { + title: hass.localize( + "ui.panel.energy.cards.energy_usage_graph_title" + ), + type: "energy-usage-graph", + collection_key: "energy_dashboard", + }, + ], }); } @@ -173,10 +146,6 @@ export class EnergyViewStrategy extends ReactiveElement { view.sections!.push({ type: "grid", cards: [ - { - type: "heading", - heading: hass.localize("ui.panel.energy.title.gas"), - }, { title: hass.localize( "ui.panel.energy.cards.energy_gas_graph_title" @@ -192,14 +161,6 @@ export class EnergyViewStrategy extends ReactiveElement { view.sections!.push({ type: "grid", cards: [ - { - type: "heading", - heading: hass.localize("ui.panel.energy.title.water"), - tap_action: { - action: "navigate", - navigation_path: "/energy/water", - }, - }, hasWaterSources ? { title: hass.localize( @@ -225,6 +186,6 @@ export class EnergyViewStrategy extends ReactiveElement { declare global { interface HTMLElementTagNameMap { - "energy-overview-view-strategy": EnergyViewStrategy; + "energy-overview-view-strategy": EnergyOverviewViewStrategy; } } diff --git a/src/panels/energy/strategies/energy-view-strategy.ts b/src/panels/energy/strategies/energy-view-strategy.ts index e3e4cd002f..64c27c5c23 100644 --- a/src/panels/energy/strategies/energy-view-strategy.ts +++ b/src/panels/energy/strategies/energy-view-strategy.ts @@ -50,6 +50,10 @@ export class EnergyViewStrategy extends ReactiveElement { (d) => d.included_in_stat ); + view.cards!.push({ + type: "energy-date-selection", + collection_key: collectionKey, + }); view.cards!.push({ type: "energy-compare", collection_key: "energy_dashboard", @@ -133,11 +137,11 @@ export class EnergyViewStrategy extends ReactiveElement { // Only include if we have at least 1 device in the config. if (prefs.device_consumption.length) { view.cards!.push({ - title: hass.localize("ui.panel.energy.cards.energy_sankey_title"), - type: "energy-sankey", + title: hass.localize( + "ui.panel.energy.cards.energy_devices_detail_graph_title" + ), + type: "energy-devices-detail-graph", collection_key: "energy_dashboard", - group_by_floor: showFloorsNAreas, - group_by_area: showFloorsNAreas, }); view.cards!.push({ title: hass.localize( @@ -147,11 +151,11 @@ export class EnergyViewStrategy extends ReactiveElement { collection_key: "energy_dashboard", }); view.cards!.push({ - title: hass.localize( - "ui.panel.energy.cards.energy_devices_detail_graph_title" - ), - type: "energy-devices-detail-graph", + title: hass.localize("ui.panel.energy.cards.energy_sankey_title"), + type: "energy-sankey", collection_key: "energy_dashboard", + group_by_floor: showFloorsNAreas, + group_by_area: showFloorsNAreas, }); } diff --git a/src/panels/energy/strategies/gas-view-strategy.ts b/src/panels/energy/strategies/gas-view-strategy.ts new file mode 100644 index 0000000000..1380cb26a8 --- /dev/null +++ b/src/panels/energy/strategies/gas-view-strategy.ts @@ -0,0 +1,70 @@ +import { ReactiveElement } from "lit"; +import { customElement } from "lit/decorators"; +import { getEnergyDataCollection } from "../../../data/energy"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; +import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; +import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; + +@customElement("gas-view-strategy") +export class GasViewStrategy extends ReactiveElement { + static async generate( + _config: LovelaceStrategyConfig, + hass: HomeAssistant + ): Promise { + const view: LovelaceViewConfig = { + type: "sections", + sections: [{ type: "grid", cards: [] }], + }; + + const collectionKey = + _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; + + const energyCollection = getEnergyDataCollection(hass, { + key: collectionKey, + }); + const prefs = energyCollection.prefs; + + const hasGasSources = prefs?.energy_sources.some( + (source) => source.type === "gas" + ); + + // No gas sources available + if (!prefs || !hasGasSources) { + return view; + } + + const section = view.sections![0] as LovelaceSectionConfig; + + section.cards!.push({ + type: "energy-date-selection", + collection_key: collectionKey, + }); + section.cards!.push({ + type: "energy-compare", + collection_key: collectionKey, + }); + + section.cards!.push({ + title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"), + type: "energy-gas-graph", + collection_key: collectionKey, + }); + + section.cards!.push({ + title: hass.localize("ui.panel.energy.cards.energy_sources_table_title"), + type: "energy-sources-table", + collection_key: collectionKey, + types: ["gas"], + }); + + return view; + } +} + +declare global { + interface HTMLElementTagNameMap { + "gas-view-strategy": GasViewStrategy; + } +} diff --git a/src/panels/energy/strategies/power-view-strategy.ts b/src/panels/energy/strategies/power-view-strategy.ts index d8623acbfd..6badb78f5a 100644 --- a/src/panels/energy/strategies/power-view-strategy.ts +++ b/src/panels/energy/strategies/power-view-strategy.ts @@ -5,6 +5,7 @@ import type { HomeAssistant } from "../../../types"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; @customElement("power-view-strategy") export class PowerViewStrategy extends ReactiveElement { @@ -12,7 +13,10 @@ export class PowerViewStrategy extends ReactiveElement { _config: LovelaceStrategyConfig, hass: HomeAssistant ): Promise { - const view: LovelaceViewConfig = { cards: [] }; + const view: LovelaceViewConfig = { + type: "sections", + sections: [{ type: "grid", cards: [] }], + }; const collectionKey = _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; @@ -20,6 +24,7 @@ export class PowerViewStrategy extends ReactiveElement { const energyCollection = getEnergyDataCollection(hass, { key: collectionKey, }); + await energyCollection.refresh(); const prefs = energyCollection.prefs; const hasPowerSources = prefs?.energy_sources.some( @@ -37,31 +42,32 @@ export class PowerViewStrategy extends ReactiveElement { return view; } - view.type = "sidebar"; + const section = view.sections![0] as LovelaceSectionConfig; - view.cards!.push({ - type: "energy-compare", - collection_key: collectionKey, - }); + if (hasPowerSources) { + section.cards!.push({ + title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), + type: "power-sources-graph", + collection_key: collectionKey, + grid_options: { + columns: 36, + }, + }); + } if (hasPowerDevices) { const showFloorsNAreas = !prefs.device_consumption.some( (d) => d.included_in_stat ); - view.cards!.push({ + section.cards!.push({ title: hass.localize("ui.panel.energy.cards.power_sankey_title"), type: "power-sankey", collection_key: collectionKey, group_by_floor: showFloorsNAreas, group_by_area: showFloorsNAreas, - }); - } - - if (hasPowerSources) { - view.cards!.push({ - title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"), - type: "power-sources-graph", - collection_key: collectionKey, + grid_options: { + columns: 36, + }, }); } diff --git a/src/panels/energy/strategies/water-view-strategy.ts b/src/panels/energy/strategies/water-view-strategy.ts index 97a932d59e..3340ca62a1 100644 --- a/src/panels/energy/strategies/water-view-strategy.ts +++ b/src/panels/energy/strategies/water-view-strategy.ts @@ -5,6 +5,7 @@ import type { HomeAssistant } from "../../../types"; import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; +import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; @customElement("water-view-strategy") export class WaterViewStrategy extends ReactiveElement { @@ -12,7 +13,10 @@ export class WaterViewStrategy extends ReactiveElement { _config: LovelaceStrategyConfig, hass: HomeAssistant ): Promise { - const view: LovelaceViewConfig = { cards: [] }; + const view: LovelaceViewConfig = { + type: "sections", + sections: [{ type: "grid", cards: [] }], + }; const collectionKey = _config.collection_key || DEFAULT_ENERGY_COLLECTION_KEY; @@ -32,15 +36,19 @@ export class WaterViewStrategy extends ReactiveElement { return view; } - view.type = "sidebar"; + const section = view.sections![0] as LovelaceSectionConfig; - view.cards!.push({ + section.cards!.push({ + type: "energy-date-selection", + collection_key: collectionKey, + }); + section.cards!.push({ type: "energy-compare", collection_key: collectionKey, }); if (hasWaterSources) { - view.cards!.push({ + section.cards!.push({ title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"), type: "energy-water-graph", collection_key: collectionKey, @@ -48,7 +56,7 @@ export class WaterViewStrategy extends ReactiveElement { } if (hasWaterSources) { - view.cards!.push({ + section.cards!.push({ title: hass.localize( "ui.panel.energy.cards.energy_sources_table_title" ), @@ -63,7 +71,7 @@ export class WaterViewStrategy extends ReactiveElement { const showFloorsNAreas = !prefs.device_consumption_water.some( (d) => d.included_in_stat ); - view.cards!.push({ + section.cards!.push({ title: hass.localize("ui.panel.energy.cards.water_sankey_title"), type: "water-sankey", collection_key: collectionKey, diff --git a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts index 524e246106..9760715f9d 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts @@ -132,7 +132,7 @@ export class HuiPowerSourcesGraphCard compareEnd ), legend: { - show: true, + show: this._config?.show_legend !== false, type: "custom", data: legendData, }, diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index d92d9e7f10..7e732282d8 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -238,6 +238,7 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig { export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig { type: "power-sources-graph"; title?: string; + show_legend?: boolean; } export interface PowerSankeyCardConfig extends EnergyCardBaseConfig { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index cb29cb204d..b8544ab8e1 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -112,6 +112,12 @@ interface ActionItem { subItems?: SubActionItem[]; } +export interface ExtraActionItem { + icon: string; + labelKey: LocalizeKeys; + action: () => void; +} + interface SubActionItem { icon: string; key: LocalizeKeys; @@ -140,6 +146,8 @@ class HUIRoot extends LitElement { prefix: string; }; + @property({ attribute: false }) public extraActionItems?: ExtraActionItem[]; + @state() private _curView?: number | "hass-unused-entities"; private _configChangedByUndo = false; @@ -347,6 +355,25 @@ class HUIRoot extends LitElement { }, ]; + // Add extra action items from parent components + if (this.extraActionItems) { + this.extraActionItems.forEach((extraItem) => { + items.push({ + icon: extraItem.icon, + key: extraItem.labelKey, + buttonAction: extraItem.action, + overflowAction: (ev: CustomEvent) => { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + extraItem.action(); + }, + visible: true, + overflow: this.narrow, + }); + }); + } + const overflowItems = items.filter((i) => i.visible && i.overflow); const overflowCanPromote = overflowItems.length === 1 && overflowItems[0].overflow_can_promote; diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts index 7e476e80ae..e08c358a7d 100644 --- a/src/panels/lovelace/strategies/get-strategy.ts +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -42,6 +42,7 @@ const STRATEGIES: Record> = { import("../../energy/strategies/energy-overview-view-strategy"), energy: () => import("../../energy/strategies/energy-view-strategy"), water: () => import("../../energy/strategies/water-view-strategy"), + gas: () => import("../../energy/strategies/gas-view-strategy"), power: () => import("../../energy/strategies/power-view-strategy"), map: () => import("./map/map-view-strategy"), iframe: () => import("./iframe/iframe-view-strategy"), diff --git a/src/translations/en.json b/src/translations/en.json index 67c86dea89..878582aa0d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -9578,10 +9578,11 @@ }, "energy": { "title": { - "energy": "Energy", - "power": "Power", + "overview": "Summary", + "electricity": "Energy", "gas": "Gas", - "water": "Water" + "water": "Water", + "now": "Now" }, "download_data": "[%key:ui::panel::history::download_data%]", "configure": "[%key:ui::dialogs::quick-bar::commands::navigation::energy%]", From bb691fa7a263ae945209062938d4a8c5d7de18f4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Dec 2025 14:30:16 +0100 Subject: [PATCH 52/99] fix paste in add tca dialog (#28286) --- .../config/automation/add-automation-element-dialog.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 393c7969ec..527a5038f1 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -555,8 +555,7 @@ class DialogAddAutomationElement interactive type="button" class="paste" - .value=${PASTE_VALUE} - @click=${this._selected} + @click=${this._paste} >
@@ -1670,6 +1669,11 @@ class DialogAddAutomationElement }); } + private _paste() { + this._params!.add(PASTE_VALUE); + this.closeDialog(); + } + private _selected(ev: CustomEvent<{ value: string }>) { let target: HassServiceTarget | undefined; if ( From 8a82483685d68167a10ca693a57b0297d9ccbfa1 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Dec 2025 14:29:11 +0100 Subject: [PATCH 53/99] Fix container alignment in section view (#28287) --- src/panels/lovelace/views/hui-sections-view.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index 588ee41b7f..d9c6783561 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -494,6 +494,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement { ); gap: var(--row-gap) var(--column-gap); padding: var(--row-gap) 0; + align-items: flex-start; } .wrapper.has-sidebar .container { From 7b51e710921ed92cb614c2df51dc990673e7c1a9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Dec 2025 14:38:34 +0100 Subject: [PATCH 54/99] Only show current weather in home overview (#28288) --- .../strategies/home/home-overview-view-strategy.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts index d9be846de8..2d69f42957 100644 --- a/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-overview-view-strategy.ts @@ -254,7 +254,12 @@ export class HomeOverviewViewStrategy extends ReactiveElement { widgetSection.cards!.push({ type: "weather-forecast", entity: weatherEntity, - forecast_type: "daily", + show_forecast: false, + show_current: true, + grid_options: { + columns: 12, + rows: "auto", + }, } as WeatherForecastCardConfig); } From 6c39e5d2c5ba393943b2f3fd019d554003672342 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:43:13 +0100 Subject: [PATCH 55/99] Use history to manage back button click in automations add TCA (#28289) --- src/dialogs/make-dialog-manager.ts | 29 ++++--- .../add-automation-element-dialog.ts | 82 +++++++++++++++--- .../ha-automation-add-from-target.ts | 86 ------------------- src/state/url-sync-mixin.ts | 6 +- 4 files changed, 91 insertions(+), 112 deletions(-) diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index 792fc4caa7..4ccf8c1c92 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -1,9 +1,9 @@ -import type { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event"; -import { mainWindow } from "../common/dom/get_main_window"; -import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin"; import { ancestorsWithProperty } from "../common/dom/ancestors-with-property"; import { deepActiveElement } from "../common/dom/deep-active-element"; +import type { HASSDomEvent, ValidHassDomEvent } from "../common/dom/fire_event"; +import { mainWindow } from "../common/dom/get_main_window"; import { nextRender } from "../common/util/render-status"; +import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin"; declare global { // for fire event @@ -22,7 +22,7 @@ declare global { export interface HassDialog extends HTMLElement { showDialog(params: T); - closeDialog?: () => boolean; + closeDialog?: (historyState?: any) => boolean; } interface ShowDialogParams { @@ -143,27 +143,32 @@ export const showDialog = async ( return true; }; -export const closeDialog = async (dialogTag: string): Promise => { +export const closeDialog = async ( + dialogTag: string, + historyState?: any +): Promise => { if (!(dialogTag in LOADED)) { return true; } const dialogElement = await LOADED[dialogTag].element; if (dialogElement.closeDialog) { - return dialogElement.closeDialog() !== false; + return dialogElement.closeDialog(historyState) !== false; } return true; }; // called on back() -export const closeLastDialog = async () => { +export const closeLastDialog = async (historyState?: any) => { if (OPEN_DIALOG_STACK.length) { - const lastDialog = OPEN_DIALOG_STACK.pop(); - const closed = await closeDialog(lastDialog!.dialogTag); + const lastDialog = OPEN_DIALOG_STACK.pop() as DialogState; + const closed = await closeDialog(lastDialog.dialogTag, historyState); if (!closed) { // if the dialog was not closed, put it back on the stack - OPEN_DIALOG_STACK.push(lastDialog!); - } - if (OPEN_DIALOG_STACK.length && mainWindow.history.state?.opensDialog) { + OPEN_DIALOG_STACK.push(lastDialog); + } else if ( + OPEN_DIALOG_STACK.length && + mainWindow.history.state?.opensDialog + ) { // if there are more dialogs open, push a new state so back() will close the next top dialog mainWindow.history.pushState( { dialog: OPEN_DIALOG_STACK[OPEN_DIALOG_STACK.length - 1].dialogTag }, diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 527a5038f1..5b6df35d1f 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -16,6 +16,7 @@ import { classMap } from "lit/directives/class-map"; import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; +import { mainWindow } from "../../../common/dom/get_main_window"; import { computeAreaName } from "../../../common/entity/compute_area_name"; import { computeDeviceName } from "../../../common/entity/compute_device_name"; import { computeDomain } from "../../../common/entity/compute_domain"; @@ -118,7 +119,6 @@ import type { HomeAssistant } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; import "./add-automation-element/ha-automation-add-from-target"; -import type HaAutomationAddFromTarget from "./add-automation-element/ha-automation-add-from-target"; import "./add-automation-element/ha-automation-add-items"; import "./add-automation-element/ha-automation-add-search"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; @@ -216,10 +216,6 @@ class DialogAddAutomationElement // #endregion state // #region queries - - @query("ha-automation-add-from-target") - private _targetPickerElement?: HaAutomationAddFromTarget; - @query("ha-automation-add-items") private _itemsListElement?: HTMLDivElement; @@ -298,6 +294,14 @@ class DialogAddAutomationElement } ); + // add initial dialog view state to history + mainWindow.history.pushState( + { + dialogData: {}, + }, + "" + ); + if (this._params?.type === "action") { this.hass.loadBackendTranslation("services"); getServiceIcons(this.hass); @@ -318,7 +322,41 @@ class DialogAddAutomationElement this._bottomSheetMode = this._narrow; } - public closeDialog() { + public closeDialog(historyState?: any) { + // prevent closing when come from popstate event and root level isn't active + if ( + this._open && + historyState && + (this._selectedTarget || this._selectedGroup) + ) { + if (historyState.dialogData?.target) { + this._selectedTarget = historyState.dialogData.target; + this._getItemsByTarget(); + this._tab = "targets"; + return false; + } + if (historyState.dialogData?.group) { + this._selectedCollectionIndex = historyState.dialogData.collectionIndex; + this._selectedGroup = historyState.dialogData.group; + this._tab = "groups"; + return false; + } + + // return to home on mobile + if (this._narrow) { + this._selectedTarget = undefined; + this._selectedGroup = undefined; + return false; + } + } + + // if dialog is closed, but root level isn't active, clean up history state + if (mainWindow.history.state?.dialogData) { + this._open = false; + mainWindow.history.back(); + return false; + } + this.removeKeyboardShortcuts(); this._unsubscribe(); if (this._params) { @@ -405,7 +443,7 @@ class DialogAddAutomationElement return html` ${this._renderContent()} @@ -417,7 +455,7 @@ class DialogAddAutomationElement ${this._renderContent()} @@ -1648,11 +1686,7 @@ class DialogAddAutomationElement } private _back() { - if (this._selectedTarget) { - this._targetPickerElement?.navigateBack(); - return; - } - this._selectedGroup = undefined; + mainWindow.history.back(); } private _groupSelected(ev) { @@ -1664,6 +1698,16 @@ class DialogAddAutomationElement } this._selectedGroup = group.value; this._selectedCollectionIndex = ev.currentTarget.index; + + mainWindow.history.pushState( + { + dialogData: { + group: this._selectedGroup, + collectionIndex: this._selectedCollectionIndex, + }, + }, + "" + ); requestAnimationFrame(() => { this._itemsListElement?.scrollTo(0, 0); }); @@ -1693,6 +1737,14 @@ class DialogAddAutomationElement this._targetItems = undefined; this._loadItemsError = false; this._selectedTarget = ev.detail.value; + mainWindow.history.pushState( + { + dialogData: { + target: this._selectedTarget, + }, + }, + "" + ); requestAnimationFrame(() => { if (this._narrow) { @@ -1812,6 +1864,10 @@ class DialogAddAutomationElement this._tab = "targets"; } + private _handleClosed() { + this.closeDialog(); + } + // #region interaction // #region render helpers diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts index 5808779391..9986f2f8ce 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -1383,92 +1383,6 @@ export default class HaAutomationAddFromTarget extends LitElement { ); } - public navigateBack() { - if (!this.value) { - return; - } - - const valueType = Object.keys(this.value)[0].replace("_id", ""); - const valueId = this.value[`${valueType}_id`]; - - if ( - valueType === "floor" || - valueType === "label" || - (!valueId && - (valueType === "device" || - valueType === "helper" || - valueType === "service" || - valueType === "area")) - ) { - fireEvent(this, "value-changed", { value: undefined }); - return; - } - - if (valueType === "area") { - fireEvent(this, "value-changed", { - value: { floor_id: this.areas[valueId].floor_id || undefined }, - }); - return; - } - - if (valueType === "device") { - if ( - !this.devices[valueId].area_id && - this.devices[valueId].entry_type === "service" - ) { - fireEvent(this, "value-changed", { - value: { service_id: undefined }, - }); - return; - } - - fireEvent(this, "value-changed", { - value: { area_id: this.devices[valueId].area_id || undefined }, - }); - return; - } - - if (valueType === "entity" && valueId) { - const deviceId = this.entities[valueId].device_id; - if (deviceId) { - fireEvent(this, "value-changed", { - value: { device_id: deviceId }, - }); - return; - } - - const areaId = this.entities[valueId].area_id; - if (areaId) { - fireEvent(this, "value-changed", { - value: { area_id: areaId }, - }); - return; - } - - const domain = valueId.split(".", 2)[0]; - const manifest = this.manifests ? this.manifests[domain] : undefined; - if (manifest?.integration_type === "helper") { - fireEvent(this, "value-changed", { - value: { [`helper_${domain}_id`]: undefined }, - }); - return; - } - - fireEvent(this, "value-changed", { - value: { [`entity_${domain}_id`]: undefined }, - }); - } - - if (valueType.startsWith("helper_") || valueType.startsWith("entity_")) { - fireEvent(this, "value-changed", { - value: { - [`${valueType.startsWith("helper_") ? "helper" : "device"}_id`]: - undefined, - }, - }); - } - } - private _expandHeight() { this._fullHeight = true; this.style.setProperty("--max-height", "none"); diff --git a/src/state/url-sync-mixin.ts b/src/state/url-sync-mixin.ts index 2ed322f74d..ba36aea1de 100644 --- a/src/state/url-sync-mixin.ts +++ b/src/state/url-sync-mixin.ts @@ -56,7 +56,11 @@ export const urlSyncMixin = < // if we are instead navigating forward, the dialogs are already closed closeLastDialog(); } - if ("dialog" in ev.state) { + if ("dialogData" in ev.state) { + // if we have dialog data we are closing a dialog with appended state + // so dialog has the change to navigate back to the previous state + closeLastDialog(ev.state); + } else if ("dialog" in ev.state) { // coming to a dialog // the dialog stack must be empty in this case so this state should be cleaned up mainWindow.history.back(); From b0cfb31bf30d9a162610b4029eb35cebeb7b5f75 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:17:55 +0100 Subject: [PATCH 56/99] Automation add TCA: fix narrow subtitles & icons (#28291) --- .../add-automation-element-dialog.ts | 35 ++++++++++++------- .../ha-automation-add-items.ts | 2 +- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 5b6df35d1f..4b8ca3f54c 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -764,15 +764,26 @@ class DialogAddAutomationElement ); if (targetId) { - if (targetType === "area" && this.hass.areas[targetId]?.floor_id) { - const floorId = this.hass.areas[targetId].floor_id; - subtitle = computeFloorName(this.hass.floors[floorId]) || floorId; - } - if (targetType === "device" && this.hass.devices[targetId]?.area_id) { - const areaId = this.hass.devices[targetId].area_id; - subtitle = computeAreaName(this.hass.areas[areaId]) || areaId; - } - if (targetType === "entity" && this.hass.states[targetId]) { + if (targetType === "area") { + const floorId = this.hass.areas[targetId]?.floor_id; + if (floorId) { + subtitle = computeFloorName(this.hass.floors[floorId]) || floorId; + } else { + subtitle = this.hass.localize( + "ui.panel.config.automation.editor.other_areas" + ); + } + } else if (targetType === "device") { + const areaId = this.hass.devices[targetId]?.area_id; + if (areaId) { + subtitle = computeAreaName(this.hass.areas[areaId]) || areaId; + } else { + const device = this.hass.devices[targetId]; + subtitle = this.hass.localize( + `ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}` + ); + } + } else if (targetType === "entity" && this.hass.states[targetId]) { const entity = this.hass.entities[targetId]; if (entity && !entity.device_id && !entity.area_id) { const domain = targetId.split(".", 2)[0]; @@ -797,10 +808,10 @@ class DialogAddAutomationElement .join(computeRTL(this.hass) ? " ◂ " : " ▸ "); } } - } - if (subtitle) { - return html`${subtitle}`; + if (subtitle) { + return html`${subtitle}`; + } } } diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts index 51657ff86c..acaac01b1d 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-items.ts @@ -372,7 +372,7 @@ export class HaAutomationAddItems extends LitElement { .selected-target ha-floor-icon { display: flex; height: 32px; - width: 24px; + width: 32px; align-items: center; } .selected-target ha-domain-icon { From d34bf83da0b4c2ba4324141453f1fe7ea1aa21a2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 2 Dec 2025 16:02:32 +0100 Subject: [PATCH 57/99] Bumped version to 20251202.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5616be2745..8ac14bb2ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251201.0" +version = "20251202.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From bd582ff816232042d208735c873a83da5c6ab451 Mon Sep 17 00:00:00 2001 From: ildar170975 <71872483+ildar170975@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:40:29 +0300 Subject: [PATCH 58/99] computeLovelaceEntityName(): allow "number" names to be processed (#28231) * allow "number" names to be processed * Apply suggestion from @MindFreeze --------- Co-authored-by: Petar Petrov --- .../lovelace/common/entity/compute-lovelace-entity-name.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panels/lovelace/common/entity/compute-lovelace-entity-name.ts b/src/panels/lovelace/common/entity/compute-lovelace-entity-name.ts index 1eaae90d01..e529f338ce 100644 --- a/src/panels/lovelace/common/entity/compute-lovelace-entity-name.ts +++ b/src/panels/lovelace/common/entity/compute-lovelace-entity-name.ts @@ -21,8 +21,8 @@ export const computeLovelaceEntityName = ( if (!config) { return stateObj ? computeStateName(stateObj) : ""; } - if (typeof config === "string") { - return config; + if (typeof config !== "object") { + return String(config); } if (stateObj) { return hass.formatEntityName(stateObj, config); From 6ba4fc08083353b2eb4329c1de820e77807ca980 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Dec 2025 17:23:09 +0100 Subject: [PATCH 59/99] Handle not existing panels in dashboard config (#28292) --- .../config/lovelace/dashboards/ha-config-lovelace-dashboards.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts index eae8181462..5bef4823f3 100644 --- a/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts +++ b/src/panels/config/lovelace/dashboards/ha-config-lovelace-dashboards.ts @@ -326,7 +326,7 @@ export class HaConfigLovelaceDashboards extends LitElement { PANEL_DASHBOARDS.forEach((panel) => { const panelInfo = this.hass.panels[panel]; - if (!panel) { + if (!panelInfo) { return; } const item: DataTableItem = { From d0966bf35ae40176907c331394a3b830b3d3f5b7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 3 Dec 2025 11:29:07 +0200 Subject: [PATCH 60/99] Hide empty System message in assist debug view (#28296) --- .../debug/assist-render-pipeline-run.ts | 105 +++++++++--------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts b/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts index 926650f7e5..63138caa25 100644 --- a/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts +++ b/src/panels/config/voice-assistants/debug/assist-render-pipeline-run.ts @@ -215,57 +215,62 @@ export class AssistPipelineDebug extends LitElement { ? html`
${messages.map((content) => - content.role === "system" || content.role === "tool_result" - ? html` - -
- ${content.role === "system" - ? "System" - : `Result for ${content.tool_name}`} -
- ${content.role === "system" - ? html`
${content.content}
` - : html` - - `} -
- ` - : html` - ${content.content - ? html` -
- ${content.content} -
- ` - : nothing} - ${content.role === "assistant" && - content.tool_calls?.length - ? html` - - - Call - ${content.tool_calls.length === 1 - ? content.tool_calls[0].tool_name - : `${content.tool_calls.length} tools`} - + content.role === "system" + ? content.content + ? html` + +
System
+
${content.content}
+
+ ` + : nothing + : content.role === "tool_result" + ? html` + +
+ Result for ${content.tool_name} +
+ +
+ ` + : html` + ${content.content + ? html` +
+ ${content.content} +
+ ` + : nothing} + ${content.role === "assistant" && + content.tool_calls?.length + ? html` + + + Call + ${content.tool_calls.length === 1 + ? content.tool_calls[0].tool_name + : `${content.tool_calls.length} tools`} + - - - ` - : nothing} - ` + +
+ ` + : nothing} + ` )}
From 8dac53c672aeaec21307f0b2281cd71ce4e1ad0a Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 3 Dec 2025 11:18:32 +0200 Subject: [PATCH 61/99] Fix binary sensor history timeline not rendering properly (#28297) --- src/components/chart/state-history-chart-timeline.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/chart/state-history-chart-timeline.ts b/src/components/chart/state-history-chart-timeline.ts index 7b4e7ddf2c..6287ac6eb2 100644 --- a/src/components/chart/state-history-chart-timeline.ts +++ b/src/components/chart/state-history-chart-timeline.ts @@ -373,6 +373,7 @@ export class StateHistoryChartTimeline extends LitElement { itemName: 3, }, renderItem: this._renderItem, + progressive: 0, }); }); From 87b5f587794eb6cfd7eb84b400ee2db169c1942b Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 3 Dec 2025 11:33:00 +0200 Subject: [PATCH 62/99] Add Y-axis label formatter to energy charts (#28298) --- .../cards/energy/common/energy-chart-options.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 7f8c2baa1a..0636e4cee3 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -56,6 +56,19 @@ export function getSuggestedPeriod( return dayDifference > 35 ? "month" : dayDifference > 2 ? "day" : "hour"; } +function createYAxisLabelFormatter(locale: FrontendLocaleData) { + let previousValue: number | undefined; + + return (value: number): string => { + const maximumFractionDigits = Math.max( + 1, + -Math.floor(Math.log10(Math.abs(value - (previousValue ?? value) || 1))) + ); + previousValue = value; + return formatNumber(value, locale, { maximumFractionDigits }); + }; +} + export function getCommonOptions( start: Date, end: Date, @@ -86,7 +99,7 @@ export function getCommonOptions( align: "left", }, axisLabel: { - formatter: (value: number) => formatNumber(Math.abs(value), locale), + formatter: createYAxisLabelFormatter(locale), }, splitLine: { show: true, From b11cb57a1e451fc57cb97c3d318648ea3d58bd86 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 3 Dec 2025 11:11:54 +0100 Subject: [PATCH 63/99] Always set ha-wa-dialog position to fixed (#28301) --- gallery/src/pages/components/ha-wa-dialog.ts | 4 ---- src/components/ha-wa-dialog.ts | 2 -- 2 files changed, 6 deletions(-) diff --git a/gallery/src/pages/components/ha-wa-dialog.ts b/gallery/src/pages/components/ha-wa-dialog.ts index 41fafe785d..5a88561e66 100644 --- a/gallery/src/pages/components/ha-wa-dialog.ts +++ b/gallery/src/pages/components/ha-wa-dialog.ts @@ -381,10 +381,6 @@ export class DemoHaWaDialog extends LitElement { --dialog-z-index Z-index for the dialog. - - --dialog-surface-position - CSS position of the dialog surface. - --dialog-surface-margin-top Top margin for the dialog surface. diff --git a/src/components/ha-wa-dialog.ts b/src/components/ha-wa-dialog.ts index a571acc38b..42448c5425 100644 --- a/src/components/ha-wa-dialog.ts +++ b/src/components/ha-wa-dialog.ts @@ -49,7 +49,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full"; * @cssprop --ha-dialog-surface-background - Dialog background color. * @cssprop --ha-dialog-border-radius - Border radius of the dialog surface. * @cssprop --dialog-z-index - Z-index for the dialog. - * @cssprop --dialog-surface-position - CSS position of the dialog surface. * @cssprop --dialog-surface-margin-top - Top margin for the dialog surface. * * @attr {boolean} open - Controls the dialog open state. @@ -244,7 +243,6 @@ export class HaWaDialog extends LitElement { calc(var(--safe-height) - var(--ha-space-20)) ); min-height: var(--ha-dialog-min-height); - position: var(--dialog-surface-position, relative); margin-top: var(--dialog-surface-margin-top, auto); /* Used to offset the dialog from the safe areas when space is limited */ transform: translate( From eaa1ddbf612f3afa0f24535080b5f47ee041be2c Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:45:18 +0100 Subject: [PATCH 64/99] Fix filtering of floors in getAreasAndFloorsItems function (#28302) --- src/data/area_floor.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/data/area_floor.ts b/src/data/area_floor.ts index 75d96434b0..bbaf4226b2 100644 --- a/src/data/area_floor.ts +++ b/src/data/area_floor.ts @@ -223,6 +223,7 @@ const getAreasAndFloorsItems = ( } let outputAreas = areas; + let outputFloors = floors; let areaIds: string[] | undefined; @@ -254,9 +255,29 @@ const getAreasAndFloorsItems = ( outputAreas = outputAreas.filter( (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) ); + + outputFloors = outputFloors.filter( + (floor) => !excludeFloors!.includes(floor.floor_id) + ); } - const hierarchy = getAreasFloorHierarchy(floors, outputAreas); + if ( + entityFilter || + deviceFilter || + includeDomains || + excludeDomains || + includeDeviceClasses + ) { + // Ensure we only include floors that have areas with the filtered entities/devices + const validFloorIds = new Set( + outputAreas.map((area) => area.floor_id).filter((id) => id) + ); + outputFloors = outputFloors.filter((floor) => + validFloorIds.has(floor.floor_id) + ); + } + + const hierarchy = getAreasFloorHierarchy(outputFloors, outputAreas); const items: ( | FloorComboBoxItem From 109c81a00de7e4f8b09c58edbb037713669a5f9b Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:27:39 +0100 Subject: [PATCH 65/99] Revert "Migrate updates dropdown to ha-dropdown" (#28303) Revert "Migrate updates dropdown to ha-dropdown (#28039)" This reverts commit ba9bab38c9cde7935492d00b195d44d346ff7800. --- .../config/core/ha-config-section-updates.ts | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/src/panels/config/core/ha-config-section-updates.ts b/src/panels/config/core/ha-config-section-updates.ts index 84bc89ccc3..ba334d7f3d 100644 --- a/src/panels/config/core/ha-config-section-updates.ts +++ b/src/panels/config/core/ha-config-section-updates.ts @@ -1,3 +1,4 @@ +import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item"; import { mdiDotsVertical, mdiRefresh } from "@mdi/js"; import type { HassEntities } from "home-assistant-js-websocket"; import type { TemplateResult } from "lit"; @@ -5,9 +6,13 @@ import { LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import "../../../components/ha-alert"; import "../../../components/ha-bar"; +import "../../../components/ha-button-menu"; import "../../../components/ha-card"; +import "../../../components/ha-check-list-item"; +import "../../../components/ha-list-item"; import "../../../components/ha-metric"; import { extractApiErrorMessage } from "../../../data/hassio/common"; import type { @@ -28,9 +33,6 @@ import "../../../layouts/hass-subpage"; import type { HomeAssistant } from "../../../types"; import "../dashboard/ha-config-updates"; import { showJoinBetaDialog } from "./updates/show-dialog-join-beta"; -import "../../../components/ha-dropdown"; -import "../../../components/ha-dropdown-item"; -import "@home-assistant/webawesome/dist/components/divider/divider"; @customElement("ha-config-section-updates") class HaConfigSectionUpdates extends LitElement { @@ -71,25 +73,24 @@ class HaConfigSectionUpdates extends LitElement { .path=${mdiRefresh} @click=${this._checkUpdates} > - + - - ${this.hass.localize("ui.panel.config.updates.show_skipped")} - + ${this._supervisorInfo ? html` - - + ${this._supervisorInfo.channel === "stable" @@ -97,10 +98,10 @@ class HaConfigSectionUpdates extends LitElement { : this.hass.localize( "ui.panel.config.updates.leave_beta" )} - + ` : ""} - +
@@ -132,19 +133,27 @@ class HaConfigSectionUpdates extends LitElement { this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass); } - private async _handleOverflowAction( - ev: CustomEvent<{ item: { value: string } }> + private _toggleSkipped(ev: CustomEvent): void { + if (ev.detail.source !== "property") { + return; + } + + this._showSkipped = !this._showSkipped; + } + + private async _toggleBeta( + ev: CustomEvent ): Promise { - if (ev.detail.item.value === "toggle_beta") { - if (this._supervisorInfo!.channel === "stable") { - showJoinBetaDialog(this, { - join: async () => this._setChannel("beta"), - }); - } else { - this._setChannel("stable"); - } - } else if (ev.detail.item.value === "show_skipped") { - this._showSkipped = !this._showSkipped; + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + + if (this._supervisorInfo!.channel === "stable") { + showJoinBetaDialog(this, { + join: async () => this._setChannel("beta"), + }); + } else { + this._setChannel("stable"); } } From 24b16360a6e7a3ff3d0e31a5efc1fbc19de9cb7c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 3 Dec 2025 12:28:55 +0100 Subject: [PATCH 66/99] Use core area sorting everywhere (#28304) --- src/components/ha-areas-display-editor.ts | 7 +------ .../ha-areas-floors-display-editor.ts | 6 +----- src/data/area_registry.ts | 20 ------------------- .../common/generate-lovelace-config.ts | 19 ++++++++++++------ .../areas/helpers/areas-strategy-helper.ts | 7 +++++-- 5 files changed, 20 insertions(+), 39 deletions(-) diff --git a/src/components/ha-areas-display-editor.ts b/src/components/ha-areas-display-editor.ts index 443d7b057c..baf1d6c6d9 100644 --- a/src/components/ha-areas-display-editor.ts +++ b/src/components/ha-areas-display-editor.ts @@ -4,7 +4,6 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import { getAreaContext } from "../common/entity/context/get_area_context"; -import { areaCompare } from "../data/area_registry"; import type { HomeAssistant } from "../types"; import "./ha-expansion-panel"; import "./ha-items-display-editor"; @@ -37,11 +36,7 @@ export class HaAreasDisplayEditor extends LitElement { public showNavigationButton = false; protected render(): TemplateResult { - const compare = areaCompare(this.hass.areas); - - const areas = Object.values(this.hass.areas).sort((areaA, areaB) => - compare(areaA.area_id, areaB.area_id) - ); + const areas = Object.values(this.hass.areas); const items: DisplayItem[] = areas.map((area) => { const { floor } = getAreaContext(area, this.hass.floors); diff --git a/src/components/ha-areas-floors-display-editor.ts b/src/components/ha-areas-floors-display-editor.ts index ca9743e9a4..29b6034346 100644 --- a/src/components/ha-areas-floors-display-editor.ts +++ b/src/components/ha-areas-floors-display-editor.ts @@ -7,7 +7,6 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeFloorName } from "../common/entity/compute_floor_name"; import { getAreaContext } from "../common/entity/context/get_area_context"; -import { areaCompare } from "../data/area_registry"; import type { FloorRegistryEntry } from "../data/floor_registry"; import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper"; import type { HomeAssistant } from "../types"; @@ -131,11 +130,8 @@ export class HaAreasFloorsDisplayEditor extends LitElement { // update items if floors change _hassFloors: HomeAssistant["floors"] ): Record => { - const compare = areaCompare(hassAreas); + const areas = Object.values(hassAreas); - const areas = Object.values(hassAreas).sort((areaA, areaB) => - compare(areaA.area_id, areaB.area_id) - ); const groupedItems: Record = areas.reduce( (acc, area) => { const { floor } = getAreaContext(area, this.hass.floors); diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 915a26c005..15a0a19fff 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -1,4 +1,3 @@ -import { stringCompare } from "../common/string/compare"; import type { HomeAssistant } from "../types"; import type { DeviceRegistryEntry } from "./device_registry"; import type { @@ -105,22 +104,3 @@ export const getAreaDeviceLookup = ( } return areaDeviceLookup; }; - -export const areaCompare = - (entries?: HomeAssistant["areas"], order?: string[]) => - (a: string, b: string) => { - const indexA = order ? order.indexOf(a) : -1; - const indexB = order ? order.indexOf(b) : -1; - if (indexA === -1 && indexB === -1) { - const nameA = entries?.[a]?.name ?? a; - const nameB = entries?.[b]?.name ?? b; - return stringCompare(nameA, nameB); - } - if (indexA === -1) { - return 1; - } - if (indexB === -1) { - return -1; - } - return indexA - indexB; - }; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 0beee6292d..a4aeed8bb0 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -5,10 +5,9 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain" import { computeStateName } from "../../../common/entity/compute_state_name"; import { splitByGroups } from "../../../common/entity/split_by_groups"; import { stripPrefixFromEntityName } from "../../../common/entity/strip_prefix_from_entity_name"; -import { stringCompare } from "../../../common/string/compare"; +import { orderCompare, stringCompare } from "../../../common/string/compare"; import type { LocalizeFunc } from "../../../common/translations/localize"; import type { AreasDisplayValue } from "../../../components/ha-areas-display-editor"; -import { areaCompare } from "../../../data/area_registry"; import type { EnergyPreferences, GridSourceTypeEnergyPreference, @@ -572,13 +571,21 @@ export const generateDefaultViewConfig = ( const areaCards: LovelaceCardConfig[] = []; - const sortedAreas = Object.keys(splittedByAreaDevice.areasWithEntities).sort( - areaCompare(areaEntries, areasPrefs?.order) - ); + const areaIds = Object.keys(areaEntries); - for (const areaId of sortedAreas) { + if (areasPrefs?.order) { + const areaOrder = areasPrefs.order; + areaIds.sort(orderCompare(areaOrder)); + } + + for (const areaId of areaIds) { + // Skip areas with no entities + if (!(areaId in splittedByAreaDevice.areasWithEntities)) { + continue; + } const areaEntities = splittedByAreaDevice.areasWithEntities[areaId]; const area = areaEntries[areaId]; + areaCards.push( ...computeCards( hass, diff --git a/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts index 111df47497..e194e09ee1 100644 --- a/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts +++ b/src/panels/lovelace/strategies/areas/helpers/areas-strategy-helper.ts @@ -5,7 +5,6 @@ import { generateEntityFilter } from "../../../../../common/entity/entity_filter import { stripPrefixFromEntityName } from "../../../../../common/entity/strip_prefix_from_entity_name"; import { orderCompare } from "../../../../../common/string/compare"; import type { AreaRegistryEntry } from "../../../../../data/area_registry"; -import { areaCompare } from "../../../../../data/area_registry"; import type { FloorRegistryEntry } from "../../../../../data/floor_registry"; import type { LovelaceCardConfig } from "../../../../../data/lovelace/config/card"; import type { HomeAssistant } from "../../../../../types"; @@ -287,7 +286,11 @@ export const getAreas = ( ? areas.filter((area) => !hiddenAreas!.includes(area.area_id)) : areas.concat(); - const compare = areaCompare(entries, areasOrder); + if (!areasOrder) { + return filteredAreas; + } + + const compare = orderCompare(areasOrder); const sortedAreas = filteredAreas.sort((areaA, areaB) => compare(areaA.area_id, areaB.area_id) From ee9e101fa64156a027fb9c5f112487b6418eea9c Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 3 Dec 2025 12:42:14 +0100 Subject: [PATCH 67/99] Rename unassigned areas to other areas (#28305) --- src/panels/config/areas/dialog-areas-floors-order.ts | 2 +- src/panels/config/areas/ha-config-areas-dashboard.ts | 2 +- src/translations/en.json | 5 ++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/panels/config/areas/dialog-areas-floors-order.ts b/src/panels/config/areas/dialog-areas-floors-order.ts index d8d1a5d288..f96e8687c4 100644 --- a/src/panels/config/areas/dialog-areas-floors-order.ts +++ b/src/panels/config/areas/dialog-areas-floors-order.ts @@ -172,7 +172,7 @@ class DialogAreasFloorsOrder extends LitElement { ? html`
${this.hass.localize( - "ui.panel.config.areas.dialog.unassigned_areas" + "ui.panel.config.areas.dialog.other_areas" )}
` diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index b10b9adc81..ae1cd10a6a 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -266,7 +266,7 @@ export class HaConfigAreasDashboard extends LitElement {

${this.hass.localize( - "ui.panel.config.areas.picker.unassigned_areas" + "ui.panel.config.areas.picker.other_areas" )}

diff --git a/src/translations/en.json b/src/translations/en.json index 878582aa0d..183aa14669 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -830,7 +830,6 @@ "add_new": "Add new area…", "no_areas": "No areas available", "no_match": "No areas found for {term}", - "unassigned_areas": "Unassigned areas", "failed_create_area": "Failed to create area." }, "floor-picker": { @@ -2463,7 +2462,7 @@ "introduction2": "To place devices in an area, use the link below to navigate to the integrations page and then click on a configured integration to get to the device cards.", "integrations_page": "Integrations page", "no_areas": "Looks like you have no areas yet!", - "unassigned_areas": "Unassigned areas", + "other_areas": "Other areas", "create_area": "Create area", "create_floor": "Create floor", "floor": { @@ -2479,7 +2478,7 @@ }, "dialog": { "reorder_title": "Reorder floors and areas", - "unassigned_areas": "Unassigned areas", + "other_areas": "Other areas", "reorder_failed": "Failed to save order", "empty_floor": "No areas on this floor", "empty_unassigned": "All your areas are assigned to floors" From feb35dbc4fa45ec51516aafa2f34c16820ae1f69 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 3 Dec 2025 12:46:07 +0100 Subject: [PATCH 68/99] Use svg for snowflakes (#28306) --- src/components/ha-snowflakes.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/components/ha-snowflakes.ts b/src/components/ha-snowflakes.ts index 05a8416e92..fa88fb86ee 100644 --- a/src/components/ha-snowflakes.ts +++ b/src/components/ha-snowflakes.ts @@ -10,7 +10,6 @@ interface Snowflake { size: number; duration: number; delay: number; - blur: number; } @customElement("ha-snowflakes") @@ -51,7 +50,6 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { size: Math.random() * 12 + 8, // Random size between 8-20px duration: Math.random() * 8 + 8, // Random duration between 8-16s delay: Math.random() * 8, // Random delay between 0-8s - blur: Math.random() * 1, // Random blur between 0-1px }); } this._snowflakes = snowflakes; @@ -75,20 +73,26 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { @@ -128,16 +132,10 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { .light .snowflake { color: #00bcd4; - text-shadow: - 0 0 5px #00bcd4, - 0 0 10px #00e5ff; } .dark .snowflake { color: #fff; - text-shadow: - 0 0 5px rgba(255, 255, 255, 0.8), - 0 0 10px rgba(255, 255, 255, 0.5); } .snowflake.hide-narrow { From f3710650f204f6109a84348c4d852127a3faf8e6 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:02:25 +0100 Subject: [PATCH 69/99] Hide disabled devices in automation target tree (#28307) --- .../ha-automation-add-from-target.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts index 9986f2f8ce..f7fb75a3b6 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-from-target.ts @@ -911,6 +911,10 @@ export default class HaAutomationAddFromTarget extends LitElement { const services: Record = {}; unassignedDevices.forEach(({ id: deviceId, entry_type }) => { + const device = this.devices[deviceId]; + if (!device || device.disabled_by) { + return; + } const deviceEntry = { open: false, entities: @@ -1012,6 +1016,10 @@ export default class HaAutomationAddFromTarget extends LitElement { const devices: Record = {}; referenced_devices.forEach(({ id: deviceId }) => { + const device = this.devices[deviceId]; + if (!device || device.disabled_by) { + return; + } devices[deviceId] = { open: false, entities: From de5778079eef1f96fe199d9b65ea6bc600d48e5c Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 3 Dec 2025 13:12:08 +0000 Subject: [PATCH 70/99] Add small rotation to snowflakes (#28308) --- src/components/ha-snowflakes.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/ha-snowflakes.ts b/src/components/ha-snowflakes.ts index fa88fb86ee..3cd9398875 100644 --- a/src/components/ha-snowflakes.ts +++ b/src/components/ha-snowflakes.ts @@ -10,6 +10,7 @@ interface Snowflake { size: number; duration: number; delay: number; + rotation: number; } @customElement("ha-snowflakes") @@ -50,6 +51,7 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { size: Math.random() * 12 + 8, // Random size between 8-20px duration: Math.random() * 8 + 8, // Random duration between 8-16s delay: Math.random() * 8, // Random delay between 0-8s + rotation: Math.random() * 720 - 360, // Random starting rotation -360 to 360deg }); } this._snowflakes = snowflakes; @@ -83,6 +85,7 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { height: ${flake.size}px; animation-duration: ${flake.duration}s; animation-delay: ${flake.delay}s; + --rotation: ${flake.rotation}deg; " viewBox="0 0 16 16" fill="none" @@ -144,19 +147,23 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { @keyframes fall { 0% { - transform: translateY(-10vh) translateX(0); + transform: translateY(-10vh) translateX(0) rotate(var(--rotation)); } 25% { - transform: translateY(30vh) translateX(10px); + transform: translateY(30vh) translateX(10px) + rotate(calc(var(--rotation) + 25deg)); } 50% { - transform: translateY(60vh) translateX(-10px); + transform: translateY(60vh) translateX(-10px) + rotate(calc(var(--rotation) + 50deg)); } 75% { - transform: translateY(85vh) translateX(10px); + transform: translateY(85vh) translateX(10px) + rotate(calc(var(--rotation) + 75deg)); } 100% { - transform: translateY(120vh) translateX(0); + transform: translateY(120vh) translateX(0) + rotate(calc(var(--rotation) + 100deg)); } } From 60724eb952bb9c28cda8b48d17661d1ff5d9bf25 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 3 Dec 2025 13:16:09 +0000 Subject: [PATCH 71/99] Add subscribeLabFeature function (#28309) * Add subscribe to lab feature function * Add docstrings to exported functions --- src/components/ha-snowflakes.ts | 17 +++---- src/data/labs.ts | 44 +++++++++++++++++++ .../add-automation-element-dialog.ts | 15 +++---- .../condition/ha-automation-condition.ts | 18 ++++---- .../trigger/ha-automation-trigger.ts | 18 ++++---- 5 files changed, 77 insertions(+), 35 deletions(-) diff --git a/src/components/ha-snowflakes.ts b/src/components/ha-snowflakes.ts index 3cd9398875..45ffbc3e18 100644 --- a/src/components/ha-snowflakes.ts +++ b/src/components/ha-snowflakes.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import type { HomeAssistant } from "../types"; -import { subscribeLabFeatures } from "../data/labs"; +import { subscribeLabFeature } from "../data/labs"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; interface Snowflake { @@ -27,13 +27,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { public hassSubscribe() { return [ - subscribeLabFeatures(this.hass!.connection, (features) => { - this._enabled = - features.find( - (f) => - f.domain === "frontend" && f.preview_feature === "winter_mode" - )?.enabled ?? false; - }), + subscribeLabFeature( + this.hass!.connection, + "frontend", + "winter_mode", + (enabled) => { + this._enabled = enabled; + } + ), ]; } diff --git a/src/data/labs.ts b/src/data/labs.ts index 64071e2b13..4eda13bc6f 100644 --- a/src/data/labs.ts +++ b/src/data/labs.ts @@ -18,6 +18,11 @@ export interface LabPreviewFeaturesResponse { features: LabPreviewFeature[]; } +/** + * Fetch all lab features + * @param hass - The Home Assistant instance + * @returns A promise to fetch the lab features + */ export const fetchLabFeatures = async ( hass: HomeAssistant ): Promise => { @@ -27,6 +32,15 @@ export const fetchLabFeatures = async ( return response.features; }; +/** + * Update a specific lab feature + * @param hass - The Home Assistant instance + * @param domain - The domain of the lab feature + * @param preview_feature - The preview feature of the lab feature + * @param enabled - Whether the lab feature is enabled + * @param create_backup - Whether to create a backup of the lab feature + * @returns A promise to update the lab feature + */ export const labsUpdatePreviewFeature = ( hass: HomeAssistant, domain: string, @@ -65,6 +79,12 @@ const subscribeLabUpdates = ( "labs_updated" ); +/** + * Subscribe to a collection of lab features + * @param conn - The connection to the Home Assistant instance + * @param onChange - The function to call when the lab features change + * @returns The unsubscribe function + */ export const subscribeLabFeatures = ( conn: Connection, onChange: (features: LabPreviewFeature[]) => void @@ -76,3 +96,27 @@ export const subscribeLabFeatures = ( conn, onChange ); + +/** + * Subscribe to a specific lab feature + * @param conn - The connection to the Home Assistant instance + * @param domain - The domain of the lab feature + * @param previewFeature - The preview feature of the lab feature + * @param onChange - The function to call when the lab feature changes + * @returns The unsubscribe function + */ +export const subscribeLabFeature = ( + conn: Connection, + domain: string, + previewFeature: string, + onChange: (enabled: boolean) => void +) => + subscribeLabFeatures(conn, (features) => { + const enabled = + features.find( + (feature) => + feature.domain === domain && + feature.preview_feature === previewFeature + )?.enabled ?? false; + onChange(enabled); + }); diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 4b8ca3f54c..fe7aa4fd9d 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -97,7 +97,7 @@ import { fetchIntegrationManifests, } from "../../../data/integration"; import type { LabelRegistryEntry } from "../../../data/label_registry"; -import { subscribeLabFeatures } from "../../../data/labs"; +import { subscribeLabFeature } from "../../../data/labs"; import { TARGET_SEPARATOR, getConditionsForTarget, @@ -281,15 +281,12 @@ class DialogAddAutomationElement this._fetchManifests(); this._calculateUsedDomains(); - this._unsubscribeLabFeatures = subscribeLabFeatures( + this._unsubscribeLabFeatures = subscribeLabFeature( this.hass.connection, - (features) => { - this._newTriggersAndConditions = - features.find( - (feature) => - feature.domain === "automation" && - feature.preview_feature === "new_triggers_conditions" - )?.enabled ?? false; + "automation", + "new_triggers_conditions", + (enabled) => { + this._newTriggersAndConditions = enabled; this._tab = this._newTriggersAndConditions ? "targets" : "groups"; } ); diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index ea20b92a67..be3df93561 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -28,7 +28,7 @@ import { CONDITION_BUILDING_BLOCKS, subscribeConditions, } from "../../../../data/condition"; -import { subscribeLabFeatures } from "../../../../data/labs"; +import { subscribeLabFeature } from "../../../../data/labs"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../../types"; import { @@ -90,14 +90,14 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) { protected hassSubscribe() { return [ - subscribeLabFeatures(this.hass!.connection, (features) => { - this._newTriggersAndConditions = - features.find( - (feature) => - feature.domain === "automation" && - feature.preview_feature === "new_triggers_conditions" - )?.enabled ?? false; - }), + subscribeLabFeature( + this.hass!.connection, + "automation", + "new_triggers_conditions", + (enabled) => { + this._newTriggersAndConditions = enabled; + } + ), ]; } diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 0510cfe166..c39be3c1e0 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -24,7 +24,7 @@ import { type Trigger, type TriggerList, } from "../../../../data/automation"; -import { subscribeLabFeatures } from "../../../../data/labs"; +import { subscribeLabFeature } from "../../../../data/labs"; import type { TriggerDescriptions } from "../../../../data/trigger"; import { isTriggerList, subscribeTriggers } from "../../../../data/trigger"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; @@ -85,14 +85,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) { protected hassSubscribe() { return [ - subscribeLabFeatures(this.hass!.connection, (features) => { - this._newTriggersAndConditions = - features.find( - (feature) => - feature.domain === "automation" && - feature.preview_feature === "new_triggers_conditions" - )?.enabled ?? false; - }), + subscribeLabFeature( + this.hass!.connection, + "automation", + "new_triggers_conditions", + (enabled) => { + this._newTriggersAndConditions = enabled; + } + ), ]; } From 5a52f8335808ef3598f55eb54f6650559bc09812 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Dec 2025 14:24:29 +0100 Subject: [PATCH 72/99] Fix sticky headers in TCA dialog when target is selected (#28310) --- .../config/automation/add-automation-element-dialog.ts | 3 ++- .../add-automation-element/ha-automation-add-items.ts | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index fe7aa4fd9d..e50c0da0f9 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -683,6 +683,7 @@ class DialogAddAutomationElement Date: Wed, 3 Dec 2025 16:29:48 +0200 Subject: [PATCH 73/99] Fix label filter losing selections when searching (#28312) --- src/components/ha-filter-labels.ts | 31 ++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/components/ha-filter-labels.ts b/src/components/ha-filter-labels.ts index db5ec941a3..71b5a27c55 100644 --- a/src/components/ha-filter-labels.ts +++ b/src/components/ha-filter-labels.ts @@ -167,30 +167,33 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) { } private async _labelSelected(ev: CustomEvent>>) { - if (!ev.detail.index.size) { - fireEvent(this, "data-table-filter-changed", { - value: [], - items: undefined, - }); - this.value = []; - return; - } - - const value: string[] = []; const filteredLabels = this._filteredLabels( this._labels, this._filter, this.value ); + const filteredLabelIds = new Set(filteredLabels.map((l) => l.label_id)); + + // Keep previously selected labels that are not in the current filtered view + const preservedLabels = (this.value || []).filter( + (id) => !filteredLabelIds.has(id) + ); + + // Build the new selection from the filtered labels based on selected indices + const newlySelectedLabels: string[] = []; for (const index of ev.detail.index) { - const labelId = filteredLabels[index].label_id; - value.push(labelId); + const labelId = filteredLabels[index]?.label_id; + if (labelId) { + newlySelectedLabels.push(labelId); + } } - this.value = value; + + const value = [...preservedLabels, ...newlySelectedLabels]; + this.value = value.length ? value : []; fireEvent(this, "data-table-filter-changed", { - value, + value: value.length ? value : undefined, items: undefined, }); } From 1260af0b456db69ff77071d24ca360626256b27d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Dec 2025 15:30:26 +0100 Subject: [PATCH 74/99] Fix add matter device my link (#28313) --- .../integration-panels/matter/matter-add-device.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panels/config/integrations/integration-panels/matter/matter-add-device.ts b/src/panels/config/integrations/integration-panels/matter/matter-add-device.ts index c677c680aa..8dad2c4824 100644 --- a/src/panels/config/integrations/integration-panels/matter/matter-add-device.ts +++ b/src/panels/config/integrations/integration-panels/matter/matter-add-device.ts @@ -9,10 +9,10 @@ export class MatterAddDevice extends HTMLElement { public hass!: HomeAssistant; connectedCallback() { - showMatterAddDeviceDialog(this); - navigate(`/config/devices`, { + navigate("/config/devices/dashboard", { replace: true, }); + showMatterAddDeviceDialog(this); } } From d77bebe96b0ec96af04876b2aa86d65a2b20aa83 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Dec 2025 15:38:49 +0100 Subject: [PATCH 75/99] Bumped version to 20251203.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8ac14bb2ec..c6f73a01c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251202.0" +version = "20251203.0" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From f6f40c1679ea3de28abb6bb647de5caf55ed0548 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 3 Dec 2025 18:18:07 +0200 Subject: [PATCH 76/99] Always show energy-sources-table in overview (#28315) --- .../energy-overview-view-strategy.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index 3cc68629eb..2687d78dcc 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -2,20 +2,12 @@ import { ReactiveElement } from "lit"; import { customElement } from "lit/decorators"; import type { GridSourceTypeEnergyPreference } from "../../../data/energy"; import { getEnergyDataCollection } from "../../../data/energy"; -import type { HomeAssistant } from "../../../types"; -import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; -import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section"; +import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy"; +import type { LovelaceViewConfig } from "../../../data/lovelace/config/view"; +import type { HomeAssistant } from "../../../types"; import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy"; -const sourceHasCost = (source: Record): boolean => - Boolean( - source.stat_cost || - source.stat_compensation || - source.entity_energy_price || - source.number_energy_price - ); - @customElement("energy-overview-view-strategy") export class EnergyOverviewViewStrategy extends ReactiveElement { static async generate( @@ -68,13 +60,6 @@ export class EnergyOverviewViewStrategy extends ReactiveElement { (source.type === "battery" && source.stat_rate) || (source.type === "grid" && source.power?.length) ); - const hasCost = prefs.energy_sources.some( - (source) => - sourceHasCost(source) || - (source.type === "grid" && - (source.flow_from?.some(sourceHasCost) || - source.flow_to?.some(sourceHasCost))) - ); const overviewSection: LovelaceSectionConfig = { type: "grid", @@ -95,7 +80,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement { } view.sections!.push(overviewSection); - if (hasCost) { + if (prefs.energy_sources.length) { view.sections!.push({ type: "grid", cards: [ From a1412e90fdd9334d227cb71699e7b8310d3a2c30 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 3 Dec 2025 20:46:59 +0100 Subject: [PATCH 77/99] Add more info to the energy demo (#28316) * Add more info to the energy demo * Also add battery power --- build-scripts/gulp/translations.js | 4 +- demo/src/stubs/energy.ts | 32 +++++++++- demo/src/stubs/entities.ts | 95 ++++++++++++++++++++++++++++++ demo/src/stubs/recorder.ts | 6 +- 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/build-scripts/gulp/translations.js b/build-scripts/gulp/translations.js index 05defb9e62..76af601e89 100755 --- a/build-scripts/gulp/translations.js +++ b/build-scripts/gulp/translations.js @@ -156,7 +156,9 @@ const createTestTranslation = () => */ const createMasterTranslation = () => gulp - .src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])]) + .src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])], { + allowEmpty: true, + }) .pipe(new CustomJSON(lokaliseTransform)) .pipe(new MergeJSON("en")) .pipe(gulp.dest(workDir)); diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts index c038828c5c..f5590f3305 100644 --- a/demo/src/stubs/energy.ts +++ b/demo/src/stubs/energy.ts @@ -44,18 +44,24 @@ export const mockEnergy = (hass: MockHomeAssistant) => { number_energy_price: null, }, ], + power: [ + { stat_rate: "sensor.power_grid" }, + { stat_rate: "sensor.power_grid_return" }, + ], cost_adjustment_day: 0, }, { type: "solar", stat_energy_from: "sensor.solar_production", + stat_rate: "sensor.power_solar", config_entry_solar_forecast: ["solar_forecast"], }, - /* { + { type: "battery", stat_energy_from: "sensor.battery_output", stat_energy_to: "sensor.battery_input", - }, */ + stat_rate: "sensor.power_battery", + }, { type: "gas", stat_energy_from: "sensor.energy_gas", @@ -63,28 +69,48 @@ export const mockEnergy = (hass: MockHomeAssistant) => { entity_energy_price: null, number_energy_price: null, }, + { + type: "water", + stat_energy_from: "sensor.energy_water", + stat_cost: "sensor.energy_water_cost", + entity_energy_price: null, + number_energy_price: null, + }, ], device_consumption: [ { stat_consumption: "sensor.energy_car", + stat_rate: "sensor.power_car", }, { stat_consumption: "sensor.energy_ac", + stat_rate: "sensor.power_ac", }, { stat_consumption: "sensor.energy_washing_machine", + stat_rate: "sensor.power_washing_machine", }, { stat_consumption: "sensor.energy_dryer", + stat_rate: "sensor.power_dryer", }, { stat_consumption: "sensor.energy_heat_pump", + stat_rate: "sensor.power_heat_pump", }, { stat_consumption: "sensor.energy_boiler", + stat_rate: "sensor.power_boiler", + }, + ], + device_consumption_water: [ + { + stat_consumption: "sensor.water_kitchen", + }, + { + stat_consumption: "sensor.water_garden", }, ], - device_consumption_water: [], }) ); hass.mockWS( diff --git a/demo/src/stubs/entities.ts b/demo/src/stubs/entities.ts index 8c863f9a3b..014e5fbc2d 100644 --- a/demo/src/stubs/entities.ts +++ b/demo/src/stubs/entities.ts @@ -154,6 +154,38 @@ export const energyEntities = () => unit_of_measurement: "EUR", }, }, + "sensor.power_grid": { + entity_id: "sensor.power_grid", + state: "500", + attributes: { + state_class: "measurement", + unit_of_measurement: "W", + }, + }, + "sensor.power_grid_return": { + entity_id: "sensor.power_grid_return", + state: "-100", + attributes: { + state_class: "measurement", + unit_of_measurement: "W", + }, + }, + "sensor.power_solar": { + entity_id: "sensor.power_solar", + state: "200", + attributes: { + state_class: "measurement", + unit_of_measurement: "W", + }, + }, + "sensor.power_battery": { + entity_id: "sensor.power_battery", + state: "100", + attributes: { + state_class: "measurement", + unit_of_measurement: "W", + }, + }, "sensor.energy_gas_cost": { entity_id: "sensor.energy_gas_cost", state: "2", @@ -171,6 +203,15 @@ export const energyEntities = () => unit_of_measurement: "m³", }, }, + "sensor.energy_water": { + entity_id: "sensor.energy_water", + state: "4000", + attributes: { + last_reset: "1970-01-01T00:00:00:00+00", + friendly_name: "Water", + unit_of_measurement: "L", + }, + }, "sensor.energy_car": { entity_id: "sensor.energy_car", state: "4", @@ -225,4 +266,58 @@ export const energyEntities = () => unit_of_measurement: "kWh", }, }, + "sensor.power_car": { + entity_id: "sensor.power_car", + state: "40", + attributes: { + state_class: "measurement", + friendly_name: "Electric car", + unit_of_measurement: "W", + }, + }, + "sensor.power_ac": { + entity_id: "sensor.power_ac", + state: "30", + attributes: { + state_class: "measurement", + friendly_name: "Air conditioning", + unit_of_measurement: "W", + }, + }, + "sensor.power_washing_machine": { + entity_id: "sensor.power_washing_machine", + state: "60", + attributes: { + state_class: "measurement", + friendly_name: "Washing machine", + unit_of_measurement: "W", + }, + }, + "sensor.power_dryer": { + entity_id: "sensor.power_dryer", + state: "55", + attributes: { + state_class: "measurement", + friendly_name: "Dryer", + unit_of_measurement: "W", + }, + }, + "sensor.power_heat_pump": { + entity_id: "sensor.power_heat_pump", + state: "60", + attributes: { + state_class: "measurement", + friendly_name: "Heat pump", + unit_of_measurement: "W", + }, + }, + "sensor.power_boiler": { + entity_id: "sensor.power_boiler", + state: "70", + attributes: { + state_class: "measurement", + friendly_name: "Boiler", + unit_of_measurement: "W", + }, + }, }); diff --git a/demo/src/stubs/recorder.ts b/demo/src/stubs/recorder.ts index 3acce8b6d7..eedba9613c 100644 --- a/demo/src/stubs/recorder.ts +++ b/demo/src/stubs/recorder.ts @@ -17,17 +17,15 @@ const generateMeanStatistics = ( end: Date, // eslint-disable-next-line default-param-last period: "5minute" | "hour" | "day" | "month" = "hour", - initValue: number, maxDiff: number ): StatisticValue[] => { const statistics: StatisticValue[] = []; let currentDate = new Date(start); currentDate.setMinutes(0, 0, 0); - let lastVal = initValue; const now = new Date(); while (end > currentDate && currentDate < now) { const delta = Math.random() * maxDiff; - const mean = lastVal + delta; + const mean = delta; statistics.push({ start: currentDate.getTime(), end: currentDate.getTime(), @@ -38,7 +36,6 @@ const generateMeanStatistics = ( state: mean, sum: null, }); - lastVal = mean; currentDate = period === "day" ? addDays(currentDate, 1) @@ -336,7 +333,6 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => { start, end, period, - state, state * (state > 80 ? 0.05 : 0.1) ); } From c1e5e0bfcb2850a9bfaddf1b9d35a6ffb65959a0 Mon Sep 17 00:00:00 2001 From: Preet Patel Date: Thu, 4 Dec 2025 19:38:43 +1300 Subject: [PATCH 78/99] Fix energy dashboard redirect for device-consumption-only configs (#28322) When users configure energy with only device consumption (no grid/solar/battery/gas/water sources), the dashboard would redirect to /config/energy instead of displaying. This occurred because _generateLovelaceConfig() returned an empty views array. The fix adds hasDeviceConsumption check and includes ENERGY_VIEW when device consumption is configured, since energy-view-strategy already supports device consumption cards. --- src/panels/energy/ha-panel-energy.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/panels/energy/ha-panel-energy.ts b/src/panels/energy/ha-panel-energy.ts index 317a87aae6..284fb08270 100644 --- a/src/panels/energy/ha-panel-energy.ts +++ b/src/panels/energy/ha-panel-energy.ts @@ -268,8 +268,10 @@ class PanelEnergy extends LitElement { (source) => source.type === "gas" ); + const hasDeviceConsumption = this._prefs.device_consumption.length > 0; + const views: LovelaceViewConfig[] = []; - if (hasEnergy) { + if (hasEnergy || hasDeviceConsumption) { views.push(ENERGY_VIEW); } if (hasGas) { From c001102f15a34cfa8a8e9be651631ef29e0a63f8 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 4 Dec 2025 11:18:48 +0200 Subject: [PATCH 79/99] Append current state to power-sources-graph (#28330) --- src/data/energy.ts | 36 ++++++++++++++++++- .../cards/energy/hui-power-sankey-card.ts | 33 +++-------------- .../energy/hui-power-sources-graph-card.ts | 15 ++++++-- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/src/data/energy.ts b/src/data/energy.ts index be73dfa3ae..035a1291f3 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -11,7 +11,7 @@ import { isLastDayOfMonth, addYears, } from "date-fns"; -import type { Collection } from "home-assistant-js-websocket"; +import type { Collection, HassEntity } from "home-assistant-js-websocket"; import { getCollection } from "home-assistant-js-websocket"; import memoizeOne from "memoize-one"; import { @@ -1361,3 +1361,37 @@ export const calculateSolarConsumedGauge = ( } return undefined; }; + +/** + * Get current power value from entity state, normalized to kW + * @param stateObj - The entity state object to get power value from + * @returns Power value in kW, or 0 if entity not found or invalid + */ +export const getPowerFromState = (stateObj: HassEntity): number | undefined => { + if (!stateObj) { + return undefined; + } + const value = parseFloat(stateObj.state); + if (isNaN(value)) { + return undefined; + } + + // Normalize to kW based on unit of measurement (case-sensitive) + // Supported units: GW, kW, MW, mW, TW, W + const unit = stateObj.attributes.unit_of_measurement; + switch (unit) { + case "W": + return value / 1000; + case "mW": + return value / 1000000; + case "MW": + return value * 1000; + case "GW": + return value * 1000000; + case "TW": + return value * 1000000000; + default: + // Assume kW if no unit or unit is kW + return value; + } +}; diff --git a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts index 8cb03b84fb..64ac382a92 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sankey-card.ts @@ -6,7 +6,10 @@ import { classMap } from "lit/directives/class-map"; import "../../../../components/ha-card"; import "../../../../components/ha-svg-icon"; import type { EnergyData, EnergyPreferences } from "../../../../data/energy"; -import { getEnergyDataCollection } from "../../../../data/energy"; +import { + getEnergyDataCollection, + getPowerFromState, +} from "../../../../data/energy"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../../types"; import type { LovelaceCard, LovelaceGridOptions } from "../../types"; @@ -724,33 +727,7 @@ class HuiPowerSankeyCard // Track this entity for state change detection this._entities.add(entityId); - const stateObj = this.hass.states[entityId]; - if (!stateObj) { - return 0; - } - const value = parseFloat(stateObj.state); - if (isNaN(value)) { - return 0; - } - - // Normalize to kW based on unit of measurement (case-sensitive) - // Supported units: GW, kW, MW, mW, TW, W - const unit = stateObj.attributes.unit_of_measurement; - switch (unit) { - case "W": - return value / 1000; - case "mW": - return value / 1000000; - case "MW": - return value * 1000; - case "GW": - return value * 1000000; - case "TW": - return value * 1000000000; - default: - // Assume kW if no unit or unit is kW - return value; - } + return getPowerFromState(this.hass.states[entityId]) ?? 0; } /** diff --git a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts index 9760715f9d..fb6a4599e0 100644 --- a/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts +++ b/src/panels/lovelace/cards/energy/hui-power-sources-graph-card.ts @@ -10,7 +10,10 @@ import { LinearGradient } from "../../../../resources/echarts/echarts"; import "../../../../components/chart/ha-chart-base"; import "../../../../components/ha-card"; import type { EnergyData } from "../../../../data/energy"; -import { getEnergyDataCollection } from "../../../../data/energy"; +import { + getEnergyDataCollection, + getPowerFromState, +} from "../../../../data/energy"; import type { StatisticValue } from "../../../../data/recorder"; import type { FrontendLocaleData } from "../../../../data/translation"; import { SubscribeMixin } from "../../../../mixins/subscribe-mixin"; @@ -197,6 +200,7 @@ export class HuiPowerSourcesGraphCard }, }; + const now = Date.now(); Object.keys(statIds).forEach((key, keyIndex) => { if (statIds[key].stats.length) { const colorHex = computedStyles.getPropertyValue(statIds[key].color); @@ -204,7 +208,14 @@ export class HuiPowerSourcesGraphCard // Echarts is supposed to handle that but it is bugged when you use it together with stacking. // The interpolation breaks the stacking, so this positive/negative is a workaround const { positive, negative } = this._processData( - statIds[key].stats.map((id: string) => energyData.stats[id] ?? []) + statIds[key].stats.map((id: string) => { + const stats = energyData.stats[id] ?? []; + const currentState = getPowerFromState(this.hass.states[id]); + if (currentState !== undefined) { + stats.push({ start: now, end: now, mean: currentState }); + } + return stats; + }) ); datasets.push({ ...commonSeriesOptions, From 4b73713f2a6545c356f6b19d5ca00852adc3483e Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 4 Dec 2025 11:13:41 +0200 Subject: [PATCH 80/99] Fix gauge severity using entity state instead of attribute value (#28331) --- src/panels/lovelace/cards/hui-gauge-card.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/panels/lovelace/cards/hui-gauge-card.ts b/src/panels/lovelace/cards/hui-gauge-card.ts index e13a78d452..9a1ceb2bdf 100644 --- a/src/panels/lovelace/cards/hui-gauge-card.ts +++ b/src/panels/lovelace/cards/hui-gauge-card.ts @@ -96,8 +96,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard { `; } - const entityState = Number(stateObj.state); - if (stateObj.state === UNAVAILABLE) { return html` Date: Thu, 4 Dec 2025 11:15:11 +0200 Subject: [PATCH 81/99] Fix markdown sections and styling (#28333) --- src/components/ha-markdown-element.ts | 5 +---- src/components/ha-markdown.ts | 4 ++-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/ha-markdown-element.ts b/src/components/ha-markdown-element.ts index ec89e5ff21..825ad9a004 100644 --- a/src/components/ha-markdown-element.ts +++ b/src/components/ha-markdown-element.ts @@ -99,10 +99,7 @@ class HaMarkdownElement extends ReactiveElement { } ); - render( - elements.map((e) => h(unsafeHTML(e))), - this.renderRoot - ); + render(h(unsafeHTML(elements.join(""))), this.renderRoot); this._resize(); diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index f5627ef118..04a18ec52b 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -25,11 +25,11 @@ export class HaMarkdown extends LitElement { @property({ type: Boolean }) public cache = false; - @query("ha-markdown-element") private _markdownElement!: ReactiveElement; + @query("ha-markdown-element") private _markdownElement?: ReactiveElement; protected async getUpdateComplete() { const result = await super.getUpdateComplete(); - await this._markdownElement.updateComplete; + await this._markdownElement?.updateComplete; return result; } From a961a878725ed515dc02c8b67f771745ce01830d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 4 Dec 2025 09:58:27 +0100 Subject: [PATCH 82/99] Move reorder areas and floors to floor overflow (#28335) --- .../config/areas/dialog-areas-floors-order.ts | 6 +- .../config/areas/ha-config-areas-dashboard.ts | 65 ++++++++++++++----- src/translations/en.json | 5 +- 3 files changed, 55 insertions(+), 21 deletions(-) diff --git a/src/panels/config/areas/dialog-areas-floors-order.ts b/src/panels/config/areas/dialog-areas-floors-order.ts index f96e8687c4..bde1ab6c22 100644 --- a/src/panels/config/areas/dialog-areas-floors-order.ts +++ b/src/panels/config/areas/dialog-areas-floors-order.ts @@ -81,8 +81,11 @@ class DialogAreasFloorsOrder extends LitElement { return nothing; } + const hasFloors = this._hierarchy.floors.length > 0; const dialogTitle = this.hass.localize( - "ui.panel.config.areas.dialog.reorder_title" + hasFloors + ? "ui.panel.config.areas.dialog.reorder_floors_areas_title" + : "ui.panel.config.areas.dialog.reorder_areas_title" ); return html` @@ -418,7 +421,6 @@ class DialogAreasFloorsOrder extends LitElement { } .floor.unassigned { - border-style: dashed; margin-top: 16px; } diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index ae1cd10a6a..8824412809 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -175,21 +175,12 @@ export class HaConfigAreasDashboard extends LitElement { .route=${this.route} has-fab > - - - - - ${this.hass.localize("ui.panel.config.areas.picker.reorder")} - - - - ${this.hass.localize("ui.common.help")} - - +
${this._hierarchy.floors.map(({ areas, id }) => { @@ -213,6 +204,16 @@ export class HaConfigAreasDashboard extends LitElement { slot="trigger" .path=${mdiDotsVertical} > + ${this.hass.localize( + "ui.panel.config.areas.picker.reorder" + )} +
  • ${this.hass.localize( - "ui.panel.config.areas.picker.other_areas" + this._hierarchy.floors.length + ? "ui.panel.config.areas.picker.other_areas" + : "ui.panel.config.areas.picker.header" )}

    +
    + + + ${this.hass.localize( + "ui.panel.config.areas.picker.reorder" + )} + +
    ) { + if (ev.detail.index === 0) { + this._showReorderDialog(); + } + } + private _createFloor() { this._openFloorDialog(); } diff --git a/src/translations/en.json b/src/translations/en.json index 183aa14669..3cec22844a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2474,10 +2474,11 @@ "area_reorder_failed": "Failed to reorder areas", "area_move_failed": "Failed to move area", "floor_reorder_failed": "Failed to reorder floors", - "reorder": "Reorder floors and areas" + "reorder": "Reorder" }, "dialog": { - "reorder_title": "Reorder floors and areas", + "reorder_areas_title": "Reorder areas", + "reorder_floors_areas_title": "Reorder floors and areas", "other_areas": "Other areas", "reorder_failed": "Failed to save order", "empty_floor": "No areas on this floor", From d197fd8f76dfeaf50248d0b697ebb15c69a7989e Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 5 Dec 2025 14:39:51 +0200 Subject: [PATCH 83/99] Fix calendar card not showing different colors for multiple calendars (#28338) --- .../lovelace/cards/hui-calendar-card.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/panels/lovelace/cards/hui-calendar-card.ts b/src/panels/lovelace/cards/hui-calendar-card.ts index 80b2ca24f2..f72815c5f2 100644 --- a/src/panels/lovelace/cards/hui-calendar-card.ts +++ b/src/panels/lovelace/cards/hui-calendar-card.ts @@ -80,12 +80,6 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { throw new Error("Entities need to be an array"); } - const computedStyles = getComputedStyle(this); - this._calendars = config!.entities.map((entity, idx) => ({ - entity_id: entity, - backgroundColor: getColorByIndex(idx, computedStyles), - })); - if (this._config?.entities !== config.entities) { this._fetchCalendarEvents(); } @@ -93,6 +87,20 @@ export class HuiCalendarCard extends LitElement implements LovelaceCard { this._config = { initial_view: "dayGridMonth", ...config }; } + public willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + if ( + !this.hasUpdated || + (changedProps.has("_config") && this._config?.entities) + ) { + const computedStyles = getComputedStyle(this); + this._calendars = this._config!.entities.map((entity, idx) => ({ + entity_id: entity, + backgroundColor: getColorByIndex(idx, computedStyles), + })); + } + } + public getCardSize(): number { return 12; } From 0c68072f8fa1d6ce2ecdb085b84e3f482214b4b8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 5 Dec 2025 17:34:22 +0100 Subject: [PATCH 84/99] Use non-admin endpoint to subscribe to one lab feature (#28352) --- src/components/ha-snowflakes.ts | 4 ++-- src/data/labs.ts | 20 ++++++++----------- .../add-automation-element-dialog.ts | 8 ++++---- .../condition/ha-automation-condition.ts | 4 ++-- .../trigger/ha-automation-trigger.ts | 4 ++-- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/src/components/ha-snowflakes.ts b/src/components/ha-snowflakes.ts index 45ffbc3e18..33440650a5 100644 --- a/src/components/ha-snowflakes.ts +++ b/src/components/ha-snowflakes.ts @@ -31,8 +31,8 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { this.hass!.connection, "frontend", "winter_mode", - (enabled) => { - this._enabled = enabled; + (feature) => { + this._enabled = feature.enabled; } ), ]; diff --git a/src/data/labs.ts b/src/data/labs.ts index 4eda13bc6f..2ef7b42422 100644 --- a/src/data/labs.ts +++ b/src/data/labs.ts @@ -101,22 +101,18 @@ export const subscribeLabFeatures = ( * Subscribe to a specific lab feature * @param conn - The connection to the Home Assistant instance * @param domain - The domain of the lab feature - * @param previewFeature - The preview feature of the lab feature + * @param previewFeature - The preview feature identifier * @param onChange - The function to call when the lab feature changes - * @returns The unsubscribe function + * @returns A promise that resolves to the unsubscribe function */ export const subscribeLabFeature = ( conn: Connection, domain: string, previewFeature: string, - onChange: (enabled: boolean) => void -) => - subscribeLabFeatures(conn, (features) => { - const enabled = - features.find( - (feature) => - feature.domain === domain && - feature.preview_feature === previewFeature - )?.enabled ?? false; - onChange(enabled); + onChange: (feature: LabPreviewFeature) => void +): Promise<() => void> => + conn.subscribeMessage(onChange, { + type: "labs/subscribe", + domain, + preview_feature: previewFeature, }); diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index e50c0da0f9..3a69e260b0 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -228,7 +228,7 @@ class DialogAddAutomationElement private _unsub?: Promise; - private _unsubscribeLabFeatures?: UnsubscribeFunc; + private _unsubscribeLabFeatures?: Promise; private _configEntryLookup: Record = {}; @@ -285,8 +285,8 @@ class DialogAddAutomationElement this.hass.connection, "automation", "new_triggers_conditions", - (enabled) => { - this._newTriggersAndConditions = enabled; + (feature) => { + this._newTriggersAndConditions = feature.enabled; this._tab = this._newTriggersAndConditions ? "targets" : "groups"; } ); @@ -422,7 +422,7 @@ class DialogAddAutomationElement this._unsub = undefined; } if (this._unsubscribeLabFeatures) { - this._unsubscribeLabFeatures(); + this._unsubscribeLabFeatures.then((unsub) => unsub()); this._unsubscribeLabFeatures = undefined; } } diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index be3df93561..31bcc063fc 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -94,8 +94,8 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) { this.hass!.connection, "automation", "new_triggers_conditions", - (enabled) => { - this._newTriggersAndConditions = enabled; + (feature) => { + this._newTriggersAndConditions = feature.enabled; } ), ]; diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index c39be3c1e0..6be41806c8 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -89,8 +89,8 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) { this.hass!.connection, "automation", "new_triggers_conditions", - (enabled) => { - this._newTriggersAndConditions = enabled; + (feature) => { + this._newTriggersAndConditions = feature.enabled; } ), ]; From da255dce40f4f23d42113559fd9b26129f907130 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Fri, 5 Dec 2025 15:05:50 +0100 Subject: [PATCH 85/99] Add add to button in more info topbar for non admin users (#28365) --- src/dialogs/more-info/ha-more-info-dialog.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index 84705d650c..bfce8235ea 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -302,7 +302,9 @@ export class MoreInfoDialog extends LitElement { } private _goToAddEntityTo(ev) { - if (!shouldHandleRequestSelectedEvent(ev)) return; + // Only check for request-selected events (from menu items), not regular clicks (from icon button) + if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev)) + return; this._setView("add_to"); } @@ -550,7 +552,18 @@ export class MoreInfoDialog extends LitElement { : nothing} ` - : nothing} + : !__DEMO__ && this._shouldShowAddEntityTo() + ? html` + + ` + : nothing} ` : isSpecificInitialView ? html` From 17c1043cfcc32bd4e3f308f70c8b34f76c2cb7c5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 5 Dec 2025 20:51:48 +0100 Subject: [PATCH 86/99] Bumped version to 20251203.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6f73a01c3..eb6396c24f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251203.0" +version = "20251203.1" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From 73ee235fef87f8db4836cb56c4eea06e4545fee2 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sat, 6 Dec 2025 23:21:08 -0800 Subject: [PATCH 87/99] Fix for undefined description_placeholders (#28395) Another fix for undefined description_placeholders --- src/data/script_i18n.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/script_i18n.ts b/src/data/script_i18n.ts index 5b47a5aa5d..9b2e41b9ea 100644 --- a/src/data/script_i18n.ts +++ b/src/data/script_i18n.ts @@ -220,12 +220,12 @@ const tryDescribeAction = ( if (config.action) { const [domain, serviceName] = config.action.split(".", 2); const descriptionPlaceholders = - hass.services[domain][serviceName].description_placeholders; + hass.services[domain]?.[serviceName]?.description_placeholders; const service = hass.localize( `component.${domain}.services.${serviceName}.name`, descriptionPlaceholders - ) || hass.services[domain][serviceName]?.name; + ) || hass.services[domain]?.[serviceName]?.name; if (config.metadata) { return hass.localize( From 30c383a2fc36c158d6abfbf9cd709c625a427636 Mon Sep 17 00:00:00 2001 From: dcapslock Date: Mon, 8 Dec 2025 17:17:19 +1100 Subject: [PATCH 88/99] Energy strategies to refresh energy collection which allows to be used in custom dashboards (#28400) * Energy strategies to refresh energy collection which allows to be used in custom dashboards * Update src/panels/energy/strategies/energy-overview-view-strategy.ts Co-authored-by: Petar Petrov * Only refresh if no prefs --------- Co-authored-by: Petar Petrov --- src/panels/energy/strategies/energy-overview-view-strategy.ts | 3 +++ src/panels/energy/strategies/energy-view-strategy.ts | 3 +++ src/panels/energy/strategies/gas-view-strategy.ts | 3 +++ src/panels/energy/strategies/water-view-strategy.ts | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/panels/energy/strategies/energy-overview-view-strategy.ts b/src/panels/energy/strategies/energy-overview-view-strategy.ts index 2687d78dcc..803c8b31a2 100644 --- a/src/panels/energy/strategies/energy-overview-view-strategy.ts +++ b/src/panels/energy/strategies/energy-overview-view-strategy.ts @@ -27,6 +27,9 @@ export class EnergyOverviewViewStrategy extends ReactiveElement { const energyCollection = getEnergyDataCollection(hass, { key: collectionKey, }); + if (!energyCollection.prefs) { + await energyCollection.refresh(); + } const prefs = energyCollection.prefs; // No energy sources available diff --git a/src/panels/energy/strategies/energy-view-strategy.ts b/src/panels/energy/strategies/energy-view-strategy.ts index 64c27c5c23..109bf22d69 100644 --- a/src/panels/energy/strategies/energy-view-strategy.ts +++ b/src/panels/energy/strategies/energy-view-strategy.ts @@ -21,6 +21,9 @@ export class EnergyViewStrategy extends ReactiveElement { const energyCollection = getEnergyDataCollection(hass, { key: collectionKey, }); + if (!energyCollection.prefs) { + await energyCollection.refresh(); + } const prefs = energyCollection.prefs; // No energy sources available diff --git a/src/panels/energy/strategies/gas-view-strategy.ts b/src/panels/energy/strategies/gas-view-strategy.ts index 1380cb26a8..3fa462711b 100644 --- a/src/panels/energy/strategies/gas-view-strategy.ts +++ b/src/panels/energy/strategies/gas-view-strategy.ts @@ -24,6 +24,9 @@ export class GasViewStrategy extends ReactiveElement { const energyCollection = getEnergyDataCollection(hass, { key: collectionKey, }); + if (!energyCollection.prefs) { + await energyCollection.refresh(); + } const prefs = energyCollection.prefs; const hasGasSources = prefs?.energy_sources.some( diff --git a/src/panels/energy/strategies/water-view-strategy.ts b/src/panels/energy/strategies/water-view-strategy.ts index 3340ca62a1..e136ff37ba 100644 --- a/src/panels/energy/strategies/water-view-strategy.ts +++ b/src/panels/energy/strategies/water-view-strategy.ts @@ -24,6 +24,9 @@ export class WaterViewStrategy extends ReactiveElement { const energyCollection = getEnergyDataCollection(hass, { key: collectionKey, }); + if (!energyCollection.prefs) { + await energyCollection.refresh(); + } const prefs = energyCollection.prefs; const hasWaterSources = prefs?.energy_sources.some( From cb93e1b7415484f6ab696a59b0abe5dfbdfb2ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nils=20Sch=C3=B6nwald?= Date: Mon, 8 Dec 2025 10:16:55 +0100 Subject: [PATCH 89/99] Update snowflake to 6 sides (#28406) --- src/components/ha-snowflakes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ha-snowflakes.ts b/src/components/ha-snowflakes.ts index 33440650a5..ca931135e4 100644 --- a/src/components/ha-snowflakes.ts +++ b/src/components/ha-snowflakes.ts @@ -93,7 +93,7 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) { xmlns="http://www.w3.org/2000/svg" > From 0802841606c0dafded81e6f17cd08c9dde633fe5 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 7 Dec 2025 22:05:35 -0800 Subject: [PATCH 90/99] More unsafe description_placeholders fixes (#28416) --- .../config/automation/sidebar/ha-automation-sidebar-action.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panels/config/automation/sidebar/ha-automation-sidebar-action.ts b/src/panels/config/automation/sidebar/ha-automation-sidebar-action.ts index bd9ec94839..1afbc9aa1b 100644 --- a/src/panels/config/automation/sidebar/ha-automation-sidebar-action.ts +++ b/src/panels/config/automation/sidebar/ha-automation-sidebar-action.ts @@ -97,9 +97,9 @@ export default class HaAutomationSidebarAction extends LitElement { title = `${domainToName(this.hass.localize, domain)}: ${ this.hass.localize( `component.${domain}.services.${service}.name`, - this.hass.services[domain][service].description_placeholders + this.hass.services[domain]?.[service]?.description_placeholders ) || - this.hass.services[domain][service]?.name || + this.hass.services[domain]?.[service]?.name || title }`; } From ce8cabbad91a766dce1873572df4d7df8b7effc3 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 8 Dec 2025 17:29:01 +0100 Subject: [PATCH 91/99] Bumped version to 20251203.2 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index eb6396c24f..4738435b2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251203.1" +version = "20251203.2" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend" From 94453dfba520b28d5b3cb7baf562057b7e427155 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 9 Dec 2025 04:15:12 -0800 Subject: [PATCH 92/99] Fix markdown card image sizing (#28449) --- src/components/ha-assist-chat.ts | 1 + src/components/ha-markdown.ts | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 1d47313e56..5e19ad4e86 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -134,6 +134,7 @@ export class HaAssistChat extends LitElement { })}" breaks cache + assist .content=${message.text} > diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index 04a18ec52b..e41d8dae4a 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -70,13 +70,15 @@ export class HaMarkdown extends LitElement { a { color: var(--markdown-link-color, var(--primary-color)); } + :host([assist]) img { + height: auto; + width: auto; + transition: height 0.2s ease-in-out; + } img { background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); max-width: 100%; - height: auto; - width: auto; - transition: height 0.2s ease-in-out; } p:first-child > img:first-child { vertical-align: top; From f4f45207730d0e1688fbd12083c3da8df44586b6 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Wed, 10 Dec 2025 16:59:35 +0100 Subject: [PATCH 93/99] Fix target picker area in history/activity (#28474) * Add max target picker width for history and activity * Fix target picker area selection in history and activity --- src/components/ha-generic-picker.ts | 5 ++++- src/components/ha-target-picker.ts | 3 --- src/panels/history/ha-panel-history.ts | 1 + src/panels/logbook/ha-panel-logbook.ts | 3 +++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 6f955cb7e2..85117b91d2 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -344,7 +344,10 @@ export class HaGenericPicker extends LitElement { wa-popover::part(body) { width: max(var(--body-width), 250px); - max-width: max(var(--body-width), 250px); + max-width: var( + --ha-generic-picker-max-width, + max(var(--body-width), 250px) + ); max-height: 500px; height: 70vh; overflow: hidden; diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0d77a72695..28894efe76 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -952,10 +952,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { let hasFloor = false; let rtl = false; let showEntityId = false; - if (type === "area" || type === "floor") { - item.id = item[type]?.[`${type}_id`]; - rtl = computeRTL(this.hass); hasFloor = type === "area" && !!(item as FloorComboBoxItem).area?.floor_id; diff --git a/src/panels/history/ha-panel-history.ts b/src/panels/history/ha-panel-history.ts index c62e9142b9..09202895ca 100644 --- a/src/panels/history/ha-panel-history.ts +++ b/src/panels/history/ha-panel-history.ts @@ -631,6 +631,7 @@ class HaPanelHistory extends LitElement { :host([virtualize]) { height: 100%; + --ha-generic-picker-max-width: 400px; } .progress-wrapper { diff --git a/src/panels/logbook/ha-panel-logbook.ts b/src/panels/logbook/ha-panel-logbook.ts index 365731df37..1f8d5eb2f4 100644 --- a/src/panels/logbook/ha-panel-logbook.ts +++ b/src/panels/logbook/ha-panel-logbook.ts @@ -303,6 +303,9 @@ export class HaPanelLogbook extends LitElement { return [ haStyle, css` + :host { + --ha-generic-picker-max-width: 400px; + } ha-logbook { height: calc( 100vh - From 6e5853a1c0c69bb4c83fb9a3cd6e65280377b81d Mon Sep 17 00:00:00 2001 From: Silas Krause Date: Thu, 11 Dec 2025 13:10:26 +0100 Subject: [PATCH 94/99] Support legacy table styles in markdown (#28488) * Remove unnecessary assist styles * Fix list styles * Remove table styles for role="presentation" --- src/components/ha-assist-chat.ts | 3 +-- src/components/ha-markdown.ts | 25 ++++++++++++++++--------- src/resources/markdown-worker.ts | 1 + 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index 5e19ad4e86..b920767823 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -134,7 +134,6 @@ export class HaAssistChat extends LitElement { })}" breaks cache - assist .content=${message.text} > @@ -660,7 +659,7 @@ export class HaAssistChat extends LitElement { --markdown-table-border-color: var(--divider-color); --markdown-code-background-color: var(--primary-background-color); --markdown-code-text-color: var(--primary-text-color); - --markdown-list-indent: 1rem; + --markdown-list-indent: 1.15em; &:not(:has(ha-markdown-element)) { min-height: 1lh; min-width: 1lh; diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts index e41d8dae4a..c0f435ab34 100644 --- a/src/components/ha-markdown.ts +++ b/src/components/ha-markdown.ts @@ -70,11 +70,6 @@ export class HaMarkdown extends LitElement { a { color: var(--markdown-link-color, var(--primary-color)); } - :host([assist]) img { - height: auto; - width: auto; - transition: height 0.2s ease-in-out; - } img { background-color: var(--markdown-image-background-color); border-radius: var(--markdown-image-border-radius); @@ -86,8 +81,7 @@ export class HaMarkdown extends LitElement { p:first-child > img:last-child { vertical-align: top; } - :host > ul, - :host > ol { + ha-markdown-element > :is(ol, ul) { padding-inline-start: var(--markdown-list-indent, revert); } li { @@ -138,6 +132,18 @@ export class HaMarkdown extends LitElement { border-bottom: none; margin: var(--ha-space-4) 0; } + table[role="presentation"] { + --markdown-table-border-collapse: separate; + --markdown-table-border-width: attr(border, 0); + --markdown-table-padding-inline: 0; + --markdown-table-padding-block: 0; + th { + vertical-align: attr(align, center); + } + td { + vertical-align: attr(align, left); + } + } table { border-collapse: var(--markdown-table-border-collapse, collapse); } @@ -145,14 +151,15 @@ export class HaMarkdown extends LitElement { overflow: auto; } th { - text-align: start; + text-align: var(--markdown-table-text-align, start); } td, th { border-width: var(--markdown-table-border-width, 1px); border-style: var(--markdown-table-border-style, solid); border-color: var(--markdown-table-border-color, var(--divider-color)); - padding: 0.25em 0.5em; + padding-inline: var(--markdown-table-padding-inline, 0.5em); + padding-block: var(--markdown-table-padding-block, 0.25em); } blockquote { border-left: 4px solid var(--divider-color); diff --git a/src/resources/markdown-worker.ts b/src/resources/markdown-worker.ts index dfade11123..414457a480 100644 --- a/src/resources/markdown-worker.ts +++ b/src/resources/markdown-worker.ts @@ -19,6 +19,7 @@ const renderMarkdown = async ( if (!whiteListNormal) { whiteListNormal = { ...getDefaultWhiteList(), + table: [...(getDefaultWhiteList().table ?? []), "role"], input: ["type", "disabled", "checked"], "ha-icon": ["icon"], "ha-svg-icon": ["path"], From 06334a039c9fef22385d4b33fe4fa1ad795e7a68 Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:48:41 +0100 Subject: [PATCH 95/99] Fix automation add TCA search icons (#28490) Fix automation add TCA seach icons --- .../ha-automation-add-search.ts | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts index f52c6d0f79..8ba75e3987 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts @@ -321,40 +321,39 @@ export class HaAutomationAddSearch extends LitElement { > ` : nothing} - ${item.icon - ? html`` - : item.icon_path - ? html`` - : type === "entity" && (item as EntityComboBoxItem).stateObj - ? html` - - ` - : type === "device" && (item as DevicePickerItem).domain + ${(item as AutomationItemComboBoxItem).renderedIcon + ? html`
    + ${(item as AutomationItemComboBoxItem).renderedIcon} +
    ` + : item.icon + ? html`` + : item.icon_path || type === "area" + ? html`` + : type === "entity" && (item as EntityComboBoxItem).stateObj ? html` - + > ` - : type === "floor" - ? html`` - : type === "area" - ? html`` + .hass=${this.hass} + .domain=${(item as DevicePickerItem).domain!} + brand-fallback + > + ` + : type === "floor" + ? html`` : nothing} ${item.primary} ${item.secondary @@ -784,7 +783,7 @@ export class HaAutomationAddSearch extends LitElement { id: key, primary: name, secondary: description, - iconPath, + icon_path: iconPath, renderedIcon: icon, type, search_labels: [key, name, description], From 7e58cedd492a76f8a40b20b707bf833bb57589ef Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Thu, 11 Dec 2025 10:14:12 +0100 Subject: [PATCH 96/99] Fix ha-toast z-index (#28491) --- src/components/ha-toast.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ha-toast.ts b/src/components/ha-toast.ts index 4f6be82b24..16ff711073 100644 --- a/src/components/ha-toast.ts +++ b/src/components/ha-toast.ts @@ -13,6 +13,7 @@ export class HaToast extends Snackbar { } .mdc-snackbar { + z-index: 10; margin: 8px; right: calc(8px + var(--safe-area-inset-right)); bottom: calc(8px + var(--safe-area-inset-bottom)); From 7817ebe9830215402fd78fd6b898b0ae06b3726a Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:23:37 -0800 Subject: [PATCH 97/99] Home strategy: don't link non-admin to config pages (#28512) --- .../home/home-area-view-strategy.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts index fdc61f029c..940abaec9e 100644 --- a/src/panels/lovelace/strategies/home/home-area-view-strategy.ts +++ b/src/panels/lovelace/strategies/home/home-area-view-strategy.ts @@ -41,7 +41,9 @@ const computeHeadingCard = ( action: "navigate", navigation_path, } - : undefined, + : { + action: "none", + }, }) satisfies HeadingCardConfig; @customElement("home-area-view-strategy") @@ -182,7 +184,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.scenes"), "mdi:palette", - "/config/scene/dashboard" + hass.user?.is_admin ? "/config/scene/dashboard" : undefined ), ...scenes.map(computeTileCard), ], @@ -285,12 +287,13 @@ export class HomeAreaViewStrategy extends ReactiveElement { { type: "heading", heading: heading, - tap_action: device - ? { - action: "navigate", - navigation_path: `/config/devices/device/${device.id}`, - } - : undefined, + tap_action: + hass.user?.is_admin && device + ? { + action: "navigate", + navigation_path: `/config/devices/device/${device.id}`, + } + : { action: "none" }, badges: [ ...batteryEntities.slice(0, 1).map((e) => ({ entity: e, @@ -334,7 +337,7 @@ export class HomeAreaViewStrategy extends ReactiveElement { computeHeadingCard( hass.localize("ui.panel.lovelace.strategy.home.automations"), "mdi:robot", - "/config/automation/dashboard" + hass.user?.is_admin ? "/config/automation/dashboard" : undefined ), ...automations.map(computeTileCard), ], From 407cb79805eee797180827f81aaed3417f206f9f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Mon, 15 Dec 2025 16:52:59 +0200 Subject: [PATCH 98/99] Fix power sources graph ordering with multiple sources (#28549) --- .../energy/common/energy-chart-options.ts | 37 ++-- .../common/energy-chart-options.test.ts | 199 ++++++++++++++++++ 2 files changed, 222 insertions(+), 14 deletions(-) create mode 100644 test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts diff --git a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts index 0636e4cee3..d49ac755ae 100644 --- a/src/panels/lovelace/cards/energy/common/energy-chart-options.ts +++ b/src/panels/lovelace/cards/energy/common/energy-chart-options.ts @@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) { }); } +function getDatapointX(datapoint: NonNullable[0]) { + const item = + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : { value: datapoint }; + return Number(item.value?.[0]); +} + export function fillLineGaps(datasets: LineSeriesOption[]) { const buckets = Array.from( new Set( datasets .map((dataset) => - dataset.data!.map((datapoint) => Number(datapoint![0])) + dataset.data!.map((datapoint) => getDatapointX(datapoint)) ) .flat() ) ).sort((a, b) => a - b); - buckets.forEach((bucket, index) => { - for (let i = datasets.length - 1; i >= 0; i--) { - const dataPoint = datasets[i].data![index]; + + datasets.forEach((dataset) => { + const dataMap = new Map(); + dataset.data!.forEach((datapoint) => { const item: LineDataItemOption = - dataPoint && typeof dataPoint === "object" && "value" in dataPoint - ? dataPoint - : ({ value: dataPoint } as LineDataItemOption); - const x = item.value?.[0]; - if (x === undefined) { - continue; + datapoint && typeof datapoint === "object" && "value" in datapoint + ? datapoint + : ({ value: datapoint } as LineDataItemOption); + const x = getDatapointX(datapoint); + if (!Number.isNaN(x)) { + dataMap.set(x, item); } - if (Number(x) !== bucket) { - datasets[i].data?.splice(index, 0, [bucket, 0]); - } - } + }); + + dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]); }); + return datasets; } diff --git a/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts new file mode 100644 index 0000000000..0f1a7dc53c --- /dev/null +++ b/test/panels/lovelace/cards/energy/common/energy-chart-options.test.ts @@ -0,0 +1,199 @@ +import { assert, describe, it } from "vitest"; +import type { LineSeriesOption } from "echarts/charts"; + +import { fillLineGaps } from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options"; + +// Helper to get x value from either [x,y] or {value: [x,y]} format +function getX(item: any): number { + return item?.value?.[0] ?? item?.[0]; +} + +// Helper to get y value from either [x,y] or {value: [x,y]} format +function getY(item: any): number { + return item?.value?.[1] ?? item?.[1]; +} + +describe("fillLineGaps", () => { + it("fills gaps in datasets with missing timestamps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [3000, 30], + ], + }, + { + type: "line", + data: [ + [1000, 100], + [2000, 200], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should have gap at 2000 filled with 0 + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be unchanged + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles unsorted data from multiple sources", () => { + // This is the bug we're fixing: when multiple power sources are combined, + // the data may not be in chronological order + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + [2000, 20], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // Data should be sorted by timestamp + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("handles multiple datasets with unsorted data", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [3000, 30], + [1000, 10], + ], + }, + { + type: "line", + data: [ + [2000, 200], + [1000, 100], + [3000, 300], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First dataset should be sorted and have gap at 2000 filled + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 0); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + + // Second dataset should be sorted + assert.equal(result[1].data!.length, 3); + assert.equal(getX(result[1].data![0]), 1000); + assert.equal(getY(result[1].data![0]), 100); + assert.equal(getX(result[1].data![1]), 2000); + assert.equal(getY(result[1].data![1]), 200); + assert.equal(getX(result[1].data![2]), 3000); + assert.equal(getY(result[1].data![2]), 300); + }); + + it("handles data with object format (LineDataItemOption)", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [{ value: [3000, 30] }, { value: [1000, 10] }], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 2); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 3000); + assert.equal(getY(result[0].data![1]), 30); + }); + + it("returns empty array for empty datasets", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.deepEqual(result[0].data, []); + }); + + it("handles already sorted data with no gaps", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + [1000, 10], + [2000, 20], + [3000, 30], + ], + }, + ]; + + const result = fillLineGaps(datasets); + + assert.equal(result[0].data!.length, 3); + assert.equal(getX(result[0].data![0]), 1000); + assert.equal(getY(result[0].data![0]), 10); + assert.equal(getX(result[0].data![1]), 2000); + assert.equal(getY(result[0].data![1]), 20); + assert.equal(getX(result[0].data![2]), 3000); + assert.equal(getY(result[0].data![2]), 30); + }); + + it("preserves original data item properties", () => { + const datasets: LineSeriesOption[] = [ + { + type: "line", + data: [ + { value: [2000, 20], itemStyle: { color: "red" } }, + { value: [1000, 10], itemStyle: { color: "blue" } }, + ], + }, + ]; + + const result = fillLineGaps(datasets); + + // First item should be the one with timestamp 1000 + const firstItem = result[0].data![0] as any; + assert.equal(getX(firstItem), 1000); + assert.equal(firstItem.itemStyle.color, "blue"); + + // Second item should be the one with timestamp 2000 + const secondItem = result[0].data![1] as any; + assert.equal(getX(secondItem), 2000); + assert.equal(secondItem.itemStyle.color, "red"); + }); +}); From d839152fd1e9b433626d83114d23c32dd191e918 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 19 Dec 2025 17:05:16 +0100 Subject: [PATCH 99/99] Bumped version to 20251203.3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4738435b2a..f64ffeaaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20251203.2" +version = "20251203.3" license = "Apache-2.0" license-files = ["LICENSE*"] description = "The Home Assistant frontend"