Compare commits

...

216 Commits

Author SHA1 Message Date
Paul Bottein
4fa1b60cb1 Only use opacity for bar graph 2025-01-31 09:54:16 +01:00
karwosts
653aeae3d8 Improve statistics graph axis when using energy_date_selection (#23974) 2025-01-31 08:45:37 +02:00
ildar170975
0aea6141ad Fix for "Increase generic entity row touch target (2) (#23973)
* Revert "Fix for "Increase generic entity row touch target" (#23953)"

This reverts commit 028472fc7b.

* conditional style
2025-01-31 08:41:38 +02:00
renovate[bot]
5243c1d871 Update typescript-eslint monorepo to v8.22.0 (#23972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-31 08:39:18 +02:00
Simon Lamon
6ac6d9c6eb Backup location translations improvements (#23940)
* Backup location translations improvements

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

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

* Reuse data

* Don't copy twice

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

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

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

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

View File

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

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.22.0",
"@typescript-eslint/parser": "8.22.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 = "20250129.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,62 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { XAXisOption } from "echarts/types/dist/shared";
import type { FrontendLocaleData } from "../../data/translation";
import {
formatDateMonth,
formatDateMonthYear,
formatDateVeryShort,
formatDateWeekdayShort,
} from "../../common/datetime/format_date";
import { formatTime } from "../../common/datetime/format_time";
export function getLabelFormatter(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference = 0
) {
return (value: number | Date) => {
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);
}
// show only date for the beginning of the day
if (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0
) {
return `{bold|${formatDateVeryShort(date, locale, config)}}`;
}
return formatTime(date, locale, config);
};
}
export function getTimeAxisLabelConfig(
locale: FrontendLocaleData,
config: HassConfig,
dayDifference?: number
): XAXisOption["axisLabel"] {
return {
formatter: getLabelFormatter(locale, config, dayDifference),
rich: {
bold: {
fontWeight: "bold",
},
},
hideOverlap: true,
};
}

View File

@@ -1,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,27 @@
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 { differenceInDays } from "date-fns";
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 { getTimeAxisLabelConfig } from "./axis-label";
import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -54,15 +62,17 @@ 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;
@state() private _yWidth = 25;
private _chartTime: Date = new Date();
@@ -72,171 +82,54 @@ 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 })}
></ha-chart-base>
`;
}
private _renderTooltip(params) {
return params
.map((param, index: number) => {
let value = `${formatNumber(
param.value[1] as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._entityIds[param.seriesIndex]]
)
)} ${this.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;
}
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
}
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 +141,128 @@ 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 dayDifference = differenceInDays(this.endTime, this.startTime);
const rtl = computeRTL(this.hass);
this._chartOptions = {
xAxis: {
type: "time",
min: this.startTime,
max: this.endTime,
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
axisLine: {
show: false,
},
splitLine: {
show: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
min: this.fitYData ? this.minYAxis : undefined,
max: this.fitYData ? this.maxYAxis : undefined,
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,
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 +278,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,9 +295,9 @@ 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;
};
@@ -300,13 +308,26 @@ export class StateHistoryChartLine extends LitElement {
colorIndex++;
}
data.push({
label: nameY,
fill: fill ? "origin" : false,
borderColor: color,
backgroundColor: color + "7F",
stepped: "before",
pointRadius: 0,
id: nameY,
data: [],
type: "line",
cursor: "default",
name: nameY,
color,
symbol: "circle",
step: "end",
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 +345,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);
@@ -575,9 +600,7 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._chartData = {
datasets,
};
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
}

View File

@@ -1,19 +1,28 @@
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 { differenceInDays } from "date-fns";
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";
import { getTimeAxisLabelConfig } from "./axis-label";
@customElement("state-history-chart-timeline")
export class StateHistoryChartTimeline extends LitElement {
@@ -44,9 +53,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 +65,97 @@ 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}
@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 } = Array.isArray(params)
? params[0]
: params;
const title = `<h4 style="text-align: center; margin: 0;">${value![0]}</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,82 @@ export class StateHistoryChartTimeline extends LitElement {
private _createOptions() {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 70 : 165;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
const dayDifference = differenceInDays(this.endTime, this.startTime);
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,
},
axisLine: {
show: false,
},
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: "category",
inverse: true,
position: rtl ? "right" : "left",
triggerEvent: true,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth - labelMargin,
overflow: "truncate",
margin: labelMargin,
formatter: (label: string) => {
const width = Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
);
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 +273,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) => {
@@ -258,7 +284,7 @@ export class StateHistoryChartTimeline extends LitElement {
const entityDisplay: string =
names[stateInfo.entity_id] || stateInfo.name;
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 +303,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: [
entityDisplay,
prevLastChanged,
newLastChanged,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
prevState = newState;
@@ -295,28 +329,51 @@ 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: [
entityDisplay,
prevLastChanged,
endTime,
locState,
color,
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
],
itemStyle: {
color,
},
});
}
datasets.push({
data: dataRow,
label: stateInfo.entity_id,
name: entityDisplay,
dimensions: ["index", "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.data[e.detail.dataIndex];
if (dataset) {
fireEvent(this, "hass-more-info", {
entityId: dataset.entity_id,
});
}
}
}
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,6 +153,7 @@ 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> `;
}
@@ -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;
}

