Compare commits

...
Author SHA1 Message Date
7ed6d18b4d Add two way audio support (frontend) (#54660)
* copy from sdb9696

* fixes

* Also log Event when timer is not running

* Find audio transceiver and request restartIce()

* use CameraFeatures instead of CameraCapabilities

* cleanup

* fixes

* lint fixes

* fix lint

* Update ha-camera-stream.ts

* [prettier] Update ha-web-rtc-player.ts

* Review feedback Snake Case

* Use ha-button instead of mwc-button

* fix import

* oeeps, wrong file, fix

* Add iceGatheringStateChanged

* Split signaling path for offer on renegotiation

* Update src/components/ha-web-rtc-player.ts

Co-authored-by: Bram Kragten <[email protected]>

* Start a fresh WebRTC session when enabling the microphone

Renegotiating the existing peer connection fails with go2rtc on Firefox,
since go2rtc answers the re-offer from a new peer with a different DTLS
fingerprint. Instead, tear down the connection and start a new one with
the microphone track as a sendrecv audio transceiver.

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

* Add media_failed message to WebRTC player translations

* Add download snapshot functionality to camera more info dialog

* Add WebRTC microphone support to camera component

- Implement microphone toggle functionality in ha-camera-stream and ha-web-rtc-player.
- Introduce audio level monitoring using watchAudioLevel.
- Update more-info-camera to manage microphone state and UI interactions.
- Enhance English translations for microphone-related messages.

* Add microphone streaming support for non-secure contexts in apps

* Refactor WebRTC microphone handling and update error messages for clarity

* Add has_two_way_audio attribute to CameraEntity and update feature check in MoreInfoCamera

* Update to allign with backend

* Fix translation key for insecure microphone warning

* Refactor camera capabilities to use 'two_way_audio' instead of 'supports_two_way_audio'

* Enhance microphone error handling and localization for camera interactions

* Refactor webrtcCapabilities to webrtc object for microphone streaming support

* Refactor microphone handling and error reporting in WebRTC player; add new translation key for microphone connection failure

* copilot review

---------

Co-authored-by: karwosts <[email protected]>
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Wendelin <[email protected]>
2026-10-22 21:12:23 +02:00
renovate[bot] 668f17ec34 Update dependency @codemirror/view to v6.43.15 (#55201)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 18:38:13 +00:00
Aidan Timson 951c445cfd Add query params to the DHCP, SSDP and Zeroconf tables (#55195)
* Add query params to the DHCP, SSDP and Zeroconf tables

* Keep the discovery table search in the settings controller
2026-10-22 20:15:34 +02:00
renovate[bot] 6f04cb918e Update dependency typescript-eslint to v8.72.0 (#55199)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 18:11:25 +00:00
Yosi Levy 30cd5383bd Rtl automation trace fix (#54841)
* Adding RTL support for trace splitter + additional fixes

* Revert gitignore

* Update .gitignore

* Fixed comments

* Fix comments

* Removed connectedCallback in ha-split-panel

* Remove wrongfully added style

Remove background property from main class styles.
2026-10-22 20:07:07 +02:00
Aidan Timson 5fe9f97948 Add query params to the hardware table (#55193)
* Add query params to the hardware table

* Keep the hardware table search in the settings controller
2026-10-22 20:00:38 +02:00
Aidan Timson c58e7d684a Add query params to the devices table (#55107)
* Keep devices table search, filters, sorting and grouping in the URL

* Pass devices table params through My links

* Filter devices by sub-entry across every selected config entry

* Use the data table settings controller on the devices page

* Compare device filter lists regardless of order

* Keep fresh filter matches when restoring saved device filters

* Ignore filter changes from a page that was left

* Keep filter items found after leaving the devices page
2026-10-22 19:34:53 +02:00
renovate[bot] f9b65b9c2b Update dependency barcode-detector to v3.2.3 (#55197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-22 19:30:17 +02:00
35 changed files with 1470 additions and 414 deletions

No files matched your search

-1
View File
@@ -67,4 +67,3 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
+4 -4
View File
@@ -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",
+107 -107
View File
@@ -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
+51
View File
@@ -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();
};
};
+114
View File
@@ -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")),
},
};
};
+54 -12
View File
@@ -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;
+226 -13
View File
@@ -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 {
+5
View File
@@ -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
View File
@@ -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);
}
}
`;
}
+82 -11
View File
@@ -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"]
>;
}
}
+59 -3
View File
@@ -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;
}
@@ -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">
@@ -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,
}
@@ -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">
+5 -3
View File
@@ -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) {
+27
View File
@@ -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",
+19 -2
View File
@@ -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);
});
});