mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-22 21:53:28 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed6d18b4d | ||
|
|
668f17ec34 | ||
|
|
951c445cfd | ||
|
|
6f04cb918e | ||
|
|
30cd5383bd | ||
|
|
5fe9f97948 | ||
|
|
c58e7d684a | ||
|
|
f9b65b9c2b |
No files matched your search
@@ -67,4 +67,3 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
+4
-4
@@ -42,7 +42,7 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
@@ -51,7 +51,7 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.1",
|
||||
@@ -84,7 +84,7 @@
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.2",
|
||||
"barcode-detector": "3.2.3",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
@@ -214,7 +214,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
|
||||
Generated
+107
-107
@@ -187,8 +187,8 @@ importers:
|
||||
specifier: 7.1.2
|
||||
version: 7.1.2
|
||||
'@codemirror/autocomplete':
|
||||
specifier: 6.20.3
|
||||
version: 6.20.3
|
||||
specifier: 6.20.4
|
||||
version: 6.20.4
|
||||
'@codemirror/commands':
|
||||
specifier: 6.11.1
|
||||
version: 6.11.1
|
||||
@@ -214,8 +214,8 @@ importers:
|
||||
specifier: 6.7.6
|
||||
version: 6.7.6
|
||||
'@codemirror/view':
|
||||
specifier: 6.43.14
|
||||
version: 6.43.14
|
||||
specifier: 6.43.15
|
||||
version: 6.43.15
|
||||
'@date-fns/tz':
|
||||
specifier: 1.5.0
|
||||
version: 1.5.0
|
||||
@@ -290,7 +290,7 @@ importers:
|
||||
version: 7.4.47
|
||||
'@replit/codemirror-indentation-markers':
|
||||
specifier: 6.5.3
|
||||
version: 6.5.3(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected]4)
|
||||
version: 6.5.3(@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected]5)
|
||||
'@swc/helpers':
|
||||
specifier: 0.5.23
|
||||
version: 0.5.23
|
||||
@@ -313,8 +313,8 @@ importers:
|
||||
specifier: 0.0.10
|
||||
version: 0.0.10
|
||||
barcode-detector:
|
||||
specifier: 3.2.2
|
||||
version: 3.2.2
|
||||
specifier: 3.2.3
|
||||
version: 3.2.3
|
||||
cally:
|
||||
specifier: 0.9.2
|
||||
version: 0.9.2
|
||||
@@ -597,7 +597,7 @@ importers:
|
||||
version: 0.13.11([email protected])
|
||||
eslint-plugin-import-x:
|
||||
specifier: 4.17.1
|
||||
version: 4.17.1(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
|
||||
version: 4.17.1(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
|
||||
eslint-plugin-lit:
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1([email protected]([email protected]))
|
||||
@@ -606,7 +606,7 @@ importers:
|
||||
version: 5.1.3([email protected]([email protected]))
|
||||
eslint-plugin-unused-imports:
|
||||
specifier: 4.4.1
|
||||
version: 4.4.1(@typescript-eslint/[email protected]1.2(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))
|
||||
version: 4.4.1(@typescript-eslint/[email protected]2.0(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))
|
||||
eslint-plugin-wc:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0([email protected]([email protected]))
|
||||
@@ -698,8 +698,8 @@ importers:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
typescript-eslint:
|
||||
specifier: 8.71.2
|
||||
version: 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
specifier: 8.72.0
|
||||
version: 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
vite:
|
||||
specifier: 8.3.4
|
||||
version: 8.3.4(@types/[email protected])([email protected])([email protected])
|
||||
@@ -1752,8 +1752,8 @@ packages:
|
||||
'@cacheable/[email protected]':
|
||||
resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==}
|
||||
|
||||
'@codemirror/[email protected].3':
|
||||
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
|
||||
'@codemirror/[email protected].4':
|
||||
resolution: {integrity: sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==}
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==}
|
||||
@@ -1791,8 +1791,8 @@ packages:
|
||||
'@codemirror/[email protected]':
|
||||
resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
|
||||
|
||||
'@codemirror/[email protected]4':
|
||||
resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
|
||||
'@codemirror/[email protected]5':
|
||||
resolution: {integrity: sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==}
|
||||
|
||||
'@commander-js/[email protected]':
|
||||
resolution: {integrity: sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA==}
|
||||
@@ -3372,63 +3372,63 @@ packages:
|
||||
'@types/[email protected]':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/parser': ^8.71.2
|
||||
'@typescript-eslint/parser': ^8.72.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: '>=4.8.4 <6.1.0'
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
resolution: {integrity: sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==}
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript/[email protected]':
|
||||
@@ -4023,8 +4023,8 @@ packages:
|
||||
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-/4QOrrNrCRmDSBWiiP4aC72dnkuXUEdcFRidHgbPRXUpy82XcCMvJssI6fEs6JT42LS7DvBVZO70qasJbYKyrA==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-PLinpWGiOQoQH4X+YObWZ6iXeffXKDM3d4vnOD86481SvukIfvkf7jEE/tud9oI4HTDxtDdlHaFilA5Kn7Xa7A==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==}
|
||||
@@ -7289,8 +7289,8 @@ packages:
|
||||
resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
[email protected]1.2:
|
||||
resolution: {integrity: sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==}
|
||||
[email protected]2.0:
|
||||
resolution: {integrity: sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
@@ -7836,8 +7836,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==}
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-mU7UNuvl+hOT+nlSOQTMHZ2tM8lRMsbjDIp5IO/+HmBl/FOq77q0OSZPJ3cm42A9c1MKU6Uve67FiGdkhsrT9g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
@@ -9130,23 +9130,23 @@ snapshots:
|
||||
hashery: 1.5.1
|
||||
keyv: 5.6.0
|
||||
|
||||
'@codemirror/[email protected].3':
|
||||
'@codemirror/[email protected].4':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.3
|
||||
@@ -9154,40 +9154,40 @@ snapshots:
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/lang-css': 6.3.1
|
||||
'@codemirror/lang-javascript': 6.2.5
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/css': 1.3.6
|
||||
'@lezer/html': 1.3.13
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/lint': 6.9.7
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/javascript': 1.5.4
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/lang-html': 6.4.12
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.3
|
||||
@@ -9195,7 +9195,7 @@ snapshots:
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.20.3
|
||||
'@codemirror/autocomplete': 6.20.4
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@lezer/common': 1.5.3
|
||||
@@ -9207,7 +9207,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/streamparser': 6.0.0
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.5
|
||||
'@lezer/lr': 1.4.10
|
||||
@@ -9216,13 +9216,13 @@ snapshots:
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
crelt: 1.0.7
|
||||
|
||||
'@codemirror/[email protected]':
|
||||
@@ -9231,7 +9231,7 @@ snapshots:
|
||||
|
||||
'@codemirror/[email protected]': {}
|
||||
|
||||
'@codemirror/[email protected]4':
|
||||
'@codemirror/[email protected]5':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.7.6
|
||||
crelt: 1.0.7
|
||||
@@ -10317,11 +10317,11 @@ snapshots:
|
||||
dependencies:
|
||||
playwright: 1.64.1
|
||||
|
||||
'@replit/[email protected](@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected]4)':
|
||||
'@replit/[email protected](@codemirror/[email protected])(@codemirror/[email protected])(@codemirror/[email protected]5)':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.13.1
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.14
|
||||
'@codemirror/view': 6.43.15
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
optional: true
|
||||
@@ -11051,14 +11051,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.19.2
|
||||
|
||||
'@typescript-eslint/[email protected]1.2(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/scope-manager': 8.71.2
|
||||
'@typescript-eslint/type-utils': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.71.2
|
||||
'@typescript-eslint/parser': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/scope-manager': 8.72.0
|
||||
'@typescript-eslint/type-utils': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
eslint: 10.13.0([email protected])
|
||||
ignore: 7.0.6
|
||||
natural-compare: 1.4.0
|
||||
@@ -11067,41 +11067,41 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/scope-manager': 8.71.2
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.71.2
|
||||
'@typescript-eslint/scope-manager': 8.72.0
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/typescript-estree': 8.72.0([email protected])([email protected])
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
debug: 4.4.3([email protected])
|
||||
eslint: 10.13.0([email protected])
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/tsconfig-utils': 8.71.2([email protected])
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/tsconfig-utils': 8.72.0([email protected])
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
debug: 4.4.3([email protected])
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/visitor-keys': 8.71.2
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected])':
|
||||
dependencies:
|
||||
typescript: 6.0.3
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/typescript-estree': 8.72.0([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
debug: 4.4.3([email protected])
|
||||
eslint: 10.13.0([email protected])
|
||||
ts-api-utils: 2.5.0([email protected])
|
||||
@@ -11109,14 +11109,14 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2': {}
|
||||
'@typescript-eslint/[email protected]2.0': {}
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@typescript-eslint/project-service': 8.71.2([email protected])([email protected])
|
||||
'@typescript-eslint/tsconfig-utils': 8.71.2([email protected])
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/visitor-keys': 8.71.2
|
||||
'@typescript-eslint/project-service': 8.72.0([email protected])([email protected])
|
||||
'@typescript-eslint/tsconfig-utils': 8.72.0([email protected])
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/visitor-keys': 8.72.0
|
||||
debug: 4.4.3([email protected])
|
||||
minimatch: 10.2.6
|
||||
semver: 7.8.5
|
||||
@@ -11126,20 +11126,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected])':
|
||||
'@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected])':
|
||||
dependencies:
|
||||
'@eslint-community/eslint-utils': 4.10.1([email protected]([email protected]))
|
||||
'@typescript-eslint/scope-manager': 8.71.2
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
|
||||
'@typescript-eslint/scope-manager': 8.72.0
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
'@typescript-eslint/typescript-estree': 8.72.0([email protected])([email protected])
|
||||
eslint: 10.13.0([email protected])
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@typescript-eslint/[email protected]1.2':
|
||||
'@typescript-eslint/[email protected]2.0':
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
eslint-visitor-keys: 5.0.1
|
||||
|
||||
'@typescript/[email protected]':
|
||||
@@ -11603,9 +11603,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected].2:
|
||||
[email protected].3:
|
||||
dependencies:
|
||||
zxing-wasm: 3.1.3
|
||||
zxing-wasm: 3.1.6
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -12394,9 +12394,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected](@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
|
||||
[email protected](@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.71.2
|
||||
'@typescript-eslint/types': 8.72.0
|
||||
comment-parser: 1.4.8
|
||||
debug: 4.4.3([email protected])
|
||||
eslint: 10.13.0([email protected])
|
||||
@@ -12407,7 +12407,7 @@ snapshots:
|
||||
stable-hash-x: 0.2.0
|
||||
unrs-resolver: 1.12.2
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/utils': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -12431,11 +12431,11 @@ snapshots:
|
||||
parse5: 8.0.1
|
||||
parse5-htmlparser2-tree-adapter: 8.0.1
|
||||
|
||||
[email protected](@typescript-eslint/[email protected]1.2(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])):
|
||||
[email protected](@typescript-eslint/[email protected]2.0(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected])):
|
||||
dependencies:
|
||||
eslint: 10.13.0([email protected])
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/eslint-plugin': 8.72.0(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
|
||||
[email protected]([email protected]([email protected])):
|
||||
dependencies:
|
||||
@@ -15203,12 +15203,12 @@ snapshots:
|
||||
possible-typed-array-names: 1.1.0
|
||||
reflect.getprototypeof: 1.0.10
|
||||
|
||||
[email protected]1.2([email protected]([email protected]))([email protected])([email protected]):
|
||||
[email protected]2.0([email protected]([email protected]))([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.71.2(@typescript-eslint/[email protected]1.2([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/parser': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/typescript-estree': 8.71.2([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.71.2([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/eslint-plugin': 8.72.0(@typescript-eslint/[email protected]2.0([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/parser': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
'@typescript-eslint/typescript-estree': 8.72.0([email protected])([email protected])
|
||||
'@typescript-eslint/utils': 8.72.0([email protected]([email protected]))([email protected])([email protected])
|
||||
eslint: 10.13.0([email protected])
|
||||
typescript: 6.0.3
|
||||
transitivePeerDependencies:
|
||||
@@ -15834,7 +15834,7 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
[email protected].3:
|
||||
[email protected].6:
|
||||
dependencies:
|
||||
'@types/emscripten': 1.41.6
|
||||
type-fest: 5.10.0
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Measures the volume of an audio track with the Web Audio API.
|
||||
*
|
||||
* Calls `onLevel` with a level between 0 and 1, rounded to steps of 0.05 and
|
||||
* only when it changed, so silence does not cause work every frame.
|
||||
* Returns a function that stops measuring and releases the audio context.
|
||||
*/
|
||||
export const watchAudioLevel = (
|
||||
track: MediaStreamTrack,
|
||||
onLevel: (level: number) => void
|
||||
): (() => void) => {
|
||||
const context = new AudioContext();
|
||||
// Browsers can start an audio context suspended when it is not created in a
|
||||
// click handler, like here. Resume without waiting: until it runs the level
|
||||
// is 0, and resume() can stay pending in browsers that need a new click.
|
||||
context.resume().catch(() => undefined);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
context.createMediaStreamSource(new MediaStream([track])).connect(analyser);
|
||||
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
let level = 0;
|
||||
let reported = -1;
|
||||
let frame = 0;
|
||||
|
||||
const tick = () => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
let sum = 0;
|
||||
for (const sample of samples) {
|
||||
sum += sample * sample;
|
||||
}
|
||||
// Root mean square is the loudness, in decibels it matches what we hear
|
||||
const decibels = 20 * Math.log10(Math.sqrt(sum / samples.length));
|
||||
// Map silence (-60 dB) to 0 and loud speech (-10 dB) to 1
|
||||
const current = Math.min(1, Math.max(0, (decibels + 60) / 50));
|
||||
// Rise immediately but fall slowly (about a second), so it fades out
|
||||
level = Math.max(current, level * 0.95);
|
||||
const rounded = Math.round(level * 20) / 20;
|
||||
if (rounded !== reported) {
|
||||
reported = rounded;
|
||||
onLevel(rounded);
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
context.close();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the devices table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const devicesFilterQueryParamConfig = {
|
||||
list: [
|
||||
"status",
|
||||
"floor",
|
||||
"area",
|
||||
"domain",
|
||||
"label",
|
||||
"config_entry",
|
||||
"sub_entry",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type DevicesFilterQueryParams = QueryParamValues<
|
||||
typeof devicesFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const devicesFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): DevicesFilterQueryParams => ({
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
config_entry: list(filters.config_entry),
|
||||
sub_entry: list(filters.sub_entry),
|
||||
});
|
||||
|
||||
// The filters as the URL holds them, so values that filter the same, such as
|
||||
// no list and an empty one, or a list in another order, compare equal. A
|
||||
// filter list sends back what it was given in its own order.
|
||||
export const devicesFilterQueryString = (filters: DataTableFilters): string => {
|
||||
const params = devicesFilterQueryParams(filters);
|
||||
const sorted: DevicesFilterQueryParams = {};
|
||||
|
||||
for (const key of devicesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, devicesFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping. The filters work out their items from the values.
|
||||
export interface DevicesLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const devicesLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): DevicesLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof DevicesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-states": filter(values("status")),
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-integrations": filter(values("domain")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
config_entry: filter(values("config_entry")),
|
||||
sub_entry: filter(values("sub_entry")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,22 +1,22 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { STATE_RUNNING } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
CameraEntityFeature,
|
||||
type CameraCapabilities,
|
||||
type CameraEntity,
|
||||
computeMJPEGStreamUrl,
|
||||
fetchCameraCapabilities,
|
||||
fetchThumbnailUrlWithCache,
|
||||
STREAM_TYPE_HLS,
|
||||
STREAM_TYPE_WEB_RTC,
|
||||
type CameraCapabilities,
|
||||
type CameraEntity,
|
||||
type StreamType,
|
||||
} from "../data/camera";
|
||||
import {
|
||||
@@ -27,11 +27,14 @@ import {
|
||||
} from "../data/context";
|
||||
import "./ha-hls-player";
|
||||
import "./ha-web-rtc-player";
|
||||
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
|
||||
|
||||
const MJPEG_STREAM = "mjpeg";
|
||||
|
||||
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
|
||||
|
||||
interface Stream {
|
||||
type: StreamType | typeof MJPEG_STREAM;
|
||||
type: CameraStreamType;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
@@ -79,6 +82,10 @@ export class HaCameraStream extends LitElement {
|
||||
|
||||
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
|
||||
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
|
||||
|
||||
private _visibleStreamType?: Stream["type"];
|
||||
|
||||
private _thumbnailApi = memoizeOne(
|
||||
(
|
||||
api: ContextType<typeof apiContext>,
|
||||
@@ -112,6 +119,16 @@ export class HaCameraStream extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/** The microphone track sent to the camera, only on WebRTC streams. */
|
||||
public get microphoneTrack(): MediaStreamTrack | undefined {
|
||||
return this._webRtcPlayer?.microphoneTrack;
|
||||
}
|
||||
|
||||
/** Toggles sending the microphone to the camera, only on WebRTC streams. */
|
||||
public async toggleMicrophone() {
|
||||
await this._webRtcPlayer?.toggleMicrophone();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._connected = true;
|
||||
@@ -122,16 +139,27 @@ export class HaCameraStream extends LitElement {
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if ((changedProps as PropertyValues).has("_capabilities")) {
|
||||
fireEvent(this, "camera-capabilities-changed", {
|
||||
capabilities: this._capabilities,
|
||||
});
|
||||
}
|
||||
const visibleStreamType = this._currentStreams().find(
|
||||
(stream) => stream.visible
|
||||
)?.type;
|
||||
if (visibleStreamType !== this._visibleStreamType) {
|
||||
this._visibleStreamType = visibleStreamType;
|
||||
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
const streams = this._streams(
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams,
|
||||
this.muted
|
||||
);
|
||||
const streams = this._currentStreams();
|
||||
return html`${repeat(
|
||||
streams,
|
||||
(stream) => stream.type + this.stateObj!.entity_id,
|
||||
@@ -197,12 +225,24 @@ export class HaCameraStream extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _currentStreams() {
|
||||
return this._streams(
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams,
|
||||
this.muted
|
||||
);
|
||||
}
|
||||
|
||||
private async _getCapabilities() {
|
||||
this._capabilities = undefined;
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
|
||||
this._capabilities = { frontend_stream_types: [] };
|
||||
this._capabilities = {
|
||||
frontend_stream_types: [],
|
||||
two_way_audio: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
this._capabilities = await fetchCameraCapabilities(
|
||||
@@ -347,6 +387,8 @@ declare global {
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
load: undefined;
|
||||
"stream-type-changed": { type?: CameraStreamType };
|
||||
"camera-capabilities-changed": { capabilities?: CameraCapabilities };
|
||||
streams: {
|
||||
hasAudio: boolean;
|
||||
hasVideo: boolean;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
} from "../data/camera";
|
||||
import { apiContext, connectionContext } from "../data/context";
|
||||
import "./ha-alert";
|
||||
import "./ha-spinner";
|
||||
import { showToast } from "../util/toast";
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
@@ -27,13 +29,15 @@ interface WebRtcPlayerError {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type WebRtcMicrophoneState = "off" | "connecting" | "on" | "denied";
|
||||
|
||||
/**
|
||||
* A WebRTC stream is established by first sending an offer through a signal
|
||||
* path via an integration. An answer is returned, then the rest of the stream
|
||||
* is handled entirely client side.
|
||||
*/
|
||||
@customElement("ha-web-rtc-player")
|
||||
class HaWebRtcPlayer extends LitElement {
|
||||
export class HaWebRtcPlayer extends LitElement {
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
@@ -68,6 +72,8 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
@state() private _error?: WebRtcPlayerError;
|
||||
|
||||
@state() private _reconnectPoster?: string;
|
||||
|
||||
@query("#remote-stream") private _videoEl!: HTMLVideoElement;
|
||||
|
||||
private _clientConfig?: WebRTCClientConfiguration;
|
||||
@@ -76,6 +82,111 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
private _remoteStream?: MediaStream;
|
||||
|
||||
private _localReturnAudioTrack?: MediaStreamTrack;
|
||||
|
||||
private _microphoneSender?: RTCRtpSender;
|
||||
|
||||
private _timerRunning = false;
|
||||
|
||||
private _microphoneConnecting = false;
|
||||
|
||||
private _microphoneDenied = false;
|
||||
|
||||
// Increased on every start, stop and clean up, so older starts are ignored
|
||||
private _microphoneRequest = 0;
|
||||
|
||||
public get microphoneState(): WebRtcMicrophoneState {
|
||||
if (this._microphoneDenied) {
|
||||
return "denied";
|
||||
}
|
||||
if (this._microphoneConnecting) {
|
||||
return "connecting";
|
||||
}
|
||||
return this._localReturnAudioTrack ? "on" : "off";
|
||||
}
|
||||
|
||||
public get microphoneTrack(): MediaStreamTrack | undefined {
|
||||
return this._localReturnAudioTrack;
|
||||
}
|
||||
|
||||
public async toggleMicrophone() {
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
// Stop (not disable) so the browser releases the microphone, also cancels connecting
|
||||
this._microphoneRequest++;
|
||||
this._localReturnAudioTrack?.stop();
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
await this._microphoneSender?.replaceTrack(null).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
if (this._microphoneDenied) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = ++this._microphoneRequest;
|
||||
this._microphoneConnecting = true;
|
||||
this._fireMicrophoneChanged();
|
||||
const track = await this._getMicrophoneTrack();
|
||||
// Cancelled or cleaned up (closed, hidden or entity changed) while asking
|
||||
if (request !== this._microphoneRequest || !this.isConnected) {
|
||||
track?.stop();
|
||||
return;
|
||||
}
|
||||
if (!track) {
|
||||
this._logEvent("unable to add audio send track");
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
return;
|
||||
}
|
||||
this._logEvent("found microphone to use for audio return track");
|
||||
if (this._microphoneSender) {
|
||||
// The connection was already set up with a microphone, swap it back in
|
||||
const sender = this._microphoneSender;
|
||||
try {
|
||||
await sender.replaceTrack(track);
|
||||
} catch (_err: unknown) {
|
||||
track.stop();
|
||||
if (request === this._microphoneRequest) {
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cancelled or cleaned up while swapping. Only stop the track, the
|
||||
// cancel already queued replaceTrack(null) and a newer start its track.
|
||||
if (request !== this._microphoneRequest) {
|
||||
track.stop();
|
||||
return;
|
||||
}
|
||||
this._localReturnAudioTrack = track;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
return;
|
||||
}
|
||||
// Renegotiating the existing connection is not supported by all providers
|
||||
// (go2rtc answers with a new DTLS fingerprint, which Firefox rejects), so
|
||||
// start a new connection and session with the microphone track instead.
|
||||
this._captureReconnectFrame();
|
||||
await this._startWebRtc(track);
|
||||
}
|
||||
|
||||
private _captureReconnectFrame() {
|
||||
const video = this._videoEl;
|
||||
if (!video?.videoWidth || !video.videoHeight) {
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d")?.drawImage(video, 0, 0);
|
||||
this._reconnectPoster = canvas.toDataURL("image/jpeg");
|
||||
}
|
||||
|
||||
private _fireMicrophoneChanged() {
|
||||
fireEvent(this, "microphone-changed", { state: this.microphoneState });
|
||||
}
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _sessionId?: string;
|
||||
@@ -120,14 +231,22 @@ class HaWebRtcPlayer extends LitElement {
|
||||
.muted=${this.muted}
|
||||
?playsinline=${this.playsInline}
|
||||
?controls=${this.controls}
|
||||
poster=${ifDefined(this.posterUrl)}
|
||||
poster=${ifDefined(this._reconnectPoster ?? this.posterUrl)}
|
||||
@loadeddata=${this._loadedData}
|
||||
@resize=${this._videoResized}
|
||||
style=${styleMap({
|
||||
height: this.aspectRatio == null ? "100%" : "auto",
|
||||
aspectRatio: this.aspectRatio,
|
||||
objectFit: this.fitMode,
|
||||
})}
|
||||
></video>
|
||||
${
|
||||
this._reconnectPoster
|
||||
? html`<div class="reconnecting">
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -158,9 +277,17 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._startWebRtc();
|
||||
}
|
||||
|
||||
private async _startWebRtc(): Promise<void> {
|
||||
private async _startWebRtc(
|
||||
localReturnAudioTrack?: MediaStreamTrack
|
||||
): Promise<void> {
|
||||
this._cleanUp();
|
||||
|
||||
if (localReturnAudioTrack) {
|
||||
this._localReturnAudioTrack = localReturnAudioTrack;
|
||||
this._microphoneConnecting = true;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
|
||||
// Browser support required for WebRTC
|
||||
if (typeof RTCPeerConnection === "undefined") {
|
||||
this._error = { type: "not_supported" };
|
||||
@@ -193,8 +320,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (cleanUpCountAtStart === this._cleanUpCount) {
|
||||
this._error = { type: "start_failed", message: err.message };
|
||||
this._cleanUp();
|
||||
this._failStart({ type: "start_failed", message: err.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -241,7 +367,14 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._remoteStream = new MediaStream();
|
||||
this._peerConnection.ontrack = this._addTrack;
|
||||
|
||||
this._peerConnection.addTransceiver("audio", { direction: "recvonly" });
|
||||
if (this._localReturnAudioTrack) {
|
||||
this._microphoneSender = this._peerConnection.addTransceiver(
|
||||
this._localReturnAudioTrack,
|
||||
{ direction: "sendrecv" }
|
||||
).sender;
|
||||
} else {
|
||||
this._peerConnection.addTransceiver("audio", { direction: "recvonly" });
|
||||
}
|
||||
this._peerConnection.addTransceiver("video", { direction: "recvonly" });
|
||||
}
|
||||
|
||||
@@ -301,8 +434,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
this._unsub = undefined;
|
||||
this._error = { type: "start_failed", message: err.message };
|
||||
this._cleanUp();
|
||||
this._failStart({ type: "start_failed", message: err.message });
|
||||
});
|
||||
};
|
||||
|
||||
@@ -363,8 +495,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
}
|
||||
if (event.type === "error") {
|
||||
this._error = { type: "start_failed", message: event.message };
|
||||
this._cleanUp();
|
||||
this._failStart({ type: "start_failed", message: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,14 +559,52 @@ class HaWebRtcPlayer extends LitElement {
|
||||
if (this._peerConnection !== peerConnection) {
|
||||
return;
|
||||
}
|
||||
this._error = { type: "connect_failed", message: err.message };
|
||||
this._cleanUp();
|
||||
this._failStart({ type: "connect_failed", message: err.message });
|
||||
}
|
||||
this._logEvent("end setRemoteDescription");
|
||||
}
|
||||
|
||||
private async _getMicrophoneTrack(): Promise<MediaStreamTrack | undefined> {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
return stream.getAudioTracks()[0];
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
||||
this._microphoneDenied = true;
|
||||
return undefined;
|
||||
}
|
||||
// A missing or busy microphone should not replace the running video
|
||||
showToast(this, {
|
||||
message: this._localize(
|
||||
"ui.components.web-rtc-player.microphone_failed",
|
||||
{ message: err instanceof Error ? err.message : String(err) }
|
||||
),
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _failStart(error: WebRtcPlayerError) {
|
||||
// The camera can reject the microphone, keep the video running without it
|
||||
if (this._localReturnAudioTrack && this._microphoneConnecting) {
|
||||
showToast(this, {
|
||||
message: this._localize(
|
||||
"ui.components.web-rtc-player.microphone_connect_failed",
|
||||
{ message: error.message ?? "" }
|
||||
),
|
||||
});
|
||||
this._startWebRtc();
|
||||
return;
|
||||
}
|
||||
this._error = error;
|
||||
this._cleanUp();
|
||||
}
|
||||
|
||||
private _cleanUp() {
|
||||
this._cleanUpCount++;
|
||||
this._microphoneRequest++;
|
||||
if (this._remoteStream) {
|
||||
this._remoteStream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
@@ -443,8 +612,15 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._remoteStream = undefined;
|
||||
}
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
this._localReturnAudioTrack?.stop();
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
const videoEl = this._videoEl;
|
||||
if (videoEl) {
|
||||
videoEl.srcObject = null;
|
||||
videoEl.removeAttribute("src");
|
||||
videoEl.load();
|
||||
}
|
||||
@@ -461,6 +637,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._peerConnection.onsignalingstatechange = null;
|
||||
|
||||
this._peerConnection = undefined;
|
||||
this._microphoneSender = undefined;
|
||||
|
||||
this._logEvent("stopped");
|
||||
this._stopTimer();
|
||||
@@ -489,6 +666,18 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("loadedData", data);
|
||||
this._stopTimer();
|
||||
|
||||
// Without a track the microphone is still waiting for permission
|
||||
if (this._microphoneConnecting && this._localReturnAudioTrack) {
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private _videoResized() {
|
||||
if (this._videoEl.videoWidth && this._videoEl.videoHeight) {
|
||||
this._reconnectPoster = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _startTimer() {
|
||||
@@ -497,12 +686,14 @@ class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.time("WebRTC");
|
||||
this._timerRunning = true;
|
||||
}
|
||||
|
||||
private _stopTimer() {
|
||||
if (!__DEV__) {
|
||||
return;
|
||||
}
|
||||
this._timerRunning = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd("WebRTC");
|
||||
}
|
||||
@@ -511,6 +702,11 @@ class HaWebRtcPlayer extends LitElement {
|
||||
if (!__DEV__) {
|
||||
return;
|
||||
}
|
||||
if (!this._timerRunning) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("WebRTC:", msg, ...args);
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeLog("WebRTC", msg, ...args);
|
||||
}
|
||||
@@ -521,10 +717,24 @@ class HaWebRtcPlayer extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
max-height: var(--video-max-height, calc(100vh - 97px));
|
||||
}
|
||||
|
||||
.reconnecting {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
--ha-spinner-indicator-color: white;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -532,4 +742,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-web-rtc-player": HaWebRtcPlayer;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"microphone-changed": { state: WebRtcMicrophoneState };
|
||||
}
|
||||
}
|
||||
@@ -656,6 +656,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -664,7 +665,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
.error ul {
|
||||
margin: var(--ha-space-1) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
padding-inline-start: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-tab-group {
|
||||
|
||||
@@ -232,6 +232,11 @@ export class HatGraphBranch extends LitElement {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
/* Branch positions are measured from the inline start. Mirror their
|
||||
connectors together with the RTL branch row, including nested rows. */
|
||||
:host(:dir(rtl)) svg {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
path {
|
||||
stroke: var(--connector-clr, var(--stroke-clr));
|
||||
stroke-width: 1;
|
||||
|
||||
+27
-1
@@ -2,8 +2,10 @@ import type {
|
||||
HassEntityAttributeBase,
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { slugify } from "../common/string/slugify";
|
||||
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
import { fileDownload } from "../util/file_download";
|
||||
import { getSignedPath } from "./auth";
|
||||
|
||||
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
|
||||
@@ -105,6 +107,29 @@ export const fetchThumbnailUrl = async (
|
||||
return hass.hassUrl(path.path);
|
||||
};
|
||||
|
||||
export const downloadCameraSnapshot = async (
|
||||
api: HomeAssistantApi,
|
||||
entityId: string
|
||||
) => {
|
||||
const result: Response | undefined = await api.callApiRaw(
|
||||
"GET",
|
||||
`camera_proxy/${entityId}`
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No response from API");
|
||||
}
|
||||
|
||||
const contentType = result.headers.get("content-type");
|
||||
const ext = contentType === "image/png" ? "png" : "jpg";
|
||||
const date = new Date().toLocaleString();
|
||||
const filename = `snapshot_${slugify(entityId)}_${date}.${ext}`;
|
||||
|
||||
const blob = await result.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
fileDownload(url, filename);
|
||||
};
|
||||
|
||||
export const fetchStreamUrl = async (
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string,
|
||||
@@ -179,6 +204,7 @@ export const getEntityIdFromCameraMediaSource = (mediaContentId: string) =>
|
||||
|
||||
export interface CameraCapabilities {
|
||||
frontend_stream_types: StreamType[];
|
||||
two_way_audio: boolean;
|
||||
}
|
||||
|
||||
export const fetchCameraCapabilities = async (
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiMicrophone, mdiMicrophoneOff } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { watchAudioLevel } from "../../../common/audio/watch-audio-level";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-camera-stream";
|
||||
import type { CameraEntity } from "../../../data/camera";
|
||||
import { apiContext } from "../../../data/context";
|
||||
import type {
|
||||
CameraStreamType,
|
||||
HaCameraStream,
|
||||
} from "../../../components/ha-camera-stream";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { WebRtcMicrophoneState } from "../../../components/ha-web-rtc-player";
|
||||
import { STREAM_TYPE_WEB_RTC, type CameraEntity } from "../../../data/camera";
|
||||
import { configContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { HomeAssistantApi } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import {
|
||||
canAppStreamMicrophone,
|
||||
isWebRtcStreamErrorCode,
|
||||
startAppMicrophoneStream,
|
||||
stopAppMicrophoneStream,
|
||||
subscribeAppStreamStopped,
|
||||
} from "../../../external_app/camera_microphone_stream";
|
||||
import type {
|
||||
EMError,
|
||||
WebRtcStreamStopped,
|
||||
} from "../../../external_app/external_messaging";
|
||||
import { showToast } from "../../../util/toast";
|
||||
|
||||
@customElement("more-info-camera")
|
||||
@@ -22,14 +41,31 @@ class MoreInfoCamera extends LitElement {
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: CameraEntity;
|
||||
|
||||
@state() private _attached = false;
|
||||
|
||||
@state() private _waiting = false;
|
||||
@state() private _microphoneState: WebRtcMicrophoneState = "off";
|
||||
|
||||
@state() private _streamType?: CameraStreamType;
|
||||
|
||||
@state() private _supportsTwoWayAudio = false;
|
||||
|
||||
@query("ha-camera-stream") private _cameraStream?: HaCameraStream;
|
||||
|
||||
@query(".microphone-meter") private _microphoneMeter?: HTMLElement;
|
||||
|
||||
private _stopAudioLevel?: () => void;
|
||||
|
||||
private _appMicrophoneSession?: string;
|
||||
|
||||
// Increased on every start and stop, so replies to older starts are ignored
|
||||
private _appMicrophoneRequest = 0;
|
||||
|
||||
private _unsubAppStreamStopped?: () => void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
@@ -39,10 +75,49 @@ class MoreInfoCamera extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._attached = false;
|
||||
this._stopMicrophoneMeter();
|
||||
this._resetMicrophone();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
const oldStateObj = changedProps.get("stateObj");
|
||||
if (oldStateObj && oldStateObj.entity_id !== this.stateObj?.entity_id) {
|
||||
this._resetMicrophone();
|
||||
}
|
||||
}
|
||||
|
||||
// A removed player can't fire microphone-changed anymore, so reset it here
|
||||
private _resetMicrophone() {
|
||||
this._stopAppMicrophone();
|
||||
this._microphoneState = "off";
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("_microphoneState")) {
|
||||
this._stopMicrophoneMeter();
|
||||
const track = this._cameraStream?.microphoneTrack;
|
||||
if (this._microphoneState === "on" && track) {
|
||||
// Set as CSS variable instead of state, to not re-render every frame
|
||||
this._stopAudioLevel = watchAudioLevel(track, (level) => {
|
||||
this._microphoneMeter?.style.setProperty(
|
||||
"--microphone-level",
|
||||
String(level)
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _stopMicrophoneMeter() {
|
||||
this._stopAudioLevel?.();
|
||||
this._stopAudioLevel = undefined;
|
||||
this._microphoneMeter?.style.removeProperty("--microphone-level");
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._attached || !this.stateObj || !this._localize) {
|
||||
if (!this._attached || !this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -51,60 +126,203 @@ class MoreInfoCamera extends LitElement {
|
||||
.stateObj=${this.stateObj}
|
||||
allow-exoplayer
|
||||
controls
|
||||
@microphone-changed=${this._microphoneChanged}
|
||||
@stream-type-changed=${this._streamTypeChanged}
|
||||
@camera-capabilities-changed=${this._capabilitiesChanged}
|
||||
></ha-camera-stream>
|
||||
${this._supportsTwoWayAudio ? this._renderMicrophoneButton() : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
<div class="actions">
|
||||
<ha-progress-button
|
||||
@click=${this._downloadSnapshot}
|
||||
.progress=${this._waiting}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
appearance="filled"
|
||||
/**
|
||||
* Browsers only allow microphone access in a secure context (HTTPS). Without
|
||||
* it, an app can send the microphone itself in a separate stream, while the
|
||||
* video stream keeps running in the frontend.
|
||||
*/
|
||||
private get _useAppMicrophone(): boolean {
|
||||
return (
|
||||
!window.isSecureContext &&
|
||||
canAppStreamMicrophone(this._config?.auth.external)
|
||||
);
|
||||
}
|
||||
|
||||
private _microphoneDisabledReason(): string | undefined {
|
||||
if (!window.isSecureContext && !this._useAppMicrophone) {
|
||||
return this._localize(
|
||||
"ui.dialogs.more_info_control.camera.microphone_not_secure"
|
||||
);
|
||||
}
|
||||
if (this._streamType !== STREAM_TYPE_WEB_RTC) {
|
||||
return this._localize(
|
||||
"ui.dialogs.more_info_control.camera.microphone_webrtc_only"
|
||||
);
|
||||
}
|
||||
if (this._microphoneState === "denied") {
|
||||
return this._localize(
|
||||
"ui.dialogs.more_info_control.camera.microphone_denied"
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderMicrophoneButton() {
|
||||
const disabledReason = this._microphoneDisabledReason();
|
||||
|
||||
const microphoneOn =
|
||||
this._microphoneState === "on" || this._microphoneState === "connecting";
|
||||
|
||||
// Stopping is always possible, so the microphone is never stuck on
|
||||
const disabled =
|
||||
!microphoneOn &&
|
||||
(disabledReason !== undefined || this.stateObj!.state === UNAVAILABLE);
|
||||
|
||||
const button = html`
|
||||
<ha-button
|
||||
.appearance=${microphoneOn ? "accent" : "filled"}
|
||||
.disabled=${disabled}
|
||||
@click=${this._toggleMicrophone}
|
||||
>
|
||||
${
|
||||
this._microphoneState === "connecting"
|
||||
? html`<ha-spinner slot="start" size="tiny"></ha-spinner>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${microphoneOn ? mdiMicrophone : mdiMicrophoneOff}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${this._localize(`ui.dialogs.more_info_control.camera.${microphoneOn ? "stop_talking" : "start_talking"}`)}
|
||||
</ha-button>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<div class="buttons">
|
||||
<div
|
||||
id="microphone-button"
|
||||
class="microphone-meter"
|
||||
tabindex=${ifDefined(disabled && disabledReason ? "0" : undefined)}
|
||||
>
|
||||
${this._localize(
|
||||
"ui.dialogs.more_info_control.camera.download_snapshot"
|
||||
)}
|
||||
</ha-progress-button>
|
||||
${
|
||||
this._microphoneState === "on"
|
||||
? html`<span class="microphone-waves"></span>`
|
||||
: nothing
|
||||
}
|
||||
${button}
|
||||
</div>
|
||||
${
|
||||
disabled && disabledReason
|
||||
? html`<ha-tooltip for="microphone-button"
|
||||
>${disabledReason}</ha-tooltip
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _downloadSnapshot(
|
||||
ev: HASSDomCurrentTargetEvent<HaProgressButton>
|
||||
) {
|
||||
const button = ev.currentTarget;
|
||||
this._waiting = true;
|
||||
|
||||
try {
|
||||
const result: Response | undefined = await this._api.callApiRaw(
|
||||
"GET",
|
||||
`camera_proxy/${this.stateObj!.entity_id}`
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw new Error("No response from API");
|
||||
}
|
||||
|
||||
const contentType = result.headers.get("content-type");
|
||||
const ext = contentType === "image/png" ? "png" : "jpg";
|
||||
const date = new Date().toLocaleString();
|
||||
const filename = `snapshot_${slugify(this.stateObj!.entity_id)}_${date}.${ext}`;
|
||||
|
||||
const blob = await result.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
fileDownload(url, filename);
|
||||
} catch (_err) {
|
||||
this._waiting = false;
|
||||
button.actionError();
|
||||
showToast(this, {
|
||||
message: this._localize(
|
||||
"ui.dialogs.more_info_control.camera.failed_to_download"
|
||||
),
|
||||
});
|
||||
private _toggleMicrophone() {
|
||||
if (this._useAppMicrophone) {
|
||||
this._toggleAppMicrophone();
|
||||
return;
|
||||
}
|
||||
this._cameraStream?.toggleMicrophone();
|
||||
}
|
||||
|
||||
this._waiting = false;
|
||||
button.actionSuccess();
|
||||
private async _toggleAppMicrophone() {
|
||||
// Stops a connected session, or cancels one the app is connecting
|
||||
if (this._appMicrophoneSession || this._microphoneState === "connecting") {
|
||||
this._stopAppMicrophone();
|
||||
return;
|
||||
}
|
||||
const request = ++this._appMicrophoneRequest;
|
||||
this._microphoneState = "connecting";
|
||||
try {
|
||||
const sessionId = await startAppMicrophoneStream(
|
||||
this._config.auth.external!,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
// Cancelled, closed or replaced while the app was connecting
|
||||
if (request !== this._appMicrophoneRequest) {
|
||||
stopAppMicrophoneStream(this._config.auth.external!, sessionId);
|
||||
return;
|
||||
}
|
||||
this._appMicrophoneSession = sessionId;
|
||||
// Only connected sessions can be stopped by the app
|
||||
this._unsubAppStreamStopped = subscribeAppStreamStopped(
|
||||
this._appStreamStopped
|
||||
);
|
||||
this._microphoneState = "on";
|
||||
} catch (err: unknown) {
|
||||
if (request !== this._appMicrophoneRequest) {
|
||||
return;
|
||||
}
|
||||
this._microphoneState = "off";
|
||||
showToast(this, { message: this._appErrorMessage(err as EMError) });
|
||||
}
|
||||
}
|
||||
|
||||
private _appErrorMessage({ code, message }: EMError): string {
|
||||
if (code === "unknown" || !isWebRtcStreamErrorCode(code)) {
|
||||
return this._localize(
|
||||
"ui.dialogs.more_info_control.camera.app_microphone_error.unknown",
|
||||
{ message }
|
||||
);
|
||||
}
|
||||
return this._localize(
|
||||
`ui.dialogs.more_info_control.camera.app_microphone_error.${code}`
|
||||
);
|
||||
}
|
||||
|
||||
private _stopAppMicrophone() {
|
||||
this._appMicrophoneRequest++;
|
||||
if (this._appMicrophoneSession) {
|
||||
stopAppMicrophoneStream(
|
||||
this._config.auth.external!,
|
||||
this._appMicrophoneSession
|
||||
);
|
||||
this._clearAppMicrophoneSession();
|
||||
}
|
||||
if (this._useAppMicrophone) {
|
||||
this._microphoneState = "off";
|
||||
}
|
||||
}
|
||||
|
||||
private _clearAppMicrophoneSession() {
|
||||
this._appMicrophoneSession = undefined;
|
||||
this._unsubAppStreamStopped?.();
|
||||
this._unsubAppStreamStopped = undefined;
|
||||
}
|
||||
|
||||
private _appStreamStopped = (stopped: WebRtcStreamStopped) => {
|
||||
if (stopped.session_id !== this._appMicrophoneSession) {
|
||||
return;
|
||||
}
|
||||
this._clearAppMicrophoneSession();
|
||||
this._microphoneState = "off";
|
||||
if (stopped.code === "interrupted") {
|
||||
return;
|
||||
}
|
||||
showToast(this, { message: this._appErrorMessage(stopped) });
|
||||
};
|
||||
|
||||
private _streamTypeChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["stream-type-changed"]>
|
||||
) {
|
||||
this._streamType = ev.detail.type;
|
||||
}
|
||||
|
||||
private _capabilitiesChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["camera-capabilities-changed"]>
|
||||
) {
|
||||
this._supportsTwoWayAudio = ev.detail.capabilities?.two_way_audio ?? false;
|
||||
}
|
||||
|
||||
private _microphoneChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["microphone-changed"]>
|
||||
) {
|
||||
if (this._useAppMicrophone) {
|
||||
return;
|
||||
}
|
||||
this._microphoneState = ev.detail.state;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -112,16 +330,60 @@ class MoreInfoCamera extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
box-sizing: border-box;
|
||||
padding: var(--ha-space-4);
|
||||
z-index: 1;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.microphone-meter {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
ha-spinner {
|
||||
--ha-spinner-indicator-color: currentColor;
|
||||
}
|
||||
|
||||
.microphone-waves {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: var(--microphone-level, 0);
|
||||
transition: opacity 150ms linear;
|
||||
}
|
||||
|
||||
.microphone-waves::before,
|
||||
.microphone-waves::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background-color: var(--ha-color-fill-primary-loud-resting);
|
||||
animation: microphone-wave 1.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.microphone-waves::after {
|
||||
animation-delay: 0.7s;
|
||||
}
|
||||
|
||||
@keyframes microphone-wave {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 0.4;
|
||||
}
|
||||
to {
|
||||
transform: scale(1.15, 1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.microphone-waves::before,
|
||||
.microphone-waves::after {
|
||||
animation: none;
|
||||
transform: scale(1.08, 1.3);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mdiContentDuplicate,
|
||||
mdiDevices,
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
mdiInformationOutline,
|
||||
mdiLinkVariant,
|
||||
mdiPencil,
|
||||
@@ -29,6 +30,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../common/dom/get_main_window";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import {
|
||||
computeEntityEntryNameParts,
|
||||
@@ -56,6 +58,8 @@ import "../../components/ha-dropdown-item";
|
||||
import "../../components/ha-icon-button";
|
||||
import "../../components/ha-icon-button-prev";
|
||||
import "./ha-more-info-related";
|
||||
import { downloadCameraSnapshot } from "../../data/camera";
|
||||
import { apiContext } from "../../data/context";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
@@ -64,6 +68,7 @@ import {
|
||||
getExtendedEntityRegistryEntry,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
|
||||
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
|
||||
import type { Helper } from "../../panels/config/helpers/const";
|
||||
@@ -74,7 +79,8 @@ import {
|
||||
haStyleScrollbar,
|
||||
} from "../../resources/styles";
|
||||
import "../../state-summary/state-card-content";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../types";
|
||||
import { showToast } from "../../util/toast";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import {
|
||||
computeShowHistoryComponent,
|
||||
@@ -139,6 +145,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
>()(ScrollableFadeMixin(LitElement)) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public large = false;
|
||||
|
||||
@state() private _fill = false;
|
||||
@@ -486,6 +495,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
case "details":
|
||||
this._setView("details");
|
||||
break;
|
||||
case "download_snapshot":
|
||||
this._downloadSnapshot();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -560,6 +572,18 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
this._setView("add_to");
|
||||
}
|
||||
|
||||
private async _downloadSnapshot() {
|
||||
try {
|
||||
await downloadCameraSnapshot(this._api, this._entityId!);
|
||||
} catch (_err) {
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.camera.failed_to_download"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _breadcrumbClick(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._setView("related");
|
||||
@@ -595,6 +619,20 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const addToMenuItem = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
);
|
||||
const downloadSnapshotMenuItem =
|
||||
domain === "camera"
|
||||
? html`
|
||||
<ha-dropdown-item
|
||||
value="download_snapshot"
|
||||
.disabled=${stateObj?.state === UNAVAILABLE}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.camera.download_snapshot"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
: nothing;
|
||||
const viewTitle = this._computeViewTitle();
|
||||
const defaultTitle = breadcrumb[breadcrumb.length - 1] || entityId;
|
||||
if (!viewTitle) {
|
||||
@@ -739,6 +777,14 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
|
||||
${
|
||||
domain === "camera"
|
||||
? html`
|
||||
${downloadSnapshotMenuItem}
|
||||
<wa-divider></wa-divider>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._shouldShowAddEntityTo()
|
||||
? html`
|
||||
@@ -857,16 +903,41 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
`
|
||||
: !__DEMO__ && this._shouldShowAddEntityTo()
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="headerActionItems"
|
||||
.label=${addToMenuItem}
|
||||
.path=${mdiPlusBoxMultipleOutline}
|
||||
@click=${this._goToAddEntityTo}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
: html`
|
||||
${
|
||||
!__DEMO__ && this._shouldShowAddEntityTo()
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="headerActionItems"
|
||||
.label=${addToMenuItem}
|
||||
.path=${mdiPlusBoxMultipleOutline}
|
||||
@click=${this._goToAddEntityTo}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
domain === "camera"
|
||||
? html`
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleMenuAction}
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass.localize(
|
||||
"ui.common.menu"
|
||||
)}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
${downloadSnapshotMenuItem}
|
||||
</ha-dropdown>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`
|
||||
}
|
||||
`
|
||||
: this._currView === "details"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Apps can stream the microphone to a camera themselves, in a separate WebRTC
|
||||
session next to the video stream of the frontend. Used when the frontend has
|
||||
no secure context, where browsers block microphone access.
|
||||
*/
|
||||
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type {
|
||||
ExternalMessaging,
|
||||
WebRtcStreamErrorCode,
|
||||
WebRtcStreamStopped,
|
||||
} from "./external_messaging";
|
||||
import { WEBRTC_STREAM_ERROR_CODES } from "./external_messaging";
|
||||
|
||||
export const canAppStreamMicrophone = (external?: ExternalMessaging) =>
|
||||
Boolean(external?.config.webrtc?.hasCameraMicrophoneStream);
|
||||
|
||||
export const isWebRtcStreamErrorCode = (
|
||||
code: string
|
||||
): code is WebRtcStreamErrorCode =>
|
||||
(WEBRTC_STREAM_ERROR_CODES as readonly string[]).includes(code);
|
||||
|
||||
/**
|
||||
* Asks the app to stream the microphone to the camera.
|
||||
* Resolves with the session ID once connected, rejects with `{ code, message }`
|
||||
* where `code` is a `WebRtcStreamErrorCode`.
|
||||
*/
|
||||
export const startAppMicrophoneStream = async (
|
||||
external: ExternalMessaging,
|
||||
entityId: string
|
||||
): Promise<string> => {
|
||||
const { session_id } = await external.sendMessage<"webrtc/stream/start">({
|
||||
type: "webrtc/stream/start",
|
||||
payload: {
|
||||
stream_type: "microphone",
|
||||
entity_id: entityId,
|
||||
},
|
||||
});
|
||||
return session_id;
|
||||
};
|
||||
|
||||
export const stopAppMicrophoneStream = (
|
||||
external: ExternalMessaging,
|
||||
sessionId: string
|
||||
) =>
|
||||
external.fireMessage({
|
||||
type: "webrtc/stream/stop",
|
||||
payload: { session_id: sessionId },
|
||||
});
|
||||
|
||||
/**
|
||||
* Calls `callback` when the app ends a session without the frontend stopping it.
|
||||
* Returns a function to unsubscribe.
|
||||
*/
|
||||
export const subscribeAppStreamStopped = (
|
||||
callback: (stopped: WebRtcStreamStopped) => void
|
||||
): (() => void) => {
|
||||
const listener = (ev: HASSDomEvent<HASSDomEvents["webrtc-stream-stopped"]>) =>
|
||||
callback(ev.detail);
|
||||
window.addEventListener("webrtc-stream-stopped", listener);
|
||||
return () => window.removeEventListener("webrtc-stream-stopped", listener);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
EMIncomingMessageCommands,
|
||||
ImprovDiscoveredDevice,
|
||||
MatterCommissionFinish,
|
||||
WebRtcStreamStopped,
|
||||
} from "./external_messaging";
|
||||
|
||||
const barCodeListeners = new Set<
|
||||
@@ -99,6 +100,8 @@ export const handleExternalMessage = (
|
||||
barCodeListeners.forEach((listener) => listener(msg));
|
||||
} else if (msg.command === "kiosk_mode/set") {
|
||||
fireEvent(window, "hass-kiosk-mode", { enable: msg.payload.enable });
|
||||
} else if (msg.command === "webrtc/stream/stopped") {
|
||||
fireEvent(window, "webrtc-stream-stopped", msg.payload);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
@@ -118,6 +121,7 @@ declare global {
|
||||
"improv-discovered-device": ImprovDiscoveredDevice;
|
||||
"improv-device-setup-done": undefined;
|
||||
"matter-commission-finish": MatterCommissionFinish;
|
||||
"webrtc-stream-stopped": WebRtcStreamStopped;
|
||||
}
|
||||
|
||||
interface GlobalEventHandlersEventMap {
|
||||
@@ -130,5 +134,8 @@ declare global {
|
||||
"matter-commission-finish": HASSDomEvent<
|
||||
HASSDomEvents["matter-commission-finish"]
|
||||
>;
|
||||
"webrtc-stream-stopped": HASSDomEvent<
|
||||
HASSDomEvents["webrtc-stream-stopped"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface EMMessage {
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface EMError {
|
||||
export interface EMError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -77,6 +77,38 @@ interface EMOutgoingMessageImportThreadCredentials extends EMMessage {
|
||||
type: "thread/import_credentials";
|
||||
}
|
||||
|
||||
// Streams the app can send to a camera itself
|
||||
export type WebRtcStreamType = "microphone";
|
||||
|
||||
// Error codes the app rejects webrtc/stream/start with, or stops a stream with
|
||||
export const WEBRTC_STREAM_ERROR_CODES = [
|
||||
"microphone_denied", // User denied the microphone permission
|
||||
"server_unavailable", // No API for the server, or its connection never became ready
|
||||
"signaling_failed", // camera/webrtc/offer failed, or Home Assistant sent an error event
|
||||
"connection_failed", // WebRTC connection failed, or dropped for more than 5 seconds
|
||||
"timeout", // Not connected 25 seconds after the permission step
|
||||
"interrupted", // App went to the background, a new start replaced it, or a page load cleared it
|
||||
"unsupported_stream_type", // App does not know the requested stream type
|
||||
"unknown", // Unexpected error, message holds the error of the app
|
||||
] as const;
|
||||
|
||||
export type WebRtcStreamErrorCode = (typeof WEBRTC_STREAM_ERROR_CODES)[number];
|
||||
|
||||
interface EMOutgoingMessageWebRtcStreamStart extends EMMessage {
|
||||
type: "webrtc/stream/start";
|
||||
payload: {
|
||||
stream_type: WebRtcStreamType;
|
||||
entity_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageWebRtcStreamStop extends EMMessage {
|
||||
type: "webrtc/stream/stop";
|
||||
payload: {
|
||||
session_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageWithAnswer {
|
||||
"config/get": {
|
||||
request: EMOutgoingMessageConfigGet;
|
||||
@@ -86,6 +118,11 @@ interface EMOutgoingMessageWithAnswer {
|
||||
request: EMOutgoingMessageEntityAddToGetActions;
|
||||
response: ExternalEntityAddToActions;
|
||||
};
|
||||
// Replies once the stream is connected
|
||||
"webrtc/stream/start": {
|
||||
request: EMOutgoingMessageWebRtcStreamStart;
|
||||
response: { session_id: string };
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageExoplayerPlayHLS extends EMMessage {
|
||||
@@ -251,7 +288,8 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMOutgoingMessageEntityControlled
|
||||
| EMOutgoingMessageFocusElement
|
||||
| EMOutgoingMessageReloadAndClearCache
|
||||
| EMOutgoingMessageAssistSettings;
|
||||
| EMOutgoingMessageAssistSettings
|
||||
| EMOutgoingMessageWebRtcStreamStop;
|
||||
|
||||
export interface EMIncomingMessageRestart {
|
||||
id: number;
|
||||
@@ -368,6 +406,20 @@ export interface EMIncomingMessageMatterCommissionFinish extends EMMessage {
|
||||
payload: MatterCommissionFinish;
|
||||
}
|
||||
|
||||
export interface WebRtcStreamStopped {
|
||||
session_id: string;
|
||||
code: WebRtcStreamErrorCode;
|
||||
message: string; // Fallback error message from the app
|
||||
}
|
||||
|
||||
// Sent when a connected stream ends without the frontend stopping it
|
||||
export interface EMIncomingMessageWebRtcStreamStopped extends EMMessage {
|
||||
id: number;
|
||||
type: "command";
|
||||
command: "webrtc/stream/stopped";
|
||||
payload: WebRtcStreamStopped;
|
||||
}
|
||||
|
||||
export type EMIncomingMessageCommands =
|
||||
| EMIncomingMessageRestart
|
||||
| EMIncomingMessageNavigate
|
||||
@@ -380,7 +432,8 @@ export type EMIncomingMessageCommands =
|
||||
| EMIncomingMessageImprovDeviceDiscovered
|
||||
| EMIncomingMessageImprovDeviceSetupDone
|
||||
| EMIncomingMessageMatterCommissionFinish
|
||||
| EMIncomingMessageKioskModeSet;
|
||||
| EMIncomingMessageKioskModeSet
|
||||
| EMIncomingMessageWebRtcStreamStopped;
|
||||
|
||||
type EMIncomingMessage =
|
||||
EMMessageResultSuccess | EMMessageResultError | EMIncomingMessageCommands;
|
||||
@@ -403,6 +456,9 @@ export interface ExternalConfig {
|
||||
hasEntityAddTo?: boolean; // Supports "Add to" from more-info dialog, with action coming from external app
|
||||
hasAssistSettings?: boolean; // Shows the "This device" section in voice assistant settings
|
||||
hasSplashscreen?: boolean; // App covers the frontend with its own loading screen until frontend/loaded, so the launch screen is removed without animation
|
||||
webrtc?: {
|
||||
hasCameraMicrophoneStream?: boolean; // App streams the microphone to a camera itself with webrtc/stream/start, used when the frontend has no secure context
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExternalEntityAddToAction {
|
||||
|
||||
@@ -89,12 +89,14 @@ type AreaQuickLinkKey =
|
||||
const NAVIGATION_ACTIONS: {
|
||||
value: string;
|
||||
path: string;
|
||||
params?: string;
|
||||
icon: string;
|
||||
countKey: AreaQuickLinkKey;
|
||||
}[] = [
|
||||
{
|
||||
value: "navigate-devices",
|
||||
path: "/config/devices/dashboard",
|
||||
params: "&status=disabled",
|
||||
icon: mdiDevices,
|
||||
countKey: "devices",
|
||||
},
|
||||
@@ -852,7 +854,9 @@ class HaConfigAreaPage extends LitElement {
|
||||
|
||||
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
|
||||
if (navAction) {
|
||||
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
|
||||
navigate(
|
||||
`${navAction.path}?historyBack=1&area=${this.areaId}${navAction.params ?? ""}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,13 @@ export class HaAutomationTrace extends LitElement {
|
||||
trace === pTrace && entities === pEntities && manifests === pManifests
|
||||
);
|
||||
|
||||
private get _isRTL() {
|
||||
return computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const stateObj = this._entityId
|
||||
? this.hass.states[this._entityId]
|
||||
@@ -285,7 +292,12 @@ export class HaAutomationTrace extends LitElement {
|
||||
: html`
|
||||
<ha-split-panel
|
||||
class="split"
|
||||
.position=${this._splitPosition}
|
||||
dir=${this._isRTL ? "rtl" : "ltr"}
|
||||
.position=${
|
||||
this._isRTL
|
||||
? 100 - this._splitPosition
|
||||
: this._splitPosition
|
||||
}
|
||||
@wa-reposition=${this._splitRepositioned}
|
||||
>
|
||||
${this._renderPanes()}
|
||||
@@ -513,7 +525,15 @@ export class HaAutomationTrace extends LitElement {
|
||||
}
|
||||
|
||||
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
|
||||
this._splitPosition = ev.target.position;
|
||||
const position = this._isRTL
|
||||
? 100 - ev.target.position
|
||||
: ev.target.position;
|
||||
// Ignore reposition events caused by applying the current position.
|
||||
if (position === this._splitPosition) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._splitPosition = position;
|
||||
this._storeSplitPosition();
|
||||
}
|
||||
|
||||
@@ -719,7 +739,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
:host([narrow]) .main {
|
||||
@@ -742,6 +761,11 @@ export class HaAutomationTrace extends LitElement {
|
||||
--ha-split-panel-divider-hit-area: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-split-panel.split[dir="rtl"] {
|
||||
--ha-split-panel-min: 20%;
|
||||
--ha-split-panel-max: 90%;
|
||||
}
|
||||
|
||||
.graph {
|
||||
background: var(
|
||||
--ha-panel-background,
|
||||
@@ -781,7 +805,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
var(--ha-panel-background, var(--primary-background-color))
|
||||
);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-tab-group-tab::part(base) {
|
||||
padding: 2px 16px;
|
||||
@@ -793,9 +816,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ha-trace-logbook {
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-trace-picker {
|
||||
flex-grow: 1;
|
||||
max-width: 500px;
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
@@ -24,12 +26,15 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { deepEqual } from "../../../common/util/deep-equal";
|
||||
import {
|
||||
devicesFilterQueryParamConfig,
|
||||
devicesFilterQueryParams,
|
||||
devicesFilterQueryString,
|
||||
devicesLinkFromUrl,
|
||||
} from "../../../common/url/devices-query-params";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
rejectedItems,
|
||||
@@ -38,7 +43,6 @@ import type {
|
||||
DataTableColumnContainer,
|
||||
RowClickedEvent,
|
||||
SelectionChangedEvent,
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
@@ -132,8 +136,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _selected: string[] = [];
|
||||
|
||||
private _selectedCanDelete: string[] = [];
|
||||
@@ -141,14 +143,11 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
@query("hass-tabs-subpage-data-table", true)
|
||||
private _dataTable!: HaTabsSubpageDataTable;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
storage: "sessionStorage",
|
||||
key: "devices-table-search",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
private _tableSettings = new DataTableSettingsController(this, {
|
||||
search: "devices-table-search",
|
||||
sorting: "devices-table-sort",
|
||||
grouping: "devices-table-grouping",
|
||||
});
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -169,12 +168,6 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
@state()
|
||||
_labels?: LabelRegistryEntry[];
|
||||
|
||||
@storage({ key: "devices-table-sort", state: false, subscribe: false })
|
||||
private _activeSorting?: SortingChangedEvent;
|
||||
|
||||
@storage({ key: "devices-table-grouping", state: false, subscribe: false })
|
||||
private _activeGrouping?: string;
|
||||
|
||||
@storage({ key: "devices-table-collapsed", state: false, subscribe: false })
|
||||
private _activeCollapsed?: string;
|
||||
|
||||
@@ -196,39 +189,16 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _ignoreLocationChange = false;
|
||||
|
||||
private _fromUrl = false;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("location-changed", this._locationChanged);
|
||||
window.addEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
window.removeEventListener("popstate", this._popState);
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
if (this._ignoreLocationChange) {
|
||||
this._ignoreLocationChange = false;
|
||||
return;
|
||||
// The filters are kept in the URL by the page, the search, sorting and
|
||||
// grouping by the table
|
||||
private _filterParams = new QueryParamsController(
|
||||
this,
|
||||
devicesFilterQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyLink(),
|
||||
}
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
|
||||
private _popState = () => {
|
||||
if (window.location.search.substring(1) !== this._searchParms.toString()) {
|
||||
this._searchParms = new URLSearchParams(window.location.search);
|
||||
this._setFiltersFromUrl();
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
private _states = memoizeOne((localize: LocalizeFunc) => [
|
||||
{
|
||||
@@ -241,7 +211,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this.hasUpdated) {
|
||||
this._filters = this._storageFilters;
|
||||
this._setFiltersFromUrl();
|
||||
this._applyLink();
|
||||
}
|
||||
if (changedProps.has("_selected") || changedProps.has("entries")) {
|
||||
this._selectedCanDelete = this._selected.filter((d) => {
|
||||
@@ -256,60 +226,45 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _setFiltersFromUrl() {
|
||||
const area = this._searchParms.get("area");
|
||||
const domain = this._searchParms.get("domain");
|
||||
const configEntry = this._searchParms.get("config_entry");
|
||||
const subEntry = this._searchParms.get("sub_entry");
|
||||
const label = this._searchParms.has("label");
|
||||
// Links, like the ones on an integration page, say in the URL what to show.
|
||||
// The router keeps the page open, so a link can land on it.
|
||||
private _applyLink() {
|
||||
const searchParams = this._filterParams.searchParams();
|
||||
const link = searchParams ? devicesLinkFromUrl(searchParams) : undefined;
|
||||
|
||||
// Shown, but not stored as the user's choice
|
||||
if (link) {
|
||||
this._tableSettings.showSearch(link.search);
|
||||
this._filters = this._keepItems(link.filters);
|
||||
} else if (searchParams?.toString() === "") {
|
||||
// Opened again without params, like from the sidebar. Written once the
|
||||
// table has seen the URL without params, so it restores what it stored
|
||||
this._filters = this._keepItems(this._storageFilters);
|
||||
queueMicrotask(() => this._writeFilters());
|
||||
|
||||
if (!area && !domain && !configEntry && !label) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": {
|
||||
value: [
|
||||
...((this._filters["ha-filter-states"]?.value as string[]) || []),
|
||||
"disabled",
|
||||
],
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-floor-areas": {
|
||||
value: area ? { areas: [area] } : undefined,
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-integrations": {
|
||||
value: domain ? [domain] : [],
|
||||
items: undefined,
|
||||
},
|
||||
config_entry: {
|
||||
value: configEntry ? [configEntry] : [],
|
||||
items: undefined,
|
||||
},
|
||||
sub_entry: {
|
||||
value: subEntry ? [subEntry] : [],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
this._filterLabel();
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _filterLabel() {
|
||||
const label = this._searchParms.get("label");
|
||||
if (!label) {
|
||||
return;
|
||||
}
|
||||
this._filters = {
|
||||
...this._filters,
|
||||
"ha-filter-labels": {
|
||||
value: [label],
|
||||
items: undefined,
|
||||
},
|
||||
};
|
||||
// The filters only work out their items again when their value changes, so
|
||||
// keep the items of the ones that stay as they are
|
||||
private _keepItems(filters: DataTableFilters): DataTableFilters {
|
||||
return Object.fromEntries(
|
||||
Object.entries(filters).map(([key, filter]) => {
|
||||
const current = this._filters[key];
|
||||
|
||||
return [
|
||||
key,
|
||||
current && deepEqual(filter.value, current.value) ? current : filter,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _writeFilters() {
|
||||
this._filterParams.write(devicesFilterQueryParams(this._filters));
|
||||
}
|
||||
|
||||
private _reloadConfigEntries() {
|
||||
@@ -318,9 +273,8 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _clearFilter() {
|
||||
this._filters = {};
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = {};
|
||||
}
|
||||
this._storageFilters = {};
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _devicesAndFilterDomains = memoizeOne(
|
||||
@@ -399,24 +353,29 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.length
|
||||
) {
|
||||
if (!(
|
||||
Array.isArray(this._filters.config_entry?.value) &&
|
||||
this._filters.config_entry.value.length === 1
|
||||
)) {
|
||||
const configEntryIds = this._filters.config_entry?.value;
|
||||
const subEntryIds = filter.value;
|
||||
|
||||
if (
|
||||
!Array.isArray(configEntryIds) ||
|
||||
!configEntryIds.length ||
|
||||
!Array.isArray(subEntryIds)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const configEntryId = this._filters.config_entry.value[0];
|
||||
outputDevices = outputDevices.filter(
|
||||
(device) =>
|
||||
device.config_entries_subentries[configEntryId] &&
|
||||
(filter.value as string[]).some((subEntryId) =>
|
||||
device.config_entries_subentries[configEntryId].includes(
|
||||
subEntryId
|
||||
)
|
||||
|
||||
outputDevices = outputDevices.filter((device) =>
|
||||
configEntryIds.some((configEntryId) =>
|
||||
device.config_entries_subentries[configEntryId]?.some(
|
||||
(subEntryId) =>
|
||||
subEntryId !== null && subEntryIds.includes(subEntryId)
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!this._subEntries) {
|
||||
this._loadSubEntries(configEntryId);
|
||||
|
||||
// The titles shown in the filter chip need a single config entry
|
||||
if (configEntryIds.length === 1 && !this._subEntries) {
|
||||
this._loadSubEntries(configEntryIds[0]);
|
||||
}
|
||||
} else if (
|
||||
key === "ha-filter-integrations" &&
|
||||
@@ -654,6 +613,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "integration",
|
||||
minWidth: "120px",
|
||||
template: (device) =>
|
||||
!this.entries
|
||||
@@ -669,6 +629,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "parent",
|
||||
defaultHidden: true,
|
||||
minWidth: "120px",
|
||||
template: (device) => device.parent_device_name || "",
|
||||
@@ -678,6 +639,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
groupable: true,
|
||||
urlName: "manufacturer",
|
||||
minWidth: "120px",
|
||||
},
|
||||
model: {
|
||||
@@ -875,7 +837,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
selectable
|
||||
.selected=${this._selected.length}
|
||||
@selection-changed=${this._handleSelectionChanged}
|
||||
.filter=${this._filter}
|
||||
.filter=${this._tableSettings.search}
|
||||
has-filters
|
||||
.filters=${
|
||||
Object.values(this._filters).filter((filter) =>
|
||||
@@ -887,19 +849,20 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
)
|
||||
).length
|
||||
}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._activeSorting}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
.hiddenColumns=${this._activeHiddenColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@clear-filter=${this._clearFilter}
|
||||
@search-changed=${this._handleSearchChange}
|
||||
@sorting-changed=${this._handleSortingChanged}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
sync-url
|
||||
has-fab
|
||||
class=${this.narrow ? "narrow" : ""}
|
||||
>
|
||||
@@ -1093,10 +1056,27 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _filterChanged(ev) {
|
||||
const type = ev.target.localName;
|
||||
this._filters = { ...this._filters, [type]: ev.detail };
|
||||
if (!this._fromUrl) {
|
||||
this._storageFilters = this._filters;
|
||||
const filters = { ...this._filters, [type]: ev.detail };
|
||||
|
||||
// A filter can still finish looking up its items after the page is left.
|
||||
// Keep them for when the page comes back, but leave the URL alone.
|
||||
if (!this.isConnected) {
|
||||
this._filters = filters;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// A filter also sends the value it was given, once it has found its items.
|
||||
// Only a change to what the URL holds is the user's, so a link isn't stored.
|
||||
if (
|
||||
devicesFilterQueryString(filters) !==
|
||||
devicesFilterQueryString(this._filters)
|
||||
) {
|
||||
this._storageFilters = { ...this._storageFilters, [type]: ev.detail };
|
||||
}
|
||||
|
||||
this._filters = filters;
|
||||
this._writeFilters();
|
||||
}
|
||||
|
||||
private _batteryEntity(
|
||||
@@ -1123,15 +1103,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
|
||||
const deviceId = ev.detail.id;
|
||||
this._ignoreLocationChange = true;
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
const { filteredConfigEntry, filteredDomains } =
|
||||
this._devicesAndFilterDomains(
|
||||
@@ -1157,8 +1131,13 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const integrations = this._filters["ha-filter-integrations"]?.value;
|
||||
showAddIntegrationDialog(this, {
|
||||
domain: this._searchParms.get("domain") || undefined,
|
||||
domain:
|
||||
Array.isArray(integrations) && integrations.length === 1
|
||||
? integrations[0]
|
||||
: undefined,
|
||||
navigateToResult: true,
|
||||
});
|
||||
}
|
||||
@@ -1319,14 +1298,6 @@ ${rejected
|
||||
this._dataTable.clearSelection();
|
||||
}
|
||||
|
||||
private _handleSortingChanged(ev: CustomEvent) {
|
||||
this._activeSorting = ev.detail;
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { DataTableSettingsController } from "../../../common/controllers/data-table-settings-controller";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
@@ -40,6 +41,8 @@ class HaConfigHardwareAll extends LitElement {
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
private _tableSettings = new DataTableSettingsController(this, {});
|
||||
|
||||
private _columns = memoizeOne(
|
||||
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
|
||||
name: {
|
||||
@@ -106,7 +109,10 @@ class HaConfigHardwareAll extends LitElement {
|
||||
this._error ||
|
||||
this.hass.localize("ui.panel.config.hardware.loading_system_data")
|
||||
}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
@row-click=${this._handleRowClicked}
|
||||
sync-url
|
||||
></hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ export class HaConfigEntryRow extends LitElement {
|
||||
href=${
|
||||
allDevices.length === 1
|
||||
? `/config/devices/device/${allDevices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="devices">
|
||||
@@ -278,7 +278,7 @@ export class HaConfigEntryRow extends LitElement {
|
||||
href=${
|
||||
allServices.length === 1
|
||||
? `/config/devices/device/${allServices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}&status=disabled`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="services">
|
||||
|
||||
@@ -617,7 +617,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
href=${
|
||||
devices.length === 1
|
||||
? `/config/devices/device/${devices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -635,7 +635,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
href=${
|
||||
services.length === 1
|
||||
? `/config/devices/device/${services[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}`
|
||||
: `/config/devices/dashboard?historyBack=1&domain=${this.domain}&status=disabled`
|
||||
}
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -113,7 +113,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
href=${
|
||||
devices.length === 1
|
||||
? `/config/devices/device/${devices[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="devices">
|
||||
@@ -138,7 +138,7 @@ class HaConfigSubEntryRow extends LitElement {
|
||||
href=${
|
||||
services.length === 1
|
||||
? `/config/devices/device/${services[0].id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`
|
||||
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}&status=disabled`
|
||||
}
|
||||
>
|
||||
<ha-dropdown-item value="services">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { DataTableColumnContainer } from "../../../../../components/data-table/ha-data-table";
|
||||
import { extractSearchParamsObject } from "../../../../../common/url/search-params";
|
||||
@@ -22,14 +23,14 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _macAddress?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
|
||||
@state() private _data: DHCPDiscoveryData[] = [];
|
||||
|
||||
private _tableSettings = new DataTableSettingsController(this, {});
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
subscribeDHCPDiscovery(this.hass.connection, (data) => {
|
||||
@@ -85,7 +86,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
const searchParams = extractSearchParamsObject();
|
||||
const macAddress = searchParams.mac_address;
|
||||
if (macAddress) {
|
||||
this._macAddress = macAddress.toUpperCase();
|
||||
this._tableSettings.showSearch(macAddress.toUpperCase());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +102,9 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.config.dhcp.no_devices_found"
|
||||
)}
|
||||
filter=${this._macAddress || ""}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
sync-url
|
||||
></hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ export class MatterConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -221,7 +221,7 @@ export class MQTTConfigPanel extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type {
|
||||
RowClickedEvent,
|
||||
@@ -32,12 +33,11 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _data: SSDPDiscoveryData[] = [];
|
||||
|
||||
@storage({
|
||||
key: "ssdp-discovery-table-grouping",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeGrouping?: string = "ssdp_location";
|
||||
private _tableSettings = new DataTableSettingsController(
|
||||
this,
|
||||
{ grouping: "ssdp-discovery-table-grouping" },
|
||||
{ defaultGrouping: "ssdp_location" }
|
||||
);
|
||||
|
||||
@storage({
|
||||
key: "ssdp-discovery-table-collapsed",
|
||||
@@ -79,6 +79,7 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
urlName: "location",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -102,16 +103,19 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/ssdp"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
.data=${this._dataWithIds(this._data)}
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.config.ssdp.no_devices_found"
|
||||
)}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
sync-url
|
||||
></hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
@@ -127,10 +131,6 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
+12
-12
@@ -3,6 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { DataTableSettingsController } from "../../../../../common/controllers/data-table-settings-controller";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type {
|
||||
RowClickedEvent,
|
||||
@@ -32,12 +33,11 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _data: ZeroconfDiscoveryData[] = [];
|
||||
|
||||
@storage({
|
||||
key: "zeroconf-discovery-table-grouping",
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _activeGrouping?: string = "type";
|
||||
private _tableSettings = new DataTableSettingsController(
|
||||
this,
|
||||
{ grouping: "zeroconf-discovery-table-grouping" },
|
||||
{ defaultGrouping: "type" }
|
||||
);
|
||||
|
||||
@storage({
|
||||
key: "zeroconf-discovery-table-collapsed",
|
||||
@@ -74,6 +74,7 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
groupable: true,
|
||||
urlName: "type",
|
||||
},
|
||||
ip_addresses: {
|
||||
title: localize("ui.panel.config.zeroconf.ip_addresses"),
|
||||
@@ -108,16 +109,19 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/zeroconf"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
@grouping-changed=${this._handleGroupingChanged}
|
||||
@grouping-changed=${this._tableSettings.groupingChanged}
|
||||
@collapsed-changed=${this._handleCollapseChanged}
|
||||
.filter=${this._tableSettings.search}
|
||||
@search-changed=${this._tableSettings.searchChanged}
|
||||
.data=${this._dataWithIds(this._data)}
|
||||
.noDataText=${this.hass.localize(
|
||||
"ui.panel.config.zeroconf.no_devices_found"
|
||||
)}
|
||||
@row-click=${this._handleRowClicked}
|
||||
clickable
|
||||
sync-url
|
||||
></hass-tabs-subpage-data-table>
|
||||
`;
|
||||
}
|
||||
@@ -129,10 +133,6 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleGroupingChanged(ev: CustomEvent) {
|
||||
this._activeGrouping = ev.detail.value;
|
||||
}
|
||||
|
||||
private _handleCollapseChanged(ev: CustomEvent) {
|
||||
this._activeCollapsed = ev.detail.value;
|
||||
}
|
||||
|
||||
@@ -237,7 +237,7 @@ class ZHAConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this._configEntry?.entry_id}&status=disabled`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ export class DialogZWaveJSAddNode extends HTMLElement {
|
||||
|
||||
private async _openDialog() {
|
||||
await navigate(
|
||||
`/config/devices/dashboard?config_entry=${this.configEntryId}`,
|
||||
`/config/devices/dashboard?config_entry=${this.configEntryId}&status=disabled`,
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
|
||||
+1
-1
@@ -326,7 +326,7 @@ class ZWaveJSConfigDashboard extends LitElement {
|
||||
)}
|
||||
>
|
||||
<ha-list-item-button
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}`}
|
||||
href=${`/config/devices/dashboard?historyBack=1&config_entry=${this.configEntryId}&status=disabled`}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDevices}></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
|
||||
@@ -468,7 +468,7 @@ export class HaConfigLabels extends LitElement {
|
||||
this._navigateConfig("/config/entities");
|
||||
break;
|
||||
case "navigate-devices":
|
||||
this._navigateConfig("/config/devices/dashboard");
|
||||
this._navigateConfig("/config/devices/dashboard", "&status=disabled");
|
||||
break;
|
||||
case "navigate-automations":
|
||||
this._navigateConfig("/config/automation/dashboard");
|
||||
@@ -485,8 +485,10 @@ export class HaConfigLabels extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
private _navigateConfig = (path: string) => {
|
||||
navigate(`${path}?historyBack=1&label=${this._overflowLabel.label_id}`);
|
||||
private _navigateConfig = (path: string, params = "") => {
|
||||
navigate(
|
||||
`${path}?historyBack=1&label=${this._overflowLabel.label_id}${params}`
|
||||
);
|
||||
};
|
||||
|
||||
private _handleColumnsChanged(ev: CustomEvent) {
|
||||
|
||||
@@ -37,6 +37,14 @@ const bluetoothTableParams = {
|
||||
group: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The DHCP, SSDP and Zeroconf discovery tables read these from the link
|
||||
const discoveryTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The Marketplace tabs with a table read these from the link
|
||||
const marketplaceTableParams = {
|
||||
search: "string?",
|
||||
@@ -124,6 +132,21 @@ const tagsTableParams = {
|
||||
direction: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
// The devices table reads these from the link
|
||||
const devicesTableParams = {
|
||||
search: "string?",
|
||||
sort: "string?",
|
||||
direction: "string?",
|
||||
group: "string?",
|
||||
status: "string?",
|
||||
floor: "string?",
|
||||
area: "string?",
|
||||
domain: "string?",
|
||||
label: "string?",
|
||||
config_entry: "string?",
|
||||
sub_entry: "string?",
|
||||
} satisfies Redirect["params"];
|
||||
|
||||
export const getMyRedirects = (): Redirects => ({
|
||||
application_credentials: {
|
||||
redirect: "/config/application_credentials",
|
||||
@@ -294,6 +317,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
config_dhcp: {
|
||||
component: "dhcp",
|
||||
redirect: "/config/dhcp",
|
||||
params: discoveryTableParams,
|
||||
},
|
||||
config_energy: {
|
||||
component: "energy",
|
||||
@@ -317,13 +341,16 @@ export const getMyRedirects = (): Redirects => ({
|
||||
config_ssdp: {
|
||||
component: "ssdp",
|
||||
redirect: "/config/ssdp",
|
||||
params: discoveryTableParams,
|
||||
},
|
||||
config_zeroconf: {
|
||||
component: "zeroconf",
|
||||
redirect: "/config/zeroconf",
|
||||
params: discoveryTableParams,
|
||||
},
|
||||
devices: {
|
||||
redirect: "/config/devices/dashboard",
|
||||
params: devicesTableParams,
|
||||
},
|
||||
entities: {
|
||||
redirect: "/config/entities",
|
||||
|
||||
@@ -1308,7 +1308,9 @@
|
||||
"web-rtc-player": {
|
||||
"not_supported": "WebRTC is not supported in this browser.",
|
||||
"start_failed": "Failed to start the WebRTC stream: {message}",
|
||||
"connect_failed": "Failed to connect the WebRTC stream: {message}"
|
||||
"connect_failed": "Failed to connect the WebRTC stream: {message}",
|
||||
"microphone_failed": "Could not access the microphone: {message}",
|
||||
"microphone_connect_failed": "Could not connect the microphone to the camera: {message}"
|
||||
},
|
||||
"siren": {
|
||||
"more_controls": "More controls",
|
||||
@@ -2086,7 +2088,22 @@
|
||||
},
|
||||
"camera": {
|
||||
"download_snapshot": "Download snapshot",
|
||||
"failed_to_download": "Failed to download snapshot. Please check the logs for more information."
|
||||
"failed_to_download": "Failed to download snapshot. Please check the logs for more information.",
|
||||
"start_talking": "Start talking",
|
||||
"stop_talking": "Stop talking",
|
||||
"microphone_not_secure": "[%key:ui::components::secure_connection_required::microphone%]",
|
||||
"microphone_webrtc_only": "Talking to this camera is only possible when the stream uses WebRTC.",
|
||||
"microphone_denied": "Microphone access was denied. Allow it in your browser settings to talk to this camera.",
|
||||
"app_microphone_error": {
|
||||
"microphone_denied": "Microphone access is needed to talk to the camera. You can allow it in your device settings.",
|
||||
"server_unavailable": "Unable to connect to Home Assistant.",
|
||||
"signaling_failed": "The camera could not start the microphone stream.",
|
||||
"connection_failed": "Unable to connect the microphone to the camera.",
|
||||
"timeout": "The camera took too long to respond.",
|
||||
"interrupted": "The microphone stream was stopped.",
|
||||
"unsupported_stream_type": "This version of the Home Assistant app doesn't support talking to cameras. Update the app and try again.",
|
||||
"unknown": "Home Assistant app error: {message}"
|
||||
}
|
||||
},
|
||||
"timer": {
|
||||
"edit_mode": "Edit presets",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
devicesFilterQueryParamConfig,
|
||||
devicesFilterQueryParams,
|
||||
devicesLinkFromUrl,
|
||||
} from "../../../src/common/url/devices-query-params";
|
||||
import { createQueryString } from "../../../src/common/url/query-params";
|
||||
|
||||
describe("devices query params", () => {
|
||||
it("reads nothing without table params", () => {
|
||||
expect(devicesLinkFromUrl("?historyBack=1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("replaces every filter and the search with what a link asks for", () => {
|
||||
expect(devicesLinkFromUrl("?config_entry=abc&label=a,b")).toEqual({
|
||||
search: "",
|
||||
filters: {
|
||||
"ha-filter-states": { value: [], items: undefined },
|
||||
"ha-filter-floor-areas": { value: undefined, items: undefined },
|
||||
"ha-filter-integrations": { value: [], items: undefined },
|
||||
"ha-filter-labels": { value: ["a", "b"], items: undefined },
|
||||
config_entry: { value: ["abc"], items: undefined },
|
||||
sub_entry: { value: [], items: undefined },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("round trips the filters", () => {
|
||||
const filters = {
|
||||
"ha-filter-states": { value: ["disabled"], items: undefined },
|
||||
"ha-filter-floor-areas": {
|
||||
value: { floors: ["ground"], areas: ["kitchen"] },
|
||||
items: undefined,
|
||||
},
|
||||
"ha-filter-integrations": { value: ["hue"], items: undefined },
|
||||
"ha-filter-labels": { value: ["a"], items: undefined },
|
||||
config_entry: { value: ["abc"], items: undefined },
|
||||
sub_entry: { value: ["def"], items: undefined },
|
||||
};
|
||||
|
||||
expect(
|
||||
devicesLinkFromUrl(
|
||||
createQueryString(
|
||||
devicesFilterQueryParams(filters),
|
||||
devicesFilterQueryParamConfig
|
||||
)
|
||||
)
|
||||
).toEqual({ search: "", filters });
|
||||
});
|
||||
|
||||
it("writes the values, not the items the filters found", () => {
|
||||
expect(
|
||||
devicesFilterQueryParams({
|
||||
"ha-filter-floor-areas": {
|
||||
value: { areas: ["kitchen"] },
|
||||
items: new Set(["device_1"]),
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
status: undefined,
|
||||
floor: undefined,
|
||||
area: ["kitchen"],
|
||||
domain: undefined,
|
||||
label: undefined,
|
||||
config_entry: undefined,
|
||||
sub_entry: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
EMIncomingMessageImprovDeviceSetupDone,
|
||||
EMIncomingMessageBarCodeScanResult,
|
||||
EMIncomingMessageBarCodeScanAborted,
|
||||
EMIncomingMessageWebRtcStreamStopped,
|
||||
} from "../../src/external_app/external_messaging";
|
||||
|
||||
vi.mock("../../src/common/dom/fire_event", () => ({
|
||||
@@ -285,4 +286,30 @@ describe("handleExternalMessage", () => {
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("handles webrtc/stream/stopped command", () => {
|
||||
const msg: EMIncomingMessageWebRtcStreamStopped = {
|
||||
type: "command",
|
||||
command: "webrtc/stream/stopped",
|
||||
id: 13,
|
||||
payload: {
|
||||
session_id: "abc",
|
||||
code: "connection_failed",
|
||||
message: "Connection lost",
|
||||
},
|
||||
};
|
||||
const result = handleExternalMessage(hassMainEl, msg);
|
||||
expect(fireEvent).toHaveBeenCalledWith(window, "webrtc-stream-stopped", {
|
||||
session_id: "abc",
|
||||
code: "connection_failed",
|
||||
message: "Connection lost",
|
||||
});
|
||||
expect(fireMessage).toHaveBeenCalledWith({
|
||||
id: 13,
|
||||
type: "result",
|
||||
success: true,
|
||||
result: null,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user