Compare commits

...

23 Commits

Author SHA1 Message Date
Bram Kragten
38b7bd18bb Bumped version to 20251127.0 2025-11-27 17:06:57 +01:00
Wendelin
a00e944a35 Add TCA by target sort like item collections (#28192) 2025-11-27 17:06:30 +01:00
Petar Petrov
481569804e Fix water sankey calculation to include total supply from sources (#28191) 2025-11-27 17:06:29 +01:00
Paul Bottein
a1d7e270ff Add hint to reorder areas and floors (#28189) 2025-11-27 17:06:28 +01:00
Wendelin
225ccf1d2f Fix lab automations icons and sidebar width (#28184)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-27 17:06:27 +01:00
Wendelin
4a5e1f9f3f "Add TCA" dialog desktop height to 800px (#28182) 2025-11-27 17:06:26 +01:00
Wendelin
b27b7210fd Show hidden entities in target tree (#28181)
* Show hidden entities in target tree

* Fix types
2025-11-27 17:06:25 +01:00
Petar Petrov
acd5181449 Fix sankey chart resizing (#28180) 2025-11-27 17:06:24 +01:00
Bram Kragten
b6b2d03a80 Always store token when using develop and serve (#28179) 2025-11-27 17:06:22 +01:00
Paul Bottein
7aee2b7cb7 Fix labs back button (#28174) 2025-11-27 17:06:21 +01:00
Paul Bottein
df1914cb7a Fix disabled dashboard picker when no custom dashboard (#28172) 2025-11-27 17:06:20 +01:00
Paul Bottein
6706d5904d Fix box shadow for sidebar tabs (#28170) 2025-11-27 17:06:19 +01:00
Wendelin
221aefd764 Fix automation add TCA autofocus (#28168)
Fix automation add tca autofocus
2025-11-27 17:06:18 +01:00
Paul Bottein
670057e8e6 Restore sidebar view when clicking back (#28167) 2025-11-27 17:06:17 +01:00
Wendelin
427e46201c Fix add condition default tab and blank styles (#28166) 2025-11-27 17:06:16 +01:00
Petar Petrov
fd1240f335 Refactor power sankey hierarchy to handle devices with not power sensor (#28164) 2025-11-27 17:06:15 +01:00
Petar Petrov
aa7670cb59 Disable axis pointer on the energy devices bar chart to fix refresh issues on touch devices (#28163) 2025-11-27 17:06:14 +01:00
Petar Petrov
468139229c Handle grouping by floor and area in power sankey card (#28162) 2025-11-27 17:06:13 +01:00
Simon Lamon
39752f0e3f Don't show more info for untracked consumption (#28151) 2025-11-27 17:06:12 +01:00
Petar Petrov
4d850d067f Replace gauges with energy usage graph in energy overview (#28150) 2025-11-27 17:06:10 +01:00
Paul Bottein
bcae64df88 Use hui-root for panel energy (#28149)
* Use hui-root for panel energy

* Review feedback

* Set empty prefs
2025-11-27 17:06:09 +01:00
Iván Pereira
690fd5a061 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
2025-11-27 17:06:08 +01:00
Bram Kragten
ac56c6df9a Bumped version to 20251126.0 2025-11-26 16:11:20 +01:00
30 changed files with 534 additions and 383 deletions

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20251029.0"
version = "20251127.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

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

View File

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

View File

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

View File

@@ -186,6 +186,7 @@ export class HaIcon extends LitElement {
static styles = css`
:host {
display: flex;
fill: currentcolor;
}
`;

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

@@ -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";
}
);
@@ -1353,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
@@ -1368,12 +1420,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(
@@ -1438,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)
);
}
@@ -1548,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)
);
}
@@ -1580,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)
);
}
@@ -1678,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) {
@@ -1913,7 +1973,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),
@@ -1922,7 +1982,7 @@ class DialogAddAutomationElement
)
);
--ha-dialog-min-height: min(
648px,
800px,
calc(
100dvh - max(
var(--safe-area-inset-bottom),

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,7 +94,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
back-path="/config/system"
.header=${this.hass.localize("ui.panel.config.labs.caption")}
>
${sortedFeatures.length

View File

@@ -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`
<div class="centered">
<ha-alert alert-type="error">
An error occurred loading energy preferences: ${this._error}
</ha-alert>
</div>
`;
}
if (!this._prefs) {
// Still loading
return html`
<div class="centered">
@@ -136,20 +196,31 @@ class PanelEnergy extends LitElement {
</div>
`;
}
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`
<div class="header">
<div class="toolbar">
<hui-root
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.route=${this.route}
.panel=${this.panel}
@reload-energy-panel=${this._reloadConfig}
>
<div class="toolbar" slot="toolbar">
${showBack
? html`
<ha-icon-button-arrow-prev
@@ -175,14 +246,17 @@ class PanelEnergy extends LitElement {
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
>
${this.hass.user?.is_admin
? html` <ha-list-item
slot="overflow-menu"
graphic="icon"
@request-selected=${this._navigateConfig}
>
<ha-svg-icon slot="graphic" .path=${mdiPencil}> </ha-svg-icon>
${this.hass!.localize("ui.panel.energy.configure")}
</ha-list-item>`
? html`
<ha-list-item
slot="overflow-menu"
graphic="icon"
@request-selected=${this._navigateConfig}
>
<ha-svg-icon slot="graphic" .path=${mdiPencil}>
</ha-svg-icon>
${this.hass!.localize("ui.panel.energy.configure")}
</ha-list-item>
`
: nothing}
<ha-list-item
slot="overflow-menu"
@@ -194,54 +268,15 @@ class PanelEnergy extends LitElement {
</ha-list-item>
</hui-energy-period-selector>
</div>
</div>
<hui-view-container
.hass=${this.hass}
@reload-energy-panel=${this._reloadView}
>
${this._error
? html`<div class="centered">
<ha-alert alert-type="error">
An error occurred while fetching your energy preferences:
${this._error}
</ha-alert>
</div>`
: this._lovelace
? html`<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${viewIndex}
></hui-view>`
: nothing}
</hui-view-container>
</hui-root>
`;
}
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<LovelaceConfig> {
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%;

View File

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

View File

@@ -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<string, any>): 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"
@@ -86,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,
},
@@ -143,43 +143,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,
});
}

View File

@@ -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}
></ha-entities-picker>
<ha-alert alert-type="info">
${this.hass.localize("ui.panel.home.editor.areas_hint", {
areas_page: html`<a
href="/config/areas?historyBack=1"
@click=${this.closeDialog}
>${this.hass.localize("ui.panel.home.editor.areas_page")}</a
>`,
})}
</ha-alert>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
@@ -140,6 +151,11 @@ export class DialogEditHome
ha-entities-picker {
display: block;
}
ha-alert {
display: block;
margin-top: var(--ha-space-4);
}
`,
];
}

View File

@@ -217,6 +217,9 @@ export class HuiEnergyDevicesGraphCard
show: true,
type: "value",
name: "kWh",
axisPointer: {
show: false,
},
};
options.yAxis = {
show: true,
@@ -551,9 +554,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,
});
}
}
}

View File

@@ -23,6 +23,9 @@ const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
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<string, string> = {};
// 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<string>();
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;

View File

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

View File

@@ -127,7 +127,7 @@ interface UndoStackItem {
@customElement("hui-root")
class HUIRoot extends LitElement {
@property({ attribute: false }) public panel?: PanelInfo<LovelacePanelConfig>;
@property({ attribute: false }) public panel?: PanelInfo;
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -543,68 +543,72 @@ class HUIRoot extends LitElement {
})}
>
<div class="header">
<div class="toolbar">
<slot name="toolbar">
<div class="toolbar">
${this._editMode
? html`
<div class="main-title">
${dashboardTitle ||
this.hass!.localize("ui.panel.lovelace.editor.header")}
<ha-icon-button
slot="actionItems"
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_lovelace.edit_title"
)}
.path=${mdiPencil}
class="edit-icon"
@click=${this._editDashboard}
></ha-icon-button>
</div>
<div class="action-items">${this._renderActionItems()}</div>
`
: html`
${isSubview
? html`
<ha-icon-button-arrow-prev
.hass=${this.hass}
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${isSubview
? html`
<div class="main-title">${curViewConfig.title}</div>
`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`}
<div class="action-items">${this._renderActionItems()}</div>
`}
</div>
${this._editMode
? html`
<div class="main-title">
${dashboardTitle ||
this.hass!.localize("ui.panel.lovelace.editor.header")}
<div class="tab-bar">
${tabs}
<ha-icon-button
slot="actionItems"
slot="nav"
id="add-view"
@click=${this._addView}
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_lovelace.edit_title"
"ui.panel.lovelace.editor.edit_view.add"
)}
.path=${mdiPencil}
class="edit-icon"
@click=${this._editDashboard}
.path=${mdiPlus}
></ha-icon-button>
</div>
<div class="action-items">${this._renderActionItems()}</div>
`
: html`
${isSubview
? html`
<ha-icon-button-arrow-prev
.hass=${this.hass}
slot="navigationIcon"
@click=${this._goBack}
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${isSubview
? html`<div class="main-title">${curViewConfig.title}</div>`
: hasTabViews
? tabs
: html`
<div class="main-title">
${views[0]?.title ?? dashboardTitle}
</div>
`}
<div class="action-items">${this._renderActionItems()}</div>
`}
</div>
${this._editMode
? html`
<div class="tab-bar">
${tabs}
<ha-icon-button
slot="nav"
id="add-view"
@click=${this._addView}
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.add"
)}
.path=${mdiPlus}
></ha-icon-button>
</div>
`
: nothing}
: nothing}
</slot>
</div>
<hui-view-container
class=${this._editMode ? "has-tab-bar" : ""}

View File

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

View File

@@ -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 {
<span slot="description">
${this.hass.localize("ui.panel.profile.dashboard.description")}
</span>
${this._dashboards
? html`<ha-select
.label=${this.hass.localize(
"ui.panel.profile.dashboard.dropdown_label"
)}
.disabled=${!this._dashboards?.length}
.value=${value}
@selected=${this._dashboardChanged}
naturalMenuWidth
>
<ha-list-item .value=${USE_SYSTEM_VALUE}>
${this.hass.localize("ui.panel.profile.dashboard.system")}
<ha-select
.label=${this.hass.localize(
"ui.panel.profile.dashboard.dropdown_label"
)}
.value=${value}
@selected=${this._dashboardChanged}
naturalMenuWidth
>
<ha-list-item .value=${USE_SYSTEM_VALUE}>
${this.hass.localize("ui.panel.profile.dashboard.system")}
</ha-list-item>
<ha-divider></ha-divider>
<ha-list-item value="lovelace" graphic="icon">
<ha-svg-icon slot="graphic" .path=${mdiViewDashboard}></ha-svg-icon>
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item>
${PANEL_DASHBOARDS.map((panel) => {
const panelInfo = this.hass.panels[panel] as PanelInfo | undefined;
if (!panelInfo) {
return nothing;
}
return html`
<ha-list-item value=${panelInfo.url_path} graphic="icon">
<ha-icon
slot="graphic"
.icon=${getPanelIcon(panelInfo)}
></ha-icon>
${getPanelTitle(this.hass, panelInfo)}
</ha-list-item>
<ha-divider></ha-divider>
<ha-list-item value="lovelace">
${this.hass.localize("ui.panel.profile.dashboard.lovelace")}
</ha-list-item>
${PANEL_DASHBOARDS.map((panel) => {
const panelInfo = this.hass.panels[panel] as
| PanelInfo
| undefined;
if (!panelInfo) {
return nothing;
}
return html`
<ha-list-item value=${panelInfo.url_path}>
${getPanelTitle(this.hass, panelInfo)}
</ha-list-item>
`;
})}
<ha-divider></ha-divider>
${this._dashboards.map((dashboard) => {
if (!this.hass.user!.is_admin && dashboard.require_admin) {
return "";
}
return html`
<ha-list-item .value=${dashboard.url_path}>
${dashboard.title}
</ha-list-item>
`;
})}
</ha-select>`
: html`<ha-select
.label=${this.hass.localize(
"ui.panel.profile.dashboard.dropdown_label"
)}
disabled
></ha-select>`}
`;
})}
${this._dashboards?.length
? html`
<ha-divider></ha-divider>
${this._dashboards.map((dashboard) => {
if (!this.hass.user!.is_admin && dashboard.require_admin) {
return "";
}
return html`
<ha-list-item .value=${dashboard.url_path} graphic="icon">
<ha-icon
slot="graphic"
.icon=${dashboard.icon || "mdi:view-dashboard"}
></ha-icon>
${dashboard.title}
</ha-list-item>
`;
})}
`
: nothing}
</ha-select>
</ha-settings-row>
`;
}

View File

@@ -2227,7 +2227,9 @@
"title": "Edit home page",
"description": "Configure your home page display preferences.",
"favorite_entities_helper": "Display your favorite entities. Home Assistant will still suggest based on commonly used up to 8 slots.",
"save_failed": "Failed to save home page configuration"
"save_failed": "Failed to save home page configuration",
"areas_hint": "You can rearrange your floors and areas in the order that best represents your house on the {areas_page}.",
"areas_page": "areas page"
}
},
"my": {
@@ -4045,6 +4047,7 @@
"other_areas": "Other areas",
"services": "Services",
"helpers": "Helpers",
"entity_hidden": "[%key:ui::panel::config::devices::entities::hidden%]",
"triggers": {
"name": "Triggers",
"header": "When",

View File

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

View File

@@ -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(() => {

View File

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