View File

@@ -1,21 +1,15 @@
import type {
ChartData,
ChartDataset,
ChartOptions,
ChartType,
} from "chart.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type {
BarSeriesOption,
LineSeriesOption,
} from "echarts/types/dist/shared";
import { styleMap } from "lit/directives/style-map";
import { getGraphColorByIndex } from "../../common/color/colors";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import {
formatNumber,
numberFormatToLocale,
getNumberFormatOptions,
} from "../../common/number/format_number";
import type {
Statistics,
StatisticsMetaData,
@@ -25,13 +19,18 @@ import {
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import type { ChartDatasetExtra } from "./ha-chart-base";
import { clickIsTouch } from "./click_is_touch";
import { computeRTL } from "../../common/util/compute_rtl";
import type { ECOption } from "../../resources/echarts";
import {
formatNumber,
getNumberFormatOptions,
} from "../../common/number/format_number";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { getTimeAxisLabelConfig } from "./axis-label";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
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,13 @@ 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")
) {
this._createOptions();
}
if (
changedProps.has("statisticsData") ||
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
) {
this._generateData();
}
}
public firstUpdated() {
@@ -157,145 +163,115 @@ 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) =>
params
.map((param, index: number) => {
const value = `${formatNumber(
// max series can have 3 values, as the second value is the max-min to form a band
(param.value[2] ?? param.value[1]) as number,
this.hass.locale,
getNumberFormatOptions(
undefined,
this.hass.entities[this._statisticIds[param.seriesIndex]]
)
)} ${this.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 || ""
}`,
},
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}
`;
})
.join("<br>");
private _createOptions() {
const splitLineStyle = this.hass.themes?.darkMode ? { opacity: 0.15 } : {};
const dayDifference = this.daysToShow ?? 1;
this._chartOptions = {
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(
this.hass.locale,
this.hass.config,
dayDifference
),
min: this.startTime,
max: this.endTime,
axisLine: {
show: false,
},
filler: {
propagate: true,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
legend: {
display: !this.hideLegend,
labels: {
usePointStyle: true,
},
minInterval:
dayDifference >= 89 // quarter
? 28 * 3600 * 24 * 1000
: dayDifference > 2
? 3600 * 24 * 1000
: undefined,
},
yAxis: {
type: this.logarithmicScale ? "log" : "value",
name: this.unit,
nameGap: 2,
nameTextStyle: {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left",
// @ts-ignore
scale: this.chartType !== "bar",
min: this.fitYData ? undefined : this.minYAxis,
max: this.fitYData ? undefined : this.maxYAxis,
splitLine: {
show: true,
lineStyle: splitLineStyle,
},
},
elements: {
line: {
tension: 0.4,
cubicInterpolationMode: "monotone",
borderWidth: 1.5,
},
bar: { borderWidth: 1.5, borderRadius: 4 },
point: {
hitRadius: 50,
},
legend: {
show: !this.hideLegend,
icon: "circle",
padding: [20, 0],
data: this._legendData,
},
// @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];
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
}
}
grid: {
...(this.hideLegend ? { top: this.unit ? 30 : 5 } : {}), // undefined is the same as 0
left: 20,
right: 1,
bottom: 0,
containLabel: true,
},
tooltip: {
trigger: "axis",
appendTo: document.body,
formatter: this._renderTooltip,
},
};
}
@@ -325,8 +301,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;
@@ -372,19 +348,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 +375,10 @@ 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([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data.push({ x: start.getTime(), y: dataValues[i]! });
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
@@ -438,49 +413,67 @@ 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 series: LineSeriesOption | BarSeriesOption = {
id: `${statistic_id}-${type}`,
type: this.chartType,
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,
lineStyle: {
width: 1.5,
},
itemStyle:
this.chartType === "bar"
? {
borderRadius: [4, 4, 0, 0],
borderColor:
band && hasMean
? color + (this.hideLegend ? "00" : "7F")
: color,
borderWidth: 1.5,
}
: undefined,
color: band
? color + "3F"
: this.chartType === "bar"
? color + "7F"
: color,
};
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
(series as LineSeriesOption).symbol = "none";
(series as LineSeriesOption).lineStyle = {
opacity: 0,
};
if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
statDataSets.push(series);
statisticIds.push(statistic_id);
}
});
@@ -494,37 +487,55 @@ 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(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: [],
});
});
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;
}

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

@@ -337,6 +337,7 @@ export class HaBaseTimeInput extends LitElement {
}
.time-input-wrap {
display: flex;
flex: 1;
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
@@ -345,6 +346,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

@@ -51,7 +51,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;

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

@@ -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

@@ -11,22 +11,34 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation";
import checkValidDate from "../common/datetime/check_valid_date";
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 +53,11 @@ export interface BackupConfig {
days?: number | null;
};
schedule: {
state: BackupScheduleState;
recurrence: BackupScheduleRecurrence;
time?: string | null;
days: BackupDay[];
};
agents: BackupAgentsConfig;
}
export interface BackupMutableConfig {
@@ -59,20 +74,35 @@ 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[];
with_automatic_settings: boolean;
}
@@ -135,8 +165,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 +263,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 +291,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 +310,15 @@ 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 const compareAgents = (a: string, b: string) => {
const isLocalA = isLocalAgent(a);
const isLocalB = isLocalAgent(b);
@@ -337,9 +391,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

@@ -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

@@ -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,14 +443,18 @@ 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;
}
ha-md-list-item {
--md-list-item-leading-space: 24px;
--md-list-item-trailing-space: 24px;
}
.actions {
width: 100%;
display: flex;

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, 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,22 +32,21 @@ 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 _availableAgents = memoizeOne(
(agents: BackupAgent[], cloudStatus: CloudStatus) =>
agents.filter(
(agent) => agent.agent_id !== CLOUD_AGENT || cloudStatus.logged_in
)
);
private get _value() {
return this.value ?? DEFAULT_AGENTS;
@@ -60,6 +63,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"
@@ -69,22 +87,25 @@ class HaBackupConfigAgents extends LitElement {
}
protected render() {
const agents = this._availableAgents(this.agents, this.cloudStatus);
return html`
${this._agentIds.length > 0
${agents.length > 0
? html`
<ha-md-list>
${this._agentIds.map((agentId) => {
${agents.map((agent) => {
const agentId = agent.agent_id;
const domain = computeDomain(agentId);
const name = computeBackupAgentName(
this.hass.localize,
agentId,
this._agentIds
this.agents
);
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)
@@ -117,6 +138,16 @@ class HaBackupConfigAgents extends LitElement {
${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}
@@ -130,12 +161,19 @@ class HaBackupConfigAgents extends LitElement {
})}
</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 _agentToggled(ev) {
ev.stopPropagation();
const value = ev.currentTarget.checked;
@@ -147,9 +185,14 @@ class HaBackupConfigAgents extends LitElement {
this.value = this._value.filter((agent) => agent !== agentId);
}
const availableAgents = this._availableAgents(
this.agents,
this.cloudStatus
);
// Ensure we don't have duplicates, agents exist in the list and cloud is logged in
this.value = [...new Set(this.value)]
.filter((agent) => this._agentIds.some((id) => id === agent))
.filter((id) => availableAgents.some((agent) => agent.agent_id === id))
.filter(
(id) =>
id !== CLOUD_AGENT ||
@@ -178,6 +221,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`
@@ -351,12 +537,20 @@ class HaBackupConfigSchedule extends LitElement {
ha-md-list-item {
--md-item-overflow: visible;
}
ha-md-select {
ha-md-select,
ha-time-input {
min-width: 210px;
}
@media all and (max-width: 450px) {
ha-md-select {
ha-md-select,
ha-time-input {
min-width: 160px;
width: 160px;
--md-filled-field-content-space: 0;
}
ha-time-input {
min-width: 120px;
width: 120px;
}
}
ha-md-textfield#value {
@@ -365,6 +559,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

@@ -8,7 +8,10 @@ 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 {
computeBackupSize,
type BackupContent,
} from "../../../../../data/backup";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
@@ -22,7 +25,7 @@ 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 }

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

@@ -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

@@ -33,15 +33,18 @@ 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,
deleteBackup,
generateBackup,
generateBackupWithAutomaticSettings,
getBackupDownloadUrl,
getPreferredAgentForDownload,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -60,13 +63,15 @@ 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";
@@ -89,6 +94,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 +141,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 +175,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: "",
@@ -286,18 +317,31 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
return filteredBackups.map((backup) => {
const type = backup.with_automatic_settings ? "automatic" : "manual";
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 data = this._data(this.backups, this._filters, this.hass.localize);
const maxDisplayedAgents = Math.min(
this._maxAgents(data),
this.narrow ? 3 : 5
);
return html`
<hass-tabs-subpage-data-table
has-fab
@@ -334,8 +378,8 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
@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"
@@ -487,12 +531,12 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!);
const signedUrl = await getSignedPath(
downloadBackup(
this.hass,
getBackupDownloadUrl(backup.backup_id, preferedAgent)
this,
backup,
this.config?.create_backup.password
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {

View File

@@ -20,15 +20,19 @@ 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,
deleteBackup,
fetchBackupDetails,
getBackupDownloadUrl,
getPreferredAgentForDownload,
isLocalAgent,
isNetworkMountAgent,
} from "../../../data/backup";
@@ -37,27 +41,37 @@ 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";
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 +81,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[] = [];
@@ -150,7 +168,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 +185,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 +226,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 +375,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 +401,13 @@ class HaConfigBackupDetails extends LitElement {
}
private async _downloadBackup(agentId?: string): Promise<void> {
const preferedAgent =
agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!);
const signedUrl = await getSignedPath(
await downloadBackup(
this.hass,
getBackupDownloadUrl(this._backup!.backup_id, preferedAgent)
this,
this._backup!,
this.config?.create_backup.password,
agentId
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(): Promise<void> {
@@ -473,6 +497,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}

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,146 @@
import type { LitElement } from "lit";
import {
canDecryptBackupOnDownload,
getBackupDownloadUrl,
getPreferredAgentForDownload,
type BackupContent,
} from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../lovelace/custom-card-helpers";
import { getSignedPath } from "../../../../data/auth";
import { fileDownload } from "../../../../util/file_download";
const triggerDownload = async (
hass: HomeAssistant,
backupId: string,
preferedAgent: string,
encryptionKey?: string | null
) => {
const signedUrl = await getSignedPath(
hass,
getBackupDownloadUrl(backupId, preferedAgent, encryptionKey)
);
fileDownload(signedUrl.path);
};
const downloadEncryptedBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
) => {
if (
await showConfirmationDialog(element, {
title: "Encryption key incorrect",
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_entered_encryption_key"
),
confirmText: "Download encrypted",
})
) {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
triggerDownload(hass, backup.backup_id, preferedAgent);
}
};
const requestEncryptionKey = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
agentId?: string
): Promise<void> => {
const encryptionKey = await showPromptDialog(element, {
title: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
text: hass.localize(
"ui.panel.config.backup.dialogs.download.incorrect_current_encryption_key"
),
inputLabel: hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
),
inputType: "password",
confirmText: hass.localize("ui.common.download"),
});
if (encryptionKey === null) {
return;
}
downloadBackup(hass, element, backup, encryptionKey, agentId, true);
};
export const downloadBackup = async (
hass: HomeAssistant,
element: LitElement,
backup: BackupContent,
encryptionKey?: string | null,
agentId?: string,
userProvided = false
): Promise<void> => {
const agentIds = Object.keys(backup.agents);
const preferedAgent = agentId ?? getPreferredAgentForDownload(agentIds);
const isProtected = backup.agents[preferedAgent]?.protected;
if (isProtected) {
if (encryptionKey) {
try {
await canDecryptBackupOnDownload(
hass,
backup.backup_id,
preferedAgent,
encryptionKey
);
} catch (err: any) {
if (err?.code === "password_incorrect") {
if (userProvided) {
downloadEncryptedBackup(hass, element, backup, agentId);
} else {
requestEncryptionKey(hass, element, backup, agentId);
}
return;
}
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() {
triggerDownload(hass, backup.backup_id, preferedAgent);
},
});
encryptionKey = undefined;
return;
}
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,
}
),
});
return;
}
} else {
requestEncryptionKey(hass, element, backup, agentId);
return;
}
}
await triggerDownload(hass, backup.backup_id, preferedAgent, encryptionKey);
};

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: () =>

View File

@@ -1,13 +1,13 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiPower } from "@mdi/js";
import type { ChartOptions } from "chart.js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { SeriesOption } from "echarts/types/dist/shared";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { numberFormatToLocale } from "../../../common/number/format_number";
import { round } from "../../../common/number/round";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/buttons/ha-progress-button";
@@ -38,16 +38,22 @@ import type { HomeAssistant } from "../../../types";
import { hardwareBrandsUrl } from "../../../util/brands-url";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { ECOption } from "../../../resources/echarts";
import { getTimeAxisLabelConfig } from "../../../components/chart/axis-label";
const DATASAMPLES = 60;
const DATA_SET_CONFIG = {
fill: "origin",
borderColor: DEFAULT_PRIMARY_COLOR,
backgroundColor: DEFAULT_PRIMARY_COLOR + "2B",
pointRadius: 0,
lineTension: 0.2,
borderWidth: 1,
const DATA_SET_CONFIG: SeriesOption = {
type: "line",
color: DEFAULT_PRIMARY_COLOR,
areaStyle: {
color: DEFAULT_PRIMARY_COLOR + "2B",
},
symbolSize: 0,
lineStyle: {
width: 1,
},
smooth: 0.25,
};
@customElement("ha-config-hardware")
@@ -62,15 +68,15 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
@state() private _hardwareInfo?: HardwareInfo;
@state() private _chartOptions?: ChartOptions;
@state() private _chartOptions?: ECOption;
@state() private _systemStatusData?: SystemStatusStreamMessage;
@state() private _configEntries?: Record<string, ConfigEntry>;
private _memoryEntries: { x: number; y: number | null }[] = [];
private _memoryEntries: [number, number | null][] = [];
private _cpuEntries: { x: number; y: number | null }[] = [];
private _cpuEntries: [number, number | null][] = [];
public hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
const subs = [
@@ -121,14 +127,14 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
this._memoryEntries.shift();
this._cpuEntries.shift();
this._memoryEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.memory_used_percent,
});
this._cpuEntries.push({
x: new Date(message.timestamp).getTime(),
y: message.cpu_percent,
});
this._memoryEntries.push([
new Date(message.timestamp).getTime(),
message.memory_used_percent,
]);
this._cpuEntries.push([
new Date(message.timestamp).getTime(),
message.cpu_percent,
]);
this._systemStatusData = message;
},
@@ -143,51 +149,44 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
}
protected willUpdate(): void {
if (!this.hasUpdated) {
if (!this.hasUpdated && !this._chartOptions) {
this._chartOptions = {
responsive: true,
scales: {
y: {
gridLines: {
drawTicks: false,
},
ticks: {
maxTicksLimit: 7,
fontSize: 10,
max: 100,
min: 0,
stepSize: 1,
callback: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
xAxis: {
type: "time",
axisLabel: getTimeAxisLabelConfig(this.hass.locale, this.hass.config),
splitLine: {
show: true,
},
x: {
type: "time",
adapters: {
date: {
locale: this.hass.locale,
config: this.hass.config,
},
},
gridLines: {
display: true,
drawTicks: false,
},
ticks: {
maxRotation: 0,
sampleSize: 5,
autoSkipPadding: 20,
major: {
enabled: true,
},
fontSize: 10,
autoSkip: true,
maxTicksLimit: 5,
},
axisLine: {
show: false,
},
},
// @ts-expect-error
locale: numberFormatToLocale(this.hass.locale),
yAxis: {
type: "value",
splitLine: {
show: true,
},
axisLabel: {
formatter: (value: number) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
axisLine: {
show: false,
},
scale: true,
},
grid: {
top: 10,
bottom: 10,
left: 10,
right: 10,
containLabel: true,
},
tooltip: {
trigger: "axis",
valueFormatter: (value) =>
value + blankBeforePercent(this.hass.locale) + "%",
},
};
}
}
@@ -201,8 +200,8 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
for (let i = 0; i < DATASAMPLES; i++) {
const t = new Date(date);
t.setSeconds(t.getSeconds() - 5 * (DATASAMPLES - i));
this._memoryEntries.push({ x: t.getTime(), y: null });
this._cpuEntries.push({ x: t.getTime(), y: null });
this._memoryEntries.push([t.getTime(), null]);
this._cpuEntries.push([t.getTime(), null]);
}
}
@@ -387,14 +386,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._cpuEntries,
},
],
}}
.data=${this._getChartData(this._cpuEntries)}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@@ -419,14 +411,7 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
<div class="card-content">
<ha-chart-base
.hass=${this.hass}
.data=${{
datasets: [
{
...DATA_SET_CONFIG,
data: this._memoryEntries,
},
],
}}
.data=${this._getChartData(this._memoryEntries)}
.options=${this._chartOptions}
></ha-chart-base>
</div>
@@ -482,6 +467,20 @@ class HaConfigHardware extends SubscribeMixin(LitElement) {
showRestartDialog(this);
}
private _getChartData = memoizeOne(
(entries: [number, number | null][]): SeriesOption[] => [
{
...DATA_SET_CONFIG,
id: entries === this._cpuEntries ? "cpu" : "memory",
name:
entries === this._cpuEntries
? this.hass.localize("ui.panel.config.hardware.processor")
: this.hass.localize("ui.panel.config.hardware.memory"),
data: entries,
} as SeriesOption,
]
);
static styles = [
haStyle,
css`

View File

@@ -137,6 +137,7 @@ export class DialogHelperDetail extends LitElement {
this._error = undefined;
this._domain = undefined;
this._params = undefined;
this._filter = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}

View File

@@ -656,7 +656,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
).length}
.columns=${this._columns(this.hass.localize)}
.data=${helpers}
.initialGroupColumn=${this._activeGrouping || "category"}
.initialGroupColumn=${this._activeGrouping ?? "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
@@ -1264,7 +1264,7 @@ ${rejected
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
this._activeGrouping = ev.detail.value ?? "";
}
private _handleCollapseChanged(ev: CustomEvent) {

View File

@@ -156,7 +156,9 @@ class HaConfigInfo extends LitElement {
</ul>
</ha-card>
<ha-card outlined class="ohf">
<div>Proud part of</div>
<div>
${this.hass.localize("ui.panel.config.info.proud_part_of")}
</div>
<a
href="https://www.openhomefoundation.org"
target="_blank"

View File

@@ -319,7 +319,6 @@ class AddIntegrationDialog extends LitElement {
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
@@ -449,12 +448,14 @@ class AddIntegrationDialog extends LitElement {
>
<lit-virtualizer
scroller
tabindex="-1"
class="ha-scrollbar"
style=${styleMap({
width: `${this._width}px`,
height: this._narrow ? "calc(100vh - 184px)" : "500px",
})}
@click=${this._integrationPicked}
@keypress=${this._handleKeyPress}
.items=${integrations}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderRow}
@@ -478,6 +479,7 @@ class AddIntegrationDialog extends LitElement {
brand
.hass=${this.hass}
.integration=${integration}
tabindex="0"
>
</ha-integration-list-item>
`;
@@ -534,6 +536,12 @@ class AddIntegrationDialog extends LitElement {
this._handleIntegrationPicked(listItem.integration);
}
private _handleKeyPress(ev) {
if (ev.key === "Enter") {
this._integrationPicked(ev);
}
}
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if (integration.supported_by) {
this._supportedBy(integration);

View File

@@ -0,0 +1,214 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@customElement("bluetooth-advertisement-monitor")
export class BluetoothAdvertisementMonitorPanel extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@state() private _data: BluetoothDeviceData[] = [];
@state() private _scanners: BluetoothScannersDetails = {};
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@storage({
key: "bluetooth-advertisement-table-grouping",
state: false,
subscribe: false,
})
private _activeGrouping?: string = "source";
@storage({
key: "bluetooth-advertisement-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed: string[] = [];
private _unsub_advertisements?: UnsubscribeFunc;
private _unsub_scanners?: UnsubscribeFunc;
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._unsub_advertisements = subscribeBluetoothAdvertisements(
this.hass.connection,
(data) => {
this._data = data;
}
);
this._unsub_scanners = subscribeBluetoothScannersDetails(
this.hass.connection,
(scanners) => {
this._scanners = scanners;
}
);
const devices = Object.values(this.hass.devices);
const bluetoothDevices = devices.filter((device) =>
device.connections.find((connection) => connection[0] === "bluetooth")
);
this._sourceDevices = Object.fromEntries(
bluetoothDevices.map((device) => {
const connection = device.connections.find(
(c) => c[0] === "bluetooth"
)!;
return [connection[1], device];
})
);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub_advertisements) {
this._unsub_advertisements();
this._unsub_advertisements = undefined;
}
if (this._unsub_scanners) {
this._unsub_scanners();
this._unsub_scanners = undefined;
}
}
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<BluetoothDeviceData> = {
address: {
title: localize("ui.panel.config.bluetooth.address"),
sortable: true,
filterable: true,
showNarrow: true,
main: true,
hideable: false,
moveable: false,
direction: "asc",
flex: 1,
},
name: {
title: localize("ui.panel.config.bluetooth.name"),
filterable: true,
sortable: true,
},
device: {
title: localize("ui.panel.config.bluetooth.device"),
filterable: true,
sortable: true,
template: (data) => html`${data.device || "-"}`,
},
source: {
title: localize("ui.panel.config.bluetooth.source"),
filterable: true,
sortable: true,
groupable: true,
},
source_address: {
title: localize("ui.panel.config.bluetooth.source_address"),
filterable: true,
sortable: true,
defaultHidden: true,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
maxWidth: "60px",
sortable: true,
},
};
return columns;
}
);
private _dataWithNamedSourceAndIds = memoizeOne((data) =>
data.map((row) => {
const device = this._sourceDevices[row.address];
const scannerDevice = this._sourceDevices[row.source];
const scanner = this._scanners[row.source];
return {
...row,
id: row.address,
source_address: row.source,
source:
scannerDevice?.name_by_user ||
scannerDevice?.name ||
scanner?.name ||
row.source,
device: device?.name_by_user || device?.name || undefined,
};
})
);
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
@row-click=${this._handleRowClicked}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
clickable
></hass-tabs-subpage-data-table>
`;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
entry: entry!,
});
}
static styles: CSSResultGroup = haStyle;
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-advertisement-monitor": BluetoothAdvertisementMonitorPanel;
}
}

View File

@@ -0,0 +1,46 @@
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../../../types";
@customElement("bluetooth-config-dashboard-router")
class BluetoothConfigDashboardRouter extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
showLoading: true,
routes: {
dashboard: {
tag: "bluetooth-config-dashboard",
load: () => import("./bluetooth-config-dashboard"),
},
"advertisement-monitor": {
tag: "bluetooth-advertisement-monitor",
load: () => import("./bluetooth-advertisement-monitor"),
},
},
};
protected updatePageEl(el): void {
el.route = this.routeTail;
el.hass = this.hass;
el.isWide = this.isWide;
el.narrow = this.narrow;
el.configEntryId = this._configEntry;
}
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-config-dashboard-router": BluetoothConfigDashboardRouter;
}
}

View File

@@ -0,0 +1,211 @@
import "@material/mwc-button";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../components/ha-card";
import "../../../../../components/ha-code-editor";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-switch";
import { getConfigEntries } from "../../../../../data/config_entries";
import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow";
import "../../../../../layouts/hass-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import { subscribeBluetoothConnectionAllocations } from "../../../../../data/bluetooth";
import {
getValueInPercentage,
roundWithOneDecimal,
} from "../../../../../util/calculate";
import "../../../../../components/ha-metric";
import type { BluetoothAllocationsData } from "../../../../../data/bluetooth";
@customElement("bluetooth-config-dashboard")
export class BluetoothConfigDashboard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _connectionAllocationData: BluetoothAllocationsData[] = [];
@state() private _connectionAllocationsError?: string;
private _configEntry = new URLSearchParams(window.location.search).get(
"config_entry"
);
private _unsubConnectionAllocations?: (() => Promise<void>) | undefined;
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._subscribeBluetoothConnectionAllocations();
}
}
private async _subscribeBluetoothConnectionAllocations(): Promise<void> {
if (this._unsubConnectionAllocations || !this._configEntry) {
return;
}
try {
this._unsubConnectionAllocations =
await subscribeBluetoothConnectionAllocations(
this.hass.connection,
(data) => {
this._connectionAllocationData = data;
},
this._configEntry
);
} catch (err: any) {
this._unsubConnectionAllocations = undefined;
this._connectionAllocationsError = err.message;
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubConnectionAllocations) {
this._unsubConnectionAllocations();
this._unsubConnectionAllocations = undefined;
}
}
protected render(): TemplateResult {
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<div class="content">
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.settings_title"
)}
>
<div class="card-actions">
<mwc-button @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}</mwc-button
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
>
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor_details"
)}
</p>
</div>
<div class="card-actions">
<a href="/config/bluetooth/advertisement-monitor"
><mwc-button>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
</mwc-button></a
>
</div>
</ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor"
)}
>
<div class="card-content">
${this._renderConnectionAllocations()}
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private _getUsedAllocations = (used: number, total: number) =>
roundWithOneDecimal(getValueInPercentage(used, 0, total));
private _renderConnectionAllocations() {
if (this._connectionAllocationsError) {
return html`<ha-alert alert-type="error"
>${this._connectionAllocationsError}</ha-alert
>`;
}
if (this._connectionAllocationData.length === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_connection_slot_allocations"
)}
</div>`;
}
const allocations = this._connectionAllocationData[0];
const allocationsUsed = allocations.slots - allocations.free;
const allocationsTotal = allocations.slots;
if (allocationsTotal === 0) {
return html`<div>
${this.hass.localize(
"ui.panel.config.bluetooth.no_active_connection_support"
)}
</div>`;
}
return html`
<p>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_slot_allocations_monitor_details",
{ slots: allocationsTotal }
)}
</p>
<ha-metric
.heading=${this.hass.localize(
"ui.panel.config.bluetooth.used_connection_slot_allocations"
)}
.value=${this._getUsedAllocations(allocationsUsed, allocationsTotal)}
.tooltip=${allocations.allocated.length > 0
? `${allocationsUsed}/${allocationsTotal} (${allocations.allocated.join(", ")})`
: `${allocationsUsed}/${allocationsTotal}`}
></ha-metric>
`;
}
private async _openOptionFlow() {
const configEntryId = this._configEntry;
if (!configEntryId) {
return;
}
const configEntries = await getConfigEntries(this.hass, {
domain: "bluetooth",
});
const configEntry = configEntries.find(
(entry) => entry.entry_id === configEntryId
);
showOptionsFlowDialog(this, configEntry!);
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
}
.content {
padding: 24px 0 32px;
max-width: 600px;
margin: 0 auto;
direction: ltr;
}
ha-card {
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-config-dashboard": BluetoothConfigDashboard;
}
}

View File

@@ -0,0 +1,124 @@
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import type { HomeAssistant } from "../../../../../types";
import type { BluetoothDeviceInfoDialogParams } from "./show-dialog-bluetooth-device-info";
import "../../../../../components/ha-button";
import { showToast } from "../../../../../util/toast";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
@customElement("dialog-bluetooth-device-info")
class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: BluetoothDeviceInfoDialogParams;
public async showDialog(
params: BluetoothDeviceInfoDialogParams
): Promise<void> {
this._params = params;
}
public closeDialog(): boolean {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
public showDataAsHex(bytestring: string): string {
return Array.from(new TextEncoder().encode(bytestring))
.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0"))
.join(" ");
}
private async _copyToClipboard(): Promise<void> {
if (!this._params) {
return;
}
await copyToClipboard(JSON.stringify(this._params!.entry));
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.bluetooth.device_information")
)}
>
<p>
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}</b>:
${this._params.entry.address}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.name")}</b>:
${this._params.entry.name}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}</b>:
${this._params.entry.source}
</p>
<h3>
${this.hass.localize("ui.panel.config.bluetooth.advertisement_data")}
</h3>
<h4>
${this.hass.localize("ui.panel.config.bluetooth.manufacturer_data")}
</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.manufacturer_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<h4>${this.hass.localize("ui.panel.config.bluetooth.service_data")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.service_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<ha-button slot="secondaryAction" @click=${this._copyToClipboard}
>${this.hass.localize(
"ui.panel.config.bluetooth.copy_to_clipboard"
)}</ha-button
>
</ha-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-bluetooth-device-info": DialogBluetoothDeviceInfo;
}
}

View File

@@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
export interface BluetoothDeviceInfoDialogParams {
entry: BluetoothDeviceData;
}
export const loadBluetoothDeviceInfoDialog = () =>
import("./dialog-bluetooth-device-info");
export const showBluetoothDeviceInfoDialog = (
element: HTMLElement,
bluetoothDeviceInfoDialogParams: BluetoothDeviceInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-bluetooth-device-info",
dialogImport: loadBluetoothDeviceInfoDialog,
dialogParams: bluetoothDeviceInfoDialogParams,
});
};

View File

@@ -800,8 +800,8 @@ class ErrorLogCard extends LitElement {
font-family: var(--code-font-family, monospace);
clear: both;
text-align: start;
padding-top: 12px;
padding-bottom: 12px;
padding-top: 16px;
padding-bottom: 16px;
overflow-y: scroll;
min-height: var(--error-log-card-height, calc(100vh - 240px));
max-height: var(--error-log-card-height, calc(100vh - 240px));

View File

@@ -260,7 +260,7 @@ export class SystemLogCard extends LitElement {
.header-buttons {
display: flex;
align-items: center;
align-items: flex-start;
}
.card-header {
@@ -271,7 +271,6 @@ export class SystemLogCard extends LitElement {
line-height: 48px;
display: block;
margin-block-start: 0px;
margin-block-end: 0px;
font-weight: normal;
}
@@ -296,6 +295,8 @@ export class SystemLogCard extends LitElement {
.card-content {
border-top: 1px solid var(--divider-color);
padding-top: 16px;
padding-bottom: 16px;
}
.row-secondary {

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