Compare commits

...

263 Commits

Author SHA1 Message Date
Bram Kragten
9d7d332790 20250205.0 (#24088) 2025-02-05 16:38:06 +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
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
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
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
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
Bram Kragten
b95e87845f Merge branch 'rc' into dev 2025-01-29 16:46:26 +01:00
Bram Kragten
bc49ebc489 Bumped version to 20250129.0 2025-01-29 16:38:25 +01:00
Wendelin
c97d0ce68a Add translations for backup agents encryption (#23938)
* Add translations for backup agents encryption

* Update src/translations/en.json

* Split class map

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-01-29 15:31:55 +00:00
renovate[bot]
a0e7d6f1c6 Update dependency lint-staged to v15.4.3 (#23937)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-29 16:23:04 +01:00
Petar Petrov
25d2a5ddac Open more-info on label click in echarts (#23921)
* Open more-info on label click in echarts

* check isExternalStatistic in energy devices click
2025-01-29 15:44:21 +01:00
Paul Bottein
a2f2d64f5c Allow to change encryption for each backup location (#23861)
* Add backup location settings page

* Add encryption settings

* Display unencrypted locations and backups

* Improve cloud detail page

* Fix encryption flag

* Fix restore backup

* Fix lint

* Fix translations

* Add warning

* Use agents in backup locations

* Feedback

* Use updated

* Improve encrypted/unencrypted status

* Improve code quality

* Remove hardcoded failed id

* Extend agent interface

* Use willupdate
2025-01-29 14:23:47 +01:00
Petar Petrov
fcdcbbda05 Fix statistics chart stacking and colors (#23922) 2025-01-29 12:46:59 +01:00
Paul Bottein
4b5c7fc2de Only restore config and database for core install (#23935) 2025-01-29 12:46:30 +01:00
Paul Bottein
dd64d88afe Use handle icon for drag and drop in energy devices settings (#23933)
* Use handle icon for drag and drop in energy devices settings

* Remove import
2025-01-29 11:04:09 +00:00
Wendelin
5feffd08ff Fix backup time supporting-text (#23931) 2025-01-29 09:08:26 +01:00
karwosts
543c7df3e0 Keyboard accessible menus in hass-tabs-subpage-data-table (#23927) 2025-01-29 08:23:27 +02:00
ildar170975
167f859f2a fix padding for error-log-card (#23923)
make padding similar to system-log-card
2025-01-29 08:22:24 +02:00
J. Nick Koston
c7d9699a24 Include addresses of Bluetooth devices in the connection slots tooltip (#23928) 2025-01-29 08:21:34 +02:00
ildar170975
a638cf443d fix overflow for ha-map (#23929) 2025-01-29 08:19:00 +02:00
Patrick Kortendick
438d1c13ef Fix misspelling for humidifier card description (#23924) 2025-01-29 05:20:01 +00:00
J. Nick Koston
cc8869b9f9 Show scanner name in the Bluetooth Advertisement Monitor (#23926) 2025-01-28 13:07:16 -10:00
J. Nick Koston
cbdb7406ad Add support for showing Bluetooth connection slot allocations (#23899) 2025-01-28 06:52:21 -10:00
Wendelin
f8d2560104 Revert "Add shortcut hint to assist dialog" (#23918)
Revert "Add shortcut hint to assist dialog (#23739)"

This reverts commit d121b33263.
2025-01-28 10:53:57 -05:00
Petar Petrov
cc48ae82d6 Fixes for echarts (#23906)
* show negative untracked energy again

* fix chart cards height

* fix timeline label width

* fix statistics chart legend

* fix layout of chart cards

* tweak timeline chart labels

* timeline label tweak

* css tweak

* fix legend colors in statistics chart

* dark mode fix

* fix for y axis with a long name

* listen for darkMode changes and update charts

* legend tweak for darkMode

* dark mode tweak

* hide insignificant echarts errors for now
2025-01-28 14:20:34 +00:00
karwosts
e1bda9b57d Use fixed positioning for ha-form-multi_select (#23781) 2025-01-28 15:13:19 +01:00
ildar170975
89e9316a40 Fix typo & layout in ha-assist-chip (#23785)
* fix typo fot slot name

* make font-size same as for label

* remove unneeded margin-top
2025-01-28 15:04:12 +01:00
Paul Bottein
85e4975206 Use name property when formatting backup location (#23916) 2025-01-28 13:53:02 +01:00
Ben Randall
142faba04d Update settings button icon on tables to mdi-table-cog. (#23915)
Update settings button icon on tables to mdi-table-cog.

The cog icon is used in a lot of places to indicate settings and it's generally clear what it's intent is.  However, the cog icon used by tables very often appears very close by the triple dots menu or just in the top right corner so it's very easy to mistakenly press that button intending to press the menu button, or to press it when looking for the appropriate settings button.

This changes the icon used by these buttons from mdi-code to mdi-table-cog to help clarify the intent of the button.
2025-01-28 11:24:08 +02:00
karwosts
c1c6d71ccf Fix keyboard in automation-picker menus (#23867) 2025-01-28 09:39:03 +02:00
renovate[bot]
5204a565cf Update dependency eslint to v9.19.0 (#23914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-28 09:37:55 +02:00
Petar Petrov
05b8a48ba8 Looser layout limits for graph cards (#23910) 2025-01-28 09:36:20 +02:00
renovate[bot]
41770a89b4 Update dependency element-internals-polyfill to v1.3.13 (#23912)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-27 16:46:14 +00:00
renovate[bot]
b7d14d7950 Update babel monorepo to v7.26.7 (#23911)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-27 17:34:34 +01:00
karwosts
4ccef6f28b Fix millisecond attributes (#23909)
fix millisecond attributes
2025-01-27 17:25:37 +02:00
karwosts
d1be441455 Re-remove time picker for Energy (#23891)
* Re-remove time picker for Energy

* flip polarity
2025-01-27 14:11:08 +00:00
Philipp
b3391b34e4 Increase generic entity row touch target (#23894) 2025-01-27 15:52:32 +02:00
Abílio Costa
9359e9d475 Reword incompatible media message (#23887) 2025-01-27 09:26:03 +02:00
Paul Bottein
5453da75ea Reintroduce backup switch when updating core and addons (#23814) 2025-01-27 08:25:28 +01:00
dependabot[bot]
bc21877008 Bump actions/stale from 9.0.0 to 9.1.0 (#23903)
Bumps [actions/stale](https://github.com/actions/stale) from 9.0.0 to 9.1.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 09:24:34 +02:00
dependabot[bot]
c9956c65e9 Bump actions/setup-node from 4.1.0 to 4.2.0 (#23902)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 09:24:01 +02:00
renovate[bot]
74814cc305 Update dependency @bundle-stats/plugin-webpack-filter to v4.18.2 (#23898)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 18:42:16 +00:00
renovate[bot]
8f231d7b3e Update rspack monorepo to v1.2.2 (#23896)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 19:33:12 +01:00
renovate[bot]
5b6818d72d Update dependency lint-staged to v15.4.2 (#23889)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 19:32:59 +01:00
renovate[bot]
8c75865a02 Update vitest monorepo to v3.0.4 (#23897)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-26 19:32:39 +01:00
J. Nick Koston
99d832ac77 Restore Bluetooth configuration panel (#23877)
* Restore Bluetooth configuration panel

* cleanup copypasta

* Apply suggestions from code review

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router.ts

* preen

* trim down updatePageEl
2025-01-26 08:11:21 +01:00
Simon Lamon
240e48f5c1 Include query params in url when default page is added (#23880)
Include query params if exists
2025-01-25 20:02:26 +01:00
Jan-Philipp Benecke
77fc11cda6 Use new improved save dialog when leaving script editor dirty (#23862)
* Use new improved save dialog when leaving script editor dirty

* Fix url
2025-01-25 19:14:47 +01:00
karwosts
f8f152f118 Add button filter to energy devices sortable (#23881)
Add filter to energy devices sortable
2025-01-25 18:42:05 +01:00
renovate[bot]
20a67cf73c Update rspack monorepo to v1.2.1 (#23884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-25 17:45:34 +01:00
ocrease
dd11b3092e Preheating support in History Chart (#23878)
Use CLIMATE_HVAC_ACTION_TO_MODE to map hvac action to heat or cool so that history chart correctly shows preheating and defrosting as heat
2025-01-25 12:25:08 +01:00
renovate[bot]
7f3363621e Update dependency @bundle-stats/plugin-webpack-filter to v4.18.0 (#23879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-25 12:20:46 +01:00
Jan-Philipp Benecke
edce1901d7 Display year when automation was last triggered in automation list (#23864)
* Display year when automation was last triggered in automation list

* Use function
2025-01-24 17:13:01 +00:00
Jan-Philipp Benecke
546087066a Display year in entities list only when last year (#23865)
* Display year in entities list only when last year

* Make function

* Revert hyphen
2025-01-24 18:03:21 +01:00
renovate[bot]
a95c589a06 Update vitest monorepo to v3.0.3 (#23870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 17:59:35 +01:00
renovate[bot]
5f6da9d959 Update rspack monorepo to v1.2.0 (#23871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 17:59:00 +01:00
renovate[bot]
bc94ca0a0a Pin dependency tslib to 2.8.1 (#23860)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-24 08:32:09 +02:00
Eric Stern
dc8d483e8b Logbook card loading fix (#23853)
* the call to unsubscribe will never do anything

* await the subscription when subscribing to logbook

* removed unneeded if
wrapped call to subscribeLogbook in try/catch
remove return values from _subscribeLogbookPeriod
2025-01-24 08:31:30 +02:00
renovate[bot]
a329553ce1 Update typescript-eslint monorepo to v8.21.0 (#23863)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 22:10:20 +01:00
Petar Petrov
1f8cfdd0de Migrate from chart.js to echarts (#23809) 2025-01-23 15:51:48 +00:00
Paul Bottein
fd1f966216 20250109.2 (#23859) 2025-01-23 16:18:56 +01:00
Paul Bottein
243a2ce0e2 Bumped version to 20250109.2 2025-01-23 16:16:42 +01:00
Paul Bottein
77c1786171 Fix delete button for state content in iOS and Android (#23847) 2025-01-23 16:15:49 +01:00
Paul Bottein
7a7c204d74 Fix delete button for state content in iOS (#23839) 2025-01-23 16:15:48 +01:00
Paul Bottein
dcb74ad2ee Fix backup data picker translations (#23826) 2025-01-23 16:15:47 +01:00
Paul Bottein
fd4c62a852 20250109.1 (#23858) 2025-01-23 14:26:04 +01:00
Paul Bottein
bf1b0ac949 Bumped version to 20250109.1 2025-01-23 14:17:53 +01:00
Bram Kragten
c6f0c62fd6 Prevent race in dialog box (#23758)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-23 14:15:23 +01:00
Wendelin
ac98672cb7 Fix background (#23736) 2025-01-23 14:15:22 +01:00
Bram Kragten
6b471ba6e7 Fix background on cast devices (#23731) 2025-01-23 14:15:21 +01:00
Petar Petrov
b28e7d2f06 Fix navigation from stacked dialogs with the same name (#23698)
* Fix navigation from stacked dialogs

* lint fix

* Keep only 1 instance per dialog tag in the stack
2025-01-23 14:15:20 +01:00
Simon Lamon
bf471eb8c3 Minor fixes for backup translations (#23691) 2025-01-23 14:15:19 +01:00
Petar Petrov
8c52bc3ffb Fix more-info chart rendering (#23619)
* Fix more-info chart rendering

* lint fix

* remove animation-container & _chartHeight

* don't change height on resize

* handle default height in ha-chart-base

* fix chart height in energy panel

* lint

* lint
2025-01-23 14:12:54 +01:00
Bram Kragten
54b328648a Match UI with core and don't allow restore config without db and vice … (#23553)
Match UI with core and dont allow restore config without db and vice versa
2025-01-23 13:59:37 +01:00
Jan-Philipp Benecke
62a9da2de2 Display year for created_at and modified_at of entities (#23772) 2025-01-23 13:36:23 +02:00
renovate[bot]
25ff96b9bf Update dependency eslint-config-prettier to v10 (#23764)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 13:31:03 +02:00
Matthias Alphart
caeb616dc6 Don't check for promise when processing DataEntryFlowStep (#23759) 2025-01-23 13:27:19 +02:00
karwosts
fb79e2cfb2 Fix keyboard for ha-config-entities bulk menus (#23776)
Fix keyboard for ha-config-entities menus
2025-01-23 13:15:23 +02:00
Paul Bottein
666316e44a Use area entities in area card for temperature and humidity (#23842)
Use area entities in area card for temperature and humidity instead of average
2025-01-23 12:26:58 +02:00
karwosts
1532093426 Support offset on input_datetime time trigger (#23855)
* Support offset on input_datetime time trigger

* no time entities
2025-01-23 12:23:27 +02:00
Jan-Philipp Benecke
2effb0935c Add helper text to inputs of time pattern trigger (#23844) 2025-01-23 09:40:13 +02:00
renovate[bot]
06a08bb4f2 Update dependency intl-messageformat to v10.7.14 (#23854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 09:04:21 +02:00
Jan-Philipp Benecke
09102d34d6 Improve automation save dialog when leaving editor dirty (#23589)
* Improve automation save dialog when leaving editor dirty

* Make CI happy
2025-01-23 09:03:39 +02:00
Wendelin
27d683f6e8 Add additional backup schedule description (#23843)
* Add additional backup schedule description

* Use ha-icon-button for description

* Remove tip style

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-01-22 20:17:50 +01:00
Paul Bottein
b89bd0be3b Fix delete button for state content in iOS and Android (#23847) 2025-01-22 14:25:06 +00:00
Paul Bottein
51e6e6d230 Fix delete button for state content in iOS (#23839) 2025-01-22 08:16:35 -05:00
Norbert Rittel
9005f8faa9 Update hidden_explanation to include labels (#23473) 2025-01-22 09:52:53 +01:00
Jan-Philipp Benecke
7aa2abc9c2 Make add integration dialog keyboard accessible (#23829)
Make add integration keyboard accessible
2025-01-22 10:06:05 +02:00
dependabot[bot]
898fd51c7d Bump vite from 6.0.7 to 6.0.11 (#23834)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.0.7 to 6.0.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v6.0.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-22 10:04:27 +02:00
karwosts
b3141a0653 Remember selection "Don't Group" in data-tables (#23836) 2025-01-22 10:03:23 +02:00
Jan-Philipp Benecke
1025f73c36 Clear filter in add helper dialog when closing (#23832) 2025-01-21 21:40:52 +01:00
Simon Lamon
1cd44728df Temporarily disable Bluetooth panel to restore access to options flow, additional fixes (#23830) 2025-01-21 09:45:47 -10:00
Sören Beye
eaab19fb8c Don't hide location entities that are "home" in the MapViewStrategy (#23462) 2025-01-21 20:14:04 +02:00
Paul Bottein
c4b2896fac Add label for add badge button in masonry and sidebar view (#23827)
Add label for add badge button in mansonry and sidebar view
2025-01-21 19:45:06 +02:00
Simon Lamon
ca2a9f9171 Initial bluetooth integration panel (#23531)
* Initial bluetooth device page

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-device-page.ts

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-device-page.ts

* Apply suggestions from code review

* Apply suggestions and implement dialog

* memoize

* Apply suggestions

* Clean up and fixes

* Adjust store usage

* Implement hassdialog

* Apply comments

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
2025-01-21 19:43:33 +02:00
Paulus Schoutsen
9e868e144d Allow storing temperature/humidity entities on an area (#23822)
* Allow storing temperature/humidity entities on an area

* Update objects after improved types
2025-01-21 17:37:51 +01:00
Bram Kragten
fcb6da55d8 Check if we can decrypt backup on download (#23756)
Co-authored-by: Kevin Cathcart <kevincathcart@gmail.com>
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-21 14:48:38 +01:00
Paul Bottein
87907b98bd Fix backup data picker translations (#23826) 2025-01-21 13:17:33 +01:00
Bram Kragten
7535d66373 Add time option for backup schedule (#23757)
Co-authored-by: Wendelin <w@pe8.at>
2025-01-21 11:58:07 +01:00
Jan-Philipp Benecke
e994e3565d Improve add cards dialog user experience (#23773)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-21 09:53:28 +01:00
Jan-Philipp Benecke
562589a6cb Fix flickering media play button (#23778) 2025-01-21 09:09:15 +01:00
renovate[bot]
e87f6d6d5e Update vitest monorepo to v3.0.2 (#23812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 09:07:51 +01:00
karwosts
3feedb792a Multi textfield helper (#23649)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-01-20 12:20:36 +01:00
Douwe
fc290028c6 Update ha-assist-chat.ts (#23790)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-20 09:20:14 +01:00
Jan-Philipp Benecke
2f5fd6f0c7 Use destructive attribute from ha-button (#23786)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-20 08:54:22 +01:00
ildar170975
9a3ca59d77 Picture glance alignment fix (#23793) 2025-01-20 08:35:29 +01:00
ildar170975
f76c22cb5b system-log-card: make a header & card-content similar to error-log-card (#23799)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-20 08:26:18 +01:00
dependabot[bot]
aad94624e2 Bump release-drafter/release-drafter from 6.0.0 to 6.1.0 (#23806) 2025-01-20 08:15:29 +01:00
renovate[bot]
076ab91199 Update dependency intl-messageformat to v10.7.12 (#23807) 2025-01-20 08:12:59 +01:00
Jan-Philipp Benecke
d121b33263 Add shortcut hint to assist dialog (#23739)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-01-20 08:12:52 +01:00
renovate[bot]
dbfcf310c3 Update vitest monorepo to v3 (major) (#23803)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-20 08:05:24 +01:00
renovate[bot]
25ea6c2c55 Update dependency lint-staged to v15.4.1 (#23802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-19 21:05:58 +01:00
renovate[bot]
b2b2c21477 Update dependency fs-extra to v11.3.0 (#23798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-19 16:41:25 +01:00
renovate[bot]
09777db549 Update dependency lint-staged to v15.4.0 (#23800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-19 16:41:13 +01:00
renovate[bot]
f108f65e39 Update dependency node-vibrant to v4.0.3 (#23797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-19 16:41:04 +01:00
renovate[bot]
2ee9824bcc Update dependency node-vibrant to v4.0.2 (#23789)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-18 10:44:52 +00:00
Norbert Rittel
812461ea00 Add translatable string for "Learn how it works" (#23788)
* Add translatable string for "Learn how it works"

* Update assist-pref.ts adding localizable key
2025-01-18 11:37:22 +01:00
Jan-Philipp Benecke
c430c28c2a Add missing localization on info page (#23787) 2025-01-18 11:36:00 +01:00
Jan-Philipp Benecke
d67c463b98 Add clear button to Assist debug page (#23774)
* Add clear button to Assist debug page

* Move destructive to ha-button
2025-01-18 09:27:30 +02:00
Bram Kragten
3ffbd435e0 Merge branch 'rc' 2025-01-09 21:02:43 +01:00
Bram Kragten
2e2f39adbd Bumped version to 20250109.0 2025-01-09 21:02:30 +01:00
Paul Bottein
5e7f356707 Fix preferred agent for backup download (#23659) 2025-01-09 21:02:09 +01:00
Gord
b02d0e58b1 Backup text changes for the english translation (#23656)
* Update backup text in en.json

* fix missing quote en.json
2025-01-09 21:02:08 +01:00
Wendelin
c01d3aee41 Fix backup summary label position (#23655) 2025-01-09 21:02:07 +01:00
Paul Bottein
6dde7d6945 Fix backup translations key issues (#23654)
Co-authored-by: Wendelin <w@pe8.at>
2025-01-09 21:02:06 +01:00
Paul Bottein
14c71f436e Remove ! from backup translation (#23648) 2025-01-09 21:02:06 +01:00
Wendelin
9acdd9f903 Voice assistants config: Filter unavailable assists (#23637)
Filter unavailable assists from num of assist devices.
2025-01-09 21:02:05 +01:00
karwosts
12b2edaa65 Retain event data when moving/resizing schedule item (#23621)
* Retain event data when moving/resizing schedule item

* update from suggestion
2025-01-09 21:02:04 +01:00
Jan-Philipp Benecke
d55d388046 Set fixed width for automation save dialog (#23618) 2025-01-09 21:02:03 +01:00
Petar Petrov
ec1dedcb6b Fix tooltip scrolling (#23616) 2025-01-09 21:02:02 +01:00
Wendelin
d0fbba5063 Improve background-editor background-attachment alignment (#23615) 2025-01-09 21:02:01 +01:00
karwosts
344e083ac6 Restore attributes removed from ha-entity-marker in ha-map (#23603)
* Restore attributes removed from ha-entity-marker in ha-map

* Use Reflect
2025-01-09 21:02:00 +01:00
Wendelin
0ee6548650 Add backup translations (#23365)
* Add translations

* Add summary and progress translations

* Add backups and settings translations

* Add backups page translations

* Add onboarding card translations

* Add settings translations

* Add details translations

* Translate delete

* Add data picker translations

* Use local add-ons

* Add encryption key translations

* Add new, generate and upload translations

* Add translations for restore backup

* Fix ts issue

* Add missing keys

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-01-09 21:01:03 +01:00
Bram Kragten
9ff1ce3c96 Merge branch 'rc' 2025-01-06 18:13:49 +01:00
Norbert Rittel
f53ac94e76 Add missing ui.panel.config.labels.headers.description (#23517)
The header "Description" for the Labels list only shows up as optional in a narrow view like on mobile.

This commit adds the missing string for proper localization.
2025-01-06 18:13:32 +01:00
Norbert Rittel
6d084813d5 Add missing localizations for Voice Assistants > Expose headers (#23452)
* Add missing localizations for Voice Assistants > Expose headers

* Add localizable labels to Icon and Remove columns

* Variant with all changes on a single line

* Prettier?

* Revert

* Line length limited to 80 chars
2025-01-06 18:13:31 +01:00
Bram Kragten
926f5e3cd8 Merge branch 'rc' 2025-01-06 18:10:30 +01:00
Bram Kragten
befc650f81 Bumped version to 20250106.0 2025-01-06 18:10:12 +01:00
Bram Kragten
2019b8992e Fix tooltip more info (#23605) 2025-01-06 18:09:56 +01:00
Paul Bottein
4d2e9f203f Improve error handling in backup status banner (#23604)
* Improve error handling in backup status banner

* Fix completion

* Fix loading

* Check attempt and completion date first
2025-01-06 18:09:55 +01:00
Bram Kragten
760d898de7 Remove backup toggle from supervisor addon page when update available (#23602) 2025-01-06 18:09:06 +01:00
Ville Skyttä
a755af96a6 Spelling and grammar fixes (#23598) 2025-01-06 18:06:42 +01:00
Norbert Rittel
046b90ae25 Add localizable "Filtering by config entry" for Entities and Devices (#23544)
* Add localizable "Filtering by config entry" to en.json

This commit adds two strings for localizing "Filtering by config entry" to the Entities panel and, referenced from there, to the Devices panel.

* Replace "Filtering by config entry" with localizable key

* Replace "Filtering by config entry" with localizable key

* Add missing comma

* Add missing }

* Add missing }
2025-01-06 18:06:41 +01:00
Norbert Rittel
a3e8bcf848 Add ICU strings for proper singular / plural in Search fields (#23530)
It does not happen that often that the lists of devices, entities, helpers etc. are filtered down to a single item.

But in that case the labels currently use incorrect plural which is more irritating in some languages but also wrong in English.

This commit fixes this by adding ICU syntax to all six strings so these work properly in English and all derived translations.

For languages that need a different wording for `zero` this also helps translators in extending the ICU syntax for that case.
2025-01-06 18:05:49 +01:00
Petar Petrov
4b13dde92e Rename base sankey chart tag so it doesn't conflict with the custom card (#23600) 2025-01-06 18:00:38 +01:00
Bram Kragten
7aa2136c21 Merge branch 'rc' 2025-01-03 15:52:04 +01:00
Bram Kragten
47308e7b46 Add change of encryption key warning (#23570) 2025-01-03 15:51:39 +01:00
Bram Kragten
ec1fc09140 Bumped version to 20250103.0 2025-01-03 15:42:39 +01:00
Bram Kragten
e2ad94469a Fix restoring backup during onboarding (#23569) 2025-01-03 15:41:56 +01:00
Bram Kragten
70541ec966 Fix restore progress check logic (#23568) 2025-01-03 15:41:55 +01:00
Bram Kragten
0bf64ee7f4 Backup onboarding: Show close button when welcome is skipped (#23567) 2025-01-03 15:41:54 +01:00
Bram Kragten
4ea0c83fbe Close restore dialog if done (#23566) 2025-01-03 15:41:53 +01:00
Bram Kragten
d126e02747 fix error display upload backup (#23565) 2025-01-03 15:41:53 +01:00
Bram Kragten
102a9eeb61 Fix tabs subpage height on desktop (#23564)
fix tabs subpage height on desktop
2025-01-03 15:41:52 +01:00
Bram Kragten
fc3e99e794 Update and add backup my links (#23556)
* Update and add backup my links
2025-01-03 15:41:51 +01:00
Bram Kragten
70953788cc Add back zopfli compression (#23555) 2025-01-03 15:41:50 +01:00
Bram Kragten
6c9df587e7 Bumped version to 20250102.0 2025-01-02 16:18:11 +01:00
Bram Kragten
8f58681d83 always zoom timeline charts on x axis (#23554) 2025-01-02 16:17:04 +01:00
Bram Kragten
4a16d9bd44 Add show encryption key dialog (#23552) 2025-01-02 16:17:03 +01:00
Bram Kragten
fcc9da6d85 Backup with db requires config, disabled next if no data is selected (#23549) 2025-01-02 16:17:02 +01:00
Bram Kragten
e03dc2c382 Move local location backup setting (#23548) 2025-01-02 16:17:01 +01:00
Bram Kragten
be967940a2 Add warning when no backup location is selected (#23550)
* Add warning when no backup location is selected

* Move to bottom
2025-01-02 16:16:04 +01:00
Bram Kragten
64ad37ed6a Update change encryption key dialog (#23551) 2025-01-02 16:15:35 +01:00
Bram Kragten
01bc45c78b Backup text updates (#23547) 2025-01-02 16:13:39 +01:00
Marcin
2206644c47 Fix copy on button to clear the selected background image (#23546) 2025-01-02 16:13:38 +01:00
Bram Kragten
486038c426 Add space for the fab on datatable without tabs (#23545)
Add space for the fab on backups datatable
2025-01-02 16:13:38 +01:00
Sören Beye
711f721007 Changes to the valueText should also rescale ha-gauge text (#23536)
Changes to the valueText should also recenter ha-gauge text
2025-01-02 16:13:37 +01:00
Philipp
3b8bc242fe Fix media management delete button misalignment (#23534) 2025-01-02 16:13:36 +01:00
karwosts
7e80eed003 Display an error if saving new script times out (#23527)
* Display an error if saving new automation times out

* changes

* update

* string tweak

* Fix save failed for scripts
2025-01-02 16:13:35 +01:00
Bram Kragten
a7ef498d75 Handle no cloud subscription better in backups (#23523) 2025-01-02 16:13:34 +01:00
Bram Kragten
a5de6ff3af Bumped version to 20241231.0 2024-12-31 20:23:30 +01:00
Bram Kragten
806cc2c608 Fix automation traces (#23524) 2024-12-31 20:21:41 +01:00
Bram Kragten
48a160f057 Use last completed automatic backup time instead of last available ba… (#23522)
* Use last completed automatic backup time instead of last available backup

* Update ha-backup-overview-summary.ts

* Update src/panels/config/backup/components/overview/ha-backup-overview-summary.ts

* Update ha-config-backup-overview.ts
2024-12-31 20:21:40 +01:00
Bram Kragten
c697843c34 Update ha-backup-overview-summary.ts 2024-12-31 20:21:06 +01:00
Bram Kragten
317a2f5b21 Fix password incorrect check when restoring backup (#23525) 2024-12-31 20:18:15 +01:00
karwosts
220011f15f Display an error if saving new automation times out (#23518) 2024-12-31 20:16:53 +01:00
Bram Kragten
e8af454705 Bumped version to 20241230.0 2024-12-30 19:44:56 +01:00
Petar Petrov
713b5c7cf7 Add default border-radius values to .disabled-bar 2024-12-30 19:42:54 +01:00
Petar Petrov
8b17286fb6 Revert "Automation/Script editor border-radius fix (#23267)"
This reverts commit e9b2a83411.
2024-12-30 19:42:40 +01:00
Bram Kragten
f3705a7e1d Fix copy encryption key (#23515) 2024-12-30 19:41:01 +01:00
Bram Kragten
d0123b2cce Fix overflow of backup agents (#23514) 2024-12-30 19:41:00 +01:00
Bram Kragten
884c22f92b Add fallback for devices without name (#23513) 2024-12-30 19:40:59 +01:00
Simon Lamon
700690474c Add script hide picker again (#23512) 2024-12-30 19:40:58 +01:00
Simon Lamon
4686808e53 Fix manual backup disabled with all backup locations (#23511) 2024-12-30 19:40:57 +01:00
Jan-Philipp Benecke
cf1df712e4 Fix dialog header (#23507) 2024-12-30 19:40:56 +01:00
Timothy Kist
c338e9cb30 Remove space at end of link from HAOS storage tip (#23492) 2024-12-30 19:40:55 +01:00
Petar Petrov
8e8fd89d56 Fix custom DNS saving (#23477) 2024-12-30 19:40:55 +01:00
Petar Petrov
f1c360c550 Add getGridOptions to history and statistics graph cards (#23476) 2024-12-30 19:40:54 +01:00
Simon Lamon
b429ecc376 Calendar trigger: Handle optional offset better (#23474)
Calendar empty offset
2024-12-30 19:39:50 +01:00
Petar Petrov
6d8422513a Button to reset chart zoom (#23469) 2024-12-30 19:39:49 +01:00
Petar Petrov
cb0a48265a Fix helper dialog close and add failsafe for similar cases (#23468) 2024-12-30 19:39:48 +01:00
Simon Lamon
c9082724a8 View background settings: Change transparancy to opacity (#23450) 2024-12-30 19:39:47 +01:00
Paulus Schoutsen
fea83c0873 Bumped version to 20241229.0 2024-12-29 18:20:14 +00:00
karwosts
d3b4014182 Fix backups fab spacer (#23490) 2024-12-29 18:19:07 +00:00
Jan-Philipp Benecke
5c7fe04562 Fix header of config entry system options dialog (#23455)
Fix config entry system options dialog header
2024-12-29 18:19:06 +00:00
karwosts
44e26c925b Fix dialog-person-detail tracker selection (#23454) 2024-12-29 18:19:05 +00:00
Jan-Philipp Benecke
205dd3f968 Fix chip spacing in automation/script save dialog (#23451) 2024-12-29 18:19:04 +00:00
Paulus Schoutsen
86133a0696 Fix typo in backups overview (#23446) 2024-12-29 18:19:03 +00:00
Bram Kragten
2105db9104 change default of backup actions card feature to no backup (#23444) 2024-12-29 18:19:02 +00:00
Bram Kragten
c05054e4ff Merge branch 'rc' 2024-12-24 16:24:59 +01:00
Bram Kragten
f416b1b5da Merge branch 'dev' into rc 2024-12-24 16:23:46 +01:00
Bram Kragten
e7d9032cc4 20241223.1 (#23402) 2024-12-23 15:38:28 +01:00
Simon Lamon
331385794c View background settings: Change radio buttons to dropdowns (#23403) 2024-12-23 15:30:46 +01:00
Bram Kragten
a7cacbbbe6 20241223.0 (#23392) 2024-12-23 12:23:14 +01:00
151 changed files with 7687 additions and 6344 deletions

View File

@@ -26,7 +26,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -62,7 +62,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -60,7 +60,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -78,7 +78,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -102,7 +102,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -27,7 +27,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -63,7 +63,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@v6.0.0
- uses: release-drafter/release-drafter@v6.1.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -92,7 +92,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -121,7 +121,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
- name: Setup Node
uses: actions/setup-node@v4.1.0
uses: actions/setup-node@v4.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@v9.0.0
uses: actions/stale@v9.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90

View File

@@ -124,6 +124,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -135,6 +137,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -146,6 +150,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -123,6 +123,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -134,6 +136,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -145,6 +149,8 @@ const AREAS: AreaRegistryEntry[] = [
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -9,6 +9,7 @@ import {
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { atLeastVersion } from "../../../src/common/config/version";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/buttons/ha-progress-button";
import "../../../src/components/ha-alert";
@@ -18,7 +19,11 @@ import "../../../src/components/ha-checkbox";
import "../../../src/components/ha-faded";
import "../../../src/components/ha-icon-button";
import "../../../src/components/ha-markdown";
import "../../../src/components/ha-md-list";
import "../../../src/components/ha-md-list-item";
import "../../../src/components/ha-svg-icon";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import type { HassioAddonDetails } from "../../../src/data/hassio/addon";
import {
fetchHassioAddonChangelog,
@@ -121,6 +126,8 @@ class UpdateAvailableCard extends LitElement {
const changelog = changelogUrl(this._updateType, this._version_latest);
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<ha-card
outlined
@@ -160,6 +167,30 @@ class UpdateAvailableCard extends LitElement {
)}
</p>
</div>
${createBackupTexts
? html`
<hr />
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${createBackupTexts.title}
</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch
slot="end"
id="create-backup"
></ha-switch>
</ha-md-list-item>
</ha-md-list>
`
: nothing}
`
: html`<ha-circular-progress
aria-label="Updating"
@@ -227,6 +258,48 @@ class UpdateAvailableCard extends LitElement {
}
}
private _computeCreateBackupTexts():
| { title: string; description?: string }
| undefined {
// Addon backup
if (
this._updateType === "addon" &&
atLeastVersion(this.hass.config.version, 2025, 2, 0)
) {
const version = this._version;
return {
title: this.supervisor.localize("update_available.create_backup.addon"),
description: this.supervisor.localize(
"update_available.create_backup.addon_description",
{ version: version }
),
};
}
// Old behavior
if (this._updateType && ["core", "addon"].includes(this._updateType)) {
return {
title: this.supervisor.localize(
"update_available.create_backup.generic"
),
};
}
return undefined;
}
get _shouldCreateBackup(): boolean {
if (this._updateType && !["core", "addon"].includes(this._updateType)) {
return false;
}
const createBackupSwitch = this.shadowRoot?.getElementById(
"create-backup"
) as HaSwitch;
if (createBackupSwitch) {
return createBackupSwitch.checked;
}
return true;
}
get _version(): string {
return this._updateType
? this._updateType === "addon"
@@ -341,14 +414,22 @@ class UpdateAvailableCard extends LitElement {
}
private async _update() {
if (this._shouldCreateBackup && this.supervisor.info.state === "freeze") {
this._error = this.supervisor.localize("backup.backup_already_running");
return;
}
this._error = undefined;
this._updating = true;
try {
if (this._updateType === "addon") {
await updateHassioAddon(this.hass, this.addonSlug!);
await updateHassioAddon(
this.hass,
this.addonSlug!,
this._shouldCreateBackup
);
} else if (this._updateType === "core") {
await updateCore(this.hass);
await updateCore(this.hass, this._shouldCreateBackup);
} else if (this._updateType === "os") {
await updateOS(this.hass);
} else if (this._updateType === "supervisor") {
@@ -403,6 +484,17 @@ class UpdateAvailableCard extends LitElement {
border-bottom: none;
margin: 16px 0 0 0;
}
ha-md-list {
padding: 0;
margin-bottom: -16px;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
`,
];
}

View File

@@ -26,7 +26,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.26.0",
"@babel/runtime": "7.26.7",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.4",
"@codemirror/commands": "6.8.0",
@@ -99,8 +99,6 @@
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "2.3.1",
"chart.js": "4.4.7",
"chartjs-plugin-zoom": "2.2.0",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.40.0",
@@ -110,14 +108,15 @@
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"element-internals-polyfill": "1.3.12",
"echarts": "5.6.0",
"element-internals-polyfill": "1.3.13",
"fuse.js": "7.0.0",
"google-timezones-json": "1.2.0",
"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",
"home-assistant-js-websocket": "9.4.0",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.7.11",
"intl-messageformat": "10.7.14",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -126,7 +125,7 @@
"luxon": "3.5.0",
"marked": "15.0.6",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.1",
"node-vibrant": "4.0.3",
"punycode": "2.3.1",
"qr-scanner": "1.4.2",
"qrcode": "1.5.4",
@@ -153,20 +152,20 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/core": "7.26.7",
"@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.25.9",
"@babel/preset-env": "7.26.0",
"@babel/preset-env": "7.26.7",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.17.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.0.0",
"@octokit/auth-oauth-device": "7.1.2",
"@octokit/plugin-retry": "7.1.3",
"@octokit/rest": "21.1.0",
"@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.1.8",
"@rspack/core": "1.1.8",
"@rspack/cli": "1.2.2",
"@rspack/core": "1.2.2",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.20",
"@types/chromecast-caf-sender": "1.0.11",
@@ -184,16 +183,16 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "8.20.0",
"@typescript-eslint/parser": "8.20.0",
"@vitest/coverage-v8": "2.1.8",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@vitest/coverage-v8": "3.0.4",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.18.0",
"eslint": "9.19.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "9.1.0",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "1.15.0",
@@ -201,7 +200,7 @@
"eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "2.2.0",
"fancy-log": "2.0.0",
"fs-extra": "11.2.0",
"fs-extra": "11.3.0",
"glob": "11.0.1",
"gulp": "5.0.0",
"gulp-brotli": "3.0.0",
@@ -211,7 +210,7 @@
"husky": "9.1.7",
"jsdom": "26.0.0",
"jszip": "3.10.1",
"lint-staged": "15.3.0",
"lint-staged": "15.4.3",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -225,7 +224,7 @@
"terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"vitest": "2.1.8",
"vitest": "3.0.4",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -239,7 +238,8 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.14.0"
"globals": "15.14.0",
"tslib": "2.8.1"
},
"packageManager": "yarn@4.6.0"
}

View File

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

View File

@@ -1,3 +1,4 @@
import memoizeOne from "memoize-one";
import { theme2hex } from "./convert-color";
export const COLORS = [
@@ -74,3 +75,12 @@ export function getGraphColorByIndex(
getColorByIndex(index);
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

@@ -65,6 +65,18 @@ const formatShortDateTimeMem = memoizeOne(
})
);
export const formatShortDateTimeWithConditionalYear = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => {
const now = new Date();
if (now.getFullYear() === dateObj.getFullYear()) {
return formatShortDateTime(dateObj, locale, config);
}
return formatShortDateTimeWithYear(dateObj, locale, config);
};
// August 9, 2021, 8:23:15 AM
export const formatDateTimeWithSeconds = (
dateObj: Date,

View File

@@ -0,0 +1,51 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
export function formatTimeLabel(
value: number | Date,
locale: FrontendLocaleData,
config: HassConfig,
minutesDifference: number
) {
const dayDifference = minutesDifference / 60 / 24;
const date = new Date(value);
if (dayDifference > 88) {
return date.getMonth() === 0
? `{bold|${formatDateMonthYear(date, locale, config)}}`
: formatDateMonth(date, locale, config);
}
if (dayDifference > 35) {
return date.getDate() === 1
? `{bold|${formatDateVeryShort(date, locale, config)}}`
: formatDateVeryShort(date, locale, config);
}
if (dayDifference > 7) {
const label = formatDateVeryShort(date, locale, config);
return date.getDate() === 1 ? `{bold|${label}}` : label;
}
if (dayDifference > 2) {
return formatDateWeekdayShort(date, locale, config);
}
if (minutesDifference && minutesDifference < 5) {
return formatTimeWithSeconds(date, locale, config);
}
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
// show only date for the beginning of the day
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
}

View File

@@ -1,269 +0,0 @@
import { _adapters } from "chart.js";
import {
startOfSecond,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
startOfMonth,
startOfQuarter,
startOfYear,
addMilliseconds,
addSeconds,
addMinutes,
addHours,
addDays,
addWeeks,
addMonths,
addQuarters,
addYears,
differenceInMilliseconds,
differenceInSeconds,
differenceInMinutes,
differenceInHours,
differenceInDays,
differenceInWeeks,
differenceInMonths,
differenceInQuarters,
differenceInYears,
endOfSecond,
endOfMinute,
endOfHour,
endOfDay,
endOfWeek,
endOfMonth,
endOfQuarter,
endOfYear,
} from "date-fns";
import {
formatDate,
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayDay,
formatDateYear,
} from "../../common/datetime/format_date";
import {
formatDateTime,
formatDateTimeWithSeconds,
} from "../../common/datetime/format_date_time";
import {
formatTime,
formatTimeWithSeconds,
} from "../../common/datetime/format_time";
const FORMATS = {
datetime: "datetime",
datetimeseconds: "datetimeseconds",
millisecond: "millisecond",
second: "second",
minute: "minute",
hour: "hour",
day: "day",
date: "date",
weekday: "weekday",
week: "week",
month: "month",
monthyear: "monthyear",
quarter: "quarter",
year: "year",
};
_adapters._date.override({
formats: () => FORMATS,
parse: (value: Date | number) => {
if (!(value instanceof Date)) {
return value;
}
return value.getTime();
},
format: function (time, fmt: keyof typeof FORMATS) {
switch (fmt) {
case "datetime":
return formatDateTime(
new Date(time),
this.options.locale,
this.options.config
);
case "datetimeseconds":
return formatDateTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "millisecond":
return formatTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "second":
return formatTimeWithSeconds(
new Date(time),
this.options.locale,
this.options.config
);
case "minute":
return formatTime(
new Date(time),
this.options.locale,
this.options.config
);
case "hour":
return formatTime(
new Date(time),
this.options.locale,
this.options.config
);
case "weekday":
return formatDateWeekdayDay(
new Date(time),
this.options.locale,
this.options.config
);
case "date":
return formatDate(
new Date(time),
this.options.locale,
this.options.config
);
case "day":
return formatDateVeryShort(
new Date(time),
this.options.locale,
this.options.config
);
case "week":
return formatDateVeryShort(
new Date(time),
this.options.locale,
this.options.config
);
case "month":
return formatDateMonth(
new Date(time),
this.options.locale,
this.options.config
);
case "monthyear":
return formatDateMonthYear(
new Date(time),
this.options.locale,
this.options.config
);
case "quarter":
return formatDate(
new Date(time),
this.options.locale,
this.options.config
);
case "year":
return formatDateYear(
new Date(time),
this.options.locale,
this.options.config
);
default:
return "";
}
},
// @ts-ignore
add: (time, amount, unit) => {
switch (unit) {
case "millisecond":
return addMilliseconds(time, amount);
case "second":
return addSeconds(time, amount);
case "minute":
return addMinutes(time, amount);
case "hour":
return addHours(time, amount);
case "day":
return addDays(time, amount);
case "week":
return addWeeks(time, amount);
case "month":
return addMonths(time, amount);
case "quarter":
return addQuarters(time, amount);
case "year":
return addYears(time, amount);
default:
return time;
}
},
diff: (max, min, unit) => {
switch (unit) {
case "millisecond":
return differenceInMilliseconds(max, min);
case "second":
return differenceInSeconds(max, min);
case "minute":
return differenceInMinutes(max, min);
case "hour":
return differenceInHours(max, min);
case "day":
return differenceInDays(max, min);
case "week":
return differenceInWeeks(max, min);
case "month":
return differenceInMonths(max, min);
case "quarter":
return differenceInQuarters(max, min);
case "year":
return differenceInYears(max, min);
default:
return 0;
}
},
// @ts-ignore
startOf: (time, unit, weekday) => {
switch (unit) {
case "second":
return startOfSecond(time);
case "minute":
return startOfMinute(time);
case "hour":
return startOfHour(time);
case "day":
return startOfDay(time);
case "week":
return startOfWeek(time);
case "isoWeek":
return startOfWeek(time, {
weekStartsOn: +weekday! as 0 | 1 | 2 | 3 | 4 | 5 | 6,
});
case "month":
return startOfMonth(time);
case "quarter":
return startOfQuarter(time);
case "year":
return startOfYear(time);
default:
return time;
}
},
// @ts-ignore
endOf: (time, unit) => {
switch (unit) {
case "second":
return endOfSecond(time);
case "minute":
return endOfMinute(time);
case "hour":
return endOfHour(time);
case "day":
return endOfDay(time);
case "week":
return endOfWeek(time);
case "month":
return endOfMonth(time);
case "quarter":
return endOfQuarter(time);
case "year":
return endOfYear(time);
default:
return time;
}
},
});

View File

@@ -1,6 +0,0 @@
import type { ChartEvent } from "chart.js";
export const clickIsTouch = (event: ChartEvent): boolean =>
!(event.native instanceof MouseEvent) ||
(event.native instanceof PointerEvent &&
event.native.pointerType !== "mouse");

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { LitElement, html, css, svg, nothing } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
import { measureTextWidth } from "../../util/text";
export interface Node {
id: string;
@@ -68,15 +69,12 @@ export class HaSankeyChart extends LitElement {
private _statePerPixel = 0;
private _textMeasureCanvas?: HTMLCanvasElement;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect,
});
disconnectedCallback() {
super.disconnectedCallback();
this._textMeasureCanvas = undefined;
}
willUpdate() {
@@ -477,7 +475,7 @@ export class HaSankeyChart extends LitElement {
(node) =>
NODE_WIDTH +
TEXT_PADDING +
(node.label ? this._getTextWidth(node.label) : 0)
(node.label ? measureTextWidth(node.label, FONT_SIZE) : 0)
)
)
: 0;
@@ -492,18 +490,6 @@ export class HaSankeyChart extends LitElement {
: fullSize / nodesPerSection.length;
}
private _getTextWidth(text: string): number {
if (!this._textMeasureCanvas) {
this._textMeasureCanvas = document.createElement("canvas");
}
const context = this._textMeasureCanvas.getContext("2d");
if (!context) return 0;
// Match the font style from CSS
context.font = `${FONT_SIZE}px sans-serif`;
return context.measureText(text).width;
}
private _getVerticalLabelFontSize(label: string, labelWidth: number): number {
// reduce the label font size so the longest word fits on one line
const longestWord = label
@@ -513,7 +499,7 @@ export class HaSankeyChart extends LitElement {
longest.length > current.length ? longest : current,
""
);
const wordWidth = this._getTextWidth(longestWord);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
return Math.min(FONT_SIZE, (labelWidth / wordWidth) * FONT_SIZE);
}

View File

@@ -1,19 +1,26 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
import type { YAXisOption } from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
import type { ECOption } from "../../resources/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
formatNumber,
} from "../../common/number/format_number";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -54,15 +61,19 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@state() private _chartData?: ChartData<"line">;
@property({ type: String }) public height?: string;
@state() private _chartData: LineSeriesOption[] = [];
@state() private _entityIds: string[] = [];
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ChartOptions;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
private _hiddenStats = new Set<string>();
@state() private _yWidth = 25;
private _chartTime: Date = new Date();
@@ -72,171 +83,109 @@ export class StateHistoryChartLine extends LitElement {
.hass=${this.hass}
.data=${this._chartData}
.options=${this._chartOptions}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="line"
.height=${this.height}
style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
`;
}
private _renderTooltip(params: any) {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.name as string)
)
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 data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${param.seriesName}: ${value}`;
}
return `${param.marker} ${value}`;
})
.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) {
if (
!this.hasUpdated ||
changedProps.has("showNames") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("unit") ||
changedProps.has("logarithmicScale") ||
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData")
) {
this._chartOptions = {
parsing: false,
interaction: {
mode: "nearest",
axis: "xy",
},
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
min: this.startTime,
max: this.endTime,
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetimeseconds",
},
},
y: {
suggestedMin: this.fitYData ? this.minYAxis : null,
suggestedMax: this.fitYData ? this.maxYAxis : null,
min: this.fitYData ? null : this.minYAxis,
max: this.fitYData ? null : this.maxYAxis,
ticks: {
maxTicksLimit: 7,
},
title: {
display: true,
text: this.unit,
},
afterUpdate: (y) => {
if (this._yWidth !== Math.floor(y.width)) {
this._yWidth = Math.floor(y.width);
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
},
position: computeRTL(this.hass) ? "right" : "left",
type: this.logarithmicScale ? "logarithmic" : "linear",
},
},
plugins: {
tooltip: {
callbacks: {
label: (context) => {
let label = `${context.dataset.label}: ${formatNumber(
context.parsed.y,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[context.datasetIndex]]
)
)} ${this.unit}`;
const dataIndex =
this._datasetToDataIndex[context.datasetIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
context.parsed.x < data.states[0].last_changed
? `\n${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `\n${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
label += source;
}
return label;
},
},
},
filler: {
propagate: true,
},
legend: {
display: this.showNames,
labels: {
usePointStyle: true,
},
},
},
elements: {
line: {
tension: 0.1,
borderWidth: 1.5,
},
point: {
hitRadius: 50,
},
},
segment: {
borderColor: (context) => {
// render stat data with a slightly transparent line
const dataIndex = this._datasetToDataIndex[context.datasetIndex];
const data = this.data[dataIndex];
return data.statistics &&
data.statistics.length > 0 &&
(data.states.length === 0 ||
context.p0.parsed.x < data.states[0].last_changed)
? this._chartData!.datasets[dataIndex].borderColor + "7F"
: undefined;
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
}
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
);
if (points.length) {
const firstPoint = points[0];
fireEvent(this, "hass-more-info", {
entityId: this._entityIds[firstPoint.datasetIndex],
});
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
},
};
}
if (
changedProps.has("data") ||
changedProps.has("startTime") ||
@@ -248,13 +197,130 @@ export class StateHistoryChartLine extends LitElement {
// so the X axis grows even if there is no new data
this._generateData();
}
if (
!this.hasUpdated ||
changedProps.has("showNames") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("unit") ||
changedProps.has("logarithmicScale") ||
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("_chartData") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
const rtl = computeRTL(this.hass);
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 }) => (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 }) => (max > 0 ? max * 1.05 : max * 0.95);
}
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
position: rtl ? "right" : "left",
scale: true,
nameGap: 2,
nameTextStyle: {
align: "left",
},
axisLine: {
show: false,
},
axisLabel: {
margin: 5,
formatter: (value: number) => {
const label = formatNumber(value, this.hass.locale);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
},
} as YAXisOption,
legend: {
show: this.showNames,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
},
grid: {
...(this.showNames ? {} : { top: 30 }), // undefined is the same as 0
left: rtl ? 1 : Math.max(this.paddingYAxis, this._yWidth),
right: rtl ? Math.max(this.paddingYAxis, this._yWidth) : 1,
bottom: 30,
},
visualMap: this._chartData
.map((_, seriesIndex) => {
const dataIndex = this._datasetToDataIndex[seriesIndex];
const data = this.data[dataIndex];
if (!data.statistics || data.statistics.length === 0) {
return false;
}
// render stat data with a slightly transparent line
const firstStateTS =
data.states[0]?.last_changed ?? this.endTime.getTime();
return {
show: false,
seriesIndex,
dimension: 0,
pieces: [
{
max: firstStateTS - 0.01,
colorAlpha: 0.5,
},
{
min: firstStateTS,
colorAlpha: 1,
},
],
};
})
.filter(Boolean) as VisualMapComponentOption[],
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip.bind(this),
},
};
}
}
private _generateData() {
let colorIndex = 0;
const computedStyles = getComputedStyle(this);
const entityStates = this.data;
const datasets: ChartDataset<"line">[] = [];
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
if (entityStates.length === 0) {
@@ -270,7 +336,7 @@ export class StateHistoryChartLine extends LitElement {
// array containing [value1, value2, etc]
let prevValues: any[] | null = null;
const data: ChartDataset<"line">[] = [];
const data: LineSeriesOption[] = [];
const pushData = (timestamp: Date, datavalues: any[] | null) => {
if (!datavalues) return;
@@ -287,26 +353,45 @@ export class StateHistoryChartLine extends LitElement {
// to the chart for the previous value. Otherwise the gap will
// be too big. It will go from the start of the previous data
// value until the start of the next data value.
d.data.push({ x: timestamp.getTime(), y: prevValues[i] });
d.data!.push([timestamp, prevValues[i]]);
}
d.data.push({ x: timestamp.getTime(), y: datavalues[i] });
d.data!.push([timestamp, datavalues[i]]);
});
prevValues = datavalues;
};
const addDataSet = (nameY: string, color?: string, fill = false) => {
const addDataSet = (
id: string,
nameY: string,
color?: string,
fill = false
) => {
if (!color) {
color = getGraphColorByIndex(colorIndex, computedStyles);
colorIndex++;
}
data.push({
label: nameY,
fill: fill ? "origin" : false,
borderColor: color,
backgroundColor: color + "7F",
stepped: "before",
pointRadius: 0,
id,
data: [],
type: "line",
cursor: "default",
name: nameY,
color,
symbol: "circle",
step: "end",
animationDurationUpdate: 0,
symbolSize: 1,
lineStyle: {
width: fill ? 0 : 1.5,
},
areaStyle: fill
? {
color: color + "7F",
}
: undefined,
tooltip: {
show: !fill,
},
});
entityIds.push(states.entity_id);
datasetToDataIndex.push(dataIdx);
@@ -324,12 +409,16 @@ export class StateHistoryChartLine extends LitElement {
const isHeating =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
entityState.attributes?.hvac_action === "heating"
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "heat"
: (entityState: LineChartState) => entityState.state === "heat";
const isCooling =
domain === "climate" && hasHvacAction
? (entityState: LineChartState) =>
entityState.attributes?.hvac_action === "cooling"
CLIMATE_HVAC_ACTION_TO_MODE[
entityState.attributes?.hvac_action
] === "cool"
: (entityState: LineChartState) => entityState.state === "cool";
const hasHeat = states.states.some(isHeating);
@@ -345,13 +434,23 @@ export class StateHistoryChartLine extends LitElement {
entityState.attributes.target_temp_low
);
addDataSet(
`${this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})}`
states.entity_id + "-current_temperature",
this.showNames
? this.hass.localize("ui.card.climate.current_temperature", {
name: name,
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.current_temperature.name"
)
);
if (hasHeat) {
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"),
true
);
@@ -360,7 +459,12 @@ export class StateHistoryChartLine extends LitElement {
}
if (hasCool) {
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"),
true
);
@@ -370,22 +474,40 @@ export class StateHistoryChartLine extends LitElement {
if (hasTargetRange) {
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})}`
states.entity_id + "-target_temperature_mode",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.high"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_high.name"
)
);
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})}`
states.entity_id + "-target_temperature_mode_low",
this.showNames
? this.hass.localize("ui.card.climate.target_temperature_mode", {
name: name,
mode: this.hass.localize("ui.card.climate.low"),
})
: this.hass.localize(
"component.climate.entity_component._.state_attributes.target_temp_low.name"
)
);
} else {
addDataSet(
`${this.hass.localize("ui.card.climate.target_temperature_entity", {
name: name,
})}`
states.entity_id + "-target_temperature",
this.showNames
? this.hass.localize(
"ui.card.climate.target_temperature_entity",
{
name: name,
}
)
: this.hass.localize(
"component.climate.entity_component._.state_attributes.temperature.name"
)
);
}
@@ -438,19 +560,29 @@ export class StateHistoryChartLine extends LitElement {
);
addDataSet(
`${this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})}`
states.entity_id + "-target_humidity",
this.showNames
? this.hass.localize("ui.card.humidifier.target_humidity_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.humidity.name"
)
);
if (hasCurrent) {
addDataSet(
`${this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)}`
states.entity_id + "-current_humidity",
this.showNames
? this.hass.localize(
"ui.card.humidifier.current_humidity_entity",
{
name: name,
}
)
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.current_humidity.name"
)
);
}
@@ -458,25 +590,40 @@ export class StateHistoryChartLine extends LitElement {
// If action attribute is not available, we shade the area when the device is on
if (hasHumidifying) {
addDataSet(
`${this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})}`,
states.entity_id + "-humidifying",
this.showNames
? this.hass.localize("ui.card.humidifier.humidifying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.humidifying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else if (hasDrying) {
addDataSet(
`${this.hass.localize("ui.card.humidifier.drying", {
name: name,
})}`,
states.entity_id + "-drying",
this.showNames
? this.hass.localize("ui.card.humidifier.drying", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state_attributes.action.state.drying"
),
computedStyles.getPropertyValue("--state-humidifier-on-color"),
true
);
} else {
addDataSet(
`${this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})}`,
states.entity_id + "-on",
this.showNames
? this.hass.localize("ui.card.humidifier.on_entity", {
name: name,
})
: this.hass.localize(
"component.humidifier.entity_component._.state.on"
),
undefined,
true
);
@@ -509,7 +656,7 @@ export class StateHistoryChartLine extends LitElement {
pushData(new Date(entityState.last_changed), series);
});
} else {
addDataSet(name);
addDataSet(states.entity_id, name);
let lastValue: number;
let lastDate: Date;
@@ -575,12 +722,23 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._chartData = {
datasets,
};
this._chartData = datasets;
this._entityIds = entityIds;
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);

View File

@@ -1,19 +1,26 @@
import type { ChartData, ChartDataset, ChartOptions } from "chart.js";
import { getRelativePosition } from "chart.js/helpers";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import millisecondsToDuration from "../../common/datetime/milliseconds_to_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { numberFormatToLocale } from "../../common/number/format_number";
import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { TimeLineData } from "./timeline-chart/const";
import { computeTimelineColor } from "./timeline-chart/timeline-color";
import { clickIsTouch } from "./click_is_touch";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts";
import echarts from "../../resources/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -44,9 +51,9 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false, type: Number }) public chartIndex?;
@state() private _chartData?: ChartData<"timeline">;
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ChartOptions<"timeline">;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -56,20 +63,99 @@ export class StateHistoryChartTimeline extends LitElement {
return html`
<ha-chart-base
.hass=${this.hass}
.data=${this._chartData}
.options=${this._chartOptions}
.height=${this.data.length * 30 + 30}
.paddingYAxis=${this.paddingYAxis - this._yWidth}
chart-type="timeline"
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
@chart-click=${this._handleChartClick}
></ha-chart-base>
`;
}
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._createOptions();
private _renderItem: CustomSeriesRenderItem = (params, api) => {
const categoryIndex = api.value(0);
const start = api.coord([api.value(1), categoryIndex]);
const end = api.coord([api.value(2), categoryIndex]);
const height = 20;
const coordSys = params.coordSys as any;
const rectShape = echarts.graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height: height,
},
{
x: coordSys.x,
y: coordSys.y,
width: coordSys.width,
height: coordSys.height,
}
);
if (!rectShape) return null;
const rect = {
type: "rect" as const,
transition: "shape" as const,
shape: rectShape,
style: {
fill: api.value(4) as string,
},
};
const text = api.value(3) as string;
const textWidth = measureTextWidth(text, 12);
const LABEL_PADDING = 4;
if (textWidth < rectShape.width - LABEL_PADDING * 2) {
return {
type: "group",
children: [
rect,
{
type: "text",
style: {
...rectShape,
x: rectShape.x + LABEL_PADDING,
text,
fill: api.value(5) as string,
fontSize: 12,
lineHeight: rectShape.height,
},
},
],
};
}
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const lines = [
marker + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (
changedProps.has("startTime") ||
changedProps.has("endTime") ||
@@ -83,9 +169,12 @@ export class StateHistoryChartTimeline extends LitElement {
}
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames")
changedProps.has("showNames") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
) {
this._createOptions();
}
@@ -93,144 +182,71 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
this._chartOptions = {
maintainAspectRatio: false,
parsing: false,
scales: {
x: {
type: "time",
position: "bottom",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
min: this.startTime,
suggestedMax: this.endTime,
ticks: {
autoSkip: true,
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
grid: {
offset: false,
},
time: {
tooltipFormat: "datetimeseconds",
},
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisTick: {
show: true,
},
y: {
type: "category",
barThickness: 20,
offset: true,
grid: {
display: false,
drawBorder: false,
drawTicks: false,
},
ticks: {
display: this.chunked || this.showNames,
},
afterSetDimensions: (y) => {
y.maxWidth = y.chart.width * 0.18;
},
afterFit: (scaleInstance) => {
if (this.chunked) {
// ensure all the chart labels are the same width
scaleInstance.width = narrow ? 105 : 185;
}
},
afterUpdate: (y) => {
const yWidth = this.showNames
? (y.width ?? 0)
: computeRTL(this.hass)
? 0
: (y.left ?? 0);
if (
this._yWidth !== Math.floor(yWidth) &&
y.ticks.length === this.data.length
) {
this._yWidth = Math.floor(yWidth);
splitLine: {
show: false,
},
},
yAxis: {
type: "category",
inverse: true,
position: rtl ? "right" : "left",
triggerEvent: true,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
position: computeRTL(this.hass) ? "right" : "left",
hideOverlap: true,
},
},
plugins: {
tooltip: {
mode: "nearest",
callbacks: {
title: (context) =>
context![0].chart!.data!.labels![
context[0].datasetIndex
] as string,
beforeBody: (context) => context[0].dataset.label || "",
label: (item) => {
const d = item.dataset.data[item.dataIndex] as TimeLineData;
const durationInMs = d.end.getTime() - d.start.getTime();
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
return [
d.label || "",
formatDateTimeWithSeconds(
d.start,
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
d.end,
this.hass.locale,
this.hass.config
),
formattedDuration,
];
},
labelColor: (item) => ({
borderColor: (item.dataset.data[item.dataIndex] as TimeLineData)
.color!,
backgroundColor: (
item.dataset.data[item.dataIndex] as TimeLineData
).color!,
}),
},
},
filler: {
propagate: true,
},
grid: {
top: 10,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
}
const chart = e.chart;
const canvasPosition = getRelativePosition(e, chart);
const index = Math.abs(
chart.scales.y.getValueForPixel(canvasPosition.y)
);
fireEvent(this, "hass-more-info", {
// @ts-ignore
entityId: this._chartData?.datasets[index]?.label,
});
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
tooltip: {
appendTo: document.body,
formatter: this._renderTooltip,
},
};
}
@@ -246,8 +262,7 @@ export class StateHistoryChartTimeline extends LitElement {
this._chartTime = new Date();
const startTime = this.startTime;
const endTime = this.endTime;
const labels: string[] = [];
const datasets: ChartDataset<"timeline">[] = [];
const datasets: CustomSeriesOption[] = [];
const names = this.names || {};
// stateHistory is a list of lists of sorted state objects
stateHistory.forEach((stateInfo) => {
@@ -255,10 +270,11 @@ export class StateHistoryChartTimeline extends LitElement {
let prevState: string | null = null;
let locState: string | null = null;
let prevLastChanged = startTime;
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
const entityDisplay: string = this.showNames
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
: "";
const dataRow: TimeLineData[] = [];
const dataRow: unknown[] = [];
stateInfo.data.forEach((entityState) => {
let newState: string | null = entityState.state;
const timeStamp = new Date(entityState.last_changed);
@@ -277,15 +293,23 @@ export class StateHistoryChartTimeline extends LitElement {
} else if (newState !== prevState) {
newLastChanged = new Date(entityState.last_changed);
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
start: prevLastChanged,
end: newLastChanged,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
value: [
stateInfo.entity_id,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
@@ -295,28 +319,52 @@ export class StateHistoryChartTimeline extends LitElement {
});
if (prevState !== null) {
const color = computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
);
dataRow.push({
start: prevLastChanged,
end: endTime,
label: locState,
color: computeTimelineColor(
prevState,
computedStyles,
this.hass.states[stateInfo.entity_id]
),
value: [
stateInfo.entity_id,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
id: stateInfo.entity_id,
data: dataRow,
label: stateInfo.entity_id,
name: entityDisplay,
dimensions: ["id", "start", "end", "name", "color", "textColor"],
type: "custom",
encode: {
x: [1, 2],
y: 0,
itemName: 3,
},
renderItem: this._renderItem,
});
labels.push(entityDisplay);
});
this._chartData = {
labels: labels,
datasets: datasets,
};
this._chartData = datasets;
}
private _handleChartClick(e: CustomEvent<ECElementEvent>): void {
if (e.detail.targetType === "axisLabel") {
const dataset = this._chartData[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
entityId: dataset.id as string,
});
}
}
}
static styles = css`

View File

@@ -69,6 +69,8 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "fit-y-data", type: Boolean }) public fitYData = false;
@property({ type: String }) public height?: string;
private _computedStartTime!: Date;
private _computedEndTime!: Date;
@@ -133,7 +135,7 @@ export class StateHistoryCharts extends LitElement {
return html``;
}
if (!Array.isArray(item)) {
return html`<div class="entry-container">
return html`<div class="entry-container line">
<state-history-chart-line
.hass=${this.hass}
.unit=${item.unit}
@@ -151,10 +153,11 @@ export class StateHistoryCharts extends LitElement {
.maxYAxis=${this.maxYAxis}
.fitYData=${this.fitYData}
@y-width-changed=${this._yWidthChanged}
.height=${this.virtualize ? undefined : this.height}
></state-history-chart-line>
</div> `;
}
return html`<div class="entry-container">
return html`<div class="entry-container timeline">
<state-history-chart-timeline
.hass=${this.hass}
.data=${item}
@@ -274,7 +277,8 @@ export class StateHistoryCharts extends LitElement {
static styles = css`
:host {
display: block;
display: flex;
flex-direction: column;
/* height of single timeline chart = 60px */
min-height: 60px;
}
@@ -297,6 +301,10 @@ export class StateHistoryCharts extends LitElement {
width: 100%;
}
.entry-container.line {
flex: 1;
}
.entry-container:hover {
z-index: 1;
}
@@ -308,6 +316,10 @@ export class StateHistoryCharts extends LitElement {
padding-inline-end: 1px;
}
.entry-container.timeline:first-child {
margin-top: var(--timeline-top-margin);
}
.entry-container:not(:first-child) {
border-top: 2px solid var(--divider-color);
margin-top: 16px;

View File

@@ -1,21 +1,22 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ChartType,
} from "chart.js";
BarSeriesOption,
LineSeriesOption,
} 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 memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { computeRTL } from "../../common/util/compute_rtl";
import type {
Statistics,
StatisticsMetaData,
@@ -25,13 +26,11 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import type { ChartDatasetExtra } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -57,12 +56,14 @@ export class StatisticsChart extends LitElement {
@property() public unit?: string;
@property({ attribute: false }) public startTime?: Date;
@property({ attribute: false }) public endTime?: Date;
@property({ attribute: false, type: Array })
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
@property({ attribute: false }) public chartType: ChartType = "line";
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
@property({ attribute: false, type: Number }) public minYAxis?: number;
@@ -84,13 +85,18 @@ export class StatisticsChart extends LitElement {
@property() public period?: string;
@state() private _chartData: ChartData = { datasets: [] };
@property({ attribute: "days-to-show", type: Number })
public daysToShow?: number;
@state() private _chartDatasetExtra: ChartDatasetExtra[] = [];
@property({ type: String }) public height?: string;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: string[] = [];
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ChartOptions;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
@@ -101,8 +107,14 @@ export class StatisticsChart extends LitElement {
}
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("legendMode")) {
this._hiddenStats.clear();
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
}
if (
!this.hasUpdated ||
@@ -113,19 +125,14 @@ export class StatisticsChart extends LitElement {
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend")
changedProps.has("hideLegend") ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("_legendData") ||
changedProps.has("_chartData")
) {
this._createOptions();
}
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
}
}
public firstUpdated() {
@@ -157,145 +164,159 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
external-hidden
.hass=${this.hass}
.data=${this._chartData}
.extraData=${this._chartDatasetExtra}
.options=${this._chartOptions}
.chartType=${this.chartType}
.height=${this.height}
style=${styleMap({ height: this.height })}
external-hidden
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
></ha-chart-base>
`;
}
private _datasetHidden(ev) {
ev.stopPropagation();
this._hiddenStats.add(this._statisticIds[ev.detail.index]);
private _datasetHidden(ev: CustomEvent) {
this._hiddenStats.add(ev.detail.name);
this.requestUpdate("_hiddenStats");
}
private _datasetUnhidden(ev) {
ev.stopPropagation();
this._hiddenStats.delete(this._statisticIds[ev.detail.index]);
private _datasetUnhidden(ev: CustomEvent) {
this._hiddenStats.delete(ev.detail.name);
this.requestUpdate("_hiddenStats");
}
private _createOptions(unit?: string) {
this._chartOptions = {
parsing: false,
interaction: {
mode: "nearest",
axis: "x",
},
scales: {
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
ticks: {
source: this.chartType === "bar" ? "data" : undefined,
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
font: (context) =>
context.tick && context.tick.major
? ({ weight: "bold" } as any)
: {},
},
time: {
tooltipFormat: "datetime",
unit:
this.chartType === "bar" &&
this.period &&
["hour", "day", "week", "month"].includes(this.period)
? this.period
: undefined,
},
},
y: {
beginAtZero: this.chartType === "bar",
ticks: {
maxTicksLimit: 7,
},
title: {
display: unit || this.unit,
text: unit || this.unit,
},
type: this.logarithmicScale ? "logarithmic" : "linear",
min: this.fitYData ? null : this.minYAxis,
max: this.fitYData ? null : this.maxYAxis,
},
},
plugins: {
tooltip: {
callbacks: {
label: (context) =>
`${context.dataset.label}: ${formatNumber(
context.parsed.y,
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => {
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
const rawValue = String(param.value[2] ?? param.value[1]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale,
options
)}${unit}`;
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[context.datasetIndex]]
)
)} ${
// @ts-ignore
context.dataset.unit || ""
}`,
},
},
filler: {
propagate: true,
},
legend: {
display: !this.hideLegend,
labels: {
usePointStyle: true,
},
},
},
elements: {
line: {
tension: 0.4,
cubicInterpolationMode: "monotone",
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 50,
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
onClick: (e: any) => {
if (!this.clickForMoreInfo || clickIsTouch(e)) {
return;
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
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 }) => (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 }) => (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);
}
});
const chart = e.chart;
const points = chart.getElementsAtEventForMode(
e,
"nearest",
{ intersect: true },
true
if (!startTime) {
// Calculate default start time based on dayDifference
startTime = new Date(
endTime.getTime() - dayDifference * 24 * 3600 * 1000
);
}
}
if (points.length) {
const firstPoint = points[0];
const statisticId = this._statisticIds[firstPoint.datasetIndex];
if (!isExternalStatistic(statisticId)) {
fireEvent(this, "hass-more-info", { entityId: statisticId });
chart.canvas.dispatchEvent(new Event("mouseout")); // to hide tooltip
}
}
this._chartOptions = {
xAxis: [
{
type: "time",
min: startTime,
max: endTime,
},
{
type: "time",
show: false,
},
],
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
nameGap: 2,
nameTextStyle: {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore
scale: true,
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
splitLine: {
show: true,
},
},
legend: {
show: !this.hideLegend,
type: "scroll",
animationDurationUpdate: 400,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 1,
right: 1,
bottom: 0,
containLabel: true,
},
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip,
},
};
}
@@ -325,8 +346,8 @@ export class StatisticsChart extends LitElement {
let colorIndex = 0;
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: ChartDataset<"line">[] = [];
const totalDatasetExtras: ChartDatasetExtra[] = [];
const totalDataSets: typeof this._chartData = [];
const legendData: { name: string; color: string }[] = [];
const statisticIds: string[] = [];
let endTime: Date;
@@ -348,6 +369,7 @@ export class StatisticsChart extends LitElement {
if (endTime > new Date()) {
endTime = new Date();
}
this.endTime = endTime;
let unit: string | undefined | null;
@@ -372,19 +394,19 @@ export class StatisticsChart extends LitElement {
}
// array containing [value1, value2, etc]
let prevValues: (number | null)[] | null = null;
let prevValues: (number | null)[][] | null = null;
let prevEndTime: Date | undefined;
// The datasets for the current statistic
const statDataSets: ChartDataset<"line">[] = [];
const statDatasetExtras: ChartDatasetExtra[] = [];
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: { name: string; color: string }[] = [];
const pushData = (
start: Date,
end: Date,
dataValues: (number | null)[] | null
dataValues: (number | null)[][]
) => {
if (!dataValues) return;
if (!dataValues.length) return;
if (start > end) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
@@ -399,11 +421,12 @@ export class StatisticsChart extends LitElement {
) {
// 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.
d.data.push({ x: prevEndTime.getTime(), y: prevValues[i]! });
// @ts-expect-error
d.data.push({ x: prevEndTime.getTime(), y: null });
d.data!.push(
this._transformDataValue([prevEndTime, ...prevValues[i]!])
);
d.data!.push([prevEndTime, null]);
}
d.data.push({ x: start.getTime(), y: dataValues[i]! });
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
});
prevValues = dataValues;
prevEndTime = end;
@@ -438,49 +461,64 @@ export class StatisticsChart extends LitElement {
})
: this.statTypes;
let displayed_legend = false;
let displayedLegend = false;
sortedTypes.forEach((type) => {
if (statisticsHaveType(stats, type)) {
const band = drawBands && (type === "min" || type === "max");
if (!this.hideLegend) {
const show_legend = hasMean
const showLegend = hasMean
? type === "mean"
: displayed_legend === false;
statDatasetExtras.push({
legend_label: name,
show_legend,
});
displayed_legend = displayed_legend || show_legend;
: displayedLegend === false;
if (showLegend) {
statLegendData.push({ name, color });
}
displayedLegend = displayedLegend || showLegend;
}
statTypes.push(type);
statDataSets.push({
label: name
const borderColor =
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color;
const backgroundColor = band ? color + "3F" : color + "7F";
const series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
smooth: this.chartType === "line" ? 0.4 : false,
smoothMonotone: "x",
cursor: "default",
data: [],
name: name
? `${name} (${this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
)})
`
)})`
: this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}`
),
fill: drawBands
? type === "min" && hasMean
? "+1"
: type === "max"
? "-1"
: false
: false,
borderColor:
band && hasMean ? color + (this.hideLegend ? "00" : "7F") : color,
backgroundColor: band ? color + "3F" : color + "7F",
pointRadius: 0,
hidden: !this.hideLegend
? this._hiddenStats.has(statistic_id)
: false,
data: [],
// @ts-ignore
unit: meta?.unit_of_measurement,
band,
});
symbol: "circle",
symbolSize: 0,
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
},
itemStyle:
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor,
borderWidth: 1.5,
}
: undefined,
color: this.chartType === "bar" ? backgroundColor : borderColor,
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
@@ -494,40 +532,79 @@ export class StatisticsChart extends LitElement {
return;
}
prevDate = startDate;
const dataValues: (number | null)[] = [];
const dataValues: (number | null)[][] = [];
statTypes.forEach((type) => {
let val: number | null | undefined;
const val: (number | null)[] = [];
if (type === "sum") {
if (firstSum === null || firstSum === undefined) {
val = 0;
val.push(0);
firstSum = stat.sum;
} else {
val = (stat.sum || 0) - firstSum;
val.push((stat.sum || 0) - firstSum);
}
} else if (type === "max" && this.chartType === "line") {
const max = stat.max || 0;
val.push(Math.abs(max - (stat.min || 0)));
val.push(max);
} else {
val = stat[type];
val.push(stat[type] ?? null);
}
dataValues.push(val ?? null);
dataValues.push(val);
});
pushData(startDate, new Date(stat.end), dataValues);
if (!this._hiddenStats.has(name)) {
pushData(startDate, new Date(stat.end), dataValues);
}
});
// Concat two arrays
Array.prototype.push.apply(totalDataSets, statDataSets);
Array.prototype.push.apply(totalDatasetExtras, statDatasetExtras);
Array.prototype.push.apply(legendData, statLegendData);
});
if (unit) {
this._createOptions(unit);
this.unit = unit;
}
this._chartData = {
datasets: totalDataSets,
};
this._chartDatasetExtra = totalDatasetExtras;
legendData.forEach(({ name, color }) => {
// Add an empty series for the legend
totalDataSets.push({
id: name + "-legend",
name: name,
color,
type: this.chartType,
data: [],
xAxisIndex: 1,
});
});
this._chartData = totalDataSets;
if (legendData.length !== this._legendData.length) {
// only update the legend if it has changed or it will trigger options update
this._legendData = legendData.map(({ name }) => name);
}
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`
:host {
display: block;

View File

@@ -1,22 +0,0 @@
import type {
BarControllerChartOptions,
BarControllerDatasetOptions,
} from "chart.js";
export interface TimeLineData {
start: Date;
end: Date;
label?: string | null;
color?: string;
}
declare module "chart.js" {
interface ChartTypeRegistry {
timeline: {
chartOptions: BarControllerChartOptions;
datasetOptions: BarControllerDatasetOptions;
defaultDataPoint: TimeLineData;
parsedDataType: any;
};
}
}

View File

@@ -1,63 +0,0 @@
import type { BarOptions, BarProps } from "chart.js";
import { BarElement } from "chart.js";
import { hex2rgb } from "../../../common/color/convert-color";
import { luminosity } from "../../../common/color/rgb";
export interface TextBarProps extends BarProps {
text?: string | null;
options?: Partial<TextBaroptions>;
}
export interface TextBaroptions extends BarOptions {
textPad?: number;
textColor?: string;
backgroundColor: string;
}
export class TextBarElement extends BarElement {
static id = "textbar";
draw(ctx: CanvasRenderingContext2D) {
super.draw(ctx);
const options = this.options as TextBaroptions;
const { x, y, base, width, text } = (
this as BarElement<TextBarProps, TextBaroptions>
).getProps(["x", "y", "base", "width", "text"]);
if (!text) {
return;
}
ctx.beginPath();
const textRect = ctx.measureText(text);
if (
textRect.width === 0 ||
textRect.width + (options.textPad || 4) + 2 > width
) {
return;
}
const textColor =
options.textColor ||
(options?.backgroundColor === "transparent"
? "transparent"
: luminosity(hex2rgb(options.backgroundColor)) > 0.5
? "#000"
: "#fff");
// ctx.font = "12px arial";
ctx.fillStyle = textColor;
ctx.lineWidth = 0;
ctx.strokeStyle = textColor;
ctx.textBaseline = "middle";
ctx.fillText(
text,
x - width / 2 + (options.textPad || 4),
y + (base - y) / 2
);
}
tooltipPosition(useFinalPosition: boolean) {
const { x, y, base } = this.getProps(["x", "y", "base"], useFinalPosition);
return { x, y: y + (base - y) / 2 };
}
}

View File

@@ -1,255 +0,0 @@
import type { BarElement } from "chart.js";
import { BarController } from "chart.js";
import type { TimeLineData } from "./const";
import type { TextBarProps } from "./textbar-element";
function borderProps(properties) {
let reverse;
let start;
let end;
let top;
let bottom;
if (properties.horizontal) {
reverse = properties.base > properties.x;
start = "left";
end = "right";
} else {
reverse = properties.base < properties.y;
start = "bottom";
end = "top";
}
if (reverse) {
top = "end";
bottom = "start";
} else {
top = "start";
bottom = "end";
}
return { start, end, reverse, top, bottom };
}
function setBorderSkipped(properties, options, stack, index) {
let edge = options.borderSkipped;
const res = {};
if (!edge) {
properties.borderSkipped = res;
return;
}
if (edge === true) {
properties.borderSkipped = {
top: true,
right: true,
bottom: true,
left: true,
};
return;
}
const { start, end, reverse, top, bottom } = borderProps(properties);
if (edge === "middle" && stack) {
properties.enableBorderRadius = true;
if ((stack._top || 0) === index) {
edge = top;
} else if ((stack._bottom || 0) === index) {
edge = bottom;
} else {
res[parseEdge(bottom, start, end, reverse)] = true;
edge = top;
}
}
res[parseEdge(edge, start, end, reverse)] = true;
properties.borderSkipped = res;
}
function parseEdge(edge, a, b, reverse) {
if (reverse) {
edge = swap(edge, a, b);
edge = startEnd(edge, b, a);
} else {
edge = startEnd(edge, a, b);
}
return edge;
}
function swap(orig, v1, v2) {
return orig === v1 ? v2 : orig === v2 ? v1 : orig;
}
function startEnd(v, start, end) {
return v === "start" ? start : v === "end" ? end : v;
}
function setInflateAmount(
properties,
{ inflateAmount }: { inflateAmount?: string | number },
ratio
) {
properties.inflateAmount =
inflateAmount === "auto" ? (ratio === 1 ? 0.33 : 0) : inflateAmount;
}
function parseValue(entry, item, vScale, i) {
const startValue = vScale.parse(entry.start, i);
const endValue = vScale.parse(entry.end, i);
const min = Math.min(startValue, endValue);
const max = Math.max(startValue, endValue);
let barStart = min;
let barEnd = max;
if (Math.abs(min) > Math.abs(max)) {
barStart = max;
barEnd = min;
}
// Store `barEnd` (furthest away from origin) as parsed value,
// to make stacking straight forward
item[vScale.axis] = barEnd;
item._custom = {
barStart,
barEnd,
start: startValue,
end: endValue,
min,
max,
};
return item;
}
export class TimelineController extends BarController {
static id = "timeline";
static defaults = {
dataElementType: "textbar",
dataElementOptions: ["text", "textColor", "textPadding"],
elements: {
showText: true,
textPadding: 4,
minBarWidth: 1,
},
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
};
static overrides = {
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
};
parseObjectData(meta, data, start, count) {
const iScale = meta.iScale;
const vScale = meta.vScale;
const labels = iScale.getLabels();
const singleScale = iScale === vScale;
const parsed: any[] = [];
let i;
let ilen;
let item;
let entry;
for (i = start, ilen = start + count; i < ilen; ++i) {
entry = data[i];
item = {};
item[iScale.axis] = singleScale || iScale.parse(labels[i], i);
parsed.push(parseValue(entry, item, vScale, i));
}
return parsed;
}
getLabelAndValue(index) {
const meta = this._cachedMeta;
const { vScale } = meta;
const data = this.getDataset().data[index] as TimeLineData;
return {
label: vScale!.getLabelForValue(this.index) || "",
value: data.label || "",
};
}
updateElements(
bars: BarElement[],
start: number,
count: number,
mode: "reset" | "resize" | "none" | "hide" | "show" | "default" | "active"
) {
const vScale = this._cachedMeta.vScale!;
const iScale = this._cachedMeta.iScale!;
const dataset = this.getDataset();
const firstOpts = this.resolveDataElementOptions(start, mode);
const sharedOptions = this.getSharedOptions(firstOpts);
const includeOptions = this.includeOptions(mode, sharedOptions!);
const horizontal = vScale.isHorizontal();
this.updateSharedOptions(sharedOptions!, mode, firstOpts);
for (let index = start; index < start + count; index++) {
const data = dataset.data[index] as TimeLineData;
const y = vScale.getPixelForValue(this.index);
const xStart = iScale.getPixelForValue(
Math.max(iScale.min, data.start.getTime())
);
const xEnd = iScale.getPixelForValue(data.end.getTime());
const width = xEnd - xStart;
const parsed = this.getParsed(index);
const stack = (parsed._stacks || {})[vScale.axis];
const height = 10;
const properties: TextBarProps = {
horizontal,
x: xStart + width / 2, // Center of the bar
y: y - height, // Top of bar
width,
height: 0,
base: y + height, // Bottom of bar,
// Text
text: data.label,
};
if (includeOptions) {
properties.options =
sharedOptions || this.resolveDataElementOptions(index, mode);
properties.options = {
...properties.options,
backgroundColor: data.color,
};
}
const options = properties.options || bars[index].options;
setBorderSkipped(properties, options, stack, index);
setInflateAmount(properties, options, 1);
this.updateElement(bars[index], index, properties as any, mode);
}
}
removeHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', false);
}
setHoverStyle(_element, _datasetIndex, _index) {
// this._setStyle(element, index, 'active', true);
}
}

View File

@@ -1,11 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getGraphColorByIndex } from "../../../common/color/colors";
import { hex2rgb, lab2hex, rgb2lab } from "../../../common/color/convert-color";
import { labBrighten } from "../../../common/color/lab";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorProperties } from "../../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity";
import { computeCssValue } from "../../../resources/css-variables";
import { getGraphColorByIndex } from "../../common/color/colors";
import { hex2rgb, lab2hex, rgb2lab } from "../../common/color/convert-color";
import { labBrighten } from "../../common/color/lab";
import { computeDomain } from "../../common/entity/compute_domain";
import { stateColorProperties } from "../../common/entity/state_color";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { computeCssValue } from "../../resources/css-variables";
const DOMAIN_STATE_SHADES: Record<string, Record<string, number>> = {
media_player: {

View File

@@ -33,9 +33,10 @@ export class HaAssistChip extends MdAssistChip {
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]),
::slotted([slot="trailingIcon"]) {
::slotted([slot="trailing-icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
font-size: var(--_label-text-size) !important;
}
.trailing.icon ::slotted(*),

View File

@@ -178,7 +178,7 @@ class HaEntityStatePicker extends LitElement {
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
filter="button.trailing.action"
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
@@ -195,12 +195,7 @@ class HaEntityStatePicker extends LitElement {
.label=${label}
selected
>
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
data-handle
></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
${label}
</ha-input-chip>
`;

View File

@@ -276,6 +276,8 @@ export class HaAreaPicker extends LitElement {
icon: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -294,6 +296,8 @@ export class HaAreaPicker extends LitElement {
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -378,6 +382,8 @@ export class HaAreaPicker extends LitElement {
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
@@ -396,6 +402,8 @@ export class HaAreaPicker extends LitElement {
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},

View File

@@ -532,7 +532,7 @@ export class HaAssistChat extends LitElement {
float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--primary-color);
background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color);
direction: var(--direction);
}
@@ -543,7 +543,10 @@ export class HaAssistChat extends LitElement {
margin-inline-start: initial;
float: var(--float-start);
border-bottom-left-radius: 0px;
background-color: var(--secondary-background-color);
background-color: var(
--chat-background-color-hass,
var(--secondary-background-color)
);
color: var(--primary-text-color);
direction: var(--direction);

View File

@@ -329,14 +329,12 @@ export class HaBaseTimeInput extends LitElement {
:host([clearable]) {
position: relative;
}
:host {
display: block;
}
.time-input-wrap-wrap {
display: flex;
}
.time-input-wrap {
display: flex;
flex: var(--time-input-flex, unset);
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
@@ -345,6 +343,7 @@ export class HaBaseTimeInput extends LitElement {
}
ha-textfield {
width: 55px;
flex-grow: 1;
text-align: center;
--mdc-shape-small: 0;
--text-field-appearance: none;

View File

@@ -23,6 +23,9 @@ export class HaButton extends Button {
.slot-container {
overflow: var(--button-slot-container-overflow, visible);
}
:host([destructive]) {
--mdc-theme-primary: var(--error-color);
}
`,
];
}

View File

@@ -9,12 +9,13 @@ import {
endOfMonth,
endOfWeek,
endOfYear,
isThisYear,
startOfDay,
startOfMonth,
startOfWeek,
startOfYear,
isThisYear,
} from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
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 { firstWeekdayIndex } from "../common/datetime/first_weekday";
import {
formatShortDateTimeWithYear,
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../common/datetime/format_date_time";
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 "./date-range-picker";
import "./ha-icon-button";
import "./ha-textarea";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
import "./ha-textarea";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -51,7 +54,7 @@ export class HaDateRangePicker extends LitElement {
public autoApply = false;
@property({ attribute: "time-picker", type: Boolean })
public timePicker = true;
public timePicker = false;
@property({ type: Boolean }) public disabled = false;
@@ -197,14 +200,15 @@ export class HaDateRangePicker extends LitElement {
?auto-apply=${this.autoApply}
time-picker=${this.timePicker}
twentyfour-hours=${this._hour24format}
start-date=${this.startDate.toISOString()}
end-date=${this.endDate.toISOString()}
start-date=${this._formatDate(this.startDate)}
end-date=${this._formatDate(this.endDate)}
?ranges=${this.ranges !== false}
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}
>
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
@@ -325,9 +329,31 @@ export class HaDateRangePicker extends LitElement {
}
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();
}
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() {
const dateRangePicker = this.shadowRoot!.querySelector(
"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`
ha-icon-button {

View File

@@ -79,6 +79,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
.disabled=${this.disabled}
@opening=${this._handleOpen}
@closing=${this._handleClose}
positioning="fixed"
>
<ha-textfield
slot="trigger"

View File

@@ -17,6 +17,7 @@ export class HaMdListItem extends MdListItem {
}
md-item {
overflow: var(--md-item-overflow, hidden);
align-items: var(--md-item-align-items, center);
}
`,
];

View File

@@ -1,6 +1,6 @@
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyle } from "../resources/styles";
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../types";
import "./ha-button";
import "./ha-icon-button";
import "./ha-textfield";
import "./ha-input-helper-text";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-multi-textfield")
@@ -20,6 +21,8 @@ class HaMultiTextField extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public helper?: string;
@property({ attribute: false }) public inputType?: string;
@property({ attribute: false }) public inputSuffix?: string;
@@ -69,12 +72,21 @@ class HaMultiTextField extends LitElement {
</div>
`;
})}
<div class="layout horizontal center-center">
<div class="layout horizontal">
<ha-button @click=${this._addItem} .disabled=${this.disabled}>
${this.addLabel ?? this.hass?.localize("ui.common.add") ?? "Add"}
${this.addLabel ??
(this.label
? this.hass?.localize("ui.components.multi-textfield.add_item", {
item: this.label,
})
: this.hass?.localize("ui.common.add")) ??
"Add"}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-button>
</div>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing}
`;
}

View File

@@ -156,6 +156,7 @@ export class HaSelectSelector extends LitElement {
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
@@ -177,7 +178,6 @@ export class HaSelectSelector extends LitElement {
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
data-handle
></ha-svg-icon>
`
: nothing}

View File

@@ -50,6 +50,7 @@ export class HaTextSelector extends LitElement {
.inputType=${this.selector.text?.type}
.inputSuffix=${this.selector.text?.suffix}
.inputPrefix=${this.selector.text?.prefix}
.helper=${this.helper}
.autocomplete=${this.selector.text?.autocomplete}
@value-changed=${this._handleChange}
>

View File

@@ -1115,6 +1115,8 @@ export class HaMediaPlayerBrowse extends LitElement {
.child .play:not(.can_expand) {
--mdc-icon-button-size: 70px;
--mdc-icon-size: 48px;
background-color: var(--primary-color);
color: var(--text-primary-color);
}
ha-card:hover .image {
@@ -1126,10 +1128,6 @@ export class HaMediaPlayerBrowse extends LitElement {
opacity: 1;
}
ha-card:hover .play:not(.can_expand) {
color: var(--primary-text-color);
}
ha-card:hover .play.can_expand {
bottom: 8px;
}
@@ -1144,10 +1142,6 @@ export class HaMediaPlayerBrowse extends LitElement {
opacity 0.1s ease-out;
}
.child .play:hover {
color: var(--primary-color);
}
.child .title {
font-size: 16px;
padding-top: 16px;
@@ -1331,11 +1325,6 @@ export class HaMediaPlayerBrowse extends LitElement {
ha-browse-media-tts {
direction: var(--direction);
}
ha-card:hover .play:not(.can_expand) {
background-color: var(--primary-color);
color: var(--text-primary-color);
}
`,
];
}

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

View File

@@ -7,13 +7,15 @@ import type { RegistryEntry } from "./registry";
export { subscribeAreaRegistry } from "./ws-area_registry";
export interface AreaRegistryEntry extends RegistryEntry {
aliases: string[];
area_id: string;
floor_id: string | null;
name: string;
picture: string | null;
humidity_entity_id: string | null;
icon: string | null;
labels: string[];
aliases: string[];
name: string;
picture: string | null;
temperature_entity_id: string | null;
}
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
@@ -21,12 +23,14 @@ export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
export interface AreaRegistryEntryMutableParams {
name: string;
floor_id?: string | null;
picture?: string | null;
icon?: string | null;
aliases?: string[];
floor_id?: string | null;
humidity_entity_id?: string | null;
icon?: string | null;
labels?: string[];
name: string;
picture?: string | null;
temperature_entity_id?: string | null;
}
export const createAreaRegistryEntry = (

View File

@@ -1,6 +1,8 @@
import { memoize } from "@fullcalendar/core/internal";
import { setHours, setMinutes } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import checkValidDate from "../common/datetime/check_valid_date";
import {
formatDateTime,
formatDateTimeNumeric,
@@ -12,21 +14,32 @@ import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
export const enum BackupScheduleState {
export const enum BackupScheduleRecurrence {
NEVER = "never",
DAILY = "daily",
MONDAY = "mon",
TUESDAY = "tue",
WEDNESDAY = "wed",
THURSDAY = "thu",
FRIDAY = "fri",
SATURDAY = "sat",
SUNDAY = "sun",
CUSTOM_DAYS = "custom_days",
}
export type BackupDay = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
export const BACKUP_DAYS: BackupDay[] = [
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
"sun",
];
export const sortWeekdays = (weekdays) =>
weekdays.sort((a, b) => BACKUP_DAYS.indexOf(a) - BACKUP_DAYS.indexOf(b));
export interface BackupConfig {
last_attempted_automatic_backup: string | null;
last_completed_automatic_backup: string | null;
next_automatic_backup: string | null;
next_automatic_backup_additional?: boolean;
create_backup: {
agent_ids: string[];
include_addons: string[] | null;
@@ -41,8 +54,11 @@ export interface BackupConfig {
days?: number | null;
};
schedule: {
state: BackupScheduleState;
recurrence: BackupScheduleRecurrence;
time?: string | null;
days: BackupDay[];
};
agents: BackupAgentsConfig;
}
export interface BackupMutableConfig {
@@ -59,21 +75,39 @@ export interface BackupMutableConfig {
copies?: number | null;
days?: number | null;
};
schedule?: BackupScheduleState;
schedule?: {
recurrence: BackupScheduleRecurrence;
time?: string | null;
days?: BackupDay[] | null;
};
agents?: BackupAgentsConfig;
}
export type BackupAgentsConfig = Record<string, BackupAgentConfig>;
export interface BackupAgentConfig {
protected: boolean;
}
export interface BackupAgent {
agent_id: string;
name: string;
}
export interface BackupContentAgent {
size: number;
protected: boolean;
}
export interface BackupContent {
backup_id: string;
date: string;
name: string;
protected: boolean;
size: number;
agent_ids?: string[];
agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[];
extra_metadata?: {
"supervisor.addon_update"?: string;
};
with_automatic_settings: boolean;
}
@@ -135,8 +169,12 @@ export const updateBackupConfig = (
config: BackupMutableConfig
) => hass.callWS({ type: "backup/config/update", ...config });
export const getBackupDownloadUrl = (id: string, agentId: string) =>
`/api/backup/download/${id}?agent_id=${agentId}`;
export const getBackupDownloadUrl = (
id: string,
agentId: string,
password?: string | null
) =>
`/api/backup/download/${id}?agent_id=${agentId}${password ? `&password=${password}` : ""}`;
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupInfo> =>
hass.callWS({
@@ -229,6 +267,19 @@ export const getPreferredAgentForDownload = (agents: string[]) => {
return agents[0];
};
export const canDecryptBackupOnDownload = (
hass: HomeAssistant,
backup_id: string,
agent_id: string,
password: string
) =>
hass.callWS({
type: "backup/can_decrypt_on_download",
backup_id,
agent_id,
password,
});
export const CORE_LOCAL_AGENT = "backup.local";
export const HASSIO_LOCAL_AGENT = "hassio.local";
export const CLOUD_AGENT = "cloud.cloud";
@@ -244,13 +295,18 @@ export const isNetworkMountAgent = (agentId: string) => {
export const computeBackupAgentName = (
localize: LocalizeFunc,
agentId: string,
agentIds?: string[]
agents: BackupAgent[]
) => {
if (isLocalAgent(agentId)) {
return localize("ui.panel.config.backup.agents.local_agent");
}
const [domain, name] = agentId.split(".");
const agent = agents.find((a) => a.agent_id === agentId);
const domain = agentId.split(".")[0];
const name = agent ? agent.name : agentId.split(".")[1];
// If it's a network mount agent, only show the name
if (isNetworkMountAgent(agentId)) {
return name;
}
@@ -258,13 +314,38 @@ export const computeBackupAgentName = (
const domainName = domainToName(localize, domain);
// If there are multiple agents for a domain, show the name
const showName = agentIds
? agentIds.filter((a) => a.split(".")[0] === domain).length > 1
: true;
const showName =
agents.filter((a) => a.agent_id.split(".")[0] === domain).length > 1;
return showName ? `${domainName}: ${name}` : domainName;
};
export const computeBackupSize = (backup: BackupContent) =>
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) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
@@ -337,9 +418,34 @@ export const downloadEmergencyKit = (
geneateEmergencyKitFileName(hass, appendFileName)
);
export const DEFAULT_OPTIMIZED_BACKUP_START_TIME = setMinutes(
setHours(new Date(), 4),
45
);
export const DEFAULT_OPTIMIZED_BACKUP_END_TIME = setMinutes(
setHours(new Date(), 5),
45
);
export const getFormattedBackupTime = memoizeOne(
(locale: FrontendLocaleData, config: HassConfig) => {
const date = setMinutes(setHours(new Date(), 4), 45);
return formatTime(date, locale, config);
(
locale: FrontendLocaleData,
config: HassConfig,
backupTime?: Date | string | null
) => {
if (checkValidDate(backupTime as Date)) {
return formatTime(backupTime as Date, locale, config);
}
if (typeof backupTime === "string" && backupTime) {
const splitted = backupTime.split(":");
const date = setMinutes(
setHours(new Date(), parseInt(splitted[0])),
parseInt(splitted[1])
);
return formatTime(date, locale, config);
}
return `${formatTime(DEFAULT_OPTIMIZED_BACKUP_START_TIME, locale, config)} - ${formatTime(DEFAULT_OPTIMIZED_BACKUP_END_TIME, locale, config)}`;
}
);

167
src/data/bluetooth.ts Normal file
View File

@@ -0,0 +1,167 @@
import {
createCollection,
type Connection,
type UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import type { DataTableRowData } from "../components/data-table/ha-data-table";
export interface BluetoothDeviceData extends DataTableRowData {
address: string;
connectable: boolean;
manufacturer_data: Record<number, string>;
name: string;
rssi: number;
service_data: Record<string, string>;
service_uuids: string[];
source: string;
time: number;
tx_power: number;
}
export interface BluetoothScannerDetails {
source: string;
connectable: boolean;
name: string;
adapter: string;
}
export type BluetoothScannersDetails = Record<string, BluetoothScannerDetails>;
interface BluetoothRemoveDeviceData {
address: string;
}
interface BluetoothAdvertisementSubscriptionMessage {
add?: BluetoothDeviceData[];
change?: BluetoothDeviceData[];
remove?: BluetoothRemoveDeviceData[];
}
interface BluetoothScannersDetailsSubscriptionMessage {
add?: BluetoothScannerDetails[];
remove?: BluetoothScannerDetails[];
}
export interface BluetoothAllocationsData {
source: string;
slots: number;
free: number;
allocated: string[];
}
export const subscribeBluetoothScannersDetailsUpdates = (
conn: Connection,
store: Store<BluetoothScannersDetails>
): Promise<UnsubscribeFunc> =>
conn.subscribeMessage<BluetoothScannersDetailsSubscriptionMessage>(
(event) => {
const data = { ...(store.state || {}) };
if (event.add) {
for (const device_data of event.add) {
data[device_data.source] = device_data;
}
}
if (event.remove) {
for (const device_data of event.remove) {
delete data[device_data.source];
}
}
store.setState(data, true);
},
{
type: `bluetooth/subscribe_scanner_details`,
}
);
export const subscribeBluetoothScannersDetails = (
conn: Connection,
callbackFunction: (bluetoothScannersDetails: BluetoothScannersDetails) => void
) =>
createCollection<BluetoothScannersDetails>(
"_bluetoothScannerDetails",
() => Promise.resolve<BluetoothScannersDetails>({}), // empty hash as initial state
subscribeBluetoothScannersDetailsUpdates,
conn,
callbackFunction
);
const subscribeBluetoothAdvertisementsUpdates = (
conn: Connection,
store: Store<BluetoothDeviceData[]>
): Promise<UnsubscribeFunc> =>
conn.subscribeMessage<BluetoothAdvertisementSubscriptionMessage>(
(event) => {
const data = [...(store.state || [])];
if (event.add) {
for (const device_data of event.add) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index === -1) {
data.push(device_data);
} else {
data[index] = device_data;
}
}
}
if (event.change) {
for (const device_data of event.change) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index !== -1) {
data[index] = device_data;
}
}
}
if (event.remove) {
for (const device_data of event.remove) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index !== -1) {
data.splice(index, 1);
}
}
}
store.setState(data, true);
},
{
type: `bluetooth/subscribe_advertisements`,
}
);
export const subscribeBluetoothAdvertisements = (
conn: Connection,
callbackFunction: (bluetoothDeviceData: BluetoothDeviceData[]) => void
) =>
createCollection<BluetoothDeviceData[]>(
"_bluetoothDeviceRows",
() => Promise.resolve<BluetoothDeviceData[]>([]), // empty array as initial state
subscribeBluetoothAdvertisementsUpdates,
conn,
callbackFunction
);
export const subscribeBluetoothConnectionAllocations = (
conn: Connection,
callbackFunction: (
bluetoothAllocationsData: BluetoothAllocationsData[]
) => void,
configEntryId?: string
): Promise<() => Promise<void>> => {
const params: { type: string; config_entry_id?: string } = {
type: "bluetooth/subscribe_connection_allocations",
};
if (configEntryId) {
params.config_entry_id = configEntryId;
}
return conn.subscribeMessage<BluetoothAllocationsData[]>(
(bluetoothAllocationsData) => callbackFunction(bluetoothAllocationsData),
params
);
};

View File

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

View File

@@ -313,21 +313,34 @@ export const installHassioAddon = async (
export const updateHassioAddon = async (
hass: HomeAssistant,
slug: string
slug: string,
backup: boolean
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/addon",
addon: slug,
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/store/addons/${slug}/update`,
method: "post",
timeout: null,
data: { backup },
});
} else {
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`
);
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`,
{ backup }
);
};
export const restartHassioAddon = async (

View File

@@ -5,6 +5,7 @@ import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export const integrationsWithPanel = {
bluetooth: "config/bluetooth",
matter: "config/matter",
mqtt: "config/mqtt",
thread: "config/thread",

View File

@@ -2,6 +2,8 @@ import type { HomeAssistant } from "../types";
export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";
export const SENSOR_DEVICE_CLASS_TEMPERATURE = "temperature";
export const SENSOR_DEVICE_CLASS_HUMIDITY = "humidity";
export interface SensorDeviceClassUnits {
units: string[];

View File

@@ -6,15 +6,27 @@ export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
export const updateCore = async (hass: HomeAssistant) => {
export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
if (atLeastVersion(hass.config.version, 2025, 2, 0)) {
await hass.callWS({
type: "hassio/update/core",
backup: backup,
});
return;
}
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
data: { backup },
});
} else {
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update");
return;
}
await hass.callApi<HassioResponse<void>>("POST", "hassio/core/update", {
backup,
});
};

View File

@@ -13,6 +13,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import type { EntitySources } from "./entity_sources";
export enum UpdateEntityFeature {
INSTALL = 1,
@@ -60,6 +61,10 @@ export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
entity_id: entityId,
});
const HOME_ASSISTANT_CORE_TITLE = "Home Assistant Core";
const HOME_ASSISTANT_SUPERVISOR_TITLE = "Home Assistant Supervisor";
const HOME_ASSISTANT_OS_TITLE = "Home Assistant Operating System";
export const filterUpdateEntities = (
entities: HassEntities,
language?: string
@@ -69,22 +74,22 @@ export const filterUpdateEntities = (
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
if (a.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
if (b.attributes.title === HOME_ASSISTANT_CORE_TITLE) {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
if (a.attributes.title === HOME_ASSISTANT_OS_TITLE) {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
if (b.attributes.title === HOME_ASSISTANT_OS_TITLE) {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
if (a.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
if (b.attributes.title === HOME_ASSISTANT_SUPERVISOR_TITLE) {
return 1;
}
return caseInsensitiveStringCompare(
@@ -201,3 +206,32 @@ export const computeUpdateStateDisplay = (
return hass.formatEntityState(stateObj);
};
type UpdateType = "addon" | "home_assistant" | "generic";
export const getUpdateType = (
stateObj: UpdateEntity,
entitySources: EntitySources
): UpdateType => {
const entity_id = stateObj.entity_id;
const domain = entitySources[entity_id]?.domain;
if (domain !== "hassio") {
return "generic";
}
const title = stateObj.attributes.title || "";
if (title === HOME_ASSISTANT_CORE_TITLE) {
return "home_assistant";
}
if (
![
HOME_ASSISTANT_CORE_TITLE,
HOME_ASSISTANT_SUPERVISOR_TITLE,
HOME_ASSISTANT_OS_TITLE,
].includes(title)
) {
return "addon";
}
return "generic";
};

View File

@@ -312,32 +312,31 @@ class DataEntryFlowDialog extends LitElement {
private async _processStep(
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
): Promise<void> {
if (step instanceof Promise) {
this._loading = "loading_step";
try {
this._step = await step;
} catch (err: any) {
this.closeDialog();
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: err?.body?.message,
});
return;
} finally {
this._loading = undefined;
}
return;
}
if (step === undefined) {
this.closeDialog();
return;
}
this._loading = "loading_step";
let _step: DataEntryFlowStep;
try {
_step = await step;
} catch (err: any) {
this.closeDialog();
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: err?.body?.message,
});
return;
} finally {
this._loading = undefined;
}
this._step = undefined;
await this.updateComplete;
this._step = step;
this._step = _step;
}
private async _subscribeDataEntryFlowProgressed() {

View File

@@ -65,7 +65,8 @@ class StepFlowCreateEntry extends LitElement {
if (
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;
}

View File

@@ -1,7 +1,6 @@
import { mdiAlertOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-md-dialog";
@@ -117,9 +116,7 @@ class DialogBox extends LitElement {
@click=${this._confirm}
?dialogInitialFocus=${!this._params.prompt &&
!this._params.destructive}
class=${classMap({
destructive: this._params.destructive || false,
})}
?destructive=${this._params.destructive}
>
${this._params.confirmText
? this._params.confirmText
@@ -187,9 +184,6 @@ class DialogBox extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
ha-textfield {
width: 100%;
}

View File

@@ -2,6 +2,7 @@ import "@material/mwc-linear-progress/mwc-linear-progress";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { BINARY_STATE_OFF } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -10,10 +11,18 @@ import "../../../components/ha-circular-progress";
import "../../../components/ha-faded";
import "../../../components/ha-formfield";
import "../../../components/ha-markdown";
import "../../../components/ha-settings-row";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import type { HaSwitch } from "../../../components/ha-switch";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import { isUnavailableState } from "../../../data/entity";
import type { EntitySources } from "../../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
import type { UpdateEntity } from "../../../data/update";
import {
getUpdateType,
UpdateEntityFeature,
updateIsInstalling,
updateReleaseNotes,
@@ -33,6 +42,103 @@ class MoreInfoUpdate extends LitElement {
@state() private _markdownLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _entitySources?: EntitySources;
private async _fetchBackupConfig() {
const { config } = await fetchBackupConfig(this.hass);
this._backupConfig = config;
}
private async _fetchEntitySources() {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
}
private _computeCreateBackupTexts():
| { title: string; description?: string }
| undefined {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return undefined;
}
const updateType = this._entitySources
? getUpdateType(this.stateObj, this._entitySources)
: "generic";
// Automatic or manual for Home Assistant update
if (updateType === "home_assistant") {
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this.hass.locale,
now,
true
),
}
)
: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// Addon backup
if (updateType === "addon") {
const version = this.stateObj.attributes.installed_version;
return {
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.addon"
),
description: version
? this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.addon_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this.hass.localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
protected render() {
if (
!this.hass ||
@@ -47,6 +153,8 @@ class MoreInfoUpdate extends LitElement {
this.stateObj.attributes.skipped_version ===
this.stateObj.attributes.latest_version;
const createBackupTexts = this._computeCreateBackupTexts();
return html`
<div class="content">
<div class="summary">
@@ -133,6 +241,27 @@ class MoreInfoUpdate extends LitElement {
: nothing}
</div>
<div class="footer">
${createBackupTexts
? html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">${createBackupTexts.title}</span>
${createBackupTexts.description
? html`
<span slot="supporting-text">
${createBackupTexts.description}
</span>
`
: nothing}
<ha-switch
slot="end"
id="create-backup"
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>
</ha-md-list-item>
</ha-md-list>
`
: nothing}
<div class="actions">
${this.stateObj.state === BINARY_STATE_OFF &&
this.stateObj.attributes.skipped_version
@@ -186,6 +315,14 @@ class MoreInfoUpdate extends LitElement {
if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources().then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (type === "home_assistant") {
this._fetchBackupConfig();
}
});
}
}
private async _markdownLoaded() {
@@ -205,11 +342,28 @@ class MoreInfoUpdate extends LitElement {
}
}
get _shouldCreateBackup(): boolean {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
return false;
}
const createBackupSwitch = this.shadowRoot?.getElementById(
"create-backup"
) as HaSwitch;
if (createBackupSwitch) {
return createBackupSwitch.checked;
}
return false;
}
private _handleInstall(): void {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
if (this._shouldCreateBackup) {
installData.backup = true;
}
if (
supportsFeature(this.stateObj!, UpdateEntityFeature.SPECIFIC_VERSION) &&
this.stateObj!.attributes.latest_version
@@ -289,12 +443,20 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
ha-settings-row {
ha-md-list {
width: 100%;
padding: 0 24px;
box-sizing: border-box;
margin-bottom: -16px;
margin-top: -4px;
--md-sys-color-surface: var(
--ha-dialog-surface-background,
var(--mdc-theme-surface, #fff)
);
}
ha-md-list-item {
--md-list-item-leading-space: 24px;
--md-list-item-trailing-space: 24px;
}
.actions {

View File

@@ -47,6 +47,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _assistConfiguration?: AssistSatelliteConfiguration;
@state() private _error?: string;
private _previousSteps: STEP[] = [];
private _nextStep?: STEP;
@@ -165,79 +167,86 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"update"
)}
></ha-voice-assistant-setup-step-update>`
: assistEntityState?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
.hass=${this.hass}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
: this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: assistEntityState?.state === UNAVAILABLE
? html`<ha-alert alert-type="error"
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.not_available"
)}</ha-alert
>`
: this._step === STEP.CHECK
? html`<ha-voice-assistant-setup-step-check
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
? html`
<ha-voice-assistant-setup-step-change-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
></ha-voice-assistant-setup-step-check>`
: this._step === STEP.WAKEWORD
? html`<ha-voice-assistant-setup-step-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
.deviceEntities=${this._deviceEntities(
this._params.deviceId,
this.hass.entities
)}
></ha-voice-assistant-setup-step-wake-word>`
: this._step === STEP.CHANGE_WAKEWORD
? html`
<ha-voice-assistant-setup-step-area
.hass=${this.hass}
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
<ha-voice-assistant-setup-step-change-wake-word
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
></ha-voice-assistant-setup-step-change-wake-word>
`
: this._step === STEP.AREA
? html`
<ha-voice-assistant-setup-step-area
.hass=${this.hass}
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.deviceId=${this._params.deviceId}
></ha-voice-assistant-setup-step-area>
`
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
></ha-voice-assistant-setup-step-success>`
: nothing}
</div>
</ha-dialog>
`;
}
private async _fetchAssistConfiguration() {
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
return this._assistConfiguration;
try {
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
this.hass,
this._findDomainEntityId(
this._params!.deviceId,
this.hass.entities,
"assist_satellite"
)!
);
} catch (err: any) {
this._error = err.message;
}
}
private _goToPreviousStep() {
@@ -293,6 +302,10 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
.skip-btn {
margin-top: 6px;
}
ha-alert {
margin: 24px;
display: block;
}
`,
];
}

View File

@@ -85,7 +85,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
<div class="rows">
${this.assistConfiguration &&
this.assistConfiguration.available_wake_words.length > 1
? html` <div class="row">
? html`<div class="row">
<ha-select
.label=${"Wake word"}
@closed=${stopPropagation}

View File

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

View File

@@ -95,7 +95,10 @@ export class HassRouterPage extends ReactiveElement {
const defaultPage = routerOptions.defaultPage;
if (route && route.path === "" && defaultPage !== undefined) {
navigate(`${route.prefix}/${defaultPage}`, { replace: true });
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
});
}
let newPage = route

View File

@@ -5,7 +5,7 @@ import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiCog,
mdiTableCog,
mdiFilterVariant,
mdiFilterVariantRemove,
mdiFormatListChecks,
@@ -309,7 +309,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@click=${this._openSettings}
.title=${localize("ui.components.subpage-data-table.settings")}
>
<ha-svg-icon slot="icon" .path=${mdiCog}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiTableCog}></ha-svg-icon>
</ha-assist-chip>`;
return html`
@@ -355,7 +355,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
></ha-assist-chip>
<ha-md-menu-item
.value=${undefined}
@click=${this._selectAll}
.clickAction=${this._selectAll}
>
<div slot="headline">
${localize("ui.components.subpage-data-table.select_all")}
@@ -363,7 +363,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</ha-md-menu-item>
<ha-md-menu-item
.value=${undefined}
@click=${this._selectNone}
.clickAction=${this._selectNone}
>
<div slot="headline">
${localize(
@@ -374,7 +374,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.value=${undefined}
@click=${this._disableSelectMode}
.clickAction=${this._disableSelectMode}
>
<div slot="headline">
${localize(
@@ -500,7 +500,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<ha-md-menu-item
.value=${id}
@click=${this._handleGroupBy}
.clickAction=${this._handleGroupBy}
.selected=${id === this._groupColumn}
class=${classMap({ selected: id === this._groupColumn })}
>
@@ -511,7 +511,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
)}
<ha-md-menu-item
.value=${undefined}
@click=${this._handleGroupBy}
.clickAction=${this._handleGroupBy}
.selected=${this._groupColumn === undefined}
class=${classMap({ selected: this._groupColumn === undefined })}
>
@@ -519,7 +519,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
@click=${this._collapseAllGroups}
.clickAction=${this._collapseAllGroups}
.disabled=${this._groupColumn === undefined}
>
<ha-svg-icon
@@ -529,7 +529,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
${localize("ui.components.subpage-data-table.collapse_all_groups")}
</ha-md-menu-item>
<ha-md-menu-item
@click=${this._expandAllGroups}
.clickAction=${this._expandAllGroups}
.disabled=${this._groupColumn === undefined}
>
<ha-svg-icon
@@ -546,6 +546,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<ha-md-menu-item
.value=${id}
@click=${this._handleSortBy}
@keydown=${this._handleSortBy}
keep-open
.selected=${id === this._sortColumn}
class=${classMap({ selected: id === this._sortColumn })}
@@ -623,6 +624,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
}
private _handleSortBy(ev) {
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") return;
const columnId = ev.currentTarget.value;
if (!this._sortDirection || this._sortColumn !== columnId) {
this._sortDirection = "asc";
@@ -639,9 +642,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
});
}
private _handleGroupBy(ev) {
this._setGroupColumn(ev.currentTarget.value);
}
private _handleGroupBy = (item) => {
this._setGroupColumn(item.value);
};
private _setGroupColumn(columnId: string) {
this._groupColumn = columnId;
@@ -665,30 +668,30 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
});
}
private _collapseAllGroups() {
private _collapseAllGroups = () => {
this._dataTable.collapseAllGroups();
}
};
private _expandAllGroups() {
private _expandAllGroups = () => {
this._dataTable.expandAllGroups();
}
};
private _enableSelectMode() {
this._selectMode = true;
}
private _disableSelectMode() {
private _disableSelectMode = () => {
this._selectMode = false;
this._dataTable.clearSelection();
}
};
private _selectAll() {
private _selectAll = () => {
this._dataTable.selectAll();
}
};
private _selectNone() {
private _selectNone = () => {
this._dataTable.clearSelection();
}
};
private _handleSearchChange(ev: CustomEvent) {
if (this.filter === ev.detail.value) {

View File

@@ -1,4 +1,3 @@
import "@material/mwc-button/mwc-button";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
@@ -8,6 +7,7 @@ import "../../components/ha-switch";
import { RecurrenceRange } from "../../data/calendar";
import type { HomeAssistant } from "../../types";
import type { ConfirmEventDialogBoxParams } from "./show-confirm-event-dialog-box";
import "../../components/ha-button";
@customElement("confirm-event-dialog-box")
class ConfirmEventDialogBox extends LitElement {
@@ -40,26 +40,26 @@ class ConfirmEventDialogBox extends LitElement {
<div>
<p>${this._params.text}</p>
</div>
<mwc-button @click=${this._dismiss} slot="secondaryAction">
<ha-button @click=${this._dismiss} slot="secondaryAction">
${this.hass.localize("ui.dialogs.generic.cancel")}
</mwc-button>
<mwc-button
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._confirm}
dialogInitialFocus
class="destructive"
destructive
>
${this._params.confirmText}
</mwc-button>
</ha-button>
${this._params.confirmFutureText
? html`
<mwc-button
<ha-button
@click=${this._confirmFuture}
class="destructive"
slot="primaryAction"
destructive
>
${this._params.confirmFutureText}
</mwc-button>
</ha-button>
`
: ""}
</ha-dialog>
@@ -120,9 +120,6 @@ class ConfirmEventDialogBox extends LitElement {
.secondary {
color: var(--secondary-text-color);
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
ha-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;

View File

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

View File

@@ -3,6 +3,7 @@ import "@material/mwc-list/mwc-list";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
@@ -12,6 +13,8 @@ import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-icon-picker";
import "../../../components/ha-floor-picker";
import "../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/ha-textfield";
import "../../../components/ha-labels-picker";
import type { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
@@ -19,6 +22,10 @@ import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-ima
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
import {
SENSOR_DEVICE_CLASS_HUMIDITY,
SENSOR_DEVICE_CLASS_TEMPERATURE,
} from "../../../data/sensor";
const cropOptions: CropOptions = {
round: false,
@@ -27,6 +34,10 @@ const cropOptions: CropOptions = {
aspectRatio: 1.78,
};
const SENSOR_DOMAINS = ["sensor"];
const TEMPERATURE_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_TEMPERATURE];
const HUMIDITY_DEVICE_CLASSES = [SENSOR_DEVICE_CLASS_HUMIDITY];
class DialogAreaDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -42,6 +53,10 @@ class DialogAreaDetail extends LitElement {
@state() private _floor!: string | null;
@state() private _temperatureEntity!: string | null;
@state() private _humidityEntity!: string | null;
@state() private _error?: string;
@state() private _params?: AreaRegistryDetailDialogParams;
@@ -53,14 +68,26 @@ class DialogAreaDetail extends LitElement {
): Promise<void> {
this._params = params;
this._error = undefined;
this._name = this._params.entry
? this._params.entry.name
: this._params.suggestedName || "";
this._aliases = this._params.entry ? this._params.entry.aliases : [];
this._labels = this._params.entry ? this._params.entry.labels : [];
this._picture = this._params.entry?.picture || null;
this._icon = this._params.entry?.icon || null;
this._floor = this._params.entry?.floor_id || null;
if (this._params.entry) {
this._name = this._params.entry.name;
this._aliases = this._params.entry.aliases;
this._labels = this._params.entry.labels;
this._picture = this._params.entry.picture;
this._icon = this._params.entry.icon;
this._floor = this._params.entry.floor_id;
this._temperatureEntity = this._params.entry.temperature_entity_id;
this._humidityEntity = this._params.entry.humidity_entity_id;
} else {
this._name = this._params.suggestedName || "";
this._aliases = [];
this._labels = [];
this._picture = null;
this._icon = null;
this._floor = null;
this._temperatureEntity = null;
this._humidityEntity = null;
}
await this.updateComplete;
}
@@ -76,6 +103,7 @@ class DialogAreaDetail extends LitElement {
}
const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
const isNew = !entry;
return html`
<ha-dialog
open
@@ -161,6 +189,40 @@ class DialogAreaDetail extends LitElement {
.aliases=${this._aliases}
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
${!isNew
? html`
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.temperature_entity"
)}
.helper=${this.hass.localize(
"ui.panel.config.areas.editor.temperature_entity_description"
)}
.value=${this._temperatureEntity}
.includeDomains=${SENSOR_DOMAINS}
.includeDeviceClasses=${TEMPERATURE_DEVICE_CLASSES}
.entityFilter=${this._areaEntityFilter}
@value-changed=${this._sensorChanged}
></ha-entity-picker>
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.humidity_entity"
)}
.helper=${this.hass.localize(
"ui.panel.config.areas.editor.humidity_entity_description"
)}
.value=${this._humidityEntity}
.includeDomains=${SENSOR_DOMAINS}
.includeDeviceClasses=${HUMIDITY_DEVICE_CLASSES}
.entityFilter=${this._areaEntityFilter}
@value-changed=${this._sensorChanged}
></ha-entity-picker>
`
: ""}
</div>
</div>
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
@@ -183,6 +245,22 @@ class DialogAreaDetail extends LitElement {
return this._name.trim() !== "";
}
private _areaEntityFilter = (stateObj: HassEntity): boolean => {
const entityReg = this.hass.entities[stateObj.entity_id];
if (!entityReg) {
return false;
}
const areaId = this._params!.entry!.area_id;
if (entityReg.area_id === areaId) {
return true;
}
if (!entityReg.device_id) {
return false;
}
const deviceReg = this.hass.devices[entityReg.device_id];
return deviceReg && deviceReg.area_id === areaId;
};
private _nameChanged(ev) {
this._error = undefined;
this._name = ev.target.value;
@@ -208,6 +286,16 @@ class DialogAreaDetail extends LitElement {
this._picture = (ev.target as HaPictureUpload).value;
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
}
private _sensorChanged(ev: CustomEvent): void {
const deviceClass = (ev.target as HaEntityPicker).includeDeviceClasses![0];
const key = `_${deviceClass}Entity`;
this[key] = ev.detail.value || null;
}
private async _updateEntry() {
const create = !this._params!.entry;
this._submitting = true;
@@ -219,6 +307,8 @@ class DialogAreaDetail extends LitElement {
floor_id: this._floor || (create ? undefined : null),
labels: this._labels || null,
aliases: this._aliases,
temperature_entity_id: this._temperatureEntity,
humidity_entity_id: this._humidityEntity,
};
if (create) {
await this._params!.createEntry!(values);
@@ -235,17 +325,14 @@ class DialogAreaDetail extends LitElement {
}
}
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-textfield,
ha-icon-picker,
ha-aliases-editor,
ha-entity-picker,
ha-floor-picker,
ha-icon-picker,
ha-labels-picker,
ha-picture-upload {
display: block;

View File

@@ -48,7 +48,7 @@ export class HaDelayAction extends LitElement implements ActionElement {
)}
.disabled=${this.disabled}
.data=${this._timeData}
enableMillisecond
enable-millisecond
required
@value-changed=${this._valueChanged}
></ha-duration-input>`;

View File

@@ -38,7 +38,7 @@ export class HaWaitForTriggerAction
)}
.data=${timeData}
.disabled=${this.disabled}
enableMillisecond
enable-millisecond
@value-changed=${this._timeoutChanged}
></ha-duration-input>
<ha-formfield

View File

@@ -20,13 +20,12 @@ import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
AutomationRenameDialogParams,
EntityRegistryUpdate,
ScriptRenameDialogParams,
} from "./show-dialog-automation-rename";
SaveDialogParams,
} from "./show-dialog-automation-save";
@customElement("ha-dialog-automation-rename")
class DialogAutomationRename extends LitElement implements HassDialog {
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@@ -37,7 +36,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
@state() private _entryUpdates!: EntityRegistryUpdate;
private _params!: AutomationRenameDialogParams | ScriptRenameDialogParams;
private _params!: SaveDialogParams;
private _newName?: string;
@@ -45,9 +44,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
private _newDescription?: string;
public showDialog(
params: AutomationRenameDialogParams | ScriptRenameDialogParams
): void {
public showDialog(params: SaveDialogParams): void {
this._opened = true;
this._params = params;
this._newIcon = "icon" in params.config ? params.config.icon : undefined;
@@ -95,20 +92,153 @@ class DialogAutomationRename extends LitElement implements HassDialog {
`;
}
protected _renderDiscard() {
if (!this._params.onDiscard) {
return nothing;
}
return html`
<ha-button
@click=${this._handleDiscard}
slot="secondaryAction"
class="destructive"
>
${this.hass.localize("ui.common.dont_save")}
</ha-button>
`;
}
protected _renderInputs() {
if (this._params.hideInputs) {
return nothing;
}
return html`
<ha-textfield
dialogInitialFocus
.value=${this._newName}
.placeholder=${this.hass.localize(
`ui.panel.config.${this._params.domain}.editor.default_name`
)}
.label=${this.hass.localize("ui.panel.config.automation.editor.alias")}
required
type="string"
@input=${this._valueChanged}
></ha-textfield>
${this._params.domain === "script" &&
this._visibleOptionals.includes("icon")
? html`
<ha-icon-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.icon"
)}
.value=${this._newIcon}
@value-changed=${this._iconChanged}
>
<ha-domain-icon
slot="fallback"
domain=${this._params.domain}
.hass=${this.hass}
>
</ha-domain-icon>
</ha-icon-picker>
`
: nothing}
${this._visibleOptionals.includes("description")
? html` <ha-textarea
.label=${this.hass.localize(
"ui.panel.config.automation.editor.description.label"
)}
.placeholder=${this.hass.localize(
"ui.panel.config.automation.editor.description.placeholder"
)}
name="description"
autogrow
.value=${this._newDescription}
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
${this._visibleOptionals.includes("category")
? html` <ha-category-picker
id="category"
.hass=${this.hass}
.scope=${this._params.domain}
.value=${this._entryUpdates.category}
@value-changed=${this._registryEntryChanged}
></ha-category-picker>`
: nothing}
${this._visibleOptionals.includes("labels")
? html` <ha-labels-picker
id="labels"
.hass=${this.hass}
.value=${this._entryUpdates.labels}
@value-changed=${this._registryEntryChanged}
></ha-labels-picker>`
: nothing}
${this._visibleOptionals.includes("area")
? html` <ha-area-picker
id="area"
.hass=${this.hass}
.value=${this._entryUpdates.area}
@value-changed=${this._registryEntryChanged}
></ha-area-picker>`
: nothing}
<ha-chip-set>
${this._renderOptionalChip(
"description",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_description"
)
)}
${this._params.domain === "script"
? this._renderOptionalChip(
"icon",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_icon"
)
)
: nothing}
${this._renderOptionalChip(
"area",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_area"
)
)}
${this._renderOptionalChip(
"category",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_category"
)
)}
${this._renderOptionalChip(
"labels",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_labels"
)
)}
</ha-chip-set>
`;
}
protected render() {
if (!this._opened) {
return nothing;
}
const title = this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
);
return html`
<ha-dialog
open
scrimClickAction
@closed=${this.closeDialog}
.heading=${this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}
.heading=${title}
>
<ha-dialog-header slot="heading">
<ha-icon-button
@@ -117,13 +247,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title"
>${this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}</span
>
<span slot="title">${this._params.title || title}</span>
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error"
@@ -132,114 +256,10 @@ class DialogAutomationRename extends LitElement implements HassDialog {
)}</ha-alert
>`
: ""}
<ha-textfield
dialogInitialFocus
.value=${this._newName}
.placeholder=${this.hass.localize(
`ui.panel.config.${this._params.domain}.editor.default_name`
)}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.alias"
)}
required
type="string"
@input=${this._valueChanged}
></ha-textfield>
${this._params.domain === "script" &&
this._visibleOptionals.includes("icon")
? html`
<ha-icon-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.icon"
)}
.value=${this._newIcon}
@value-changed=${this._iconChanged}
>
<ha-domain-icon
slot="fallback"
domain=${this._params.domain}
.hass=${this.hass}
>
</ha-domain-icon>
</ha-icon-picker>
`
${this._params.description
? html`<p>${this._params.description}</p>`
: nothing}
${this._visibleOptionals.includes("description")
? html` <ha-textarea
.label=${this.hass.localize(
"ui.panel.config.automation.editor.description.label"
)}
.placeholder=${this.hass.localize(
"ui.panel.config.automation.editor.description.placeholder"
)}
name="description"
autogrow
.value=${this._newDescription}
@input=${this._valueChanged}
></ha-textarea>`
: nothing}
${this._visibleOptionals.includes("category")
? html` <ha-category-picker
id="category"
.hass=${this.hass}
.scope=${this._params.domain}
.value=${this._entryUpdates.category}
@value-changed=${this._registryEntryChanged}
></ha-category-picker>`
: nothing}
${this._visibleOptionals.includes("labels")
? html` <ha-labels-picker
id="labels"
.hass=${this.hass}
.value=${this._entryUpdates.labels}
@value-changed=${this._registryEntryChanged}
></ha-labels-picker>`
: nothing}
${this._visibleOptionals.includes("area")
? html` <ha-area-picker
id="area"
.hass=${this.hass}
.value=${this._entryUpdates.area}
@value-changed=${this._registryEntryChanged}
></ha-area-picker>`
: nothing}
<ha-chip-set>
${this._renderOptionalChip(
"description",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_description"
)
)}
${this._params.domain === "script"
? this._renderOptionalChip(
"icon",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_icon"
)
)
: nothing}
${this._renderOptionalChip(
"area",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_area"
)
)}
${this._renderOptionalChip(
"category",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_category"
)
)}
${this._renderOptionalChip(
"labels",
this.hass.localize(
"ui.panel.config.automation.editor.dialog.add_labels"
)
)}
</ha-chip-set>
${this._renderInputs()} ${this._renderDiscard()}
<div slot="primaryAction">
<mwc-button @click=${this.closeDialog}>
@@ -247,7 +267,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
</mwc-button>
<mwc-button @click=${this._save}>
${this.hass.localize(
this._params.config.alias
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
: "ui.panel.config.automation.editor.save"
)}
@@ -286,14 +306,19 @@ class DialogAutomationRename extends LitElement implements HassDialog {
}
}
private _save(): void {
private _handleDiscard() {
this._params.onDiscard?.();
this.closeDialog();
}
private async _save(): Promise<void> {
if (!this._newName) {
this._error = "Name is required";
return;
}
if (this._params.domain === "script") {
this._params.updateConfig(
await this._params.updateConfig(
{
...this._params.config,
alias: this._newName,
@@ -303,7 +328,7 @@ class DialogAutomationRename extends LitElement implements HassDialog {
this._entryUpdates
);
} else {
this._params.updateConfig(
await this._params.updateConfig(
{
...this._params.config,
alias: this._newName,
@@ -351,6 +376,9 @@ class DialogAutomationRename extends LitElement implements HassDialog {
display: block;
margin-bottom: 16px;
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
`,
];
}
@@ -358,6 +386,6 @@ class DialogAutomationRename extends LitElement implements HassDialog {
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-automation-rename": DialogAutomationRename;
"ha-dialog-automation-save": DialogAutomationSave;
}
}

View File

@@ -3,13 +3,18 @@ import type { AutomationConfig } from "../../../../data/automation";
import type { ScriptConfig } from "../../../../data/script";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
export const loadAutomationRenameDialog = () =>
import("./dialog-automation-rename");
export const loadAutomationSaveDialog = () =>
import("./dialog-automation-save");
interface BaseRenameDialogParams {
entityRegistryUpdate?: EntityRegistryUpdate;
entityRegistryEntry?: EntityRegistryEntry;
onClose: () => void;
onDiscard?: () => void;
saveText?: string;
description?: string;
title?: string;
hideInputs?: boolean;
}
export interface EntityRegistryUpdate {
@@ -18,31 +23,35 @@ export interface EntityRegistryUpdate {
category: string;
}
export interface AutomationRenameDialogParams extends BaseRenameDialogParams {
export interface AutomationSaveDialogParams extends BaseRenameDialogParams {
config: AutomationConfig;
domain: "automation";
updateConfig: (
config: AutomationConfig,
entityRegistryUpdate: EntityRegistryUpdate
) => void;
) => Promise<void>;
}
export interface ScriptRenameDialogParams extends BaseRenameDialogParams {
export interface ScriptSaveDialogParams extends BaseRenameDialogParams {
config: ScriptConfig;
domain: "script";
updateConfig: (
config: ScriptConfig,
entityRegistryUpdate: EntityRegistryUpdate
) => void;
) => Promise<void>;
}
export const showAutomationRenameDialog = (
export type SaveDialogParams =
| AutomationSaveDialogParams
| ScriptSaveDialogParams;
export const showAutomationSaveDialog = (
element: HTMLElement,
dialogParams: AutomationRenameDialogParams | ScriptRenameDialogParams
dialogParams: SaveDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-automation-rename",
dialogImport: loadAutomationRenameDialog,
dialogTag: "ha-dialog-automation-save",
dialogImport: loadAutomationSaveDialog,
dialogParams,
});
};

View File

@@ -19,7 +19,7 @@ import {
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consume } from "@lit-labs/context";
@@ -70,8 +70,8 @@ import "../ha-config-section";
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
import {
type EntityRegistryUpdate,
showAutomationRenameDialog,
} from "./automation-rename-dialog/show-dialog-automation-rename";
showAutomationSaveDialog,
} from "./automation-save-dialog/show-dialog-automation-save";
import "./blueprint-automation-editor";
import "./manual-automation-editor";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
@@ -500,7 +500,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
.label=${this.hass.localize("ui.panel.config.automation.editor.save")}
.disabled=${this._saving}
extended
@click=${this._saveAutomation}
@click=${this._handleSaveAutomation}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
@@ -743,20 +743,48 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (this._dirty) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.automation.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (!this._dirty) {
return true;
}
return true;
return new Promise<boolean>((resolve) => {
showAutomationSaveDialog(this, {
config: this._config!,
domain: "automation",
updateConfig: async (config, entityRegistryUpdate) => {
this._config = config;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
this.requestUpdate();
const id = this.automationId || String(Date.now());
try {
await this._saveAutomation(id);
} catch (_err: any) {
this.requestUpdate();
resolve(false);
return;
}
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => resolve(true),
entityRegistryUpdate: this._entityRegistryUpdate,
entityRegistryEntry: this._registryEntry,
title: this.hass.localize(
this.automationId
? "ui.panel.config.automation.editor.leave.unsaved_confirm_title"
: "ui.panel.config.automation.editor.leave.unsaved_new_title"
),
description: this.hass.localize(
this.automationId
? "ui.panel.config.automation.editor.leave.unsaved_confirm_text"
: "ui.panel.config.automation.editor.leave.unsaved_new_text"
),
hideInputs: this.automationId !== null,
});
});
}
private _backTapped = async () => {
@@ -878,10 +906,10 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
private async _promptAutomationAlias(): Promise<boolean> {
return new Promise((resolve) => {
showAutomationRenameDialog(this, {
showAutomationSaveDialog(this, {
config: this._config!,
domain: "automation",
updateConfig: (config, entityRegistryUpdate) => {
updateConfig: async (config, entityRegistryUpdate) => {
this._config = config;
this._entityRegistryUpdate = entityRegistryUpdate;
this._dirty = true;
@@ -910,7 +938,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
});
}
private async _saveAutomation(): Promise<void> {
private async _handleSaveAutomation(): Promise<void> {
if (this._yamlErrors) {
showToast(this, {
message: this._yamlErrors,
@@ -926,6 +954,13 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
}
}
await this._saveAutomation(id);
if (!this.automationId) {
navigate(`/config/automation/edit/${id}`, { replace: true });
}
}
private async _saveAutomation(id): Promise<void> {
this._saving = true;
this._validationErrors = undefined;
@@ -990,10 +1025,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
}
this._dirty = false;
if (!this.automationId) {
navigate(`/config/automation/edit/${id}`, { replace: true });
}
} catch (errors: any) {
this._errors = errors.body?.message || errors.error || errors.body;
showToast(this, {
@@ -1016,7 +1047,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
protected supportedShortcuts(): SupportedShortcuts {
return {
s: () => this._saveAutomation(),
s: () => this._handleSaveAutomation(),
};
}

View File

@@ -28,7 +28,7 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -324,7 +324,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
const dayDifference = differenceInDays(now, date);
return html`
${dayDifference > 3
? formatShortDateTime(date, locale, this.hass.config)
? formatShortDateTimeWithConditionalYear(
date,
this.hass.locale,
this.hass.config
)
: relativeTime(date, locale)}
`;
},
@@ -399,7 +403,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
(category) =>
html`<ha-md-menu-item
.value=${category.category_id}
@click=${this._handleBulkCategory}
.clickAction=${this._handleBulkCategory}
>
${category.icon
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
@@ -407,7 +411,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<div slot="headline">${category.name}</div>
</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">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category"
@@ -415,7 +419,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</div>
</ha-md-menu-item>
<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">
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
@@ -452,7 +456,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-md-menu-item>`;
})}
<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">
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-md-menu-item
@@ -462,7 +466,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
(area) =>
html`<ha-md-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
.clickAction=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
@@ -473,7 +477,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<div slot="headline">${area.name}</div>
</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">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
@@ -481,7 +485,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</div>
</ha-md-menu-item>
<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">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
@@ -538,7 +542,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this.hass.localize,
this.hass.locale
)}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
@@ -756,7 +760,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-sub-menu>`
: nothing
}
<ha-md-menu-item @click=${this._handleBulkEnable}>
<ha-md-menu-item .clickAction=${this._handleBulkEnable}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -764,7 +768,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item @click=${this._handleBulkDisable}>
<ha-md-menu-item .clickAction=${this._handleBulkDisable}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
@@ -1239,10 +1243,10 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
}
private async _handleBulkCategory(ev) {
const category = ev.currentTarget.value;
private _handleBulkCategory = async (item) => {
const category = item.value;
this._bulkAddCategory(category);
}
};
private async _bulkAddCategory(category: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1305,10 +1309,10 @@ ${rejected
}
}
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
private _handleBulkArea = (item) => {
const area = item.value;
this._bulkAddArea(area);
}
};
private async _bulkAddArea(area: string) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1335,7 +1339,7 @@ ${rejected
}
}
private async _bulkCreateArea() {
private _bulkCreateArea = async () => {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
@@ -1343,9 +1347,9 @@ ${rejected
return area;
},
});
}
};
private async _handleBulkEnable() {
private _handleBulkEnable = async () => {
const promises: Promise<ServiceCallResponse>[] = [];
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, true));
@@ -1364,9 +1368,9 @@ ${rejected
>`,
});
}
}
};
private async _handleBulkDisable() {
private _handleBulkDisable = async () => {
const promises: Promise<ServiceCallResponse>[] = [];
this._selected.forEach((entityId) => {
promises.push(turnOnOffEntity(this.hass, entityId, false));
@@ -1385,9 +1389,9 @@ ${rejected
>`,
});
}
}
};
private async _bulkCreateCategory() {
private _bulkCreateCategory = async () => {
showCategoryRegistryDetailDialog(this, {
scope: "automation",
createEntry: async (values) => {
@@ -1400,9 +1404,9 @@ ${rejected
return category;
},
});
}
};
private _bulkCreateLabel() {
private _bulkCreateLabel = () => {
showLabelDetailDialog(this, {
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
@@ -1410,14 +1414,14 @@ ${rejected
return label;
},
});
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
this._activeGrouping = ev.detail.value ?? "";
}
private _handleCollapseChanged(ev: CustomEvent) {

View File

@@ -9,9 +9,11 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { TimeTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import { computeDomain } from "../../../../../common/entity/compute_domain";
const MODE_TIME = "time";
const MODE_ENTITY = "entity";
const VALID_DOMAINS = ["sensor", "input_datetime"];
@customElement("ha-automation-trigger-time")
export class HaTimeTrigger extends LitElement implements TriggerElement {
@@ -33,8 +35,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
private _schema = memoizeOne(
(
localize: LocalizeFunc,
inputMode: typeof MODE_TIME | typeof MODE_ENTITY,
showOffset: boolean
inputMode: typeof MODE_TIME | typeof MODE_ENTITY
) =>
[
{
@@ -65,16 +66,13 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
entity: {
filter: [
{ domain: "input_datetime" },
{ domain: "time" },
{ domain: "sensor", device_class: "timestamp" },
],
},
},
},
{ name: "offset", selector: { text: {} } },
] as const)),
...(showOffset
? ([{ name: "offset", selector: { text: {} } }] as const)
: ([] as const)),
] as const
);
@@ -107,9 +105,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
const entity =
typeof at === "object"
? at.entity_id
: at?.startsWith("input_datetime.") ||
at?.startsWith("time.") ||
at?.startsWith("sensor.")
: at && VALID_DOMAINS.includes(computeDomain(at))
? at
: undefined;
const time = entity ? undefined : (at as string | undefined);
@@ -132,9 +128,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
}
const data = this._data(this._inputMode, at);
const showOffset =
data.mode === MODE_ENTITY && data.entity?.startsWith("sensor.");
const schema = this._schema(this.hass.localize, data.mode, !!showOffset);
const schema = this._schema(this.hass.localize, data.mode);
return html`
<ha-form
@@ -157,9 +151,6 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
delete newValue.offset;
} else {
delete newValue.time;
if (!newValue.entity?.startsWith("sensor.")) {
delete newValue.offset;
}
}
fireEvent(this, "value-changed", {
value: {

View File

@@ -33,6 +33,7 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
.data=${this.trigger}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
@@ -50,6 +51,13 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
);
private _computeHelperCallback = (
_schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
);
}
declare global {

View File

@@ -1,24 +1,28 @@
import { mdiHarddisk, mdiNas } from "@mdi/js";
import type { PropertyValues } from "lit";
import { mdiCog, mdiDelete, mdiHarddisk, mdiNas } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-switch";
import type {
BackupAgent,
BackupAgentsConfig,
} from "../../../../../data/backup";
import {
CLOUD_AGENT,
compareAgents,
computeBackupAgentName,
fetchBackupAgentsInfo,
isLocalAgent,
isNetworkMountAgent,
} from "../../../../../data/backup";
import type { CloudStatus } from "../../../../../data/cloud";
import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url";
import { navigate } from "../../../../../common/navigate";
const DEFAULT_AGENTS = [];
@@ -28,23 +32,15 @@ class HaBackupConfigAgents extends LitElement {
@property({ attribute: false }) public cloudStatus!: CloudStatus;
@state() private _agentIds: string[] = [];
@property({ attribute: false }) public agents: BackupAgent[] = [];
@property({ attribute: false }) public agentsConfig?: BackupAgentsConfig;
@property({ type: Boolean, attribute: "show-settings" }) public showSettings =
false;
@state() private value?: string[];
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._fetchAgents();
}
private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agentIds = agents
.map((agent) => agent.agent_id)
.filter((id) => id !== CLOUD_AGENT || this.cloudStatus.logged_in)
.sort(compareAgents);
}
private get _value() {
return this.value ?? DEFAULT_AGENTS;
}
@@ -60,6 +56,21 @@ class HaBackupConfigAgents extends LitElement {
"ui.panel.config.backup.agents.cloud_agent_description"
);
}
const encryptionTurnedOff =
this.agentsConfig?.[agentId]?.protected === false;
if (encryptionTurnedOff) {
return html`
<span class="dot warning"></span>
<span>
${this.hass.localize(
"ui.panel.config.backup.agents.encryption_turned_off"
)}
</span>
`;
}
if (isNetworkMountAgent(agentId)) {
return this.hass.localize(
"ui.panel.config.backup.agents.network_mount_agent_description"
@@ -68,74 +79,171 @@ class HaBackupConfigAgents extends LitElement {
return "";
}
protected render() {
private _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private _unavailableAgents = memoizeOne(
(
agents: BackupAgent[],
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`
${this._agentIds.length > 0
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
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>
${this._agentIds.map((agentId) => {
const domain = computeDomain(agentId);
${availableAgents.map((agent) => {
const agentId = agent.agent_id;
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this._agentIds
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>
${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
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
${this._renderAgentIcon(agentId)}
<div slot="headline" class="name">${name}</div>
${description
? html`<div slot="supporting-text">${description}</div>`
: nothing}
${this.showSettings
? html`
<ha-icon-button
id=${agentId}
slot="end"
path=${mdiCog}
@click=${this._showAgentSettings}
></ha-icon-button>
`
: nothing}
<ha-switch
slot="end"
id=${agentId}
.checked=${!noCloudSubscription &&
this._value.includes(agentId)}
.disabled=${noCloudSubscription}
.checked=${this._value.includes(agentId)}
.disabled=${noCloudSubscription &&
!this._value.includes(agentId)}
@change=${this._agentToggled}
></ha-switch>
</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>
`
: html`<p>
${this.hass.localize("ui.panel.config.backup.agents.no_agents")}
</p>`}
: html`
<p>
${this.hass.localize("ui.panel.config.backup.agents.no_agents")}
</p>
`}
`;
}
private _showAgentSettings(ev): void {
const agentId = ev.currentTarget.id;
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) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -148,13 +256,7 @@ class HaBackupConfigAgents extends LitElement {
}
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)]
.filter((agent) => this._agentIds.some((id) => id === agent))
.filter(
(id) =>
id !== CLOUD_AGENT ||
(this.cloudStatus.logged_in && this.cloudStatus.active_subscription)
);
this.value = [...new Set(this.value)];
fireEvent(this, "value-changed", { value: this.value });
}
@@ -178,6 +280,25 @@ class HaBackupConfigAgents extends LitElement {
--mdc-icon-size: 48px;
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;
}
.dot {
display: block;
position: relative;
width: 8px;
height: 8px;
background-color: var(--disabled-color);
border-radius: 50%;
flex: none;
}
.dot.warning {
background-color: var(--warning-color);
}
`;
}

View File

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

View File

@@ -12,12 +12,22 @@ import type { HaMdSelect } from "../../../../../components/ha-md-select";
import "../../../../../components/ha-md-select-option";
import "../../../../../components/ha-md-textfield";
import "../../../../../components/ha-switch";
import type { BackupConfig } from "../../../../../data/backup";
import type { BackupConfig, BackupDay } from "../../../../../data/backup";
import {
BackupScheduleState,
getFormattedBackupTime,
BACKUP_DAYS,
BackupScheduleRecurrence,
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
sortWeekdays,
} from "../../../../../data/backup";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-time-input";
import "../../../../../components/ha-tip";
import "../../../../../components/ha-expansion-panel";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-formfield";
import { formatTime } from "../../../../../common/datetime/format_time";
import { documentationUrl } from "../../../../../util/documentation-url";
export type BackupConfigSchedule = Pick<BackupConfig, "schedule" | "retention">;
@@ -30,6 +40,11 @@ enum RetentionPreset {
CUSTOM = "custom",
}
enum BackupScheduleTime {
DEFAULT = "default",
CUSTOM = "custom",
}
interface RetentionData {
type: "copies" | "days";
value: number;
@@ -44,15 +59,10 @@ const RETENTION_PRESETS: Record<
};
const SCHEDULE_OPTIONS = [
BackupScheduleState.DAILY,
BackupScheduleState.MONDAY,
BackupScheduleState.TUESDAY,
BackupScheduleState.WEDNESDAY,
BackupScheduleState.THURSDAY,
BackupScheduleState.FRIDAY,
BackupScheduleState.SATURDAY,
BackupScheduleState.SUNDAY,
] as const satisfies BackupScheduleState[];
BackupScheduleRecurrence.NEVER,
BackupScheduleRecurrence.DAILY,
BackupScheduleRecurrence.CUSTOM_DAYS,
] as const satisfies BackupScheduleRecurrence[];
const RETENTION_PRESETS_OPTIONS = [
RetentionPreset.COPIES_3,
@@ -60,6 +70,11 @@ const RETENTION_PRESETS_OPTIONS = [
RetentionPreset.CUSTOM,
] as const satisfies RetentionPreset[];
const SCHEDULE_TIME_OPTIONS = [
BackupScheduleTime.DEFAULT,
BackupScheduleTime.CUSTOM,
] as const satisfies BackupScheduleTime[];
const computeRetentionPreset = (
data: RetentionData
): RetentionPreset | undefined => {
@@ -72,8 +87,10 @@ const computeRetentionPreset = (
};
interface FormData {
enabled: boolean;
schedule: BackupScheduleState;
recurrence: BackupScheduleRecurrence;
time_option: BackupScheduleTime;
time?: string | null;
days: BackupDay[];
retention: {
type: "copies" | "days";
value: number;
@@ -81,8 +98,9 @@ interface FormData {
}
const INITIAL_FORM_DATA: FormData = {
enabled: false,
schedule: BackupScheduleState.NEVER,
recurrence: BackupScheduleRecurrence.NEVER,
time_option: BackupScheduleTime.DEFAULT,
days: [],
retention: {
type: "copies",
value: 3,
@@ -114,8 +132,15 @@ class HaBackupConfigSchedule extends LitElement {
const config = value;
return {
enabled: config.schedule.state !== BackupScheduleState.NEVER,
schedule: config.schedule.state,
recurrence: config.schedule.recurrence,
time_option: config.schedule.time
? BackupScheduleTime.CUSTOM
: BackupScheduleTime.DEFAULT,
time: config.schedule.time,
days:
config.schedule.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? config.schedule.days
: [],
retention: {
type: config.retention.days != null ? "days" : "copies",
value: config.retention.days ?? config.retention.copies ?? 3,
@@ -125,8 +150,14 @@ class HaBackupConfigSchedule extends LitElement {
private _setData(data: FormData) {
this.value = {
...this.value,
schedule: {
state: data.enabled ? data.schedule : BackupScheduleState.NEVER,
recurrence: data.recurrence,
time: data.time_option === BackupScheduleTime.CUSTOM ? data.time : null,
days:
data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? data.days
: [],
},
retention:
data.retention.type === "days"
@@ -140,49 +171,113 @@ class HaBackupConfigSchedule extends LitElement {
protected render() {
const data = this._getData(this.value);
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
return html`
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.use_automatic_backups"
"ui.panel.config.backup.schedule.schedule"
)}</span
>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule_description"
)}
</span>
<ha-switch
<ha-md-select
slot="end"
@change=${this._enabledChanged}
.checked=${data.enabled}
></ha-switch>
@change=${this._scheduleChanged}
.value=${data.recurrence}
>
${SCHEDULE_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.schedule_options.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item>
${data.enabled
${data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_schedule"
)}
outlined
>
<ha-md-list-item class="days">
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.backup_every"
)}
</span>
<div slot="end">
${BACKUP_DAYS.map(
(day) => html`
<div>
<ha-formfield
.label=${this.hass.localize(`ui.panel.config.backup.overview.settings.weekdays.${day}`)}
>
<ha-checkbox
@change=${this._daysChanged}
.checked=${data.days.includes(day)}
.value=${day}
>
</ha-checkbox>
</span>
</ha-formfield>
</div>
`
)}
</div>
</ha-md-list-item>
</ha-expansion-panel>`
: nothing}
${data.recurrence === BackupScheduleRecurrence.DAILY ||
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length > 0)
? html`
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule"
)}
</span>
"ui.panel.config.backup.schedule.time"
)}</span
>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.schedule_description"
"ui.panel.config.backup.schedule.schedule_time_description",
{
time_range_start: formatTime(
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
this.hass.locale,
this.hass.config
),
time_range_end: formatTime(
DEFAULT_OPTIMIZED_BACKUP_END_TIME,
this.hass.locale,
this.hass.config
),
}
)}
</span>
<ha-md-select
slot="end"
@change=${this._scheduleChanged}
.value=${data.schedule}
@change=${this._scheduleTimeChanged}
.value=${data.time_option}
>
${SCHEDULE_OPTIONS.map(
${SCHEDULE_TIME_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.schedule_options.${option}`,
{ time }
`ui.panel.config.backup.schedule.time_options.${option}`
)}
</div>
</ha-md-select-option>
@@ -190,100 +285,197 @@ class HaBackupConfigSchedule extends LitElement {
)}
</ha-md-select>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention`
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_description`
)}
</span>
<ha-md-select
slot="end"
@change=${this._retentionPresetChanged}
.value=${this._retentionPreset}
>
${RETENTION_PRESETS_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_presets.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item>
${this._retentionPreset === RetentionPreset.CUSTOM
? html`
${data.time_option === BackupScheduleTime.CUSTOM
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time"
)}
outlined
>
<ha-md-list-item>
<ha-md-textfield
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_label"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_time_description",
{
time: formatTime(
DEFAULT_OPTIMIZED_BACKUP_START_TIME,
this.hass.locale,
this.hass.config
),
}
)}
</span>
<ha-time-input
slot="end"
@change=${this._retentionValueChanged}
.value=${data.retention.value}
id="value"
type="number"
.min=${MIN_VALUE}
.max=${MAX_VALUE}
step="1"
@value-changed=${this._timeChanged}
.value=${data.time ?? undefined}
.locale=${this.hass.locale}
>
</ha-md-textfield>
<ha-md-select
slot="end"
@change=${this._retentionTypeChanged}
.value=${data.retention.type}
id="type"
>
<ha-md-select-option value="days">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.days"
)}
</div>
</ha-md-select-option>
<ha-md-select-option value="copies">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.copies"
)}
</ha-md-select-option>
</ha-md-select>
</ha-time-input>
</ha-md-list-item>
`
</ha-expansion-panel>`
: nothing}
`
: nothing}
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_description`
)}
</span>
<ha-md-select
slot="end"
@change=${this._retentionPresetChanged}
.value=${this._retentionPreset ?? ""}
>
${RETENTION_PRESETS_OPTIONS.map(
(option) => html`
<ha-md-select-option .value=${option}>
<div slot="headline">
${this.hass.localize(
`ui.panel.config.backup.schedule.retention_presets.${option}`
)}
</div>
</ha-md-select-option>
`
)}
</ha-md-select>
</ha-md-list-item>
${this._retentionPreset === RetentionPreset.CUSTOM
? html`<ha-expansion-panel
expanded
.header=${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention"
)}
outlined
>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.custom_retention_label"
)}
</span>
<ha-md-textfield
slot="end"
@change=${this._retentionValueChanged}
.value=${data.retention.value.toString()}
id="value"
type="number"
.min=${MIN_VALUE.toString()}
.max=${MAX_VALUE.toString()}
step="1"
>
</ha-md-textfield>
<ha-md-select
slot="end"
@change=${this._retentionTypeChanged}
.value=${data.retention.type}
id="type"
>
<ha-md-select-option value="days">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.days"
)}
</div>
</ha-md-select-option>
<ha-md-select-option value="copies">
${this.hass.localize(
"ui.panel.config.backup.schedule.retention_units.copies"
)}
</ha-md-select-option>
</ha-md-select>
</ha-md-list-item></ha-expansion-panel
> `
: nothing}
<ha-tip .hass=${this.hass}
>${this.hass.localize("ui.panel.config.backup.schedule.tip", {
backup_create: html`<a
href=${documentationUrl(
this.hass,
"/integrations/backup/#action-backupcreate_automatic"
)}
target="_blank"
rel="noopener noreferrer"
>backup.create_automatic</a
>`,
})}</ha-tip
>
</ha-md-list>
`;
}
private _enabledChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaCheckbox;
const data = this._getData(this.value);
this._setData({
...data,
enabled: target.checked,
schedule: target.checked
? BackupScheduleState.DAILY
: BackupScheduleState.NEVER,
});
fireEvent(this, "value-changed", { value: this.value });
}
private _scheduleChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
const data = this._getData(this.value);
let days = [...data.days];
if (
target.value === BackupScheduleRecurrence.CUSTOM_DAYS &&
data.days.length === 0
) {
days = [...BACKUP_DAYS];
}
this._setData({
...data,
schedule: target.value as BackupScheduleState,
recurrence: target.value as BackupScheduleRecurrence,
days,
});
}
private _scheduleTimeChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect;
const data = this._getData(this.value);
this._setData({
...data,
time_option: target.value as BackupScheduleTime,
time: target.value === BackupScheduleTime.CUSTOM ? "04:45:00" : undefined,
});
}
private _timeChanged(ev) {
ev.stopPropagation();
const data = this._getData(this.value);
this._setData({
...data,
time: ev.detail.value,
});
}
private _daysChanged(ev) {
ev.stopPropagation();
const target = ev.currentTarget as HaCheckbox;
const value = target.value as BackupDay;
const data = this._getData(this.value);
const days = [...data.days];
if (target.checked && !data.days.includes(value)) {
days.push(value);
} else if (!target.checked && data.days.includes(value)) {
days.splice(days.indexOf(value), 1);
}
sortWeekdays(days);
this._setData({
...data,
days,
});
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionPresetChanged(ev) {
@@ -304,8 +496,6 @@ class HaBackupConfigSchedule extends LitElement {
retention: RETENTION_PRESETS[value],
});
}
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionValueChanged(ev) {
@@ -321,8 +511,6 @@ class HaBackupConfigSchedule extends LitElement {
value: clamped,
},
});
fireEvent(this, "value-changed", { value: this.value });
}
private _retentionTypeChanged(ev) {
@@ -338,8 +526,6 @@ class HaBackupConfigSchedule extends LitElement {
type: value,
},
});
fireEvent(this, "value-changed", { value: this.value });
}
static styles = css`
@@ -354,9 +540,19 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select {
min-width: 210px;
}
ha-time-input {
min-width: 194px;
--time-input-flex: 1;
}
@media all and (max-width: 450px) {
ha-md-select {
min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-time-input {
min-width: 145px;
width: 145px;
}
}
ha-md-textfield#value {
@@ -365,6 +561,31 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-select#type {
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 {
--expansion-panel-summary-padding: 0 16px;
--expansion-panel-content-padding: 0 16px;
margin-bottom: 16px;
}
ha-tip {
text-align: unset;
margin: 16px 0;
}
ha-md-list-item.days {
--md-item-align-items: flex-start;
}
a {
color: var(--primary-color);
}
`;
}

View File

@@ -7,6 +7,7 @@ import { computeDomain } from "../../../../common/entity/compute_domain";
import "../../../../components/ha-checkbox";
import "../../../../components/ha-formfield";
import "../../../../components/ha-svg-icon";
import type { BackupAgent } from "../../../../data/backup";
import {
computeBackupAgentName,
isLocalAgent,
@@ -24,7 +25,7 @@ class HaBackupAgentsPicker extends LitElement {
public disabled = false;
@property({ attribute: false })
public agentIds!: string[];
public agents!: BackupAgent[];
@property({ attribute: false })
public disabledAgentIds?: string[];
@@ -35,30 +36,30 @@ class HaBackupAgentsPicker extends LitElement {
render() {
return html`
<div class="agents">
${this.agentIds.map((agent) => this._renderAgent(agent))}
${this.agents.map((agent) => this._renderAgent(agent))}
</div>
`;
}
private _renderAgent(agentId: string) {
const domain = computeDomain(agentId);
private _renderAgent(agent: BackupAgent) {
const domain = computeDomain(agent.agent_id);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agentIds
agent.agent_id,
this.agents
);
const disabled =
this.disabled || this.disabledAgentIds?.includes(agentId) || false;
this.disabled || this.disabledAgentIds?.includes(agent.agent_id) || false;
return html`
<ha-formfield>
<span class="label ${classMap({ disabled })}" slot="label">
${isLocalAgent(agentId)
${isLocalAgent(agent.agent_id)
? html`
<ha-svg-icon .path=${mdiHarddisk} slot="start"> </ha-svg-icon>
`
: isNetworkMountAgent(agentId)
: isNetworkMountAgent(agent.agent_id)
? html` <ha-svg-icon .path=${mdiNas} slot="start"></ha-svg-icon> `
: html`
<img
@@ -77,8 +78,8 @@ class HaBackupAgentsPicker extends LitElement {
${name}
</span>
<ha-checkbox
.checked=${this.value.includes(agentId)}
.value=${agentId}
.checked=${this.value.includes(agent.agent_id)}
.value=${agent.agent_id}
.disabled=${disabled}
@change=${this._checkboxChanged}
></ha-checkbox>

View File

@@ -4,6 +4,7 @@ import {
mdiFolder,
mdiPlayBoxMultiple,
mdiPuzzle,
mdiShieldCheck,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -36,6 +37,7 @@ const ITEM_ICONS = {
database: mdiChartBox,
media: mdiPlayBoxMultiple,
share: mdiFolder,
ssl: mdiShieldCheck,
};
interface SelectedItems {
@@ -104,6 +106,8 @@ export class HaBackupDataPicker extends LitElement {
return this.hass.localize(
"ui.panel.config.backup.data_picker.share_folder"
);
case "ssl":
return this.hass.localize("ui.panel.config.backup.data_picker.ssl");
case "addons/local":
return this.hass.localize(
"ui.panel.config.backup.data_picker.local_addons"
@@ -167,15 +171,14 @@ export class HaBackupDataPicker extends LitElement {
})
);
private _itemChanged(ev: Event) {
private _homeassistantChanged(ev: Event) {
const itemValues = this._parseValue(this.value);
const checkbox = ev.currentTarget as HaCheckbox;
const section = (checkbox as any).section;
if (checkbox.checked) {
itemValues[section].push(checkbox.id);
itemValues.homeassistant.push(checkbox.id);
} else {
itemValues[section] = itemValues[section].filter(
itemValues.homeassistant = itemValues.homeassistant.filter(
(id) => id !== checkbox.id
);
}
@@ -262,8 +265,7 @@ export class HaBackupDataPicker extends LitElement {
.checked=${selectedItems.homeassistant.includes(
item.id
)}
.section=${"homeassistant"}
@change=${this._itemChanged}
@change=${this._homeassistantChanged}
></ha-checkbox>
</ha-formfield>
`
@@ -279,7 +281,7 @@ export class HaBackupDataPicker extends LitElement {
<ha-backup-formfield-label
slot="label"
.label=${this.hass.localize(
"ui.panel.config.backup.data_picker.local_addons"
"ui.panel.config.backup.data_picker.addons"
)}
.iconPath=${mdiPuzzle}
>

View File

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

View File

@@ -9,9 +9,9 @@ import "../../../../../components/ha-icon-next";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../../../data/backup";
import type { BackupAgent, BackupConfig } from "../../../../../data/backup";
import {
BackupScheduleState,
BackupScheduleRecurrence,
computeBackupAgentName,
getFormattedBackupTime,
isLocalAgent,
@@ -25,30 +25,95 @@ class HaBackupBackupsSummary extends LitElement {
@property({ attribute: false }) public config!: BackupConfig;
@property({ attribute: false }) public agents!: BackupAgent[];
private _configure() {
navigate("/config/backup/settings");
}
private _scheduleDescription(config: BackupConfig): string {
const { copies, days } = config.retention;
const { state: schedule } = config.schedule;
const { recurrence } = config.schedule;
if (schedule === BackupScheduleState.NEVER) {
if (recurrence === BackupScheduleRecurrence.NEVER) {
return this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_never"
);
}
const time = getFormattedBackupTime(this.hass.locale, this.hass.config);
const time: string | undefined | null =
this.config.schedule.time &&
getFormattedBackupTime(
this.hass.locale,
this.hass.config,
this.config.schedule.time
);
const scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${schedule}`,
{ time }
let scheduleText = this.hass.localize(
"ui.panel.config.backup.overview.settings.schedule_never"
);
const configDays = this.config.schedule.days;
if (
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY ||
(this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
configDays.length === 7)
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}daily`,
{
time,
}
);
} else if (
this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
configDays.length !== 0
) {
if (
configDays.length === 2 &&
configDays.includes("sat") &&
configDays.includes("sun")
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekend`,
{
time,
}
);
} else if (
configDays.length === 5 &&
!configDays.includes("sat") &&
!configDays.includes("sun")
) {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}weekdays`,
{
time,
}
);
} else {
scheduleText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_${!this.config.schedule.time ? "optimized_" : ""}days`,
{
count: configDays.length,
days: configDays
.map((dayCode) =>
this.hass.localize(
`ui.panel.config.backup.overview.settings.${configDays.length > 2 ? "short_weekdays" : "weekdays"}.${dayCode}`
)
)
.join(", "),
time,
}
);
}
}
let copiesText = this.hass.localize(
`ui.panel.config.backup.overview.settings.schedule_copies_all`,
{ time }
`ui.panel.config.backup.overview.settings.schedule_copies_all`
);
if (copies) {
copiesText = this.hass.localize(
@@ -97,7 +162,7 @@ class HaBackupBackupsSummary extends LitElement {
const name = computeBackupAgentName(
this.hass.localize,
offsiteLocations[0],
offsiteLocations
this.agents
);
return this.hass.localize(
"ui.panel.config.backup.overview.settings.locations_one",

View File

@@ -1,7 +1,7 @@
import { mdiBackupRestore, mdiCalendar } from "@mdi/js";
import { addHours, differenceInDays } from "date-fns";
import { mdiBackupRestore, mdiCalendar, mdiInformation } from "@mdi/js";
import { addHours, differenceInDays, isToday, isTomorrow } from "date-fns";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../../../common/datetime/relative_time";
@@ -10,14 +10,20 @@ import "../../../../../components/ha-card";
import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-icon-button";
import type { BackupConfig, BackupContent } from "../../../../../data/backup";
import {
BackupScheduleState,
BackupScheduleRecurrence,
getFormattedBackupTime,
} from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-backup-summary-card";
import {
formatDate,
formatDateWeekday,
} from "../../../../../common/datetime/format_date";
import { showAlertDialog } from "../../../../lovelace/custom-card-helpers";
const OVERDUE_MARGIN_HOURS = 3;
@@ -76,16 +82,6 @@ class HaBackupOverviewBackups extends LitElement {
const lastBackup = this._lastBackup(this.backups);
const backupTime = getFormattedBackupTime(
this.hass.locale,
this.hass.config
);
const nextBackupDescription = this.hass.localize(
`ui.panel.config.backup.overview.summary.next_backup_description.${this.config.schedule.state}`,
{ time: backupTime }
);
const lastAttemptDate = this.config.last_attempted_automatic_backup
? new Date(this.config.last_attempted_automatic_backup)
: new Date(0);
@@ -94,6 +90,49 @@ class HaBackupOverviewBackups extends LitElement {
? new Date(this.config.last_completed_automatic_backup)
: new Date(0);
const nextAutomaticDate = this.config.next_automatic_backup
? new Date(this.config.next_automatic_backup)
: undefined;
const backupTime = getFormattedBackupTime(
this.hass.locale,
this.hass.config,
nextAutomaticDate || this.config.schedule.time
);
const showAdditionalBackupDescription =
this.config.next_automatic_backup_additional;
const nextBackupDescription =
this.config.schedule.recurrence === BackupScheduleRecurrence.NEVER ||
(this.config.schedule.recurrence ===
BackupScheduleRecurrence.CUSTOM_DAYS &&
this.config.schedule.days.length === 0)
? this.hass.localize(
`ui.panel.config.backup.overview.summary.no_automatic_backup`
)
: nextAutomaticDate
? this.hass.localize(
`ui.panel.config.backup.overview.summary.next_automatic_backup`,
{
day: isTomorrow(nextAutomaticDate)
? this.hass.localize(
"ui.panel.config.backup.overview.summary.tomorrow"
)
: isToday(nextAutomaticDate)
? this.hass.localize(
"ui.panel.config.backup.overview.summary.today"
)
: formatDateWeekday(
nextAutomaticDate,
this.hass.locale,
this.hass.config
),
time: backupTime,
}
)
: "";
// If last attempt is after last completed backup, show error
if (lastAttemptDate > lastCompletedDate) {
const lastUploadedBackup = this._lastUploadedBackup(this.backups);
@@ -122,25 +161,33 @@ class HaBackupOverviewBackups extends LitElement {
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">
${lastUploadedBackup
? this.hass.localize(
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
{
relative_time: relativeTime(
new Date(lastUploadedBackup.date),
this.hass.locale,
now,
true
),
count: lastUploadedBackup.agent_ids?.length ?? 0,
}
)
: nextBackupDescription}
</span>
</ha-md-list-item>
${lastUploadedBackup || nextBackupDescription
? html`
<ha-md-list-item>
<ha-svg-icon
slot="start"
.path=${mdiCalendar}
></ha-svg-icon>
<span slot="headline">
${lastUploadedBackup
? this.hass.localize(
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
{
relative_time: relativeTime(
new Date(lastUploadedBackup.date),
this.hass.locale,
now,
true
),
count: Object.keys(lastUploadedBackup.agents)
.length,
}
)
: nextBackupDescription}
</span>
</ha-md-list-item>
`
: nothing}
</ha-md-list>
</ha-backup-summary-card>
`;
@@ -164,10 +211,11 @@ class HaBackupOverviewBackups extends LitElement {
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
${this._renderNextBackupDescription(
nextBackupDescription,
lastCompletedDate,
showAdditionalBackupDescription
)}
</ha-md-list>
</ha-backup-summary-card>
`;
@@ -203,25 +251,29 @@ class HaBackupOverviewBackups extends LitElement {
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">
${lastUploadedBackup
? this.hass.localize(
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
{
relative_time: relativeTime(
new Date(lastUploadedBackup.date),
this.hass.locale,
now,
true
),
count: lastUploadedBackup.agent_ids?.length ?? 0,
}
)
: nextBackupDescription}
</span>
</ha-md-list-item>
${lastUploadedBackup || nextBackupDescription
? html` <ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">
${lastUploadedBackup
? this.hass.localize(
"ui.panel.config.backup.overview.summary.last_successful_backup_description",
{
relative_time: relativeTime(
new Date(lastUploadedBackup.date),
this.hass.locale,
now,
true
),
count: Object.keys(lastUploadedBackup.agents)
.length,
}
)
: nextBackupDescription}
</span>
</ha-md-list-item>`
: nothing}
</ha-md-list>
</ha-backup-summary-card>
`;
@@ -236,7 +288,7 @@ class HaBackupOverviewBackups extends LitElement {
now,
true
),
count: lastBackup.agent_ids?.length ?? 0,
count: Object.keys(lastBackup.agents).length,
}
);
@@ -248,53 +300,71 @@ class HaBackupOverviewBackups extends LitElement {
const isOverdue =
(numberOfDays >= 1 &&
this.config.schedule.state === BackupScheduleState.DAILY) ||
this.config.schedule.recurrence === BackupScheduleRecurrence.DAILY) ||
numberOfDays >= 7;
if (isOverdue) {
return html`
<ha-backup-summary-card
.heading=${this.hass.localize(
"ui.panel.config.backup.overview.summary.backup_too_old_heading",
{ count: numberOfDays }
)}
status="warning"
>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">${lastSuccessfulBackupDescription}</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
</ha-md-list>
</ha-backup-summary-card>
`;
}
return html`
<ha-backup-summary-card
.heading=${this.hass.localize(
"ui.panel.config.backup.overview.summary.backup_success_heading"
`ui.panel.config.backup.overview.summary.${isOverdue ? "backup_too_old_heading" : "backup_success_heading"}`,
{ count: numberOfDays }
)}
status="success"
.status=${isOverdue ? "warning" : "success"}
>
<ha-md-list>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">${lastSuccessfulBackupDescription}</span>
</ha-md-list-item>
<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
</ha-md-list-item>
${this._renderNextBackupDescription(
nextBackupDescription,
lastCompletedDate,
showAdditionalBackupDescription
)}
</ha-md-list>
</ha-backup-summary-card>
`;
}
private _renderNextBackupDescription(
nextBackupDescription: string,
lastCompletedDate: Date,
showTip = false
) {
// handle edge case that there is an additional backup scheduled
const openAdditionalBackupDescriptionDialog = showTip
? () => {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.backup.overview.summary.additional_backup_description",
{
date: formatDate(
lastCompletedDate,
this.hass.locale,
this.hass.config
),
}
),
});
}
: undefined;
return nextBackupDescription
? html`<ha-md-list-item>
<ha-svg-icon slot="start" .path=${mdiCalendar}></ha-svg-icon>
<span slot="headline">${nextBackupDescription}</span>
${showTip
? html` <ha-icon-button
slot="end"
@click=${openAdditionalBackupDescriptionDialog}
.path=${mdiInformation}
></ha-icon-button>`
: nothing}
</ha-md-list-item>`
: nothing;
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -21,7 +21,7 @@ import type {
BackupMutableConfig,
} from "../../../../data/backup";
import {
BackupScheduleState,
BackupScheduleRecurrence,
CLOUD_AGENT,
CORE_LOCAL_AGENT,
downloadEmergencyKit,
@@ -68,10 +68,15 @@ const RECOMMENDED_CONFIG: BackupConfig = {
days: null,
},
schedule: {
state: BackupScheduleState.DAILY,
recurrence: BackupScheduleRecurrence.DAILY,
time: null,
days: [],
},
agents: {},
last_attempted_automatic_backup: null,
last_completed_automatic_backup: null,
next_automatic_backup: null,
next_automatic_backup_additional: false,
};
@customElement("ha-dialog-backup-onboarding")
@@ -145,7 +150,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
include_database: this._config.create_backup.include_database,
agent_ids: this._config.create_backup.agent_ids,
},
schedule: this._config.schedule.state,
schedule: this._config.schedule,
retention: this._config.retention,
};

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

@@ -18,6 +18,7 @@ import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-textfield";
import type {
BackupAgent,
BackupConfig,
GenerateBackupParams,
} from "../../../../data/backup";
@@ -64,7 +65,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
@state() private _step?: "data" | "sync";
@state() private _agentIds: string[] = [];
@state() private _agents: BackupAgent[] = [];
@state() private _backupConfig?: BackupConfig;
@@ -89,7 +90,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
}
this._step = undefined;
this._formData = undefined;
this._agentIds = [];
this._agents = [];
this._backupConfig = undefined;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -97,15 +98,14 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agentIds = agents
.map((agent) => agent.agent_id)
this._agents = agents
.filter(
(id) =>
id !== CLOUD_AGENT ||
(agent) =>
agent.agent_id !== CLOUD_AGENT ||
(this._params?.cloudStatus?.logged_in &&
this._params?.cloudStatus?.active_subscription)
)
.sort(compareAgents);
.sort((a, b) => compareAgents(a.agent_id, b.agent_id));
}
private async _fetchBackupConfig() {
@@ -134,6 +134,10 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
this._step = STEPS[index + 1];
}
private get _allAgentIds() {
return this._agents.map((agent) => agent.agent_id);
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
@@ -144,7 +148,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
// Remove disallowed agents from the list
const agentsIds =
this._formData.agents_mode === "all"
? this._agentIds
? this._allAgentIds
: this._formData.agent_ids;
const filteredAgents = agentsIds.filter(
@@ -309,7 +313,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
<div slot="headline">
${this.hass.localize(
"ui.panel.config.backup.dialogs.generate.sync.locations_options.all",
{ count: this._agentIds.length }
{ count: this._allAgentIds.length }
)}
</div>
</ha-md-select-option>
@@ -350,7 +354,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
.hass=${this.hass}
.value=${this._formData.agent_ids}
@value-changed=${this._agentsChanged}
.agentIds=${this._agentIds}
.agents=${this._agents}
.disabledAgentIds=${disabledAgentIds}
></ha-backup-agents-picker>
</ha-expansion-panel>
@@ -385,7 +389,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
if (!this._formData) {
return [];
}
const allAgents = this._agentIds;
const allAgents = this._allAgentIds;
return !this._formData.data.include_homeassistant
? DISALLOWED_AGENTS_NO_HA.filter((agentId) => allAgents.includes(agentId))
: [];
@@ -403,7 +407,7 @@ class DialogGenerateBackup extends LitElement implements HassDialog {
const params: GenerateBackupParams = {
name,
password,
agent_ids: agents_mode === "all" ? this._agentIds : agent_ids,
agent_ids: agents_mode === "all" ? this._allAgentIds : agent_ids,
// We always include homeassistant if we include database
include_homeassistant:
data.include_homeassistant || data.include_database,

View File

@@ -1,33 +1,35 @@
import { mdiClose } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-circular-progress";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-password-field";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import "../../../../components/ha-alert";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-svg-icon";
import type { RestoreBackupParams } from "../../../../data/backup";
import {
fetchBackupConfig,
getPreferredAgentForDownload,
restoreBackup,
} from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
import type {
RestoreBackupStage,
RestoreBackupState,
} from "../../../../data/backup_manager";
import { subscribeBackupEvents } from "../../../../data/backup_manager";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { RestoreBackupDialogParams } from "./show-dialog-restore-backup";
interface FormData {
encryption_key_type: "config" | "custom";
@@ -76,7 +78,12 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
this._error = undefined;
this._state = undefined;
this._stage = undefined;
if (this._params.backup.protected) {
const agentIds = Object.keys(this._params.backup.agents);
const preferedAgent = getPreferredAgentForDownload(agentIds);
const isProtected = this._params.backup.agents[preferedAgent]?.protected;
if (isProtected) {
this._backupEncryptionKey = await this._fetchEncryptionKey();
if (!this._backupEncryptionKey) {
this._step = STEPS[1];
@@ -222,7 +229,7 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
<ha-button @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._restoreBackup} class="destructive">
<ha-button @click=${this._restoreBackup} destructive>
${this.hass.localize(
"ui.panel.config.backup.dialogs.restore.actions.restore"
)}
@@ -320,22 +327,26 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
return;
}
const preferedAgent = getPreferredAgentForDownload(
this._params.backup.agent_ids!
);
const agentIds = Object.keys(this._params.backup.agents);
const preferedAgent = getPreferredAgentForDownload(agentIds);
const { addons, database_included, homeassistant_included, folders } =
this._params.selectedData;
await restoreBackup(this.hass, {
const restoreParams: RestoreBackupParams = {
backup_id: this._params.backup.backup_id,
agent_id: preferedAgent,
password,
restore_addons: addons.map((addon) => addon.slug),
restore_database: database_included,
restore_folders: folders,
restore_homeassistant: homeassistant_included,
});
};
if (isComponentLoaded(this.hass, "hassio")) {
restoreParams.restore_addons = addons.map((addon) => addon.slug);
restoreParams.restore_folders = folders;
}
await restoreBackup(this.hass, restoreParams);
}
static get styles(): CSSResultGroup {
@@ -350,9 +361,6 @@ class DialogRestoreBackup extends LitElement implements HassDialog {
.content p {
margin: 0 0 16px;
}
.destructive {
--mdc-theme-primary: var(--error-color);
}
.centered {
display: flex;
flex-direction: column;

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 { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
@@ -33,15 +34,20 @@ import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import type {
BackupAgent,
BackupConfig,
BackupContent,
} from "../../../data/backup";
import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup,
generateBackup,
generateBackupWithAutomaticSettings,
getBackupDownloadUrl,
getPreferredAgentForDownload,
getBackupTypes,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -60,19 +66,17 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { bytesToString } from "../../../util/bytes-to-string";
import { fileDownload } from "../../../util/file_download";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
import { showUploadBackupDialog } from "./dialogs/show-dialog-upload-backup";
import { downloadBackup } from "./helper/download_backup";
interface BackupRow extends DataTableRowData, BackupContent {
formatted_type: string;
size: number;
agent_ids: string[];
}
type BackupType = "automatic" | "manual";
const TYPE_ORDER: BackupType[] = ["automatic", "manual"];
@customElement("ha-config-backup-backups")
class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -89,6 +93,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@state() private _selected: string[] = [];
@storage({
@@ -134,7 +140,10 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
};
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<BackupRow> => ({
(
localize: LocalizeFunc,
maxDisplayedAgents: number
): DataTableColumnContainer<BackupRow> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
@@ -165,54 +174,75 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
locations: {
title: localize("ui.panel.config.backup.locations"),
showNarrow: true,
minWidth: "60px",
template: (backup) => html`
<div style="display: flex; gap: 4px;">
${(backup.agent_ids || []).map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
backup.agent_ids
);
if (isLocalAgent(agentId)) {
// 24 icon size, 4 gap, 16 left and right padding
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;">
${displayedAgentIds.map((agentId) => {
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
);
if (isLocalAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiHarddisk}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiNas}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
return html`
<ha-svg-icon
.path=${mdiHarddisk}
<img
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;"
></ha-svg-icon>
/>
`;
}
if (isNetworkMountAgent(agentId)) {
return html`
<ha-svg-icon
.path=${mdiNas}
title=${name}
style="flex-shrink: 0;"
></ha-svg-icon>
`;
}
const domain = computeDomain(agentId);
return html`
<img
title=${name}
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
height="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${name}
slot="graphic"
style="flex-shrink: 0;"
/>
`;
})}
</div>
`,
})}
${agentsMore
? html`
<span
style="display: flex; align-items: center; font-size: 14px;"
>
+${agentsMore}
</span>
`
: nothing}
</div>
`;
},
},
actions: {
title: "",
@@ -246,9 +276,13 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
);
private _groupOrder = memoizeOne(
(activeGrouping: string | undefined, localize: LocalizeFunc) =>
(
activeGrouping: string | undefined,
localize: LocalizeFunc,
isHassio: boolean
) =>
activeGrouping === "formatted_type"
? TYPE_ORDER.map((type) =>
? getBackupTypes(isHassio).map((type) =>
localize(`ui.panel.config.backup.type.${type}`)
)
: undefined
@@ -272,31 +306,48 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
(
backups: BackupContent[],
filters: DataTableFiltersValues,
localize: LocalizeFunc
localize: LocalizeFunc,
isHassio: boolean
): BackupRow[] => {
const typeFilter = filters["ha-filter-states"] as string[] | undefined;
let filteredBackups = backups;
if (typeFilter?.length) {
filteredBackups = filteredBackups.filter(
(backup) =>
(backup.with_automatic_settings &&
typeFilter.includes("automatic")) ||
(!backup.with_automatic_settings && typeFilter.includes("manual"))
);
filteredBackups = filteredBackups.filter((backup) => {
const type = computeBackupType(backup, isHassio);
return typeFilter.includes(type);
});
}
return filteredBackups.map((backup) => {
const type = backup.with_automatic_settings ? "automatic" : "manual";
const type = computeBackupType(backup, isHassio);
const agentIds = Object.keys(backup.agents);
return {
...backup,
size: computeBackupSize(backup),
agent_ids: agentIds.sort(compareAgents),
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 {
const backupInProgress =
"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`
<hass-tabs-subpage-data-table
@@ -327,15 +378,16 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.initialCollapsedGroups=${this._activeCollapsed}
.groupOrder=${this._groupOrder(
this._activeGrouping,
this.hass.localize
this.hass.localize,
isHassio
)}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged}
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)}
.data=${this._data(this.backups, this._filters, this.hass.localize)}
.columns=${this._columns(this.hass.localize, maxDisplayedAgents)}
.data=${data}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
@@ -391,7 +443,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.label=${this.hass.localize("ui.panel.config.backup.backup_type")}
.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}
slot="filter-pane"
expanded
@@ -416,8 +468,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
`;
}
private _states = memoizeOne((localize: LocalizeFunc) =>
TYPE_ORDER.map((type) => ({
private _states = memoizeOne((localize: LocalizeFunc, isHassio: boolean) =>
getBackupTypes(isHassio).map((type) => ({
value: type,
label: localize(`ui.panel.config.backup.type.${type}`),
}))
@@ -487,12 +539,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(backup.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
downloadBackup(this.hass, this, backup, this.config);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@@ -20,15 +20,20 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { getSignedPath } from "../../../data/auth";
import type { BackupContentExtended, BackupData } from "../../../data/backup";
import type {
BackupAgent,
BackupConfig,
BackupContentAgent,
BackupContentExtended,
BackupData,
} from "../../../data/backup";
import {
compareAgents,
computeBackupAgentName,
computeBackupSize,
computeBackupType,
deleteBackup,
fetchBackupDetails,
getBackupDownloadUrl,
getPreferredAgentForDownload,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -37,27 +42,38 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { bytesToString } from "../../../util/bytes-to-string";
import { fileDownload } from "../../../util/file_download";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import "./components/ha-backup-data-picker";
import { showRestoreBackupDialog } from "./dialogs/show-dialog-restore-backup";
import { fireEvent } from "../../../common/dom/fire_event";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { downloadBackup } from "./helper/download_backup";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
interface Agent {
interface Agent extends BackupContentAgent {
id: string;
success: boolean;
}
const computeAgents = (agent_ids: string[], failed_agent_ids: string[]) =>
[
...agent_ids.filter((id) => !failed_agent_ids.includes(id)),
...failed_agent_ids,
const computeAgents = (backup: BackupContentExtended) => {
const agentIds = Object.keys(backup.agents);
const failedAgentIds = backup.failed_agent_ids ?? [];
return [
...agentIds.filter((id) => !failedAgentIds.includes(id)),
...failedAgentIds,
]
.map<Agent>((id) => ({
id,
success: !failed_agent_ids.includes(id),
}))
.map<Agent>((id) => {
const agent: BackupContentAgent = backup.agents[id] ?? {
protected: false,
size: 0,
};
return {
...agent,
id: id,
success: !failedAgentIds.includes(id),
};
})
.sort((a, b) => compareAgents(a.id, b.id));
};
@customElement("ha-config-backup-details")
class HaConfigBackupDetails extends LitElement {
@@ -67,6 +83,10 @@ class HaConfigBackupDetails extends LitElement {
@property({ attribute: "backup-id" }) public backupId!: string;
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@state() private _backup?: BackupContentExtended | null;
@state() private _agents: Agent[] = [];
@@ -92,6 +112,8 @@ class HaConfigBackupDetails extends LitElement {
return nothing;
}
const isHassio = isComponentLoaded(this.hass, "hassio");
return html`
<hass-subpage
back-path="/config/backup/backups"
@@ -143,6 +165,18 @@ class HaConfigBackupDetails extends LitElement {
</div>
<div class="card-content">
<ha-md-list class="summary">
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.backup_type"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.type.${computeBackupType(this._backup, isHassio)}`
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
@@ -150,7 +184,7 @@ class HaConfigBackupDetails extends LitElement {
)}
</span>
<span slot="supporting-text">
${bytesToString(this._backup.size)}
${bytesToString(computeBackupSize(this._backup))}
</span>
</ha-md-list-item>
<ha-md-list-item>
@@ -167,22 +201,6 @@ class HaConfigBackupDetails extends LitElement {
)}
</span>
</ha-md-list-item>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.details.summary.protection"
)}
</span>
<span slot="supporting-text">
${this._backup.protected
? this.hass.localize(
"ui.panel.config.backup.details.summary.protected_encrypted_aes_128"
)
: this.hass.localize(
"ui.panel.config.backup.details.summary.protected_not_encrypted"
)}
</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
@@ -224,87 +242,112 @@ class HaConfigBackupDetails extends LitElement {
<ha-md-list>
${this._agents.map((agent) => {
const agentId = agent.id;
const success = agent.success;
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this._backup!.agent_ids
this.agents
);
const success = agent.success;
const failed = !agent.success;
const unencrypted = !agent.protected;
return html`
<ha-md-list-item>
${isLocalAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiHarddisk}
slot="start"
>
</ha-svg-icon>
`
: isNetworkMountAgent(agentId)
${
isLocalAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
.path=${mdiHarddisk}
slot="start"
></ha-svg-icon>
>
</ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized:
this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
`}
: isNetworkMountAgent(agentId)
? html`
<ha-svg-icon
.path=${mdiNas}
slot="start"
></ha-svg-icon>
`
: html`
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized:
this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${`${domain} logo`}
slot="start"
/>
`
}
<div slot="headline">${name}</div>
<div slot="supporting-text">
<span
class="dot ${success ? "success" : "error"}"
>
</span>
<span>
${success
? this.hass.localize(
"ui.panel.config.backup.details.locations.backup_stored"
)
: this.hass.localize(
"ui.panel.config.backup.details.locations.backup_failed"
)}
</span>
<div slot="supporting-text">
${
failed
? html`
<span class="dot error"></span>
<span>
${this.hass.localize(
"ui.panel.config.backup.details.locations.backup_failed"
)}
</span>
`
: unencrypted
? html`
<span class="dot warning"></span>
<span>
${this.hass.localize(
"ui.panel.config.backup.details.locations.unencrypted"
)}</span
>
`
: html`
<span class="dot success"></span>
<span
>${this.hass.localize(
"ui.panel.config.backup.details.locations.encrypted"
)}</span
>
`
}
</div>
</div>
${success
? html`<ha-button-menu
slot="end"
@action=${this._handleAgentAction}
.agent=${agentId}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
"ui.common.menu"
)}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiDownload}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.details.locations.download"
)}
</ha-list-item>
</ha-button-menu>`
: nothing}
${
success
? html`
<ha-button-menu
slot="end"
@action=${this._handleAgentAction}
.agent=${agentId}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
"ui.common.menu"
)}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
<ha-svg-icon
slot="graphic"
.path=${mdiDownload}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.details.locations.download"
)}
</ha-list-item>
</ha-button-menu>
`
: nothing
}
</ha-md-list-item>
`;
})}
@@ -348,10 +391,7 @@ class HaConfigBackupDetails extends LitElement {
try {
const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup;
this._agents = computeAgents(
response.backup.agent_ids || [],
response.backup.failed_agent_ids || []
);
this._agents = computeAgents(response.backup);
} catch (err: any) {
this._error =
err?.message ||
@@ -377,13 +417,7 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise<void> {
const preferedAgent =
agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(this._backup!.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
await downloadBackup(this.hass, this, this._backup!, this.config, agentId);
}
private async _deleteBackup(): Promise<void> {
@@ -473,6 +507,9 @@ class HaConfigBackupDetails extends LitElement {
.dot.success {
background-color: var(--success-color);
}
.dot.warning {
background-color: var(--warning-color);
}
.dot.error {
background-color: var(--error-color);
}

View File

@@ -0,0 +1,368 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-switch";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type {
BackupAgent,
BackupAgentConfig,
BackupConfig,
} from "../../../data/backup";
import {
CLOUD_AGENT,
computeBackupAgentName,
fetchBackupAgentsInfo,
updateBackupConfig,
} from "../../../data/backup";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./components/ha-backup-data-picker";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import { fireEvent } from "../../../common/dom/fire_event";
@customElement("ha-config-backup-location")
class HaConfigBackupDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "agent-id" }) public agentId!: string;
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@state() private _agent?: BackupAgent | null;
@state() private _error?: string;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has("agentId")) {
if (this.agentId) {
this._fetchAgent();
} else {
this._error = "Agent id not defined";
}
}
}
protected render() {
if (!this.hass) {
return nothing;
}
const encrypted = this._isEncryptionTurnedOn();
return html`
<hass-subpage
back-path="/config/backup/settings"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${(this._agent &&
computeBackupAgentName(
this.hass.localize,
this.agentId,
this.agents
)) ||
this.hass.localize("ui.panel.config.backup.location.header")}
>
<div class="content">
${this._error &&
html`<ha-alert alert-type="error">${this._error}</ha-alert>`}
${this._agent === null
? html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.backup.location.not_found"
)}
>
${this.hass.localize(
"ui.panel.config.backup.location.not_found_description",
{ agentId: this.agentId }
)}
</ha-alert>
`
: !this.agentId
? html`<ha-circular-progress active></ha-circular-progress>`
: html`
${CLOUD_AGENT === this.agentId
? html`
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.location.configuration.title"
)}
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.location.configuration.cloud_description"
)}
</p>
</div>
</ha-card>
`
: nothing}
<ha-card>
<div class="card-header">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.title"
)}
</div>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.description"
)}
</p>
<ha-md-list>
${CLOUD_AGENT === this.agentId
? html`
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_description"
)}
</span>
<a
href="https://www.nabucasa.com/config/backups/"
target="_blank"
slot="end"
rel="noreferrer noopener"
>
<ha-button>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_learn_more"
)}
</ha-button>
</a>
</ha-md-list-item>
`
: encrypted
? html`
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_encrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.location.encryption.location_encrypted_description`
)}
</span>
<ha-button
slot="end"
@click=${this._turnOffEncryption}
destructive
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_off"
)}
</ha-button>
</ha-md-list-item>
`
: html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off"
)}
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
)}
</ha-alert>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.location.encryption.location_unencrypted"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
`ui.panel.config.backup.location.encryption.location_unencrypted_description`
)}
</span>
<ha-button
slot="end"
@click=${this._turnOnEncryption}
>
${this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_on"
)}
</ha-button>
</ha-md-list-item>
`}
</ha-md-list>
</div>
</ha-card>
`}
</div>
</hass-subpage>
`;
}
private _isEncryptionTurnedOn() {
const agentConfig = this.config?.agents[this.agentId] as
| BackupAgentConfig
| undefined;
if (!agentConfig) {
return true;
}
return agentConfig.protected;
}
private async _fetchAgent() {
try {
const { agents } = await fetchBackupAgentsInfo(this.hass);
const agent = agents.find((a) => a.agent_id === this.agentId);
if (!agent) {
throw new Error("Agent not found");
}
this._agent = agent;
} catch (err: any) {
this._error =
err?.message ||
this.hass.localize("ui.panel.config.backup.details.error");
}
}
private async _updateAgentEncryption(value: boolean) {
const agentsConfig = {
...this.config?.agents,
[this.agentId]: {
...this.config?.agents[this.agentId],
protected: value,
},
};
await updateBackupConfig(this.hass, {
agents: agentsConfig,
});
fireEvent(this, "ha-refresh-backup-config");
}
private _turnOnEncryption() {
this._updateAgentEncryption(true);
}
private async _turnOffEncryption() {
const response = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_title"
),
text: this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_text"
),
confirmText: this.hass.localize(
"ui.panel.config.backup.location.encryption.encryption_turn_off_confirm_action"
),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (response) {
this._updateAgentEncryption(false);
}
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: grid;
margin-bottom: 24px;
}
.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 img {
width: 48px;
}
ha-md-list-item ha-svg-icon[slot="start"] {
--mdc-icon-size: 48px;
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 {
color: var(--error-color);
}
.warning ha-svg-icon {
color: var(--error-color);
}
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}
ha-backup-data-picker {
display: block;
}
ha-md-list-item [slot="supporting-text"] {
display: flex;
align-items: center;
flex-direction: row;
gap: 8px;
line-height: normal;
}
.dot {
display: block;
position: relative;
width: 8px;
height: 8px;
background-color: var(--disabled-color);
border-radius: 50%;
flex: none;
}
.dot.success {
background-color: var(--success-color);
}
.dot.error {
background-color: var(--error-color);
}
.card-header {
padding-bottom: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-location": HaConfigBackupDetails;
}
}

View File

@@ -13,11 +13,14 @@ import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import type {
BackupAgent,
BackupConfig,
BackupContent,
} from "../../../data/backup";
import {
generateBackup,
generateBackupWithAutomaticSettings,
type BackupConfig,
type BackupContent,
} from "../../../data/backup";
import type { ManagerStateEvent } from "../../../data/backup_manager";
import type { CloudStatus } from "../../../data/cloud";
@@ -53,6 +56,8 @@ class HaConfigBackupOverview extends LitElement {
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
private async _uploadBackup(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
@@ -184,6 +189,7 @@ class HaConfigBackupOverview extends LitElement {
<ha-backup-overview-settings
.hass=${this.hass}
.config=${this.config!}
.agents=${this.agents}
></ha-backup-overview-settings>
`
: nothing}
@@ -215,8 +221,7 @@ class HaConfigBackupOverview extends LitElement {
gap: 24px;
display: flex;
flex-direction: column;
margin-bottom: 24px;
margin-bottom: 72px;
margin-bottom: calc(env(safe-area-inset-bottom) + 72px);
}
.card-actions {
display: flex;

View File

@@ -16,7 +16,7 @@ import "../../../components/ha-list-item";
import "../../../components/ha-alert";
import "../../../components/ha-password-field";
import "../../../components/ha-svg-icon";
import type { BackupConfig } from "../../../data/backup";
import type { BackupAgent, BackupConfig } from "../../../data/backup";
import { updateBackupConfig } from "../../../data/backup";
import type { CloudStatus } from "../../../data/cloud";
import "../../../layouts/hass-subpage";
@@ -39,6 +39,8 @@ class HaConfigBackupSettings extends LitElement {
@property({ attribute: false }) public config?: BackupConfig;
@property({ attribute: false }) public agents: BackupAgent[] = [];
@state() private _config?: BackupConfig;
protected willUpdate(changedProperties: PropertyValues): void {
@@ -48,9 +50,11 @@ class HaConfigBackupSettings extends LitElement {
}
}
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
public connectedCallback(): void {
super.connectedCallback();
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() {
@@ -177,8 +181,11 @@ class HaConfigBackupSettings extends LitElement {
<ha-backup-config-agents
.hass=${this.hass}
.value=${this._config.create_backup.agent_ids}
.agentsConfig=${this._config.agents}
.cloudStatus=${this.cloudStatus}
.agents=${this.agents}
@value-changed=${this._agentsConfigChanged}
show-settings
></ha-backup-config-agents>
${!this._config.create_backup.agent_ids.length
? html`
@@ -308,7 +315,7 @@ class HaConfigBackupSettings extends LitElement {
password: this._config!.create_backup.password,
},
retention: this._config!.retention,
schedule: this._config!.schedule.state,
schedule: this._config!.schedule,
});
fireEvent(this, "ha-refresh-backup-config");
}

View File

@@ -1,9 +1,14 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { BackupConfig, BackupContent } from "../../../data/backup";
import type {
BackupAgent,
BackupConfig,
BackupContent,
} from "../../../data/backup";
import {
compareAgents,
fetchBackupAgentsInfo,
fetchBackupConfig,
fetchBackupInfo,
} from "../../../data/backup";
@@ -41,6 +46,8 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@state() private _backups: BackupContent[] = [];
@state() private _agents: BackupAgent[] = [];
@state() private _fetching = false;
@state() private _config?: BackupConfig;
@@ -54,15 +61,20 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
this.addEventListener("ha-refresh-backup-config", () => {
this._fetchBackupConfig();
});
this.addEventListener("ha-refresh-backup-agents", () => {
this._fetchBackupAgents();
});
}
private _fetchAll() {
this._fetching = true;
Promise.all([this._fetchBackupInfo(), this._fetchBackupConfig()]).finally(
() => {
this._fetching = false;
}
);
Promise.all([
this._fetchBackupInfo(),
this._fetchBackupConfig(),
this._fetchBackupAgents(),
]).finally(() => {
this._fetching = false;
});
}
public connectedCallback() {
@@ -70,16 +82,13 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
if (this.hasUpdated) {
this._fetchBackupInfo();
this._fetchBackupConfig();
this._fetchBackupAgents();
}
}
private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass);
this._backups = info.backups.map((backup) => ({
...backup,
agent_ids: backup.agent_ids?.sort(compareAgents),
failed_agent_ids: backup.failed_agent_ids?.sort(compareAgents),
}));
this._backups = info.backups;
}
private async _fetchBackupConfig() {
@@ -87,6 +96,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
this._config = config;
}
private async _fetchBackupAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agents = agents.sort((a, b) => compareAgents(a.agent_id, b.agent_id));
}
protected routerOptions: RouterOptions = {
defaultPage: "overview",
routes: {
@@ -105,6 +119,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
settings: {
tag: "ha-config-backup-settings",
load: () => import("./ha-config-backup-settings"),
cache: true,
},
location: {
tag: "ha-config-backup-location",
load: () => import("./ha-config-backup-location"),
},
},
};
@@ -117,13 +136,18 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
pageEl.manager = this._manager;
pageEl.backups = this._backups;
pageEl.config = this._config;
pageEl.agents = this._agents;
pageEl.fetching = this._fetching;
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
) {
pageEl.backupId = this.routeTail.path.substr(1);
if (!changedProps || changedProps.has("route")) {
switch (this._currentPage) {
case "details":
pageEl.backupId = this.routeTail.path.substr(1);
break;
case "location":
pageEl.agentId = this.routeTail.path.substr(1);
break;
}
}
}

View File

@@ -0,0 +1,103 @@
import type { LitElement } from "lit";
import { getSignedPath } from "../../../../data/auth";
import type { BackupConfig, BackupContent } from "../../../../data/backup";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import { fileDownload } from "../../../../util/file_download";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
import { showDownloadDecryptedBackupDialog } from "../dialogs/show-dialog-download-decrypted-backup";
export const downloadBackupFile = async (
hass: HomeAssistant,
backupId: string,
preferedAgent: string,
encryptionKey?: string | null
) => {
const signedUrl = await getSignedPath(
hass,
getBackupDownloadUrl(backupId, preferedAgent, encryptionKey)
);
fileDownload(signedUrl.path);
};
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
backupConfig?: BackupConfig,
agentId?: string
): Promise<void> => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
if (!isProtected) {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
return;
}
const encryptionKey = backupConfig?.create_backup?.password;
if (!encryptionKey) {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
try {
// Check if we can decrypt it
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
downloadBackupFile(hass, backup.backup_id, preferedAgent, encryptionKey);
} catch (err: any) {
// If encryption key is incorrect, ask for encryption key
if (err?.code === "password_incorrect") {
showDownloadDecryptedBackupDialog(element, {
backup,
agentId: preferedAgent,
});
return;
}
// If decryption is not supported, ask for confirmation and download it encrypted
if (err?.code === "decrypt_not_supported") {
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported_title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.decryption_unsupported"
),
confirm() {
downloadBackupFile(hass, backup.backup_id, preferedAgent);
},
});
return;
}
// Else, show generic error
showAlertDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_title",
{
error: err.message,
}
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.error_check_description",
{
error: err.message,
}
),
});
}
};

View File

@@ -1,15 +1,15 @@
import "@material/mwc-button";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import { debounce } from "../../../../common/util/debounce";
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-card";
import "../../../../components/ha-list-item";
import "../../../../components/ha-tip";
import type {
CloudStatusLoggedIn,
SubscriptionInfo,
@@ -32,6 +32,7 @@ import "./cloud-ice-servers-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
import { showSupportPackageDialog } from "./show-dialog-cloud-support-package";
@customElement("cloud-account")
export class CloudAccount extends SubscribeMixin(LitElement) {
@@ -52,7 +53,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
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
slot="trigger"
.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-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>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
@@ -286,6 +293,16 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
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() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -316,6 +333,10 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
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-list/mwc-list";
import { mdiDeleteForever, mdiDotsVertical } from "@mdi/js";
import { mdiDeleteForever, mdiDotsVertical, mdiDownload } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -27,6 +27,7 @@ import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@@ -57,7 +58,7 @@ export class CloudLogin extends LitElement {
.narrow=${this.narrow}
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
slot="trigger"
.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-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>
<div class="content">
<ha-config-section .isWide=${this.isWide}>
@@ -348,6 +355,16 @@ export class CloudLogin extends LitElement {
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() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
@@ -377,6 +394,10 @@ export class CloudLogin extends LitElement {
}
}
private async _downloadSupportPackage() {
showSupportPackageDialog(this);
}
static get styles() {
return [
haStyle,

View File

@@ -1073,7 +1073,14 @@ export class HaConfigDevicePage extends LitElement {
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
);
const domains = this._integrations(
device,
this.entries,
this.manifests
).map((int) => int.domain);
if (
!domains.includes("voip") &&
assistSatellite &&
assistSatelliteSupportsSetupFlow(
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")) {
const mqtt = await import(
"./device-detail/integration-elements/mqtt/device-actions"

View File

@@ -1,5 +1,5 @@
import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiDevices, mdiPencil } from "@mdi/js";
import { mdiDelete, mdiDevices, mdiDrag, mdiPencil } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { repeat } from "lit/directives/repeat";
@@ -7,7 +7,6 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-state-icon";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type {
@@ -82,41 +81,37 @@ export class EnergyDeviceSettings extends LitElement {
"ui.panel.config.energy.device_consumption.devices"
)}
</h3>
<ha-sortable handle-selector=".row" @item-moved=${this._itemMoved}>
<ha-sortable handle-selector=".handle" @item-moved=${this._itemMoved}>
<div class="devices">
${repeat(
this.preferences.device_consumption,
(device) => device.stat_consumption,
(device) => {
const entityState = this.hass.states[device.stat_consumption];
return html`
<div class="row" .device=${device}>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entityState}
></ha-state-icon>
<span class="content"
>${device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption]
)}</span
>
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editDevice}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteDevice}
.device=${device}
.path=${mdiDelete}
></ha-icon-button>
(device) => html`
<div class="row" .device=${device}>
<div class="handle">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
`;
}
<span class="content"
>${device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption]
)}</span
>
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editDevice}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button
.label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteDevice}
.device=${device}
.path=${mdiDelete}
></ha-icon-button>
</div>
`
)}
</div>
</ha-sortable>
@@ -214,7 +209,7 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
}

View File

@@ -23,7 +23,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -410,7 +410,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
minWidth: "128px",
template: (entry) =>
entry.created_at
? formatShortDateTime(
? formatShortDateTimeWithConditionalYear(
new Date(entry.created_at * 1000),
this.hass.locale,
this.hass.config
@@ -425,7 +425,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
minWidth: "128px",
template: (entry) =>
entry.modified_at
? formatShortDateTime(
? formatShortDateTimeWithConditionalYear(
new Date(entry.modified_at * 1000),
this.hass.locale,
this.hass.config
@@ -729,7 +729,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
</ha-md-menu-item>`;
})}
<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">
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-md-menu-item
@@ -844,7 +844,7 @@ ${
: nothing
}
<ha-md-menu-item @click=${this._enableSelected}>
<ha-md-menu-item .clickAction=${this._enableSelected}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
@@ -852,7 +852,7 @@ ${
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item @click=${this._disableSelected}>
<ha-md-menu-item .clickAction=${this._disableSelected}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
@@ -865,7 +865,7 @@ ${
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._unhideSelected}>
<ha-md-menu-item .clickAction=${this._unhideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEye}
@@ -876,7 +876,7 @@ ${
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item @click=${this._hideSelected}>
<ha-md-menu-item .clickAction=${this._hideSelected}>
<ha-svg-icon
slot="start"
.path=${mdiEyeOff}
@@ -889,7 +889,7 @@ ${
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item @click=${this._removeSelected} class="warning">
<ha-md-menu-item .clickAction=${this._removeSelected} class="warning">
<ha-svg-icon
slot="start"
.path=${mdiDelete}
@@ -1123,7 +1123,7 @@ ${
this._selected = ev.detail.value;
}
private async _enableSelected() {
private _enableSelected = async () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.entities.picker.enable_selected.confirm_title",
@@ -1191,9 +1191,9 @@ ${
}
},
});
}
};
private _disableSelected() {
private _disableSelected = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.entities.picker.disable_selected.confirm_title",
@@ -1213,9 +1213,9 @@ ${
this._clearSelection();
},
});
}
};
private _hideSelected() {
private _hideSelected = () => {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.entities.picker.hide_selected.confirm_title",
@@ -1235,16 +1235,16 @@ ${
this._clearSelection();
},
});
}
};
private _unhideSelected() {
private _unhideSelected = () => {
this._selected.forEach((entity) =>
updateEntityRegistryEntry(this.hass, entity, {
hidden_by: null,
})
);
this._clearSelection();
}
};
private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value;
@@ -1286,7 +1286,7 @@ ${rejected
}
}
private _bulkCreateLabel() {
private _bulkCreateLabel = () => {
showLabelDetailDialog(this, {
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
@@ -1294,9 +1294,9 @@ ${rejected
return label;
},
});
}
};
private async _removeSelected() {
private _removeSelected = async () => {
if (!this._entities || !this.hass) {
return;
}
@@ -1369,7 +1369,7 @@ ${rejected
this._clearSelection();
},
});
}
};
private _clearSelection() {
this._dataTable.clearSelection();

View File

@@ -548,6 +548,13 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
"./integrations/integration-panels/thread/thread-config-panel"
),
},
bluetooth: {
tag: "bluetooth-config-dashboard-router",
load: () =>
import(
"./integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router"
),
},
application_credentials: {
tag: "ha-config-application-credentials",
load: () =>

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