Compare commits

..
60 changed files with 1258 additions and 1460 deletions

No files matched your search

+1 -8
View File
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
+4 -1
View File
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { ThemeSettings } from "../../../src/types";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
+1
View File
@@ -242,6 +242,7 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@@ -109,30 +109,6 @@ const disabledFailedUnloadEntry = createConfigEntry(
}
);
// Other brands to compare how status badges look on different icons.
const brandEntries: ConfigEntryExtended[] = (
[
["hue", "Philips Hue", "loaded"],
["hue", "Philips Hue", "setup_error"],
["mqtt", "MQTT", "setup_retry"],
["zha", "Zigbee Home Automation", "not_loaded"],
["sonos", "Sonos", "setup_error"],
["shelly", "Shelly", "failed_unload"],
["spotify", "Spotify", "setup_retry"],
["tuya", "Tuya", "setup_error"],
["unifi", "UniFi Network", "not_loaded"],
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
["google_assistant", "Google Assistant", "not_loaded"],
["homekit_controller", "HomeKit Device", "setup_retry"],
] as [string, string, ConfigEntryExtended["state"]][]
).map(([domain, name, entryState]) =>
createConfigEntry(`${name} ${entryState}`, {
domain,
localized_domain_name: name,
state: entryState,
})
);
const configFlows: DataEntryFlowProgressExtended[] = [
{
flow_id: "adbb401329d8439ebb78ef29837826a8",
@@ -349,21 +325,6 @@ export class DemoIntegrationCard extends LitElement {
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
></ha-integration-card>
</div>
<div class="container">
${brandEntries.map(
(entry) => html`
<ha-integration-card
.domain=${entry.domain}
.items=${[entry]}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud
)}
.entityRegistryEntries=${createEntityRegistryEntries(entry)}
></ha-integration-card>
`
)}
</div>
`;
}
+2 -2
View File
@@ -173,7 +173,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.4",
"@vitest/coverage-v8": "5.0.3",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
@@ -220,7 +220,7 @@
"typescript-eslint": "8.71.1",
"vite": "8.3.4",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.4",
"vitest": "5.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
+76 -70
View File
@@ -576,8 +576,8 @@ importers:
specifier: npm:[email protected]
version: [email protected]
'@vitest/coverage-v8':
specifier: 5.0.4
version: 5.0.4([email protected].4)
specifier: 5.0.3
version: 5.0.3([email protected].3)
babel-loader:
specifier: 10.1.1
version: 10.1.1(@babel/[email protected])(@rspack/[email protected](@swc/[email protected]))
@@ -717,8 +717,8 @@ importers:
specifier: 6.1.1
version: 6.1.1([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
vitest:
specifier: 5.0.4
version: 5.0.4(@types/[email protected])(@vitest/[email protected].4)([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
specifier: 5.0.3
version: 5.0.3(@types/[email protected])(@vitest/[email protected].3)([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
webpack-stats-plugin:
specifier: 1.1.3
version: 1.1.3
@@ -3738,27 +3738,27 @@ packages:
'@vibrant/[email protected]':
resolution: {integrity: sha512-Q/R6PYhSMWCXEk/IcXbWIzIu7Z4b58ABkGvcdF8Y+q/7g+KnpxKW5x/jfQ/6ciyYSby13wZZoEdNr3QQVgsdBQ==}
'@vitest/[email protected].4':
resolution: {integrity: sha512-P5YcQY8Y6RmoE9xRXI38rT8pssbcubEzqtYTM2XQoxOXMDn04nRaTI1SM8qCl9/NfW3ag9GlzdSXu7IwO5Ft8A==}
'@vitest/[email protected].3':
resolution: {integrity: sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==}
peerDependencies:
'@vitest/browser': 5.0.4
vitest: 5.0.4
'@vitest/browser': 5.0.3
vitest: 5.0.3
peerDependenciesMeta:
'@vitest/browser':
optional: true
'@vitest/[email protected].2':
resolution: {integrity: sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==}
engines: {node: '>=22'}
'@vitest/[email protected].2':
resolution: {integrity: sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==}
'@vitest/[email protected].1':
resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==}
engines: {node: '>=22'}
'@vitest/[email protected].4':
resolution: {integrity: sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==}
'@vitest/[email protected].3':
resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
peerDependencies:
msw: ^2.4.9 || ^3.0.0
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
@@ -3766,8 +3766,8 @@ packages:
vite:
optional: true
'@vitest/[email protected].4':
resolution: {integrity: sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==}
'@vitest/[email protected].3':
resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
'@vscode/[email protected]':
resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==}
@@ -3955,8 +3955,8 @@ packages:
resolution: {integrity: sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==}
engines: {node: '>=0.10.0'}
[email protected].7:
resolution: {integrity: sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==}
[email protected].5:
resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==}
[email protected]:
resolution: {integrity: sha512-j0s3bzYq9yKIVLKGE/tWlCpa3PfFLcrDZLTSVdnnCTGagXuXBJO4SsY9Xdk/fQBirCkH4evW5xOeJXqlAQFdsw==}
@@ -4192,8 +4192,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==}
chai@6.3.0:
resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
[email protected]:
@@ -5893,11 +5893,11 @@ packages:
[email protected]:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magic-string@1.4.3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
magic-string@1.2.3:
resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==}
[email protected].5:
resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==}
[email protected].4:
resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==}
[email protected]:
resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==}
@@ -6246,6 +6246,10 @@ packages:
resolution: {integrity: sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
[email protected]:
resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==}
engines: {node: '>=12.20.0'}
@@ -6961,8 +6965,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
std-env@4.3.0:
resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
[email protected]:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
@@ -7158,8 +7162,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==}
[email protected].2:
resolution: {integrity: sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==}
[email protected].0:
resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
engines: {node: '>=20.0.0'}
[email protected]:
@@ -7180,8 +7184,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==}
tinyrainbow@3.2.0:
resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==}
tinyrainbow@3.1.1:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
[email protected]:
@@ -7504,20 +7508,20 @@ packages:
yaml:
optional: true
[email protected].4:
resolution: {integrity: sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==}
[email protected].3:
resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 5.0.4
'@vitest/browser-preview': 5.0.4
'@vitest/browser-playwright': 5.0.3
'@vitest/browser-preview': 5.0.3
'@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
'@vitest/coverage-istanbul': 5.0.4
'@vitest/coverage-v8': 5.0.4
'@vitest/ui': 5.0.4
'@vitest/coverage-istanbul': 5.0.3
'@vitest/coverage-v8': 5.0.3
'@vitest/ui': 5.0.3
happy-dom: '*'
jsdom: '*'
vite: ^6.4.0 || ^7.0.0 || ^8.0.0
@@ -11348,34 +11352,34 @@ snapshots:
dependencies:
'@vibrant/types': 4.0.4
'@vitest/[email protected].4([email protected].4)':
'@vitest/[email protected].3([email protected].3)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
'@vitest/istanbul-lib-report': 1.0.2
ast-v8-to-istanbul: 1.0.7
magicast: 0.5.5
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.0.4(@types/[email protected])(@vitest/[email protected].4)([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
'@vitest/istanbul-lib-coverage': 1.0.1
'@vitest/istanbul-lib-report': 1.0.1
ast-v8-to-istanbul: 1.0.5
magicast: 0.5.4
obug: 2.1.4
std-env: 4.2.0
tinyrainbow: 3.1.1
vitest: 5.0.3(@types/[email protected])(@vitest/[email protected].3)([email protected])([email protected](@types/[email protected])([email protected])([email protected]))
'@vitest/[email protected].2': {}
'@vitest/[email protected].1': {}
'@vitest/[email protected].2':
'@vitest/[email protected].1':
dependencies:
'@vitest/istanbul-lib-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.1
'@vitest/[email protected].4([email protected](@types/[email protected])([email protected])([email protected]))':
'@vitest/[email protected].3([email protected](@types/[email protected])([email protected])([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.4
'@vitest/spy': 5.0.3
estree-walker: 3.0.3
magic-string: 1.4.3
magic-string: 1.2.3
optionalDependencies:
vite: 8.3.4(@types/[email protected])([email protected])([email protected])
'@vitest/[email protected].4': {}
'@vitest/[email protected].3': {}
'@vscode/[email protected]': {}
@@ -11535,7 +11539,7 @@ snapshots:
[email protected]: {}
[email protected].7:
[email protected].5:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
estree-walker: 3.0.3
@@ -11801,7 +11805,7 @@ snapshots:
[email protected]: {}
chai@6.3.0: {}
chai@6.2.2: {}
[email protected]:
dependencies:
@@ -13616,11 +13620,11 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
magic-string@1.4.3:
magic-string@1.2.3:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
[email protected].5:
[email protected].4:
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
@@ -13731,7 +13735,7 @@ snapshots:
acorn: 8.18.0
html-minifier-next: 8.1.0
lightningcss: 1.33.0
magic-string: 1.4.3
magic-string: 1.2.3
transitivePeerDependencies:
- '@swc/core'
@@ -14027,6 +14031,8 @@ snapshots:
dependencies:
isobject: 3.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -14856,7 +14862,7 @@ snapshots:
[email protected]: {}
std-env@4.3.0: {}
std-env@4.2.0: {}
[email protected]:
dependencies:
@@ -15065,7 +15071,7 @@ snapshots:
[email protected]: {}
[email protected].2: {}
[email protected].0: {}
[email protected]: {}
@@ -15080,7 +15086,7 @@ snapshots:
[email protected]: {}
tinyrainbow@3.2.0: {}
tinyrainbow@3.1.1: {}
[email protected]: {}
@@ -15433,25 +15439,25 @@ snapshots:
terser: 5.50.0
yaml: 2.9.1
[email protected].4(@types/[email protected])(@vitest/[email protected].4)([email protected])([email protected](@types/[email protected])([email protected])([email protected])):
[email protected].3(@types/[email protected])(@vitest/[email protected].3)([email protected])([email protected](@types/[email protected])([email protected])([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.4([email protected](@types/[email protected])([email protected])([email protected]))
chai: 6.3.0
'@vitest/mocker': 5.0.3([email protected](@types/[email protected])([email protected])([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
magic-string: 1.4.3
obug: 3.0.0
magic-string: 1.2.3
obug: 2.1.4
picomatch: 4.0.7
std-env: 4.3.0
tinybench: 6.2.2
std-env: 4.2.0
tinybench: 6.2.0
tinyexec: 1.3.1
tinyglobby: 0.2.17
vite: 8.3.4(@types/[email protected])([email protected])([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
'@types/node': 24.19.2
'@vitest/coverage-v8': 5.0.4([email protected].4)
'@vitest/coverage-v8': 5.0.3([email protected].3)
jsdom: 30.1.2
transitivePeerDependencies:
- msw
@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
</svg>

Before

Width:  |  Height:  |  Size: 482 B

+1 -1
View File
@@ -7,7 +7,7 @@ export interface EntityLocation {
gpsAccuracy?: number;
}
export const findFirstActiveZone = (
const findFirstActiveZone = (
inZones: readonly string[],
states: HassEntities
): HassEntity | undefined => {
@@ -1,25 +1,18 @@
import type { ContextType } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import { connectionContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@@ -58,7 +51,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this._localize(
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
@@ -76,7 +69,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this._localize(
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
@@ -99,14 +92,14 @@ export class HaAutomationConditionLiveTest extends LitElement {
}
const conditionUnsub = subscribeCondition(
this._connection.connection,
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
@@ -128,7 +121,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
+29 -45
View File
@@ -43,10 +43,7 @@ import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./input/ha-input";
@@ -54,7 +51,7 @@ import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string;
text: string | TemplateResult;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
@@ -542,18 +539,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
this._conversation = [...this._conversation!, message];
}
private _showNotSupportedMessage() {
const documentationLink = `[${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}](${documentationUrl(this._config, SECURE_CONNECTION_DOCUMENTATION_PATH)})`;
private async _showNotSupportedMessage() {
this._addMessage({
who: "hass",
text: `${this._localize(
"ui.components.secure_connection_required.microphone"
)}\n\n${this._localize(
"ui.components.secure_connection_required.learn_more",
{ documentation_link: documentationLink }
)}`,
text:
// New lines matter for messages
// prettier-ignore
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
thinking: "",
tool_calls: {},
});
@@ -573,31 +583,13 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
this._stt_binary_handler_id = undefined;
this._audioBuffer = [];
const microphoneError = await this._audioRecorder.start();
if (microphoneError) {
this._audioBuffer = undefined;
this._processing = false;
this._addMessage({
who: "hass",
text: this._localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
thinking: "",
tool_calls: {},
error: true,
});
return;
}
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
this._addMessage(userMessage);
@@ -610,9 +602,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(
this._connection.hassUrl(event.data.tts_output!.url)
);
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
@@ -626,11 +616,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_playing_audio"
),
});
showAlertDialog(this, { title: "Error playing audio." });
});
}
@@ -680,9 +666,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
} catch (err: any) {
await showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_starting_pipeline"
),
title: "Error starting pipeline",
text: err.message || err,
});
this._stopListening();
+3 -6
View File
@@ -1,19 +1,16 @@
import type { ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { configContext } from "../data/context";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./item/ha-list-item-button";
import "./list/ha-list-nav";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
@customElement("ha-config-navigation-list")
class HaConfigNavigationList extends LitElement {
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@@ -66,7 +63,7 @@ class HaConfigNavigationList extends LitElement {
}
private _handleExternalApp() {
this._config.auth.external!.fireMessage({ type: "config_screen/show" });
this.hass.auth.external!.fireMessage({ type: "config_screen/show" });
}
static styles: CSSResultGroup = css`
+9 -10
View File
@@ -20,7 +20,6 @@ import "./ha-button";
import "./ha-dropdown";
import type { HaDropdownSelectEvent } from "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-secure-connection-alert";
import "./ha-spinner";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
@@ -164,15 +163,15 @@ class HaQrScanner extends LitElement {
: nothing
}
</div>`
: html`${
!window.isSecureContext
? html`<ha-secure-connection-alert
feature="camera"
></ha-secure-connection-alert>`
: html`<ha-alert alert-type="warning">
${this._localize("ui.components.qr-scanner.not_supported")}
</ha-alert>`
}
: html`<ha-alert alert-type="warning">
${
!window.isSecureContext
? this._localize(
"ui.components.qr-scanner.only_https_supported"
)
: this._localize("ui.components.qr-scanner.not_supported")
}
</ha-alert>
<p>${this._localize("ui.components.qr-scanner.manual_input")}</p>
<div class="row">
<ha-input
@@ -1,74 +0,0 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { configContext } from "../data/context";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import "./ha-alert";
export type SecureConnectionFeature =
"microphone" | "camera" | "push_notifications";
@customElement("ha-secure-connection-alert")
class HaSecureConnectionAlert extends LitElement {
@property() public feature!: SecureConnectionFeature;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
protected render() {
if (!this._localize || !this._config) {
return nothing;
}
return html`
<ha-alert alert-type="warning">
${this._localize(
`ui.components.secure_connection_required.${this.feature}`
)}
${this._localize(
"ui.components.secure_connection_required.learn_more",
{
documentation_link: html`<a
href=${documentationUrl(
this._config,
SECURE_CONNECTION_DOCUMENTATION_PATH
)}
target="_blank"
rel="noopener noreferrer"
>${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}</a
>`,
}
)}
</ha-alert>
`;
}
static styles = css`
:host {
display: block;
}
a {
color: var(--primary-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-secure-connection-alert": HaSecureConnectionAlert;
}
}
+6 -13
View File
@@ -1,15 +1,12 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { fireEvent } from "../../common/dom/fire_event";
import type { ConfigEntry } from "../../data/config_entries";
import { getConfigEntries } from "../../data/config_entries";
import { apiContext, registriesContext } from "../../data/context";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
import type { AreaSelector } from "../../data/selector";
@@ -23,11 +20,7 @@ import "../ha-areas-picker";
@customElement("ha-selector-area")
export class HaAreaSelector extends LitElement {
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AreaSelector;
@@ -84,7 +77,7 @@ export class HaAreaSelector extends LitElement {
protected updated(): void {
if (!this._fetchedConfigEntries && this._hasIntegration(this.selector)) {
this._fetchedConfigEntries = true;
getConfigEntries(this._api)
getConfigEntries(this.hass)
.then((entries) => {
this._configEntries = entries;
})
@@ -147,8 +140,8 @@ export class HaAreaSelector extends LitElement {
filter,
entity,
undefined,
this._registries.entities,
this._registries.devices
this.hass.entities,
this.hass.devices
)
);
};
@@ -160,8 +153,8 @@ export class HaAreaSelector extends LitElement {
const deviceIntegrations = this._hasIntegration(this.selector)
? this._deviceIntegrationLookup(
this._registries.entities,
this._registries.devices,
this.hass.entities,
this.hass.devices,
this._configEntries
)
: undefined;
@@ -134,7 +134,7 @@ export class HaNumberSelector extends LitElement {
.required=${this.required}
.validationMessage=${this.selector.number?.validation_message}
type="number"
auto-validate
autoValidate
.withoutSpinButtons=${!isBox}
@input=${this._handleInputChange}
>
@@ -1,13 +1,9 @@
import type { ContextType } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { configContext, connectionContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-code-editor";
import "../ha-input-helper-text";
@@ -21,14 +17,7 @@ const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"];
@customElement("ha-selector-template")
export class HaTemplateSelector extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: TemplateSelector;
@@ -90,7 +79,7 @@ export class HaTemplateSelector extends LitElement {
${
this.warn
? html`<ha-alert alert-type="warning"
>${this._localize(
>${this.hass.localize(
"ui.components.selectors.template.yaml_warning",
{ string: this.warn }
)}
@@ -99,10 +88,10 @@ export class HaTemplateSelector extends LitElement {
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
this.hass,
"/docs/configuration/templating/"
)}
>${this._localize(
>${this.hass.localize(
"ui.components.selectors.template.learn_more"
)}</a
></ha-alert
@@ -165,7 +154,7 @@ ${
try {
this._unsubRenderTemplate = subscribeRenderTemplate(
this._connection.connection,
this.hass.connection,
(result) => {
if ("error" in result) {
// We show the latest error, or a warning if there are no errors
+12 -17
View File
@@ -1,16 +1,13 @@
import type { ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { normalizeLuminance } from "../common/color/palette";
import { consume } from "../common/decorators/consume";
import { fireEvent } from "../common/dom/fire_event";
import { uiContext } from "../data/context";
import {
DefaultAccentColor,
DefaultPrimaryColor,
} from "../resources/theme/color/color.globals";
import type { ThemeSettings, ValueChangedEvent } from "../types";
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
import "./ha-button";
import "./item/ha-list-item-base";
import "./list/ha-list-base";
@@ -37,9 +34,7 @@ export interface ThemeSettingsLabels {
@customElement("ha-theme-settings")
export class HaThemeSettings extends LitElement {
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selectedTheme?: ThemeSettings | null;
@@ -61,19 +56,19 @@ export class HaThemeSettings extends LitElement {
public themePickerDisabled = false;
protected render(): TemplateResult {
const themeSettings = this.selectedTheme ?? this._ui.selectedTheme;
const themeSettings = this.selectedTheme ?? this.hass.selectedTheme;
const curThemeIsUseDefault = themeSettings?.theme === "";
const curTheme = themeSettings?.theme
? themeSettings.theme
: this._ui.themes.darkMode
? this._ui.themes.default_dark_theme || this._ui.themes.default_theme
: this._ui.themes.default_theme;
: this.hass.themes.darkMode
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
: this.hass.themes.default_theme;
const showDarkMode =
curTheme === HOME_ASSISTANT_THEME ||
(curThemeIsUseDefault &&
this._ui.themes.default_dark_theme &&
this._ui.themes.default_theme) ||
this.hass.themes.default_dark_theme &&
this.hass.themes.default_theme) ||
this._supportsModeSelection(curTheme);
return html`
@@ -208,7 +203,7 @@ export class HaThemeSettings extends LitElement {
}
private _supportsModeSelection(themeName: string): boolean {
const theme = this._ui.themes.themes[themeName];
const theme = this.hass.themes.themes[themeName];
if (!theme) {
return false;
}
@@ -236,7 +231,7 @@ export class HaThemeSettings extends LitElement {
const theme = ev.detail.value;
if (theme === undefined) {
if (this.selectedTheme?.theme || this._ui.selectedTheme?.theme) {
if (this.selectedTheme?.theme || this.hass.selectedTheme?.theme) {
fireEvent(this, "theme-settings-changed", {
theme: "",
primaryColor: undefined,
@@ -246,7 +241,7 @@ export class HaThemeSettings extends LitElement {
return;
}
if (theme === (this.selectedTheme ?? this._ui.selectedTheme)?.theme) {
if (theme === (this.selectedTheme ?? this.hass.selectedTheme)?.theme) {
return;
}
+1 -14
View File
@@ -1,4 +1,4 @@
import { type LitElement, type PropertyValues, css } from "lit";
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
@@ -23,7 +23,6 @@ export interface WaInput {
): void;
checkValidity(): boolean;
validationMessage: string;
updateComplete?: Promise<boolean>;
}
export interface WaInputMixinInterface {
@@ -227,18 +226,6 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
}
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (this._invalid && changedProperties.has("value")) {
this._revalidate();
}
}
private async _revalidate(): Promise<void> {
await this._formControl?.updateComplete;
this._invalid = !this.checkValidity();
}
protected _handleInvalid(): void {
// Polyfilled internals dispatch `invalid` themselves, so only trust the
// event when validity comes from the platform (#51338).
+40
View File
@@ -0,0 +1,40 @@
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../common/const";
import { computeDomain } from "../../common/entity/compute_domain";
import type { EntityCategory } from "../entity/entity_registry";
export const DEVICE_ENTITY_CATEGORIES = [
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const;
export type DeviceEntityCategory = (typeof DEVICE_ENTITY_CATEGORIES)[number];
export const computeDeviceEntityCategory = (entry: {
entity_id: string;
entity_category?: EntityCategory | null;
}): DeviceEntityCategory => {
const domain = computeDomain(entry.entity_id);
if (ASSIST_ENTITIES.includes(domain)) {
return "assist";
}
if (domain === "event" || domain === "notify") {
return domain;
}
if (entry.entity_category) {
return entry.entity_category;
}
if (SENSOR_ENTITIES.includes(domain)) {
return "sensor";
}
return "control";
};
+1 -1
View File
@@ -10,7 +10,7 @@ import type { LightColor } from "../light";
import type { NextNamePart, RegistryEntry } from "../registry";
import type { Segment } from "../vacuum";
type EntityCategory = "config" | "diagnostic";
export type EntityCategory = "config" | "diagnostic";
export interface EntityRegistryDisplayEntry {
entity_id: string;
+2 -5
View File
@@ -104,16 +104,13 @@ export const fetchDateWS = (
hass: Pick<HomeAssistant, "states" | "callWS">,
startTime: Date,
endTime: Date,
entityIds: string[],
options: {
minimalResponse?: boolean;
} = {}
entityIds: string[]
) => {
const params = {
type: "history/history_during_period",
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
minimal_response: options.minimalResponse ?? true,
minimal_response: true,
no_attributes: !entityIds.some((entityId) =>
entityIdHistoryNeedsAttributes(hass, entityId)
),
+2 -1
View File
@@ -133,7 +133,8 @@ export class DialogEnterCode
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
auto-validate
autoValidate
validateOnInitialRender
pattern=${ifDefined(this._dialogParams.codePattern)}
@keydown=${this._handleKeyDown}
inputmode="text"
@@ -365,6 +365,7 @@ export default class HaAutomationActionRow extends LitElement {
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
.hass=${this.hass}
.condition=${liveTestCondition}
>
<ha-svg-icon
@@ -185,6 +185,7 @@ export default class HaAutomationConditionRow extends LitElement {
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition}
>
<ha-condition-icon
@@ -532,9 +532,6 @@ export class HaBackupOverviewProgress extends LitElement {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-list-item-base::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-base::part(supporting-text) {
display: flex;
align-items: center;
@@ -145,6 +145,7 @@ class HaConfigSystemNavigation extends LitElement {
>
<ha-card outlined>
<ha-config-navigation-list
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
has-secondary
@@ -106,6 +106,7 @@ class HaConfigNavigation extends LitElement {
<div class="visually-hidden" role="heading" aria-level="2">${label}</div>
<ha-config-navigation-list
has-secondary
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
.label=${label}
@@ -25,7 +25,6 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -65,6 +64,11 @@ import {
sortConfigEntries,
} from "../../../data/config_entries";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import {
DEVICE_ENTITY_CATEGORIES,
computeDeviceEntityCategory,
type DeviceEntityCategory,
} from "../../../data/device/device_entity_category";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import {
removeDeviceFromRegistry,
@@ -305,44 +309,11 @@ export class HaConfigDevicePage extends LitElement {
private _entitiesByCategory = memoizeOne(
(entities: EntityRegistryEntry[]) => {
const result = groupBy(entities, (entry) => {
const domain = computeDomain(entry.entity_id);
if (ASSIST_ENTITIES.includes(domain)) {
return "assist";
}
if (domain === "event" || domain === "notify") {
return domain;
}
if (entry.entity_category) {
return entry.entity_category;
}
if (SENSOR_ENTITIES.includes(domain)) {
return "sensor";
}
return "control";
}) as Record<
| "control"
| "event"
| "sensor"
| "assist"
| "notify"
| NonNullable<EntityRegistryEntry["entity_category"]>,
const result = groupBy(entities, computeDeviceEntityCategory) as Record<
DeviceEntityCategory,
EntityRegistryStateEntry[]
>;
for (const key of [
"assist",
"config",
"control",
"diagnostic",
"event",
"notify",
"sensor",
]) {
for (const key of DEVICE_ENTITY_CATEGORIES) {
if (!(key in result)) {
result[key] = [];
}
@@ -909,17 +880,7 @@ export class HaConfigDevicePage extends LitElement {
`;
const entitiesColumn = html`
${(
[
"control",
"sensor",
"notify",
"event",
"assist",
"config",
"diagnostic",
] as const
).map((category) =>
${DEVICE_ENTITY_CATEGORIES.map((category) =>
// Make sure we render controls if no other cards will be rendered
entitiesByCategory[category].length > 0 ||
(entities.length === 0 && category === "control")
@@ -664,13 +664,8 @@ export class HaConfigEntryRow extends LitElement {
const locale_key = result.require_restart
? "reload_restart_confirm"
: "reload_confirm";
// The row can move between sections when the entry state changes on reload
const host =
(this.isConnected
? this
: document.querySelector<HTMLElement>("home-assistant")) ?? this;
showToast(host, {
message: this.hass.localize(
showAlertDialog(this, {
text: this.hass.localize(
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
@@ -0,0 +1,253 @@
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
import type { ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeRTL } from "../../../common/util/compute_rtl";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import {
devicesContext,
internationalizationContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import type { HomeAssistantInternationalization } from "../../../types";
import type { ConfigEntryExtended } from "./ha-config-integrations";
@customElement("ha-integration-card-footer")
export class HaIntegrationCardFooter extends LitElement {
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public items!: ConfigEntryExtended[];
@property({ attribute: false })
public entityRegistryEntries!: EntityRegistryEntry[];
@property({ attribute: false }) public domainEntities: string[] = [];
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, boolean>({
transformer: ({ language, translationMetadata }) =>
computeRTL(language, translationMetadata.translations),
})
private _isRTL!: boolean;
protected render(): TemplateResult | typeof nothing {
const devices = this._getDevices(this.items, this._devices);
const entitiesCount = devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
);
const services = !devices.some((device) => device.entry_type !== "service");
const hasIcons =
Boolean(this.manifest && !this.manifest.is_built_in) ||
Boolean(this.manifest?.iot_class?.startsWith("cloud_")) ||
Boolean(
this.manifest &&
!this.manifest.config_flow &&
!this.items.every((itm) => itm.source === "system")
);
let countBadge: TemplateResult | typeof nothing = nothing;
if (devices.length > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.${services ? "services" : "devices"}`,
{ count: devices.length }
)}</span
>`;
} else if (entitiesCount > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entitiesCount }
)}</span
>`;
} else if (this.items.find((itm) => itm.source !== "yaml")) {
countBadge = html`<span class="count-badge"
>${this._localize(`ui.panel.config.integrations.config_entry.entries`, {
count: this.items.filter((itm) => itm.source !== "yaml").length,
})}</span
>`;
}
if (countBadge === nothing && !hasIcons) return nothing;
const placement = this._isRTL ? "right" : "left";
return html`
<div class="footer">
${countBadge}
<div class="icons">
${
this.manifest && !this.manifest.is_built_in
? html`<span
class="icon ${
this.manifest.overwrites_built_in ? "overwrites" : "custom"
}"
>
<ha-svg-icon
id="icon-custom"
.path=${mdiPackageVariant}
></ha-svg-icon>
<ha-tooltip for="icon-custom" .placement=${placement}>
${this._localize(
this.manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
? html`<span class="icon cloud">
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
<ha-tooltip for="icon-cloud" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest &&
!this.manifest?.config_flow &&
!this.items.every((itm) => itm.source === "system")
? html`<span class="icon yaml">
<ha-svg-icon
id="icon-yaml"
.path=${mdiFileCodeOutline}
></ha-svg-icon>
<ha-tooltip for="icon-yaml" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)}
</ha-tooltip>
</span>`
: nothing
}
</div>
</div>
`;
}
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntryExtended[],
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return domainEntities.length;
}
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntryExtended[],
deviceRegistryEntries: ContextType<typeof devicesContext>
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return Object.values(deviceRegistryEntries).filter((device) =>
device.config_entries.some((entryId) => entryIds.includes(entryId))
);
}
);
static styles = css`
:host {
display: block;
}
.footer {
display: flex;
align-items: center;
padding-top: var(--ha-space-2);
min-height: var(--ha-space-8);
}
.count-badge {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.icons {
display: flex;
margin-inline-start: auto;
}
.icon {
color: var(--label-badge-grey);
padding: var(--ha-space-1);
margin-inline-start: var(--ha-space-2);
}
.icon.custom {
color: var(--warning-color);
}
.icon.overwrites {
color: var(--error-color);
}
.icon ha-svg-icon {
width: 24px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-footer": HaIntegrationCardFooter;
}
}
@@ -0,0 +1,178 @@
import { mdiAlertCircleOutline, mdiAlertOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import { configContext, uiContext } from "../../../data/context";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
@customElement("ha-integration-card-header")
export class HaIntegrationCardHeader extends LitElement {
@property() public error?: string;
@property() public warning?: string;
@property({ attribute: false }) public localizedDomainName?: string;
@property() public domain!: string;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: ({ themes }) => themes?.darkMode,
})
private _darkMode?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, string>({
transformer: ({ auth }) => auth.data.hassUrl,
})
private _hassUrl!: string;
protected render(): TemplateResult {
const domainName =
this.localizedDomainName ||
domainToName(this._localize, this.domain, this.manifest);
return html`
<div class="header">
<img
alt=""
src=${brandsUrl(
{
domain: this.domain,
type: "icon",
darkOptimized: this._darkMode,
},
this._hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<div class="info">
<div
class="primary ${this.warning || this.error ? "has-secondary" : ""}"
role="heading"
aria-level="1"
>
${domainName}
</div>
${
this.error
? html`
<div class="secondary error">
<ha-svg-icon .path=${mdiAlertCircleOutline}></ha-svg-icon>
<span>${this.error}</span>
</div>
`
: this.warning
? html`
<div class="secondary warning">
<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>
<span>${this.warning}</span>
</div>
`
: nothing
}
</div>
</div>
`;
}
private _onImageLoad(ev) {
ev.target.style.visibility = "initial";
}
private _onImageError(ev) {
ev.target.style.visibility = "hidden";
}
static styles = css`
:host {
display: block;
flex: 1;
padding-bottom: var(--ha-space-2);
}
.header {
display: flex;
align-items: center;
direction: var(--direction);
min-width: 0;
}
.header img {
margin-inline-start: initial;
margin-inline-end: var(--ha-space-4);
width: 40px;
height: 40px;
direction: var(--direction);
}
.info {
display: flex;
flex-direction: column;
flex: 1;
align-self: center;
min-width: 0;
}
.primary {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
color: var(--primary-text-color);
}
.has-secondary {
-webkit-line-clamp: 1;
font-size: var(--ha-font-size-m);
}
.secondary {
min-width: 0;
--mdc-icon-size: 20px;
font-size: var(--ha-font-size-s);
display: flex;
flex-direction: row;
align-items: flex-start;
}
.secondary > span {
position: relative;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary > ha-svg-icon {
margin-inline-end: var(--ha-space-1);
flex-shrink: 0;
}
.error ha-svg-icon {
color: var(--error-color);
}
.warning ha-svg-icon {
color: var(--warning-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-header": HaIntegrationCardHeader;
}
}
@@ -1,72 +1,31 @@
import type { ContextType } from "@lit/context";
import {
mdiAlertCircle,
mdiBugOutline,
mdiFileCodeOutline,
mdiPackageVariant,
mdiWeb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-card";
import "../../../components/ha-ripple";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/item/ha-row-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { ERROR_STATES } from "../../../data/config_entries";
import {
configContext,
devicesContext,
uiContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type {
IntegrationLogInfo,
IntegrationManifest,
} from "../../../data/integration";
import { domainToName, LogSeverity } from "../../../data/integration";
import { LogSeverity } from "../../../data/integration";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { ConfigEntryExtended } from "./ha-config-integrations";
import type { IntegrationCardSummary } from "./integration-card-summary";
import {
computeIntegrationCardState,
computeIntegrationCardSummary,
} from "./integration-card-summary";
interface IntegrationCardStatus {
level: "error" | "warning" | "progress";
/** Icon path; omitted for "progress", which shows a spinner. */
icon?: string;
message: string;
}
// Status shown for card states that need attention or are in progress.
const STATE_STATUS: Partial<
Record<ConfigEntry["state"], Omit<IntegrationCardStatus, "message">>
> = {
setup_error: { level: "error", icon: mdiAlertCircle },
setup_retry: { level: "error", icon: mdiAlertCircle },
migration_error: { level: "error", icon: mdiAlertCircle },
not_loaded: { level: "warning", icon: mdiAlertCircle },
failed_unload: { level: "warning", icon: mdiAlertCircle },
setup_in_progress: { level: "progress" },
unload_in_progress: { level: "progress" },
};
import "./ha-integration-card-footer";
import "./ha-integration-card-header";
@customElement("ha-integration-card")
export class HaIntegrationCard extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property() public domain!: string;
@property({ attribute: false }) public items!: ConfigEntryExtended[];
@@ -80,54 +39,22 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false }) public domainEntities: string[] = [];
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: ({ themes }) => themes?.darkMode,
})
private _darkMode?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, string>({
transformer: ({ auth }) => auth.data.hassUrl,
})
private _hassUrl!: string;
// There is no hover on touch devices, so a tap on a tooltip anchor shows
// the tooltip instead of following the card link.
private _lastPointerType?: string;
protected render(): TemplateResult {
const entryState = computeIntegrationCardState(this.items);
const isError = ERROR_STATES.includes(entryState);
const debugLoggingEnabled = this.logInfo?.level === LogSeverity.DEBUG;
const entryState = this._getState(this.items);
const status = this._computeStatus(entryState, debugLoggingEnabled);
const domainName =
this.items[0].localized_domain_name ||
domainToName(this._localize, this.domain, this.manifest);
const debugLoggingEnabled =
this.logInfo && this.logInfo.level === LogSeverity.DEBUG;
return html`
<ha-card
outlined
class=${classMap({
"state-loaded": entryState === "loaded",
"state-not-loaded": entryState === "not_loaded",
"all-not-loaded": this.items.every(
(entry) => entry.state === "not_loaded"
),
"state-failed-unload": entryState === "failed_unload",
"state-error": isError,
"debug-logging": debugLoggingEnabled,
"state-setup": entryState === "setup_in_progress",
"state-error": ERROR_STATES.includes(entryState),
"debug-logging": Boolean(debugLoggingEnabled),
})}
>
<a
@@ -135,296 +62,55 @@ export class HaIntegrationCard extends LitElement {
class="ripple-anchor"
>
<ha-ripple></ha-ripple>
<ha-row-item>
<img
slot="start"
alt=""
src=${brandsUrl(
{
domain: this.domain,
type: "icon",
darkOptimized: this._darkMode,
},
this._hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<span slot="headline" class="name" role="heading" aria-level="1"
>${domainName}</span
>
${this._renderSupportingText(status)}
</ha-row-item>
<div class="card-content">
<ha-integration-card-header
.domain=${this.domain}
.localizedDomainName=${this.items[0].localized_domain_name}
.error=${
ERROR_STATES.includes(entryState)
? this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
)
: undefined
}
.warning=${
entryState !== "loaded" && !ERROR_STATES.includes(entryState)
? this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
)
: debugLoggingEnabled
? this._localize(
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
)
: undefined
}
.manifest=${this.manifest}
></ha-integration-card-header>
<ha-integration-card-footer
.manifest=${this.manifest}
.items=${this.items}
.entityRegistryEntries=${this.entityRegistryEntries}
.domainEntities=${this.domainEntities}
></ha-integration-card-footer>
</div>
</a>
</ha-card>
`;
}
private _computeStatus(
entryState: ConfigEntry["state"],
debugLoggingEnabled: boolean
): IntegrationCardStatus | undefined {
const stateStatus = STATE_STATUS[entryState];
if (stateStatus) {
return {
...stateStatus,
message: this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
),
};
}
if (debugLoggingEnabled) {
return {
level: "warning",
icon: mdiBugOutline,
message: this._localize(
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
),
};
}
return undefined;
}
private _computeSupportingText(
summary?: IntegrationCardSummary,
status?: IntegrationCardStatus
): string | undefined {
if (!summary) {
return status?.message;
}
if (!summary.attention) {
return this._localize(
`ui.panel.config.integrations.config_entry.${summary.unit}`,
{ count: summary.count }
);
}
const attentionState = summary.attentionState ?? "other";
if (summary.attention >= summary.count) {
return this._localize(
`ui.panel.config.integrations.config_entry.attention.${summary.unit}_all`,
{ count: summary.count, state: attentionState }
);
}
return this._localize(
`ui.panel.config.integrations.config_entry.attention.${summary.unit}_some`,
{
count: summary.count,
attention: summary.attention,
state: attentionState,
private _getState = memoizeOne(
(configEntry: ConfigEntry[]): ConfigEntry["state"] => {
if (configEntry.length === 1) {
return configEntry[0].state;
}
);
}
private _renderSupportingText(
status?: IntegrationCardStatus
): TemplateResult | typeof nothing {
const devices = this._getDevices(this.items, this._devices);
const summary = computeIntegrationCardSummary(
this.items,
devices,
this.entityRegistryEntries,
devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
)
);
const icons = this._renderIcons();
if (!summary && !status && icons === nothing) {
return nothing;
}
const text = this._computeSupportingText(summary, status);
return html`<span slot="supporting-text" class="supporting-text">
${
status
? html`<span
id="status"
class="status status-${status.level}"
@pointerdown=${this._handleTooltipPointerDown}
@click=${this._handleTooltipClick}
>
${
status.icon
? html`<ha-svg-icon
.path=${status.icon}
role="img"
aria-label=${status.message}
></ha-svg-icon>`
: html`<ha-spinner
size="tiny"
role="img"
aria-label=${status.message}
></ha-spinner>`
}
${text ? html`<span class="text">${text}</span>` : nothing}
</span>
<ha-tooltip for="status" trigger="hover focus click"
>${status.message}</ha-tooltip
>`
: text
? html`<span class="text">${text}</span>`
: nothing
}
${icons}
</span>`;
}
private _renderIcons(): TemplateResult | typeof nothing {
const manifest = this.manifest;
if (!manifest) {
return nothing;
}
const isCustom = !manifest.is_built_in;
const isCloud = Boolean(manifest.iot_class?.startsWith("cloud_"));
const isYaml =
!manifest.config_flow &&
!this.items.every((itm) => itm.source === "system");
if (!isCustom && !isCloud && !isYaml) {
return nothing;
}
return html`
<span class="icons">
${
isCustom
? this._renderIcon(
"icon-custom",
mdiPackageVariant,
this._localize(
manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
),
manifest.overwrites_built_in ? "overwrites" : "custom"
)
: nothing
let entryState: ConfigEntry["state"];
for (const entry of configEntry) {
if (ERROR_STATES.includes(entry.state)) {
return entry.state;
}
${
isCloud
? this._renderIcon(
"icon-cloud",
mdiWeb,
this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)
)
: nothing
}
${
isYaml
? this._renderIcon(
"icon-yaml",
mdiFileCodeOutline,
this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)
)
: nothing
}
</span>
`;
}
private _renderIcon(
id: string,
path: string,
label: string,
className = ""
): TemplateResult {
return html`<span
id=${id}
class="icon ${className}"
role="img"
aria-label=${label}
@pointerdown=${this._handleTooltipPointerDown}
@click=${this._handleTooltipClick}
>
<ha-svg-icon .path=${path}></ha-svg-icon>
</span>
<ha-tooltip for=${id} trigger="hover focus click">${label}</ha-tooltip>`;
}
private _handleTooltipPointerDown(ev: PointerEvent) {
this._lastPointerType = ev.pointerType;
}
private _handleTooltipClick(ev: MouseEvent) {
if (this._lastPointerType !== "touch") {
return;
}
ev.preventDefault();
ev.stopPropagation();
}
private _onImageLoad(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "initial";
}
private _onImageError(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "hidden";
}
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntryExtended[],
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return domainEntities.length;
entryState = entry.state;
}
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntryExtended[],
deviceRegistryEntries: ContextType<typeof devicesContext>
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return Object.values(deviceRegistryEntries).filter((device) =>
device.config_entries.some((entryId) => entryIds.includes(entryId))
);
return entryState!;
}
);
@@ -439,6 +125,7 @@ export class HaIntegrationCard extends LitElement {
overflow: hidden;
cursor: pointer;
--state-color: var(--divider-color, #e0e0e0);
--state-message-color: var(--state-color);
}
ha-card:hover {
background-color: var(--ha-color-fill-neutral-quiet-resting);
@@ -461,107 +148,41 @@ export class HaIntegrationCard extends LitElement {
background-color: var(--secondary-text-color);
opacity: 0.08;
}
ha-row-item {
.card-content {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
flex: 1;
display: flex;
--ha-row-item-padding-block: var(--ha-space-4);
--ha-row-item-padding-inline: var(--ha-space-4);
--ha-row-item-gap: var(--ha-space-4);
}
/* Fill the card height so content stays centered when a neighboring
card in the grid row is taller */
ha-row-item::part(base) {
flex: 1;
min-width: 0;
}
ha-row-item::part(headline) {
font-size: var(--ha-font-size-l);
}
.name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
img {
width: 40px;
height: 40px;
}
.supporting-text {
display: flex;
align-items: center;
gap: var(--ha-space-1);
min-height: calc(
var(--ha-font-size-s) * var(--ha-line-height-normal)
);
}
.supporting-text .text,
.status .text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
display: flex;
align-items: center;
gap: var(--ha-space-1);
min-width: 0;
line-height: 16px;
}
.status ha-svg-icon,
.status ha-spinner {
flex-shrink: 0;
--mdc-icon-size: 16px;
}
.status ha-spinner {
--track-width: 2px;
}
.status-error ha-svg-icon {
color: var(--error-color);
}
.status-warning ha-svg-icon {
color: var(--warning-color);
}
.icons {
display: flex;
flex-shrink: 0;
gap: var(--ha-space-1);
margin-inline-start: auto;
padding-inline-start: var(--ha-space-1);
}
.icon {
display: flex;
color: var(--label-badge-grey);
--mdc-icon-size: 16px;
}
.icon.custom {
color: var(--warning-color);
}
.icon.overwrites {
color: var(--error-color);
flex-direction: column;
}
.debug-logging {
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--primary-text-color);
}
.state-error {
--state-color: var(--error-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--text-primary-color);
}
.state-failed-unload {
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--primary-text-color);
}
.state-not-loaded {
opacity: 0.8;
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--state-message-color: var(--primary-text-color);
}
.all-not-loaded {
.state-setup {
opacity: 0.8;
--state-message-color: var(--secondary-text-color);
}
:host(.highlight) ha-card {
--state-color: var(--primary-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--text-primary-color);
}
a {
text-decoration: none;
@@ -1,136 +0,0 @@
import type { ConfigEntry } from "../../../data/config_entries";
import { ERROR_STATES } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
export type IntegrationCardSummaryUnit =
"devices" | "services" | "entities" | "entries";
export interface IntegrationCardSummary {
unit: IntegrationCardSummaryUnit;
/** Total number of items of `unit`. */
count: number;
/** Number of items of `unit` that belong to a config entry needing attention. */
attention: number;
/**
* The state shared by all config entries needing attention, or `undefined`
* when they are in different states.
*/
attentionState?: ConfigEntry["state"];
}
// Transient states (setup/unload in progress) and "loaded" don't need attention.
const ATTENTION_STATES: ConfigEntry["state"][] = [
...ERROR_STATES,
"not_loaded",
"failed_unload",
];
// Card state priority, most important first.
const CARD_STATE_PRIORITY: ConfigEntry["state"][] = [
...ATTENTION_STATES,
"setup_in_progress",
"unload_in_progress",
];
export const configEntryNeedsAttention = (entry: ConfigEntry): boolean =>
ATTENTION_STATES.includes(entry.state);
/**
* The state the integration card shows for a group of config entries: the
* most important state of any entry, so the card never looks healthy while
* one of its entries needs attention.
*/
export const computeIntegrationCardState = (
entries: ConfigEntry[]
): ConfigEntry["state"] => {
for (const cardState of CARD_STATE_PRIORITY) {
if (entries.some((entry) => entry.state === cardState)) {
return cardState;
}
}
return "loaded";
};
/**
* Compute what the integration card shows as supporting text: the number of
* devices, services, entities or entries, and how many of those belong to a
* config entry that needs attention.
*
* When an entry needing attention owns none of the counted items (for
* example an entry that never set up and has no devices yet), the summary
* counts entries instead so the problem is never hidden.
*/
export const computeIntegrationCardSummary = (
entries: ConfigEntry[],
devices: DeviceRegistryEntry[],
entityRegistryEntries: EntityRegistryEntry[],
entityCount: number
): IntegrationCardSummary | undefined => {
const attentionEntries = entries.filter(configEntryNeedsAttention);
const attentionEntryIds = new Set(
attentionEntries.map((entry) => entry.entry_id)
);
const attentionStates = new Set(attentionEntries.map((entry) => entry.state));
const attentionState =
attentionStates.size === 1 ? [...attentionStates][0] : undefined;
const uiEntries = entries.filter((entry) => entry.source !== "yaml");
// Attention entries that own at least one of the counted items.
const representedEntryIds = new Set<string>();
let summary: IntegrationCardSummary | undefined;
if (devices.length) {
let attention = 0;
for (const device of devices) {
const deviceAttentionIds = device.config_entries.filter((id) =>
attentionEntryIds.has(id)
);
if (deviceAttentionIds.length) {
attention++;
deviceAttentionIds.forEach((id) => representedEntryIds.add(id));
}
}
summary = {
unit: devices.every((device) => device.entry_type === "service")
? "services"
: "devices",
count: devices.length,
attention,
};
} else if (entityCount > 0) {
let attention = 0;
for (const entity of entityRegistryEntries) {
if (
entity.config_entry_id &&
attentionEntryIds.has(entity.config_entry_id)
) {
attention++;
representedEntryIds.add(entity.config_entry_id);
}
}
summary = {
unit: "entities",
count: entityCount,
attention: Math.min(entityCount, attention),
};
} else if (uiEntries.length) {
return {
unit: "entries",
count: uiEntries.length,
attention: attentionEntryIds.size,
attentionState,
};
}
if (representedEntryIds.size < attentionEntryIds.size) {
return {
unit: "entries",
count: Math.max(uiEntries.length, attentionEntryIds.size),
attention: attentionEntryIds.size,
attentionState,
};
}
return summary && { ...summary, attentionState };
};
@@ -3,8 +3,8 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import type { HomeAssistant } from "../../../../../../types";
import "../../../../../../components/ha-alert";
import "../../../../../../components/ha-icon-next";
import "../../../../../../components/ha-secure-connection-alert";
import "../../../../../../components/item/ha-list-item-button";
import "../../../../../../components/list/ha-list-base";
@@ -19,9 +19,11 @@ export class ZWaveJsAddNodeSelectMethod extends LitElement {
return html`
${
!this.hideQrWebcam && !window.isSecureContext
? html`<ha-secure-connection-alert
feature="camera"
></ha-secure-connection-alert>`
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.zwave_js.add_node.select_method.webcam_unsupported"
)}</ha-alert
>`
: nothing
}
<ha-list-base>
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { extractSearchParam } from "../../../../common/url/search-params";
@@ -7,7 +7,6 @@ import "../../../../components/ha-assist-pipeline-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../components/ha-checkbox";
import "../../../../components/ha-secure-connection-alert";
import "../../../../components/input/ha-input";
import type { HaInput } from "../../../../components/input/ha-input";
import type {
@@ -98,13 +97,6 @@ export class AssistPipelineRunDebug extends LitElement {
.value=${this._pipelineId}
@value-changed=${this._pipelinePicked}
></ha-assist-pipeline-picker>
${
!window.isSecureContext
? html`<ha-secure-connection-alert
feature="microphone"
></ha-secure-connection-alert>`
: nothing
}
<div class="start-buttons">
<ha-button
appearance="filled"
@@ -261,24 +253,6 @@ export class AssistPipelineRunDebug extends LitElement {
);
}
private async _startRecorder(audioRecorder: AudioRecorder) {
const microphoneError = await audioRecorder.start();
if (!microphoneError) {
return true;
}
this._audioBuffer = undefined;
this._finished = true;
showAlertDialog(this, {
text: this.hass.localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
});
return false;
}
private async _runAudioWakeWordPipeline() {
const audioRecorder = new AudioRecorder((data) => {
if (this._audioBuffer) {
@@ -289,10 +263,7 @@ export class AssistPipelineRunDebug extends LitElement {
});
this._audioBuffer = [];
if (!(await this._startRecorder(audioRecorder))) {
return;
}
await audioRecorder.start();
let run: PipelineRun | undefined;
@@ -335,7 +306,7 @@ export class AssistPipelineRunDebug extends LitElement {
// Play audio when we're done.
if (updatedRun.stage === "done" && !updatedRun.error) {
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
const url = updatedRun.tts!.tts_output!.url;
const audio = new Audio(url);
audio.addEventListener("ended", () => {
if (
@@ -375,10 +346,7 @@ export class AssistPipelineRunDebug extends LitElement {
});
this._audioBuffer = [];
if (!(await this._startRecorder(audioRecorder))) {
return;
}
await audioRecorder.start();
let run: PipelineRun | undefined;
@@ -418,7 +386,7 @@ export class AssistPipelineRunDebug extends LitElement {
// Play audio when we're done.
if (updatedRun.stage === "done") {
const url = this.hass.hassUrl(updatedRun.tts!.tts_output!.url);
const url = updatedRun.tts!.tts_output!.url;
const audio = new Audio(url);
audio.addEventListener("ended", () => {
if (
@@ -567,10 +535,6 @@ export class AssistPipelineRunDebug extends LitElement {
width: 100%;
margin-bottom: 16px;
}
ha-secure-connection-alert {
width: 100%;
margin-bottom: 16px;
}
.start-row ha-input {
flex: 1;
}
@@ -590,7 +590,7 @@ export class AssistPipelineDebug extends LitElement {
// Stop any existing audio first
this._stopTTS();
const url = this.hass.hassUrl(this.pipelineRun!.tts!.tts_output!.url);
const url = this.pipelineRun!.tts!.tts_output!.url;
this._audioElement = new Audio(url);
this._audioElement.addEventListener("error", () => {
@@ -27,10 +27,7 @@ import type {
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import {
findFirstActiveZone,
getEntityLocation,
} from "../../../../common/entity/get_entity_location";
import { getEntityLocation } from "../../../../common/entity/get_entity_location";
import {
HOME_ZONE_ENTITY_ID,
zoneColor,
@@ -61,7 +58,6 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
import type { HistoryStates } from "../../../../data/history";
import { fetchDateWS } from "../../../../data/history";
import { computeUserInitials } from "../../../../data/user";
import { computeZoneTrackerState } from "../../../../data/zone";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
import type {
CurrentUser,
@@ -73,6 +69,7 @@ import type {
} from "../../../../types";
import type { ActivityEntry } from "./map-activity";
import { personActivity, zoneActivity } from "./map-activity";
import { computeZoneTrackerState } from "../../../../data/zone";
export type OverviewTab = "people" | "devices" | "zones";
@@ -429,8 +426,7 @@ export class HuiMapOverview extends LitElement {
{ states: this._states, callWS: this._api.callWS },
new Date(since),
new Date(),
entityIds,
{ minimalResponse: false }
entityIds
);
} catch (_err) {
return null;
@@ -489,7 +485,9 @@ export class HuiMapOverview extends LitElement {
this._activity = [];
return;
}
this._activity = zoneActivity(history, personIds, entityId, since);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneState = computeZoneTrackerState(zone);
this._activity = zoneActivity(history, personIds, zoneState, since);
}
protected render() {
@@ -654,10 +652,7 @@ export class HuiMapOverview extends LitElement {
? this._personName(person)
: entry.personId
: this._formatters.formatEntityState(stateObj, entry.state);
const zone = entry.personId
? undefined
: ((entry.inZones && findFirstActiveZone(entry.inZones, this._states)) ??
this._zoneForState(entry.state));
const zone = entry.personId ? undefined : this._zoneForState(entry.state);
const tile = zone
? this._renderZoneAvatar(zone)
: person
@@ -11,7 +11,6 @@ const NO_LOCATION_STATES: string[] = [UNAVAILABLE, UNKNOWN];
export interface ActivityEntry {
state: string;
when: Date;
inZones?: string[];
/** Zone detail: the person that arrived at or left the zone */
personId?: string;
arrived?: boolean;
@@ -39,22 +38,19 @@ export const personActivity = (
if (!changed || entry.lu * 1000 < since) {
continue;
}
entries.push({
state: entry.s,
inZones: entry.a?.in_zones,
when: new Date(entry.lu * 1000),
});
entries.push({ state: entry.s, when: new Date(entry.lu * 1000) });
}
return entries.reverse().slice(0, ACTIVITY_MAX_ENTRIES);
};
/**
* Arrivals at and departures from a zone by the given persons, newest first.
* A person is in the zone while their state equals zoneState.
*/
export const zoneActivity = (
history: HistoryStates | undefined,
personIds: string[],
zoneId: string,
zoneState: string,
since: number
): ActivityEntry[] => {
const entries: ActivityEntry[] = [];
@@ -64,7 +60,7 @@ export const zoneActivity = (
if (NO_LOCATION_STATES.includes(entry.s)) {
continue;
}
const inZone = entry.a?.in_zones?.includes(zoneId) ?? false;
const inZone = entry.s === zoneState;
// The first sample is an event only if it is an in-window arrival.
const isEvent = wasInZone === undefined ? inZone : inZone !== wasInZone;
if (isEvent && entry.lu * 1000 >= since) {
@@ -6,25 +6,19 @@ import { stateColorCss } from "../../../../../common/entity/state_color";
import "../../../../../components/ha-icon";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/tile/ha-tile-badge";
import { computeZoneTrackerState } from "../../../../../data/zone";
import type { HomeAssistant } from "../../../../../types";
import type { RenderBadgeFunction } from "./tile-badge";
import { computeZoneTrackerState } from "../../../../../data/zone";
function getZone(entity: HassEntity, hass: HomeAssistant) {
const state = entity.state;
if (state === "home" || state === "not_home") return undefined;
// Zone names aren't unique, so check the zones the entity is in first
const zoneIds: string[] = [
...(entity.attributes.in_zones ?? []),
...Object.keys(hass.states).filter((entityId) =>
entityId.startsWith("zone.")
),
];
const zones = Object.values(hass.states).filter((stateObj) =>
stateObj.entity_id.startsWith("zone.")
);
return zoneIds
.map((zoneId) => hass.states[zoneId])
.find((zone) => zone && state === computeZoneTrackerState(zone));
return zones.find((z) => state === computeZoneTrackerState(z));
}
export const renderPersonBadge: RenderBadgeFunction = (stateObj, hass) => {
@@ -35,7 +35,6 @@ export class HuiBarGaugeCardFeatureEditor
selector: {
number: {
mode: "box",
step: "any",
},
},
},
@@ -45,7 +44,6 @@ export class HuiBarGaugeCardFeatureEditor
selector: {
number: {
mode: "box",
step: "any",
},
},
},
@@ -35,20 +35,12 @@ export class HuiNumericInputCardFeatureEditor
selector: {
select: {
multiple: false,
mode: "box",
mode: "list",
options: ["slider", "buttons"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.numeric-input.style_list.${mode}`
),
description: localize(
`ui.panel.lovelace.editor.features.types.numeric-input.style_list.${mode}_description`
),
image: {
src: `/static/images/form/feature_style_${mode}.svg`,
src_dark: `/static/images/form/feature_style_${mode}_dark.svg`,
flip_rtl: true,
},
})),
},
},
@@ -116,11 +116,11 @@ export class HuiSensorCardEditor
schema: [
{
name: "min",
selector: { number: { mode: "box", step: "any" } },
selector: { number: { mode: "box" } },
},
{
name: "max",
selector: { number: { mode: "box", step: "any" } },
selector: { number: { mode: "box" } },
},
],
},
@@ -35,20 +35,12 @@ export class HuiTargetHumidityCardFeatureEditor
selector: {
select: {
multiple: false,
mode: "box",
mode: "list",
options: ["slider", "buttons"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.target-humidity.style_list.${mode}`
),
description: localize(
`ui.panel.lovelace.editor.features.types.target-humidity.style_list.${mode}_description`
),
image: {
src: `/static/images/form/feature_style_${mode}.svg`,
src_dark: `/static/images/form/feature_style_${mode}_dark.svg`,
flip_rtl: true,
},
})),
},
},
@@ -35,20 +35,12 @@ export class HuiTargetTemperatureCardFeatureEditor
selector: {
select: {
multiple: false,
mode: "box",
mode: "list",
options: ["buttons", "slider"].map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.features.types.target-temperature.style_list.${mode}`
),
description: localize(
`ui.panel.lovelace.editor.features.types.target-temperature.style_list.${mode}_description`
),
image: {
src: `/static/images/form/feature_style_${mode}.svg`,
src_dark: `/static/images/form/feature_style_${mode}_dark.svg`,
flip_rtl: true,
},
})),
},
},
@@ -0,0 +1,244 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { STRINGS_SEPARATOR_DOT } from "../../../../common/const";
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 { computeEntityEntryName } from "../../../../common/entity/compute_entity_name_display";
import { stringCompare } from "../../../../common/string/compare";
import {
computeDeviceEntityCategory,
type DeviceEntityCategory,
} from "../../../../data/device/device_entity_category";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { findBatteryEntity } from "../../../../data/entity/entity_registry";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { HomeAssistant } from "../../../../types";
import type {
EntitiesCardConfig,
HeadingCardConfig,
MarkdownCardConfig,
TileCardConfig,
} from "../../cards/types";
import type { EntityConfig, SectionConfig } from "../../entity-rows/types";
import type {
LovelaceStrategyDependency,
LovelaceStrategyEditor,
} from "../types";
export const DEVICE_SECTION_CATEGORIES = [
"control",
"sensor",
"event",
"config",
"diagnostic",
] as const satisfies readonly DeviceEntityCategory[];
export type DeviceSectionCategory = (typeof DEVICE_SECTION_CATEGORIES)[number];
export const DEFAULT_DEVICE_SECTION_CATEGORIES: DeviceSectionCategory[] = [
"control",
"sensor",
];
export const DEVICE_SECTION_HEADINGS = ["none", "device", "area"] as const;
export type DeviceSectionHeading = (typeof DEVICE_SECTION_HEADINGS)[number];
export interface DeviceSectionStrategyConfig {
type: "device";
device_id?: string;
categories?: DeviceSectionCategory[];
display?: "tiles" | "list";
group_by_category?: boolean;
heading?: DeviceSectionHeading;
exclude_entities?: string[];
}
const computeTileConfig = (entityId: string): TileCardConfig => ({
type: "tile",
entity: entityId,
name: { type: "entity" },
});
const computeRowConfig = (entityId: string): EntityConfig => ({
entity: entityId,
name: { type: "entity" },
});
const computeDeviceSectionHeading = (
device: DeviceRegistryEntry,
heading: Exclude<DeviceSectionHeading, "none">,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
localize: HomeAssistant["localize"]
): string => {
const area =
heading === "area" ? getDeviceArea(device, areas, devices) : undefined;
const parentDevice =
device.next_name_part === "parent_device" && device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parts = [
area ? computeAreaName(area) : undefined,
parentDevice ? computeDeviceName(parentDevice) : undefined,
computeDeviceName(device) ||
localize("ui.components.device-picker.unnamed_device"),
].filter(Boolean);
return parts.join(STRINGS_SEPARATOR_DOT);
};
@customElement("device-section-strategy")
export class DeviceSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [
"entities",
"devices",
"areas",
];
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
await import("./hui-device-section-strategy-editor");
return document.createElement("hui-device-section-strategy-editor");
}
static async generate(
config: DeviceSectionStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceSectionConfig> {
const section: LovelaceSectionConfig = {
type: "grid",
cards: [],
};
const device = config.device_id
? hass.devices[config.device_id]
: undefined;
if (!device) {
section.cards!.push({
type: "markdown",
content: hass.localize(
config.device_id
? "ui.panel.lovelace.strategy.device.device_not_found"
: "ui.panel.lovelace.strategy.device.no_device"
),
} satisfies MarkdownCardConfig);
return section;
}
const heading = config.heading ?? "device";
const categories: readonly DeviceEntityCategory[] =
config.categories ?? DEFAULT_DEVICE_SECTION_CATEGORIES;
const entries = Object.values(hass.entities)
.filter(
(entry) =>
entry.device_id === device.id &&
!entry.hidden &&
!config.exclude_entities?.includes(entry.entity_id)
)
.sort((entry1, entry2) => {
const name1 = computeEntityEntryName(entry1);
const name2 = computeEntityEntryName(entry2);
if (!name1 || !name2) {
return Number(!!name1) - Number(!!name2);
}
return stringCompare(name1, name2, hass.locale.language);
});
const batteryEntry =
heading !== "none" ? findBatteryEntity(hass.states, entries) : undefined;
if (heading !== "none") {
section.cards!.push({
type: "heading",
heading: computeDeviceSectionHeading(
device,
heading,
hass.devices,
hass.areas,
hass.localize
),
tap_action: hass.user?.is_admin
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: { action: "none" },
badges: batteryEntry
? [
{
type: "entity",
entity: batteryEntry.entity_id,
tap_action: { action: "more-info" },
},
]
: [],
} satisfies HeadingCardConfig);
}
const entitiesByCategory = new Map<DeviceEntityCategory, string[]>();
for (const entry of entries) {
if (entry === batteryEntry) continue;
const category = computeDeviceEntityCategory(entry);
if (!categories.includes(category)) continue;
if (!entitiesByCategory.has(category)) {
entitiesByCategory.set(category, []);
}
entitiesByCategory.get(category)!.push(entry.entity_id);
}
const shownCategories = DEVICE_SECTION_CATEGORIES.filter((category) =>
entitiesByCategory.has(category)
);
const showCategoryNames =
config.group_by_category && shownCategories.length > 1;
const computeCategoryName = (category: DeviceSectionCategory) =>
hass.localize(`ui.panel.lovelace.strategy.device.categories.${category}`);
if (config.display === "list") {
if (shownCategories.length === 0) {
return section;
}
section.cards!.push({
type: "entities",
entities: shownCategories.flatMap((category) => [
...(showCategoryNames
? [
{
type: "section",
label: computeCategoryName(category),
} satisfies SectionConfig,
]
: []),
...entitiesByCategory.get(category)!.map(computeRowConfig),
]),
} satisfies EntitiesCardConfig);
return section;
}
for (const category of shownCategories) {
if (showCategoryNames) {
section.cards!.push({
type: "heading",
heading_style: "subtitle",
heading: computeCategoryName(category),
} satisfies HeadingCardConfig);
}
section.cards!.push(
...entitiesByCategory.get(category)!.map(computeTileConfig)
);
}
return section;
}
}
declare global {
interface HTMLElementTagNameMap {
"device-section-strategy": DeviceSectionStrategy;
}
}
@@ -0,0 +1,186 @@
import { mdiFormatListBulleted } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
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/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LovelaceStrategyEditor } from "../types";
import {
DEFAULT_DEVICE_SECTION_CATEGORIES,
DEVICE_SECTION_CATEGORIES,
DEVICE_SECTION_HEADINGS,
type DeviceSectionStrategyConfig,
} from "./device-section-strategy";
@customElement("hui-device-section-strategy-editor")
export class HuiDeviceSectionStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _config?: DeviceSectionStrategyConfig;
public setConfig(config: DeviceSectionStrategyConfig): void {
this._config = config;
}
private _deviceEntities = memoizeOne(
(deviceId: string | undefined, entities: HomeAssistant["entities"]) =>
deviceId
? Object.values(entities)
.filter((entry) => entry.device_id === deviceId && !entry.hidden)
.map((entry) => entry.entity_id)
: []
);
private _schema = memoizeOne(
(localize: LocalizeFunc, deviceEntities: string[]) =>
[
{
name: "device_id",
required: true,
selector: { device: {} },
},
{
name: "heading",
selector: {
select: {
mode: "dropdown",
options: DEVICE_SECTION_HEADINGS.map((heading) => ({
value: heading,
label: localize(
`ui.panel.lovelace.editor.strategy.device.heading_options.${heading}`
),
})),
},
},
},
{
name: "categories",
selector: {
select: {
multiple: true,
mode: "list",
options: DEVICE_SECTION_CATEGORIES.map((category) => ({
value: category,
label: localize(
`ui.panel.lovelace.strategy.device.categories.${category}`
),
})),
},
},
},
{
name: "display",
selector: {
select: {
mode: "box",
options: (["tiles", "list"] as const).map((display) => ({
value: display,
label: localize(
`ui.panel.lovelace.editor.strategy.device.display_options.${display}`
),
description: localize(
`ui.panel.lovelace.editor.strategy.device.display_options.${display}_description`
),
})),
},
},
},
{
name: "group_by_category",
selector: { boolean: {} },
},
...(deviceEntities.length
? ([
{
name: "entities",
type: "expandable",
flatten: true,
iconPath: mdiFormatListBulleted,
schema: [
{
name: "exclude_entities",
selector: {
entity: {
multiple: true,
include_entities: deviceEntities,
},
},
},
],
},
] as const)
: []),
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this._config) return nothing;
const data = {
categories: DEFAULT_DEVICE_SECTION_CATEGORIES,
display: "tiles",
heading: "device",
...this._config,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(
this._localize,
this._deviceEntities(
this._config.device_id,
this.hass?.entities ?? {}
)
)}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _computeLabel = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this._localize(`ui.panel.lovelace.editor.strategy.device.${schema.name}`);
private _computeHelper = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
schema.name === "group_by_category" ||
schema.name === "heading" ||
schema.name === "exclude_entities"
? this._localize(
`ui.panel.lovelace.editor.strategy.device.${schema.name}_helper`
)
: "";
private _valueChanged(ev: ValueChangedEvent<DeviceSectionStrategyConfig>) {
ev.stopPropagation();
const config = { ...this._config, ...ev.detail.value };
if (config.device_id !== this._config?.device_id) {
delete config.exclude_entities;
}
fireEvent(this, "config-changed", { config });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-device-section-strategy-editor": HuiDeviceSectionStrategyEditor;
}
}
@@ -76,6 +76,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
section: {
"common-controls": () =>
import("./usage_prediction/common-controls-section-strategy"),
device: () => import("./device/device-section-strategy"),
},
};
@@ -81,7 +81,7 @@ class HaChangePasswordCard extends LitElement {
@input=${this._newPasswordChanged}
@change=${this._newPasswordChanged}
required
auto-validate
autoValidate
></ha-input>
<ha-input
type="password"
@@ -95,7 +95,7 @@ class HaChangePasswordCard extends LitElement {
@input=${this._newPasswordConfirmChanged}
@change=${this._newPasswordConfirmChanged}
required
auto-validate
autoValidate
></ha-input>`
: ""
}
+1
View File
@@ -47,6 +47,7 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
return html`
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.hass.selectedTheme}
.narrow=${this.narrow}
.heading=${this.hass.localize("ui.panel.profile.themes.header")}
@@ -121,6 +121,7 @@ class HaProfileDashboard extends LitElement {
</ha-card>
<ha-card outlined>
<ha-config-navigation-list
.hass=${this.hass}
.pages=${pages}
has-secondary
.label=${this.hass.localize("panel.profile")}
+11 -24
View File
@@ -5,10 +5,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { pushSupported } from "../../components/ha-push-notifications-toggle";
import "../../components/item/ha-row-item";
import type { HomeAssistant } from "../../types";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../../util/documentation-url";
import { documentationUrl } from "../../util/documentation-url";
@customElement("ha-push-notifications-row")
class HaPushNotificationsRow extends LitElement {
@@ -16,26 +13,14 @@ class HaPushNotificationsRow extends LitElement {
protected render(): TemplateResult {
const platformLoaded = isComponentLoaded(this.hass.config, "html5.notify");
let description: string;
let documentationPath = "/integrations/html5";
if (!window.isSecureContext) {
description = this.hass.localize(
"ui.components.secure_connection_required.push_notifications"
);
documentationPath = SECURE_CONNECTION_DOCUMENTATION_PATH;
} else if (!pushSupported) {
description = this.hass.localize(
"ui.panel.profile.push_notifications.error_not_supported"
);
let descriptionKey:
"error_use_https" | "error_load_platform" | "description";
if (!pushSupported) {
descriptionKey = "error_use_https";
} else if (!platformLoaded) {
description = this.hass.localize(
"ui.panel.profile.push_notifications.error_load_platform"
);
descriptionKey = "error_load_platform";
} else {
description = this.hass.localize(
"ui.panel.profile.push_notifications.description"
);
descriptionKey = "description";
}
const isDisabled = !platformLoaded || !pushSupported;
@@ -48,9 +33,11 @@ class HaPushNotificationsRow extends LitElement {
)}</span
>
<span slot="supporting-text"
>${description}
>${this.hass.localize(
`ui.panel.profile.push_notifications.${descriptionKey}`
)}
<a
href=${documentationUrl(this.hass, documentationPath)}
href=${documentationUrl(this.hass, "/integrations/html5")}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
+43 -33
View File
@@ -1509,6 +1509,7 @@
},
"qr-scanner": {
"select_camera": "Select camera",
"only_https_supported": "You can only use your camera to scan a QR code when using HTTPS.",
"not_supported": "Your browser doesn't support QR scanning.",
"manual_input": "You can scan the QR code with another QR scanner and paste the code in the input below",
"enter_qr_code": "Enter QR code value",
@@ -1521,13 +1522,6 @@
"alternativeOptionLabel": "More options"
}
},
"secure_connection_required": {
"microphone": "Your browser blocks the microphone because your connection to Home Assistant is not secured using HTTPS.",
"camera": "Your browser blocks the camera because your connection to Home Assistant is not secured using HTTPS.",
"push_notifications": "Your browser blocks push notifications because your connection to Home Assistant is not secured using HTTPS.",
"learn_more": "Use the Home Assistant app, or {documentation_link} to learn how to set up a secure connection.",
"documentation_link": "read the documentation"
},
"climate-control": {
"temperature_up": "Increase temperature",
"temperature_down": "Decrease temperature"
@@ -1762,12 +1756,9 @@
"send_text": "Send text",
"start_listening": "Start listening",
"manage_assistants": "Manage assistants",
"microphone_permission_denied": "Home Assistant doesn't have permission to use your microphone. Allow microphone access in your browser settings and try again.",
"microphone_not_found": "No microphone was found. Connect a microphone and try again.",
"microphone_in_use": "Your microphone could not be started. It may be in use by another app.",
"microphone_unknown": "Your microphone could not be started.",
"error_playing_audio": "The response could not be played",
"error_starting_pipeline": "Assist could not start",
"not_supported_microphone_browser": "Your connection to Home Assistant is not secured using HTTPS. This causes browsers to block Home Assistant from accessing the microphone.",
"not_supported_microphone_documentation": "Use the Home Assistant app or visit {documentation_link} to learn how to use a secure URL",
"not_supported_microphone_documentation_link": "the documentation",
"unknown_error_load_assist": "Loading the Assist pipeline failed",
"not_found_error_load_assist": "Cannot find the Assist pipeline",
"show_details": "Show details"
@@ -7528,16 +7519,6 @@
"entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"services": "{count} {count, plural,\n one {service}\n other {services}\n}",
"entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}",
"attention": {
"devices_some": "{attention} of {count} {count, plural,\n one {device}\n other {devices}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"devices_all": "{count} {count, plural,\n one {device}\n other {devices}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"services_some": "{attention} of {count} {count, plural,\n one {service}\n other {services}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"services_all": "{count} {count, plural,\n one {service}\n other {services}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entities_some": "{attention} of {count} {count, plural,\n one {entity}\n other {entities}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entities_all": "{count} {count, plural,\n one {entity}\n other {entities}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entries_some": "{attention} of {count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entries_all": "{count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}"
},
"no_devices_or_entities": "No devices or entities",
"devices_without_subentry": "Devices that don't belong to a sub-entry",
"copy": "Copy entry ID",
@@ -8541,6 +8522,7 @@
"check_logs": "Please check the logs for more information.",
"timeout_error": "No device found after {minutes} minutes. Please check the device and try again.",
"select_method": {
"webcam_unsupported": "You can only use your camera to scan a QR code when using a secure connection with the app or over HTTPS.",
"qr_code_webcam": "Scan QR code using webcam",
"qr_code_webcam_description": "Find and scan the code on your device.",
"qr_code_manual": "Enter QR code manually",
@@ -9594,6 +9576,17 @@
"not_loaded": "Usage Prediction integration is not loaded.",
"no_data": "This place will soon fill up with the entities you use most often, based on your activity."
},
"device": {
"no_device": "Select a device to show its entities.",
"device_not_found": "This device no longer exists.",
"categories": {
"control": "[%key:ui::panel::config::devices::entities::control%]",
"sensor": "[%key:ui::panel::config::devices::entities::sensor%]",
"event": "[%key:ui::panel::config::devices::entities::event%]",
"config": "[%key:ui::panel::config::devices::entities::config%]",
"diagnostic": "[%key:ui::panel::config::devices::entities::diagnostic%]"
}
},
"light": {
"lights": "Lights",
"other_lights": "Other lights",
@@ -11452,9 +11445,7 @@
"style": "Style",
"style_list": {
"buttons": "Buttons",
"buttons_description": "Change the value step by step with minus and plus",
"slider": "Slider",
"slider_description": "Drag to set the value"
"slider": "Slider"
}
},
"target-temperature": {
@@ -11462,9 +11453,7 @@
"style": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style%]",
"style_list": {
"buttons": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons%]",
"buttons_description": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons_description%]",
"slider": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider%]",
"slider_description": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider_description%]"
"slider": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider%]"
}
},
"target-humidity": {
@@ -11472,9 +11461,7 @@
"style": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style%]",
"style_list": {
"buttons": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons%]",
"buttons_description": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::buttons_description%]",
"slider": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider%]",
"slider_description": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider_description%]"
"slider": "[%key:ui::panel::lovelace::editor::features::types::numeric-input::style_list::slider%]"
}
},
"water-heater-operation-modes": {
@@ -11598,6 +11585,29 @@
"exclude_entities_helper": "These entities will not be suggested based on your activity",
"hide_empty": "Hide when empty"
},
"device": {
"device_id": "Device",
"categories": "Categories",
"display": "Display",
"display_options": {
"tiles": "Tiles",
"tiles_description": "One card per entity",
"list": "List",
"list_description": "All entities in a single card"
},
"group_by_category": "Group by category",
"group_by_category_helper": "Adds a subtitle above each category",
"heading": "Heading",
"heading_helper": "When shown, the heading includes the battery level",
"heading_options": {
"none": "No heading",
"device": "Device name",
"area": "Area and device name"
},
"entities": "Entities",
"exclude_entities": "Exclude entities",
"exclude_entities_helper": "Not shown in this section, even if their category is selected"
},
"original-states": {
"areas": "Areas to display",
"hide_entities_without_area": "Hide entities without area",
@@ -11792,7 +11802,7 @@
"header": "Receive notifications",
"description": "Enable HTML5 browser notifications on this device.",
"error_load_platform": "Configure notify.html5.",
"error_not_supported": "Your browser doesn't support push notifications.",
"error_use_https": "Requires SSL enabled for frontend.",
"push_notifications": "Push notifications",
"link_promo": "Learn more",
"add_device_prompt": {
+5 -27
View File
@@ -1,21 +1,3 @@
export type MicrophoneError =
"permission_denied" | "not_found" | "in_use" | "unknown";
const microphoneErrorFromName = (name?: string): MicrophoneError => {
switch (name) {
case "NotAllowedError":
case "SecurityError":
return "permission_denied";
case "NotFoundError":
case "OverconstrainedError":
return "not_found";
case "NotReadableError":
return "in_use";
default:
return "unknown";
}
};
export class AudioRecorder {
private _active = false;
@@ -49,24 +31,20 @@ export class AudioRecorder {
);
}
public async start(): Promise<MicrophoneError | undefined> {
public async start() {
if (!this._context || !this._stream || !this._source || !this._recorder) {
try {
await this._createContext();
} catch (err) {
this.close();
return microphoneErrorFromName(
err instanceof DOMException ? err.name : undefined
);
// eslint-disable-next-line no-console
console.error(err);
this._active = false;
}
} else {
this._stream.getTracks()[0].enabled = true;
await this._context.resume();
this._active = true;
}
return undefined;
}
public async stop() {
@@ -94,7 +72,6 @@ export class AudioRecorder {
private async _createContext() {
// @ts-expect-error webkitAudioContext is not recognized
const context = new (AudioContext || webkitAudioContext)();
this._context = context;
this._stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// Syntax here must match an item of `parser.worker` in Webpack config in
// order for module to be parsed and a chunk to be properly created.
@@ -102,6 +79,7 @@ export class AudioRecorder {
/* webpackChunkName: "recorder-worklet" */
new URL("./recorder-worklet.js", import.meta.url)
);
this._context = context;
this._source = this._context.createMediaStreamSource(this._stream);
this._recorder = new AudioWorkletNode(this._context, "recorder-worklet");
this._recorder.port.onmessage = (e) => {
-3
View File
@@ -4,9 +4,6 @@ const DOCUMENTATION_DOMAIN = "home-assistant.io";
export const DOCUMENTATION_URL = `https://www.${DOCUMENTATION_DOMAIN}`;
export const SECURE_CONNECTION_DOCUMENTATION_PATH =
"/docs/configuration/securing/#remote-access";
export const documentationUrl = (
{ config }: Pick<HomeAssistantConfig, "config">,
path: string
@@ -106,29 +106,6 @@ describe("WaInputMixin validity", () => {
el.handleInvalid();
expect(el.isInvalid).toBe(true);
});
it("rechecks a shown error when the value changes from outside", async () => {
const el = createInput({ controlValid: false });
document.body.append(el);
el.isInvalid = true;
await el.updateComplete;
el.controlValid = true;
el.value = "33";
await el.updateComplete;
await el.updateComplete;
expect(el.isInvalid).toBe(false);
el.remove();
});
it("does not flag a field when the value changes from outside", async () => {
const el = createInput({ controlValid: false });
document.body.append(el);
await el.updateComplete;
el.value = "500";
await el.updateComplete;
expect(el.isInvalid).toBe(false);
el.remove();
});
});
// Polyfilled internals report validity the app cannot trust, so every path
@@ -1,141 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import {
computeIntegrationCardState,
computeIntegrationCardSummary,
} from "../../../../src/panels/config/integrations/integration-card-summary";
const entry = (
entry_id: string,
state: ConfigEntry["state"] = "loaded",
source = "user"
) => ({ entry_id, state, source }) as ConfigEntry;
const device = (
config_entries: string[],
entry_type: DeviceRegistryEntry["entry_type"] = null
) => ({ config_entries, entry_type }) as DeviceRegistryEntry;
const entity = (config_entry_id: string) =>
({ config_entry_id }) as EntityRegistryEntry;
describe("computeIntegrationCardState", () => {
it("returns the most important state regardless of entry order", () => {
expect(
computeIntegrationCardState([entry("a", "not_loaded"), entry("b")])
).toBe("not_loaded");
expect(
computeIntegrationCardState([
entry("a", "setup_in_progress"),
entry("b", "setup_retry"),
])
).toBe("setup_retry");
expect(computeIntegrationCardState([entry("a"), entry("b")])).toBe(
"loaded"
);
});
});
describe("computeIntegrationCardSummary", () => {
it("counts devices, services and entities that need attention", () => {
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_error")],
[device(["a"]), device(["b"])],
[],
0
)
).toEqual({
unit: "devices",
count: 2,
attention: 1,
attentionState: "setup_error",
});
expect(
computeIntegrationCardSummary(
[entry("a", "setup_retry")],
[device(["a"], "service")],
[],
0
)
).toEqual({
unit: "services",
count: 1,
attention: 1,
attentionState: "setup_retry",
});
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "not_loaded")],
[],
[entity("a"), entity("b"), entity("b")],
3
)
).toEqual({
unit: "entities",
count: 3,
attention: 2,
attentionState: "not_loaded",
});
});
it("has no shared attention state when entries fail differently", () => {
expect(
computeIntegrationCardSummary(
[entry("a", "setup_error"), entry("b", "not_loaded")],
[device(["a"]), device(["b"])],
[],
0
)?.attentionState
).toBeUndefined();
});
it("counts entries when a failing entry owns none of the counted items", () => {
// "c" failed before creating any device, so counting devices would hide it.
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_error"), entry("c", "setup_error")],
[device(["a"]), device(["b"])],
[],
0
)
).toEqual({
unit: "entries",
count: 3,
attention: 2,
attentionState: "setup_error",
});
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_retry")],
[],
[entity("a")],
1
)
).toEqual({
unit: "entries",
count: 2,
attention: 1,
attentionState: "setup_retry",
});
});
it("ignores YAML entries when counting entries", () => {
expect(
computeIntegrationCardSummary(
[entry("a", "loaded", "yaml"), entry("b")],
[],
[],
0
)
).toEqual({ unit: "entries", count: 1, attention: 0 });
expect(
computeIntegrationCardSummary([entry("a", "loaded", "yaml")], [], [], 0)
).toBeUndefined();
});
});
@@ -8,11 +8,8 @@ import {
// The window starts at t=1000s; samples are (state, seconds)
const SINCE = 1000 * 1000;
const sample = (
s: string,
seconds: number,
inZones?: string[]
): EntityHistoryState => ({ s, lu: seconds, a: { in_zones: inZones } });
const sample = (s: string, seconds: number): EntityHistoryState =>
({ s, lu: seconds, a: {} }) as EntityHistoryState;
describe("personActivity", () => {
it("lists state changes inside the window, newest first", () => {
@@ -62,16 +59,13 @@ describe("zoneActivity", () => {
{
"person.anne": [
sample("not_home", 900),
sample("Work", 1100, ["zone.work"]),
sample("Work", 1100),
sample("not_home", 1300),
],
"person.bob": [
sample("home", 900),
sample("Work", 1200, ["zone.work"]),
],
"person.bob": [sample("home", 900), sample("Work", 1200)],
},
["person.anne", "person.bob"],
"zone.work",
"Work",
SINCE
);
expect(
@@ -87,13 +81,13 @@ describe("zoneActivity", () => {
const entries = zoneActivity(
{
"person.anne": [
sample("Work", 900, ["zone.work"]),
sample("Work", 900),
sample("home", 950),
sample("Gym", 1100),
],
},
["person.anne"],
"zone.work",
"Work",
SINCE
);
// Leaving Work happened before the window; Gym is not Work
@@ -104,9 +98,9 @@ describe("zoneActivity", () => {
// A newly created person has no state at the window's start, so their
// first sample inside the window is a real arrival
const entries = zoneActivity(
{ "person.anne": [sample("Work", 1100, ["zone.work"])] },
{ "person.anne": [sample("Work", 1100)] },
["person.anne"],
"zone.work",
"Work",
SINCE
);
expect(entries.map((e) => [e.arrived, e.when.getTime() / 1000])).toEqual([
@@ -118,85 +112,17 @@ describe("zoneActivity", () => {
const entries = zoneActivity(
{
"person.anne": [
sample("Work", 900, ["zone.work"]),
sample("Work", 900),
sample("unavailable", 1100),
sample("Work", 1150, ["zone.work"]),
sample("Work", 1150),
sample("unknown", 1200),
sample("Work", 1250, ["zone.work"]),
sample("Work", 1250),
],
},
["person.anne"],
"zone.work",
"Work",
SINCE
);
expect(entries).toEqual([]);
});
it("keeps arrivals and departures in same-named zones separate", () => {
const history = {
"person.anne": [
sample("not_home", 900, []),
sample("Work", 1100, ["zone.work"]),
sample("not_home", 1300, []),
],
"person.bob": [
sample("not_home", 900, []),
sample("Work", 1200, ["zone.other_work"]),
sample("not_home", 1400, []),
],
};
const personIds = Object.keys(history);
expect(zoneActivity(history, personIds, "zone.work", SINCE)).toEqual([
{
state: "not_home",
personId: "person.anne",
arrived: false,
when: new Date(1300 * 1000),
},
{
state: "Work",
personId: "person.anne",
arrived: true,
when: new Date(1100 * 1000),
},
]);
expect(zoneActivity(history, personIds, "zone.other_work", SINCE)).toEqual([
{
state: "not_home",
personId: "person.bob",
arrived: false,
when: new Date(1400 * 1000),
},
{
state: "Work",
personId: "person.bob",
arrived: true,
when: new Date(1200 * 1000),
},
]);
});
it("includes membership in overlapping zones without counting active-zone changes", () => {
const entries = zoneActivity(
{
"person.anne": [
sample("not_home", 900, []),
sample("home", 1100, ["zone.home", "zone.work"]),
sample("Work", 1200, ["zone.work"]),
sample("not_home", 1300, []),
],
},
["person.anne"],
"zone.work",
SINCE
);
expect(
entries.map((entry) => [entry.arrived, entry.when.getTime() / 1000])
).toEqual([
[false, 1300],
[true, 1100],
]);
});
});