Compare commits

..

63 Commits

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

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

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

* Increase JS budgets

---------

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

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

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

* Route blur and invalid handling through the guarded validity check
2026-07-29 13:51:35 +03:00
Bram Kragten 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
Aidan Timson 70ec53ff43 Wait for initial panel readiness (#53351)
* Wait for initial panel readiness

* Keep panel readiness internal
2026-07-28 14:03:04 +01: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
137 changed files with 3647 additions and 1081 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
+2 -6
View File
@@ -151,9 +151,7 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "co2_intensity",
original_name: null,
translation_key: null,
options: {},
options: null,
created_at: 0,
modified_at: 0,
},
@@ -174,9 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
entity_category: null,
has_entity_name: false,
unique_id: "grid_fossil_fuel_percentage",
original_name: null,
translation_key: null,
options: {},
options: null,
created_at: 0,
modified_at: 0,
},
+2 -3
View File
@@ -43,7 +43,7 @@ const cloudStatus: CloudStatusLoggedIn = {
email: "demo@home-assistant.io",
google_registered: true,
google_entities: emptyFilter(),
google_local_connected: true,
google_domains: ["light", "switch", "climate", "cover"],
alexa_registered: true,
alexa_entities: emptyFilter(),
remote_domain: "demo-instance.ui.nabu.casa",
@@ -64,8 +64,7 @@ const cloudStatus: CloudStatusLoggedIn = {
alexa_enabled: true,
remote_enabled: true,
remote_allow_remote_enable: true,
alexa_default_expose: null,
google_default_expose: null,
strict_connection: "disabled",
google_secure_devices_pin: undefined,
cloudhooks: {},
alexa_report_state: true,
-2
View File
@@ -21,8 +21,6 @@ const baseEntry = {
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
created_at: 0,
modified_at: 0,
};
// Each entry is tagged with its integration type so we can honor the
-2
View File
@@ -14,8 +14,6 @@ const baseDevice = {
name_by_user: null,
disabled_by: null,
configuration_url: null,
config_entry_id: null,
config_subentry_id: null,
created_at: 0,
modified_at: 0,
};
+1 -8
View File
@@ -16,14 +16,7 @@ export const mockEntityRegistry = (
for (const entityId of msg.entity_ids) {
const entry = data.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = {
...entry,
capabilities: {},
original_icon: null,
device_class: null,
original_device_class: null,
aliases: [],
};
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
}
}
return result;
+2 -31
View File
@@ -114,14 +114,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
audio_output: null,
audio: false,
auth_api: false,
auto_uart: false,
auto_update: false,
boot: "auto",
boot_config: "auto",
changelog: false,
devices: [],
devicetree: false,
discovery: [],
dns: [],
docker_api: false,
documentation: false,
full_access: false,
@@ -134,10 +133,8 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
host_ipc: false,
host_network: false,
host_pid: false,
host_uts: false,
ingress_entry: null,
ingress_panel: false,
ingress_port: null,
ingress_url: null,
ingress: false,
ip_address: "172.30.33.2",
@@ -151,17 +148,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
protected: true,
rating: 6,
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
services: [],
services_role: [],
signed: false,
startup: "application",
stdin: false,
system_managed: false,
system_managed_config_entry: null,
translations: {},
uart: false,
udev: false,
usb: false,
video: false,
watchdog: true,
webui: null,
});
@@ -219,13 +212,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
debug: false,
debug_block: false,
diagnostics: true,
auto_update: true,
country: "NL",
detect_blocking_io: false,
feature_flags: {
supervisor_v2_api: false,
supervisor_websocket_v2_api: false,
},
addons: DEMO_ADDONS as any,
addons_repositories: [
"https://github.com/music-assistant/home-assistant-addon",
@@ -278,7 +264,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
hostname: "homeassistant",
logging: "info",
machine: "green",
machine_id: "0123456789abcdef0123456789abcdef",
state: "running",
operating_system: "Home Assistant OS 18.2",
supervisor: "2026.07.1",
@@ -292,9 +277,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
if (msg.endpoint === "/host/info") {
const data: HassioHostInfo = {
agent_version: "1.8.0",
apparmor_version: "3.0.7",
broadcast_llmnr: true,
broadcast_mdns: true,
chassis: "embedded",
cpe: "cpe:2.3:o:home-assistant:haos:18.2:*:production:*:*:*:aarch64:*",
deployment: "production",
@@ -302,18 +284,12 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
disk_free: 22.3,
disk_total: 31.2,
disk_used: 8.9,
dt_synchronized: true,
dt_utc: "2026-07-28T10:00:00+00:00",
features: ["reboot", "shutdown", "network", "hostname", "os_agent"],
hostname: "homeassistant",
kernel: "6.12.48-haos",
llmnr_hostname: "homeassistant",
operating_system: "Home Assistant OS 18.2",
boot_timestamp: 1751932800000000,
startup_time: 12.4,
timezone: "Europe/Amsterdam",
use_ntp: true,
virtualization: "",
};
return data;
}
@@ -322,14 +298,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
const data: HassioHassOSInfo = {
board: "green",
boot: "A",
boot_slots: {
A: { state: "booted", status: "good", version: "18.2" },
B: { state: "inactive", status: "good", version: "18.1" },
},
update_available: false,
version: "18.2",
version_latest: "18.2",
version_pending: null,
data_disk: "Home Assistant Green (mmcblk0)",
};
return data;
-6
View File
@@ -69,8 +69,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -95,8 +93,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -121,8 +117,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -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;
}
}
@@ -82,8 +82,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -108,8 +106,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
@@ -134,8 +130,6 @@ const DEVICES: DeviceRegistryEntry[] = [
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
disabled_by: null,
entry_type: null,
+1 -7
View File
@@ -29,8 +29,6 @@ const createConfigEntry = (
title,
source: "zeroconf",
state: "loaded",
created_at: 0,
modified_at: 0,
supports_options: false,
supports_remove_device: false,
supports_unload: true,
@@ -206,9 +204,7 @@ const createEntityRegistryEntries = (
platform: "updater",
has_entity_name: false,
unique_id: "updater",
original_name: null,
translation_key: null,
options: {},
options: null,
labels: [],
categories: {},
created_at: 0,
@@ -223,8 +219,6 @@ const createDeviceRegistryEntries = (
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
config_entry_id: null,
config_subentry_id: null,
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260729.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -0,0 +1,15 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
@@ -1,8 +1,29 @@
let supported: boolean | undefined;
const detect = (): boolean => {
if (
!globalThis.ElementInternals ||
!globalThis.HTMLElement?.prototype.attachInternals
) {
return false;
}
// Native internals keep their WebIDL brand even when `attachInternals` is
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
// broke the previous `[native code]` source check in the app bundle).
// `element-internals-polyfill` swaps in a plain class, which has no brand,
// and must not count as native: login on legacy browsers relies on
// validation being skipped there (#51338).
return (
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
"[object ElementInternals]"
);
};
/**
* Indicates whether the current browser has native ElementInternals support.
* Probed on first use so importing this module has no side effects.
*/
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
export const supportsNativeElementInternals = (): boolean => {
supported ??= detect();
return supported;
};
@@ -0,0 +1,26 @@
import { mainWindow } from "../dom/get_main_window";
/**
* Checks that a path resolves to the origin the frontend is served from, so it
* is safe to use as a link target. Rejects URIs that carry their own scheme,
* like `javascript:`, and URLs pointing at another origin. Resolves against the
* main window, because that is where `navigate()` applies the path.
*/
const isSameOriginPath = (path: string): boolean => {
try {
const { origin } = mainWindow.location;
return new URL(path, origin).origin === origin;
} catch (_err) {
return false;
}
};
/**
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
* paths that can be influenced by a URL parameter or by dashboard config before
* they end up in an `href` or in `navigate()`.
*/
export const sanitizeNavigationPath = (
path: string | null | undefined
): string | undefined =>
path != null && isSameOriginPath(path) ? path : undefined;
+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,142 @@
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 type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
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;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
}
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"]
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
secondary: area ? computeAreaName(area) : 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
).map(
(item) => html`
<ha-list-item-button .deviceId=${item.deviceId}>
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
<span slot="headline">${item.name}</span>
${
item.secondary || item.isPrimary
? html`<span slot="supporting-text">
${[
item.secondary,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ")}
</span>`
: 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);
}
`;
}
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,
});
+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;
}
-2
View File
@@ -187,8 +187,6 @@ export class HaPictureUpload extends LitElement {
can_play: true,
can_expand: false,
can_search: false,
children_media_class: null,
search_media_classes: null,
thumbnail: generateImageThumbnailUrl(media.id, 256),
} as const;
const navigateIds = [
+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"
+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;
}
}
+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) => {
@@ -94,8 +94,6 @@ const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_play: false,
can_search: false,
children_media_class: "",
search_media_classes: null,
thumbnail: null,
media_class: "app",
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
@@ -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;
+1 -1
View File
@@ -112,7 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
padding: 10px;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+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 -2
View File
@@ -7,7 +7,7 @@ export interface AssistPipeline {
name: string;
language: string;
conversation_engine: string;
conversation_language: string;
conversation_language: string | null;
prefer_local_intents?: boolean;
stt_engine: string | null;
stt_language: string | null;
@@ -27,7 +27,7 @@ export interface AssistPipelineMutableParams {
name: string;
language: string;
conversation_engine: string;
conversation_language: string;
conversation_language: string | null;
prefer_local_intents?: boolean;
stt_engine: string | null;
stt_language: string | null;
-1
View File
@@ -5,7 +5,6 @@ import { UNAVAILABLE } from "./entity/entity";
export enum AssistSatelliteEntityFeature {
ANNOUNCE = 1,
START_CONVERSATION = 2,
}
export interface WakeWordInterceptMessage {
+9 -8
View File
@@ -81,8 +81,7 @@ export interface BackupMutableConfig {
schedule?: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
// The backend schema has no null branch for days, send [] to clear
days?: BackupDay[];
days?: BackupDay[] | null;
};
agents?: BackupAgentsConfig;
}
@@ -122,19 +121,22 @@ export interface BackupContent {
"supervisor.addon_update"?: string;
"supervisor.app_update"?: string;
};
with_automatic_settings: boolean | null;
with_automatic_settings: boolean;
}
export interface BackupData {
addons: BackupAddon[];
database_included: boolean;
folders: string[];
// null when homeassistant_included is false
homeassistant_version: string | null;
homeassistant_version: string;
homeassistant_included: boolean;
}
export type BackupAddon = AddonInfo;
export interface BackupAddon {
name: string;
slug: string;
version: string;
}
export interface BackupContentExtended extends BackupContent, BackupData {}
@@ -150,8 +152,7 @@ export interface BackupInfo {
}
export interface BackupDetails {
backup: BackupContentExtended | null;
agent_errors: Record<string, string>;
backup: BackupContentExtended;
}
export interface BackupAgentsInfo {
+5 -16
View File
@@ -1,7 +1,7 @@
import type { HomeAssistant } from "../types";
export type BackupManagerState =
"idle" | "blocked" | "create_backup" | "receive_backup" | "restore_backup";
"idle" | "create_backup" | "receive_backup" | "restore_backup";
export type CreateBackupStage =
| "addon_repositories"
@@ -39,34 +39,26 @@ export type RestoreBackupStage =
| "remove_delta_addons"
| "remove_delta_apps";
export type RestoreBackupState =
"completed" | "core_restart" | "failed" | "in_progress";
export type RestoreBackupState = "completed" | "failed" | "in_progress";
interface IdleEvent {
manager_state: "idle";
}
interface BlockedEvent {
manager_state: "blocked";
}
interface CreateBackupEvent {
manager_state: "create_backup";
reason: string | null;
stage: CreateBackupStage | null;
state: CreateBackupState;
}
interface ReceiveBackupEvent {
manager_state: "receive_backup";
reason: string | null;
stage: ReceiveBackupStage | null;
state: ReceiveBackupState;
}
interface RestoreBackupEvent {
manager_state: "restore_backup";
reason: string | null;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
}
@@ -78,14 +70,11 @@ export interface UploadBackupEvent {
total_bytes: number;
}
export type ManagerState = BackupManagerState;
export type ManagerState =
"idle" | "create_backup" | "receive_backup" | "restore_backup";
export type ManagerStateEvent =
| IdleEvent
| BlockedEvent
| CreateBackupEvent
| ReceiveBackupEvent
| RestoreBackupEvent;
IdleEvent | CreateBackupEvent | ReceiveBackupEvent | RestoreBackupEvent;
export type BackupSubscriptionEvent = ManagerStateEvent | UploadBackupEvent;
+1 -2
View File
@@ -4,9 +4,8 @@ import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
import type { RegistryEntry } from "./registry";
export interface CategoryRegistryEntry extends RegistryEntry {
export interface CategoryRegistryEntry {
category_id: string;
name: string;
icon: string | null;
+1
View File
@@ -64,6 +64,7 @@ export type ClimateEntity = HassEntityBase & {
swing_modes?: string[];
swing_horizontal_mode?: string;
swing_horizontal_modes?: string[];
aux_heat?: "on" | "off";
};
};
+5 -3
View File
@@ -1,6 +1,8 @@
import type { EntityDomainFilter } from "../common/entity/entity_domain_filter";
import type { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
interface CloudStatusNotLoggedIn {
logged_in: false;
cloud: "disconnected" | "connecting" | "connected";
@@ -19,8 +21,7 @@ export interface CloudPreferences {
alexa_enabled: boolean;
remote_enabled: boolean;
remote_allow_remote_enable: boolean;
alexa_default_expose: string[] | null;
google_default_expose: string[] | null;
strict_connection: StrictConnectionMode;
google_secure_devices_pin: string | undefined;
cloudhooks: Record<string, CloudWebhook>;
alexa_report_state: boolean;
@@ -41,7 +42,7 @@ export interface CloudStatusLoggedIn {
email: string;
google_registered: boolean;
google_entities: EntityDomainFilter;
google_local_connected: boolean;
google_domains: string[];
alexa_registered: boolean;
alexa_entities: EntityDomainFilter;
prefs: CloudPreferences;
@@ -172,6 +173,7 @@ export const updateCloudPref = (
google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"];
tts_default_voice?: CloudPreferences["tts_default_voice"];
remote_allow_remote_enable?: CloudPreferences["remote_allow_remote_enable"];
strict_connection?: CloudPreferences["strict_connection"];
cloud_ice_servers_enabled?: CloudPreferences["cloud_ice_servers_enabled"];
}
) =>
+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;
+3 -5
View File
@@ -1,9 +1,8 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { IntegrationType } from "./integration";
import type { RegistryEntry } from "./registry";
export interface ConfigEntry extends RegistryEntry {
export interface ConfigEntry {
entry_id: string;
domain: string;
title: string;
@@ -15,8 +14,7 @@ export interface ConfigEntry extends RegistryEntry {
| "setup_retry"
| "not_loaded"
| "failed_unload"
| "setup_in_progress"
| "unload_in_progress";
| "setup_in_progress";
supports_options: boolean;
supports_remove_device: boolean;
supports_unload: boolean;
@@ -35,7 +33,7 @@ export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string | null;
unique_id: string;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
+2 -10
View File
@@ -91,20 +91,12 @@ export const fetchConfigFlowInProgress = (
type: "config_entries/flow/progress",
});
interface ConfigFlowInProgressAddedMessage {
type: null | "added";
export interface ConfigFlowInProgressMessage {
type: null | "added" | "removed";
flow_id: string;
flow: DataEntryFlowProgress;
}
interface ConfigFlowInProgressRemovedMessage {
type: "removed";
flow_id: string;
}
export type ConfigFlowInProgressMessage =
ConfigFlowInProgressAddedMessage | ConfigFlowInProgressRemovedMessage;
export const subscribeConfigFlowInProgress = (
hass: HomeAssistant,
onChange: (update: ConfigFlowInProgressMessage[]) => void
+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,
+65 -3
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";
@@ -18,8 +19,6 @@ export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
config_entry_id: string | null;
config_subentry_id: string | null;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;
@@ -56,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[]
@@ -89,7 +151,7 @@ export const removeConfigEntryFromDevice = (
deviceId: string,
configEntryId: string
) =>
hass.callWS<DeviceRegistryEntry | null>({
hass.callWS<DeviceRegistryEntry>({
type: "config/device_registry/remove_config_entry",
device_id: deviceId,
config_entry_id: configEntryId,
+1 -1
View File
@@ -243,7 +243,7 @@ export interface EnergyInfo {
export interface EnergyValidationIssue {
type: string;
affected_entities: [string, unknown][];
translation_placeholders: Record<string, string> | null;
translation_placeholders: Record<string, string>;
}
export interface EnergyPreferencesValidation {
+9 -9
View File
@@ -37,7 +37,7 @@ export interface EntityRegistryDisplayEntryResponse {
ec?: number;
en?: string;
ic?: string;
pl: string;
pl?: string;
tk?: string;
hb?: boolean;
dp?: number;
@@ -58,21 +58,21 @@ export interface EntityRegistryEntry extends RegistryEntry {
area_id: string | null;
labels: string[];
disabled_by: "user" | "device" | "integration" | "config_entry" | null;
hidden_by: "user" | "integration" | null;
hidden_by: Exclude<EntityRegistryEntry["disabled_by"], "config_entry">;
entity_category: EntityCategory | null;
has_entity_name: boolean;
original_name: string | null;
original_name?: string;
unique_id: string;
translation_key: string | null;
options: EntityRegistryOptions;
translation_key?: string;
options: EntityRegistryOptions | null;
categories: Record<string, string>;
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
capabilities: Record<string, unknown> | null;
original_icon: string | null;
device_class: string | null;
original_device_class: string | null;
capabilities: Record<string, unknown>;
original_icon?: string;
device_class?: string;
original_device_class?: string;
aliases: (string | null)[];
}
@@ -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);
@@ -5,6 +5,7 @@ export enum ClimateEntityFeature {
FAN_MODE = 8,
PRESET_MODE = 16,
SWING_MODE = 32,
AUX_HEAT = 64,
TURN_OFF = 128,
TURN_ON = 256,
SWING_HORIZONTAL_MODE = 512,
@@ -19,7 +19,4 @@ export enum MediaPlayerEntityFeature {
BROWSE_MEDIA = 131072,
REPEAT_SET = 262144,
GROUPING = 524288,
MEDIA_ANNOUNCE = 1048576,
MEDIA_ENQUEUE = 2097152,
SEARCH_MEDIA = 4194304,
}
+1 -1
View File
@@ -35,7 +35,7 @@ export const updateFloorRegistryEntry = (
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<FloorRegistryEntry>({
hass.callWS<AreaRegistryEntry>({
type: "config/floor_registry/update",
floor_id: floorId,
...updates,
+4 -12
View File
@@ -17,7 +17,6 @@ export type AddonCapability = Exclude<
>;
export type AddonStage = "stable" | "experimental" | "deprecated";
export type AddonAppArmour = "disable" | "default" | "profile";
export type AddonBootConfig = "auto" | "manual" | "manual_only";
export type AddonRole = "default" | "homeassistant" | "manager" | "admin";
export type AddonStartup =
"initialize" | "system" | "services" | "application" | "once";
@@ -64,14 +63,13 @@ export interface HassioAddonDetails extends HassioAddonInfo {
audio_output: null | string;
audio: boolean;
auth_api: boolean;
auto_uart: boolean;
auto_update: boolean;
boot: "auto" | "manual";
boot_config: AddonBootConfig;
changelog: boolean;
devices: string[];
devicetree: boolean;
discovery: string[];
dns: string[];
docker_api: boolean;
documentation: boolean;
full_access: boolean;
@@ -84,24 +82,22 @@ export interface HassioAddonDetails extends HassioAddonInfo {
host_ipc: boolean;
host_network: boolean;
host_pid: boolean;
host_uts: boolean;
ingress_entry: null | string;
ingress_panel: boolean;
ingress_port: number | null;
ingress_url: null | string;
ingress: boolean;
ip_address: string;
kernel_modules: boolean;
long_description: null | string;
machine: string[];
machine: any;
network_description: null | Record<string, string>;
network: null | Record<string, number>;
options: Record<string, unknown>;
privileged: string[];
privileged: any;
protected: boolean;
rating: number;
schema: HaFormSchema[] | null;
services: string[];
services_role: string[];
signed: boolean;
slug: string;
startup: AddonStartup;
@@ -109,10 +105,6 @@ export interface HassioAddonDetails extends HassioAddonInfo {
system_managed: boolean;
system_managed_config_entry: string | null;
translations: Record<string, AddonTranslations>;
uart: boolean;
udev: boolean;
usb: boolean;
video: boolean;
watchdog: null | boolean;
webui: null | string;
}
+10 -27
View File
@@ -1,46 +1,29 @@
import type { HomeAssistant } from "../../types";
export interface HassioHostInfo {
agent_version: string | null;
apparmor_version: string | null;
boot_timestamp: number | null;
broadcast_llmnr: boolean | null;
broadcast_mdns: boolean | null;
chassis: string | null;
cpe: string | null;
deployment: string | null;
agent_version: string;
chassis: string;
cpe: string;
deployment: string;
disk_life_time: number | null;
disk_free: number;
disk_total: number;
disk_used: number;
dt_synchronized: boolean | null;
dt_utc: string;
features: string[];
hostname: string | null;
kernel: string | null;
llmnr_hostname: string | null;
operating_system: string | null;
startup_time: number | null;
timezone: string | null;
use_ntp: boolean | null;
virtualization: string | null;
}
export interface HassioHassOSBootSlot {
state: string | null;
status: string | null;
version: string | null;
hostname: string;
kernel: string;
operating_system: string;
boot_timestamp: number;
startup_time: number;
}
export interface HassioHassOSInfo {
board: string | null;
boot: string | null;
boot_slots: Record<string, HassioHassOSBootSlot>;
update_available: boolean;
version_latest: string | null;
version_pending: string | null;
version: string | null;
data_disk: string | null;
data_disk: string;
}
export interface Datadisk {
+2 -20
View File
@@ -1,28 +1,10 @@
import type { HomeAssistant, TranslationDict } from "../../types";
export interface ResolutionIssue {
type: string;
context: string;
reference: string | null;
reference_extra: Record<string, unknown> | null;
uuid: string;
}
export interface ResolutionSuggestion extends ResolutionIssue {
auto: boolean;
}
export interface ResolutionCheck {
enabled: boolean;
slug: string;
}
export interface HassioResolution {
unsupported: (keyof TranslationDict["ui"]["dialogs"]["unsupported"]["reasons"])[];
unhealthy: (keyof TranslationDict["ui"]["dialogs"]["unhealthy"]["reasons"])[];
issues: ResolutionIssue[];
suggestions: ResolutionSuggestion[];
checks: ResolutionCheck[];
issues: string[];
suggestions: string[];
}
export const fetchHassioResolution = async (
+5 -31
View File
@@ -1,6 +1,5 @@
import type { HomeAssistant, PanelInfo } from "../../types";
import type { SupervisorArch } from "../supervisor/supervisor";
import type { AddonState } from "./addon";
import type { HassioResponse } from "./common";
export interface HassioHomeAssistantInfo {
@@ -20,37 +19,14 @@ export interface HassioHomeAssistantInfo {
watchdog: boolean;
}
export interface HassioSupervisorAddonInfo {
name: string;
slug: string;
version: string | null;
version_latest: string;
update_available: boolean;
state: AddonState;
repository: string;
icon: boolean;
}
export interface HassioSupervisorRepositoryInfo {
name: string;
slug: string;
}
export type SupervisorFeatureFlag =
"supervisor_v2_api" | "supervisor_websocket_v2_api";
export interface HassioSupervisorInfo {
addons: HassioSupervisorAddonInfo[];
addons_repositories: HassioSupervisorRepositoryInfo[];
addons: string[];
addons_repositories: string[];
arch: SupervisorArch;
auto_update: boolean;
channel: string;
country: string | null;
debug: boolean;
debug_block: boolean;
detect_blocking_io: boolean;
diagnostics: boolean | null;
feature_flags: Record<SupervisorFeatureFlag, boolean>;
healthy: boolean;
ip_address: string;
logging: string;
@@ -59,7 +35,6 @@ export interface HassioSupervisorInfo {
update_available: boolean;
version: string;
version_latest: string;
/** @deprecated No longer used by the Supervisor */
wait_boot: number;
}
@@ -68,12 +43,11 @@ export interface HassioInfo {
channel: string;
docker: string;
features: string[];
hassos: string | null;
hassos: null;
homeassistant: string;
hostname: string | null;
hostname: string;
logging: string;
machine: string;
machine_id: string | null;
state:
| "initialize"
| "setup"
@@ -83,7 +57,7 @@ export interface HassioInfo {
| "shutdown"
| "stopping"
| "close";
operating_system: string | null;
operating_system: string;
supervisor: string;
supported: boolean;
supported_arch: SupervisorArch[];
+2 -8
View File
@@ -20,12 +20,6 @@ export const integrationsWithPanel = {
export type IntegrationType =
"device" | "helper" | "hub" | "service" | "hardware" | "entity" | "system";
/**
* `integration_type` as reported by manifests and brand data. Virtual
* integrations only exist as metadata, they never have config entries.
*/
export type ManifestIntegrationType = IntegrationType | "virtual";
export type DomainManifestLookup = Record<string, IntegrationManifest>;
export interface IntegrationManifest {
@@ -41,9 +35,9 @@ export interface IntegrationManifest {
codeowners?: string[];
requirements?: string[];
ssdp?: { manufacturer?: string; modelName?: string; st?: string }[];
zeroconf?: (string | Record<string, string>)[];
zeroconf?: string[];
homekit?: { models: string[] };
integration_type?: ManifestIntegrationType;
integration_type?: IntegrationType;
loggers?: string[];
quality_scale?:
| "bronze"
+2 -2
View File
@@ -1,10 +1,10 @@
import type { HomeAssistant } from "../types";
import type { ManifestIntegrationType } from "./integration";
import type { IntegrationType } from "./integration";
export type IotStandards = "zwave" | "zigbee" | "homekit" | "matter";
export interface Integration {
integration_type: ManifestIntegrationType;
integration_type: IntegrationType;
name?: string;
config_flow?: boolean;
iot_standards?: IotStandards[];
+4 -4
View File
@@ -27,17 +27,17 @@ export interface MatterFabricData {
fabric_id: number;
vendor_id: number;
fabric_index: number;
fabric_label: string | null;
vendor_name: string | null;
fabric_label?: string;
vendor_name?: string;
}
export interface MatterNodeDiagnostics {
node_id: number;
network_type: NetworkType;
node_type: NodeType;
network_name: string | null;
network_name?: string;
ip_adresses: string[];
mac_address: string | null;
mac_address?: string;
available: boolean;
active_fabrics: MatterFabricData[];
active_fabric_index: number;
+3 -3
View File
@@ -182,14 +182,14 @@ export interface MediaPlayerItem {
media_content_type: string;
media_content_id: string;
media_class: keyof TranslationDict["ui"]["components"]["media-browser"]["class"];
children_media_class: string | null;
children_media_class?: string | null;
can_play: boolean;
can_expand: boolean;
can_search: boolean;
search_media_classes:
search_media_classes?:
| (keyof TranslationDict["ui"]["components"]["media-browser"]["class"])[]
| null;
thumbnail: string | null;
thumbnail?: string;
iconPath?: string;
children?: MediaPlayerItem[];
not_shown?: number;
+1 -4
View File
@@ -47,12 +47,9 @@ export enum StatisticMeanType {
export interface StatisticsMetaData {
statistics_unit_of_measurement: string | null;
display_unit_of_measurement: string | null;
statistic_id: string;
source: string;
name: string | null;
/** @deprecated Use `mean_type` instead, removed in HA Core 2026.4 */
has_mean: boolean;
name?: string | null;
has_sum: boolean;
mean_type: StatisticMeanType;
unit_class: string | null;
-5
View File
@@ -7,11 +7,8 @@ export interface RelatedResult {
config_entry?: string[];
device?: string[];
entity?: string[];
floor?: string[];
group?: string[];
integration?: string[];
label?: string[];
person?: string[];
scene?: string[];
script?: string[];
script_blueprint?: string[];
@@ -32,9 +29,7 @@ export type ItemType =
| "entity"
| "floor"
| "group"
| "integration"
| "label"
| "person"
| "scene"
| "script"
| "automation_blueprint"
+2 -4
View File
@@ -12,11 +12,9 @@ export interface SpeechMetadata {
export interface STTEngine {
engine_id: string;
supported_languages: string[];
/** Only set for legacy providers. */
supported_languages?: string[];
name?: string;
/** Not currently set by stt/engine/list, unlike the tts equivalent. */
deprecated?: boolean;
deprecated: boolean;
}
export const listSTTEngines = (
+12 -27
View File
@@ -12,64 +12,49 @@ export enum SupervisorMountUsage {
SHARE = "share",
}
/** Mirrors the systemd unit active state the Supervisor reports. */
export enum SupervisorMountState {
ACTIVE = "active",
ACTIVATING = "activating",
DEACTIVATING = "deactivating",
FAILED = "failed",
INACTIVE = "inactive",
MAINTENANCE = "maintenance",
RELOADING = "reloading",
UNKNOWN = "unknown",
}
interface MountOptions {
default_backup_mount?: string | null;
}
/** `auto` is a UI-only value, it is stripped before the mount is submitted. */
export type CIFSVersion = "auto" | "1.0" | "2.0";
interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
read_only?: boolean;
server: string;
port?: number;
port: number;
}
interface SupervisorNFSMountBase extends SupervisorMountBase {
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
type: SupervisorMountType.NFS;
path: string;
}
interface SupervisorCIFSMountBase extends SupervisorMountBase {
export interface SupervisorCIFSMount extends SupervisorMountResponse {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion | null;
version?: CIFSVersion;
}
/** Fields the Supervisor adds to a configured mount when reporting it. */
export interface SupervisorMountResponse {
read_only: boolean;
state: SupervisorMountState | null;
user_path: string | null;
}
export type SupervisorNFSMount = SupervisorNFSMountBase &
SupervisorMountResponse;
export type SupervisorCIFSMount = SupervisorCIFSMountBase &
SupervisorMountResponse;
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMountBase;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMountBase {
export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
username?: string;
password?: string;
version?: CIFSVersion;
}
export type SupervisorMountRequestParams =
+6 -9
View File
@@ -1,15 +1,13 @@
import type { HomeAssistant } from "../../types";
import type { AddonAppArmour, AddonRole, AddonStage } from "../hassio/addon";
import type { AddonRole, AddonStage } from "../hassio/addon";
import { supervisorApiCall } from "./common";
import type { SupervisorArch } from "./supervisor";
export interface StoreAddon {
advanced: boolean;
arch: SupervisorArch[];
available: boolean;
build: boolean;
description: string;
documentation: boolean;
homeassistant: string | null;
icon: boolean;
installed: boolean;
@@ -19,20 +17,19 @@ export interface StoreAddon {
slug: string;
stage: AddonStage;
update_available: boolean;
url: string | null;
url: string;
version_latest: string;
// The Supervisor reports the installed version here, but the frontend only
// ever loads store details for add-ons that are not installed. Kept as `null`
// so it discriminates `StoreAddon` from `HassioAddonInfo`.
version: null;
}
export interface StoreAddonDetails extends StoreAddon {
apparmor: AddonAppArmour;
apparmor: boolean;
arch: SupervisorArch[];
auth_api: boolean;
changelog: boolean;
detached: boolean;
docker_api: boolean;
documentation: boolean;
full_access: boolean;
hassio_api: boolean;
hassio_role: AddonRole;
@@ -40,7 +37,7 @@ export interface StoreAddonDetails extends StoreAddon {
host_network: boolean;
host_pid: boolean;
ingress: boolean;
long_description: string | null;
long_description: string;
rating: number;
signed: boolean;
}
+1 -2
View File
@@ -5,8 +5,7 @@ export type SystemLogLevel =
export interface LoggedError {
name: string;
// The backend keeps up to 5 deduplicated messages per entry
message: string[];
message: [string];
level: SystemLogLevel;
source: [string, number];
exception: string;
+7 -23
View File
@@ -1,27 +1,10 @@
import type { HomeAssistant } from "../types";
/** Brands the backend maps a router's mDNS vendor name to. */
export type ThreadBrand =
| "amazon"
| "apple"
| "aqara_gateway"
| "eero"
| "glinet"
| "google"
| "homeassistant"
| "ikea"
| "nanoleaf"
| "openthread"
| "samsung"
| "smartthings"
| "smlight"
| "yeelight";
export interface ThreadRouter {
instance_name: string;
addresses: string[];
addresses: [string];
border_agent_id: string | null;
brand: ThreadBrand | null;
brand: "google" | "apple" | "homeassistant";
extended_address: string;
extended_pan_id: string;
model_name: string | null;
@@ -45,10 +28,11 @@ export interface ThreadDataSet {
source: string;
}
export type ThreadRouterDiscoveryEvent =
| { type: "router_discovered"; key: string; data: ThreadRouter }
// No data is sent when a router disappears
| { type: "router_removed"; key: string };
export interface ThreadRouterDiscoveryEvent {
key: string;
type: "router_discovered" | "router_removed";
data: ThreadRouter;
}
class DiscoveryStream {
routers: Record<string, ThreadRouter>;
+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;
+2 -4
View File
@@ -2,11 +2,9 @@ import type { HomeAssistant } from "../types";
export interface TTSEngine {
engine_id: string;
supported_languages: string[];
/** Only set for legacy providers superseded by an entity-based engine. */
deprecated?: boolean;
/** Only set for legacy providers. */
supported_languages?: string[];
name?: string;
deprecated: boolean;
}
export interface TTSVoice {
-1
View File
@@ -7,7 +7,6 @@ export enum WaterHeaterEntityFeature {
TARGET_TEMPERATURE = 1,
OPERATION_MODE = 2,
AWAY_MODE = 4,
ON_OFF = 8,
}
export const OPERATION_MODES = [
+9 -17
View File
@@ -12,40 +12,33 @@ export interface ZHADevice {
name: string;
ieee: string;
nwk: number;
lqi: number | null;
rssi: number | null;
lqi: number;
rssi: string;
last_seen: string;
manufacturer: string;
model: string;
quirk_applied: boolean;
quirk_class: string;
exposes_features: string[];
entities: ZHAEntityReference[];
endpoint_names: { name: string }[];
manufacturer_code: number | null;
manufacturer_code: number;
device_reg_id: string;
user_given_name?: string | null;
user_given_name?: string;
power_source?: string;
area_id?: string | null;
area_id?: string;
device_type: string;
active_coordinator: boolean;
signature: Record<string, unknown>;
signature: any;
neighbors: Neighbor[];
routes: Route[];
/** Only sent on device_joined / device_fully_initialized gateway events. */
pairing_status?: string;
}
export interface Neighbor {
device_type: string;
rx_on_when_idle: string;
relationship: string;
extended_pan_id: string;
ieee: string;
nwk: string;
permit_joining: string;
depth: string;
lqi: string;
depth: string;
relationship: string;
}
export interface Route {
@@ -189,7 +182,6 @@ export interface ZHANetworkBackupNetworkInfo {
}
export interface ZHANetworkBackup {
version: number;
backup_time: string;
network_info: ZHANetworkBackupNetworkInfo;
node_info: ZHANetworkBackupNodeInfo;
@@ -203,7 +195,7 @@ export interface ZHADeviceSettings {
export interface ZHANetworkSettings {
settings: ZHANetworkBackup;
radio_type: "ezsp" | "znp" | "deconz" | "ziggurat" | "zigate" | "xbee";
radio_type: "ezsp" | "znp" | "deconz" | "zigate" | "xbee";
device: ZHADeviceSettings;
}
-11
View File
@@ -15,15 +15,6 @@ export enum InclusionState {
SmartStart,
}
export enum ControllerStatus {
/** The controller is ready to accept commands and transmit. */
Ready,
/** The controller is unresponsive. */
Unresponsive,
/** The controller is unable to transmit because the airwaves are jammed. */
Jammed,
}
export const enum InclusionStrategy {
/**
* Always uses Security S2 if supported, otherwise uses Security S0 for certain devices which don't work without encryption and uses no encryption otherwise.
@@ -204,7 +195,6 @@ export interface ZWaveJSClient {
ws_server_url: string;
server_version: string;
driver_version: string;
server_logging_enabled: boolean;
}
export interface ZWaveJSController {
@@ -228,7 +218,6 @@ export interface ZWaveJSController {
supports_timers: boolean;
is_rebuilding_routes: boolean;
inclusion_state: InclusionState;
status: ControllerStatus;
nodes: ZWaveJSNodeStatus[];
supports_long_range: boolean;
}
+13
View File
@@ -619,6 +619,19 @@ export const demoServices: HassServices = {
},
},
climate: {
set_aux_heat: {
description: "Turn auxiliary heater on/off for climate device.",
fields: {
entity_id: {
description: "Name(s) of entities to change.",
example: "climate.kitchen",
},
aux_heat: {
description: "New value of axillary heater.",
example: "true",
},
},
},
set_temperature: {
description: "Set target temperature of climate device.",
fields: {
+10
View File
@@ -100,6 +100,11 @@ export class MockClimateEntity extends MockBaseEntity {
return;
}
if (service === "set_aux_heat") {
this.update({ attributes: { aux_heat: data.aux_heat } });
return;
}
super.handleService(domain, service, data);
}
@@ -222,6 +227,11 @@ export class MockClimateEntity extends MockBaseEntity {
: (attrs.swing_horizontal_mode ?? null);
}
// Aux heat
if (supportsFeatureFromAttributes(attrs, ClimateEntityFeature.AUX_HEAT)) {
stateAttrs.aux_heat = isOff ? null : (attrs.aux_heat ?? null);
}
return stateAttrs;
}
+18 -1
View File
@@ -5,6 +5,7 @@ import memoizeOne from "memoize-one";
import { navigate } from "../common/navigate";
import { computeRouteTail } from "../common/url/route";
import type { Route } from "../types";
import { PanelReady } from "./panel-ready";
const extractPage = (path: string, defaultPage: string) => {
if (path === "") {
@@ -22,6 +23,7 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
waitForReady?: boolean;
}
export interface RouterOptions {
@@ -53,6 +55,8 @@ export class HassRouterPage extends ReactiveElement {
private _currentLoadProm?: Promise<void>;
private _panelReady = new PanelReady();
private _cache = {};
private _initialLoadDone = false;
@@ -180,6 +184,10 @@ export class HassRouterPage extends ReactiveElement {
// If we don't show loading screen, just show the panel.
// It will be automatically upgraded when loading done.
if (!routerOptions.showLoading) {
const loadComplete = () => {
this._currentLoadProm = undefined;
};
this._currentLoadProm = loadProm.then(loadComplete, loadComplete);
this._createPanel(routerOptions, newPage, routeOptions);
return;
}
@@ -287,7 +295,15 @@ export class HassRouterPage extends ReactiveElement {
* Promise that resolves when the page has rendered.
*/
protected get pageRendered(): Promise<void> {
return this.updateComplete.then(() => this._currentLoadProm);
return this.updateComplete
.then(() => this._currentLoadProm)
.then(() => {
const page = this.lastElementChild;
return Promise.all([
this._panelReady.ready,
page instanceof HassRouterPage ? page.pageRendered : undefined,
]).then(() => undefined);
});
}
protected createElement(tag: string) {
@@ -312,6 +328,7 @@ export class HassRouterPage extends ReactiveElement {
}
const panelEl = this._cache[page] || this.createElement(routeOptions.tag);
this._panelReady.track(panelEl, routeOptions.waitForReady);
this.updatePageEl(panelEl);
this.appendChild(panelEl);
+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`
+4 -14
View File
@@ -17,10 +17,7 @@ import { HassElement } from "../state/hass-element";
import QuickBarMixin from "../state/quick-bar-mixin";
import type { HomeAssistant, Route } from "../types";
import { storeState } from "../util/ha-pref-storage";
import {
removeLaunchScreen,
renderLaunchScreenContent,
} from "../util/launch-screen";
import { renderLaunchScreenContent } from "../util/launch-screen";
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
import {
registerServiceWorker,
@@ -136,16 +133,9 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
) {
this.render = this.renderHass;
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
// partial-panel-resolver removes the launch screen after the first panel
// is ready. Native apps request instant removal because their own splash
// screen covers the frontend until frontend/loaded is sent.
}
super.update(changedProps);
}
+30
View File
@@ -0,0 +1,30 @@
import { ReactiveElement } from "lit";
import { fireEvent } from "../common/dom/fire_event";
declare global {
interface HASSDomEvents {
"hass-panel-ready": undefined;
}
}
export const panelIsReady = async (element: HTMLElement) => {
if (element instanceof ReactiveElement) {
// Ensure pending Lit changes are rendered before revealing the panel.
await element.updateComplete;
}
fireEvent(element, "hass-panel-ready");
};
export class PanelReady {
public ready?: Promise<void>;
public track(element: HTMLElement, waitForReady = false) {
this.ready = waitForReady
? new Promise((resolve) => {
element.addEventListener("hass-panel-ready", () => resolve(), {
once: true,
});
})
: undefined;
}
}
+7 -2
View File
@@ -8,6 +8,7 @@ import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { deepEqual } from "../common/util/deep-equal";
import { promiseTimeout } from "../common/util/promise-timeout";
import { narrowViewportContext } from "../data/context";
import { getDefaultPanel } from "../data/panel";
import type { CustomPanelInfo } from "../data/panel_custom";
@@ -17,6 +18,7 @@ import type { RouteOptions, RouterOptions } from "./hass-router-page";
import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const PANEL_READY_TIMEOUT = 2000;
const COMPONENTS = {
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
@@ -219,9 +221,12 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
await this.pageRendered;
await promiseTimeout(PANEL_READY_TIMEOUT, this.pageRendered).catch(
() => undefined
);
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
// screen, so later panel updates do not fire it again. Native apps remove
// it instantly because their own splash screen is still visible.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
@@ -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;
@@ -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;
@@ -91,7 +91,7 @@ export class HaBackupDataPicker extends LitElement {
`ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
),
id: "config",
version: data.homeassistant_version ?? undefined,
version: data.homeassistant_version,
});
}
items.push(
@@ -133,9 +133,9 @@ export class HaBackupDataPicker extends LitElement {
addonIcons: Record<string, boolean>
) =>
data.addons.map<BackupAddonItem>((addon) => ({
name: addon.name || addon.slug,
name: addon.name,
slug: addon.slug,
version: addon.version ?? undefined,
version: addon.version,
icon: addonIcons[addon.slug],
}))
);
@@ -85,7 +85,7 @@ export class HaBackupOverviewProgress extends LitElement {
private get _heading() {
const managerState = this.manager.manager_state;
if (managerState === "idle" || managerState === "blocked") {
if (managerState === "idle") {
return "";
}
return this.hass.localize(
@@ -94,10 +94,7 @@ export class HaBackupOverviewProgress extends LitElement {
}
private get _isUploadStage(): boolean {
if (
this.manager.manager_state === "idle" ||
this.manager.manager_state === "blocked"
) {
if (this.manager.manager_state === "idle") {
return false;
}
return this.manager.stage === "upload_to_agents";
@@ -314,7 +314,7 @@ class HaConfigBackupDetails extends LitElement {
try {
const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup;
this._agents = response.backup ? computeAgents(response.backup) : [];
this._agents = computeAgents(response.backup);
} catch (err: any) {
this._error =
err?.message ||
@@ -39,7 +39,7 @@ export class CloudRemotePref extends LitElement {
return nothing;
}
const { remote_enabled, remote_allow_remote_enable } =
const { remote_enabled, remote_allow_remote_enable, strict_connection } =
this.cloudStatus.prefs;
const {
@@ -120,7 +120,17 @@ export class CloudRemotePref extends LitElement {
)}
></ha-alert>
`
: nothing
: strict_connection === "drop_connection"
? html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning_title`
)}
>${this.hass.localize(
`ui.panel.config.cloud.account.remote.drop_connection_warning`
)}</ha-alert
>`
: nothing
}
<p>
${this.hass.localize(
+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,215 @@
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 { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
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 { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@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;
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;
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
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() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</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;
}
}

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