Compare commits

..
Author SHA1 Message Date
Petar Petrov b6ec0cc537 Only scan the area's entities in the area controls card feature 2026-11-05 09:18:46 +02:00
JonathanandXyolyp e97745d7d5 Filter the device classes by the chosen unit (#55422)
* Filter the device classes by the chosen unit

* Keep unsupported device classes selectable

* Only preselect a required device class and keep the default rows

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:57:19 +02:00
Mr-Hell 790c957405 Add device class column, search and filter to entities table (#55366)
* Add device class column and filter to entities table

* Localize device classes and preserve redirect filters

* Fix import conflict after rebase

* Refactor device class filter based on review feedback

* Avoid rebuilding device class options while filter is collapsed

* Address final device class review feedback

* Use shared device class translation key for filter
2026-11-05 06:26:31 +00:00
JonathanandXyolyp f740091579 Prevent a double Finish click in the config flow success step (#55616)
* Prevent a double Finish click in the config flow success step

* Keep only the double-click test and stub ha-input

---------

Co-authored-by: Xyolyp <[email protected]>
2026-11-05 08:19:55 +02:00
Simon Lamon f76c88dd9f Clean up check list item based on mwc-list (#55643) 2026-11-05 08:19:18 +02:00
renovate[bot] a428d61cdb Update formatjs (#55644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 01:13:34 +00:00
Bram KragtenandClaude Opus 5.5 fcf7eeae46 Use the preloaded Lovelace config again (#55626)
Since the "lovelace" dashboard got a real url_path (#28446), the panel threw
the config preloaded in core.ts away and fetched it a second time on every
page load at / or /lovelace/.

Use the preload for the "lovelace" dashboard and fetch again if it failed.
Only preload the config on /lovelace/, as / opens the default panel, which
is not known yet and is home for new installs.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 20:47:32 +01:00
6d2817b7cf Migrate the Cast launcher view picker (#55634)
* Migrate the Cast launcher view picker from ha-list

The view picker in the Cast launcher was the last use of the mwc-based
list outside of open pull requests. It is now a single-select
ha-list-selectable.

Views without an icon get the dashboard icon in the start slot, which
mwc never showed because it was put in a slot ha-list-item does not
have. As every row has an icon when any view has one, the hide-icons
attribute is no longer needed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ln7qMagr2YDdssKkaBiqf5

* Enhance view selection in HcCast component with accessible header and click handling

* Type Cast view click event target

Co-authored-by: wendevlin <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-04 19:46:53 +00:00
Maarten Lakerveld 640e4e2f94 Resolve security and maintenance entities through one helper per page (#55609)
The security page, the maintenance page, the Home summary badges and
the Home overview each rebuilt the same filter functions and ran
findEntities over every state. Add findFilteredEntities, which takes
the states and registries as one object, and a findSecurityEntities
and findMaintenanceEntities wrapper so every consumer resolves a
page's entity set the same way. No behaviour change.
2026-11-04 20:32:36 +01:00
renovate[bot] 6670139c02 Update dependency @bundle-stats/plugin-webpack-filter to v4.23.0 (#55642)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 20:13:39 +02:00
Erwin DounaandCopilot Autofix powered by AI 93eb44b041 Offer log download after disabling debug logging (#55621)
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-04 16:49:28 +01:00
Joost LekkerkerkerandClaude Opus 5.5 ba6067f048 Label marketplace open issue count as issues and pull requests (#55635)
* Label marketplace open issue count as issues and pull requests

The GitHub open issues count includes pull requests. Rename the
translation keys and update the English strings so the label matches
the number shown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Update marketplace tests for the renamed open issues keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 17:22:53 +02:00
renovate[bot] cd866a497a Update dependency globals to v17.14.0 (#55633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 17:20:30 +02:00
98a8483426 onboarding-location migrate list (#55467)
* onboarding-location migrate list

* Apply batched suggestions from code review

Co-authored-by: Aidan Timson <[email protected]>

* fix tests and review

---------

Co-authored-by: Aidan Timson <[email protected]>
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-11-04 15:39:56 +02:00
33af67bafe Respect safe area insets on the onboarding page (#55623)
* Respect safe area insets on the onboarding page

* Update src/html/onboarding.html.template

Co-authored-by: Wendelin <[email protected]>

* Update src/html/onboarding.html.template

Co-authored-by: Maarten Lakerveld <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
Co-authored-by: Maarten Lakerveld <[email protected]>
2026-11-04 12:58:51 +00:00
Bram KragtenandClaude Opus 5.5 027d9578ec Match the entity filter in the states tool to the entity column (#55622)
The entity filter matched the full name and the friendly name, so typing a
device name returned entities whose entity column showed no match. It now
matches the entity ID and the name the entity column shows. When the device
column is hidden, it still matches the full name.

Fixes #55451

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:54:05 +02:00
Bram KragtenandClaude Opus 5.5 5fef8f3a68 Refetch the cached lovelace dashboard after it changed elsewhere (#55624)
The "lovelace" panel is cached by the panel resolver, so it should stay
subscribed to config updates while the user is on another page. Since the
urlPath getter returns "lovelace" instead of null, the null check in
disconnectedCallback always unsubscribed it, and nothing resubscribed on
reconnect. Changes saved from another page, like adding a device section
from the device page, only showed after a browser refresh.

Also reset the refetch flag once used, so returning to the dashboard
without a change doesn't refetch the config again, and let a pending
refetch take precedence over the locale rebuild, which would otherwise
rebuild from the stale config.

Fixes #55440

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-04 14:53:35 +02:00
Wendelin 18e65c1940 Show a loading skeleton while the card editor loads (#55625) 2026-11-04 11:22:55 +00:00
38 changed files with 942 additions and 341 deletions

No files matched your search

+42 -49
View File
@@ -1,7 +1,7 @@
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { CastManager } from "../../../../src/cast/cast_manager";
import {
@@ -13,12 +13,14 @@ import {
enableWrite,
saveTokens,
} from "../../../../src/common/auth/token_storage";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { atLeastVersion } from "../../../../src/common/config/version";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-icon";
import "../../../../src/components/ha-list";
import "../../../../src/components/ha-list-item";
import "../../../../src/components/ha-svg-icon";
import "../../../../src/components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-selectable";
import {
getLegacyLovelaceCollection,
getLovelaceCollection,
@@ -52,6 +54,9 @@ class HcCast extends LitElement {
`
: undefined;
const views = this.lovelaceViews ?? [{ title: "Home" }];
const hasIcons = views.some((view) => view.icon);
return html`
<hc-layout .auth=${this.auth} .connection=${this.connection}>
${
@@ -92,45 +97,41 @@ class HcCast extends LitElement {
</p>
`
: html`
<div class="section-header">PICK A VIEW</div>
<ha-list @action=${this._handlePickView} activatable>
${(
this.lovelaceViews ?? [
{
title: "Home",
},
]
).map(
<div class="section-header" id="pick-view-header">
PICK A VIEW
</div>
<ha-list-selectable
controlled
aria-labelledby="pick-view-header"
>
${views.map(
(view, idx) => html`
<ha-list-item
graphic="avatar"
.activated=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
<ha-list-item-option
data-index=${idx}
@click=${this._handlePickView}
.headline=${view.title || view.path || "Unnamed view"}
.selected=${
this.castManager.status?.lovelacePath ===
(view.path ?? idx)
}
>
${view.title || view.path || "Unnamed view"}
${
view.icon
? html`
<ha-icon
!hasIcons
? nothing
: view.icon
? html`<ha-icon
slot="start"
.icon=${view.icon}
slot="graphic"
></ha-icon>
`
: html`<ha-svg-icon
slot="item-icon"
.path=${mdiViewDashboard}
></ha-svg-icon>`
></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiViewDashboard}
></ha-svg-icon>`
}
</ha-list-item>
</ha-list-item-option>
`
)}</ha-list
>
)}
</ha-list-selectable>
`
}
@@ -193,14 +194,6 @@ class HcCast extends LitElement {
});
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
this.toggleAttribute(
"hide-icons",
this.lovelaceViews ? !this.lovelaceViews.some((view) => view.icon) : true
);
}
private async _handleSkipSaveTokens() {
this.askWrite = false;
}
@@ -214,8 +207,13 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
// Listens to clicks instead of `ha-list-item-selected`, which does not fire
// for the selected view, so the current view can still be cast again.
private async _handlePickView(
ev: HASSDomCurrentTargetEvent<HaListItemOption>
) {
const index = Number(ev.currentTarget.dataset.index);
const path = this.lovelaceViews?.[index]?.path ?? index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
}
@@ -270,16 +268,11 @@ class HcCast extends LitElement {
color: var(--secondary-text-color);
}
ha-list-item ha-icon,
ha-list-item ha-svg-icon {
padding: 12px;
ha-list-item-option ha-icon,
ha-list-item-option ha-svg-icon {
color: var(--secondary-text-color);
}
:host([hide-icons]) ha-icon {
display: none;
}
.spacer {
flex: 1;
}
+7 -2
View File
@@ -683,8 +683,13 @@ const SCHEMAS: {
name: "Unit of measurement",
input: {
sensor_device_class: {
name: "Device class",
selector: { device_class: { domain: "sensor" } },
name: "Device class of the unit below",
selector: {
device_class: {
domain: "sensor",
context: { filter_unit_of_measurement: "unit_linked" },
},
},
},
sensor_state_class: {
name: "State class",
+6 -6
View File
@@ -54,14 +54,14 @@
"@codemirror/view": "6.43.15",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "8.0.1",
"@formatjs/intl-datetimeformat": "8.0.2",
"@formatjs/intl-displaynames": "8.0.1",
"@formatjs/intl-durationformat": "0.12.2",
"@formatjs/intl-durationformat": "0.12.3",
"@formatjs/intl-getcanonicallocales": "3.3.0",
"@formatjs/intl-listformat": "9.0.1",
"@formatjs/intl-locale": "5.4.2",
"@formatjs/intl-numberformat": "10.0.1",
"@formatjs/intl-pluralrules": "7.0.1",
"@formatjs/intl-numberformat": "10.0.2",
"@formatjs/intl-pluralrules": "7.0.2",
"@formatjs/intl-relativetimeformat": "13.0.1",
"@home-assistant/webawesome": "3.14.0-ha.2",
"@lezer/common": "1.5.5",
@@ -144,7 +144,7 @@
"@babel/plugin-transform-private-methods": "8.0.1",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@bundle-stats/plugin-webpack-filter": "4.23.0",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
@@ -189,7 +189,7 @@
"fs-extra": "11.4.1",
"generate-license-file": "4.2.5",
"glob": "14.0.3",
"globals": "17.13.0",
"globals": "17.14.0",
"gulp": "5.0.1",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.1.0",
+37 -37
View File
@@ -223,14 +223,14 @@ importers:
specifier: 2.0.17
version: 2.0.17
'@formatjs/intl-datetimeformat':
specifier: 8.0.1
version: 8.0.1
specifier: 8.0.2
version: 8.0.2
'@formatjs/intl-displaynames':
specifier: 8.0.1
version: 8.0.1
'@formatjs/intl-durationformat':
specifier: 0.12.2
version: 0.12.2
specifier: 0.12.3
version: 0.12.3
'@formatjs/intl-getcanonicallocales':
specifier: 3.3.0
version: 3.3.0
@@ -241,11 +241,11 @@ importers:
specifier: 5.4.2
version: 5.4.2
'@formatjs/intl-numberformat':
specifier: 10.0.1
version: 10.0.1
specifier: 10.0.2
version: 10.0.2
'@formatjs/intl-pluralrules':
specifier: 7.0.1
version: 7.0.1
specifier: 7.0.2
version: 7.0.2
'@formatjs/intl-relativetimeformat':
specifier: 13.0.1
version: 13.0.1
@@ -488,8 +488,8 @@ importers:
specifier: 8.0.7
version: 8.0.7(@babel/[email protected])
'@bundle-stats/plugin-webpack-filter':
specifier: 4.22.3
version: 4.22.3([email protected])
specifier: 4.23.0
version: 4.23.0([email protected])
'@eslint/js':
specifier: 10.0.1
version: 10.0.1([email protected]([email protected]))
@@ -623,8 +623,8 @@ importers:
specifier: 14.0.3
version: 14.0.3
globals:
specifier: 17.13.0
version: 17.13.0
specifier: 17.14.0
version: 17.14.0
gulp:
specifier: 5.0.1
version: 5.0.1
@@ -1740,8 +1740,8 @@ packages:
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@bundle-stats/[email protected]2.3':
resolution: {integrity: sha512-tDYUsXFf1LLwaYF2v7ntWjDx1weh4PCEgNtySzNJgf+AwcC8WPydjad1TiGclLG3XuqUi/ZgTW+7tn/lhAZqCg==}
'@bundle-stats/[email protected]3.0':
resolution: {integrity: sha512-tqPJqUmyev0Kef8qPWcaAwvJ0c/CAClj0EKwJeSFnED4zYprjb7j5Tk8e6i4gJBNFp4KC+5gVgxqhZYoIx08ww==}
engines: {node: '>= 14.0'}
peerDependencies:
core-js: ^3.0.0
@@ -1929,8 +1929,8 @@ packages:
'@floating-ui/[email protected]':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@formatjs/[email protected].9':
resolution: {integrity: sha512-rV5U95Koz9LADq080OdPOLy629Z1HNaid3842EORxm+0Q7QhvUalH7rgVAK3Zsa9pbmNvac/mNmkP7+xf8wYwg==}
'@formatjs/[email protected].10':
resolution: {integrity: sha512-npIs6UARNOg5amJY5UOVsd6HUQQfzYjet0sa0SWIqzsnVhxLSpaIx2DjoIVrr/vqmjURjvroeW6dItjfy3Uf8A==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-qrycXDeaORzIqNhBOx0btnhpD1c+/qFIHAN9znofuMJX6QBwtbrmlpWfD4oiUUD2vJUOIYFA/gYtg2KAMGG7sA==}
@@ -1953,14 +1953,14 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-NSgDp17ZaXNkSuKpvSfzh5bzV0x6i4KlQKgv8fEfLobkL/spFRBrY/WzWJAeangH18bcmAVoHXgz1Uyz9l7KRw==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-isKDo8jeQ8VoYvrweku92ajF/Z9fJ76DDnw2eCmKBTJTwZa5GeGl4u40RJiFYPbR6ROluvN0CsbNtGtnd2NyIQ==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-8RTQvKIGx+SAqI+bvVrrupwzeEZGGzdzJwVbZaK9U5VKuVo34LOFOkn7GX9KfwHuVIKxM7pKv8yYTs6UIkeyTA==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-qQzwm47mlPFRGv0vl7xBzzy6xdUQSo+1bIJqy05vxRCT4J0CXu7U0LUHYtW/LMY7o2bCyWi9ZHhMtF7roOLXkg==}
'@formatjs/[email protected].3':
resolution: {integrity: sha512-cpyMSdAZxRXVmA0TjZZn38ERjfxV8Kc1nDcp1RBAXyU6pB7Mgox+o3dVUnPLsNfblW5OAr1o281IyPRrod/T6g==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-ndj9jw/LHAftSe34D/A6+lj3Y7GHVfPEZ6FZP2wjd8/beYeh3d1sjlSpZSBlzegSKS3U3mUEIzLCpI0+jKNT6w==}
@@ -1977,11 +1977,11 @@ packages:
'@formatjs/[email protected]':
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-P0PUpnUZz9Gy70EYYdUVVgDqd4H/dBRiO0k82hoYWS0mlxcjAjQica63sVJIO8+892bd80ybjZC4QZHVXHh+Rg==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-iLS3ly1lGZ/a/IVpcSUgC37RjQUZ2wN6zR9+Zbr2uXsxATfMLD+fBqggiXuFudLmZXpbPueCs4Mz3vDsaOVE9A==}
'@formatjs/[email protected].1':
resolution: {integrity: sha512-Ca0gf/5lplUfDXl0OATgcQIogrGJzXTZPBz8xx1tnkALuibvS5fA71h+V0ghnUJsp1cA4PyPVj5LkbrlyhVvGw==}
'@formatjs/[email protected].2':
resolution: {integrity: sha512-PJ4+ldlqXlo3csmP02u2HqyXw09TDB/fatYpDCyWryfEQeVWl2EbMzij9SBksPaW2U//+q69lXR3IMnCRQAmrw==}
'@formatjs/[email protected]':
resolution: {integrity: sha512-Gl5dCHb7elEC19tTzxIyllWLHaHnN+HpQYEaA9xxAt9rKgRLwC3vGty1w8bVGG7p2YcGSPIg5c4qn4KdkGcTqg==}
@@ -5126,8 +5126,8 @@ packages:
resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==}
engines: {node: '>=0.10.0'}
[email protected]3.0:
resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==}
[email protected]4.0:
resolution: {integrity: sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==}
engines: {node: '>=18'}
[email protected]:
@@ -9135,7 +9135,7 @@ snapshots:
dependencies:
css-tree: 3.2.1
'@bundle-stats/[email protected]2.3([email protected])':
'@bundle-stats/[email protected]3.0([email protected])':
dependencies:
core-js: 3.51.0
tslib: 2.9.1
@@ -9384,7 +9384,7 @@ snapshots:
'@floating-ui/[email protected]': {}
'@formatjs/[email protected].9': {}
'@formatjs/[email protected].10': {}
'@formatjs/[email protected]':
dependencies:
@@ -9416,18 +9416,18 @@ snapshots:
'@formatjs/[email protected]': {}
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]':
dependencies:
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].2':
'@formatjs/[email protected].3':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]': {}
@@ -9449,14 +9449,14 @@ snapshots:
dependencies:
tslib: 2.9.1
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected].1':
'@formatjs/[email protected].2':
dependencies:
'@formatjs/bigdecimal': 0.2.9
'@formatjs/bigdecimal': 0.2.10
'@formatjs/intl-localematcher': 0.10.1
'@formatjs/[email protected]':
@@ -12889,7 +12889,7 @@ snapshots:
is-windows: 1.0.2
which: 1.3.1
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]:
dependencies:
+22
View File
@@ -20,6 +20,11 @@ export interface EntityFilter {
export type EntityFilterFunc = (entityId: string) => boolean;
export type EntityFilterSources = Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>;
const normalizeFilterArray = <T>(
value: T | null | T[] | (T | null)[] | undefined
): Set<T | null> | undefined => {
@@ -157,3 +162,20 @@ export const findEntities = (
return results;
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
sources.states,
sources.entities,
sources.devices,
sources.areas,
sources.floors,
filter
)
);
return findEntities(Object.keys(sources.states), filterFuncs);
};
+3
View File
@@ -20,6 +20,7 @@ export const entitiesFilterQueryParamConfig = {
"floor",
"area",
"device",
"device_class",
"domain",
"entity_domain",
"label",
@@ -50,6 +51,7 @@ export const entitiesFilterQueryParams = (
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
device: list(filters["ha-filter-devices"]),
device_class: list(filters["ha-filter-device-classes"]),
domain: list(filters["ha-filter-integrations"]),
entity_domain: list(filters["ha-filter-domains"]),
label: list(filters["ha-filter-labels"]),
@@ -114,6 +116,7 @@ export const entitiesLinkFromUrl = (
"ha-filter-floor-areas":
floors.length || areas.length ? { floors, areas } : undefined,
"ha-filter-devices": values("device"),
"ha-filter-device-classes": values("device_class"),
"ha-filter-integrations": values("domain"),
"ha-filter-domains": values("entity_domain"),
"ha-filter-labels": values("label"),
-93
View File
@@ -1,93 +0,0 @@
import { CheckListItemBase } from "@material/mwc-list/mwc-check-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { preventDefault } from "../common/dom/prevent_default";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-checkbox";
@customElement("ha-check-list-item")
export class HaCheckListItem extends CheckListItemBase {
@property({ type: Boolean, attribute: "checkbox-disabled" })
checkboxDisabled = false;
@property({ type: Boolean })
indeterminate = false;
@property({ type: Boolean, attribute: "separate-checkbox-click" })
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
override render() {
const text = this.renderText();
const graphic =
this.graphic && this.graphic !== "control" && !this.left
? this.renderGraphic()
: nothing;
const meta = this.hasMeta && this.left ? this.renderMeta() : nothing;
const ripple = this.renderRipple();
return html` ${ripple} ${graphic} ${this.left ? "" : text}
<ha-checkbox
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
.checked=${this.selected}
.indeterminate=${this.indeterminate}
?disabled=${this.disabled || this.checkboxDisabled}
@change=${this.onChange}
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
class=${this.left ? "left" : ""}
>
</ha-checkbox>
${this.left ? text : ""} ${meta}`;
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
ha-checkbox {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
height: 100%;
justify-content: center;
}
ha-checkbox.left {
margin-inline-start: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--check-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-check-list-item": HaCheckListItem;
}
}
+65 -4
View File
@@ -5,10 +5,13 @@ import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext } from "../data/context";
import { DOMAIN_DEVICE_CLASSES } from "../data/device_classes";
import { computeDeviceClassName } from "../data/entity/device_class";
import { computeUnitDeviceClasses } from "../data/sensor/unit_of_measurement";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type {
HomeAssistantInternationalization,
ValueChangedEvent,
@@ -27,6 +30,13 @@ export const getDeviceClassOptions = (
return { id: deviceClass, primary, sorting_label: primary };
});
// The reason line of a device class that does not support the unit names the
// unit, so searching it would match every unsupported device class
const UNIT_FILTER_SEARCH_KEYS: FuseWeightedKey[] = [
{ name: "primary", weight: 10 },
{ name: "id", weight: 3 },
];
@customElement("ha-device-class-picker")
export class HaDeviceClassPicker extends LitElement {
@property() public domain?: string;
@@ -43,6 +53,9 @@ export class HaDeviceClassPicker extends LitElement {
@property({ type: Boolean }) public required = false;
/** Unit of measurement: device classes that do not support it are listed last */
@property() public unit?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -70,18 +83,60 @@ export class HaDeviceClassPicker extends LitElement {
private _options = memoizeOne(
(
domain: string | undefined,
localize: LocalizeFunc | undefined
): PickerComboBoxItem[] =>
domain && localize ? getDeviceClassOptions(domain, localize) : []
localize: LocalizeFunc | undefined,
unit: string | undefined,
language: string | undefined
): PickerComboBoxItem[] => {
if (!domain || !localize) {
return [];
}
const options = getDeviceClassOptions(domain, localize);
const fitting = computeUnitDeviceClasses(domain, unit);
if (!fitting) {
return options;
}
// Device classes of the unit first, then those that accept any unit
const rank = (deviceClass: string) =>
fitting.matching.includes(deviceClass)
? 0
: fitting.unrestricted.includes(deviceClass)
? 1
: 2;
const notSupported = localize(
"ui.components.device-class-picker.unit_not_supported",
{ unit }
);
return options
.map((option) =>
rank(option.id) === 2
? { ...option, secondary: notSupported }
: option
)
.sort(
(a, b) =>
rank(a.id) - rank(b.id) ||
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
}
);
// The options are sorted here when the unit filters them
private get _filtered(): boolean {
return computeUnitDeviceClasses(this.domain, this.unit) !== undefined;
}
private _availableOptions = memoizeOne(
(options: PickerComboBoxItem[], selected: string[]) =>
options.filter((option) => !selected.includes(option.id))
);
private _getItems = () => {
const options = this._options(this.domain, this._i18n?.localize);
const options = this._options(
this.domain,
this._i18n?.localize,
this.unit,
this._i18n?.language
);
return this.multiple
? this._availableOptions(options, this._value)
: options;
@@ -104,6 +159,8 @@ export class HaDeviceClassPicker extends LitElement {
const emptyLabel = localize?.(
"ui.components.device-class-picker.no_device_classes"
);
const filtered = this._filtered;
const searchKeys = filtered ? UNIT_FILTER_SEARCH_KEYS : undefined;
if (this.multiple) {
const value = this._value;
@@ -145,6 +202,8 @@ export class HaDeviceClassPicker extends LitElement {
.getItems=${this._getItems}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._itemAdded}
></ha-generic-picker>
`;
@@ -164,6 +223,8 @@ export class HaDeviceClassPicker extends LitElement {
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${emptyLabel}
.noSort=${filtered}
.searchKeys=${searchKeys}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
@@ -0,0 +1,78 @@
import type { ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { computeDomain } from "../common/entity/compute_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import { stringCompare } from "../common/string/compare";
import { internationalizationContext, statesContext } from "../data/context";
import { computeDeviceClassName } from "../data/entity/device_class";
import { HaFilterStates } from "./ha-filter-states";
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends HaFilterStates {
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates?: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string
) => {
const classes = new Map<string, string>();
for (const [entityId, stateObj] of Object.entries(states)) {
const deviceClass = stateObj.attributes.device_class;
if (typeof deviceClass !== "string" || classes.has(deviceClass)) {
continue;
}
classes.set(
deviceClass,
computeDeviceClassName(localize, computeDomain(entityId), deviceClass)
);
}
return [...classes]
.map(([value, label]) => ({ value, label }))
.sort((a, b) => stringCompare(a.label, b.label, language));
}
);
protected firstUpdated() {
this._i18n?.loadBackendTranslation("entity_component");
}
protected willUpdate() {
if (
this._entityStates &&
this._localize &&
this._i18n &&
(this.expanded || !this.states)
) {
this.states = this._deviceClasses(
this._entityStates,
this._localize,
this._i18n.locale.language
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
@@ -1,6 +1,9 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { DeviceClassSelector } from "../../data/selector";
import { computeDeviceClassForUnit } from "../../data/sensor/unit_of_measurement";
import "../ha-device-class-picker";
@customElement("ha-selector-device_class")
@@ -17,12 +20,17 @@ export class HaDeviceClassSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
filter_unit_of_measurement?: string;
};
protected render() {
return html`
<ha-device-class-picker
.domain=${this.selector.device_class?.domain}
.value=${this.value}
.multiple=${this.selector.device_class?.multiple ?? false}
.unit=${this.context?.filter_unit_of_measurement}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -31,6 +39,39 @@ export class HaDeviceClassSelector extends LitElement {
`;
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
// A unit does not imply a device class, so an optional field stays empty
if (
!this.required ||
!changedProps.has("context") ||
this.value ||
this.selector.device_class?.multiple
) {
return;
}
// Preselect only when the unit changes, so a form that opens with a unit
// and a device class the user cleared stay without a device class
const oldContext = changedProps.get("context");
const unit = this.context?.filter_unit_of_measurement;
if (
oldContext === undefined ||
oldContext.filter_unit_of_measurement === unit
) {
return;
}
// Preselect the only device class of the unit
const deviceClass = computeDeviceClassForUnit(
this.selector.device_class?.domain,
unit
);
if (deviceClass) {
fireEvent(this, "value-changed", { value: deviceClass });
}
}
static styles = css`
ha-device-class-picker {
width: 100%;
+6
View File
@@ -527,10 +527,16 @@ export interface SelectSelector {
} | null;
}
// Maps a context key to the name of another field or blueprint input
export interface DeviceClassSelectorContext {
filter_unit_of_measurement?: string;
}
export interface DeviceClassSelector {
device_class: {
domain: string;
multiple?: boolean;
context?: DeviceClassSelectorContext;
} | null;
}
+50
View File
@@ -1,4 +1,5 @@
import { ensureArray } from "../../common/array/ensure-array";
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
import {
SENSOR_DEVICE_CLASS_UNITS,
SENSOR_NUMERIC_DEVICE_CLASSES,
@@ -97,3 +98,52 @@ export const computeSelectorUnits = (
}
return fromConfig ?? fromContext;
};
// Number entities share the unit tables of sensors
const UNIT_DOMAINS = ["sensor", "number"];
export interface UnitDeviceClasses {
/** Device classes whose unit table contains the unit */
matching: string[];
/** Numeric device classes without a unit table (e.g. `monetary`), which accept any unit */
unrestricted: string[];
}
/**
* Compute which device classes of a domain accept a unit. A custom unit is in
* no unit table, so only the device classes without a unit table accept it.
*
* @returns `undefined` when nothing is filtered: no unit, or a domain without units
*/
export const computeUnitDeviceClasses = (
domain: string | undefined,
unit: string | null | undefined
): UnitDeviceClasses | undefined => {
if (!unit || !domain || !UNIT_DOMAINS.includes(domain)) {
return undefined;
}
const result: UnitDeviceClasses = { matching: [], unrestricted: [] };
for (const deviceClass of DOMAIN_DEVICE_CLASSES[domain] ?? []) {
const units = SENSOR_DEVICE_CLASS_UNITS[deviceClass];
if (units) {
if (units.includes(unit)) {
result.matching.push(deviceClass);
}
} else if (SENSOR_NUMERIC_DEVICE_CLASSES.includes(deviceClass)) {
result.unrestricted.push(deviceClass);
}
}
return result;
};
/**
* The device class to preselect for a unit: the only one whose unit table
* contains it.
*/
export const computeDeviceClassForUnit = (
domain: string | undefined,
unit: string | null | undefined
): string | undefined => {
const matching = computeUnitDeviceClasses(domain, unit)?.matching;
return matching?.length === 1 ? matching[0] : undefined;
};
@@ -595,6 +595,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
@@ -55,6 +55,8 @@ class StepFlowCreateEntry extends LitElement {
{ name?: string; area?: string }
> = {};
@state() private _loading = false;
private _deviceEntities = memoizeOne(
(
deviceId: string,
@@ -217,6 +219,11 @@ class StepFlowCreateEntry extends LitElement {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
private async _loadDomains() {
@@ -295,8 +302,19 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
public async finish(): Promise<void> {
if (this._loading) {
return;
}
// Stay loading until the dialog closes, so a second click cannot save
// the updates again
this._loading = true;
try {
await this._flowDone();
} catch (err) {
this._loading = false;
throw err;
}
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
+6 -2
View File
@@ -140,11 +140,15 @@ window.hassConnection.then(({ conn }) => {
const preloadWindow = window as WindowWithPreloads;
preloadWindow.recorderInfoProm = getRecorderInfo(conn);
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
preloadWindow.llConfProm = fetchConfig(conn, null, false);
// "/" opens the default panel, which is not known yet, so only preload the
// config when the "lovelace" dashboard is opened directly.
if (location.pathname.startsWith("/lovelace/")) {
preloadWindow.llConfProm = fetchConfig(conn, "lovelace", false);
preloadWindow.llConfProm.catch(() => {
// Ignore it, it is handled by Lovelace panel.
});
}
if (location.pathname === "/" || location.pathname.startsWith("/lovelace/")) {
preloadWindow.llResProm = fetchResources(conn);
}
});
+4 -2
View File
@@ -21,9 +21,11 @@
}
.content {
max-width: min(560px, calc(100vw - var(--safe-area-inset-right, 0px) - var(--safe-area-inset-left, 0px)));
max-width: 560px;
margin: 0 auto;
padding: 0 16px;
padding: 0 calc(var(--ha-space-4, 16px) + var(--safe-area-inset-right, 0px))
var(--safe-area-inset-bottom, 0px)
calc(var(--ha-space-4, 16px) + var(--safe-area-inset-left, 0px));
box-sizing: content-box;
}
+3 -3
View File
@@ -524,9 +524,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
--ha-progress-bar-border-radius: 0;
--ha-progress-bar-track-height: 4px;
position: fixed;
top: 0;
left: 0;
width: 100%;
top: var(--safe-area-inset-top, 0px);
left: var(--safe-area-inset-left, 0px);
right: var(--safe-area-inset-right, 0px);
z-index: 10;
}
.footer {
+32 -20
View File
@@ -8,6 +8,7 @@ import {
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
@@ -18,11 +19,12 @@ import {
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/ha-spinner";
import "../components/input/ha-input";
import type { HaInput } from "../components/input/ha-input";
import "../components/item/ha-list-item-base";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import "../components/map/ha-locations-editor";
import type {
HaLocationsEditor,
@@ -141,7 +143,11 @@ class OnboardingLocation extends LitElement {
${
this._places !== undefined
? html`
<ha-list activatable>
<ha-list-base
aria-label=${this._localize(
"ui.panel.page-onboarding.core-config.search_results"
)}
>
${
this._places?.length
? this._places.map((place) => {
@@ -164,26 +170,28 @@ class OnboardingLocation extends LitElement {
]
.filter(Boolean)
.join(", ");
return html`<ha-list-item
return html`<ha-list-item-button
@click=${this._itemClicked}
.placeId=${place.place_id}
.selected=${this._highlightedMarker === place.place_id}
.activated=${this._highlightedMarker === place.place_id}
.twoline=${primary && secondary}
aria-current=${this._highlightedMarker === place.place_id ? "true" : nothing}
class=${classMap({
selected:
this._highlightedMarker === place.place_id,
})}
>
${primary || secondary}
<span slot="secondary"
<span slot="headline">${primary || secondary}</span>
<span slot="supporting-text"
>${primary ? secondary : ""}</span
>
</ha-list-item>`;
</ha-list-item-button>`;
})
: html`<ha-list-item noninteractive
>${
this._places === null ? "" : "No results"
}</ha-list-item
>`
: html`<ha-list-item-base>
<span slot="headline"
>${this._places === null ? "" : "No results"}</span
>
</ha-list-item-base>`
}
</ha-list>
</ha-list-base>
`
: nothing
}
@@ -514,17 +522,21 @@ class OnboardingLocation extends LitElement {
border-radius: var(--mdc-shape-large, 16px);
overflow: hidden;
}
ha-list {
ha-list-base {
width: 100%;
border: 1px solid var(--divider-color);
box-sizing: border-box;
border-top-width: 0;
border-bottom-left-radius: var(--mdc-shape-small, 4px);
border-bottom-right-radius: var(--mdc-shape-small, 4px);
--mdc-list-vertical-padding: 0;
overflow: hidden;
--ha-row-item-min-height: 72px;
}
ha-list-item {
height: 72px;
ha-list-item-button.selected::part(base) {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
ha-list-item-button.selected::part(headline) {
color: var(--primary-color);
}
.attribution {
/* input helper style */
@@ -67,6 +67,7 @@ import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-entity-id-icon";
import "../../../components/ha-filter-device-classes";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
@@ -87,6 +88,7 @@ import type {
DataTableFiltersItems,
DataTableFiltersValues,
} from "../../../data/data_table_filters";
import { computeDeviceClassName } from "../../../data/entity/device_class";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type {
EntityRegistryEntry,
@@ -160,6 +162,7 @@ export interface EntityRow extends StateEntity {
status: string | undefined;
area?: string;
device?: string;
device_class: string | null;
device_full?: string;
localized_platform: string;
domain: string;
@@ -298,6 +301,22 @@ export class HaConfigEntities extends LitElement {
);
}
private _getDeviceClass(entry: StateEntity): string | null {
return this.hass.states[entry.entity_id]?.attributes.device_class ?? null;
}
private _getDeviceClassName(
localize: LocalizeFunc,
entityId: string,
deviceClass: string
): string {
return computeDeviceClassName(
localize,
computeDomain(entityId),
deviceClass
);
}
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
@@ -392,6 +411,13 @@ export class HaConfigEntities extends LitElement {
filterable: true,
},
domain: getDomainTableColumn(localize),
device_class: {
title: localize("ui.panel.config.entities.picker.headers.device_class"),
sortable: true,
filterable: true,
defaultHidden: true,
template: (entry) => entry.device_class || "—",
},
disabled_by: {
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
hidden: true,
@@ -629,6 +655,15 @@ export class HaConfigEntities extends LitElement {
entryIds.includes(entity.config_entry_id))
);
filter!.forEach((domain) => filteredDomains.add(domain));
} else if (
key === "ha-filter-device-classes" &&
Array.isArray(filter) &&
filter.length
) {
filteredEntities = filteredEntities.filter((entry) => {
const deviceClass = this._getDeviceClass(entry);
return deviceClass != null && filter.includes(deviceClass);
});
} else if (
key === "ha-filter-domains" &&
Array.isArray(filter) &&
@@ -713,9 +748,13 @@ export class HaConfigEntities extends LitElement {
exposedEntities,
entry.entity_id
);
const deviceClass = this._getDeviceClass(entry);
result.push({
...entry,
device_class: deviceClass
? this._getDeviceClassName(localize, entry.entity_id, deviceClass)
: null,
name:
entityName ||
deviceName ||
@@ -1064,6 +1103,17 @@ export class HaConfigEntities extends LitElement {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-device-classes
.label=${this.hass.localize(
"ui.panel.config.entities.picker.headers.device_class"
)}
.value=${this._filters["ha-filter-device-classes"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-device-classes"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-device-classes>
<ha-filter-domains
.value=${this._filters["ha-filter-domains"]}
@data-table-filter-changed=${this._filterChanged}
@@ -1163,6 +1213,7 @@ export class HaConfigEntities extends LitElement {
}
protected firstUpdated() {
this.hass.loadBackendTranslation("entity_component");
this._fetchExposedEntities();
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
(sources) => {
@@ -3,6 +3,7 @@ import {
mdiBugPlay,
mdiBugStop,
mdiDotsVertical,
mdiDownload,
mdiFileCodeOutline,
mdiHelpCircleOutline,
mdiOpenInNew,
@@ -172,6 +173,10 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _logInfo?: IntegrationLogInfo;
@state() private _debugLogReady = false;
private _debugLogRequest = 0;
@state() private _searchParms = new URLSearchParams(
__DEMO__ ? window.location.search : window.location.hash.substring(1)
);
@@ -228,6 +233,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("hashchange", this._handleHashChange);
this._resetDebugLogReady();
}
public hassSubscribe(): UnsubscribeFunc[] {
@@ -238,6 +244,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
subscribeLogInfo(this.hass.connection, (log_infos) => {
for (const log_info of log_infos) {
if (log_info.domain === this.domain) {
if (
log_info.level === LogSeverity.DEBUG &&
this._logInfo?.level !== LogSeverity.DEBUG
) {
this._resetDebugLogReady();
}
this._logInfo = log_info;
}
}
@@ -251,6 +263,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.hass.loadBackendTranslation("config", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined;
this._resetDebugLogReady();
this._filter = "";
this._subEntries = {};
this._fetchManifest();
@@ -840,7 +853,34 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
</ha-alert>
</div>`
: nothing
: this._debugLogReady
? html`<div class="section">
<ha-alert
alert-type="info"
dismissable
.title=${this.hass.localize(
"ui.panel.config.integrations.config_entry.debug_log_ready"
)}
@alert-dismissed-clicked=${this._resetDebugLogReady}
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.debug_log_ready_description"
)}
<br /><br />
<ha-button
size="s"
appearance="filled"
@click=${this._downloadDebugLog}
>
<ha-svg-icon
slot="start"
.path=${mdiDownload}
></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-button>
</ha-alert>
</div>`
: nothing
}
${
filteredDiscoveryData.length
@@ -1381,16 +1421,19 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
private async _handleDisableDebugLogging(ev: Event) {
// Stop propagation since otherwise we end up here twice while we await the log level change
// and trigger two identical debug log downloads.
// Stop propagation since otherwise we end up here twice while we await the log level change.
ev.stopPropagation();
const integration = this.domain;
const request = this._debugLogRequest;
await setIntegrationLogLevel(
this.hass,
integration,
LogSeverity[LogSeverity.NOTSET],
"once"
);
if (request !== this._debugLogRequest) {
return;
}
const logFileDownloadUnavailableReason =
getCoreLogFileDownloadUnavailableReason(this.hass);
if (logFileDownloadUnavailableReason) {
@@ -1404,7 +1447,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
});
return;
}
this._debugLogReady = true;
}
// Also invalidates pending disable requests so they can't restore the alert.
private _resetDebugLogReady() {
this._debugLogReady = false;
this._debugLogRequest++;
}
private async _downloadDebugLog() {
const integration = this.domain;
const timeString = new Date().toISOString().replace(/:/g, "-");
const logFileName = `home-assistant_${integration}_${timeString}.log`;
const signedUrl = await getSignedPath(
+10 -4
View File
@@ -15,7 +15,6 @@ import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_da
import { storage } from "../../../../common/decorators/storage";
import type { HASSDomTargetEvent } from "../../../../common/dom/fire_event";
import { escapeRegExp } from "../../../../common/string/escape_regexp";
import { computeEntitySearchLabels } from "../../../../common/entity/compute_entity_name_display";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
@@ -132,6 +131,7 @@ class HaPanelDevState extends LitElement {
attributeFilter: string,
deviceFilter: string,
areaFilter: string,
deviceColumnShown: boolean,
states: HassEntities,
entities: HomeAssistantRegistries["entities"],
devices: HomeAssistantRegistries["devices"],
@@ -143,6 +143,7 @@ class HaPanelDevState extends LitElement {
attributeFilter,
deviceFilter,
areaFilter,
deviceColumnShown,
states,
entities,
devices,
@@ -157,6 +158,7 @@ class HaPanelDevState extends LitElement {
this._attributeFilter,
this._deviceFilter,
this._areaFilter,
!this.narrow && this._showDevice,
this._states,
this._registries.entities,
this._registries.devices,
@@ -493,6 +495,7 @@ class HaPanelDevState extends LitElement {
attributeFilter: string,
deviceFilter: string,
areaFilter: string,
deviceColumnShown: boolean,
states: HassEntities,
entities: HomeAssistantRegistries["entities"],
devices: HomeAssistantRegistries["devices"],
@@ -540,13 +543,16 @@ class HaPanelDevState extends LitElement {
return Object.values(states)
.filter((value) => {
// Match the entity column's name; without the device column, match
// the full name.
if (
entityFilterRegExp &&
!entityFilterRegExp.test(value.entity_id) &&
!entityFilterRegExp.test(this.hass.formatEntityName(value)) &&
!entityFilterRegExp.test(
computeEntitySearchLabels(value, this._registries).friendlyName ??
""
this.hass.formatEntityName(
value,
deviceColumnShown ? { type: "entity" } : undefined
)
)
) {
return false;
@@ -12,6 +12,7 @@ import {
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { getEntityAreaId } from "../../../common/entity/context/get_entity_context";
import {
computeGroupEntitiesState,
toggleGroupEntities,
@@ -236,8 +237,25 @@ class HuiAreaControlsCardFeature
.map((n) => n.value)
);
private _areaEntities = memoizeOne(
(
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areaId: string
): HomeAssistant["entities"] => {
const areaEntities: HomeAssistant["entities"] = {};
for (const [entityId, entry] of Object.entries(entities)) {
if (getEntityAreaId(entityId, entities, devices) === areaId) {
areaEntities[entityId] = entry;
}
}
return areaEntities;
}
);
// States are part of the key: the filter reads device classes from them,
// so an entity can only match once its state exists.
// so an entity can only match once its state exists. Only the area's
// entities are filtered, as this runs on every state change.
private _controlEntities = memoizeOne(
(
controls: AreaControlDomain[],
@@ -248,7 +266,7 @@ class HuiAreaControlsCardFeature
) =>
getAreaControlEntities(
states,
registries.entities,
this._areaEntities(registries.entities, registries.devices, areaId),
registries.devices,
registries.areas,
registries.floors,
@@ -51,11 +51,13 @@ import {
filterLowBatteryEntities,
filterProblemEntities,
filterUnavailableBatteryEntities,
findMaintenanceEntities,
} from "../../maintenance/strategies/maintenance-view-strategy";
import {
isSecurityAlertActive,
resolveSecurityAlertSeverity,
} from "../../security/strategies/security-alerts";
import { findSecurityEntities } from "../../security/strategies/security-view-strategy";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { HomeSummaryCard } from "./types";
@@ -231,11 +233,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
}
case "security": {
// Alarm and lock status
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(states, entities, devices, areas, floors, filter)
);
const securityEntities = findEntities(allEntities, securityFilters);
const securityEntities = findSecurityEntities({
states,
entities,
devices,
areas,
floors,
});
const locks = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
@@ -318,22 +322,13 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
: localize("ui.card.home-summary.no_media_playing");
}
case "maintenance": {
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) =>
generateEntityFilter(
states,
entities,
devices,
areas,
floors,
filter
)
);
const maintenanceEntities = findEntities(
allEntities,
maintenanceFilters
);
const maintenanceEntities = findMaintenanceEntities({
states,
entities,
devices,
areas,
floors,
});
const lowBatteryEntities = filterLowBatteryEntities(
states,
+23 -19
View File
@@ -26,6 +26,7 @@ import {
saveConfig,
} from "../../data/lovelace/config/types";
import { fetchResources } from "../../data/lovelace/resource";
import { LOVELACE_PANEL } from "../../data/panel";
import type { WindowWithPreloads } from "../../data/preloads";
import "../../layouts/hass-error-screen";
import "../../layouts/hass-loading-screen";
@@ -83,7 +84,11 @@ export class LovelacePanel extends LitElement {
public connectedCallback(): void {
super.connectedCallback();
if (
if (this._fetchConfigOnConnect) {
// Config was changed when we were not at the lovelace panel
this._fetchConfigOnConnect = false;
this._fetchConfig(false);
} else if (
this.lovelace &&
this.hass &&
this.lovelace.locale !== this.hass.locale
@@ -94,17 +99,15 @@ export class LovelacePanel extends LitElement {
this.lovelace.rawConfig,
this.lovelace.mode
);
} else if (this._fetchConfigOnConnect) {
// Config was changed when we were not at the lovelace panel
this._fetchConfig(false);
}
window.addEventListener("connection-status", this._handleConnectionStatus);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
// On the main dashboard we want to stay subscribed as that one is cached.
if (this.urlPath !== null && this._unsubUpdates) {
// The "lovelace" panel is cached by the panel resolver, stay subscribed so
// we refetch its config when it is shown again.
if (this.urlPath !== LOVELACE_PANEL && this._unsubUpdates) {
this._unsubUpdates.then((unsub) => unsub());
this._unsubUpdates = undefined;
}
@@ -315,21 +318,22 @@ export class LovelacePanel extends LitElement {
(resources) => loadLovelaceResources(resources, this.hass!)
);
}
if (this.urlPath !== null || !confProm) {
// Refreshing a YAML config can trigger an update event. We will ignore
// all update events while fetching the config and for 2 seconds after the config is back.
// We ignore because we already have the latest config.
if (confMode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
confProm = fetchConfig(
this.hass!.connection,
this.urlPath,
forceDiskRefresh
);
// Refreshing a YAML config can trigger an update event. We will ignore
// all update events while fetching the config and for 2 seconds after the config is back.
// We ignore because we already have the latest config.
if (confMode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
const fetchDashboardConfig = () =>
fetchConfig(this.hass!.connection, this.urlPath, forceDiskRefresh);
// The preload is the config of the "lovelace" dashboard. Fetch it again if
// it failed, for example because the connection dropped before it came back.
confProm =
confProm && this.urlPath === "lovelace"
? confProm.catch(fetchDashboardConfig)
: fetchDashboardConfig();
try {
rawConf = await confProm;
@@ -37,11 +37,13 @@ import type {
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import { findMaintenanceEntities } from "../../../maintenance/strategies/maintenance-view-strategy";
import {
computeDefaultSecurityAlertVisibility,
filterSecurityAlertEntities,
resolveSecurityAlertSeverity,
} from "../../../security/strategies/security-alerts";
import { findSecurityEntities } from "../../../security/strategies/security-view-strategy";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -354,40 +356,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
)
);
const securityFilters = HOME_SUMMARIES_FILTERS.security.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const maintenanceFilters = HOME_SUMMARIES_FILTERS.maintenance.map(
(filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const hasLights =
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
const hasSecurity =
hass.panels.security &&
findEntities(allEntities, securityFilters).length > 0;
hass.panels.security && findSecurityEntities(hass).length > 0;
const hasMaintenance =
hass.panels.maintenance &&
findEntities(allEntities, maintenanceFilters).length > 0;
hass.panels.maintenance && findMaintenanceEntities(hass).length > 0;
const alertEntities = config.alert_entities ?? [];
const alertSeverityEntities = filterSecurityAlertEntities(
@@ -3,9 +3,10 @@ import { customElement } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -33,6 +34,10 @@ export const maintenanceEntityFilters: EntityFilter[] = [
},
];
export const findMaintenanceEntities = (
sources: EntityFilterSources
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
@@ -198,20 +203,7 @@ export class MaintenanceViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const batteryFilters = maintenanceEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const entities = findEntities(allEntities, batteryFilters);
const entities = findMaintenanceEntities(hass);
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -968,7 +968,7 @@ type: module</pre>`,
? {
icon: mdiAlertCircleOutline,
text: localize(
"ui.panel.marketplace.repository.community.open_issues",
"ui.panel.marketplace.repository.community.open_issues_and_prs",
{
number: repository.issues,
count: html`<strong
@@ -980,7 +980,7 @@ type: module</pre>`,
: {
icon: mdiCheckCircleOutline,
text: localize(
"ui.panel.marketplace.repository.community.no_open_issues"
"ui.panel.marketplace.repository.community.no_open_issues_and_prs"
),
}
);
+1
View File
@@ -172,6 +172,7 @@ const entitiesTableParams = {
floor: "string?",
area: "string?",
device: "string?",
device_class: "string?",
domain: "string?",
entity_domain: "string?",
label: "string?",
@@ -6,9 +6,10 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
findEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
@@ -78,11 +79,11 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const isSecurityPanelEntity = (
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
hass: EntityFilterSources,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
@@ -182,20 +183,7 @@ export class SecurityViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const securityFilters = securityEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const entities = findEntities(allEntities, securityFilters);
const entities = findSecurityEntities(hass);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
+9 -4
View File
@@ -1050,7 +1050,8 @@
"device_class": "Device class",
"add": "Add device class",
"no_device_classes": "No device classes available",
"no_match": "No device classes found for {term}"
"no_match": "No device classes found for {term}",
"unit_not_supported": "Does not support {unit}"
},
"category-picker": {
"clear": "Clear",
@@ -7634,6 +7635,7 @@
"state_icon": "State icon",
"entity": "Entity",
"device": "Device",
"device_class": "Device class",
"integration": "Integration",
"disabled_by": "Disabled by",
"status": "Status",
@@ -7926,8 +7928,10 @@
"no_config_flow": "This integration was not set up from the UI",
"disabled_polling": "Automatic polling for updated data disabled",
"debug_logging_enabled": "Debug logging enabled",
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it. Home Assistant will download the log for your issue report if log file downloads are available. Leaving it on can slow Home Assistant down and fill up the logs.",
"debug_logging_enabled_description": "Extra detail from this integration is being written to the logs. Reproduce the problem, then disable it to download the log for your issue report. Leaving it on can slow Home Assistant down and fill up the logs.",
"show_logs": "Show logs",
"debug_log_ready": "Debug log ready",
"debug_log_ready_description": "Debug logging is disabled. Download the log to attach it to your issue report.",
"live_log": {
"title": "Live log",
"waiting": "Waiting for log messages from {integration}…",
@@ -12804,6 +12808,7 @@
"location_name": "Name of your Home Assistant installation",
"location_name_default": "Home",
"address_label": "Search address",
"search_results": "Search results",
"button_detect": "Detect",
"finish": "Next"
},
@@ -13193,8 +13198,8 @@
"last_change": "Last change on GitHub {time}",
"downloads": "{number, plural,\n one {Downloaded {count} time}\n other {Downloaded {count} times}\n}",
"stars": "{number, plural,\n one {{count} star on GitHub}\n other {{count} stars on GitHub}\n}",
"open_issues": "{number, plural,\n one {{count} open issue on GitHub}\n other {{count} open issues on GitHub}\n}",
"no_open_issues": "No open issues on GitHub"
"open_issues_and_prs": "{number, plural,\n one {{count} open issue or pull request on GitHub}\n other {{count} open issues and pull requests on GitHub}\n}",
"no_open_issues_and_prs": "No open issues or pull requests on GitHub"
},
"details": {
"requires": "Requires Home Assistant {version} or newer",
+18 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { generateEntityFilter } from "../../../src/common/entity/entity_filter";
import {
findFilteredEntities,
generateEntityFilter,
} from "../../../src/common/entity/entity_filter";
import type { HomeAssistant } from "../../../src/types";
// Mock HomeAssistant with comprehensive data
@@ -705,3 +708,17 @@ describe("generateEntityFilter", () => {
});
});
});
describe("findFilteredEntities", () => {
it("should match entities against any of the filters", () => {
const entities = findFilteredEntities(mockHass, [
{ domain: "light" },
{ domain: "switch" },
]);
expect(entities).toContain("light.living_room");
expect(entities).toContain("light.bedroom");
expect(entities).toContain("switch.kitchen");
expect(entities).not.toContain("sensor.temperature");
});
});
@@ -22,6 +22,7 @@ describe("entities query params", () => {
"ha-filter-states": [],
"ha-filter-floor-areas": undefined,
"ha-filter-devices": ["abc"],
"ha-filter-device-classes": [],
"ha-filter-integrations": [],
"ha-filter-domains": [],
"ha-filter-labels": ["a", "b"],
@@ -63,6 +64,7 @@ describe("entities query params", () => {
"ha-filter-states": ["enabled", "hidden"],
"ha-filter-floor-areas": { floors: ["ground"], areas: ["kitchen"] },
"ha-filter-devices": ["abc"],
"ha-filter-device-classes": ["problem", "temperature"],
"ha-filter-integrations": ["hue"],
"ha-filter-domains": ["light"],
"ha-filter-labels": ["a"],
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import { DOMAIN_DEVICE_CLASSES } from "../../src/data/device_classes";
import {
computeAllowedUnits,
computeDeviceClassForUnit,
computeSelectorUnits,
computeUnitDeviceClasses,
} from "../../src/data/sensor/unit_of_measurement";
import { SENSOR_DEVICE_CLASS_UNITS } from "../../src/data/sensor_entity_constants";
const sorted = (units?: (string | null)[]) =>
units === undefined ? undefined : [...units].sort();
@@ -164,3 +168,90 @@ describe("computeSelectorUnits", () => {
).toEqual([]);
});
});
describe("computeUnitDeviceClasses", () => {
it("does not filter without a unit", () => {
expect(computeUnitDeviceClasses("sensor", undefined)).toBeUndefined();
expect(computeUnitDeviceClasses("sensor", null)).toBeUndefined();
expect(computeUnitDeviceClasses("sensor", "")).toBeUndefined();
});
it("does not filter domains without units", () => {
expect(computeUnitDeviceClasses("binary_sensor", "lx")).toBeUndefined();
expect(computeUnitDeviceClasses(undefined, "lx")).toBeUndefined();
});
it("finds the only device class of a unit", () => {
expect(computeUnitDeviceClasses("sensor", "lx")).toEqual({
matching: ["illuminance"],
unrestricted: ["monetary"],
});
});
it("finds all device classes of a unit", () => {
expect(computeUnitDeviceClasses("sensor", "°C")?.matching.sort()).toEqual([
"temperature",
"temperature_delta",
]);
});
it("only accepts device classes without a unit table for a custom unit", () => {
expect(computeUnitDeviceClasses("sensor", "beans")).toEqual({
matching: [],
unrestricted: ["monetary"],
});
});
it("never accepts a unit for unitless and non-numeric device classes", () => {
const result = computeUnitDeviceClasses("sensor", "%")!;
for (const deviceClass of ["aqi", "ph", "enum", "date", "timestamp"]) {
expect(result.matching).not.toContain(deviceClass);
expect(result.unrestricted).not.toContain(deviceClass);
}
});
it("uses the sensor unit tables for numbers", () => {
expect(computeUnitDeviceClasses("number", "lx")).toEqual({
matching: ["illuminance"],
unrestricted: ["monetary"],
});
});
it("matches the units each device class allows", () => {
const units = new Set(
Object.values(SENSOR_DEVICE_CLASS_UNITS)
.flat()
.filter((unit): unit is string => unit !== null)
);
for (const unit of units) {
const { matching } = computeUnitDeviceClasses("sensor", unit)!;
for (const deviceClass of DOMAIN_DEVICE_CLASSES.sensor) {
const allowed = computeAllowedUnits([deviceClass]);
expect(matching.includes(deviceClass), `${unit} ${deviceClass}`).toBe(
allowed?.includes(unit) ?? false
);
}
}
});
});
describe("computeDeviceClassForUnit", () => {
it("preselects the only device class of a unit", () => {
expect(computeDeviceClassForUnit("sensor", "lx")).toBe("illuminance");
expect(computeDeviceClassForUnit("number", "lx")).toBe("illuminance");
});
it("does not preselect when several device classes fit", () => {
expect(computeDeviceClassForUnit("sensor", "°C")).toBeUndefined();
expect(computeDeviceClassForUnit("sensor", "%")).toBeUndefined();
});
it("does not preselect for a custom unit", () => {
expect(computeDeviceClassForUnit("sensor", "beans")).toBeUndefined();
});
it("does not preselect without a unit or for domains without units", () => {
expect(computeDeviceClassForUnit("sensor", undefined)).toBeUndefined();
expect(computeDeviceClassForUnit("binary_sensor", "lx")).toBeUndefined();
});
});
@@ -1,13 +1,16 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaDialog } from "../../../src/components/ha-dialog";
import type { ConfigEntry } from "../../../src/data/config_entries";
import type {
DataEntryFlowProgressedEvent,
DataEntryFlowStep,
DataEntryFlowStepCreateEntry,
DataEntryFlowStepExternal,
DataEntryFlowStepForm,
DataEntryFlowStepMenu,
DataEntryFlowStepProgress,
} from "../../../src/data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import "../../../src/dialogs/config-flow/dialog-data-entry-flow";
import type { FlowConfig } from "../../../src/dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../src/types";
@@ -53,6 +56,16 @@ vi.mock("../../../src/components/ha-list-item", () => {
return {};
});
vi.mock("../../../src/components/ha-area-picker", () => {
customElements.define("ha-area-picker", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/input/ha-input", () => {
customElements.define("ha-input", class extends HTMLElement {});
return {};
});
const formStep: DataEntryFlowStepForm = {
type: "form",
flow_id: "test-flow",
@@ -131,6 +144,7 @@ const eventListeners: Record<string, (ev: unknown) => void> = {};
const hass = {
localize: (key: string) => key,
devices: {},
callWS: vi.fn(),
connection: {
subscribeEvents: vi.fn(
async (callback: (ev: unknown) => void, eventType: string) => {
@@ -398,3 +412,83 @@ describe("dialog-data-entry-flow progress", () => {
expect(dialog["_step"]).toBeUndefined();
});
});
describe("dialog-data-entry-flow finish", () => {
const createEntryStep: DataEntryFlowStepCreateEntry = {
type: "create_entry",
flow_id: "test-flow",
handler: "test",
title: "Test",
result: {
entry_id: "test-entry",
domain: "test",
title: "Test",
state: "loaded",
} as ConfigEntry,
description: null,
};
const device = {
id: "device-1",
name: "Lamp",
name_by_user: null,
manufacturer: null,
model: null,
area_id: null,
primary_config_entry: null,
config_entries: ["test-entry"],
} as DeviceRegistryEntry;
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
it("saves the device updates once when Finish is clicked twice", async () => {
const pendingSaves: (() => void)[] = [];
vi.mocked(hass.callWS).mockImplementation((async (msg: {
type: string;
}) => {
if (msg.type === "config/device_registry/update") {
return new Promise<unknown>((resolve) => {
pendingSaves.push(() => resolve({}));
});
}
if (msg.type === "config/entity_registry/get_automatic_entity_ids") {
return {};
}
return [];
}) as HomeAssistant["callWS"]);
const step = document.createElement("step-flow-create-entry");
step.hass = { ...hass, entities: {} };
step.flowConfig = flowConfig;
step.step = createEntryStep;
step.devices = [device];
document.body.append(step);
await step.updateComplete;
step
.shadowRoot!.querySelector("ha-area-picker")!
.dispatchEvent(
new CustomEvent("value-changed", { detail: { value: "kitchen" } })
);
await step.updateComplete;
const flowUpdates: unknown[] = [];
step.addEventListener("flow-update", (ev) => {
flowUpdates.push(ev.detail);
});
const first = step.finish();
const second = step.finish();
pendingSaves.forEach((save) => save());
await Promise.all([first, second]);
expect(
vi
.mocked(hass.callWS)
.mock.calls.filter(
([msg]) => msg.type === "config/device_registry/update"
)
).toHaveLength(1);
expect(flowUpdates).toHaveLength(1);
});
});
+3 -1
View File
@@ -371,7 +371,9 @@ export async function searchAddress(page: Page) {
const input = location.locator("ha-input input");
await input.fill(place.name);
await input.press("Enter");
await expect(location.locator("ha-list")).toContainText(place.name);
await expect(
location.getByRole("list", { name: "Search results" })
).toContainText(place.name);
}
export async function confirmLocation(page: Page) {
@@ -0,0 +1,102 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { subscribeLovelaceUpdates } from "../../../src/data/lovelace";
import { fetchConfig } from "../../../src/data/lovelace/config/types";
import "../../../src/panels/lovelace/ha-panel-lovelace";
type LovelacePanel = HTMLElement & {
hass: unknown;
panel: unknown;
updateComplete: Promise<boolean>;
};
vi.mock("../../../src/panels/lovelace/hui-root", () => ({}));
vi.mock("../../../src/layouts/hass-loading-screen", () => ({}));
vi.mock("../../../src/layouts/hass-error-screen", () => ({}));
vi.mock("../../../src/data/lovelace", () => ({
subscribeLovelaceUpdates: vi.fn(),
}));
vi.mock("../../../src/data/lovelace/config/types", async (importOriginal) => ({
...(await importOriginal<object>()),
fetchConfig: vi.fn(),
}));
vi.mock("../../../src/data/lovelace/resource", () => ({
fetchResources: vi.fn(async () => []),
}));
vi.mock("../../../src/panels/lovelace/common/load-resources", () => ({
loadLovelaceResources: vi.fn(),
}));
const flush = () =>
new Promise<void>((resolve) => {
setTimeout(resolve);
});
const openPanel = async (urlPath: string) => {
let notifyUpdated: () => void = () => undefined;
const unsub = vi.fn(async () => {
notifyUpdated = () => undefined;
});
vi.mocked(subscribeLovelaceUpdates).mockImplementation(
(_conn, _urlPath, onChange) => {
notifyUpdated = onChange;
return Promise.resolve(unsub);
}
);
vi.mocked(fetchConfig).mockResolvedValue({ views: [{ cards: [] }] });
const panel = document.createElement(
"ha-panel-lovelace"
) as unknown as LovelacePanel;
panel.hass = {
connection: {},
locale: { language: "en" },
localize: (key: string) => key,
};
panel.panel = {
url_path: urlPath,
component_name: "lovelace",
config: { mode: "storage" },
};
document.body.append(panel);
await panel.updateComplete;
await flush();
return { panel, unsub, notifyUpdated: () => notifyUpdated() };
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("ha-panel-lovelace config updates", () => {
it("refetches the cached lovelace dashboard when it was updated while away", async () => {
const { panel, unsub, notifyUpdated } = await openPanel("lovelace");
expect(fetchConfig).toHaveBeenCalledTimes(1);
// Navigate away: the panel resolver keeps this element cached.
panel.remove();
await flush();
expect(unsub).not.toHaveBeenCalled();
// Config saved from elsewhere, e.g. "Add to dashboard" on a device page.
notifyUpdated();
expect(fetchConfig).toHaveBeenCalledTimes(1);
document.body.append(panel);
await flush();
expect(fetchConfig).toHaveBeenCalledTimes(2);
// Coming back again without a change uses the cached config.
panel.remove();
document.body.append(panel);
await flush();
expect(fetchConfig).toHaveBeenCalledTimes(2);
});
it("unsubscribes other dashboards when they are removed", async () => {
const { panel, unsub } = await openPanel("dashboard-test");
panel.remove();
await flush();
expect(unsub).toHaveBeenCalledTimes(1);
});
});
@@ -673,7 +673,7 @@ describe("ha-marketplace-repository-dashboard", () => {
)
).toEqual([
"ui.panel.marketplace.repository.community.stars",
"ui.panel.marketplace.repository.community.open_issues",
"ui.panel.marketplace.repository.community.open_issues_and_prs",
]);
});
+2 -2
View File
@@ -42,9 +42,9 @@ describe("Marketplace strings", () => {
"1 star on GitHub",
],
[
"repository.community.open_issues",
"repository.community.open_issues_and_prs",
{ number: 1, count: "1" },
"1 open issue on GitHub",
"1 open issue or pull request on GitHub",
],
])("formats %s with %o", (key, values, expected) => {
expect(format(key, values)).toBe(expected);