Compare commits

..

101 Commits

Author SHA1 Message Date
Paul Bottein
67a5152c36 Improve download backup (#22905) 2024-11-20 12:34:30 +01:00
Paul Bottein
918fca4d0a Add new backup dialog to choose between automatic and manual (#22895)
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2024-11-20 12:32:25 +01:00
Paul Bottein
258a19028b Add backup details page (#22884) 2024-11-20 09:49:08 +01:00
Paul Bottein
7b4536564e Merge branch 'dev' into allthebackupchanges 2024-11-20 09:30:54 +01:00
Norbert Rittel
64c260c1c4 Form correct headline for 'Delete backups?' alert, separate "Delete backup" menu item (#22891)
Create correct headline for 'Delete backups?' alert

Change 'Delete backup' to 'Delete backups?' to use correct plural and form a question the user has to confirm.

Separates the 'Delete backup' menu item that is currently referenced from this key.
2024-11-20 09:21:51 +01:00
Norbert Rittel
36f3ef9e86 Update en.json to make all automation conditions use "if", not "when" (#22883)
* Update en.json to make all automation conditions use "if", not "when"

Fixes the remaining inconsistencies in all conditions defined in HA Frontend.

Especially important as these are prefixed with "Test" in the condition building block of automations and scripts, so they currently result in "Test when …" instead of the correct "Test if …".

* Updated en.json to fix the two wrong lowercase if
2024-11-19 16:56:21 +00:00
Bram Kragten
42622fe21e Hide wake word in pipeline settings (#22879)
* hide wake word in pipeline settings

* move logic to pipeline-editor
2024-11-19 16:35:44 +02:00
Wendelin
64f7afd60f Fix lint-staged with eslint v9 (#22880) 2024-11-19 14:03:38 +01:00
Paul Bottein
d9cd428bf4 Create generate backup dialog (#22866)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-11-19 12:33:45 +01:00
Ivan Kara
3282785cf2 Fix media browser (#22875) 2024-11-19 11:24:36 +00:00
DominikBitzer
2c1931adb1 Add Y-Axis limits functionality from history graphs card to statistics graph card (#22771) 2024-11-19 12:19:35 +01:00
Petar Petrov
c9cad254d2 Add tone,volume & duration selector to more-info dialog for sirens (#22786)
* Add tone selector to more-info for sirens

* add selected tone to service call

* rework the tone into an advanced controls dialog

* tweaks from PR comments

* fix % conversion

* assume duration is in seconds
2024-11-19 11:56:52 +01:00
Paul Bottein
be6ecefb9e Add delete backup action to datatable (#22867) 2024-11-19 11:53:15 +01:00
Wendelin
f4f2cce57e Fix logbook date range alignment (#22877) 2024-11-19 11:32:13 +02:00
Paul Bottein
99bde50c01 Rename backup slug to backup id (#22876) 2024-11-19 09:45:48 +01:00
Paul Bottein
a2471f82a3 Merge branch 'dev' into allthebackupchanges 2024-11-19 09:34:15 +01:00
renovate[bot]
556315b360 Update dependency eslint to v9.15.0 (#22870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-19 09:12:20 +01:00
boern99
bed470f79d add previous and next button to History and Logbook (#22802)
* add previous and next button to History and Logbook

* used date-fns and changed media-query-resolution to fit on mobiles

* hide .prev and .next on small screens; optimized dateRange for ranges lower 1 day

* fixed Date type number
2024-11-19 08:57:08 +01:00
renovate[bot]
9acf946097 Update dependency @bundle-stats/plugin-webpack-filter to v4.17.0 (#22869)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-19 08:40:32 +01:00
renovate[bot]
231ef4b5b4 Lock file maintenance (#22873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-19 06:25:02 +01:00
renovate[bot]
f8bcc6dde4 Pin dependency globals to 15.12.0 (#22858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 16:55:18 +00:00
dependabot[bot]
11ed4600fd Bump http-proxy-middleware from 2.0.6 to 2.0.7 (#22865)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 16:42:37 +00:00
dependabot[bot]
c0e2d6fa23 Bump cross-spawn from 7.0.3 to 7.0.6 (#22864)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.6.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.6)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 16:41:46 +00:00
renovate[bot]
942562161a Update dependency @codemirror/view to v6.34.3 (#22863)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 16:33:20 +00:00
renovate[bot]
d35c40b585 Update dependency globals to v15 (#22861)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 17:23:13 +01:00
renovate[bot]
8cd0ddceb8 Update dependency eslint to v9.14.0 (#22859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 17:22:09 +01:00
dependabot[bot]
c3ee49298a Bump @eslint/plugin-kit from 0.2.0 to 0.2.3 (#22860)
Bumps [@eslint/plugin-kit](https://github.com/eslint/rewrite) from 0.2.0 to 0.2.3.
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/release-please-config.json)
- [Commits](https://github.com/eslint/rewrite/compare/core-v0.2.0...plugin-kit-v0.2.3)

---
updated-dependencies:
- dependency-name: "@eslint/plugin-kit"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 17:21:07 +01:00
Simon Lamon
89dc1a7ebc ESLint Flat Config (#22221)
* Flat config file

* Plugin

* prettier

* Set eslint to latest version (non dev)

* yarn dedupe

* push changes from eslint type pr

* dedupe
2024-11-18 15:49:59 +01:00
Paul Bottein
c90e820c7f Fix backup agents 2024-11-18 15:37:01 +01:00
Wendelin
ced70fd9a1 Fix 2fa login validation, add autofocus to login (#22856) 2024-11-18 15:25:51 +02:00
Simon Lamon
253c8f358b Logbook target picker (#22851)
Logbook picker
2024-11-18 14:19:17 +01:00
Paul Bottein
0c0b657c79 Merge branch 'dev' into allthebackupchanges 2024-11-18 11:38:32 +01:00
Paul Bottein
8941837697 Improve backup screen and navigation (#22827)
* Add location page

* Start dashboard

* Move list to dashboard

* Add mocked config page

* Fix hardcoded boolean

* Add summary card

* Use new format for BackupAgent

* Use new API

* Rename to ha-backup-summary-card

* Use new api
2024-11-18 11:37:48 +01:00
Paul Bottein
23b55484c3 Improve grid size editor (#22697)
* Use table instead of grid

* Add animation

* Change size

* Simplify precideMode logic

* Add tooltip and improve slider style

* Improve size

* Back to default instead of min

* Limit number of cells for the grid when more than 24 cells
2024-11-18 09:43:52 +01:00
dependabot[bot]
1990b8fa84 Bump softprops/action-gh-release from 2.0.9 to 2.1.0 (#22854)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 08:54:58 +01:00
Paul Bottein
03ea08f98c Create a section when dropping card on the create section button (#22790)
* Create a section when dropping card on the create section button

* Add translations

* Add title by default

* And case when moving a heading card
2024-11-18 08:53:01 +02:00
Matt Way
1f5f6c5f8a Fix back gesture on Android activating buttons (#22852)
Touchcancel event cancels touch regardless of cancelled flag

Co-authored-by: Benjamin Paul <benjamin.ian.paul@gmail.com>
2024-11-17 16:55:43 +00:00
renovate[bot]
fa821b1c4f Update dependency @types/chromecast-caf-receiver to v6.0.18 (#22849)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-17 17:47:56 +01:00
karwosts
f51bc40203 Catch yaml errors in script editor (#22853) 2024-11-17 15:09:03 +00:00
renovate[bot]
c7dae49c42 Update dependency marked to v15 (#22782)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-16 18:23:45 +01:00
Joost Lekkerkerker
b056b71557 Only download verified translations (#22844) 2024-11-16 13:20:06 +01:00
dependabot[bot]
0db2b45cc3 Bump cross-spawn from 7.0.3 to 7.0.5 (#22843)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-16 10:16:36 +01:00
renovate[bot]
1be1003549 Update dependency @codemirror/autocomplete to v6.18.3 (#22842)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-16 09:02:45 +01:00
Petar Petrov
b8a13dd6eb ZWaveJS: Add names to colors in Installer Settings (#22819) 2024-11-15 17:55:02 +01:00
Petar Petrov
cae5540c44 ZWaveJS: Configuration.resetAll is only supported on CC v4+ (#22823) 2024-11-15 17:40:38 +01:00
karwosts
d47966cdf7 Allow attaching additional data to schedule in UI (#22798)
* Allow attaching additional data to schedule in UI

* use expandable
2024-11-15 18:13:04 +02:00
renovate[bot]
991cf83ff3 Update dependency @babel/helper-define-polyfill-provider to v0.6.3 (#22829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 12:00:34 +01:00
Simon Lamon
b83be38514 Introduce calendar trigger description (#22814) 2024-11-14 09:28:15 +02:00
Simon Lamon
17982e0bdc Fix swapped plural and singular match use in and condition (#22815)
* Fix swapped plural and singular match

* Test if => if
2024-11-14 09:11:20 +02:00
Simon Lamon
b918862bb1 Confirm uses to if for clarity (#22816) 2024-11-14 09:08:54 +02:00
ildar170975
6bdc7af09f Add outline to a label (3) - consistency (#22812)
Update ha-config-labels.ts
2024-11-14 09:05:01 +02:00
Paul Bottein
01adef6d9f Fix import 2024-11-13 17:46:12 +01:00
ildar170975
7cbebfd603 Add outline to a label (2) (#22803)
* Update ha-filter-labels.ts

* Update ha-automation-picker.ts

* Update ha-scene-dashboard.ts

* Update ha-script-picker.ts

* Update ha-config-devices-dashboard.ts

* Update ha-config-entities.ts

* Update ha-config-helpers.ts

* Update ha-filter-labels.ts

* Update ha-automation-picker.ts

* Update ha-config-devices-dashboard.ts

* Update ha-config-helpers.ts

* Update ha-script-picker.ts

* Update ha-scene-dashboard.ts

* Update ha-config-entities.ts

* Update ha-data-table-labels.ts

* Update ha-label.ts

* Update ha-label.ts
2024-11-13 16:05:03 +00:00
Paul Bottein
4a1adf42b8 Merge branch 'dev' into allthebackupchanges 2024-11-13 16:21:12 +01:00
Joakim Sørensen
0c2e62ec91 Early pushout of changes to the backup panel (#22321)
* Eary pushout of changes to the backup panel

* Add location icons

* Path is optional

* Set backupSlug from route

* No need for subscription mixin

* update

* Reorder

* init details
2024-11-13 16:15:14 +01:00
Petar Petrov
42b1f938d6 Increase ZwaveJS add device timeout to 5 mins (#22809) 2024-11-13 16:13:29 +01:00
boern99
311f221387 Add describeCondition and entity_ids as additional information to automation step details (#21965)
* Add describeCondition and entity_ids as additional information to automation step details

* Update src/components/trace/ha-trace-path-details.ts

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

* tested suggestions

* Update src/components/trace/ha-trace-path-details.ts

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

* Update src/components/trace/ha-trace-path-details.ts

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

* code style fixes

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2024-11-13 14:30:29 +02:00
ildar170975
3c6be8cf99 Fix visibility for shown entities on device card (#22579)
* Update ha-device-entities-card.ts

* Update ha-device-entities-card.ts

* Update src/panels/config/devices/device-detail/ha-device-entities-card.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Update ha-device-entities-card.ts

* Update src/panels/config/devices/device-detail/ha-device-entities-card.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Update ha-device-entities-card.ts

* Update src/panels/config/devices/device-detail/ha-device-entities-card.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Update ha-device-entities-card.ts

* Update ha-device-entities-card.ts

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2024-11-13 07:56:58 +01:00
karwosts
28703b39da Allow entities table to delete helpers (#22248)
* Allow deleting helpers in entities table

* Fix calling the right delete on restored legacy helpers
2024-11-12 18:16:24 +00:00
Petar Petrov
db03e271f5 ZWaveJS: Color Switch support fot the expert UI (#22722)
* ZWaveJS: Color Switch support fot the expert UI

* remove debug code

* fix stopTransition options

* fix options format
2024-11-12 18:21:45 +01:00
AlCalzone
7c851d4542 Z-Wave JS: Fix validation/parsing for custom config param UI (#22789)
* Z-Wave JS: Fix validation/parsing for custom config param UI

* Apply suggestions from code review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2024-11-12 14:57:32 +00:00
Jan-Philipp Benecke
4d107f978c Add download snapshot button to camera more info dialog (#22704)
* Add take snapshot button to camera more info dialog

* Change to download

* Use camera proxy

* Remove filename to have right extension

* Add error handling and process indication

* Update src/dialogs/more-info/controls/more-info-camera.ts

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

* Run prettier

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2024-11-12 12:59:29 +02:00
karwosts
de57b025e6 Warn on switching to automation UI mode with yaml errors (#22780) 2024-11-12 12:47:12 +02:00
Joakim Sørensen
2218a7121b Add dialog to upload a backup file (#22405)
* Add dialog to upload a backup file

* Prosess feedback

* Remoe unused definition
2024-11-12 09:13:55 +01:00
renovate[bot]
3f4351476f Update vaadinWebComponents monorepo to v24.5.3 (#22779)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-11 17:06:31 +01:00
karwosts
d763a014ad Show YAML parsing errors in automation editor (#22753)
* Show YAML parsing errors in automation editor

* make dirty on error

* formatting
2024-11-11 15:40:20 +00:00
Petar Petrov
52a91d8403 Allow GET/SET custom config param in Z-Wave device configuration (#22364)
* Allow GET/SET custom config param in Z-Wave device configuration

* update api calls and validation

* update imports

* fix import

* PR review comments

* fix import

* fix merge error

* fix merge
2024-11-11 08:50:13 +01:00
Petar Petrov
f6cc435f86 More flexible translation keys for logbook binary sensors (#22696)
* Revert "Revert "More flexible translation keys for logbook binary sensors" (#22687)"

This reverts commit c3b7ce8dc4.

* fix type issue
2024-11-11 08:39:11 +01:00
dependabot[bot]
349b1ccaad Bump home-assistant/wheels from 2024.07.1 to 2024.11.0 (#22774) 2024-11-11 08:17:16 +01:00
Paulus Schoutsen
ca921be9d2 CSS Fixes for md-dialog (#22638)
* CSS Fixes for md-dialog

* Update src/components/ha-md-dialog.ts

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2024-11-11 07:11:58 +00:00
Simon Lamon
919932e414 Improve choose description in automation editor (#22769) 2024-11-10 21:11:48 +01:00
Simon Lamon
97a8b6da34 Fix calendar add/edit event dialogs not saving via keyboard (#22767) 2024-11-10 20:18:59 +01:00
renovate[bot]
1eceaa0d1b Update dependency marked to v14.1.4 (#22768)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 20:10:57 +01:00
renovate[bot]
ba3fae2577 Update dependency barcode-detector to v2.3.1 (#22763)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 15:51:14 +01:00
renovate[bot]
93ed1cae5e Update dependency comlink to v4.4.2 (#22761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 15:34:45 +01:00
Adam Jež
d8618b4a25 Fix typo in Czech language for blank before percent (#22760) 2024-11-10 12:30:07 +01:00
Simon Lamon
1f6b0360de Collection of localization issues (2) (#22758)
* Clarify delay action

* Change order

* Add translations for "line" and "bar"
2024-11-10 10:58:04 +00:00
karwosts
e1830470b6 Better disabled/error handling on config/helpers page (#22237)
* Add a way to fix/remove broken helpers

* more disabled/sources fixes

* Update src/translations/en.json

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* Update ha-config-helpers.ts

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2024-11-10 00:40:49 +01:00
renovate[bot]
9e002f7940 Update dependency webpackbar to v7 (#22752)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-09 16:43:33 +01:00
renovate[bot]
a1380e93ea Update dependency barcode-detector to v2.3.0 (#22743)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 20:16:20 +01:00
renovate[bot]
c511672b0d Update dependency barcode-detector to v2.2.12 (#22741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 17:23:28 +00:00
renovate[bot]
d6d6d1d0b5 Update formatjs monorepo (#22728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 18:10:48 +01:00
Wendelin
bee629f7ed Improve stream and iOS checks in error-log-card (#22738)
Improve stream and download checks in error-log-card
2024-11-08 12:59:35 +00:00
Wendelin
d8df380edc Add reset to default to zwave node config (#21991)
* Add reset to default to zwave node config

* use invoke_cc_api instead of a new API

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2024-11-08 11:53:22 +00:00
Wendelin
cbfcad71d5 Fix live-logs loading (#22737) 2024-11-08 12:39:21 +01:00
renovate[bot]
327a9ff836 Update CodeMirror (#22732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-08 13:05:04 +02:00
dependabot[bot]
ae2c389273 Bump http-proxy-middleware from 2.0.6 to 2.0.7 (#22731)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.7/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.7)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-08 11:01:31 +01:00
Wendelin
5ce75cea0d Fix join-beta text (#22733) 2024-11-08 11:00:54 +01:00
Simon Lamon
ee79c3a983 Remove rollup build configuration (#22181)
Rollup remove
2024-11-08 10:19:52 +02:00
tzagim
f396be2ed7 Fix for RTL languages in logs (#22727)
Fix for RTL Languages (log)
2024-11-08 10:16:42 +02:00
Bram Kragten
9f55ef811d move download logs button, switch between raw and normal logs (#22721) 2024-11-07 21:32:28 +01:00
Paul Bottein
4c898a2a5a Enable auto-scroll for drag and drop (#22725) 2024-11-07 17:33:18 +01:00
Wendelin
a56e22790d Fix logs live-indicator on older boots (#22719) 2024-11-07 14:50:24 +01:00
renovate[bot]
2d8fbc652f Update vaadinWebComponents monorepo to v24.5.2 (#22709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-07 14:00:50 +02:00
Bram Kragten
46f0e0212d Add support for helper text in form boolean (#22711) 2024-11-07 09:09:24 +00:00
Bram Kragten
786b9ee8d6 Update value of password field on change event (#22706) 2024-11-07 09:53:34 +01:00
Wendelin
1e73cebda6 Fix hassio logs for core < 2024.11 (#22708) 2024-11-07 09:45:58 +01:00
Paul Bottein
9b9adf3c7a Fix typo for fixed background attribute (#22707) 2024-11-07 08:40:18 +00:00
renovate[bot]
a08c7a319f Update formatjs monorepo (#22681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-07 08:52:53 +02:00
Petar Petrov
5e8868e4b1 Fix import type linter issues (#22702) 2024-11-07 06:39:30 +00:00
Bram Kragten
64285d5155 Add zwave expert UI / Installer settings (#21897)
* Add zwave expert UI / Installer settings

* Fix zwave invoceCC api function name

* Fix function calls of invokeZWaveCCApi

* Add zwave node-installer translations and endpoint separation

* Add zwave capability-control error handling, translations and thermostat setback

* Fix zwave capability thermostat setback

---------

Co-authored-by: Wendelin <w@pe8.at>
2024-11-07 08:00:51 +02:00
132 changed files with 6360 additions and 2664 deletions

View File

@@ -1,132 +0,0 @@
{
"extends": [
"airbnb-base",
"airbnb-typescript/base",
"plugin:@typescript-eslint/recommended",
"plugin:wc/recommended",
"plugin:lit/all",
"plugin:lit-a11y/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"ecmaFeatures": {
"modules": true
},
"sourceType": "module",
"project": "./tsconfig.json"
},
"settings": {
"import/resolver": {
"webpack": {
"config": "./webpack.config.cjs"
}
}
},
"globals": {
"__DEV__": false,
"__DEMO__": false,
"__BUILD__": false,
"__VERSION__": false,
"__STATIC_PATH__": false,
"__SUPERVISOR__": false,
"Polymer": true
},
"env": {
"browser": true,
"es6": true
},
"rules": {
"class-methods-use-this": "off",
"new-cap": "off",
"prefer-template": "off",
"object-shorthand": "off",
"func-names": "off",
"no-underscore-dangle": "off",
"strict": "off",
"no-plusplus": "off",
"no-bitwise": "error",
"comma-dangle": "off",
"vars-on-top": "off",
"no-continue": "off",
"no-param-reassign": "off",
"no-multi-assign": "off",
"no-console": "error",
"radix": "off",
"no-alert": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never",
"js": "never"
}
],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/naming-convention": [
"off",
{
"selector": "default",
"format": ["camelCase", "snake_case"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": ["variable"],
"format": ["camelCase", "snake_case", "UPPER_CASE"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-vars": [
"error",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_",
"ignoreRestSiblings": true
}
],
"unused-imports/no-unused-imports": "error",
"lit/attribute-names": "warn",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
"lit/no-native-attributes": "warn",
"lit/no-this-assign-in-render": "warn",
"lit-a11y/click-events-have-key-events": ["off"],
"lit-a11y/no-autofocus": "off",
"lit-a11y/alt-text": "warn",
"lit-a11y/anchor-is-valid": "warn",
"lit-a11y/role-has-required-aria-attrs": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-import-type-side-effects": "error"
},
"plugins": ["unused-imports"]
}

View File

@@ -55,7 +55,7 @@ jobs:
script/release
- name: Upload release assets
uses: softprops/action-gh-release@v2.0.9
uses: softprops/action-gh-release@v2.1.0
with:
files: |
dist/*.whl
@@ -74,7 +74,7 @@ jobs:
echo "home-assistant-frontend==$version" > ./requirements.txt
- name: Build wheels
uses: home-assistant/wheels@2024.07.1
uses: home-assistant/wheels@2024.11.0
with:
abi: cp312
tag: musllinux_1_2

View File

@@ -1,12 +0,0 @@
{
"extends": "../.eslintrc.json",
"rules": {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-var-requires": "off",
"prefer-arrow-callback": "off"
}
}

View File

@@ -15,7 +15,7 @@ The Home Assistant build pipeline contains various steps to prepare a build.
Currently in Home Assistant we use a bundler to convert TypeScript, CSS and JSON files to JavaScript files that the browser understands.
We currently rely on Webpack but also have experimental Rollup support. Both of these programs bundle the converted files in both production and development.
We currently rely on Webpack. Both of these programs bundle the converted files in both production and development.
For development, bundling is optional. We just want to get the right files in the browser.

View File

@@ -226,13 +226,12 @@ module.exports.config = {
return {
name: "frontend" + nameSuffix(latestBuild),
entry: {
"service-worker":
!env.useRollup() && !latestBuild
? {
import: "./src/entrypoints/service-worker.ts",
layer: "sw",
}
: "./src/entrypoints/service-worker.ts",
"service-worker": !latestBuild
? {
import: "./src/entrypoints/service-worker.ts",
layer: "sw",
}
: "./src/entrypoints/service-worker.ts",
app: "./src/entrypoints/app.ts",
authorize: "./src/entrypoints/authorize.ts",
onboarding: "./src/entrypoints/onboarding.ts",

View File

@@ -3,9 +3,6 @@ const path = require("path");
const paths = require("./paths.cjs");
module.exports = {
useRollup() {
return process.env.ROLLUP === "1";
},
useWDS() {
return process.env.WDS === "1";
},

View File

@@ -0,0 +1,16 @@
import rootConfig from "../eslint.config.mjs";
export default [
...rootConfig,
{
rules: {
"no-console": "off",
"import/no-extraneous-dependencies": "off",
"import/extensions": "off",
"import/no-dynamic-require": "off",
"global-require": "off",
"@typescript-eslint/no-var-requires": "off",
"prefer-arrow-callback": "off",
},
},
];

View File

@@ -6,7 +6,6 @@ import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./locale-data.js";
import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./wds.js";
@@ -27,11 +26,7 @@ gulp.task(
"build-locale-data"
),
"copy-static-app",
env.useWDS()
? "wds-watch-app"
: env.useRollup()
? "rollup-watch-app"
: "webpack-watch-app"
env.useWDS() ? "wds-watch-app" : "webpack-watch-app"
)
);
@@ -44,7 +39,7 @@ gulp.task(
"clean",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-app",
env.useRollup() ? "rollup-prod-app" : "webpack-prod-app",
"webpack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
// Don't compress running tests
...(env.isTestBuild() ? [] : ["compress-app"])

View File

@@ -1,9 +1,7 @@
import gulp from "gulp";
import env from "../env.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -19,7 +17,7 @@ gulp.task(
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-cast",
"gen-pages-cast-dev",
env.useRollup() ? "rollup-dev-server-cast" : "webpack-dev-server-cast"
"webpack-dev-server-cast"
)
);
@@ -33,7 +31,7 @@ gulp.task(
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-cast",
env.useRollup() ? "rollup-prod-cast" : "webpack-prod-cast",
"webpack-prod-cast",
"gen-pages-cast-prod"
)
);

View File

@@ -1,10 +1,8 @@
import gulp from "gulp";
import env from "../env.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -24,7 +22,7 @@ gulp.task(
"build-locale-data"
),
"copy-static-demo",
env.useRollup() ? "rollup-dev-server-demo" : "webpack-dev-server-demo"
"webpack-dev-server-demo"
)
);
@@ -39,7 +37,7 @@ gulp.task(
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-demo",
env.useRollup() ? "rollup-prod-demo" : "webpack-prod-demo",
"webpack-prod-demo",
"gen-pages-demo-prod"
)
);

View File

@@ -127,6 +127,7 @@ gulp.task("fetch-lokalise", async function () {
replace_breaks: false,
json_unescaped_slashes: true,
export_empty_as: "skip",
filter_data: ["verified"],
})
.then((download) => fetch(download.bundle_url))
.then((response) => {

View File

@@ -56,7 +56,6 @@ const getCommonTemplateVars = () => {
{ ignorePatch: true, allowHigherVersions: true }
);
return {
useRollup: env.useRollup(),
useWDS: env.useWDS(),
modernRegex: compileRegex(browserRegexes.concat(haMacOSRegex)).toString(),
};

View File

@@ -4,13 +4,11 @@ import gulp from "gulp";
import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
import env from "../env.cjs";
import paths from "../paths.cjs";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./rollup.js";
import "./service-worker.js";
import "./translations.js";
import "./webpack.js";
@@ -158,9 +156,7 @@ gulp.task(
"copy-static-gallery",
"gen-pages-gallery-dev",
gulp.parallel(
env.useRollup()
? "rollup-dev-server-gallery"
: "webpack-dev-server-gallery",
"webpack-dev-server-gallery",
async function watchMarkdownFiles() {
gulp.watch(
[
@@ -189,7 +185,7 @@ gulp.task(
"gather-gallery-pages"
),
"copy-static-gallery",
env.useRollup() ? "rollup-prod-gallery" : "webpack-prod-gallery",
"webpack-prod-gallery",
"gen-pages-gallery-prod"
)
);

View File

@@ -4,7 +4,6 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import env from "../env.cjs";
const npmPath = (...parts) =>
path.resolve(paths.polymer_dir, "node_modules", ...parts);
@@ -69,9 +68,6 @@ function copyPolyfills(staticDir) {
}
function copyLoaderJS(staticDir) {
if (!env.useRollup()) {
return;
}
const staticPath = genStaticPath(staticDir);
copyFileDir(npmPath("systemjs/dist/s.min.js"), staticPath("js"));
copyFileDir(npmPath("systemjs/dist/s.min.js.map"), staticPath("js"));

View File

@@ -5,7 +5,6 @@ import "./compress.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./rollup.js";
import "./translations.js";
import "./webpack.js";
@@ -22,7 +21,7 @@ gulp.task(
"copy-translations-supervisor",
"build-locale-data",
"copy-static-supervisor",
env.useRollup() ? "rollup-watch-hassio" : "webpack-watch-hassio"
"webpack-watch-hassio"
)
);
@@ -38,7 +37,7 @@ gulp.task(
"copy-translations-supervisor",
"build-locale-data",
"copy-static-supervisor",
env.useRollup() ? "rollup-prod-hassio" : "webpack-prod-hassio",
"webpack-prod-hassio",
"gen-pages-hassio-prod",
...// Don't compress running tests
(env.isTestBuild() ? [] : ["compress-hassio"])

View File

@@ -1,147 +0,0 @@
// Tasks to run Rollup
import log from "fancy-log";
import gulp from "gulp";
import http from "http";
import open from "open";
import path from "path";
import { rollup } from "rollup";
import handler from "serve-handler";
import paths from "../paths.cjs";
import rollupConfig from "../rollup.cjs";
const bothBuilds = (createConfigFunc, params) =>
gulp.series(
async function buildLatest() {
await buildRollup(
createConfigFunc({
...params,
latestBuild: true,
})
);
},
async function buildES5() {
await buildRollup(
createConfigFunc({
...params,
latestBuild: false,
})
);
}
);
function createServer(serveOptions) {
const server = http.createServer((request, response) =>
handler(request, response, {
public: serveOptions.root,
})
);
server.listen(
serveOptions.port,
serveOptions.networkAccess ? "0.0.0.0" : undefined,
() => {
log.info(`Available at http://localhost:${serveOptions.port}`);
open(`http://localhost:${serveOptions.port}`);
}
);
}
function watchRollup(createConfig, extraWatchSrc = [], serveOptions = null) {
const { inputOptions, outputOptions } = createConfig({
isProdBuild: false,
latestBuild: true,
});
const watcher = rollup.watch({
...inputOptions,
output: [outputOptions],
watch: {
include: ["src/**"] + extraWatchSrc,
},
});
let startedHttp = false;
watcher.on("event", (event) => {
if (event.code === "BUNDLE_END") {
log(`Build done @ ${new Date().toLocaleTimeString()}`);
} else if (event.code === "ERROR") {
log.error(event.error);
} else if (event.code === "END") {
if (startedHttp || !serveOptions) {
return;
}
startedHttp = true;
createServer(serveOptions);
}
});
gulp.watch(
path.join(paths.translations_src, "en.json"),
gulp.series("build-translations", "copy-translations-app")
);
}
async function buildRollup(config) {
const bundle = await rollup.rollup(config.inputOptions);
await bundle.write(config.outputOptions);
}
gulp.task("rollup-watch-app", () => {
watchRollup(rollupConfig.createAppConfig);
});
gulp.task("rollup-watch-hassio", () => {
watchRollup(rollupConfig.createHassioConfig, ["hassio/src/**"]);
});
gulp.task("rollup-dev-server-demo", () => {
watchRollup(rollupConfig.createDemoConfig, ["demo/src/**"], {
root: paths.demo_output_root,
port: 8090,
});
});
gulp.task("rollup-dev-server-cast", () => {
watchRollup(rollupConfig.createCastConfig, ["cast/src/**"], {
root: paths.cast_output_root,
port: 8080,
networkAccess: true,
});
});
gulp.task("rollup-dev-server-gallery", () => {
watchRollup(rollupConfig.createGalleryConfig, ["gallery/src/**"], {
root: paths.gallery_output_root,
port: 8100,
});
});
gulp.task(
"rollup-prod-app",
bothBuilds(rollupConfig.createAppConfig, { isProdBuild: true })
);
gulp.task(
"rollup-prod-demo",
bothBuilds(rollupConfig.createDemoConfig, { isProdBuild: true })
);
gulp.task(
"rollup-prod-cast",
bothBuilds(rollupConfig.createCastConfig, { isProdBuild: true })
);
gulp.task("rollup-prod-hassio", () =>
bothBuilds(rollupConfig.createHassioConfig, { isProdBuild: true })
);
gulp.task("rollup-prod-gallery", () =>
buildRollup(
rollupConfig.createGalleryConfig({
isProdBuild: true,
latestBuild: true,
})
)
);

View File

@@ -1,14 +0,0 @@
module.exports = function (opts = {}) {
const dontHash = opts.dontHash || new Set();
return {
name: "dont-hash",
renderChunk(_code, chunk, _options) {
if (!chunk.isEntry || !dontHash.has(chunk.name)) {
return null;
}
chunk.fileName = `${chunk.name}.js`;
return null;
},
};
};

View File

@@ -1,24 +0,0 @@
module.exports = function (userOptions = {}) {
// Files need to be absolute paths.
// This only works if the file has no exports
// and only is imported for its side effects
const files = userOptions.files || [];
if (files.length === 0) {
return {
name: "ignore",
};
}
return {
name: "ignore",
load(id) {
return files.some((toIgnorePath) => id.startsWith(toIgnorePath))
? {
code: "",
}
: null;
},
};
};

View File

@@ -1,34 +0,0 @@
const url = require("url");
const defaultOptions = {
publicPath: "",
};
module.exports = function (userOptions = {}) {
const options = { ...defaultOptions, ...userOptions };
return {
name: "manifest",
generateBundle(outputOptions, bundle) {
const manifest = {};
for (const chunk of Object.values(bundle)) {
if (!chunk.isEntry) {
continue;
}
// Add js extension to mimic Webpack manifest.
manifest[`${chunk.name}.js`] = url.resolve(
options.publicPath,
chunk.fileName
);
}
this.emitFile({
type: "asset",
source: JSON.stringify(manifest, undefined, 2),
name: "manifest.json",
fileName: "manifest.json",
});
},
};
};

View File

@@ -1,152 +0,0 @@
// Worker plugin
// Each worker will include all of its dependencies
// instead of relying on an importer.
// Forked from v.1.4.1
// https://github.com/surma/rollup-plugin-off-main-thread
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const rollup = require("rollup");
const path = require("path");
const MagicString = require("magic-string");
const defaultOpts = {
// A RegExp to find `new Workers()` calls. The second capture group _must_
// capture the provided file name without the quotes.
workerRegexp: /new Worker\((["'])(.+?)\1(,[^)]+)?\)/g,
plugins: ["node-resolve", "commonjs", "babel", "terser", "ignore"],
};
async function getBundledWorker(workerPath, rollupOptions) {
const bundle = await rollup.rollup({
...rollupOptions,
input: {
worker: workerPath,
},
});
const { output } = await bundle.generate({
// Generates cleanest output, we shouldn't have any imports/exports
// that would be incompatible with ES5.
format: "es",
// We should not export anything. This will fail build if we are.
exports: "none",
});
let code;
for (const chunkOrAsset of output) {
if (chunkOrAsset.name === "worker") {
code = chunkOrAsset.code;
} else if (chunkOrAsset.type !== "asset") {
throw new Error("Unexpected extra output");
}
}
return code;
}
module.exports = function (opts = {}) {
opts = { ...defaultOpts, ...opts };
let rollupOptions;
let refIds;
return {
name: "hass-worker",
async buildStart(options) {
refIds = {};
rollupOptions = {
plugins: options.plugins.filter((plugin) =>
opts.plugins.includes(plugin.name)
),
};
},
async transform(code, id) {
// Copy the regexp as they are stateful and this hook is async.
const workerRegexp = new RegExp(
opts.workerRegexp.source,
opts.workerRegexp.flags
);
if (!workerRegexp.test(code)) {
return undefined;
}
const ms = new MagicString(code);
// Reset the regexp
workerRegexp.lastIndex = 0;
for (;;) {
const match = workerRegexp.exec(code);
if (!match) {
break;
}
const workerFile = match[2];
let optionsObject = {};
// Parse the optional options object
if (match[3] && match[3].length > 0) {
// FIXME: ooooof!
// eslint-disable-next-line @typescript-eslint/no-implied-eval
optionsObject = new Function(`return ${match[3].slice(1)};`)();
}
delete optionsObject.type;
if (!/^.*\//.test(workerFile)) {
this.warn(
`Paths passed to the Worker constructor must be relative or absolute, i.e. start with /, ./ or ../ (just like dynamic import!). Ignoring "${workerFile}".`
);
continue;
}
// Find worker file and store it as a chunk with ID prefixed for our loader
// eslint-disable-next-line no-await-in-loop
const resolvedWorkerFile = (await this.resolve(workerFile, id)).id;
let chunkRefId;
if (resolvedWorkerFile in refIds) {
chunkRefId = refIds[resolvedWorkerFile];
} else {
this.addWatchFile(resolvedWorkerFile);
// eslint-disable-next-line no-await-in-loop
const source = await getBundledWorker(
resolvedWorkerFile,
rollupOptions
);
chunkRefId = refIds[resolvedWorkerFile] = this.emitFile({
name: path.basename(resolvedWorkerFile),
source,
type: "asset",
});
}
const workerParametersStartIndex = match.index + "new Worker(".length;
const workerParametersEndIndex =
match.index + match[0].length - ")".length;
ms.overwrite(
workerParametersStartIndex,
workerParametersEndIndex,
`import.meta.ROLLUP_FILE_URL_${chunkRefId}, ${JSON.stringify(
optionsObject
)}`
);
}
return {
code: ms.toString(),
map: ms.generateMap({ hires: true }),
};
},
};
};

View File

@@ -1,146 +0,0 @@
const path = require("path");
const commonjs = require("@rollup/plugin-commonjs");
const resolve = require("@rollup/plugin-node-resolve");
const json = require("@rollup/plugin-json");
const { babel } = require("@rollup/plugin-babel");
const replace = require("@rollup/plugin-replace");
const visualizer = require("rollup-plugin-visualizer");
const { string } = require("rollup-plugin-string");
const { terser } = require("rollup-plugin-terser");
const manifest = require("./rollup-plugins/manifest-plugin.cjs");
const worker = require("./rollup-plugins/worker-plugin.cjs");
const dontHashPlugin = require("./rollup-plugins/dont-hash-plugin.cjs");
const ignore = require("./rollup-plugins/ignore-plugin.cjs");
const bundle = require("./bundle.cjs");
const paths = require("./paths.cjs");
const extensions = [".js", ".ts"];
/**
* @param {Object} arg
* @param { import("rollup").InputOption } arg.input
*/
const createRollupConfig = ({
entry,
outputPath,
defineOverlay,
isProdBuild,
latestBuild,
isStatsBuild,
publicPath,
dontHash,
isWDS,
}) => ({
/**
* @type { import("rollup").InputOptions }
*/
inputOptions: {
input: entry,
// Some entry points contain no JavaScript. This setting silences a warning about that.
// https://rollupjs.org/configuration-options/#preserveentrysignatures
preserveEntrySignatures: false,
plugins: [
ignore({
files: bundle
.emptyPackages({ latestBuild })
// TEMP HACK: Makes Rollup build work again
.concat(
require.resolve(
"@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min"
)
),
}),
resolve({
extensions,
preferBuiltins: false,
browser: true,
rootDir: paths.polymer_dir,
}),
commonjs(),
json(),
babel({
...bundle.babelOptions({ latestBuild, isProdBuild }),
extensions,
babelHelpers: isWDS ? "inline" : "bundled",
}),
string({
// Import certain extensions as strings
include: [path.join(paths.polymer_dir, "node_modules/**/*.css")],
}),
replace(bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })),
!isWDS &&
manifest({
publicPath,
}),
!isWDS && worker(),
!isWDS && dontHashPlugin({ dontHash }),
!isWDS && isProdBuild && terser(bundle.terserOptions({ latestBuild })),
!isWDS &&
isStatsBuild &&
visualizer({
// https://github.com/btd/rollup-plugin-visualizer#options
open: true,
sourcemap: true,
}),
].filter(Boolean),
},
/**
* @type { import("rollup").OutputOptions }
*/
outputOptions: {
// https://rollupjs.org/configuration-options/#output-dir
dir: outputPath,
// https://rollupjs.org/configuration-options/#output-format
format: latestBuild ? "es" : "systemjs",
// https://rollupjs.org/configuration-options/#output-externallivebindings
externalLiveBindings: false,
// https://rollupjs.org/configuration-options/#output-entryfilenames
// https://rollupjs.org/configuration-options/#output-chunkfilenames
// https://rollupjs.org/configuration-options/#output-assetfilenames
entryFileNames:
isProdBuild && !isStatsBuild ? "[name]-[hash].js" : "[name].js",
chunkFileNames: isProdBuild && !isStatsBuild ? "c.[hash].js" : "[name].js",
assetFileNames: isProdBuild && !isStatsBuild ? "a.[hash].js" : "[name].js",
// https://rollupjs.org/configuration-options/#output-sourcemap
sourcemap: isProdBuild ? true : "inline",
},
});
const createAppConfig = ({ isProdBuild, latestBuild, isStatsBuild, isWDS }) =>
createRollupConfig(
bundle.config.app({
isProdBuild,
latestBuild,
isStatsBuild,
isWDS,
})
);
const createDemoConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRollupConfig(
bundle.config.demo({
isProdBuild,
latestBuild,
isStatsBuild,
})
);
const createCastConfig = ({ isProdBuild, latestBuild }) =>
createRollupConfig(bundle.config.cast({ isProdBuild, latestBuild }));
const createHassioConfig = ({ isProdBuild, latestBuild }) =>
createRollupConfig(bundle.config.hassio({ isProdBuild, latestBuild }));
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
createRollupConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
module.exports = {
createAppConfig,
createDemoConfig,
createCastConfig,
createHassioConfig,
createGalleryConfig,
createRollupConfig,
};

View File

@@ -188,6 +188,7 @@ const createWebpackConfig = ({
"lit/directives/cache$": "lit/directives/cache.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",

View File

@@ -1,10 +0,0 @@
import rollup from "../build-scripts/rollup.cjs";
import env from "../build-scripts/env.cjs";
const config = rollup.createCastConfig({
isProdBuild: env.isProdBuild(),
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
});
export default { ...config.inputOptions, output: config.outputOptions };

View File

@@ -1,10 +0,0 @@
import rollup from "../build-scripts/rollup.cjs";
import env from "../build-scripts/env.cjs";
const config = rollup.createDemoConfig({
isProdBuild: env.isProdBuild(),
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
});
export default { ...config.inputOptions, output: config.outputOptions };

163
eslint.config.mjs Normal file
View File

@@ -0,0 +1,163 @@
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
...compat.extends(
"airbnb-base",
"airbnb-typescript/base",
"plugin:@typescript-eslint/recommended",
"plugin:wc/recommended",
"plugin:lit/all",
"plugin:lit-a11y/recommended",
"prettier"
),
{
plugins: {
"unused-imports": unusedImports,
},
languageOptions: {
globals: {
...globals.browser,
__DEV__: false,
__DEMO__: false,
__BUILD__: false,
__VERSION__: false,
__STATIC_PATH__: false,
__SUPERVISOR__: false,
Polymer: true,
},
parser: tsParser,
ecmaVersion: 2020,
sourceType: "module",
parserOptions: {
ecmaFeatures: {
modules: true,
},
project: "./tsconfig.json",
},
},
settings: {
"import/resolver": {
webpack: {
config: "./webpack.config.cjs",
},
},
},
rules: {
"class-methods-use-this": "off",
"new-cap": "off",
"prefer-template": "off",
"object-shorthand": "off",
"func-names": "off",
"no-underscore-dangle": "off",
strict: "off",
"no-plusplus": "off",
"no-bitwise": "error",
"comma-dangle": "off",
"vars-on-top": "off",
"no-continue": "off",
"no-param-reassign": "off",
"no-multi-assign": "off",
"no-console": "error",
radix: "off",
"no-alert": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"import/prefer-default-export": "off",
"import/no-default-export": "off",
"import/no-unresolved": "off",
"import/no-cycle": "off",
"import/extensions": [
"error",
"ignorePackages",
{
ts: "never",
js: "never",
},
],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/naming-convention": [
"off",
{
selector: "default",
format: ["camelCase", "snake_case"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{
selector: ["variable"],
format: ["camelCase", "snake_case", "UPPER_CASE"],
leadingUnderscore: "allow",
trailingUnderscore: "allow",
},
{
selector: "typeLike",
format: ["PascalCase"],
},
],
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-vars": [
"error",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
ignoreRestSiblings: true,
},
],
"unused-imports/no-unused-imports": "error",
"lit/attribute-names": "warn",
"lit/attribute-value-entities": "off",
"lit/no-template-map": "off",
"lit/no-native-attributes": "warn",
"lit/no-this-assign-in-render": "warn",
"lit-a11y/click-events-have-key-events": ["off"],
"lit-a11y/no-autofocus": "off",
"lit-a11y/alt-text": "warn",
"lit-a11y/anchor-is-valid": "warn",
"lit-a11y/role-has-required-aria-attrs": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-import-type-side-effects": "error",
},
},
];

View File

@@ -1,6 +0,0 @@
{
"extends": "../.eslintrc.json",
"rules": {
"no-console": 0
}
}

10
gallery/eslint.config.mjs Normal file
View File

@@ -0,0 +1,10 @@
import rootConfig from "../eslint.config.mjs";
export default [
...rootConfig,
{
rules: {
"no-console": "off",
},
},
];

View File

@@ -1,10 +0,0 @@
import rollup from "../build-scripts/rollup.cjs";
import env from "../build-scripts/env.cjs";
const config = rollup.createGalleryConfig({
isProdBuild: env.isProdBuild(),
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
});
export default { ...config.inputOptions, output: config.outputOptions };

View File

@@ -1,4 +1,3 @@
/* eslint-disable lit/no-template-arrow */
import type { TemplateResult } from "lit";
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators";

View File

@@ -510,6 +510,7 @@ class DemoHaForm extends LitElement {
.computeError=${(error) => translations[error] || error}
.computeLabel=${(schema) =>
translations[schema.name] || schema.name}
.computeHelper=${() => "Helper text"}
@value-changed=${(e) => {
this.data[idx] = e.detail.value;
this.requestUpdate();

View File

@@ -1,10 +0,0 @@
import rollup from "../build-scripts/rollup.cjs";
import env from "../build-scripts/env.cjs";
const config = rollup.createHassioConfig({
isProdBuild: env.isProdBuild(),
latestBuild: false,
isStatsBuild: env.isStatsBuild(),
});
export default { ...config.inputOptions, output: config.outputOptions };

View File

@@ -47,7 +47,6 @@ class HassioAddonLogDashboard extends LitElement {
.localizeFunc=${this.supervisor.localize}
.header=${this.addon.name}
.provider=${this.addon.slug}
show
.filter=${this._filter}
>
</error-log-card>

View File

@@ -1,6 +1,6 @@
export default {
"*.?(c|m){js,ts}": [
"eslint --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"eslint --flag unstable_config_lookup_from_file --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --fix",
"prettier --cache --write",
"lit-analyzer --quiet",
],

View File

@@ -8,8 +8,8 @@
"version": "1.0.0",
"scripts": {
"build": "script/build_frontend",
"lint:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-path=.gitignore",
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-path=.gitignore --fix",
"lint:eslint": "eslint --flag unstable_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore",
"format:eslint": "eslint --flag unstable_config_lookup_from_file \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
"lint:prettier": "prettier . --cache --check",
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
@@ -27,22 +27,22 @@
"dependencies": {
"@babel/runtime": "7.26.0",
"@braintree/sanitize-url": "7.1.0",
"@codemirror/autocomplete": "6.18.2",
"@codemirror/autocomplete": "6.18.3",
"@codemirror/commands": "6.7.1",
"@codemirror/language": "6.10.3",
"@codemirror/legacy-modes": "6.4.1",
"@codemirror/legacy-modes": "6.4.2",
"@codemirror/search": "6.5.7",
"@codemirror/state": "6.4.1",
"@codemirror/view": "6.34.1",
"@codemirror/view": "6.34.3",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.16.1",
"@formatjs/intl-displaynames": "6.8.1",
"@formatjs/intl-getcanonicallocales": "2.5.1",
"@formatjs/intl-listformat": "7.7.1",
"@formatjs/intl-locale": "4.2.1",
"@formatjs/intl-numberformat": "8.14.1",
"@formatjs/intl-pluralrules": "5.3.1",
"@formatjs/intl-relativetimeformat": "11.4.1",
"@formatjs/intl-datetimeformat": "6.16.4",
"@formatjs/intl-displaynames": "6.8.4",
"@formatjs/intl-getcanonicallocales": "2.5.2",
"@formatjs/intl-listformat": "7.7.4",
"@formatjs/intl-locale": "4.2.4",
"@formatjs/intl-numberformat": "8.14.4",
"@formatjs/intl-pluralrules": "5.3.4",
"@formatjs/intl-relativetimeformat": "11.4.4",
"@fullcalendar/core": "6.1.15",
"@fullcalendar/daygrid": "6.1.15",
"@fullcalendar/interaction": "6.1.15",
@@ -89,8 +89,8 @@
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.5.1",
"@vaadin/vaadin-themable-mixin": "24.5.1",
"@vaadin/combo-box": "24.5.3",
"@vaadin/vaadin-themable-mixin": "24.5.3",
"@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@@ -98,10 +98,10 @@
"@webcomponents/scoped-custom-element-registry": "0.0.9",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "2.2.11",
"barcode-detector": "2.3.1",
"chart.js": "4.4.6",
"color-name": "2.0.0",
"comlink": "4.4.1",
"comlink": "4.4.2",
"core-js": "3.39.0",
"cropperjs": "1.6.2",
"date-fns": "4.1.0",
@@ -115,13 +115,13 @@
"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.3",
"intl-messageformat": "10.7.6",
"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",
"lit": "2.8.0",
"luxon": "3.5.0",
"marked": "14.1.3",
"marked": "15.0.0",
"memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2",
@@ -153,25 +153,20 @@
},
"devDependencies": {
"@babel/core": "7.26.0",
"@babel/helper-define-polyfill-provider": "0.6.2",
"@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-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.16.0",
"@bundle-stats/plugin-webpack-filter": "4.17.0",
"@koa/cors": "5.0.0",
"@lokalise/node-api": "12.8.0",
"@octokit/auth-oauth-device": "7.1.1",
"@octokit/plugin-retry": "7.1.2",
"@octokit/rest": "21.0.2",
"@open-wc/dev-server-hmr": "0.1.4",
"@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-commonjs": "26.0.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-node-resolve": "15.2.4",
"@rollup/plugin-replace": "5.0.7",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.17",
"@types/chromecast-caf-receiver": "6.0.18",
"@types/chromecast-caf-sender": "1.0.10",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
@@ -191,13 +186,12 @@
"@typescript-eslint/eslint-plugin": "7.18.0",
"@typescript-eslint/parser": "7.18.0",
"@web/dev-server": "0.1.38",
"@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"chai": "5.1.2",
"del": "8.0.0",
"eslint": "8.57.1",
"eslint": "9.15.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-airbnb-typescript": "18.0.0",
"eslint-config-prettier": "9.1.0",
@@ -230,10 +224,6 @@
"open": "10.1.0",
"pinst": "3.0.0",
"prettier": "3.3.3",
"rollup": "2.79.2",
"rollup-plugin-string": "3.0.0",
"rollup-plugin-terser": "7.0.2",
"rollup-plugin-visualizer": "5.12.0",
"serve-handler": "6.1.6",
"sinon": "19.0.2",
"systemjs": "6.15.1",
@@ -247,7 +237,7 @@
"webpack-dev-server": "5.1.0",
"webpack-manifest-plugin": "5.0.0",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "6.0.1",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
},
"_comment": "Polymer 3.2 contained a bug, fixed in https://github.com/Polymer/polymer/pull/5569, add as patch",
@@ -257,7 +247,8 @@
"lit": "2.8.0",
"clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15"
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.12.0"
},
"packageManager": "yarn@4.5.1"
}

View File

@@ -1,10 +0,0 @@
import rollup from "../build-scripts/rollup.cjs";
import env from "../build-scripts/env.cjs";
const config = rollup.createAppConfig({
isProdBuild: env.isProdBuild(),
latestBuild: true,
isStatsBuild: env.isStatsBuild(),
});
export default { ...config.inputOptions, output: config.outputOptions };

View File

@@ -3,6 +3,7 @@ import "@material/mwc-button";
import { genClientId } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { keyed } from "lit/directives/keyed";
import { customElement, property, state } from "lit/decorators";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
@@ -224,16 +225,19 @@ export class HaAuthFlow extends LitElement {
: this.localize("ui.panel.page-authorize.just_checking")}
</h1>
${this._computeStepDescription(step)}
<ha-auth-form
.localize=${this.localize}
.data=${this._stepData!}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
.disabled=${this._submitting}
.computeLabel=${this._computeLabelCallback(step)}
.computeError=${this._computeErrorCallback(step)}
@value-changed=${this._stepDataChanged}
></ha-auth-form>
${keyed(
step.step_id,
html`<ha-auth-form
.localize=${this.localize}
.data=${this._stepData!}
.schema=${autocompleteLoginFields(step.data_schema)}
.error=${step.errors}
.disabled=${this._submitting}
.computeLabel=${this._computeLabelCallback(step)}
.computeError=${this._computeErrorCallback(step)}
@value-changed=${this._stepDataChanged}
></ha-auth-form>`
)}
${this.clientId === genClientId() &&
!["select_mfa_module", "mfa"].includes(step.step_id)
? html`

View File

@@ -54,6 +54,7 @@ export class HaAuthFormString extends HaFormString {
.autoValidate=${this.schema.required}
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${
this.isPassword
? // reserve some space for the icon.

View File

@@ -69,6 +69,7 @@ export class HaAuthTextField extends HaTextField {
name=${ifDefined(this.name === "" ? undefined : this.name)}
inputmode=${ifDefined(this.inputMode)}
autocapitalize=${ifDefined(autocapitalizeOrUndef)}
?autofocus=${this.autofocus}
@input=${this.handleInputChange}
@focus=${this.onInputFocus}
@blur=${this.onInputBlur}
@@ -246,6 +247,14 @@ export class HaAuthTextField extends HaTextField {
this.append(style);
return this;
}
public firstUpdated() {
super.firstUpdated();
if (this.autofocus) {
this.focus();
}
}
}
declare global {

View File

@@ -1,5 +1,6 @@
import type { HaDurationData } from "../../components/ha-duration-input";
import type { FrontendLocaleData } from "../../data/translation";
import { formatListWithAnds } from "../string/format-list";
const leftPad = (num: number) => (num < 10 ? `0${num}` : num);
@@ -42,3 +43,62 @@ export const formatDuration = (
}
return null;
};
export const formatDurationLong = (
locale: FrontendLocaleData,
duration: HaDurationData
) => {
const d = duration.days || 0;
const h = duration.hours || 0;
const m = duration.minutes || 0;
const s = duration.seconds || 0;
const ms = duration.milliseconds || 0;
const parts: string[] = [];
if (d > 0) {
parts.push(
Intl.NumberFormat(locale.language, {
style: "unit",
unit: "day",
unitDisplay: "long",
}).format(d)
);
}
if (h > 0) {
parts.push(
Intl.NumberFormat(locale.language, {
style: "unit",
unit: "hour",
unitDisplay: "long",
}).format(h)
);
}
if (m > 0) {
parts.push(
Intl.NumberFormat(locale.language, {
style: "unit",
unit: "minute",
unitDisplay: "long",
}).format(m)
);
}
if (s > 0) {
parts.push(
Intl.NumberFormat(locale.language, {
style: "unit",
unit: "second",
unitDisplay: "long",
}).format(s)
);
}
if (ms > 0) {
parts.push(
Intl.NumberFormat(locale.language, {
style: "unit",
unit: "millisecond",
unitDisplay: "long",
}).format(ms)
);
}
return formatListWithAnds(locale, parts);
};

View File

@@ -0,0 +1,91 @@
import type { HomeAssistant } from "../../types";
import type { IntegrationManifest } from "../../data/integration";
import { computeDomain } from "./compute_domain";
import { HELPERS_CRUD } from "../../data/helpers_crud";
import type { Helper } from "../../panels/config/helpers/const";
import { isHelperDomain } from "../../panels/config/helpers/const";
import { isComponentLoaded } from "../config/is_component_loaded";
import type { EntityRegistryEntry } from "../../data/entity_registry";
import { removeEntityRegistryEntry } from "../../data/entity_registry";
import type { ConfigEntry } from "../../data/config_entries";
import { deleteConfigEntry } from "../../data/config_entries";
export const isDeletableEntity = (
hass: HomeAssistant,
entity_id: string,
manifests: IntegrationManifest[],
entityRegistry: EntityRegistryEntry[],
configEntries: ConfigEntry[],
fetchedHelpers: Helper[]
): boolean => {
const restored = !!hass.states[entity_id]?.attributes.restored;
if (restored) {
return true;
}
const domain = computeDomain(entity_id);
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
if (isHelperDomain(domain)) {
return !!(
isComponentLoaded(hass, domain) &&
entityRegEntry &&
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
);
}
const configEntryId = entityRegEntry?.config_entry_id;
if (!configEntryId) {
return false;
}
const configEntry = configEntries.find((e) => e.entry_id === configEntryId);
return (
manifests.find((m) => m.domain === configEntry?.domain)
?.integration_type === "helper"
);
};
export const deleteEntity = (
hass: HomeAssistant,
entity_id: string,
manifests: IntegrationManifest[],
entityRegistry: EntityRegistryEntry[],
configEntries: ConfigEntry[],
fetchedHelpers: Helper[]
) => {
// This function assumes the entity_id already was validated by isDeletableEntity and does not repeat all those checks.
const domain = computeDomain(entity_id);
const entityRegEntry = entityRegistry.find((e) => e.entity_id === entity_id);
if (isHelperDomain(domain)) {
if (isComponentLoaded(hass, domain)) {
if (
entityRegEntry &&
fetchedHelpers.some((e) => e.id === entityRegEntry.unique_id)
) {
HELPERS_CRUD[domain].delete(hass, entityRegEntry.unique_id);
return;
}
}
const stateObj = hass.states[entity_id];
if (!stateObj?.attributes.restored) {
return;
}
removeEntityRegistryEntry(hass, entity_id);
return;
}
const configEntryId = entityRegEntry?.config_entry_id;
const configEntry = configEntryId
? configEntries.find((e) => e.entry_id === configEntryId)
: undefined;
const isHelperEntryType = configEntry
? manifests.find((m) => m.domain === configEntry.domain)
?.integration_type === "helper"
: false;
if (isHelperEntryType) {
deleteConfigEntry(hass, configEntryId!);
return;
}
removeEntityRegistryEntry(hass, entity_id);
};

View File

@@ -5,7 +5,7 @@ export const blankBeforePercent = (
localeOptions: FrontendLocaleData
): string => {
switch (localeOptions.language) {
case "cz":
case "cs":
case "de":
case "fi":
case "fr":

View File

@@ -16,6 +16,8 @@ export type LocalizeKeys =
| `ui.card.lawn_mower.actions.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`
| `ui.components.logbook.messages.cleared_device_classes.${string}`
| `ui.dialogs.entity_registry.editor.${string}`
| `ui.dialogs.more_info_control.lawn_mower.${string}`
| `ui.dialogs.more_info_control.vacuum.${string}`

View File

@@ -72,6 +72,12 @@ export class StatisticsChart extends LitElement {
@property() public chartType: ChartType = "line";
@property({ type: Number }) public minYAxis?: number;
@property({ type: Number }) public maxYAxis?: number;
@property({ type: Boolean }) public fitYData = false;
@property({ type: Boolean }) public hideLegend = false;
@property({ type: Boolean }) public logarithmicScale = false;
@@ -113,6 +119,9 @@ export class StatisticsChart extends LitElement {
changedProps.has("unit") ||
changedProps.has("period") ||
changedProps.has("chartType") ||
changedProps.has("minYAxis") ||
changedProps.has("maxYAxis") ||
changedProps.has("fitYData") ||
changedProps.has("logarithmicScale") ||
changedProps.has("hideLegend")
) {
@@ -232,6 +241,8 @@ export class StatisticsChart extends LitElement {
text: unit || this.unit,
},
type: this.logarithmicScale ? "logarithmic" : "linear",
min: this.fitYData ? null : this.minYAxis,
max: this.fitYData ? null : this.maxYAxis,
},
},
plugins: {

View File

@@ -113,7 +113,6 @@ class HaDataTableLabels extends LitElement {
ha-label {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
outline: 1px solid var(--outline-color);
}
ha-button-menu {
border-radius: 10px;

View File

@@ -8,7 +8,7 @@ export class HaCircularProgress extends MdCircularProgress {
@property({ attribute: "aria-label", type: String }) public ariaLabel =
"Loading";
@property() public size: "tiny" | "small" | "medium" | "large" = "medium";
@property() public size?: "tiny" | "small" | "medium" | "large";
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
@@ -21,7 +21,6 @@ export class HaCircularProgress extends MdCircularProgress {
case "small":
this.style.setProperty("--md-circular-progress-size", "28px");
break;
// medium is default size
case "medium":
this.style.setProperty("--md-circular-progress-size", "48px");
break;

View File

@@ -15,6 +15,10 @@ import {
startOfMonth,
startOfWeek,
startOfYear,
differenceInMilliseconds,
addMilliseconds,
subMilliseconds,
roundToNearestHours,
} from "date-fns";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -30,6 +34,8 @@ import "./date-range-picker";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import "./ha-icon-button-next";
import "./ha-icon-button-prev";
export interface DateRangePickerRanges {
[key: string]: [Date, Date];
@@ -249,6 +255,12 @@ export class HaDateRangePicker extends LitElement {
<div slot="input" class="date-range-inputs" @click=${this._handleClick}>
${!this.minimal
? html`<ha-svg-icon .path=${mdiCalendar}></ha-svg-icon>
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.previous")}
class="prev"
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-textfield
.value=${this.timePicker
? formatDateTime(
@@ -286,7 +298,13 @@ export class HaDateRangePicker extends LitElement {
.disabled=${this.disabled}
@click=${this._handleInputClick}
readonly
></ha-textfield>`
></ha-textfield>
<ha-icon-button-next
.label=${this.hass.localize("ui.common.next")}
class="next"
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
.label=${this.hass.localize(
"ui.components.date-range-picker.select_date_range"
@@ -317,6 +335,45 @@ export class HaDateRangePicker extends LitElement {
`;
}
private _handleNext(): void {
const dateRange = [
roundToNearestHours(this.endDate),
subMilliseconds(
roundToNearestHours(
addMilliseconds(
this.endDate,
Math.max(
3600000,
differenceInMilliseconds(this.endDate, this.startDate)
)
)
),
1
),
];
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _handlePrev(): void {
const dateRange = [
roundToNearestHours(
subMilliseconds(
this.startDate,
Math.max(
3600000,
differenceInMilliseconds(this.endDate, this.startDate)
)
)
),
subMilliseconds(roundToNearestHours(this.startDate), 1),
];
const dateRangePicker = this._dateRangePicker;
dateRangePicker.clickRange(dateRange);
dateRangePicker.clickedApply();
}
private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange = Object.values(this.ranges || this._ranges!)[
ev.detail.index
@@ -418,7 +475,9 @@ export class HaDateRangePicker extends LitElement {
min-width: inherit;
}
ha-svg-icon {
ha-svg-icon,
.prev,
.next {
display: none;
}
}

View File

@@ -1,6 +1,6 @@
import "@material/mwc-formfield";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type {
@@ -19,6 +19,8 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
@property() public label!: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@query("ha-checkbox", true) private _input?: HTMLElement;
@@ -37,6 +39,12 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
.disabled=${this.disabled}
@change=${this._valueChanged}
></ha-checkbox>
<span slot="label">
<p class="primary">${this.label}</p>
${this.helper
? html`<p class="secondary">${this.helper}</p>`
: nothing}
</span>
</mwc-formfield>
`;
}
@@ -46,6 +54,28 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
value: (ev.target as HaCheckbox).checked,
});
}
static get styles(): CSSResultGroup {
return css`
ha-formfield {
display: flex;
min-height: 56px;
align-items: center;
--mdc-typography-body2-font-size: 1em;
}
p {
margin: 0;
}
.secondary {
direction: var(--direction);
padding-top: 4px;
box-sizing: border-box;
color: var(--secondary-text-color);
font-size: 0.875rem;
font-weight: var(--mdc-typography-body2-font-weight, 400);
}
`;
}
}
declare global {

View File

@@ -1,4 +1,3 @@
/* eslint-disable lit/prefer-static-styles */
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";

View File

@@ -85,6 +85,7 @@ export interface HaFormStringSchema extends HaFormBaseSchema {
type: "string";
format?: string;
autocomplete?: string;
autofocus?: boolean;
}
export interface HaFormBooleanSchema extends HaFormBaseSchema {

View File

@@ -2,9 +2,7 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../panels/lovelace/editor/card-editor/ha-grid-layout-slider";
import "./ha-icon-button";
import { mdiRestore } from "@mdi/js";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
@@ -20,7 +18,7 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public rows = 8;
@property({ attribute: false }) public columns = 4;
@property({ attribute: false }) public columns = 12;
@property({ attribute: false }) public rowMin?: number;
@@ -32,6 +30,8 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public isDefault?: boolean;
@property({ attribute: false }) public step: number = 1;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@@ -51,8 +51,9 @@ export class HaGridSizeEditor extends LitElement {
const rowMin = this.rowMin ?? 1;
const rowMax = this.rowMax ?? this.rows;
const columnMin = this.columnMin ?? 1;
const columnMax = this.columnMax ?? this.columns;
const columnMin = Math.ceil((this.columnMin ?? 1) / this.step) * this.step;
const columnMax =
Math.ceil((this.columnMax ?? this.columns) / this.step) * this.step;
const rowValue = autoHeight ? rowMin : this._localValue?.rows;
const columnValue = this._localValue?.columns;
@@ -67,9 +68,11 @@ export class HaGridSizeEditor extends LitElement {
.max=${columnMax}
.range=${this.columns}
.value=${fullWidth ? this.columns : this.value?.columns}
.step=${this.step}
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledColumns}
tooltip-mode="always"
></ha-grid-layout-slider>
<ha-grid-layout-slider
@@ -85,6 +88,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledRows}
tooltip-mode="always"
></ha-grid-layout-slider>
${!this.isDefault
? html`
@@ -102,34 +106,44 @@ export class HaGridSizeEditor extends LitElement {
</ha-icon-button>
`
: nothing}
<div
class="preview ${classMap({ "full-width": fullWidth })}"
style=${styleMap({
"--total-rows": this.rows,
"--total-columns": this.columns,
"--rows": rowValue,
"--columns": fullWidth ? this.columns : columnValue,
})}
>
<div>
${Array(this.rows * this.columns)
<div class="preview">
<table>
${Array(this.rows)
.fill(0)
.map((_, index) => {
const row = Math.floor(index / this.columns) + 1;
const column = (index % this.columns) + 1;
const row = index + 1;
return html`
<div
class="cell"
data-row=${row}
data-column=${column}
@click=${this._cellClick}
></div>
<tr>
${Array(this.columns)
.fill(0)
.map((__, columnIndex) => {
const column = columnIndex + 1;
if (
column % this.step !== 0 ||
(this.columns > 24 && column % 3 !== 0)
) {
return nothing;
}
return html`
<td
data-row=${row}
data-column=${column}
@click=${this._cellClick}
></td>
`;
})}
</tr>
`;
})}
</div>
<div class="selected">
<div class="cell"></div>
</div>
</table>
<div
class="preview-card"
style=${styleMap({
"--rows": rowValue,
"--columns": fullWidth ? this.columns : columnValue,
"--total-columns": this.columns,
})}
></div>
</div>
</div>
`;
@@ -223,42 +237,40 @@ export class HaGridSizeEditor extends LitElement {
}
.reset {
grid-area: reset;
--mdc-icon-button-size: 36px;
}
.preview {
position: relative;
grid-area: preview;
}
.preview > div {
position: relative;
display: grid;
grid-template-columns: repeat(var(--total-columns), 1fr);
grid-template-rows: repeat(var(--total-rows), 25px);
gap: 4px;
.preview table,
.preview tr,
.preview td {
border: 2px dotted var(--divider-color);
border-collapse: collapse;
}
.preview .cell {
background-color: var(--disabled-color);
grid-column: span 1;
grid-row: span 1;
border-radius: 4px;
opacity: 0.2;
cursor: pointer;
}
.preview .selected {
position: absolute;
pointer-events: none;
top: 0;
left: 0;
height: 100%;
.preview table {
width: 100%;
}
.selected .cell {
background-color: var(--primary-color);
grid-column: 1 / span min(var(--columns, 0), var(--total-columns));
grid-row: 1 / span min(var(--rows, 0), var(--total-rows));
opacity: 0.5;
.preview tr {
height: 30px;
}
.preview.full-width .selected .cell {
grid-column: 1 / -1;
.preview td {
cursor: pointer;
}
.preview-card {
position: absolute;
top: 0;
left: 0;
background-color: var(--primary-color);
opacity: 0.3;
border-radius: 8px;
height: calc(var(--rows, 1) * 30px);
width: calc(var(--columns, 1) * 100% / var(--total-columns, 12));
pointer-events: none;
transition:
width ease-in-out 180ms,
height ease-in-out 180ms;
}
`,
];

View File

@@ -20,6 +20,8 @@ class HaHLSPlayer extends LitElement {
@property() public entityid?: string;
@property() public url?: string;
@property({ attribute: "poster-url" }) public posterUrl?: string;
@property({ type: Boolean, attribute: "controls" })
@@ -94,14 +96,19 @@ class HaHLSPlayer extends LitElement {
super.updated(changedProps);
const entityChanged = changedProps.has("entityid");
const urlChanged = changedProps.has("url");
if (!entityChanged) {
return;
if (entityChanged) {
this._getStreamUrlFromEntityId();
} else if (urlChanged && this.url) {
this._cleanUp();
this._resetError();
this._url = this.url;
this._startHls();
}
this._getStreamUrl();
}
private async _getStreamUrl(): Promise<void> {
private async _getStreamUrlFromEntityId(): Promise<void> {
this._cleanUp();
this._resetError();

View File

@@ -26,6 +26,7 @@ class HaLabel extends LitElement {
0.15
);
--ha-label-background-opacity: 1;
border: 1px solid var(--outline-color);
position: relative;
box-sizing: border-box;
display: inline-flex;

View File

@@ -164,8 +164,8 @@ export class HaMdDialog extends MdDialog {
min-width: 320px;
}
:host(:not([type="alert"])) {
@media all and (max-width: 450px), all and (max-height: 500px) {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host(:not([type="alert"])) {
min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
@@ -178,7 +178,7 @@ export class HaMdDialog extends MdDialog {
}
}
:host ::slotted(ha-dialog-header) {
::slotted(ha-dialog-header[slot="headline"]) {
display: contents;
}
@@ -190,7 +190,7 @@ export class HaMdDialog extends MdDialog {
padding: var(--dialog-content-padding, 24px);
}
.scrim {
z-index: 10; // overlay navigation
z-index: 10; /* overlay navigation */
}
`,
];

View File

@@ -117,8 +117,8 @@ export class HaPasswordField extends LitElement {
.autocapitalize=${this.autocapitalize}
.type=${this._unmaskedPassword ? "text" : "password"}
.suffix=${html`<div style="width: 24px"></div>`}
@input=${this._handleInputChange}
@change=${this._reDispatchEvent}
@input=${this._handleInputEvent}
@change=${this._handleChangeEvent}
></ha-textfield>
<ha-icon-button
toggles
@@ -153,11 +153,16 @@ export class HaPasswordField extends LitElement {
}
@eventOptions({ passive: true })
private _handleInputChange(ev) {
private _handleInputEvent(ev) {
this.value = ev.target.value;
}
@eventOptions({ passive: true })
private _handleChangeEvent(ev) {
this.value = ev.target.value;
this._reDispatchEvent(ev);
}
private _reDispatchEvent(oldEvent: Event) {
const newEvent = new Event(oldEvent.type, oldEvent);
this.dispatchEvent(newEvent);

View File

@@ -135,6 +135,10 @@ export class HaSortable extends LitElement {
const Sortable = (await import("../resources/sortable")).default;
const options: SortableInstance.Options = {
scroll: true,
// Force the autoscroll fallback because it works better than the native one
forceAutoScrollFallback: true,
scrollSpeed: 20,
animation: 150,
...this.options,
onChoose: this._handleChoose,

View File

@@ -130,6 +130,7 @@ export class HaYamlEditor extends LitElement {
this._yaml = ev.detail.value;
let parsed;
let isValid = true;
let errorMsg;
if (this._yaml) {
try {
@@ -137,6 +138,7 @@ export class HaYamlEditor extends LitElement {
} catch (err: any) {
// Invalid YAML
isValid = false;
errorMsg = `${this.hass.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this.hass.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
@@ -145,7 +147,11 @@ export class HaYamlEditor extends LitElement {
this.value = parsed;
this.isValid = isValid;
fireEvent(this, "value-changed", { value: parsed, isValid } as any);
fireEvent(this, "value-changed", {
value: parsed,
isValid,
errorMsg,
} as any);
}
get yaml() {

View File

@@ -18,6 +18,7 @@ import "../../panels/logbook/ha-logbook-renderer";
import { traceTabStyles } from "./trace-tab-styles";
import type { HomeAssistant } from "../../types";
import type { NodeInfo } from "./hat-script-graph";
import { describeCondition } from "../../data/automation_i18n";
const TRACE_PATH_TABS = [
"step_config",
@@ -121,6 +122,19 @@ export class HaTracePathDetails extends LitElement {
const data: ActionTraceStep[] = paths[curPath];
// Extract details from this.selected.config child properties used to add 'alias' (to headline), describeCondition and 'entity_id' (to result)
const nestPath = curPath
.substring(this.selected.path.length + 1)
.split("/");
let currentDetail = this.selected.config;
for (let i = 0; i < nestPath.length; i++) {
if (
!["undefined", "string"].includes(typeof currentDetail[nestPath[i]])
) {
currentDetail = currentDetail[nestPath[i]];
}
}
parts.push(
data.map((trace, idx) => {
const { path, timestamp, result, error, changed_variables, ...rest } =
@@ -134,7 +148,9 @@ export class HaTracePathDetails extends LitElement {
return html`
${curPath === this.selected.path
? ""
? currentDetail.alias
? html`<h2>${currentDetail.alias}</h2>`
: nothing
: html`<h2>
${curPath.substring(this.selected.path.length + 1)}
</h2>`}
@@ -146,6 +162,15 @@ export class HaTracePathDetails extends LitElement {
{ number: idx + 1 }
)}
</h3>`}
${curPath
.substring(this.selected.path.length + 1)
.includes("condition")
? html`[${describeCondition(
currentDetail,
this.hass,
currentDetail.alias
)}]<br />`
: nothing}
${this.hass!.localize(
"ui.panel.config.automation.trace.path.executed",
{
@@ -176,6 +201,12 @@ export class HaTracePathDetails extends LitElement {
${Object.keys(rest).length === 0
? nothing
: html`<pre>${dump(rest)}</pre>`}
${currentDetail.entity_id &&
curPath
.substring(this.selected.path.length + 1)
.includes("entity_id")
? html`<pre>entity: ${currentDetail.entity_id}</pre>`
: nothing}
`;
})
);

View File

@@ -30,11 +30,11 @@ export const autocompleteLoginFields = (schema: HaFormSchema[]) =>
if (field.type !== "string") return field;
switch (field.name) {
case "username":
return { ...field, autocomplete: "username" };
return { ...field, autocomplete: "username", autofocus: true };
case "password":
return { ...field, autocomplete: "current-password" };
case "code":
return { ...field, autocomplete: "one-time-code" };
return { ...field, autocomplete: "one-time-code", autofocus: true };
default:
return field;
}

View File

@@ -1,6 +1,9 @@
import type { HassConfig } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { formatDuration } from "../common/datetime/format_duration";
import {
formatDuration,
formatDurationLong,
} from "../common/datetime/format_duration";
import {
formatTime,
formatTimeWithSeconds,
@@ -720,6 +723,38 @@ const tryDescribeTrigger = (
}`;
}
// Calendar Trigger
if (trigger.trigger === "calendar") {
const calendarEntity = hass.states[trigger.entity_id]
? computeStateName(hass.states[trigger.entity_id])
: trigger.entity_id;
let offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
let offset: string | string[] = trigger.offset.startsWith("-")
? trigger.offset.substring(1).split(":")
: trigger.offset.split(":");
const duration = {
hours: offset.length > 0 ? +offset[0] : 0,
minutes: offset.length > 1 ? +offset[1] : 0,
seconds: offset.length > 2 ? +offset[2] : 0,
};
offset = formatDurationLong(hass.locale, duration);
if (offset === "") {
offsetChoice = "other";
}
return hass.localize(
`${triggerTranslationBaseKey}.calendar.description.full`,
{
eventChoice: trigger.event,
offsetChoice: offsetChoice,
offset: offset,
hasCalendar: trigger.entity_id ? "true" : "false",
calendar: calendarEntity,
}
);
}
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`

View File

@@ -1,36 +1,98 @@
import type { HomeAssistant } from "../types";
export interface BackupAgent {
agent_id: string;
}
export interface BackupContent {
slug: string;
backup_id: string;
date: string;
name: string;
protected: boolean;
size: number;
path: string;
agent_ids?: string[];
}
export interface BackupData {
backing_up: boolean;
export interface BackupInfo {
backups: BackupContent[];
backing_up: boolean;
}
export const getBackupDownloadUrl = (slug: string) =>
`/api/backup/download/${slug}`;
export interface BackupDetails {
backup: BackupContent;
}
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupData> =>
export interface BackupAgentsInfo {
agents: BackupAgent[];
}
export type GenerateBackupParams = {
agent_ids: string[];
database_included?: boolean;
folders_included?: string[];
addons_included?: string[];
name?: string;
password?: string;
};
export const getBackupDownloadUrl = (id: string, agentId: string) =>
`/api/backup/download/${id}?agent_id=${agentId}`;
export const fetchBackupInfo = (hass: HomeAssistant): Promise<BackupInfo> =>
hass.callWS({
type: "backup/info",
});
export const removeBackup = (
export const fetchBackupDetails = (
hass: HomeAssistant,
slug: string
): Promise<void> =>
id: string
): Promise<BackupDetails> =>
hass.callWS({
type: "backup/remove",
slug,
type: "backup/details",
backup_id: id,
});
export const generateBackup = (hass: HomeAssistant): Promise<BackupContent> =>
export const fetchBackupAgentsInfo = (
hass: HomeAssistant
): Promise<BackupAgentsInfo> =>
hass.callWS({
type: "backup/agents/info",
});
export const removeBackup = (hass: HomeAssistant, id: string): Promise<void> =>
hass.callWS({
type: "backup/remove",
backup_id: id,
});
export const generateBackup = (
hass: HomeAssistant,
params: GenerateBackupParams
): Promise<{ backup_id: string }> =>
hass.callWS({
type: "backup/generate",
...params,
});
export const uploadBackup = async (
hass: HomeAssistant,
file: File
): Promise<void> => {
const fd = new FormData();
fd.append("file", file);
const resp = await hass.fetchWithAuth("/api/backup/upload", {
method: "POST",
body: fd,
});
if (!resp.ok) {
throw new Error(`${resp.status} ${resp.statusText}`);
}
};
export const getPreferredAgentForDownload = (agents: string[]) => {
const localAgents = agents.filter(
(agent) => agent.split(".")[0] === "backup"
);
return localAgents[0] || agents[0];
};

View File

@@ -23,6 +23,7 @@ export const STATE_ATTRIBUTES = [
"state_class",
"supported_features",
"unit_of_measurement",
"available_tones",
];
export const TEMPERATURE_ATTRIBUTES = new Set([

View File

@@ -10,8 +10,6 @@ import type { LightColor } from "./light";
import { computeDomain } from "../common/entity/compute_domain";
import type { RegistryEntry } from "./registry";
export { subscribeEntityRegistryDisplay } from "./ws-entity_registry_display";
type EntityCategory = "config" | "diagnostic";
export interface EntityRegistryDisplayEntry {

View File

@@ -185,6 +185,15 @@ export const fetchHassioInfo = async (
export const fetchHassioBoots = async (hass: HomeAssistant) =>
hass.callApi<HassioResponse<HassioBoots>>("GET", `hassio/host/logs/boots`);
export const fetchHassioLogsLegacy = async (
hass: HomeAssistant,
provider: string
) =>
hass.callApi<string>(
"GET",
`hassio/${provider.includes("_") ? `addons/${provider}` : provider}/logs`
);
export const fetchHassioLogs = async (
hass: HomeAssistant,
provider: string,

View File

@@ -63,8 +63,8 @@ const triggerPhrases: Record<TriggerPhraseKeys, string> = {
triggered_by_numeric_state_of: "numeric state of", // number state trigger
triggered_by_state_of: "state of", // state trigger
triggered_by_event: "event", // event trigger
triggered_by_time: "time", // time trigger
triggered_by_time_pattern: "time pattern", // time trigger
triggered_by_time: "time", // time trigger
triggered_by_homeassistant_stopping: "Home Assistant stopping", // stop event
triggered_by_homeassistant_starting: "Home Assistant starting", // start event
};
@@ -218,114 +218,32 @@ export const localizeStateMessage = (
const isOff = state === BINARY_STATE_OFF;
const device_class = stateObj.attributes.device_class;
switch (device_class) {
case "battery":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_low`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_normal`);
}
break;
case "connectivity":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_connected`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_disconnected`);
}
break;
case "door":
case "garage_door":
case "opening":
case "window":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_opened`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_closed`);
}
break;
case "lock":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unlocked`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_locked`);
}
break;
case "plug":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_plugged_in`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unplugged`);
}
break;
case "presence":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_at_home`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_away`);
}
break;
case "safety":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_unsafe`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.was_safe`);
}
break;
case "cold":
case "gas":
case "heat":
case "moisture":
case "motion":
case "occupancy":
case "power":
case "problem":
case "smoke":
case "sound":
case "vibration":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_device_class`, {
if (device_class && (isOn || isOff)) {
return (
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_classes" : "cleared_device_classes"}.${device_class}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
),
) || device_class,
hass.language
),
});
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_device_class`, {
}
) ||
// If there's no key for a specific device class, fallback to generic string
localize(
`${LOGBOOK_LOCALIZE_PATH}.${isOn ? "detected_device_class" : "cleared_device_class"}`,
{
device_class: autoCaseNoun(
localize(
`component.binary_sensor.entity_component.${device_class}.name`
),
) || device_class,
hass.language
),
});
}
break;
case "tamper":
if (isOn) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.detected_tampering`);
}
if (isOff) {
return localize(`${LOGBOOK_LOCALIZE_PATH}.cleared_tampering`);
}
break;
}
)
);
}
break;

View File

@@ -1,4 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
HassEntity,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
@@ -871,3 +874,65 @@ export const computeCreateDomains = (
return [...new Set(createDomains)];
};
export const resolveEntityIDs = (
hass: HomeAssistant,
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] => {
if (!targetPickerValue) {
return [];
}
const targetSelector = { target: {} };
const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
const targetAreas = new Set(ensureArray(targetPickerValue.area_id));
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
labelId,
areas,
devices,
entities,
targetSelector
);
expanded.devices.forEach((id) => targetDevices.add(id));
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.areas.forEach((id) => targetAreas.add(id));
});
targetFloors.forEach((floorId) => {
const expanded = expandFloorTarget(hass, floorId, areas, targetSelector);
expanded.areas.forEach((id) => targetAreas.add(id));
});
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
areaId,
devices,
entities,
targetSelector
);
expanded.devices.forEach((id) => targetDevices.add(id));
expanded.entities.forEach((id) => targetEntities.add(id));
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
deviceId,
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
});
return Array.from(targetEntities);
};

7
src/data/siren.ts Normal file
View File

@@ -0,0 +1,7 @@
export const SirenEntityFeature = {
TURN_ON: 1,
TURN_OFF: 2,
TONES: 4,
VOLUME_SET: 8,
DURATION: 16,
};

View File

@@ -209,6 +209,17 @@ export interface ZWaveJSNodeStatus {
has_firmware_update_cc: boolean;
}
export type ZWaveJSNodeCapabilities = {
[endpoint: number]: ZWaveJSEndpointCapability[];
};
export interface ZWaveJSEndpointCapability {
id: number;
name: string;
version: number;
is_secure: boolean;
}
export interface ZwaveJSNodeMetadata {
node_id: number;
exclusion: string;
@@ -264,6 +275,15 @@ export interface ZWaveJSSetConfigParamData {
value: string | number;
}
export interface ZWaveJSSetRawConfigParamData {
type: string;
device_id: string;
property: number;
value: number;
value_size: number;
value_format: number;
}
export interface ZWaveJSSetConfigParamResult {
value_id?: string;
status?: string;
@@ -404,6 +424,25 @@ export interface RequestedGrant {
clientSideAuth: boolean;
}
export const invokeZWaveCCApi = (
hass: HomeAssistant,
device_id: string,
command_class: number,
endpoint: number | undefined,
method_name: string,
parameters: any[],
wait_for_result?: boolean
): Promise<unknown> =>
hass.callWS({
type: "zwave_js/invoke_cc_api",
device_id,
command_class,
endpoint,
method_name,
parameters,
wait_for_result,
});
export const fetchZwaveNetworkStatus = (
hass: HomeAssistant,
device_or_entry_id: {
@@ -579,6 +618,15 @@ export const fetchZwaveNodeStatus = (
device_id,
});
export const fetchZwaveNodeCapabilities = (
hass: HomeAssistant,
device_id: string
): Promise<ZWaveJSNodeCapabilities> =>
hass.callWS({
type: "zwave_js/node_capabilities",
device_id,
});
export const subscribeZwaveNodeStatus = (
hass: HomeAssistant,
device_id: string,
@@ -638,6 +686,36 @@ export const setZwaveNodeConfigParameter = (
return hass.callWS(data);
};
export const setZwaveNodeRawConfigParameter = (
hass: HomeAssistant,
device_id: string,
property: number,
value: number,
value_size: number,
value_format: number
): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetRawConfigParamData = {
type: "zwave_js/set_raw_config_parameter",
device_id,
property,
value,
value_size,
value_format,
};
return hass.callWS(data);
};
export const getZwaveNodeRawConfigParameter = (
hass: HomeAssistant,
device_id: string,
property: number
): Promise<number> =>
hass.callWS({
type: "zwave_js/get_raw_config_parameter",
device_id,
property,
});
export const reinterviewZwaveNode = (
hass: HomeAssistant,
device_id: string,

View File

@@ -0,0 +1,136 @@
import { mdiClose, mdiFolderUpload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-alert";
import "../../components/ha-file-upload";
import "../../components/ha-header-bar";
import "../../components/ha-dialog";
import "../../components/ha-icon-button";
import { uploadBackup } from "../../data/backup";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import type { HassDialog } from "../make-dialog-manager";
import type { BackupUploadDialogParams } from "./show-dialog-backup-upload";
const SUPPORTED_FORMAT = "application/x-tar";
@customElement("dialog-backup-upload")
export class DialogBackupUpload
extends LitElement
implements HassDialog<BackupUploadDialogParams>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _dialogParams?: BackupUploadDialogParams;
@state() private _uploading = false;
@state() private _error?: string;
public async showDialog(
dialogParams: BackupUploadDialogParams
): Promise<void> {
this._dialogParams = dialogParams;
await this.updateComplete;
}
public closeDialog(): void {
this._dialogParams = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._dialogParams || !this.hass) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
heading="Upload backup"
@closed=${this.closeDialog}
>
<div slot="heading">
<ha-header-bar>
<span slot="title"> Upload backup </span>
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="actionItems"
dialogAction="cancel"
dialogInitialFocus
></ha-icon-button>
</ha-header-bar>
</div>
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
accept=${SUPPORTED_FORMAT}
label="Upload a backup"
supports="Supports .tar files"
@file-picked=${this._uploadFile}
></ha-file-upload>
${this._error
? html`<ha-alert alertType="error">${this._error}</ha-alert>`
: nothing}
</ha-dialog>
`;
}
private async _uploadFile(ev: CustomEvent<{ files: File[] }>): Promise<void> {
this._error = undefined;
const file = ev.detail.files[0];
if (file.type !== SUPPORTED_FORMAT) {
showAlertDialog(this, {
title: "Unsupported file format",
text: "Please choose a Home Assistant backup file (.tar)",
confirmText: "ok",
});
return;
}
this._uploading = true;
try {
await uploadBackup(this.hass!, file);
this._dialogParams!.onUploadComplete();
this.closeDialog();
} catch (err: any) {
this._error = err.message;
} finally {
this._uploading = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
flex-shrink: 0;
}
/* overrule the ha-style-dialog max-height on small screens */
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-header-bar {
--mdc-theme-primary: var(--app-header-background-color);
--mdc-theme-on-primary: var(--app-header-text-color, white);
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-backup-upload": DialogBackupUpload;
}
}

View File

@@ -0,0 +1,17 @@
import { fireEvent } from "../../common/dom/fire_event";
import "./dialog-backup-upload";
export interface BackupUploadDialogParams {
onUploadComplete: () => void;
}
export const showBackupUploadDialog = (
element: HTMLElement,
dialogParams: BackupUploadDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-backup-upload",
dialogImport: () => import("./dialog-backup-upload"),
dialogParams,
});
};

View File

@@ -0,0 +1,224 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiClose, mdiPlay, mdiStop } from "@mdi/js";
import type { HomeAssistant } from "../../../../types";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import {
getMobileCloseToBottomAnimation,
getMobileOpenFromBottomAnimation,
} from "../../../../components/ha-md-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-button";
import "../../../../components/ha-textfield";
import "../../../../components/ha-control-button";
import "../../../../components/ha-select";
import "../../../../components/ha-list-item";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import { fireEvent } from "../../../../common/dom/fire_event";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import { SirenEntityFeature } from "../../../../data/siren";
import { haStyle } from "../../../../resources/styles";
@customElement("ha-more-info-siren-advanced-controls")
class MoreInfoSirenAdvancedControls extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() _stateObj?: HassEntity;
@state() _tone?: string;
@state() _volume?: number;
@state() _duration?: number;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog({ stateObj }: { stateObj: HassEntity }) {
this._stateObj = stateObj;
}
public closeDialog(): void {
this._dialog?.close();
}
private _dialogClosed(): void {
this._stateObj = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
render() {
if (!this._stateObj) {
return nothing;
}
const supportsTones =
supportsFeature(this._stateObj, SirenEntityFeature.TONES) &&
this._stateObj.attributes.available_tones;
const supportsVolume = supportsFeature(
this._stateObj,
SirenEntityFeature.VOLUME_SET
);
const supportsDuration = supportsFeature(
this._stateObj,
SirenEntityFeature.DURATION
);
return html`
<ha-md-dialog
open
@closed=${this._dialogClosed}
aria-labelledby="dialog-light-color-favorite-title"
.getOpenAnimation=${getMobileOpenFromBottomAnimation}
.getCloseAnimation=${getMobileCloseToBottomAnimation}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title" id="dialog-light-color-favorite-title"
>${this.hass.localize(
"ui.components.siren.advanced_controls"
)}</span
>
</ha-dialog-header>
<div slot="content">
<div class="options">
${supportsTones
? html`
<ha-select
.label=${this.hass.localize("ui.components.siren.tone")}
@closed=${stopPropagation}
@change=${this._handleToneChange}
.value=${this._tone}
>
${Object.entries(
this._stateObj.attributes.available_tones
).map(
([toneId, toneName]) => html`
<ha-list-item .value=${toneId}
>${toneName}</ha-list-item
>
`
)}
</ha-select>
`
: nothing}
${supportsVolume
? html`
<ha-textfield
type="number"
.label=${this.hass.localize("ui.components.siren.volume")}
.suffix=${"%"}
.value=${this._volume ? this._volume * 100 : undefined}
@change=${this._handleVolumeChange}
.min=${0}
.max=${100}
.step=${1}
></ha-textfield>
`
: nothing}
${supportsDuration
? html`
<ha-textfield
type="number"
.label=${this.hass.localize("ui.components.siren.duration")}
.value=${this._duration}
suffix="s"
@change=${this._handleDurationChange}
></ha-textfield>
`
: nothing}
</div>
<div class="controls">
<ha-control-button
.label=${this.hass.localize("ui.card.common.turn_on")}
@click=${this._turnOn}
>
<ha-svg-icon .path=${mdiPlay}></ha-svg-icon>
</ha-control-button>
<ha-control-button
.label=${this.hass.localize("ui.card.common.turn_off")}
@click=${this._turnOff}
>
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
</ha-control-button>
</div>
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</div>
</ha-md-dialog>
`;
}
private _handleToneChange(ev) {
this._tone = ev.target.value;
}
private _handleVolumeChange(ev) {
this._volume = parseFloat(ev.target.value) / 100;
if (isNaN(this._volume)) {
this._volume = undefined;
}
}
private _handleDurationChange(ev) {
this._duration = parseInt(ev.target.value);
if (isNaN(this._duration)) {
this._duration = undefined;
}
}
private async _turnOn() {
await this.hass.callService("siren", "turn_on", {
entity_id: this._stateObj!.entity_id,
tone: this._tone,
volume: this._volume,
duration: this._duration,
});
}
private async _turnOff() {
await this.hass.callService("siren", "turn_off", {
entity_id: this._stateObj!.entity_id,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.options {
display: flex;
flex-direction: column;
gap: 16px;
}
.controls {
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
margin-top: 16px;
}
ha-control-button {
--control-button-border-radius: 16px;
--mdc-icon-size: 24px;
width: 64px;
height: 64px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-siren-advanced-controls": MoreInfoSirenAdvancedControls;
}
}

View File

@@ -0,0 +1,18 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadSirenAdvancedControlsView = () =>
import("./ha-more-info-siren-advanced-controls");
export const showSirenAdvancedControlsView = (
element: HTMLElement,
stateObj: HassEntity
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-more-info-siren-advanced-controls",
dialogImport: loadSirenAdvancedControlsView,
dialogParams: {
stateObj,
},
});
};

View File

@@ -4,14 +4,20 @@ import { property, state } from "lit/decorators";
import "../../../components/ha-camera-stream";
import type { CameraEntity } from "../../../data/camera";
import type { HomeAssistant } from "../../../types";
import "../../../components/buttons/ha-progress-button";
import { UNAVAILABLE } from "../../../data/entity";
import { fileDownload } from "../../../util/file_download";
import { showToast } from "../../../util/toast";
class MoreInfoCamera extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: CameraEntity;
@state() private _attached = false;
@state() private _waiting = false;
public connectedCallback() {
super.connectedCallback();
this._attached = true;
@@ -23,7 +29,7 @@ class MoreInfoCamera extends LitElement {
}
protected render() {
if (!this._attached || !this.hass || !this.stateObj) {
if (!this._attached || !this.stateObj) {
return nothing;
}
@@ -34,14 +40,70 @@ class MoreInfoCamera extends LitElement {
allow-exoplayer
controls
></ha-camera-stream>
<div class="actions">
<ha-progress-button
@click=${this._downloadSnapshot}
.progress=${this._waiting}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
${this.hass.localize(
"ui.dialogs.more_info_control.camera.download_snapshot"
)}
</ha-progress-button>
</div>
`;
}
private async _downloadSnapshot(ev: CustomEvent) {
const button = ev.currentTarget as any;
this._waiting = true;
try {
const result: Response | undefined = await this.hass.callApiRaw(
"GET",
`camera_proxy/${this.stateObj!.entity_id}`
);
if (!result) {
throw new Error("No response from API");
}
const blob = await result.blob();
const url = window.URL.createObjectURL(blob);
fileDownload(url);
} catch (err) {
this._waiting = false;
button.actionError();
showToast(this, {
message: this.hass.localize(
"ui.dialogs.more_info_control.camera.failed_to_download"
),
});
return;
}
this._waiting = false;
button.actionSuccess();
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
.actions {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-end;
box-sizing: border-box;
padding: 12px;
z-index: 1;
gap: 8px;
}
`;
}
}

View File

@@ -5,9 +5,13 @@ import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import "../../../state-control/ha-state-control-toggle";
import "../../../components/ha-button";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { SirenEntityFeature } from "../../../data/siren";
import { showSirenAdvancedControlsView } from "../components/siren/show-dialog-siren-advanced-controls";
@customElement("more-info-siren")
class MoreInfoSiren extends LitElement {
@@ -20,6 +24,20 @@ class MoreInfoSiren extends LitElement {
return nothing;
}
const supportsTones =
supportsFeature(this.stateObj, SirenEntityFeature.TONES) &&
this.stateObj.attributes.available_tones;
const supportsVolume = supportsFeature(
this.stateObj,
SirenEntityFeature.VOLUME_SET
);
const supportsDuration = supportsFeature(
this.stateObj,
SirenEntityFeature.DURATION
);
// show advanced controls dialog if extra features are supported
const allowAdvanced = supportsTones || supportsVolume || supportsDuration;
return html`
<ha-more-info-state-header
.hass=${this.hass}
@@ -32,6 +50,11 @@ class MoreInfoSiren extends LitElement {
.iconPathOn=${mdiVolumeHigh}
.iconPathOff=${mdiVolumeOff}
></ha-state-control-toggle>
${allowAdvanced
? html`<ha-button @click=${this._showAdvancedControlsDialog}>
${this.hass.localize("ui.components.siren.advanced_controls")}
</ha-button>`
: nothing}
</div>
<ha-attributes
.hass=${this.hass}
@@ -40,6 +63,10 @@ class MoreInfoSiren extends LitElement {
`;
}
private _showAdvancedControlsDialog() {
showSirenAdvancedControlsView(this, this.stateObj!);
}
static get styles(): CSSResultGroup {
return moreInfoControlStyle;
}

View File

@@ -9,16 +9,8 @@
<script>
(function() {
if (!window.latestJS) {
<% if (useRollup) { %>
_ls("/static/js/s.min.js").onload = function() {
<% for (const entry of es5EntryJS) { %>
System.import("<%= entry %>");
<% } %>
}
<% } else { %>
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
})();

View File

@@ -97,16 +97,8 @@
<script>
if (!window.latestJS) {
window.customPanelJS = "<%= es5CustomPanelJS %>";
<% if (useRollup) { %>
_ls("/static/js/s.min.js").onload = function() {
<% for (const entry of es5EntryJS) { %>
System.import("<%= entry %>");
<% } %>
}
<% } else { %>
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
<% for (const entry of es5EntryJS) { %>
_ls("<%= entry %>", true);
<% } %>
}
</script>

View File

@@ -456,6 +456,7 @@ export class HaTabsSubpageDataTable extends LitElement {
${!this.narrow
? html`
<div slot="header">
<slot name="top_header"></slot>
<slot name="header">
<div class="table-header">
${this.hasFilters && !this.showFilters

View File

@@ -173,7 +173,7 @@ class DialogCalendarEventEditor extends LitElement {
.label=${this.hass.localize("ui.components.calendar.event.summary")}
.value=${this._summary}
required
@change=${this._handleSummaryChanged}
@input=${this._handleSummaryChanged}
.validationMessage=${this.hass.localize("ui.common.error_required")}
dialogInitialFocus
></ha-textfield>

View File

@@ -103,6 +103,8 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
@state() private _errors?: string;
@state() private _yamlErrors?: string;
@state() private _entityId?: string;
@state() private _mode: "gui" | "yaml" = "gui";
@@ -629,15 +631,17 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
private _yamlChanged(ev: CustomEvent) {
ev.stopPropagation();
this._dirty = true;
if (!ev.detail.isValid) {
this._yamlErrors = ev.detail.errorMsg;
return;
}
this._yamlErrors = undefined;
this._config = {
id: this._config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._errors = undefined;
this._dirty = true;
}
private async confirmUnsavedChanged(): Promise<boolean> {
@@ -752,7 +756,21 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
}
}
private _switchUiMode() {
private async _switchUiMode() {
if (this._yamlErrors) {
const result = await showConfirmationDialog(this, {
text: html`${this.hass.localize(
"ui.panel.config.automation.editor.switch_ui_yaml_error"
)}<br /><br />${this._yamlErrors}`,
confirmText: this.hass!.localize("ui.common.continue"),
destructive: true,
dismissText: this.hass!.localize("ui.common.cancel"),
});
if (!result) {
return;
}
}
this._yamlErrors = undefined;
this._mode = "gui";
}
@@ -792,6 +810,13 @@ export class HaAutomationEditor extends KeyboardShortcutMixin(LitElement) {
}
private async _saveAutomation(): Promise<void> {
if (this._yamlErrors) {
showToast(this, {
message: this._yamlErrors,
});
return;
}
const id = this.automationId || String(Date.now());
if (!this.automationId) {
const saved = await this._promptAutomationAlias();

View File

@@ -0,0 +1,104 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-checkbox";
import "../../../../components/ha-formfield";
import type { BackupAgent } from "../../../../data/backup";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import { domainToName } from "../../../../data/integration";
@customElement("ha-backup-agents-select")
class HaBackupAgentsSelect extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant;
@property({ type: Boolean })
public disabled = false;
@property({ attribute: false })
public agents!: BackupAgent[];
@property({ attribute: false })
public disabledAgents?: string[];
@property({ attribute: false })
public value!: string[];
render() {
return html`
<div class="agents">
${this.agents.map((agent) => this._renderAgent(agent))}
</div>
`;
}
private _renderAgent(agent: BackupAgent) {
const [domain, name] = agent.agent_id.split(".");
const domainName = domainToName(this.hass.localize, domain);
return html`
<ha-formfield>
<span class="label" slot="label">
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
${domainName}: ${name}</span
>
<ha-checkbox
.checked=${this.value.includes(agent.agent_id)}
.value=${agent.agent_id}
.disabled=${this.disabled ||
this.disabledAgents?.includes(agent.agent_id)}
@change=${this._checkboxChanged}
></ha-checkbox>
</ha-formfield>
`;
}
private _checkboxChanged(ev: Event) {
const checkbox = ev.target as HTMLInputElement;
const value = checkbox.value;
const index = this.value.indexOf(value);
if (checkbox.checked && index === -1) {
this.value = [...this.value, value];
} else if (!checkbox.checked && index !== -1) {
this.value = [
...this.value.slice(0, index),
...this.value.slice(index + 1),
];
}
fireEvent(this, "value-changed", { value: this.value });
}
static styles = css`
img {
height: 24px;
width: 24px;
}
.agents {
display: flex;
flex-direction: column;
}
.label {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-agents-select": HaBackupAgentsSelect;
}
}

View File

@@ -0,0 +1,149 @@
import {
mdiAlertCircleCheckOutline,
mdiAlertOutline,
mdiCheck,
mdiInformationOutline,
mdiSync,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-circular-progress";
import "../../../../components/ha-icon";
type SummaryStatus = "success" | "error" | "info" | "warning" | "loading";
const ICONS: Record<SummaryStatus, string> = {
success: mdiCheck,
error: mdiAlertCircleCheckOutline,
warning: mdiAlertOutline,
info: mdiInformationOutline,
loading: mdiSync,
};
@customElement("ha-backup-summary-card")
class HaBackupSummaryCard extends LitElement {
@property()
public title!: string;
@property()
public description!: string;
@property({ type: Boolean, attribute: "has-action" })
public hasAction = false;
@property()
public status: SummaryStatus = "info";
render() {
return html`
<ha-card outlined>
<div class="summary">
${this.status === "loading"
? html`<ha-circular-progress indeterminate></ha-circular-progress>`
: html`
<div class="icon ${this.status}">
<ha-svg-icon .path=${ICONS[this.status]}></ha-svg-icon>
</div>
`}
<div class="content">
<p class="title">${this.title}</p>
<p class="description">${this.description}</p>
</div>
${this.hasAction
? html`
<div class="action">
<slot name="action"></slot>
</div>
`
: nothing}
</div>
</ha-card>
`;
}
static styles = css`
.summary {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
padding: 20px;
width: 100%;
box-sizing: border-box;
}
.icon {
position: relative;
border-radius: 20px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
--icon-color: var(--primary-color);
}
.icon.success {
--icon-color: var(--success-color);
}
.icon.warning {
--icon-color: var(--warning-color);
}
.icon.error {
--icon-color: var(--error-color);
}
.icon::before {
display: block;
content: "";
position: absolute;
inset: 0;
background-color: var(--icon-color, var(--primary-color));
opacity: 0.2;
}
.icon ha-svg-icon {
color: var(--icon-color, var(--primary-color));
width: 24px;
height: 24px;
}
ha-circular-progress {
--md-circular-progress-size: 40px;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.title {
font-size: 22px;
font-style: normal;
font-weight: 400;
line-height: 28px;
color: var(--primary-text-color);
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.description {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.25px;
color: var(--secondary-text-color);
margin: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-backup-summary-card": HaBackupSummaryCard;
}
}

View File

@@ -0,0 +1,375 @@
import {
mdiChartBox,
mdiClose,
mdiCog,
mdiFolder,
mdiPlayBoxMultiple,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import type { BackupAgent } from "../../../../data/backup";
import { fetchBackupAgentsInfo, generateBackup } from "../../../../data/backup";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../components/ha-backup-agents-select";
import type { GenerateBackupDialogParams } from "./show-dialog-generate-backup";
type FormData = {
name: string;
history: boolean;
media: boolean;
share: boolean;
addons_mode: "all" | "custom";
addons: string[];
agents_mode: "all" | "custom";
agents: string[];
};
const INITIAL_FORM_DATA: FormData = {
name: "",
history: true,
media: false,
share: false,
addons_mode: "all",
addons: [],
agents_mode: "all",
agents: [],
};
const STEPS = ["data", "sync"] as const;
@customElement("ha-dialog-generate-backup")
class DialogGenerateBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _formData?: FormData;
@state() private _step?: "data" | "sync";
@state() private _agents: BackupAgent[] = [];
@state() private _params?: GenerateBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(_params: GenerateBackupDialogParams): void {
this._step = STEPS[0];
this._formData = INITIAL_FORM_DATA;
this._params = _params;
this._fetchAgents();
}
private _dialogClosed() {
if (this._params!.cancel) {
this._params!.cancel();
}
this._step = undefined;
this._formData = undefined;
this._agents = [];
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private async _fetchAgents() {
const { agents } = await fetchBackupAgentsInfo(this.hass);
this._agents = agents;
}
public closeDialog() {
this._dialog?.close();
}
private _previousStep() {
const index = STEPS.indexOf(this._step!);
if (index === 0) {
return;
}
this._step = STEPS[index - 1];
}
private _nextStep() {
const index = STEPS.indexOf(this._step!);
if (index === STEPS.length - 1) {
return;
}
this._step = STEPS[index + 1];
}
protected render() {
if (!this._step || !this._formData) {
return nothing;
}
const dialogTitle =
this._step === "sync" ? "Synchronization" : "Backup data";
const isFirstStep = this._step === STEPS[0];
const isLastStep = this._step === STEPS[STEPS.length - 1];
return html`
<ha-md-dialog open disable-cancel-action @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
${isFirstStep
? html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`
: html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`}
<span slot="title" .title=${dialogTitle}> ${dialogTitle} </span>
</ha-dialog-header>
<div slot="content" class="content">
${this._step === "data" ? this._renderData() : this._renderSync()}
</div>
<div slot="actions">
${isFirstStep
? html`<ha-button @click=${this.closeDialog}>Cancel</ha-button>`
: nothing}
${isLastStep
? html`<ha-button @click=${this._submit}>Create backup</ha-button>`
: html`<ha-button @click=${this._nextStep}>Next</ha-button>`}
</div>
</ha-md-dialog>
`;
}
private _renderData() {
if (!this._formData) {
return nothing;
}
return html`
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiCog}></ha-svg-icon>
<span slot="heading">Home Assistant settings</span>
<span slot="description">
With these settings you are able to restore your system.
</span>
<ha-switch disabled checked></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiChartBox}></ha-svg-icon>
<span slot="heading">History</span>
<span slot="description">For example of your energy dashboard.</span>
<ha-switch
id="history"
name="history"
@change=${this._switchChanged}
.checked=${this._formData.history}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiPlayBoxMultiple}></ha-svg-icon>
<span slot="heading">Media</span>
<span slot="description">
Folder that is often used for advanced or older configurations.
</span>
<ha-switch
id="media"
name="media"
@change=${this._switchChanged}
.checked=${this._formData.media}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<ha-svg-icon slot="prefix" .path=${mdiFolder}></ha-svg-icon>
<span slot="heading">Share folder</span>
<span slot="description">
Folder that is often used for advanced or older configurations.
</span>
<ha-switch
id="share"
name="share"
@change=${this._switchChanged}
.checked=${this._formData.share}
></ha-switch>
</ha-settings-row>
`;
}
private _renderSync() {
if (!this._formData) {
return nothing;
}
return html`
<ha-textfield
name="name"
.label=${"Backup name"}
.value=${this._formData.name}
@change=${this._nameChanged}
>
</ha-textfield>
<ha-settings-row>
<span slot="heading">Locations</span>
<span slot="description">
What locations you want to automatically backup to.
</span>
<ha-md-select
@change=${this._agentModeChanged}
.value=${this._formData.agents_mode}
>
<ha-md-select-option value="all">
<div slot="headline">All (${this._agents.length})</div>
</ha-md-select-option>
<ha-md-select-option value="custom">
<div slot="headline">Custom</div>
</ha-md-select-option>
</ha-md-select>
</ha-settings-row>
${this._formData.agents_mode === "custom"
? html`
<ha-expansion-panel .header=${"Location"} outlined expanded>
<ha-backup-agents-select
.hass=${this.hass}
.value=${this._formData.agents}
@value-changed=${this._agentsChanged}
.agents=${this._agents}
></ha-backup-agents-select>
</ha-expansion-panel>
`
: nothing}
`;
}
private _agentModeChanged(ev) {
const select = ev.currentTarget;
this._formData = {
...this._formData!,
agents_mode: select.value,
};
}
private _agentsChanged(ev) {
this._formData = {
...this._formData!,
agents: ev.detail.value,
};
}
private _switchChanged(ev) {
const _switch = ev.currentTarget;
this._formData = {
...this._formData!,
[_switch.id]: _switch.checked,
};
}
private _nameChanged(ev) {
this._formData = {
...this._formData!,
name: ev.target.value,
};
}
private async _submit() {
if (!this._formData) {
return;
}
const {
addons,
addons_mode,
agents,
agents_mode,
history,
media,
name,
share,
} = this._formData;
const folders: string[] = [];
if (media) {
folders.push("media");
}
if (share) {
folders.push("share");
}
// TODO: Fetch all addons
const ALL_ADDONS = [];
const { backup_id } = await generateBackup(this.hass, {
name,
agent_ids:
agents_mode === "all"
? this._agents.map((agent) => agent.agent_id)
: agents,
database_included: history,
folders_included: folders,
addons_included: addons_mode === "all" ? ALL_ADDONS : addons,
});
this._params!.submit?.({ backup_id });
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
:host {
--dialog-content-overflow: visible;
}
ha-md-dialog {
--dialog-content-padding: 24px;
}
ha-settings-row {
--settings-row-prefix-display: flex;
padding: 0;
}
ha-settings-row > ha-svg-icon {
align-self: center;
margin-inline-end: 16px;
}
ha-settings-row > ha-md-select {
min-width: 150px;
}
ha-settings-row > ha-md-select > span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
ha-settings-row > ha-md-select-option {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
ha-textfield {
width: 100%;
}
.content {
padding-top: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-generate-backup": DialogGenerateBackup;
}
}

View File

@@ -0,0 +1,147 @@
import { mdiBackupRestore, mdiClose, mdiCogs } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { NewBackupDialogParams } from "./show-dialog-new-backup";
@customElement("ha-dialog-new-backup")
class DialogNewBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _params?: NewBackupDialogParams;
public showDialog(params: NewBackupDialogParams): void {
this._opened = true;
this._params = params;
}
public closeDialog(): void {
if (this._params!.cancel) {
this._params!.cancel();
}
if (this._opened) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._params = undefined;
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
const heading = "New backup";
return html`
<ha-md-dialog
open
@closed=${this.closeDialog}
aria-labelledby="dialog-box-title"
aria-describedby="dialog-box-description"
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title" id="dialog-light-color-favorite-title">
${heading}
</span>
</ha-dialog-header>
<div slot="content">
<ha-md-list
innerRole="listbox"
itemRoles="option"
innerAriaLabel=${heading}
rootTabbable
dialogInitialFocus
>
<ha-md-list-item @click=${this._automatic} type="button">
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
<span slot="headline">Use automatic backup settings</span>
<span slot="supporting-text">
Trigger a backup using the configured settings for automatic backups
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
<ha-md-list-item @click=${this._manual} type="button">
<ha-svg-icon slot="start" .path=${mdiCogs}></ha-svg-icon>
<span slot="headline"> Create a manual backup</span>
<span slot="supporting-text">
Create a backup with custom settings (e.g. specific add-ons,
database, etc.)
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-md-dialog>
`;
}
private async _manual() {
this._params!.submit?.("manual");
this.closeDialog();
}
private async _automatic() {
this._params!.submit?.("automatic");
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 0;
max-width: 500px;
}
div[slot="content"] {
margin-top: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
ha-md-list {
background: none;
}
ha-md-list-item {
}
ha-icon-next {
width: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-new-backup": DialogNewBackup;
}
}

View File

@@ -0,0 +1,37 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface GenerateBackupDialogParams {
submit?: (response: { backup_id: string }) => void;
cancel?: () => void;
}
export const loadGenerateBackupDialog = () =>
import("./dialog-generate-backup");
export const showGenerateBackupDialog = (
element: HTMLElement,
params: GenerateBackupDialogParams
) =>
new Promise<{ backup_id: string } | null>((resolve) => {
const origCancel = params.cancel;
const origSubmit = params.submit;
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-generate-backup",
dialogImport: loadGenerateBackupDialog,
dialogParams: {
...params,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (response) => {
resolve(response);
if (origSubmit) {
origSubmit(response);
}
},
},
});
});

View File

@@ -0,0 +1,37 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export type NewBackupType = "automatic" | "manual";
export interface NewBackupDialogParams {
submit?: (type: NewBackupType) => void;
cancel?: () => void;
}
export const loadNewBackupDialog = () => import("./dialog-new-backup");
export const showNewBackupDialog = (
element: HTMLElement,
params: NewBackupDialogParams
) =>
new Promise<NewBackupType | null>((resolve) => {
const origCancel = params.cancel;
const origSubmit = params.submit;
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-new-backup",
dialogImport: loadNewBackupDialog,
dialogParams: {
...params,
cancel: () => {
resolve(null);
if (origCancel) {
origCancel();
}
},
submit: (response) => {
resolve(response);
if (origSubmit) {
origSubmit(response);
}
},
},
});
});

View File

@@ -0,0 +1,132 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import "../../../components/ha-switch";
import "../../../components/ha-select";
import "../../../components/ha-button";
import "../../../components/ha-list-item";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-backup-automatic-config")
class HaConfigBackupAutomaticConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render(): TemplateResult {
return html`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${"Automatic backups"}
>
<div class="content">
<ha-card>
<div class="card-header">Automation</div>
<div class="card-content">
<ha-settings-row>
<span slot="heading">Schedule</span>
<span slot="description">
How often you want to create a backup.
</span>
<ha-select naturalMenuWidth>
<ha-list-item>Daily at 02:00</ha-list-item>
</ha-select>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Maximum copies</span>
<span slot="description">
The number of backups that are saved
</span>
<ha-select naturalMenuWidth>
<ha-list-item>Latest 3 copies</ha-list-item>
</ha-select>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Locations</span>
<span slot="description">
What locations you want to automatically backup to.
</span>
<ha-button> Configure </ha-button>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Password</span>
<span slot="description">
Automatic backups are protected with this password
</span>
<ha-switch></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Custom backup name</span>
<span slot="description">
By default it will use the date and description (2024-07-05
Automatic backup).
</span>
<ha-switch></ha-switch>
</ha-settings-row>
</div>
</ha-card>
<ha-card>
<div class="card-header">Backup data</div>
<div class="card-content">
<ha-settings-row>
<span slot="heading">
Home Assistant settings is always included
</span>
<span slot="description">
With these settings you are able to restore your system.
</span>
<ha-button>Learn more</ha-button>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">History</span>
<span slot="description">
For example of your energy dashboard.
</span>
<ha-switch></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Media</span>
<span slot="description">For example camera recordings.</span>
<ha-switch></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">Add-ons</span>
<span slot="description">
Select what add-ons you want to backup.
</span>
<ha-select naturalMenuWidth>
<ha-list-item>All, including new (4)</ha-list-item>
</ha-select>
</ha-settings-row>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: flex;
flex-direction: column;
}
.card-content {
padding: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-automatic-config": HaConfigBackupAutomaticConfig;
}
}

View File

@@ -0,0 +1,424 @@
import { mdiDelete, mdiDownload, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { relativeTime } from "../../../common/datetime/relative_time";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import {
fetchBackupInfo,
getBackupDownloadUrl,
removeBackup,
type BackupContent,
getPreferredAgentForDownload,
} from "../../../data/backup";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "./components/ha-backup-summary-card";
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
@customElement("ha-config-backup-dashboard")
class HaConfigBackupDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _backingUp = false;
@state() private _backups: BackupContent[] = [];
@state() private _selected: string[] = [];
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<BackupContent> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
template: (backup) => backup.name,
},
size: {
title: localize("ui.panel.config.backup.size"),
filterable: true,
sortable: true,
template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB",
},
date: {
title: localize("ui.panel.config.backup.created"),
direction: "desc",
filterable: true,
sortable: true,
template: (backup) =>
relativeTime(new Date(backup.date), this.hass.locale),
},
locations: {
title: "Locations",
template: (backup) =>
html`${(backup.agent_ids || []).map((agent) => {
const [domain, name] = agent.split(".");
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"
/>
`;
})}`,
},
actions: {
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
showNarrow: true,
moveable: false,
hideable: false,
type: "overflow-menu",
template: (backup) => html`
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
label: this.hass.localize("ui.common.download"),
path: mdiDownload,
action: () => this._downloadBackup(backup),
},
{
label: this.hass.localize("ui.common.delete"),
path: mdiDelete,
action: () => this._deleteBackup(backup),
warning: true,
},
]}
>
</ha-icon-overflow-menu>
`,
},
})
);
private _handleSelectionChanged(
ev: HASSDomEvent<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
hasFab
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup/list`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
clickable
id="backup_id"
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
.route=${this.route}
@row-click=${this._showBackupDetails}
.columns=${this._columns(this.hass.localize)}
.data=${this._backups ?? []}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<div slot="top_header" class="header">
<ha-backup-summary-card
title="Automatically backed up"
description="Your configuration has been backed up."
has-action
.status=${this._backingUp ? "loading" : "success"}
>
<ha-button slot="action" @click=${this._configureAutomaticBackup}>
Configure
</ha-button>
</ha-backup-summary-card>
<ha-backup-summary-card
title="3 automatic backup locations"
description="One is off-site"
has-action
.status=${"success"}
>
<ha-button slot="action" @click=${this._configureBackupLocations}>
Configure
</ha-button>
</ha-backup-summary-card>
</div>
${this._selected.length
? html`<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this._selected.length} backups selected
</p>
<div class="header-btns">
${!this.narrow
? html`
<ha-button @click=${this._deleteSelected} class="warning">
Delete selected
</ha-button>
`
: html`
<ha-icon-button
.label=${"Delete selected"}
.path=${mdiDelete}
id="delete-btn"
class="warning"
@click=${this._deleteSelected}
></ha-icon-button>
<simple-tooltip animation-delay="0" for="delete-btn">
Delete selected
</simple-tooltip>
`}
</div>
</div> `
: nothing}
<ha-fab
slot="fab"
?disabled=${this._backingUp}
.label=${this.hass.localize("ui.panel.config.backup.create_backup")}
extended
@click=${this._newBackup}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._fetchBackupInfo();
}
private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass);
this._backups = info.backups;
this._backingUp = info.backing_up;
}
private async _newBackup(): Promise<void> {
const type = await showNewBackupDialog(this, {});
if (!type) {
return;
}
if (type === "manual") {
await this._generateBackup();
} else {
// Todo: implement trigger automatic backup
}
}
private async _generateBackup(): Promise<void> {
const response = await showGenerateBackupDialog(this, {});
if (!response) {
return;
}
await this._fetchBackupInfo();
// Todo subscribe for status updates instead of polling
const interval = setInterval(async () => {
await this._fetchBackupInfo();
if (!this._backingUp) {
clearInterval(interval);
}
}, 2000);
}
private _showBackupDetails(ev: CustomEvent): void {
const id = (ev.detail as RowClickedEvent).id;
navigate(`/config/backup/details/${id}`);
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
const preferedAgent = getPreferredAgentForDownload(backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(backup.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(backup: BackupContent): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: "Delete backup",
text: "This backup will be permanently deleted.",
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
await removeBackup(this.hass, backup.backup_id);
this._fetchBackupInfo();
}
private async _deleteSelected() {
const confirm = await showConfirmationDialog(this, {
title: "Delete selected backups",
text: "These backups will be permanently deleted.",
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await Promise.all(
this._selected.map((slug) => removeBackup(this.hass, slug))
);
} catch (err: any) {
showAlertDialog(this, {
title: "Failed to delete backups",
text: extractApiErrorMessage(err),
});
return;
}
await this._fetchBackupInfo();
this._dataTable.clearSelection();
}
private _configureAutomaticBackup() {
navigate("/config/backup/automatic-config");
}
private _configureBackupLocations() {
navigate("/config/backup/locations");
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.header {
padding: 16px 16px 0 16px;
display: flex;
flex-direction: row;
gap: 16px;
background-color: var(--primary-background-color);
}
@media (max-width: 1000px) {
.header {
flex-direction: column;
}
}
.header > * {
flex: 1;
min-width: 0;
}
ha-fab[disabled] {
--mdc-theme-secondary: var(--disabled-text-color) !important;
}
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
height: var(--header-height);
box-sizing: border-box;
}
.header-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--secondary-text-color);
position: relative;
top: -4px;
}
.selected-txt {
font-weight: bold;
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
color: var(--primary-text-color);
}
.table-header .selected-txt {
margin-top: 20px;
}
.header-toolbar .selected-txt {
font-size: 16px;
}
.header-toolbar .header-btns {
margin-right: -12px;
margin-inline-end: -12px;
margin-inline-start: initial;
}
.header-btns > ha-button,
.header-btns > ha-icon-button {
margin: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-dashboard": HaConfigBackupDashboard;
}
}

View File

@@ -0,0 +1,252 @@
import type { ActionDetail } from "@material/mwc-list";
import { mdiDelete, mdiDotsVertical, mdiDownload } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatDateTime } from "../../../common/datetime/format_date_time";
import { navigate } from "../../../common/navigate";
import "../../../components/ha-alert";
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 { getSignedPath } from "../../../data/auth";
import type { BackupContent } from "../../../data/backup";
import {
fetchBackupDetails,
getBackupDownloadUrl,
getPreferredAgentForDownload,
removeBackup,
} from "../../../data/backup";
import { domainToName } from "../../../data/integration";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
@customElement("ha-config-backup-details")
class HaConfigBackupDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "backup-id" }) public backupId!: string;
@state() private _backup?: BackupContent | null;
@state() private _error?: string;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (this.backupId) {
this._fetchBackup();
} else {
this._error = "Backup id not defined";
}
}
protected render() {
if (!this.hass) {
return nothing;
}
return html`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this._backup?.name || "Backup"}
>
<ha-button-menu slot="toolbar-icon" @action=${this._handleAction}>
<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.common.download")}
</ha-list-item>
<ha-list-item graphic="icon" class="warning">
<ha-svg-icon slot="graphic" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-list-item>
</ha-button-menu>
<div class="content">
${this._error &&
html`<ha-alert alert-type="error">${this._error}</ha-alert>`}
${this._backup === null
? html`<ha-alert alert-type="warning" title="Not found">
Backup matching ${this.backupId} not found
</ha-alert>`
: !this._backup
? html`<ha-circular-progress active></ha-circular-progress>`
: html`
<ha-card header="Backup">
<div class="card-content">
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${Math.ceil(this._backup.size * 10) / 10 + " MB"}
</span>
<span slot="supporting-text">Size</span>
</ha-md-list-item>
<ha-md-list-item>
${formatDateTime(
new Date(this._backup.date),
this.hass.locale,
this.hass.config
)}
<span slot="supporting-text">Created</span>
</ha-md-list-item>
</ha-md-list>
</div>
</ha-card>
<ha-card header="Locations">
<div class="card-content">
<ha-md-list>
${this._backup.agent_ids?.map((agent) => {
const [domain, name] = agent.split(".");
const domainName = domainToName(
this.hass.localize,
domain
);
return html`
<ha-md-list-item>
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
<div slot="headline">${domainName}: ${name}</div>
<ha-button-menu
slot="end"
@action=${this._handleAgentAction}
.agent=${agent}
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>
Download from this location
</ha-list-item>
</ha-button-menu>
</ha-md-list-item>
`;
})}
</ha-md-list>
</div>
</ha-card>
`}
</div>
</hass-subpage>
`;
}
private async _fetchBackup() {
try {
const response = await fetchBackupDetails(this.hass, this.backupId);
this._backup = response.backup;
} catch (err: any) {
this._error = err?.message || "Could not fetch backup details";
}
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._downloadBackup();
break;
case 1:
this._deleteBackup();
break;
}
}
private _handleAgentAction(ev: CustomEvent<ActionDetail>) {
const button = ev.currentTarget;
const agentId = (button as any).agent;
this._downloadBackup(agentId);
}
private async _downloadBackup(agentId?: string): Promise<void> {
const preferedAgent =
agentId ?? getPreferredAgentForDownload(this._backup!.agent_ids!);
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(this._backup!.backup_id, preferedAgent)
);
fileDownload(signedUrl.path);
}
private async _deleteBackup(): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: "Delete backup",
text: "This backup will be permanently deleted.",
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
await removeBackup(this.hass, this._backup!.backup_id);
navigate("/config/backup");
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: grid;
}
.card-content {
padding: 0 20px 8px 20px;
}
ha-md-list {
background: none;
padding: 0;
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
}
ha-md-list-item img {
width: 48px;
}
.warning {
color: var(--error-color);
}
.warning ha-svg-icon {
color: var(--error-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-details": HaConfigBackupDetails;
}
}

View File

@@ -0,0 +1,138 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import type { BackupAgent } from "../../../data/backup";
import { fetchBackupAgentsInfo } from "../../../data/backup";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { domainToName } from "../../../data/integration";
@customElement("ha-config-backup-locations")
class HaConfigBackupLocations extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _agents: BackupAgent[] = [];
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._fetchAgents();
}
protected render(): TemplateResult {
return html`
<hass-subpage
back-path="/config/backup"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.caption")}
>
<div class="content">
<div class="header">
<h2 class="title">Locations</h2>
<p class="description">
To keep your data safe it is recommended your backups is at least
on two different locations and one of them is off-site.
</p>
</div>
<ha-card class="agents">
<div class="card-content">
${this._agents.length > 0
? html`
<ha-md-list>
${this._agents.map((agent) => {
const [domain, name] = agent.agent_id.split(".");
const domainName = domainToName(
this.hass.localize,
domain
);
return html`
<ha-md-list-item
type="link"
href="/config/backup/locations/${agent.agent_id}"
>
<img
.src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=""
slot="start"
/>
<div slot="headline">${domainName}: ${name}</div>
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-list-item>
`;
})}
</ha-md-list>
`
: html`<p>No sync agents configured</p>`}
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private async _fetchAgents() {
const data = await fetchBackupAgentsInfo(this.hass);
this._agents = data.agents;
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 690px;
margin: 0 auto;
gap: 24px;
display: flex;
flex-direction: column;
}
.header .title {
font-size: 22px;
font-style: normal;
font-weight: 400;
line-height: 28px;
color: var(--primary-text-color);
margin: 0;
margin-bottom: 8px;
}
.header .description {
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.25px;
color: var(--secondary-text-color);
margin: 0;
}
ha-md-list {
background: none;
}
ha-md-list-item img {
width: 48px;
}
.card-content {
padding: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-backup-locations": HaConfigBackupLocations;
}
}

View File

@@ -1,235 +1,49 @@
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { mdiDelete, mdiDownload, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one";
import { relativeTime } from "../../../common/datetime/relative_time";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-circular-progress";
import "../../../components/ha-fab";
import "../../../components/ha-icon";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import type { BackupContent, BackupData } from "../../../data/backup";
import {
fetchBackupInfo,
generateBackup,
getBackupDownloadUrl,
removeBackup,
} from "../../../data/backup";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import type { RouterOptions } from "../../../layouts/hass-router-page";
import { HassRouterPage } from "../../../layouts/hass-router-page";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { fileDownload } from "../../../util/file_download";
import type { HomeAssistant } from "../../../types";
import "./ha-config-backup-dashboard";
@customElement("ha-config-backup")
class HaConfigBackup extends LitElement {
class HaConfigBackup extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _backupData?: BackupData;
private _columns = memoize(
(
narrow,
_language,
localize: LocalizeFunc
): DataTableColumnContainer<BackupContent> => ({
name: {
title: localize("ui.panel.config.backup.name"),
main: true,
sortable: true,
filterable: true,
flex: 2,
template: narrow
? undefined
: (backup) =>
html`${backup.name}
<div class="secondary">${backup.path}</div>`,
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
dashboard: {
tag: "ha-config-backup-dashboard",
cache: true,
},
path: {
title: localize("ui.panel.config.backup.path"),
hidden: !narrow,
details: {
tag: "ha-config-backup-details",
load: () => import("./ha-config-backup-details"),
},
size: {
title: localize("ui.panel.config.backup.size"),
filterable: true,
sortable: true,
template: (backup) => Math.ceil(backup.size * 10) / 10 + " MB",
locations: {
tag: "ha-config-backup-locations",
load: () => import("./ha-config-backup-locations"),
},
date: {
title: localize("ui.panel.config.backup.created"),
direction: "desc",
filterable: true,
sortable: true,
template: (backup) =>
relativeTime(new Date(backup.date), this.hass.locale),
"automatic-config": {
tag: "ha-config-backup-automatic-config",
load: () => import("./ha-config-backup-automatic-config"),
},
},
};
actions: {
title: "",
type: "overflow-menu",
showNarrow: true,
hideable: false,
moveable: false,
template: (backup) =>
html`<ha-icon-overflow-menu
.hass=${this.hass}
.narrow=${this.narrow}
.items=${[
// Download Button
{
path: mdiDownload,
label: this.hass.localize(
"ui.panel.config.backup.download_backup"
),
action: () => this._downloadBackup(backup),
},
// Delete button
{
path: mdiDelete,
label: this.hass.localize(
"ui.panel.config.backup.remove_backup"
),
action: () => this._removeBackup(backup),
},
]}
style="color: var(--secondary-text-color)"
>
</ha-icon-overflow-menu>`,
},
})
);
protected updatePageEl(pageEl, changedProps: PropertyValues) {
pageEl.hass = this.hass;
pageEl.route = this.routeTail;
private _getItems = memoize((backupItems: BackupContent[]) =>
backupItems.map((backup) => ({
name: backup.name,
slug: backup.slug,
date: backup.date,
size: backup.size,
path: backup.path,
}))
);
protected render(): TemplateResult {
if (!this.hass || this._backupData === undefined) {
return html`<hass-loading-screen></hass-loading-screen>`;
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "details"
) {
pageEl.backupId = this.routeTail.path.substr(1);
}
return html`
<hass-tabs-subpage-data-table
hasFab
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.columns=${this._columns(
this.narrow,
this.hass.language,
this.hass.localize
)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<ha-fab
slot="fab"
?disabled=${this._backupData.backing_up}
.label=${this._backupData.backing_up
? this.hass.localize("ui.panel.config.backup.creating_backup")
: this.hass.localize("ui.panel.config.backup.create_backup")}
extended
@click=${this._generateBackup}
>
${this._backupData.backing_up
? html`<ha-circular-progress
slot="icon"
indeterminate
></ha-circular-progress>`
: html`<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>`}
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._getBackups();
}
private async _getBackups(): Promise<void> {
this._backupData = await fetchBackupInfo(this.hass);
}
private async _downloadBackup(backup: BackupContent): Promise<void> {
const signedUrl = await getSignedPath(
this.hass,
getBackupDownloadUrl(backup.slug)
);
fileDownload(signedUrl.path);
}
private async _generateBackup(): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.backup.create.title"),
text: this.hass.localize("ui.panel.config.backup.create.description"),
confirmText: this.hass.localize("ui.panel.config.backup.create.confirm"),
});
if (!confirm) {
return;
}
generateBackup(this.hass)
.then(() => this._getBackups())
.catch((err) => showAlertDialog(this, { text: (err as Error).message }));
await this._getBackups();
}
private async _removeBackup(backup: BackupContent): Promise<void> {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.backup.remove.title"),
text: this.hass.localize("ui.panel.config.backup.remove.description", {
name: backup.name,
}),
confirmText: this.hass.localize("ui.panel.config.backup.remove.confirm"),
});
if (!confirm) {
return;
}
await removeBackup(this.hass, backup.slug);
await this._getBackups();
}
static get styles(): CSSResultGroup {
return [
css`
ha-fab[disabled] {
--mdc-theme-secondary: var(--disabled-text-color) !important;
}
`,
];
}
}

View File

@@ -48,7 +48,7 @@ export class DialogJoinBeta
${this.hass.localize("ui.dialogs.join_beta_channel.backup")}
</ha-alert>
<p>
${this.hass.localize("ui.dialogs.join_beta_channel.warning")}
${this.hass.localize("ui.dialogs.join_beta_channel.warning")}.<br />
${this.hass.localize("ui.dialogs.join_beta_channel.release_items")}
</p>
<ul>

View File

@@ -1,7 +1,8 @@
import "@material/mwc-list/mwc-list";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { until } from "lit/directives/until";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stripPrefixFromEntityName } from "../../../../common/entity/strip_prefix_from_entity_name";
@@ -86,36 +87,42 @@ export class HaDeviceEntitiesCard extends LitElement {
return html`
<ha-card outlined .header=${this.header}>
<div id="entities">
<mwc-list>
${shownEntities.map((entry) =>
this.hass.states[entry.entity_id]
? this._renderEntity(entry)
: this._renderEntry(entry)
)}
</mwc-list>
</div>
${hiddenEntities.length
? !this.showHidden
? html`
<button class="show-more" @click=${this._toggleShowHidden}>
${this.hass.localize(
"ui.panel.config.devices.entities.hidden_entities",
{ count: hiddenEntities.length }
)}
</button>
`
: html`
${shownEntities.length
? html`
<div id="entities" class="move-up">
<mwc-list>
${hiddenEntities.map((entry) => this._renderEntry(entry))}
</mwc-list>
<button class="show-more" @click=${this._toggleShowHidden}>
${this.hass.localize(
"ui.panel.config.devices.entities.show_less"
${shownEntities.map((entry) =>
this.hass.states[entry.entity_id]
? this._renderEntity(entry)
: this._renderEntry(entry)
)}
</button>
`
: ""}
</mwc-list>
</div>
`
: nothing}
${hiddenEntities.length
? html`<div class=${classMap({ "move-up": !shownEntities.length })}>
${!this.showHidden
? html`
<button class="show-more" @click=${this._toggleShowHidden}>
${this.hass.localize(
"ui.panel.config.devices.entities.hidden_entities",
{ count: hiddenEntities.length }
)}
</button>
`
: html`
<mwc-list>
${hiddenEntities.map((entry) => this._renderEntry(entry))}
</mwc-list>
<button class="show-more" @click=${this._toggleShowHidden}>
${this.hass.localize(
"ui.panel.config.devices.entities.show_less"
)}
</button>
`}
</div>`
: nothing}
<div class="card-actions">
<mwc-button @click=${this._addToLovelaceView}>
${this.hass.localize(
@@ -257,8 +264,8 @@ export class HaDeviceEntitiesCard extends LitElement {
.disabled-entry {
color: var(--secondary-text-color);
}
#entities {
margin-top: -24px; /* match the spacing between card title and content of the device info card above it */
.move-up {
margin-top: -24px;
}
#entities > * {
margin: 8px 16px 8px 8px;

View File

@@ -5,6 +5,7 @@ import {
mdiHospitalBox,
mdiInformation,
mdiUpload,
mdiWrench,
} from "@mdi/js";
import { getConfigEntries } from "../../../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
@@ -98,6 +99,13 @@ export const getZwaveDeviceActions = async (
showZWaveJSNodeStatisticsDialog(el, {
device,
}),
},
{
label: hass.localize(
"ui.panel.config.zwave_js.device_info.installer_settings"
),
icon: mdiWrench,
href: `/config/zwave_js/node_installer/${device.id}?config_entry=${entryId}`,
}
);
}

View File

@@ -27,6 +27,13 @@ import { formatShortDateTime } 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";
import {
isDeletableEntity,
deleteEntity,
} from "../../../common/entity/delete_entity";
import type { Helper } from "../helpers/const";
import { isHelperDomain } from "../helpers/const";
import { HELPERS_CRUD } from "../../../data/helpers_crud";
import { computeStateName } from "../../../common/entity/compute_state_name";
import {
PROTOCOL_INTEGRATIONS,
@@ -73,12 +80,15 @@ import type {
} from "../../../data/entity_registry";
import {
computeEntityRegistryName,
removeEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
fetchIntegrationManifests,
domainToName,
} from "../../../data/integration";
import type { EntitySources } from "../../../data/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
import { domainToName } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label_registry";
import {
createLabelRegistryEntry,
@@ -136,6 +146,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entries?: ConfigEntry[];
@state() private _manifests?: IntegrationManifest[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entities!: EntityRegistryEntry[];
@@ -1280,11 +1292,46 @@ ${rejected
});
}
private _removeSelected() {
const removeableEntities = this._selected.filter((entity) => {
const stateObj = this.hass.states[entity];
return stateObj?.attributes.restored;
private async _removeSelected() {
if (!this._entities || !this.hass) {
return;
}
const manifestsProm = this._manifests
? undefined
: fetchIntegrationManifests(this.hass);
const helperDomains = [
...new Set(this._selected.map((s) => computeDomain(s))),
].filter((d) => isHelperDomain(d));
const configEntriesProm = this._entries
? undefined
: this._loadConfigEntries();
const domainProms = helperDomains.map((d) =>
HELPERS_CRUD[d].fetch(this.hass)
);
const helpersResult = await Promise.all(domainProms);
let fetchedHelpers: Helper[] = [];
helpersResult.forEach((r) => {
fetchedHelpers = fetchedHelpers.concat(r);
});
if (manifestsProm) {
this._manifests = await manifestsProm;
}
if (configEntriesProm) {
await configEntriesProm;
}
const removeableEntities = this._selected.filter((entity_id) =>
isDeletableEntity(
this.hass,
entity_id,
this._manifests!,
this._entities,
this._entries!,
fetchedHelpers
)
);
showConfirmationDialog(this, {
title: this.hass.localize(
`ui.panel.config.entities.picker.delete_selected.confirm_title`
@@ -1305,8 +1352,15 @@ ${rejected
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
confirm: () => {
removeableEntities.forEach((entity) =>
removeEntityRegistryEntry(this.hass, entity)
removeableEntities.forEach((entity_id) =>
deleteEntity(
this.hass,
entity_id,
this._manifests!,
this._entities,
this._entries!,
fetchedHelpers
)
);
this._clearSelection();
},

View File

@@ -1,5 +1,6 @@
import type { CSSResultGroup } from "lit";
import { html, LitElement, nothing } from "lit";
import memoizeOne from "memoize-one";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { createCloseHeading } from "../../../../components/ha-dialog";
@@ -13,19 +14,6 @@ import type {
} from "./show-dialog-schedule-block-info";
import type { SchemaUnion } from "../../../../components/ha-form/types";
const SCHEMA = [
{
name: "from",
required: true,
selector: { time: { no_second: true } },
},
{
name: "to",
required: true,
selector: { time: { no_second: true } },
},
];
class DialogScheduleBlockInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -35,10 +23,39 @@ class DialogScheduleBlockInfo extends LitElement {
@state() private _params?: ScheduleBlockInfoDialogParams;
private _expand = false;
private _schema = memoizeOne((expand: boolean) => [
{
name: "from",
required: true,
selector: { time: { no_second: true } },
},
{
name: "to",
required: true,
selector: { time: { no_second: true } },
},
{
name: "advanced_settings",
type: "expandable" as const,
flatten: true,
expanded: expand,
schema: [
{
name: "data",
required: false,
selector: { object: {} },
},
],
},
]);
public showDialog(params: ScheduleBlockInfoDialogParams): void {
this._params = params;
this._error = undefined;
this._data = params.block;
this._expand = !!params.block?.data;
}
public closeDialog(): void {
@@ -66,7 +83,7 @@ class DialogScheduleBlockInfo extends LitElement {
<div>
<ha-form
.hass=${this.hass}
.schema=${SCHEMA}
.schema=${this._schema(this._expand)}
.data=${this._data}
.error=${this._error}
.computeLabel=${this._computeLabelCallback}
@@ -110,12 +127,20 @@ class DialogScheduleBlockInfo extends LitElement {
}
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "from":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.start");
case "to":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.end");
case "data":
return this.hass!.localize("ui.dialogs.helper_settings.schedule.data");
case "advanced_settings":
return this.hass!.localize(
"ui.dialogs.helper_settings.schedule.advanced_settings"
);
}
return "";
};

View File

@@ -3,6 +3,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
export interface ScheduleBlockInfo {
from: string;
to: string;
data?: Record<string, any>;
}
export interface ScheduleBlockInfoDialogParams {

View File

@@ -3,19 +3,23 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import {
mdiAlertCircle,
mdiCancel,
mdiChevronRight,
mdiCog,
mdiDotsVertical,
mdiMenuDown,
mdiPencilOff,
mdiProgressHelper,
mdiPlus,
mdiTag,
mdiTrashCan,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { debounce } from "../../../common/util/debounce";
import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
@@ -54,7 +58,11 @@ import {
subscribeCategoryRegistry,
} from "../../../data/category_registry";
import type { ConfigEntry } from "../../../data/config_entries";
import { subscribeConfigEntries } from "../../../data/config_entries";
import {
ERROR_STATES,
deleteConfigEntry,
subscribeConfigEntries,
} from "../../../data/config_entries";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { fullEntitiesContext } from "../../../data/context";
import type {
@@ -97,6 +105,7 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { isHelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
@@ -220,6 +229,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
callback: (entries) => entries[0]?.contentRect.width,
});
private _debouncedFetchEntitySources = debounce(
() => this._fetchEntitySources(),
500,
false
);
public hassSubscribe() {
return [
subscribeConfigEntries(
@@ -236,6 +251,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
} else if (message.type === "updated") {
newEntries[message.entry.entry_id] = message.entry;
}
if (
this._entitySource &&
this._configEntries &&
message.entry.state === "loaded" &&
this._configEntries[message.entry.entry_id]?.state !== "loaded"
) {
this._debouncedFetchEntitySources();
}
});
this._configEntries = newEntries;
},
@@ -352,6 +375,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.hass=${this.hass}
narrow
.items=${[
...(helper.configEntry &&
ERROR_STATES.includes(helper.configEntry.state)
? [
{
path: mdiAlertCircle,
label: this.hass.localize(
"ui.panel.config.helpers.picker.error_information"
),
warning: true,
action: () => this._showError(helper),
},
]
: []),
{
path: mdiCog,
label: this.hass.localize(
@@ -366,6 +402,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
),
action: () => this._editCategory(helper),
},
...(helper.configEntry &&
helper.editable &&
ERROR_STATES.includes(helper.configEntry.state) &&
helper.entity === undefined
? [
{
path: mdiTrashCan,
label: this.hass.localize("ui.common.delete"),
warning: true,
action: () => this._deleteEntry(helper),
},
]
: []),
]}
>
</ha-icon-overflow-menu>
@@ -417,17 +466,27 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
};
});
const entries = Object.values(configEntriesCopy).map((configEntry) => ({
id: configEntry.entry_id,
entity_id: "",
icon: mdiAlertCircle,
name: configEntry.title || "",
editable: true,
type: configEntry.domain,
configEntry,
entity: undefined,
selectable: false,
}));
const entries = Object.values(configEntriesCopy).map((configEntry) => {
const entityEntry = Object.values(entityEntries).find(
(entry) => entry.config_entry_id === configEntry.entry_id
);
const entityIsDisabled = !!entityEntry?.disabled_by;
return {
id: entityIsDisabled ? entityEntry.entity_id : configEntry.entry_id,
entity_id: entityIsDisabled ? entityEntry.entity_id : "",
icon: entityIsDisabled
? mdiCancel
: configEntry.state === "setup_in_progress"
? mdiProgressHelper
: mdiAlertCircle,
name: configEntry.title || "",
editable: true,
type: configEntry.domain,
configEntry,
entity: undefined,
selectable: entityIsDisabled,
};
});
return [...states, ...entries]
.filter((item) =>
@@ -1081,6 +1140,34 @@ ${rejected
}
}
private _showError(helper: HelperItem) {
showAlertDialog(this, {
title: this.hass.localize("ui.errors.config.configuration_error"),
text: renderConfigEntryError(this.hass, helper.configEntry!),
warning: true,
});
}
private async _deleteEntry(helper: HelperItem) {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: helper.configEntry!.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
deleteConfigEntry(this.hass, helper.id);
}
private _openSettings(helper: HelperItem) {
if (helper.entity) {
showMoreInfoDialog(this, {

View File

@@ -106,6 +106,38 @@ import { fileDownload } from "../../../util/file_download";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
export const renderConfigEntryError = (
hass: HomeAssistant,
entry: ConfigEntry
): TemplateResult => {
if (entry.reason) {
if (entry.error_reason_translation_key) {
const lokalisePromExc = hass
.loadBackendTranslation("exceptions", entry.domain)
.then(
(localize) =>
localize(
`component.${entry.domain}.exceptions.${entry.error_reason_translation_key}.message`,
entry.error_reason_translation_placeholders ?? undefined
) || entry.reason
);
return html`${until(lokalisePromExc)}`;
}
const lokalisePromError = hass
.loadBackendTranslation("config", entry.domain)
.then(
(localize) =>
localize(`component.${entry.domain}.config.error.${entry.reason}`) ||
entry.reason
);
return html`${until(lokalisePromError, entry.reason)}`;
}
return html`
<br />
${hass.localize("ui.panel.config.integrations.config_entry.check_the_logs")}
`;
};
@customElement("ha-config-integration-page")
class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -618,37 +650,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
if (item.reason) {
if (item.error_reason_translation_key) {
const lokalisePromExc = this.hass
.loadBackendTranslation("exceptions", item.domain)
.then(
(localize) =>
localize(
`component.${item.domain}.exceptions.${item.error_reason_translation_key}.message`,
item.error_reason_translation_placeholders ?? undefined
) || item.reason
);
stateTextExtra = html`${until(lokalisePromExc)}`;
} else {
const lokalisePromError = this.hass
.loadBackendTranslation("config", item.domain)
.then(
(localize) =>
localize(
`component.${item.domain}.config.error.${item.reason}`
) || item.reason
);
stateTextExtra = html`${until(lokalisePromError, item.reason)}`;
}
} else {
stateTextExtra = html`
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}
`;
}
stateTextExtra = renderConfigEntryError(this.hass, item);
}
const devices = this._getConfigEntryDevices(item);

View File

@@ -0,0 +1,98 @@
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import type { HomeAssistant } from "../../../../../../types";
import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
import "../../../../../../components/ha-alert";
import "../../../../../../components/ha-circular-progress";
import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
import "./zwave_js-capability-control-multilevel-switch";
enum ColorComponent {
"Warm White" = 0,
"Cold White",
Red,
Green,
Blue,
Amber,
Cyan,
Purple,
Index,
}
@customElement("zwave_js-capability-control-color_switch")
class ZWaveJSCapabilityColorSwitch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ type: Number }) public endpoint!: number;
@property({ type: Number }) public command_class!: number;
@property({ type: Number }) public version!: number;
@state() private _color_components?: ColorComponent[];
@state() private _error?: string;
protected render() {
if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
}
if (!this._color_components) {
return html`<ha-circular-progress indeterminate></ha-circular-progress>`;
}
return this._color_components.map(
(color) =>
html` <h5>
${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.color_component"
)}:
${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.color_switch.colors.${color}`
)}
</h5>
<zwave_js-capability-control-multilevel_switch
.hass=${this.hass}
.device=${this.device}
.endpoint=${this.endpoint}
.command_class=${this.command_class}
.version=${this.version}
.transform_options=${this._transformOptions(color)}
></zwave_js-capability-control-multilevel_switch>`
);
}
protected async firstUpdated() {
try {
this._color_components = (await invokeZWaveCCApi(
this.hass,
this.device.id,
this.command_class,
this.endpoint,
"getSupported",
[],
true
)) as number[];
} catch (error) {
this._error = extractApiErrorMessage(error);
}
}
private _transformOptions(color: number) {
return (opts: Record<string, any>, control: string) =>
control === "startLevelChange"
? {
...opts,
colorComponent: color,
}
: color;
}
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-capability-control-color_switch": ZWaveJSCapabilityColorSwitch;
}
}

View File

@@ -0,0 +1,167 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../../../components/buttons/ha-progress-button";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import type { HomeAssistant } from "../../../../../../types";
import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
import "../../../../../../components/ha-textfield";
import "../../../../../../components/ha-select";
import "../../../../../../components/ha-list-item";
import "../../../../../../components/ha-alert";
import "../../../../../../components/ha-formfield";
import "../../../../../../components/ha-switch";
import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button";
import type { HaSelect } from "../../../../../../components/ha-select";
import type { HaTextField } from "../../../../../../components/ha-textfield";
import type { HaSwitch } from "../../../../../../components/ha-switch";
import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
@customElement("zwave_js-capability-control-multilevel_switch")
class ZWaveJSCapabilityMultiLevelSwitch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ type: Number }) public endpoint!: number;
@property({ type: Number }) public command_class!: number;
@property({ type: Number }) public version!: number;
@property({ attribute: false }) public transform_options?: (
opts: Record<string, any>,
control: string
) => unknown;
@state() private _error?: string;
protected render() {
return html`
<h3>
${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.title"
)}
</h3>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<ha-select
.label=${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.direction"
)}
id="direction"
>
<ha-list-item .value=${"up"} selected
>${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.up"
)}</ha-list-item
>
<ha-list-item .value=${"down"}
>${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.down"
)}</ha-list-item
>
</ha-select>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.ignore_start_level"
)}
>
<ha-switch id="ignore_start_level"></ha-switch>
</ha-formfield>
<ha-textfield
type="number"
id="start_level"
value="0"
.label=${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.start_level"
)}
></ha-textfield>
<div class="actions">
<ha-progress-button
.control=${"startLevelChange"}
@click=${this._controlTransition}
>
${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.start_transition"
)}
</ha-progress-button>
<ha-progress-button
.control=${"stopLevelChange"}
@click=${this._controlTransition}
>
${this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.stop_transition"
)}
</ha-progress-button>
</div>
`;
}
private async _controlTransition(ev: any) {
const control = ev.currentTarget!.control;
const button = ev.currentTarget as HaProgressButton;
button.progress = true;
const direction = (this.shadowRoot!.getElementById("direction") as HaSelect)
.value;
const ignoreStartLevel = (
this.shadowRoot!.getElementById("ignore_start_level") as HaSwitch
).checked;
const startLevel = Number(
(this.shadowRoot!.getElementById("start_level") as HaTextField).value
);
const options = {
direction,
ignoreStartLevel,
startLevel,
};
try {
button.actionSuccess();
await invokeZWaveCCApi(
this.hass,
this.device.id,
this.command_class,
this.endpoint,
control,
[
this.transform_options
? this.transform_options(options, control)
: options,
],
true
);
} catch (err) {
button.actionError();
this._error = this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.multilevel_switch.control_failed",
{ error: extractApiErrorMessage(err) }
);
}
button.progress = false;
}
static styles = css`
ha-select,
ha-formfield,
ha-textfield {
display: block;
margin-bottom: 8px;
}
.actions {
display: flex;
justify-content: flex-end;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-capability-control-multilevel_switch": ZWaveJSCapabilityMultiLevelSwitch;
}
}

View File

@@ -0,0 +1,241 @@
import { LitElement, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import type { HomeAssistant } from "../../../../../../types";
import { invokeZWaveCCApi } from "../../../../../../data/zwave_js";
import "../../../../../../components/ha-button";
import "../../../../../../components/buttons/ha-progress-button";
import "../../../../../../components/ha-textfield";
import "../../../../../../components/ha-select";
import "../../../../../../components/ha-list-item";
import "../../../../../../components/ha-alert";
import type { HaSelect } from "../../../../../../components/ha-select";
import type { HaTextField } from "../../../../../../components/ha-textfield";
import { extractApiErrorMessage } from "../../../../../../data/hassio/common";
import type { HaProgressButton } from "../../../../../../components/buttons/ha-progress-button";
// enum with special states
enum SpecialState {
frost_protection = "Frost Protection",
energy_saving = "Energy Saving",
unused = "Unused",
}
const SETBACK_TYPE_OPTIONS = ["none", "temporary", "permanent"];
@customElement("zwave_js-capability-control-thermostat_setback")
class ZWaveJSCapabilityThermostatSetback extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ type: Number }) public endpoint!: number;
@property({ type: Number }) public command_class!: number;
@property({ type: Number }) public version!: number;
@state() private _disableSetbackState = false;
@query("#setback_type") private _setbackTypeInput!: HaSelect;
@query("#setback_state") private _setbackStateInput!: HaTextField;
@query("#setback_special_state")
private _setbackSpecialStateSelect!: HaSelect;
@state() private _error?: string;
@state() private _loading = true;
protected render() {
return html`
<h3>
${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.title`
)}
</h3>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<ha-select
.label=${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_type.label`
)}
id="setback_type"
.value=${"0"}
.disabled=${this._loading}
>
${SETBACK_TYPE_OPTIONS.map(
(translationKey, index) =>
html`<ha-list-item .value=${String(index)}>
${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_type.${translationKey}`
)}
</ha-list-item>`
)}
</ha-select>
<div class="setback-state">
<ha-textfield
type="number"
id="setback_state"
value="0"
.label=${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_state_label`
)}
min="-12.8"
max="12.0"
step=".1"
.helper=${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_state_helper`
)}
.disabled=${this._disableSetbackState || this._loading}
></ha-textfield>
<ha-select
.label=${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_special_state.label`
)}
id="setback_special_state"
@change=${this._changeSpecialState}
.disabled=${this._loading}
>
<ha-list-item selected> </ha-list-item>
${Object.entries(SpecialState).map(
([translationKey, value]) =>
html`<ha-list-item .value=${value}>
${this.hass.localize(
`ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.setback_special_state.${translationKey}`
)}
</ha-list-item>`
)}
</ha-select>
</div>
<div class="actions">
<ha-button
class="clear-button"
@click=${this._clear}
.disabled=${this._loading}
>${this.hass.localize("ui.common.clear")}</ha-button
>
<ha-progress-button
@click=${this._saveSetback}
.disabled=${this._loading}
>
${this.hass.localize("ui.common.save")}
</ha-progress-button>
</div>
`;
}
protected firstUpdated() {
this._loadSetback();
}
private async _loadSetback() {
this._loading = true;
try {
const { setbackType, setbackState } = (await invokeZWaveCCApi(
this.hass,
this.device.id,
this.command_class,
this.endpoint,
"get",
[],
true
)) as { setbackType: number; setbackState: number | SpecialState };
this._setbackTypeInput.value = String(setbackType);
if (typeof setbackState === "number") {
this._setbackStateInput.value = String(setbackState);
this._setbackSpecialStateSelect.value = "";
} else {
this._setbackSpecialStateSelect.value = setbackState;
}
} catch (err) {
this._error = this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.get_setback_failed",
{ error: extractApiErrorMessage(err) }
);
}
this._loading = false;
}
private _changeSpecialState() {
this._disableSetbackState = !!this._setbackSpecialStateSelect.value;
}
private async _saveSetback(ev: CustomEvent) {
const button = ev.currentTarget as HaProgressButton;
button.progress = true;
this._error = undefined;
const setbackType = this._setbackTypeInput.value;
let setbackState: number | string = Number(this._setbackStateInput.value);
if (this._setbackSpecialStateSelect.value) {
setbackState = this._setbackSpecialStateSelect.value;
}
try {
await invokeZWaveCCApi(
this.hass,
this.device.id,
this.command_class,
this.endpoint,
"set",
[Number(setbackType), setbackState],
true
);
button.actionSuccess();
} catch (err) {
button.actionError();
this._error = this.hass.localize(
"ui.panel.config.zwave_js.node_installer.capability_controls.thermostat_setback.save_setback_failed",
{ error: extractApiErrorMessage(err) }
);
}
button.progress = false;
}
private _clear() {
this._loadSetback();
}
static styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
:host > ha-select {
width: 100%;
}
.actions {
width: 100%;
display: flex;
justify-content: flex-end;
}
.actions .clear-button {
--mdc-theme-primary: var(--red-color);
}
.setback-state {
width: 100%;
display: flex;
gap: 16px;
}
.setback-state ha-select,
ha-textfield {
flex: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-capability-control-thermostat_setback": ZWaveJSCapabilityThermostatSetback;
}
}

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