Compare commits

...

176 Commits

Author SHA1 Message Date
Wendelin
f3f0dfc62b Update easing animation function 2025-02-14 11:00:08 +01:00
Wendelin
d4125d433f Add switch theme animation 2025-02-13 16:00:35 +01:00
Wendelin
c824e58e0a Show users theme even if browser theme is active 2025-02-13 14:46:38 +01:00
Wendelin
c6617718b7 Fix tests 2025-02-13 11:43:02 +01:00
Wendelin
cd1ca72e45 Fix storeState overrides user theme 2025-02-13 09:51:39 +01:00
Wendelin
4f9ca3b173 Add save theme for user functionality 2025-02-12 15:30:15 +01:00
ildar170975
1349c8520c developer-tools-template: allow "select all" for "rendered" (2) (#24171)
removed a harmful user-select
2025-02-11 13:26:46 +01:00
renovate[bot]
6d1a55cc3a Update vaadinWebComponents monorepo to v24.6.4 (#24153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 09:48:10 +01:00
Wendelin
23a9ae6835 Onboarding restore use error code (#24172)
Use error code for incorrect password
2025-02-11 09:47:18 +01:00
Wendelin
dbd1e928de Make restore button destructive (#24173) 2025-02-11 09:37:56 +01:00
renovate[bot]
e86ad21ce2 Update dependency eslint to v9.20.0 (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-11 09:12:21 +01:00
Bram Kragten
0d97afb3f2 Add base support for sub entries (#23160)
* Add base support for sub entries

* add demo types

* fix translations

* Use sub entry name when deleting

* Update show-dialog-sub-config-flow.ts

* adjust for multiple sub types

* WIP, not functional

* add subentry_type

* rename to supported_subentry_types

* config_subentries -> config_entries_subentries

* Add localized sub flow title

* use Record

* rename

* more rename
2025-02-10 21:24:05 +01:00
Wendelin
03a415beff Onboarding restore use core api (#23920)
* Fix type issues

* Extract backup-upload

* Add onboarding upload section

* Extract and use ha-backup-details

* Implement backup details and restore

* remove unused hassio onboarding calls

* Require hass in dialog-hassio-backup

* Add restore view

* Add formatDateTime without locale and config

* Add restore status

* Fix prettier

* Fix styles of backup details

* Remove unused localize

* Fix onboarding restore translations

* Hide data-picker on core only instance

* Split uploadBackup into 2 separate funcs

* Add formatDateTimeWithBrowserDefaults

* Fix selected data for core only

* Show error reasons on status page

* Use new backup info agents

* Add mem function for formatDateTimeWithBrowserDefaults

* Fix overflow on mobile

* Handle errors when in hassio mode

* Fix cancel restore texts

* Fix hassio localize type issue

* Remove unused onboarding localize in hassio backup restore

* improve format_date_time

* Fix tests

* Fix and simplify backup upload issues

* Use foreach instead of reduce

* Fix attributes, unused styles and properties

* Simplify supervisor warning

* Fix language type issues

* Fix ha-backup-data-picker

* Improve backup-details-restore

* Fix onboarding-restore issues

* Improve loadBackupInfo

* Revert uploadBackup changes

* Improve cancel restore

* Use destructive

* Update src/panels/config/backup/dialogs/dialog-upload-backup.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Show backup type not at onboarding

* Only show backup type in correct translationPanel

* Fix quotes

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-02-10 16:40:08 +01:00
Paulus Schoutsen
44cc75afbc Require opt-in config flow navigateToResult (#24120) 2025-02-10 15:58:10 +01:00
Petar Petrov
748642a8d6 Limit max label width of hui-energy-devices-graph-card (#24152) 2025-02-10 15:54:20 +01:00
Petar Petrov
3d5c65d652 Bring back energy usage graph order (#24156) 2025-02-10 15:53:36 +01:00
Paul Bottein
a26bf80b13 Fix section border radius (#24159) 2025-02-10 15:53:05 +01:00
Petar Petrov
497c6c35f1 Fix device energy card with max_devices (#24150) 2025-02-10 10:12:42 +01:00
Petar Petrov
b0b06a2787 Round log scale limits (#24151) 2025-02-10 10:12:12 +01:00
Paulus Schoutsen
f3d55447ca Add support for intent-progress assist events (#24143) 2025-02-09 23:36:56 -05:00
renovate[bot]
1b3d4b77d3 Update dependency ua-parser-js to v2.0.1 (#24125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-08 21:14:11 +02:00
Paulus Schoutsen
6ec4041c4c Navigate to newly created config entry (#24109) 2025-02-07 08:32:21 +02:00
karwosts
d919e8d333 Integrate Statistic Card with Energy Date Picker (#23794)
* Support energy-date-selection for statistic card

* reuse period key
2025-02-07 08:29:28 +02:00
karwosts
af7bb85667 Stack solar forecasts (#24113) 2025-02-07 06:44:39 +01:00
renovate[bot]
9061e2039b Update dependency @vitest/coverage-v8 to v3.0.5 (#24106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-06 19:40:41 +01:00
renovate[bot]
906e6f4a88 Update dependency @codemirror/state to v6.5.2 (#24105)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-06 19:26:48 +01:00
renovate[bot]
73fbe9a69d Update typescript-eslint monorepo to v8.23.0 (#24108)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-06 19:26:16 +01:00
Petar Petrov
2a0f69a629 Fix energy dashboard data formatting (#24101) 2025-02-06 12:38:22 +01:00
Petar Petrov
9411a77f14 Set charts font to Roboto (#24097) 2025-02-06 13:16:35 +02:00
Petar Petrov
de3bf2e088 Show energy-self-sufficiency-gauge card without grid return (#24098)
Show energy-self-sufficiency-gauge card if solar is defined
2025-02-06 10:07:06 +00:00
Brynley McDonald
16181b48ae Add Mastodon and Bluesky to help tip (#24099)
Add Mastodon and Bluesky to socials tip
2025-02-06 10:59:58 +01:00
Curt Grimes
8682debe61 Fix punctuation in some toast and warning messages (#24093)
There are some toast messages that are comma splices (two independent
clauses joined by a comma), which is not correct grammar. This commit
fixes the punctuation in these messages.
2025-02-06 09:37:54 +01:00
Petar Petrov
bdbc9bc1b4 Reduce padding in energy charts and align unit (#24095) 2025-02-06 09:36:58 +01:00
renovate[bot]
79b9f8d083 Update dependency barcode-detector to v3 (#24015)
* Update dependency barcode-detector to v3

* fix breaking changes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-02-06 07:53:26 +00:00
Wendelin
3918194d2d Add network adapter translations (#24096) 2025-02-06 09:49:05 +02:00
Charles Garwood
e9fef1f873 Update more-info dialog layout for weather entities (#22818)
* Update weather more-info style

* Cleanup unused var

* Use badges for attributes

* Remove unnecessary flex class

* CSS cleanup

* Wrap badges

* Revert "Cleanup unused var"

This reverts commit 89ab0f6ad05e1e669b84e69f4c263e3d302794f2.

* Revert badges for attributes

* Scroll long forecasts

* Use nothing instead of empty strings

* Cleanup
2025-02-06 07:11:30 +01:00
Jan-Philipp Benecke
35face602b Fix area registry dialog field (#24090) 2025-02-05 18:33:00 +01:00
Bram Kragten
803ac496f6 Merge branch 'rc' into dev 2025-02-05 16:32:25 +01:00
Bram Kragten
f1173dd84b Bumped version to 20250205.0 2025-02-05 16:27:17 +01:00
Petar Petrov
44dcca9923 Fix chart height (#24028) 2025-02-05 16:25:44 +01:00
Bram Kragten
bd74d39dd8 Use max of width and actualBoundingBox to get text width (#24085) 2025-02-05 16:20:22 +01:00
Petar Petrov
172d6c3079 Disable chart update animation (#24084) 2025-02-05 16:20:21 +01:00
Bram Kragten
56539e8065 Charts: set tooltip triggerOn to click on mobile (#24083)
set tooltip triggerOn to click on mobile
2025-02-05 16:20:20 +01:00
Bram Kragten
8f6867f142 Chart: Add tooltip styling to theme (#24082) 2025-02-05 16:20:19 +01:00
Bram Kragten
d51f8995dd Charts: add styles for legend page controls (#24081) 2025-02-05 16:20:19 +01:00
Petar Petrov
f2e35dc70a Fix chart preview (#24080)
* Fix chart preview

* Revert change to timeline-chart labels
2025-02-05 16:20:18 +01:00
Petar Petrov
6487b9b7ea Fix device energy bar chart (#24079) 2025-02-05 16:20:17 +01:00
Bram Kragten
e50b658db7 Set min height for graphs, adjust margins (#24078)
* Set min height for graphs, adjust margins

* stats + header adjustments

* set min to 200
2025-02-05 16:19:29 +01:00
Bram Kragten
6efe237639 Fix label truncated timeline chart (#24077) 2025-02-05 16:15:00 +01:00
Bram Kragten
4a94cfc05b Set list color of update more info to dialog background (#24076) 2025-02-05 16:15:00 +01:00
Bram Kragten
7cbdb1dcfd Fix condition in tracing graph (#24075) 2025-02-05 16:14:59 +01:00
Paul Bottein
553bb61db7 Fix statistic chart tooltip values (#24074) 2025-02-05 16:14:58 +01:00
Petar Petrov
786ff787d1 Fix spacing & colors in statistics-graph chart (#24068)
* Fix statistic chart colors

* Fix spacing in statistics-graph

* set start time based on data
2025-02-05 16:14:57 +01:00
Bram Kragten
28b3f2970a Use max of width and actualBoundingBox to get text width (#24085) 2025-02-05 17:12:48 +02:00
Petar Petrov
7d170a710e Disable chart update animation (#24084) 2025-02-05 15:04:03 +00:00
Bram Kragten
cc40b50675 Charts: set tooltip triggerOn to click on mobile (#24083)
set tooltip triggerOn to click on mobile
2025-02-05 15:02:43 +00:00
Bram Kragten
b6eaff46e9 Chart: Add tooltip styling to theme (#24082) 2025-02-05 16:37:13 +02:00
Bram Kragten
674bb0d16a Set min height for graphs, adjust margins (#24078)
* Set min height for graphs, adjust margins

* stats + header adjustments

* set min to 200
2025-02-05 15:16:33 +01:00
Petar Petrov
6ff018afc9 Fix chart preview (#24080)
* Fix chart preview

* Revert change to timeline-chart labels
2025-02-05 15:16:16 +01:00
Bram Kragten
ad48732bb7 Charts: add styles for legend page controls (#24081) 2025-02-05 16:04:14 +02:00
Bram Kragten
fef162346a Set list color of update more info to dialog background (#24076) 2025-02-05 12:48:13 +01:00
Bram Kragten
72d208d1ac Fix label truncated timeline chart (#24077) 2025-02-05 12:47:39 +01:00
Petar Petrov
5a8b1b0fd4 Fix device energy bar chart (#24079) 2025-02-05 12:47:21 +01:00
Bram Kragten
4cfc651799 Fix condition in tracing graph (#24075) 2025-02-05 10:04:39 +00:00
Petar Petrov
b4a3f4cb2c Fix spacing & colors in statistics-graph chart (#24068)
* Fix statistic chart colors

* Fix spacing in statistics-graph

* set start time based on data
2025-02-05 11:22:31 +02:00
Paul Bottein
f0507a88a6 Fix statistic chart tooltip values (#24074) 2025-02-05 11:18:29 +02:00
renovate[bot]
fe041e442d Update dependency vitest to v3.0.5 [SECURITY] (#24066)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-04 19:28:01 +01:00
Bram Kragten
e5fea98460 Bumped version to 20250204.0 2025-02-04 18:22:43 +01:00
Petar Petrov
31180e3a9e Fix energy charts with leap years (#24059)
* Fix energy charts with leap years

* handle quarters
2025-02-04 18:21:27 +01:00
Paul Bottein
ce0f02a45b Display unavailable backups locations (#24058)
Display anavailable backups locations
2025-02-04 18:21:27 +01:00
Paul Bottein
53f090356e Improve value formatting inside backup tooltip (#24057) 2025-02-04 18:21:26 +01:00
Bram Kragten
776c4da688 Add support package download to cloud (#24051) 2025-02-04 18:21:25 +01:00
Bram Kragten
849922f7be Dont show voice wizard for voip (#24050)
dont show voice wizard for voip
2025-02-04 18:21:24 +01:00
Paul Bottein
a26701808f Add support for add-on update type for backups in the UI (#24044)
* Add support for add-on update type for backups in the UI

* Add type to backup detail page

* Use new model

* Fix detail page

* Fix type
2025-02-04 18:21:23 +01:00
Bram Kragten
904ee2e418 Add support package download to cloud (#24051) 2025-02-04 18:06:41 +01:00
Paul Bottein
11ae3a77e8 Add support for add-on update type for backups in the UI (#24044)
* Add support for add-on update type for backups in the UI

* Add type to backup detail page

* Use new model

* Fix detail page

* Fix type
2025-02-04 16:04:11 +01:00
Paul Bottein
3a12019b64 Display unavailable backups locations (#24058)
Display anavailable backups locations
2025-02-04 14:45:38 +01:00
Bram Kragten
6c2cf1ff60 Dont show voice wizard for voip (#24050)
dont show voice wizard for voip
2025-02-04 14:38:03 +02:00
Petar Petrov
02ae0b5864 Fix energy charts with leap years (#24059)
* Fix energy charts with leap years

* handle quarters
2025-02-04 12:28:31 +00:00
Jan-Philipp Benecke
85fe2213c1 Align view mount dialog with design guidelines (#24060)
Align view mount dialog with design requirements
2025-02-04 13:25:59 +01:00
Paul Bottein
7dbc78f1d6 Improve value formatting inside backup tooltip (#24057) 2025-02-04 13:47:24 +02:00
Ingolf Becker
f965a3504f Improve weather forecast card layout (#23704)
* Improve weather forecast card layout

* fix units and improve naming

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-02-04 06:32:30 +00:00
karwosts
077f5efe7e Fix menus in Todo list for Keyboard (#24054) 2025-02-04 08:30:45 +02:00
Bram Kragten
ef3bea71a0 Bumped version to 20250203.0 2025-02-03 18:20:20 +01:00
Petar Petrov
fcf655b0ec FIx console errors in charts (#24048)
* FIx console errors in charts

* handle undefined unit_of_measurement
2025-02-03 18:20:17 +01:00
Paul Bottein
b263b74916 Increase margin to avoid fab overlap on backup overview page (#24047) 2025-02-03 18:20:16 +01:00
Paul Bottein
0f4b6b423a Improve chart height and narrow option in grid section (#24046)
* Fix chart size in grid

* Set minimal height to 2 for history chart

* Update history chart
2025-02-03 18:20:15 +01:00
Paul Bottein
72df585c5e Fix download unencrypted backup logic (#24045) 2025-02-03 18:20:15 +01:00
Petar Petrov
4698a63642 Show seconds on x axis when chart is zoomed a lot (#24043)
Show seconds on x axis when charts is zoomed a lot
2025-02-03 18:20:14 +01:00
Petar Petrov
6eb43a7d61 Workaround for chart size bug in editor preview (#24040) 2025-02-03 18:20:13 +01:00
Petar Petrov
af35b15400 Fix click action for timeline chart labels (#24039)
* Fix click action for timeline chart labels

* Use id in line charts
2025-02-03 18:20:12 +01:00
Petar Petrov
0d50d2664f Fix legend in charts (#24025)
* Fix legend in line charts

* fix statistics graph legend
2025-02-03 18:20:11 +01:00
Philipp
ff1159402e Fix browser media player showing more info dialog (#24021) 2025-02-03 18:20:11 +01:00
Jan-Philipp Benecke
f8742ae690 Display year conditionally when script was last triggered on script list (#24012)
Display year conditionally when script was last triggered in script list
2025-02-03 18:20:10 +01:00
ildar170975
c786d26542 Fix for "Increase generic entity row touch target (3): climate entities (#24002)
return to max-height + set vertical alignment
2025-02-03 18:20:09 +01:00
Bram Kragten
3f8ff94002 Make date period picker respect timezone settings (#23996) 2025-02-03 18:20:08 +01:00
Petar Petrov
64a968543b FIx console errors in charts (#24048)
* FIx console errors in charts

* handle undefined unit_of_measurement
2025-02-03 18:13:35 +01:00
karwosts
aea98f702b Prioritize local image over entity_picture in picture-entity card (#24032)
* Prioritize local image over entity_picture in picture-entity

* Remove the default stub image if we switch to an entity with a picture

* minor cleanup
2025-02-03 16:01:16 +01:00
Paul Bottein
863ff622be Increase margin to avoid fab overlap on backup overview page (#24047) 2025-02-03 16:00:08 +01:00
Petar Petrov
730cea6646 Workaround for chart size bug in editor preview (#24040) 2025-02-03 15:59:43 +01:00
karwosts
7d1f8d618a Fix more keyboard menus (devices/helpers/scenes/scripts) (#24037)
* Fix more keyboard menus (devices/helpers/scenes/scripts)

* Apply suggestions from code review

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* less async

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-02-03 16:13:20 +02:00
Paul Bottein
67b970fcaa Improve chart height and narrow option in grid section (#24046)
* Fix chart size in grid

* Set minimal height to 2 for history chart

* Update history chart
2025-02-03 14:12:28 +00:00
Paul Bottein
38bcdaa6f6 Revert "Fix chart size in grid"
This reverts commit 8f1389de66.
2025-02-03 14:55:17 +01:00
Paul Bottein
8f1389de66 Fix chart size in grid 2025-02-03 14:51:19 +01:00
Paul Bottein
37ac796c8f Fix download unencrypted backup logic (#24045) 2025-02-03 14:40:49 +01:00
Petar Petrov
716cd19d41 Show seconds on x axis when chart is zoomed a lot (#24043)
Show seconds on x axis when charts is zoomed a lot
2025-02-03 13:27:33 +01:00
Paul Bottein
173725f011 Use ignoreDiacritics in fuse library (#24041) 2025-02-03 12:53:34 +01:00
Petar Petrov
ad561b885b Fix click action for timeline chart labels (#24039)
* Fix click action for timeline chart labels

* Use id in line charts
2025-02-03 10:36:59 +00:00
Philipp
d77bdf4ac6 Fix browser media player showing more info dialog (#24021) 2025-02-03 11:36:19 +01:00
Petar Petrov
ac3796ec31 Fix chart height (#24028) 2025-02-03 11:21:30 +01:00
Petar Petrov
8c3fdfb6fb Fix legend in charts (#24025)
* Fix legend in line charts

* fix statistics graph legend
2025-02-03 11:09:59 +01:00
karwosts
b7c7d0b4b5 Scroll todo list if it overflows grid_layout (#24000) 2025-02-03 09:09:01 +02:00
ildar170975
8b0e6eed3a Fix for "Increase generic entity row touch target (3): climate entities (#24002)
return to max-height + set vertical alignment
2025-02-03 09:07:11 +02:00
renovate[bot]
603f884e8c Update dependency @types/chromecast-caf-receiver to v6.0.21 (#24038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-03 08:09:55 +02:00
Bram Kragten
97dfccf4c7 Make date period picker respect timezone settings (#23996) 2025-02-01 17:32:07 +01:00
Jan-Philipp Benecke
fd1e31c0cc Display year conditionally when script was last triggered on script list (#24012)
Display year conditionally when script was last triggered in script list
2025-02-01 17:16:43 +01:00
Bram Kragten
1de740e7b5 Bumped version to 20250131.0 2025-01-31 18:20:12 +01:00
Bram Kragten
5abfb90b16 fix time input width (#23998) 2025-01-31 18:19:45 +01:00
Petar Petrov
6b691063a8 Hide "heating" data from climate charts (#23997) 2025-01-31 18:19:44 +01:00
Paul Bottein
d1d746e7e6 Remove name from the chart series when using showNames = false (#23995)
* Remove name from the chart series when using showNames = false

* Remove translations
2025-01-31 18:19:43 +01:00
Petar Petrov
2fcb64d4a1 Echarts: auto scale Y in log charts (#23994)
* Echarts: auto scale Y in log charts

* fix statistics chart log scale
2025-01-31 18:19:42 +01:00
Petar Petrov
3769f8c7c0 Hide irrelevant errors from echarts zoom (#23992) 2025-01-31 18:19:41 +01:00
Paul Bottein
f0a56e75f5 Improve encrypted backup dialog (#23991)
* Improve encrypted backup dialog

* Remove unused code
2025-01-31 18:19:40 +01:00
Petar Petrov
15f33e1f19 Echarts: show all series in tooltip (#23989)
* Echarts: show all series in tooltip

* fix typo

* remove duplicate tooltip entries in statistics chart

* take last valid point instead of first
2025-01-31 18:19:40 +01:00
Petar Petrov
181122177b Echarts: fix Y scaling (#23988)
* Echarts: fix scaling of Y axis

* fix fit logic to only extend the limits

* handle invalid min for log scale
2025-01-31 18:19:39 +01:00
Petar Petrov
684cd0f627 Fix legend resetting on zoom (#23985) 2025-01-31 18:19:38 +01:00
Paul Bottein
277202e363 Use smooth line for statistic line chart (#23984)
* Use smooth line for statistic line chart

* Use same smooth options as chartjs
2025-01-31 18:19:37 +01:00
Petar Petrov
b388d1fd42 Fix statistics echarts with negative values (#23983)
* Fix statistics echarts with negative values

* fix border-radius of negative bar values

* revert timeline label width to previous max values
2025-01-31 18:19:37 +01:00
Paul Bottein
251e6399f5 Reduce chart height to 300px (#23979) 2025-01-31 18:19:36 +01:00
karwosts
f44c5d7a63 Improve statistics graph axis when using energy_date_selection (#23974) 2025-01-31 18:19:35 +01:00
ildar170975
cae1ca52f0 Fix for "Increase generic entity row touch target (2) (#23973)
* Revert "Fix for "Increase generic entity row touch target" (#23953)"

This reverts commit 028472fc7b.

* conditional style
2025-01-31 18:19:34 +01:00
Petar Petrov
f8de2c64a5 Hide "heating" data from climate charts (#23997) 2025-01-31 17:13:13 +00:00
Bram Kragten
34ef5be720 fix time input width (#23998) 2025-01-31 17:12:49 +00:00
Petar Petrov
1402802031 Echarts: auto scale Y in log charts (#23994)
* Echarts: auto scale Y in log charts

* fix statistics chart log scale
2025-01-31 17:42:51 +01:00
Paul Bottein
816989ab4d Remove name from the chart series when using showNames = false (#23995)
* Remove name from the chart series when using showNames = false

* Remove translations
2025-01-31 17:38:50 +01:00
Paul Bottein
d4497ca39c Improve encrypted backup dialog (#23991)
* Improve encrypted backup dialog

* Remove unused code
2025-01-31 16:10:43 +00:00
Petar Petrov
6e39242ca3 Echarts: fix Y scaling (#23988)
* Echarts: fix scaling of Y axis

* fix fit logic to only extend the limits

* handle invalid min for log scale
2025-01-31 15:44:22 +01:00
Petar Petrov
0197e32783 Echarts: show all series in tooltip (#23989)
* Echarts: show all series in tooltip

* fix typo

* remove duplicate tooltip entries in statistics chart

* take last valid point instead of first
2025-01-31 15:42:46 +01:00
Petar Petrov
87dfed4beb Hide irrelevant errors from echarts zoom (#23992) 2025-01-31 15:37:47 +01:00
Wendelin
dae991dc89 Improve develop and serve (#23990) 2025-01-31 15:50:15 +02:00
Paul Bottein
6197e3483b Use smooth line for statistic line chart (#23984)
* Use smooth line for statistic line chart

* Use same smooth options as chartjs
2025-01-31 11:46:01 +01:00
Petar Petrov
b2a6c8bd36 Fix legend resetting on zoom (#23985) 2025-01-31 11:13:46 +01:00
Petar Petrov
938855e13c Fix statistics echarts with negative values (#23983)
* Fix statistics echarts with negative values

* fix border-radius of negative bar values

* revert timeline label width to previous max values
2025-01-31 12:02:39 +02:00
renovate[bot]
a8712e3b8e Update vaadinWebComponents monorepo to v24.6.3 (#23981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-31 10:31:11 +01:00
Paul Bottein
b15b577057 Reduce chart height to 300px (#23979) 2025-01-31 10:42:51 +02:00
karwosts
653aeae3d8 Improve statistics graph axis when using energy_date_selection (#23974) 2025-01-31 08:45:37 +02:00
ildar170975
0aea6141ad Fix for "Increase generic entity row touch target (2) (#23973)
* Revert "Fix for "Increase generic entity row touch target" (#23953)"

This reverts commit 028472fc7b.

* conditional style
2025-01-31 08:41:38 +02:00
renovate[bot]
5243c1d871 Update typescript-eslint monorepo to v8.22.0 (#23972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-31 08:39:18 +02:00
Bram Kragten
9449f5ad0a Bumped version to 20250130.0 2025-01-30 18:08:13 +01:00
Paul Bottein
c337bc5f97 Improve backup settings display on mobile (#23967) 2025-01-30 18:07:52 +01:00
Petar Petrov
6aab60cf45 Dynamically reorder energy devices (echarts) (#23966)
* Dynamically reorder energy devices (echarts)

* fix initial sorting in hui-energy-devices-detail-graph-card

* fix dynamic reordering in devices detail
2025-01-30 18:07:51 +01:00
Paul Bottein
52e9bc3213 Fix backup location config not updated (#23965) 2025-01-30 18:07:50 +01:00
Paul Bottein
e48b2383cf Fix location icon when many locations in backup datatable (#23964)
* Fix location icon when many locations in backup datatable

* Reuse data

* Don't copy twice

* Improve naming
2025-01-30 18:07:50 +01:00
Petar Petrov
002a249777 Use CSS variables to theme echarts (#23963)
* Use CSS variables to theme echarts

* fix styles
2025-01-30 18:07:49 +01:00
Paul Bottein
10498ce18d Display device name in bluetooth panel (#23960) 2025-01-30 18:07:48 +01:00
Wendelin
6a5936b2b2 Add correct link to backup.create_automatic (#23959) 2025-01-30 18:07:47 +01:00
Norbert Rittel
dc68aaa803 Add localizable "Actions" label to OAuth credentials picker (#23958)
* Add localizable "Actions" label to OAuth credentials picker

* Prettier
2025-01-30 18:07:46 +01:00
Paul Bottein
e7931ce049 Restore scroll position go back to backup settings page (#23955) 2025-01-30 18:07:46 +01:00
ildar170975
59b2582fe3 Fix for "Increase generic entity row touch target" (#23953)
fix for "touch target"
2025-01-30 18:07:45 +01:00
karwosts
8577b0721c Fix untracked energy in compare (#23949) 2025-01-30 18:07:44 +01:00
J. Nick Koston
91319be855 Reduce size of address column on Bluetooth Advertisement monitor (#23942) 2025-01-30 18:07:43 +01:00
Simon Lamon
0dff538298 Backup location translations improvements (#23940)
* Backup location translations improvements

* Apply better translations
2025-01-30 18:07:42 +01:00
Simon Lamon
6ac6d9c6eb Backup location translations improvements (#23940)
* Backup location translations improvements

* Apply better translations
2025-01-30 18:06:57 +01:00
Norbert Rittel
6ba0071296 Add localizable "Actions" label to OAuth credentials picker (#23958)
* Add localizable "Actions" label to OAuth credentials picker

* Prettier
2025-01-30 18:05:18 +01:00
Paul Bottein
fef5dc4232 Fix location icon when many locations in backup datatable (#23964)
* Fix location icon when many locations in backup datatable

* Reuse data

* Don't copy twice

* Improve naming
2025-01-30 17:02:56 +00:00
Paul Bottein
ce58962dbb Fix backup location config not updated (#23965) 2025-01-30 17:43:39 +01:00
Petar Petrov
9fb1e1d2ed Dynamically reorder energy devices (echarts) (#23966)
* Dynamically reorder energy devices (echarts)

* fix initial sorting in hui-energy-devices-detail-graph-card

* fix dynamic reordering in devices detail
2025-01-30 17:43:06 +01:00
Paul Bottein
a29544c1e6 Improve backup settings display on mobile (#23967) 2025-01-30 17:49:05 +02:00
Petar Petrov
b2b71edd04 Use CSS variables to theme echarts (#23963)
* Use CSS variables to theme echarts

* fix styles
2025-01-30 14:39:59 +01:00
ildar170975
028472fc7b Fix for "Increase generic entity row touch target" (#23953)
fix for "touch target"
2025-01-30 13:26:11 +01:00
Paul Bottein
b056ce228b Display device name in bluetooth panel (#23960) 2025-01-30 12:36:10 +01:00
Wendelin
0cd4256c0e Add correct link to backup.create_automatic (#23959) 2025-01-30 11:05:33 +00:00
Yosi Levy
e274c5b23f Add node memory to allow commit (#23954) 2025-01-30 11:06:50 +02:00
karwosts
ea57846465 Fix untracked energy in compare (#23949) 2025-01-30 09:57:54 +01:00
Paul Bottein
3f2e2bc659 Restore scroll position go back to backup settings page (#23955) 2025-01-30 09:56:52 +01:00
J. Nick Koston
e3f2f66206 Reduce size of address column on Bluetooth Advertisement monitor (#23942) 2025-01-29 19:01:47 +00:00
148 changed files with 6333 additions and 2259 deletions

View File

@@ -11,6 +11,9 @@
"DEV_CONTAINER": "1", "DEV_CONTAINER": "1",
"WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}" "WORKSPACE_DIRECTORY": "${containerWorkspaceFolder}"
}, },
"remoteEnv": {
"NODE_OPTIONS": "--max_old_space_size=8192"
},
"customizations": { "customizations": {
"vscode": { "vscode": {
"extensions": [ "extensions": [

View File

@@ -65,6 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockEntityRegistry(hass, [ mockEntityRegistry(hass, [
{ {
config_entry_id: "co2signal", config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal", device_id: "co2signal",
area_id: null, area_id: null,
disabled_by: null, disabled_by: null,
@@ -85,6 +86,7 @@ export class HaDemo extends HomeAssistantAppEl {
}, },
{ {
config_entry_id: "co2signal", config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal", device_id: "co2signal",
area_id: null, area_id: null,
disabled_by: null, disabled_by: null,

View File

@@ -11,6 +11,7 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_remove_device: false, supports_remove_device: false,
supports_unload: true, supports_unload: true,
supports_reconfigure: true, supports_reconfigure: true,
supported_subentry_types: {},
pref_disable_new_entities: false, pref_disable_new_entities: false,
pref_disable_polling: false, pref_disable_polling: false,
disabled_by: null, disabled_by: null,

View File

@@ -48,6 +48,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom", area_id: "bedroom",
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_1"], config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,
@@ -71,6 +72,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard", area_id: "backyard",
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_2"], config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,
@@ -94,6 +96,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null, area_id: null,
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_3"], config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,

View File

@@ -47,6 +47,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom", area_id: "bedroom",
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_1"], config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,
@@ -70,6 +71,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard", area_id: "backyard",
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_2"], config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,
@@ -93,6 +95,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null, area_id: null,
configuration_url: null, configuration_url: null,
config_entries: ["config_entry_3"], config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [], connections: [],
disabled_by: null, disabled_by: null,
entry_type: null, entry_type: null,

View File

@@ -32,6 +32,8 @@ const createConfigEntry = (
supports_remove_device: false, supports_remove_device: false,
supports_unload: true, supports_unload: true,
supports_reconfigure: true, supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
disabled_by: null, disabled_by: null,
pref_disable_new_entities: false, pref_disable_new_entities: false,
pref_disable_polling: false, pref_disable_polling: false,
@@ -188,6 +190,7 @@ const createEntityRegistryEntries = (
): EntityRegistryEntry[] => [ ): EntityRegistryEntry[] => [
{ {
config_entry_id: item.entry_id, config_entry_id: item.entry_id,
config_subentry_id: null,
device_id: "mock-device-id", device_id: "mock-device-id",
area_id: null, area_id: null,
disabled_by: null, disabled_by: null,
@@ -214,6 +217,7 @@ const createDeviceRegistryEntries = (
{ {
entry_type: null, entry_type: null,
config_entries: [item.entry_id], config_entries: [item.entry_id],
config_entries_subentries: {},
connections: [], connections: [],
manufacturer: "ESPHome", manufacturer: "ESPHome",
model: "Mock Device", model: "Mock Device",

View File

@@ -1,8 +1,6 @@
import type { IFuseOptions } from "fuse.js"; import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { stripDiacritics } from "../../../src/common/string/strip-diacritics";
import type { StoreAddon } from "../../../src/data/supervisor/store"; import type { StoreAddon } from "../../../src/data/supervisor/store";
import { getStripDiacriticsFn } from "../../../src/util/fuse";
export function filterAndSort(addons: StoreAddon[], filter: string) { export function filterAndSort(addons: StoreAddon[], filter: string) {
const options: IFuseOptions<StoreAddon> = { const options: IFuseOptions<StoreAddon> = {
@@ -10,8 +8,8 @@ export function filterAndSort(addons: StoreAddon[], filter: string) {
isCaseSensitive: false, isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2), minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2, threshold: 0.2,
getFn: getStripDiacriticsFn, ignoreDiacritics: true,
}; };
const fuse = new Fuse(addons, options); const fuse = new Fuse(addons, options);
return fuse.search(stripDiacritics(filter)).map((result) => result.item); return fuse.search(filter).map((result) => result.item);
} }

View File

@@ -14,7 +14,7 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"backup-uploaded": { backup: HassioBackup }; "hassio-backup-uploaded": { backup: HassioBackup };
"backup-cleared": undefined; "backup-cleared": undefined;
} }
} }
@@ -70,7 +70,7 @@ export class HassioUploadBackup extends LitElement {
this._uploading = true; this._uploading = true;
try { try {
const backup = await uploadBackup(this.hass, file); const backup = await uploadBackup(this.hass, file);
fireEvent(this, "backup-uploaded", { backup: backup.data }); fireEvent(this, "hassio-backup-uploaded", { backup: backup.data });
} catch (err: any) { } catch (err: any) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Upload failed", title: "Upload failed",

View File

@@ -5,7 +5,6 @@ import { customElement, property, query } from "lit/decorators";
import { atLeastVersion } from "../../../src/common/config/version"; import { atLeastVersion } from "../../../src/common/config/version";
import { formatDate } from "../../../src/common/datetime/format_date"; import { formatDate } from "../../../src/common/datetime/format_date";
import { formatDateTime } from "../../../src/common/datetime/format_date_time"; import { formatDateTime } from "../../../src/common/datetime/format_date_time";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import "../../../src/components/ha-checkbox"; import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-formfield"; import "../../../src/components/ha-formfield";
import "../../../src/components/ha-textfield"; import "../../../src/components/ha-textfield";
@@ -19,13 +18,10 @@ import type {
} from "../../../src/data/hassio/backup"; } from "../../../src/data/hassio/backup";
import type { Supervisor } from "../../../src/data/supervisor/supervisor"; import type { Supervisor } from "../../../src/data/supervisor/supervisor";
import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg"; import { mdiHomeAssistant } from "../../../src/resources/home-assistant-logo-svg";
import type { HomeAssistant, TranslationDict } from "../../../src/types"; import type { HomeAssistant } from "../../../src/types";
import "./supervisor-formfield-label"; import "./supervisor-formfield-label";
import type { HaTextField } from "../../../src/components/ha-textfield"; import type { HaTextField } from "../../../src/components/ha-textfield";
type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];
interface CheckboxItem { interface CheckboxItem {
slug: string; slug: string;
checked: boolean; checked: boolean;
@@ -67,8 +63,6 @@ const _computeAddons = (addons): AddonCheckboxItem[] =>
export class SupervisorBackupContent extends LitElement { export class SupervisorBackupContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ attribute: false }) public supervisor?: Supervisor; @property({ attribute: false }) public supervisor?: Supervisor;
@property({ attribute: false }) public backup?: HassioBackupDetail; @property({ attribute: false }) public backup?: HassioBackupDetail;
@@ -115,10 +109,6 @@ export class SupervisorBackupContent extends LitElement {
this._focusTarget?.focus(); this._focusTarget?.focus();
} }
private _localize = (key: BackupOrRestoreKey) =>
this.supervisor?.localize(`backup.${key}`) ||
this.localize!(`ui.panel.page-onboarding.restore.${key}`);
protected render() { protected render() {
if (!this.onboarding && !this.supervisor) { if (!this.onboarding && !this.supervisor) {
return nothing; return nothing;
@@ -132,8 +122,8 @@ export class SupervisorBackupContent extends LitElement {
${this.backup ${this.backup
? html`<div class="details"> ? html`<div class="details">
${this.backup.type === "full" ${this.backup.type === "full"
? this._localize("full_backup") ? this.supervisor?.localize("backup.full_backup")
: this._localize("partial_backup")} : this.supervisor?.localize("backup.partial_backup")}
(${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br /> (${Math.ceil(this.backup.size * 10) / 10 + " MB"})<br />
${this.hass ${this.hass
? formatDateTime( ? formatDateTime(
@@ -145,7 +135,7 @@ export class SupervisorBackupContent extends LitElement {
</div>` </div>`
: html`<ha-textfield : html`<ha-textfield
name="backupName" name="backupName"
.label=${this._localize("name")} .label=${this.supervisor?.localize("backup.name")}
.value=${this.backupName} .value=${this.backupName}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
> >
@@ -153,11 +143,13 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup || this.backup.type === "full" ${!this.backup || this.backup.type === "full"
? html`<div class="sub-header"> ? html`<div class="sub-header">
${!this.backup ${!this.backup
? this._localize("type") ? this.supervisor?.localize("backup.type")
: this._localize("select_type")} : this.supervisor?.localize("backup.select_type")}
</div> </div>
<div class="backup-types"> <div class="backup-types">
<ha-formfield .label=${this._localize("full_backup")}> <ha-formfield
.label=${this.supervisor?.localize("backup.full_backup")}
>
<ha-radio <ha-radio
@change=${this._handleRadioValueChanged} @change=${this._handleRadioValueChanged}
value="full" value="full"
@@ -166,7 +158,9 @@ export class SupervisorBackupContent extends LitElement {
> >
</ha-radio> </ha-radio>
</ha-formfield> </ha-formfield>
<ha-formfield .label=${this._localize("partial_backup")}> <ha-formfield
.label=${this.supervisor?.localize("backup.partial_backup")}
>
<ha-radio <ha-radio
@change=${this._handleRadioValueChanged} @change=${this._handleRadioValueChanged}
value="partial" value="partial"
@@ -202,7 +196,7 @@ export class SupervisorBackupContent extends LitElement {
? html` ? html`
<ha-formfield <ha-formfield
.label=${html`<supervisor-formfield-label .label=${html`<supervisor-formfield-label
.label=${this._localize("folders")} .label=${this.supervisor?.localize("backup.folders")}
.iconPath=${mdiFolder} .iconPath=${mdiFolder}
> >
</supervisor-formfield-label>`} </supervisor-formfield-label>`}
@@ -222,7 +216,7 @@ export class SupervisorBackupContent extends LitElement {
? html` ? html`
<ha-formfield <ha-formfield
.label=${html`<supervisor-formfield-label .label=${html`<supervisor-formfield-label
.label=${this._localize("addons")} .label=${this.supervisor?.localize("backup.addons")}
.iconPath=${mdiPuzzle} .iconPath=${mdiPuzzle}
> >
</supervisor-formfield-label>`} </supervisor-formfield-label>`}
@@ -247,7 +241,7 @@ export class SupervisorBackupContent extends LitElement {
${!this.backup ${!this.backup
? html`<ha-formfield ? html`<ha-formfield
class="password" class="password"
.label=${this._localize("password_protection")} .label=${this.supervisor?.localize("backup.password_protection")}
> >
<ha-checkbox <ha-checkbox
.checked=${this.backupHasPassword} .checked=${this.backupHasPassword}
@@ -259,7 +253,7 @@ export class SupervisorBackupContent extends LitElement {
${this.backupHasPassword ${this.backupHasPassword
? html` ? html`
<ha-password-field <ha-password-field
.label=${this._localize("password")} .label=${this.supervisor?.localize("backup.password")}
name="backupPassword" name="backupPassword"
.value=${this.backupPassword} .value=${this.backupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}
@@ -267,7 +261,7 @@ export class SupervisorBackupContent extends LitElement {
</ha-password-field> </ha-password-field>
${!this.backup ${!this.backup
? html`<ha-password-field ? html`<ha-password-field
.label=${this._localize("confirm_password")} .label=${this.supervisor?.localize("backup.confirm_password")}
name="confirmBackupPassword" name="confirmBackupPassword"
.value=${this.confirmBackupPassword} .value=${this.confirmBackupPassword}
@change=${this._handleTextValueChanged} @change=${this._handleTextValueChanged}

View File

@@ -72,7 +72,7 @@ export class DialogHassioBackupUpload
</ha-header-bar> </ha-header-bar>
</div> </div>
<hassio-upload-backup <hassio-upload-backup
@backup-uploaded=${this._backupUploaded} @hassio-backup-uploaded=${this._backupUploaded}
.hass=${this.hass} .hass=${this.hass}
></hassio-upload-backup> ></hassio-upload-backup>
</ha-dialog> </ha-dialog>

View File

@@ -35,7 +35,6 @@ import { fileDownload } from "../../../../src/util/file_download";
import "../../components/supervisor-backup-content"; import "../../components/supervisor-backup-content";
import type { SupervisorBackupContent } from "../../components/supervisor-backup-content"; import type { SupervisorBackupContent } from "../../components/supervisor-backup-content";
import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup"; import type { HassioBackupDialogParams } from "./show-dialog-hassio-backup";
import type { BackupOrRestoreKey } from "../../util/translations";
import type { HaMdDialog } from "../../../../src/components/ha-md-dialog"; import type { HaMdDialog } from "../../../../src/components/ha-md-dialog";
@customElement("dialog-hassio-backup") @customElement("dialog-hassio-backup")
@@ -43,7 +42,7 @@ class HassioBackupDialog
extends LitElement extends LitElement
implements HassDialog<HassioBackupDialogParams> implements HassDialog<HassioBackupDialogParams>
{ {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: string; @state() private _error?: string;
@@ -62,9 +61,13 @@ class HassioBackupDialog
this._dialogParams = dialogParams; this._dialogParams = dialogParams;
this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug); this._backup = await fetchHassioBackupInfo(this.hass, dialogParams.slug);
if (!this._backup) { if (!this._backup) {
this._error = this._localize("no_backup_found"); this._error = this._dialogParams.supervisor?.localize(
"backup.no_backup_found"
);
} else if (this._dialogParams.onboarding && !this._backup.homeassistant) { } else if (this._dialogParams.onboarding && !this._backup.homeassistant) {
this._error = this._localize("restore_no_home_assistant"); this._error = this._dialogParams.supervisor?.localize(
"backup.restore_no_home_assistant"
);
} }
this._restoringBackup = false; this._restoringBackup = false;
} }
@@ -82,13 +85,6 @@ class HassioBackupDialog
return true; return true;
} }
private _localize(key: BackupOrRestoreKey) {
return (
this._dialogParams!.supervisor?.localize(`backup.${key}`) ||
this._dialogParams!.localize!(`ui.panel.page-onboarding.restore.${key}`)
);
}
protected render() { protected render() {
if (!this._dialogParams || !this._backup) { if (!this._dialogParams || !this._backup) {
return nothing; return nothing;
@@ -102,7 +98,7 @@ class HassioBackupDialog
<ha-dialog-header slot="headline"> <ha-dialog-header slot="headline">
<ha-icon-button <ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this._localize("close")} .label=${this._dialogParams.supervisor?.localize("backup.close")}
.path=${mdiClose} .path=${mdiClose}
@click=${this.closeDialog} @click=${this.closeDialog}
.disabled=${this._restoringBackup} .disabled=${this._restoringBackup}
@@ -150,7 +146,6 @@ class HassioBackupDialog
.supervisor=${this._dialogParams.supervisor} .supervisor=${this._dialogParams.supervisor}
.backup=${this._backup} .backup=${this._backup}
.onboarding=${this._dialogParams.onboarding || false} .onboarding=${this._dialogParams.onboarding || false}
.localize=${this._dialogParams.localize}
dialogInitialFocus dialogInitialFocus
> >
</supervisor-backup-content> </supervisor-backup-content>
@@ -161,7 +156,7 @@ class HassioBackupDialog
.disabled=${this._restoringBackup || !!this._error} .disabled=${this._restoringBackup || !!this._error}
@click=${this._restoreClicked} @click=${this._restoreClicked}
> >
${this._localize("restore")} ${this._dialogParams.supervisor?.localize("backup.restore")}
</ha-button> </ha-button>
</div> </div>
</ha-md-dialog> </ha-md-dialog>
@@ -196,18 +191,22 @@ class HassioBackupDialog
} }
if ( if (
!(await showConfirmationDialog(this, { !(await showConfirmationDialog(this, {
title: this._localize( title: supervisor?.localize(
`backup.${
this._backup!.type === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_title" ? "confirm_restore_full_backup_title"
: "confirm_restore_partial_backup_title" : "confirm_restore_partial_backup_title"
}`
), ),
text: this._localize( text: supervisor?.localize(
`backup.${
this._backup!.type === "full" this._backup!.type === "full"
? "confirm_restore_full_backup_text" ? "confirm_restore_full_backup_text"
: "confirm_restore_partial_backup_text" : "confirm_restore_partial_backup_text"
}`
), ),
confirmText: this._localize("restore"), confirmText: supervisor?.localize("backup.restore"),
dismissText: this._localize("cancel"), dismissText: supervisor?.localize("backup.cancel"),
})) }))
) { ) {
this._restoringBackup = false; this._restoringBackup = false;
@@ -227,7 +226,8 @@ class HassioBackupDialog
this.closeDialog(); this.closeDialog();
} catch (error: any) { } catch (error: any) {
this._error = this._error =
error?.body?.message || this._localize("restore_start_failed"); error?.body?.message ||
supervisor?.localize("backup.restore_start_failed");
} finally { } finally {
this._restoringBackup = false; this._restoringBackup = false;
} }
@@ -286,7 +286,7 @@ class HassioBackupDialog
title: supervisor.localize("backup.remote_download_title"), title: supervisor.localize("backup.remote_download_title"),
text: supervisor.localize("backup.remote_download_text"), text: supervisor.localize("backup.remote_download_text"),
confirmText: supervisor.localize("backup.download"), confirmText: supervisor.localize("backup.download"),
dismissText: this._localize("cancel"), dismissText: supervisor?.localize("backup.cancel"),
}); });
if (!confirm) { if (!confirm) {
return; return;
@@ -302,7 +302,7 @@ class HassioBackupDialog
private get _computeName() { private get _computeName() {
return this._backup return this._backup
? this._backup.name || this._backup.slug ? this._backup.name || this._backup.slug
: this._localize("unnamed_backup"); : this._dialogParams!.supervisor?.localize("backup.unnamed_backup") || "";
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../src/common/dom/fire_event"; import { fireEvent } from "../../../../src/common/dom/fire_event";
import type { LocalizeFunc } from "../../../../src/common/translations/localize";
import type { Supervisor } from "../../../../src/data/supervisor/supervisor"; import type { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioBackupDialogParams { export interface HassioBackupDialogParams {
@@ -8,7 +7,6 @@ export interface HassioBackupDialogParams {
onRestoring?: () => void; onRestoring?: () => void;
onboarding?: boolean; onboarding?: boolean;
supervisor?: Supervisor; supervisor?: Supervisor;
localize?: LocalizeFunc;
} }
export const showHassioBackupDialog = ( export const showHassioBackupDialog = (

View File

@@ -1,4 +0,0 @@
import type { TranslationDict } from "../../../src/types";
export type BackupOrRestoreKey = keyof TranslationDict["supervisor"]["backup"] &
keyof TranslationDict["ui"]["panel"]["page-onboarding"]["restore"];

View File

@@ -33,7 +33,7 @@
"@codemirror/language": "6.10.8", "@codemirror/language": "6.10.8",
"@codemirror/legacy-modes": "6.4.2", "@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.8", "@codemirror/search": "6.5.8",
"@codemirror/state": "6.5.1", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.2", "@codemirror/view": "6.36.2",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.2", "@formatjs/intl-datetimeformat": "6.17.2",
@@ -91,14 +91,14 @@
"@polymer/polymer": "3.5.2", "@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.2", "@vaadin/combo-box": "24.6.4",
"@vaadin/vaadin-themable-mixin": "24.6.2", "@vaadin/vaadin-themable-mixin": "24.6.4",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.9", "@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0", "@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1", "app-datepicker": "5.1.1",
"barcode-detector": "2.3.1", "barcode-detector": "3.0.0",
"color-name": "2.0.0", "color-name": "2.0.0",
"comlink": "4.4.2", "comlink": "4.4.2",
"core-js": "3.40.0", "core-js": "3.40.0",
@@ -110,7 +110,7 @@
"dialog-polyfill": "0.5.6", "dialog-polyfill": "0.5.6",
"echarts": "5.6.0", "echarts": "5.6.0",
"element-internals-polyfill": "1.3.13", "element-internals-polyfill": "1.3.13",
"fuse.js": "7.0.0", "fuse.js": "7.1.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2", "gulp-zopfli-green": "6.0.2",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
@@ -137,7 +137,7 @@
"tinykeys": "3.0.0", "tinykeys": "3.0.0",
"tsparticles-engine": "2.12.0", "tsparticles-engine": "2.12.0",
"tsparticles-preset-links": "2.12.0", "tsparticles-preset-links": "2.12.0",
"ua-parser-js": "2.0.0", "ua-parser-js": "2.0.1",
"vis-data": "7.1.9", "vis-data": "7.1.9",
"vis-network": "9.1.9", "vis-network": "9.1.9",
"vue": "2.7.16", "vue": "2.7.16",
@@ -167,7 +167,7 @@
"@rspack/cli": "1.2.2", "@rspack/cli": "1.2.2",
"@rspack/core": "1.2.2", "@rspack/core": "1.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.20", "@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11", "@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0", "@types/color-name": "2.0.0",
"@types/glob": "8.1.0", "@types/glob": "8.1.0",
@@ -183,14 +183,14 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "8.21.0", "@typescript-eslint/eslint-plugin": "8.23.0",
"@typescript-eslint/parser": "8.21.0", "@typescript-eslint/parser": "8.23.0",
"@vitest/coverage-v8": "3.0.4", "@vitest/coverage-v8": "3.0.5",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0", "del": "8.0.0",
"eslint": "9.19.0", "eslint": "9.20.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1", "eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
@@ -219,12 +219,13 @@
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.4.2", "prettier": "3.4.2",
"rspack-manifest-plugin": "5.0.3", "rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "19.0.2", "sinon": "19.0.2",
"tar": "7.4.3", "tar": "7.4.3",
"terser-webpack-plugin": "5.3.11", "terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.7.3", "typescript": "5.7.3",
"vitest": "3.0.4", "vitest": "3.0.5",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20250129.0" version = "20250205.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@@ -64,7 +64,7 @@ echo Core is used from ${coreUrl}
HASS_URL="$coreUrl" ./script/develop & HASS_URL="$coreUrl" ./script/develop &
# serve the frontend # serve the frontend
yarn dlx serve -l $frontendPort ./hass_frontend -s & ./node_modules/.bin/serve -p $frontendPort --single --no-port-switching --config ../script/serve-config.json ./hass_frontend &
# keep the script running while serving # keep the script running while serving
wait wait

3
script/serve-config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"cleanUrls": false
}

View File

@@ -1,3 +1,4 @@
import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color"; import { theme2hex } from "./convert-color";
export const COLORS = [ export const COLORS = [
@@ -74,3 +75,12 @@ export function getGraphColorByIndex(
getColorByIndex(index); getColorByIndex(index);
return theme2hex(themeColor); return theme2hex(themeColor);
} }
export const getAllGraphColors = memoizeOne(
(style: CSSStyleDeclaration) =>
COLORS.map((_color, index) => getGraphColorByIndex(index, style)),
(newArgs: [CSSStyleDeclaration], lastArgs: [CSSStyleDeclaration]) =>
// this is not ideal, but we need to memoize the colors
newArgs[0].getPropertyValue("--graph-color-1") ===
lastArgs[0].getPropertyValue("--graph-color-1")
);

View File

@@ -26,6 +26,20 @@ const formatDateTimeMem = memoizeOne(
}) })
); );
export const formatDateTimeWithBrowserDefaults = (dateObj: Date) =>
formatDateTimeWithBrowserDefaultsMem().format(dateObj);
const formatDateTimeWithBrowserDefaultsMem = memoizeOne(
() =>
new Intl.DateTimeFormat(undefined, {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
);
// Aug 9, 2021, 8:23 AM // Aug 9, 2021, 8:23 AM
export const formatShortDateTimeWithYear = ( export const formatShortDateTimeWithYear = (
dateObj: Date, dateObj: Date,

View File

@@ -41,7 +41,7 @@ export class HaProgressButton extends LitElement {
indeterminate indeterminate
></ha-circular-progress> ></ha-circular-progress>
` `
: ""} : nothing}
</div> </div>
`} `}
`; `;
@@ -117,6 +117,9 @@ export class HaProgressButton extends LitElement {
mwc-button.error slot { mwc-button.error slot {
visibility: hidden; visibility: hidden;
} }
:host([destructive]) {
--mdc-theme-primary: var(--error-color);
}
`; `;
} }

View File

@@ -1,5 +1,4 @@
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { import {
formatDateMonth, formatDateMonth,
@@ -7,14 +6,18 @@ import {
formatDateVeryShort, formatDateVeryShort,
formatDateWeekdayShort, formatDateWeekdayShort,
} from "../../common/datetime/format_date"; } from "../../common/datetime/format_date";
import { formatTime } from "../../common/datetime/format_time"; import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function getLabelFormatter( export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData, locale: FrontendLocaleData,
config: HassConfig, config: HassConfig,
dayDifference = 0 minutesDifference: number
) { ) {
return (value: number | Date) => { const dayDifference = minutesDifference / 60 / 24;
const date = new Date(value); const date = new Date(value);
if (dayDifference > 88) { if (dayDifference > 88) {
return date.getMonth() === 0 return date.getMonth() === 0
@@ -33,30 +36,16 @@ export function getLabelFormatter(
if (dayDifference > 2) { if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config); return formatDateWeekdayShort(date, locale, config);
} }
// show only date for the beginning of the day if (minutesDifference && minutesDifference < 5) {
return formatTimeWithSeconds(date, locale, config);
}
if ( if (
date.getHours() === 0 && date.getHours() === 0 &&
date.getMinutes() === 0 && date.getMinutes() === 0 &&
date.getSeconds() === 0 date.getSeconds() === 0
) { ) {
// show only date for the beginning of the day
return `{bold|${formatDateVeryShort(date, locale, config)}}`; return `{bold|${formatDateVeryShort(date, locale, config)}}`;
} }
return formatTime(date, locale, config); return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
} }

View File

@@ -1,25 +1,29 @@
import type { PropertyValues } from "lit"; import { consume } from "@lit-labs/context";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { mdiRestart } from "@mdi/js";
import type { EChartsType } from "echarts/core";
import type { DataZoomComponentOption } from "echarts/components";
import { ResizeController } from "@lit-labs/observers/resize-controller"; import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiRestart } from "@mdi/js";
import { differenceInMinutes } from "date-fns";
import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core";
import type { import type {
ECElementEvent, ECElementEvent,
XAXisOption, XAXisOption,
YAXisOption, YAXisOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import { consume } from "@lit-labs/context"; import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { getAllGraphColors } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac"; import { isMac } from "../../util/is_mac";
import "../ha-icon-button"; import "../ha-icon-button";
import type { ECOption } from "../../resources/echarts"; import { formatTimeLabel } from "./axis-label";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { Themes } from "../../data/ws-themes";
import { themesContext } from "../../data/context";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@@ -44,6 +48,10 @@ export class HaChartBase extends LitElement {
@state() private _isZoomed = false; @state() private _isZoomed = false;
@state() private _zoomRatio = 1;
@state() private _minutesDifference = 24 * 60;
private _modifierPressed = false; private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window; private _isTouchDevice = "ontouchstart" in window;
@@ -135,16 +143,7 @@ export class HaChartBase extends LitElement {
this.chart.setOption(this._createOptions(), { this.chart.setOption(this._createOptions(), {
lazyUpdate: true, lazyUpdate: true,
// if we replace the whole object, it will reset the dataZoom // if we replace the whole object, it will reset the dataZoom
replaceMerge: [ replaceMerge: ["grid"],
"xAxis",
"yAxis",
"dataZoom",
"dataset",
"tooltip",
"legend",
"grid",
"visualMap",
],
}); });
} }
} }
@@ -152,7 +151,10 @@ export class HaChartBase extends LitElement {
protected render() { protected render() {
return html` return html`
<div <div
class="chart-container" class=${classMap({
"chart-container": true,
"has-legend": !!this.options?.legend,
})}
style=${styleMap({ style=${styleMap({
height: this.height ?? `${this._getDefaultHeight()}px`, height: this.height ?? `${this._getDefaultHeight()}px`,
})} })}
@@ -173,6 +175,14 @@ export class HaChartBase extends LitElement {
`; `;
} }
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
this.hass.locale,
this.hass.config,
this._minutesDifference * this._zoomRatio
);
private async _setupChart() { private async _setupChart() {
if (this._loading) return; if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement; const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
@@ -183,10 +193,9 @@ export class HaChartBase extends LitElement {
} }
const echarts = (await import("../../resources/echarts")).default; const echarts = (await import("../../resources/echarts")).default;
this.chart = echarts.init( echarts.registerTheme("custom", this._createTheme());
container,
this._themes.darkMode ? "dark" : "light" this.chart = echarts.init(container, "custom");
);
this.chart.on("legendselectchanged", (params: any) => { this.chart.on("legendselectchanged", (params: any) => {
if (this.externalHidden) { if (this.externalHidden) {
const isSelected = params.selected[params.name]; const isSelected = params.selected[params.name];
@@ -200,6 +209,7 @@ export class HaChartBase extends LitElement {
this.chart.on("datazoom", (e: any) => { this.chart.on("datazoom", (e: any) => {
const { start, end } = e.batch?.[0] ?? e; const { start, end } = e.batch?.[0] ?? e;
this._isZoomed = start !== 0 || end !== 100; this._isZoomed = start !== 0 || end !== 100;
this._zoomRatio = (end - start) / 100;
}); });
this.chart.on("click", (e: ECElementEvent) => { this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e); fireEvent(this, "chart-click", e);
@@ -237,24 +247,60 @@ export class HaChartBase extends LitElement {
} }
private _createOptions(): ECOption { private _createOptions(): ECOption {
const darkMode = this._themes.darkMode ?? false; let xAxis = this.options?.xAxis;
if (xAxis) {
xAxis = Array.isArray(xAxis) ? xAxis : [xAxis];
xAxis = xAxis.map((axis: XAXisOption) => {
if (axis.type !== "time" || axis.show === false) {
return axis;
}
if (axis.max && axis.min) {
this._minutesDifference = differenceInMinutes(
axis.max as Date,
axis.min as Date
);
}
const dayDifference = this._minutesDifference / 60 / 24;
let minInterval: number | undefined;
if (dayDifference) {
minInterval =
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined;
}
return {
axisLine: {
show: false,
},
splitLine: {
show: true,
},
...axis,
axisLabel: {
formatter: this._formatTimeLabel,
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
...axis.axisLabel,
},
minInterval,
} as XAXisOption;
});
}
const options = { const options = {
backgroundColor: "transparent",
animation: !this._reducedMotion, animation: !this._reducedMotion,
darkMode, darkMode: this._themes.darkMode ?? false,
aria: { aria: {
show: true, show: true,
}, },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
...this.options, ...this.options,
legend: this.options?.legend xAxis,
? {
// we should create our own theme but this is a quick fix for now
inactiveColor: darkMode ? "#444" : "#ccc",
...this.options.legend,
}
: undefined,
}; };
const isMobile = window.matchMedia( const isMobile = window.matchMedia(
@@ -268,18 +314,207 @@ export class HaChartBase extends LitElement {
tooltips.forEach((tooltip) => { tooltips.forEach((tooltip) => {
tooltip.confine = true; tooltip.confine = true;
tooltip.appendTo = undefined; tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
}); });
options.tooltip = tooltips; options.tooltip = tooltips;
} }
return options; return options;
} }
private _createTheme() {
const style = getComputedStyle(this);
return {
color: getAllGraphColors(style),
backgroundColor: "transparent",
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontFamily: "Roboto, Noto, sans-serif",
},
title: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
subtextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
line: {
lineStyle: {
width: 1.5,
},
symbolSize: 1,
symbol: "circle",
smooth: false,
},
bar: {
itemStyle: {
barBorderWidth: 1.5,
},
},
categoryAxis: {
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: false,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
valueAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
logAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
timeAxis: {
axisLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisTick: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
axisLabel: {
show: true,
color: style.getPropertyValue("--primary-text-color"),
},
splitLine: {
show: true,
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
splitArea: {
show: false,
areaStyle: {
color: [
style.getPropertyValue("--divider-color") + "3F",
style.getPropertyValue("--divider-color") + "7F",
],
},
},
},
legend: {
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
},
inactiveColor: style.getPropertyValue("--disabled-text-color"),
pageIconColor: style.getPropertyValue("--primary-text-color"),
pageIconInactiveColor: style.getPropertyValue("--disabled-text-color"),
pageTextStyle: {
color: style.getPropertyValue("--secondary-text-color"),
},
},
tooltip: {
backgroundColor: style.getPropertyValue("--card-background-color"),
borderColor: style.getPropertyValue("--divider-color"),
textStyle: {
color: style.getPropertyValue("--primary-text-color"),
fontSize: 12,
},
axisPointer: {
lineStyle: {
color: style.getPropertyValue("--divider-color"),
},
crossStyle: {
color: style.getPropertyValue("--divider-color"),
},
},
},
timeline: {},
};
}
private _getDefaultHeight() { private _getDefaultHeight() {
return Math.max(this.clientWidth / 2, 400); return Math.max(this.clientWidth / 2, 200);
} }
private _handleZoomReset() { private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
this._modifierPressed = false;
} }
private _handleWheel(e: WheelEvent) { private _handleWheel(e: WheelEvent) {
@@ -302,10 +537,11 @@ export class HaChartBase extends LitElement {
:host { :host {
display: block; display: block;
position: relative; position: relative;
letter-spacing: normal;
} }
.chart-container { .chart-container {
position: relative; position: relative;
max-height: var(--chart-max-height, 400px); max-height: var(--chart-max-height, 350px);
} }
.chart { .chart {
width: 100%; width: 100%;
@@ -321,6 +557,9 @@ export class HaChartBase extends LitElement {
color: var(--primary-color); color: var(--primary-color);
border: 1px solid var(--divider-color); border: 1px solid var(--divider-color);
} }
.has-legend .zoom-reset {
top: 64px;
}
`; `;
} }

View File

@@ -4,7 +4,6 @@ import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components"; import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts"; import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared"; import type { YAXisOption } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors"; import { getGraphColorByIndex } from "../../common/color/colors";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
@@ -18,10 +17,10 @@ import {
getNumberFormatOptions, getNumberFormatOptions,
formatNumber, formatNumber,
} from "../../common/number/format_number"; } from "../../common/number/format_number";
import { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text"; import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate"; import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => { const safeParseFloat = (value) => {
const parsed = parseFloat(value); const parsed = parseFloat(value);
@@ -72,6 +71,8 @@ export class StateHistoryChartLine extends LitElement {
@state() private _chartOptions?: ECOption; @state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25; @state() private _yWidth = 25;
private _chartTime: Date = new Date(); private _chartTime: Date = new Date();
@@ -84,21 +85,73 @@ export class StateHistoryChartLine extends LitElement {
.options=${this._chartOptions} .options=${this._chartOptions}
.height=${this.height} .height=${this.height}
style=${styleMap({ height: this.height })} style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base> ></ha-chart-base>
`; `;
} }
private _renderTooltip(params) { private _renderTooltip(params: any) {
return params const time = params[0].axisValue;
.map((param, index: number) => { const title =
let value = `${formatNumber( formatDateTimeWithSeconds(
param.value[1] as number, new Date(time),
this.hass.locale, this.hass.locale,
getNumberFormatOptions( this.hass.config
undefined, ) + "<br>";
this.hass.entities[this._entityIds[param.seriesIndex]] const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.name as string)
) )
)} ${this.unit}`; return;
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
if (param) {
datapoints.push(param);
return;
}
// If the datapoint is not found, we need to find the last datapoint before the current time
let lastData;
const data = dataset.data || [];
for (let i = data.length - 1; i >= 0; i--) {
const point = data[i];
if (point && point[0] <= time && point[1]) {
lastData = point;
break;
}
}
if (!lastData) return;
datapoints.push({
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex]; const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex]; const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) { if (data.statistics && data.statistics.length > 0) {
@@ -115,18 +168,21 @@ export class StateHistoryChartLine extends LitElement {
value += source; value += source;
} }
const time = if (param.seriesName) {
index === 0 return `${param.marker} ${param.seriesName}: ${value}`;
? formatDateTimeWithSeconds( }
new Date(param.value[0]), return `${param.marker} ${value}`;
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
}) })
.join("<br>"); .join("<br>")
);
}
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
}
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
} }
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
@@ -156,49 +212,44 @@ export class StateHistoryChartLine extends LitElement {
changedProps.has("paddingYAxis") || changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth") changedProps.has("_yWidth")
) { ) {
const dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass); const rtl = computeRTL(this.hass);
const splitLineStyle = this.hass.themes?.darkMode let minYAxis: number | ((values: { min: number }) => number) | undefined =
? { opacity: 0.15 } this.minYAxis;
: {}; let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
min: this.startTime, min: this.startTime,
max: this.endTime, max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
}, },
yAxis: { yAxis: {
type: this.logarithmicScale ? "log" : "value", type: this.logarithmicScale ? "log" : "value",
name: this.unit, name: this.unit,
min: this.fitYData ? this.minYAxis : undefined, min: this._clampYAxis(minYAxis),
max: this.fitYData ? this.maxYAxis : undefined, max: this._clampYAxis(maxYAxis),
position: rtl ? "right" : "left", position: rtl ? "right" : "left",
scale: true, scale: true,
nameGap: 2, nameGap: 2,
nameTextStyle: { nameTextStyle: {
align: "left", align: "left",
}, },
splitLine: { axisLine: {
show: true, show: false,
lineStyle: splitLineStyle,
}, },
axisLabel: { axisLabel: {
margin: 5, margin: 5,
@@ -218,6 +269,8 @@ export class StateHistoryChartLine extends LitElement {
} as YAXisOption, } as YAXisOption,
legend: { legend: {
show: this.showNames, show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle", icon: "circle",
padding: [20, 0], padding: [20, 0],
}, },
@@ -307,13 +360,18 @@ export class StateHistoryChartLine extends LitElement {
prevValues = datavalues; prevValues = datavalues;
}; };
const addDataSet = (nameY: string, color?: string, fill = false) => { const addDataSet = (
id: string,
nameY: string,
color?: string,
fill = false
) => {
if (!color) { if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles); color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++; colorIndex++;
} }
data.push({ data.push({
id: nameY, id,
data: [], data: [],
type: "line", type: "line",
cursor: "default", cursor: "default",
@@ -321,6 +379,7 @@ export class StateHistoryChartLine extends LitElement {
color, color,
symbol: "circle", symbol: "circle",
step: "end", step: "end",
animationDurationUpdate: 0,
symbolSize: 1, symbolSize: 1,
lineStyle: { lineStyle: {
width: fill ? 0 : 1.5, width: fill ? 0 : 1.5,
@@ -375,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low entityState.attributes.target_temp_low
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.current_temperature", { states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name, name: name,
})}` })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
); );
if (hasHeat) { if (hasHeat) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.heating", { name: name })}`, states.entity_id + "-heating",
this.showNames
? this.hass.localize("ui.card.climate.heating", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.heating"
),
computedStyles.getPropertyValue("--state-climate-heat-color"), computedStyles.getPropertyValue("--state-climate-heat-color"),
true true
); );
@@ -390,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
} }
if (hasCool) { if (hasCool) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.cooling", { name: name })}`, states.entity_id + "-cooling",
this.showNames
? this.hass.localize("ui.card.climate.cooling", { name: name })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.hvac_action.state.cooling"
),
computedStyles.getPropertyValue("--state-climate-cool-color"), computedStyles.getPropertyValue("--state-climate-cool-color"),
true true
); );
@@ -400,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) { if (hasTargetRange) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", { states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name, name: name,
mode: this.hass.localize("ui.card.climate.high"), mode: this.hass.localize("ui.card.climate.high"),
})}` })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", { states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name, name: name,
mode: this.hass.localize("ui.card.climate.low"), mode: this.hass.localize("ui.card.climate.low"),
})}` })
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
); );
} else { } else {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_entity", { states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name, name: name,
})}` }
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
); );
} }
@@ -468,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
); );
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", { states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name, name: name,
})}` })
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
); );
if (hasCurrent) { if (hasCurrent) {
addDataSet( addDataSet(
`${this.hass.localize( states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity", "ui.card.humidifier.current_humidity_entity",
{ {
name: name, name: name,
} }
)}` )
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
); );
} }
@@ -488,25 +590,40 @@ export class StateHistoryChartLine extends LitElement {
// If action attribute is not available, we shade the area when the device is on // If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) { if (hasHumidifying) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.humidifying", { states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name, name: name,
})}`, })
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"), computedStyles.getPropertyValue("--state-humidifier-on-color"),
true true
); );
} else if (hasDrying) { } else if (hasDrying) {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.drying", { states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name, name: name,
})}`, })
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"), computedStyles.getPropertyValue("--state-humidifier-on-color"),
true true
); );
} else { } else {
addDataSet( addDataSet(
`${this.hass.localize("ui.card.humidifier.on_entity", { states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name, name: name,
})}`, })
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
undefined, undefined,
true true
); );
@@ -539,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series); pushData(new Date(entityState.last_changed), series);
}); });
} else { } else {
addDataSet(name); addDataSet(states.entity_id, name);
let lastValue: number; let lastValue: number;
let lastDate: Date; let lastDate: Date;
@@ -609,6 +726,19 @@ export class StateHistoryChartLine extends LitElement {
this._entityIds = entityIds; this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex; this._datasetToDataIndex = datasetToDataIndex;
} }
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
} }
customElements.define("state-history-chart-line", StateHistoryChartLine); customElements.define("state-history-chart-line", StateHistoryChartLine);

View File

@@ -8,7 +8,6 @@ import type {
TooltipFormatterCallback, TooltipFormatterCallback,
TooltipPositionCallbackParams, TooltipPositionCallbackParams,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import { differenceInDays } from "date-fns";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration"; import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
@@ -22,7 +21,6 @@ import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color"; import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text"; import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline") @customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement { export class StateHistoryChartTimeline extends LitElement {
@@ -67,7 +65,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.options=${this._chartOptions} .options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`} .height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData} .data=${this._chartData as ECOption["series"]}
@chart-click=${this._handleChartClick} @chart-click=${this._handleChartClick}
></ha-chart-base> ></ha-chart-base>
`; `;
@@ -129,10 +127,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> = private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => { (params: TooltipPositionCallbackParams) => {
const { value, name, marker } = Array.isArray(params) const { value, name, marker, seriesName } = Array.isArray(params)
? params[0] ? params[0]
: params; : params;
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</h4>`; const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1]; const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize( const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration" "ui.components.history_charts.duration"
@@ -183,13 +183,12 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() { private _createOptions() {
const narrow = this.narrow; const narrow = this.narrow;
const showNames = this.chunked || this.showNames; const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 70 : 165; const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth) ? Math.max(this.paddingYAxis, this._yWidth)
: 0; : 0;
const labelMargin = 5; const labelMargin = 5;
const rtl = computeRTL(this.hass); const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
@@ -197,21 +196,10 @@ export class StateHistoryChartTimeline extends LitElement {
max: this.endTime, max: this.endTime,
axisTick: { axisTick: {
show: true, show: true,
lineStyle: {
opacity: 0.4,
}, },
splitLine: {
show: false,
}, },
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
}, },
yAxis: { yAxis: {
type: "category", type: "category",
@@ -226,14 +214,18 @@ export class StateHistoryChartTimeline extends LitElement {
}, },
axisLabel: { axisLabel: {
show: showNames, show: showNames,
width: labelWidth - labelMargin, width: labelWidth,
overflow: "truncate", overflow: "truncate",
margin: labelMargin, margin: labelMargin,
formatter: (label: string) => { formatter: (id: string) => {
const width = Math.min( const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin, measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth maxInternalLabelWidth
); )
: 0;
if (width > this._yWidth) { if (width > this._yWidth) {
this._yWidth = width; this._yWidth = width;
fireEvent(this, "y-width-changed", { fireEvent(this, "y-width-changed", {
@@ -278,8 +270,9 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null; let prevState: string | null = null;
let locState: string | null = null; let locState: string | null = null;
let prevLastChanged = startTime; let prevLastChanged = startTime;
const entityDisplay: string = const entityDisplay: string = this.showNames
names[stateInfo.entity_id] || stateInfo.name; ? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: unknown[] = []; const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => { stateInfo.data.forEach((entityState) => {
@@ -307,7 +300,7 @@ export class StateHistoryChartTimeline extends LitElement {
); );
dataRow.push({ dataRow.push({
value: [ value: [
entityDisplay, stateInfo.entity_id,
prevLastChanged, prevLastChanged,
newLastChanged, newLastChanged,
locState, locState,
@@ -333,7 +326,7 @@ export class StateHistoryChartTimeline extends LitElement {
); );
dataRow.push({ dataRow.push({
value: [ value: [
entityDisplay, stateInfo.entity_id,
prevLastChanged, prevLastChanged,
endTime, endTime,
locState, locState,
@@ -346,9 +339,10 @@ export class StateHistoryChartTimeline extends LitElement {
}); });
} }
datasets.push({ datasets.push({
id: stateInfo.entity_id,
data: dataRow, data: dataRow,
name: entityDisplay, name: entityDisplay,
dimensions: ["index", "start", "end", "name", "color", "textColor"], dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom", type: "custom",
encode: { encode: {
x: [1, 2], x: [1, 2],
@@ -364,10 +358,10 @@ export class StateHistoryChartTimeline extends LitElement {
private _handleChartClick(e: CustomEvent<ECElementEvent>): void { private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") { if (e.detail.targetType === "axisLabel") {
const dataset = this.data[e.detail.dataIndex]; const dataset = this._chartData[e.detail.dataIndex];
if (dataset) { if (dataset) {
fireEvent(this, "hass-more-info", { fireEvent(this, "hass-more-info", {
entityId: dataset.entity_id, entityId: dataset.id as string,
}); });
} }
} }

View File

@@ -135,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``; return html``;
} }
if (!Array.isArray(item)) { if (!Array.isArray(item)) {
return html`<div class="entry-container"> return html`<div class="entry-container line">
<state-history-chart-line <state-history-chart-line
.hass=${this.hass} .hass=${this.hass}
.unit=${item.unit} .unit=${item.unit}
@@ -157,7 +157,7 @@ export class StateHistoryCharts extends LitElement {
></state-history-chart-line> ></state-history-chart-line>
</div> `; </div> `;
} }
return html`<div class="entry-container"> return html`<div class="entry-container timeline">
<state-history-chart-timeline <state-history-chart-timeline
.hass=${this.hass} .hass=${this.hass}
.data=${item} .data=${item}
@@ -299,6 +299,9 @@ export class StateHistoryCharts extends LitElement {
.entry-container { .entry-container {
width: 100%; width: 100%;
}
.entry-container.line {
flex: 1; flex: 1;
} }
@@ -313,6 +316,10 @@ export class StateHistoryCharts extends LitElement {
padding-inline-end: 1px; padding-inline-end: 1px;
} }
.entry-container.timeline:first-child {
margin-top: var(--timeline-top-margin);
}
.entry-container:not(:first-child) { .entry-container:not(:first-child) {
border-top: 2px solid var(--divider-color); border-top: 2px solid var(--divider-color);
margin-top: 16px; margin-top: 16px;

View File

@@ -1,15 +1,22 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { import type {
BarSeriesOption, BarSeriesOption,
LineSeriesOption, LineSeriesOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors"; import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeRTL } from "../../common/util/compute_rtl";
import type { import type {
Statistics, Statistics,
StatisticsMetaData, StatisticsMetaData,
@@ -21,16 +28,9 @@ import {
getStatisticMetadata, getStatisticMetadata,
statisticsHaveType, statisticsHaveType,
} from "../../data/recorder"; } from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "./ha-chart-base"; import "./ha-chart-base";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = { export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean", mean: "mean",
@@ -56,6 +56,8 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string; @property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@property({ attribute: false }) public endTime?: Date; @property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
@@ -124,7 +126,10 @@ export class StatisticsChart extends LitElement {
changedProps.has("fitYData") || changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") || changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend") || changedProps.has("hideLegend") ||
changedProps.has("_legendData") changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("_legendData") ||
changedProps.has("_chartData")
) { ) {
this._createOptions(); this._createOptions();
} }
@@ -181,18 +186,31 @@ export class StatisticsChart extends LitElement {
this.requestUpdate("_hiddenStats"); this.requestUpdate("_hiddenStats");
} }
private _renderTooltip = (params: any) => private _renderTooltip = (params: any) => {
params const rendered: Record<string, boolean> = {};
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => { .map((param, index: number) => {
const value = `${formatNumber( if (rendered[param.seriesName]) return "";
rendered[param.seriesName] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band // max series can have 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number, const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale, this.hass.locale,
getNumberFormatOptions( options
undefined, )}${unit}`;
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.unit}`;
const time = const time =
index === 0 index === 0
@@ -202,36 +220,68 @@ export class StatisticsChart extends LitElement {
this.hass.config this.hass.config
) + "<br>" ) + "<br>"
: ""; : "";
return `${time}${param.marker} ${param.seriesName}: ${value} return `${time}${param.marker} ${param.seriesName}: ${value}`;
`;
}) })
.filter(Boolean)
.join("<br>"); .join("<br>");
};
private _createOptions() { private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1; const dayDifference = this.daysToShow ?? 1;
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
this.maxYAxis;
if (typeof minYAxis === "number") {
if (this.fitYData) {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
if (!startTime) {
// set start time to the earliest point in the chart data
this._chartData.forEach((series) => {
if (!Array.isArray(series.data) || !series.data[0]) return;
const firstPoint = series.data[0] as any;
const timestamp = Array.isArray(firstPoint)
? firstPoint[0]
: firstPoint.value?.[0];
if (timestamp && (!startTime || new Date(timestamp) < startTime)) {
startTime = new Date(timestamp);
}
});
if (!startTime) {
// Calculate default start time based on dayDifference
startTime = new Date(
endTime.getTime() - dayDifference * 24 * 3600 * 1000
);
}
}
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: [
{
type: "time",
min: startTime,
max: endTime,
},
{
type: "time", type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false, show: false,
}, },
splitLine: { ],
show: true,
lineStyle: splitLineStyle,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: { yAxis: {
type: this.logarithmicScale ? "log" : "value", type: this.logarithmicScale ? "log" : "value",
name: this.unit, name: this.unit,
@@ -240,24 +290,24 @@ export class StatisticsChart extends LitElement {
align: "left", align: "left",
}, },
position: computeRTL(this.hass) ? "right" : "left", position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore scale: true,
scale: this.chartType !== "bar", min: this._clampYAxis(minYAxis),
min: this.fitYData ? undefined : this.minYAxis, max: this._clampYAxis(maxYAxis),
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: { splitLine: {
show: true, show: true,
lineStyle: splitLineStyle,
}, },
}, },
legend: { legend: {
show: !this.hideLegend, show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle", icon: "circle",
padding: [20, 0], padding: [20, 0],
data: this._legendData, data: this._legendData,
}, },
grid: { grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0 ...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 20, left: 1,
right: 1, right: 1,
bottom: 0, bottom: 0,
containLabel: true, containLabel: true,
@@ -318,6 +368,7 @@ export class StatisticsChart extends LitElement {
if (endTime > new Date()) { if (endTime > new Date()) {
endTime = new Date(); endTime = new Date();
} }
this.endTime = endTime;
let unit: string | undefined | null; let unit: string | undefined | null;
@@ -369,10 +420,12 @@ export class StatisticsChart extends LitElement {
) { ) {
// if the end of the previous data doesn't match the start of the current data, // if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value. // we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]); d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, null]); d.data!.push([prevEndTime, null]);
} }
d.data!.push([start, ...dataValues[i]!]); d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
}); });
prevValues = dataValues; prevValues = dataValues;
prevEndTime = end; prevEndTime = end;
@@ -421,9 +474,14 @@ export class StatisticsChart extends LitElement {
displayedLegend = displayedLegend || showLegend; displayedLegend = displayedLegend || showLegend;
} }
statTypes.push(type); statTypes.push(type);
const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = { const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`, id: `${statistic_id}-${type}`,
type: this.chartType, type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
smoothMonotone: "x",
cursor: "default", cursor: "default",
data: [], data: [],
name: name name: name
@@ -435,6 +493,7 @@ export class StatisticsChart extends LitElement {
), ),
symbol: "circle", symbol: "circle",
symbolSize: 0, symbolSize: 0,
animationDurationUpdate: 0,
lineStyle: { lineStyle: {
width: 1.5, width: 1.5,
}, },
@@ -442,21 +501,16 @@ export class StatisticsChart extends LitElement {
this.chartType === "bar" this.chartType === "bar"
? { ? {
borderRadius: [4, 4, 0, 0], borderRadius: [4, 4, 0, 0],
borderColor: borderColor,
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5, borderWidth: 1.5,
} }
: undefined, : undefined,
color: band ? color + "3F" : color + "7F", color: this.chartType === "bar" ? backgroundColor : borderColor,
}; };
if (band && this.chartType === "line") { if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`; series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none"; (series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") { if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = { (series as LineSeriesOption).areaStyle = {
color: color + "3F", color: color + "3F",
@@ -489,7 +543,7 @@ export class StatisticsChart extends LitElement {
} }
} else if (type === "max" && this.chartType === "line") { } else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0; const max = stat.max || 0;
val.push(max - (stat.min || 0)); val.push(Math.abs(max - (stat.min || 0)));
val.push(max); val.push(max);
} else { } else {
val.push(stat[type] ?? null); val.push(stat[type] ?? null);
@@ -518,6 +572,7 @@ export class StatisticsChart extends LitElement {
color, color,
type: this.chartType, type: this.chartType,
data: [], data: [],
xAxisIndex: 1,
}); });
}); });
@@ -529,6 +584,26 @@ export class StatisticsChart extends LitElement {
this._statisticIds = statisticIds; this._statisticIds = statisticIds;
} }
private _transformDataValue(val: [Date, ...(number | null)[]]) {
if (this.chartType === "bar" && val[1] && val[1] < 0) {
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
}
return val;
}
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;
}
static styles = css` static styles = css`
:host { :host {
display: block; display: block;

View File

@@ -295,10 +295,12 @@ export class HaAssistChat extends LitElement {
this._addMessage(userMessage); this._addMessage(userMessage);
this.requestUpdate("_audioRecorder"); this.requestUpdate("_audioRecorder");
const hassMessage: AssistMessage = { let hassMessage = {
who: "hass", who: "hass",
text: "…", text: "…",
error: false,
}; };
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it // To make sure the answer is placed at the right user text, we add it before we process it
try { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
@@ -328,6 +330,43 @@ export class HaAssistChat extends LitElement {
this._addMessage(hassMessage); this._addMessage(hassMessage);
} }
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") { if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id; this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain; const plain = event.data.intent_output.response.speech?.plain;
@@ -435,28 +474,71 @@ export class HaAssistChat extends LitElement {
this._processing = true; this._processing = true;
this._audio?.pause(); this._audio?.pause();
this._addMessage({ who: "user", text }); this._addMessage({ who: "user", text });
const message: AssistMessage = { let hassMessage = {
who: "hass", who: "hass",
text: "…", text: "…",
error: false,
}; };
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it // To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(message); this._addMessage(hassMessage);
try { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
this.hass, this.hass,
(event) => { (event) => {
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message and previous message has content
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (
currentDeltaRole &&
delta.role === "assistant" &&
hassMessage.text !== "…"
) {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") { if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id; this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain; const plain = event.data.intent_output.response.speech?.plain;
if (plain) { if (plain) {
message.text = plain.speech; hassMessage.text = plain.speech;
} }
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
unsub(); unsub();
} }
if (event.type === "error") { if (event.type === "error") {
message.text = event.data.message; hassMessage.text = event.data.message;
message.error = true; hassMessage.error = true;
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
unsub(); unsub();
} }
@@ -470,8 +552,8 @@ export class HaAssistChat extends LitElement {
} }
); );
} catch { } catch {
message.text = this.hass.localize("ui.dialogs.voice_command.error"); hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
message.error = true; hassMessage.error = true;
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
} finally { } finally {
this._processing = false; this._processing = false;

View File

@@ -329,15 +329,12 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) { :host([clearable]) {
position: relative; position: relative;
} }
:host {
display: block;
}
.time-input-wrap-wrap { .time-input-wrap-wrap {
display: flex; display: flex;
} }
.time-input-wrap { .time-input-wrap {
display: flex; display: flex;
flex: 1; flex: var(--time-input-flex, unset);
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0; border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden; overflow: hidden;
position: relative; position: relative;

View File

@@ -9,12 +9,13 @@ import {
endOfMonth, endOfMonth,
endOfWeek, endOfWeek,
endOfYear, endOfYear,
isThisYear,
startOfDay, startOfDay,
startOfMonth, startOfMonth,
startOfWeek, startOfWeek,
startOfYear, startOfYear,
isThisYear,
} from "date-fns"; } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@@ -22,16 +23,18 @@ import { ifDefined } from "lit/directives/if-defined";
import { calcDate, shiftDateRange } from "../common/datetime/calc_date"; import { calcDate, shiftDateRange } from "../common/datetime/calc_date";
import { firstWeekdayIndex } from "../common/datetime/first_weekday"; import { firstWeekdayIndex } from "../common/datetime/first_weekday";
import { import {
formatShortDateTimeWithYear,
formatShortDateTime, formatShortDateTime,
formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time"; } from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm"; import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import { TimeZone } from "../data/translation";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./date-range-picker"; import "./date-range-picker";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-textarea";
import "./ha-icon-button-next"; import "./ha-icon-button-next";
import "./ha-icon-button-prev"; import "./ha-icon-button-prev";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>; export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply} ?auto-apply=${this.autoApply}
time-picker=${this.timePicker} time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format} twentyfour-hours=${this._hour24format}
start-date=${this.startDate.toISOString()} start-date=${this._formatDate(this.startDate)}
end-date=${this.endDate.toISOString()} end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false} ?ranges=${this.ranges !== false}
opening-direction=${ifDefined( opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection this.openingDirection || this._calcedOpeningDirection
)} )}
first-day=${firstWeekdayIndex(this.hass.locale)} first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language} language=${this.hass.locale.language}
@change=${this._handleChange}
> >
<div slot="input" class="date-range-inputs" @click=${this._handleClick}> <div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal ${!this.minimal
@@ -325,9 +329,31 @@ export class HaDateRangePicker extends LitElement {
} }
private _applyDateRange() { private _applyDateRange() {
if (this.hass.locale.time_zone === TimeZone.server) {
const dateRangePicker = this._dateRangePicker;
const startDate = fromZonedTime(
dateRangePicker.start,
this.hass.config.time_zone
);
const endDate = fromZonedTime(
dateRangePicker.end,
this.hass.config.time_zone
);
dateRangePicker.clickRange([startDate, endDate]);
}
this._dateRangePicker.clickedApply(); this._dateRangePicker.clickedApply();
} }
private _formatDate(date: Date): string {
if (this.hass.locale.time_zone === TimeZone.server) {
return toZonedTime(date, this.hass.config.time_zone).toISOString();
}
return date.toISOString();
}
private get _dateRangePicker() { private get _dateRangePicker() {
const dateRangePicker = this.shadowRoot!.querySelector( const dateRangePicker = this.shadowRoot!.querySelector(
"date-range-picker" "date-range-picker"
@@ -358,6 +384,16 @@ export class HaDateRangePicker extends LitElement {
} }
} }
private _handleChange(ev: CustomEvent) {
ev.stopPropagation();
const startDate = ev.detail.startDate;
const endDate = ev.detail.endDate;
fireEvent(this, "value-changed", {
value: { startDate, endDate },
});
}
static styles = css` static styles = css`
ha-icon-button { ha-icon-button {

View File

@@ -11,6 +11,7 @@ import "./ha-icon-button";
import { blankBeforePercent } from "../common/translations/blank_before_percent"; import { blankBeforePercent } from "../common/translations/blank_before_percent";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import { bytesToString } from "../util/bytes-to-string"; import { bytesToString } from "../util/bytes-to-string";
import type { LocalizeFunc } from "../common/translations/localize";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@@ -23,6 +24,8 @@ declare global {
export class HaFileUpload extends LitElement { export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property() public accept!: string; @property() public accept!: string;
@property() public icon?: string; @property() public icon?: string;
@@ -31,6 +34,10 @@ export class HaFileUpload extends LitElement {
@property() public secondary?: string; @property() public secondary?: string;
@property({ attribute: "uploading-label" }) public uploadingLabel?: string;
@property({ attribute: "delete-label" }) public deleteLabel?: string;
@property() public supports?: string; @property() public supports?: string;
@property({ type: Object }) public value?: File | File[] | FileList | string; @property({ type: Object }) public value?: File | File[] | FileList | string;
@@ -73,23 +80,22 @@ export class HaFileUpload extends LitElement {
} }
public render(): TemplateResult { public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
return html` return html`
${this.uploading ${this.uploading
? html`<div class="container"> ? html`<div class="container">
<div class="uploading"> <div class="uploading">
<span class="header" <span class="header"
>${this.value >${this.uploadingLabel || this.value
? this.hass?.localize( ? localize("ui.components.file-upload.uploading_name", {
"ui.components.file-upload.uploading_name", name: this._name,
{ name: this._name } })
) : localize("ui.components.file-upload.uploading")}</span
: this.hass?.localize(
"ui.components.file-upload.uploading"
)}</span
> >
${this.progress ${this.progress
? html`<div class="progress"> ? html`<div class="progress">
${this.progress}${blankBeforePercent(this.hass!.locale)}% ${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
</div>` </div>`
: nothing} : nothing}
</div> </div>
@@ -116,14 +122,11 @@ export class HaFileUpload extends LitElement {
.path=${this.icon || mdiFileUpload} .path=${this.icon || mdiFileUpload}
></ha-svg-icon> ></ha-svg-icon>
<ha-button unelevated @click=${this._openFilePicker}> <ha-button unelevated @click=${this._openFilePicker}>
${this.label || ${this.label || localize("ui.components.file-upload.label")}
this.hass?.localize("ui.components.file-upload.label")}
</ha-button> </ha-button>
<span class="secondary" <span class="secondary"
>${this.secondary || >${this.secondary ||
this.hass?.localize( localize("ui.components.file-upload.secondary")}</span
"ui.components.file-upload.secondary"
)}</span
> >
<span class="supports">${this.supports}</span>` <span class="supports">${this.supports}</span>`
: typeof this.value === "string" : typeof this.value === "string"
@@ -136,8 +139,7 @@ export class HaFileUpload extends LitElement {
</div> </div>
<ha-icon-button <ha-icon-button
@click=${this._clearValue} @click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") || .label=${this.deleteLabel || localize("ui.common.delete")}
"Delete"}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
</div>` </div>`
@@ -155,8 +157,8 @@ export class HaFileUpload extends LitElement {
</div> </div>
<ha-icon-button <ha-icon-button
@click=${this._clearValue} @click=${this._clearValue}
.label=${this.hass?.localize("ui.common.delete") || .label=${this.deleteLabel ||
"Delete"} localize("ui.common.delete")}
.path=${mdiDelete} .path=${mdiDelete}
></ha-icon-button> ></ha-icon-button>
</div>` </div>`
@@ -238,6 +240,10 @@ export class HaFileUpload extends LitElement {
border-radius: var(--mdc-shape-small, 4px); border-radius: var(--mdc-shape-small, 4px);
height: 100%; height: 100%;
} }
.row {
display: flex;
align-items: center;
}
label.container { label.container {
border: dashed 1px border: dashed 1px
var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42));

View File

@@ -64,9 +64,13 @@ export class HaNetwork extends LitElement {
> >
</ha-checkbox> </ha-checkbox>
</span> </span>
<span slot="heading" data-for="auto_configure"> Auto Configure </span> <span slot="heading" data-for="auto_configure">
${this.hass.localize(
"ui.panel.config.network.adapter.auto_configure"
)}
</span>
<span slot="description" data-for="auto_configure"> <span slot="description" data-for="auto_configure">
Detected: ${this.hass.localize("ui.panel.config.network.adapter.detected")}:
${format_auto_detected_interfaces(this.networkConfig.adapters)} ${format_auto_detected_interfaces(this.networkConfig.adapters)}
</span> </span>
</ha-settings-row> </ha-settings-row>
@@ -85,18 +89,21 @@ export class HaNetwork extends LitElement {
</ha-checkbox> </ha-checkbox>
</span> </span>
<span slot="heading"> <span slot="heading">
Adapter: ${adapter.name} ${this.hass.localize(
"ui.panel.config.network.adapter.adapter"
)}:
${adapter.name}
${adapter.default ${adapter.default
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon> ? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
(Default)` (${this.hass.localize("ui.common.default")})`
: ""} : nothing}
</span> </span>
<span slot="description"> <span slot="description">
${format_addresses([...adapter.ipv4, ...adapter.ipv6])} ${format_addresses([...adapter.ipv4, ...adapter.ipv6])}
</span> </span>
</ha-settings-row>` </ha-settings-row>`
) )
: ""} : nothing}
`; `;
} }

View File

@@ -8,7 +8,7 @@ import { customElement, property, query, state } from "lit/decorators";
// and "qr-scanner" defaults to a suboptimal implementation if it is not available. // and "qr-scanner" defaults to a suboptimal implementation if it is not available.
// The following import makes a better implementation available that is based on a // The following import makes a better implementation available that is based on a
// WebAssembly port of ZXing: // WebAssembly port of ZXing:
import { setZXingModuleOverrides } from "barcode-detector"; import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner"; import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation"; import { stopPropagation } from "../common/dom/stop_propagation";
@@ -21,13 +21,15 @@ import "./ha-list-item";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
setZXingModuleOverrides({ prepareZXingModule({
overrides: {
locateFile: (path: string, prefix: string) => { locateFile: (path: string, prefix: string) => {
if (path.endsWith(".wasm")) { if (path.endsWith(".wasm")) {
return "/static/js/zxing_reader.wasm"; return "/static/js/zxing_reader.wasm";
} }
return prefix + path; return prefix + path;
}, },
},
}); });
@customElement("ha-qr-scanner") @customElement("ha-qr-scanner")

View File

@@ -1,4 +1,4 @@
import { css, html, LitElement, svg } from "lit"; import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const"; import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
@@ -41,8 +41,8 @@ export class HatGraphBranch extends LitElement {
branches.push({ branches.push({
x: width / 2 + total_width, x: width / 2 + total_width,
height, height,
start: c.hasAttribute("graphStart"), start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graphEnd"), end: c.hasAttribute("graph-end"),
track: c.hasAttribute("track"), track: c.hasAttribute("track"),
}); });
total_width += width; total_width += width;
@@ -65,11 +65,8 @@ export class HatGraphBranch extends LitElement {
return html` return html`
<slot name="head"></slot> <slot name="head"></slot>
${!this.start ${!this.start
? svg` ? html`
<svg <svg id="top" width=${this._totalWidth}>
id="top"
width="${this._totalWidth}"
>
${this._branches.map((branch) => ${this._branches.map((branch) =>
branch.start branch.start
? "" ? ""
@@ -86,7 +83,7 @@ export class HatGraphBranch extends LitElement {
)} )}
</svg> </svg>
` `
: ""} : nothing}
<div id="branches"> <div id="branches">
<svg id="lines" width=${this._totalWidth} height=${this._maxHeight}> <svg id="lines" width=${this._totalWidth} height=${this._maxHeight}>
${this._branches.map((branch) => { ${this._branches.map((branch) => {
@@ -107,11 +104,8 @@ export class HatGraphBranch extends LitElement {
</div> </div>
${!this.short ${!this.short
? svg` ? html`
<svg <svg id="bottom" width=${this._totalWidth}>
id="bottom"
width="${this._totalWidth}"
>
${this._branches.map((branch) => { ${this._branches.map((branch) => {
if (branch.end) return ""; if (branch.end) return "";
return svg` return svg`
@@ -128,7 +122,7 @@ export class HatGraphBranch extends LitElement {
})} })}
</svg> </svg>
` `
: ""} : nothing}
`; `;
} }

View File

@@ -108,6 +108,34 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
intent_input: string; intent_input: string;
}; };
} }
interface ConversationChatLogAssistantDelta {
role: "assistant";
content: string;
tool_calls: {
id: string;
tool_name: string;
tool_args: Record<string, unknown>;
}[];
}
interface ConversationChatLogToolResultDelta {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: unknown;
}
interface PipelineIntentProgressEvent extends PipelineEventBase {
type: "intent-progress";
data: {
chat_log_delta:
| Partial<ConversationChatLogAssistantDelta>
// These always come in 1 chunk
| ConversationChatLogToolResultDelta;
};
}
interface PipelineIntentEndEvent extends PipelineEventBase { interface PipelineIntentEndEvent extends PipelineEventBase {
type: "intent-end"; type: "intent-end";
data: { data: {
@@ -141,6 +169,7 @@ export type PipelineRunEvent =
| PipelineSTTStartEvent | PipelineSTTStartEvent
| PipelineSTTEndEvent | PipelineSTTEndEvent
| PipelineIntentStartEvent | PipelineIntentStartEvent
| PipelineIntentProgressEvent
| PipelineIntentEndEvent | PipelineIntentEndEvent
| PipelineTTSStartEvent | PipelineTTSStartEvent
| PipelineTTSEndEvent; | PipelineTTSEndEvent;

View File

@@ -1,3 +1,4 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns"; import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket"; import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@@ -12,6 +13,7 @@ import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration"; import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date"; import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api";
export const enum BackupScheduleRecurrence { export const enum BackupScheduleRecurrence {
NEVER = "never", NEVER = "never",
@@ -104,6 +106,9 @@ export interface BackupContent {
name: string; name: string;
agents: Record<string, BackupContentAgent>; agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[]; failed_agent_ids?: string[];
extra_metadata?: {
"supervisor.addon_update"?: string;
};
with_automatic_settings: boolean; with_automatic_settings: boolean;
} }
@@ -227,27 +232,23 @@ export const restoreBackup = (
export const uploadBackup = async ( export const uploadBackup = async (
hass: HomeAssistant, hass: HomeAssistant,
file: File, file: File,
agent_ids: string[] agentIds: string[]
): Promise<void> => { ): Promise<{ backup_id: string }> => {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
const params = agent_ids.reduce((acc, agent_id) => { const params = new URLSearchParams();
acc.append("agent_id", agent_id);
return acc;
}, new URLSearchParams());
const resp = await hass.fetchWithAuth( agentIds.forEach((agentId) => {
`/api/backup/upload?${params.toString()}`, params.append("agent_id", agentId);
{ });
return handleFetchPromise(
hass.fetchWithAuth(`/api/backup/upload?${params.toString()}`, {
method: "POST", method: "POST",
body: fd, body: fd,
} })
); );
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
}; };
export const getPreferredAgentForDownload = (agents: string[]) => { export const getPreferredAgentForDownload = (agents: string[]) => {
@@ -319,6 +320,29 @@ export const computeBackupAgentName = (
export const computeBackupSize = (backup: BackupContent) => export const computeBackupSize = (backup: BackupContent) =>
Math.max(...Object.values(backup.agents).map((agent) => agent.size)); Math.max(...Object.values(backup.agents).map((agent) => agent.size));
export type BackupType = "automatic" | "manual" | "addon_update";
const BACKUP_TYPE_ORDER: BackupType[] = ["automatic", "manual", "addon_update"];
export const getBackupTypes = memoize((isHassio: boolean) =>
isHassio
? BACKUP_TYPE_ORDER
: BACKUP_TYPE_ORDER.filter((type) => type !== "addon_update")
);
export const computeBackupType = (
backup: BackupContent,
isHassio: boolean
): BackupType => {
if (backup.with_automatic_settings) {
return "automatic";
}
if (isHassio && backup.extra_metadata?.["supervisor.addon_update"] != null) {
return "addon_update";
}
return "manual";
};
export const compareAgents = (a: string, b: string) => { export const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a); const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b); const isLocalB = isLocalAgent(b);
@@ -422,3 +446,13 @@ export const getFormattedBackupTime = memoizeOne(
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`; return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
} }
); );
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
export interface BackupUploadFileFormData {
file?: File;
}
export const INITIAL_UPLOAD_FORM_DATA: BackupUploadFileFormData = {
file: undefined,
};

View File

@@ -0,0 +1,66 @@
import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupContentExtended } from "./backup";
import type {
BackupManagerState,
RestoreBackupStage,
RestoreBackupState,
} from "./backup_manager";
export interface BackupOnboardingInfo {
state: BackupManagerState;
last_non_idle_event?: {
manager_state: BackupManagerState;
stage: RestoreBackupStage | null;
state: RestoreBackupState;
reason: string | null;
} | null;
}
export interface BackupOnboardingConfig extends BackupOnboardingInfo {
backups: BackupContentExtended[];
}
export const fetchBackupOnboardingInfo = async () =>
handleFetchPromise<BackupOnboardingConfig>(
fetch("/api/onboarding/backup/info")
);
export interface RestoreOnboardingBackupParams {
backup_id: string;
agent_id: string;
password?: string;
restore_addons?: string[];
restore_database?: boolean;
restore_folders?: string[];
}
export const restoreOnboardingBackup = async (
params: RestoreOnboardingBackupParams
) =>
handleFetchPromise(
fetch("/api/onboarding/backup/restore", {
method: "POST",
body: JSON.stringify(params),
})
);
export const uploadOnboardingBackup = async (
file: File,
agentIds: string[]
): Promise<{ backup_id: string }> => {
const fd = new FormData();
fd.append("file", file);
const params = new URLSearchParams();
agentIds.forEach((agentId) => {
params.append("agent_id", agentId);
});
return handleFetchPromise(
fetch(`/api/onboarding/backup/upload?${params.toString()}`, {
method: "POST",
body: fd,
})
);
};

View File

@@ -181,3 +181,6 @@ export const updateCloudGoogleEntityConfig = (
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) => export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync"); hass.callApi("POST", "cloud/google_actions/sync");
export const fetchSupportPackage = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "cloud/support_package");

View File

@@ -19,6 +19,8 @@ export interface ConfigEntry {
supports_remove_device: boolean; supports_remove_device: boolean;
supports_unload: boolean; supports_unload: boolean;
supports_reconfigure: boolean; supports_reconfigure: boolean;
supported_subentry_types: Record<string, { supports_reconfigure: boolean }>;
num_subentries: number;
pref_disable_new_entities: boolean; pref_disable_new_entities: boolean;
pref_disable_polling: boolean; pref_disable_polling: boolean;
disabled_by: "user" | null; disabled_by: "user" | null;
@@ -27,6 +29,30 @@ export interface ConfigEntry {
error_reason_translation_placeholders: Record<string, string> | null; error_reason_translation_placeholders: Record<string, string> | null;
} }
export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
hass.callWS<SubEntry[]>({
type: "config_entries/subentries/list",
entry_id,
});
export const deleteSubEntry = (
hass: HomeAssistant,
entry_id: string,
subentry_id: string
) =>
hass.callWS({
type: "config_entries/subentries/delete",
entry_id,
subentry_id,
});
export type ConfigEntryMutableParams = Partial< export type ConfigEntryMutableParams = Partial<
Pick< Pick<
ConfigEntry, ConfigEntry,

View File

@@ -2,7 +2,11 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types"; import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries"; import type { ConfigEntry } from "./config_entries";
export type FlowType = "config_flow" | "options_flow" | "repair_flow"; export type FlowType =
| "config_flow"
| "config_subentries_flow"
| "options_flow"
| "repair_flow";
export interface DataEntryFlowProgressedEvent { export interface DataEntryFlowProgressedEvent {
type: "data_entry_flow_progressed"; type: "data_entry_flow_progressed";

View File

@@ -17,6 +17,7 @@ export {
export interface DeviceRegistryEntry extends RegistryEntry { export interface DeviceRegistryEntry extends RegistryEntry {
id: string; id: string;
config_entries: string[]; config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
connections: [string, string][]; connections: [string, string][];
identifiers: [string, string][]; identifiers: [string, string][];
manufacturer: string | null; manufacturer: string | null;

View File

@@ -50,6 +50,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
icon: string | null; icon: string | null;
platform: string; platform: string;
config_entry_id: string | null; config_entry_id: string | null;
config_subentry_id: string | null;
device_id: string | null; device_id: string | null;
area_id: string | null; area_id: string | null;
labels: string[]; labels: string[];

View File

@@ -1,6 +1,5 @@
import { atLeastVersion } from "../../common/config/version"; import { atLeastVersion } from "../../common/config/version";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { handleFetchPromise } from "../../util/hass-call-api";
import type { HassioResponse } from "./common"; import type { HassioResponse } from "./common";
import { hassioApiResultExtractor } from "./common"; import { hassioApiResultExtractor } from "./common";
@@ -82,10 +81,9 @@ export const fetchHassioBackups = async (
}; };
export const fetchHassioBackupInfo = async ( export const fetchHassioBackupInfo = async (
hass: HomeAssistant | undefined, hass: HomeAssistant,
backup: string backup: string
): Promise<HassioBackupDetail> => { ): Promise<HassioBackupDetail> => {
if (hass) {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) { if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return hass.callWS({ return hass.callWS({
type: "supervisor/api", type: "supervisor/api",
@@ -103,15 +101,6 @@ export const fetchHassioBackupInfo = async (
}/${backup}/info` }/${backup}/info`
) )
); );
}
// When called from onboarding we don't have hass
return hassioApiResultExtractor(
await handleFetchPromise(
fetch(`/api/hassio/backups/${backup}/info`, {
method: "GET",
})
)
);
}; };
export const reloadHassioBackups = async (hass: HomeAssistant) => { export const reloadHassioBackups = async (hass: HomeAssistant) => {
@@ -240,24 +229,15 @@ export const uploadBackup = async (
}; };
export const restoreBackup = async ( export const restoreBackup = async (
hass: HomeAssistant | undefined, hass: HomeAssistant,
type: HassioBackupDetail["type"], type: HassioBackupDetail["type"],
backupSlug: string, backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams, backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useSnapshotUrl: boolean useSnapshotUrl: boolean
): Promise<void> => { ): Promise<void> => {
if (hass) {
await hass.callApi<HassioResponse<{ job_id: string }>>( await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST", "POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`, `hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`,
backupDetails backupDetails
); );
} else {
await handleFetchPromise(
fetch(`/api/hassio/backups/${backupSlug}/restore/${type}`, {
method: "POST",
body: JSON.stringify(backupDetails),
})
);
}
}; };

View File

@@ -0,0 +1,46 @@
import type { HomeAssistant } from "../types";
import type { DataEntryFlowStep } from "./data_entry_flow";
const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`,
};
export const createSubConfigFlow = (
hass: HomeAssistant,
configEntryId: string,
subFlowType: string,
subentry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
);
export const fetchSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi<DataEntryFlowStep>(
"GET",
`config/config_entries/subentries/flow/${flowId}`,
undefined,
HEADERS
);
export const handleSubConfigFlowStep = (
hass: HomeAssistant,
flowId: string,
data: Record<string, any>
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/subentries/flow/${flowId}`,
data,
HEADERS
);
export const deleteSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi("DELETE", `config/config_entries/subentries/flow/${flowId}`);

View File

@@ -63,6 +63,7 @@ export type TranslationCategory =
| "entity_component" | "entity_component"
| "exceptions" | "exceptions"
| "config" | "config"
| "config_subentries"
| "config_panel" | "config_panel"
| "options" | "options"
| "device_automation" | "device_automation"

View File

@@ -1,5 +1,11 @@
import type { Connection } from "home-assistant-js-websocket"; import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket"; import { createCollection } from "home-assistant-js-websocket";
import type { HomeAssistant, ThemeSettings } from "../types";
import {
fetchFrontendUserData,
saveFrontendUserData,
subscribeFrontendUserData,
} from "./frontend";
export interface ThemeVars { export interface ThemeVars {
// Incomplete // Incomplete
@@ -50,3 +56,16 @@ export const subscribeThemes = (
conn, conn,
onChange onChange
); );
export const SELECTED_THEME_KEY = "selectedTheme";
export const saveSelectedTheme = (hass: HomeAssistant, data?: ThemeSettings) =>
saveFrontendUserData(hass.connection, SELECTED_THEME_KEY, data);
export const subscribeSelectedTheme = (
hass: HomeAssistant,
callback: (selectedTheme?: ThemeSettings | null) => void
) => subscribeFrontendUserData(hass.connection, SELECTED_THEME_KEY, callback);
export const fetchSelectedTheme = (hass: HomeAssistant) =>
fetchFrontendUserData(hass.connection, SELECTED_THEME_KEY);

View File

@@ -282,6 +282,8 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig} .flowConfig=${this._params.flowConfig}
.step=${this._step} .step=${this._step}
.hass=${this.hass} .hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult}
></step-flow-create-entry> ></step-flow-create-entry>
`} `}
`} `}

View File

@@ -77,7 +77,7 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)(); (await this._unsub)();
this._unsub = undefined; this._unsub = undefined;
} }
if (this.flowType === "repair_flow") { if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return; return;
} }
try { try {

View File

@@ -147,7 +147,7 @@ class FlowPreviewTemplate extends LitElement {
(await this._unsub)(); (await this._unsub)();
this._unsub = undefined; this._unsub = undefined;
} }
if (this.flowType === "repair_flow") { if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return; return;
} }
try { try {

View File

@@ -16,7 +16,9 @@ export const loadConfigFlowDialog = loadDataEntryFlowDialog;
export const showConfigFlowDialog = ( export const showConfigFlowDialog = (
element: HTMLElement, element: HTMLElement,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
entryId?: string;
}
): void => ): void =>
showFlowDialog(element, dialogParams, { showFlowDialog(element, dialogParams, {
flowType: "config_flow", flowType: "config_flow",

View File

@@ -148,8 +148,8 @@ export interface DataEntryFlowDialogParams {
}) => void; }) => void;
flowConfig: FlowConfig; flowConfig: FlowConfig;
showAdvanced?: boolean; showAdvanced?: boolean;
entryId?: string;
dialogParentElement?: HTMLElement; dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
} }
export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow"); export const loadDataEntryFlowDialog = () => import("./dialog-data-entry-flow");

View File

@@ -0,0 +1,275 @@
import { html } from "lit";
import type { ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import {
createSubConfigFlow,
deleteSubConfigFlow,
fetchSubConfigFlow,
handleSubConfigFlowStep,
} from "../../data/sub_config_flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
export const loadSubConfigFlowDialog = loadDataEntryFlowDialog;
export const showSubConfigFlowDialog = (
element: HTMLElement,
configEntry: ConfigEntry,
flowType: string,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
subEntryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_subentries_flow",
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createSubConfigFlow(hass, handler, flowType, dialogParams.subEntryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config_subentries", configEntry.domain),
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
]);
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: step.reason;
},
renderShowFormStepHeader(hass, step) {
return (
hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderShowFormStepFieldLabel(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.name`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data.${field.name}`
) || field.name
);
},
renderShowFormStepFieldHelper(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.description`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data_description.${field.name}`,
step.description_placeholders
);
return description
? html`<ha-markdown breaks .content=${description}></ha-markdown>`
: "";
},
renderShowFormStepFieldError(hass, step, error) {
return (
hass.localize(
`component.${step.translation_domain || step.translation_domain || configEntry.domain}.config_subentries.${flowType}.error.${error}`,
step.description_placeholders
) || error
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
},
renderShowFormStepSubmitButton(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.submit`
) ||
hass.localize(
`ui.panel.config.integrations.config_flow.${
step.last_step === false ? "next" : "submit"
}`
)
);
},
renderExternalStepHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) ||
hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)
);
},
renderExternalStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return html`
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.external_step.description"
)}
</p>
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
`;
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.create_entry.${
step.description || "default"
}`,
step.description_placeholders
);
return html`
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: step.title }
)}
</p>
`;
},
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormProgressDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.progress.${step.progress_action}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderMenuDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.menu_options.${option}`,
step.description_placeholders
);
},
renderLoadingDescription(hass, reason, handler, step) {
if (reason !== "loading_flow" && reason !== "loading_step") {
return "";
}
const domain = step?.handler || handler;
return hass.localize(
`ui.panel.config.integrations.config_flow.loading.${reason}`,
{
integration: domain
? domainToName(hass.localize, domain)
: // when we are continuing a config flow, we only know the ID and not the domain
hass.localize(
"ui.panel.config.integrations.config_flow.loading.fallback_title"
),
}
);
},
});

View File

@@ -60,6 +60,7 @@ class StepFlowAbort extends LitElement {
dialogClosedCallback: this.params.dialogClosedCallback, dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.domain, startFlowHandler: this.domain,
showAdvanced: this.hass.userData?.showAdvanced, showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
}); });
}, },
}); });

View File

@@ -19,6 +19,7 @@ import { showAlertDialog } from "../generic/show-dialog-box";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog"; import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
import type { FlowConfig } from "./show-dialog-data-entry-flow"; import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles"; import { configFlowContentStyles } from "./styles";
import { navigate } from "../../common/navigate";
@customElement("step-flow-create-entry") @customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement { class StepFlowCreateEntry extends LitElement {
@@ -28,6 +29,8 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry; @property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
navigateToResult = false;
private _devices = memoizeOne( private _devices = memoizeOne(
( (
showDevices: boolean, showDevices: boolean,
@@ -65,7 +68,8 @@ class StepFlowCreateEntry extends LitElement {
if ( if (
devices.length !== 1 || devices.length !== 1 ||
devices[0].primary_config_entry !== this.step.result?.entry_id devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
) { ) {
return; return;
} }
@@ -151,6 +155,11 @@ class StepFlowCreateEntry extends LitElement {
private _flowDone(): void { private _flowDone(): void {
fireEvent(this, "flow-update", { step: undefined }); fireEvent(this, "flow-update", { step: undefined });
if (this.step.result && this.navigateToResult) {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
}
} }
private async _areaPicked(ev: CustomEvent) { private async _areaPicked(ev: CustomEvent) {

View File

@@ -33,6 +33,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"switch", "switch",
"valve", "valve",
"water_heater", "water_heater",
"weather",
]; ];
/** Domains with full height more info dialog */ /** Domains with full height more info dialog */
export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"]; export const DOMAINS_FULL_HEIGHT_MORE_INFO = ["update"];

View File

@@ -448,6 +448,10 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box; box-sizing: border-box;
margin-bottom: -16px; margin-bottom: -16px;
margin-top: -4px; margin-top: -4px;
--md-sys-color-surface: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
} }
ha-md-list-item { ha-md-list-item {

View File

@@ -1,18 +1,13 @@
import "@material/mwc-tab"; import "@material/mwc-tab";
import "@material/mwc-tab-bar"; import "@material/mwc-tab-bar";
import { import { mdiEye, mdiGauge, mdiWaterPercent, mdiWeatherWindy } from "@mdi/js";
mdiEye, import type { CSSResultGroup, PropertyValues } from "lit";
mdiGauge,
mdiThermometer,
mdiWaterPercent,
mdiWeatherWindy,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { formatDateWeekdayDay } from "../../../common/datetime/format_date"; import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
import { formatTimeWeekday } from "../../../common/datetime/format_time"; import { formatTime } from "../../../common/datetime/format_time";
import { formatNumber } from "../../../common/number/format_number";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import type { import type {
ForecastEvent, ForecastEvent,
@@ -23,11 +18,16 @@ import {
getDefaultForecastType, getDefaultForecastType,
getForecast, getForecast,
getSupportedForecastTypes, getSupportedForecastTypes,
getSecondaryWeatherAttribute,
getWeatherStateIcon,
getWeatherUnit,
getWind, getWind,
subscribeForecast, subscribeForecast,
weatherIcons, weatherSVGStyles,
} from "../../../data/weather"; } from "../../../data/weather";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../../../components/ha-relative-time";
import "../../../components/ha-state-icon";
@customElement("more-info-weather") @customElement("more-info-weather")
class MoreInfoWeather extends LitElement { class MoreInfoWeather extends LitElement {
@@ -137,23 +137,90 @@ class MoreInfoWeather extends LitElement {
const hourly = forecastData?.type === "hourly"; const hourly = forecastData?.type === "hourly";
const dayNight = forecastData?.type === "twice_daily"; const dayNight = forecastData?.type === "twice_daily";
const weatherStateIcon = getWeatherStateIcon(this.stateObj.state, this);
return html` return html`
${this._showValue(this.stateObj.attributes.temperature) <div class="content">
? html` <div class="icon-image">
<div class="flex"> ${weatherStateIcon ||
<ha-svg-icon .path=${mdiThermometer}></ha-svg-icon> html`
<div class="main"> <ha-state-icon
${this.hass.localize("ui.card.weather.attributes.temperature")} class="weather-icon"
.stateObj=${this.stateObj}
.hass=${this.hass}
></ha-state-icon>
`}
</div> </div>
<div class="info">
<div class="name-state">
<div class="state">
${this.hass.formatEntityState(this.stateObj)}
</div>
<div class="time-ago">
<ha-relative-time
id="last_changed"
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
<simple-tooltip animation-delay="0" for="last_changed">
<div> <div>
${this.hass.formatEntityAttributeValue( <div class="row">
<span class="column-name">
${this.hass.localize(
"ui.dialogs.more_info_control.last_changed"
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
</div>
<div class="row">
<span>
${this.hass.localize(
"ui.dialogs.more_info_control.last_updated"
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_updated}
capitalize
></ha-relative-time>
</div>
</div>
</simple-tooltip>
</div>
</div>
<div class="temp-attribute">
<div class="temp">
${this.stateObj.attributes.temperature !== undefined &&
this.stateObj.attributes.temperature !== null
? html`
${formatNumber(
this.stateObj.attributes.temperature,
this.hass.locale
)}&nbsp;<span
>${getWeatherUnit(
this.hass.config,
this.stateObj, this.stateObj,
"temperature" "temperature"
)}</span
>
`
: nothing}
</div>
<div class="attribute">
${getSecondaryWeatherAttribute(
this.hass,
this.stateObj,
forecast!
)} )}
</div> </div>
</div> </div>
` </div>
: ""} </div>
${this._showValue(this.stateObj.attributes.pressure) ${this._showValue(this.stateObj.attributes.pressure)
? html` ? html`
<div class="flex"> <div class="flex">
@@ -169,7 +236,7 @@ class MoreInfoWeather extends LitElement {
</div> </div>
</div> </div>
` `
: ""} : nothing}
${this._showValue(this.stateObj.attributes.humidity) ${this._showValue(this.stateObj.attributes.humidity)
? html` ? html`
<div class="flex"> <div class="flex">
@@ -185,7 +252,7 @@ class MoreInfoWeather extends LitElement {
</div> </div>
</div> </div>
` `
: ""} : nothing}
${this._showValue(this.stateObj.attributes.wind_speed) ${this._showValue(this.stateObj.attributes.wind_speed)
? html` ? html`
<div class="flex"> <div class="flex">
@@ -203,7 +270,7 @@ class MoreInfoWeather extends LitElement {
</div> </div>
</div> </div>
` `
: ""} : nothing}
${this._showValue(this.stateObj.attributes.visibility) ${this._showValue(this.stateObj.attributes.visibility)
? html` ? html`
<div class="flex"> <div class="flex">
@@ -219,7 +286,7 @@ class MoreInfoWeather extends LitElement {
</div> </div>
</div> </div>
` `
: ""} : nothing}
${forecast ${forecast
? html` ? html`
<div class="section"> <div class="section">
@@ -242,76 +309,90 @@ class MoreInfoWeather extends LitElement {
)} )}
</mwc-tab-bar>` </mwc-tab-bar>`
: nothing} : nothing}
<div class="forecast">
${forecast.map((item) => ${forecast.map((item) =>
this._showValue(item.templow) || this._showValue(item.temperature) this._showValue(item.templow) ||
? html`<div class="flex"> this._showValue(item.temperature)
${item.condition
? html` ? html`
<ha-svg-icon <div>
.path=${weatherIcons[item.condition]} <div>
></ha-svg-icon>
`
: ""}
<div class="main">
${dayNight ${dayNight
? html` ? html`
${formatDateWeekdayDay( ${formatDateWeekdayShort(
new Date(item.datetime), new Date(item.datetime),
this.hass!.locale, this.hass!.locale,
this.hass!.config this.hass!.config
)} )}
(${item.is_daytime !== false <div class="daynight">
${item.is_daytime !== false
? this.hass!.localize("ui.card.weather.day") ? this.hass!.localize("ui.card.weather.day")
: this.hass!.localize("ui.card.weather.night")}) : this.hass!.localize(
"ui.card.weather.night"
)}<br />
</div>
` `
: hourly : hourly
? html` ? html`
${formatTimeWeekday( ${formatTime(
new Date(item.datetime), new Date(item.datetime),
this.hass!.locale, this.hass!.locale,
this.hass!.config this.hass!.config
)} )}
` `
: html` : html`
${formatDateWeekdayDay( ${formatDateWeekdayShort(
new Date(item.datetime), new Date(item.datetime),
this.hass!.locale, this.hass!.locale,
this.hass!.config this.hass!.config
)} )}
`} `}
</div> </div>
<div class="templow"> ${this._showValue(item.condition)
${this._showValue(item.templow) ? html`
? this.hass.formatEntityAttributeValue( <div class="forecast-image-icon">
this.stateObj!, ${getWeatherStateIcon(
"templow", item.condition!,
item.templow this,
!(
item.is_daytime ||
item.is_daytime === undefined
) )
: hourly )}
? ""
: "—"}
</div> </div>
`
: nothing}
<div class="temp"> <div class="temp">
${this._showValue(item.temperature) ${this._showValue(item.temperature)
? this.hass.formatEntityAttributeValue( ? html`${formatNumber(
this.stateObj!, item.temperature,
"temperature", this.hass!.locale
item.temperature )}°`
)
: "—"} : "—"}
</div> </div>
</div>` <div class="templow">
: "" ${this._showValue(item.templow)
)} ? html`${formatNumber(
item.templow!,
this.hass!.locale
)}°`
: hourly
? nothing
: "—"}
</div>
</div>
` `
: ""} : nothing
)}
</div>
`
: nothing}
${this.stateObj.attributes.attribution ${this.stateObj.attributes.attribution
? html` ? html`
<div class="attribution"> <div class="attribution">
${this.stateObj.attributes.attribution} ${this.stateObj.attributes.attribution}
</div> </div>
` `
: ""} : nothing}
`; `;
} }
@@ -321,7 +402,10 @@ class MoreInfoWeather extends LitElement {
]; ];
} }
static styles = css` static get styles(): CSSResultGroup {
return [
weatherSVGStyles,
css`
ha-svg-icon { ha-svg-icon {
color: var(--paper-item-icon-color); color: var(--paper-item-icon-color);
margin-left: 8px; margin-left: 8px;
@@ -354,23 +438,150 @@ class MoreInfoWeather extends LitElement {
margin-inline-end: initial; margin-inline-end: initial;
} }
.temp, .attribution {
.templow { text-align: center;
min-width: 48px; margin-top: 16px;
text-align: right; }
.time-ago,
.attribute {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attribution,
.templow,
.daynight,
.attribute,
.time-ago {
color: var(--secondary-text-color);
}
.content {
display: flex;
flex-wrap: nowrap;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.icon-image {
display: flex;
align-items: center;
min-width: 64px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
.icon-image > * {
flex: 0 0 64px;
height: 64px;
}
.weather-icon {
--mdc-icon-size: 64px;
}
.info {
display: flex;
justify-content: space-between;
flex-grow: 1;
overflow: hidden;
}
.temp-attribute {
text-align: var(--float-end);
}
.temp-attribute .temp {
position: relative;
margin-right: 24px;
direction: ltr; direction: ltr;
} }
.templow { .temp-attribute .temp span {
margin: 0 16px; position: absolute;
color: var(--secondary-text-color); font-size: 24px;
top: 1px;
} }
.attribution { .state,
color: var(--secondary-text-color); .temp-attribute .temp {
text-align: center; font-size: 28px;
line-height: 1.2;
}
.attribute {
font-size: 14px;
line-height: 1;
}
.name-state {
overflow: hidden;
padding-right: 12px;
padding-inline-end: 12px;
padding-inline-start: initial;
width: 100%;
}
.state {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forecast {
display: flex;
justify-content: space-around;
padding: 16px;
padding-bottom: 0px;
overflow-x: auto;
scrollbar-color: var(--scrollbar-thumb-color) transparent;
scrollbar-width: thin;
mask-image: linear-gradient(
90deg,
transparent 0%,
black 5%,
black 94%,
transparent 100%
);
}
.forecast > div {
text-align: center;
padding: 0 10px;
}
.forecast .icon,
.forecast .temp {
margin: 4px 0;
}
.forecast .temp {
font-size: 16px;
}
.forecast-image-icon {
padding-top: 4px;
padding-bottom: 4px;
display: flex;
justify-content: center;
}
.forecast-image-icon > * {
width: 40px;
height: 40px;
--mdc-icon-size: 40px;
}
.forecast-icon {
--mdc-icon-size: 40px;
}
`,
];
} }
`;
private _showValue(item: number | string | undefined): boolean { private _showValue(item: number | string | undefined): boolean {
return typeof item !== "undefined" && item !== null; return typeof item !== "undefined" && item !== null;

View File

@@ -47,6 +47,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _assistConfiguration?: AssistSatelliteConfiguration; @state() private _assistConfiguration?: AssistSatelliteConfiguration;
@state() private _error?: string;
private _previousSteps: STEP[] = []; private _previousSteps: STEP[] = [];
private _nextStep?: STEP; private _nextStep?: STEP;
@@ -165,10 +167,14 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"update" "update"
)} )}
></ha-voice-assistant-setup-step-update>` ></ha-voice-assistant-setup-step-update>`
: this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: assistEntityState?.state === UNAVAILABLE : assistEntityState?.state === UNAVAILABLE
? this.hass.localize( ? html`<ha-alert alert-type="error"
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available" "ui.panel.config.voice_assistants.satellite_wizard.not_available"
) )}</ha-alert
>`
: this._step === STEP.CHECK : this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check ? html`<ha-voice-assistant-setup-step-check
.hass=${this.hass} .hass=${this.hass}
@@ -229,6 +235,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
} }
private async _fetchAssistConfiguration() { private async _fetchAssistConfiguration() {
try {
this._assistConfiguration = await fetchAssistSatelliteConfiguration( this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass, this.hass,
this._findDomainEntityId( this._findDomainEntityId(
@@ -237,7 +244,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"assist_satellite" "assist_satellite"
)! )!
); );
return this._assistConfiguration; } catch (err: any) {
this._error = err.message;
}
} }
private _goToPreviousStep() { private _goToPreviousStep() {
@@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn { .skip-btn {
margin-top: 6px; margin-top: 6px;
} }
ha-alert {
margin: 24px;
display: block;
}
`, `,
]; ];
} }

View File

@@ -44,6 +44,15 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
protected override willUpdate(changedProperties: PropertyValues) { protected override willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties); super.willUpdate(changedProperties);
if (changedProperties.has("assistConfiguration")) {
if (
this.assistConfiguration &&
!this.assistConfiguration.available_wake_words.length
) {
this._nextStep();
}
}
if (changedProperties.has("assistEntityId")) { if (changedProperties.has("assistEntityId")) {
this._detected = false; this._detected = false;
this._muteSwitchEntity = this.deviceEntities?.find( this._muteSwitchEntity = this.deviceEntities?.find(
@@ -135,13 +144,16 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
>` >`
: nothing} : nothing}
</div> </div>
<div class="footer centered"> ${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html`<div class="footer centered">
<ha-button @click=${this._changeWakeWord} <ha-button @click=${this._changeWakeWord}
>${this.hass.localize( >${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word" "ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
)}</ha-button )}</ha-button
> >
</div>`; </div>`
: nothing}`;
} }
private async _listenWakeWord() { private async _listenWakeWord() {

View File

@@ -20,6 +20,12 @@
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<%= renderTemplate("_style_base.html.template") %> <%= renderTemplate("_style_base.html.template") %>
<style> <style>
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
html { html {
background-color: var(--primary-background-color, #fafafa); background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121); color: var(--primary-text-color, #212121);

View File

@@ -17,7 +17,7 @@ export const litLocalizeLiteMixin = <T extends Constructor<LitElement>>(
@property({ attribute: false }) public localize: LocalizeFunc = empty; @property({ attribute: false }) public localize: LocalizeFunc = empty;
// Use browser language setup before login. // Use browser language setup before login.
@property() public language?: string = getLocalLanguage(); @property() public language: string = getLocalLanguage();
@property() public translationFragment?: string; @property() public translationFragment?: string;

View File

@@ -41,6 +41,7 @@ import "./onboarding-analytics";
import "./onboarding-create-user"; import "./onboarding-create-user";
import "./onboarding-loading"; import "./onboarding-loading";
import "./onboarding-welcome"; import "./onboarding-welcome";
import "./onboarding-restore-backup";
import "./onboarding-welcome-links"; import "./onboarding-welcome-links";
import { makeDialogManager } from "../dialogs/make-dialog-manager"; import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
@@ -157,8 +158,9 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
private _renderStep() { private _renderStep() {
if (this._restoring) { if (this._restoring) {
return html`<onboarding-restore-backup return html`<onboarding-restore-backup
.hass=${this.hass}
.localize=${this.localize} .localize=${this.localize}
.supervisor=${this._supervisor ?? false}
.language=${this.language}
> >
</onboarding-restore-backup>`; </onboarding-restore-backup>`;
} }
@@ -166,8 +168,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (this._init) { if (this._init) {
return html`<onboarding-welcome return html`<onboarding-welcome
.localize=${this.localize} .localize=${this.localize}
.language=${this.language}
.supervisor=${this._supervisor}
></onboarding-welcome>`; ></onboarding-welcome>`;
} }
@@ -236,7 +236,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
} }
} }
if (changedProps.has("language")) { if (changedProps.has("language")) {
document.querySelector("html")!.setAttribute("lang", this.language!); document.querySelector("html")!.setAttribute("lang", this.language);
} }
if (changedProps.has("hass")) { if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
@@ -272,10 +272,6 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
"Home Assistant OS", "Home Assistant OS",
"Home Assistant Supervised", "Home Assistant Supervised",
].includes(response.installation_type); ].includes(response.installation_type);
if (this._supervisor) {
// Only load if we have supervisor
import("./onboarding-restore-backup");
}
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error( console.error(
@@ -454,7 +450,7 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
subscribeOne(conn, subscribeUser), subscribeOne(conn, subscribeUser),
]); ]);
this.initializeHass(auth, conn); this.initializeHass(auth, conn);
if (this.language && this.language !== this.hass!.language) { if (this.language !== this.hass!.language) {
this._updateHass({ this._updateHass({
locale: { ...this.hass!.locale, language: this.language }, locale: { ...this.hass!.locale, language: this.language },
language: this.language, language: this.language,

View File

@@ -1,137 +1,337 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup"; import "./restore-backup/onboarding-restore-backup-upload";
import "../../hassio/src/components/hassio-upload-backup"; import "./restore-backup/onboarding-restore-backup-details";
import "./restore-backup/onboarding-restore-backup-restore";
import "./restore-backup/onboarding-restore-backup-status";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-ansi-to-html";
import "../components/ha-card"; import "../components/ha-card";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-circular-progress";
import "../components/ha-alert"; import "../components/ha-alert";
import "../components/ha-button";
import { fetchInstallationType } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import "./onboarding-loading"; import "./onboarding-loading";
import { onBoardingStyles } from "./styles";
import { removeSearchParam } from "../common/url/search-params"; import { removeSearchParam } from "../common/url/search-params";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import { onBoardingStyles } from "./styles";
import {
fetchBackupOnboardingInfo,
type BackupOnboardingConfig,
type BackupOnboardingInfo,
} from "../data/backup_onboarding";
import type { BackupContentExtended, BackupData } from "../data/backup";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { storage } from "../common/decorators/storage";
const STATUS_INTERVAL_IN_MS = 5000;
@customElement("onboarding-restore-backup") @customElement("onboarding-restore-backup")
class OnboardingRestoreBackup extends LitElement { class OnboardingRestoreBackup extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property() public language!: string; @property() public language!: string;
@state() private _restoring = false; @property({ type: Boolean }) public supervisor = false;
@state() private _backupSlug?: string; @state() private _view:
| "loading"
| "upload"
| "select_data"
| "confirm_restore"
| "status" = "loading";
@state() private _backup?: BackupContentExtended;
@state() private _backupInfo?: BackupOnboardingInfo;
@state() private _selectedData?: BackupData;
@state() private _error?: string;
@state() private _failed?: boolean;
@storage({
key: "onboarding-restore-backup-backup-id",
})
private _backupId?: string;
@storage({
key: "onboarding-restore-running",
})
private _restoreRunning?: boolean;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
${this._restoring ${
? html`<h1> this._view !== "status" || this._failed
${this.localize("ui.panel.page-onboarding.restore.in_progress")} ? html`<ha-icon-button-arrow-prev
</h1> .label=${this.localize("ui.panel.page-onboarding.restore.back")}
<ha-alert alert-type="info"> @click=${this._back}
${this.localize("ui.panel.page-onboarding.restore.in_progress")} ></ha-icon-button-arrow-prev>`
</ha-alert> : nothing
<onboarding-loading></onboarding-loading>` }
: html` <h1> </ha-icon-button>
${this.localize("ui.panel.page-onboarding.restore.header")} <h1>${this.localize("ui.panel.page-onboarding.restore.header")}</h1>
</h1> ${
<hassio-upload-backup this._error || (this._failed && this._view !== "status")
@backup-uploaded=${this._backupUploaded} ? html`<ha-alert
@backup-cleared=${this._backupCleared} alert-type="error"
.hass=${this.hass} .title=${this._failed && this._view !== "status"
.localize=${this.localize} ? this.localize("ui.panel.page-onboarding.restore.failed")
></hassio-upload-backup>`} : ""}
<div class="footer">
<ha-button @click=${this._back} .disabled=${this._restoring}>
${this.localize("ui.panel.page-onboarding.back")}
</ha-button>
${this._backupSlug
? html`<ha-button
@click=${this._showBackupDialog}
.disabled=${this._restoring}
> >
${this.localize("ui.panel.page-onboarding.restore.restore")} ${this._failed && this._view !== "status"
</ha-button>` ? this.localize(
: nothing} `ui.panel.page-onboarding.restore.${this._backupInfo?.last_non_idle_event?.reason === "password_incorrect" ? "failed_wrong_password_description" : "failed_description"}`
</div> )
: this._error}
</ha-alert>`
: nothing
}
${
this._view === "loading"
? html`<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>`
: this._view === "upload"
? html`
<onboarding-restore-backup-upload
.supervisor=${this.supervisor}
.localize=${this.localize}
@backup-uploaded=${this._backupUploaded}
></onboarding-restore-backup-upload>
`
: this._view === "select_data"
? html`<onboarding-restore-backup-details
.localize=${this.localize}
.backup=${this._backup!}
@backup-restore=${this._restore}
></onboarding-restore-backup-details>`
: this._view === "confirm_restore"
? html`<onboarding-restore-backup-restore
.localize=${this.localize}
.backup=${this._backup!}
.supervisor=${this.supervisor}
.selectedData=${this._selectedData!}
@restore-started=${this._restoreStarted}
></onboarding-restore-backup-restore>`
: nothing
}
${
this._view === "status" && this._backupInfo
? html`<onboarding-restore-backup-status
.localize=${this.localize}
.backupInfo=${this._backupInfo}
@show-backup-upload=${this._reupload}
></onboarding-restore-backup-status>`
: nothing
}
${
["select_data", "confirm_restore"].includes(this._view) && this._backup
? html`<div class="backup-summary-wrapper">
<ha-backup-details-summary
translation-key-panel="page-onboarding.restore"
show-upload-another
.backup=${this._backup}
.localize=${this.localize}
@show-backup-upload=${this._reupload}
.isHassio=${this.supervisor}
></ha-backup-details-summary>
</div>`
: nothing
}
`; `;
} }
private _back(): void {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
private _backupUploaded(ev) {
const backup = ev.detail.backup;
this._backupSlug = backup.slug;
this._showBackupDialog();
}
private _backupCleared() {
this._backupSlug = undefined;
}
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._loadBackupInfo();
} }
private async _checkRestoreStatus(): Promise<void> { private async _loadBackupInfo() {
if (this._restoring) { let onboardingInfo: BackupOnboardingConfig;
try { try {
await fetchInstallationType(); onboardingInfo = await fetchBackupOnboardingInfo();
} catch (err: any) { } catch (err: any) {
if (this._restoreRunning) {
if ( if (
(err as Error).message === "unauthorized" || err.error === "Request error" ||
(err as Error).message === "not_found" // core can restart but haven't loaded the backup integration yet
(err.status_code === 500 && err.body?.error === "backup_disabled")
) { ) {
// core is down because of restore, keep trying
this._scheduleLoadBackupInfo();
return;
}
// core seems to be back up restored
if (err.status_code === 404) {
this._restoreRunning = undefined;
this._backupId = undefined;
window.location.replace("/"); window.location.replace("/");
} return;
}
} }
} }
private _scheduleCheckRestoreStatus(): void { this._error = err?.message || "Cannot get backup info";
setTimeout(() => this._checkRestoreStatus(), 1000);
// if we are in an unknown state, show upload
if (this._view === "loading") {
this._view = "upload";
}
return;
} }
private _showBackupDialog(): void { const {
showHassioBackupDialog(this, { last_non_idle_event: lastNonIdleEvent,
slug: this._backupSlug!, state: currentState,
onboarding: true, backups,
localize: this.localize, } = onboardingInfo;
onRestoring: () => {
this._restoring = true; this._backupInfo = {
this._scheduleCheckRestoreStatus(); state: currentState,
}, last_non_idle_event: lastNonIdleEvent,
};
if (this._backupId) {
this._backup = backups.find(
({ backup_id }) => backup_id === this._backupId
);
}
const failedRestore =
lastNonIdleEvent?.manager_state === "restore_backup" &&
lastNonIdleEvent?.state === "failed";
if (failedRestore) {
this._failed = true;
}
if (this._restoreRunning) {
this._view = "status";
if (failedRestore || currentState !== "restore_backup") {
this._failed = true;
this._restoreRunning = undefined;
} else {
this._scheduleLoadBackupInfo();
}
return;
}
if (
this._backup &&
// after backup was uploaded
(lastNonIdleEvent?.manager_state === "receive_backup" ||
// when restore was confirmed but failed to start (for example, encryption key was wrong)
failedRestore)
) {
if (!this.supervisor && this._backup.homeassistant_included) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this._backup.homeassistant_version,
database_included: this._backup.database_included,
};
// skip select data when supervisor is not available and backup includes HA
this._view = "confirm_restore";
} else {
this._view = "select_data";
}
return;
}
// show upload as default
this._view = "upload";
}
private _scheduleLoadBackupInfo() {
setTimeout(() => this._loadBackupInfo(), STATUS_INTERVAL_IN_MS);
}
private async _backupUploaded(ev: CustomEvent) {
this._backupId = ev.detail.backupId;
await this._loadBackupInfo();
}
private async _restoreStarted() {
if (this._backupInfo) {
this._backupInfo.state = "restore_backup";
}
this._view = "status";
this._restoreRunning = true;
await this._loadBackupInfo();
}
private async _back() {
if (this._view === "upload" || (this._view === "status" && this._failed)) {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
} else {
const confirmed = await showConfirmationDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.text"
),
confirmText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.yes"
),
dismissText: this.localize(
"ui.panel.page-onboarding.restore.cancel_restore.no"
),
}); });
if (!confirmed) {
return;
}
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
} }
static get styles(): CSSResultGroup { private _restore(ev: CustomEvent) {
return [ if (!this._backup || !ev.detail.selectedData) {
return;
}
this._selectedData = ev.detail.selectedData;
this._view = "confirm_restore";
}
private _reupload() {
this._backup = undefined;
this._backupId = undefined;
this._view = "upload";
}
static styles = [
onBoardingStyles, onBoardingStyles,
css` css`
:host { :host {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; position: relative;
} }
hassio-upload-backup { ha-icon-button-arrow-prev {
position: absolute;
top: 12px;
}
ha-card {
width: 100%; width: 100%;
} }
.footer { .loading {
display: flex; display: flex;
justify-content: space-between; justify-content: center;
width: 100%; padding: 32px;
}
.backup-summary-wrapper {
margin-top: 24px;
padding: 0 20px;
} }
`, `,
]; ];
} }
}
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {

View File

@@ -1,5 +1,5 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -13,8 +13,6 @@ class OnboardingWelcome extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc; @property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Boolean }) public supervisor = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1> <h1>${this.localize("ui.panel.page-onboarding.welcome.header")}</h1>
@@ -24,11 +22,9 @@ class OnboardingWelcome extends LitElement {
${this.localize("ui.panel.page-onboarding.welcome.start")} ${this.localize("ui.panel.page-onboarding.welcome.start")}
</ha-button> </ha-button>
${this.supervisor <ha-button @click=${this._restoreBackup}>
? html`<ha-button @click=${this._restoreBackup}>
${this.localize("ui.panel.page-onboarding.welcome.restore_backup")} ${this.localize("ui.panel.page-onboarding.welcome.restore_backup")}
</ha-button>` </ha-button>
: nothing}
`; `;
} }

View File

@@ -0,0 +1,57 @@
import { css, html, LitElement, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../panels/config/backup/components/ha-backup-details-restore";
import "../../panels/config/backup/components/ha-backup-details-summary";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupContentExtended } from "../../data/backup";
@customElement("onboarding-restore-backup-details")
class OnboardingRestoreBackupDetails extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
render() {
return html`
${this.backup.homeassistant_included
? html`<ha-backup-details-restore
.backup=${this.backup}
.localize=${this.localize}
translation-key-panel="page-onboarding.restore"
ha-required
></ha-backup-details-restore>`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.home_assistant_missing"
)}
</ha-alert>
`}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
ha-backup-details-restore {
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-details": OnboardingRestoreBackupDetails;
}
}

View File

@@ -0,0 +1,175 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-alert";
import "../../components/buttons/ha-progress-button";
import "../../components/ha-password-field";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
type BackupContentExtended,
type BackupData,
} from "../../data/backup";
import { restoreOnboardingBackup } from "../../data/backup_onboarding";
import type { HaProgressButton } from "../../components/buttons/ha-progress-button";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("onboarding-restore-backup-restore")
class OnboardingRestoreBackupRestore extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false }) public backup!: BackupContentExtended;
@property({ attribute: false })
public selectedData!: BackupData;
@property({ type: Boolean }) public supervisor = false;
@state() private _encryptionKey = "";
@state() private _encryptionKeyWrong = false;
@state() private _error?: string;
@state() private _loading = false;
render() {
const agentId = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
const backupProtected = this.backup.agents[agentId].protected;
return html`
${this.backup.homeassistant_included &&
!this.supervisor &&
(this.backup.addons.length > 0 || this.backup.folders.length > 0)
? html`<ha-alert alert-type="warning" class="supervisor-warning">
${this.localize(
"ui.panel.page-onboarding.restore.details.addons_unsupported"
)}
</ha-alert>`
: nothing}
<ha-card
.header=${this.localize("ui.panel.page-onboarding.restore.restore")}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert> `
: nothing}
<p>
${this.localize(
"ui.panel.page-onboarding.restore.confirm_restore_full_backup_text"
)}
</p>
${backupProtected
? html`<p>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.title"
)}
</p>
${this._encryptionKeyWrong
? html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.incorrect_key"
)}
</ha-alert>
`
: nothing}
<ha-password-field
.disabled=${this._loading}
@input=${this._encryptionKeyChanged}
.label=${this.localize(
"ui.panel.page-onboarding.restore.details.restore.encryption.input_label"
)}
.value=${this._encryptionKey}
></ha-password-field>`
: nothing}
</div>
<div class="card-actions">
<ha-progress-button
.progress=${this._loading}
.disabled=${this._loading ||
(backupProtected && this._encryptionKey === "")}
@click=${this._startRestore}
destructive
>
${this.localize(
"ui.panel.page-onboarding.restore.details.restore.action"
)}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _encryptionKeyChanged(ev): void {
this._encryptionKey = ev.target.value;
}
private async _startRestore(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as HaProgressButton;
this._loading = true;
this._error = undefined;
this._encryptionKeyWrong = false;
const backupAgent = this.supervisor ? HASSIO_LOCAL_AGENT : CORE_LOCAL_AGENT;
try {
await restoreOnboardingBackup({
agent_id: backupAgent,
backup_id: this.backup.backup_id,
password: this._encryptionKey || undefined,
restore_addons: this.selectedData.addons.map((addon) => addon.slug),
restore_database: this.selectedData.database_included,
restore_folders: this.selectedData.folders,
});
button.actionSuccess();
fireEvent(this, "restore-started");
} catch (err: any) {
if (err.error === "Request error") {
// core can shutdown before we get a response
button.actionSuccess();
fireEvent(this, "restore-started");
return;
}
button.actionError();
if (err.body?.code === "incorrect_password") {
this._encryptionKeyWrong = true;
} else {
this._error =
err.body?.message || err.message || "Unknown error occurred";
}
this._loading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.supervisor-warning {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-restore": OnboardingRestoreBackupRestore;
}
interface HASSDomEvents {
"restore-started";
}
}

View File

@@ -0,0 +1,119 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-circular-progress";
import "../../components/ha-alert";
import "../../components/ha-button";
import { haStyle } from "../../resources/styles";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { BackupOnboardingInfo } from "../../data/backup_onboarding";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { removeSearchParam } from "../../common/url/search-params";
@customElement("onboarding-restore-backup-status")
class OnboardingRestoreBackupStatus extends LitElement {
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ attribute: false })
public backupInfo!: BackupOnboardingInfo;
render() {
return html`
<ha-card
.header=${this.localize(
`ui.panel.page-onboarding.restore.${this.backupInfo.state === "restore_backup" ? "in_progress" : "failed"}`
)}
>
<div class="card-content">
${this.backupInfo.state === "restore_backup"
? html`
<div class="loading">
<ha-circular-progress indeterminate></ha-circular-progress>
</div>
<p>
${this.localize(
"ui.panel.page-onboarding.restore.in_progress_description"
)}
</p>
`
: html`
<ha-alert alert-type="error">
${this.localize(
"ui.panel.page-onboarding.restore.failed_status_description"
)}
</ha-alert>
${this.backupInfo.last_non_idle_event?.reason
? html`
<div class="failed">
<h4>Error:</h4>
${this.backupInfo.last_non_idle_event?.reason}
</div>
`
: nothing}
`}
</div>
${this.backupInfo.state !== "restore_backup"
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
<ha-button @click=${this._home} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.home`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
private _home() {
navigate(`${location.pathname}?${removeSearchParam("page")}`);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
padding: 28px 20px 0;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.loading {
display: flex;
justify-content: center;
padding: 32px;
}
p {
text-align: center;
padding: 0 16px;
font-size: 16px;
}
.failed {
padding: 16px 0;
font-size: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-status": OnboardingRestoreBackupStatus;
}
interface HASSDomEvents {
"restore-started";
}
}

View File

@@ -0,0 +1,126 @@
import { mdiFolderUpload } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-file-upload";
import "../../components/ha-alert";
import { haStyle } from "../../resources/styles";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
import { uploadOnboardingBackup } from "../../data/backup_onboarding";
declare global {
interface HASSDomEvents {
"backup-uploaded": { backupId: string };
}
}
@customElement("onboarding-restore-backup-upload")
class OnboardingRestoreBackupUpload extends LitElement {
@property({ type: Boolean }) public supervisor = false;
@property({ attribute: false }) public localize!: LocalizeFunc;
@state() private _uploading = false;
@state() private _error?: string;
render() {
return html`
<ha-card
.header=${this.localize(
"ui.panel.page-onboarding.restore.upload_backup"
)}
>
<div class="card-content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.localize}
.label=${this.localize(
"ui.panel.page-onboarding.restore.upload_input_label"
)}
.secondary=${this.localize(
"ui.panel.page-onboarding.restore.upload_secondary"
)}
.supports=${this.localize(
"ui.panel.page-onboarding.restore.upload_supports_tar"
)}
.deleteLabel=${this.localize(
"ui.panel.page-onboarding.restore.delete"
)}
.uploadingLabel=${this.localize(
"ui.panel.page-onboarding.restore.uploading"
)}
@file-picked=${this._filePicked}
></ha-file-upload>
</div>
</ha-card>
`;
}
private async _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
),
text: this.localize(
"ui.panel.page-onboarding.restore.unsupported.text"
),
confirmText: this.localize("ui.panel.page-onboarding.restore.ok"),
});
return;
}
const agentIds = this.supervisor
? [HASSIO_LOCAL_AGENT]
: [CORE_LOCAL_AGENT];
this._uploading = true;
try {
const { backup_id } = await uploadOnboardingBackup(file, agentIds);
fireEvent(this, "backup-uploaded", { backupId: backup_id });
} catch (err: any) {
this._error =
typeof err.body === "string"
? err.body
: err.body?.message || err.message || "Unknown error occurred";
} finally {
this._uploading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
width: 100%;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"onboarding-restore-backup-upload": OnboardingRestoreBackupUpload;
}
}

View File

@@ -106,6 +106,7 @@ export class HaConfigApplicationCredentials extends LitElement {
}, },
actions: { actions: {
title: "", title: "",
label: localize("ui.panel.config.generic.headers.actions"),
type: "overflow-menu", type: "overflow-menu",
showNarrow: true, showNarrow: true,
hideable: false, hideable: false,

View File

@@ -329,6 +329,9 @@ class DialogAreaDetail extends LitElement {
return [ return [
haStyleDialog, haStyleDialog,
css` css`
ha-textfield {
display: block;
}
ha-aliases-editor, ha-aliases-editor,
ha-entity-picker, ha-entity-picker,
ha-floor-picker, ha-floor-picker,

View File

@@ -11,17 +11,16 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { stringCompare } from "../../../common/string/compare"; import { stringCompare } from "../../../common/string/compare";
import { stripDiacritics } from "../../../common/string/strip-diacritics";
import type { LocalizeFunc } from "../../../common/translations/localize"; import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal"; import { deepEqual } from "../../../common/util/deep-equal";
import "../../../components/ha-dialog"; import "../../../components/ha-dialog";
import type { HaDialog } from "../../../components/ha-dialog"; import type { HaDialog } from "../../../components/ha-dialog";
import "../../../components/ha-dialog-header"; import "../../../components/ha-dialog-header";
import "../../../components/ha-md-divider";
import "../../../components/ha-domain-icon"; import "../../../components/ha-domain-icon";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev"; import "../../../components/ha-icon-button-prev";
import "../../../components/ha-icon-next"; import "../../../components/ha-icon-next";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-list"; import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item"; import "../../../components/ha-md-list-item";
import "../../../components/ha-service-icon"; import "../../../components/ha-service-icon";
@@ -45,7 +44,6 @@ import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { getStripDiacriticsFn } from "../../../util/fuse";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog"; import { PASTE_VALUE } from "./show-add-automation-element-dialog";
@@ -202,10 +200,10 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
ignoreLocation: true, ignoreLocation: true,
minMatchCharLength: Math.min(filter.length, 2), minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2, threshold: 0.2,
getFn: getStripDiacriticsFn, ignoreDiacritics: true,
}; };
const fuse = new Fuse(items, options); const fuse = new Fuse(items, options);
return fuse.search(stripDiacritics(filter)).map((result) => result.item); return fuse.search(filter).map((result) => result.item);
} }
); );

View File

@@ -1,4 +1,4 @@
import { mdiCog, mdiHarddisk, mdiNas } from "@mdi/js"; import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
@@ -41,13 +41,6 @@ class HaBackupConfigAgents extends LitElement {
@state() private value?: string[]; @state() private value?: string[];
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private get _value() { private get _value() {
return this.value ?? DEFAULT_AGENTS; return this.value ?? DEFAULT_AGENTS;
} }
@@ -86,41 +79,46 @@ class HaBackupConfigAgents extends LitElement {
return ""; return "";
} }
protected render() { private _availableAgents = memoizeOne(
const agents = this._availableAgents(this.agents, this.cloudStatus); (agents: BackupAgent[], cloudStatus: CloudStatus) =>
return html` agents.filter(
${agents.length > 0 (agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
? html` )
<ha-md-list>
${agents.map((agent) => {
const agentId = agent.agent_id;
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
); );
const description = this._description(agentId);
const noCloudSubscription = private _unavailableAgents = memoizeOne(
agentId === CLOUD_AGENT && (
this.cloudStatus.logged_in && agents: BackupAgent[],
!this.cloudStatus.active_subscription; cloudStatus: CloudStatus,
selectedAgentIds: string[]
) => {
const availableAgentIds = this._availableAgents(agents, cloudStatus).map(
(agent) => agent.agent_id
);
return selectedAgentIds
.filter((agent) => !availableAgentIds.includes(agent))
.map<BackupAgent>((id) => ({
agent_id: id,
name: id.split(".")[1] || id, // Use the id as name as it is not available in the list
}));
}
);
private _renderAgentIcon(agentId: string) {
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`<ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon>`;
}
const domain = computeDomain(agentId);
return html` return html`
<ha-md-list-item>
${isLocalAgent(agentId)
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start">
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
slot="start"
></ha-svg-icon>
`
: html`
<img <img
.src=${brandsUrl({ .src=${brandsUrl({
domain, domain,
@@ -133,7 +131,42 @@ class HaBackupConfigAgents extends LitElement {
alt="" alt=""
slot="start" slot="start"
/> />
`} `;
}
protected render() {
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
const unavailableAgents = this._unavailableAgents(
this.agents,
this.cloudStatus,
this._value
);
const allAgents = [...availableAgents, ...unavailableAgents];
return html`
${allAgents.length > 0
? html`
<ha-md-list>
${availableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
);
const description = this._description(agentId);
const noCloudSubscription =
agentId === CLOUD_AGENT &&
this.cloudStatus.logged_in &&
!this.cloudStatus.active_subscription;
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div> <div slot="headline" class="name">${name}</div>
${description ${description
? html`<div slot="supporting-text">${description}</div>` ? html`<div slot="supporting-text">${description}</div>`
@@ -151,14 +184,44 @@ class HaBackupConfigAgents extends LitElement {
<ha-switch <ha-switch
slot="end" slot="end"
id=${agentId} id=${agentId}
.checked=${!noCloudSubscription && .checked=${this._value.includes(agentId)}
this._value.includes(agentId)} .disabled=${noCloudSubscription &&
.disabled=${noCloudSubscription} !this._value.includes(agentId)}
@change=${this._agentToggled} @change=${this._agentToggled}
></ha-switch> ></ha-switch>
</ha-md-list-item> </ha-md-list-item>
`; `;
})} })}
${unavailableAgents.length > 0 && this.showSettings
? html`
<p class="heading">
${this.hass.localize(
"ui.panel.config.backup.agents.unavailable_agents"
)}
</p>
${unavailableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
allAgents
);
return html`
<ha-md-list-item>
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
<ha-icon-button
id=${agentId}
slot="end"
path=${mdiDelete}
@click=${this._deleteAgent}
></ha-icon-button>
</ha-md-list-item>
`;
})}
`
: nothing}
</ha-md-list> </ha-md-list>
` `
: html` : html`
@@ -174,6 +237,13 @@ class HaBackupConfigAgents extends LitElement {
navigate(`/config/backup/location/${agentId}`); navigate(`/config/backup/location/${agentId}`);
} }
private _deleteAgent(ev): void {
ev.stopPropagation();
const agentId = ev.currentTarget.id;
this.value = this._value.filter((agent) => agent !== agentId);
fireEvent(this, "value-changed", { value: this.value });
}
private _agentToggled(ev) { private _agentToggled(ev) {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.currentTarget.checked; const value = ev.currentTarget.checked;
@@ -185,19 +255,8 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId); this.value = this._value.filter((agent) => agent !== agentId);
} }
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in // Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)] this.value = [...new Set(this.value)];
.filter((id) => availableAgents.some((agent) => agent.agent_id === id))
.filter(
(id) =>
id !== CLOUD_AGENT ||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
);
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }

View File

@@ -378,8 +378,9 @@ class HaBackupConfigData extends LitElement {
} }
@media all and (max-width: 450px) { @media all and (max-width: 450px) {
ha-md-select { ha-md-select {
min-width: 160px; min-width: 140px;
width: 160px; width: 140px;
--md-filled-field-content-space: 0;
} }
} }
`; `;

View File

@@ -403,11 +403,11 @@ class HaBackupConfigSchedule extends LitElement {
backup_create: html`<a backup_create: html`<a
href=${documentationUrl( href=${documentationUrl(
this.hass, this.hass,
"/integrations/backup#example-backing-up-every-night-at-300-am" "/integrations/backup/#action-backupcreate_automatic"
)} )}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
>backup.create</a >backup.create_automatic</a
>`, >`,
})}</ha-tip })}</ha-tip
> >
@@ -537,14 +537,22 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item { ha-md-list-item {
--md-item-overflow: visible; --md-item-overflow: visible;
} }
ha-md-select, ha-md-select {
ha-time-input {
min-width: 210px; min-width: 210px;
} }
@media all and (max-width: 450px) {
ha-md-select,
ha-time-input { ha-time-input {
min-width: 194px;
--time-input-flex: 1;
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 160px; min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-time-input {
min-width: 145px;
width: 145px;
} }
} }
ha-md-textfield#value { ha-md-textfield#value {
@@ -553,6 +561,16 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type { ha-md-select#type {
min-width: 100px; min-width: 100px;
} }
@media all and (max-width: 450px) {
ha-md-textfield#value {
min-width: 60px;
margin: 0 -8px;
}
ha-md-select#type {
min-width: 120px;
width: 120px;
}
}
ha-expansion-panel { ha-expansion-panel {
--expansion-panel-summary-padding: 0 16px; --expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px; --expansion-panel-content-padding: 0 16px;

View File

@@ -21,7 +21,7 @@ export interface BackupAddonItem {
@customElement("ha-backup-addons-picker") @customElement("ha-backup-addons-picker")
export class HaBackupAddonsPicker extends LitElement { export class HaBackupAddonsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public addons!: BackupAddonItem[]; @property({ attribute: false }) public addons!: BackupAddonItem[];
@@ -32,7 +32,7 @@ export class HaBackupAddonsPicker extends LitElement {
private _addons = memoizeOne((addons: BackupAddonItem[]) => private _addons = memoizeOne((addons: BackupAddonItem[]) =>
addons.sort((a, b) => addons.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language) stringCompare(a.name, b.name, this.hass?.locale?.language)
) )
); );

View File

@@ -47,23 +47,32 @@ interface SelectedItems {
@customElement("ha-backup-data-picker") @customElement("ha-backup-data-picker")
export class HaBackupDataPicker extends LitElement { export class HaBackupDataPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public data!: BackupData; @property({ attribute: false }) public data!: BackupData;
@property({ attribute: false }) public value?: BackupData; @property({ attribute: false }) public value?: BackupData;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Array, attribute: "required-items" })
public requiredItems: string[] = [];
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() public _addonIcons: Record<string, boolean> = {}; @state() public _addonIcons: Record<string, boolean> = {};
protected firstUpdated(changedProps: PropertyValues): void { protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) { if (this.hass && isComponentLoaded(this.hass, "hassio")) {
this._fetchAddonInfo(); this._fetchAddonInfo();
} }
} }
private async _fetchAddonInfo() { private async _fetchAddonInfo() {
const { addons } = await fetchHassioAddonsInfo(this.hass); const { addons } = await fetchHassioAddonsInfo(this.hass!);
this._addonIcons = addons.reduce<Record<string, boolean>>( this._addonIcons = addons.reduce<Record<string, boolean>>(
(acc, addon) => ({ (acc, addon) => ({
...acc, ...acc,
@@ -74,16 +83,14 @@ export class HaBackupDataPicker extends LitElement {
} }
private _homeAssistantItems = memoizeOne( private _homeAssistantItems = memoizeOne(
(data: BackupData, _localize: LocalizeFunc) => { (data: BackupData, localize: LocalizeFunc) => {
const items: CheckBoxItem[] = []; const items: CheckBoxItem[] = [];
if (data.homeassistant_included) { if (data.homeassistant_included) {
items.push({ items.push({
label: data.database_included label: localize(
? this.hass.localize( `ui.panel.${this.translationKeyPanel}.data_picker.${data.database_included ? "settings_and_history" : "settings"}`
"ui.panel.config.backup.data_picker.settings_and_history" ),
)
: this.hass.localize("ui.panel.config.backup.data_picker.settings"),
id: "config", id: "config",
version: data.homeassistant_version, version: data.homeassistant_version,
}); });
@@ -99,18 +106,22 @@ export class HaBackupDataPicker extends LitElement {
); );
private _localizeFolder(folder: string): string { private _localizeFolder(folder: string): string {
const localize = this.localize || this.hass!.localize;
switch (folder) { switch (folder) {
case "media": case "media":
return this.hass.localize("ui.panel.config.backup.data_picker.media"); return localize(
`ui.panel.${this.translationKeyPanel}.data_picker.media`
);
case "share": case "share":
return this.hass.localize( return localize(
"ui.panel.config.backup.data_picker.share_folder" `ui.panel.${this.translationKeyPanel}.data_picker.share_folder`
); );
case "ssl": case "ssl":
return this.hass.localize("ui.panel.config.backup.data_picker.ssl"); return localize(`ui.panel.${this.translationKeyPanel}.data_picker.ssl`);
case "addons/local": case "addons/local":
return this.hass.localize( return localize(
"ui.panel.config.backup.data_picker.local_addons" `ui.panel.${this.translationKeyPanel}.data_picker.local_addons`
); );
} }
return capitalizeFirstLetter(folder); return capitalizeFirstLetter(folder);
@@ -215,14 +226,13 @@ export class HaBackupDataPicker extends LitElement {
} }
protected render() { protected render() {
const homeAssistantItems = this._homeAssistantItems( const localize = this.localize || this.hass!.localize;
this.data,
this.hass.localize const homeAssistantItems = this._homeAssistantItems(this.data, localize);
);
const addonsItems = this._addonsItems( const addonsItems = this._addonsItems(
this.data, this.data,
this.hass.localize, localize,
this._addonIcons this._addonIcons
); );
@@ -247,6 +257,7 @@ export class HaBackupDataPicker extends LitElement {
selectedItems.homeassistant.length < selectedItems.homeassistant.length <
homeAssistantItems.length} homeAssistantItems.length}
@change=${this._sectionChanged} @change=${this._sectionChanged}
?disabled=${this.requiredItems.length > 0}
></ha-checkbox> ></ha-checkbox>
</ha-formfield> </ha-formfield>
<div class="items"> <div class="items">
@@ -266,6 +277,7 @@ export class HaBackupDataPicker extends LitElement {
item.id item.id
)} )}
@change=${this._homeassistantChanged} @change=${this._homeassistantChanged}
.disabled=${this.requiredItems.includes(item.id)}
></ha-checkbox> ></ha-checkbox>
</ha-formfield> </ha-formfield>
` `
@@ -280,8 +292,8 @@ export class HaBackupDataPicker extends LitElement {
<ha-formfield> <ha-formfield>
<ha-backup-formfield-label <ha-backup-formfield-label
slot="label" slot="label"
.label=${this.hass.localize( .label=${localize(
"ui.panel.config.backup.data_picker.addons" `ui.panel.${this.translationKeyPanel}.data_picker.addons`
)} )}
.iconPath=${mdiPuzzle} .iconPath=${mdiPuzzle}
> >

View File

@@ -0,0 +1,148 @@
import memoizeOne from "memoize-one";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import "./ha-backup-data-picker";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type {
BackupContentExtended,
BackupData,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
@customElement("ha-backup-details-restore")
class HaBackupDetailsRestore extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "ha-required" })
public haRequired = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@state() private _selectedData?: BackupData;
protected willUpdate() {
if (!this.hasUpdated && this.haRequired) {
this._selectedData = {
homeassistant_included: true,
folders: [],
addons: [],
homeassistant_version: this.backup.homeassistant_version,
database_included: this.backup.database_included,
};
}
}
render() {
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.title`
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.translationKeyPanel=${this.translationKeyPanel}
.localize=${this.localize}
.hass=${this.hass}
.data=${this.backup}
.value=${this._selectedData}
@value-changed=${this._selectedBackupChanged}
.requiredItems=${this._isHomeAssistantRequired(this.haRequired)}
>
</ha-backup-data-picker>
</div>
<div class="card-actions">
<ha-button
@click=${this._restore}
.disabled=${this._isRestoreDisabled}
destructive
>
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.restore.action`
)}
</ha-button>
</div>
</ha-card>
`;
}
private _restore() {
fireEvent(this, "backup-restore", { selectedData: this._selectedData });
}
private _selectedBackupChanged(ev: CustomEvent) {
ev.stopPropagation();
this._selectedData = ev.detail.value;
}
private _isHomeAssistantRequired = memoizeOne((required: boolean) =>
required ? ["config"] : []
);
private get _isRestoreDisabled(): boolean {
return (
!this._selectedData ||
(this.haRequired && !this._selectedData.homeassistant_included) ||
!(
this._selectedData?.database_included ||
this._selectedData?.homeassistant_included ||
this._selectedData.addons.length ||
this._selectedData.folders.length
)
);
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-restore": HaBackupDetailsRestore;
}
interface HASSDomEvents {
"backup-restore": { selectedData?: BackupData };
}
}

View File

@@ -0,0 +1,153 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-card";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-button";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
formatDateTime,
formatDateTimeWithBrowserDefaults,
} from "../../../../common/datetime/format_date_time";
import {
computeBackupSize,
computeBackupType,
type BackupContentExtended,
} from "../../../../data/backup";
import { fireEvent } from "../../../../common/dom/fire_event";
import { bytesToString } from "../../../../util/bytes-to-string";
declare global {
interface HASSDomEvents {
"show-backup-upload": undefined;
}
}
@customElement("ha-backup-details-summary")
class HaBackupDetailsSummary extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize!: LocalizeFunc;
@property({ type: Object }) public backup!: BackupContentExtended;
@property({ type: Boolean, attribute: "hassio" }) public isHassio = false;
@property({ attribute: "translation-key-panel" }) public translationKeyPanel:
| "page-onboarding.restore"
| "config.backup" = "config.backup";
@property({ type: Boolean, attribute: "show-upload-another" })
public showUploadAnother = false;
render() {
const backupDate = new Date(this.backup.date);
const formattedDate = this.hass
? formatDateTime(backupDate, this.hass.locale, this.hass.config)
: formatDateTimeWithBrowserDefaults(backupDate);
return html`
<ha-card>
<div class="card-header">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.title`
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
${this.translationKeyPanel === "config.backup"
? html`<ha-md-list-item>
<span slot="headline">
${this.localize("ui.panel.config.backup.backup_type")}
</span>
<span slot="supporting-text">
${this.localize(
`ui.panel.config.backup.type.${computeBackupType(this.backup, this.isHassio)}`
)}
</span>
</ha-md-list-item>`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.size`
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this.backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.localize(
`ui.panel.${this.translationKeyPanel}.details.summary.created`
)}
</span>
<span slot="supporting-text"> ${formattedDate} </span>
</ha-md-list-item>
</ha-md-list>
</div>
${this.showUploadAnother
? html`<div class="card-actions">
<ha-button @click=${this._uploadAnother} destructive>
${this.localize(
`ui.panel.page-onboarding.restore.details.summary.upload_another`
)}
</ha-button>
</div>`
: nothing}
</ha-card>
`;
}
private _uploadAnother() {
fireEvent(this, "show-backup-upload");
}
static styles = css`
:host {
max-width: 690px;
width: 100%;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-list-item-two-line-container-height: 64px;
}
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-details-summary": HaBackupDetailsSummary;
}
}

View File

@@ -1,16 +1,19 @@
import { mdiCalendarSync, mdiGestureTap } from "@mdi/js"; import { mdiCalendarSync, mdiGestureTap, mdiPuzzle } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
import "../../../../../components/ha-button"; import "../../../../../components/ha-button";
import "../../../../../components/ha-card"; import "../../../../../components/ha-card";
import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list"; import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item"; import "../../../../../components/ha-md-list-item";
import type { BackupContent, BackupType } from "../../../../../data/backup";
import { import {
computeBackupSize, computeBackupSize,
type BackupContent, computeBackupType,
getBackupTypes,
} from "../../../../../data/backup"; } from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles"; import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
@@ -21,6 +24,12 @@ interface BackupStats {
size: number; size: number;
} }
const TYPE_ICONS: Record<BackupType, string> = {
automatic: mdiCalendarSync,
manual: mdiGestureTap,
addon_update: mdiPuzzle,
};
const computeBackupStats = (backups: BackupContent[]): BackupStats => const computeBackupStats = (backups: BackupContent[]): BackupStats =>
backups.reduce( backups.reduce(
(stats, backup) => { (stats, backup) => {
@@ -37,23 +46,22 @@ class HaBackupOverviewBackups extends LitElement {
@property({ attribute: false }) public backups: BackupContent[] = []; @property({ attribute: false }) public backups: BackupContent[] = [];
private _automaticStats = memoizeOne((backups: BackupContent[]) => { private _stats = memoizeOne(
const automaticBackups = backups.filter( (
(backup) => backup.with_automatic_settings backups: BackupContent[],
isHassio: boolean
): [BackupType, BackupStats][] =>
getBackupTypes(isHassio).map((type) => {
const backupsOfType = backups.filter(
(backup) => computeBackupType(backup, isHassio) === type
); );
return computeBackupStats(automaticBackups); return [type, computeBackupStats(backupsOfType)] as const;
}); })
private _manualStats = memoizeOne((backups: BackupContent[]) => {
const manualBackups = backups.filter(
(backup) => !backup.with_automatic_settings
); );
return computeBackupStats(manualBackups);
});
render() { render() {
const automaticStats = this._automaticStats(this.backups); const isHassio = isComponentLoaded(this.hass, "hassio");
const manualStats = this._manualStats(this.backups); const stats = this._stats(this.backups, isHassio);
return html` return html`
<ha-card class="my-backups"> <ha-card class="my-backups">
@@ -62,44 +70,32 @@ class HaBackupOverviewBackups extends LitElement {
</div> </div>
<div class="card-content"> <div class="card-content">
<ha-md-list> <ha-md-list>
${stats.map(
([type, { count, size }]) => html`
<ha-md-list-item <ha-md-list-item
type="link" type="link"
href="/config/backup/backups?type=automatic" href="/config/backup/backups?type=${type}"
> >
<ha-svg-icon slot="start" .path=${mdiCalendarSync}></ha-svg-icon> <ha-svg-icon
slot="start"
.path=${TYPE_ICONS[type]}
></ha-svg-icon>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.overview.backups.automatic", `ui.panel.config.backup.overview.backups.${type}`,
{ count: automaticStats.count } { count }
)} )}
</div> </div>
<div slot="supporting-text"> <div slot="supporting-text">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size", "ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(automaticStats.size, 1) } { size: bytesToString(size) }
)} )}
</div> </div>
<ha-icon-next slot="end"></ha-icon-next> <ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item> </ha-md-list-item>
<ha-md-list-item `
type="link"
href="/config/backup/backups?type=manual"
>
<ha-svg-icon slot="start" .path=${mdiGestureTap}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.manual",
{ count: manualStats.count }
)} )}
</div>
<div slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.overview.backups.total_size",
{ size: bytesToString(manualStats.size, 1) }
)}
</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list> </ha-md-list>
</div> </div>
<div class="card-actions"> <div class="card-actions">

View File

@@ -0,0 +1,225 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-password-field";
import "../../../../components/ha-alert";
import {
canDecryptBackupOnDownload,
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { downloadBackupFile } from "../helper/download_backup";
import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download-decrypted-backup";
@customElement("ha-dialog-download-decrypted-backup")
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _params?: DownloadDecryptedBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _encryptionKey = "";
@state() private _error = "";
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._opened = true;
this._params = params;
}
public closeDialog() {
this._dialog?.close();
return true;
}
private _dialogClosed() {
if (this._opened) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._params = undefined;
this._encryptionKey = "";
this._error = "";
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
</span>
</ha-dialog-header>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button @click=${this._cancel}>
${this.hass.localize("ui.dialogs.generic.cancel")}
</ha-button>
<ha-button @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _cancel() {
this.closeDialog();
}
private async _submit() {
if (this._encryptionKey === "") {
return;
}
try {
await canDecryptBackupOnDownload(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId,
this._encryptionKey
);
this.closeDialog();
} catch (err: any) {
if (err?.code === "password_incorrect") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_encryption_key"
);
} else if (err?.code === "decrypt_not_supported") {
this._error = this.hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_not_supported"
);
} else {
alert(err.message);
}
}
}
private _keyChanged(ev) {
this._encryptionKey = ev.currentTarget.value;
this._error = "";
}
private get _agentId() {
if (this._params?.agentId) {
return this._params.agentId;
}
return getPreferredAgentForDownload(
Object.keys(this._params!.backup.agents)
);
}
private async _downloadEncrypted() {
downloadBackupFile(
this.hass,
this._params!.backup.backup_id,
this._agentId
);
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 8px 24px;
max-width: 500px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
button.link {
background: none;
border: none;
padding: 0;
font-size: 14px;
color: var(--primary-color);
text-decoration: underline;
cursor: pointer;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-download-decrypted-backup": DialogDownloadDecryptedBackup;
}
}

View File

@@ -3,7 +3,10 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event"; import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-dialog-header"; import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
@@ -14,7 +17,10 @@ import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import { import {
CORE_LOCAL_AGENT, CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT, HASSIO_LOCAL_AGENT,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup, uploadBackup,
INITIAL_UPLOAD_FORM_DATA,
type BackupUploadFileFormData,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
@@ -22,16 +28,6 @@ import type { HomeAssistant } from "../../../../types";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers"; import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import type { UploadBackupDialogParams } from "./show-dialog-upload-backup"; import type { UploadBackupDialogParams } from "./show-dialog-upload-backup";
const SUPPORTED_FORMAT = "application/x-tar";
interface FormData {
file?: File;
}
const INITIAL_DATA: FormData = {
file: undefined,
};
@customElement("ha-dialog-upload-backup") @customElement("ha-dialog-upload-backup")
export class DialogUploadBackup export class DialogUploadBackup
extends LitElement extends LitElement
@@ -45,13 +41,13 @@ export class DialogUploadBackup
@state() private _error?: string; @state() private _error?: string;
@state() private _formData?: FormData; @state() private _formData?: BackupUploadFileFormData;
@query("ha-md-dialog") private _dialog?: HaMdDialog; @query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(params: UploadBackupDialogParams): Promise<void> { public async showDialog(params: UploadBackupDialogParams): Promise<void> {
this._params = params; this._params = params;
this._formData = INITIAL_DATA; this._formData = INITIAL_UPLOAD_FORM_DATA;
} }
private _dialogClosed() { private _dialogClosed() {
@@ -78,13 +74,18 @@ export class DialogUploadBackup
} }
return html` return html`
<ha-md-dialog open @closed=${this._dialogClosed}> <ha-md-dialog
open
@closed=${this._dialogClosed}
.disableCancelAction=${this._uploading}
>
<ha-dialog-header slot="headline"> <ha-dialog-header slot="headline">
<ha-icon-button <ha-icon-button
slot="navigationIcon" slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")} .label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose} .path=${mdiClose}
@click=${this.closeDialog} @click=${this.closeDialog}
.disabled=${this._uploading}
></ha-icon-button> ></ha-icon-button>
<span slot="title"> <span slot="title">
@@ -99,7 +100,8 @@ export class DialogUploadBackup
.hass=${this.hass} .hass=${this.hass}
.uploading=${this._uploading} .uploading=${this._uploading}
.icon=${mdiFolderUpload} .icon=${mdiFolderUpload}
accept=${SUPPORTED_FORMAT} .accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label" "ui.panel.config.backup.dialogs.upload.input_label"
)} )}
@@ -107,13 +109,17 @@ export class DialogUploadBackup
"ui.panel.config.backup.dialogs.upload.supports_tar" "ui.panel.config.backup.dialogs.upload.supports_tar"
)} )}
@file-picked=${this._filePicked} @file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload> ></ha-file-upload>
</div> </div>
<div slot="actions"> <div slot="actions">
<ha-button @click=${this.closeDialog} <ha-button @click=${this.closeDialog} .disabled=${this._uploading}
>${this.hass.localize("ui.common.cancel")}</ha-button >${this.hass.localize("ui.common.cancel")}</ha-button
> >
<ha-button @click=${this._upload} .disabled=${!this._formValid()}> <ha-button
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.action" "ui.panel.config.backup.dialogs.upload.action"
)} )}
@@ -123,7 +129,7 @@ export class DialogUploadBackup
`; `;
} }
private async _filePicked(ev: CustomEvent<{ files: File[] }>): Promise<void> { private _filePicked(ev: HASSDomEvent<{ files: File[] }>) {
this._error = undefined; this._error = undefined;
const file = ev.detail.files[0]; const file = ev.detail.files[0];
@@ -133,9 +139,14 @@ export class DialogUploadBackup
}; };
} }
private _filesCleared() {
this._error = undefined;
this._formData = INITIAL_UPLOAD_FORM_DATA;
}
private async _upload() { private async _upload() {
const { file } = this._formData!; const { file } = this._formData!;
if (!file || file.type !== SUPPORTED_FORMAT) { if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize( title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title" "ui.panel.config.backup.dialogs.upload.unsupported.title"
@@ -154,7 +165,7 @@ export class DialogUploadBackup
this._uploading = true; this._uploading = true;
try { try {
await uploadBackup(this.hass!, file, agentIds); await uploadBackup(this.hass, file, agentIds);
this._params!.submit?.(); this._params!.submit?.();
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {

View File

@@ -0,0 +1,21 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { BackupContent } from "../../../../data/backup";
export interface DownloadDecryptedBackupDialogParams {
backup: BackupContent;
agentId?: string;
}
export const loadDownloadDecryptedBackupDialog = () =>
import("./dialog-download-decrypted-backup");
export const showDownloadDecryptedBackupDialog = (
element: HTMLElement,
params: DownloadDecryptedBackupDialogParams
) => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-download-decrypted-backup",
dialogImport: loadDownloadDecryptedBackupDialog,
dialogParams: params,
});
};

View File

@@ -11,6 +11,7 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage"; import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event"; import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
@@ -42,9 +43,11 @@ import {
compareAgents, compareAgents,
computeBackupAgentName, computeBackupAgentName,
computeBackupSize, computeBackupSize,
computeBackupType,
deleteBackup, deleteBackup,
generateBackup, generateBackup,
generateBackupWithAutomaticSettings, generateBackupWithAutomaticSettings,
getBackupTypes,
isLocalAgent, isLocalAgent,
isNetworkMountAgent, isNetworkMountAgent,
} from "../../../data/backup"; } from "../../../data/backup";
@@ -74,10 +77,6 @@ interface BackupRow extends DataTableRowData, BackupContent {
agent_ids: string[]; agent_ids: string[];
} }
type BackupType = "automatic" | "manual";
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
@customElement("ha-config-backup-backups") @customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) { class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -141,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}; };
private _columns = memoizeOne( private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({ (
localize: LocalizeFunc,
maxDisplayedAgents: number
): DataTableColumnContainer<BackupRow> => ({
name: { name: {
title: localize("ui.panel.config.backup.name"), title: localize("ui.panel.config.backup.name"),
main: true, main: true,
@@ -172,10 +174,21 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
locations: { locations: {
title: localize("ui.panel.config.backup.locations"), title: localize("ui.panel.config.backup.locations"),
showNarrow: true, showNarrow: true,
minWidth: "60px", // 24 icon size, 4 gap, 16 left and right padding
template: (backup) => html` minWidth: `${maxDisplayedAgents * 24 + (maxDisplayedAgents - 1) * 4 + 32}px`,
template: (backup) => {
const agentIds = backup.agent_ids;
const displayedAgentIds =
agentIds.length > maxDisplayedAgents
? [...agentIds].splice(0, maxDisplayedAgents - 1)
: agentIds;
const agentsMore = Math.max(
agentIds.length - displayedAgentIds.length,
0
);
return html`
<div style="display: flex; gap: 4px;"> <div style="display: flex; gap: 4px;">
${(backup.agent_ids || []).map((agentId) => { ${displayedAgentIds.map((agentId) => {
const name = computeBackupAgentName( const name = computeBackupAgentName(
this.hass.localize, this.hass.localize,
agentId, agentId,
@@ -218,8 +231,18 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
/> />
`; `;
})} })}
${agentsMore
? html`
<span
style="display: flex; align-items: center; font-size: 14px;"
>
+${agentsMore}
</span>
`
: nothing}
</div> </div>
`, `;
},
}, },
actions: { actions: {
title: "", title: "",
@@ -253,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
); );
private _groupOrder = memoizeOne( private _groupOrder = memoizeOne(
(activeGrouping: string | undefined, localize: LocalizeFunc) => (
activeGrouping: string | undefined,
localize: LocalizeFunc,
isHassio: boolean
) =>
activeGrouping === "formatted_type" activeGrouping === "formatted_type"
? TYPE_ORDER.map((type) => ? getBackupTypes(isHassio).map((type) =>
localize(`ui.panel.config.backup.type.${type}`) localize(`ui.panel.config.backup.type.${type}`)
) )
: undefined : undefined
@@ -279,33 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
( (
backups: BackupContent[], backups: BackupContent[],
filters: DataTableFiltersValues, filters: DataTableFiltersValues,
localize: LocalizeFunc localize: LocalizeFunc,
isHassio: boolean
): BackupRow[] => { ): BackupRow[] => {
const typeFilter = filters["ha-filter-states"] as string[] | undefined; const typeFilter = filters["ha-filter-states"] as string[] | undefined;
let filteredBackups = backups; let filteredBackups = backups;
if (typeFilter?.length) { if (typeFilter?.length) {
filteredBackups = filteredBackups.filter( filteredBackups = filteredBackups.filter((backup) => {
(backup) => const type = computeBackupType(backup, isHassio);
(backup.with_automatic_settings && return typeFilter.includes(type);
typeFilter.includes("automatic")) || });
(!backup.with_automatic_settings && typeFilter.includes("manual"))
);
} }
return filteredBackups.map((backup) => { return filteredBackups.map((backup) => {
const type = backup.with_automatic_settings ? "automatic" : "manual"; const type = computeBackupType(backup, isHassio);
const agentIds = Object.keys(backup.agents);
return { return {
...backup, ...backup,
size: computeBackupSize(backup), size: computeBackupSize(backup),
agent_ids: Object.keys(backup.agents).sort(compareAgents), agent_ids: agentIds.sort(compareAgents),
formatted_type: localize(`ui.panel.config.backup.type.${type}`), formatted_type: localize(`ui.panel.config.backup.type.${type}`),
}; };
}); });
} }
); );
private _maxAgents = memoizeOne((data: BackupRow[]): number =>
Math.max(...data.map((row) => row.agent_ids.length))
);
protected render(): TemplateResult { protected render(): TemplateResult {
const backupInProgress = const backupInProgress =
"state" in this.manager && this.manager.state === "in_progress"; "state" in this.manager && this.manager.state === "in_progress";
const isHassio = isComponentLoaded(this.hass, "hassio");
const data = this._data(
this.backups,
this._filters,
this.hass.localize,
isHassio
);
const maxDisplayedAgents = Math.min(
this._maxAgents(data),
this.narrow ? 3 : 5
);
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
@@ -336,15 +378,16 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.initialCollapsedGroups=${this._activeCollapsed} .initialCollapsedGroups=${this._activeCollapsed}
.groupOrder=${this._groupOrder( .groupOrder=${this._groupOrder(
this._activeGrouping, this._activeGrouping,
this.hass.localize this.hass.localize,
isHassio
)} )}
@grouping-changed=${this._handleGroupingChanged} @grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged} @collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged} @selection-changed=${this._handleSelectionChanged}
.route=${this.route} .route=${this.route}
@row-click=${this._showBackupDetails} @row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)} .columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
.data=${this._data(this.backups, this._filters, this.hass.localize)} .data=${data}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")} .noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize( .searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search" "ui.panel.config.backup.picker.search"
@@ -400,7 +443,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")} .label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.value=${this._filters["ha-filter-states"]} .value=${this._filters["ha-filter-states"]}
.states=${this._states(this.hass.localize)} .states=${this._states(this.hass.localize, isHassio)}
@data-table-filter-changed=${this._filterChanged} @data-table-filter-changed=${this._filterChanged}
slot="filter-pane" slot="filter-pane"
expanded expanded
@@ -425,8 +468,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
`; `;
} }
private _states = memoizeOne((localize: LocalizeFunc) => private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
TYPE_ORDER.map((type) => ({ getBackupTypes(isHassio).map((type) => ({
value: type, value: type,
label: localize(`ui.panel.config.backup.type.${type}`), label: localize(`ui.panel.config.backup.type.${type}`),
})) }))
@@ -496,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
} }
private async _downloadBackup(backup: BackupContent): Promise<void> { private async _downloadBackup(backup: BackupContent): Promise<void> {
downloadBackup( downloadBackup(this.hass, this, backup, this.config);
this.hass,
this,
backup,
this.config?.create_backup.password
);
} }
private async _deleteBackup(backup: BackupContent): Promise<void> { private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@@ -8,7 +8,6 @@ import {
} from "@mdi/js"; } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
@@ -25,27 +24,25 @@ import type {
BackupConfig, BackupConfig,
BackupContentAgent, BackupContentAgent,
BackupContentExtended, BackupContentExtended,
BackupData,
} from "../../../data/backup"; } from "../../../data/backup";
import "./components/ha-backup-details-summary";
import "./components/ha-backup-details-restore";
import { import {
compareAgents, compareAgents,
computeBackupAgentName, computeBackupAgentName,
computeBackupSize,
deleteBackup, deleteBackup,
fetchBackupDetails, fetchBackupDetails,
isLocalAgent, isLocalAgent,
isNetworkMountAgent, isNetworkMountAgent,
} from "../../../data/backup"; } from "../../../data/backup";
import type { HassioAddonInfo } from "../../../data/hassio/addon";
import "../../../layouts/hass-subpage"; import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
import { bytesToString } from "../../../util/bytes-to-string";
import "./components/ha-backup-data-picker";
import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup"; import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { downloadBackup } from "./helper/download_backup"; import { downloadBackup } from "./helper/download_backup";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
interface Agent extends BackupContentAgent { interface Agent extends BackupContentAgent {
id: string; id: string;
@@ -91,10 +88,6 @@ class HaConfigBackupDetails extends LitElement {
@state() private _error?: string; @state() private _error?: string;
@state() private _selectedData?: BackupData;
@state() private _addonsInfo?: HassioAddonInfo[];
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
@@ -110,6 +103,8 @@ class HaConfigBackupDetails extends LitElement {
return nothing; return nothing;
} }
const isHassio = isComponentLoaded(this.hass, "hassio");
return html` return html`
<hass-subpage <hass-subpage
back-path="/config/backup/backups" back-path="/config/backup/backups"
@@ -153,69 +148,18 @@ class HaConfigBackupDetails extends LitElement {
: !this._backup : !this._backup
? html`<ha-circular-progress active></ha-circular-progress>` ? html`<ha-circular-progress active></ha-circular-progress>`
: html` : html`
<ha-card> <ha-backup-details-summary
<div class="card-header"> .backup=${this._backup}
${this.hass.localize(
"ui.panel.config.backup.details.summary.title"
)}
</div>
<div class="card-content">
<ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.size"
)}
</span>
<span slot="supporting-text">
${bytesToString(computeBackupSize(this._backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.created"
)}
</span>
<span slot="supporting-text">
${formatDateTime(
new Date(this._backup.date),
this.hass.locale,
this.hass.config
)}
</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.details.restore.title"
)}
</div>
<div class="card-content">
<ha-backup-data-picker
.hass=${this.hass} .hass=${this.hass}
.data=${this._backup} .localize=${this.hass.localize}
.value=${this._selectedData} .isHassio=${isHassio}
@value-changed=${this._selectedBackupChanged} ></ha-backup-details-summary>
.addonsInfo=${this._addonsInfo} <ha-backup-details-restore
> .backup=${this._backup}
</ha-backup-data-picker> @backup-restore=${this._restore}
</div> .hass=${this.hass}
<div class="card-actions"> .localize=${this.hass.localize}
<ha-button ></ha-backup-details-restore>
@click=${this._restore}
.disabled=${this._isRestoreDisabled()}
class="danger"
>
${this.hass.localize(
"ui.panel.config.backup.details.restore.action"
)}
</ha-button>
</div>
</ha-card>
<ha-card> <ha-card>
<div class="card-header"> <div class="card-header">
${this.hass.localize( ${this.hass.localize(
@@ -344,30 +288,13 @@ class HaConfigBackupDetails extends LitElement {
`; `;
} }
private _selectedBackupChanged(ev: CustomEvent) { private _restore(ev: CustomEvent) {
ev.stopPropagation(); if (!this._backup || !ev.detail.selectedData) {
this._selectedData = ev.detail.value;
}
private _isRestoreDisabled() {
return (
!this._selectedData ||
!(
this._selectedData?.database_included ||
this._selectedData?.homeassistant_included ||
this._selectedData.addons.length ||
this._selectedData.folders.length
)
);
}
private _restore() {
if (!this._backup || !this._selectedData) {
return; return;
} }
showRestoreBackupDialog(this, { showRestoreBackupDialog(this, {
backup: this._backup, backup: this._backup,
selectedData: this._selectedData, selectedData: ev.detail.selectedData,
}); });
} }
@@ -401,13 +328,7 @@ class HaConfigBackupDetails extends LitElement {
} }
private async _downloadBackup(agentId?: string): Promise<void> { private async _downloadBackup(agentId?: string): Promise<void> {
await downloadBackup( await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
this.hass,
this,
this._backup!,
this.config?.create_backup.password,
agentId
);
} }
private async _deleteBackup(): Promise<void> { private async _deleteBackup(): Promise<void> {
@@ -459,13 +380,6 @@ class HaConfigBackupDetails extends LitElement {
--mdc-icon-size: 48px; --mdc-icon-size: 48px;
color: var(--primary-text-color); color: var(--primary-text-color);
} }
ha-md-list.summary ha-md-list-item {
--md-list-item-supporting-text-size: 1rem;
--md-list-item-label-text-size: 0.875rem;
--md-list-item-label-text-color: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--primary-text-color);
}
.warning { .warning {
color: var(--error-color); color: var(--error-color);
} }
@@ -475,9 +389,6 @@ class HaConfigBackupDetails extends LitElement {
ha-button.danger { ha-button.danger {
--mdc-theme-primary: var(--error-color); --mdc-theme-primary: var(--error-color);
} }
ha-backup-data-picker {
display: block;
}
ha-md-list-item [slot="supporting-text"] { ha-md-list-item [slot="supporting-text"] {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -221,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px; gap: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-bottom: 24px; margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
margin-bottom: 72px;
} }
.card-actions { .card-actions {
display: flex; display: flex;

View File

@@ -50,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
} }
} }
protected firstUpdated(_changedProperties: PropertyValues): void { public connectedCallback(): void {
super.firstUpdated(_changedProperties); super.connectedCallback();
this._scrollToSection(); this._scrollToSection();
// Update config the page is displayed (e.g. when coming back from a location detail page)
this._config = this.config;
} }
private async _scrollToSection() { private async _scrollToSection() {

View File

@@ -119,6 +119,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
settings: { settings: {
tag: "ha-config-backup-settings", tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"), load: () => import("./ha-config-backup-settings"),
cache: true,
}, },
location: { location: {
tag: "ha-config-backup-location", tag: "ha-config-backup-location",

View File

@@ -1,20 +1,17 @@
import type { LitElement } from "lit"; import type { LitElement } from "lit";
import { getSignedPath } from "../../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../../data/backup";
import { import {
canDecryptBackupOnDownload, canDecryptBackupOnDownload,
getBackupDownloadUrl, getBackupDownloadUrl,
getPreferredAgentForDownload, getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup"; } from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download"; import { fileDownload } from "../../../../util/file_download";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
const triggerDownload = async ( export const downloadBackupFile = async (
hass: HomeAssistant, hass: HomeAssistant,
backupId: string, backupId: string,
preferedAgent: string, preferedAgent: string,
@@ -27,83 +24,51 @@ const triggerDownload = async (
fileDownload(signedUrl.path); fileDownload(signedUrl.path);
}; };
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async ( export const downloadBackup = async (
hass: HomeAssistant, hass: HomeAssistant,
element: LitElement, element: LitElement,
backup: BackupContent, backup: BackupContent,
encryptionKey?: string | null, backupConfig?: BackupConfig,
agentId?: string, agentId?: string
userProvided = false
): Promise<void> => { ): Promise<void> => {
const agentIds = Object.keys(backup.agents); const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds); const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected; const isProtected = backup.agents[preferedAgent]?.protected;
if (isProtected) { if (!isProtected) {
if (encryptionKey) { downloadBackupFile(hass, backup.backup_id, preferedAgent);
return;
}
const encryptionKey = backupConfig?.create_backup?.password;
if (!encryptionKey) {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
try { try {
// Check if we can decrypt it
await canDecryptBackupOnDownload( await canDecryptBackupOnDownload(
hass, hass,
backup.backup_id, backup.backup_id,
preferedAgent, preferedAgent,
encryptionKey encryptionKey
); );
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
} catch (err: any) { } catch (err: any) {
// If encryption key is incorrect, ask for encryption key
if (err?.code === "password_incorrect") { if (err?.code === "password_incorrect") {
if (userProvided) { showDownloadDecryptedBackupDialog(element, {
downloadEncryptedBackup(hass, element, backup, agentId); backup,
} else { agentId: preferedAgent,
requestEncryptionKey(hass, element, backup, agentId); });
}
return; return;
} }
// If decryption is not supported, ask for confirmation and download it encrypted
if (err?.code === "decrypt_not_supported") { if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, { showAlertDialog(element, {
title: hass.localize( title: hass.localize(
@@ -113,13 +78,13 @@ export const downloadBackup = async (
"ui.panel.config.backup.dialogs.download.decryption_unsupported" "ui.panel.config.backup.dialogs.download.decryption_unsupported"
), ),
confirm() { confirm() {
triggerDownload(hass, backup.backup_id, preferedAgent); downloadBackupFile(hass, backup.backup_id, preferedAgent);
}, },
}); });
encryptionKey = undefined;
return; return;
} }
// Else, show generic error
showAlertDialog(element, { showAlertDialog(element, {
title: hass.localize( title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title", "ui.panel.config.backup.dialogs.download.error_check_title",
@@ -134,13 +99,5 @@ export const downloadBackup = async (
} }
), ),
}); });
return;
} }
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
}
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey);
}; };

View File

@@ -1,15 +1,15 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time"; import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce"; import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-tip";
import "../../../../components/ha-list-item";
import "../../../../components/ha-button-menu"; import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-list-item";
import "../../../../components/ha-tip";
import type { import type {
CloudStatusLoggedIn, CloudStatusLoggedIn,
SubscriptionInfo, SubscriptionInfo,
@@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
import "./cloud-remote-pref"; import "./cloud-remote-pref";
import "./cloud-tts-pref"; import "./cloud-tts-pref";
import "./cloud-webhooks"; import "./cloud-webhooks";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
@customElement("cloud-account") @customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) { export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
header="Home Assistant Cloud" header="Home Assistant Cloud"
> >
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}> <ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
@@ -65,6 +66,12 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
)} )}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu> </ha-button-menu>
<div class="content"> <div class="content">
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
@@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
fireEvent(this, "ha-refresh-cloud-status"); fireEvent(this, "ha-refresh-cloud-status");
} }
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() { private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, { const confirm = await showConfirmationDialog(this, {
title: this.hass.localize( title: this.hass.localize(
@@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
} }
} }
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() { static get styles() {
return [ return [
haStyle, haStyle,

View File

@@ -0,0 +1,206 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-circular-progress";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-markdown-element";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-select";
import "../../../../components/ha-textarea";
import { fetchSupportPackage } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download";
@customElement("dialog-cloud-support-package")
export class DialogSupportPackage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _open = false;
@state() private _supportPackage?: string;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog() {
this._open = true;
this._loadSupportPackage();
}
private _dialogClosed(): void {
this._open = false;
this._supportPackage = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._open) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title">Download support package</span>
</ha-dialog-header>
<div slot="content">
${this._supportPackage
? html`<ha-markdown-element
.content=${this._supportPackage}
breaks
></ha-markdown-element>`
: html`
<div class="progress-container">
<ha-circular-progress indeterminate></ha-circular-progress>
Generating preview...
</div>
`}
</div>
<div class="footer" slot="actions">
<ha-alert>
This file may contain personal data about your home. Avoid sharing
them with unverified or untrusted parties.
</ha-alert>
<hr />
<div class="actions">
<ha-button @click=${this.closeDialog}>Close</ha-button>
<ha-button @click=${this._download}>Download</ha-button>
</div>
</div>
</ha-md-dialog>
`;
}
private async _loadSupportPackage() {
this._supportPackage = await fetchSupportPackage(this.hass);
}
private async _download() {
fileDownload(
"data:text/plain;charset=utf-8," +
encodeURIComponent(this._supportPackage || ""),
"support-package.md"
);
}
static styles = css`
ha-md-dialog {
min-width: 90vw;
min-height: 90vh;
}
.progress-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(90vh - 260px);
width: 100%;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
min-width: 100vw;
min-height: 100vh;
}
.progress-container {
height: calc(100vh - 260px);
}
}
.footer {
flex-direction: column;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
hr {
border: none;
border-top: 1px solid var(--divider-color);
width: calc(100% + 48px);
margin-right: -24px;
margin-left: -24px;
}
table,
th,
td {
border: none;
}
table {
width: 100%;
display: table;
border-collapse: collapse;
border-spacing: 0;
}
table tr {
border-bottom: none;
}
table > tbody > tr:nth-child(odd) {
background-color: rgba(var(--rgb-primary-text-color), 0.04);
}
table > tbody > tr > td {
border-radius: 0;
}
table > tbody > tr {
-webkit-transition: background-color 0.25s ease;
transition: background-color 0.25s ease;
}
table > tbody > tr:hover {
background-color: rgba(var(--rgb-primary-text-color), 0.08);
}
tr {
border-bottom: 1px solid var(--divider-color);
}
td,
th {
padding: 15px 5px;
display: table-cell;
text-align: left;
vertical-align: middle;
border-radius: 2px;
}
details {
background-color: var(--secondary-background-color);
padding: 16px 24px;
margin: 8px 0;
border: 1px solid var(--divider-color);
border-radius: 16px;
}
summary {
font-weight: bold;
cursor: pointer;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-support-package": DialogSupportPackage;
}
}

View File

@@ -0,0 +1,12 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadSupportPackageDialog = () =>
import("./dialog-cloud-support-package");
export const showSupportPackageDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-cloud-support-package",
dialogImport: loadSupportPackageDialog,
dialogParams: {},
});
};

View File

@@ -1,6 +1,6 @@
import "@material/mwc-button"; import "@material/mwc-button";
import "@material/mwc-list/mwc-list"; import "@material/mwc-list/mwc-list";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js"; import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
@@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section"; import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
@customElement("cloud-login") @customElement("cloud-login")
export class CloudLogin extends LitElement { export class CloudLogin extends LitElement {
@@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
header="Home Assistant Cloud" header="Home Assistant Cloud"
> >
<ha-button-menu slot="toolbar-icon" @action=${this._deleteCloudData}> <ha-button-menu slot="toolbar-icon" @action=${this._handleMenuAction}>
<ha-icon-button <ha-icon-button
slot="trigger" slot="trigger"
.label=${this.hass.localize("ui.common.menu")} .label=${this.hass.localize("ui.common.menu")}
@@ -70,6 +71,12 @@ export class CloudLogin extends LitElement {
)} )}
<ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon> <ha-svg-icon slot="graphic" .path=${mdiDeleteForever}></ha-svg-icon>
</ha-list-item> </ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu> </ha-button-menu>
<div class="content"> <div class="content">
<ha-config-section .isWide=${this.isWide}> <ha-config-section .isWide=${this.isWide}>
@@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
fireEvent(this, "flash-message-changed", { value: "" }); fireEvent(this, "flash-message-changed", { value: "" });
} }
private _handleMenuAction(ev) {
switch (ev.detail.index) {
case 0:
this._deleteCloudData();
break;
case 1:
this._downloadSupportPackage();
}
}
private async _deleteCloudData() { private async _deleteCloudData() {
const confirm = await showConfirmationDialog(this, { const confirm = await showConfirmationDialog(this, {
title: this.hass.localize( title: this.hass.localize(
@@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
} }
} }
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() { static get styles() {
return [ return [
haStyle, haStyle,

View File

@@ -66,6 +66,18 @@ const randomTip = (hass: HomeAssistant, narrow: boolean) => {
rel="noreferrer" rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_x")}</a >${hass.localize("ui.panel.config.tips.join_x")}</a
>`, >`,
mastodon: html`<a
href=${documentationUrl(hass, `/mastodon`)}
target="_blank"
rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_mastodon")}</a
>`,
bluesky: html`<a
href=${documentationUrl(hass, `/bluesky`)}
target="_blank"
rel="noreferrer"
>${hass.localize("ui.panel.config.tips.join_bluesky")}</a
>`,
discord: html`<a discord: html`<a
href=${documentationUrl(hass, `/join-chat`)} href=${documentationUrl(hass, `/join-chat`)}
target="_blank" target="_blank"

View File

@@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
(ent) => computeDomain(ent.entity_id) === "assist_satellite" (ent) => computeDomain(ent.entity_id) === "assist_satellite"
); );
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if ( if (
!domains.includes("voip") &&
assistSatellite && assistSatellite &&
assistSatelliteSupportsSetupFlow( assistSatelliteSupportsSetupFlow(
this.hass.states[assistSatellite.entity_id] this.hass.states[assistSatellite.entity_id]
@@ -1088,12 +1095,6 @@ export class HaConfigDevicePage extends LitElement {
}); });
} }
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if (domains.includes("mqtt")) { if (domains.includes("mqtt")) {
const mqtt = await import( const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions" "./device-detail/integration-elements/mqtt/device-actions"

View File

@@ -51,8 +51,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item"; import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu"; import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry"; import { createAreaRegistryEntry } from "../../../data/area_registry";
import type { ConfigEntry } from "../../../data/config_entries"; import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { sortConfigEntries } from "../../../data/config_entries"; import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context"; import { fullEntitiesContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters"; import type { DataTableFilters } from "../../../data/data_table_filters";
import { import {
@@ -108,6 +108,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public entries!: ConfigEntry[]; @property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() @state()
@consume({ context: fullEntitiesContext, subscribe: true }) @consume({ context: fullEntitiesContext, subscribe: true })
entities!: EntityRegistryEntry[]; entities!: EntityRegistryEntry[];
@@ -219,6 +221,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
private _setFiltersFromUrl() { private _setFiltersFromUrl() {
const domain = this._searchParms.get("domain"); const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry"); const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label"); const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) { if (!domain && !configEntry && !label) {
@@ -243,6 +246,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
value: configEntry ? [configEntry] : [], value: configEntry ? [configEntry] : [],
items: undefined, items: undefined,
}, },
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
}; };
this._filterLabel(); this._filterLabel();
} }
@@ -334,6 +341,32 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) { if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0]; filteredConfigEntry = configEntries[0];
} }
} else if (
key === "sub_entry" &&
Array.isArray(filter.value) &&
filter.value.length
) {
if (
!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)
) {
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
)
)
);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
}
} else if ( } else if (
key === "ha-filter-integrations" && key === "ha-filter-integrations" &&
Array.isArray(filter.value) && Array.isArray(filter.value) &&
@@ -626,7 +659,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
(area) => (area) =>
html`<ha-md-menu-item html`<ha-md-menu-item
.value=${area.area_id} .value=${area.area_id}
@click=${this._handleBulkArea} .clickAction=${this._handleBulkArea}
> >
${area.icon ${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
@@ -637,7 +670,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
<div slot="headline">${area.name}</div> <div slot="headline">${area.name}</div>
</ha-md-menu-item>` </ha-md-menu-item>`
)} )}
<ha-md-menu-item .value=${null} @click=${this._handleBulkArea}> <ha-md-menu-item .value=${null} .clickAction=${this._handleBulkArea}>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area" "ui.panel.config.devices.picker.bulk_actions.no_area"
@@ -645,7 +678,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
</div> </div>
</ha-md-menu-item> </ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider> <ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._bulkCreateArea}> <ha-md-menu-item .clickAction=${this._bulkCreateArea}>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area" "ui.panel.config.devices.picker.bulk_actions.add_area"
@@ -684,7 +717,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
</ha-md-menu-item>`; </ha-md-menu-item>`;
})} })}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider> <ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._bulkCreateLabel}> <ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
<div slot="headline"> <div slot="headline">
${this.hass.localize("ui.panel.config.labels.add_label")} ${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-md-menu-item </div></ha-md-menu-item
@@ -755,7 +788,15 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
${this.entries?.find( ${this.entries?.find(
(entry) => (entry) =>
entry.entry_id === this._filters.config_entry!.value![0] entry.entry_id === this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]} )?.title || this._filters.config_entry.value[0]}${this._filters
.config_entry.value.length === 1 &&
Array.isArray(this._filters.sub_entry?.value) &&
this._filters.sub_entry.value.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry!.value![0]
)?.title || this._filters.sub_entry!.value![0]})`
: nothing}
</ha-alert>` </ha-alert>`
: nothing} : nothing}
<ha-filter-floor-areas <ha-filter-floor-areas
@@ -888,6 +929,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
`; `;
} }
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _filterExpanded(ev) { private _filterExpanded(ev) {
if (ev.detail.expanded) { if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName; this._expandedFilter = ev.target.localName;
@@ -969,10 +1014,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
this._selected = ev.detail.value; this._selected = ev.detail.value;
} }
private async _handleBulkArea(ev) { private _handleBulkArea = (item) => {
const area = ev.currentTarget.value; const area = item.value;
this._bulkAddArea(area); this._bulkAddArea(area);
} };
private async _bulkAddArea(area: string) { private async _bulkAddArea(area: string) {
const promises: Promise<DeviceRegistryEntry>[] = []; const promises: Promise<DeviceRegistryEntry>[] = [];
@@ -999,7 +1044,7 @@ ${rejected
} }
} }
private async _bulkCreateArea() { private _bulkCreateArea = () => {
showAreaRegistryDetailDialog(this, { showAreaRegistryDetailDialog(this, {
createEntry: async (values) => { createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values); const area = await createAreaRegistryEntry(this.hass, values);
@@ -1007,7 +1052,7 @@ ${rejected
return area; return area;
}, },
}); });
} };
private async _handleBulkLabel(ev) { private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value; const label = ev.currentTarget.value;
@@ -1045,7 +1090,7 @@ ${rejected
} }
} }
private _bulkCreateLabel() { private _bulkCreateLabel = () => {
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
@@ -1053,7 +1098,7 @@ ${rejected
return label; return label;
}, },
}); });
} };
private _handleSortingChanged(ev: CustomEvent) { private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail; this._activeSorting = ev.detail;

View File

@@ -66,8 +66,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item"; import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu"; import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../data/config_entries"; import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getConfigEntries } from "../../../data/config_entries"; import { getConfigEntries, getSubEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context"; import { fullEntitiesContext } from "../../../data/context";
import type { import type {
DataTableFiltersItems, DataTableFiltersItems,
@@ -146,6 +146,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entries?: ConfigEntry[]; @state() private _entries?: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() private _manifests?: IntegrationManifest[]; @state() private _manifests?: IntegrationManifest[];
@state() @state()
@@ -522,6 +524,27 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) { if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0]; filteredConfigEntry = configEntries[0];
} }
} else if (
key === "sub_entry" &&
Array.isArray(filter) &&
filter.length
) {
if (
!(
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry.length === 1
)
) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
(filter as string[]).includes(entity.config_subentry_id)
);
if (!this._subEntries) {
this._loadSubEntries(this._filters.config_entry[0]);
}
} else if ( } else if (
key === "ha-filter-integrations" && key === "ha-filter-integrations" &&
Array.isArray(filter) && Array.isArray(filter) &&
@@ -904,14 +927,22 @@ ${
</ha-md-button-menu> </ha-md-button-menu>
${ ${
Array.isArray(this._filters.config_entry) && Array.isArray(this._filters.config_entry) &&
this._filters.config_entry?.length this._filters.config_entry.length
? html`<ha-alert slot="filter-pane"> ? html`<ha-alert slot="filter-pane">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.entities.picker.filtering_by_config_entry" "ui.panel.config.entities.picker.filtering_by_config_entry"
)} )}
${this._entries?.find( ${this._entries?.find(
(entry) => entry.entry_id === this._filters.config_entry![0] (entry) => entry.entry_id === this._filters.config_entry![0]
)?.title || this._filters.config_entry[0]} )?.title || this._filters.config_entry[0]}${this._filters
.config_entry.length === 1 &&
Array.isArray(this._filters.sub_entry) &&
this._filters.sub_entry.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry![0]
)?.title || this._filters.sub_entry[0]})`
: nothing}
</ha-alert>` </ha-alert>`
: nothing : nothing
} }
@@ -1024,6 +1055,7 @@ ${
private _setFiltersFromUrl() { private _setFiltersFromUrl() {
const domain = this._searchParms.get("domain"); const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry"); const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label"); const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) { if (!domain && !configEntry && !label) {
@@ -1036,6 +1068,7 @@ ${
"ha-filter-states": [], "ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [], "ha-filter-integrations": domain ? [domain] : [],
config_entry: configEntry ? [configEntry] : [], config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
}; };
this._filterLabel(); this._filterLabel();
} }
@@ -1093,6 +1126,7 @@ ${
hidden_by: null, hidden_by: null,
area_id: null, area_id: null,
config_entry_id: null, config_entry_id: null,
config_subentry_id: null,
device_id: null, device_id: null,
icon: null, icon: null,
readonly: true, readonly: true,
@@ -1384,6 +1418,10 @@ ${rejected
this._entries = await getConfigEntries(this.hass); this._entries = await getConfigEntries(this.hass);
} }
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _addDevice() { private _addDevice() {
const { filteredConfigEntry, filteredDomains } = const { filteredConfigEntry, filteredDomains } =
this._filteredEntitiesAndDomains( this._filteredEntitiesAndDomains(

View File

@@ -39,7 +39,6 @@ import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available"; import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common"; import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts"; import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60; const DATASAMPLES = 60;
@@ -153,13 +152,6 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
type: "time", type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
},
axisLine: {
show: false,
},
}, },
yAxis: { yAxis: {
type: "value", type: "value",

View File

@@ -561,7 +561,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
(category) => (category) =>
html`<ha-md-menu-item html`<ha-md-menu-item
.value=${category.category_id} .value=${category.category_id}
@click=${this._handleBulkCategory} .clickAction=${this._handleBulkCategory}
> >
${category.icon ${category.icon
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
@@ -569,7 +569,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<div slot="headline">${category.name}</div> <div slot="headline">${category.name}</div>
</ha-md-menu-item>` </ha-md-menu-item>`
)} )}
<ha-md-menu-item .value=${null} @click=${this._handleBulkCategory}> <ha-md-menu-item .value=${null} .clickAction=${this._handleBulkCategory}>
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category" "ui.panel.config.automation.picker.bulk_actions.no_category"
@@ -577,7 +577,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
</div> </div>
</ha-md-menu-item> </ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider> <ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._bulkCreateCategory}> <ha-md-menu-item .clickAction=${this._bulkCreateCategory}>
<div slot="headline"> <div slot="headline">
${this.hass.localize("ui.panel.config.category.editor.add")} ${this.hass.localize("ui.panel.config.category.editor.add")}
</div> </div>
@@ -612,7 +612,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
</ha-label> </ha-label>
</ha-md-menu-item> `; </ha-md-menu-item> `;
})}<ha-md-divider role="separator" tabindex="-1"></ha-md-divider> })}<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._bulkCreateLabel}> <ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
<div slot="headline"> <div slot="headline">
${this.hass.localize("ui.panel.config.labels.add_label")} ${this.hass.localize("ui.panel.config.labels.add_label")}
</div> </div>
@@ -958,10 +958,10 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
}); });
} }
private async _handleBulkCategory(ev) { private _handleBulkCategory = (item) => {
const category = ev.currentTarget.value; const category = item.value;
this._bulkAddCategory(category); this._bulkAddCategory(category);
} };
private async _bulkAddCategory(category: string) { private async _bulkAddCategory(category: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = []; const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1234,7 +1234,7 @@ ${rejected
showHelperDetailDialog(this, {}); showHelperDetailDialog(this, {});
} }
private async _bulkCreateCategory() { private _bulkCreateCategory = () => {
showCategoryRegistryDetailDialog(this, { showCategoryRegistryDetailDialog(this, {
scope: "helpers", scope: "helpers",
createEntry: async (values) => { createEntry: async (values) => {
@@ -1247,9 +1247,9 @@ ${rejected
return category; return category;
}, },
}); });
} };
private _bulkCreateLabel() { private _bulkCreateLabel = () => {
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
createEntry: async (values) => { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
@@ -1257,7 +1257,7 @@ ${rejected
return label; return label;
}, },
}); });
} };
private _handleSortingChanged(ev: CustomEvent) { private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail; this._activeSorting = ev.detail;

View File

@@ -21,6 +21,7 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import { createCloseHeading } from "../../../components/ha-dialog"; import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-button-prev"; import "../../../components/ha-icon-button-prev";
import "../../../components/search-input"; import "../../../components/search-input";
import { getConfigEntries } from "../../../data/config_entries";
import { fetchConfigFlowInProgress } from "../../../data/config_flow"; import { fetchConfigFlowInProgress } from "../../../data/config_flow";
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow"; import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import { import {
@@ -49,9 +50,6 @@ import "./ha-domain-integrations";
import "./ha-integration-list-item"; import "./ha-integration-list-item";
import type { AddIntegrationDialogParams } from "./show-add-integration-dialog"; import type { AddIntegrationDialogParams } from "./show-add-integration-dialog";
import { showYamlIntegrationDialog } from "./show-add-integration-dialog"; import { showYamlIntegrationDialog } from "./show-add-integration-dialog";
import { getConfigEntries } from "../../../data/config_entries";
import { stripDiacritics } from "../../../common/string/strip-diacritics";
import { getStripDiacriticsFn } from "../../../util/fuse";
export interface IntegrationListItem { export interface IntegrationListItem {
name: string; name: string;
@@ -256,7 +254,7 @@ class AddIntegrationDialog extends LitElement {
isCaseSensitive: false, isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2), minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2, threshold: 0.2,
getFn: getStripDiacriticsFn, ignoreDiacritics: true,
}; };
const helpers = Object.entries(h).map(([domain, integration]) => ({ const helpers = Object.entries(h).map(([domain, integration]) => ({
domain, domain,
@@ -266,16 +264,15 @@ class AddIntegrationDialog extends LitElement {
is_built_in: integration.is_built_in !== false, is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"), cloud: integration.iot_class?.startsWith("cloud_"),
})); }));
const normalizedFilter = stripDiacritics(filter);
return [ return [
...new Fuse(integrations, options) ...new Fuse(integrations, options)
.search(normalizedFilter) .search(filter)
.map((result) => result.item), .map((result) => result.item),
...new Fuse(yamlIntegrations, options) ...new Fuse(yamlIntegrations, options)
.search(normalizedFilter) .search(filter)
.map((result) => result.item), .map((result) => result.item),
...new Fuse(helpers, options) ...new Fuse(helpers, options)
.search(normalizedFilter) .search(filter)
.map((result) => result.item), .map((result) => result.item),
]; ];
} }
@@ -657,6 +654,7 @@ class AddIntegrationDialog extends LitElement {
startFlowHandler: domain, startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced, showAdvanced: this.hass.userData?.showAdvanced,
manifest, manifest,
navigateToResult: true,
}); });
} }

View File

@@ -137,6 +137,7 @@ export class HaConfigFlowCard extends LitElement {
} }
showConfigFlowDialog(this, { showConfigFlowDialog(this, {
continueFlowId: this.flow.flow_id, continueFlowId: this.flow.flow_id,
navigateToResult: true,
dialogClosedCallback: () => { dialogClosedCallback: () => {
this._handleFlowUpdated(); this._handleFlowUpdated();
}, },

View File

@@ -16,6 +16,7 @@ import {
mdiOpenInNew, mdiOpenInNew,
mdiPackageVariant, mdiPackageVariant,
mdiPlayCircleOutline, mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper, mdiProgressHelper,
mdiReload, mdiReload,
mdiReloadAlert, mdiReloadAlert,
@@ -52,14 +53,17 @@ import { getSignedPath } from "../../../data/auth";
import type { import type {
ConfigEntry, ConfigEntry,
DisableConfigEntryResult, DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries"; } from "../../../data/config_entries";
import { import {
ERROR_STATES, ERROR_STATES,
RECOVERABLE_STATES, RECOVERABLE_STATES,
deleteConfigEntry, deleteConfigEntry,
deleteSubEntry,
disableConfigEntry, disableConfigEntry,
enableConfigEntry, enableConfigEntry,
getConfigEntries, getConfigEntries,
getSubEntries,
reloadConfigEntry, reloadConfigEntry,
updateConfigEntry, updateConfigEntry,
} from "../../../data/config_entries"; } from "../../../data/config_entries";
@@ -106,6 +110,7 @@ import { fileDownload } from "../../../util/file_download";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog"; import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale"; import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
export const renderConfigEntryError = ( export const renderConfigEntryError = (
hass: HomeAssistant, hass: HomeAssistant,
@@ -172,6 +177,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _domainEntities: Record<string, string[]> = {}; @state() private _domainEntities: Record<string, string[]> = {};
@state() private _subEntries: Record<string, SubEntry[]> = {};
private _configPanel = memoizeOne( private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined => (domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find( Object.values(panels).find(
@@ -214,11 +221,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
protected willUpdate(changedProperties: PropertyValues): void { protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("domain")) { if (changedProperties.has("domain")) {
this.hass.loadBackendTranslation("title", [this.domain]); this.hass.loadBackendTranslation("title", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined; this._extraConfigEntries = undefined;
this._fetchManifest(); this._fetchManifest();
this._fetchDiagnostics(); this._fetchDiagnostics();
this._fetchEntitySources(); this._fetchEntitySources();
} }
if (
changedProperties.has("configEntries") ||
changedProperties.has("_extraConfigEntries")
) {
this._fetchSubEntries();
}
} }
private async _fetchEntitySources() { private async _fetchEntitySources() {
@@ -673,6 +687,73 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
ev.target.style.display = "none"; ev.target.style.display = "none";
} }
private _renderDeviceLine(
item: ConfigEntry,
devices: DeviceRegistryEntry[],
services: DeviceRegistryEntry[],
entities: EntityRegistryEntry[],
subItem?: SubEntry
) {
let devicesLine: (TemplateResult | string)[] = [];
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as const) {
if (items.length === 0) {
continue;
}
const url =
items.length === 1
? `/config/devices/device/${items[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`;
devicesLine.push(
// no white space before/after template on purpose
html`<a href=${url}
>${this.hass.localize(
`ui.panel.config.integrations.config_entry.${localizeKey}`,
{ count: items.length }
)}</a
>`
);
}
if (entities.length) {
devicesLine.push(
// no white space before/after template on purpose
html`<a
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
if (devicesLine.length === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else if (devicesLine.length === 2) {
devicesLine = [
devicesLine[0],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[1],
];
} else if (devicesLine.length === 3) {
devicesLine = [
devicesLine[0],
", ",
devicesLine[1],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[2],
];
}
return devicesLine;
}
private _renderConfigEntry(item: ConfigEntry) { private _renderConfigEntry(item: ConfigEntry) {
let stateText: Parameters<typeof this.hass.localize> | undefined; let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined; let stateTextExtra: TemplateResult | string | undefined;
@@ -720,66 +801,13 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}.`); )}.`);
} }
} else { } else {
for (const [items, localizeKey] of [ devicesLine = this._renderDeviceLine(item, devices, services, entities);
[devices, "devices"],
[services, "services"],
] as const) {
if (items.length === 0) {
continue;
}
const url =
items.length === 1
? `/config/devices/device/${items[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`;
devicesLine.push(
// no white space before/after template on purpose
html`<a href=${url}
>${this.hass.localize(
`ui.panel.config.integrations.config_entry.${localizeKey}`,
{ count: items.length }
)}</a
>`
);
}
if (entities.length) {
devicesLine.push(
// no white space before/after template on purpose
html`<a
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
if (devicesLine.length === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else if (devicesLine.length === 2) {
devicesLine = [
devicesLine[0],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[1],
];
} else if (devicesLine.length === 3) {
devicesLine = [
devicesLine[0],
", ",
devicesLine[1],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[2],
];
}
} }
const configPanel = this._configPanel(item.domain, this.hass.panels); const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries[item.entry_id] || [];
return html`<ha-md-list-item return html`<ha-md-list-item
class=${classMap({ class=${classMap({
config_entry: true, config_entry: true,
@@ -913,6 +941,21 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)} )}
</ha-md-menu-item> </ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.title`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider> <ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this._diagnosticHandler && item.state === "loaded" ${this._diagnosticHandler && item.state === "loaded"
@@ -989,6 +1032,69 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
` `
: nothing} : nothing}
</ha-md-button-menu> </ha-md-button-menu>
</ha-md-list-item>
${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`;
}
private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubEntry) {
const devices = this._getConfigEntryDevices(configEntry).filter((device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const services = this._getConfigEntryServices(configEntry).filter(
(device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const entities = this._getConfigEntryEntities(configEntry).filter(
(entity) => entity.config_subentry_id === subEntry.subentry_id
);
return html`<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this._renderDeviceLine(
configEntry,
devices,
services,
entities,
subEntry
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-button slot="end" @click=${this._handleReconfigureSub}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-md-list-item>`; </ha-md-list-item>`;
} }
@@ -1009,6 +1115,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _continueFlow(ev) { private _continueFlow(ev) {
showConfigFlowDialog(this, { showConfigFlowDialog(this, {
continueFlowId: ev.target.flow.flow_id, continueFlowId: ev.target.flow.flow_id,
navigateToResult: true,
dialogClosedCallback: () => { dialogClosedCallback: () => {
// this._handleFlowUpdated(); // this._handleFlowUpdated();
}, },
@@ -1030,6 +1137,27 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
} }
} }
private async _fetchSubEntries() {
const subEntriesPromises = (
this._extraConfigEntries || this.configEntries
)?.map((entry) =>
entry.num_subentries
? getSubEntries(this.hass, entry.entry_id).then((subEntries) => ({
entry_id: entry.entry_id,
subEntries,
}))
: undefined
);
if (subEntriesPromises) {
const subEntries = await Promise.all(subEntriesPromises);
this._subEntries = {};
subEntries.forEach((entry) => {
if (!entry) return;
this._subEntries[entry.entry_id] = entry.subEntries;
});
}
}
private async _fetchDiagnostics() { private async _fetchDiagnostics() {
if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) { if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) {
return; return;
@@ -1177,6 +1305,49 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
); );
} }
private async _handleReconfigureSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
showSubConfigFlowDialog(
this,
configEntry,
subEntry.flowType || subEntry.subentry_type,
{
startFlowHandler: configEntry.entry_id,
subEntryId: subEntry.subentry_id,
}
);
}
private async _handleDeleteSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: subEntry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await deleteSubEntry(this.hass, configEntry.entry_id, subEntry.subentry_id);
}
private _handleDisable(ev: Event): void { private _handleDisable(ev: Event): void {
this._disableIntegration( this._disableIntegration(
((ev.target as HTMLElement).closest(".config_entry") as any).configEntry ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry
@@ -1384,6 +1555,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
showAdvanced: this.hass.userData?.showAdvanced, showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, configEntry.domain), manifest: await fetchIntegrationManifest(this.hass, configEntry.domain),
entryId: configEntry.entry_id, entryId: configEntry.entry_id,
navigateToResult: true,
}); });
} }
@@ -1454,6 +1626,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}); });
} }
private async _addSubEntry(ev) {
showSubConfigFlowDialog(this, ev.target.entry, ev.target.flowType, {
startFlowHandler: ev.target.entry.entry_id,
});
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
@@ -1583,6 +1761,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
pointer-events: none; pointer-events: none;
content: ""; content: "";
} }
ha-md-list-item.sub-entry {
--md-list-item-leading-space: 50px;
}
a { a {
text-decoration: none; text-decoration: none;
} }

Some files were not shown because too many files have changed in this diff Show More