Compare commits

..

53 Commits

Author SHA1 Message Date
Bram Kragten e42be9f4d6 Bumped version to 20260729.0 2026-07-29 14:52:39 +02:00
Bram Kragten a096f8383d Merge branch 'rc' into dev 2026-07-29 14:50:42 +02:00
Simon Lamon a3e59d64fc Pass browserlist targets to lightningcss (#53128)
* Pass browserlist targets to lightningcss

* Fix pass-browser-list branch (#53243)

Add failsafe for empty browserlist. Add comment to describe prefix issue in literals. Use default browserlistEnv naming convention. SW uses more efficient browserlist only targeting browsers that support sw.

* Increase JS budgets

---------

Co-authored-by: Maarten Lakerveld <maarten.lakerveld@openhomefoundation.org>
2026-07-29 14:33:33 +02:00
Petar Petrov e90e8169cd Fix save button state in the energy battery and grid dialogs (#53371)
Editing only the battery capacity left the dirty state untouched, so
Save stayed disabled and the change could not be saved. Add the missing
dirty state update.

The Save button also read power config validity off the
ha-energy-power-config child element. Lit evaluates every binding in
render() before committing any of them, so disabled was computed from
the child's pre-update state and lagged one render behind. Derive
validity from the dialog's own power type and config instead, through a
shared helper extracted alongside the other pure power config helpers,
and cover that helper with unit tests.

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: bramkragten <5662298+bramkragten@users.noreply.github.com>
2026-07-29 12:29:48 +00:00
Petar Petrov 959a798b5c Reject non same-origin back paths before using them as links (#53376) 2026-07-29 14:13:47 +02:00
Petar Petrov 9c117d446e Show the generated power helper sensor in energy power settings (#53368) 2026-07-29 12:59:31 +02:00
Petar Petrov 11913292b1 Fix required field validation being skipped in the app bundle (#53359)
* Fix required field validation being skipped in the app bundle

* Route blur and invalid handling through the guarded validity check
2026-07-29 13:51:35 +03:00
Bram Kragten 2dabc28e7f Bumped version to 20260624.6 2026-07-17 16:42:23 +02:00
Paul Bottein fd70fcef2f Fix history graph editor crash on unavailable entity (#53141) 2026-07-17 16:42:08 +02:00
Paul Bottein 9bf46415f1 Fix save button not enabling on whitespace-only entity name changes (#53138) 2026-07-17 16:42:07 +02:00
Jan-Philipp Benecke c552af4c12 Remove custom app header text color in zha group page (#53101) 2026-07-17 16:42:06 +02:00
Bram Kragten c573669786 Bumped version to 20260624.5 2026-07-10 14:57:54 +02:00
Petar Petrov 4e1ccab159 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-10 14:57:14 +02:00
Petar Petrov 347d63bce9 Wrap device automation picker option labels instead of truncating (#53065) 2026-07-10 14:57:13 +02:00
Petar Petrov a1d7b31732 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-10 14:57:12 +02:00
Petar Petrov 4d61df7ed7 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-10 14:57:11 +02:00
Petar Petrov 991dda70fc Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-10 14:57:10 +02:00
Petar Petrov 58f5480ca3 Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-10 14:57:09 +02:00
Petar Petrov 1f20bc0749 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-10 14:57:09 +02:00
Petar Petrov 0846cb3be3 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-10 14:57:08 +02:00
Petar Petrov 33afb77367 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-10 14:57:07 +02:00
Petar Petrov b2f85e2595 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-10 14:57:06 +02:00
Simon Lamon 09956a7d9c No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-10 14:57:05 +02:00
Simon Lamon 8507e222f8 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-10 14:57:04 +02:00
Petar Petrov 5737480398 Match Statistic card line height to Entity card (#52981) 2026-07-10 14:57:03 +02:00
Petar Petrov 44163b9ccb Fix focus loss in text selector with multiple (#52980) 2026-07-10 14:57:03 +02:00
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
Bram Kragten 91dee86697 Bumped version to 20260624.3 2026-07-01 13:34:46 +02:00
Paul Bottein 9877377cb9 Show the event type in the logbook for event entities (#52863) 2026-07-01 13:34:34 +02:00
Bram Kragten 7a1c8c556f Bumped version to 20260624.2 2026-06-30 15:58:00 +02:00
Bram Kragten 2c3d8eb230 Show a warning when deprecated automation options where used and migr… (#52915)
Show a warning when deprecated automation options where used and migrated
2026-06-30 15:57:34 +02:00
Aidan Timson 67489affe7 Fix loading spinner position for more info weather forecast (#52903) 2026-06-30 15:57:33 +02:00
Paul Bottein 7fcbd8e245 Show dedicated icons for Cloud and Cast in Actvity and add tooltip (#52896) 2026-06-30 15:57:32 +02:00
Jan-Philipp Benecke 12a88231f3 Fix overflow issue in mobile automation target picker (#52883)
Fix overflow issue in mobile target picker
2026-06-30 15:57:31 +02:00
Paulus Schoutsen f66619fae6 Fix duplicate logbook entries in more info dialog after reconnect (#52880)
* Fix duplicate logbook entries after reconnect in more info dialog

When a more info dialog is left open while the app is backgrounded, the
WebSocket connection drops and reconnects on resume. The logbook stream
subscription relied on home-assistant-js-websocket's auto-resubscribe,
which replays the original subscription with its stale start_time. The
backend then resends the entire historical chunk, and ha-logbook appends
streamed events without deduplicating, so every entry was shown twice
(and a third time after another background/reconnect cycle).

Mirror the approach already used for the history stream: disable the
library's auto-resubscribe for the logbook event stream and have
ha-logbook listen for the connection "ready" event, resubscribing from a
clean state on reconnect instead of appending a replayed history chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYMwT7ZQJrjyzrNfGQyWaU

* Simplify logbook reconnect comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-30 15:57:30 +02:00
Bram Kragten 43ff58010a Bumped version to 20260624.1 2026-06-25 16:16:23 +02:00
Copilot c391d571d7 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:15:51 +02:00
Petar Petrov 18e15f8a99 Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:15:50 +02:00
Paul Bottein dfdd55b649 Show dash for unavailable number entity in slider row (#52866) 2026-06-25 16:15:49 +02:00
Paul Bottein bed98776c3 Fix logbook padding and margin (#52864) 2026-06-25 16:15:48 +02:00
Paul Bottein ad37f1bb58 Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 16:15:47 +02:00
Franck Nijhof 2a00b0d0ec Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 16:15:46 +02:00
Paul Bottein 20efc35da3 Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:45 +02:00
Michael Hansen ac71b4c400 Add demo voice assistants and exposed entities (#52855) 2026-06-25 16:15:44 +02:00
Franck Nijhof b85422e652 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 16:15:43 +02:00
Paulus Schoutsen 4ff69aab8f Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:42 +02:00
Bram Kragten ebb15d1118 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-25 16:15:41 +02:00
42 changed files with 935 additions and 497 deletions
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
}
}
@@ -12,18 +12,42 @@
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
const loaderInitPromises = new Map();
const initLoader = (browserslistEnv) => {
if (!loaderInitPromises.has(browserslistEnv)) {
loaderInitPromises.set(
browserslistEnv,
Promise.all([
import("minify-literals"),
import("browserslist"),
import("lightningcss"),
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
const browserslist = browserslistModule.default;
const { browserslistToTargets } = lightningcssModule;
const rawTargets = browserslist(null, { env: browserslistEnv });
if (!rawTargets.length) {
throw new Error(
`No browsers resolved for browserslist environment "${browserslistEnv}"`
);
}
const lightningcssTargets = browserslistToTargets(rawTargets);
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
};
})
);
}
return minifyPromise;
return loaderInitPromises.get(browserslistEnv);
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
// css`` templates and inline <style> is handled by minify-literals'
// lightningcss. We pass in the targets from browserslist so lightningcss
// can minify CSS appropriately for the build environment.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
@@ -40,11 +64,16 @@ const htmlOptions = {
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
getMinifier()
.then((minifyHTMLLiterals) =>
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
},
})
)
.then((result) => {
+5
View File
@@ -96,6 +96,11 @@ const createRspackConfig = ({
__dirname,
"minify-template-literals-loader.cjs"
),
options: {
browserslistEnv: latestBuild
? "modern"
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
},
},
!latestBuild &&
info.resource.startsWith(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -1,8 +1,29 @@
let supported: boolean | undefined;
const detect = (): boolean => {
if (
!globalThis.ElementInternals ||
!globalThis.HTMLElement?.prototype.attachInternals
) {
return false;
}
// Native internals keep their WebIDL brand even when `attachInternals` is
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
// broke the previous `[native code]` source check in the app bundle).
// `element-internals-polyfill` swaps in a plain class, which has no brand,
// and must not count as native: login on legacy browsers relies on
// validation being skipped there (#51338).
return (
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
"[object ElementInternals]"
);
};
/**
* Indicates whether the current browser has native ElementInternals support.
* Probed on first use so importing this module has no side effects.
*/
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
export const supportsNativeElementInternals = (): boolean => {
supported ??= detect();
return supported;
};
@@ -0,0 +1,26 @@
import { mainWindow } from "../dom/get_main_window";
/**
* Checks that a path resolves to the origin the frontend is served from, so it
* is safe to use as a link target. Rejects URIs that carry their own scheme,
* like `javascript:`, and URLs pointing at another origin. Resolves against the
* main window, because that is where `navigate()` applies the path.
*/
const isSameOriginPath = (path: string): boolean => {
try {
const { origin } = mainWindow.location;
return new URL(path, origin).origin === origin;
} catch (_err) {
return false;
}
};
/**
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
* paths that can be influenced by a URL parameter or by dashboard config before
* they end up in an `href` or in `navigate()`.
*/
export const sanitizeNavigationPath = (
path: string | null | undefined
): string | undefined =>
path != null && isSameOriginPath(path) ? path : undefined;
+9 -8
View File
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../types";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
class="snowflake ${
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
}"
style="
left: ${flake.left}%;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
--rotation: ${flake.rotation}deg;
"
style=${styleMap({
left: `${flake.left}%`,
width: `${flake.size}px`,
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"
xmlns="http://www.w3.org/2000/svg"
+9 -5
View File
@@ -1,7 +1,7 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
import type { Constructor } from "../../types";
/**
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
}
public checkValidity(): boolean {
return nativeElementInternalsSupported
return supportsNativeElementInternals()
? (this._formControl?.checkValidity() ?? true)
: true;
}
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this._formControl?.checkValidity()) {
if (this._invalid && this.checkValidity()) {
this._invalid = false;
}
}
@@ -222,12 +222,16 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this._formControl?.checkValidity();
this._invalid = !this.checkValidity();
}
}
protected _handleInvalid(): void {
this._invalid = true;
// Polyfilled internals dispatch `invalid` themselves, so only trust the
// event when validity comes from the platform (#51338).
if (supportsNativeElementInternals()) {
this._invalid = true;
}
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
-1
View File
@@ -23,7 +23,6 @@ export interface LovelaceViewElement extends HTMLElement {
badges?: HuiBadge[];
sections?: HuiSection[];
isStrategy: boolean;
initialRenderComplete?: Promise<void>;
setConfig(config: LovelaceViewConfig): void;
}
+1 -4
View File
@@ -1,7 +1,6 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/animation/ha-fade-in";
import "../components/ha-top-app-bar-fixed";
import "../components/ha-spinner";
import type { HomeAssistant } from "../types";
@@ -37,9 +36,7 @@ class HassLoadingScreen extends LitElement {
private _renderContent(): TemplateResult {
return html`
<div class="content">
<ha-fade-in .delay=${500}>
<ha-spinner></ha-spinner>
</ha-fade-in>
<ha-spinner></ha-spinner>
${
this.message
? html`<div id="loading-text">${this.message}</div>`
+5 -2
View File
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -29,16 +30,18 @@ class HassSubpage extends LitElement {
@restoreScroll(".content") private _savedScrollPos?: number;
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: this.backPath
: backPath
? html`
<ha-icon-button-arrow-prev
href=${this.backPath}
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
+6 -3
View File
@@ -15,6 +15,7 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -164,17 +165,19 @@ export class HassTabsSubpage extends LitElement {
this._narrow,
this.localizeFunc || this.hass.localize
);
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!this.backPath && history.state?.root)
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: this.backPath
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${this.backPath}
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
-39
View File
@@ -1,5 +1,3 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveController, ReactiveControllerHost } from "lit";
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
@@ -17,43 +15,6 @@ export const panelIsReady = async (element: HTMLElement) => {
fireEvent(element, "hass-panel-ready");
};
export type RegisterChildPanelReady = (ready: Promise<void>) => void;
export const childPanelReadyContext =
createContext<RegisterChildPanelReady>("child-panel-ready");
export class ChildPanelReady implements ReactiveController {
private _promises: Promise<void>[] = [];
private _host: ReactiveControllerHost & HTMLElement;
private _resolveReady?: () => void;
public ready = new Promise<void>((resolve) => {
this._resolveReady = resolve;
});
public constructor(host: ReactiveControllerHost & HTMLElement) {
this._host = host;
host.addController(this);
new ContextProvider(host, {
context: childPanelReadyContext,
initialValue: (ready) => this._promises.push(ready),
});
}
public hostUpdated() {
Promise.all(this._promises).then(
() => {
this._resolveReady?.();
return panelIsReady(this._host);
},
() => undefined
);
this._host.removeController(this);
}
}
export class PanelReady {
public ready?: Promise<void>;
+28 -63
View File
@@ -19,62 +19,29 @@ import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const PANEL_READY_TIMEOUT = 2000;
const DASHBOARD_READY_TIMEOUT = 5000;
const COMPONENTS = {
app: { load: () => import("../panels/app/ha-panel-app") },
energy: {
load: () => import("../panels/energy/ha-panel-energy"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
calendar: { load: () => import("../panels/calendar/ha-panel-calendar") },
config: { load: () => import("../panels/config/ha-panel-config") },
custom: { load: () => import("../panels/custom/ha-panel-custom") },
lovelace: {
load: () => import("../panels/lovelace/ha-panel-lovelace"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
history: { load: () => import("../panels/history/ha-panel-history") },
iframe: { load: () => import("../panels/iframe/ha-panel-iframe") },
logbook: { load: () => import("../panels/logbook/ha-panel-logbook") },
map: { load: () => import("../panels/map/ha-panel-map") },
my: { load: () => import("../panels/my/ha-panel-my") },
profile: { load: () => import("../panels/profile/ha-panel-profile") },
todo: { load: () => import("../panels/todo/ha-panel-todo") },
"media-browser": {
load: () => import("../panels/media-browser/ha-panel-media-browser"),
},
light: {
load: () => import("../panels/light/ha-panel-light"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
security: {
load: () => import("../panels/security/ha-panel-security"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
climate: {
load: () => import("../panels/climate/ha-panel-climate"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
maintenance: {
load: () => import("../panels/maintenance/ha-panel-maintenance"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
home: {
load: () => import("../panels/home/ha-panel-home"),
waitForReady: true,
readyTimeout: DASHBOARD_READY_TIMEOUT,
},
notfound: { load: () => import("../panels/notfound/ha-panel-notfound") },
} satisfies Record<
string,
Pick<RouteOptions, "load" | "waitForReady"> & { readyTimeout?: number }
>;
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
calendar: () => import("../panels/calendar/ha-panel-calendar"),
config: () => import("../panels/config/ha-panel-config"),
custom: () => import("../panels/custom/ha-panel-custom"),
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
history: () => import("../panels/history/ha-panel-history"),
iframe: () => import("../panels/iframe/ha-panel-iframe"),
logbook: () => import("../panels/logbook/ha-panel-logbook"),
map: () => import("../panels/map/ha-panel-map"),
my: () => import("../panels/my/ha-panel-my"),
profile: () => import("../panels/profile/ha-panel-profile"),
todo: () => import("../panels/todo/ha-panel-todo"),
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
light: () => import("../panels/light/ha-panel-light"),
security: () => import("../panels/security/ha-panel-security"),
climate: () => import("../panels/climate/ha-panel-climate"),
maintenance: () => import("../panels/maintenance/ha-panel-maintenance"),
home: () => import("../panels/home/ha-panel-home"),
notfound: () => import("../panels/notfound/ha-panel-notfound"),
};
@customElement("partial-panel-resolver")
class PartialPanelResolver extends HassRouterPage {
@@ -159,12 +126,13 @@ class PartialPanelResolver extends HassRouterPage {
private _getRoutes(panels: Panels): RouterOptions {
const routes: RouterOptions["routes"] = {};
Object.values(panels).forEach((panel) => {
const component = COMPONENTS[panel.component_name];
const data: RouteOptions = {
tag: `ha-panel-${panel.component_name}`,
cache: CACHE_URL_PATHS.includes(panel.url_path),
...component,
};
if (panel.component_name in COMPONENTS) {
data.load = COMPONENTS[panel.component_name];
}
routes[panel.url_path] = data;
});
@@ -253,12 +221,9 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
const panel = this.hass.panels[this._currentPage];
const component = panel ? COMPONENTS[panel.component_name] : undefined;
await promiseTimeout(
component?.readyTimeout ?? PANEL_READY_TIMEOUT,
this.pageRendered
).catch(() => undefined);
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
() => undefined
);
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
-4
View File
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -32,8 +31,6 @@ class PanelClimate extends LitElement {
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -130,7 +127,6 @@ class PanelClimate extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
@@ -36,7 +36,6 @@ import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import { showShortcutsDialog } from "../../../dialogs/shortcuts/show-shortcuts-dialog";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { ChildPanelReady } from "../../../layouts/panel-ready";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -158,11 +157,6 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
total: 0,
};
public constructor() {
super();
new ChildPanelReady(this);
}
private _pages = memoizeOne(
(
cloudStatus,
@@ -1,5 +1,4 @@
import { consume } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { filterNavigationPages } from "../../../common/config/filter_navigation_pages";
@@ -8,10 +7,6 @@ import "../../../components/ha-icon-next";
import type { CloudStatus } from "../../../data/cloud";
import { getConfigEntries } from "../../../data/config_entries";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
@@ -23,20 +18,21 @@ class HaConfigNavigation extends LitElement {
@property({ attribute: false }) public pages!: PageNavigation[];
@state() private _visiblePages?: PageNavigation[];
@state() private _hasBluetoothConfigEntries = false;
private _hasBluetoothConfigEntries = false;
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
public connectedCallback() {
super.connectedCallback();
this._registerChildPanelReady?.(this._resolveVisiblePages());
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
getConfigEntries(this.hass, {
domain: "bluetooth",
}).then((bluetoothEntries) => {
this._hasBluetoothConfigEntries = bluetoothEntries.length > 0;
});
}
protected render(): TemplateResult {
const pages = (this._visiblePages ?? []).map((page) => ({
const pages = filterNavigationPages(this.hass, this.pages, {
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
}).map((page) => ({
...page,
name:
page.name ||
@@ -79,20 +75,6 @@ class HaConfigNavigation extends LitElement {
`;
}
private async _resolveVisiblePages(): Promise<void> {
if (this.pages.some((page) => page.component === "bluetooth")) {
const entries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
this._hasBluetoothConfigEntries = entries.length > 0;
}
this._visiblePages = filterNavigationPages(this.hass, this.pages, {
hasBluetoothConfigEntries: this._hasBluetoothConfigEntries,
});
await this.updateComplete;
}
static styles: CSSResultGroup = css`
/* Accessibility */
.visually-hidden {
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -29,10 +29,11 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
isPowerConfigValid,
type PowerType,
} from "./ha-energy-power-config";
} from "./power-config";
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -67,8 +68,6 @@ export class DialogEnergyBatterySettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -229,6 +228,10 @@ export class DialogEnergyBatterySettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -295,11 +298,7 @@ export class DialogEnergyBatterySettings
}
// Check power config validity
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
return true;
return isPowerConfigValid(this._powerType, this._powerConfig);
}
private async _updateMetadata(statId: string) {
@@ -374,6 +373,7 @@ export class DialogEnergyBatterySettings
if (this._source.capacity === undefined) {
delete this._source.capacity;
}
this._updateFormDirtyState();
}
private async _save() {
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
@@ -33,10 +33,11 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
type HaEnergyPowerConfig,
isPowerConfigValid,
type PowerType,
} from "./ha-energy-power-config";
} from "./power-config";
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -77,8 +78,6 @@ export class DialogEnergyGridSettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -471,6 +470,10 @@ export class DialogEnergyGridSettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -508,10 +511,8 @@ export class DialogEnergyGridSettings
}
// Check power config validity (if power is configured)
if (hasPower) {
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
return false;
}
return true;
@@ -1,99 +1,24 @@
import { mdiInformationOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { PowerConfig } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import { buttonLinkStyle } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
import type { PowerType } from "./power-config";
const powerUnitClasses = ["power"];
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
declare global {
interface HASSDomEvents {
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
@@ -110,10 +35,14 @@ export class HaEnergyPowerConfig extends LitElement {
@property({ attribute: false }) public excludeList?: string[];
/** Entity id of the power sensor generated for the saved config, if any. */
@property({ attribute: false }) public helperEntityId?: string;
/**
* Base key for localization lookups.
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
*/
@property({ attribute: false }) public localizeBaseKey =
"ui.panel.config.energy.battery.dialog";
@@ -164,11 +93,13 @@ export class HaEnergyPowerConfig extends LitElement {
${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
${this._renderHelperSensorNote("inverted")}
</ha-radio-option>
<ha-radio-option value="two_sensors">
${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
${this._renderHelperSensorNote("two_sensors")}
</ha-radio-option>
</ha-radio-group>
@@ -243,9 +174,50 @@ export class HaEnergyPowerConfig extends LitElement {
`
: nothing
}
${this._renderHelperSensorInUse()}
`;
}
private _renderHelperSensorNote(powerType: PowerType) {
const id = `helper-sensor-note-${powerType}`;
return html`
<ha-svg-icon
id=${id}
tabindex="0"
class="note-icon"
.path=${mdiInformationOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip .for=${id} placement="top">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
)}
</ha-tooltip>
`;
}
private _renderHelperSensorInUse() {
if (!this.helperEntityId) {
return nothing;
}
return html`
<p class="helper-sensor-in-use">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
{
entity: html`<button class="link" @click=${this._showHelperSensor}>
${this.helperEntityId}
</button>`,
}
)}
</p>
`;
}
private _showHelperSensor() {
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
}
private _handlePowerTypeChanged(ev: Event) {
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
// Clear power config when switching types
@@ -289,48 +261,46 @@ export class HaEnergyPowerConfig extends LitElement {
});
}
/**
* Validates that the power config is complete for the selected type.
*/
public isValid(): boolean {
switch (this.powerType) {
case "none":
return true;
case "standard":
return !!this.powerConfig.stat_rate;
case "inverted":
return !!this.powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
);
default:
return false;
}
}
static readonly styles: CSSResultGroup = css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
`;
static readonly styles: CSSResultGroup = [
buttonLinkStyle,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
.note-icon {
margin-inline-start: var(--ha-space-1);
color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.helper-sensor-in-use {
margin: var(--ha-space-2) 0 0 0;
color: var(--secondary-text-color);
font-size: 0.875em;
}
.helper-sensor-in-use button.link {
color: var(--primary-color);
/* entity ids offer no break opportunities of their own */
overflow-wrap: anywhere;
}
`,
];
}
declare global {
@@ -0,0 +1,127 @@
import type { PowerConfig } from "../../../../data/energy";
import { deepEqual } from "../../../../common/util/deep-equal";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Checks that the power config is complete for the selected power type.
*/
export function isPowerConfigValid(
powerType: PowerType,
powerConfig: PowerConfig
): boolean {
switch (powerType) {
case "none":
return true;
case "standard":
return !!powerConfig.stat_rate;
case "inverted":
return !!powerConfig.stat_rate_inverted;
case "two_sensors":
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
default:
return false;
}
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
/**
* Returns the entity id of the power sensor the backend generated for a saved
* source, if any. Inverted and two sensor configs get such a helper, its entity
* id is stored in `stat_rate`. Returns nothing while the config differs from the
* saved one, as the helper doesn't match the edited config yet.
*/
export function getPowerHelperEntityId(
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
currentPowerConfig: PowerConfig
): string | undefined {
if (!source?.stat_rate || !source.power_config) {
return undefined;
}
const savedPowerType = getPowerTypeFromConfig(source.power_config);
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
return undefined;
}
if (!deepEqual(source.power_config, currentPowerConfig)) {
return undefined;
}
return source.stat_rate;
}
-1
View File
@@ -89,7 +89,6 @@ class HaPanelConfig extends HassRouterPage {
dashboard: {
tag: "ha-config-dashboard",
load: () => import("./dashboard/ha-config-dashboard"),
waitForReady: true,
},
entities: {
tag: "ha-config-entities",
+4 -1
View File
@@ -2,6 +2,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { navigate } from "../../common/navigate";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -161,7 +162,9 @@ class PanelEnergy extends LitElement {
.route=${this.route}
.panel=${this.panel}
.backButton=${this._searchParms.has("historyBack")}
.backPath=${this._searchParms.get("backPath") || "/"}
.backPath=${
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
}
@reload-energy-panel=${this._reloadConfig}
>
</hui-root>
-4
View File
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -32,8 +31,6 @@ class PanelLight extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -130,7 +127,6 @@ class PanelLight extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
@@ -3,7 +3,6 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-card";
import "../../../components/ha-spinner";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -39,9 +38,7 @@ export class HuiStartingCard extends LitElement implements LovelaceCard {
return html`
<div class="content">
<ha-fade-in .delay=${500}>
<ha-spinner></ha-spinner>
</ha-fade-in>
<ha-spinner></ha-spinner>
${this.hass.localize("ui.panel.lovelace.cards.starting.description")}
</div>
`;
+16 -19
View File
@@ -31,6 +31,7 @@ import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
import {
addSearchParam,
extractSearchParamsObject,
@@ -75,7 +76,6 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles";
import { ChildPanelReady } from "../../layouts/panel-ready";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { isMac } from "../../util/is_mac";
@@ -164,8 +164,6 @@ class HUIRoot extends LitElement {
private _restoreScroll = false;
private _childPanelReady?: ChildPanelReady;
private _undoRedoController = new UndoRedoController<UndoStackItem>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => ({
@@ -779,7 +777,7 @@ class HUIRoot extends LitElement {
huiView.narrow = this.narrow;
}
let newSelectView: HUIRoot["_curView"];
let newSelectView;
let viewPath: string | undefined = this.route!.path.split("/")[1];
viewPath = viewPath ? decodeURI(viewPath) : undefined;
@@ -896,10 +894,12 @@ class HUIRoot extends LitElement {
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
if (curViewConfig?.back_path != null) {
navigate(curViewConfig.back_path, { replace: true });
} else if (this.backPath) {
navigate(this.backPath, { replace: true });
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
navigate(backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
@@ -921,11 +921,12 @@ class HUIRoot extends LitElement {
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
if (curViewConfig?.back_path != null) {
return curViewConfig.back_path;
}
if (this.backPath) {
return this.backPath;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
return backPath;
}
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
@@ -1257,7 +1258,7 @@ class HUIRoot extends LitElement {
return;
}
let view: HUIView;
let view;
const viewConfig = this.config.views[viewIndex];
if (!viewConfig) {
@@ -1268,16 +1269,12 @@ class HUIRoot extends LitElement {
if (this._viewCache[viewIndex]) {
view = this._viewCache[viewIndex];
} else {
if (!this._childPanelReady) {
this._childPanelReady = new ChildPanelReady(this);
this.requestUpdate();
}
view = document.createElement("hui-view");
view.index = viewIndex;
this._viewCache[viewIndex] = view;
}
view.lovelace = this.lovelace!;
view.lovelace = this.lovelace;
view.hass = this.hass;
view.narrow = this.narrow;
+1 -17
View File
@@ -58,12 +58,6 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
private _mqlListenerRef?: () => void;
private _resolveInitialRender?: () => void;
public initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
public connectedCallback() {
super.connectedCallback();
this._initMqls();
@@ -172,13 +166,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
root.removeChild(root.lastChild);
}
columns.forEach((column) => {
root.appendChild(column);
});
if (this.cards.length === 0 || columns.some((column) => column.lastChild)) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
columns.forEach((column) => root.appendChild(column));
}
private async _createColumns() {
@@ -246,10 +234,6 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
index,
this.lovelace!.editMode
);
if (columnElements.some((column) => column.isConnected)) {
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
}
// Remove empty columns
-26
View File
@@ -1,5 +1,4 @@
import deepClone from "deep-clone-simple";
import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -20,10 +19,6 @@ import type {
} from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import {
childPanelReadyContext,
type RegisterChildPanelReady,
} from "../../../layouts/panel-ready";
import "../badges/hui-badge";
import type { HuiBadge } from "../badges/hui-badge";
import "../cards/hui-card";
@@ -100,15 +95,6 @@ export class HUIView extends ReactiveElement {
private _config?: LovelaceViewConfig;
private _resolveInitialRender?: () => void;
private _initialRenderComplete = new Promise<void>((resolve) => {
this._resolveInitialRender = resolve;
});
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
@storage({
key: "dashboardCardClipboard",
state: false,
@@ -166,11 +152,6 @@ export class HUIView extends ReactiveElement {
return this;
}
public connectedCallback(): void {
super.connectedCallback();
this._registerChildPanelReady?.(this._initialRenderComplete);
}
public willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
@@ -343,13 +324,6 @@ export class HUIView extends ReactiveElement {
const viewConfig = await this._generateConfig(rawConfig);
this._setConfig(viewConfig, isStrategy);
await customElements.whenDefined(this._layoutElement!.localName);
if (this._layoutElement instanceof ReactiveElement) {
await this._layoutElement.updateComplete;
}
await this._layoutElement!.initialRenderComplete;
this._resolveInitialRender?.();
this._resolveInitialRender = undefined;
}
private _createLayoutElement(config: LovelaceViewConfig): void {
@@ -5,7 +5,6 @@ import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -32,8 +31,6 @@ class PanelMaintenance extends LitElement {
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -130,7 +127,6 @@ class PanelMaintenance extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
-4
View File
@@ -5,7 +5,6 @@ import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
@@ -32,8 +31,6 @@ class PanelSecurity extends LitElement {
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -130,7 +127,6 @@ class PanelSecurity extends LitElement {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
+6 -2
View File
@@ -4211,7 +4211,9 @@
"power_stat": "Power sensor",
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
"power_from": "Power imported from grid",
"power_to": "Power exported to grid"
"power_to": "Power exported to grid",
"helper_sensor_note": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_note%]",
"helper_sensor_in_use": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_in_use%]"
},
"flow_dialog": {
"from": {
@@ -4278,7 +4280,9 @@
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
"type_two_sensors": "Two sensors",
"power_from": "Discharge power",
"power_to": "Charge power"
"power_to": "Charge power",
"helper_sensor_note": "Home Assistant will create a helper sensor entity for this option.",
"helper_sensor_in_use": "Currently using helper {entity}"
}
},
"gas": {
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalAttachInternals = HTMLElement.prototype.attachInternals;
const originalElementInternals = window.ElementInternals;
// The probe memoizes its result, so re-import the module for each scenario.
const loadProbe = async () => {
vi.resetModules();
const mod =
await import("../../../src/common/feature-detect/support-native-element-internals");
return mod.supportsNativeElementInternals;
};
describe("supportsNativeElementInternals", () => {
afterEach(() => {
HTMLElement.prototype.attachInternals = originalAttachInternals;
window.ElementInternals = originalElementInternals;
});
it("returns true with native ElementInternals", async () => {
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(true);
});
it("returns true when attachInternals is wrapped by a delegating function", async () => {
// Simulates @webcomponents/scoped-custom-element-registry, which the app
// bundle loads. Wrapping used to make detection fail (#53337).
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
return originalAttachInternals.call(this);
};
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(true);
});
it("returns false when element-internals-polyfill replaces the global", async () => {
// The polyfill swaps in its own class, so the mere presence of
// window.ElementInternals says nothing about native support (#51338).
class PolyfilledElementInternals {
public setFormValue = (): void => undefined;
public setValidity = (): void => undefined;
public checkValidity = (): boolean => true;
public reportValidity = (): boolean => true;
}
window.ElementInternals =
PolyfilledElementInternals as unknown as typeof window.ElementInternals;
HTMLElement.prototype.attachInternals = () =>
new PolyfilledElementInternals() as unknown as ElementInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("returns false without ElementInternals", async () => {
window.ElementInternals =
undefined as unknown as typeof window.ElementInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("returns false without attachInternals", async () => {
HTMLElement.prototype.attachInternals =
undefined as unknown as typeof originalAttachInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("probes on first use rather than on import", async () => {
// Imported while support is present, so an eager probe would cache true.
const supportsNativeElementInternals = await loadProbe();
window.ElementInternals =
undefined as unknown as typeof window.ElementInternals;
expect(supportsNativeElementInternals()).toBe(false);
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { sanitizeNavigationPath } from "../../../src/common/url/sanitize-navigation-path";
describe("sanitizeNavigationPath", () => {
it("keeps paths on the current origin", () => {
expect(sanitizeNavigationPath("/")).toEqual("/");
expect(sanitizeNavigationPath("/config/areas")).toEqual("/config/areas");
expect(sanitizeNavigationPath("/energy?historyBack=1")).toEqual(
"/energy?historyBack=1"
);
expect(sanitizeNavigationPath("config/areas")).toEqual("config/areas");
expect(sanitizeNavigationPath(`${location.origin}/lovelace/0`)).toEqual(
`${location.origin}/lovelace/0`
);
});
/* eslint-disable no-script-url */
it("rejects URIs with their own scheme", () => {
expect(sanitizeNavigationPath("javascript:alert(1)")).toBeUndefined();
expect(sanitizeNavigationPath("JavaScript:alert(1)")).toBeUndefined();
// the URL parser strips tabs and newlines, just like the browser does for href
expect(sanitizeNavigationPath("java\tscript:alert(1)")).toBeUndefined();
expect(sanitizeNavigationPath(" javascript:alert(1)")).toBeUndefined();
expect(
sanitizeNavigationPath("data:text/html,<script>alert(1)</script>")
).toBeUndefined();
expect(sanitizeNavigationPath("vbscript:msgbox(1)")).toBeUndefined();
});
/* eslint-enable no-script-url */
it("rejects other origins", () => {
expect(sanitizeNavigationPath("https://example.com/")).toBeUndefined();
expect(sanitizeNavigationPath("//example.com/")).toBeUndefined();
expect(sanitizeNavigationPath("\\\\example.com/")).toBeUndefined();
});
it("rejects missing values", () => {
expect(sanitizeNavigationPath(undefined)).toBeUndefined();
expect(sanitizeNavigationPath(null)).toBeUndefined();
});
});
@@ -0,0 +1,144 @@
import { LitElement } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WaInput } from "../../../src/components/input/wa-input-mixin";
import { WaInputMixin } from "../../../src/components/input/wa-input-mixin";
const supportsNative = vi.hoisted(() => ({ value: true }));
vi.mock(
"../../../src/common/feature-detect/support-native-element-internals",
() => ({
supportsNativeElementInternals: () => supportsNative.value,
})
);
// Subclassing gives legitimate access to the mixin's protected members, which
// ha-input and ha-textarea reach through their @input/@blur/@wa-invalid bindings.
class TestInput extends WaInputMixin(LitElement) {
public controlValid = true;
protected get _formControl(): WaInput {
return {
value: "",
select: () => undefined,
setSelectionRange: () => undefined,
setRangeText: () => undefined,
checkValidity: () => this.controlValid,
validationMessage: "Please fill out this field.",
};
}
public get isInvalid(): boolean {
return this._invalid;
}
public set isInvalid(value: boolean) {
this._invalid = value;
}
public handleInput(): void {
this._handleInput();
}
public handleBlur(): void {
this._handleBlur();
}
public handleInvalid(): void {
this._handleInvalid();
}
}
customElements.define("test-wa-input-mixin", TestInput);
declare global {
interface HTMLElementTagNameMap {
"test-wa-input-mixin": TestInput;
}
}
const createInput = (props: Partial<TestInput> = {}): TestInput => {
const el = document.createElement("test-wa-input-mixin");
Object.assign(el, props);
return el;
};
describe("WaInputMixin validity", () => {
beforeEach(() => {
supportsNative.value = true;
});
describe("with native element internals", () => {
it("marks invalid on blur when auto-validate is set", () => {
const el = createInput({ autoValidate: true, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(true);
});
it("keeps valid on blur when the control is valid", () => {
const el = createInput({ autoValidate: true, controlValid: true });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("ignores blur without auto-validate", () => {
const el = createInput({ autoValidate: false, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("clears invalid on input once the control is valid", () => {
const el = createInput({ controlValid: true });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(false);
});
it("keeps invalid on input while the control is invalid", () => {
const el = createInput({ controlValid: false });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(true);
});
it("marks invalid on the invalid event", () => {
const el = createInput();
el.handleInvalid();
expect(el.isInvalid).toBe(true);
});
});
// Polyfilled internals report validity the app cannot trust, so every path
// must agree with checkValidity() and leave the field alone (#51338).
describe("without native element internals", () => {
beforeEach(() => {
supportsNative.value = false;
});
it("does not mark invalid on blur", () => {
const el = createInput({ autoValidate: true, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("clears invalid on input", () => {
const el = createInput({ controlValid: false });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(false);
});
it("ignores the invalid event", () => {
const el = createInput();
el.handleInvalid();
expect(el.isInvalid).toBe(false);
});
it("reports valid so submission is never blocked", () => {
const el = createInput({ required: true, controlValid: false });
expect(el.checkValidity()).toBe(true);
expect(el.reportValidity()).toBe(true);
expect(el.isInvalid).toBe(false);
});
});
});
-34
View File
@@ -166,40 +166,6 @@ defineRouteSmokeTests(appRouteSmokeGroups);
// ---------------------------------------------------------------------------
test.describe("Lovelace dashboard", () => {
test("keeps the launch screen until generated content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-generated-dashboard#/climate");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-view")).not.toBeAttached();
await page.evaluate(() => window.resolveGeneratedDashboard?.());
await expect(page.locator("hui-view")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("keeps the launch screen until initial content renders", async ({
page,
}) => {
await goToPanel(page, "/?scenario=delayed-lovelace#/lovelace");
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator("hui-card")).not.toBeAttached();
await page.evaluate(() => window.resolveLovelaceConfig?.());
await expect(page.locator("hui-card").first()).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
});
test("renders cards", async ({ page }) => {
await goToPanel(page, "/lovelace");
// At least one card should appear
-2
View File
@@ -92,8 +92,6 @@ declare global {
interface Window {
__assistRun?: unknown;
__mockHass: MockHomeAssistant;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
}
}
-47
View File
@@ -1,6 +1,5 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -125,50 +124,6 @@ const quickSearchAssistScenario: Scenario = async (hass) => {
});
};
const addLaunchScreen = () => {
const launchScreen = document.createElement("div");
launchScreen.id = "ha-launch-screen";
document.body.prepend(launchScreen);
};
const delayedLovelaceScenario: Scenario = (hass) => {
addLaunchScreen();
const config: LovelaceRawConfig = {
views: [
{
title: "Home",
cards: [{ type: "markdown", content: "Dashboard ready" }],
},
],
};
let resolveConfig: ((config: LovelaceRawConfig) => void) | undefined;
const configPromise = new Promise<LovelaceRawConfig>((resolve) => {
resolveConfig = resolve;
});
window.resolveLovelaceConfig = () => resolveConfig?.(config);
hass.mockWS("lovelace/config", () => configPromise);
};
const delayedGeneratedDashboardScenario: Scenario = (hass) => {
addLaunchScreen();
const loadFragmentTranslation = hass.loadFragmentTranslation;
let resolveTranslation: (() => void) | undefined;
const translationReady = new Promise<void>((resolve) => {
resolveTranslation = resolve;
});
hass.loadFragmentTranslation = async (fragment) => {
if (fragment === "lovelace") {
await translationReady;
}
return loadFragmentTranslation(fragment);
};
window.resolveGeneratedDashboard = resolveTranslation;
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -176,8 +131,6 @@ export const scenarios: Record<string, Scenario> = {
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
"light-more-info": lightMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
"delayed-lovelace": delayedLovelaceScenario,
};
+42
View File
@@ -0,0 +1,42 @@
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
await import("../../src/layouts/hass-subpage");
let host: HTMLDivElement | undefined;
const mount = async (backPath: string) => {
host = document.createElement("div");
document.body.append(host);
const element = document.createElement("hass-subpage");
element.setAttribute("back-path", backPath);
host.append(element);
await (element as LitElement).updateComplete;
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
};
afterEach(() => {
host?.remove();
host = undefined;
});
describe("hass-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config/system");
expect(backButton!.getAttribute("href")).toEqual("/config/system");
});
// eslint-disable-next-line no-script-url
it.each(["javascript:alert(1)", "https://example.com/"])(
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.hasAttribute("href")).toBe(false);
}
);
});
+60
View File
@@ -0,0 +1,60 @@
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../src/types";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/components/ha-tab", () => ({}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
customElements.define("ha-tab", class extends LitElement {});
await import("../../src/layouts/hass-tabs-subpage");
const hass = {
config: { components: [] },
language: "en",
localize: (key: string) => key,
} as unknown as HomeAssistant;
let host: HTMLDivElement | undefined;
const mount = async (backPath: string) => {
host = document.createElement("div");
document.body.append(host);
const element = document.createElement(
"hass-tabs-subpage"
) as HassTabsSubpage;
Object.assign(element, {
hass,
route: { prefix: "", path: "" },
tabs: [],
backPath,
});
host.append(element);
await element.updateComplete;
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
(LitElement & { href?: string }) | null;
};
afterEach(() => {
host?.remove();
host = undefined;
});
describe("hass-tabs-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config");
expect(backButton!.href).toEqual("/config");
});
// eslint-disable-next-line no-script-url
it.each(["javascript:alert(1)", "https://example.com/"])(
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.href).toBeUndefined();
}
);
});
@@ -0,0 +1,71 @@
import { assert, describe, it } from "vitest";
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
describe("getPowerHelperEntityId", () => {
it("returns the helper for an inverted config", () => {
const powerConfig = { stat_rate_inverted: "sensor.battery_power" };
assert.strictEqual(
getPowerHelperEntityId(
{
stat_rate: "sensor.battery_power_inverted",
power_config: powerConfig,
},
{ ...powerConfig }
),
"sensor.battery_power_inverted"
);
});
it("returns the helper for a two sensor config", () => {
const powerConfig = {
stat_rate_from: "sensor.discharge",
stat_rate_to: "sensor.charge",
};
assert.strictEqual(
getPowerHelperEntityId(
{
stat_rate: "sensor.energy_battery_discharge_charge_net_power",
power_config: powerConfig,
},
{ ...powerConfig }
),
"sensor.energy_battery_discharge_charge_net_power"
);
});
it("returns nothing for a standard config, as no helper is created", () => {
const powerConfig = { stat_rate: "sensor.battery_power" };
assert.isUndefined(
getPowerHelperEntityId(
{ stat_rate: "sensor.battery_power", power_config: powerConfig },
{ ...powerConfig }
)
);
});
it("returns nothing for a legacy config without power_config", () => {
assert.isUndefined(
getPowerHelperEntityId({ stat_rate: "sensor.battery_power" }, {})
);
});
it("returns nothing when the config was edited", () => {
assert.isUndefined(
getPowerHelperEntityId(
{
stat_rate: "sensor.battery_power_inverted",
power_config: { stat_rate_inverted: "sensor.battery_power" },
},
{ stat_rate_inverted: "sensor.other_battery_power" }
)
);
});
it("returns nothing for an unsaved source", () => {
assert.isUndefined(
getPowerHelperEntityId(undefined, {
stat_rate_inverted: "sensor.battery_power",
})
);
});
});
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { isPowerConfigValid } from "../../../../src/panels/config/energy/dialogs/power-config";
describe("isPowerConfigValid", () => {
it("accepts any config when no power sensor is configured", () => {
expect(isPowerConfigValid("none", {})).toBe(true);
expect(isPowerConfigValid("none", { stat_rate: "sensor.power" })).toBe(
true
);
});
it("requires a rate statistic for the standard type", () => {
expect(isPowerConfigValid("standard", {})).toBe(false);
expect(isPowerConfigValid("standard", { stat_rate: "" })).toBe(false);
expect(isPowerConfigValid("standard", { stat_rate: "sensor.power" })).toBe(
true
);
expect(
isPowerConfigValid("standard", { stat_rate_inverted: "sensor.power" })
).toBe(false);
});
it("requires an inverted rate statistic for the inverted type", () => {
expect(isPowerConfigValid("inverted", {})).toBe(false);
expect(isPowerConfigValid("inverted", { stat_rate: "sensor.power" })).toBe(
false
);
expect(
isPowerConfigValid("inverted", { stat_rate_inverted: "sensor.power" })
).toBe(true);
});
it("requires both statistics for the two sensors type", () => {
expect(isPowerConfigValid("two_sensors", {})).toBe(false);
expect(
isPowerConfigValid("two_sensors", { stat_rate_from: "sensor.power_from" })
).toBe(false);
expect(
isPowerConfigValid("two_sensors", { stat_rate_to: "sensor.power_to" })
).toBe(false);
expect(
isPowerConfigValid("two_sensors", {
stat_rate_from: "sensor.power_from",
stat_rate_to: "sensor.power_to",
})
).toBe(true);
});
it("rejects unknown power types", () => {
expect(
isPowerConfigValid("unexpected" as never, { stat_rate: "sensor.power" })
).toBe(false);
});
});
+1
View File
@@ -3,3 +3,4 @@ global.navigator = (global.navigator ?? {}) as any;
global.__DEMO__ = false;
global.__DEV__ = false;
global.__HASS_URL__ = "";