Compare commits

..

65 Commits

Author SHA1 Message Date
renovate[bot] 9699851c26 Update dependency jsdom to v30 (#53395)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-30 12:15:24 +02:00
Raman Gupta a318593c9f Add live updates to Z-Wave JS node config parameters (#53013)
* Fix Z-Wave JS config parameter value type

Type ZWaveJSNodeConfigParam.value as number | null instead of any,
matching what the backend sends. This surfaced two comparison bugs:

- The enumerated picker no-op check compared the stored number against
  the picker's string value, so it never matched and re-selecting the
  current option re-sent the command to the device.
- The numeric input guard coerced null (unknown value) to 0, so
  entering 0 for a parameter with an unknown value was ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Treat empty numeric input as invalid instead of 0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add live updates to Z-Wave JS node config parameters

Subscribe to config parameter value updates so the page reflects
changes made outside the UI (Z-Wave JS UI, another browser tab, or
the device itself) and so queued changes to sleeping nodes resolve
to a success message when the node wakes and applies them.

- Manage the subscription across connect/disconnect and device
  navigation, deriving deviceId reactively from the route
- Show success results briefly, then clear them automatically
- Clear stale error results when the parameter changes externally
- Discard fetch responses that arrive after navigating to another node

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Address review comments

- Use slice() instead of deprecated substr()
- Clear pending result timeouts when navigating to another node
- Handle subscription promise rejections so an older backend without
  the subscribe command degrades gracefully instead of logging
  unhandled rejections

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:14:26 +02:00
Aidan Timson 749dd180ae Launch screen tweaks (#53380)
* Polish the launch screen

- Use the system sans-serif font so the launch screen no longer blocks on
  loading Roboto
- Reduce the gap between the logo and the loading text
- Dim the loading text to 66% opacity
- Match the OHF logo variant to the applied theme instead of the system
  color scheme
- Make the launch screen text and buttons unselectable

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Drop unit test

* Use classMap

* Drop view transition for css transition to avoid flash

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:13:49 +02:00
Maarten Lakerveld 247780e4c8 Tools > Template use WA Split Panel (#53021)
* Add Web Awesome Split Panel component

* Use SplitPanel for template page. Add vertical/horizontal option. Cleanup, use flexbox.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 10:39:41 +02:00
Paul Bottein 7991090313 Allow area card name to wrap to two lines in vertical layout (#53370)
* Allow area card name to wrap to two lines in vertical layout

* Align vertical tile icons in grid sections

* Cut single big word
2026-07-30 10:03:43 +03:00
Aidan Timson 2efa0bd86d Prefetch home dashboard resources concurrently (#53373)
Load Home dashboard resources concurrently
2026-07-30 10:01:13 +03:00
Aidan Timson 0fad4f0097 Remove empty items on narrow data table secondary lines (#53382)
* Skip empty cells on the narrow data table secondary line

In narrow mode the main column renders every other visible column on a
secondary line, but the dot separator was inserted based on the column
index instead of whether the cell rendered anything. A row whose extra
columns are all empty showed a secondary line consisting only of dots,
and an empty column between two filled ones produced a double dot.

Filter empty cells out before joining, and return `nothing` instead of
`html`${nothing}`` for missing timestamps so those cells are detectably
empty too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Extract / clarify data table column visibility

* Fix unbound method and add all-empty secondary line test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

* Fix prettier formatting in ha-data-table test

Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com>

---------

Co-authored-by: marcinbauer85 <marcinbauer85@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-30 07:07:43 +02:00
Marcin Bauer 2b90c9a628 Show the automation state on mobile in the automations table (#53375)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 19:48:51 +02:00
renovate[bot] f3848df19d Update dependency globals to v17.8.0 (#53387)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:26 +02:00
renovate[bot] e2a91c358d Update Node.js to v24.18.1 (#53386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-29 19:48:22 +02:00
Paul Bottein 8793cb58b9 Show integration logo and name in the replace device dialog (#53384) 2026-07-29 19:24:39 +02:00
Bram Kragten 2442bf662a Strip empty strings from http config (#53377) 2026-07-29 15:33:02 +02:00
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
70 changed files with 1914 additions and 1093 deletions
+1 -1
View File
@@ -1 +1 @@
24.18.0
24.18.1
+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(
+4 -10
View File
@@ -34,16 +34,8 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#03a9f4" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_social_meta.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
@@ -64,12 +56,14 @@
padding: 0;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
user-select: none;
-webkit-user-select: none;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
}
#ha-launch-screen.removing {
@@ -104,7 +98,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+4 -4
View File
@@ -186,14 +186,14 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.8.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "29.1.1",
"jsdom": "30.0.0",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.2.0",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.8.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.0"
"node": "24.18.1"
}
}
+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;
+39 -33
View File
@@ -12,6 +12,7 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { join } from "lit/directives/join";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
@@ -483,13 +484,7 @@ export class HaDataTable extends LitElement {
: ""
}
${Object.entries(columns).map(([key, column]) => {
if (
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ??
column.defaultHidden)
: column.defaultHidden)
) {
if (!this._isColumnVisible(key, column)) {
return nothing;
}
const sorted = key === this.sortColumn;
@@ -657,10 +652,7 @@ export class HaDataTable extends LitElement {
${Object.entries(columns).map(([key, column]) => {
if (
(narrow && !column.main && !column.showNarrow) ||
column.hidden ||
(this.columnOrder && this.columnOrder.includes(key)
? (this.hiddenColumns?.includes(key) ?? column.defaultHidden)
: column.defaultHidden)
!this._isColumnVisible(key, column)
) {
return nothing;
}
@@ -692,28 +684,19 @@ export class HaDataTable extends LitElement {
: narrow && column.main
? html`<div class="primary">${row[key]}</div>
<div class="secondary">
${Object.entries(columns)
.filter(
([key2, column2]) =>
!column2.hidden &&
!column2.main &&
!column2.showNarrow &&
!(this.columnOrder &&
this.columnOrder.includes(key2)
? (this.hiddenColumns?.includes(key2) ??
column2.defaultHidden)
: column2.defaultHidden)
)
.map(
([key2, column2], i) =>
html`${
i !== 0 ? STRINGS_SEPARATOR_DOT : nothing
}${
column2.template
? column2.template(row)
: row[key2]
}`
)}
${join(
Object.entries(columns)
.filter(([key2, column2]) =>
this._isSecondaryColumnVisible(key2, column2)
)
.map(([key2, column2]) =>
column2.template
? column2.template(row)
: row[key2]
)
.filter(this._hasCellValue),
STRINGS_SEPARATOR_DOT
)}
</div>
${
column.extraTemplate
@@ -733,6 +716,29 @@ export class HaDataTable extends LitElement {
`;
};
private _isColumnVisible(key: string, column: DataTableColumnData): boolean {
if (column.hidden) {
return false;
}
if (!this.columnOrder?.includes(key)) {
return !column.defaultHidden;
}
return !(this.hiddenColumns?.includes(key) ?? column.defaultHidden);
}
private _isSecondaryColumnVisible(
key: string,
column: DataTableColumnData
): boolean {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
}
private _hasCellValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== "" && value !== nothing;
private async _sortFilterData() {
const startTime = new Date().getTime();
const timeBetweenUpdate = startTime - this._lastUpdate;
+69 -21
View File
@@ -6,8 +6,11 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
@@ -23,11 +26,21 @@ export class DialogDeviceReplaced
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
@@ -55,15 +68,23 @@ export class DialogDeviceReplaced
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
secondary: area ? computeAreaName(area) : undefined,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
@@ -92,31 +113,54 @@ export class DialogDeviceReplaced
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas
).map(
(item) => html`
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
item.secondary || item.isPrimary
? html`<span slot="supporting-text">
${[
item.secondary,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`
)}
`;
})}
</ha-list-base>
</ha-dialog>
`;
@@ -132,6 +176,10 @@ export class DialogDeviceReplaced
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
+10 -2
View File
@@ -57,7 +57,10 @@ import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
import "./ha-icon";
import "./ha-icon-button-toolbar";
import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
import type {
HaIconButtonToolbar,
HaIconButtonToolbarItem,
} from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
@@ -115,6 +118,9 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false })
public toolbarItems?: (HaIconButtonToolbarItem | string)[];
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@@ -351,7 +357,8 @@ export class HaCodeEditor extends ReactiveElement {
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo") ||
changedProps.has("testing")
changedProps.has("testing") ||
changedProps.has("toolbarItems")
) {
this._updateToolbarButtons();
}
@@ -529,6 +536,7 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.toolbarItems ?? []),
...(this.hasTest && !this._isFullscreen
? [
{
+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"
+68
View File
@@ -0,0 +1,68 @@
import SplitPanel from "@home-assistant/webawesome/dist/components/split-panel/split-panel";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-split-panel")
export class HaSplitPanel extends SplitPanel {
static get styles(): CSSResultGroup {
return [
SplitPanel.styles,
css`
:host {
--divider-width: var(--ha-split-panel-divider-width, 2px);
--divider-hit-area: var(--ha-split-panel-divider-hit-area, 12px);
--min: var(--ha-split-panel-min, 0);
--max: var(--ha-split-panel-max, 100%);
}
.divider {
background-color: var(--divider-color);
transition: background-color var(--ha-animation-duration-fast)
ease-out;
}
/* Grip affordance so the divider reads as draggable. The divider
already centers its children via flexbox, so keep this in flow.
Consumers slotting their own divider handle can hide it with
--ha-split-panel-grip-display: none. */
.divider::before {
content: "";
width: 2px;
height: var(--ha-space-8);
display: var(--ha-split-panel-grip-display, block);
border-radius: var(--ha-border-radius-pill, 9999px);
background-color: var(--secondary-text-color);
opacity: 0.5;
transition: opacity var(--ha-animation-duration-fast) ease-out;
}
/* In vertical orientation the divider is horizontal, so the grip pill
lies flat instead of standing upright. */
:host([orientation="vertical"]) .divider::before {
width: var(--ha-space-8);
height: 2px;
}
@media (hover: hover) {
:host(:not([disabled])) .divider:hover {
background-color: var(--primary-color);
}
:host(:not([disabled])) .divider:hover::before {
opacity: 1;
}
}
:host(:not([disabled])) .divider:focus-visible {
background-color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-split-panel": HaSplitPanel;
}
}
+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) => {
@@ -43,7 +43,6 @@ import {
} from "../../data/media_source";
import { isTTSMediaSource } from "../../data/tts";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { panelIsReady } from "../../layouts/panel-ready";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
@@ -160,8 +159,6 @@ export class HaMediaPlayerBrowse extends LitElement {
private _resizeObserver?: ResizeObserver;
private _initialReady = false;
public connectedCallback(): void {
super.connectedCallback();
this.updateComplete.then(() => this._attachResizeObserver());
@@ -275,7 +272,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: this._currentItem,
});
this._signalInitialReady();
} else {
if (!currentProm) {
currentProm = this._fetchData(
@@ -291,7 +287,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ids: navigateIds,
current: item,
});
this._signalInitialReady();
},
(err) => {
// When we change entity ID, we will first try to see if the new entity is
@@ -325,10 +320,8 @@ export class HaMediaPlayerBrowse extends LitElement {
),
code: "entity_not_found",
});
this._signalInitialReady();
} else {
this._setError(err);
this._signalInitialReady();
}
}
);
@@ -1132,21 +1125,6 @@ export class HaMediaPlayerBrowse extends LitElement {
fireEvent(this, "close-dialog");
}
private _signalInitialReady(): void {
if (this._initialReady) {
return;
}
this._initialReady = true;
const root = this.getRootNode();
panelIsReady(
root instanceof ShadowRoot &&
root.host instanceof HTMLElement &&
root.host.tagName.startsWith("HA-PANEL-")
? root.host
: this
);
}
private _setError(error: any) {
if (!this.dialog) {
this._error = error;
@@ -1211,8 +1189,8 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _animateHeaderHeight() {
let start: number | undefined;
const animate = (time: number) => {
let start;
const animate = (time) => {
if (start === undefined) {
start = time;
}
+17 -2
View File
@@ -15,6 +15,10 @@ export class HaTileContainer extends LitElement {
@property({ type: Boolean })
public vertical = false;
/* reserve a consistent height for the info block instead of sizing to content, so sibling tiles stay aligned */
@property({ type: Boolean, attribute: "fixed-info-height" })
public fixedInfoHeight = false;
@property({ attribute: false })
public interactive = false;
@@ -34,7 +38,10 @@ export class HaTileContainer extends LitElement {
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = { vertical: this.vertical };
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
};
return html`
<div
@@ -112,7 +119,15 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px;
padding: 10px var(--ha-space-2);
}
.vertical.fixed-info-height {
/* pin sizing so every tile in a grid reserves the same height, wrapping or not, secondary or not */
gap: 2px;
--ha-tile-info-gap: 2px;
--ha-tile-info-primary-line-height: var(--ha-space-4);
--ha-tile-info-primary-min-height: var(--ha-space-8);
--ha-tile-info-min-height: var(--ha-space-12);
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+31 -3
View File
@@ -15,9 +15,13 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @cssprop --ha-tile-info-gap - The vertical gap between the primary and secondary text. defaults to `0`.
* @cssprop --ha-tile-info-min-height - Minimum height of the primary/secondary block. Set this to reserve space for a missing secondary so it doesn't shift surrounding content. defaults to `auto`.
* @cssprop --ha-tile-info-primary-min-height - Minimum height of the primary text block, independent of `--ha-tile-info-primary-line-clamp`. Lets tiles that never wrap still match the height of tiles that do. defaults to `auto` (sizes to the actual rendered lines).
* @cssprop --ha-tile-info-primary-font-size - The font size of the primary text. defaults to `var(--ha-font-size-m)`.
* @cssprop --ha-tile-info-primary-font-weight - The font weight of the primary text. defaults to `var(--ha-font-weight-medium)`.
* @cssprop --ha-tile-info-primary-line-height - The line height of the primary text. defaults to `var(--ha-line-height-normal)`.
* @cssprop --ha-tile-info-primary-line-clamp - The maximum number of lines for the primary text before truncating with an ellipsis. defaults to `1`.
* @cssprop --ha-tile-info-primary-letter-spacing - The letter spacing of the primary text. defaults to `0.1px`.
* @cssprop --ha-tile-info-primary-color - The color of the primary text. defaults to `var(--primary-text-color)`.
* @cssprop --ha-tile-info-secondary-font-size - The font size of the secondary text. defaults to `var(--ha-font-size-s)`.
@@ -59,6 +63,8 @@ export class HaTileInfo extends LitElement {
display: block;
width: 100%;
min-width: 0;
--tile-info-gap: var(--ha-tile-info-gap, 0);
--tile-info-min-height: var(--ha-tile-info-min-height, auto);
--tile-info-primary-font-size: var(
--ha-tile-info-primary-font-size,
var(--ha-font-size-m)
@@ -71,6 +77,11 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--tile-info-primary-line-clamp: var(--ha-tile-info-primary-line-clamp, 1);
--tile-info-primary-min-height: var(
--ha-tile-info-primary-min-height,
auto
);
--tile-info-primary-letter-spacing: var(
--ha-tile-info-primary-letter-spacing,
0.1px
@@ -106,28 +117,45 @@ export class HaTileInfo extends LitElement {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: var(--tile-info-gap);
min-height: var(--tile-info-min-height);
}
span,
::slotted(*) {
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
width: 100%;
}
.primary span,
::slotted([slot="primary"]) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tile-info-primary-line-clamp);
overflow: hidden;
overflow-wrap: anywhere;
width: 100%;
}
.primary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-primary-font-size);
font-weight: var(--tile-info-primary-font-weight);
line-height: var(--tile-info-primary-line-height);
letter-spacing: var(--tile-info-primary-letter-spacing);
color: var(--tile-info-primary-color);
min-height: var(--tile-info-primary-min-height);
}
.secondary {
display: flex;
align-items: center;
width: 100%;
font-size: var(--tile-info-secondary-font-size);
font-weight: var(--tile-info-secondary-font-weight);
line-height: var(--tile-info-secondary-line-height);
letter-spacing: var(--tile-info-secondary-letter-spacing);
color: var(--tile-info-secondary-color);
width: 100%;
}
.placeholder {
width: 140px;
-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;
}
+15
View File
@@ -314,6 +314,11 @@ export interface ZWaveJSSetConfigParamResult {
error?: string;
}
export interface ZwaveJSNodeConfigParameterUpdate {
id: string;
value: number | null;
}
export interface ZWaveJSDataCollectionStatus {
enabled: boolean;
opted_in: boolean;
@@ -732,6 +737,16 @@ export const fetchZwaveNodeConfigParameters = (
device_id,
});
export const subscribeZwaveNodeConfigParameterUpdates = (
hass: HomeAssistant,
device_id: string,
callback: (update: ZwaveJSNodeConfigParameterUpdate) => void
): Promise<UnsubscribeFunc> =>
hass.connection.subscribeMessage(callback, {
type: "zwave_js/subscribe_config_parameter_updates",
device_id,
});
export const setZwaveNodeConfigParameter = (
hass: HomeAssistant,
device_id: string,
+4 -26
View File
@@ -18,38 +18,15 @@
<meta name="referrer" content="same-origin" />
<meta name="theme-color" content="{{ theme_color }}" />
<meta name="color-scheme" content="dark light" />
<link rel="preload" href="/static/fonts/roboto/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin>
<%= renderTemplate("_style_base.html.template") %>
<style>
@font-face {
font-family: "Roboto Launch Screen";
font-display: block;
src: url("/static/fonts/roboto/Roboto-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
::view-transition-group(launch-screen) {
animation-duration: var(--ha-animation-duration-normal, 250ms);
animation-timing-function: ease-out;
}
::view-transition-old(launch-screen) {
animation: fade-out var(--ha-animation-duration-normal, 250ms) ease-out;
}
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
height: 100vh;
}
#ha-launch-screen {
font-family: "Roboto Launch Screen", sans-serif;
font-family: ui-sans-serif, system-ui, sans-serif;
position: fixed;
top: 0;
left: 0;
@@ -61,7 +38,8 @@
flex-direction: column;
justify-content: center;
align-items: center;
view-transition-name: launch-screen;
user-select: none;
-webkit-user-select: none;
background-color: var(--primary-background-color, #fafafa);
z-index: 100;
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
@@ -98,7 +76,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
flex: 1;
padding-top: 48px;
padding-top: 16px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
+6 -2
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-button";
@@ -59,7 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -68,7 +69,7 @@ export class HaInitPage extends LitElement {
"Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient."
}</span
>`
: this.localize?.("ui.init.loading") || "Loading data"
: this.localize?.("ui.init.loading") || "Loading..."
}
</p>`;
}
@@ -120,6 +121,9 @@ export class HaInitPage extends LitElement {
.migration-text {
white-space: pre-line;
}
.loading-text {
opacity: 0.66;
}
`;
}
+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 -67
View File
@@ -19,66 +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"),
waitForReady: true,
},
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"),
waitForReady: true,
},
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 {
@@ -163,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;
});
@@ -257,12 +221,9 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
const component =
COMPONENTS[this.hass.panels[this._currentPage].component_name];
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.
-7
View File
@@ -35,7 +35,6 @@ import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { panelIsReady } from "../../layouts/panel-ready";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { haStyle } from "../../resources/styles";
import type { CalendarViewChanged, HomeAssistant } from "../../types";
@@ -78,8 +77,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _mql?: MediaQueryList;
private _initialReady = false;
public connectedCallback() {
super.connectedCallback();
this._mql = window.matchMedia(
@@ -106,10 +103,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
this._entityRegistry = entities;
// Refresh calendars when entity registry updates (includes color changes)
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
if (!this._initialReady) {
this._initialReady = true;
panelIsReady(this);
}
// Resubscribe events if view dates are available (handles both initial load and color updates)
if (this._start && this._end) {
this._unsubscribeAll().then(() => {
-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,
@@ -346,17 +346,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -203,12 +203,10 @@ export function getModifiedAtTableColumn<T>(
}
const renderDateTimeColumn = (valueDateTime: number, hass: HomeAssistant) =>
html`${
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing
}`;
valueDateTime
? formatShortDateTimeWithConditionalYear(
new Date(valueDateTime * 1000),
hass.locale,
hass.config
)
: nothing;
@@ -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,6 +4,7 @@ import {
mdiCloseCircle,
mdiProgressClock,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -25,10 +26,10 @@ import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/input/ha-input";
import type {
ZWaveJSNodeCapabilities,
ZWaveJSNodeConfigParam,
ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
ZwaveJSNodeConfigParameterUpdate,
ZwaveJSNodeMetadata,
} from "../../../../../data/zwave_js";
import {
@@ -37,6 +38,7 @@ import {
fetchZwaveNodeMetadata,
invokeZWaveCCApi,
setZwaveNodeConfigParameter,
subscribeZwaveNodeConfigParameterUpdates,
} from "../../../../../data/zwave_js";
import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "../../../../../layouts/hass-error-screen";
@@ -59,7 +61,7 @@ const icons = {
@customElement("zwave_js-node-config")
class ZWaveJSNodeConfig extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@@ -83,13 +85,41 @@ class ZWaveJSNodeConfig extends LitElement {
@state() private _resetDialogProgress = false;
private _unsubConfigParamUpdates?: Promise<UnsubscribeFunc>;
private _resultTimeouts: Record<string, number> = {};
public connectedCallback(): void {
super.connectedCallback();
this.deviceId = this.route.path.substr(1);
this._subscribeConfigParameterUpdates();
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this._unsubscribeConfigParameterUpdates();
this._clearAllResultTimeouts();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!changedProps.has("route") || !this.route) {
return;
}
const deviceId = this.route.path.slice(1);
if (deviceId !== this.deviceId) {
this.deviceId = deviceId;
this._config = undefined;
this._clearAllResultTimeouts();
this._results = {};
this._error = undefined;
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (!this._config || changedProps.has("deviceId")) {
if (changedProps.has("deviceId")) {
this._fetchData();
this._subscribeConfigParameterUpdates();
} else if (!this._config) {
this._fetchData();
}
}
@@ -443,6 +473,58 @@ class ZWaveJSNodeConfig extends LitElement {
<p>${item.value}</p>`;
}
private _subscribeConfigParameterUpdates(): void {
this._unsubscribeConfigParameterUpdates();
if (!this.isConnected || !this.hass || !this.deviceId) {
return;
}
this._unsubConfigParamUpdates = subscribeZwaveNodeConfigParameterUpdates(
this.hass,
this.deviceId,
this._handleConfigParameterUpdate
);
this._unsubConfigParamUpdates.catch(() => {
// The backend doesn't support the subscription; the page still works,
// it just won't receive live updates
this._unsubConfigParamUpdates = undefined;
});
}
private _unsubscribeConfigParameterUpdates(): void {
if (this._unsubConfigParamUpdates) {
this._unsubConfigParamUpdates
.then((unsub) => unsub())
.catch(() => {
// The subscription never succeeded, so there is nothing to clean up
});
this._unsubConfigParamUpdates = undefined;
}
}
private _handleConfigParameterUpdate = (
update: ZwaveJSNodeConfigParameterUpdate
): void => {
const param = this._config?.[update.id];
if (!param) {
return;
}
const status = this._results[update.id]?.status;
if (status === "queued") {
// The device applied the queued change; the accepted result is cleared
// after a short delay by _setResult so the success message shows.
// The value was already set optimistically when the change was queued,
// so this runs even if the reported value matches the stored one.
this._setResult(update.id, "accepted");
} else if (status === "error" && param.value !== update.value) {
// The parameter changed, so the previous error no longer applies
this._setResult(update.id, undefined);
}
if (param.value !== update.value) {
param.value = update.value;
this._config = { ...this._config };
}
};
private _isEnumeratedBool(item: ZWaveJSNodeConfigParam): boolean {
// Some Z-Wave config values use a states list with two options where index 0 = Disabled and 1 = Enabled
// We want those to be considered boolean and show a toggle switch
@@ -609,16 +691,38 @@ class ZWaveJSNodeConfig extends LitElement {
}
}
private _clearResultTimeout(key: string): void {
if (key in this._resultTimeouts) {
clearTimeout(this._resultTimeouts[key]);
delete this._resultTimeouts[key];
}
}
private _clearAllResultTimeouts(): void {
Object.values(this._resultTimeouts).forEach((timeout) =>
clearTimeout(timeout)
);
this._resultTimeouts = {};
}
private _setResult(key: string, value: string | undefined) {
this._clearResultTimeout(key);
if (value === undefined) {
delete this._results[key];
this.requestUpdate();
} else {
this._results = { ...this._results, [key]: { status: value } };
if (value === "accepted") {
// Show the success message briefly, then clear it
this._resultTimeouts[key] = window.setTimeout(() => {
this._setResult(key, undefined);
}, 2000);
}
}
}
private _setError(key: string, message: string) {
this._clearResultTimeout(key);
const errorParam = { status: "error", error: message };
this._results = { ...this._results, [key]: errorParam };
}
@@ -634,12 +738,17 @@ class ZWaveJSNodeConfig extends LitElement {
return;
}
let capabilities: ZWaveJSNodeCapabilities | undefined;
[this._nodeMetadata, this._config, capabilities] = await Promise.all([
const [nodeMetadata, config, capabilities] = await Promise.all([
fetchZwaveNodeMetadata(this.hass, device.id),
fetchZwaveNodeConfigParameters(this.hass, device.id),
fetchZwaveNodeCapabilities(this.hass, device.id),
]);
if (device.id !== this.deviceId) {
// The user navigated to another node while the data was loading
return;
}
this._nodeMetadata = nodeMetadata;
this._config = config;
this._canResetAll =
capabilities &&
Object.values(capabilities).some((endpoint) =>
@@ -397,8 +397,20 @@ class HaConfigHttpForm extends LitElement {
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
// Drop empty entries from multi-value fields, and omit the field entirely
// once it is empty so the backend applies its default. Otherwise a cleared
// "IP address to bind to" would submit [""] / [], which binds to nothing.
const config = Object.fromEntries(
Object.entries(this._config).map(([key, value]) => {
if (Array.isArray(value)) {
const filtered = value.filter(Boolean);
return [key, filtered.length ? filtered : undefined];
}
return [key, value];
})
) as HttpConfig;
try {
const result = await saveHttpConfig(this.hass, this._config);
const result = await saveHttpConfig(this.hass, config);
if (!result.restart) {
this._showNoChanges = true;
}
+356 -214
View File
@@ -1,17 +1,27 @@
import {
mdiRestore,
mdiTrashCanOutline,
mdiViewSplitHorizontal,
mdiViewSplitVertical,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-code-editor";
import "../../../../components/ha-expansion-panel";
import type { HaIconButtonToolbarItem } from "../../../../components/ha-icon-button-toolbar";
import "../../../../components/ha-label";
import "../../../../components/ha-spinner";
import "../../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../../components/ha-split-panel";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tip";
import type { RenderTemplateResult } from "../../../../data/ws-templates";
import { subscribeRenderTemplate } from "../../../../data/ws-templates";
@@ -50,11 +60,18 @@ const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_functions", path: "/template-functions/" },
];
const STORAGE_KEY_TEMPLATE = "panel-dev-template-template";
const STORAGE_KEY_SPLIT_POSITION = "panel-dev-template-split-position";
const STORAGE_KEY_SPLIT_ORIENTATION = "panel-dev-template-split-orientation";
const DEFAULT_SPLIT_POSITION = 50;
type SplitOrientation = "horizontal" | "vertical";
@customElement("tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _error?: string;
@@ -66,9 +83,9 @@ class HaPanelDevTemplate extends LitElement {
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
@state() private _descriptionExpanded = false;
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("ha-tip") private _editorTip?: HTMLElement;
@state() private _splitOrientation: SplitOrientation = "horizontal";
private _template = "";
@@ -78,8 +95,6 @@ class HaPanelDevTemplate extends LitElement {
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
super.connectedCallback();
if (this._template && !this._unsubRenderTemplate) {
@@ -90,18 +105,25 @@ class HaPanelDevTemplate extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeTemplate();
this._tipResizeObserver?.disconnect();
this._tipResizeObserver = undefined;
}
protected firstUpdated() {
if (localStorage && localStorage["panel-dev-template-template"]) {
this._template = localStorage["panel-dev-template-template"];
if (localStorage && localStorage[STORAGE_KEY_TEMPLATE]) {
this._template = localStorage[STORAGE_KEY_TEMPLATE];
} else {
this._template = DEMO_TEMPLATE;
}
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed >= 0 && parsed <= 100) {
this._splitPosition = parsed;
}
}
if (localStorage?.[STORAGE_KEY_SPLIT_ORIENTATION] === "vertical") {
this._splitOrientation = "vertical";
}
this._subscribeTemplate();
this._observeTipHeight();
this._inited = true;
}
@@ -114,15 +136,20 @@ class HaPanelDevTemplate extends LitElement {
: "dict"
: type;
const editorCard = this._renderEditorCard();
const resultCard = this._renderResultCard(type, resultType);
// On narrow viewports side-by-side is too cramped, so force the (still
// resizable) stacked layout and hide the orientation toggle.
const orientation = this.narrow ? "vertical" : this._splitOrientation;
return html`
<div class="content">
<div class="about">
<ha-expansion-panel
.header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.about"
)}
outlined
.expanded=${this._descriptionExpanded}
@expanded-changed=${this._expandedChanged}
>
<div class="description">
<p>
@@ -164,92 +191,159 @@ class HaPanelDevTemplate extends LitElement {
</div>
</ha-expansion-panel>
</div>
<div
class="content ${classMap({
layout: !this.narrow,
horizontal: !this.narrow,
})}"
style="--description-expanded: ${this._descriptionExpanded ? 1 : 0}"
>
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
<div class="card-actions">
<ha-button appearance="plain" @click=${this._restoreDemo}>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.reset"
)}
</ha-button>
<ha-button appearance="plain" @click=${this._clear}>
${this.hass.localize("ui.common.clear")}
</ha-button>
</div>
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
</ha-card>
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
this._templateResult
? html`<pre
class="rendered ${classMap({
[resultType]: resultType,
})}"
>
<ha-split-panel
class="panes ${orientation === "vertical" ? "vertical" : ""}"
.position=${this._splitPosition}
.orientation=${orientation}
snap="50%"
@wa-reposition=${this._splitRepositioned}
>
<div slot="start" class="pane">${editorCard}</div>
<div slot="end" class="pane">${resultCard}</div>
${this.narrow ? nothing : this._renderOrientationToggle()}
</ha-split-panel>
`;
}
private _renderOrientationToggle() {
const label = this.hass.localize(
this._splitOrientation === "vertical"
? "ui.panel.config.tools.tabs.templates.layout_side_by_side"
: "ui.panel.config.tools.tabs.templates.layout_stacked"
);
return html`
<button
type="button"
slot="divider"
class="divider-toggle"
.title=${label}
aria-label=${label}
@mousedown=${stopPropagation}
@touchstart=${stopPropagation}
@click=${this._toggleOrientation}
>
<ha-svg-icon
.path=${
this._splitOrientation === "vertical"
? mdiViewSplitVertical
: mdiViewSplitHorizontal
}
></ha-svg-icon>
</button>
`;
}
// Reset/clear live in the editor toolbar next to the built-in undo/redo,
// copy, search and fullscreen buttons; the trailing divider separates them.
private _editorToolbarItems = memoizeOne(
(
localize: HomeAssistant["localize"]
): (HaIconButtonToolbarItem | string)[] => [
{
id: "restore-demo",
label: localize("ui.panel.config.tools.tabs.templates.reset"),
path: mdiRestore,
action: () => this._restoreDemo(),
},
{
id: "clear",
label: localize("ui.common.clear"),
path: mdiTrashCanOutline,
action: () => this._clear(),
},
"divider",
]
);
private _renderEditorCard() {
return html`
<ha-card
class="edit-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.editor"
)}
>
<div class="card-content">
<ha-code-editor
mode="jinja2"
.value=${this._template}
.error=${this._error}
.toolbarItems=${this._editorToolbarItems(this.hass.localize)}
autofocus
autocomplete-entities
autocomplete-icons
@value-changed=${this._templateChanged}
dir="ltr"
></ha-code-editor>
</div>
${
this.narrow
? nothing
: html`
<ha-tip>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.keyboard_tip",
{
autocomplete: html`<kbd>Ctrl</kbd>+<kbd>Space</kbd>`,
}
)}
</ha-tip>
`
}
</ha-card>
`;
}
private _renderResultCard(type: string, resultType: string) {
const showEmptyState =
!this._error && !this._rendering && !this._template?.trim();
return html`
<ha-card
class="render-pane"
header=${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result"
)}
>
<div class="card-content ha-scrollbar">
${
this._rendering
? html`<ha-spinner
class="render-spinner"
size="small"
></ha-spinner>`
: ""
}
${
this._error
? html`<ha-alert
alert-type=${this._errorLevel?.toLowerCase() || "error"}
>${this._error}</ha-alert
>`
: nothing
}
${
showEmptyState
? html`<div class="empty">
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_placeholder"
)}
</div>`
: this._templateResult
? html`
<ha-label dense>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</ha-label>
<pre class="rendered">
${
type === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result
}</pre>
<p>
${this.hass.localize(
"ui.panel.config.tools.tabs.templates.result_type"
)}:
${resultType}
</p>
${
this._templateResult.listeners.time
? html`
@@ -316,109 +410,179 @@ ${
)}
</span>`
: nothing
}`
}
`
: nothing
}
</div>
</ha-card>
</div>
}
</div>
</ha-card>
`;
}
private _observeTipHeight() {
if (!this._editorTip || this._tipResizeObserver) {
return;
}
this._tipResizeObserver = new ResizeObserver((entries) => {
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ??
entries[0]?.contentRect.height;
if (height) {
this.style.setProperty("--tip-height", `${height}px`);
}
});
this._tipResizeObserver.observe(this._editorTip);
private _splitRepositioned(ev: Event) {
this._splitPosition = (ev.target as HaSplitPanel).position;
this._storeSplitPosition();
}
private _expandedChanged(
ev: HASSDomEvent<HASSDomEvents["expanded-changed"]>
) {
this._descriptionExpanded = ev.detail.expanded;
private _toggleOrientation() {
this._splitOrientation =
this._splitOrientation === "vertical" ? "horizontal" : "vertical";
if (this._inited) {
localStorage[STORAGE_KEY_SPLIT_ORIENTATION] = this._splitOrientation;
}
}
private _storeSplitPosition = debounce(
() => {
if (!this._inited) {
return;
}
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
:host {
user-select: none;
display: flex;
flex-direction: column;
height: 100%;
}
.content {
gap: var(--ha-space-4);
.about {
flex: none;
padding: var(--ha-space-4);
}
.content:has(ha-expansion-panel) {
padding-bottom: 0;
}
.content.horizontal {
--panel-header-height: calc(
var(--header-height) + 1em * 2 + var(--ha-line-height-normal) *
var(--ha-font-size-m) + 1px + 2px
);
--description-pane-height: calc(
var(--ha-space-4) + 48px +
(
var(--ha-line-height-normal) * var(--ha-font-size-m) * 3 +
var(--ha-space-1) * 2
) *
var(--description-expanded) + var(--ha-card-border-width, 1px) * 2
);
--card-header-height: calc(
var(--ha-space-3) + var(--ha-space-4) +
var(--ha-line-height-expanded) *
var(--ha-card-header-font-size, var(--ha-font-size-2xl))
);
--card-actions-height: calc(1px + var(--ha-space-2) * 2 + 40px);
--tip-height-minimal: calc(
var(--mdc-icon-size, 24px) + var(--ha-space-4)
);
--edit-pane-height: calc(
100vh - var(--panel-header-height) - var(
--description-pane-height
) - var(--ha-space-4) *
2
);
--code-mirror-max-height: calc(
var(--edit-pane-height) - var(--card-header-height) +
var(--ha-space-2) - var(--card-actions-height) - var(
--tip-height,
var(--tip-height-minimal)
) - var(--ha-space-4) - var(--ha-card-border-width, 1px) *
2
);
.about a {
color: var(--primary-color);
}
.panes {
flex: 1;
min-height: 0;
box-sizing: border-box;
padding: var(--ha-space-4);
--ha-split-panel-min: 20%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
/* On wide viewports we slot our own handle (the orientation toggle)
into the divider, so hide the default grip. On narrow there is no
toggle, so keep the default grip as the resize affordance. */
:host(:not([narrow])) .panes {
--ha-split-panel-grip-display: none;
}
/* Orientation toggle that lives on the divider. Clicking it toggles
orientation; resizing is done by dragging the divider elsewhere. */
.divider-toggle {
position: relative;
z-index: 1;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 24px;
height: 24px;
margin: 0;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
background-color: var(--card-background-color);
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 16px;
transition:
color var(--ha-animation-duration-fast) ease-out,
border-color var(--ha-animation-duration-fast) ease-out;
}
@media (hover: hover) {
.divider-toggle:hover {
color: var(--primary-color);
border-color: var(--primary-color);
}
}
.divider-toggle:focus-visible {
outline: none;
color: var(--primary-color);
border-color: var(--primary-color);
}
.pane {
display: flex;
min-width: 0;
height: 100%;
box-sizing: border-box;
contain: size;
}
.pane[slot="start"] {
padding-inline-end: var(--ha-space-4);
}
.pane[slot="end"] {
padding-inline-start: var(--ha-space-4);
}
.panes.vertical .pane[slot="start"] {
padding-inline-end: 0;
padding-block-end: var(--ha-space-4);
}
.panes.vertical .pane[slot="end"] {
padding-inline-start: 0;
padding-block-start: var(--ha-space-4);
}
.pane ha-card {
flex: 1;
min-width: 0;
}
ha-card {
margin-bottom: var(--ha-space-4);
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
.edit-pane .card-content {
flex: 1;
min-height: 0;
display: flex;
}
.edit-pane ha-code-editor {
flex: 1;
min-height: 0;
width: 100%;
--code-mirror-height: 100%;
}
.render-pane .card-content {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.edit-pane {
direction: var(--direction);
}
.edit-pane a {
color: var(--primary-color);
}
.content.horizontal > * {
width: 50%;
margin-bottom: 0px;
}
.render-spinner {
position: absolute;
top: var(--ha-space-2);
@@ -428,10 +592,24 @@ ${
}
ha-alert {
margin-bottom: var(--ha-space-2);
display: block;
}
.render-pane ha-label {
align-self: flex-start;
}
.empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 120px;
padding: var(--ha-space-4);
text-align: center;
color: var(--secondary-text-color);
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
@@ -439,6 +617,7 @@ ${
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-2);
margin-top: 0;
margin-bottom: 0;
@@ -447,7 +626,7 @@ ${
p,
ul {
margin-block-end: 0;
margin-block: 0;
}
.description > p {
margin-block-start: 0;
@@ -468,26 +647,6 @@ ${
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
}
.content.horizontal .render-pane .card-content {
overflow: auto;
max-height: calc(
var(--code-mirror-max-height) +
47px - var(--ha-card-border-radius, var(--ha-border-radius-lg))
);
}
.content.horizontal .render-pane {
overflow: hidden;
padding-bottom: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.all_listeners {
color: var(--warning-color);
}
@@ -507,19 +666,6 @@ ${
background-color: var(--secondary-background-color);
white-space: nowrap;
}
@media all and (max-width: 870px) {
.content ha-card {
max-width: 100%;
}
}
.card-actions {
display: flex;
}
.card-actions > ha-button:last-child {
margin-inline-start: auto;
}
`,
];
}
@@ -615,7 +761,7 @@ ${
if (!this._inited) {
return;
}
localStorage["panel-dev-template-template"] = this._template;
localStorage[STORAGE_KEY_TEMPLATE] = this._template;
}
private async _restoreDemo() {
@@ -631,7 +777,7 @@ ${
}
this._template = DEMO_TEMPLATE;
this._subscribeTemplate();
delete localStorage["panel-dev-template-template"];
delete localStorage[STORAGE_KEY_TEMPLATE];
}
private async _clear() {
@@ -647,12 +793,8 @@ ${
}
this._unsubscribeTemplate();
this._template = "";
// Reset to empty result. Setting to 'undefined' results in a different visual
// behaviour compared to manually emptying the template input box.
this._templateResult = {
result: "",
listeners: { all: false, entities: [], domains: [], time: false },
};
// An empty template shows the placeholder empty state.
this._templateResult = undefined;
}
}
+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>
+13
View File
@@ -130,6 +130,19 @@ class PanelHome extends LitElement {
}
private async _setup() {
void import("../lovelace/strategies/home/home-dashboard-strategy").catch(
() => undefined
);
void import("../lovelace/strategies/home/home-overview-view-strategy")
.then(({ preloadHomeEnergyPreferences }) => {
const path = this.route?.path?.split("/")[1];
return !path || path === "overview"
? preloadHomeEnergyPreferences(this.hass)
: undefined;
})
.catch(() => undefined);
void import("../lovelace/views/hui-sections-view").catch(() => undefined);
this._updateExtraActionItems();
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
-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,
@@ -620,6 +620,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
${
@@ -683,6 +686,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${Boolean(this._hasCardAction)}
@action=${this._handleAction}
>
@@ -699,6 +703,9 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
</ha-tile-icon>
<ha-tile-info
slot="info"
class=${ifDefined(
this._config.vertical && fixedInfoHeight ? "twoline" : undefined
)}
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
@@ -818,6 +825,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline {
--ha-tile-info-primary-line-clamp: 2;
--ha-tile-info-primary-line-height: var(--ha-space-4);
}
`,
];
}
@@ -40,6 +40,8 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: ShortcutCardConfig;
private _navInfo = new NavigationPathInfoController(this);
@@ -128,10 +130,14 @@ export class HuiShortcutCard extends LitElement implements LovelaceCard {
const style = color ? { "--tile-color": color } : {};
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
<ha-tile-container
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),
@@ -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>
`;
@@ -81,6 +81,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: TileCardConfig;
@state() private _featureContext: LovelaceCardFeatureContext = {};
@@ -288,11 +290,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const hasImage = Boolean(imageUrl);
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)} class=${classMap({ active })}>
<ha-tile-container
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.actionHandlerOptions=${{
hasHold: hasAction(this._config!.hold_action),
+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;
@@ -9,6 +9,7 @@ import {
} from "../../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../../components/ha-floor-icon";
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
import type { EnergyPreferences } from "../../../../data/energy";
import { getEnergyPreferences } from "../../../../data/energy";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
@@ -50,6 +51,26 @@ export interface HomeOverviewViewStrategyConfig {
shortcuts?: ShortcutItem[];
}
const energyPreferencesPromises = new WeakMap<
HomeAssistant["connection"],
Promise<EnergyPreferences | undefined>
>();
export const preloadHomeEnergyPreferences = (hass: HomeAssistant) => {
if (!isComponentLoaded(hass.config, "energy")) {
return Promise.resolve(undefined);
}
const existing = energyPreferencesPromises.get(hass.connection);
if (existing) {
return existing;
}
const request = getEnergyPreferences(hass).catch(() => undefined);
energyPreferencesPromises.set(hass.connection, request);
return request;
};
const computeAreaCard = (
areaId: string,
hass: HomeAssistant
@@ -305,10 +326,8 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
.filter(weatherFilter)
.sort()[0];
const energyPrefs = isComponentLoaded(hass.config, "energy")
? // It raises if not configured, just swallow that.
await getEnergyPreferences(hass).catch(() => undefined)
: undefined;
const energyPrefs = await preloadHomeEnergyPreferences(hass);
energyPreferencesPromises.delete(hass.connection);
const hasEnergy =
hass.panels.energy &&
+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,
+3
View File
@@ -8,6 +8,7 @@ import type { HASSDomEvent } from "../common/dom/fire_event";
import { subscribeThemePreferences, saveThemePreferences } from "../data/theme";
import { subscribeThemes } from "../data/ws-themes";
import type { Constructor, HomeAssistant } from "../types";
import { updateLaunchScreenLogo } from "../util/launch-screen";
import { storeState } from "../util/ha-pref-storage";
import type { HassBaseEl } from "./hass-base-mixin";
@@ -145,6 +146,8 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
true
);
updateLaunchScreenLogo(darkMode);
if (darkMode !== this.hass.themes.darkMode) {
this._updateHass({
themes: { ...this.hass.themes!, darkMode },
+10 -3
View File
@@ -75,7 +75,7 @@
},
"ui": {
"init": {
"loading": "Loading data",
"loading": "Loading...",
"migration": "Database upgrade is in progress, Home Assistant will not start until the upgrade is completed.\n\nThe upgrade may need a long time to complete, please be patient.",
"project_from": "A project from the",
"error": {
@@ -3955,6 +3955,9 @@
"about": "About templates",
"editor": "Template editor",
"result": "Result",
"result_placeholder": "Your template result will appear here.",
"layout_stacked": "Drag to resize, click for stacked view",
"layout_side_by_side": "Drag to resize, click for side-by-side view",
"reset": "Reset to demo template",
"confirm_reset": "Do you want to reset your current template back to the demo template?",
"confirm_clear": "Do you want to clear your current template?",
@@ -4211,7 +4214,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 +4283,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": {
+24 -18
View File
@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { render } from "lit";
import { parseAnimationDuration } from "../common/util/parse-animation-duration";
import { withViewTransition } from "../common/util/view-transition";
let removalInitiated = false;
/**
* Removes the launch screen with a fade-out view transition.
* Removes the launch screen with a CSS fade-out transition.
*
* @param instant - Removes the launch screen without animation. Used when the
* external app covers the frontend with its own splash screen until the
@@ -26,23 +25,16 @@ export const removeLaunchScreen = (instant = false): boolean => {
return true;
}
withViewTransition((viewTransitionAvailable) => {
if (viewTransitionAvailable) {
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
return;
}
launchScreenElement.classList.add("removing");
const durationFromCss = getComputedStyle(document.documentElement)
.getPropertyValue("--ha-animation-duration-normal")
.trim();
setTimeout(
() => {
launchScreenElement.parentElement?.removeChild(launchScreenElement);
},
parseAnimationDuration(durationFromCss || "250ms")
);
});
},
parseAnimationDuration(durationFromCss || "250ms")
);
return true;
};
@@ -57,6 +49,20 @@ export const renderLaunchScreenContent = (
updateLaunchScreenAttribution(attribution);
};
/**
* Switches the launch screen OHF logo to the variant matching the applied
* theme. The `<picture>` element initially picks a variant based on the system
* color scheme, which can differ from the theme the frontend ends up applying.
*/
export const updateLaunchScreenLogo = (darkMode: boolean) => {
const logoSourceElement = document.querySelector<HTMLSourceElement>(
"#ha-launch-screen .ohf-logo source"
);
if (logoSourceElement) {
logoSourceElement.media = darkMode ? "all" : "not all";
}
};
export const updateLaunchScreenAttribution = (attribution: string) => {
const attributionElement = document.getElementById(
"ha-launch-screen-attribution"
@@ -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();
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from "vitest";
import { html, nothing, render } from "lit";
import "../../src/components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
HaDataTable,
} from "../../src/components/data-table/ha-data-table";
const columns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
filled_template: { title: "Filled", template: () => html`filled` },
};
// The narrow row puts every non-main column on a secondary line, joined by dots.
const renderNarrowSecondary = (row: DataTableRowData) => {
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render((el as any)._renderRow(columns, true, row, 0), container);
return container.querySelector(".secondary")!.textContent!.trim();
};
describe("ha-data-table narrow secondary line", () => {
it("does not render separators for empty columns", () => {
expect(renderNarrowSecondary({ id: "1", name: "Test" })).toBe("filled");
});
it("separates only the columns that have a value", () => {
expect(
renderNarrowSecondary({ id: "1", name: "Test", area: "Kitchen" })
).toBe("Kitchen · filled");
});
it("renders a blank secondary line when all secondary columns are empty", () => {
const emptyColumns: DataTableColumnContainer = {
name: { title: "Name", main: true },
area: { title: "Area" },
category: { title: "Category" },
empty_template: { title: "Empty", template: () => nothing },
};
const el = document.createElement("ha-data-table") as HaDataTable;
const container = document.createElement("div");
render(
(el as any)._renderRow(emptyColumns, true, { id: "1", name: "Test" }, 0),
container
);
expect(container.querySelector(".secondary")!.textContent!.trim()).toBe("");
});
});
@@ -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);
});
});
});
+1 -96
View File
@@ -4,7 +4,7 @@
* Run with:
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import { test, expect } from "@playwright/test";
import {
appSidebar,
appSidebarConfig,
@@ -161,106 +161,11 @@ test.describe("Quick search", () => {
defineRouteSmokeTests(appRouteSmokeGroups);
const assertInitialReadiness = async (
page: Page,
options: {
path: string;
loadingSelector: string;
resolver:
"rejectMediaBrowse" | "resolveCalendarRegistry" | "resolveMediaBrowse";
readySelector: string;
}
) => {
await goToPanel(page, options.path);
const launchScreen = page.locator("#ha-launch-screen");
await expect(launchScreen).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(page.locator(options.loadingSelector)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
await page.evaluate((resolver) => window[resolver]?.(), options.resolver);
await expect(page.locator(options.readySelector)).toBeAttached({
timeout: PANEL_TIMEOUT,
});
await expect(launchScreen).not.toBeAttached({ timeout: QUICK_TIMEOUT });
};
test.describe("Initial readiness", () => {
test("keeps the launch screen until calendar content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
path: "/?scenario=delayed-calendar#/calendar",
loadingSelector: "ha-panel-calendar ha-spinner",
resolver: "resolveCalendarRegistry",
readySelector: "ha-full-calendar",
});
});
test("keeps the launch screen until media content renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
path: "/?scenario=delayed-media-browse#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "resolveMediaBrowse",
readySelector: "ha-media-player-browse .no-items",
});
});
test("keeps the launch screen until a media error renders", async ({
page,
}) => {
await assertInitialReadiness(page, {
path: "/?scenario=delayed-media-browse-error#/media-browser/browser",
loadingSelector: "ha-media-player-browse > ha-spinner",
resolver: "rejectMediaBrowse",
readySelector: "ha-media-player-browse ha-alert",
});
});
});
// ---------------------------------------------------------------------------
// Lovelace
// ---------------------------------------------------------------------------
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
-5
View File
@@ -92,11 +92,6 @@ declare global {
interface Window {
__assistRun?: unknown;
__mockHass: MockHomeAssistant;
rejectMediaBrowse?: () => void;
resolveCalendarRegistry?: () => void;
resolveGeneratedDashboard?: () => void;
resolveLovelaceConfig?: () => void;
resolveMediaBrowse?: () => void;
}
}
+1 -103
View File
@@ -1,10 +1,5 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
} from "../../../../../src/data/entity/entity_registry";
import type { LovelaceRawConfig } from "../../../../../src/data/lovelace/config/types";
import type { MediaPlayerItem } from "../../../../../src/data/media-player";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -129,98 +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;
};
const delayedCalendarScenario: Scenario = (hass) => {
addLaunchScreen();
let resolveRegistry: ((entries: EntityRegistryEntry[]) => void) | undefined;
const registryPromise = new Promise<EntityRegistryEntry[]>((resolve) => {
resolveRegistry = resolve;
});
window.resolveCalendarRegistry = () => resolveRegistry?.([]);
hass.mockWS("config/entity_registry/list", () => registryPromise);
};
const delayedMediaBrowseScenario: Scenario = (hass) => {
addLaunchScreen();
const root: MediaPlayerItem = {
title: "Media",
media_content_id: "media-source://media_source",
media_content_type: "app",
media_class: "directory",
can_play: false,
can_expand: true,
can_search: false,
children: [],
};
let resolveBrowse: ((item: MediaPlayerItem) => void) | undefined;
const browsePromise = new Promise<MediaPlayerItem>((resolve) => {
resolveBrowse = resolve;
});
window.resolveMediaBrowse = () => resolveBrowse?.(root);
hass.mockWS("media_source/browse_media", () => browsePromise);
};
const delayedMediaBrowseErrorScenario: Scenario = (hass) => {
addLaunchScreen();
let rejectBrowse:
((reason: { code: string; message: string }) => void) | undefined;
const browsePromise = new Promise<MediaPlayerItem>((_resolve, reject) => {
rejectBrowse = reject;
});
window.rejectMediaBrowse = () =>
rejectBrowse?.({ code: "unknown_error", message: "Browse failed" });
hass.mockWS("media_source/browse_media", () => browsePromise);
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -228,11 +131,6 @@ export const scenarios: Record<string, Scenario> = {
"non-admin": nonAdminScenario,
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"delayed-calendar": delayedCalendarScenario,
"delayed-generated-dashboard": delayedGeneratedDashboardScenario,
"delayed-media-browse": delayedMediaBrowseScenario,
"delayed-media-browse-error": delayedMediaBrowseErrorScenario,
"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__ = "";
+63 -74
View File
@@ -27,43 +27,28 @@ __metadata:
languageName: node
linkType: hard
"@asamuzakjp/css-color@npm:^5.1.11":
version: 5.1.11
resolution: "@asamuzakjp/css-color@npm:5.1.11"
"@asamuzakjp/css-color@npm:^6.0.5":
version: 6.0.5
resolution: "@asamuzakjp/css-color@npm:6.0.5"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@csstools/css-calc": "npm:^3.2.0"
"@csstools/css-color-parser": "npm:^4.1.0"
"@csstools/css-calc": "npm:^3.2.1"
"@csstools/css-color-parser": "npm:^4.1.9"
"@csstools/css-parser-algorithms": "npm:^4.0.0"
"@csstools/css-tokenizer": "npm:^4.0.0"
checksum: 10/2e337cc94b5a3f9741a27f92b4e4b7dc467a76b1dcf66c40e71808fed71695f10c8cf07c8b13313cbb637154314ca1d8626bb9a045fe94b404b242a390cf3bd3
lru-cache: "npm:^11.5.2"
checksum: 10/bd88a9a1d00711f5f63c4288a82489c33e28de43dee37f418cd30a42517d7b7e2040dc79fc223ececf290e7b3f12bbd2dd8ce7450f73b03bf0613df449b9563f
languageName: node
linkType: hard
"@asamuzakjp/dom-selector@npm:^7.1.1":
version: 7.1.1
resolution: "@asamuzakjp/dom-selector@npm:7.1.1"
"@asamuzakjp/dom-selector@npm:^8.2.5":
version: 8.3.0
resolution: "@asamuzakjp/dom-selector@npm:8.3.0"
dependencies:
"@asamuzakjp/generational-cache": "npm:^1.0.1"
"@asamuzakjp/nwsapi": "npm:^2.3.9"
bidi-js: "npm:^1.0.3"
css-tree: "npm:^3.2.1"
is-potential-custom-element-name: "npm:^1.0.1"
checksum: 10/49a065a64db5f53a3008c231d09606e4b67f509fa20148a67419451c2dc91a421202ed17bfc4bc679ad2f0432d7260720d602c1d5c9c5e165931fff5199c3f12
languageName: node
linkType: hard
"@asamuzakjp/generational-cache@npm:^1.0.1":
version: 1.0.1
resolution: "@asamuzakjp/generational-cache@npm:1.0.1"
checksum: 10/e1cf3f1916a334c6153f624982f0eb3d50fa3048435ea5c5b0f441f8f1ab74a0fe992dac214b612d22c0acafad3cd1a1f6b45d99c7b6e3b63cfdf7f6ca5fc144
languageName: node
linkType: hard
"@asamuzakjp/nwsapi@npm:^2.3.9":
version: 2.3.9
resolution: "@asamuzakjp/nwsapi@npm:2.3.9"
checksum: 10/95a6d1c102e1117fe818da087fcc5b914d23e0699855991bae50b891435dd1945ad7d384198f8bcf616207fd85b7ec32e3db6b96e9309d84c6903b8dc4151e34
lru-cache: "npm:^11.5.2"
checksum: 10/458632671954613e1bd653c9c9d2e8ded44701f19ca8a9641ec3fa0df9eb60089769e0c98dd7991afd53b86e40f8a720024653f451af71ee644d6cf3241c9ec0
languageName: node
linkType: hard
@@ -2482,26 +2467,26 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-calc@npm:^3.2.0, @csstools/css-calc@npm:^3.2.1":
version: 3.2.1
resolution: "@csstools/css-calc@npm:3.2.1"
"@csstools/css-calc@npm:^3.2.1, @csstools/css-calc@npm:^3.3.0":
version: 3.3.0
resolution: "@csstools/css-calc@npm:3.3.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/39042a9382cbd7c4fa241c1a6c10d64c23fa7653970fc11df532e9bc42a366a8b50c3ac3036bd5f2a77b94144e7f804f19d2b52800ad2f48bd9a3840377165d8
checksum: 10/46dde1c26c8a62d7b849a616a521cb97abc2d6c8c480f7be6f14c70fe60689a2a58c1f5f905aae237250ad27172968e10c70f6f83864c7dc48d8f8921f55f6a9
languageName: node
linkType: hard
"@csstools/css-color-parser@npm:^4.1.0":
version: 4.1.9
resolution: "@csstools/css-color-parser@npm:4.1.9"
"@csstools/css-color-parser@npm:^4.1.9":
version: 4.1.10
resolution: "@csstools/css-color-parser@npm:4.1.10"
dependencies:
"@csstools/color-helpers": "npm:^6.1.0"
"@csstools/css-calc": "npm:^3.2.1"
"@csstools/css-calc": "npm:^3.3.0"
peerDependencies:
"@csstools/css-parser-algorithms": ^4.0.0
"@csstools/css-tokenizer": ^4.0.0
checksum: 10/6369b601bcd3a8ce58dbcdc389a732b754c8f3fc35421b37169f6d4b07c7261f7b8c23e7d04e457da5e5e0bb082e680acb137d8c86fa1f7d6ff14ea873bf02a2
checksum: 10/92f5f590617a2cc9ff358b2d79716cbe15dd4cc7537cdc45ffc55878282677a9ef6c8bfb215a7dc0731f302c32bf1b0d1b7fc3c550147fd8a6d85f7c3cfb0cdd
languageName: node
linkType: hard
@@ -2514,15 +2499,15 @@ __metadata:
languageName: node
linkType: hard
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.3":
version: 1.1.6
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.6"
"@csstools/css-syntax-patches-for-csstree@npm:^1.1.6":
version: 1.1.7
resolution: "@csstools/css-syntax-patches-for-csstree@npm:1.1.7"
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
checksum: 10/8747268b42f1afbe450d38b7f388a4983c810883f8c0716fa24f5b1eef0f9cbaa3a0f4ea72d1e5cbd015dc4fd5af9cc96ade42792912ed2dcc1d4054de102163
checksum: 10/ba372ab41c351509d47e77f58eb56112fe6fd42e104b70c9c9c797faf4fbb3e837a5df5c9d7e3464b04607ee42e91701e34f935aeec8abb94f24be67cfa1b707
languageName: node
linkType: hard
@@ -2726,7 +2711,7 @@ __metadata:
languageName: node
linkType: hard
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.0, @exodus/bytes@npm:^1.6.0":
"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.15.1, @exodus/bytes@npm:^1.6.0":
version: 1.15.1
resolution: "@exodus/bytes@npm:1.15.1"
peerDependencies:
@@ -9675,10 +9660,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.7.0":
version: 17.7.0
resolution: "globals@npm:17.7.0"
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
"globals@npm:17.8.0":
version: 17.8.0
resolution: "globals@npm:17.8.0"
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
languageName: node
linkType: hard
@@ -10011,7 +9996,7 @@ __metadata:
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.7.0"
globals: "npm:17.8.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
@@ -10024,7 +10009,7 @@ __metadata:
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.12"
js-yaml: "npm:5.2.2"
jsdom: "npm:29.1.1"
jsdom: "npm:30.0.0"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
@@ -10979,37 +10964,37 @@ __metadata:
languageName: node
linkType: hard
"jsdom@npm:29.1.1":
version: 29.1.1
resolution: "jsdom@npm:29.1.1"
"jsdom@npm:30.0.0":
version: 30.0.0
resolution: "jsdom@npm:30.0.0"
dependencies:
"@asamuzakjp/css-color": "npm:^5.1.11"
"@asamuzakjp/dom-selector": "npm:^7.1.1"
"@asamuzakjp/css-color": "npm:^6.0.5"
"@asamuzakjp/dom-selector": "npm:^8.2.5"
"@bramus/specificity": "npm:^2.4.2"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.3"
"@exodus/bytes": "npm:^1.15.0"
"@csstools/css-syntax-patches-for-csstree": "npm:^1.1.6"
"@exodus/bytes": "npm:^1.15.1"
css-tree: "npm:^3.2.1"
data-urls: "npm:^7.0.0"
decimal.js: "npm:^10.6.0"
html-encoding-sniffer: "npm:^6.0.0"
is-potential-custom-element-name: "npm:^1.0.1"
lru-cache: "npm:^11.3.5"
lru-cache: "npm:^11.5.2"
parse5: "npm:^8.0.1"
saxes: "npm:^6.0.0"
symbol-tree: "npm:^3.2.4"
tough-cookie: "npm:^6.0.1"
undici: "npm:^7.25.0"
tough-cookie: "npm:^6.0.2"
undici: "npm:^8.7.0"
w3c-xmlserializer: "npm:^5.0.0"
webidl-conversions: "npm:^8.0.1"
whatwg-mimetype: "npm:^5.0.0"
whatwg-url: "npm:^16.0.1"
whatwg-url: "npm:^17.1.0"
xml-name-validator: "npm:^5.0.0"
peerDependencies:
canvas: ^3.0.0
canvas: ^3.2.3
peerDependenciesMeta:
canvas:
optional: true
checksum: 10/344aed7f91839b6c7d1b40778c5542d6ded7d42d88e1b787e10bf12d4ccd65464a5f23f774eb84350885c75a48efc99f6972adbb94dffe324a1b065d3650843c
checksum: 10/dc73c071e67224465018733525047d05772667bd8e00a3703a6f6515238c027b6ca768bf86874a53d512fd0bb962d72cc6a6c31c1bc38e52318f9d412ad0bb90
languageName: node
linkType: hard
@@ -11595,7 +11580,7 @@ __metadata:
languageName: node
linkType: hard
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.3.5":
"lru-cache@npm:^11.0.0, lru-cache@npm:^11.1.0, lru-cache@npm:^11.2.1, lru-cache@npm:^11.5.2":
version: 11.5.2
resolution: "lru-cache@npm:11.5.2"
checksum: 10/122789c66605ddf29df58ff279e9e2c9499499d0b80b895ec524496f14bcde045d1582e3e38008f3457226d98067556719573b352119d6be8bdb1f8849f85681
@@ -14745,7 +14730,7 @@ __metadata:
languageName: node
linkType: hard
"tough-cookie@npm:^6.0.1":
"tough-cookie@npm:^6.0.2":
version: 6.0.2
resolution: "tough-cookie@npm:6.0.2"
dependencies:
@@ -15071,17 +15056,10 @@ __metadata:
languageName: node
linkType: hard
"undici@npm:^7.25.0":
version: 7.28.0
resolution: "undici@npm:7.28.0"
checksum: 10/154423b280d623278a61decb437f8a7e581fb18b8c95556ef956b32a58cd668eadbb812d28e20678cb2dc545a566f35a3afc0962307ca801da30f4741117986d
languageName: node
linkType: hard
"undici@npm:^8.4.1":
version: 8.7.0
resolution: "undici@npm:8.7.0"
checksum: 10/e9644903bda97f825228b4c7bee95b3586609f94df685b598c32bdaf3ac795928cbd25b0ed2690c3ea0b5abd324f5a9641b63b6c81c9b19f3ef4d5b3e402a48c
"undici@npm:^8.4.1, undici@npm:^8.7.0":
version: 8.9.0
resolution: "undici@npm:8.9.0"
checksum: 10/dfad3e233087eafdf1d361acd17c4d45a9590d5db9400458f1c03f47d8f1ef68647e2109ebb982c862e50c227093abd2d5f44b154904fc0b3d22576b6e95cfe7
languageName: node
linkType: hard
@@ -15710,7 +15688,7 @@ __metadata:
languageName: node
linkType: hard
"whatwg-url@npm:^16.0.0, whatwg-url@npm:^16.0.1":
"whatwg-url@npm:^16.0.0":
version: 16.0.1
resolution: "whatwg-url@npm:16.0.1"
dependencies:
@@ -15721,6 +15699,17 @@ __metadata:
languageName: node
linkType: hard
"whatwg-url@npm:^17.1.0":
version: 17.1.0
resolution: "whatwg-url@npm:17.1.0"
dependencies:
"@exodus/bytes": "npm:^1.15.1"
tr46: "npm:^6.0.0"
webidl-conversions: "npm:^8.0.1"
checksum: 10/52052ba12a63e7665ee49d84d251a3d4776be4777fb259155ea3ca9bc49cdbbd18cbb5fce50f187e8450ce81d7abbbc4c39cfb15207c6d11d5c2539f9b87d426
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"