Compare commits

..
76 changed files with 858 additions and 1359 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}
+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,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,6 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 408 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,5 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 330 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,4 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 B

@@ -1,3 +0,0 @@
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 174 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}`
),
});
@@ -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,
},
})),
},
},
@@ -1,38 +1,26 @@
import {
mdiCellphone,
mdiClose,
mdiDotsVertical,
mdiFileMoveOutline,
mdiMonitor,
} from "@mdi/js";
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { cache } from "lit/directives/cache";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import {
DEFAULT_SECTION_COLUMN_SPAN,
isStrategySection,
} from "../../../../data/lovelace/config/section";
import { isStrategySection } from "../../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
@@ -44,7 +32,6 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
@@ -58,8 +45,6 @@ import {
setAtPath,
} from "../lovelace-path";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "../../sections/hui-section";
import "../../sections/hui-section-background";
import "./hui-section-settings-editor";
import "./hui-section-visibility-editor";
import "./hui-section-strategy-element-editor";
@@ -68,19 +53,10 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import { getViewType } from "../../views/get-view-type";
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
type PreviewMode = "desktop" | "mobile";
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
{ mode: "mobile", iconPath: mdiCellphone },
{ mode: "desktop", iconPath: mdiMonitor },
];
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
@customElement("hui-dialog-edit-section")
export class HuiDialogEditSection
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
@@ -90,8 +66,6 @@ export class HuiDialogEditSection
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _params?: EditSectionDialogParams;
@state() private _config?: LovelaceSectionRawConfig;
@@ -108,15 +82,6 @@ export class HuiDialogEditSection
@state() private _yamlError = false;
@storage({
key: "dashboardSectionPreviewMode",
state: true,
subscribe: false,
})
private _storedPreviewMode?: PreviewMode;
@state() private _defaultPreviewMode: PreviewMode = "mobile";
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
@query("hui-section-strategy-element-editor")
@@ -152,12 +117,6 @@ export class HuiDialogEditSection
viewPath
);
this._initDirtyTracking({ type: "deep" }, this._config);
this._defaultPreviewMode =
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
}
private get _previewMode(): PreviewMode {
return this._storedPreviewMode ?? this._defaultPreviewMode;
}
public closeDialog() {
@@ -167,7 +126,6 @@ export class HuiDialogEditSection
private _dialogClosed(): void {
this._params = undefined;
this.large = false;
this._yamlMode = false;
this._config = undefined;
this._currTab = TABS[0];
@@ -181,23 +139,19 @@ export class HuiDialogEditSection
return nothing;
}
const heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
const strategy = isStrategySection(this._config)
? cleanLegacyStrategyConfig(this._config.strategy)
: undefined;
const heading = this._computeHeading(strategy);
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
const currentTab =
!strategy && this._currTab === "tab-strategy"
? "tab-appearance"
: this._currTab;
const columnSpan = Math.min(
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
);
const singleColumn = columnSpan === 1;
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
let content: TemplateResult<1> | typeof nothing = nothing;
if (this._yamlMode) {
@@ -247,151 +201,65 @@ export class HuiDialogEditSection
return html`
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
class=${classMap({
"yaml-mode": this._yamlMode,
})}
>
<ha-icon-button
slot="headerNavigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span
slot="headerTitle"
class="title-enlargeable"
@click=${this._enlarge}
>${heading}</span
>
<ha-dropdown
slot="headerActionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
slot="navigationIcon"
@click=${this._cancel}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="element-editor ha-scrollbar">
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
`
: nothing
}
${this._yamlMode ? content : cache(content)}
</div>
<div class="element-preview ha-scrollbar">
<div class="preview-header">
<span id="preview-label">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_section.preview"
)}
</span>
${
singleColumn
? nothing
: html`
<div
class="preview-modes"
role="group"
aria-labelledby="preview-label"
>
${PREVIEW_MODES.map(
({ mode, iconPath }) => html`
<button
id="preview-mode-${mode}"
class=${classMap({
"preview-mode": true,
active: this._previewMode === mode,
})}
aria-pressed=${this._previewMode === mode}
aria-label=${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
.mode=${mode}
@click=${this._previewModeClicked}
>
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
</button>
<ha-tooltip
for="preview-mode-${mode}"
placement="bottom"
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
)}
</ha-tooltip>
`
)}
</div>
`
}
</div>
<div class="preview-frame">
<div
class=${classMap({
"section-preview": true,
wide: previewColumnSpan > 1,
})}
style=${styleMap({
"--column-span": previewColumnSpan,
})}
>
<div
class=${classMap({
"section-container": true,
"has-background": this._config.background !== undefined,
blur: this._yamlError || this._strategyError,
})}
>
${
this._config.background !== undefined
? html`
<hui-section-background
.hass=${this.hass}
.background=${this._config.background}
.theme=${this._config.theme}
></hui-section-background>
`
: nothing
}
<hui-section
.hass=${this.hass}
.config=${this._config}
preview
></hui-section>
</div>
</div>
</div>
</div>
</div>
<span slot="title">${heading}</span>
<ha-dropdown
slot="actionItems"
placement="bottom-end"
@closed=${stopPropagation}
@wa-select=${this._handleAction}
>
<ha-icon-button
slot="trigger"
.label=${this.hass!.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="move-to-view">
<ha-svg-icon
slot="icon"
.path=${mdiFileMoveOutline}
></ha-svg-icon>
${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.move_to_view"
)}
</ha-dropdown-item>
</ha-dropdown>
${
!this._yamlMode
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${tabs.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${currentTab === tab}
>
${this.hass!.localize(
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
`
: nothing
}
</ha-dialog-header>
${this._yamlMode ? content : cache(content)}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@@ -450,37 +318,6 @@ export class HuiDialogEditSection
this._updateDirtyState(this._config);
}
private _computeHeading(strategy?: LovelaceStrategyConfig) {
const header = this.hass.localize(
"ui.panel.lovelace.editor.edit_section.header"
);
if (!strategy) {
return header;
}
const strategyName = this.hass.localize(
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
);
if (!strategyName) {
return header;
}
return this.hass.localize(
"ui.panel.lovelace.editor.edit_section.typed_header",
{ type: strategyName }
);
}
private _previewModeClicked(ev: Event) {
this._storedPreviewMode = (
ev.currentTarget as HTMLElement & { mode: PreviewMode }
).mode;
}
private _enlarge() {
withViewTransition(() => {
this.large = !this.large;
});
}
private _handleTabChanged(ev: CustomEvent): void {
const newTab = ev.detail.name;
if (newTab === this._currTab) {
@@ -689,153 +526,15 @@ export class HuiDialogEditSection
return [
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
css`
:host {
--code-mirror-max-height: calc(100vh - 209px);
}
ha-dialog {
--dialog-content-padding: var(--ha-space-2);
--dialog-content-padding: var(--ha-space-6);
}
.content {
display: flex;
flex-direction: column;
width: 100%;
max-width: 100%;
ha-dialog.yaml-mode {
--dialog-content-padding: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.content {
gap: var(--ha-space-3);
}
}
@media all and (min-width: 451px) and (min-height: 501px) {
:host([large]) .content {
max-width: none;
}
}
.content .preview-frame {
padding: 4px;
overflow-x: auto;
}
.content .section-preview {
--preview-column-width: 390px;
display: block;
margin: 0 auto;
max-width: var(--preview-column-width);
}
.content .section-preview.wide {
max-width: none;
width: calc(
var(--column-span) * var(--preview-column-width) +
(var(--column-span) - 1) *
var(--ha-view-sections-column-gap, 32px)
);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-2);
min-height: 32px;
padding: var(--ha-space-1) var(--ha-space-2);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
}
.preview-modes {
display: flex;
gap: 2px;
padding: 2px;
border-radius: var(--ha-border-radius-pill);
background: var(--ha-color-fill-neutral-normal-resting);
}
.preview-mode {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
padding: 0;
border: none;
border-radius: var(--ha-border-radius-pill);
background: none;
color: var(--secondary-text-color);
cursor: pointer;
--mdc-icon-size: 18px;
}
.preview-mode:hover {
color: var(--primary-text-color);
}
.preview-mode.active {
color: var(--primary-text-color);
background: var(--card-background-color);
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
}
.preview-mode:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
.content .element-editor {
padding-inline-start: var(--ha-space-1);
padding-inline-end: var(--ha-space-2);
margin-bottom: 0;
}
@media (min-width: 1000px) {
.content {
flex-direction: row;
max-height: var(--code-mirror-max-height);
}
.content > .element-editor {
padding-inline-end: var(--ha-space-4);
max-width: 500px;
}
.content > .element-editor,
.content > .element-preview {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
height: auto;
}
.content .preview-frame {
padding: 8px 10px;
}
}
.element-editor {
margin-bottom: 8px;
}
.blur {
filter: blur(2px) grayscale(100%);
}
.element-preview {
position: relative;
height: max-content;
background: var(--primary-background-color);
padding: 4px;
border-radius: var(--ha-border-radius-sm);
}
.section-container {
position: relative;
}
.section-container.has-background {
padding: var(--ha-space-2);
border-radius: var(
--ha-section-border-radius,
var(--ha-border-radius-xl)
);
}
.section-container hui-section {
display: block;
}
ha-tab-group {
margin-bottom: 16px;
.gui-mode-button {
margin-inline-end: auto;
}
ha-tab-group-tab {
flex: 1;
@@ -844,14 +543,6 @@ export class HuiDialogEditSection
width: 100%;
justify-content: center;
}
.gui-mode-button {
margin-right: auto;
margin-inline-end: auto;
margin-inline-start: initial;
}
.title-enlargeable {
display: block;
}
`,
];
}
@@ -19,71 +19,8 @@ import {
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
const computeColumnSpanLabel = (
span: number,
maxColumns: number,
localize: LocalizeFunc
) => {
if (span >= maxColumns) {
return localize(
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
);
}
return localize(
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
);
};
const computeColumnSpanSchemas = (
maxColumns: number,
localize: LocalizeFunc
) => {
if (maxColumns <= 1) {
return [];
}
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
return [
{
name: "column_span",
selector: {
number: { min: 1, max: maxColumns, slider_ticks: true },
},
},
] as const;
}
const options = Array.from({ length: maxColumns }, (_, i) => {
const span = i + 1;
return {
value: String(span),
label: computeColumnSpanLabel(span, maxColumns, localize),
image: {
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
flip_rtl: true,
},
};
});
return [
{
name: "column_span",
selector: {
select: {
mode: "box",
box_max_columns:
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
options,
},
},
},
] as const;
};
interface SettingsData {
column_span?: string | number;
column_span?: number;
background_enabled?: boolean;
background_color?: string;
background_opacity?: number;
@@ -99,30 +36,20 @@ export class HuiDialogEditSection extends LitElement {
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
private get _maxColumns() {
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
}
private get _displayedColumnSpan() {
return String(
Math.min(
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
this._maxColumns
)
);
}
private get _columnSpanFormValue() {
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
return Number(this._displayedColumnSpan);
}
return this._displayedColumnSpan;
}
private _schema = memoizeOne(
(localize: LocalizeFunc, maxColumns: number) =>
(maxColumns: number, localize: LocalizeFunc) =>
[
...computeColumnSpanSchemas(maxColumns, localize),
{
name: "column_span",
default: DEFAULT_SECTION_COLUMN_SPAN,
selector: {
number: {
min: 1,
max: maxColumns,
slider_ticks: true,
},
},
},
{
name: "background_enabled",
selector: { boolean: {} },
@@ -182,14 +109,17 @@ export class HuiDialogEditSection extends LitElement {
const background = resolveSectionBackground(this.config.background);
const data: SettingsData = {
column_span: this._columnSpanFormValue,
column_span: this.config.column_span,
background_enabled: backgroundEnabled,
background_color: background?.color,
background_opacity: background?.opacity,
theme: this.config.theme,
};
const schema = this._schema(this._localize, this._maxColumns);
const schema = this._schema(
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
this._localize
);
return html`
<ha-form
@@ -224,15 +154,10 @@ export class HuiDialogEditSection extends LitElement {
...this.config,
};
if (newData.column_span === undefined) {
if (newData.column_span) {
newConfig.column_span = newData.column_span;
} else {
delete newConfig.column_span;
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
const columnSpan = Number(newData.column_span);
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
delete newConfig.column_span;
} else {
newConfig.column_span = columnSpan;
}
}
if (newData.background_enabled) {
@@ -3,8 +3,6 @@ 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 { listenMediaQuery } from "../../../../common/dom/media_query";
import { clamp } from "../../../../common/number/clamp";
import { slugify } from "../../../../common/string/slugify";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
@@ -34,37 +32,6 @@ declare global {
const VALID_PATH_REGEX = /^[a-zA-Z0-9_-]+$/;
const INTEGER_REGEX = /^[0-9]+$/;
const MAX_COLUMNS_PRESETS = [1, 2, 3, 4] as const;
const MAX_COLUMNS_MORE = "more";
const MIN_CUSTOM_MAX_COLUMNS = MAX_COLUMNS_PRESETS.length + 1;
const MAX_CUSTOM_MAX_COLUMNS = 10;
interface MaxColumnsFormData {
max_columns: string;
max_columns_custom?: number;
}
const maxColumnsToFormData = (maxColumns: number): MaxColumnsFormData => {
if (maxColumns <= MAX_COLUMNS_PRESETS.length) {
return {
max_columns: String(maxColumns),
max_columns_custom: MIN_CUSTOM_MAX_COLUMNS,
};
}
return { max_columns: MAX_COLUMNS_MORE, max_columns_custom: maxColumns };
};
const maxColumnsFromFormData = (data: MaxColumnsFormData): number => {
if (data.max_columns !== MAX_COLUMNS_MORE) {
return Number(data.max_columns);
}
return clamp(
data.max_columns_custom ?? MIN_CUSTOM_MAX_COLUMNS,
MIN_CUSTOM_MAX_COLUMNS,
MAX_CUSTOM_MAX_COLUMNS
);
};
@customElement("hui-view-editor")
export class HuiViewEditor extends LitElement {
@consumeLocalize()
@@ -76,27 +43,10 @@ export class HuiViewEditor extends LitElement {
@state() private _error: Record<string, string> | undefined;
@state() private _narrow = false;
private _unsubMql?: () => void;
private _suggestedPath = false;
connectedCallback(): void {
super.connectedCallback();
this._unsubMql = listenMediaQuery("(max-width: 600px)", (matches) => {
this._narrow = matches;
});
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubMql?.();
this._unsubMql = undefined;
}
private _schema = memoizeOne(
(localize: LocalizeFunc, narrow: boolean) =>
(localize: LocalizeFunc) =>
[
{
name: "type",
@@ -151,33 +101,13 @@ export class HuiViewEditor extends LitElement {
schema: [
{
name: "max_columns",
selector: {
select: {
mode: "box",
box_max_columns: narrow ? 3 : 5,
options: [
...MAX_COLUMNS_PRESETS.map((count) => ({
value: String(count),
label: String(count),
})),
{
value: MAX_COLUMNS_MORE,
label: localize(
"ui.panel.lovelace.editor.edit_view.max_columns_more"
),
},
],
},
},
},
{
name: "max_columns_custom",
visible: { field: "max_columns", value: MAX_COLUMNS_MORE },
default: DEFAULT_MAX_COLUMNS,
selector: {
number: {
min: MIN_CUSTOM_MAX_COLUMNS,
max: MAX_CUSTOM_MAX_COLUMNS,
mode: "box",
min: 1,
max: 10,
mode: "slider",
slider_ticks: true,
},
},
},
@@ -202,10 +132,6 @@ export class HuiViewEditor extends LitElement {
this._config = config;
}
private get _maxColumns(): number {
return this._config.max_columns ?? DEFAULT_MAX_COLUMNS;
}
get _type(): string {
return getViewType(this._config);
}
@@ -213,7 +139,6 @@ export class HuiViewEditor extends LitElement {
protected render() {
const data = {
...this._config,
...maxColumnsToFormData(this._maxColumns),
type: this._type,
theme:
this._config.theme?.toLowerCase() === "backend-selected"
@@ -224,7 +149,7 @@ export class HuiViewEditor extends LitElement {
return html`
<ha-form
.data=${data}
.schema=${this._schema(this._localize, this._narrow)}
.schema=${this._schema(this._localize)}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
.computeError=${this._computeError}
@@ -235,23 +160,7 @@ export class HuiViewEditor extends LitElement {
}
private _valueChanged(ev: CustomEvent): void {
const {
max_columns: maxColumnsChoice,
max_columns_custom: maxColumnsCustom,
...rest
} = ev.detail.value as Omit<LovelaceViewConfig, "max_columns"> &
MaxColumnsFormData;
const config: LovelaceViewConfig = rest;
const maxColumns = maxColumnsFromFormData({
max_columns: maxColumnsChoice,
max_columns_custom: maxColumnsCustom,
});
if (maxColumns !== this._maxColumns) {
config.max_columns = maxColumns;
} else if (this._config.max_columns !== undefined) {
config.max_columns = this._config.max_columns;
}
const config = ev.detail.value as LovelaceViewConfig;
if (config.type !== SECTIONS_VIEW_LAYOUT) {
delete config.max_columns;
@@ -302,7 +211,6 @@ export class HuiViewEditor extends LitElement {
case "show_icon_and_title":
case "subview":
case "max_columns":
case "max_columns_custom":
case "dense_section_placement":
case "top_margin":
case "section_specifics":
@@ -323,7 +231,6 @@ export class HuiViewEditor extends LitElement {
case "path":
case "show_icon_and_title":
case "subview":
case "max_columns":
case "dense_section_placement":
case "top_margin":
return this._localize(
+1 -5
View File
@@ -279,8 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
}
private async _initializeConfig() {
const config = this.config;
let sectionConfig = { ...config };
let sectionConfig = { ...this.config };
let isStrategy = false;
if (isStrategySection(sectionConfig)) {
@@ -289,9 +288,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
sectionConfig,
this.hass!
);
if (config !== this.config) {
return;
}
}
sectionConfig = {
@@ -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(
+46 -44
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"
@@ -8531,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",
@@ -9584,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",
@@ -9972,10 +9975,7 @@
},
"show_icon_and_title": "Show icon and title",
"subview": "Subview",
"max_columns": "Max sections side by side",
"max_columns_helper": "Smaller screens fit fewer side by side, and phones stack them one under the other.",
"max_columns_more": "More",
"max_columns_custom": "Number of sections",
"max_columns": "Max number of sections wide",
"section_specifics": "Sections view specific settings",
"dense_section_placement": "Dense section placement",
"dense_section_placement_helper": "Will try to fill gaps with sections that fit the gap. This may make section placement less predictable.",
@@ -10167,17 +10167,8 @@
"error_section": {
"title": "Error"
},
"section_strategy": {
"common-controls": {
"name": "Common controls"
}
},
"edit_section": {
"header": "Section configuration",
"typed_header": "{type} section configuration",
"preview": "Preview",
"preview_desktop": "Desktop preview",
"preview_mobile": "Mobile preview",
"header": "Edit section",
"tab_strategy": "Configuration",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
"tab_appearance": "Appearance",
@@ -10185,13 +10176,7 @@
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
"settings": {
"column_span": "Width",
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
"column_span_options": {
"single": "Single",
"double": "Double",
"triple": "Triple",
"full": "Full width"
},
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
"background": "Background options",
"background_enabled": "Background",
"background_enabled_helper": "Display a colored background behind the section",
@@ -11460,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": {
@@ -11470,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": {
@@ -11480,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": {
@@ -11606,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",
@@ -11800,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
@@ -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],
]);
});
});