Compare commits

..

82 Commits

Author SHA1 Message Date
Bram Kragten 1454f4d081 Bumped version to 20260729.3 2026-07-31 16:44:13 +02:00
Paul Bottein 1a47c326b2 Fix tile name truncated too early (#53416) 2026-07-31 16:43:50 +02:00
Aidan Timson d8e827e08b Fix profile theme link colour (#53415) 2026-07-31 16:43:49 +02:00
John G. 301c907fa9 Fix typo in Google Home Matter pairing instructions (#53413)
Corrected a typo in the instructions for pairing a Matter device from Google Home with a pairing code.
2026-07-31 16:43:48 +02:00
karwosts 1ef3bc94d0 Add a few missing logbook csv fields (#53412) 2026-07-31 16:43:47 +02:00
Bram Kragten ba0367be2f use defaults provided by core for ports in strings, add url to new lo… (#53407)
* use defaults provided by core for ports in strings, add url to new location after restart

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-31 16:43:46 +02:00
Bram Kragten c186a31056 Prevent http confirm dialog from getting closed (#53406) 2026-07-31 16:43:45 +02:00
Paul Bottein bf8c92c95e Use backend slugify for the entity ID format preview (#53394)
* Use backend slugify for the entity ID format preview

* Update src/panels/config/core/ha-config-entity-id-format.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Use top-level slugify websocket command

* Fix formatting

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-31 16:43:44 +02:00
Bram Kragten 9743117abf Bumped version to 20260729.2 2026-07-30 23:27:41 +02:00
Petar Petrov 5e012973b2 Reject non-http URLs from integrations before using them as links (#53379) 2026-07-30 23:27:10 +02:00
Petar Petrov ea659f1b33 Show which energy power statistic is missing (#53404) 2026-07-30 23:25:16 +02:00
Marcel van der Veldt 539803cb5b Search a media player's own library from the media browser (#53402)
* Search a media player's own library from the media browser

The media browser search only ever asked the media sources, so searching
inside a media player's own library (Music Assistant, Sonos, Squeezebox,
Jellyfin) failed. Ask the entity instead when the current item belongs to
it, the same way browsing already does.

* Update src/data/media_source.ts

* Remove blank line in media_source.ts

Removed unnecessary blank line at the top of media_source.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-30 23:25:15 +02:00
karwosts 944d3332d1 Fix schedule editor dirty tracking (#53401) 2026-07-30 23:25:14 +02:00
Petar Petrov 4a267d160f Fix numeric input feature editor showing the wrong default style (#53398) 2026-07-30 23:25:13 +02:00
Aidan Timson 7edb9f8164 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 23:25:12 +02:00
Bram Kragten ff814167c6 Bumped version to 20260729.1 2026-07-30 10:41:32 +02:00
Paul Bottein d08d8dde64 Show integration logo and name in the replace device dialog (#53384) 2026-07-30 10:41:12 +02:00
Bram Kragten 5da441ceed Strip empty strings from http config (#53377) 2026-07-30 10:41:11 +02:00
Paul Bottein 22e646a68a 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:41:11 +02:00
Maarten Lakerveld d0ab20479f 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:41:10 +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 52f2b5abed Add link on the device page to shared hardware devices (#53363) 2026-07-29 09:37:09 +02:00
Petar Petrov e658113ef4 Fix gauge value missing when the card is revealed by a visibility condition (#53321)
* Fix gauge value text missing when revealed by a visibility condition

* Use a resize controller to retry the measurement

* Give the gauge host a block display so its size is observed
2026-07-29 07:10:32 +02:00
Maarten Lakerveld c35e316ef7 Conditions/triggers/actions use the same logic for automation picker sorting (#53136)
* Conditions/triggers/actions use the same logic for automation picker sorting

* Move all Integrations to a separate integrations group.
2026-07-29 06:54:12 +02:00
Paul Bottein 946c40d8a3 Add entity ID format setting for new entities (#53268) 2026-07-28 22:03:59 +02:00
Paulus Schoutsen 7ca7c08090 Add horizontal padding to vertical tile content (#53277)
* Add horizontal padding to vertical tile content

The vertical tile layout overrode the content padding to 10px 0,
removing the horizontal padding entirely. This allowed the tile name
in vertical cards (e.g. area cards on the default dashboard) to
truncate flush against the card edge. Keep the same 10px side padding
the horizontal layout uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Reduce vertical tile side padding to 4px

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Use 2px side padding for vertical tile content

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011ciaPX4SwWZp1mB2mtvbqd

* Apply suggestion from @piitaya

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-07-28 18:38:09 +00:00
Aidan Timson 73865161e7 Move demo guidance into a skill (#53355)
Move demo agent guidance into a skill
2026-07-28 20:29:09 +02:00
Petar Petrov f9d1059ff6 Fire haptic feedback when pressing the button card feature (#53361) 2026-07-28 18:41:34 +02:00
Krisjanis Lejejs 691e77f948 Update cloud account page thank you text (#53354) 2026-07-28 15:54:11 +01:00
Bram Kragten 6667d0e57e Surface replaced composite devices in device and target pickers (#53169)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:59:17 +02: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
130 changed files with 4911 additions and 1542 deletions
+65
View File
@@ -0,0 +1,65 @@
---
name: ha-frontend-demo
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
---
# HA Frontend Demo
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
## Purpose
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running The Demo
Run commands from the repository root:
```bash
yarn dev:demo # Development server on http://localhost:8090
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
```
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
## Opening A Specific Demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
## Opening A Specific Page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```text
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
For example, the water tab of the energy dashboard of the second demo is:
```text
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
## Verification
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
+1 -2
View File
@@ -2,8 +2,6 @@
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
```bash
@@ -47,6 +45,7 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
## Pull Requests
+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(
-56
View File
@@ -1,56 +0,0 @@
# Demo Agent Instructions
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running the demo
Run from the repository root:
```bash
yarn dev:demo # dev server on http://localhost:8090
yarn dev:demo --background # detached; also supports --status/--stop/--logs
```
## Opening a specific demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
## Opening a specific page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
Example — the water tab of the energy dashboard of the second demo:
```
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
## The gallery imports these stubs too
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo.
```bash
grep -rn "stubs/<name>" demo/src gallery/src
```
The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page.
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+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;
@@ -0,0 +1,23 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -0,0 +1,318 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Provide the demo areas and link the demo entities to the split devices.
// Set them directly via updateHass (typed against the real registry types)
// instead of the area stub, whose demo-specific type differs.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260729.3"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-9
View File
@@ -1,6 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -28,13 +26,6 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
return false;
}
// Tilt-only covers toggle via toggle_cover_tilt, which requires both tilt features
if (domain === "cover" && isTiltOnly(stateObj)) {
return [CoverEntityFeature.OPEN_TILT, CoverEntityFeature.CLOSE_TILT].every(
(f) => supportsFeature(stateObj, f)
);
}
if (
domain in SPECIAL_TOGGLE_ACTIONS &&
SPECIAL_TOGGLE_ACTIONS[domain].feature
+1 -11
View File
@@ -1,5 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
@@ -65,15 +63,7 @@ export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
// This function assumes that the passed domain can toggle, it may otherwise
// return a service that does not exist.
export const getToggleAction = (
domain: string,
onOff: boolean,
stateObj?: HassEntity
): string => {
// Tilt-only covers don't support open_cover/close_cover
if (domain === "cover" && stateObj && isTiltOnly(stateObj)) {
return onOff ? "open_cover_tilt" : "close_cover_tilt";
}
export const getToggleAction = (domain: string, onOff: boolean): string => {
return (
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
+10 -35
View File
@@ -1,21 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
import { getToggleAction } from "./get_toggle_action";
import { supportsFeature } from "./supports-feature";
// Tilt-only covers can only stop their tilt, and may not support it at all
const getStopAction = (stateObj: HassEntity): string | undefined => {
if (!isTiltOnly(stateObj)) {
return "stop_cover";
}
return supportsFeature(stateObj, CoverEntityFeature.STOP_TILT)
? "stop_cover_tilt"
: undefined;
};
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
@@ -71,29 +58,17 @@ export const toggleGroupEntities = (
const isOn = state === "on" || state === "open";
// If the cover is opening or closing, we toggle it to stop it
const stopping =
domain === "cover" && (state === "opening" || state === "closing");
// The service can differ per entity, e.g. for tilt-only covers,
// so group the entities by the service they need
const entityIdsByService: Record<string, string[]> = {};
states.forEach((stateObj) => {
const service = stopping
? getStopAction(stateObj)
: getToggleAction(domain, !isOn, stateObj);
if (!service) {
return;
let service = getToggleAction(domain, !isOn);
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
}
if (!(service in entityIdsByService)) {
entityIdsByService[service] = [];
}
entityIdsByService[service].push(stateObj.entity_id);
});
}
Object.entries(entityIdsByService).forEach(([service, entityIds]) => {
hass.callService(domain, service, {
entity_id: entityIds,
});
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
hass.callService(domain, service, {
entity_id: entitiesIds,
});
};
@@ -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;
};
+52
View File
@@ -0,0 +1,52 @@
import { sanitizeNavigationPath } from "./sanitize-navigation-path";
const HOME_ASSISTANT_SCHEME = "homeassistant://";
/**
* Returns the URL if it is safe to use as a link target, `undefined` otherwise.
* Only absolute `http:` and `https:` URLs pass, the same check
* `ha-attribute-value` already applies to attribute links.
*
* Use for every URL that reaches the frontend as data — from an integration
* manifest, an add-on, a config flow or an entity attribute — so that a
* `javascript:` URI can never become a clickable link.
*/
export const sanitizeHttpUrl = (
url: string | null | undefined
): string | undefined => {
if (!url) {
return undefined;
}
try {
const { protocol } = new URL(url);
return protocol === "http:" || protocol === "https:" ? url : undefined;
} catch (_err) {
return undefined;
}
};
/** Whether the URL is a `homeassistant://` deep link into the frontend. */
export const isHomeAssistantUrl = (url: string | null | undefined): boolean =>
!!url?.startsWith(HOME_ASSISTANT_SCHEME);
/**
* Turns a `homeassistant://` deep link into an in-app path, or returns
* `undefined` when it does not point inside the frontend. Rewriting the scheme
* on its own is not enough: `homeassistant:///example.com` would become
* `//example.com`, which resolves to another origin.
*/
export const homeAssistantUrlToPath = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url)
? sanitizeNavigationPath(`/${url!.slice(HOME_ASSISTANT_SCHEME.length)}`)
: undefined;
/**
* Sanitizes a URL that may be either an external link or a `homeassistant://`
* deep link, returning something safe to bind to an `href`.
*/
export const sanitizeLinkUrl = (
url: string | null | undefined
): string | undefined =>
isHomeAssistantUrl(url) ? homeAssistantUrlToPath(url) : sanitizeHttpUrl(url);
@@ -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;
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
:host([appearance="brand"]) ha-svg-icon {
.progress ha-svg-icon {
color: var(--white-color);
}
`;
+1
View File
@@ -27,6 +27,7 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
@@ -0,0 +1,190 @@
import { mdiDevices } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
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";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@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 {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
private _items = memoizeOne(
(
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
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,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
);
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._items(
this._params.candidates,
this._params.primaryId,
this.hass.devices,
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}>
${
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>
${
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`;
})}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -201,12 +202,22 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the value.
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+244 -46
View File
@@ -1,6 +1,7 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -13,12 +14,20 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -95,6 +104,10 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -123,6 +136,29 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -130,6 +166,43 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -144,49 +217,66 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -235,7 +325,39 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const valueRenderer = this._valueRenderer(this._configEntryLookup);
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
return html`
<ha-generic-picker
@@ -256,15 +378,84 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -281,6 +472,13 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -0,0 +1,22 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
+1 -1
View File
@@ -128,7 +128,7 @@ export class HaEntityToggle extends LitElement {
const serviceDomain =
stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn, this.stateObj);
const service = getToggleAction(stateDomain, turnOn);
const currentState = this.stateObj;
@@ -85,6 +85,10 @@ export class HaStatisticPicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean }) public invalid = false;
@property() public placeholder?: string;
@property({ attribute: "statistic-types" })
@@ -526,6 +530,9 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
.required=${this.required}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
use-top-label
.placeholder=${placeholder}
+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
? [
{
+37 -10
View File
@@ -1,6 +1,7 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
@@ -46,15 +47,32 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
@query(".text") private _textSvg?: SVGSVGElement;
@query(".value-text") private _valueText?: SVGTextElement;
private _sortedLevels?: LevelDefinition[];
private _rescaleOnConnect = false;
// Set when the value text could not be measured because we have no layout box
// yet, either disconnected or inside a hidden container.
private _rescalePending = false;
// Measure again once we get a layout box, e.g. when a section hidden by a
// visibility condition is revealed. Nothing else re-renders the gauge then.
// @ts-ignore side-effect-only controller, its value is never read
private _resizeController = new ResizeController(this, {
skipInitial: true,
callback: (entries) => {
if (this._rescalePending && entries[0]?.contentRect.width) {
this._rescaleSvg();
}
},
});
public connectedCallback(): void {
super.connectedCallback();
if (this._rescaleOnConnect) {
if (this._rescalePending && this.hasUpdated) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
@@ -221,15 +239,22 @@ export class HaGauge extends LitElement {
// fit the text
// That way it will auto-scale correctly
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
if (!this._textSvg || !this._valueText || !this.isConnected) {
this._rescalePending = true;
return;
}
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
const box = this._valueText.getBBox();
// An empty box means we have no layout, so keep the last known good viewBox
// and retry later. A viewBox with a 0 width or height would hide the label.
if (!box.width || !box.height) {
this._rescalePending = true;
return;
}
this._rescalePending = false;
this._textSvg.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
@@ -248,6 +273,8 @@ export class HaGauge extends LitElement {
static styles = css`
:host {
/* a non replaced inline element never reports a size to a resize observer */
display: block;
position: relative;
}
+17 -3
View File
@@ -304,14 +304,21 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _renderHelper() {
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
if (!showError && !this.helper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
${
showError
? html`<span class="error">${this.errorMessage}</span> ${
this.helper
? html`<span class="helper">${this.helper}</span>`
: nothing
}`
: this.helper
}
</ha-input-helper-text>`;
}
@@ -448,6 +455,13 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
ha-input-helper-text .error,
ha-input-helper-text .helper {
display: block;
}
ha-input-helper-text .helper {
color: var(--secondary-text-color);
}
wa-popover {
--wa-space-l: 0;
+7 -9
View File
@@ -488,6 +488,11 @@ export class HaServiceControl extends LitElement {
)) ||
serviceData?.description;
const documentationLink =
this._manifest?.is_built_in && this._value?.action
? documentationUrl(this.hass, `/actions/${this._value.action}`)
: this._manifest?.documentation;
const targetSelector =
serviceData && "target" in serviceData
? this._targetSelector(
@@ -514,16 +519,9 @@ export class HaServiceControl extends LitElement {
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${
this._manifest
documentationLink
? html` <a
href=${
this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/actions/${this._value.action}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+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;
}
}
+55
View File
@@ -27,6 +27,10 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -122,6 +126,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -211,6 +219,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -297,6 +323,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -390,6 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -398,6 +426,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1175,6 +1204,31 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1357,6 +1411,7 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
+3
View File
@@ -228,6 +228,9 @@ export class HaThemeSettings extends LitElement {
}
static styles = css`
a {
color: var(--primary-color);
}
.inputs {
display: flex;
flex-wrap: wrap;
+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) => {
@@ -34,10 +34,12 @@ import {
browseMediaPlayer,
BROWSER_PLAYER,
MediaClassBrowserSettings,
searchMediaPlayer,
} from "../../data/media-player";
import {
browseLocalMediaPlayer,
isManualMediaSourceContentId,
isMediaSourceContentId,
MANUAL_MEDIA_SOURCE_PREFIX,
searchMedia,
} from "../../data/media_source";
@@ -869,13 +871,31 @@ export class HaMediaPlayerBrowse extends LitElement {
const mediaFilterClasses = this._mediaClassFilter.length
? this._mediaClassFilter
: undefined;
// A player's tree can embed media sources, which resolve their own searches;
// everything else in it uses integration specific ids only the entity knows.
const searchEntityId =
this.entityId &&
this.entityId !== BROWSER_PLAYER &&
!isMediaSourceContentId(navigateId.media_content_id ?? "")
? this.entityId
: undefined;
try {
const { result } = await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
const { result } = searchEntityId
? await searchMediaPlayer(
this.hass,
searchEntityId,
searchQuery,
navigateId.media_content_id,
navigateId.media_content_type,
mediaFilterClasses
)
: await searchMedia(
this.hass,
navigateId.media_content_id,
searchQuery,
mediaFilterClasses
);
// Ignore the response if a newer search started or we navigated away
if (requestId !== this._searchRequestId) {
return;
@@ -1,5 +1,6 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -52,6 +53,9 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -87,6 +91,7 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,7 +34,10 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -54,6 +57,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-svg-icon";
@@ -108,6 +112,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -128,6 +135,15 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -174,15 +190,20 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${name}</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -229,6 +250,24 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<ha-button
class="migrate"
slot="end"
appearance="plain"
variant="warning"
size="s"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -707,6 +746,51 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -746,6 +830,11 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -9,6 +10,7 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -19,6 +21,7 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -39,6 +42,9 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -51,38 +57,74 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${this.type}
class=${classMap({ [this.type]: true, replaced })}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name"> ${name} </span>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
${
this.type === "entity"
this.type === "entity" || replaced
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -125,7 +167,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
if (device?.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -240,6 +282,11 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+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 0;
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%;
+25 -4
View File
@@ -15,6 +15,11 @@ import { customElement, property } from "lit/decorators";
*
* @property {boolean} secondaryLoading - Whether the secondary text is loading. Shows a skeleton placeholder.
*
* @csspart primary - The primary text. Style it to opt into another truncation, such as a multi line clamp.
*
* @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 the number of rendered lines. 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)`.
@@ -39,7 +44,7 @@ export class HaTileInfo extends LitElement {
return html`
<div class="info">
<slot name="primary" class="primary">
<span>${this.primary}</span>
<span part="primary">${this.primary}</span>
</slot>
${
this.secondaryLoading
@@ -59,6 +64,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 +78,10 @@ export class HaTileInfo extends LitElement {
--ha-tile-info-primary-line-height,
var(--ha-line-height-normal)
);
--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,38 @@ 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(*) {
.primary span,
::slotted([slot="primary"]),
.secondary span,
::slotted([slot="secondary"]) {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
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;
+13 -11
View File
@@ -49,23 +49,25 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
dynamicGroups: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
event: {},
service: {},
set_conversation_response: {},
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
generic: true,
groups: {
event: {},
service: {},
set_conversation_response: {},
other: {},
device_id: {},
},
},
{
titleKey:
"ui.panel.config.automation.editor.actions.groups.integrations.label",
groups: {
integrationGroups: {},
},
},
] as const;
+2 -3
View File
@@ -21,7 +21,6 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
helpers: {},
template: {},
trigger: {},
other: {},
},
},
{
@@ -35,9 +34,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
groups: {
customDynamicGroups: {},
integrationGroups: {},
},
},
] as const;
+47 -1
View File
@@ -2,9 +2,10 @@ import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { CallWS } from "../../types";
import type { CallWS, HomeAssistant } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { DeviceCompositeSplits } from "./device_registry";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
entityRegistryByEntityId,
@@ -158,6 +159,51 @@ export const deviceAutomationsEqual = (
return true;
};
// Decides how a device automation editor should handle its referenced device.
// A missing device that was replaced by a split device stays editable so the
// device picker can offer to fix the reference; a genuinely unknown device
// cannot be edited visually. Returns "loading" while the split map is unknown.
export const deviceAutomationEditorMode = (
hass: HomeAssistant,
deviceId: string | undefined,
compositeSplits: DeviceCompositeSplits | undefined
): "editable" | "loading" | "unknown-device" => {
if (!deviceId || deviceId in hass.devices) {
return "editable";
}
if (compositeSplits === undefined) {
return "loading";
}
// Only editable if at least one of the split (replacement) devices still
// exists; otherwise the reference is stale and cannot be fixed here.
const split = compositeSplits[deviceId];
return split?.split_ids.some((id) => id in hass.devices)
? "editable"
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
entityRegistry: EntityRegistryEntry[],
entityIdA?: string,
+64
View File
@@ -1,3 +1,4 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -54,6 +55,69 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: Pick<HomeAssistant, "connection" | "callWS">
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
/**
* Fetch the devices that are linked to the given device because they share at
* least one connection or identifier. These are separate devices (one per
* config entry) that represent the same physical hardware, managed by
* different integrations.
*/
export const fetchLinkedDevices = (
hass: Pick<HomeAssistant, "callWS">,
deviceId: string
): Promise<string[]> =>
hass
.callWS<{ linked_devices: string[] }>({
type: "config/device_registry/list_linked_devices",
device_id: deviceId,
})
.then((result) => result.linked_devices);
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
@@ -0,0 +1,22 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
+12
View File
@@ -0,0 +1,12 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
+27 -5
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeHttpUrl } from "../common/url/sanitize-http-url";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
@@ -28,7 +29,7 @@ export interface IntegrationManifest {
domain: string;
name: string;
config_flow: boolean;
documentation: string;
documentation?: string;
issue_tracker?: string;
dependencies?: string[];
after_dependencies?: string[];
@@ -78,11 +79,27 @@ export enum LogSeverity {
export type IntegrationLogPersistance = "none" | "once" | "permanent";
/**
* A custom integration supplies its own manifest, so its URLs are untrusted
* input. Strip them here, where manifests enter the frontend, so no consumer can
* turn one into a link that runs script.
*/
const sanitizeManifest = <T extends IntegrationManifest | undefined>(
manifest: T
): T =>
manifest
? ({
...manifest,
documentation: sanitizeHttpUrl(manifest.documentation),
issue_tracker: sanitizeHttpUrl(manifest.issue_tracker),
} as T)
: manifest;
export const integrationIssuesUrl = (
domain: string,
manifest: IntegrationManifest
) =>
manifest.issue_tracker ||
sanitizeHttpUrl(manifest.issue_tracker) ||
`https://github.com/home-assistant/core/issues?q=is%3Aissue+is%3Aopen+label%3A%22integration%3A+${domain}%22`;
export const domainToName = (
@@ -101,7 +118,9 @@ export const fetchIntegrationManifests = (
if (integrations) {
params.integrations = integrations;
}
return hass.callWS<IntegrationManifest[]>(params);
return hass
.callWS<IntegrationManifest[]>(params)
.then((manifests) => manifests.map(sanitizeManifest));
};
export const fetchIntegrationManifestsCollection = async (
@@ -113,7 +132,7 @@ export const fetchIntegrationManifestsCollection = async (
});
const manifests: DomainManifestLookup = {};
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
manifests[manifest.domain] = sanitizeManifest(manifest);
}
setValue(manifests);
// One-time fetch — nothing to unsubscribe from
@@ -125,7 +144,10 @@ export const fetchIntegrationManifestsCollection = async (
export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
) =>
hass
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
.then(sanitizeManifest);
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
+23
View File
@@ -208,6 +208,29 @@ export const browseMediaPlayer = (
media_content_type: mediaContentType,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMediaPlayer = (
hass: HomeAssistant,
entityId: string,
searchQuery: string,
mediaContentId?: string,
mediaContentType?: string,
mediaFilterClasses?: string[]
): Promise<SearchMediaResult> =>
hass.callWS<SearchMediaResult>({
type: "media_player/search_media",
entity_id: entityId,
search_query: searchQuery,
// the backend requires these two to be passed together, and JSON
// serialization drops them both when the current item is the root
media_content_id: mediaContentId,
media_content_type: mediaContentType,
media_filter_classes: mediaFilterClasses,
});
export const getCurrentProgress = (stateObj: MediaPlayerEntity): number => {
let progress = stateObj.attributes.media_position!;
+1 -5
View File
@@ -1,5 +1,5 @@
import type { HomeAssistant } from "../types";
import type { MediaPlayerItem } from "./media-player";
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
export interface ResolvedMediaSource {
url: string;
@@ -24,10 +24,6 @@ export const browseLocalMediaPlayer = (
media_content_id: mediaContentId,
});
export interface SearchMediaResult {
result: MediaPlayerItem[];
}
export const searchMedia = (
hass: HomeAssistant,
mediaContentId: string | undefined,
+2 -3
View File
@@ -35,7 +35,6 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
webhook: {},
persistent_notification: {},
helpers: {},
other: {},
},
},
{
@@ -48,9 +47,9 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.triggers.groups.custom_integrations.label",
"ui.panel.config.automation.editor.triggers.groups.integrations.label",
groups: {
customDynamicGroups: {},
integrationGroups: {},
},
},
] as const;
+10
View File
@@ -0,0 +1,10 @@
import type { HomeAssistantApi } from "../types";
export const fetchSlug = (
api: HomeAssistantApi,
text: string
): Promise<{ slug: string }> =>
api.callWS<{ slug: string }>({
type: "slugify",
text,
});
@@ -7,6 +7,7 @@ import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
@@ -337,6 +338,13 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.manifest?.is_built_in) ||
!!this._params.manifest?.documentation;
const documentationLink = this._params.manifest?.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest.domain}`
)
: this._params.manifest?.documentation;
const dialogTitle = this._getDialogTitle();
const dialogSubtitle = this._getDialogSubtitle();
@@ -368,19 +376,15 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
: nothing
}
${
showDocumentationLink && !this._loading && this._step
showDocumentationLink &&
documentationLink &&
!this._loading &&
this._step
? html`
<a
slot="headerActionItems"
class="help"
href=${
this._params.manifest!.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._params.manifest!.domain}`
)
: this._params.manifest!.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer noopener"
>
@@ -542,21 +546,29 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
</ha-button>
</ha-dialog-footer>
`;
case "external":
case "external": {
const externalUrl = sanitizeHttpUrl(this._step.url);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
${
externalUrl
? html`
<ha-button
slot="primaryAction"
href=${externalUrl}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
`
: nothing
}
</ha-dialog-footer>
`;
}
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { sanitizeHttpUrl } from "../../common/url/sanitize-http-url";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
@@ -24,7 +25,11 @@ class StepFlowExternal extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
window.open(this.step.url);
// Opened without user interaction, so only ever follow an http(s) URL
const url = sanitizeHttpUrl(this.step.url);
if (url) {
window.open(url);
}
}
static get styles(): CSSResultGroup {
@@ -43,12 +43,19 @@ export class DialogHttpPendingConfig
private _interval?: number;
// This dialog must only be dismissed through its own footer buttons
// (confirm / revert / close). This flag is flipped right before such a
// button closes the dialog, so `closeDialog()` can refuse every other
// close request (navigation, back button, `closeAllDialogs`, …).
private _resolved = false;
public showDialog(params: HttpPendingConfigDialogParams): void {
this._params = params;
this._open = true;
this._busy = undefined;
this._error = undefined;
this._reverted = false;
this._resolved = false;
this._startCountdown();
// The field labels live in the config panel fragment, which is not loaded
// yet when this dialog pops up on startup. Load it so the changed-field
@@ -57,6 +64,12 @@ export class DialogHttpPendingConfig
}
public closeDialog(): boolean {
// Refuse programmatic close requests (navigation, back button,
// `closeAllDialogs`) so a pending HTTP config is never left silently
// unresolved. The dialog only closes once the user picks a footer action.
if (!this._resolved) {
return false;
}
this._open = false;
this._stopCountdown();
return true;
@@ -337,6 +350,9 @@ export class DialogHttpPendingConfig
}
private _notifyResolved(): void {
// Mark the dialog as user-resolved so `closeDialog()` is allowed to close
// it; every footer action calls this before setting `_open = false`.
this._resolved = true;
this._params?.onResolved?.();
// The form on Settings > System > Network may be mounted and showing
// stale state; let it know to refetch.
@@ -9,6 +9,7 @@ import { consumeLocalize } from "../../../common/decorators/consume-context-entr
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -232,6 +233,7 @@ class MoreInfoUpdate extends LitElement {
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
<div class="content">
@@ -283,14 +285,10 @@ class MoreInfoUpdate extends LitElement {
</div>
${
this.stateObj.attributes.release_url
releaseUrl
? html`<div class="row">
<div class="key">
<a
href=${this.stateObj.attributes.release_url}
target="_blank"
rel="noreferrer"
>
<a href=${releaseUrl} target="_blank" rel="noreferrer">
${this._localize(
"ui.dialogs.more_info_control.update.release_announcement"
)}
+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;
}
`;
}
+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`
@@ -107,6 +107,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
const selectedDomainName = this._params.selectedDomain
? domainToName(this.hass.localize, this._domain!)
: "";
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._domain}`)
: this._manifest?.documentation;
return html`
<ha-dialog
.open=${this._open}
@@ -139,17 +142,9 @@ export class DialogAddApplicationCredential extends DirtyStateProviderMixin<Cred
}
)}
${
this._manifest?.is_built_in ||
this._manifest?.documentation
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>
@@ -42,6 +42,7 @@ import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
import type { LocalizeKeys } from "../../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/chips/ha-assist-chip";
import "../../../../../components/chips/ha-chip-set";
@@ -549,7 +550,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"ui.panel.config.apps.dashboard.visit_app_page",
{
name: html`<a
href=${this._currentAddon.url!}
href=${ifDefined(sanitizeHttpUrl(this._currentAddon.url))}
target="_blank"
rel="noreferrer"
>${getAppDisplayName(
@@ -1107,7 +1108,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private get _pathWebui(): string | null {
const addon = this._currentAddon as HassioAddonDetails;
return addon.webui!.replace("[HOST]", document.location.hostname);
return (
sanitizeHttpUrl(
addon.webui!.replace("[HOST]", document.location.hostname)
) ?? null
);
}
private get _computeShowWebUI(): boolean | "" | null {
@@ -13,11 +13,16 @@ import type {
DeviceCapabilities,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceAction extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -59,14 +68,19 @@ export class HaDeviceAction extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("action")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.action.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.action.device_id &&
!(this.action.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -81,6 +95,20 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.action.device_id;
@@ -183,12 +183,7 @@ const ENTITY_DOMAINS_OTHER = new Set([
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
const DYNAMIC_KEYWORDS = [
"dynamicGroups",
"helpers",
"other",
"customDynamicGroups",
];
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
@@ -197,7 +192,13 @@ const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
type CollectionGroupType = "helper" | "dynamic" | "integration";
interface DomainClassificationOptions {
type: AddAutomationElementDialogParams["type"];
usedDomains?: Set<string>;
activeSystemDomains?: Set<string>;
}
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement
@@ -501,7 +502,9 @@ class DialogAddAutomationElement
);
private get _systemDomains() {
if (!this._manifests) {
// System domains are derived from trigger/condition descriptions, so
// they don't apply to actions.
if (!this._manifests || this._params?.type === "action") {
return undefined;
}
const descriptions =
@@ -1029,7 +1032,6 @@ class DialogAddAutomationElement
this._params!.type,
this._selectedGroup,
this._selectedCollectionIndex ?? 0,
this._domains,
this.hass.localize,
this.hass.services,
this._manifests,
@@ -1123,6 +1125,13 @@ class DialogAddAutomationElement
const exclusiveDomains = this._getExclusiveDomains(type);
const domainList =
type === "trigger"
? Object.keys(triggerDescriptions ?? {}).map(getTriggerDomain)
: type === "condition"
? Object.keys(conditionDescriptions ?? {}).map(getConditionDomain)
: Object.keys(services ?? {});
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1134,70 +1143,23 @@ class DialogAddAutomationElement
if (collection.groups.helpers) {
types.push("helper");
}
if (collection.groups.other) {
types.push("other");
}
if (collection.groups.customDynamicGroups) {
types.push("customDynamic");
if (collection.groups.integrationGroups) {
types.push("integration");
}
if (
type === "trigger" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
if (types.length) {
groups.push(
...this._triggerGroups(
...this._dynamicDomainGroups(
localize,
triggerDescriptions,
domainList,
manifests,
domains,
types,
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "condition" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._conditionGroups(
localize,
conditionDescriptions,
manifests,
domains,
types,
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
{
type,
usedDomains: domains,
activeSystemDomains: this._systemDomains?.active,
exclusiveDomains,
}
)
);
@@ -1289,7 +1251,6 @@ class DialogAddAutomationElement
type: AddAutomationElementDialogParams["type"],
group: string,
collectionIndex: number,
domains: Set<string> | undefined,
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests?: DomainManifestLookup,
@@ -1339,144 +1300,69 @@ class DialogAddAutomationElement
}
}
if (type === "action") {
if (!this._selectedGroup) {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
undefined
)
);
} else if (this._selectedGroup === "helpers") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"helper"
)
);
} else if (this._selectedGroup === "other") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"other"
)
);
}
}
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
private _serviceGroups = (
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
type: "helper" | "other" | undefined
): AddAutomationElementListItem[] => {
if (!services || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
Object.keys(services).forEach((domain) => {
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (
(type === undefined &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
domainUsed &&
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
(type === "helper" && manifest?.integration_type === "helper") ||
(type === "other" &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
(!domainUsed && manifest?.integration_type === "entity") ||
!["helper", "entity"].includes(manifest?.integration_type || "")))
) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _domainMatchesGroupType(
private _classifyDomain(
domain: string,
manifest: DomainManifestLookup[string] | undefined,
domainUsed: boolean,
types: CollectionGroupType[]
): boolean {
const matchDynamic =
((types.includes("dynamic") && (!manifest || manifest.is_built_in)) ||
(types.includes("customDynamic") &&
!(manifest?.is_built_in ?? true))) &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(domainUsed || (this._systemDomains?.active.has(domain) ?? false))) ||
(manifest?.integration_type === "system" &&
(this._systemDomains?.active.has(domain) ?? false)));
options: DomainClassificationOptions
): CollectionGroupType | undefined {
const integrationType = manifest?.integration_type;
const matchHelper =
types.includes("helper") && manifest?.integration_type === "helper";
if (integrationType === "helper") {
return "helper";
}
const matchOther =
types.includes("other") &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
!["helper", "entity", "system"].includes(
manifest?.integration_type || ""
));
if (ENTITY_DOMAINS_MAIN.has(domain) || integrationType === "entity") {
// Core entity domains. Actions always list them; triggers/conditions
// only when matching entities exist or a system domain covers them.
if (
options.type === "action" ||
!options.usedDomains ||
options.usedDomains.has(domain) ||
ENTITY_DOMAINS_OTHER.has(domain) ||
(options.activeSystemDomains?.has(domain) ?? false)
) {
return "dynamic";
}
return undefined;
}
return matchDynamic || matchHelper || matchOther;
if (integrationType === "system" && options.type !== "action") {
return options.activeSystemDomains?.has(domain) ? "dynamic" : undefined;
}
// Integrations that bring their own elements, built-in (like Apple TV,
// FFmpeg) and custom (like HACS) alike.
return "integration";
}
private _triggerGroups = (
private _dynamicDomainGroups = (
localize: LocalizeFunc,
triggers: TriggerDescriptions,
domains: string[],
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
options: DomainClassificationOptions & { exclusiveDomains?: Set<string> }
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
if (!manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
domains.forEach((domain) => {
if (addedDomains.has(domain) || options.exclusiveDomains?.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
const groupType = this._classifyDomain(domain, manifest, options);
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
if (groupType && types.includes(groupType)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
@@ -1525,46 +1411,6 @@ class DialogAddAutomationElement
}
);
private _conditionGroups = (
localize: LocalizeFunc,
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _conditions = memoizeOne(
(
localize: LocalizeFunc,
@@ -1665,26 +1511,13 @@ class DialogAddAutomationElement
);
}
if (group && !["helpers", "other"].includes(group)) {
if (group) {
return [];
}
Object.keys(services)
.sort()
.forEach((dmn) => {
const manifest = manifests?.[dmn];
if (group === "helpers" && manifest?.integration_type !== "helper") {
return;
}
if (
group === "other" &&
(ENTITY_DOMAINS_OTHER.has(dmn) ||
["helper", "entity"].includes(manifest?.integration_type || ""))
) {
return;
}
addDomain(dmn);
});
.forEach((dmn) => addDomain(dmn));
return result;
}
@@ -1695,25 +1528,20 @@ class DialogAddAutomationElement
);
private _getDomainType(domain: string) {
if (
ENTITY_DOMAINS_MAIN.has(domain) ||
(this._manifests?.[domain]?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(this._domains?.has(domain) ||
(this._systemDomains?.active.has(domain) ?? false)))
) {
return "dynamicGroups";
}
if (this._manifests?.[domain]?.integration_type === "helper") {
const groupType = this._classifyDomain(domain, this._manifests?.[domain], {
type: this._params!.type,
usedDomains: this._domains,
activeSystemDomains: this._systemDomains?.active,
});
if (groupType === "helper") {
return "helpers";
}
if (
this._manifests?.[domain]?.integration_type === "system" &&
this._systemDomains?.active.has(domain)
) {
return "dynamicGroups";
if (groupType === "integration") {
return "integrationGroups";
}
return "other";
// "dynamic", plus domains hidden in the by-type list (like unused entity
// domains) that can still surface when browsing by target.
return "dynamicGroups";
}
private _sortDomainsByCollection(
@@ -13,11 +13,16 @@ import type {
DeviceCondition,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -33,6 +38,10 @@ export class HaDeviceCondition extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -60,14 +69,19 @@ export class HaDeviceCondition extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("condition")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.condition.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.condition.device_id &&
!(this.condition.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -82,6 +96,20 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.condition.device_id;
@@ -185,20 +185,17 @@ export class HaPlatformCondition extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/conditions/${this.condition.condition}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
@@ -33,11 +33,16 @@ import type { ConfigEntry } from "../../../../data/config_entries";
import {
apiContext,
configEntriesContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
} from "../../../../data/context";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import {
deviceMeetsTargetSelector,
@@ -89,9 +94,16 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
@@ -108,6 +120,43 @@ export class HaAutomationRowTargets extends LitElement {
) {
this._rerenderCount = true;
}
if (
(changedProps.has("target") || changedProps.has("_registries")) &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this._hasMissingDevice()
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map so we can flag it.
this._loadCompositeSplits();
}
}
private _hasMissingDevice(): boolean {
const deviceIds = this.target?.device_id
? ensureArray(this.target.device_id)
: [];
return deviceIds.some(
(id) => !isTemplate(id) && !this._registries?.devices?.[id]
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits({
connection: this._connection.connection,
callWS: this._api.callWS,
});
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected updated(changedProps: PropertyValues) {
@@ -342,7 +391,8 @@ export class HaAutomationRowTargets extends LitElement {
error = false,
targetId?: string,
targetType?: string,
countTemplate: unknown = nothing
countTemplate: unknown = nothing,
title?: string
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
@@ -351,6 +401,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -366,6 +417,7 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -385,6 +437,7 @@ export class HaAutomationRowTargets extends LitElement {
let icon: string | undefined;
let label: string;
let warning = false;
let title: string | undefined;
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
let countTemplate: unknown = nothing;
@@ -408,17 +461,28 @@ export class HaAutomationRowTargets extends LitElement {
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
icon = mdiAlert;
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
title = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced_description"
);
} else {
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
}
} else {
label = getTargetText(
this._registries,
@@ -456,6 +520,7 @@ export class HaAutomationRowTargets extends LitElement {
targetType: badgeTargetType,
label,
}}
title=${title ?? nothing}
class=${classMap({
warning,
})}
@@ -470,7 +535,8 @@ export class HaAutomationRowTargets extends LitElement {
false,
badgeTargetId,
badgeTargetType,
countTemplate
countTemplate,
title
);
}
@@ -15,11 +15,16 @@ import type {
DeviceTrigger,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -35,6 +40,10 @@ export class HaDeviceTrigger extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -64,14 +73,19 @@ export class HaDeviceTrigger extends LitElement {
}
);
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return true;
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.trigger.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
}
if (
this.trigger.device_id &&
!(this.trigger.device_id in this.hass.devices)
) {
if (mode === "unknown-device") {
fireEvent(
this,
"ui-mode-not-available",
@@ -86,6 +100,20 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.trigger.device_id;
@@ -180,20 +180,17 @@ export class HaPlatformTrigger extends LitElement {
)
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/triggers/${this.trigger.trigger}`)
: this._manifest?.documentation;
return html`
<div class="description">
${description ? html`<p>${description}</p>` : nothing}
${
this._manifest
documentationLink
? html`<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation
}
href=${documentationLink}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
+9
View File
@@ -22,6 +22,7 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -494,6 +495,14 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/entity-id-format",
translationKey: "entity_id_format",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -0,0 +1,264 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { debounce } from "../../../common/util/debounce";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../data/entity/entity_registry_settings";
import {
DEFAULT_ENTITY_ID_FORMAT,
isDefaultEntityIdFormat,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { fetchSlug } from "../../../data/ws-slugify";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
const PREVIEW_DEBOUNCE_MS = 200;
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
@state() private _preview?: string;
@state() private _previewError = false;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (
(changedProps.has("_format") || changedProps.has("_localize")) &&
this._format
) {
if (changedProps.get("_format") === undefined) {
this._updatePreview();
} else {
this._debouncedUpdatePreview();
}
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize("ui.panel.config.entity_id_format.card.examples.area"),
device: localize("ui.panel.config.entity_id_format.card.examples.device"),
entity: localize("ui.panel.config.entity_id_format.card.examples.entity"),
floor: localize("ui.panel.config.entity_id_format.card.examples.floor"),
})
);
private _debouncedUpdatePreview = debounce(
() => this._updatePreview(),
PREVIEW_DEBOUNCE_MS
);
private async _updatePreview() {
const examples = this._examples(this._localize);
const fullName = this._format!.map((part) => examples[part])
.filter(Boolean)
.join(" ");
try {
const { slug } = await fetchSlug(this._api, fullName);
this._preview = slug;
this._previewError = false;
} catch (_err: any) {
this._previewError = true;
}
}
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.entity_id_format.card.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.entity_id_format.card.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.entity_id_format.card.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
${
this._previewError
? html`<ha-alert alert-type="error">
${this._localize(
"ui.panel.config.entity_id_format.card.preview_error"
)}
</ha-alert>`
: html`<code>${EXAMPLE_DOMAIN}.${this._preview ?? "…"}</code>`
}
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entity-id-format": HaConfigEntityIdFormat;
}
}
@@ -0,0 +1,51 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-config-entity-id-format";
@customElement("ha-config-section-entity-id-format")
export class HaConfigSectionEntityIdFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.entity_id_format.caption"
)}
>
<div class="content">
<ha-config-entity-id-format></ha-config-entity-id-format>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-config-entity-id-format {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
}
}
@@ -0,0 +1,348 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.entity_id_format.card.editor.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.add"
)}
class="add"
>
<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
</div>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
@@ -0,0 +1,196 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { fetchLinkedDevices } from "../../../../data/device/device_registry";
import { domainToName } from "../../../../data/integration";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
@customElement("ha-device-linked-devices-card")
export class HaDeviceLinkedDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _linkedDeviceIds: string[] = [];
private _entryLookup = memoizeOne(
(entries: ConfigEntry[]): Record<string, ConfigEntry> => {
const lookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
lookup[entry.entry_id] = entry;
}
return lookup;
}
);
private _linkedDevices = memoizeOne(
(
linkedDeviceIds: string[],
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
linkedDeviceIds
.map((id) => devices[id])
.filter((device): device is DeviceRegistryEntry => Boolean(device))
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("deviceId")) {
this._fetchLinkedDevices();
}
}
protected render() {
const linkedDevices = this._linkedDevices(
this._linkedDeviceIds,
this.hass.devices
);
if (linkedDevices.length === 0) {
return nothing;
}
const entryLookup = this._entryLookup(this.entries ?? []);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.linked_devices.heading")}
</h1>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.devices.linked_devices.description"
)}
</div>
${linkedDevices.map((linkedDevice) => {
const domain = this._deviceDomain(linkedDevice, entryLookup);
const integrationName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return html`
<a href=${`/config/devices/device/${linkedDevice.id}`}>
<ha-list-item
graphic=${domain ? "icon" : nothing}
hasMeta
.twoline=${!!integrationName}
>
${
domain
? html`<img
slot="graphic"
loading="lazy"
alt=""
src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing
}
${computeDeviceNameDisplay(
linkedDevice,
this.hass.localize,
this.hass.states
)}
${
integrationName
? html`<span slot="secondary">${integrationName}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
</ha-card>
`;
}
private _deviceDomain(
device: DeviceRegistryEntry,
entryLookup: Record<string, ConfigEntry>
): string | undefined {
const entryId =
device.primary_config_entry ?? device.config_entries[0] ?? undefined;
const entry = entryId ? entryLookup[entryId] : undefined;
return entry?.domain;
}
private async _fetchLinkedDevices() {
const deviceId = this.deviceId;
if (!deviceId) {
this._linkedDeviceIds = [];
return;
}
let linkedDeviceIds: string[];
try {
linkedDeviceIds = await fetchLinkedDevices(this.hass, deviceId);
} catch (_err) {
linkedDeviceIds = [];
}
if (this.deviceId !== deviceId) {
// Device changed while fetching, ignore stale result.
return;
}
this._linkedDeviceIds = linkedDeviceIds;
}
static styles = css`
:host {
display: block;
}
ha-card {
overflow: hidden;
}
.card-header {
padding-bottom: 0;
}
.card-content {
color: var(--secondary-text-color);
padding-bottom: var(--ha-space-2);
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
ha-list-item img {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-linked-devices-card": HaDeviceLinkedDevicesCard;
}
}
@@ -96,12 +96,17 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
import "./device-detail/ha-device-via-devices-card";
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
import {
@@ -892,6 +897,11 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
.entries=${this.entries}
></ha-device-linked-devices-card>
`;
const entitiesColumn = html`
@@ -1257,12 +1267,11 @@ export class HaConfigDevicePage extends LitElement {
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
device.configuration_url
);
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url?.replace("homeassistant://", "/")
: device.configuration_url;
const configurationUrl = sanitizeLinkUrl(device.configuration_url);
if (configurationUrl) {
deviceActions.push({
@@ -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";
@@ -132,6 +61,10 @@ export class HaEnergyPowerConfig extends LitElement {
}
}
private get _requiredError(): string {
return this.hass.localize("ui.common.error_required");
}
protected render(): TemplateResult {
return html`
<p class="power-section-label">
@@ -164,11 +97,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>
@@ -188,6 +123,9 @@ export class HaEnergyPowerConfig extends LitElement {
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
{ unit: this._powerUnits?.join(", ") || "" }
)}
required
.invalid=${!this.powerConfig.stat_rate}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
@@ -207,11 +145,16 @@ export class HaEnergyPowerConfig extends LitElement {
.helper=${this.hass.localize(
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
)}
required
.invalid=${!this.powerConfig.stat_rate_inverted}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: nothing
}
${
// These two exclude each other, so they keep their clear button
// (and therefore no required marker) to stay swappable.
this.powerType === "two_sensors"
? html`
<ha-statistic-picker
@@ -226,6 +169,8 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_to,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._fromPowerChanged}
.invalid=${!this.powerConfig.stat_rate_from}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
<ha-statistic-picker
.hass=${this.hass}
@@ -239,13 +184,56 @@ export class HaEnergyPowerConfig extends LitElement {
this.powerConfig.stat_rate_from,
].filter((id): id is string => Boolean(id))}
@value-changed=${this._toPowerChanged}
.invalid=${!this.powerConfig.stat_rate_to}
.errorMessage=${this._requiredError}
></ha-statistic-picker>
`
: 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 +277,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;
}
+4
View File
@@ -171,6 +171,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
load: () => import("./core/ha-config-section-entity-id-format"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -8,6 +8,7 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/chart/ha-chart-base";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -228,7 +229,7 @@ class HaConfigHardwareOverview extends SubscribeMixin(LitElement) {
) as ConfigEntry[];
boardId = boardData.board!.hassio_board_id;
boardName = boardData.name;
documentationURL = boardData.url;
documentationURL = sanitizeHttpUrl(boardData.url);
imageURL = hardwareBrandsUrl(
{
category: "boards",
@@ -300,6 +300,7 @@ class HaScheduleForm extends LitElement {
const newValue = { ...this._item };
const endFormatted = formatTime24h(end, this.hass.locale, this.hass.config);
newValue[day] = [...newValue[day]];
newValue[day][index] = {
...newValue[day][index],
from: value.from,
@@ -337,6 +338,7 @@ class HaScheduleForm extends LitElement {
};
if (newDay === day) {
newValue[day] = [...newValue[day]];
newValue[day][index] = event;
} else {
newValue[day].splice(index, 1);
@@ -10,6 +10,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -46,6 +50,15 @@ export class HaConfigFlowCard extends LitElement {
protected render(): TemplateResult {
const attention = ATTENTION_SOURCES.includes(this.flow.context.source);
const configurationUrlIsHomeAssistant = isHomeAssistantUrl(
this.flow.context.configuration_url
);
const configurationUrl = sanitizeLinkUrl(
this.flow.context.configuration_url
);
const documentationLink = this.manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this.manifest.domain}`)
: this.manifest?.documentation;
return html`
<ha-integration-action-card
class=${classMap({
@@ -78,7 +91,7 @@ export class HaConfigFlowCard extends LitElement {
)}
</ha-button>
${
this.flow.context.configuration_url || this.manifest || attention
configurationUrl || documentationLink || attention
? html`<ha-dropdown
slot="header-button"
placement="bottom-end"
@@ -90,19 +103,12 @@ export class HaConfigFlowCard extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.flow.context.configuration_url
configurationUrl
? html`<a
href=${this.flow.context.configuration_url.replace(
/^homeassistant:\/\//,
"/"
)}
href=${configurationUrl}
rel="noreferrer"
target=${
this.flow.context.configuration_url.startsWith(
"homeassistant://"
)
? "_self"
: "_blank"
configurationUrlIsHomeAssistant ? "_self" : "_blank"
}
>
<ha-dropdown-item>
@@ -122,16 +128,9 @@ export class HaConfigFlowCard extends LitElement {
: nothing
}
${
this.manifest
documentationLink
? html`<a
href=${
this.manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this.manifest.domain}`
)
: this.manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -368,21 +368,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.domain
);
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
${
this._manifest
documentationLink
? html`
<a
slot="toolbar-icon"
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
rel="noreferrer"
target="_blank"
>
@@ -11,6 +11,7 @@ import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../../common/entity/compute_device_name";
import { sanitizeHttpUrl } from "../../../../../common/url/sanitize-http-url";
import { groupBy } from "../../../../../common/util/group-by";
import "../../../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../../../components/buttons/ha-progress-button";
@@ -154,8 +155,9 @@ class ZWaveJSNodeConfig extends LitElement {
device_database: html`<a
rel="noreferrer noopener"
href=${
this._nodeMetadata?.device_database_url ||
"https://devices.zwave-js.io"
sanitizeHttpUrl(
this._nodeMetadata?.device_database_url
) || "https://devices.zwave-js.io"
}
target="_blank"
>${this.hass.localize(
+8 -4
View File
@@ -4,6 +4,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -199,6 +200,9 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
const feedbackUrl = sanitizeHttpUrl(preview_feature.feedback_url);
const reportIssueUrl = sanitizeHttpUrl(preview_feature.report_issue_url);
// Build description with learn more link if available
const descriptionWithLink = preview_feature.learn_more_url
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
@@ -237,11 +241,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
<div class="card-actions">
<div>
${
preview_feature.feedback_url
feedbackUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.feedback_url}
href=${feedbackUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -253,11 +257,11 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
: nothing
}
${
preview_feature.report_issue_url
reportIssueUrl
? html`
<ha-button
appearance="plain"
href=${preview_feature.report_issue_url}
href=${reportIssueUrl}
target="_blank"
rel="noopener noreferrer"
>
@@ -25,6 +25,15 @@ import { showToast } from "../../../util/toast";
import type { SystemLogDetailDialogParams } from "./show-dialog-system-log-detail";
import { formatSystemLogTime } from "./util";
/** Compares the host, so a URL that merely contains ours does not pass. */
const isOfficialDocumentationUrl = (url: string): boolean => {
try {
return new URL(url).hostname === "www.home-assistant.io";
} catch (_err) {
return false;
}
};
@customElement("dialog-system-log-detail")
class DialogSystemLogDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -76,7 +85,12 @@ class DialogSystemLogDetail extends LitElement {
this._manifest &&
(this._manifest.is_built_in ||
// Custom components with our official docs should not link to our docs
!this._manifest.documentation.includes("://www.home-assistant.io"));
(!!this._manifest.documentation &&
!isOfficialDocumentationUrl(this._manifest.documentation)));
const documentationLink = this._manifest?.is_built_in
? documentationUrl(this.hass, `/integrations/${this._manifest.domain}`)
: this._manifest?.documentation;
const title = this.hass.localize("ui.panel.config.logs.details", {
level: html`<span class=${item.level}
@@ -124,18 +138,12 @@ class DialogSystemLogDetail extends LitElement {
${
!this._manifest ||
// Can happen with custom integrations
!showDocumentation
!showDocumentation ||
!documentationLink
? ""
: html`
(<a
href=${
this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation
}
href=${documentationLink}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
@@ -3,6 +3,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mainWindow } from "../../../common/dom/get_main_window";
import {
IP_ADDRESS_OR_NETWORK_PATTERN,
IP_ADDRESS_PATTERN,
@@ -24,7 +25,10 @@ import type {
HttpConfig,
HttpConfigWithMeta,
} from "../../../data/http";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -167,6 +171,10 @@ class HaConfigHttpForm extends LitElement {
@state() private _activeConfigType?: ActiveConfigType;
// The built-in default config as reported by core; used to show the default
// port in the helper text instead of a hard-coded value.
@state() private _default?: HttpConfigWithMeta;
// A pending config that was reverted/failed and kept only for display.
@state() private _revertedPending?: HttpConfigWithMeta;
@@ -201,6 +209,8 @@ class HaConfigHttpForm extends LitElement {
const portChanged =
!!this._stable && this._config?.server_port !== this._stable.server_port;
const hasListenAddresses = !!this._config?.server_host?.some(Boolean);
return html`
<ha-card
outlined
@@ -255,6 +265,17 @@ class HaConfigHttpForm extends LitElement {
`
: nothing
}
${
hasListenAddresses
? html`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.network.http.server_host_warning"
)}
</ha-alert>
`
: nothing
}
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -309,12 +330,16 @@ class HaConfigHttpForm extends LitElement {
private async _fetchConfig(): Promise<void> {
try {
const { stable, pending, active_config_type } = await fetchHttpConfig(
this.hass
);
const {
stable,
pending,
active_config_type,
default: defaultConfig,
} = await fetchHttpConfig(this.hass);
this._stable = stable;
this._config = { ...stable };
this._activeConfigType = active_config_type;
this._default = defaultConfig;
// An active trial pending (no error) is handled by the global
// confirm/revert dialog. A pending carrying an error was reverted or
// failed to apply and is kept only so we can surface it here.
@@ -351,6 +376,12 @@ class HaConfigHttpForm extends LitElement {
if ("type" in schema && schema.type === "expandable") {
return "";
}
if (schema.name === "server_port") {
return this.hass.localize(
"ui.panel.config.network.http.helpers.server_port",
{ port: this._default?.server_port ?? 8123 }
);
}
return (
this.hass.localize(
`ui.panel.config.network.http.helpers.${schema.name}` as any
@@ -365,6 +396,63 @@ class HaConfigHttpForm extends LitElement {
this._showNoChanges = false;
}
// Build a link to the new address for an address-changing restart, so the
// user (still on the old address) can jump to it once Home Assistant is back.
// Best-effort: skip Home Assistant Cloud remote UI (Nabu Casa), and skip when
// the current page is not on the old port — that usually means a reverse
// proxy, where swapping the port would point at the wrong place. Even when
// shown, the new address may not be reachable (e.g. a firewall).
private _newAddressUrl(): string | undefined {
if (!this._stable || !this._config) {
return undefined;
}
const loc = mainWindow.location;
if (loc.hostname.endsWith("nabu.casa")) {
return undefined;
}
const oldHttps = !!this._stable.ssl_certificate;
const newHttps = !!this._config.ssl_certificate;
const oldPort = this._stable.server_port ?? (oldHttps ? 443 : 80);
const newPort = this._config.server_port ?? (newHttps ? 443 : 80);
// The reachable address only changes when the scheme or the port changes.
if (oldHttps === newHttps && oldPort === newPort) {
return undefined;
}
const currentPort = loc.port
? Number(loc.port)
: loc.protocol === "https:"
? 443
: 80;
if (currentPort !== oldPort) {
return undefined;
}
const url = new URL(loc.origin);
url.protocol = newHttps ? "https:" : "http:";
url.port = String(newPort);
return url.toString();
}
private _showNewAddress(url: string): void {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.network.http.restart_address.title"
),
text: html`
<p>
${this.hass.localize(
"ui.panel.config.network.http.restart_address.text"
)}
</p>
<a href=${url} rel="noreferrer noopener">${url}</a>
<p class="dialog-note">
${this.hass.localize(
"ui.panel.config.network.http.restart_address.note"
)}
</p>
`,
});
}
private async _save(): Promise<void> {
if (!this._config || !this._stable) {
return;
@@ -393,24 +481,44 @@ class HaConfigHttpForm extends LitElement {
return;
}
// Capture the new address before the restart drops the connection.
const newAddressUrl = this._newAddressUrl();
this._saving = true;
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;
} else if (newAddressUrl) {
// restart === true: a restart is in flight. The reply usually races
// with the connection drop; if we do reach this branch, offer the new
// address so the user can follow along.
this._showNewAddress(newAddressUrl);
}
// restart === true: a restart is in flight. The reply usually races with
// the connection drop; if we do reach this branch, the disconnected
// overlay will appear in moments. Leave the form as is.
} catch (err: any) {
// The restart kills the WS connection before the ack — that's expected.
if (
err?.error?.code === ERR_CONNECTION_LOST ||
err === ERR_CONNECTION_LOST
) {
if (newAddressUrl) {
this._showNewAddress(newAddressUrl);
}
return;
}
this._handleSaveError(err);
@@ -15,6 +15,8 @@ import type { HaInputCopy } from "../../../components/input/ha-input-copy";
import type { CloudStatus } from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import { saveCoreConfig } from "../../../data/core";
import type { HttpConfigWithMeta } from "../../../data/http";
import { fetchHttpConfig } from "../../../data/http";
import { getNetworkUrls, type NetworkUrls } from "../../../data/network";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../types";
@@ -34,6 +36,12 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
@state() private _internal_url = "";
// Stable HTTP config from core; used to build a realistic internal URL
// placeholder (scheme from SSL, port from the configured/default port).
@state() private _httpConfig?: HttpConfigWithMeta;
@state() private _httpDefault?: HttpConfigWithMeta;
@state() private _cloudStatus?: CloudStatus | null;
@state() private _showCustomExternalUrl = false;
@@ -97,6 +105,28 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
}
}
// Build realistic URL placeholders: use https when an SSL certificate is
// configured, and only show the port when it differs from the scheme
// default (80 for http, 443 for https).
const useHttps = this._httpConfig?.ssl_certificate ? true : httpUseHttps;
const placeholderPort =
this._httpConfig?.server_port ?? this._httpDefault?.server_port;
const placeholderParts = {
protocol: useHttps ? "https" : "http",
port:
placeholderPort && placeholderPort !== (useHttps ? 443 : 80)
? `:${placeholderPort}`
: "",
};
const internalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.internal_url_placeholder",
placeholderParts
);
const externalUrlPlaceholder = this.hass.localize(
"ui.panel.config.url.external_url_placeholder",
placeholderParts
);
return html`
<ha-card
outlined
@@ -157,7 +187,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="external_url"
type="url"
.maskedToggle=${!(this._showCustomExternalUrl && canEdit)}
placeholder="https://example.duckdns.org:8123"
.placeholder=${externalUrlPlaceholder}
.value=${externalUrl}
.maskedValue=${
this._showCustomExternalUrl && canEdit
@@ -260,9 +290,7 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
data-name="internal_url"
.maskedToggle=${!(this._showCustomInternalUrl && canEdit)}
type="url"
placeholder=${this.hass.localize(
"ui.panel.config.url.internal_url_placeholder"
)}
.placeholder=${internalUrlPlaceholder}
.value=${internalUrl}
.maskedValue=${
this._showCustomInternalUrl && canEdit
@@ -325,6 +353,14 @@ class ConfigUrlForm extends SubscribeMixin(LitElement) {
this._cloudStatus = null;
}
this._fetchUrls();
// Best-effort: the placeholder still works without it, just without a port.
fetchHttpConfig(this.hass).then(
({ stable, default: defaultConfig }) => {
this._httpConfig = stable;
this._httpDefault = defaultConfig;
},
() => undefined
);
}
private _toggleCloud(ev: Event) {
@@ -4,6 +4,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import {
isHomeAssistantUrl,
sanitizeLinkUrl,
} from "../../../common/url/sanitize-http-url";
import "../../../components/ha-alert";
import "../../../components/ha-dialog";
import "../../../components/ha-button";
@@ -52,8 +56,10 @@ class DialogRepairsIssue extends LitElement {
return nothing;
}
const learnMoreUrlIsHomeAssistant =
this._issue.learn_more_url?.startsWith("homeassistant://") || false;
const learnMoreUrlIsHomeAssistant = isHomeAssistantUrl(
this._issue.learn_more_url
);
const learnMoreUrl = sanitizeLinkUrl(this._issue.learn_more_url);
const dialogTitle =
this.hass.localize(
@@ -127,20 +133,13 @@ class DialogRepairsIssue extends LitElement {
}
</ha-button>
${
this._issue.learn_more_url
learnMoreUrl
? html`
<ha-button
slot="primaryAction"
appearance="filled"
rel="noopener noreferrer"
href=${
learnMoreUrlIsHomeAssistant
? this._issue.learn_more_url.replace(
"homeassistant://",
"/"
)
: this._issue.learn_more_url
}
href=${learnMoreUrl}
.target=${learnMoreUrlIsHomeAssistant ? "" : "_blank"}
@click=${
learnMoreUrlIsHomeAssistant ? this.closeDialog : undefined
@@ -5,6 +5,8 @@ import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../common/url/sanitize-navigation-path";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { subscribePollingCollection } from "../../../common/util/subscribe-polling";
import "../../../components/ha-alert";
@@ -335,14 +337,15 @@ class DialogSystemInformation extends LitElement {
if (info.type === "pending") {
value = html` <ha-spinner size="small"></ha-spinner> `;
} else if (info.type === "failed") {
const moreInfoUrl = sanitizeHttpUrl(info.more_info);
value = html`
<span class="error">${info.error}</span>${
!info.more_info
!moreInfoUrl
? ""
: html`
<a
href=${info.more_info}
href=${moreInfoUrl}
target="_blank"
rel="noreferrer noopener"
>
@@ -378,18 +381,22 @@ class DialogSystemInformation extends LitElement {
`);
}
if (domain !== "homeassistant") {
// No target, so an in-app path is also a valid destination here
const manageUrl =
sanitizeHttpUrl(domainInfo.manage_url) ??
sanitizeNavigationPath(domainInfo.manage_url);
sections.push(html`
<div class="card-header">
<h3>${domainToName(this.hass.localize, domain)}</h3>
${
!domainInfo.manage_url
!manageUrl
? ""
: html`
<ha-button
appearance="plain"
size="s"
class="manage"
href=${domainInfo.manage_url}
href=${manageUrl}
>
${this.hass.localize(
"ui.panel.config.info.system_health.manage"
@@ -43,11 +43,15 @@ class IntegrationsStartupTime extends LitElement {
<ha-md-list>
${this._setups?.map((setup) => {
const manifest = this._manifests && this._manifests[setup.domain];
const docLink = manifest
? manifest.is_built_in
? documentationUrl(this.hass, `/integrations/${manifest.domain}`)
: manifest.documentation
: "";
const docLink =
(manifest
? manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${manifest.domain}`
)
: manifest.documentation
: "") || "";
const setupSeconds = setup.seconds?.toFixed(2);
return html`
@@ -5,6 +5,7 @@ import { dump, JSON_SCHEMA, load } from "js-yaml";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
@@ -17,6 +18,8 @@ import {
isTemplate,
} from "../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../../common/url/sanitize-http-url";
import { sanitizeNavigationPath } from "../../../../common/url/sanitize-navigation-path";
import { extractSearchParam } from "../../../../common/url/search-params";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import type { HaProgressButton } from "../../../../components/buttons/ha-progress-button";
@@ -562,7 +565,10 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
`
: html`
<a
href=${resolved.url}
href=${ifDefined(
sanitizeHttpUrl(resolved.url) ??
sanitizeNavigationPath(resolved.url)
)}
target="_blank"
rel="noreferrer"
><ha-button>
+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>
+14
View File
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
@@ -15,6 +16,19 @@ class HaPanelIframe extends LitElement {
@property({ attribute: false }) panel!: PanelInfo<{ url: string }>;
render() {
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(this.panel.config.url) === "about:blank") {
return html`
<hass-error-screen
.hass=${this.hass}
.narrow=${this.narrow}
error="Unable to load iframes with this URL."
rootnav
></hass-error-screen>
`;
}
if (
location.protocol === "https:" &&
new URL(this.panel.config.url, location.toString()).protocol !== "https:"
+6
View File
@@ -321,6 +321,9 @@ export class HaPanelLogbook extends LitElement {
"entity_id",
"state",
"event_type",
"name",
"message",
"source",
"context_id",
"context_user_id",
"context_event_type",
@@ -339,6 +342,9 @@ export class HaPanelLogbook extends LitElement {
d.entity_id || "",
csvSafeString(d.state),
csvSafeString(d.attributes?.event_type),
csvSafeString(d.name),
csvSafeString(d.message),
csvSafeString(d.source),
d.context_id || "",
d.context_user_id || "",
csvSafeString(d.context_event_type),
@@ -11,6 +11,7 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import { apiContext, servicesContext } from "../../../data/context";
import { forwardHaptic } from "../../../data/haptics";
import {
hasRequiredScriptFieldsForServices,
requiredScriptFieldsFilledForServices,
@@ -96,6 +97,8 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
: {}),
};
forwardHaptic(this, "light");
this._api.callService(domain, service, serviceData);
}
@@ -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,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
justify-content: center;
color: white;
}
ha-tile-info.twoline::part(primary) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
overflow-wrap: anywhere;
}
`,
];
}
@@ -1,3 +1,4 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -52,6 +53,12 @@ export class HuiIframeCard extends LitElement implements LovelaceCard {
throw new Error("URL required");
}
// The sandbox keeps allow-same-origin, so a javascript: URL would run in
// the frontend's own origin
if (sanitizeUrl(config.url) === "about:blank") {
throw new Error("Invalid URL");
}
this._config = config;
}
@@ -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),

Some files were not shown because too many files have changed in this diff Show More